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

@@ -0,0 +1,578 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
/**
* WS-5d commit 2 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.
*
* Forward+back pair safe as a unit; partial rollback unsupported.
* Pair with `2026_04_27_100002_drop_form_field_options_json_columns`
* to restore the full pre-WS-5d data shape.
*/
return new class extends Migration
{
private const OPTION_BEARING_TYPES = ['RADIO', 'SELECT', 'MULTISELECT', 'CHECKBOX_LIST'];
public function up(): void
{
DB::transaction(function (): void {
$this->backfillOptionsTable('form_fields', 'form_field');
$this->backfillOptionsTable('form_field_library', 'form_field_library');
$this->backfillTranslations('form_fields', 'form_field');
$this->backfillTranslations('form_field_library', 'form_field_library');
$this->verifyTranslationsCleanedOrFail();
$this->rewriteSnapshots('form_submissions');
$this->rewriteSnapshots('form_templates');
});
}
public function down(): void
{
if (! Schema::hasTable('form_field_options')) {
return;
}
DB::transaction(function (): void {
$this->reconstructJsonAndTranslations('form_fields', 'form_field');
$this->reconstructJsonAndTranslations('form_field_library', 'form_field_library');
$this->revertSnapshots('form_submissions');
$this->revertSnapshots('form_templates');
DB::table('form_field_options')->delete();
});
}
/**
* Step A / B read pre-WS-5d JSON column rows and emit one
* form_field_options row per option string, sort_order = index.
*/
private function backfillOptionsTable(string $table, string $ownerType): void
{
if (! Schema::hasTable($table) || ! Schema::hasColumn($table, 'options')) {
return;
}
$rows = DB::table($table)
->whereNotNull('options')
->orderBy('id')
->get(['id', 'field_type', 'options']);
$now = now();
$inserts = [];
foreach ($rows as $row) {
$decoded = is_string($row->options)
? json_decode((string) $row->options, true)
: $row->options;
if ($decoded === null || $decoded === []) {
continue;
}
$fieldType = (string) $row->field_type;
if (! in_array($fieldType, self::OPTION_BEARING_TYPES, true)) {
throw new RuntimeException(sprintf(
'Stale options on %s row %s (type=%s). Expected null for field types '
.'outside the RADIO/SELECT/MULTISELECT/CHECKBOX_LIST set. Post-WS-5b '
.'TAG_PICKER options should live in form_field_configs. Fix the '
.'offending row and re-run the migration.',
$table,
$row->id,
$fieldType,
));
}
if (! is_array($decoded) || ! array_is_list($decoded)) {
throw new RuntimeException(sprintf(
'Unexpected options shape on %s row %s. Expected flat string array, '
.'got: %s. Fix the offending row and re-run.',
$table,
$row->id,
json_encode($decoded),
));
}
foreach ($decoded as $entry) {
if (! is_string($entry)) {
throw new RuntimeException(sprintf(
'Unexpected options shape on %s row %s. Expected flat string array, '
.'got entry: %s. Fix the offending row and re-run.',
$table,
$row->id,
json_encode($entry),
));
}
}
foreach ($decoded as $i => $entry) {
$inserts[] = [
'id' => (string) Str::ulid(),
'owner_type' => $ownerType,
'owner_id' => (string) $row->id,
'value' => (string) $entry,
'label' => (string) $entry,
'sort_order' => $i,
'translations' => null,
'created_at' => $now,
'updated_at' => $now,
];
}
}
if ($inserts === []) {
return;
}
foreach (array_chunk($inserts, 500) as $batch) {
DB::table('form_field_options')->insert($batch);
}
}
/**
* Step C for each owner row with translations.{locale}.options[],
* write each translated label onto its corresponding option row
* (matched by sort_order) and strip the per-locale options array
* from the translations bag.
*/
private function backfillTranslations(string $table, string $ownerType): void
{
if (! Schema::hasTable($table) || ! Schema::hasColumn($table, 'translations')) {
return;
}
$rows = DB::table($table)
->whereNotNull('translations')
->orderBy('id')
->get(['id', 'translations']);
foreach ($rows as $row) {
$bag = json_decode((string) $row->translations, true);
if (! is_array($bag)) {
continue;
}
$expectedCount = DB::table('form_field_options')
->where('owner_type', $ownerType)
->where('owner_id', (string) $row->id)
->count();
$changed = false;
foreach ($bag as $locale => $localeData) {
if (! is_array($localeData) || ! array_key_exists('options', $localeData)) {
continue;
}
$localeOptions = $localeData['options'];
if (! is_array($localeOptions) || ! array_is_list($localeOptions)) {
throw new RuntimeException(sprintf(
'Unexpected translations.%s.options shape on %s row %s. '
.'Expected flat array, got: %s.',
$locale,
$table,
$row->id,
json_encode($localeOptions),
));
}
if (count($localeOptions) !== $expectedCount) {
throw new RuntimeException(sprintf(
'Translations length mismatch on %s row %s locale %s: '
.'%d translations vs %d options.',
$table,
$row->id,
$locale,
count($localeOptions),
$expectedCount,
));
}
foreach ($localeOptions as $i => $translated) {
if (! is_string($translated) || $translated === '' || strlen($translated) > 255) {
throw new RuntimeException(sprintf(
'Invalid translated label on %s row %s locale %s index %d: '
.'must be a non-empty string ≤255 chars; got %s.',
$table,
$row->id,
$locale,
$i,
json_encode($translated),
));
}
$optionRow = DB::table('form_field_options')
->where('owner_type', $ownerType)
->where('owner_id', (string) $row->id)
->where('sort_order', $i)
->first();
if ($optionRow === null) {
throw new RuntimeException(sprintf(
'No option row at sort_order %d for %s row %s while '
.'migrating locale %s translations.',
$i,
$table,
$row->id,
$locale,
));
}
$existing = $optionRow->translations === null
? []
: (json_decode((string) $optionRow->translations, true) ?: []);
$existing[$locale] = $translated;
DB::table('form_field_options')
->where('id', $optionRow->id)
->update(['translations' => json_encode($existing)]);
}
unset($bag[$locale]['options']);
if ($bag[$locale] === []) {
unset($bag[$locale]);
}
$changed = true;
}
if ($changed) {
DB::table($table)->where('id', $row->id)->update([
'translations' => $bag === [] ? null : json_encode($bag),
]);
}
}
}
/**
* Step C verification no per-locale options[] key may survive on
* either source table after step C. Belt-and-braces guard against
* silent residuals.
*/
private function verifyTranslationsCleanedOrFail(): void
{
foreach (['form_fields', 'form_field_library'] as $table) {
if (! Schema::hasTable($table) || ! Schema::hasColumn($table, 'translations')) {
continue;
}
$rows = DB::table($table)->whereNotNull('translations')->get(['id', 'translations']);
foreach ($rows as $row) {
$bag = json_decode((string) $row->translations, true);
if (! is_array($bag)) {
continue;
}
foreach ($bag as $locale => $data) {
if (is_array($data) && array_key_exists('options', $data)) {
throw new RuntimeException(sprintf(
'Residual translations.%s.options key on %s row %s after '
.'step C migration. This indicates step C did not run '
.'exhaustively — investigate before retrying.',
$locale,
$table,
$row->id,
));
}
}
}
}
}
/**
* Step D walk every snapshot's fields[*] and rewrite options to
* the new rich-shape array; strip the parallel
* translations.{locale}.options[] bag from each field's translations.
*/
private function rewriteSnapshots(string $table): void
{
if (! Schema::hasTable($table) || ! Schema::hasColumn($table, 'schema_snapshot')) {
return;
}
$rows = DB::table($table)
->whereNotNull('schema_snapshot')
->orderBy('id')
->get(['id', 'schema_snapshot']);
foreach ($rows as $row) {
$snapshot = json_decode((string) $row->schema_snapshot, true);
if (! is_array($snapshot) || ! isset($snapshot['fields']) || ! is_array($snapshot['fields'])) {
continue;
}
$touched = false;
foreach ($snapshot['fields'] as $idx => $field) {
if (! is_array($field) || ! array_key_exists('options', $field) || $field['options'] === null) {
continue;
}
$fieldType = (string) ($field['field_type'] ?? '');
if (! in_array($fieldType, self::OPTION_BEARING_TYPES, true)) {
throw new RuntimeException(sprintf(
'Snapshot %s row %s field %s has options but field_type is %s. '
.'Seed-bug — fix source data.',
$table,
$row->id,
$field['slug'] ?? '<unknown>',
$fieldType,
));
}
$originalOptions = $field['options'];
if (! is_array($originalOptions) || ! array_is_list($originalOptions)) {
throw new RuntimeException(sprintf(
'Snapshot %s row %s field %s: unexpected options shape. '
.'Expected flat string array, got: %s.',
$table,
$row->id,
$field['slug'] ?? '<unknown>',
json_encode($originalOptions),
));
}
foreach ($originalOptions as $entry) {
if (! is_string($entry)) {
throw new RuntimeException(sprintf(
'Snapshot %s row %s field %s: option entry not a string: %s.',
$table,
$row->id,
$field['slug'] ?? '<unknown>',
json_encode($entry),
));
}
}
$fieldTranslations = is_array($field['translations'] ?? null) ? $field['translations'] : [];
$newOptions = [];
foreach ($originalOptions as $i => $entry) {
$rich = [
'value' => (string) $entry,
'label' => (string) $entry,
'sort_order' => $i,
];
$perOptionTranslations = [];
foreach ($fieldTranslations as $locale => $localeData) {
if (! is_array($localeData) || ! isset($localeData['options'])) {
continue;
}
$localeOptions = $localeData['options'];
if (! is_array($localeOptions) || ! array_is_list($localeOptions)) {
throw new RuntimeException(sprintf(
'Snapshot %s row %s field %s translations.%s.options: '
.'expected flat array, got %s.',
$table,
$row->id,
$field['slug'] ?? '<unknown>',
$locale,
json_encode($localeOptions),
));
}
if (count($localeOptions) !== count($originalOptions)) {
throw new RuntimeException(sprintf(
'Snapshot %s row %s field %s locale %s: translations '
.'length %d vs options length %d.',
$table,
$row->id,
$field['slug'] ?? '<unknown>',
$locale,
count($localeOptions),
count($originalOptions),
));
}
$translated = $localeOptions[$i];
if (! is_string($translated) || $translated === '' || strlen($translated) > 255) {
throw new RuntimeException(sprintf(
'Snapshot %s row %s field %s locale %s index %d: '
.'translated label must be a non-empty string ≤255 chars.',
$table,
$row->id,
$field['slug'] ?? '<unknown>',
$locale,
$i,
));
}
$perOptionTranslations[$locale] = $translated;
}
if ($perOptionTranslations !== []) {
$rich['translations'] = $perOptionTranslations;
}
$newOptions[] = $rich;
}
$snapshot['fields'][$idx]['options'] = $newOptions;
// Strip {locale}.options from this field's per-locale bag.
$cleanedTranslations = [];
foreach ($fieldTranslations as $locale => $localeData) {
if (is_array($localeData)) {
unset($localeData['options']);
if ($localeData !== []) {
$cleanedTranslations[$locale] = $localeData;
}
} else {
$cleanedTranslations[$locale] = $localeData;
}
}
$snapshot['fields'][$idx]['translations'] = $cleanedTranslations === []
? null
: $cleanedTranslations;
$touched = true;
}
if ($touched) {
DB::table($table)->where('id', $row->id)->update([
'schema_snapshot' => json_encode($snapshot),
]);
}
}
}
/**
* Rollback step A' / B' / C' read the relational rows back into the
* JSON column on the source tables, and rebuild
* translations.{locale}.options[] parallel arrays from the per-row
* translations bag.
*/
private function reconstructJsonAndTranslations(string $table, string $ownerType): void
{
if (! Schema::hasTable($table) || ! Schema::hasColumn($table, 'options')) {
return;
}
$rowsByOwner = DB::table('form_field_options')
->where('owner_type', $ownerType)
->orderBy('owner_id')
->orderBy('sort_order')
->get();
$grouped = [];
foreach ($rowsByOwner as $row) {
$grouped[(string) $row->owner_id][] = $row;
}
foreach ($grouped as $ownerId => $rows) {
$values = array_map(static fn ($r): string => (string) $r->value, $rows);
DB::table($table)->where('id', $ownerId)->update([
'options' => json_encode($values),
]);
$perLocale = [];
foreach ($rows as $i => $r) {
$bag = $r->translations === null
? []
: (json_decode((string) $r->translations, true) ?: []);
foreach ($bag as $locale => $translated) {
$perLocale[$locale] ??= array_fill(0, count($rows), null);
$perLocale[$locale][$i] = (string) $translated;
}
}
if ($perLocale !== []) {
$existing = DB::table($table)->where('id', $ownerId)->value('translations');
$existingBag = $existing === null
? []
: (json_decode((string) $existing, true) ?: []);
if (! is_array($existingBag)) {
$existingBag = [];
}
foreach ($perLocale as $locale => $optionsList) {
$hasNonNull = false;
foreach ($optionsList as $entry) {
if ($entry !== null) {
$hasNonNull = true;
break;
}
}
if (! $hasNonNull) {
continue;
}
$existingBag[$locale] ??= [];
if (! is_array($existingBag[$locale])) {
$existingBag[$locale] = [];
}
$existingBag[$locale]['options'] = $optionsList;
}
DB::table($table)->where('id', $ownerId)->update([
'translations' => json_encode($existingBag),
]);
}
}
}
/**
* Rollback for snapshots convert rich-shape options back to flat
* string arrays + rebuild translations.{locale}.options[] parallel
* arrays from per-entry translations.
*/
private function revertSnapshots(string $table): void
{
if (! Schema::hasTable($table) || ! Schema::hasColumn($table, 'schema_snapshot')) {
return;
}
$rows = DB::table($table)->whereNotNull('schema_snapshot')->get(['id', 'schema_snapshot']);
foreach ($rows as $row) {
$snapshot = json_decode((string) $row->schema_snapshot, true);
if (! is_array($snapshot) || ! isset($snapshot['fields']) || ! is_array($snapshot['fields'])) {
continue;
}
$touched = false;
foreach ($snapshot['fields'] as $idx => $field) {
if (! is_array($field) || ! isset($field['options']) || ! is_array($field['options'])) {
continue;
}
$opts = $field['options'];
if ($opts === [] || ! is_array($opts[0] ?? null)) {
continue;
}
$values = [];
$perLocale = [];
$count = count($opts);
foreach ($opts as $i => $rich) {
if (! is_array($rich)) {
continue;
}
$values[] = (string) ($rich['value'] ?? '');
$bag = is_array($rich['translations'] ?? null) ? $rich['translations'] : [];
foreach ($bag as $locale => $translated) {
$perLocale[$locale] ??= array_fill(0, $count, null);
$perLocale[$locale][$i] = (string) $translated;
}
}
$snapshot['fields'][$idx]['options'] = $values;
if ($perLocale !== []) {
$existing = is_array($field['translations'] ?? null) ? $field['translations'] : [];
foreach ($perLocale as $locale => $list) {
$existing[$locale] ??= [];
if (! is_array($existing[$locale])) {
$existing[$locale] = [];
}
$existing[$locale]['options'] = $list;
}
$snapshot['fields'][$idx]['translations'] = $existing;
}
$touched = true;
}
if ($touched) {
DB::table($table)->where('id', $row->id)->update([
'schema_snapshot' => json_encode($snapshot),
]);
}
}
}
};