refactor(form-builder): pre-publish check reads form_field_bindings; drop binding JSON columns

This commit is contained in:
2026-04-24 20:09:27 +02:00
parent 6933e6d700
commit 61719bf8bf
18 changed files with 375 additions and 97 deletions

View File

@@ -36,11 +36,17 @@ final class FormFieldService
$data['form_schema_id'] = $schema->id;
$data['sort_order'] ??= $this->nextSortOrder($schema);
$bindingSpec = $this->extractBindingSpec($data);
$this->assertNoConditionalCycle($schema, null, $data['conditional_logic'] ?? null, $data['slug'] ?? null);
/** @var FormField $field */
$field = FormField::create($data);
if ($bindingSpec !== null) {
$this->bindingService->replaceBindings($field, [$bindingSpec]);
}
$this->schemaService->bumpVersion($schema);
$field->logFieldChange('field.created');
@@ -56,7 +62,13 @@ final class FormFieldService
$schema = $field->schema;
$this->assertNotFrozenForStructural($schema, $data);
if (array_key_exists('binding', $data) && $data['binding'] !== $field->binding) {
$bindingProvided = array_key_exists('binding', $data);
$rawBinding = $bindingProvided ? $data['binding'] : null;
$bindingSpec = $bindingProvided ? $this->extractBindingSpec($data) : null;
$currentBindingShape = $this->bindingService->toJsonShape($field->bindings()->first());
if ($bindingProvided && $this->bindingChanged($currentBindingShape, $rawBinding)) {
$this->assertBindingChangeAllowed($field, $forceBindingChange);
}
@@ -65,7 +77,7 @@ final class FormFieldService
}
$before = [
'binding' => $field->binding,
'binding' => $currentBindingShape,
'is_filterable' => $field->is_filterable,
'is_pii' => $field->is_pii,
'field_type' => $field->field_type,
@@ -74,12 +86,16 @@ final class FormFieldService
$field->fill($data);
$field->save();
if ($bindingProvided) {
$this->bindingService->replaceBindings($field, $bindingSpec === null ? [] : [$bindingSpec]);
}
$this->schemaService->bumpVersion($schema);
$field->logFieldChange('field.updated', [
'old' => $before,
'new' => [
'binding' => $field->binding,
'binding' => $this->bindingService->toJsonShape($field->bindings()->first()),
'is_filterable' => $field->is_filterable,
'is_pii' => $field->is_pii,
'field_type' => $field->field_type,
@@ -93,6 +109,51 @@ final class FormFieldService
return $field->refresh();
}
/**
* @param array<string, mixed> $data
* @return array{target_entity:string,target_attribute:string,mode:string,sync_direction?:?string}|null
*/
private function extractBindingSpec(array &$data): ?array
{
if (! array_key_exists('binding', $data)) {
return null;
}
$raw = $data['binding'];
unset($data['binding']);
if (! is_array($raw) || $raw === []) {
return null;
}
return [
'target_entity' => (string) ($raw['entity'] ?? ''),
'target_attribute' => (string) ($raw['column'] ?? ''),
'mode' => (string) ($raw['mode'] ?? ''),
'sync_direction' => isset($raw['sync_direction']) ? (string) $raw['sync_direction'] : null,
];
}
/**
* @param array<string, mixed>|null $current Pre-WS-5a ARCH §6.3 shape
* @param array<string, mixed>|null $next
*/
private function bindingChanged(?array $current, ?array $next): bool
{
$normalise = static function (?array $value): array {
if ($value === null || $value === []) {
return [];
}
return [
'mode' => (string) ($value['mode'] ?? ''),
'entity' => (string) ($value['entity'] ?? ''),
'column' => (string) ($value['column'] ?? ''),
'sync_direction' => (string) ($value['sync_direction'] ?? ''),
];
};
return $normalise($current) !== $normalise($next);
}
public function delete(FormField $field, ?string $confirmedName = null): void
{
$schema = $field->schema;

View File

@@ -11,6 +11,7 @@ use App\Exceptions\FormBuilder\EditLockConflictException;
use App\Exceptions\FormBuilder\PurposeRequirementsNotMetException;
use App\FormBuilder\Purposes\PurposeRegistry;
use App\Models\FormBuilder\FormField;
use App\Models\FormBuilder\FormFieldBinding;
use App\Models\FormBuilder\FormSchema;
use App\Models\FormBuilder\FormSchemaSection;
use App\Models\FormBuilder\FormSubmission;
@@ -133,8 +134,8 @@ final class FormSchemaService
* purpose is bound by at least one field on the schema.
*
* Binding paths follow Pattern A notation (`{entity}.{attribute}`).
* In v1.0 we read `form_fields.binding` JSON; WS-5a will switch this
* to the relational `form_field_bindings` table.
* Sourced from the relational `form_field_bindings` table (WS-5a;
* ARCH §6.7, §17.3).
*/
private function assertRequiredBindingsPresent(FormSchema $schema): void
{
@@ -151,19 +152,16 @@ final class FormSchemaService
return;
}
$bound = [];
foreach ($schema->fields as $field) {
$binding = $field->binding;
if (! is_array($binding)) {
continue;
}
$entity = (string) ($binding['entity'] ?? '');
$column = (string) ($binding['column'] ?? '');
if ($entity === '' || $column === '') {
continue;
}
$bound[] = $entity.'.'.$column;
}
$fieldIds = $schema->fields()->pluck('id');
$bound = FormFieldBinding::query()
->withoutGlobalScopes()
->where('owner_type', 'form_field')
->whereIn('owner_id', $fieldIds)
->get(['target_entity', 'target_attribute'])
->map(fn (FormFieldBinding $b) => $b->target_entity.'.'.$b->target_attribute)
->unique()
->all();
$missing = array_values(array_diff($required, $bound));
if ($missing !== []) {

View File

@@ -291,18 +291,13 @@ final class FormValueService
private function writeEntityMirror(FormSubmission $submission, FormField $field, mixed $raw): void
{
$binding = $field->binding;
if (! is_array($binding) || ($binding['mode'] ?? null) === null) {
$binding = $field->bindings()->first();
if ($binding === null) {
return;
}
$mode = (string) $binding['mode'];
if (! in_array($mode, ['entity_owned', 'mirrored'], true)) {
return;
}
$entity = (string) ($binding['entity'] ?? '');
$column = (string) ($binding['column'] ?? '');
$entity = (string) $binding->target_entity;
$column = (string) $binding->target_attribute;
if ($entity === '' || $column === '') {
return;
}