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:
@@ -33,12 +33,13 @@ final class FormFieldBindingMigrationTest extends TestCase
|
||||
|
||||
public function test_forward_migrations_backfill_rows_from_both_json_sources(): void
|
||||
{
|
||||
// Roll back to pre-WS-5a state: 2 WS-5c migrations
|
||||
// (create-conditional-logic-conditions, create-conditional-logic-groups) +
|
||||
// 5 WS-5b migrations (drop-validation-cols, configs-backfill,
|
||||
// create-configs, validation-rules-backfill, create-validation-rules) +
|
||||
// 2 WS-5a migrations (drop-binding-cols, create-bindings) = 9.
|
||||
$this->artisan('migrate:rollback', ['--step' => 9])->assertSuccessful();
|
||||
// Roll back to pre-WS-5a state: 3 WS-5c migrations
|
||||
// (backfill-conditional-logic, create-conditional-logic-conditions,
|
||||
// create-conditional-logic-groups) + 5 WS-5b migrations
|
||||
// (drop-validation-cols, configs-backfill, create-configs,
|
||||
// validation-rules-backfill, create-validation-rules) +
|
||||
// 2 WS-5a migrations (drop-binding-cols, create-bindings) = 10.
|
||||
$this->artisan('migrate:rollback', ['--step' => 10])->assertSuccessful();
|
||||
$this->assertFalse(Schema::hasTable('form_field_bindings'));
|
||||
$this->assertTrue(Schema::hasColumn('form_fields', 'binding'));
|
||||
$this->assertTrue(Schema::hasColumn('form_field_library', 'default_binding'));
|
||||
@@ -99,8 +100,8 @@ final class FormFieldBindingMigrationTest extends TestCase
|
||||
|
||||
public function test_rollback_reconstructs_json_and_drops_table(): void
|
||||
{
|
||||
// Walk back the full WS-5c + WS-5b + WS-5a stack (9 migrations).
|
||||
$this->artisan('migrate:rollback', ['--step' => 9])->assertSuccessful();
|
||||
// Walk back the full WS-5c + WS-5b + WS-5a stack (10 migrations).
|
||||
$this->artisan('migrate:rollback', ['--step' => 10])->assertSuccessful();
|
||||
[$fieldAId, , ] = $this->seedFieldsWithBindingJson();
|
||||
[$libAId, ] = $this->seedLibraryWithBindingJson();
|
||||
|
||||
@@ -110,11 +111,11 @@ final class FormFieldBindingMigrationTest extends TestCase
|
||||
$this->assertFalse(Schema::hasColumn('form_fields', 'binding'));
|
||||
$this->assertSame(5, DB::table('form_field_bindings')->count());
|
||||
|
||||
// Step back over WS-5c (2 migrations) + WS-5b (5 migrations) in one
|
||||
// Step back over WS-5c (3 migrations) + WS-5b (5 migrations) in one
|
||||
// go → restores the pre-WS-5b state (conditional-logic,
|
||||
// validation-rules and configs tables gone, validation_rules JSON
|
||||
// columns reappear on source tables; binding contract intact).
|
||||
$this->artisan('migrate:rollback', ['--step' => 7])->assertSuccessful();
|
||||
$this->artisan('migrate:rollback', ['--step' => 8])->assertSuccessful();
|
||||
$this->assertFalse(Schema::hasTable('form_field_conditional_logic_groups'));
|
||||
$this->assertFalse(Schema::hasTable('form_field_conditional_logic_conditions'));
|
||||
$this->assertFalse(Schema::hasTable('form_field_validation_rules'));
|
||||
|
||||
Reference in New Issue
Block a user