refactor(form-builder): drop legacy snapshot 'binding' (singular) key (WS-6)

Session 2 wrote both 'binding' (singular) and 'bindings' (plural)
in form_submissions.schema_snapshot for backward compatibility. With
no production data yet and dev seeders re-running every cycle, dual-
key state has no upside. Snapshots now write 'bindings' only;
all readers updated to match.

FormFieldBindingService::snapshotShapesFor() simplified to return
only ['bindings' => $all]. Pre-existing
SchemaSnapshotEmbedsBindingFromRelationalTableTest updated to assert
the applicator shape (with id, merge_strategy, trust_level,
is_identity_key) on bindings[0]; new
SnapshotOnlyContainsBindingsKeyTest enforces the no-legacy-key
contract going forward.

FormBuilderDevSeeder template snapshot embeds 'bindings' => [] for
form-owned fields (Pattern B) instead of 'binding' => null.

Other 'binding' string occurrences in the codebase (FormFieldResource,
FormFieldService, request validation rules, BindingConflictResolver
internal helper key) are unrelated to snapshot dual-state and remain
untouched.

Refs: WS-6 session 2 deviation #9 cleanup

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 00:11:20 +02:00
parent d2059e3cff
commit 6dace312b4
4 changed files with 104 additions and 31 deletions

View File

@@ -192,26 +192,22 @@ final class FormFieldBindingService
}
/**
* Build snapshot fragments for both the legacy `binding` (singular)
* key and the new WS-6 `bindings` (plural) key in one pass over the
* collection. Single helper so the FormSubmissionService snapshot
* loop accesses `$field->bindings` only once.
* Build the snapshot fragment for the WS-6 `bindings` (plural) key.
* Session 2.5 dropped the legacy `binding` (singular) see RFC v1.1
* + WS-6 session 2 deviation #9. With no production data and dev
* seeders re-running every cycle, dual-key state had no upside.
*
* @param iterable<FormFieldBinding> $bindings
* @return array{binding: array<string, mixed>|null, bindings: list<array<string, mixed>>}
* @return array{bindings: list<array<string, mixed>>}
*/
public function snapshotShapesFor(iterable $bindings): array
{
$first = null;
$all = [];
foreach ($bindings as $binding) {
if ($first === null) {
$first = $this->toJsonShape($binding);
}
$all[] = $this->toApplicatorShape($binding);
}
return ['binding' => $first, 'bindings' => $all];
return ['bindings' => $all];
}
private function ownerTypeFor(FormField|FormFieldLibrary $owner): string

View File

@@ -97,7 +97,7 @@ final class FormBuilderDevSeeder
'is_required' => $field['is_required'] ?? false,
'is_filterable' => $field['is_filterable'] ?? false,
'is_pii' => $field['is_pii'] ?? false,
'binding' => null, // Pattern B — snapshot embeds null for form-owned fields.
'bindings' => [], // Pattern B — snapshot embeds an empty array for form-owned fields.
'conditional_logic' => null, // snapshot shape: null for fields without conditional logic
'translations' => null,
'value_storage_hint' => $field['type']->recommendedValueStorageHint()->value,

View File

@@ -19,17 +19,17 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Asserts the `schema_snapshot` JSON keeps its ARCH §4.6.1 / §6.3 shape
* after WS-5a: the snapshot writer now serialises bindings from the
* relational table via FormFieldBindingService::toJsonShape, but the
* per-field JSON embedded in the snapshot is byte-for-byte the pre-WS-5a
* shape for entity-owned and mirrored patterns.
* Asserts the `schema_snapshot` JSON `bindings` (plural) key carries the
* RFC-WS-6 §3 Q6 applicator shape via
* FormFieldBindingService::toApplicatorShape. Pre-WS-6 used a legacy
* `binding` (singular) key with a leaner shape; that key was dropped
* in WS-6 session 2.5 (no production data; dual-key state had no upside).
*/
final class SchemaSnapshotEmbedsBindingFromRelationalTableTest extends TestCase
{
use RefreshDatabase;
public function test_snapshot_embeds_entity_owned_binding_in_arch_shape(): void
public function test_snapshot_embeds_entity_owned_binding_in_applicator_shape(): void
{
$schema = $this->schemaWithSnapshot();
@@ -39,7 +39,7 @@ final class SchemaSnapshotEmbedsBindingFromRelationalTableTest extends TestCase
'slug' => 'email',
'label' => 'E-mail',
]);
FormFieldBinding::factory()->forField($field)->entityOwned('person', 'email')->create();
$binding = FormFieldBinding::factory()->forField($field)->entityOwned('person', 'email')->create();
$submission = $this->submitFor($schema);
$snapshot = $submission->fresh()->schema_snapshot;
@@ -47,11 +47,16 @@ final class SchemaSnapshotEmbedsBindingFromRelationalTableTest extends TestCase
$this->assertIsArray($snapshot);
$embedded = collect($snapshot['fields'])->firstWhere('id', $field->id);
$this->assertNotNull($embedded);
$this->assertSame([
'mode' => 'entity_owned',
'entity' => 'person',
'column' => 'email',
], $embedded['binding']);
$this->assertArrayNotHasKey('binding', $embedded);
$this->assertCount(1, $embedded['bindings']);
$shape = $embedded['bindings'][0];
$this->assertSame((string) $binding->id, $shape['id']);
$this->assertSame('entity_owned', $shape['mode']);
$this->assertSame('person', $shape['entity']);
$this->assertSame('email', $shape['column']);
$this->assertSame('overwrite', $shape['merge_strategy']);
$this->assertSame(50, $shape['trust_level']);
$this->assertFalse($shape['is_identity_key']);
}
public function test_snapshot_embeds_mirrored_binding_with_sync_direction(): void
@@ -70,15 +75,15 @@ final class SchemaSnapshotEmbedsBindingFromRelationalTableTest extends TestCase
$snapshot = $submission->fresh()->schema_snapshot;
$embedded = collect($snapshot['fields'])->firstWhere('id', $field->id);
$this->assertSame([
'mode' => 'mirrored',
'entity' => 'user_profile',
'column' => 'emergency_contact_name',
'sync_direction' => 'write_on_submit',
], $embedded['binding']);
$this->assertCount(1, $embedded['bindings']);
$shape = $embedded['bindings'][0];
$this->assertSame('mirrored', $shape['mode']);
$this->assertSame('user_profile', $shape['entity']);
$this->assertSame('emergency_contact_name', $shape['column']);
$this->assertSame('write_on_submit', $shape['sync_direction']);
}
public function test_snapshot_embeds_null_binding_for_form_owned_field(): void
public function test_snapshot_embeds_empty_bindings_for_form_owned_field(): void
{
$schema = $this->schemaWithSnapshot();
@@ -93,7 +98,8 @@ final class SchemaSnapshotEmbedsBindingFromRelationalTableTest extends TestCase
$snapshot = $submission->fresh()->schema_snapshot;
$embedded = collect($snapshot['fields'])->firstWhere('id', $field->id);
$this->assertNull($embedded['binding']);
$this->assertSame([], $embedded['bindings']);
$this->assertArrayNotHasKey('binding', $embedded);
}
private function schemaWithSnapshot(): FormSchema

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\FormBuilder\Schema;
use App\Enums\FormBuilder\FormFieldType;
use App\Enums\FormBuilder\FormPurpose;
use App\Enums\FormBuilder\FormSchemaSnapshotMode;
use App\Models\FormBuilder\FormField;
use App\Models\FormBuilder\FormFieldBinding;
use App\Models\FormBuilder\FormSchema;
use App\Models\FormBuilder\FormSubmission;
use App\Models\Organisation;
use App\Models\User;
use App\Services\FormBuilder\FormSubmissionService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* RFC-WS-6 v1.1 / session 2.5 schema_snapshot.fields[*] carries
* `bindings` (plural) only. The legacy `binding` (singular) key was
* dropped no production data; dual-key state had no upside.
*/
final class SnapshotOnlyContainsBindingsKeyTest extends TestCase
{
use RefreshDatabase;
public function test_snapshot_field_entries_have_bindings_plural_only(): void
{
$organisation = Organisation::factory()->create();
$schema = FormSchema::factory()->create([
'organisation_id' => $organisation->id,
'purpose' => FormPurpose::USER_PROFILE->value,
'snapshot_mode' => FormSchemaSnapshotMode::ALWAYS->value,
]);
// Two fields: one with binding, one without (Pattern B).
$boundField = FormField::factory()->create([
'form_schema_id' => $schema->id,
'field_type' => FormFieldType::EMAIL->value,
'slug' => 'email',
]);
FormFieldBinding::factory()->forField($boundField)->entityOwned('user', 'email')->create();
FormField::factory()->create([
'form_schema_id' => $schema->id,
'field_type' => FormFieldType::TEXT->value,
'slug' => 'note',
]);
$user = User::factory()->create();
$service = $this->app->make(FormSubmissionService::class);
$draft = $service->createDraft($schema, $user, $user);
/** @var FormSubmission $submitted */
$submitted = $service->submit($draft, $user);
$snapshot = $submitted->fresh()->schema_snapshot;
$this->assertIsArray($snapshot);
foreach ($snapshot['fields'] as $entry) {
$this->assertArrayHasKey('bindings', $entry);
$this->assertIsArray($entry['bindings']);
$this->assertArrayNotHasKey(
'binding',
$entry,
'Snapshot field entries must NOT contain the legacy `binding` (singular) key.',
);
}
}
}