feat(form-builder): FormFieldValidationRuleService + legacy backfill + snapshot + library row-copy

This commit is contained in:
2026-04-24 22:12:08 +02:00
parent fedaed1b32
commit 800b1b6c01
16 changed files with 1430 additions and 18 deletions

View File

@@ -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');

View 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}.",
);
}
}
}

View File

@@ -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,