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>
260 lines
9.8 KiB
PHP
260 lines
9.8 KiB
PHP
<?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;
|
|
}
|
|
|
|
/**
|
|
* Richer snapshot shape for the WS-6 binding pipeline. Captures
|
|
* applicator-relevant metadata (binding id, merge_strategy,
|
|
* trust_level, is_identity_key) on top of the legacy
|
|
* mode/entity/column triple.
|
|
*
|
|
* RFC-WS-6.md §3 (Q6): the applicator reads from snapshot, not from
|
|
* the live form_field_bindings table — this shape carries everything
|
|
* needed for conflict resolution and person provisioning.
|
|
*
|
|
* @return array{
|
|
* id:string,
|
|
* mode:string,
|
|
* entity:string,
|
|
* column:string,
|
|
* sync_direction?:string,
|
|
* merge_strategy:string,
|
|
* trust_level:int,
|
|
* is_identity_key:bool,
|
|
* }
|
|
*/
|
|
public function toApplicatorShape(FormFieldBinding $binding): array
|
|
{
|
|
// FormFieldBinding casts mode/merge_strategy to enum already; access
|
|
// the value directly without redundant instanceof guards.
|
|
$shape = [
|
|
'id' => (string) $binding->id,
|
|
'mode' => $binding->mode->value,
|
|
'entity' => (string) $binding->target_entity,
|
|
'column' => (string) $binding->target_attribute,
|
|
'merge_strategy' => ($binding->merge_strategy ?? FormFieldBindingMergeStrategy::Overwrite)->value,
|
|
'trust_level' => (int) $binding->trust_level,
|
|
'is_identity_key' => (bool) $binding->is_identity_key,
|
|
];
|
|
if ($binding->sync_direction !== null && $binding->sync_direction !== '') {
|
|
$shape['sync_direction'] = (string) $binding->sync_direction;
|
|
}
|
|
|
|
return $shape;
|
|
}
|
|
|
|
/**
|
|
* 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{bindings: list<array<string, mixed>>}
|
|
*/
|
|
public function snapshotShapesFor(iterable $bindings): array
|
|
{
|
|
$all = [];
|
|
foreach ($bindings as $binding) {
|
|
$all[] = $this->toApplicatorShape($binding);
|
|
}
|
|
|
|
return ['bindings' => $all];
|
|
}
|
|
|
|
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.",
|
|
);
|
|
}
|
|
}
|
|
}
|