feat(form-builder): FormFieldValidationRuleService + legacy backfill + snapshot + library row-copy
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user