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