*/ 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, * 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>> $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, 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 */ 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 $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; } }