feat(form-field): backfill form_fields.options to form_field_options

Atomic data migration. Every options datum in the database — in
form_fields and form_field_library, their translations bags, and the
form_submissions.schema_snapshot + form_templates.schema_snapshot JSON
blobs — is converted to the new relational rich-shape representation.

Strict dispatch per §17.4.4 / §8.7 convention:
  - Fail on field_type ∉ {RADIO, SELECT, MULTISELECT, CHECKBOX_LIST}
    carrying non-null options (post-WS-5b TAG_PICKER seed-bug indicator)
  - Fail on non-flat-string-array options shape
  - Fail on translations.{locale}.options[] length mismatch
  - Fail on non-string / >255-char translated labels
  - Fail on any residual translations.{locale}.options key after
    step C migration

Snapshot rewrite in-place: both form_submissions.schema_snapshot and
form_templates.schema_snapshot walk fields[*] and rewrite options to
the new rich-shape, strip per-locale options[] from the parallel
translations bag. Zero-compromise directive — no reader tolerance for
pre-WS-5d shape in commit 3 onwards.

Rollback reconstructs JSON column shapes plus translations bags.
Forward+back pair safe as a unit; partial rollback unsupported.

FormFieldService::insertFromLibrary switches from JSON-copy to
FormFieldOptionService::copyOptions row-clone per addendum Q3 row-copy
mandate. The field's own translations bag no longer carries
{locale}.options keys — those live on option rows now.

Seeders and factories switch to service-level option creation:
  - FormBuilderDevSeeder.canonicalFields keeps flat-string options as
    its data shape; FormField::create no longer receives an options
    key, the post-create FormFieldOptionService::replaceOptions call
    inserts the rich rows. The same applies to
    seedEventRegistrationShowcaseSchema. The vergoedingstype field's
    legacy {label, description} object shape (a pre-WS-5d seed-bug
    that the strict backfill would reject) is normalised to flat
    strings; the descriptions are dropped.
  - seedSystemTemplates embeds rich-shape options in the template
    snapshot — no flat-array snapshot data remains in newly-seeded
    rows.
  - FormFieldFactory + FormFieldLibraryFactory drop the options
    default; new ::withOptions() helper accepts either flat strings
    (each becomes value+label) or full spec arrays and routes through
    the service.

JSON columns (form_fields.options, form_field_library.options) remain
present and writable via fillable; column-drop lands in commit 5.
Reads from the JSON column still exist in resources, snapshot writer,
FormRequests, FormValueService, and FilterRegistryController — commit
3 switches those all atomically.

Migration step-count tests in WS-5a/b/c bumped by 1 to account for
the new backfill_form_field_options migration on the migration stack.

Tests: 1182 → 1193 green (+11 tests / +56 assertions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 02:21:26 +02:00
parent 11588623c5
commit 15e4e49d8c
10 changed files with 1216 additions and 38 deletions

View File

@@ -30,6 +30,7 @@ final class FormFieldService
private readonly FormFieldValidationRuleService $validationRuleService,
private readonly FormFieldConfigService $configService,
private readonly FormFieldConditionalLogicService $conditionalLogicService,
private readonly FormFieldOptionService $optionService,
) {}
public function create(FormSchema $schema, array $data): FormField
@@ -270,13 +271,16 @@ final class FormFieldService
'slug' => $this->ensureUniqueSlug($schema, $library->slug),
'label' => $library->label,
'help_text' => $library->help_text,
'options' => $library->options,
'is_required' => (bool) $library->default_is_required,
'is_filterable' => (bool) $library->default_is_filterable,
'translations' => $library->translations,
'sort_order' => $this->nextSortOrder($schema),
], $overrides);
// Options: post-WS-5d row-clone via the service. The legacy JSON
// column is no longer copied.
unset($data['options']);
if (! isset($data['slug']) || $data['slug'] === '') {
$data['slug'] = $this->ensureUniqueSlug($schema, $library->slug);
} else {
@@ -289,6 +293,7 @@ final class FormFieldService
$this->bindingService->copyBindings($library, $field);
$this->validationRuleService->copyRules($library, $field);
$this->configService->copyConfigs($library, $field);
$this->optionService->copyOptions($library, $field);
FormFieldLibrary::query()->whereKey($library->id)->increment('usage_count');