feat(form-builder): form_field_bindings table + polymorphic owner + cascade observer

WS-5a commit 1 of 4 per ARCH-CONSOLIDATION-ADDENDUM-2026-04-24 Q3.

Creates the relational home for what was form_fields.binding JSON and
form_field_library.default_binding JSON. Owner discriminator is polymorphic
morph (owner_type/owner_id) — the pattern the rest of WS-5 (5b validation_rules,
5d options) will reuse.

Migration backfills rows from both JSON sources in a single transaction and
is genuinely reversible (rollback reconstructs the JSON). Old columns remain
in place until commit 3 has switched all readers.

Pattern B (binding=null) is represented by absence of row. mode enum covers
entity_owned / mirrored only.

Cascade on owner delete via observer — bindings are physical state, not
historical audit. FormFieldBindingScope enforces multi-tenancy via UNION over
both owner chains (form_field → schema → org OR form_field_library → org) —
Q2's declarative tenantScopeStrategy() can't walk morph parents.

Tests: migration forward/back, morph relation, cascade observer, scope
isolation, enum coverage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-24 18:43:11 +02:00
parent 76090b934e
commit af8a9da038
17 changed files with 1081 additions and 0 deletions

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Database\Factories\FormBuilder;
use App\Enums\FormBuilder\FormFieldBindingMergeStrategy;
use App\Enums\FormBuilder\FormFieldBindingMode;
use App\Models\FormBuilder\FormField;
use App\Models\FormBuilder\FormFieldBinding;
use App\Models\FormBuilder\FormFieldLibrary;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends Factory<FormFieldBinding> */
final class FormFieldBindingFactory extends Factory
{
protected $model = FormFieldBinding::class;
/** @return array<string, mixed> */
public function definition(): array
{
return [
'owner_type' => 'form_field',
'owner_id' => FormField::factory(),
'target_entity' => 'person',
'target_attribute' => 'email',
'mode' => FormFieldBindingMode::EntityOwned->value,
'sync_direction' => null,
'merge_strategy' => FormFieldBindingMergeStrategy::Overwrite->value,
'trust_level' => 50,
'is_identity_key' => false,
];
}
public function forField(FormField $field): static
{
return $this->state(fn () => [
'owner_type' => 'form_field',
'owner_id' => $field->id,
]);
}
public function forLibrary(FormFieldLibrary $library): static
{
return $this->state(fn () => [
'owner_type' => 'form_field_library',
'owner_id' => $library->id,
]);
}
public function entityOwned(string $entity, string $attribute): static
{
return $this->state(fn () => [
'target_entity' => $entity,
'target_attribute' => $attribute,
'mode' => FormFieldBindingMode::EntityOwned->value,
'sync_direction' => null,
]);
}
public function mirrored(string $entity, string $attribute, string $syncDirection = 'write_on_submit'): static
{
return $this->state(fn () => [
'target_entity' => $entity,
'target_attribute' => $attribute,
'mode' => FormFieldBindingMode::Mirrored->value,
'sync_direction' => $syncDirection,
]);
}
}

View File

@@ -4,9 +4,11 @@ declare(strict_types=1);
namespace Database\Factories\FormBuilder;
use App\Enums\FormBuilder\FormFieldBindingMode;
use App\Enums\FormBuilder\FormFieldDisplayWidth;
use App\Enums\FormBuilder\FormFieldType;
use App\Models\FormBuilder\FormField;
use App\Models\FormBuilder\FormFieldBinding;
use App\Models\FormBuilder\FormSchema;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
@@ -73,4 +75,30 @@ final class FormFieldFactory extends Factory
{
return $this->state(fn () => ['is_filterable' => true]);
}
/**
* Attach an entity-binding row in `form_field_bindings` after the field
* is persisted. Use this instead of populating the legacy `binding` JSON
* column which WS-5a will drop in commit 3.
*/
public function withEntityBinding(
string $entity,
string $attribute,
FormFieldBindingMode $mode = FormFieldBindingMode::EntityOwned,
?string $syncDirection = null,
): static {
return $this->afterCreating(function (FormField $field) use ($entity, $attribute, $mode, $syncDirection): void {
FormFieldBinding::factory()
->forField($field)
->state([
'target_entity' => $entity,
'target_attribute' => $attribute,
'mode' => $mode->value,
'sync_direction' => $mode === FormFieldBindingMode::Mirrored
? ($syncDirection ?? 'write_on_submit')
: null,
])
->create();
});
}
}

View File

@@ -4,7 +4,9 @@ declare(strict_types=1);
namespace Database\Factories\FormBuilder;
use App\Enums\FormBuilder\FormFieldBindingMode;
use App\Enums\FormBuilder\FormFieldType;
use App\Models\FormBuilder\FormFieldBinding;
use App\Models\FormBuilder\FormFieldLibrary;
use App\Models\Organisation;
use Illuminate\Database\Eloquent\Factories\Factory;
@@ -45,4 +47,29 @@ final class FormFieldLibraryFactory extends Factory
{
return $this->state(fn () => ['is_system' => true]);
}
/**
* Attach a binding row in `form_field_bindings` after the library entry
* is persisted. Replaces the legacy `default_binding` JSON column.
*/
public function withDefaultBinding(
string $entity,
string $attribute,
FormFieldBindingMode $mode = FormFieldBindingMode::EntityOwned,
?string $syncDirection = null,
): static {
return $this->afterCreating(function (FormFieldLibrary $library) use ($entity, $attribute, $mode, $syncDirection): void {
FormFieldBinding::factory()
->forLibrary($library)
->state([
'target_entity' => $entity,
'target_attribute' => $attribute,
'mode' => $mode->value,
'sync_direction' => $mode === FormFieldBindingMode::Mirrored
? ($syncDirection ?? 'write_on_submit')
: null,
])
->create();
});
}
}

View File

@@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
/**
* WS-5a commit 1 of 4 relational home for what was `form_fields.binding`
* and `form_field_library.default_binding` JSON. Polymorphic owner
* (`owner_type` / `owner_id`) with morph-map aliases `form_field` and
* `form_field_library`, per ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md §Q3.
*
* Forward migration backfills rows from both JSON sources in a single
* transaction; the old columns are dropped only in a later migration once
* every reader has been switched over. Rollback genuinely reconstructs the
* JSON shape from rows before dropping the table.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('form_field_bindings', function (Blueprint $table) {
$table->ulid('id')->primary();
$table->string('owner_type', 40);
$table->ulid('owner_id');
$table->string('target_entity', 50);
$table->string('target_attribute', 100);
$table->string('mode', 20);
$table->string('sync_direction', 30)->nullable();
$table->string('merge_strategy', 20)->default('overwrite');
$table->unsignedTinyInteger('trust_level')->default(50);
$table->boolean('is_identity_key')->default(false);
$table->timestamps();
$table->unique(
['owner_type', 'owner_id', 'target_entity', 'target_attribute'],
'ffb_owner_target_unique',
);
$table->index(['target_entity', 'target_attribute'], 'ffb_target_idx');
$table->index(['owner_type', 'owner_id'], 'ffb_owner_idx');
});
DB::transaction(function (): void {
$this->backfillFromForeignJson(
table: 'form_fields',
jsonColumn: 'binding',
ownerType: 'form_field',
);
$this->backfillFromForeignJson(
table: 'form_field_library',
jsonColumn: 'default_binding',
ownerType: 'form_field_library',
);
});
}
public function down(): void
{
if (! Schema::hasTable('form_field_bindings')) {
return;
}
DB::transaction(function (): void {
$this->restoreJsonFromRows(
table: 'form_fields',
jsonColumn: 'binding',
ownerType: 'form_field',
);
$this->restoreJsonFromRows(
table: 'form_field_library',
jsonColumn: 'default_binding',
ownerType: 'form_field_library',
);
});
Schema::drop('form_field_bindings');
}
private function backfillFromForeignJson(string $table, string $jsonColumn, string $ownerType): void
{
if (! Schema::hasTable($table) || ! Schema::hasColumn($table, $jsonColumn)) {
return;
}
$rows = DB::table($table)
->whereNotNull($jsonColumn)
->orderBy('id')
->get(['id', $jsonColumn]);
if ($rows->isEmpty()) {
return;
}
$now = now();
$inserts = [];
foreach ($rows as $row) {
$raw = $row->$jsonColumn;
$decoded = is_string($raw) ? json_decode($raw, true) : $raw;
if (! is_array($decoded)) {
continue;
}
$mode = (string) ($decoded['mode'] ?? '');
if ($mode === 'form_owned') {
throw new \RuntimeException(
"form_field_bindings backfill: row {$row->id} in {$ownerType} has legacy "
."mode='form_owned'. Pattern B must be represented by null JSON, "
.'not a row. Clean up source data before re-running migration.',
);
}
if (! in_array($mode, ['entity_owned', 'mirrored'], true)) {
continue;
}
$entity = (string) ($decoded['entity'] ?? '');
$attribute = (string) ($decoded['column'] ?? '');
if ($entity === '' || $attribute === '') {
continue;
}
$inserts[] = [
'id' => (string) Str::ulid(),
'owner_type' => $ownerType,
'owner_id' => (string) $row->id,
'target_entity' => $entity,
'target_attribute' => $attribute,
'mode' => $mode,
'sync_direction' => isset($decoded['sync_direction'])
? (string) $decoded['sync_direction']
: null,
'merge_strategy' => 'overwrite',
'trust_level' => 50,
'is_identity_key' => false,
'created_at' => $now,
'updated_at' => $now,
];
}
if ($inserts !== []) {
foreach (array_chunk($inserts, 500) as $batch) {
DB::table('form_field_bindings')->insert($batch);
}
}
}
private function restoreJsonFromRows(string $table, string $jsonColumn, string $ownerType): void
{
if (! Schema::hasTable($table) || ! Schema::hasColumn($table, $jsonColumn)) {
return;
}
$rows = DB::table('form_field_bindings')
->where('owner_type', $ownerType)
->orderBy('owner_id')
->get();
foreach ($rows as $row) {
$json = [
'mode' => $row->mode,
'entity' => $row->target_entity,
'column' => $row->target_attribute,
];
if ($row->sync_direction !== null && $row->sync_direction !== '') {
$json['sync_direction'] = $row->sync_direction;
}
DB::table($table)
->where('id', $row->owner_id)
->update([$jsonColumn => json_encode($json)]);
}
}
};