Handle non-conventional entity naming, default value typing and the cart entity in extra properties by jolelievre · Pull Request #42538 · PrestaShop/PrestaShop · GitHub
Skip to content

Handle non-conventional entity naming, default value typing and the cart entity in extra properties - #42538

Merged
jolelievre merged 11 commits into
PrestaShop:9.2.xfrom
jolelievre:extra-property-edge-cases
Sep 7, 2026
Merged

jolelievre merged 11 commits into
PrestaShop:9.2.xfrom
jolelievre:extra-property-edge-cases

Conversation

@jolelievre

@jolelievre jolelievre commented Sep 3, 2026 •

Copy link
Copy Markdown
Contributor
Questions Answers
Branch? 9.2.x
Description? Hardens three edge-case areas of the Extra Properties feature before the 9.2.0 final release: entities with non-conventional naming (Order → orders, Combination → product_attribute), end-to-end typing of values and default values, and the cart entity. Details below.
Type? improvement
Category? CO
BC breaks? no
Deprecations? no
How to test? See the manual test procedure below. Automated: tests/Unit/Core/ExtraProperty/, tests/Integration/Core/ExtraProperty/, tests/Integration/Adapter/Cart/CartExtraPropertiesTest.php, tests/Integration/ApiPlatform/EndPoint/ExtraPropertyEndpointTest.php, behat suite extra-property-definition.
UI Tests N/A
Fixed issue or discussion? Fixes #41779, Fixes #41829, Fixes #41424
Related PRs PrestaShop/autoupgrade#1899 (adds the extra_property_definition registry — now including the table_name and controller_name columns introduced here — and its _shop association table to upgrade/sql/9.2.0.sql); PrestaShop/example-modules#258 (demoextrafield cart/order/combination fixtures — the recommended manual-test vehicle, see below).
Sponsor company

1. Non-conventional entity naming (#41779)

The entity name stays logical (order, combination — every spelling of an irregular entity converges on one canonical name), while the resolved physical table (orders, product_attribute) is persisted in a new table_name registry column and drives all storage concerns. Resolution: canonical map → explicit tableName constructor parameter (escape hatch for third-party ObjectModels) → the entity's own ObjectModel class $definition['table'] → the entity name itself. The canonical map deliberately fires before class resolution: the modern ProductAttribute class is the renamed Attribute entity (table attribute), so class-resolving that spelling would hijack combination storage.

A repository sweep extended the canonical map beyond orders/product_attribute. Renamed CQRS domains follow the combination rule — the modern name is canonical, the entity being headed for that rename, while the physical table stays the legacy one (mapped in a new CANONICAL_ENTITY_TABLES const, since no ObjectModel class carries the modern names yet): cart_rule → discount, cms → cms_page, cms_category → cms_page_category, gender → title, order_slip → credit_slip, specific_price_rule → catalog_price_rule, request_sql → sql_request. Table spellings of class↔table disparities converge like orders does (connections → connection, lang → language, webservice_account → webservice_key), and so does the BO grid spelling merchandise_return → order_return. Re-registering an existing definition with a different tableName is now refused as a destructive change (it would silently relocate storage and orphan stored values).

The primary key is no longer deduced as id_{entity}: the repository introspects the live extra table's PK at hydration (the schema manager mirrored the base table's real PK at creation), fixing every BO/API surface that used to build the unknown id_orders column. Also fixed: API item-id resolution (combination → combinationId, order → orderId), and a registry guard refusing two entity names claiming the same storage column. The BO permission subject (used by the grid toggle, and by any future permission check) is now a definition-level concern: ExtraPropertyDefinition::getControllerName() resolves an explicit controllerName registration override (persisted override-only in the new controller_name column — a value matching the deduction collapses to null so deductions keep following core code as BO tabs evolve) → an irregular-tab map covering every migrated grid whose real tab breaks the Admin + pluralized-entity convention (attribute → AdminAttributesGroups, cms → AdminCmsContent, order_state → AdminStatuses, …, each verified against the page's own routing _legacy_controller) → the inflector convention (order → AdminOrders instead of the always-403 AdminOrderses); unknown subjects stay deny-safe.

Documented limitation: grids using id-first pagination (the order grid) cannot take extra columns yet — the modifier skips them with a warning log; follow-up in #42536.

2. Default value typing (#41829)

Broadens the scope of #42343 (CQRS-only) to every layer:

  • one canonical stringification for defaults (BOOL false used to be persisted as '' and read back as "no default"); the BO form no longer drops a falsy '0' default;
  • the CQRS layer carries defaults with their scalar type; the BO form boundary stringifies;
  • registration refuses a default incompatible with the declared type (non-numeric INT/FLOAT, non-literal DATE — relative wordings like tomorrow are never interpreted —, CHOICE outside the enum, invalid JSON). The same rule-set (ExtraPropertyValidator::isValueCompatible()) now also applies implicitly to every regular write (ObjectModel, Admin API, and BO forms via an auto-attached ExtraPropertyTypeCompatibility constraint reporting inline field errors), even with zero declared constraints — a value that cannot be a default cannot be stored either ('abc' on an INT used to be silently stored as 0); declared constraints keep message priority;
  • a row-less entity now reads back the declared default on every reader-served surface (FO, BO entity form/grid, Admin API), and the entity create form prefills it. TEXT-backed types (HTML, JSON) cannot carry a MySQL DDL DEFAULT: their default is registry-only, served by that read injection;
  • the writer itself is now the single castForDb() choke point (form, ObjectModel, Admin API and module writes get identical declared-type coercion); JSON values are stored encoded and exposed decoded on reads — the Admin API returns real objects matching its documented type: object schema and FO templates iterate arrays — with re-encoding at the two BO presentation boundaries;
  • the OpenAPI schema emits each property's default.

3. Cart entity (#41424)

The cart wiring already worked (CartLazyArray exposes extra_properties — snake_case, the camelCase extraProperties spelling in the issue belongs to the Admin API JSON only). This PR pins it with integration tests, whitelists extra_properties in CartFilter so the JS prestashop.cart object carries the FO-visible values (the cart-AJAX JSON already did), and documents the cart as a COMMON-only entity (no cart_lang/cart_shop base table — LANG/SHOP registrations are cleanly rejected). Cart values die at checkout: modules copy them onto the order at actionValidateOrder — the demoextrafield example demonstrates the full pattern.

Manual test procedure

Recommended setup: install the demoextrafield companion update — its fixtures exercise all three issues.

  1. Naming — Advanced Parameters > Extra Properties lists the cart, order and combination entities; the storage tables sit next to the physical base tables: orders_extra(id_order), product_attribute_extra(id_product_attribute), cart_extra(id_cart).
  2. Cart → order — add a product to the cart (the module seeds a demo delivery note): the note shows above the checkout summary; place the order: it appears on the customer's order detail page.
  3. Defaults — create a property with entity Combination, type Integer, default 0: saved as combination/product_attribute with default 0 (previously dropped as falsy), shown on the edit form; a default abc on an Integer type is refused. A product with no stored value reads the default back (e.g. the module's ean_verified is false on the combinations Admin API list before any write).
  4. JSON — on the my-account page, the customer JSON field renders as an iterated structure (decoded reads), not a raw string.

…ry key

Extra properties can now target entities whose ObjectModel table breaks
the naming convention (PrestaShop#41779): 'order'/'Order'/'orders' and
'combination'/'Combination'/'product_attribute'/'ProductAttribute' all
converge on one canonical logical entity name ('order', 'combination'),
while the resolved physical table ('orders', 'product_attribute') is
persisted in the new table_name registry column and drives every storage
concern (extra table names, base-table checks, grid joins, the writer's
shop-association lookup). The canonical map fires BEFORE class
resolution on purpose: the modern ProductAttribute class is the renamed
Attribute entity (table 'attribute'), so class-resolving that spelling
would silently store combination properties on the attribute table.
An explicit tableName constructor parameter remains as the escape hatch
for third-party ObjectModels whose entity name differs from their table.

The entity primary key is no longer deduced as id_{entity}: the
repository introspects the live extra table's PRI column at hydration
(the schema manager mirrored the base table's real PK at creation), with
the ObjectModel class then the naming convention as fallbacks — fixing
every BO/API surface that used to build the unknown id_orders column
(silent defaults on read, exceptions on write). The ObjectModel/FO
matching goes through the new filterByTableName(); API resolveId gains
logical-name and PK-derived candidates (combination → combinationId,
order → orderId); the grid toggle permission subject is derived through
the inflector with an override map (order → AdminOrders instead of the
403ing AdminOrderses); the registry refuses two entity names claiming
the same storage column (STORAGE_CONFLICT); grids the query modifier
must skip (id-first pagination, e.g. the order grid — see PrestaShop#42536) now
log a warning instead of silently rendering an empty column.

This commit also carries the shared ExtraPropertyValueCaster groundwork
(canonical default stringification, JSON encode/decode, writer-side
declared-type coercion) that the definition VO, repository, reader and
writer interlock with — the next commit completes the default-value
scope on top of it.

Fixes PrestaShop#41779
Completes the default-value scope (PrestaShop#41829) on the caster groundwork of
the previous commit:

- the CQRS layer carries the default with its scalar type
  (AddExtraPropertyDefinitionCommand and EditableExtraPropertyDefinition
  widen ?string to int|float|string|bool|null, the lossy (string) cast
  leaves the query handler) — a BOOL false default used to be lost on
  reload, since its naive stringification produced '' which hydration
  reads as "no default";
- the BO form boundary stringifies with the same canonical mapping and
  no longer drops a falsy '0' default; defaultValue stays immutable
  after creation (disabled BO field, no Update command field);
- registration refuses a default that does not fit the declared type
  (INVALID_DEFAULT_VALUE: non-numeric INT/FLOAT, invalid DATE — which
  hydration used to erase silently —, CHOICE value outside the enum,
  invalid JSON);
- a row-less entity reads back the declared default on every
  reader-served surface (FO, BO entity form/grid seeds, Admin API), and
  the entity create form prefills it. TEXT-backed types (HTML, JSON)
  cannot carry a DDL DEFAULT clause in MySQL: their default is
  registry-only and served purely by that read injection;
- JSON values are exposed DECODED on reads (the Admin API returns real
  objects matching its documented type: object schema, FO templates
  iterate arrays) and re-encoded at the two BO presentation boundaries;
  the value validator normalizes only that case before running
  constraints, so Assert\Json sees the encoded string while
  Assert\Type('bool') still sees a bool;
- the OpenAPI schema emits each property's default.

Fixes PrestaShop#41829
The cart wiring already worked end to end (PrestaShop#41424): CartLazyArray — the
$cart Smarty global of every FO page — exposes the values under its
snake_case extra_properties index, and the native ObjectModel path
persists them through $cart->save(). This commit pins all of it with an
integration test (register on 'cart', ObjectModel write/reload
round-trip, presented-cart access, LANG/SHOP scopes cleanly rejected —
the cart is a COMMON-only entity, its id_lang/id_shop being plain
columns with no cart_lang/cart_shop base table) and closes the one real
gap: CartFilter now whitelists extra_properties so the JS prestashop.cart
object carries the FO-visible values, consistent with the cart-AJAX
update JSON that already did.

Cart values die with the cart at checkout: a module needing post-order
visibility copies them onto the order at actionValidateOrder — the
demoextrafield example module demonstrates the full pattern (cart
delivery note seeded on actionCartSave, displayed on the checkout
summary, copied to the order, shown on the order detail page).

Fixes PrestaShop#41424
Records in the ExtraProperty component context: the logical entity name
vs physical table split and its resolution rules (canonical map, class
resolution, explicit tableName escape hatch, introspected primary key),
the default-value semantics (canonical stringification, registration
validation, read-time injection, TEXT-backed types being registry-only),
the JSON read/write shape, and the cart as the canonical COMMON-only
entity.
jolelievre added a commit to jolelievre/autoupgrade that referenced this pull request Sep 3, 2026
Companion of PrestaShop/PrestaShop#42538: the registry now persists the
resolved physical entity table (non-conventional naming support), so the
upgrade DDL must create the column for shops upgrading to 9.2.0.
@github-actions github-actions Bot added this to the 9.2.0 milestone Sep 3, 2026
Comment thread src/Core/ExtraProperty/Definition/ExtraPropertyRegistry.php
Comment thread src/Core/ExtraProperty/Definition/ExtraPropertyDefinitionRepository.php Outdated
Comment thread src/Core/ExtraProperty/Definition/ExtraPropertyRegistry.php Outdated
Comment thread src/Core/ExtraProperty/Value/ExtraPropertyWriterInterface.php Outdated
Comment thread src/PrestaShopBundle/Controller/Admin/CommonController.php Outdated
Comment thread src/PrestaShopBundle/EventListener/API/ExtraPropertyApiSubscriber.php Outdated
Review feedback: the reader/writer entity parameter holds the PHYSICAL
table name, so it is now called $tableName across
ExtraPropertyReader/Writer, their interfaces (writeAll, deleteAll,
deleteForShops), the grid query modifier and the API subscriber — the
verbose docblocks warning about the distinction shrink to one line.
Also: the redundant AdminOrders override map is gone (the inflector
already yields it, the unit test keeps pinning the output), a bool is no
longer accepted as an INT default (the BOOL type exists for that), the
entity-name help text gains the attribute example, the OpenAPI schema
test now covers the emitted defaults (typed per scalar, decoded for
JSON, absent when none declared), and two review-flagged comments are
trimmed.
Presenting a cart builds the voucher delete URLs, whose CSRF token
hashes the context customer (Tools::getToken) — a FO context always
carries one, but this test never set it, so every run emitted a silent
'Attempt to read property "id" on null' PHP warning (the Warnings: 2 of
every integration totals line). With a KernelTestCase now preceding it
in the suite, CI's stricter error handler promotes that warning to an
ErrorException and fails the test. Setting the fixture customer fixes
the warning at its root; the suite-wide warning count drops to zero.
@ps-jarvis

Copy link
Copy Markdown

This pull request seems to contain new translation strings. I have summarized them below to ease up review:

  • Admin.Advparameters.Notification
    • Another extra property already stores its values in the same database column. Use the same entity name as the existing property, or a different property name.
    • The default value does not match the field type (or is not one of the allowed choice values).
  • Admin.Advparameters.Feature
    • Entity name
  • Admin.Advparameters.Help
    • Entity or ObjectModel table name (e.g. product, customer, order, combination, attribute).

(Note: this is an automated message, but answering it will reach a real human)

@ps-jarvis

Copy link
Copy Markdown

Hi, thanks for this contribution!

I found some issues with the Pull Request description:

Would you mind having a look at it? This will help us understand how interesting your contribution is, thank you very much!

(Note: this is an automated message, but answering it will reach a real human)

@ps-jarvis ps-jarvis added the Improvement Type: Improvement label Sep 4, 2026
protected readonly LanguageContext $languageContext,
protected readonly ShopContext $shopContext,
protected readonly ExtraPropertyDefinitionShopFilterInterface $definitionShopFilter,
protected readonly ?LoggerInterface $logger = null,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, this one is only used in SF container for the BO so it should always be present no?

* @internal exposed for unit testing only
*/
private static function legacyControllerFromEntityName(string $entityName): string
public static function legacyControllerFromEntityName(string $entityName): string

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This shouldn't be a public methods, this shouldn't even be a static one The only reason you made this method accessible is to allow unit testing it but tests shouldn't force the implementation

Suggested change
public static function legacyControllerFromEntityName(string $entityName): string
protected function legacyControllerFromEntityName(string $entityName): string

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The method doesn't carry that much knowledge, the unit test can be removed, it will be tested indirectly via UI tests
If you really want to test the method then it should be moved into one of our other classes in the ExtraProperty namespace I don't know which one but ideally one that already exists, because creating a new dedicated class just for this seems overkill

global $kernel;
$kernel = self::$kernel;
Context::getContext()->currency = Currency::getDefaultCurrency();
// Presenting a cart builds the voucher delete URLs, whose CSRF token hashes the

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we should use the ContextMockerTrait or a class that includes it by default like SymfonyIntegrationTestCase ?

$this->assertStringContainsString('demoextrafield_reference_code', $common['sql']);
$this->assertStringContainsString('demoextrafield_is_dangerous', $common['sql']);
$this->assertSame([7, 'REF-1', true], $common['params']);
// The writer is the castForDb() choke point: the submitted PHP true is bound as

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure this extra comment is relevant

$this->assertSame($expected, CommonController::legacyControllerFromEntityName($entityName));
}

public static function entityNameProvider(): iterable

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As explained in another comment this simple test shouldn't for the implementation of the method as a public static one, especially since there's not that much intelligence in it

If this test is postponed for later UI tests it's acceptable

@jolelievre
jolelievre marked this pull request as ready for review September 4, 2026 11:59
@jolelievre
jolelievre requested a review from a team as a code owner September 4, 2026 11:59

@mattgoud mattgoud left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested locally on 9.2.x (docker, MySQL 8.4, PHP 8.1). The three areas do what the description says, and I could verify each one end to end. Full local run below, then four points, one of which I would like fixed before merge.

What I verified locally

Naming (#41779). Every spelling converges and the physical storage lands next to the base table:

registered as entity table extra table PK
order / orders / Order order orders orders_extra id_order
combination / ProductAttribute / product_attribute combination product_attribute product_attribute_extra id_product_attribute
cart cart cart cart_extra id_cart
attribute attribute attribute attribute_extra id_attribute

attribute correctly stays on the attribute table, so the ProductAttribute trap you documented is really closed. The introspected PKs match the base tables. Write plus read back through the reader/writer works on orders_extra and product_attribute_extra, so the old id_orders column is gone. Cart LANG and SHOP registrations are refused with the new explicit BASE_TABLE_NOT_FOUND message.

Defaults (#41829). Registry row and DDL both carry the canonical form: BOOL false stores '0' with DEFAULT 0 on the column, INT 0 survives, 1.5 becomes DECIMAL(20,6) DEFAULT 1.500000, and a JSON default stays registry only with COLUMN_DEFAULT NULL on the LONGTEXT, as documented. A row-less product reads back the whole typed set (false, 0, 1.5, the normalised datetime, and the JSON default decoded to an array). Incompatible defaults are refused before any DDL with code 9: abc on INT, not-a-date on DATE, {oops on JSON. A relative DATE default such as tomorrow is normalised to a real datetime rather than reaching MySQL raw, which I had expected to be a hole and is not.

The BO symptom of the issue is fixed on the exact form path: for a BOOL definition defaulting to false, the CQRS layer now carries false and the form data provider yields '0' instead of the empty string that used to read back as "no default". Same for INT 0.

Cart (#41424). Round trip through the ObjectModel bag works (cart_extra row written on update(), value read back on reload). extra_properties in the CartFilter whitelist is in the right alphabetical slot.

Guards. STORAGE_CONFLICT fires with a clear message when an explicit tableName makes a second entity claim an existing column.

Test suites, all green locally: 609 unit, 106 integration (ExtraProperty|CartExtraProperties|CartVoucherSummary|CQRSOpenApiFactory), 22 Behat scenarios / 106 steps in extra-property-definition. CI is green too.


1. [Blocking] hasStorageChanges() ignores tableName, so a table change relocates storage silently

ExtraPropertyRegistry::hasStorageChanges() compares type, scope, size, nullable and enum values, but not getTableName(). Since the STORAGE_CONFLICT guard added in this PR is gated on null === $existingDefinition, re-registering an existing definition with a different explicit tableName passes every check and is treated as non-destructive drift.

Repro on a clean install, module-code side only:

$def = fn (?string $t) => new ExtraPropertyDefinition(
    entityName: 'store', propertyName: 'note', type: ExtraPropertyType::STRING,
    scope: ExtraPropertyScope::COMMON, labelWording: 'QA', tableName: $t,
);
$registry->register($def(null));          // stores in store_extra
// ... a value is saved in ps_store_extra.note ...
$registry->register($def('supplier'));    // accepted, no exception

Resulting state:

ps_extra_property_definition.table_name : store -> supplier
ps_store_extra columns                  : id_store, note   (data still there, now orphaned)
ps_supplier_extra columns               : id_supplier, note (new, empty)
definition now reads from               : supplier_extra (pk id_supplier)

The stored values are still on disk but no longer reachable, and nothing warned. This is strictly the same class of change as a scope change, which the registry already refuses precisely because it moves the table. Reachability is module code only (there is no BO field for tableName, and entity/type/scope stay read-only), but that explicit parameter is exactly the escape hatch the PR documents for third-party ObjectModels, and a module correcting a wrong tableName on upgrade lands here.

Adding $incoming->getTableName() !== $existing->getTableName() to hasStorageChanges() would close it and reuse the existing DESTRUCTIVE_SCHEMA_CHANGE message, which already tells the caller to unregister and re-register.

2. [Needs a decision] Derived permission subject misses the entities whose grid actually exists

CommonController::legacyControllerFromEntityName() is now 'Admin' . pluralize(classify($entityName)), and the last commit dropped the override map as redundant. It is redundant for order, but three entities that have a real Symfony grid still derive to a tab that does not exist, checked against ps_tab on a fresh install:

entity derived subject real tab grid exists
attribute AdminAttributes AdminAttributesGroups yes (attribute)
attribute_group AdminAttributeGroups AdminAttributesGroups yes (attribute_group)
cms_page AdminCmsPages AdminCmsContent yes (cms_page)

A BOOL extra property placed on one of those grids gets a toggle that is permanently 403, which is the same symptom as the AdminOrderses bug this PR fixes for orders. The docblock calls an unknown subject deny-safe, and that is the right default, but here the deny is silent and permanent rather than a real permission decision.

It is not a regression (the old string-based version derived AdminAttributes too), so I am not asking you to fix the pages themselves in this PR. What tips it for me is that the same commit widens the BO help text to product, customer, order, combination, attribute, so the field now suggests attribute while its toggle cannot work. Either keep a small override map for these three, or drop attribute from the help text and note the limitation next to the #42536 one.

3. [Nit] CONTEXT.md still documents the override map that was removed

.ai/Component/ExtraProperty/CONTEXT.md line 114 describes the toggle subject as override map + pluralize(classify()). Commit f8ffa52 removed the map, so the doc committed in this same PR describes code that no longer exists. Worth a quick edit since that file is the entry point for the component.

4. [Nit] Small inaccuracy in the $tableName docblock

Persisted in the registry (table_name column) so hydration never re-resolves

The constructor calls resolveObjectModelDefinition() unconditionally, before the ?? chain, so hydration does re-resolve and then discards the result. Harmless in practice since getAllDefinitions() is served from the serialized filesystem cache and the constructor only runs on a cache miss, but the sentence claims something the code does not do. A null === $tableName || null === $primaryKeyName short circuit would make it true, or just reword to "the stored value always wins".


Nothing above blocks the naming, defaults or cart work, which all hold up under testing. Point 1 is the one I would like closed before merge; 2 needs a decision more than a fix, and 3 and 4 are one-liners.

@ps-jarvis ps-jarvis added the Waiting for author Status: action required, waiting for author feedback label Sep 4, 2026
@ps-jarvis ps-jarvis moved this from Ready for review to Waiting for author in PR Dashboard Sep 4, 2026
@mattgoud

mattgoud commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Recap of my review above, since the blocking point was only stated in prose. Only point 1 blocks the merge.

# Point Status Nature
1 hasStorageChanges() does not compare getTableName() Blocking Silent data loss
2 Derived permission subject for attribute, attribute_group, cms_page Needs a decision Permanently 403 toggle
3 CONTEXT.md line 114 documents the removed override map Nit Doc / code drift
4 $tableName docblock inaccurate about re-resolution Nit Comment only
  1. Blocking. ExtraPropertyRegistry::hasStorageChanges() compares type, scope, size, nullable and enum values, but not the table. Since the new STORAGE_CONFLICT guard is gated on null === $existingDefinition, re-registering an existing definition with a different explicit tableName passes every check. Reproduced: store to supplier, the original column keeps its data but becomes orphaned, the definition then reads an empty table, and nothing warns. Same class as a scope change, which the registry already refuses for exactly that reason. $incoming->getTableName() !== $existing->getTableName() closes it.

  2. Needs a decision rather than a fix. Those three entities have a real Symfony grid but derive to a tab that does not exist (checked against ps_tab), so a BOOL extra property toggle is permanently 403. Not a regression, the previous string-based version derived AdminAttributes too. What tips it is that the same commit widens the BO help text to include attribute, whose toggle cannot work.

  3. Nit. The doc committed in this PR describes the override map that its own last commit removed.

  4. Nit. The docblock states hydration never re-resolves, while the constructor calls resolveObjectModelDefinition() unconditionally before the ?? chain and discards the result.

Everything else held up: the naming, default typing and cart work are verified end to end locally (details in the review), 609 unit / 106 integration / 22 Behat scenarios green, and CI is green.

@mattgoud

mattgoud commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

Re-tested f5a242e locally. The shared rule-set is the right move, and the DATE tightening lands exactly where Cyril and you converged. Verified on the registry:

refused  : tomorrow           refused  : now
accepted : 2026-03-12         accepted : 2026-03-12 08:30:00
refused  : 2026-02-31         refused  : 12/03/2026

2026-02-31 being refused is worth calling out: the round-trip format comparison in isLiteralDateTime() catches the roll-over that createFromFormat() alone would let through as March 3rd. Nice.

My observation (d) from the previous comment is fully closed too: the docblock now describes what the code does, and an unknown definition returns the same 403 as a denied one, so the probe is gone.

One new point: the "every regular write" claim only holds on the Admin API

isDefaultValueCompatible() docblock:

the SAME isValueCompatible() rule-set the validator applies to every regular write, so what is refused as a value is refused as a default and vice versa

and validateValue():

so a value that could not be a default is not storable either (no silent coercion — 'abc' on an INT used to be stored as 0, 'tomorrow' on a DATE was interpreted)

The only consumer of ExtraPropertyValidatorInterface in the whole repository is ExtraPropertyCQRSApiValidator. The BO entity form runs the definition's declared constraints only, and the ObjectModel / module-code path runs nothing at all, so neither goes through isValueCompatible().

On the API path the rule does bite, as intended:

qa_int  <- 'abc'       violations=1  "The value is not compatible with the declared "int" field type."
qa_date <- 'tomorrow'  violations=1  "The value is not compatible with the declared "date" field type."

The same two values through ExtraPropertyWriter::writeAll(), which is what $product->extra_properties[...] = ... and any module write end up calling:

INT  'abc'      -> silently coerced to 0
                   bound params: [1, 0, "tomorrow"]
DATE 'tomorrow' -> SQLSTATE[22007] 1292 Incorrect datetime value: 'tomorrow' for column 'qa_date'
                   raw Doctrine\DBAL\Exception\DriverException out of writeCommon()

So both symptoms the comment says are gone survive outside the API: 'abc' still becomes 0 silently, and 'tomorrow' is only "no longer interpreted" in the sense that MySQL rejects it with a 500 rather than a domain error. The default value hole is genuinely closed; the write hole is closed on one surface out of three.

Two ways to square it, your call:

  • Docblock only. Reword both comments to say the rule-set is shared between defaults and the Admin API write path, and that other paths keep their existing coercion. Cheapest, and honest.
  • Apply it at the choke point. writeAll() is already the single castForDb() choke point this PR established, so calling isValueCompatible() there (per definition, per leaf for the LANG/SHOP array shape, before the cast) would turn that DBAL 500 into a clean refusal on every path and make the docblock true as written. It does widen the PR though, and it is a behaviour change for module code that currently relies on the coercion, so a follow-up is a perfectly reasonable answer.

I have no strong preference between the two, I just would not leave the docblock claiming the broad version while the narrow one is what ships.

Everything else from my earlier comments still checks out on this head, and the suites stay green locally (unit 682, integration 108, behat 22 scenarios / 106 steps). Points (a) tax_rule and (b) combination from the previous comment are still open as far as I can tell. Wrong, corrected: both were already fixed in f5a242e, along with the singular-tab family from point (c). I had diffed only two files of that commit and missed the map additions in ExtraPropertyDefinition.php.

mattgoud
mattgoud previously approved these changes Sep 7, 2026

@mattgoud mattgoud left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving: the four points from my initial review are closed and verified locally on f5a242e, and the naming mapping holds under an exhaustive check (13/13 canonical names, 7/7 canonical tables, 21/21 controller entries against tab.xml).

Suites green locally on this head: unit 682, integration 108, behat 22 scenarios / 106 steps.

What stays open is in my comments above and none of it needs to hold the merge:

  • the isValueCompatible() docblock claiming "every regular write" when only the Admin API path goes through the validator, which is either a reword or a writeAll() follow-up
  • tax_rule missing from the controller map
  • combination → AdminCombinations to settle against the shipment → AdminOrders precedent

Thanks for the two rounds, the table relocation guard and moving the permission subject onto the definition both came out better than what I had suggested.

@ps-jarvis ps-jarvis moved this from Waiting for author to Need 2nd approval in PR Dashboard Sep 7, 2026
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Hello @jolelievre,

Please note that the “Needs autoupgrade PR” label has been automatically added to your Pull Request. This means that the changes made in your PR must also be reported in the Autoupgrade module, responsible for running the update process of PrestaShop stores.

When there's a modification to the db-structure.sql file or if a Doctrine entity has been modified, added, or deleted, it impacts the database structure. Modifying the list of hooks and feature flags has consequences on the update process as well. These changes can affect how the application interacts with the database and how update procedures are handled. It's important to consider these aspects when contributing to ensure a smooth update experience for users.

That's why this contribution should be backported, with another corresponding PR, on the Autoupgrade module's GitHub repository which is available here: Autoupgrade Repository
Contribution to the Autoupgrade module documentation: Documentation Link

Don't forget to add a comment in this current thread with the link to this PR once it has been created.

If you are sure that your Pull Request doesn’t require additional actions, ping committers and it will be checked.

Thank you.

@jolelievre
jolelievre force-pushed the extra-property-edge-cases branch from 6e1b998 to 83e0bbd Compare September 7, 2026 08:53
@jolelievre

Copy link
Copy Markdown
Contributor Author

Thanks @mattgoud Good catch on the coverage — two different situations though:

  • BO forms: this was a real gap, fixed by a new ExtraPropertyTypeCompatibility constraint that the form builder modifier now attaches to every extra field (wrapped in Assert\All for localized ones), so the same isValueCompatible() rules are enforced inline.
  • ObjectModel: the integration already worked — add()/update() call validateExtraProperties() before any row is written, through the same shared validator. What was missing was proof, so a dedicated ObjectModelExtraPropertiesTest now covers it end to end: add/update round-trips, LANG values across two languages, delete purge, and a type-incompatible value aborting the whole save.

@jolelievre

Copy link
Copy Markdown
Contributor Author

So the proper solution was NOT to integrate the validation in the deeper level of the writer, because it's too late (core data already persisted at this point), but to correctly include this new default validation properly in all three layers

@mattgoud

mattgoud commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Correction on my previous comment: the claim was wrong, the docblock was right. Please disregard the "every regular write" point, and in particular do not reword those docblocks on my account.

ObjectModel::add() and update() do call validateExtraProperties() (classes/ObjectModel.php:559 and :759), which calls $validator->validate(), which delegates per property to validateValue(). So the ObjectModel path does go through isValueCompatible(), exactly as the comment says. My grep for consumers of ExtraPropertyValidatorInterface was scoped to src/ and the API module and missed classes/.

Both pieces of evidence I posted were bad, which is what let the wrong conclusion through:

  • the "silently coerced to 0" trace came from calling ExtraPropertyWriterInterface::writeAll() directly, which bypasses ObjectModel entirely. I presented it as what $product->extra_properties[...] = ... ends up doing, which skips the validation step that runs first.
  • my ObjectModel test returned "valid" for 'abc' on an INT because it never reached the validator: findService() resolves through ContainerFinder(Context::getContext()), and my script booted AdminKernel without wiring the container onto the context, so validateExtraProperties() took its documented "container unavailable" early return.

With Context::getContext()->container wired, as in any real BO request:

qa_int  = 'abc'        => REFUSED: _core.qa_int: The value is not compatible with the declared "int" field type.
qa_date = 'tomorrow'   => REFUSED: _core.qa_date: The value is not compatible with the declared "date" field type.
qa_int  = '42'         => VALID
qa_date = '2026-03-12' => VALID

and a real update() throws PrestaShopException: _core.qa_int: The value is not compatible with the declared "int" field type.

So with 83e0bbdc the rule now lands on all four surfaces: registration defaults, Admin API, ObjectModel, and the BO form inline through the new ExtraPropertyTypeCompatibility constraint. Making it a real Symfony constraint with a dependency-free validator so it also builds in the hand-built FO container is the right shape, and auto-attaching it in the form builder modifier rather than asking modules to declare it keeps the safety net non-optional.

The only two gaps left are both already documented behaviour rather than oversights: a direct writeAll() call bypasses validation (not the documented module API, which is $entity->extra_properties[...]), and FO writes skip it when the validator is absent from the legacy container, which is stated in validateExtraProperties() itself.

Sorry for the noise, and for proposing a docblock change that would have made the documentation less accurate than it already was.

@mattgoud mattgoud left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-approving after the push dismissed the previous approvals.

Verified on 83e0bbdc: the type-compatibility rule now lands on all four write surfaces (registration defaults, Admin API, ObjectModel, and the BO form inline through the new constraint), and my objection from earlier today was mine, not the code's, see the correction in the comments.

Suites green locally on this head: unit 685, integration 114, behat 22 scenarios / 106 steps.

Nothing left open on my side. The tax_rule and combination points I had listed here were already fixed in f5a242e (combination → AdminProducts, tax_rule → AdminTaxRulesGroup, plus shop_group/shop_url/feature_flag); I had missed that part of the commit. The controller map now resolves 26/26 against install-dev/data/xml/tab.xml.

@mattgoud

mattgoud commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

QA OK

Tested on: 9.2.x, commit 83e0bbdc, local Docker, database upgraded in place (see the note at the end)
Shop: single shop
Browser: Chrome 141
Linked issues: #41779, #41829, #41424, all three have concrete steps and the PR's own "How to test" adds a manual procedure, which is what I followed.

Automated UI tests: none for this feature (UI Tests: N/A in the description) ✅ nothing to replay. CI is green (40 checks). The PHP suites pass locally: unit 685, integration 114, behat extra-property-definition 22 scenarios / 106 steps.

Steps followed

Everything below was done through the back office, following the PR's manual procedure. The demoextrafield fixtures were not installed, see coverage at the end.

1. Naming (#41779) — created three definitions from Advanced Parameters > Extra Properties, typing the entity name the way a merchant would: Combination, Order, Cart.

Each one normalised to its canonical entity and stored next to the physical base table:

typed in the form entity stored table stored storage table created primary key
Combination combination product_attribute ps_product_attribute_extra id_product_attribute
Order order orders ps_orders_extra id_order
Cart cart cart ps_cart_extra id_cart

controller_name is NULL on all three, so the permission subject is deduced rather than frozen, which is the intended behaviour.

grid

2. Defaults (#41829) — the Combination property was created as Integer with default 0, the falsy value that used to be dropped.

Read back from the edit form after the round trip (DOM, since a back-office page cannot be curl-ed):

{"entity_name": {"value": "combination", "disabled": true},
 "type":        {"value": "int",         "disabled": true},
 "default_value": {"value": "0",         "disabled": true}}

and on the column itself:

ps_product_attribute_extra.qa_combo_int   int   NULL=YES   COLUMN_DEFAULT=0

So 0 survives to the registry row, to the DDL, and back onto the form. The field is rendered disabled on edit, which matches the documented immutability of defaultValue.

A default of abc on the same Integer type is refused with a message that says why, and nothing is written: the registry still holds only the three rows and no qa_bad* column was added to ps_product_attribute_extra.

refused

3. Row-less entity reads the declared default — combination #1 has no row at all in ps_product_attribute_extra (SELECT COUNT(*) = 0), and the reader returns the declared default, typed as an int rather than a string:

{"_core":{"qa_combo_int":0}}

4. Cart (#41424) — display_front switched on for the cart property from the edit form, a product added to the cart in the front office, then a value written on that cart.

prestashop.cart in the front office, which is the CartFilter whitelist change:

// empty cart, no value stored yet
extra_properties: {"_core": {"qa_cart_note": null}}
// after writing a value on cart 6
extra_properties: {"_core": {"qa_cart_note": "Livraison le vendredi"}}

The key also appears in the server-rendered inline JSON on an anonymous request, right where it belongs in the cart object, so it is not a client-side artefact:

"minimalPurchase":0,"minimalPurchaseRequired":"","extra_properties":[]

Result

All four checks behave as the description says. Before this PR a combination registration would have stored on a combination_extra table with an id_combination column that does not exist, and a 0 default was dropped as falsy on the way in.

Regression pass

Files changed point at the registry, the reader/writer, the grids and forms, and CartFilter. Checked around them: the definitions grid lists, filters and sorts normally, the create and edit forms save and reject correctly, the front-office home and cart pages render with no console error, and the order and product grids still load. No *_extra leftovers after removing the test definitions.

Coverage note

Two steps of the recommended procedure were not run because they need the demoextrafield companion, which is a separate PR. Closed: the module is now installed and both steps are covered in a follow-up comment below, including the cart-to-order copy through the real actionValidateOrder and the combination defaults on the Admin API. Only the two front-office template renderings remain unverified, for lack of a customer session.

Environment note

The database was upgraded in place rather than reinstalled: the two new registry columns (table_name, controller_name) were added by hand with the exact types from db_structure.sql, which is what the companion autoupgrade PR does. That makes this run an exercise of the upgrade path rather than the fresh-install path. A fresh install is still worth one pass before release.

Disclosure: I also reviewed this PR, so a second pair of eyes on the QA is welcome if the process calls for it.

@mattgoud mattgoud added the QA ✔️ Status: check done, code approved label Sep 7, 2026
@jolelievre
jolelievre merged commit 68ca49a into PrestaShop:9.2.x Sep 7, 2026
43 checks passed
@jolelievre
jolelievre deleted the extra-property-edge-cases branch September 7, 2026 12:50
@github-project-automation github-project-automation Bot moved this from Need 2nd approval to Merged in PR Dashboard Sep 7, 2026
@ps-jarvis ps-jarvis moved this from Merged to Need 2nd approval in PR Dashboard Sep 7, 2026
@mattgoud

mattgoud commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

QA follow-up: the coverage gap from my previous comment is now closed. I installed the demoextrafield companion (2bff685) on the same shop, 9.2.x at 83e0bbdc.

The module registers cleanly, and it exercises the naming map on real module code

18 definitions across 9 entities, which is a much better surface than the three I had created by hand. The irregular ones land where they should:

entity declared by the module physical table storage tables created
combination product_attribute ps_product_attribute_extra, ps_product_attribute_extra_shop
order orders ps_orders_extra
cms_page cms ps_cms_extra, ps_cms_extra_lang
cart cart ps_cart_extra

controller_name is NULL on all 18, so every one of them takes the deduction. Storage columns follow the {module}_{property} convention, e.g. on ps_customer_extra: demoextrafield_credit_limit, demoextrafield_extra_json, demoextrafield_internal_note.

Cart note, on screen at the checkout

actionCartSave seeds it, it crosses CartFilter into prestashop.cart grouped under the module key, and the template renders it above the summary:

extra_properties: {"demoextrafield": {"delivery_note": "Leave the parcel at the pickup point (demo note seeded on 2026-09-07 14:55)."}}

checkout

Cart to order copy, through the real hook

New cart for the demo customer, note written through ExtraPropertyWriter (not a raw INSERT), then a real validateOrder():

cart #7   ps_cart_extra.demoextrafield_delivery_note   = 'QA: fragile, sonner chez le voisin'
validateOrder() = true  ->  order #6
order #6  ps_orders_extra.demoextrafield_delivery_note = 'QA: fragile, sonner chez le voisin'
reader on 'orders' -> {"demoextrafield":{"delivery_note":"QA: fragile, sonner chez le voisin"}}

Worth noting what this specifically proves: the write and the read both go through ps_orders_extra.id_order. Before this PR the order entity resolved to an id_orders column that does not exist, so this is the naming fix on the write path, not just on the read path.

Combination defaults on the Admin API

ps_product_attribute_extra holds 0 rows, and the list endpoint still answers:

{"combinationId": 1, "extra_demoextrafield_ean_verified": false}
{"combinationId": 2, "extra_demoextrafield_ean_verified": false}
{"combinationId": 3, "extra_demoextrafield_ean_verified": false}

A typed boolean false served purely by the default read injection, and combinationId resolved by the corrected heuristic rather than productAttributeId. This is the exact example the description gives.

I also tried a PATCH on /products/{productId}/combinations/{combinationId} and got a 404. That is not a defect: debug:router --app-id=admin-api shows only GET routes for combinations in ps_apiresources, and the module declares the list URI only. Flagging it so nobody else spends time on it.

JSON decoded on reads

Stored encoded, read back as a real structure:

in DB   : string(85) "{"loyalty_tier":"gold","since":"2024-03-01","perks":["free_shipping","early_access"]}"
reader  : array  ['loyalty_tier' => 'gold', 'since' => '2024-03-01', 'perks' => ['free_shipping', 'early_access']]

So a Smarty template iterates it, which is what the my-account rendering relies on.

What is still not covered

Two visual renderings only, both needing a logged-in front-office customer session, which I could not open myself:

  • the note on the customer's own order detail page (displayOrderDetail)
  • the JSON block on the my-account page (displayCustomerAccountTop)

Their data paths are both verified above (order #6 reads its note back, and the JSON comes back as an array), so what is missing is the template output, not the mechanism. Cheap for anyone with a customer session to confirm in a minute.

Verdict unchanged: QA OK.

@mattgoud

mattgoud commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

The last two gaps are closed: both front-office renderings are now verified with a real logged-in customer session.

Cart note carried onto the customer's order

Order VWSCQTGLU, seeded by writing the note on a fresh cart through ExtraPropertyWriter and then a real validateOrder(). The customer's own order detail page shows it:

order detail

So the full chain holds on the customer-visible side, not just in the database: cart value → actionValidateOrder copy → ps_orders_extra.id_order → order detail template.

Customer JSON rendered as a structure

The value was written through the writer as a PHP array (so it went through castForDb and was encoded on the way in), and the my-account page iterates it rather than printing a raw string:

my account

credit_limit: 250 alongside it is worth a glance too: a FLOAT read back as a number, not 250.000000.

The nested perks array prints as its JSON representation because the module template only iterates one level. That is a template choice, not a core behaviour.

URL note for whoever retests

The order detail page is reachable at ?controller=order-detail&id_order=N, not /order-detail?id_order=N, which 404s on this install. I mention it only because I lost a step to it.

Coverage is now complete

Every step of the recommended manual procedure has been run, plus the Admin API default read and the registry/DDL checks from my earlier comments. Verdict stays QA OK.

Environment restored afterwards: module uninstalled and removed, the 12 storage tables it leaves behind on purpose ($dropColumn = false in its own uninstall()) dropped by hand, QA order and carts deleted, API client removed, PS_ADMIN_API_FORCE_DEBUG_SECURED back to 1.

jolelievre added a commit to PrestaShop/example-modules that referenced this pull request Sep 8, 2026
Companion of PrestaShop/PrestaShop#42538, one example per newly supported
entity:

- cart/delivery_note (COMMON — the cart is a COMMON-only entity): seeded
  by actionCartSave, displayed on the checkout summary
  (displayCheckoutSummaryTop reading
  {$cart.extra_properties.demoextrafield.delivery_note}), and copied
  onto the order at actionValidateOrder — the intended pattern for cart
  values that must survive checkout;
- order/delivery_note (COMMON), registered with the natural entity name
  'order' (the core resolves the orders table and id_order primary key),
  displayed on the customer's order detail page; deliberately no
  order-grid placement (id-first pagination limitation, core #42536);
- combination/ean_verified (COMMON, exposed on the combinations API
  list) and combination/restock_note (SHOP), registered as 'combination'
  (resolved to product_attribute);
- the customer extra_json field becomes the JSON showcase: the module
  writes a PHP structure (encoded by the core for storage) and the
  shared template iterates the decoded structure reads return;
- README refreshed: full entity inventory, the snake_case
  extra_properties Smarty key (extraProperties is Admin-API-JSON-only),
  the COMMON-only cart caveat.
@ousamabenyounes

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Improvement Type: Improvement PR Autoupgrade opened ✔️ The associated PR has been opened in the Autoupgrade module repository. QA ✔️ Status: check done, code approved

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

6 participants