feat(form-builder): add BindingConflictResolver per RFC Q7 (WS-6)

Resolves bindings within a submission to one winner per (target_entity,
target_attribute) group. Candidate set = form_values rows present
(absence excludes; null value is explicit clear and IS a candidate).
Trust-precedence with sort_order tie-break. Section-filtering for
RFC Q10 stub future-readiness.

Pure-logic resolver — no DB writes, only reads form_values for the
candidate gate. Works against the 'bindings' (plural) snapshot key
introduced alongside PersonProvisioner.

Refs: RFC-WS-6.md §3 (Q7, Q10)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-26 12:48:11 +02:00
parent d257d64925
commit 47265e9d4f
3 changed files with 419 additions and 0 deletions

View File

@@ -0,0 +1,202 @@
<?php
declare(strict_types=1);
namespace App\FormBuilder\Bindings;
use App\Enums\FormBuilder\FormFieldBindingMergeStrategy;
use App\Models\FormBuilder\FormSubmission;
use App\Models\FormBuilder\FormValue;
/**
* RFC-WS-6 §3 (Q7) pure-logic resolver. Given the bindings from a
* submission's schema_snapshot and the form_values rows, produces one
* winning ResolvedBinding per (target_entity, target_attribute) group.
*
* - Candidate set = bindings whose source form_field has a row in
* form_values for this submission, regardless of value content.
* - Sort by trust_level DESC, tie-break by form_field.sort_order ASC.
* - Empty winner writes null when merge_strategy permits (caller
* applies the per-strategy null matrix from
* FormFieldBindingMergeStrategy::nullWinnerBehaviour()).
*
* RFC §3 Q10: optional sectionId filter. Null = all bindings; non-null =
* only bindings whose source form_field belongs to a section matching
* the given id.
*/
final readonly class BindingConflictResolver
{
public function __construct(private BindingTypeRegistry $registry) {}
/**
* @return list<ResolvedBinding>
*/
public function resolve(FormSubmission $submission, ?string $sectionId = null): array
{
$snapshot = $submission->schema_snapshot;
if (! is_array($snapshot)) {
return [];
}
// Resolve section filter: snapshot fields carry section_slug, not
// section_id. Build a slug → id lookup so the caller can pass either.
$sectionSlugFilter = $this->resolveSectionSlugFilter($snapshot, $sectionId);
// Read every form_value row keyed by form_field_id for fast lookup.
$valuesByFieldId = $this->readValuesByFieldId($submission);
// Walk fields → bindings, collecting candidate entries.
/** @var list<array{
* form_field_id:string,
* sort_order:int,
* binding:array<string,mixed>,
* value:mixed,
* value_is_explicit:bool,
* }> $candidates */
$candidates = [];
foreach ($snapshot['fields'] ?? [] as $field) {
if (! is_array($field)) {
continue;
}
$fieldId = (string) ($field['id'] ?? '');
if ($fieldId === '') {
continue;
}
if ($sectionSlugFilter !== null && ($field['section_slug'] ?? null) !== $sectionSlugFilter) {
continue;
}
$bindings = $field['bindings'] ?? [];
if (! is_array($bindings)) {
continue;
}
if ($bindings === []) {
continue;
}
if (! array_key_exists($fieldId, $valuesByFieldId)) {
// RFC Q7 candidate-set rule: form_value row must exist.
continue;
}
$sortOrder = (int) ($field['sort_order'] ?? 0);
foreach ($bindings as $binding) {
if (! is_array($binding)) {
continue;
}
$candidates[] = [
'form_field_id' => $fieldId,
'sort_order' => $sortOrder,
'binding' => $binding,
'value' => $valuesByFieldId[$fieldId],
'value_is_explicit' => true,
];
}
}
// Group by (entity, attribute), sort by trust_level DESC then sort_order ASC.
/** @var array<string, list<array<string, mixed>>> $groups */
$groups = [];
foreach ($candidates as $candidate) {
$entity = (string) ($candidate['binding']['entity'] ?? '');
if ($entity === '') {
continue;
}
$attribute = (string) ($candidate['binding']['column'] ?? '');
if ($attribute === '') {
continue;
}
$key = $entity . '.' . $attribute;
$groups[$key][] = $candidate;
}
$resolved = [];
foreach ($groups as $group) {
usort($group, static function (array $a, array $b): int {
$trustA = (int) ($a['binding']['trust_level'] ?? 50);
$trustB = (int) ($b['binding']['trust_level'] ?? 50);
if ($trustA !== $trustB) {
return $trustB <=> $trustA;
}
return $a['sort_order'] <=> $b['sort_order'];
});
$winner = $group[0];
$resolved[] = $this->makeResolvedBinding($winner);
}
return $resolved;
}
/**
* Map a candidate row to a ResolvedBinding via the type registry.
*
* @param array{form_field_id:string, sort_order:int, binding:array<string,mixed>, value:mixed, value_is_explicit:bool} $candidate
*/
private function makeResolvedBinding(array $candidate): ResolvedBinding
{
$binding = $candidate['binding'];
$entity = (string) $binding['entity'];
$attribute = (string) $binding['column'];
$meta = $this->registry->resolve($entity, $attribute);
$strategy = FormFieldBindingMergeStrategy::from(
(string) ($binding['merge_strategy'] ?? FormFieldBindingMergeStrategy::Overwrite->value),
);
return new ResolvedBinding(
sourceFormFieldId: $candidate['form_field_id'],
bindingId: (string) ($binding['id'] ?? ''),
targetEntity: $entity,
targetAttribute: $attribute,
targetType: $meta->type,
mergeStrategy: $strategy,
trustLevel: (int) ($binding['trust_level'] ?? 50),
isIdentityKey: (bool) ($binding['is_identity_key'] ?? false),
value: $candidate['value'],
valueIsExplicit: $candidate['value_is_explicit'],
);
}
/**
* @return array<string, mixed>
*/
private function readValuesByFieldId(FormSubmission $submission): array
{
$rows = FormValue::query()
->withoutGlobalScopes()
->where('form_submission_id', $submission->id)
->get(['form_field_id', 'value']);
$out = [];
foreach ($rows as $row) {
$out[(string) $row->form_field_id] = $row->value;
}
return $out;
}
/**
* Snapshot fields store `section_slug`, not section_id (the slug is
* stable across schema versions). Translate the caller's sectionId
* to a slug if necessary.
*
* @param array<string, mixed> $snapshot
*/
private function resolveSectionSlugFilter(array $snapshot, ?string $sectionId): ?string
{
if ($sectionId === null) {
return null;
}
foreach ($snapshot['sections'] ?? [] as $section) {
if (! is_array($section)) {
continue;
}
if ((string) ($section['id'] ?? '') === $sectionId) {
return (string) ($section['slug'] ?? '');
}
}
// Caller may have passed a slug already.
return $sectionId;
}
}