refactor(form-builder): strict validator on save; strip rules.unique fallback
This commit is contained in:
@@ -27,6 +27,10 @@ use App\Models\FormBuilder\FormSchema;
|
||||
*/
|
||||
final class FormFieldRuleBuilder
|
||||
{
|
||||
public function __construct(
|
||||
private readonly FormFieldValidationRuleService $validationRuleService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
@@ -180,26 +184,36 @@ final class FormFieldRuleBuilder
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortcuts picked up from form_fields.validation_rules JSON.
|
||||
* Shortcuts picked up from the relational validation-rules table.
|
||||
* Service-layer FormValueService does the deeper min/max/regex/unique
|
||||
* enforcement — these are quick boundary checks surfaced at the
|
||||
* Request layer when cheap.
|
||||
*
|
||||
* Laravel's `min:N` / `max:N` already do the right thing for both
|
||||
* numeric inputs (value comparison) and strings (length check), so
|
||||
* `min_length`/`min_value` both emit the same `min:N` rule.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function validationRuleShortcuts(FormField $field): array
|
||||
{
|
||||
$rules = [];
|
||||
$v = $field->validation_rules ?? null;
|
||||
$v = $this->validationRuleService->toJsonShape($field->validationRules);
|
||||
if (! is_array($v)) {
|
||||
return $rules;
|
||||
}
|
||||
|
||||
if (isset($v['min']) && is_numeric($v['min'])) {
|
||||
$rules[] = 'min:'.(string) $v['min'];
|
||||
if (isset($v['min_value']) && is_numeric($v['min_value'])) {
|
||||
$rules[] = 'min:'.(string) $v['min_value'];
|
||||
}
|
||||
if (isset($v['max']) && is_numeric($v['max'])) {
|
||||
$rules[] = 'max:'.(string) $v['max'];
|
||||
if (isset($v['max_value']) && is_numeric($v['max_value'])) {
|
||||
$rules[] = 'max:'.(string) $v['max_value'];
|
||||
}
|
||||
if (isset($v['min_length']) && is_numeric($v['min_length'])) {
|
||||
$rules[] = 'min:'.(string) $v['min_length'];
|
||||
}
|
||||
if (isset($v['max_length']) && is_numeric($v['max_length'])) {
|
||||
$rules[] = 'max:'.(string) $v['max_length'];
|
||||
}
|
||||
if (isset($v['regex']) && is_string($v['regex'])) {
|
||||
$rules[] = 'regex:'.$v['regex'];
|
||||
|
||||
@@ -38,6 +38,7 @@ final class FormFieldService
|
||||
$data['sort_order'] ??= $this->nextSortOrder($schema);
|
||||
|
||||
$bindingSpec = $this->extractBindingSpec($data);
|
||||
$validationRuleSpecs = $this->extractValidationRuleSpecs($data);
|
||||
|
||||
$this->assertNoConditionalCycle($schema, null, $data['conditional_logic'] ?? null, $data['slug'] ?? null);
|
||||
|
||||
@@ -48,6 +49,10 @@ final class FormFieldService
|
||||
$this->bindingService->replaceBindings($field, [$bindingSpec]);
|
||||
}
|
||||
|
||||
if ($validationRuleSpecs !== null) {
|
||||
$this->validationRuleService->replaceRules($field, $validationRuleSpecs);
|
||||
}
|
||||
|
||||
$this->schemaService->bumpVersion($schema);
|
||||
$field->logFieldChange('field.created');
|
||||
|
||||
@@ -67,6 +72,9 @@ final class FormFieldService
|
||||
$rawBinding = $bindingProvided ? $data['binding'] : null;
|
||||
$bindingSpec = $bindingProvided ? $this->extractBindingSpec($data) : null;
|
||||
|
||||
$validationRulesProvided = array_key_exists('validation_rules', $data);
|
||||
$validationRuleSpecs = $validationRulesProvided ? $this->extractValidationRuleSpecs($data) : null;
|
||||
|
||||
$currentBindingShape = $this->bindingService->toJsonShape($field->bindings()->first());
|
||||
|
||||
if ($bindingProvided && $this->bindingChanged($currentBindingShape, $rawBinding)) {
|
||||
@@ -91,6 +99,10 @@ final class FormFieldService
|
||||
$this->bindingService->replaceBindings($field, $bindingSpec === null ? [] : [$bindingSpec]);
|
||||
}
|
||||
|
||||
if ($validationRulesProvided) {
|
||||
$this->validationRuleService->replaceRules($field, $validationRuleSpecs ?? []);
|
||||
}
|
||||
|
||||
$this->schemaService->bumpVersion($schema);
|
||||
|
||||
$field->logFieldChange('field.updated', [
|
||||
@@ -110,6 +122,36 @@ final class FormFieldService
|
||||
return $field->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the `validation_rules` key from the request data array and
|
||||
* return it as the service-layer spec list. The JSON column is no
|
||||
* longer written (WS-5b commit 3) — writes go through
|
||||
* `FormFieldValidationRuleService::replaceRules` after the FormField
|
||||
* row is created/updated.
|
||||
*
|
||||
* Returns `null` when the key was absent (no change requested), or an
|
||||
* empty list when the caller explicitly cleared rules. Callers
|
||||
* distinguish via `array_key_exists('validation_rules', $data)`
|
||||
* BEFORE invoking this helper.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
* @return list<array<string, mixed>>|null
|
||||
*/
|
||||
private function extractValidationRuleSpecs(array &$data): ?array
|
||||
{
|
||||
if (! array_key_exists('validation_rules', $data)) {
|
||||
return null;
|
||||
}
|
||||
$raw = $data['validation_rules'];
|
||||
unset($data['validation_rules']);
|
||||
if (! is_array($raw)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
/** @var list<array<string, mixed>> $raw */
|
||||
return array_values($raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array{target_entity:string,target_attribute:string,mode:string,sync_direction?:?string}|null
|
||||
|
||||
@@ -53,9 +53,7 @@ final class FormFieldValidationRuleService
|
||||
*/
|
||||
public function replaceRules(FormField|FormFieldLibrary $owner, array $specs): void
|
||||
{
|
||||
foreach ($specs as $spec) {
|
||||
$this->assertSpecValid($spec);
|
||||
}
|
||||
$this->assertSpecsValid($specs);
|
||||
|
||||
$ownerType = $this->ownerTypeFor($owner);
|
||||
|
||||
@@ -182,6 +180,21 @@ final class FormFieldValidationRuleService
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Public wrapper around `assertSpecValid` — iterates a caller-supplied
|
||||
* list and throws the first `UnknownValidationRuleTypeException`.
|
||||
* Used by FormRequests (WS-5b commit 3 strict validator on save) to
|
||||
* reject bad specs at the HTTP boundary before any write lands.
|
||||
*
|
||||
* @param list<array<string, mixed>> $specs
|
||||
*/
|
||||
public function assertSpecsValid(array $specs): void
|
||||
{
|
||||
foreach ($specs as $spec) {
|
||||
$this->assertSpecValid($spec);
|
||||
}
|
||||
}
|
||||
|
||||
private function ownerTypeFor(FormField|FormFieldLibrary $owner): string
|
||||
{
|
||||
return $owner instanceof FormField ? 'form_field' : 'form_field_library';
|
||||
|
||||
@@ -26,6 +26,7 @@ final class FormValueService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly FieldAccessService $fieldAccess,
|
||||
private readonly FormFieldValidationRuleService $validationRuleService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -76,17 +77,23 @@ final class FormValueService
|
||||
}
|
||||
|
||||
/**
|
||||
* Backstop enforcement of form_fields.validation_rules JSON per
|
||||
* S2c D8. The FormFieldRuleBuilder already surfaces min/max/regex
|
||||
* shortcuts at the request layer; this is where the deeper checks
|
||||
* (is_unique, validation_rules.unique) live.
|
||||
* Backstop enforcement of per-field validation rules. Rules are sourced
|
||||
* from the relational `form_field_validation_rules` table via
|
||||
* `FormFieldValidationRuleService::toJsonShape()`, which returns the
|
||||
* canonical flat bag: `min_value`/`max_value` for numeric fields,
|
||||
* `min_length`/`max_length` for strings, `regex` for pattern checks.
|
||||
* The pre-WS-5b ambiguous `min`/`max` keys no longer exist.
|
||||
*
|
||||
* Uniqueness: `is_unique` column is the single source of truth (WS-5b
|
||||
* consolidation — the legacy `validation_rules.unique` JSON fallback
|
||||
* was removed in commit 3).
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function validateAgainstFieldRules(FormField $field, mixed $raw, FormSubmission $submission): array
|
||||
{
|
||||
$errors = [];
|
||||
$rules = is_array($field->validation_rules) ? $field->validation_rules : [];
|
||||
$rules = $this->validationRuleService->toJsonShape($field->validationRules) ?? [];
|
||||
|
||||
if ($field->field_type === FormFieldType::SECTION_PRIORITY->value) {
|
||||
$shapeErrors = $this->validateSectionPriorityShape($raw, $submission);
|
||||
@@ -99,19 +106,24 @@ final class FormValueService
|
||||
return $errors;
|
||||
}
|
||||
|
||||
if (isset($rules['min']) && is_numeric($rules['min']) && is_numeric($raw) && (float) $raw < (float) $rules['min']) {
|
||||
$errors[] = sprintf('Minimum is %s.', (string) $rules['min']);
|
||||
if (isset($rules['min_value']) && is_numeric($rules['min_value']) && is_numeric($raw) && (float) $raw < (float) $rules['min_value']) {
|
||||
$errors[] = sprintf('Minimum is %s.', (string) $rules['min_value']);
|
||||
}
|
||||
if (isset($rules['max']) && is_numeric($rules['max']) && is_numeric($raw) && (float) $raw > (float) $rules['max']) {
|
||||
$errors[] = sprintf('Maximum is %s.', (string) $rules['max']);
|
||||
if (isset($rules['max_value']) && is_numeric($rules['max_value']) && is_numeric($raw) && (float) $raw > (float) $rules['max_value']) {
|
||||
$errors[] = sprintf('Maximum is %s.', (string) $rules['max_value']);
|
||||
}
|
||||
if (isset($rules['min_length']) && is_numeric($rules['min_length']) && is_string($raw) && mb_strlen($raw) < (int) $rules['min_length']) {
|
||||
$errors[] = sprintf('Minimaal %d tekens.', (int) $rules['min_length']);
|
||||
}
|
||||
if (isset($rules['max_length']) && is_numeric($rules['max_length']) && is_string($raw) && mb_strlen($raw) > (int) $rules['max_length']) {
|
||||
$errors[] = sprintf('Maximaal %d tekens.', (int) $rules['max_length']);
|
||||
}
|
||||
if (isset($rules['regex']) && is_string($rules['regex']) && is_string($raw)
|
||||
&& @preg_match($rules['regex'], $raw) !== 1) {
|
||||
$errors[] = 'Value does not match the expected format.';
|
||||
}
|
||||
|
||||
$unique = (bool) $field->is_unique || (bool) ($rules['unique'] ?? false);
|
||||
if ($unique) {
|
||||
if ($field->is_unique) {
|
||||
$scalar = is_scalar($raw) ? (string) $raw : null;
|
||||
if ($scalar !== null) {
|
||||
$exists = \App\Models\FormBuilder\FormValue::query()
|
||||
|
||||
Reference in New Issue
Block a user