feat(form-builder): FormFieldValidationRuleService + legacy backfill + snapshot + library row-copy
This commit is contained in:
@@ -27,6 +27,7 @@ final class FormFieldService
|
||||
public function __construct(
|
||||
private readonly FormSchemaService $schemaService,
|
||||
private readonly FormFieldBindingService $bindingService,
|
||||
private readonly FormFieldValidationRuleService $validationRuleService,
|
||||
) {}
|
||||
|
||||
public function create(FormSchema $schema, array $data): FormField
|
||||
@@ -216,6 +217,7 @@ final class FormFieldService
|
||||
$field = FormField::create($data);
|
||||
|
||||
$this->bindingService->copyBindings($library, $field);
|
||||
$this->validationRuleService->copyRules($library, $field);
|
||||
|
||||
FormFieldLibrary::query()->whereKey($library->id)->increment('usage_count');
|
||||
|
||||
|
||||
312
api/app/Services/FormBuilder/FormFieldValidationRuleService.php
Normal file
312
api/app/Services/FormBuilder/FormFieldValidationRuleService.php
Normal file
@@ -0,0 +1,312 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\FormBuilder;
|
||||
|
||||
use App\Enums\FormBuilder\FormFieldValidationRuleType;
|
||||
use App\Exceptions\FormBuilder\UnknownValidationRuleTypeException;
|
||||
use App\Models\FormBuilder\FormField;
|
||||
use App\Models\FormBuilder\FormFieldLibrary;
|
||||
use App\Models\FormBuilder\FormFieldValidationRule;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Owns all writes to `form_field_validation_rules`. Single source of truth
|
||||
* for:
|
||||
*
|
||||
* - rule-type enum + per-rule-type parameter shape enforcement
|
||||
* - callback-key validation against `config('form_builder.validation_callbacks')`
|
||||
* (ARCH-FORM-BUILDER §17.4)
|
||||
* - library → field row-copy on insertFromLibrary (addendum Q3 row-copy mandate)
|
||||
* - serialisation of rows into the legacy-format flat JSON bag consumed
|
||||
* by the snapshot writer and API resources
|
||||
*
|
||||
* Pattern: one row per (owner, rule_type). An empty specs array on
|
||||
* `replaceRules()` clears all rules for the owner.
|
||||
*
|
||||
* Activity log convention — matches WS-5a: emit
|
||||
* `field.validation_rules_replaced` on the parent `FormField` only, not on
|
||||
* `FormFieldLibrary`. Library-level audits live elsewhere.
|
||||
*/
|
||||
final class FormFieldValidationRuleService
|
||||
{
|
||||
/**
|
||||
* @return Collection<int, FormFieldValidationRule>
|
||||
*/
|
||||
public function rulesFor(FormField|FormFieldLibrary $owner): Collection
|
||||
{
|
||||
$type = $this->ownerTypeFor($owner);
|
||||
|
||||
return FormFieldValidationRule::query()
|
||||
->where('owner_type', $type)
|
||||
->where('owner_id', $owner->getKey())
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the full rule set on an owner transactionally. Validates every
|
||||
* spec's rule_type + parameter shape before any write lands.
|
||||
*
|
||||
* @param list<array{rule_type:string,parameters?:array<string,mixed>,error_message_key?:?string}> $specs
|
||||
*/
|
||||
public function replaceRules(FormField|FormFieldLibrary $owner, array $specs): void
|
||||
{
|
||||
foreach ($specs as $spec) {
|
||||
$this->assertSpecValid($spec);
|
||||
}
|
||||
|
||||
$ownerType = $this->ownerTypeFor($owner);
|
||||
|
||||
DB::transaction(function () use ($owner, $ownerType, $specs): void {
|
||||
FormFieldValidationRule::query()
|
||||
->withoutGlobalScopes()
|
||||
->where('owner_type', $ownerType)
|
||||
->where('owner_id', $owner->getKey())
|
||||
->delete();
|
||||
|
||||
foreach ($specs as $spec) {
|
||||
FormFieldValidationRule::query()->withoutGlobalScopes()->create([
|
||||
'owner_type' => $ownerType,
|
||||
'owner_id' => $owner->getKey(),
|
||||
'rule_type' => $spec['rule_type'],
|
||||
'parameters' => $spec['parameters'] ?? [],
|
||||
'error_message_key' => $spec['error_message_key'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($owner instanceof FormField) {
|
||||
$owner->logFieldChange('field.validation_rules_replaced', [
|
||||
'count' => count($specs),
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Row-copy from a library entry to a freshly-inserted field (addendum
|
||||
* Q3 row-copy mandate). Every column is preserved; only `owner_type` /
|
||||
* `owner_id` change.
|
||||
*/
|
||||
public function copyRules(FormFieldLibrary $from, FormField $to): void
|
||||
{
|
||||
$rules = $this->rulesFor($from);
|
||||
|
||||
if ($rules->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($rules, $to): void {
|
||||
foreach ($rules as $rule) {
|
||||
FormFieldValidationRule::query()->withoutGlobalScopes()->create([
|
||||
'owner_type' => 'form_field',
|
||||
'owner_id' => $to->id,
|
||||
'rule_type' => $rule->rule_type instanceof FormFieldValidationRuleType
|
||||
? $rule->rule_type->value
|
||||
: (string) $rule->rule_type,
|
||||
'parameters' => (array) $rule->parameters,
|
||||
'error_message_key' => $rule->error_message_key,
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialise a rule collection to the flat JSON bag shape consumed by
|
||||
* snapshot writer and API resources. Returns null on empty — matches
|
||||
* pre-WS-5b `validation_rules: null`.
|
||||
*
|
||||
* Shape uses the canonical rule_type keys (new post-WS-5b names — the
|
||||
* legacy ambiguous `min` / `max` were renamed at backfill to
|
||||
* `min_length`/`min_value` etc., and `max_priorities` canonicalised
|
||||
* to `max_selected`).
|
||||
*
|
||||
* @param Collection<int, FormFieldValidationRule> $rules
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function toJsonShape(Collection $rules): ?array
|
||||
{
|
||||
if ($rules->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($rules as $rule) {
|
||||
$type = $rule->rule_type instanceof FormFieldValidationRuleType
|
||||
? $rule->rule_type->value
|
||||
: (string) $rule->rule_type;
|
||||
$params = (array) $rule->parameters;
|
||||
|
||||
$out[$type] = $this->flattenParameters($type, $params);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical flat rendering per rule_type — mirrors the pre-WS-5b bag
|
||||
* shape convention (scalar value for single-value rules, array for
|
||||
* collections, boolean `true` for no-param markers).
|
||||
*
|
||||
* @param array<string, mixed> $params
|
||||
*/
|
||||
private function flattenParameters(string $ruleType, array $params): mixed
|
||||
{
|
||||
return match ($ruleType) {
|
||||
FormFieldValidationRuleType::MinLength->value,
|
||||
FormFieldValidationRuleType::MaxLength->value,
|
||||
FormFieldValidationRuleType::MinValue->value,
|
||||
FormFieldValidationRuleType::MaxValue->value,
|
||||
FormFieldValidationRuleType::MinSelected->value,
|
||||
FormFieldValidationRuleType::MaxSelected->value => $params['value'] ?? null,
|
||||
|
||||
FormFieldValidationRuleType::MaxFileSize->value => $params['bytes'] ?? null,
|
||||
|
||||
FormFieldValidationRuleType::Regex->value => isset($params['flags']) && $params['flags'] !== ''
|
||||
? ['pattern' => $params['pattern'] ?? '', 'flags' => $params['flags']]
|
||||
: ($params['pattern'] ?? ''),
|
||||
|
||||
FormFieldValidationRuleType::AllowedMimeTypes->value => $params['mime_types'] ?? [],
|
||||
|
||||
FormFieldValidationRuleType::DateMin->value,
|
||||
FormFieldValidationRuleType::DateMax->value => $params['date'] ?? null,
|
||||
|
||||
FormFieldValidationRuleType::Callback->value => $params['key'] ?? '',
|
||||
|
||||
FormFieldValidationRuleType::EmailFormat->value,
|
||||
FormFieldValidationRuleType::UrlFormat->value,
|
||||
FormFieldValidationRuleType::PhoneE164->value => true,
|
||||
|
||||
default => $params,
|
||||
};
|
||||
}
|
||||
|
||||
private function ownerTypeFor(FormField|FormFieldLibrary $owner): string
|
||||
{
|
||||
return $owner instanceof FormField ? 'form_field' : 'form_field_library';
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $spec */
|
||||
private function assertSpecValid(array $spec): void
|
||||
{
|
||||
$ruleTypeRaw = (string) ($spec['rule_type'] ?? '');
|
||||
$enum = FormFieldValidationRuleType::tryFrom($ruleTypeRaw);
|
||||
if ($enum === null) {
|
||||
throw new UnknownValidationRuleTypeException(
|
||||
"Validation rule_type '{$ruleTypeRaw}' is not a registered "
|
||||
.'FormFieldValidationRuleType case.',
|
||||
);
|
||||
}
|
||||
|
||||
$params = (array) ($spec['parameters'] ?? []);
|
||||
$this->assertParametersMatchShape($enum, $params);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $params */
|
||||
private function assertParametersMatchShape(FormFieldValidationRuleType $type, array $params): void
|
||||
{
|
||||
switch ($type) {
|
||||
case FormFieldValidationRuleType::MinLength:
|
||||
case FormFieldValidationRuleType::MaxLength:
|
||||
case FormFieldValidationRuleType::MinSelected:
|
||||
case FormFieldValidationRuleType::MaxSelected:
|
||||
$this->requireInt($type, $params, 'value');
|
||||
|
||||
return;
|
||||
|
||||
case FormFieldValidationRuleType::MinValue:
|
||||
case FormFieldValidationRuleType::MaxValue:
|
||||
$this->requireNumeric($type, $params, 'value');
|
||||
|
||||
return;
|
||||
|
||||
case FormFieldValidationRuleType::MaxFileSize:
|
||||
$this->requireInt($type, $params, 'bytes');
|
||||
|
||||
return;
|
||||
|
||||
case FormFieldValidationRuleType::Regex:
|
||||
$this->requireString($type, $params, 'pattern');
|
||||
|
||||
return;
|
||||
|
||||
case FormFieldValidationRuleType::AllowedMimeTypes:
|
||||
if (! isset($params['mime_types']) || ! is_array($params['mime_types'])) {
|
||||
throw new UnknownValidationRuleTypeException(
|
||||
"Validation rule '{$type->value}' requires parameters.mime_types (array of strings).",
|
||||
);
|
||||
}
|
||||
foreach ($params['mime_types'] as $mime) {
|
||||
if (! is_string($mime) || $mime === '') {
|
||||
throw new UnknownValidationRuleTypeException(
|
||||
"Validation rule '{$type->value}' parameters.mime_types must be non-empty strings.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
|
||||
case FormFieldValidationRuleType::DateMin:
|
||||
case FormFieldValidationRuleType::DateMax:
|
||||
$this->requireString($type, $params, 'date');
|
||||
|
||||
return;
|
||||
|
||||
case FormFieldValidationRuleType::Callback:
|
||||
$this->requireString($type, $params, 'key');
|
||||
$registered = (array) config('form_builder.validation_callbacks', []);
|
||||
if (! array_key_exists($params['key'], $registered)) {
|
||||
throw new UnknownValidationRuleTypeException(
|
||||
"Validation callback '{$params['key']}' is not registered in "
|
||||
.'config/form_builder.php under `validation_callbacks`.',
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
|
||||
case FormFieldValidationRuleType::EmailFormat:
|
||||
case FormFieldValidationRuleType::UrlFormat:
|
||||
case FormFieldValidationRuleType::PhoneE164:
|
||||
// Boolean markers — parameters must be empty or absent. Any
|
||||
// unexpected keys signal a caller bug; reject loudly.
|
||||
if ($params !== []) {
|
||||
throw new UnknownValidationRuleTypeException(
|
||||
"Validation rule '{$type->value}' takes no parameters; got ".count($params).'.',
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $params */
|
||||
private function requireInt(FormFieldValidationRuleType $type, array $params, string $key): void
|
||||
{
|
||||
if (! isset($params[$key]) || ! is_int($params[$key])) {
|
||||
throw new UnknownValidationRuleTypeException(
|
||||
"Validation rule '{$type->value}' requires integer parameters.{$key}.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $params */
|
||||
private function requireNumeric(FormFieldValidationRuleType $type, array $params, string $key): void
|
||||
{
|
||||
if (! isset($params[$key]) || ! is_numeric($params[$key])) {
|
||||
throw new UnknownValidationRuleTypeException(
|
||||
"Validation rule '{$type->value}' requires numeric parameters.{$key}.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $params */
|
||||
private function requireString(FormFieldValidationRuleType $type, array $params, string $key): void
|
||||
{
|
||||
if (! isset($params[$key]) || ! is_string($params[$key]) || $params[$key] === '') {
|
||||
throw new UnknownValidationRuleTypeException(
|
||||
"Validation rule '{$type->value}' requires non-empty string parameters.{$key}.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ final class FormSubmissionService
|
||||
private readonly FormLocaleResolver $localeResolver,
|
||||
private readonly FormValueService $valueService,
|
||||
private readonly FormFieldBindingService $bindingService,
|
||||
private readonly FormFieldValidationRuleService $validationRuleService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -200,7 +201,7 @@ final class FormSubmissionService
|
||||
*/
|
||||
private function buildSnapshot(FormSchema $schema): array
|
||||
{
|
||||
$schema->loadMissing(['fields.bindings', 'sections']);
|
||||
$schema->loadMissing(['fields.bindings', 'fields.validationRules', 'sections']);
|
||||
|
||||
return [
|
||||
'schema_version' => $schema->version,
|
||||
@@ -232,7 +233,7 @@ final class FormSubmissionService
|
||||
'help_text' => $f->help_text,
|
||||
'section_slug' => $this->sectionSlug($schema, $f->form_schema_section_id),
|
||||
'options' => $f->options,
|
||||
'validation_rules' => $f->validation_rules,
|
||||
'validation_rules' => $this->validationRuleService->toJsonShape($f->validationRules),
|
||||
'is_required' => (bool) $f->is_required,
|
||||
'is_filterable' => (bool) $f->is_filterable,
|
||||
'is_pii' => (bool) $f->is_pii,
|
||||
|
||||
Reference in New Issue
Block a user