refactor(form-builder): strict validator + drop form_fields.conditional_logic JSON column
WS-5c commit 3 of 4. FormRequests (Store/Update) now reject bad
conditional_logic trees at the HTTP boundary — the `after()` hook
unwraps the `show_when` envelope, normalises legacy `{all|any: [...]}`
group shape to the service's internal form, and delegates to
`FormFieldConditionalLogicService::assertSpecsValid()`. Unknown
operators, root conditions, empty groups, and unknown field_slug
references produce a 422 with a readable error before any write.
`form_fields.conditional_logic` JSON column dropped. FormField model
`$fillable` and `$casts` no longer mention the column; factory default
no longer writes `null` to it. Snapshot fixtures in the dev seeder and
the legacy-forms migration command keep `conditional_logic` in their
snapshot JSON shape — that's the schema_snapshot contract, not the DB
column.
FormFieldController now maps InvalidConditionalLogicSpecException to
422 alongside FrozenSchemaException / CyclicDependencyException.
Rollback path: roll back WS-5c commits 1–3 together. Partial rollback
(drop-column reversed but backfill still applied) is not a supported
state — matching the WS-5a/b precedent on the family's full-rollback
contract.
Tests: 6 new (strict FormRequest rejection cases + JSON-column drop
assertion). Rollback step counts in WS-5a/b migration tests bumped +1
for the drop_conditional_logic_json_column migration. Baseline
1142 → 1148 green (3085 → 3099 assertions).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,8 +7,10 @@ namespace App\Http\Requests\Api\V1\FormBuilder;
|
||||
use App\Enums\FormBuilder\FormFieldDisplayWidth;
|
||||
use App\Enums\FormBuilder\FormFieldType;
|
||||
use App\Enums\FormBuilder\FormValueStorageHint;
|
||||
use App\Exceptions\FormBuilder\InvalidConditionalLogicSpecException;
|
||||
use App\Exceptions\FormBuilder\UnknownValidationRuleTypeException;
|
||||
use App\Models\FormBuilder\FormSchema;
|
||||
use App\Services\FormBuilder\FormFieldConditionalLogicService;
|
||||
use App\Services\FormBuilder\FormFieldValidationRuleService;
|
||||
use Illuminate\Contracts\Validation\Validator;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
@@ -89,6 +91,61 @@ final class StoreFormFieldRequest extends FormRequest
|
||||
$validator->errors()->add('validation_rules', $e->getMessage());
|
||||
}
|
||||
},
|
||||
function (Validator $validator): void {
|
||||
$logic = $this->input('conditional_logic');
|
||||
if ($logic === null || $logic === [] || ! is_array($logic)) {
|
||||
return;
|
||||
}
|
||||
$root = isset($logic['show_when']) && is_array($logic['show_when'])
|
||||
? $logic['show_when']
|
||||
: $logic;
|
||||
$normalised = $this->normaliseLegacyGroupShape($root);
|
||||
try {
|
||||
app(FormFieldConditionalLogicService::class)->assertSpecsValid($normalised);
|
||||
} catch (InvalidConditionalLogicSpecException $e) {
|
||||
$validator->errors()->add('conditional_logic', $e->getMessage());
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror of FormFieldService::normaliseLegacyGroupShape — translates
|
||||
* the ARCH §8 JSON group shape (`{"all": [...]}` / `{"any": [...]}`)
|
||||
* to the service's internal `{"operator", "children"}` form so the
|
||||
* boundary validator can reuse the service's canonical assertion.
|
||||
*
|
||||
* @param array<string, mixed> $node
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function normaliseLegacyGroupShape(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[] = $this->normaliseLegacyGroupShape($child);
|
||||
}
|
||||
}
|
||||
|
||||
return ['operator' => $node['operator'], 'children' => $children];
|
||||
}
|
||||
foreach (['all', 'any'] as $candidate) {
|
||||
if (isset($node[$candidate]) && is_array($node[$candidate])) {
|
||||
$children = [];
|
||||
foreach ($node[$candidate] as $child) {
|
||||
if (is_array($child)) {
|
||||
$children[] = $this->normaliseLegacyGroupShape($child);
|
||||
}
|
||||
}
|
||||
|
||||
return ['operator' => $candidate, 'children' => $children];
|
||||
}
|
||||
}
|
||||
|
||||
return $node;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,10 @@ namespace App\Http\Requests\Api\V1\FormBuilder;
|
||||
use App\Enums\FormBuilder\FormFieldDisplayWidth;
|
||||
use App\Enums\FormBuilder\FormFieldType;
|
||||
use App\Enums\FormBuilder\FormValueStorageHint;
|
||||
use App\Exceptions\FormBuilder\InvalidConditionalLogicSpecException;
|
||||
use App\Exceptions\FormBuilder\UnknownValidationRuleTypeException;
|
||||
use App\Models\FormBuilder\FormSchema;
|
||||
use App\Services\FormBuilder\FormFieldConditionalLogicService;
|
||||
use App\Services\FormBuilder\FormFieldValidationRuleService;
|
||||
use Illuminate\Contracts\Validation\Validator;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
@@ -89,6 +91,59 @@ final class UpdateFormFieldRequest extends FormRequest
|
||||
$validator->errors()->add('validation_rules', $e->getMessage());
|
||||
}
|
||||
},
|
||||
function (Validator $validator): void {
|
||||
if (! $this->has('conditional_logic')) {
|
||||
return;
|
||||
}
|
||||
$logic = $this->input('conditional_logic');
|
||||
if ($logic === null || $logic === [] || ! is_array($logic)) {
|
||||
return;
|
||||
}
|
||||
$root = isset($logic['show_when']) && is_array($logic['show_when'])
|
||||
? $logic['show_when']
|
||||
: $logic;
|
||||
$normalised = $this->normaliseLegacyGroupShape($root);
|
||||
try {
|
||||
app(FormFieldConditionalLogicService::class)->assertSpecsValid($normalised);
|
||||
} catch (InvalidConditionalLogicSpecException $e) {
|
||||
$validator->errors()->add('conditional_logic', $e->getMessage());
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $node
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function normaliseLegacyGroupShape(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[] = $this->normaliseLegacyGroupShape($child);
|
||||
}
|
||||
}
|
||||
|
||||
return ['operator' => $node['operator'], 'children' => $children];
|
||||
}
|
||||
foreach (['all', 'any'] as $candidate) {
|
||||
if (isset($node[$candidate]) && is_array($node[$candidate])) {
|
||||
$children = [];
|
||||
foreach ($node[$candidate] as $child) {
|
||||
if (is_array($child)) {
|
||||
$children[] = $this->normaliseLegacyGroupShape($child);
|
||||
}
|
||||
}
|
||||
|
||||
return ['operator' => $candidate, 'children' => $children];
|
||||
}
|
||||
}
|
||||
|
||||
return $node;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user