conditionalLogicGroups() ->whereNull('parent_group_id') ->with($this->nestedEagerLoad(5)) ->first(); return $root; } /** * Replace the full conditional-logic tree on a field transactionally. * Validates structure, operators, field_slug references, and cross- * field cycles before any write lands. Emits the semantic activity-log * entry on the owning FormField. * * Passing `null` clears the logic entirely (matches the legacy * `conditional_logic = null` JSON state). * * @param array|null $tree root group shape (no * `show_when` wrapper — callers unwrap the wrapper before calling) */ public function replaceLogic(FormField $field, ?array $tree): void { if ($tree === null || $tree === []) { DB::transaction(function () use ($field): void { $this->deleteExistingTree($field); $field->logFieldChange('field.conditional_logic_replaced', [ 'count' => 0, ]); }); return; } $this->assertSpecsValid($tree); $this->assertFieldSlugsResolve($field, $tree); $this->assertNoCycles($field, $tree); DB::transaction(function () use ($field, $tree): void { $this->deleteExistingTree($field); $this->insertNode($field->id, null, $tree, 0); $field->logFieldChange('field.conditional_logic_replaced', [ 'count' => $this->countNodes($tree), ]); }); } /** * Serialise a root group (plus its subtree) back to the ARCH §8 * `{show_when: {...}}` JSON shape. Returns null when the field has no * logic — matches the pre-WS-5c `conditional_logic = null` state. */ public function toJsonShape(?FormFieldConditionalLogicGroup $root): ?array { if ($root === null) { return null; } return [ 'show_when' => $this->renderGroup($root), ]; } /** * Public guard the FormRequests invoke in their `after()` hook (WS-5c * commit 3 strict validator on save). Rejects bad tree structure at * the HTTP boundary before any write lands. * * @param array $tree root group shape */ public function assertSpecsValid(array $tree): void { $this->assertNodeValid($tree, isRoot: true); } /** * Translate the legacy ARCH §8 JSON group shape (`{"all": [...]}` / * `{"any": [...]}`) into the service's internal tree form * (`{"operator": "all"|"any", "children": [...]}`). Pure recursive * passthrough — does NOT validate; malformed shapes are returned * as-is and surface as `InvalidConditionalLogicSpecException` from * the downstream `assertSpecsValid`. Static so both the service path * (`FormFieldService::extractConditionalLogicTree`) and the boundary * validators (Store/Update FormRequests' `after()` hooks) can call * it without container resolution. * * Group operator catalogue is sourced from * `FormFieldConditionalLogicGroupOperator::values()` so a future * operator addition lands in one place. * * @param array $node * @return array */ public static function normaliseLegacyShape(array $node): array { if (isset($node['field_slug'])) { return $node; } if (isset($node['operator'], $node['children']) && is_array($node['children'])) { $children = []; foreach ($node['children'] as $child) { if (is_array($child)) { $children[] = self::normaliseLegacyShape($child); } } return ['operator' => $node['operator'], 'children' => $children]; } foreach (FormFieldConditionalLogicGroupOperator::values() as $candidate) { if (isset($node[$candidate]) && is_array($node[$candidate])) { $children = []; foreach ($node[$candidate] as $child) { if (is_array($child)) { $children[] = self::normaliseLegacyShape($child); } } return ['operator' => $candidate, 'children' => $children]; } } return $node; } /** * Cross-field cycle detection (ARCH §8; contract preserved from the * pre-WS-5c `FormFieldService::assertNoConditionalCycle`). Builds a * dependency graph over every field in the owning schema — this field's * proposed tree plus the persisted trees of every sibling — and runs * DFS from this field's slug. A back-edge raises. * * @param array $tree proposed root group shape */ public function assertNoCycles(FormField $field, array $tree): void { $subjectSlug = (string) $field->slug; $dependsOn = $this->extractDependencySlugs($tree); if ($dependsOn === []) { return; } $adjacency = $this->buildSchemaAdjacency($field); $adjacency[$subjectSlug] = $dependsOn; $visiting = []; $visited = []; $walk = function (string $node) use (&$walk, &$adjacency, &$visiting, &$visited, $subjectSlug): void { if (isset($visited[$node])) { return; } if (isset($visiting[$node])) { throw CyclicDependencyException::forField($subjectSlug); } $visiting[$node] = true; foreach ($adjacency[$node] ?? [] as $next) { $walk($next); } unset($visiting[$node]); $visited[$node] = true; }; $walk($subjectSlug); } // --- internals ---------------------------------------------------- private function deleteExistingTree(FormField $field): void { // Cascade: deleting the root groups deletes all descendants and // all conditions via the DB-level ON DELETE CASCADE chain. FormFieldConditionalLogicGroup::query() ->withoutGlobalScopes() ->where('form_field_id', $field->id) ->whereNull('parent_group_id') ->get() ->each(fn (FormFieldConditionalLogicGroup $g) => $g->delete()); // Defensive: clear any orphaned descendants (should never exist // after the root delete, but we guard against partial failures). FormFieldConditionalLogicGroup::query() ->withoutGlobalScopes() ->where('form_field_id', $field->id) ->delete(); } /** * @param array $node */ private function insertNode(string $fieldId, ?string $parentGroupId, array $node, int $sortOrder): void { if (isset($node['field_slug'])) { $operator = FormFieldConditionalLogicConditionOperator::from((string) $node['operator']); FormFieldConditionalLogicCondition::query()->withoutGlobalScopes()->create([ 'group_id' => $parentGroupId, 'field_slug' => (string) $node['field_slug'], 'comparison_operator' => $operator->value, 'value' => $operator->isValueless() ? null : ($node['value'] ?? null), 'sort_order' => $sortOrder, ]); return; } $groupOperator = FormFieldConditionalLogicGroupOperator::from((string) $node['operator']); $group = FormFieldConditionalLogicGroup::query()->withoutGlobalScopes()->create([ 'form_field_id' => $fieldId, 'parent_group_id' => $parentGroupId, 'operator' => $groupOperator->value, 'sort_order' => $sortOrder, ]); /** @var array> $children */ $children = $node['children'] ?? []; foreach ($children as $childSortOrder => $child) { $this->insertNode($fieldId, $group->id, $child, (int) $childSortOrder); } } /** * @param array $node */ private function assertNodeValid(array $node, bool $isRoot): void { if (isset($node['field_slug'])) { if ($isRoot) { throw new InvalidConditionalLogicSpecException( 'Conditional logic tree root must be a group (with an "all" or "any" operator), not a bare condition.', ); } $rawOperator = (string) ($node['operator'] ?? ''); if (FormFieldConditionalLogicConditionOperator::tryFrom($rawOperator) === null) { throw new InvalidConditionalLogicSpecException( "Unknown comparison operator '{$rawOperator}'. Valid operators: " .implode(', ', array_map( fn (FormFieldConditionalLogicConditionOperator $op) => $op->value, FormFieldConditionalLogicConditionOperator::cases(), )) .'.', ); } $slug = (string) ($node['field_slug'] ?? ''); if ($slug === '') { throw new InvalidConditionalLogicSpecException( 'Condition requires a non-empty field_slug.', ); } return; } $rawOperator = (string) ($node['operator'] ?? ''); if (FormFieldConditionalLogicGroupOperator::tryFrom($rawOperator) === null) { throw new InvalidConditionalLogicSpecException( "Unknown group operator '{$rawOperator}'. Valid operators: all, any.", ); } $children = $node['children'] ?? null; if (! is_array($children) || $children === []) { throw new InvalidConditionalLogicSpecException( 'Conditional logic groups must have at least one child (group or condition).', ); } foreach ($children as $child) { if (! is_array($child)) { throw new InvalidConditionalLogicSpecException( 'Group children must be arrays (group or condition nodes).', ); } $this->assertNodeValid($child, isRoot: false); } } /** * @param array $tree */ private function assertFieldSlugsResolve(FormField $field, array $tree): void { $slugs = $this->extractDependencySlugs($tree); if ($slugs === []) { return; } $known = FormField::query() ->withoutGlobalScopes() ->where('form_schema_id', $field->form_schema_id) ->pluck('slug') ->all(); $missing = array_values(array_diff($slugs, $known)); if ($missing !== []) { throw new InvalidConditionalLogicSpecException( 'Conditional logic references unknown field_slug(s): '.implode(', ', $missing).'.', ); } } /** * @param array $tree * @return list */ private function extractDependencySlugs(array $tree): array { $out = []; $walk = function (array $node) use (&$walk, &$out): void { if (isset($node['field_slug'])) { $out[] = (string) $node['field_slug']; return; } foreach ($node['children'] ?? [] as $child) { if (is_array($child)) { $walk($child); } } }; $walk($tree); return array_values(array_unique($out)); } /** * @param array $tree */ private function countNodes(array $tree): int { $count = 0; $walk = function (array $node) use (&$walk, &$count): void { $count++; foreach ($node['children'] ?? [] as $child) { if (is_array($child)) { $walk($child); } } }; $walk($tree); return $count; } /** * Build a slug → list-of-dependent-slugs map for every OTHER field in * the owning schema. Subject slug is excluded; caller merges its * proposed deps in separately. Reads from the relational tables — the * post-WS-5c source of truth. * * @return array> */ private function buildSchemaAdjacency(FormField $subject): array { $siblings = FormField::query() ->withoutGlobalScopes() ->where('form_schema_id', $subject->form_schema_id) ->where('id', '!=', $subject->id) ->get(['id', 'slug']); if ($siblings->isEmpty()) { return []; } $adjacency = []; foreach ($siblings as $sibling) { $root = $sibling->rootConditionalLogicGroup(); if ($root === null) { continue; } $deps = $this->collectConditionSlugsFromPersistedTree($root); if ($deps !== []) { $adjacency[(string) $sibling->slug] = $deps; } } return $adjacency; } /** * @return list */ private function collectConditionSlugsFromPersistedTree(FormFieldConditionalLogicGroup $root): array { $slugs = []; $walk = function (FormFieldConditionalLogicGroup $group) use (&$walk, &$slugs): void { foreach ($group->conditions as $condition) { $slugs[] = (string) $condition->field_slug; } foreach ($group->childGroups as $child) { $walk($child); } }; $walk($root); return array_values(array_unique($slugs)); } /** * @return array */ private function renderGroup(FormFieldConditionalLogicGroup $group): array { $conditions = $group->conditions->map(fn (FormFieldConditionalLogicCondition $c) => [ 'node' => $this->renderCondition($c), 'sort_order' => (int) $c->sort_order, 'id' => (string) $c->id, ]); $children = $group->childGroups->map(fn (FormFieldConditionalLogicGroup $g) => [ 'node' => $this->renderGroup($g), 'sort_order' => (int) $g->sort_order, 'id' => (string) $g->id, ]); // Interleave by sort_order, stable secondary by id. $merged = $conditions->concat($children) ->sortBy([ ['sort_order', 'asc'], ['id', 'asc'], ]) ->values() ->map(fn (array $entry): array => $entry['node']) ->all(); $operatorValue = $group->operator instanceof FormFieldConditionalLogicGroupOperator ? $group->operator->value : (string) $group->operator; return [$operatorValue => $merged]; } /** * @return array */ private function renderCondition(FormFieldConditionalLogicCondition $condition): array { $operator = $condition->comparison_operator instanceof FormFieldConditionalLogicConditionOperator ? $condition->comparison_operator : FormFieldConditionalLogicConditionOperator::from((string) $condition->comparison_operator); $row = [ 'field_slug' => (string) $condition->field_slug, 'operator' => $operator->value, ]; if (! $operator->isValueless()) { $row['value'] = $condition->value; } return $row; } /** * Depth-bounded eager-load spec for logicFor. Builds nested * `childGroups.childGroups...` strings up to the given depth, with * `conditions` on each level. * * @return list */ private function nestedEagerLoad(int $depth): array { $loads = ['conditions']; $prefix = ''; for ($i = 0; $i < $depth; $i++) { $prefix = $prefix === '' ? 'childGroups' : $prefix.'.childGroups'; $loads[] = $prefix; $loads[] = $prefix.'.conditions'; } return $loads; } }