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:
2026-04-24 23:56:39 +02:00
parent 2064b9901e
commit d06ea01b09
16 changed files with 1514 additions and 96 deletions

View File

@@ -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];
}
};