feat(form-builder): FormFieldConditionalLogicService + cycle detection + legacy backfill + snapshot
WS-5c commit 2 of 4 — the service layer, backfill migration, and
read-path switch. Per addendum Q3, conditional_logic applies to
FormField only — no library mirror and no copyLogic on
FormFieldService::insertFromLibrary.
FormFieldConditionalLogicService owns every write:
- logicFor(field): depth-limited eager-load of the tree
- replaceLogic(field, tree): transactional structure + operator +
field_slug validation + cycle check + activity-log emit
(field.conditional_logic_replaced)
- toJsonShape(root): reconstructs the canonical ARCH §8
`{show_when: {...}}` shape — single source of truth for the
snapshot writer + API resources
- assertSpecsValid(tree): public boundary guard for the FormRequest
strict validator (WS-5c commit 3 wires this up)
- assertNoCycles(field, tree): contract preserved from
FormFieldService::assertNoConditionalCycle, implementation now
reads the relational adjacency.
Backfill migration translates pre-WS-5c conditional_logic JSON to
rows. Strict dispatch: unknown operators / unknown top-level keys /
malformed groups FAIL the migration — Phase A seed-scan confirmed
the catalogue parity, so any drift is a data bug to fix at source,
not silently absorb. Rollback rebuilds canonical JSON and clears
the relational tree.
FormFieldService.create/update route `conditional_logic` through
the new service (matching the extract-and-delegate pattern from
WS-5a bindings and WS-5b validation rules). Snapshot writer + both
resources (FormFieldResource, PublicFormSchemaResource) read via
`toJsonShape(rootConditionalLogicGroup())` — byte-for-byte parity
with the pre-WS-5c JSON contract.
InvalidConditionalLogicSpecException handled in FormFieldController
as 422, same as FrozenSchemaException / CyclicDependencyException.
Tests: 20 new under tests/Feature/FormBuilder/ConditionalLogic/
(service, cycle detection, backfill forward+rollback+failure cases,
snapshot + resource parity). FormFieldApiTest cyclic rejection test
rewritten to use the new factory state. Rollback step counts in
WS-5a/b migration tests bumped +1 for the new backfill migration.
Baseline 1122 → 1142 green (3032 → 3085 assertions).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\FormBuilder\FormFieldConditionalLogicConditionOperator;
|
||||
use App\Enums\FormBuilder\FormFieldConditionalLogicGroupOperator;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* WS-5c commit 2 of 4 — translates pre-WS-5c `form_fields.conditional_logic`
|
||||
* JSON into rows in the new relational `form_field_conditional_logic_groups`
|
||||
* + `form_field_conditional_logic_conditions` tables.
|
||||
*
|
||||
* Per addendum Q3, only `form_fields` is in scope — there is no
|
||||
* library-mirror for conditional_logic.
|
||||
*
|
||||
* Strict dispatch — no guessing:
|
||||
* - Operators outside the ARCH §8 catalogue: FAIL the migration.
|
||||
* - Top-level keys other than `show_when`: FAIL the migration
|
||||
* (Phase A seed-scan confirmed only `show_when` exists in the wild).
|
||||
* - Malformed tree (non-array children, missing operator): FAIL.
|
||||
*
|
||||
* Rollback reconstructs the canonical ARCH §8 JSON shape from the
|
||||
* relational tree and writes back to `form_fields.conditional_logic`
|
||||
* (which is still present until WS-5c commit 3 drops it). The forward+
|
||||
* back pair is safe as a unit; no cross-field cycle check runs at
|
||||
* backfill — pre-WS-5c data is assumed acyclic because the JSON-era
|
||||
* cycle check was enforced on every save. Post-backfill, the service
|
||||
* enforces going forward.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('form_fields') || ! Schema::hasColumn('form_fields', 'conditional_logic')) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function (): void {
|
||||
$rows = DB::table('form_fields')
|
||||
->whereNotNull('conditional_logic')
|
||||
->orderBy('id')
|
||||
->get(['id', 'conditional_logic']);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$decoded = json_decode((string) $row->conditional_logic, true);
|
||||
if (! is_array($decoded) || $decoded === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->assertLegacyShape((string) $row->id, $decoded);
|
||||
|
||||
/** @var array<string, mixed> $rootGroup */
|
||||
$rootGroup = $decoded['show_when'];
|
||||
$this->insertNode((string) $row->id, null, $rootGroup, 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (
|
||||
! Schema::hasTable('form_field_conditional_logic_groups')
|
||||
|| ! Schema::hasTable('form_field_conditional_logic_conditions')
|
||||
|| ! Schema::hasTable('form_fields')
|
||||
|| ! Schema::hasColumn('form_fields', 'conditional_logic')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function (): void {
|
||||
$rootGroups = DB::table('form_field_conditional_logic_groups')
|
||||
->whereNull('parent_group_id')
|
||||
->orderBy('form_field_id')
|
||||
->get(['id', 'form_field_id', 'operator']);
|
||||
|
||||
foreach ($rootGroups as $root) {
|
||||
$json = [
|
||||
'show_when' => $this->renderGroup((string) $root->id, (string) $root->operator),
|
||||
];
|
||||
DB::table('form_fields')
|
||||
->where('id', $root->form_field_id)
|
||||
->update(['conditional_logic' => json_encode($json, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)]);
|
||||
}
|
||||
|
||||
DB::table('form_field_conditional_logic_conditions')->delete();
|
||||
DB::table('form_field_conditional_logic_groups')->delete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $decoded
|
||||
*/
|
||||
private function assertLegacyShape(string $fieldId, array $decoded): void
|
||||
{
|
||||
$allowedRootKeys = ['show_when'];
|
||||
$unknownKeys = array_diff(array_keys($decoded), $allowedRootKeys);
|
||||
if ($unknownKeys !== []) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'form_fields.id=%s conditional_logic has unknown top-level key(s): %s. '
|
||||
.'ARCH §8 allows only "show_when"; Phase A seed-scan (2026-04-26) confirmed '
|
||||
.'no other keys in the wild. Fix source data before re-running the migration.',
|
||||
$fieldId,
|
||||
implode(', ', $unknownKeys),
|
||||
));
|
||||
}
|
||||
|
||||
if (! isset($decoded['show_when']) || ! is_array($decoded['show_when'])) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'form_fields.id=%s conditional_logic.show_when is missing or not a group.',
|
||||
$fieldId,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $node a group node has the shape
|
||||
* `["all"|"any" => [ <children> ]]`; a condition leaf has
|
||||
* `["field_slug" => ..., "operator" => ..., "value" => ...]`.
|
||||
*/
|
||||
private function insertNode(string $fieldId, ?string $parentGroupId, array $node, int $sortOrder): void
|
||||
{
|
||||
if (isset($node['field_slug'])) {
|
||||
$rawOperator = (string) ($node['operator'] ?? '');
|
||||
$operator = FormFieldConditionalLogicConditionOperator::tryFrom($rawOperator);
|
||||
if ($operator === null) {
|
||||
throw new RuntimeException(sprintf(
|
||||
"form_fields.id=%s conditional_logic contains unknown comparison operator '%s'. "
|
||||
.'Valid operators: equals, not_equals, contains, not_contains, in, not_in, '
|
||||
.'greater_than, less_than, empty, not_empty. Fix source data and re-run.',
|
||||
$fieldId,
|
||||
$rawOperator,
|
||||
));
|
||||
}
|
||||
|
||||
$value = $operator->isValueless() ? null : ($node['value'] ?? null);
|
||||
|
||||
DB::table('form_field_conditional_logic_conditions')->insert([
|
||||
'id' => (string) Str::ulid(),
|
||||
'group_id' => $parentGroupId,
|
||||
'field_slug' => (string) ($node['field_slug'] ?? ''),
|
||||
'comparison_operator' => $operator->value,
|
||||
'value' => $value === null ? null : json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'sort_order' => $sortOrder,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Group node: exactly one of "all" / "any" carries the children list.
|
||||
$operatorValue = null;
|
||||
$children = [];
|
||||
foreach (['all', 'any'] as $candidate) {
|
||||
if (array_key_exists($candidate, $node) && is_array($node[$candidate])) {
|
||||
$operatorValue = $candidate;
|
||||
$children = $node[$candidate];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($operatorValue === null) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'form_fields.id=%s conditional_logic contains a group with no "all" or "any" key. '
|
||||
.'Fix source data and re-run.',
|
||||
$fieldId,
|
||||
));
|
||||
}
|
||||
|
||||
$groupOperator = FormFieldConditionalLogicGroupOperator::from($operatorValue);
|
||||
|
||||
$groupId = (string) Str::ulid();
|
||||
DB::table('form_field_conditional_logic_groups')->insert([
|
||||
'id' => $groupId,
|
||||
'form_field_id' => $fieldId,
|
||||
'parent_group_id' => $parentGroupId,
|
||||
'operator' => $groupOperator->value,
|
||||
'sort_order' => $sortOrder,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
foreach ($children as $childSortOrder => $child) {
|
||||
if (! is_array($child)) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'form_fields.id=%s conditional_logic child at position %d is not an array. Fix source data and re-run.',
|
||||
$fieldId,
|
||||
(int) $childSortOrder,
|
||||
));
|
||||
}
|
||||
$this->insertNode($fieldId, $groupId, $child, (int) $childSortOrder);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function renderGroup(string $groupId, string $operator): array
|
||||
{
|
||||
$conditions = DB::table('form_field_conditional_logic_conditions')
|
||||
->where('group_id', $groupId)
|
||||
->orderBy('sort_order')
|
||||
->orderBy('id')
|
||||
->get(['id', 'field_slug', 'comparison_operator', 'value', 'sort_order']);
|
||||
|
||||
$childGroups = DB::table('form_field_conditional_logic_groups')
|
||||
->where('parent_group_id', $groupId)
|
||||
->orderBy('sort_order')
|
||||
->orderBy('id')
|
||||
->get(['id', 'operator', 'sort_order']);
|
||||
|
||||
$rendered = [];
|
||||
foreach ($conditions as $row) {
|
||||
$opEnum = FormFieldConditionalLogicConditionOperator::from((string) $row->comparison_operator);
|
||||
$node = [
|
||||
'field_slug' => (string) $row->field_slug,
|
||||
'operator' => $opEnum->value,
|
||||
];
|
||||
if (! $opEnum->isValueless()) {
|
||||
$node['value'] = $row->value === null ? null : json_decode((string) $row->value, true);
|
||||
}
|
||||
$rendered[] = [
|
||||
'sort_order' => (int) $row->sort_order,
|
||||
'id' => (string) $row->id,
|
||||
'node' => $node,
|
||||
];
|
||||
}
|
||||
foreach ($childGroups as $row) {
|
||||
$rendered[] = [
|
||||
'sort_order' => (int) $row->sort_order,
|
||||
'id' => (string) $row->id,
|
||||
'node' => $this->renderGroup((string) $row->id, (string) $row->operator),
|
||||
];
|
||||
}
|
||||
|
||||
usort($rendered, function (array $a, array $b): int {
|
||||
return $a['sort_order'] <=> $b['sort_order']
|
||||
?: strcmp($a['id'], $b['id']);
|
||||
});
|
||||
|
||||
$children = array_map(static fn (array $entry): array => $entry['node'], $rendered);
|
||||
|
||||
return [$operator => $children];
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user