feat(form-field): backfill form_fields.options to form_field_options
Atomic data migration. Every options datum in the database — in
form_fields and form_field_library, their translations bags, and the
form_submissions.schema_snapshot + form_templates.schema_snapshot JSON
blobs — is converted to the new relational rich-shape representation.
Strict dispatch per §17.4.4 / §8.7 convention:
- Fail on field_type ∉ {RADIO, SELECT, MULTISELECT, CHECKBOX_LIST}
carrying non-null options (post-WS-5b TAG_PICKER seed-bug indicator)
- Fail on non-flat-string-array options shape
- Fail on translations.{locale}.options[] length mismatch
- Fail on non-string / >255-char translated labels
- Fail on any residual translations.{locale}.options key after
step C migration
Snapshot rewrite in-place: both form_submissions.schema_snapshot and
form_templates.schema_snapshot walk fields[*] and rewrite options to
the new rich-shape, strip per-locale options[] from the parallel
translations bag. Zero-compromise directive — no reader tolerance for
pre-WS-5d shape in commit 3 onwards.
Rollback reconstructs JSON column shapes plus translations bags.
Forward+back pair safe as a unit; partial rollback unsupported.
FormFieldService::insertFromLibrary switches from JSON-copy to
FormFieldOptionService::copyOptions row-clone per addendum Q3 row-copy
mandate. The field's own translations bag no longer carries
{locale}.options keys — those live on option rows now.
Seeders and factories switch to service-level option creation:
- FormBuilderDevSeeder.canonicalFields keeps flat-string options as
its data shape; FormField::create no longer receives an options
key, the post-create FormFieldOptionService::replaceOptions call
inserts the rich rows. The same applies to
seedEventRegistrationShowcaseSchema. The vergoedingstype field's
legacy {label, description} object shape (a pre-WS-5d seed-bug
that the strict backfill would reject) is normalised to flat
strings; the descriptions are dropped.
- seedSystemTemplates embeds rich-shape options in the template
snapshot — no flat-array snapshot data remains in newly-seeded
rows.
- FormFieldFactory + FormFieldLibraryFactory drop the options
default; new ::withOptions() helper accepts either flat strings
(each becomes value+label) or full spec arrays and routes through
the service.
JSON columns (form_fields.options, form_field_library.options) remain
present and writable via fillable; column-drop lands in commit 5.
Reads from the JSON column still exist in resources, snapshot writer,
FormRequests, FormValueService, and FilterRegistryController — commit
3 switches those all atomically.
Migration step-count tests in WS-5a/b/c bumped by 1 to account for
the new backfill_form_field_options migration on the migration stack.
Tests: 1182 → 1193 green (+11 tests / +56 assertions).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,578 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* WS-5d commit 2 — atomic data migration. Every options datum in the
|
||||
* database — in form_fields and form_field_library, their translations
|
||||
* bags, and the form_submissions.schema_snapshot +
|
||||
* form_templates.schema_snapshot JSON blobs — is converted to the new
|
||||
* relational rich-shape representation.
|
||||
*
|
||||
* Strict dispatch per §17.4.4 / §8.7 convention:
|
||||
*
|
||||
* - FAIL on field_type ∉ {RADIO, SELECT, MULTISELECT, CHECKBOX_LIST}
|
||||
* carrying non-null options (post-WS-5b TAG_PICKER seed-bug
|
||||
* indicator).
|
||||
* - FAIL on non-flat-string-array options shape.
|
||||
* - FAIL on translations.{locale}.options[] length mismatch.
|
||||
* - FAIL on non-string / >255-char translated labels.
|
||||
* - FAIL on any residual translations.{locale}.options key after
|
||||
* step C migration.
|
||||
*
|
||||
* Forward+back pair safe as a unit; partial rollback unsupported.
|
||||
* Pair with `2026_04_27_100002_drop_form_field_options_json_columns`
|
||||
* to restore the full pre-WS-5d data shape.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
private const OPTION_BEARING_TYPES = ['RADIO', 'SELECT', 'MULTISELECT', 'CHECKBOX_LIST'];
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
DB::transaction(function (): void {
|
||||
$this->backfillOptionsTable('form_fields', 'form_field');
|
||||
$this->backfillOptionsTable('form_field_library', 'form_field_library');
|
||||
$this->backfillTranslations('form_fields', 'form_field');
|
||||
$this->backfillTranslations('form_field_library', 'form_field_library');
|
||||
$this->verifyTranslationsCleanedOrFail();
|
||||
$this->rewriteSnapshots('form_submissions');
|
||||
$this->rewriteSnapshots('form_templates');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable('form_field_options')) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function (): void {
|
||||
$this->reconstructJsonAndTranslations('form_fields', 'form_field');
|
||||
$this->reconstructJsonAndTranslations('form_field_library', 'form_field_library');
|
||||
$this->revertSnapshots('form_submissions');
|
||||
$this->revertSnapshots('form_templates');
|
||||
|
||||
DB::table('form_field_options')->delete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Step A / B — read pre-WS-5d JSON column rows and emit one
|
||||
* form_field_options row per option string, sort_order = index.
|
||||
*/
|
||||
private function backfillOptionsTable(string $table, string $ownerType): void
|
||||
{
|
||||
if (! Schema::hasTable($table) || ! Schema::hasColumn($table, 'options')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rows = DB::table($table)
|
||||
->whereNotNull('options')
|
||||
->orderBy('id')
|
||||
->get(['id', 'field_type', 'options']);
|
||||
|
||||
$now = now();
|
||||
$inserts = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$decoded = is_string($row->options)
|
||||
? json_decode((string) $row->options, true)
|
||||
: $row->options;
|
||||
|
||||
if ($decoded === null || $decoded === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fieldType = (string) $row->field_type;
|
||||
if (! in_array($fieldType, self::OPTION_BEARING_TYPES, true)) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'Stale options on %s row %s (type=%s). Expected null for field types '
|
||||
.'outside the RADIO/SELECT/MULTISELECT/CHECKBOX_LIST set. Post-WS-5b '
|
||||
.'TAG_PICKER options should live in form_field_configs. Fix the '
|
||||
.'offending row and re-run the migration.',
|
||||
$table,
|
||||
$row->id,
|
||||
$fieldType,
|
||||
));
|
||||
}
|
||||
|
||||
if (! is_array($decoded) || ! array_is_list($decoded)) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'Unexpected options shape on %s row %s. Expected flat string array, '
|
||||
.'got: %s. Fix the offending row and re-run.',
|
||||
$table,
|
||||
$row->id,
|
||||
json_encode($decoded),
|
||||
));
|
||||
}
|
||||
|
||||
foreach ($decoded as $entry) {
|
||||
if (! is_string($entry)) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'Unexpected options shape on %s row %s. Expected flat string array, '
|
||||
.'got entry: %s. Fix the offending row and re-run.',
|
||||
$table,
|
||||
$row->id,
|
||||
json_encode($entry),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($decoded as $i => $entry) {
|
||||
$inserts[] = [
|
||||
'id' => (string) Str::ulid(),
|
||||
'owner_type' => $ownerType,
|
||||
'owner_id' => (string) $row->id,
|
||||
'value' => (string) $entry,
|
||||
'label' => (string) $entry,
|
||||
'sort_order' => $i,
|
||||
'translations' => null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if ($inserts === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (array_chunk($inserts, 500) as $batch) {
|
||||
DB::table('form_field_options')->insert($batch);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step C — for each owner row with translations.{locale}.options[],
|
||||
* write each translated label onto its corresponding option row
|
||||
* (matched by sort_order) and strip the per-locale options array
|
||||
* from the translations bag.
|
||||
*/
|
||||
private function backfillTranslations(string $table, string $ownerType): void
|
||||
{
|
||||
if (! Schema::hasTable($table) || ! Schema::hasColumn($table, 'translations')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rows = DB::table($table)
|
||||
->whereNotNull('translations')
|
||||
->orderBy('id')
|
||||
->get(['id', 'translations']);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$bag = json_decode((string) $row->translations, true);
|
||||
if (! is_array($bag)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$expectedCount = DB::table('form_field_options')
|
||||
->where('owner_type', $ownerType)
|
||||
->where('owner_id', (string) $row->id)
|
||||
->count();
|
||||
|
||||
$changed = false;
|
||||
foreach ($bag as $locale => $localeData) {
|
||||
if (! is_array($localeData) || ! array_key_exists('options', $localeData)) {
|
||||
continue;
|
||||
}
|
||||
$localeOptions = $localeData['options'];
|
||||
if (! is_array($localeOptions) || ! array_is_list($localeOptions)) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'Unexpected translations.%s.options shape on %s row %s. '
|
||||
.'Expected flat array, got: %s.',
|
||||
$locale,
|
||||
$table,
|
||||
$row->id,
|
||||
json_encode($localeOptions),
|
||||
));
|
||||
}
|
||||
|
||||
if (count($localeOptions) !== $expectedCount) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'Translations length mismatch on %s row %s locale %s: '
|
||||
.'%d translations vs %d options.',
|
||||
$table,
|
||||
$row->id,
|
||||
$locale,
|
||||
count($localeOptions),
|
||||
$expectedCount,
|
||||
));
|
||||
}
|
||||
|
||||
foreach ($localeOptions as $i => $translated) {
|
||||
if (! is_string($translated) || $translated === '' || strlen($translated) > 255) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'Invalid translated label on %s row %s locale %s index %d: '
|
||||
.'must be a non-empty string ≤255 chars; got %s.',
|
||||
$table,
|
||||
$row->id,
|
||||
$locale,
|
||||
$i,
|
||||
json_encode($translated),
|
||||
));
|
||||
}
|
||||
$optionRow = DB::table('form_field_options')
|
||||
->where('owner_type', $ownerType)
|
||||
->where('owner_id', (string) $row->id)
|
||||
->where('sort_order', $i)
|
||||
->first();
|
||||
if ($optionRow === null) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'No option row at sort_order %d for %s row %s while '
|
||||
.'migrating locale %s translations.',
|
||||
$i,
|
||||
$table,
|
||||
$row->id,
|
||||
$locale,
|
||||
));
|
||||
}
|
||||
$existing = $optionRow->translations === null
|
||||
? []
|
||||
: (json_decode((string) $optionRow->translations, true) ?: []);
|
||||
$existing[$locale] = $translated;
|
||||
DB::table('form_field_options')
|
||||
->where('id', $optionRow->id)
|
||||
->update(['translations' => json_encode($existing)]);
|
||||
}
|
||||
|
||||
unset($bag[$locale]['options']);
|
||||
if ($bag[$locale] === []) {
|
||||
unset($bag[$locale]);
|
||||
}
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
if ($changed) {
|
||||
DB::table($table)->where('id', $row->id)->update([
|
||||
'translations' => $bag === [] ? null : json_encode($bag),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step C verification — no per-locale options[] key may survive on
|
||||
* either source table after step C. Belt-and-braces guard against
|
||||
* silent residuals.
|
||||
*/
|
||||
private function verifyTranslationsCleanedOrFail(): void
|
||||
{
|
||||
foreach (['form_fields', 'form_field_library'] as $table) {
|
||||
if (! Schema::hasTable($table) || ! Schema::hasColumn($table, 'translations')) {
|
||||
continue;
|
||||
}
|
||||
$rows = DB::table($table)->whereNotNull('translations')->get(['id', 'translations']);
|
||||
foreach ($rows as $row) {
|
||||
$bag = json_decode((string) $row->translations, true);
|
||||
if (! is_array($bag)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($bag as $locale => $data) {
|
||||
if (is_array($data) && array_key_exists('options', $data)) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'Residual translations.%s.options key on %s row %s after '
|
||||
.'step C migration. This indicates step C did not run '
|
||||
.'exhaustively — investigate before retrying.',
|
||||
$locale,
|
||||
$table,
|
||||
$row->id,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step D — walk every snapshot's fields[*] and rewrite options to
|
||||
* the new rich-shape array; strip the parallel
|
||||
* translations.{locale}.options[] bag from each field's translations.
|
||||
*/
|
||||
private function rewriteSnapshots(string $table): void
|
||||
{
|
||||
if (! Schema::hasTable($table) || ! Schema::hasColumn($table, 'schema_snapshot')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rows = DB::table($table)
|
||||
->whereNotNull('schema_snapshot')
|
||||
->orderBy('id')
|
||||
->get(['id', 'schema_snapshot']);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$snapshot = json_decode((string) $row->schema_snapshot, true);
|
||||
if (! is_array($snapshot) || ! isset($snapshot['fields']) || ! is_array($snapshot['fields'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$touched = false;
|
||||
foreach ($snapshot['fields'] as $idx => $field) {
|
||||
if (! is_array($field) || ! array_key_exists('options', $field) || $field['options'] === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fieldType = (string) ($field['field_type'] ?? '');
|
||||
if (! in_array($fieldType, self::OPTION_BEARING_TYPES, true)) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'Snapshot %s row %s field %s has options but field_type is %s. '
|
||||
.'Seed-bug — fix source data.',
|
||||
$table,
|
||||
$row->id,
|
||||
$field['slug'] ?? '<unknown>',
|
||||
$fieldType,
|
||||
));
|
||||
}
|
||||
|
||||
$originalOptions = $field['options'];
|
||||
if (! is_array($originalOptions) || ! array_is_list($originalOptions)) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'Snapshot %s row %s field %s: unexpected options shape. '
|
||||
.'Expected flat string array, got: %s.',
|
||||
$table,
|
||||
$row->id,
|
||||
$field['slug'] ?? '<unknown>',
|
||||
json_encode($originalOptions),
|
||||
));
|
||||
}
|
||||
foreach ($originalOptions as $entry) {
|
||||
if (! is_string($entry)) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'Snapshot %s row %s field %s: option entry not a string: %s.',
|
||||
$table,
|
||||
$row->id,
|
||||
$field['slug'] ?? '<unknown>',
|
||||
json_encode($entry),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
$fieldTranslations = is_array($field['translations'] ?? null) ? $field['translations'] : [];
|
||||
|
||||
$newOptions = [];
|
||||
foreach ($originalOptions as $i => $entry) {
|
||||
$rich = [
|
||||
'value' => (string) $entry,
|
||||
'label' => (string) $entry,
|
||||
'sort_order' => $i,
|
||||
];
|
||||
$perOptionTranslations = [];
|
||||
foreach ($fieldTranslations as $locale => $localeData) {
|
||||
if (! is_array($localeData) || ! isset($localeData['options'])) {
|
||||
continue;
|
||||
}
|
||||
$localeOptions = $localeData['options'];
|
||||
if (! is_array($localeOptions) || ! array_is_list($localeOptions)) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'Snapshot %s row %s field %s translations.%s.options: '
|
||||
.'expected flat array, got %s.',
|
||||
$table,
|
||||
$row->id,
|
||||
$field['slug'] ?? '<unknown>',
|
||||
$locale,
|
||||
json_encode($localeOptions),
|
||||
));
|
||||
}
|
||||
if (count($localeOptions) !== count($originalOptions)) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'Snapshot %s row %s field %s locale %s: translations '
|
||||
.'length %d vs options length %d.',
|
||||
$table,
|
||||
$row->id,
|
||||
$field['slug'] ?? '<unknown>',
|
||||
$locale,
|
||||
count($localeOptions),
|
||||
count($originalOptions),
|
||||
));
|
||||
}
|
||||
$translated = $localeOptions[$i];
|
||||
if (! is_string($translated) || $translated === '' || strlen($translated) > 255) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'Snapshot %s row %s field %s locale %s index %d: '
|
||||
.'translated label must be a non-empty string ≤255 chars.',
|
||||
$table,
|
||||
$row->id,
|
||||
$field['slug'] ?? '<unknown>',
|
||||
$locale,
|
||||
$i,
|
||||
));
|
||||
}
|
||||
$perOptionTranslations[$locale] = $translated;
|
||||
}
|
||||
if ($perOptionTranslations !== []) {
|
||||
$rich['translations'] = $perOptionTranslations;
|
||||
}
|
||||
$newOptions[] = $rich;
|
||||
}
|
||||
|
||||
$snapshot['fields'][$idx]['options'] = $newOptions;
|
||||
|
||||
// Strip {locale}.options from this field's per-locale bag.
|
||||
$cleanedTranslations = [];
|
||||
foreach ($fieldTranslations as $locale => $localeData) {
|
||||
if (is_array($localeData)) {
|
||||
unset($localeData['options']);
|
||||
if ($localeData !== []) {
|
||||
$cleanedTranslations[$locale] = $localeData;
|
||||
}
|
||||
} else {
|
||||
$cleanedTranslations[$locale] = $localeData;
|
||||
}
|
||||
}
|
||||
$snapshot['fields'][$idx]['translations'] = $cleanedTranslations === []
|
||||
? null
|
||||
: $cleanedTranslations;
|
||||
|
||||
$touched = true;
|
||||
}
|
||||
|
||||
if ($touched) {
|
||||
DB::table($table)->where('id', $row->id)->update([
|
||||
'schema_snapshot' => json_encode($snapshot),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rollback step A' / B' / C' — read the relational rows back into the
|
||||
* JSON column on the source tables, and rebuild
|
||||
* translations.{locale}.options[] parallel arrays from the per-row
|
||||
* translations bag.
|
||||
*/
|
||||
private function reconstructJsonAndTranslations(string $table, string $ownerType): void
|
||||
{
|
||||
if (! Schema::hasTable($table) || ! Schema::hasColumn($table, 'options')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rowsByOwner = DB::table('form_field_options')
|
||||
->where('owner_type', $ownerType)
|
||||
->orderBy('owner_id')
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
|
||||
$grouped = [];
|
||||
foreach ($rowsByOwner as $row) {
|
||||
$grouped[(string) $row->owner_id][] = $row;
|
||||
}
|
||||
|
||||
foreach ($grouped as $ownerId => $rows) {
|
||||
$values = array_map(static fn ($r): string => (string) $r->value, $rows);
|
||||
|
||||
DB::table($table)->where('id', $ownerId)->update([
|
||||
'options' => json_encode($values),
|
||||
]);
|
||||
|
||||
$perLocale = [];
|
||||
foreach ($rows as $i => $r) {
|
||||
$bag = $r->translations === null
|
||||
? []
|
||||
: (json_decode((string) $r->translations, true) ?: []);
|
||||
foreach ($bag as $locale => $translated) {
|
||||
$perLocale[$locale] ??= array_fill(0, count($rows), null);
|
||||
$perLocale[$locale][$i] = (string) $translated;
|
||||
}
|
||||
}
|
||||
|
||||
if ($perLocale !== []) {
|
||||
$existing = DB::table($table)->where('id', $ownerId)->value('translations');
|
||||
$existingBag = $existing === null
|
||||
? []
|
||||
: (json_decode((string) $existing, true) ?: []);
|
||||
if (! is_array($existingBag)) {
|
||||
$existingBag = [];
|
||||
}
|
||||
foreach ($perLocale as $locale => $optionsList) {
|
||||
$hasNonNull = false;
|
||||
foreach ($optionsList as $entry) {
|
||||
if ($entry !== null) {
|
||||
$hasNonNull = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (! $hasNonNull) {
|
||||
continue;
|
||||
}
|
||||
$existingBag[$locale] ??= [];
|
||||
if (! is_array($existingBag[$locale])) {
|
||||
$existingBag[$locale] = [];
|
||||
}
|
||||
$existingBag[$locale]['options'] = $optionsList;
|
||||
}
|
||||
DB::table($table)->where('id', $ownerId)->update([
|
||||
'translations' => json_encode($existingBag),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rollback for snapshots — convert rich-shape options back to flat
|
||||
* string arrays + rebuild translations.{locale}.options[] parallel
|
||||
* arrays from per-entry translations.
|
||||
*/
|
||||
private function revertSnapshots(string $table): void
|
||||
{
|
||||
if (! Schema::hasTable($table) || ! Schema::hasColumn($table, 'schema_snapshot')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rows = DB::table($table)->whereNotNull('schema_snapshot')->get(['id', 'schema_snapshot']);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$snapshot = json_decode((string) $row->schema_snapshot, true);
|
||||
if (! is_array($snapshot) || ! isset($snapshot['fields']) || ! is_array($snapshot['fields'])) {
|
||||
continue;
|
||||
}
|
||||
$touched = false;
|
||||
foreach ($snapshot['fields'] as $idx => $field) {
|
||||
if (! is_array($field) || ! isset($field['options']) || ! is_array($field['options'])) {
|
||||
continue;
|
||||
}
|
||||
$opts = $field['options'];
|
||||
if ($opts === [] || ! is_array($opts[0] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
$values = [];
|
||||
$perLocale = [];
|
||||
$count = count($opts);
|
||||
foreach ($opts as $i => $rich) {
|
||||
if (! is_array($rich)) {
|
||||
continue;
|
||||
}
|
||||
$values[] = (string) ($rich['value'] ?? '');
|
||||
$bag = is_array($rich['translations'] ?? null) ? $rich['translations'] : [];
|
||||
foreach ($bag as $locale => $translated) {
|
||||
$perLocale[$locale] ??= array_fill(0, $count, null);
|
||||
$perLocale[$locale][$i] = (string) $translated;
|
||||
}
|
||||
}
|
||||
$snapshot['fields'][$idx]['options'] = $values;
|
||||
|
||||
if ($perLocale !== []) {
|
||||
$existing = is_array($field['translations'] ?? null) ? $field['translations'] : [];
|
||||
foreach ($perLocale as $locale => $list) {
|
||||
$existing[$locale] ??= [];
|
||||
if (! is_array($existing[$locale])) {
|
||||
$existing[$locale] = [];
|
||||
}
|
||||
$existing[$locale]['options'] = $list;
|
||||
}
|
||||
$snapshot['fields'][$idx]['translations'] = $existing;
|
||||
}
|
||||
$touched = true;
|
||||
}
|
||||
if ($touched) {
|
||||
DB::table($table)->where('id', $row->id)->update([
|
||||
'schema_snapshot' => json_encode($snapshot),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user