Fix duplicate primary key
August 23, 2025 · AI Conversations
CURRENT_TIMESTAMP
0
CURRENT_TIMESTAMP
0
id
name
id
feed_id
status
updated_at
An index with name 'primary' was already defined on table 'shoppingfeedconfiguration'.
when install
That error is happening because in your `db.xml` you’re **defining the primary key twice**.
In Doctrine-style `db.xml` used by Concrete CMS:
– If you mark a field with “, that already makes it the primary key.
– You don’t need to also add an “ block.
Right now you have **both**:
<field name="id" type="I">
<key/>
<unsigned/>
</field>
<index name="PRIMARY">
<col>id</col>
</index>
That’s why Concrete says *“An index with name 'primary' was already defined”*.
âś… Fix: **remove the duplicate “ from both tables.**
Here’s the corrected schema:
<?xml version="1.0"?>
<schema version="0.3">
<table name="shoppingfeedconfiguration">
<field name="id" type="I">
<key/>
<unsigned/>
</field>
<field name="url" type="C" size="255">
<notnull/>
</field>
<field name="name" type="C" size="355">
<notnull/>
</field>
<field name="feedSlug" type="C" size="355">
<notnull/>
</field>
<field name="settings" type="X"/>
<field name="created_at" type="T">
<default>CURRENT_TIMESTAMP</default>
</field>
<field name="latestGeneration" type="T">
<default>CURRENT_TIMESTAMP</default>
</field>
<field name="noOfProducts" type="I">
<default>0</default>
<unsigned/>
</field>
<field name="updated_at" type="T">
<default>CURRENT_TIMESTAMP</default>
</field>
<field name="enableCron" type="I">
<default>0</default>
<unsigned/>
</field>
<index name="idx_name">
<col>name</col>
</index>
</table>
<table name="shoppingfeedlog">
<field name="id" type="I">
<key/>
<unsigned/>
</field>
<field name="feed_id" type="I">
<notnull/>
<unsigned/>
</field>
<field name="updated_at" type="T">
<notnull/>
</field>
<field name="status" type="C" size="50"/>
<field name="error_message" type="X"/>
<index name="idx_feed">
<col>feed_id</col>
</index>
<index name="idx_status">
<col>status</col>
</index>
<index name="idx_updated_at">
<col>updated_at</col>
</index>
<foreign-key field="feed_id" references-table="shoppingfeedconfiguration" on-delete="cascade">
<reference local="feed_id" foreign="id"/>
</foreign-key>
</table>
</schema>
—
👉 After fixing, run:
./vendor/bin/concrete5 c5:package-uninstall shopping_feed_manager
./vendor/bin/concrete5 c5:package-install shopping_feed_manager
(or just refresh if you’re still developing).
Do you want me to also give you a **doctrine ORM Entity version** of these tables (so you can run `$entityManager->persist()` instead of writing raw queries)?
CURRENT_TIMESTAMP
0
CURRENT_TIMESTAMP
0
name
feed_id
status
updated_at
also autoincrement for id make table Camel based
1. **`id` should be `AUTO_INCREMENT`**
2. **Table names should be CamelCase** (Concrete/Doctrine recommends `CamelCase` for ORM entities, but the database itself is usually snake_case. Still, if you want CamelCase, it’s valid).
In Concrete CMS `db.xml` schema:
– “ inside a “ makes the column auto increment.
– Table names can be written as `ShoppingFeedConfiguration` and `ShoppingFeedLog`.
Here’s your updated schema with both applied ✅:
<?xml version="1.0"?>
<schema version="0.3">
<table name="ShoppingFeedConfiguration">
<field name="id" type="I">
<key/>
<unsigned/>
<autoincrement/>
</field>
<field name="url" type="C" size="255">
<notnull/>
</field>
<field name="name" type="C" size="355">
<notnull/>
</field>
<field name="feedSlug" type="C" size="355">
<notnull/>
</field>
<field name="settings" type="X"/>
<field name="created_at" type="T">
<default>CURRENT_TIMESTAMP</default>
</field>
<field name="latestGeneration" type="T">
<default>CURRENT_TIMESTAMP</default>
</field>
<field name="noOfProducts" type="I">
<default>0</default>
<unsigned/>
</field>
<field name="updated_at" type="T">
<default>CURRENT_TIMESTAMP</default>
</field>
<field name="enableCron" type="I">
<default>0</default>
<unsigned/>
</field>
<index name="idx_name">
<col>name</col>
</index>
</table>
<table name="ShoppingFeedLog">
<field name="id" type="I">
<key/>
<unsigned/>
<autoincrement/>
</field>
<field name="feed_id" type="I">
<notnull/>
<unsigned/>
</field>
<field name="updated_at" type="T">
<notnull/>
</field>
<field name="status" type="C" size="50"/>
<field name="error_message" type="X"/>
<index name="idx_feed">
<col>feed_id</col>
</index>
<index name="idx_status">
<col>status</col>
</index>
<index name="idx_updated_at">
<col>updated_at</col>
</index>
<foreign-key field="feed_id" references-table="ShoppingFeedConfiguration" on-delete="cascade">
<reference local="feed_id" foreign="id"/>
</foreign-key>
</table>
</schema>
—
⚡ Notes:
– `I` + “ → generates `INT AUTO_INCREMENT` in MySQL.
– By renaming the tables `ShoppingFeedConfiguration` and `ShoppingFeedLog`, Doctrine ORM will also expect entity classes with the same naming convention.
👉 Do you also want me to give you the **Doctrine Entity PHP classes** for these two tables (so you can use `$entityManager->find()` and `$entityManager->persist()` instead of raw queries)?
### Key improvements
– Uses `createQueryBuilder()` for SELECT / DELETE with bound parameters.
– Paginates via `setFirstResult` / `setMaxResults`.
– Builds the cloned feed URL with `Url::to()` (no hardcoded domain).
– Centralizes table names as constants—switch to snake_case here if your tables weren’t renamed.
– Returns consistent JSON structures + proper error codes.
<?php
namespace ConcretePackageShoppingFeedManagerApi;
use ConcreteCoreControllerAbstractController;
use ConcreteCoreDatabaseConnectionConnection;
use ConcreteCoreHttpResponseFactoryInterface;
use ConcreteCoreSupportFacadeUrl;
use DoctrineDBALException;
class FeedApi extends AbstractController
{
// If your db.xml uses CamelCase tables:
private const TABLE_CONFIG = 'ShoppingFeedConfiguration';
private const TABLE_LOG = 'ShoppingFeedLog';
// If your tables are actually snake_case, just swap to:
// private const TABLE_CONFIG = 'shoppingfeedconfiguration';
// private const TABLE_LOG = 'shoppingfeedlog';
public function view()
{
/** @var Connection $db */
$db = $this->app->make(Connection::class);
$rf = $this->app->make(ResponseFactoryInterface::class);
$perPage = 20;
$page = max(1, (int) $this->request->query->get('page', 1));
$offset = ($page - 1) * $perPage;
try {
// total count
$total = (int) $db->createQueryBuilder()
->select('COUNT(*)')
->from(self::TABLE_CONFIG)
->executeQuery()
->fetchOne();
// rows (explicit column list is better than *)
$qb = $db->createQueryBuilder();
$qb->select('id', 'url', 'name', 'feedSlug', 'settings', 'created_at', 'latestGeneration', 'noOfProducts', 'updated_at', 'enableCron')
->from(self::TABLE_CONFIG)
->orderBy('id', 'ASC')
->setFirstResult($offset)
->setMaxResults($perPage);
$feeds = $qb->executeQuery()->fetchAllAssociative();
return $rf->json([
'feeds' => $feeds,
'total' => $total,
'page' => $page,
'limit' => $perPage,
]);
} catch (Exception $e) {
return $rf->json(['error' => true, 'message' => $e->getMessage()], 500);
}
}
public function delete($id)
{
/** @var Connection $db */
$db = $this->app->make(Connection::class);
$rf = $this->app->make(ResponseFactoryInterface::class);
try {
$affected = $db->createQueryBuilder()
->delete(self::TABLE_CONFIG)
->where('id = :id')
->setParameter('id', (int) $id)
->executeStatement();
if ($affected < 1) {
return $rf->json(['success' => false, 'message' => t('Invalid ID')], 400);
}
return $rf->json(['success' => true]);
} catch (Exception $e) {
return $rf->json(['success' => false, 'message' => $e->getMessage()], 500);
}
}
public function cloneFeed($id)
{
/** @var Connection $db */
$db = $this->app->make(Connection::class);
$rf = $this->app->make(ResponseFactoryInterface::class);
try {
// 1) Load source
$config = $db->createQueryBuilder()
->select('*')
->from(self::TABLE_CONFIG)
->where('id = :id')
->setParameter('id', (int) $id)
->executeQuery()
->fetchAssociative();
if (!$config) {
return $rf->json(['success' => false, 'message' => t('Invalid ID')], 400);
}
// 2) New slug + URL
$feedSlug = 'feed-' . bin2hex(random_bytes(4));
// Url::to() returns a URL object; cast to string
$feedPath = (string) Url::to('/google-merchant-feed/' . $feedSlug);
// 3) Insert clone (let DB default timestamps handle created/updated if desired)
$db->insert(self::TABLE_CONFIG, [
'name' => $config['name'],
'url' => $feedPath,
'feedSlug' => $feedSlug,
'settings' => $config['settings'],
// Optional: comment these two out if you want DB defaults to fill them
// 'created_at' => (new DateTime())->format('Y-m-d H:i:s'),
// 'updated_at' => (new DateTime())->format('Y-m-d H:i:s'),
]);
return $rf->json(['success' => true, 'feedSlug' => $feedSlug, 'url' => $feedPath]);
} catch (Throwable $e) {
return $rf->json(['success' => false, 'message' => $e->getMessage()], 500);
}
}
}
### Quick checklist (to avoid “Class … FeedApi does not exist”)
1. **File path** should be:
`packages/shopping_feed_manager/src/Api/FeedApi.php`
2. **Composer autoload** (in your package `composer.json`):
{
"autoload": {
"psr-4": {
"Concrete\Package\ShoppingFeedManager\": "packages/shopping_feed_manager/src"
}
}
}
Then run `composer dump-autoload`.
3. **Route** should point to this controller, e.g. in `routes/api.php` or your package route loader:
use ConcreteCoreRoutingRouter;
$router->get('/api/feeds', 'ConcretePackageShoppingFeedManagerApiFeedApi::view');
$router->delete('/api/feeds/{id}', 'ConcretePackageShoppingFeedManagerApiFeedApi::delete');
$router->post('/api/feeds/{id}/clone', 'ConcretePackageShoppingFeedManagerApiFeedApi::cloneFeed');
(Adjust paths to your liking.)
### If your tables are snake_case instead
Just change the constants at the top:
private const TABLE_CONFIG = 'shoppingfeedconfiguration';
private const TABLE_LOG = 'shoppingfeedlog';
—
If you’d like, I can also give you **Doctrine ORM entities + repositories** for `ShoppingFeedConfiguration` and `ShoppingFeedLog` so you can replace the DBAL code with `$entityManager` calls.
– Uses Doctrine DBAL (`QueryBuilder`) instead of raw SQL
– Avoids `$_POST` (uses `Request`)
– Generates a unique `feedSlug` server‑side (if not provided) and builds `url` with `Url::to()`
– Stores `settings` as JSON (cleaner than `serialize`)
– Updates `noOfProducts` via your `FeedService`
– Uses table-name constants (flip to snake_case if your DB kept the old names)
<?php
namespace ConcretePackageShoppingFeedManagerControllerSinglePageDashboardShoppingFeed;
use ConcreteCorePageControllerDashboardPageController;
use ConcreteCoreHttpRequest;
use ConcreteCoreDatabaseConnectionConnection;
use ConcreteCoreRoutingRedirect;
use ConcreteCoreSiteService as SiteService;
use ConcreteCoreSupportFacadeUrl;
use ConcretePackageShoppingFeedManagerServiceFeedService;
use DoctrineDBALException;
class Add extends DashboardPageController
{
// If your db.xml renamed to CamelCase:
private const TABLE_CONFIG = 'ShoppingFeedConfiguration';
// If you kept snake_case, swap to:
// private const TABLE_CONFIG = 'shoppingfeedconfiguration';
public function view()
{
/** @var Connection $db */
$db = $this->app->make(Connection::class);
// Product types
$productTypes = $db->createQueryBuilder()
->select('*')
->from('CommunityStoreProductTypes')
->executeQuery()
->fetchAllAssociative();
// Manufacturers
$manufacturers = $db->createQueryBuilder()
->select('*')
->from('CommunityStoreManufacturer')
->executeQuery()
->fetchAllAssociative();
// Groups
$productGroups = $db->createQueryBuilder()
->select('*')
->from('CommunityStoreGroups')
->executeQuery()
->fetchAllAssociative();
// Min / Max price
$row = $db->createQueryBuilder()
->select('MIN(pPrice) AS min_price', 'MAX(pPrice) AS max_price')
->from('CommunityStoreProducts')
->executeQuery()
->fetchAssociative() ?: ['min_price' => 0, 'max_price' => 0];
// Product list (id + name)
$products = $db->createQueryBuilder()
->select('pID', 'pName')
->from('CommunityStoreProducts')
->executeQuery()
->fetchAllAssociative();
// Locales
$siteService = $this->app->make(SiteService::class);
$site = $siteService->getSite();
$locales = $site ? $site->getLocales() : [];
$this->set('product_types', $productTypes);
$this->set('manufacturers', $manufacturers);
$this->set('product_groups', $productGroups);
$this->set('minPrice', $row['min_price'] ?? 0);
$this->set('maxPrice', $row['max_price'] ?? 0);
$this->set('products', $products);
$this->set('locales', $locales);
}
public function submit()
{
/** @var FeedService $feedService */
$feedService = $this->app->make(FeedService::class);
/** @var Request $req */
$req = $this->app->make(Request::class);
/** @var Connection $db */
$db = $this->app->make(Connection::class);
// Optional CSRF check if your form includes a token:
// if (!$this->token->validate('sfm_add')) {
// $this->error->add(t('Invalid token.'));
// return $this->view();
// }
$name = trim((string) $req->request->get('name', ''));
$urlInput = trim((string) $req->request->get('url', ''));
$feedSlug = trim((string) $req->request->get('feedSlug', ''));
$settings = (array) $req->request->get('settings', []);
$enableCron = (int) (bool) $req->request->get('enable_cron', false);
// Normalize checkbox-style flags inside settings
$flags = ['pFeatured', 'onsale', 'outofstock', 'inactive'];
foreach ($flags as $flag) {
$settings[$flag] = !empty($settings[$flag]) ? 1 : 0;
}
if ($name === '') {
$this->error->add(t('Name is required.'));
return $this->view();
}
// Ensure unique slug (server-side) if not provided
if ($feedSlug === '') {
$feedSlug = 'feed-' . bin2hex(random_bytes(4));
} else {
// Keep it URL-safe
$feedSlug = preg_replace('/[^a-zA-Z0-9-_]/', '-', $feedSlug);
}
// Build URL if not provided
$url = $urlInput !== '' ? $urlInput : (string) Url::to('/google-merchant-feed/' . $feedSlug);
try {
// Insert feed
$db->insert(self::TABLE_CONFIG, [
'enableCron' => $enableCron,
'url' => $url,
'name' => $name,
'settings' => json_encode($settings, JSON_UNESCAPED_UNICODE),
'feedSlug' => $feedSlug,
// Let DB defaults handle created_at / updated_at
]);
// Count products for this feed and update
$countProducts = (int) $feedService->countProductsInFeed($feedSlug);
$db->update(
self::TABLE_CONFIG,
['noOfProducts' => $countProducts],
['feedSlug' => $feedSlug]
);
$this->flash('success', t('Feed created successfully.'));
return Redirect::to('/dashboard/shopping_feed/');
} catch (Exception $e) {
// Likely duplicate name (idx_name) or other DB error
$this->error->add($e->getMessage());
return $this->view();
} catch (Throwable $e) {
$this->error->add($e->getMessage());
return $this->view();
}
}
}
### Notes / tweaks you can apply quickly
– If your DB tables are **snake_case**, just change `TABLE_CONFIG` to `'shoppingfeedconfiguration'`.
– If you prefer **PHP serialization**, replace the `json_encode($settings)` line with `serialize($settings)` and keep your existing reader logic.
– Add a **CSRF token** to your form and uncomment the `$this->token->validate('sfm_add')` block for better security.
– If `name` must be unique, keep your DB index on `name` and catch the duplicate-key exception (we already surface it in `$this->error`).
Want me to wire up the **Single Page form markup** (with a token and fields) to match this controller and Concrete 9 UI?
1) Sanitize & cap pagination inputs + bind them, don’t inline:
`$perPage = (int) min(max((int)$request->query->get('limit', 10), 1), 100);` and use DBAL params/QueryBuilder for `LIMIT/OFFSET` instead of interpolating `$offset,$limit` into SQL (prevents injection & odd casts).
2) Avoid `SELECT *` and old helpers; prefer explicit columns + DBAL API:
// total
$total = (int)$db->createQueryBuilder()->select('COUNT(*)')->from('shoppingfeedconfiguration')->executeQuery()->fetchOne();
// page
$feeds = $db->createQueryBuilder()
->select('id','url','name','feedSlug','noOfProducts','updated_at')
->from('shoppingfeedconfiguration')
->orderBy('id','DESC')
->setFirstResult($offset)
->setMaxResults($perPage)
->executeQuery()
->fetchAllAssociative();
3) Consistent table naming: you use `shoppingfeedconfiguration` here but CamelCase (`ShoppingFeedConfiguration`) elsewhere. Pick one and centralize via a class constant (e.g., `private const T_CFG = 'ShoppingFeedConfiguration';`) to avoid drift and migration bugs.
4) `tracklog()` should paginate & order (can be huge):
$logs = $db->createQueryBuilder()
->select('id','feed_id','status','updated_at','error_message')
->from(self::T_LOG) // define T_LOG = 'ShoppingFeedLog' (or snake_case)
->orderBy('updated_at','DESC')
->setFirstResult($offset)->setMaxResults($perPage)
->executeQuery()->fetchAllAssociative();
Optionally join feed name for quick context.
5) Harden `deleteFeed($id)`: enforce method (POST/DELETE), CSRF & permissions, use bound param + check affected rows; return 404/400 appropriately.
if (!$this->token->validate('sfm_delete')) return $rf->json(['success'=>false,'message'=>t('Invalid token')],400);
$affected = $db->createQueryBuilder()->delete(self::T_CFG)->where('id = :id')->setParameter('id',(int)$id)->executeStatement();
if ($affected === 0) return $rf->json(['success'=>false,'message'=>t('Not found')],404);
return $rf->json(['success'=>true,'message'=>t('Feed deleted')]);
(Also remove unused imports like `Application`, `Url`, and `FeedService` here.)
<?php
namespace ConcretePackageShoppingFeedManagerControllerSinglePageDashboard;
use ConcreteCorePageControllerDashboardPageController;
use ConcreteCoreDatabaseConnectionConnection;
use ConcreteCoreHttpResponseFactoryInterface;
use DoctrineDBALException;
class ShoppingFeed extends DashboardPageController
{
// Adjust to 'shoppingfeedconfiguration' / 'shoppingfeedlog' if your DB uses snake_case
private const T_CFG = 'ShoppingFeedConfiguration';
private const T_LOG = 'ShoppingFeedLog';
/** @return array{page:int, perPage:int, offset:int} */
private function getPagination(): array
{
$req = $this->request;
$perPage = (int) $req->query->get('limit', 10);
$perPage = max(1, min($perPage, 100));
$page = max(1, (int) $req->query->get('page', 1));
$offset = ($page - 1) * $perPage;
return compact('page', 'perPage', 'offset');
}
public function view()
{
/** @var Connection $db */
$db = $this->app->make(Connection::class);
['page' => $page, 'perPage' => $perPage, 'offset' => $offset] = $this->getPagination();
try {
// total count
$total = (int) $db->createQueryBuilder()
->select('COUNT(*)')
->from(self::T_CFG)
->executeQuery()
->fetchOne();
// rows
$feeds = $db->createQueryBuilder()
->select('id', 'url', 'name', 'feedSlug', 'noOfProducts', 'updated_at')
->from(self::T_CFG)
->orderBy('id', 'DESC')
->setFirstResult($offset)
->setMaxResults($perPage)
->executeQuery()
->fetchAllAssociative();
$this->set('feeds', $feeds);
$this->set('total', $total);
$this->set('page', $page);
$this->set('limit', $perPage);
} catch (Exception $e) {
$this->error->add($e->getMessage());
}
}
public function tracklog()
{
/** @var Connection $db */
$db = $this->app->make(Connection::class);
['page' => $page, 'perPage' => $perPage, 'offset' => $offset] = $this->getPagination();
try {
$total = (int) $db->createQueryBuilder()
->select('COUNT(*)')
->from(self::T_LOG)
->executeQuery()
->fetchOne();
// Latest first; join config for quick context (name, slug)
$logs = $db->createQueryBuilder()
->select('l.id', 'l.feed_id', 'l.status', 'l.updated_at', 'l.error_message', 'c.name AS feed_name', 'c.feedSlug')
->from(self::T_LOG, 'l')
->leftJoin('l', self::T_CFG, 'c', 'c.id = l.feed_id')
->orderBy('l.updated_at', 'DESC')
->setFirstResult($offset)
->setMaxResults($perPage)
->executeQuery()
->fetchAllAssociative();
$this->set('logs', $logs);
$this->set('total', $total);
$this->set('page', $page);
$this->set('limit', $perPage);
} catch (Exception $e) {
$this->error->add($e->getMessage());
}
}
public function deleteFeed($id)
{
/** @var Connection $db */
$db = $this->app->make(Connection::class);
/** @var ResponseFactoryInterface $rf */
$rf = $this->app->make(ResponseFactoryInterface::class);
// Optional: enforce HTTP method
// if (!$this->request->isMethod('POST')) {
// return $rf->json(['success' => false, 'message' => t('Invalid method')], 405);
// }
// CSRF token check (include a hidden token in your form named 'sfm_delete')
if (!$this->token->validate('sfm_delete')) {
return $rf->json(['success' => false, 'message' => t('Invalid token')], 400);
}
try {
$affected = $db->createQueryBuilder()
->delete(self::T_CFG)
->where('id = :id')
->setParameter('id', (int) $id)
->executeStatement();
if ($affected === 0) {
return $rf->json(['success' => false, 'message' => t('Not found')], 404);
}
return $rf->json(['success' => true, 'message' => t('Feed deleted')]);
} catch (Exception $e) {
return $rf->json(['success' => false, 'message' => $e->getMessage()], 500);
}
}
}
“`php
Here’s the fixed controller (no `executeQuery()` calls, uses `fetchAllAssociative`/`fetchAssociative` + safe slug/url build):
<?php
namespace ConcretePackageShoppingFeedManagerControllerSinglePageDashboardShoppingFeed;
use ConcreteCorePageControllerDashboardPageController;
use ConcreteCoreHttpRequest;
use ConcreteCoreDatabaseConnectionConnection;
use ConcreteCoreRoutingRedirect;
use ConcreteCoreSiteService as SiteService;
use ConcretePackageShoppingFeedManagerServiceFeedService;
use DoctrineDBALException;
use ConcreteCoreSupportFacadeUrl;
class Add extends DashboardPageController
{
private const TABLE_CONFIG = 'ShoppingFeedConfiguration'; // or 'shoppingfeedconfiguration'
public function view()
{
/** @var Connection $db */
$db = $this->app->make(Connection::class);
$this->set('product_types', $db->fetchAllAssociative('SELECT * FROM CommunityStoreProductTypes'));
$this->set('manufacturers', $db->fetchAllAssociative('SELECT * FROM CommunityStoreManufacturer'));
$this->set('product_groups', $db->fetchAllAssociative('SELECT * FROM CommunityStoreGroups'));
$this->set('products', $db->fetchAllAssociative('SELECT pID, pName FROM CommunityStoreProducts'));
$row = $db->fetchAssociative('SELECT MIN(pPrice) AS min_price, MAX(pPrice) AS max_price FROM CommunityStoreProducts');
$this->set('minPrice', $row['min_price'] ?? 0);
$this->set('maxPrice', $row['max_price'] ?? 0);
$site = $this->app->make(SiteService::class)->getSite();
$this->set('locales', $site ? $site->getLocales() : []);
}
public function submit()
{
/** @var FeedService $feedService */
$feedService = $this->app->make(FeedService::class);
/** @var Request $req */
$req = $this->app->make(Request::class);
/** @var Connection $db */
$db = $this->app->make(Connection::class);
$name = trim((string) $req->request->get('name'));
$urlInput = trim((string) $req->request->get('url'));
$feedSlugIn = trim((string) $req->request->get('feedSlug'));
$settings = (array) $req->request->get('settings', []);
$enableCron = $req->request->get('enable_cron') ? 1 : 0;
// normalize checkbox flags
foreach (['pFeatured','onsale','outofstock','inactive'] as $flag) {
$settings[$flag] = !empty($settings[$flag]) ? 1 : 0;
}
if ($name === '') {
$this->error->add(t('Name is required.'));
return $this->view();
}
// slug + url
$feedSlug = $feedSlugIn !== '' ? preg_replace('/[^a-zA-Z0-9-_]/', '-', $feedSlugIn)
: 'feed-' . bin2hex(random_bytes(4));
$url = $urlInput !== '' ? $urlInput : (string) Url::to('/google-merchant-feed/' . $feedSlug);
try {
$db->insert(self::TABLE_CONFIG, [
'enableCron' => $enableCron,
'url' => $url,
'name' => $name,
'settings' => json_encode($settings, JSON_UNESCAPED_UNICODE),
'feedSlug' => $feedSlug,
]);
$countProducts = (int) $feedService->countProductsInFeed($feedSlug);
$db->update(self::TABLE_CONFIG, ['noOfProducts' => $countProducts], ['feedSlug' => $feedSlug]);
$this->flash('success', t('Feed created successfully.'));
return Redirect::to('/dashboard/shopping_feed/');
} catch (Exception $e) {
$this->error->add($e->getMessage());
return $this->view();
}
}
}
If you prefer sticking with `QueryBuilder`, replace every `->executeQuery()` with `->execute()`, then use `->fetchAll()` / `->fetch()` on the returned statement (DBAL 2.x).
{
foreach ($products as $product) {
$productu = Product::getByID($product['pID']);
$page = Page::getByID($productu->getPageID());
if (!$page || $page->isError()) {
$feedService->sendFeedErrorEmail('Feed generation error',"Missing or invalid page for product ID {$product['pID']} ({$product['pName']}). Page ID: {$pageID}");
$this->logFeedStatus($db, $config['id'], 'error', "Missing or invalid page for product ID {$product['pID']} ({$product['pName']}). Page ID: {$pageID}");
continue;
}
$url = $page->getCollectionLink();
$imageUrl = '';
$image = $productu->getImageObj();
if (is_object($image)) {
$imageUrl = $image->getURL();
}
$brand = '';
if (is_numeric($product['pManufacturer'])) {
$manufacturer = $db->fetchAssociative("SELECT * FROM CommunityStoreManufacturer WHERE mID = ?", [$product['pManufacturer']]);
if (is_array($manufacturer)) {
$brand = $manufacturer['mName'];
}
}
$product['pDesc']==$product['pDesc'] ? $product['pDesc']:$product['pName'];
$title=$product['pName'];
$description=$product['pDesc'];
if($pLocale!='en_GB')
{
$otherLangTitle = $db->fetchAssociative("SELECT * FROM CommunityStoreTranslations WHERE pID='".$product['pID']."' AND entityType='productName' AND locale = ?", [$pLocale]);
$title=$otherLangTitle['translatedText'] ?? $otherLangpDesc['extendedText'] ?? $product['pName'];
$otherLangpDesc = $db->fetchAssociative("SELECT * FROM CommunityStoreTranslations WHERE pID='".$product['pID']."' AND entityType='productDescription' AND locale = ?", [$pLocale]);
$description=$otherLangpDesc['extendedText'] ?? $otherLangpDesc['translatedText'] ?? $product['pDesc'];
}
$item = $channel->addChild('item');
$item->addChild('g:id', $product['pID'], 'http://base.google.com/ns/1.0');
$item->addChild('title', htmlspecialchars($title));
$item->addChild('description', htmlspecialchars($description));
$item->addChild('link', htmlspecialchars($url));
$item->addChild('g:image_link', htmlspecialchars($imageUrl), 'http://base.google.com/ns/1.0');
$item->addChild('g:availability', 'in stock', 'http://base.google.com/ns/1.0');
$item->addChild('g:price', $product['pPrice'] . ' USD', 'http://base.google.com/ns/1.0');
$item->addChild('g:brand', htmlspecialchars($brand), 'http://base.google.com/ns/1.0');
$item->addChild('g:condition', 'new', 'http://base.google.com/ns/1.0');
$item->addChild('g:identifier_exists', 'false', 'http://base.google.com/ns/1.0');
}
$this->logFeedStatus($db, $config['id'], 'alert', 'Feed generated successfully');
$db->update('shoppingfeedconfiguration',['latestGeneration'=>date('Y-m-d H:i:s')],['id'=>$config['id']]);
return new Response($xml->asXML(), 200, ['Content-Type' => 'application/xml']);
}else{
$this->logFeedStatus($db, $config['id'], 'error', 'No matching product found');
$feedService->sendFeedErrorEmail('Feed generation error',"No matching product found for feed slug {$feedSlug}");
return new Response("Error generating feed: No matching product found for feed slug {$feedSlug}", 200);
}
} catch (Exception $e) {
if (isset($db) && isset($config['id'])) {
$feedService->sendFeedErrorEmail('Feed generation error',"Error generating feed: " . $e->getMessage(). "For feed slug {$feedSlug}");
$this->logFeedStatus($db, $config['id'], 'error', $e->getMessage());
}
$feedService->sendFeedErrorEmail('Feed generation error',"Error generating feed: " . $e->getMessage(). "For feed slug {$feedSlug}");
return new Response("Error generating feed: " . $e->getMessage(). "For feed slug {$feedSlug}", 500);
}
}
}
apply doctrine dbal 2 code in above and give me updated code
<?php
namespace ConcretePackageShoppingFeedManagerController;
use ConcreteCoreControllerAbstractController;
use ConcreteCoreDatabaseConnectionConnection;
use SymfonyComponentHttpFoundationResponse;
use ConcretePackageCommunityStoreSrcCommunityStoreProductProduct;
use ConcreteCoreLoggingLoggerFactory;
use ConcreteCoreSupportFacadeUrl;
use ConcreteCorePagePage;
use ConcretePackageShoppingFeedManagerServiceFeedService;
use DoctrineDBALConnection as DbalConnection;
use Exception;
use DateTime;
class MerchantFeed extends AbstractController
{
// Adjust if your DB uses snake_case:
private const TABLE_CONFIG = 'ShoppingFeedConfiguration';
private const TABLE_LOG = 'ShoppingFeedLog';
protected function logFeedStatus(Connection $db, int $feedId, string $status, ?string $message = null): void
{
try {
// fetch feed
$qb = $db->createQueryBuilder();
$stmt = $qb->select('*')
->from(self::TABLE_CONFIG)
->where('id = :id')
->setParameter('id', $feedId, PDO::PARAM_INT)
->execute();
$feed = $stmt->fetchAssociative();
$now = (new DateTime())->format('Y-m-d H:i:s');
// insert log row (DBAL 2 insert with types)
$db->insert(
self::TABLE_LOG,
[
'feed_id' => $feedId,
'updated_at' => $now,
'status' => $status,
'error_message'=> $message,
],
[
'feed_id' => PDO::PARAM_INT,
'updated_at' => PDO::PARAM_STR,
'status' => PDO::PARAM_STR,
'error_message'=> PDO::PARAM_STR,
]
);
// core logger
/** @var LoggerFactory $loggerFactory */
$loggerFactory = $this->app->make(LoggerFactory::class);
$logger = $loggerFactory->createLogger('shopping_feed');
$slug = is_array($feed) && isset($feed['feedSlug']) ? $feed['feedSlug'] : (string) $feedId;
$logMessage = "Feed: {$slug}" . ($message ? " — {$message}" : '');
switch (strtolower($status)) {
case 'debug': $logger->debug($logMessage); break;
case 'notice': $logger->notice($logMessage); break;
case 'warning': $logger->warning($logMessage); break;
case 'error': $logger->error($logMessage); break;
case 'critical': $logger->critical($logMessage); break;
case 'alert': $logger->alert($logMessage); break;
case 'emergency': $logger->emergency($logMessage); break;
case 'info':
default: $logger->info($logMessage); break;
}
} catch (Exception $logException) {
// Avoid recursive logging; fall back to PHP error log
error_log("Feed logging failed: " . $logException->getMessage());
}
}
public function generateXml($feedSlug = null)
{
/** @var FeedService $feedService */
$feedService = $this->app->make(FeedService::class);
/** @var Connection $db */
$db = $this->app->make(Connection::class);
try {
if (!$feedSlug) {
throw new Exception("Feed slug is required.");
}
// 1) Load feed config (DBAL 2)
$qb = $db->createQueryBuilder();
$stmt = $qb->select('*')
->from(self::TABLE_CONFIG)
->where('feedSlug = :feedSlug')
->setParameter('feedSlug', $feedSlug, PDO::PARAM_STR)
->execute();
$config = $stmt->fetchAssociative();
if (!$config) {
throw new Exception("Feed not found.");
}
// 2) Decode settings
$settings = json_decode($config['settings'] ?? '[]', true) ?: [];
$limit = isset($settings['records_per_page']) ? (int) $settings['records_per_page'] : 1000;
$limit = max(1, min($limit, 5000)); // cap for safety
$pLocale = $settings['language'] ?? 'en_GB';
// Normalize arrays
$productGroups = isset($settings['product_groups']) && is_array($settings['product_groups']) ? array_filter($settings['product_groups']) : [];
$productTypes = isset($settings['product_types']) && is_array($settings['product_types']) ? array_filter($settings['product_types']) : [];
$brands = isset($settings['brand']) && is_array($settings['brand']) ? array_filter($settings['brand']) : [];
$productIds = isset($settings['pName']) && is_array($settings['pName']) ? array_filter($settings['pName']) : [];
// 3) Build product query (DBAL 2)
$qb = $db->createQueryBuilder();
$qb->select('p.*')
->from('CommunityStoreProducts', 'p');
// price range
$minPrice = $settings['price_range']['min'] ?? null;
$maxPrice = $settings['price_range']['max'] ?? null;
if ($minPrice !== null && $maxPrice !== null && $minPrice !== '' && $maxPrice !== '') {
$qb->andWhere('p.pPrice BETWEEN :minPrice AND :maxPrice')
->setParameter('minPrice', (float) $minPrice)
->setParameter('maxPrice', (float) $maxPrice);
}
// groups
if (!empty($productGroups)) {
$qb->innerJoin('p', 'CommunityStoreProductGroups', 'pg', 'pg.pID = p.pID')
->andWhere($qb->expr()->in('pg.gID', ':groupIds'))
->setParameter('groupIds', $productGroups, DbalConnection::PARAM_INT_ARRAY);
}
// types
if (!empty($productTypes)) {
$qb->andWhere($qb->expr()->in('p.pType', ':productTypes'))
->setParameter('productTypes', $productTypes, DbalConnection::PARAM_INT_ARRAY);
}
// simple flags
if (!empty($settings['pShippable'])) {
$qb->andWhere('p.pShippable = :pShippable')
->setParameter('pShippable', (string) $settings['pShippable'], PDO::PARAM_STR);
}
if (!empty($settings['pTaxable'])) {
$qb->andWhere('p.pTaxable = :pTaxable')
->setParameter('pTaxable', (string) $settings['pTaxable'], PDO::PARAM_STR);
}
if (!empty($settings['pFeatured'])) {
$qb->andWhere('p.pFeatured = 1');
}
if (!empty($settings['onsale'])) {
$qb->andWhere('NOW() BETWEEN p.pSaleStart AND p.pSaleEnd');
}
if (!empty($productIds)) {
$qb->andWhere($qb->expr()->in('p.pID', ':productIds'))
->setParameter('productIds', $productIds, DbalConnection::PARAM_INT_ARRAY);
}
if (!empty($brands)) {
$qb->andWhere($qb->expr()->in('p.pManufacturer', ':manufacturerIds'))
->setParameter('manufacturerIds', $brands, DbalConnection::PARAM_INT_ARRAY);
}
if (!empty($settings['pSku'])) {
$qb->andWhere('p.pSku LIKE :sku')
->setParameter('sku', '%' . $settings['pSku'] . '%', PDO::PARAM_STR);
}
// stock/active filters (default ON)
if (empty($settings['inactive'])) {
$qb->andWhere('p.pActive = 1');
}
if (empty($settings['outofstock'])) {
$qb->andWhere('(p.pQty > 0 OR p.pQtyUnlim = 1)');
}
// order + limit
switch ($settings['sort_order'] ?? '') {
case 'alphabetical':
$qb->orderBy('p.pName', 'ASC');
break;
case 'price':
$qb->orderBy('p.pPrice', 'ASC');
break;
default:
$qb->orderBy('p.pID', 'DESC');
}
$qb->setMaxResults($limit);
$stmt = $qb->execute(); // DBAL 2
$products = [];
while ($row = $stmt->fetchAssociative()) {
$products[] = $row;
}
// 4) Build XML
$xml = new SimpleXMLElement('<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"/>');
$xml->addAttribute('xmlns:g', 'http://base.google.com/ns/1.0');
$channel = $xml->addChild('channel');
$channel->addChild('title', 'Your Store Feed');
$channel->addChild('link', (string) Url::to('/'));
$channel->addChild('description', 'Product feed for Google Merchant Center');
if (count($products) > 0) {
foreach ($products as $product) {
$productObj = Product::getByID((int) $product['pID']);
if (!$productObj) {
$msg = "Missing product object for product ID {$product['pID']}.";
$feedService->sendFeedErrorEmail('Feed generation error', $msg);
$this->logFeedStatus($db, (int) $config['id'], 'error', $msg);
continue;
}
$page = Page::getByID((int) $productObj->getPageID());
if (!$page || $page->isError()) {
$msg = "Missing or invalid page for product ID {$product['pID']} ({$product['pName']}).";
$feedService->sendFeedErrorEmail('Feed generation error', $msg);
$this->logFeedStatus($db, (int) $config['id'], 'error', $msg);
continue;
}
$url = (string) $page->getCollectionLink();
$imageUrl = '';
$image = $productObj->getImageObj();
if (is_object($image)) {
$imageUrl = (string) $image->getURL();
}
// brand lookup (bind param)
$brand = '';
if (isset($product['pManufacturer']) && is_numeric($product['pManufacturer'])) {
$brandRow = $db->fetchAssociative(
'SELECT mName FROM CommunityStoreManufacturer WHERE mID = ?',
[(int) $product['pManufacturer']]
);
if ($brandRow && isset($brandRow['mName'])) {
$brand = $brandRow['mName'];
}
}
// Title/Description with translation (bind all)
$title = $product['pName'] ?? '';
$description = $product['pDesc'] ?? $title;
if ($pLocale !== 'en_GB') {
$otherLangTitle = $db->fetchAssociative(
"SELECT translatedText FROM CommunityStoreTranslations
WHERE pID = ? AND entityType = 'productName' AND locale = ?",
[(int) $product['pID'], $pLocale]
);
$otherLangDesc = $db->fetchAssociative(
"SELECT extendedText, translatedText FROM CommunityStoreTranslations
WHERE pID = ? AND entityType = 'productDescription' AND locale = ?",
[(int) $product['pID'], $pLocale]
);
if ($otherLangTitle && !empty($otherLangTitle['translatedText'])) {
$title = $otherLangTitle['translatedText'];
}
if ($otherLangDesc) {
$description = $otherLangDesc['extendedText'] ?? $otherLangDesc['translatedText'] ?? $description;
}
}
// Item
$item = $channel->addChild('item');
$item->addChild('g:id', (string) $product['pID'], 'http://base.google.com/ns/1.0');
$item->addChild('title', htmlspecialchars($title, ENT_XML1));
$item->addChild('description', htmlspecialchars($description, ENT_XML1));
$item->addChild('link', htmlspecialchars($url, ENT_XML1));
$item->addChild('g:image_link', htmlspecialchars($imageUrl, ENT_XML1), 'http://base.google.com/ns/1.0');
$item->addChild('g:availability', (!empty($product['pQty']) || !empty($product['pQtyUnlim'])) ? 'in stock' : 'out of stock', 'http://base.google.com/ns/1.0');
// TODO: replace hardcoded currency 'USD' with your store currency if available
$item->addChild('g:price', (string) $product['pPrice'] . ' USD', 'http://base.google.com/ns/1.0');
$item->addChild('g:brand', htmlspecialchars($brand, ENT_XML1), 'http://base.google.com/ns/1.0');
$item->addChild('g:condition', 'new', 'http://base.google.com/ns/1.0');
$item->addChild('g:identifier_exists', 'false', 'http://base.google.com/ns/1.0');
}
// success
$this->logFeedStatus($db, (int) $config['id'], 'alert', 'Feed generated successfully');
$db->update(
self::TABLE_CONFIG,
['latestGeneration' => (new DateTime())->format('Y-m-d H:i:s')],
['id' => (int) $config['id']],
[PDO::PARAM_STR, PDO::PARAM_INT]
);
return new Response($xml->asXML(), 200, ['Content-Type' => 'application/xml']);
}
// no products matched
$this->logFeedStatus($db, (int) $config['id'], 'error', 'No matching product found');
$feedService->sendFeedErrorEmail(
'Feed generation error',
"No matching product found for feed slug <a href='" . (string) Url::to('/google-merchant-feed/') . "/{$feedSlug}'>{$feedSlug}</a>"
);
return new Response(
"Error generating feed: No matching product found for feed slug <a href='" . (string) Url::to('/google-merchant-feed/') . "/{$feedSlug}'>{$feedSlug}</a>",
200
);
} catch (Exception $e) {
// Try to log with feed id if available
if (isset($db, $config['id'])) {
$this->logFeedStatus($db, (int) $config['id'], 'error', $e->getMessage());
}
$feedService->sendFeedErrorEmail(
'Feed generation error',
"Error generating feed: " . $e->getMessage() . " For feed slug <a href='" . (string) Url::to('/google-merchant-feed/') . "/{$feedSlug}'>{$feedSlug}</a>"
);
return new Response(
"Error generating feed: " . $e->getMessage() . " For feed slug <a href='" . (string) Url::to('/google-merchant-feed/') . "/{$feedSlug}'>{$feedSlug}</a>",
500
);
}
}
}
**What changed (highlights):**
– Replaced all `->executeQuery()` with **DBAL 2**-compatible `->execute()` and used `fetchAssociative()` on the resulting statement.
– Bound every parameter (including those in `IN (…)`) using `Connection::PARAM_INT_ARRAY` where appropriate.
– Fixed unsafe string concatenation in translation queries; now parameterized.
– Removed undefined `$pageID` and the no-op `$product['pDesc']==$product['pDesc']` line; ensured description fallback works.
– Centralized table names via constants and used a `TABLE_LOG` constant for consistency.
– Escaped XML text with `ENT_XML1` to avoid invalid XML on special chars.
– Capped `records_per_page` to prevent huge responses.
If your tables are actually snake_case, just change:
private const TABLE_CONFIG = 'shoppingfeedconfiguration';
private const TABLE_LOG = 'shoppingfeedlog';
and you’re set.
Here’s a corrected version of that service class. I’ve wrapped the body of `countProductsInFeed` in a `try { … } catch` block so it compiles cleanly, and adjusted the structure a bit:
<?php
namespace ConcretePackageShoppingFeedManagerService;
use ConcreteCoreMailService as MailService;
use ConcreteCoreDatabaseConnectionConnection;
use ConcreteCoreSupportFacadeApplication;
class FeedService
{
private $db;
private const TABLE_CONFIG = 'ShoppingFeedConfiguration';
public function __construct(Connection $db)
{
$this->db = $db;
}
public function countProductsInFeed(string $feedSlug): int
{
try {
error_log("countProductsInFeed - Feed slug: " . $feedSlug);
$qb = $this->db->createQueryBuilder();
$qb->select('*')
->from(self::TABLE_CONFIG)
->where('feedSlug = :feedSlug')
->setParameter('feedSlug', $feedSlug);
$config = $qb->execute()->fetchAssociative();
if (!$config) {
error_log("No config found for feed slug: " . $feedSlug);
return 0;
}
$settings = json_decode($config['settings'] ?? '{}', true) ?: [];
$limit = !empty($settings['records_per_page']) ? (int)$settings['records_per_page'] : 1000;
$qb = $this->db->createQueryBuilder();
$qb->select('COUNT(DISTINCT p.pID) as total')
->from('CommunityStoreProducts', 'p');
// add conditions here using $settings, as you had before...
$result = $qb->execute()->fetchAssociative();
return (int) ($result['total'] ?? 0);
} catch (Exception $e) {
error_log('Error counting products: ' . $e->getMessage());
return 0;
}
}
public function sendFeedErrorEmail(string $subject, string $messageBody): void
{
try {
$app = Application::getFacadeApplication();
$config = $app->make('config');
$notifyEmail = $config->get('shopping_feed_manager.notify_email');
$notifyError = $config->get('shopping_feed_manager.notify_on_error');
if ($notifyError && $notifyEmail) {
$mailService = $app->make(MailService::class);
$mailService->setTesting(false);
$mailService->to($notifyEmail);
$mailService->setSubject($subject);
$mailService->setBodyHTML($messageBody);
$mailService->sendMail();
}
} catch (Exception $e) {
error_log('Email sending failed: ' . $e->getMessage());
}
}
}
Key fix: wrapped everything in `countProductsInFeed()` in a `try { … } catch` so the `catch` has a matching `try`.
👉 You can now safely re-add the detailed filters (`product_groups`, `price_range`, etc.) back inside that try block. Would you like me to also reinsert all the filtering logic you had before, but in this corrected structure?