Skip to main content
Version: 4.0 preview

Deep dive: the option/answer collapse (xref → answer)

Version
4.0 preview
Updated
View markdown

Audience: a developer — or an AI instructed to make an extensively-customized CBX site compatible with CBX 4. This is the exhaustive reference behind the summary in the breaking-changes log and the mechanical recipe in model-property-customizations.md. Read this once before touching any customization that mentions options, xrefs, option_id, xref_id, or answer titles/prices.

If you only need to fix model_property_customization/ files, the mechanical recipe in model-property-customizations.md is enough. Come here when custom code touches the data model, SQL, caches, orders, pricing, or strings around answers.

Migration status — the automatic data collapse SHIPS as helpers/updates/3.5.3.php. It runs during the package install/upgrade itself (the component's postflight applies outstanding updates), so by the time you open the site the collapse has happened. It creates #__configbox_answers, folds the option columns onto the answer rows, fans shared options out into independent answers, re-keys #__configbox_strings and the frozen cbcheckout_order_strings from option_id to answer_id, renames cbcheckout_order_configurations.xref_idanswer_id (dropping its option_id), and drops #__configbox_options + #__configbox_xref_element_option. Where sections below say the core "migrates" / "re-keys" / "fans out", that is what 3.5.3.php does. 3.6.2.php then backfills the answers.option_id breadcrumb (§3.8) onto databases collapsed before that column existed.

What it does NOT do: your customization's own tables. It drops any foreign key that pointed at #__configbox_options and leaves that table's rows exactly as they are — see §3.8 for the hand-over and the re-link recipe.


1. The old model (CBX ≤ 3) — what you must un-learn

An "answer" a customer could pick was represented by two tables:

  • #__configbox_options — the "global option": a reusable, shared answer definition. It held the shared attributes — sku, price, price_overrides, price_recurring(_overrides), weight, was_price(_recurring), option_custom_1..4, availability, image — and its title/description lived in #__configbox_strings keyed by the option id (langType 5 = title, 15 = description, 60/61 = custom). One option row could be assigned to many questions.
  • #__configbox_xref_element_option — the "assignment" / "xref": the per-question row that placed a given option into a specific question (element_id + option_id), and carried the question-specific overrides — rules, calculation models, per-answer price-calc overrides, visualization image, picker image, ordering, published flag, internal_name, assignment custom fields, shapediver value.

At cache-build time CBX merged the option's columns onto the xref record, so downstream code saw one flat "answer" object. Two ids therefore existed for every answer:

  • the xref id = the identity of this answer in this question (used in cart selections, orders, rules, pricing — everywhere a selection is referenced);
  • the option id = the identity of the shared definition (used only to reach the shared columns and the title/description strings).

The single most important fact: the xref id was already the answer's identity everywhere. The option id was only ever a pointer to shared attributes + strings.

2. The new model (CBX 4) — one table

The two tables are collapsed into #__configbox_answers, a clean 1:n from questions:

  • answers.id == the old xref id. Preserved exactly. So every stored selection, order line, and rule reference keeps resolving with no data migration of those references.
  • The option columns are folded onto the answer row (sku, price(+overrides), was_price(_recurring), weight, availability, image, option_custom_1..4, desc_display_method).
  • Title/description/custom strings (#__configbox_strings types 5/15/60/61) are re-keyed from the option id to the answer id. A shared option used by N answers was fanned out into N string rows.
  • A formerly-shared option becomes N independent answer rows (one per question that used it). They no longer share anything — editing one does not change the others. This is the whole point: no more "global option" reuse.
  • #__configbox_options and #__configbox_xref_element_option are dropped. The option_id column and the option_id "Reused Answer" join are gone.

The mental-model swap

Think in old terms……now think
"an option, reusable across questions""an answer, owned by exactly one question"
"the xref that assigns option O to question Q""the answer" (there is no separate assignment)
"option_id → shared columns / strings"those columns/strings live on the answer, keyed by answer.id
"xref_id = the selection identity""answer.id = the selection identity" (same value, new name)
two ids per answerone id per answer

3. Every touch-point (find-and-fix checklist)

Grep the customization for each pattern below. For each hit, apply the mapping.

3.1 Table names (raw SQL)

  • #__configbox_xref_element_option#__configbox_answers.
  • #__configbox_optionsgone. Its columns are now on #__configbox_answers. A query that JOINed options to xref becomes a plain read from #__configbox_answers; select the (formerly option) column directly off the answer.
  • Hard-coded prefixes evade grep. Some custom code writes the real prefix (e.g. e5xae_configbox_options instead of #__configbox_options). Grep for the bare table stem (configbox_options, xref_element_option, xref_listing) as well as the #__ form.

3.2 The option_id column / concept

  • option_id as a join key (options↔xref) → the join is gone; read the column off the answer.
  • option_id as the strings key (title/description lookups) → use the answer id.
  • The option_id join property ("Reused Answer", type=joinConfigboxModelAdminoptions) → remove it. There is no shared option to point at. A custom join subtype like bcoptionjoin that re-parents an answer to a different option has no meaning now — delete it.
  • $answer->option_id, $configuration->option_id, ConfigboxAnswer::$option_id → removed. Use $answer->id.

3.3 Strings (title / description / custom)

  • Types 5 (title), 15 (description), 60/61 (custom 5/6) are keyed by the answer id now (were option id).
    // BEFORE
    ConfigboxCacheHelper::getTranslation('#__configbox_strings', 5, $answer->option_id);
    // AFTER
    ConfigboxCacheHelper::getTranslation('#__configbox_strings', 5, $answer->id);
  • If your customization writes answer strings, key them by the answer id. Never write these directly if a translatable property can do it (the property's store() handles all active languages).

3.4 Model / controller / view names

OldNew
ConfigboxModelAdminoptions, ...Adminoptionassignments, ...AdminxrefelementoptionsConfigboxModelAnswers
controller=adminoptions / adminoptionassignments / adminxrefelementoptions (+ singular)controller=adminanswers (adminanswer)
view adminoption(s), adminoptionassignment(s), adminxrefelementoption(s)adminanswer(s)
model_property_customization/{adminoptions,adminoptionassignments,adminxrefelementoptions}.phpadminanswers.php
  • KenedoModel::getModel('ConfigboxModelAdminoptions') still resolves (aliased → ...Adminanswers, logged as legacy) — but update it. ConfigboxModelAdminoptions::class used as a bare string also resolves via the alias, but the class no longer exists, so anything else touching that class name fatals.

3.5 Caches (only if you read them)

Old keyNew key
xref_to_element, element_to_xref, xref_to_product, product_to_xref, xref_to_page, page_to_xrefanswer_to_question, question_to_answer, answer_to_product, product_to_answer, answer_to_page, page_to_answer
priceByXref, weightByXref, calcModelByXref, priceOverridesByXref, … (*ByXref)*ByAnswer
product cache xrefs.product_<id>answers.product_<id>

Note — two renames stacked. This collapse first produced answer_to_element / element_to_answer; the later element→question rename then renamed those to answer_to_question / question_to_answer (the current live keys). answer_to_element / element_to_answer still resolve but only as legacy aliases now — target *_to_question / question_to_* in new code. See element-question-rename.md.

3.6 Pricing API

  • ConfigboxPrices::getXrefPrice/getXrefPriceRecurring/getXrefWasPrice/getXrefWasPriceRecurring/getXrefWeightgetAnswerPrice/getAnswerPriceRecurring/getAnswerWasPrice/getAnswerWasPriceRecurring/getAnswerWeight. (The frontend already keyed these by the answer id; only the method name changed.)

3.7 Orders (frozen data)

  • cbcheckout_order_configurations used to carry both xref_id and option_id. It now carries a single answer_id (xref_id renamed, option_id dropped). Update custom code that reads order-config rows.
  • Frozen order strings (cbcheckout_order_strings, types 5/15/28/60/61) are keyed by the answer id. 3.5.3 re-keys existing orders' frozen strings from option_id to answer_id, mapping through order_strings.order_id → order_positions → order_configurations (which carried both ids on the row). A string for a shared option fans out to one row per answer, exactly like the live strings. If your customization reads frozen order strings by option_id, switch to answer_id.

3.8 External-append tables (heavily-customized sites)

This is the subtle one, and the only place the core deliberately hands you an unfinished job.

#__configbox_answers.option_id — the breadcrumb that makes this possible. The collapse records, on every answer, the id of the option it came from. CBX never reads it; it exists solely so customization data keyed by option id can be re-linked after #__configbox_options is gone. Without it the mapping would be unrecoverable, because 3.5.3 drops both old tables. A shared option appears as option_id on each of the N answers it fanned out into — so the breadcrumb carries the fan-out for you.

option_id is NULL on a database that was collapsed before the column existed (3.6.2 adds the column but cannot invent the values — the xref it would need is long gone). If you see NULLs everywhere, that install cannot be re-linked automatically; the source data no longer exists.

  • A property that stored against the option (foreignTableKey => 'option_id', table e.g. #__configbox_external_option_appends) now hangs off the answer. The core's normaliseLegacyCustomPropertyDefinitions() remaps the code key option_id → answer_id automatically — but it does NOT touch the table, and 3.5.3 drops the foreign key that table had into #__configbox_options (logging that it did) so the options table can go. Your rows are all still there, untouched; their option_id values now point at ids that no longer exist. You must:
    1. add an answer_id column to that external table (guard with tableFieldExists), and
    2. migrate the rows via the breadcrumb — one option-append fans out to N answer-appends:
      INSERT INTO `#__configbox_external_option_appends_new` (`answer_id`, /* …your columns… */)
      SELECT a.`id`, /* …your columns… */
      FROM `#__configbox_external_option_appends` AS ap
      JOIN `#__configbox_answers` AS a ON a.`option_id` = ap.`option_id`;
      Append rows whose option had no answer (an option no question ever used) match nothing and are dropped — that is correct, they were already unreachable.
    3. Deliver it as a data/customization/updates/ migration (see the migrations skill / ../technical/com_configbox_migrations.md), and decide the semantic question the core cannot: if a shared option's appended value was per-option (a SKU, say), duplicating it to N answers means editing one no longer affects the others. That may be exactly what you want, or may need consolidating.
    4. Run and verify it via the CLI rather than waiting for a web request: configbox:migrate applies it (and names it in the output), configbox:migrate --status confirms nothing is left pending, and if your script throws, configbox:migrate -v prints the failing script + exception chain + log tail, and configbox:migrate:unblock lifts the freeze once you have fixed it. Remember core scripts run before customization scripts, so by the time yours executes, #__configbox_options is already gone — exactly why the breadcrumb join above is the only route.
  • A property that already stored against the answer (foreignTableKey => 'answer_id', e.g. #__configbox_external_answer_appends) needs no table change and no data migration — answer ids are the old xref ids, so its answer_id values stay correct. Its foreign key constraint, however, referenced the xref table and is removed by 3.5.3 (see the contract below). The data keeps matching #__configbox_answers.id; re-add the constraint from a customization update script if you want it enforced again.

⚠️ FKs may get removed — the #__configbox_external_* constraint contract. The migration scripts find inbound foreign keys via information_schema (no name assumptions) and apply one policy, stated here so nobody is surprised:

  • Tables following the documented #__configbox_external_* convention: any FK they hold on a retiring table — #__configbox_elements (renamed in 3.5.2), #__configbox_xref_element_option and #__configbox_options (dropped in 3.5.3) — is removed by the migration, and deliberately NOT re-created. Re-adding constraints on behalf of schema the core does not own is brittle. Your data is untouched, and the id columns keep matching where a successor exists (element_id#__configbox_questions.id; xref-referencing columns → #__configbox_answers.id, ids identical). If you want the referential integrity back, re-add the constraint yourself from a data/customization/updates/ script, pointing at the successor table. Each removal is logged to configbox_upgrade_errors.log with exactly that instruction.
  • Tables with any other name: the migration does not touch them. On the renames their FKs simply follow the table to its new name (InnoDB carries inbound constraints across) and keep working. On the drops a remaining inbound FK makes the DROP TABLE fail and freezes the migration — deliberately: the site owns that constraint and must remove or re-point it itself, then re-run (configbox:migrate:unblock, then configbox:migrate). The script logs which constraint blocked it before attempting the drop.

Name your customization side tables #__configbox_external_* to be inside the contract.

3.9 The product-copy remap bucket

  • Copy pipelines remap old→new answer ids through $copyIds['<controller>']. The bucket key changed from $copyIds['adminoptionassignments'] to $copyIds['adminanswers']. Update custom rule-condition types or copy hooks that read that bucket (e.g. a custom getCopiedConditionData).

3.10 Code that creates answers — the shims' one genuinely dangerous case

Everything else in this document is about code that reads the old model. This section is about code that writes it, and it is the most dangerous item in the whole collapse — because it does not fail.

getModel() shims all three old model names onto ConfigboxModelAnswers:

ConfigboxModelAdminoptions ─┐
ConfigboxModelAdminoptionassignments ─┼─→ ConfigboxModelAnswers
ConfigboxModelAdminxrefelementoptions ─┘

That is exactly right for reads. For the standard pre-CB4 creation idiom it is a trap, because the idiom used two models on purpose — an option row, then an xref row linking it to the question:

// BEFORE (pre-CB4) — two models, two tables, two rows. Correct then.
$optionModel = KenedoModel::getModel('ConfigboxModelAdminoptions');
$opt = $optionModel->initData();
$opt->title = 'Red';
$optionModel->store($opt); // → #__configbox_options

$xrefModel = KenedoModel::getModel('ConfigboxModelAdminxrefelementoptions');
$x = $xrefModel->initData();
$x->element_id = $questionId;
$x->option_id = $opt->id;
$xrefModel->store($x); // → #__configbox_xref_element_option

Post-collapse both variables point at the same model and the same table, so this still runs start to finish without a single error — and writes two answer rows:

  • an orphan with no question_id (the "option" that no longer has a table of its own), and
  • a real answer whose option_id points at that orphan.

No exception. No log line. The site accumulates orphaned answers on every create, and the ones that are visible look fine, so nobody investigates. This is worth stating plainly: the shim is what makes the bug invisible. Had the old model names simply been deleted, this would have been a loud fatal and a five-minute fix.

The fix — one model, one row:

// AFTER — an answer IS the option; it belongs to exactly one question.
$model = KenedoModel::getModel('ConfigboxModelAnswers');
$answer = $model->initData();
$answer->question_id = $questionId; // required
$answer->title = 'Red';
$model->store($answer); // → #__configbox_answers, one row

Two things that travel with this change:

  • Delete the rollback. Two-step creates usually had $optionModel->delete($optionId) to undo step 1 when step 2 failed validation. There is no separate row to undo any more, and that id is now an answer id — so the rollback does not merely no-op, it can delete a live answer.
  • Drop the "title lives on the option, not the xref" branching. Any code that fetched the option record to set a title, then stored it separately from the assignment, collapses to editing the answer record you already have. It reads as perfectly sensible code, which is why it is easy to miss.

Guard and detection. ConfigboxModelAnswers::store() now refuses an insert whose question_id is empty, logs the reason (logs/configbox/), and returns false — the old idiom fails loudly instead of corrupting data. question_id is nullable in the schema and carries no foreign key, so the database will never catch this on its own; the model guard is the only backstop. Updates are deliberately left unguarded, so an existing orphan can still be repaired by assigning it a question and re-saving.

To find damage already written on a site that ran the two-step idiom under CB4:

-- Orphans (expect 0):
SELECT COUNT(*) FROM `#__configbox_answers` WHERE `question_id` = 0 OR `question_id` IS NULL;

-- Inspect before deleting — check whether a live answer points at each orphan via the breadcrumb:
SELECT o.`id`, o.`option_id`, (
SELECT COUNT(*) FROM `#__configbox_answers` r WHERE r.`option_id` = o.`id`
) AS `referenced_by`
FROM `#__configbox_answers` o
WHERE o.`question_id` = 0 OR o.`question_id` IS NULL;

An orphan with referenced_by > 0 is one half of a two-step create: the referencing answer is the real one and carries the data, so the orphan can go — but re-point or clear the survivor's option_id first if your own re-linking migration (§3.8) still depends on that breadcrumb.

4. Decision procedure (per customization file)

For each customization file that matched a grep above:

  1. Is it under data/customization/ and actually loaded on this install? Some "brand" (bc*) code is dormant (references tables that don't exist here). Confirm the tables/classes it uses exist before spending effort; if the feature is dead on this deployment, note it and move on.
  2. Model-property customization? → follow model-property-customizations.md. Most cases auto-convert; the manual cases are the option_id join re-export and the external-table re-key (§3.8).
  3. Raw SQL / cache / pricing / order reader? → apply the §3 mappings. These have no shim — they must be edited. 3b. Does the file CREATE options/answers rather than read them? → §3.10. This one does not announce itself in any grep for removed identifiers, because it uses names that all still resolve.
  4. External-store append fields? → code key auto-remapped, but you owe a data migration (§3.8).
  5. After editing, grep the file again for every removed identifier: xref_element_option, configbox_options, option_id, xref_id, ConfigboxModelAdminoptions, ConfigboxModelAdminoptionassignments, ConfigboxModelAdminxrefelementoptions, getXref, *ByXref, xref_to_/_to_xref. Each remaining hit must be justified (e.g. an unrelated xref_country_zone table) or fixed.

5. Worked conversions

A shared-option customization → per-answer. A custom column added to every option and read on the answer:

// BEFORE — read a custom option column via the merged answer
$value = $answer->my_option_col; // came from options table, merged at cache time
// AFTER — same column now lives on the answer row directly; no change to this read,
// but the ALTER that added it must target #__configbox_answers, not #__configbox_options.

A raw options↔xref join.

// BEFORE
$sql = "SELECT o.sku, xref.element_id
FROM `#__configbox_xref_element_option` xref
LEFT JOIN `#__configbox_options` o ON o.id = xref.option_id
WHERE o.sku = '".$db->getEscaped($sku)."'";
// AFTER — one table
$sql = "SELECT a.sku, a.question_id
FROM `#__configbox_answers` a
WHERE a.sku = '".$db->getEscaped($sku)."'";

A title lookup.

// BEFORE
$title = ConfigboxCacheHelper::getTranslation('#__configbox_strings', 5, $answer->option_id);
// AFTER
$title = ConfigboxCacheHelper::getTranslation('#__configbox_strings', 5, $answer->id);

An external-append property (code + the migration you owe).

// customization property def — the core remaps this key for you at load time…
'storeExternally' => true,
'foreignTableName' => '#__configbox_external_option_appends', // rename table too if you own it
'foreignTableKey' => 'option_id', // -> core reads it as 'answer_id'
// …but you must ship a data/customization/updates/<ver>.php that:
// 1) adds an `answer_id` column to the append table (guard with tableFieldExists)
// 2) fans out each option-append row to every answer that used that option, keyed by answer_id

6. Why it was done (context for judgement calls)

The two-table model existed for one feature: reusing a single answer definition across many questions. In practice that reuse caused more confusion than it saved (edit-one-affects-many surprises, a whole "assignment" editing layer, doubled ids, the merge machinery). CBX 4 trades it for a clean 1:n: every answer belongs to one question. When a judgement call is ambiguous during conversion, prefer the interpretation that treats the answer as a self-contained, single-question entity — that is the model you are converging on.