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

@@ -1,10 +1,28 @@
# Crewli — Core Database Schema
> Source: Design Document v1.3 — Section 3.5
> **Version: 2.5** — Updated April 2026
> **Version: 2.6** — Updated April 2026
>
> **Changelog:**
>
> - v2.6: WS-5d — `form_fields.options` and `form_field_library.options`
> JSON columns **dropped**; replaced by a single polymorphic relational
> table `form_field_options` (rows owned via `owner_type` /
> `owner_id`, reusing the `form_field` / `form_field_library` morph
> aliases from WS-5a). Row carries `value` + `label` + `sort_order` +
> `translations` JSON (per-locale BCP-47 string map). UNIQUE index on
> `(owner_type, owner_id, value)` enforces no duplicate values per
> owner. Submission + template snapshots rewritten in-place to the
> rich-shape `[{value, label, sort_order, translations?}, ...]`; the
> parallel pre-WS-5d `translations.{locale}.options[]` arrays stripped
> from both source rows and field-snapshot translation bags — option
> translations live on the option row now. WS-5 family complete; base
> morph-scope class extraction across the four siblings
> (`FormFieldBindingScope`, `FormFieldValidationRuleScope`,
> `FormFieldConfigScope`, `FormFieldOptionScope`) deferred to a
> follow-up work package now that all four concrete implementations
> exist. See ARCH-FORM-BUILDER.md §17.6 and
> ARCH-CONSOLIDATION-ADDENDUM-2026-04-24 §Q3 WS-5d Uitvoering.
> - v2.5: WS-5c — `form_fields.conditional_logic` JSON column **dropped**;
> replaced by a two-table relational tree:
> `form_field_conditional_logic_groups` (AND/OR nodes with optional
@@ -2009,7 +2027,6 @@ that aggregates the user's submitted, non-test `form_submissions`.
| `field_type` | string(50) | One of `FormFieldType` or a registered custom type |
| `label` | string | |
| `help_text` | text nullable | |
| `options` | JSON nullable | Choice options |
| `default_is_required` | bool | default: false |
| `default_is_filterable` | bool | default: false |
| `translations` | JSON nullable | Per-locale overrides |
@@ -2019,7 +2036,7 @@ that aggregates the user's submitted, non-test `form_submissions`.
| `is_active` | bool | default: true |
| `created_at`, `updated_at` | timestamps | |
**Relations:** `belongsTo` organisation; `hasMany` form_fields via `library_field_id`; `morphMany` form_field_bindings as `owner`; `morphMany` form_field_validation_rules as `owner`; `morphMany` form_field_configs as `owner`
**Relations:** `belongsTo` organisation; `hasMany` form_fields via `library_field_id`; `morphMany` form_field_bindings as `owner`; `morphMany` form_field_validation_rules as `owner`; `morphMany` form_field_configs as `owner`; `morphMany` form_field_options as `owner`
**Indexes:** `(organisation_id, field_type)`, `(organisation_id, is_active)`
**Unique constraint:** `UNIQUE(organisation_id, slug)`
**Global scope:** `OrganisationScope`
@@ -2033,6 +2050,11 @@ that aggregates the user's submitted, non-test `form_submissions`.
> table (ARCH-FORM-BUILDER.md §17.4). The column was dropped in WS-5b;
> see `/dev-docs/ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md` §Q3 WS-5b
> Uitvoering for the full catalogue and migration notes.
>
> Options moved to the relational `form_field_options` table
> (ARCH-FORM-BUILDER.md §17.6). The column was dropped in WS-5d;
> see `/dev-docs/ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md` §Q3 WS-5d
> Uitvoering.
---
@@ -2058,7 +2080,6 @@ that aggregates the user's submitted, non-test `form_submissions`.
| `label` | string | Default-locale label |
| `help_text` | text nullable | |
| `section` | string(100) null | Visual grouping header (independent of `form_schema_section_id`) |
| `options` | JSON nullable | Choice options |
| `is_required` | bool | default: false |
| `is_filterable` | bool | default: false — populates `form_values.value_indexed` / pivot |
| `is_portal_visible` | bool | default: true |
@@ -2067,14 +2088,14 @@ that aggregates the user's submitted, non-test `form_submissions`.
| `is_pii` | bool | default: false — drives retention + anonymisation |
| `display_width` | string(10) | default: `full`; `FormFieldDisplayWidth` enum |
| `role_restrictions` | JSON nullable | Per-field RBAC driving `FieldAccessService` |
| `translations` | JSON nullable | `{ <locale>: { label, help_text, options } }` |
| `translations` | JSON nullable | `{ <locale>: { label, help_text } }` (per-option translations live on `form_field_options.translations` post-WS-5d) |
| `value_storage_hint` | string(10) | default: `json`. `FormValueStorageHint` enum — guides typed-column population |
| `review_required` | bool | default: false |
| `sort_order` | int unsigned | default: 0 |
| `created_at`, `updated_at` | timestamps | |
| `deleted_at` | timestamp nullable | Soft delete preserves history |
**Relations:** `belongsTo` schema, section (nullable), libraryField; `hasMany` form_values, conditionalLogicGroups; `morphMany` form_field_bindings as `owner`; `morphMany` form_field_validation_rules as `owner`; `morphMany` form_field_configs as `owner`
**Relations:** `belongsTo` schema, section (nullable), libraryField; `hasMany` form_values, conditionalLogicGroups; `morphMany` form_field_bindings as `owner`; `morphMany` form_field_validation_rules as `owner`; `morphMany` form_field_configs as `owner`; `morphMany` form_field_options as `owner`
**Indexes:** `(form_schema_id, sort_order)`, `(form_schema_id, is_filterable)`, `(library_field_id)`, `(form_schema_id, slug)`
**Soft delete:** yes
@@ -2093,6 +2114,11 @@ that aggregates the user's submitted, non-test `form_submissions`.
> `/dev-docs/ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md` §Q3 WS-5c
> Uitvoering. No library mirror — addendum Q3 excludes library from
> conditional_logic scope.
>
> Options moved to the relational `form_field_options` table
> (ARCH-FORM-BUILDER.md §17.6). The column was dropped in WS-5d; see
> `/dev-docs/ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md` §Q3 WS-5d
> Uitvoering.
---
@@ -2186,6 +2212,36 @@ that aggregates the user's submitted, non-test `form_submissions`.
---
### `form_field_options`
> Relational home for option rows on RADIO / SELECT / MULTISELECT /
> CHECKBOX_LIST fields, replacing the pre-WS-5d
> `form_fields.options` and `form_field_library.options` JSON columns.
> Same polymorphic-morph pattern as the binding / validation-rules /
> configs siblings. Each row carries the option's storage value, the
> default-locale display label, a stable sort_order within owner, and
> an optional per-locale translations bag. ARCH-FORM-BUILDER.md §17.6;
> addendum §Q3 WS-5d Uitvoering.
| Column | Type | Notes |
| -------------- | --------------- | ---------------------------------------------------------------- |
| `id` | ULID | PK |
| `owner_type` | string(40) | morph alias: `form_field` or `form_field_library` |
| `owner_id` | ULID | parent row (a `form_fields.id` or `form_field_library.id`) |
| `value` | string(255) | canonical storage value (used by `in:options` validator) |
| `label` | string(255) | default-locale display label |
| `sort_order` | int unsigned | default: 0; stable ordering within owner |
| `translations` | JSON nullable | `{ <locale>: <translated label> }` (BCP-47 short form keys) |
| `created_at`, `updated_at` | timestamps | |
**Relations:** `morphTo` owner (`form_field` or `form_field_library`)
**Indexes:** `(owner_type, owner_id, sort_order)` as `ffo_owner_sort_idx`
**Unique constraint:** `UNIQUE(owner_type, owner_id, value)` as `ffo_owner_value_unique` — seed-bug guard
**Global scope:** `FormFieldOptionScope` — fourth and final sibling in the morph-scope family (alongside `FormFieldBindingScope`, `FormFieldValidationRuleScope`, `FormFieldConfigScope`); same UNION shape. Escape hatch: `withoutGlobalScope(FormFieldOptionScope::class)`. Base-class extraction across the four siblings deferred to a follow-up work package now that all four concrete implementations exist.
**Soft delete:** no — options are current state, not audit. Submission snapshots carry the historical shape.
---
### `form_field_conditional_logic_groups`
> Tree nodes (root + branches) of the relational conditional-logic