refactor(form-builder): pre-publish check reads form_field_bindings; drop binding JSON columns
This commit is contained in:
@@ -128,28 +128,24 @@ final class VerifyFormsDataIntegrity extends Command
|
||||
->havingRaw('COUNT(*) > 1')
|
||||
->count();
|
||||
|
||||
// Binding registry cross-check
|
||||
$binding = (array) config('form_binding', []);
|
||||
// Binding registry cross-check (WS-5a: relational form_field_bindings).
|
||||
$registry = (array) config('form_binding', []);
|
||||
$badBindings = 0;
|
||||
$invalidBindings = DB::table('form_fields')->whereNotNull('binding')->select('binding')->get();
|
||||
foreach ($invalidBindings as $row) {
|
||||
$b = is_string($row->binding) ? json_decode($row->binding, true) : null;
|
||||
if (! is_array($b) || ! isset($b['mode'], $b['entity'], $b['column'])) {
|
||||
$bindingRows = DB::table('form_field_bindings')
|
||||
->select(['mode', 'target_entity', 'target_attribute'])
|
||||
->get();
|
||||
foreach ($bindingRows as $row) {
|
||||
if (! in_array($row->mode, ['entity_owned', 'mirrored'], true)) {
|
||||
$badBindings++;
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! in_array($b['mode'], ['entity_owned', 'mirrored'], true)) {
|
||||
if (! isset($registry[$row->target_entity][$row->target_attribute])) {
|
||||
$badBindings++;
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! isset($binding[$b['entity']][$b['column']])) {
|
||||
$badBindings++;
|
||||
|
||||
continue;
|
||||
}
|
||||
if (($binding[$b['entity']][$b['column']]['writable'] ?? false) !== true) {
|
||||
if (($registry[$row->target_entity][$row->target_attribute]['writable'] ?? false) !== true) {
|
||||
$badBindings++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Http\Requests\Api\V1\FormBuilder\UpdateFormFieldLibraryRequest;
|
||||
use App\Http\Resources\FormBuilder\FormFieldLibraryResource;
|
||||
use App\Models\FormBuilder\FormFieldLibrary;
|
||||
use App\Models\Organisation;
|
||||
use App\Services\FormBuilder\FormFieldBindingService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
@@ -17,6 +18,10 @@ use Illuminate\Support\Str;
|
||||
|
||||
final class FormFieldLibraryController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly FormFieldBindingService $bindingService,
|
||||
) {}
|
||||
|
||||
public function index(Organisation $organisation): AnonymousResourceCollection
|
||||
{
|
||||
Gate::authorize('viewAny', [FormFieldLibrary::class, $organisation]);
|
||||
@@ -42,6 +47,7 @@ final class FormFieldLibraryController extends Controller
|
||||
Gate::authorize('create', [FormFieldLibrary::class, $organisation]);
|
||||
|
||||
$data = $request->validated();
|
||||
$bindingSpec = $this->extractBindingSpec($data);
|
||||
$data['organisation_id'] = $organisation->id;
|
||||
$data['is_system'] = false;
|
||||
$data['is_active'] ??= true;
|
||||
@@ -50,6 +56,10 @@ final class FormFieldLibraryController extends Controller
|
||||
/** @var FormFieldLibrary $library */
|
||||
$library = FormFieldLibrary::create($data);
|
||||
|
||||
if ($bindingSpec !== null) {
|
||||
$this->bindingService->replaceBindings($library, [$bindingSpec]);
|
||||
}
|
||||
|
||||
return $this->created(new FormFieldLibraryResource($library));
|
||||
}
|
||||
|
||||
@@ -58,12 +68,46 @@ final class FormFieldLibraryController extends Controller
|
||||
$this->assertSameOrg($organisation, $fieldLibrary);
|
||||
Gate::authorize('update', $fieldLibrary);
|
||||
|
||||
$fieldLibrary->fill($request->validated());
|
||||
$data = $request->validated();
|
||||
$bindingProvided = array_key_exists('default_binding', $data);
|
||||
$bindingSpec = $bindingProvided ? $this->extractBindingSpec($data) : null;
|
||||
|
||||
$fieldLibrary->fill($data);
|
||||
$fieldLibrary->save();
|
||||
|
||||
if ($bindingProvided) {
|
||||
$this->bindingService->replaceBindings(
|
||||
$fieldLibrary,
|
||||
$bindingSpec === null ? [] : [$bindingSpec],
|
||||
);
|
||||
}
|
||||
|
||||
return $this->success(new FormFieldLibraryResource($fieldLibrary));
|
||||
}
|
||||
|
||||
/**
|
||||
* @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('default_binding', $data)) {
|
||||
return null;
|
||||
}
|
||||
$raw = $data['default_binding'];
|
||||
unset($data['default_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,
|
||||
];
|
||||
}
|
||||
|
||||
public function destroy(Organisation $organisation, FormFieldLibrary $fieldLibrary): JsonResponse
|
||||
{
|
||||
$this->assertSameOrg($organisation, $fieldLibrary);
|
||||
|
||||
@@ -59,7 +59,6 @@ final class FormField extends Model
|
||||
'is_unique',
|
||||
'is_pii',
|
||||
'display_width',
|
||||
'binding',
|
||||
'conditional_logic',
|
||||
'role_restrictions',
|
||||
'translations',
|
||||
@@ -72,7 +71,6 @@ final class FormField extends Model
|
||||
protected $casts = [
|
||||
'options' => 'array',
|
||||
'validation_rules' => 'array',
|
||||
'binding' => 'array',
|
||||
'conditional_logic' => 'array',
|
||||
'role_restrictions' => 'array',
|
||||
'translations' => 'array',
|
||||
|
||||
@@ -38,7 +38,6 @@ final class FormFieldLibrary extends Model
|
||||
'validation_rules',
|
||||
'default_is_required',
|
||||
'default_is_filterable',
|
||||
'default_binding',
|
||||
'translations',
|
||||
'description',
|
||||
'is_active',
|
||||
@@ -48,7 +47,6 @@ final class FormFieldLibrary extends Model
|
||||
protected $casts = [
|
||||
'options' => 'array',
|
||||
'validation_rules' => 'array',
|
||||
'default_binding' => 'array',
|
||||
'translations' => 'array',
|
||||
'default_is_required' => 'bool',
|
||||
'default_is_filterable' => 'bool',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 !== []) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user