326 lines
12 KiB
PHP
326 lines
12 KiB
PHP
<?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
|
|
{
|
|
$this->assertSpecsValid($specs);
|
|
|
|
$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,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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';
|
|
}
|
|
|
|
/** @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}.",
|
|
);
|
|
}
|
|
}
|
|
}
|