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,14 +33,15 @@ final class FormFieldBindingMigrationTest extends TestCase
public function test_forward_migrations_backfill_rows_from_both_json_sources(): void
{
// Roll back to pre-WS-5a state: 2 WS-5d migrations (backfill-options,
// create-options) + 4 WS-5c migrations (drop-conditional-logic-col,
// backfill-conditional-logic, create-conditional-logic-conditions,
// Roll back to pre-WS-5a state: 3 WS-5d migrations (drop-options-cols,
// backfill-options, create-options) + 4 WS-5c migrations
// (drop-conditional-logic-col, backfill-conditional-logic,
// create-conditional-logic-conditions,
// create-conditional-logic-groups) + 5 WS-5b migrations
// (drop-validation-cols, configs-backfill, create-configs,
// validation-rules-backfill, create-validation-rules) +
// 2 WS-5a migrations (drop-binding-cols, create-bindings) = 13.
$this->artisan('migrate:rollback', ['--step' => 13])->assertSuccessful();
// 2 WS-5a migrations (drop-binding-cols, create-bindings) = 14.
$this->artisan('migrate:rollback', ['--step' => 14])->assertSuccessful();
$this->assertFalse(Schema::hasTable('form_field_bindings'));
$this->assertTrue(Schema::hasColumn('form_fields', 'binding'));
$this->assertTrue(Schema::hasColumn('form_field_library', 'default_binding'));
@@ -101,8 +102,8 @@ final class FormFieldBindingMigrationTest extends TestCase
public function test_rollback_reconstructs_json_and_drops_table(): void
{
// Walk back the full WS-5d + WS-5c + WS-5b + WS-5a stack (13 migrations).
$this->artisan('migrate:rollback', ['--step' => 13])->assertSuccessful();
// Walk back the full WS-5d + WS-5c + WS-5b + WS-5a stack (14 migrations).
$this->artisan('migrate:rollback', ['--step' => 14])->assertSuccessful();
[$fieldAId, , ] = $this->seedFieldsWithBindingJson();
[$libAId, ] = $this->seedLibraryWithBindingJson();
@@ -112,12 +113,12 @@ final class FormFieldBindingMigrationTest extends TestCase
$this->assertFalse(Schema::hasColumn('form_fields', 'binding'));
$this->assertSame(5, DB::table('form_field_bindings')->count());
// Step back over WS-5d (2 migrations) + WS-5c (4 migrations) +
// Step back over WS-5d (3 migrations) + WS-5c (4 migrations) +
// WS-5b (5 migrations) in one go → restores the pre-WS-5b state
// (conditional-logic, validation-rules, configs and options tables
// gone, validation_rules JSON columns reappear on source tables;
// binding contract intact).
$this->artisan('migrate:rollback', ['--step' => 11])->assertSuccessful();
// gone, validation_rules + options JSON columns reappear on source
// tables; binding contract intact).
$this->artisan('migrate:rollback', ['--step' => 12])->assertSuccessful();
$this->assertFalse(Schema::hasTable('form_field_options'));
$this->assertFalse(Schema::hasTable('form_field_conditional_logic_groups'));
$this->assertFalse(Schema::hasTable('form_field_conditional_logic_conditions'));

View File

@@ -31,11 +31,11 @@ final class ConditionalLogicBackfillTest extends TestCase
public function test_forward_backfill_builds_nested_tree_from_legacy_json(): void
{
// Roll back the WS-5d backfill-options + create-options + WS-5c
// drop-cl-col + WS-5c backfill-cl migrations to land in the
// conditional-logic JSON-era state with no relational
// form_field_options table yet.
$this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful();
// Roll back the WS-5d drop-options-cols + backfill-options +
// create-options + WS-5c drop-cl-col + WS-5c backfill-cl
// migrations to land in the conditional-logic JSON-era state with
// no relational form_field_options table yet.
$this->artisan('migrate:rollback', ['--step' => 5])->assertSuccessful();
$this->assertTrue(Schema::hasColumn('form_fields', 'conditional_logic'));
$fieldId = $this->seedFieldWithJson([
@@ -156,7 +156,7 @@ final class ConditionalLogicBackfillTest extends TestCase
]);
// Roll back only the backfill migration — writes the JSON back.
$this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 5])->assertSuccessful();
$reconstructed = DB::table('form_fields')
->where('id', $fieldId)
@@ -183,7 +183,7 @@ final class ConditionalLogicBackfillTest extends TestCase
public function test_unknown_top_level_key_fails_migration(): void
{
$this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 5])->assertSuccessful();
$this->seedFieldWithJson([
'hide_when' => ['all' => [['field_slug' => 'x', 'operator' => 'equals', 'value' => 1]]],
@@ -196,7 +196,7 @@ final class ConditionalLogicBackfillTest extends TestCase
public function test_unknown_comparison_operator_fails_migration(): void
{
$this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 5])->assertSuccessful();
$this->seedFieldWithJson([
'show_when' => ['all' => [['field_slug' => 'x', 'operator' => 'matches_regex', 'value' => 'y']]],

View File

@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\FormBuilder;
use App\Enums\FormBuilder\FormFieldType;
use App\Models\FormBuilder\FormField;
use App\Models\FormBuilder\FormFieldLibrary;
use App\Models\FormBuilder\FormFieldOption;
use App\Models\FormBuilder\FormSchema;
use App\Models\Organisation;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* Regression test for the post-WS-5d-commit-5 access pattern. With the
* form_fields.options / form_field_library.options JSON columns dropped
* (and the temporary getOptionsAttribute accessor-bridge removed),
* Eloquent's getAttribute('options') falls through to the morphMany
* relation naturally. `$model->options` (no parens) must:
*
* - return an Eloquent Collection
* - whose entries are FormFieldOption instances
* - lazily lazy-load on first access (1 query for the parent fetch
* plus 1 query for the lazy-load no surprise extra reads)
*
* Tested for both polymorphic owners FormField and FormFieldLibrary.
*/
final class FormFieldOptionsAccessTest extends TestCase
{
use RefreshDatabase;
public function test_form_field_options_resolves_to_morph_many_collection_with_lazy_load(): void
{
$org = Organisation::factory()->create();
$schema = FormSchema::factory()->create(['organisation_id' => $org->id]);
FormField::factory()
->withOptions(['XS', 'S', 'M'])
->create([
'form_schema_id' => $schema->id,
'field_type' => FormFieldType::SELECT->value,
'slug' => 'shirtmaat',
]);
DB::flushQueryLog();
DB::enableQueryLog();
// Fresh fetch — no with('options') eager-load. The lazy-load
// happens on first $field->options access.
$field = FormField::query()->where('slug', 'shirtmaat')->first();
$options = $field->options;
DB::disableQueryLog();
$queries = DB::getQueryLog();
$this->assertInstanceOf(Collection::class, $options);
$this->assertNotEmpty($options);
$this->assertInstanceOf(FormFieldOption::class, $options->first());
$this->assertSame(['XS', 'S', 'M'], $options->pluck('value')->all());
// Exactly two queries: 1× FormField fetch + 1× lazy-load options.
$this->assertCount(
2,
$queries,
sprintf(
'Expected exactly 2 queries (parent fetch + lazy-load options); got %d. Queries: %s',
count($queries),
json_encode(array_column($queries, 'query')),
),
);
}
public function test_form_field_library_options_resolves_to_morph_many_collection_with_lazy_load(): void
{
$org = Organisation::factory()->create();
FormFieldLibrary::factory()
->withOptions(['a', 'b'])
->create([
'organisation_id' => $org->id,
'slug' => 'lib-select',
]);
DB::flushQueryLog();
DB::enableQueryLog();
$library = FormFieldLibrary::query()->where('slug', 'lib-select')->first();
$options = $library->options;
DB::disableQueryLog();
$queries = DB::getQueryLog();
$this->assertInstanceOf(Collection::class, $options);
$this->assertNotEmpty($options);
$this->assertInstanceOf(FormFieldOption::class, $options->first());
$this->assertSame(['a', 'b'], $options->pluck('value')->all());
$this->assertCount(
2,
$queries,
sprintf(
'Expected exactly 2 queries (parent fetch + lazy-load options); got %d. Queries: %s',
count($queries),
json_encode(array_column($queries, 'query')),
),
);
}
}

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(),

View File

@@ -31,15 +31,15 @@ final class FormFieldValidationRuleBackfillTest extends TestCase
public function test_forward_migration_backfills_rows_with_field_type_dispatch(): void
{
// Roll back: 2 WS-5d migrations (backfill-options, create-options) +
// 4 WS-5c migrations (drop-conditional-logic-col,
// Roll back: 3 WS-5d migrations (drop-options-cols, backfill-options,
// create-options) + 4 WS-5c migrations (drop-conditional-logic-col,
// backfill-conditional-logic, create-conditional-logic-conditions,
// create-conditional-logic-groups) + 5 WS-5b migrations
// (drop-cols + configs-backfill + create-configs +
// validation-rules-backfill + create-validation-rules) = 11.
// validation-rules-backfill + create-validation-rules) = 12.
// Brings us to the pre-WS-5b state: validation_rules JSON column
// present, no relational tables for WS-5b/c/d.
$this->artisan('migrate:rollback', ['--step' => 11])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 12])->assertSuccessful();
$this->assertFalse(Schema::hasTable('form_field_validation_rules'));
$this->assertTrue(Schema::hasColumn('form_fields', 'validation_rules'));
@@ -100,7 +100,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase
// (validation_rules JSON column present; no relational tables for
// WS-5b). Step count: drop-cols + configs-backfill + create-configs
// + validation-rules-backfill + create-validation-rules = 5.
$this->artisan('migrate:rollback', ['--step' => 11])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 12])->assertSuccessful();
$fieldId = $this->seedFieldWithJson([
'field_type' => 'TAG_PICKER',
@@ -124,7 +124,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase
// (validation_rules JSON column present; no relational tables for
// WS-5b). Step count: drop-cols + configs-backfill + create-configs
// + validation-rules-backfill + create-validation-rules = 5.
$this->artisan('migrate:rollback', ['--step' => 11])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 12])->assertSuccessful();
$fieldId = $this->seedFieldWithJson([
'field_type' => 'TEXT',
@@ -151,7 +151,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase
// (validation_rules JSON column present; no relational tables for
// WS-5b). Step count: drop-cols + configs-backfill + create-configs
// + validation-rules-backfill + create-validation-rules = 5.
$this->artisan('migrate:rollback', ['--step' => 11])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 12])->assertSuccessful();
$this->seedFieldWithJson([
'field_type' => 'TEXT',
@@ -168,7 +168,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase
// (validation_rules JSON column present; no relational tables for
// WS-5b). Step count: drop-cols + configs-backfill + create-configs
// + validation-rules-backfill + create-validation-rules = 5.
$this->artisan('migrate:rollback', ['--step' => 11])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 12])->assertSuccessful();
$this->seedFieldWithJson([
'field_type' => 'BOOLEAN',
@@ -187,7 +187,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase
// full-back-then-full-forward cycle — rolling back all WS-5b
// migrations restores the pre-WS-5b state (columns present on
// source tables; validation rules relational table gone).
$this->artisan('migrate:rollback', ['--step' => 11])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 12])->assertSuccessful();
[$numberId] = $this->seedFields();
$this->artisan('migrate')->assertSuccessful();
@@ -202,7 +202,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase
// Roll back WS-5b fully → column reappears and carries canonical JSON
// reconstructed from the relational rows.
$this->artisan('migrate:rollback', ['--step' => 11])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 12])->assertSuccessful();
$this->assertTrue(Schema::hasColumn('form_fields', 'validation_rules'));
$field = DB::table('form_fields')->where('id', $numberId)->first();