refactor(form-field): drop form_fields.options + form_field_library.options

Final WS-5d cleanup. The JSON columns that have been unread since
commit 3 are now physically dropped on both source tables. Their
canonical rich-shape lives in form_field_options, accessed
exclusively through the morphMany relation.

Defensive sweep: any lingering translations.{locale}.options key in
either source table's translations bag is stripped. Commit 2's
backfill should already have done so exhaustively; this is
belt-and-braces.

Rollback re-creates the columns as nullable JSON but leaves them
empty. Pair with commit 2's rollback to restore the pre-WS-5d data
shape on every owner row.

The commit-3 getOptionsAttribute accessor-bridge on FormField +
FormFieldLibrary is removed — Eloquent's getAttribute() resolution
now naturally falls through to the morphMany relation since there's
no underlying column to shadow it. New regression test
FormFieldOptionsAccessTest asserts $field->options resolves to an
Eloquent Collection of FormFieldOption instances and lazy-loads in
exactly 2 queries (1 parent + 1 lazy-load options) on a fresh fetch
without with() preload. Same trio for FormFieldLibrary.

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

Documentation:
  - SCHEMA.md v2.6: form_field_options table documented; options row
    removed from form_fields and form_field_library; morphMany
    relations updated; cross-references to ARCH-FORM-BUILDER §17.6
    and addendum §Q3 WS-5d Uitvoering added on both source-table
    docblocks.
  - ARCH-FORM-BUILDER.md v1.8: new §17.6 "Field options (relational)"
    mirrors the §17.4 / §17.5 relational-sibling structure with
    sub-sections 17.6.1 rationale, 17.6.2 table + catalogue, 17.6.3
    service / scope / cascade / activity log, 17.6.4 snapshot
    embedding, 17.6.5 external API contract. Existing Webhooks
    section renumbered from §17.6 to §17.7.
  - ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md: "Uitvoering — WS-5d
    (2026-04-27)" section added. Eight paragraphs covering the
    snapshot atomic rewrite, strict-fail backfill dispatch, dual
    activity-log emit, four-sibling base-class extraction warrant,
    commit 0 dead-code precondition, the temporary getOptionsAttribute
    accessor-bridge pattern (with reusability note for future
    JSON→relational refactors), the dev-seeder vergoedingstype RADIO
    normalisation (drift correction explicitly distinguished from the
    parallel apps/app RegistrationFieldTemplate description domain),
    and the WS-5 family completion note.
  - BACKLOG.md: FORM-BUILDER-LIBRARY-AUDIT-LOG entry extended to four
    services (adds library.options_replaced); new
    FORM-BUILDER-MORPH-SCOPE-BASE-CLASS entry added as the WS-5d
    follow-up now that all four concrete morph-scope siblings exist.

Tests: 1193 → 1208 green (+15 across commits 3+4+5; this commit alone:
+2 from the regression test).

This completes the WS-5 family.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 03:00:20 +02:00
parent dd7dfe9c0b
commit e7c9482474
12 changed files with 475 additions and 102 deletions

View File

@@ -33,7 +33,7 @@ final class FormFieldOptionsBackfillTest extends TestCase
// Roll back only the backfill migration (latest WS-5d step).
// Leaves the form_field_options table in place, JSON columns
// present on the source tables, and snapshots in pre-WS-5d shape.
$this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful();
$this->assertTrue(Schema::hasTable('form_field_options'));
$this->assertTrue(Schema::hasColumn('form_fields', 'options'));
@@ -113,7 +113,7 @@ final class FormFieldOptionsBackfillTest extends TestCase
public function test_rollback_reconstructs_json_columns_and_snapshots(): void
{
$this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful();
[$selectId, $multiId, $libraryId] = $this->seedFieldsAndLibraryWithJson();
$submissionId = $this->seedSubmissionWithSnapshot($selectId);
@@ -126,7 +126,7 @@ final class FormFieldOptionsBackfillTest extends TestCase
// Step back over only the backfill migration → JSON columns repopulate
// and snapshots revert to flat-string-array shape.
$this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful();
$this->assertSame(0, DB::table('form_field_options')->count());
$select = DB::table('form_fields')->where('id', $selectId)->first();
@@ -145,7 +145,7 @@ final class FormFieldOptionsBackfillTest extends TestCase
public function test_fails_when_options_present_on_non_option_field_type(): void
{
$this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful();
$this->seedFieldWithOptions('TAG_PICKER', ['Veiligheid', 'Horeca']);
$this->expectException(\RuntimeException::class);
@@ -155,7 +155,7 @@ final class FormFieldOptionsBackfillTest extends TestCase
public function test_fails_when_options_contains_non_string_entry(): void
{
$this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful();
$this->seedFieldWithOptionsRaw('SELECT', json_encode([
['label' => 'A'],
@@ -169,7 +169,7 @@ final class FormFieldOptionsBackfillTest extends TestCase
public function test_fails_when_options_is_object_shape(): void
{
$this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful();
$this->seedFieldWithOptionsRaw('SELECT', json_encode([
'XS' => 'Extra small',
@@ -183,7 +183,7 @@ final class FormFieldOptionsBackfillTest extends TestCase
public function test_fails_on_translations_length_mismatch(): void
{
$this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful();
$this->seedFieldWithOptionsRaw('SELECT', json_encode(['XS', 'S', 'M']), json_encode([
'de' => ['options' => ['Klein', 'Mittel']], // 2 vs 3
]));
@@ -195,7 +195,7 @@ final class FormFieldOptionsBackfillTest extends TestCase
public function test_fails_on_non_string_translation(): void
{
$this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful();
$this->seedFieldWithOptionsRaw('SELECT', json_encode(['XS', 'S']), json_encode([
'de' => ['options' => ['Klein', 42]],
]));
@@ -207,7 +207,7 @@ final class FormFieldOptionsBackfillTest extends TestCase
public function test_fails_on_oversized_translation(): void
{
$this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful();
$this->seedFieldWithOptionsRaw('SELECT', json_encode(['XS']), json_encode([
'de' => ['options' => [str_repeat('x', 256)]],
]));
@@ -219,7 +219,7 @@ final class FormFieldOptionsBackfillTest extends TestCase
public function test_fails_when_snapshot_options_present_on_non_option_field_type(): void
{
$this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful();
$this->seedTemplateWithSnapshotRaw([
'fields' => [[
'id' => (string) Str::ulid(),