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

@@ -0,0 +1,342 @@
<?php
declare(strict_types=1);
use App\Enums\FormBuilder\FormFieldType;
use App\Enums\FormBuilder\FormFieldValidationRuleType;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
/**
* WS-5b commit 2 of 5 translates pre-WS-5b validation-rules JSON on
* `form_fields` and `form_field_library` into rows in the new relational
* `form_field_validation_rules` table. Per addendum Q3 strict-enterprise
* decisions:
*
* - `required` keys: WARN-log and skip. `is_required` column is the
* single source of truth (pre-existing convention).
* - `unique` keys: WARN-log and skip. `is_unique` column is the
* single source of truth (WS-5b consolidation decision).
* - `tag_categories` / `storage_disk`: skipped with a log line here
* commit 5's backfill migration picks them up into the separate
* `form_field_configs` table (ARCH §17.5).
* - `max_priorities`: canonicalised to rule_type `max_selected`.
* Both keys share the same semantic (cap on entries in a list-valued
* field); two enum cases would be rot. The seeder and assertions
* are updated in parallel to use the canonical name directly.
* - `min` / `max`: ambiguous legacy keys. Field-type-driven dispatch:
* NUMBER min_value / max_value
* TEXT/TEXTAREA/EMAIL/PHONE/URL min_length / max_length
* DATE/DATETIME date_min / date_max
* anything else FAIL the migration.
* Strict FAIL on unmapped types: type-inappropriate uses of `min` /
* `max` are seed-data bugs. Force correction, don't absorb silently.
* - Unknown top-level keys: FAIL the migration. Phase A seed-scan
* should have caught these; if one slipped through we want the
* crash, not the skip.
*
* Transactional. Rollback reconstructs the JSON bag on both source
* tables using the canonical rule_type names (post-rename); it does
* NOT resurrect `required` / `unique` / `tag_categories` / `storage_disk`
* those keys never landed in the relational table and are unreachable
* from this migration's perspective. The forward+back pair is safe when
* run as a unit; a partial "rollback this migration but not its
* create-table sibling" state is not supported.
*/
return new class extends Migration
{
public function up(): void
{
DB::transaction(function (): void {
$this->backfill('form_fields', 'form_field');
$this->backfill('form_field_library', 'form_field_library');
});
}
public function down(): void
{
if (! Schema::hasTable('form_field_validation_rules')) {
return;
}
DB::transaction(function (): void {
$this->reconstructJson('form_fields', 'form_field');
$this->reconstructJson('form_field_library', 'form_field_library');
});
}
private function backfill(string $table, string $ownerType): void
{
if (! Schema::hasTable($table) || ! Schema::hasColumn($table, 'validation_rules')) {
return;
}
$rows = DB::table($table)
->whereNotNull('validation_rules')
->orderBy('id')
->get(['id', 'field_type', 'validation_rules']);
if ($rows->isEmpty()) {
return;
}
$now = now();
$inserts = [];
foreach ($rows as $row) {
$raw = $row->validation_rules;
$decoded = is_string($raw) ? json_decode($raw, true) : $raw;
if (! is_array($decoded) || $decoded === []) {
continue;
}
$fieldType = (string) ($row->field_type ?? '');
foreach ($decoded as $key => $value) {
$translated = $this->translateKey($ownerType, $row->id, $fieldType, (string) $key, $value);
if ($translated === null) {
continue;
}
$inserts[] = [
'id' => (string) Str::ulid(),
'owner_type' => $ownerType,
'owner_id' => (string) $row->id,
'rule_type' => $translated['rule_type'],
'parameters' => json_encode($translated['parameters']),
'error_message_key' => null,
'created_at' => $now,
'updated_at' => $now,
];
}
}
if ($inserts === []) {
return;
}
foreach (array_chunk($inserts, 500) as $batch) {
DB::table('form_field_validation_rules')->insert($batch);
}
}
/**
* @return array{rule_type:string, parameters:array<string,mixed>}|null
*/
private function translateKey(
string $ownerType,
string $ownerId,
string $fieldType,
string $key,
mixed $value,
): ?array {
// Column-owned keys: not migrated. WARN-log defensively; these
// should not appear in the wild per Phase A seed scan.
if ($key === 'required' || $key === 'unique') {
Log::warning(sprintf(
'form_field_validation_rules backfill: skipping legacy `%s` key on %s/%s — '
.'column is-%s is the source of truth.',
$key, $ownerType, $ownerId, $key,
));
return null;
}
// Configs keys: handled by commit 5's configs-backfill migration.
if ($key === 'tag_categories' || $key === 'storage_disk') {
Log::info(sprintf(
'form_field_validation_rules backfill: skipping `%s` key on %s/%s — '
.'will be picked up by the form_field_configs backfill (WS-5b commit 5).',
$key, $ownerType, $ownerId,
));
return null;
}
// Canonicalisations.
if ($key === 'max_priorities') {
return [
'rule_type' => FormFieldValidationRuleType::MaxSelected->value,
'parameters' => ['value' => (int) $value],
];
}
// Ambiguous legacy min/max → field-type-driven dispatch.
if ($key === 'min' || $key === 'max') {
return $this->dispatchMinMax($ownerType, $ownerId, $fieldType, $key, $value);
}
// Catalogue-enumerated keys: direct mapping with parameter wrapping.
$enum = FormFieldValidationRuleType::tryFrom($key);
if ($enum === null) {
throw new \RuntimeException(sprintf(
'form_field_validation_rules backfill: unknown validation rule key `%s` on %s/%s. '
.'Phase A seed-scan did not surface this; inspect the data and either register '
.'the key in the FormFieldValidationRuleType enum or clean the source row.',
$key, $ownerType, $ownerId,
));
}
return [
'rule_type' => $enum->value,
'parameters' => $this->wrapParameters($enum, $value),
];
}
/**
* @return array{rule_type:string, parameters:array<string,mixed>}
*/
private function dispatchMinMax(
string $ownerType,
string $ownerId,
string $fieldType,
string $key,
mixed $value,
): array {
$isNumeric = in_array($fieldType, [FormFieldType::NUMBER->value], true);
$isString = in_array($fieldType, [
FormFieldType::TEXT->value,
FormFieldType::TEXTAREA->value,
FormFieldType::EMAIL->value,
FormFieldType::PHONE->value,
FormFieldType::URL->value,
], true);
$isDate = in_array($fieldType, [
FormFieldType::DATE->value,
FormFieldType::DATETIME->value,
], true);
if ($isNumeric) {
return [
'rule_type' => $key === 'min'
? FormFieldValidationRuleType::MinValue->value
: FormFieldValidationRuleType::MaxValue->value,
'parameters' => ['value' => is_numeric($value) ? $value + 0 : $value],
];
}
if ($isString) {
return [
'rule_type' => $key === 'min'
? FormFieldValidationRuleType::MinLength->value
: FormFieldValidationRuleType::MaxLength->value,
'parameters' => ['value' => (int) $value],
];
}
if ($isDate) {
return [
'rule_type' => $key === 'min'
? FormFieldValidationRuleType::DateMin->value
: FormFieldValidationRuleType::DateMax->value,
'parameters' => ['date' => (string) $value],
];
}
throw new \RuntimeException(sprintf(
'form_field_validation_rules backfill: cannot dispatch legacy `%s` key on %s/%s with '
.'field_type `%s`. Only NUMBER, TEXT/TEXTAREA/EMAIL/PHONE/URL, DATE/DATETIME are '
.'mapped; inspect the source data.',
$key, $ownerType, $ownerId, $fieldType,
));
}
/**
* @return array<string, mixed>
*/
private function wrapParameters(FormFieldValidationRuleType $type, mixed $value): array
{
return match ($type) {
FormFieldValidationRuleType::MinLength,
FormFieldValidationRuleType::MaxLength,
FormFieldValidationRuleType::MinSelected,
FormFieldValidationRuleType::MaxSelected => ['value' => (int) $value],
FormFieldValidationRuleType::MinValue,
FormFieldValidationRuleType::MaxValue => ['value' => is_numeric($value) ? $value + 0 : $value],
FormFieldValidationRuleType::MaxFileSize => ['bytes' => (int) $value],
FormFieldValidationRuleType::Regex => is_array($value)
? array_filter([
'pattern' => (string) ($value['pattern'] ?? ''),
'flags' => (string) ($value['flags'] ?? ''),
], static fn ($v): bool => $v !== '')
: ['pattern' => (string) $value],
FormFieldValidationRuleType::AllowedMimeTypes => [
'mime_types' => is_array($value)
? array_values(array_map(static fn ($m): string => (string) $m, $value))
: [],
],
FormFieldValidationRuleType::DateMin,
FormFieldValidationRuleType::DateMax => ['date' => (string) $value],
FormFieldValidationRuleType::Callback => ['key' => (string) $value],
FormFieldValidationRuleType::EmailFormat,
FormFieldValidationRuleType::UrlFormat,
FormFieldValidationRuleType::PhoneE164 => [],
};
}
private function reconstructJson(string $table, string $ownerType): void
{
if (! Schema::hasTable($table) || ! Schema::hasColumn($table, 'validation_rules')) {
return;
}
$rows = DB::table('form_field_validation_rules')
->where('owner_type', $ownerType)
->orderBy('owner_id')
->get();
if ($rows->isEmpty()) {
return;
}
$grouped = [];
foreach ($rows as $row) {
$ownerId = (string) $row->owner_id;
$grouped[$ownerId] ??= [];
$params = json_decode((string) $row->parameters, true);
$params = is_array($params) ? $params : [];
$grouped[$ownerId][(string) $row->rule_type] = $this->flattenForJson((string) $row->rule_type, $params);
}
foreach ($grouped as $ownerId => $bag) {
DB::table($table)
->where('id', $ownerId)
->update(['validation_rules' => json_encode($bag)]);
}
}
/** @param array<string, mixed> $params */
private function flattenForJson(string $ruleType, array $params): mixed
{
return match ($ruleType) {
'min_length', 'max_length', 'min_value', 'max_value',
'min_selected', 'max_selected' => $params['value'] ?? null,
'max_file_size' => $params['bytes'] ?? null,
'regex' => isset($params['flags']) && $params['flags'] !== ''
? ['pattern' => $params['pattern'] ?? '', 'flags' => $params['flags']]
: ($params['pattern'] ?? ''),
'allowed_mime_types' => $params['mime_types'] ?? [],
'date_min', 'date_max' => $params['date'] ?? null,
'callback' => $params['key'] ?? '',
'email_format', 'url_format', 'phone_e164' => true,
default => $params,
};
}
};