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:
202
api/app/FormBuilder/Bindings/BindingConflictResolver.php
Normal file
202
api/app/FormBuilder/Bindings/BindingConflictResolver.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\FormBuilder\Bindings\BindingConflictResolver;
|
||||
use App\FormBuilder\Bindings\BindingTypeRegistry;
|
||||
use App\FormBuilder\Bindings\PersonProvisioner;
|
||||
use App\FormBuilder\Purposes\PurposeRegistry;
|
||||
@@ -92,6 +93,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
$this->app->singleton(PurposeRegistry::class);
|
||||
$this->app->singleton(BindingTypeRegistry::class);
|
||||
$this->app->singleton(PersonProvisioner::class);
|
||||
$this->app->singleton(BindingConflictResolver::class);
|
||||
|
||||
// Telescope is a dev-only debugging dashboard. Three-layer
|
||||
// defense keeps it out of production: composer `dont-discover`
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\FormBuilder\Bindings;
|
||||
|
||||
use App\Enums\FormBuilder\BindingTargetType;
|
||||
use App\Enums\FormBuilder\FormFieldBindingMergeStrategy;
|
||||
use App\FormBuilder\Bindings\BindingConflictResolver;
|
||||
use App\FormBuilder\Bindings\ResolvedBinding;
|
||||
use App\Models\FormBuilder\FormField;
|
||||
use App\Models\FormBuilder\FormSchema;
|
||||
use App\Models\FormBuilder\FormSubmission;
|
||||
use App\Models\FormBuilder\FormValue;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class BindingConflictResolverTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_single_binding_wins_trivially(): void
|
||||
{
|
||||
$submission = $this->makeSubmission([
|
||||
$this->fieldRow('email', 'person', 'email', trustLevel: 80, value: 'a@example.nl'),
|
||||
]);
|
||||
|
||||
$resolved = $this->resolver()->resolve($submission);
|
||||
$this->assertCount(1, $resolved);
|
||||
$this->assertSame('a@example.nl', $resolved[0]->value);
|
||||
$this->assertSame('person', $resolved[0]->targetEntity);
|
||||
$this->assertSame(BindingTargetType::SCALAR, $resolved[0]->targetType);
|
||||
}
|
||||
|
||||
public function test_higher_trust_wins(): void
|
||||
{
|
||||
$submission = $this->makeSubmission([
|
||||
$this->fieldRow('email_low', 'person', 'email', trustLevel: 30, value: 'low@example.nl'),
|
||||
$this->fieldRow('email_high', 'person', 'email', trustLevel: 90, value: 'high@example.nl'),
|
||||
]);
|
||||
|
||||
$resolved = $this->resolver()->resolve($submission);
|
||||
$this->assertCount(1, $resolved);
|
||||
$this->assertSame('high@example.nl', $resolved[0]->value);
|
||||
}
|
||||
|
||||
public function test_equal_trust_breaks_by_sort_order(): void
|
||||
{
|
||||
$submission = $this->makeSubmission([
|
||||
$this->fieldRow('first_email', 'person', 'email', trustLevel: 50, value: 'first@example.nl', sortOrder: 1),
|
||||
$this->fieldRow('second_email', 'person', 'email', trustLevel: 50, value: 'second@example.nl', sortOrder: 0),
|
||||
]);
|
||||
|
||||
$resolved = $this->resolver()->resolve($submission);
|
||||
$this->assertCount(1, $resolved);
|
||||
// sort_order 0 wins over sort_order 1 at equal trust
|
||||
$this->assertSame('second@example.nl', $resolved[0]->value);
|
||||
}
|
||||
|
||||
public function test_field_absent_from_form_values_is_not_a_candidate(): void
|
||||
{
|
||||
$submission = $this->makeSubmission([
|
||||
$this->fieldRow('email_skipped', 'person', 'email', trustLevel: 90, writeValue: false),
|
||||
$this->fieldRow('first_name', 'person', 'first_name', trustLevel: 50, value: 'Jan'),
|
||||
]);
|
||||
|
||||
$resolved = $this->resolver()->resolve($submission);
|
||||
$this->assertCount(1, $resolved);
|
||||
$this->assertSame('person.first_name', $resolved[0]->targetEntity . '.' . $resolved[0]->targetAttribute);
|
||||
}
|
||||
|
||||
public function test_explicit_null_value_is_a_candidate(): void
|
||||
{
|
||||
$submission = $this->makeSubmission([
|
||||
$this->fieldRow('email', 'person', 'email', trustLevel: 80, value: ''),
|
||||
]);
|
||||
|
||||
$resolved = $this->resolver()->resolve($submission);
|
||||
$this->assertCount(1, $resolved);
|
||||
$this->assertTrue($resolved[0]->valueIsExplicit);
|
||||
}
|
||||
|
||||
public function test_section_filter_includes_only_matching_section(): void
|
||||
{
|
||||
$submission = $this->makeSubmission([
|
||||
$this->fieldRow('a_email', 'person', 'email', trustLevel: 80, value: 'a@example.nl', sectionSlug: 'section-a'),
|
||||
$this->fieldRow('b_first_name', 'person', 'first_name', trustLevel: 80, value: 'Jan', sectionSlug: 'section-b'),
|
||||
]);
|
||||
$submission->schema_snapshot = array_merge($submission->schema_snapshot, [
|
||||
'sections' => [
|
||||
['id' => 'sec-a-id', 'slug' => 'section-a'],
|
||||
['id' => 'sec-b-id', 'slug' => 'section-b'],
|
||||
],
|
||||
]);
|
||||
$submission->save();
|
||||
|
||||
$resolved = $this->resolver()->resolve($submission, sectionId: 'sec-a-id');
|
||||
$this->assertCount(1, $resolved);
|
||||
$this->assertSame('person.email', $resolved[0]->targetEntity . '.' . $resolved[0]->targetAttribute);
|
||||
}
|
||||
|
||||
public function test_null_section_id_returns_all_candidates(): void
|
||||
{
|
||||
$submission = $this->makeSubmission([
|
||||
$this->fieldRow('a_email', 'person', 'email', trustLevel: 80, value: 'a@example.nl', sectionSlug: 'section-a'),
|
||||
$this->fieldRow('b_first_name', 'person', 'first_name', trustLevel: 80, value: 'Jan', sectionSlug: 'section-b'),
|
||||
]);
|
||||
|
||||
$resolved = $this->resolver()->resolve($submission);
|
||||
$this->assertCount(2, $resolved);
|
||||
}
|
||||
|
||||
public function test_different_target_groups_resolved_independently(): void
|
||||
{
|
||||
$submission = $this->makeSubmission([
|
||||
$this->fieldRow('email', 'person', 'email', trustLevel: 80, value: 'a@example.nl'),
|
||||
$this->fieldRow('first_name', 'person', 'first_name', trustLevel: 70, value: 'Jan'),
|
||||
$this->fieldRow('last_name', 'person', 'last_name', trustLevel: 60, value: 'Jansen'),
|
||||
]);
|
||||
|
||||
$resolved = $this->resolver()->resolve($submission);
|
||||
$this->assertCount(3, $resolved);
|
||||
$attributes = array_map(fn (ResolvedBinding $r): string => $r->targetAttribute, $resolved);
|
||||
$this->assertContains('email', $attributes);
|
||||
$this->assertContains('first_name', $attributes);
|
||||
$this->assertContains('last_name', $attributes);
|
||||
}
|
||||
|
||||
public function test_empty_form_values_returns_empty(): void
|
||||
{
|
||||
$submission = $this->makeSubmission([]);
|
||||
$this->assertSame([], $this->resolver()->resolve($submission));
|
||||
}
|
||||
|
||||
private function resolver(): BindingConflictResolver
|
||||
{
|
||||
return $this->app->make(BindingConflictResolver::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $fieldRows
|
||||
*/
|
||||
private function makeSubmission(array $fieldRows): FormSubmission
|
||||
{
|
||||
$schema = FormSchema::factory()->create();
|
||||
$submission = FormSubmission::factory()->create([
|
||||
'form_schema_id' => $schema->id,
|
||||
'organisation_id' => $schema->organisation_id,
|
||||
]);
|
||||
|
||||
$snapshotFields = [];
|
||||
foreach ($fieldRows as $row) {
|
||||
$field = FormField::factory()->create([
|
||||
'form_schema_id' => $schema->id,
|
||||
'slug' => $row['slug'],
|
||||
'sort_order' => $row['sort_order'],
|
||||
]);
|
||||
|
||||
$snapshotFields[] = [
|
||||
'id' => (string) $field->id,
|
||||
'slug' => (string) $row['slug'],
|
||||
'sort_order' => (int) $row['sort_order'],
|
||||
'section_slug' => $row['section_slug'] ?? null,
|
||||
'bindings' => [[
|
||||
'id' => 'bnd-' . $row['slug'],
|
||||
'mode' => 'entity_owned',
|
||||
'entity' => $row['entity'],
|
||||
'column' => $row['column'],
|
||||
'merge_strategy' => FormFieldBindingMergeStrategy::Overwrite->value,
|
||||
'trust_level' => $row['trust_level'],
|
||||
'is_identity_key' => false,
|
||||
]],
|
||||
];
|
||||
|
||||
if ($row['write_value'] ?? true) {
|
||||
$val = new FormValue();
|
||||
$val->form_submission_id = $submission->id;
|
||||
$val->form_field_id = $field->id;
|
||||
$val->setAttribute('value', $row['value']);
|
||||
$val->value_anonymised = false;
|
||||
$val->save();
|
||||
}
|
||||
}
|
||||
|
||||
$submission->schema_snapshot = ['fields' => $snapshotFields];
|
||||
$submission->save();
|
||||
|
||||
return $submission->fresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function fieldRow(
|
||||
string $slug,
|
||||
string $entity,
|
||||
string $column,
|
||||
int $trustLevel,
|
||||
mixed $value = null,
|
||||
int $sortOrder = 0,
|
||||
bool $writeValue = true,
|
||||
?string $sectionSlug = null,
|
||||
): array {
|
||||
return [
|
||||
'slug' => $slug,
|
||||
'entity' => $entity,
|
||||
'column' => $column,
|
||||
'trust_level' => $trustLevel,
|
||||
'value' => $value ?? '',
|
||||
'sort_order' => $sortOrder,
|
||||
'write_value' => $writeValue,
|
||||
'section_slug' => $sectionSlug,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user