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

@@ -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')),
),
);
}
}