feat(form-builder): FormFieldBindingService + library-to-field row copy + snapshot writer

WS-5a commit 2 of 4.

FormFieldBindingService owns all writes to the relational binding table.
Validation against config/form_binding.php entity-column registry lives here
(ARCH §6.2).

FormFieldService::insertFromLibrary now calls copyBindings instead of
hydrating JSON — the Q3 row-copy mandate. Library and field bindings share
the same table; insertion is a row-clone operation.

Snapshot writer (FormSubmissionService::buildSnapshot) serialises bindings
via toJsonShape so schema_snapshot JSON keeps its ARCH §4.6.1 / §6.3
contract. No snapshot format change.
API resources source binding output from the relational table via the same
serialiser — external shape preserved.

Tests: service transactional behaviour, copyBindings preservation,
snapshot parity, API resource parity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-24 18:48:47 +02:00
parent af8a9da038
commit 6933e6d700
9 changed files with 712 additions and 5 deletions

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Resources\FormBuilder;
use App\Models\FormBuilder\FormFieldLibrary;
use App\Services\FormBuilder\FormFieldBindingService;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -30,7 +31,9 @@ final class FormFieldLibraryResource extends JsonResource
'validation_rules' => $this->validation_rules,
'default_is_required' => (bool) $this->default_is_required,
'default_is_filterable' => (bool) $this->default_is_filterable,
'default_binding' => $this->default_binding,
'default_binding' => app(FormFieldBindingService::class)->toJsonShape(
$this->resource->bindings->first(),
),
'translations' => $this->translations,
'description' => $this->description,
'usage_count' => (int) ($this->usage_count ?? 0),

View File

@@ -7,6 +7,7 @@ namespace App\Http\Resources\FormBuilder;
use App\Enums\FormBuilder\FormFieldType;
use App\Models\FormBuilder\FormField;
use App\Models\PersonTag;
use App\Services\FormBuilder\FormFieldBindingService;
use App\Services\FormBuilder\FormLocaleResolver;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -49,7 +50,9 @@ final class FormFieldResource extends JsonResource
'is_unique' => (bool) $this->is_unique,
'is_pii' => (bool) $this->is_pii,
'display_width' => $this->display_width instanceof \BackedEnum ? $this->display_width->value : $this->display_width,
'binding' => $this->binding,
'binding' => app(FormFieldBindingService::class)->toJsonShape(
$this->resource->bindings->first(),
),
'conditional_logic' => $this->conditional_logic,
'role_restrictions' => $this->role_restrictions,
'translations' => $this->translations,

View File

@@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
namespace App\Services\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\Support\Collection;
use Illuminate\Support\Facades\DB;
/**
* Owns all writes to `form_field_bindings`. Single source of truth for:
* - entity-column registry validation (config/form_binding.php, ARCH §6.2)
* - library field row-copy on insertFromLibrary (Q3 row-copy mandate)
* - serialisation of rows into the ARCH §6.3 JSON shape (for snapshot
* embedding and API resource output)
*
* Pattern B (no binding) is represented by the absence of a row callers
* pass an empty array to replaceBindings() to clear bindings.
*
* Danger guards (pre-existing in FormFieldService::update for owner=field)
* sit above this service; replaceBindings() trusts the guard upstream and
* only enforces registry validation + transactional write.
*/
final class FormFieldBindingService
{
/**
* @return Collection<int, FormFieldBinding>
*/
public function bindingsFor(FormField|FormFieldLibrary $owner): Collection
{
$type = $this->ownerTypeFor($owner);
return FormFieldBinding::query()
->where('owner_type', $type)
->where('owner_id', $owner->getKey())
->get();
}
/**
* Replace the set of bindings on an owner transactionally. Callers pass
* an array of binding specs; each spec is validated against the entity-
* column registry before anything is written. An empty array clears all
* bindings for the owner (Pattern B).
*
* @param list<array{target_entity:string,target_attribute:string,mode:string,sync_direction?:?string,merge_strategy?:string,trust_level?:int,is_identity_key?:bool}> $bindingData
*/
public function replaceBindings(FormField|FormFieldLibrary $owner, array $bindingData): void
{
foreach ($bindingData as $spec) {
$this->assertSpecValid($spec);
}
$ownerType = $this->ownerTypeFor($owner);
DB::transaction(function () use ($owner, $ownerType, $bindingData): void {
FormFieldBinding::query()
->withoutGlobalScopes()
->where('owner_type', $ownerType)
->where('owner_id', $owner->getKey())
->delete();
foreach ($bindingData as $spec) {
FormFieldBinding::query()->withoutGlobalScopes()->create([
'owner_type' => $ownerType,
'owner_id' => $owner->getKey(),
'target_entity' => $spec['target_entity'],
'target_attribute' => $spec['target_attribute'],
'mode' => $spec['mode'],
'sync_direction' => $spec['sync_direction'] ?? null,
'merge_strategy' => $spec['merge_strategy']
?? FormFieldBindingMergeStrategy::Overwrite->value,
'trust_level' => $spec['trust_level'] ?? 50,
'is_identity_key' => $spec['is_identity_key'] ?? false,
]);
}
if ($owner instanceof FormField) {
$owner->logFieldChange('field.bindings_replaced', [
'count' => count($bindingData),
]);
}
});
}
/**
* Row-copy from a library entry to a freshly-inserted field (ARCH §6.7,
* addendum Q3). Preserves every binding column; only owner_type /
* owner_id are rewritten.
*/
public function copyBindings(FormFieldLibrary $from, FormField $to): void
{
$bindings = $this->bindingsFor($from);
if ($bindings->isEmpty()) {
return;
}
DB::transaction(function () use ($bindings, $to): void {
foreach ($bindings as $binding) {
FormFieldBinding::query()->withoutGlobalScopes()->create([
'owner_type' => 'form_field',
'owner_id' => $to->id,
'target_entity' => $binding->target_entity,
'target_attribute' => $binding->target_attribute,
'mode' => $binding->mode instanceof FormFieldBindingMode
? $binding->mode->value
: (string) $binding->mode,
'sync_direction' => $binding->sync_direction,
'merge_strategy' => $binding->merge_strategy instanceof FormFieldBindingMergeStrategy
? $binding->merge_strategy->value
: (string) $binding->merge_strategy,
'trust_level' => (int) $binding->trust_level,
'is_identity_key' => (bool) $binding->is_identity_key,
]);
}
});
}
/**
* Serialise a binding row into the ARCH §6.3 JSON shape. Returned null
* if no binding is given callers can pipe directly into snapshot /
* resource output (Pattern B = null).
*
* @return array{mode:string,entity:string,column:string,sync_direction?:string}|null
*/
public function toJsonShape(?FormFieldBinding $binding): ?array
{
if ($binding === null) {
return null;
}
$mode = $binding->mode instanceof FormFieldBindingMode
? $binding->mode->value
: (string) $binding->mode;
$shape = [
'mode' => $mode,
'entity' => $binding->target_entity,
'column' => $binding->target_attribute,
];
if ($binding->sync_direction !== null && $binding->sync_direction !== '') {
$shape['sync_direction'] = $binding->sync_direction;
}
return $shape;
}
private function ownerTypeFor(FormField|FormFieldLibrary $owner): string
{
return $owner instanceof FormField ? 'form_field' : 'form_field_library';
}
/** @param array<string, mixed> $spec */
private function assertSpecValid(array $spec): void
{
$entity = (string) ($spec['target_entity'] ?? '');
$attribute = (string) ($spec['target_attribute'] ?? '');
$mode = (string) ($spec['mode'] ?? '');
if ($entity === '' || $attribute === '') {
throw new \InvalidArgumentException(
'Binding spec requires target_entity and target_attribute.',
);
}
if (FormFieldBindingMode::tryFrom($mode) === null) {
throw new \InvalidArgumentException(
"Binding spec mode '{$mode}' is not a valid FormFieldBindingMode.",
);
}
if (array_key_exists('merge_strategy', $spec)) {
$strategy = (string) $spec['merge_strategy'];
if (FormFieldBindingMergeStrategy::tryFrom($strategy) === null) {
throw new \InvalidArgumentException(
"Binding spec merge_strategy '{$strategy}' is not a valid FormFieldBindingMergeStrategy.",
);
}
}
$registry = (array) config('form_binding.'.$entity);
if ($registry === []) {
throw new \InvalidArgumentException(
"Binding target_entity '{$entity}' is not registered in config/form_binding.php.",
);
}
if (! array_key_exists($attribute, $registry)) {
throw new \InvalidArgumentException(
"Binding target_attribute '{$entity}.{$attribute}' is not registered in config/form_binding.php.",
);
}
}
}

View File

@@ -26,6 +26,7 @@ final class FormFieldService
{
public function __construct(
private readonly FormSchemaService $schemaService,
private readonly FormFieldBindingService $bindingService,
) {}
public function create(FormSchema $schema, array $data): FormField
@@ -140,7 +141,6 @@ final class FormFieldService
'validation_rules' => $library->validation_rules,
'is_required' => (bool) $library->default_is_required,
'is_filterable' => (bool) $library->default_is_filterable,
'binding' => $library->default_binding,
'translations' => $library->translations,
'sort_order' => $this->nextSortOrder($schema),
], $overrides);
@@ -154,6 +154,8 @@ final class FormFieldService
/** @var FormField $field */
$field = FormField::create($data);
$this->bindingService->copyBindings($library, $field);
FormFieldLibrary::query()->whereKey($library->id)->increment('usage_count');
$this->schemaService->bumpVersion($schema);

View File

@@ -33,6 +33,7 @@ final class FormSubmissionService
public function __construct(
private readonly FormLocaleResolver $localeResolver,
private readonly FormValueService $valueService,
private readonly FormFieldBindingService $bindingService,
) {}
/**
@@ -199,7 +200,7 @@ final class FormSubmissionService
*/
private function buildSnapshot(FormSchema $schema): array
{
$schema->loadMissing(['fields', 'sections']);
$schema->loadMissing(['fields.bindings', 'sections']);
return [
'schema_version' => $schema->version,
@@ -235,7 +236,7 @@ final class FormSubmissionService
'is_required' => (bool) $f->is_required,
'is_filterable' => (bool) $f->is_filterable,
'is_pii' => (bool) $f->is_pii,
'binding' => $f->binding,
'binding' => $this->bindingService->toJsonShape($f->bindings->first()),
'conditional_logic' => $f->conditional_logic,
'translations' => $f->translations,
'value_storage_hint' => $f->value_storage_hint instanceof \BackedEnum ? $f->value_storage_hint->value : $f->value_storage_hint,