feat(form-builder): form_field_configs relational table + non-validation key split + drop validation_rules JSON columns

This commit is contained in:
2026-04-24 22:42:35 +02:00
parent 9d2758a42c
commit d494478c08
31 changed files with 1233 additions and 60 deletions

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* WS-5b commit 5 of 5 parallel relational table to
* `form_field_validation_rules` that holds non-validation field
* configuration (tag_categories, storage_disk). Same polymorphic
* owner pattern; semantically distinct concern (ARCH-FORM-BUILDER
* §17.5; addendum Q3 WS-5b Uitvoering).
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('form_field_configs', function (Blueprint $table) {
$table->ulid('id')->primary();
$table->string('owner_type', 40);
$table->ulid('owner_id');
$table->string('config_type', 40);
$table->json('parameters');
$table->timestamps();
$table->unique(
['owner_type', 'owner_id', 'config_type'],
'ffc_owner_config_unique',
);
$table->index('config_type', 'ffc_config_idx');
$table->index(['owner_type', 'owner_id'], 'ffc_owner_idx');
});
}
public function down(): void
{
Schema::dropIfExists('form_field_configs');
}
};

View File

@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
use App\Enums\FormBuilder\FormFieldConfigType;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
/**
* WS-5b commit 5 translates the non-validation keys that WS-5b
* commit 2's backfill deliberately skipped (`tag_categories`,
* `storage_disk`) into rows in `form_field_configs`.
*
* Reads from the pre-drop JSON columns (`form_fields.validation_rules`,
* `form_field_library.validation_rules`). The sibling drop-migration
* (`2026_04_25_120002`) runs after this one; rolling back WS-5b
* commits 15 as a unit reconstructs source JSON on the source tables
* using canonical keys before the table is dropped.
*/
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_configs')) {
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', 'validation_rules']);
if ($rows->isEmpty()) {
return;
}
$now = now();
$inserts = [];
foreach ($rows as $row) {
$decoded = is_string($row->validation_rules)
? json_decode((string) $row->validation_rules, true)
: $row->validation_rules;
if (! is_array($decoded) || $decoded === []) {
continue;
}
if (isset($decoded['tag_categories']) && is_array($decoded['tag_categories'])) {
$inserts[] = [
'id' => (string) Str::ulid(),
'owner_type' => $ownerType,
'owner_id' => (string) $row->id,
'config_type' => FormFieldConfigType::TagCategories->value,
'parameters' => json_encode([
'categories' => array_values(array_map(
static fn ($c): string => (string) $c,
$decoded['tag_categories'],
)),
]),
'created_at' => $now,
'updated_at' => $now,
];
}
if (isset($decoded['storage_disk']) && is_string($decoded['storage_disk']) && $decoded['storage_disk'] !== '') {
$inserts[] = [
'id' => (string) Str::ulid(),
'owner_type' => $ownerType,
'owner_id' => (string) $row->id,
'config_type' => FormFieldConfigType::StorageDisk->value,
'parameters' => json_encode(['disk' => $decoded['storage_disk']]),
'created_at' => $now,
'updated_at' => $now,
];
}
}
if ($inserts === []) {
return;
}
foreach (array_chunk($inserts, 500) as $batch) {
DB::table('form_field_configs')->insert($batch);
}
}
private function reconstructJson(string $table, string $ownerType): void
{
if (! Schema::hasTable($table) || ! Schema::hasColumn($table, 'validation_rules')) {
return;
}
$rows = DB::table('form_field_configs')
->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 : [];
if ($row->config_type === FormFieldConfigType::TagCategories->value) {
$grouped[$ownerId]['tag_categories'] = $params['categories'] ?? [];
} elseif ($row->config_type === FormFieldConfigType::StorageDisk->value) {
$grouped[$ownerId]['storage_disk'] = $params['disk'] ?? '';
}
}
foreach ($grouped as $ownerId => $configs) {
$existing = DB::table($table)->where('id', $ownerId)->value('validation_rules');
$existingBag = is_string($existing) ? (json_decode($existing, true) ?: []) : [];
$merged = array_merge(is_array($existingBag) ? $existingBag : [], $configs);
DB::table($table)->where('id', $ownerId)->update([
'validation_rules' => json_encode($merged),
]);
}
}
};

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* WS-5b commit 5 drops the `validation_rules` JSON columns on
* `form_fields` and `form_field_library`. By this point both WS-5b
* backfills (validation-rules in commit 2, configs in commit 5) have
* populated their respective relational tables from the source JSON.
*
* Rollback re-adds the columns as `json nullable` **without** backfilling
* the rollback path is "roll back WS-5b commits 15 together". After
* this migration's `down()` the two backfill migrations' `down()` hooks
* reconstruct the source JSON bags.
*/
return new class extends Migration
{
public function up(): void
{
if (Schema::hasColumn('form_fields', 'validation_rules')) {
Schema::table('form_fields', function (Blueprint $table): void {
$table->dropColumn('validation_rules');
});
}
if (Schema::hasColumn('form_field_library', 'validation_rules')) {
Schema::table('form_field_library', function (Blueprint $table): void {
$table->dropColumn('validation_rules');
});
}
}
public function down(): void
{
if (! Schema::hasColumn('form_fields', 'validation_rules')) {
Schema::table('form_fields', function (Blueprint $table): void {
$table->json('validation_rules')->nullable()->after('options');
});
}
if (! Schema::hasColumn('form_field_library', 'validation_rules')) {
Schema::table('form_field_library', function (Blueprint $table): void {
$table->json('validation_rules')->nullable()->after('options');
});
}
}
};