@omnifyjp/omnify 3.20.0 → 3.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -6
- package/ts-dist/php/service-generator.js +427 -110
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@omnifyjp/omnify",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.21.1",
|
|
4
4
|
"description": "Schema-driven code generation for Laravel, TypeScript, and SQL",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -36,10 +36,10 @@
|
|
|
36
36
|
"zod": "^3.24.0"
|
|
37
37
|
},
|
|
38
38
|
"optionalDependencies": {
|
|
39
|
-
"@omnifyjp/omnify-darwin-arm64": "3.
|
|
40
|
-
"@omnifyjp/omnify-darwin-x64": "3.
|
|
41
|
-
"@omnifyjp/omnify-linux-x64": "3.
|
|
42
|
-
"@omnifyjp/omnify-linux-arm64": "3.
|
|
43
|
-
"@omnifyjp/omnify-win32-x64": "3.
|
|
39
|
+
"@omnifyjp/omnify-darwin-arm64": "3.21.1",
|
|
40
|
+
"@omnifyjp/omnify-darwin-x64": "3.21.1",
|
|
41
|
+
"@omnifyjp/omnify-linux-x64": "3.21.1",
|
|
42
|
+
"@omnifyjp/omnify-linux-arm64": "3.21.1",
|
|
43
|
+
"@omnifyjp/omnify-win32-x64": "3.21.1"
|
|
44
44
|
}
|
|
45
45
|
}
|
|
@@ -262,17 +262,17 @@ function generateBaseService(name, schema, reader, config) {
|
|
|
262
262
|
// Allowed sort columns whitelist (#75.2: prevent SQL injection via sort filter)
|
|
263
263
|
const allowedSortColumns = buildAllowedSortColumns(searchableFields, filterableFields, sortableFields, defaultSort, hasSoftDelete);
|
|
264
264
|
// list() method
|
|
265
|
-
sections.push(buildListMethod(modelName, searchableFields, filterableFields, sortableFields, hasSoftDelete, eagerLoad, eagerCount, defaultSort, perPage, allowedSortColumns, translationCtx));
|
|
265
|
+
sections.push(buildListMethod(modelName, searchableFields, filterableFields, sortableFields, hasSoftDelete, eagerLoad, eagerCount, defaultSort, perPage, allowedSortColumns, translationCtx, reader));
|
|
266
266
|
// findById() method
|
|
267
|
-
sections.push(buildFindByIdMethod(modelName, eagerLoad, eagerCount));
|
|
267
|
+
sections.push(buildFindByIdMethod(modelName, eagerLoad, eagerCount, hasSoftDelete));
|
|
268
268
|
// ── Create section ────────────────────────────────────────────────────────
|
|
269
269
|
sections.push('');
|
|
270
270
|
sections.push(buildSectionComment('Create'));
|
|
271
|
-
sections.push(buildCreateMethod(modelName, hasAuditCreatedBy, hasTranslatable, translatableFields, eagerLoad, eagerCount, defaults));
|
|
271
|
+
sections.push(buildCreateMethod(modelName, name, schema, reader, hasAuditCreatedBy, hasTranslatable, translatableFields, eagerLoad, eagerCount, defaults, locales));
|
|
272
272
|
// ── Update section ────────────────────────────────────────────────────────
|
|
273
273
|
sections.push('');
|
|
274
274
|
sections.push(buildSectionComment('Update'));
|
|
275
|
-
sections.push(buildUpdateMethod(modelName, hasAuditUpdatedBy, hasTranslatable, eagerLoad, eagerCount));
|
|
275
|
+
sections.push(buildUpdateMethod(modelName, name, schema, reader, hasAuditUpdatedBy, hasTranslatable, eagerLoad, eagerCount, translatableFields, locales));
|
|
276
276
|
// ── Delete & Restore section ──────────────────────────────────────────────
|
|
277
277
|
sections.push('');
|
|
278
278
|
if (hasSoftDelete) {
|
|
@@ -291,14 +291,13 @@ function generateBaseService(name, schema, reader, config) {
|
|
|
291
291
|
if (lookup) {
|
|
292
292
|
sections.push('');
|
|
293
293
|
sections.push(buildSectionComment('Lookup'));
|
|
294
|
-
sections.push(buildLookupMethod(modelName, lookupFields, filterableFields, defaultSort, translationCtx));
|
|
294
|
+
sections.push(buildLookupMethod(modelName, lookupFields, filterableFields, defaultSort, translationCtx, reader, hasSoftDelete));
|
|
295
295
|
}
|
|
296
296
|
// ── Translatable helpers ──────────────────────────────────────────────────
|
|
297
297
|
if (hasTranslatable) {
|
|
298
298
|
sections.push('');
|
|
299
299
|
sections.push(buildSectionComment('Translatable Helpers'));
|
|
300
|
-
sections.push(
|
|
301
|
-
sections.push(buildSyncTranslationsMethod(modelName));
|
|
300
|
+
sections.push(buildFlushTranslationsMethod(modelName));
|
|
302
301
|
}
|
|
303
302
|
// ── Assemble file ─────────────────────────────────────────────────────────
|
|
304
303
|
const classDoc = buildClassDocblock({
|
|
@@ -341,6 +340,254 @@ function buildSectionComment(title) {
|
|
|
341
340
|
// ${title}
|
|
342
341
|
// =========================================================================`;
|
|
343
342
|
}
|
|
343
|
+
// ============================================================================
|
|
344
|
+
// #77 follow-up: strict @param array-shape + @example block derivation
|
|
345
|
+
// ============================================================================
|
|
346
|
+
/**
|
|
347
|
+
* Map a schema PropertyDefinition to a PHPStan/Psalm array-shape value type.
|
|
348
|
+
* EnumRef is resolved to a literal union by the reader before we get here.
|
|
349
|
+
*/
|
|
350
|
+
function mapPropertyToPhpType(prop, reader) {
|
|
351
|
+
// Inline enum values: ['draft', 'published'] → 'draft'|'published'
|
|
352
|
+
if (prop.enum) {
|
|
353
|
+
if (typeof prop.enum === 'string') {
|
|
354
|
+
// Reference to a schema enum — resolve values
|
|
355
|
+
const enumSchema = reader.getSchema(prop.enum);
|
|
356
|
+
const values = enumSchema?.values ?? [];
|
|
357
|
+
if (values.length > 0) {
|
|
358
|
+
return values.map(v => `'${v.value}'`).join('|');
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
else if (Array.isArray(prop.enum) && prop.enum.length > 0) {
|
|
362
|
+
return prop.enum.map(v => `'${v}'`).join('|');
|
|
363
|
+
}
|
|
364
|
+
return 'string';
|
|
365
|
+
}
|
|
366
|
+
switch (prop.type) {
|
|
367
|
+
case 'Boolean': return 'bool';
|
|
368
|
+
case 'Int':
|
|
369
|
+
case 'BigInt':
|
|
370
|
+
case 'TinyInt':
|
|
371
|
+
case 'Integer': return 'int';
|
|
372
|
+
case 'Float':
|
|
373
|
+
case 'Decimal': return 'float';
|
|
374
|
+
case 'Json': return 'array<string, mixed>';
|
|
375
|
+
case 'File': return '\\Illuminate\\Http\\UploadedFile|string';
|
|
376
|
+
case 'Text':
|
|
377
|
+
case 'LongText':
|
|
378
|
+
case 'MediumText':
|
|
379
|
+
case 'String':
|
|
380
|
+
case 'Date':
|
|
381
|
+
case 'DateTime':
|
|
382
|
+
case 'Timestamp':
|
|
383
|
+
case 'Time':
|
|
384
|
+
case 'Uuid':
|
|
385
|
+
case 'Email':
|
|
386
|
+
case 'Url':
|
|
387
|
+
case 'Password':
|
|
388
|
+
case 'Slug': return 'string';
|
|
389
|
+
default: return 'string';
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Placeholder value for an @example block, derived from the property's type
|
|
394
|
+
* and name so consumers can copy-paste and tweak. Not intended to be valid
|
|
395
|
+
* production data — just type-realistic.
|
|
396
|
+
*/
|
|
397
|
+
function samplePhpValueForProperty(prop, colName, reader) {
|
|
398
|
+
// Enum — prefer the first declared value
|
|
399
|
+
if (prop.enum) {
|
|
400
|
+
if (typeof prop.enum === 'string') {
|
|
401
|
+
const enumSchema = reader.getSchema(prop.enum);
|
|
402
|
+
const first = enumSchema?.values?.[0]?.value;
|
|
403
|
+
if (first)
|
|
404
|
+
return `'${first}'`;
|
|
405
|
+
}
|
|
406
|
+
else if (Array.isArray(prop.enum) && prop.enum.length > 0) {
|
|
407
|
+
return `'${prop.enum[0]}'`;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
switch (prop.type) {
|
|
411
|
+
case 'Boolean': return 'false';
|
|
412
|
+
case 'Int':
|
|
413
|
+
case 'BigInt':
|
|
414
|
+
case 'TinyInt':
|
|
415
|
+
case 'Integer': return colName.endsWith('_order') ? '0' : '1';
|
|
416
|
+
case 'Float':
|
|
417
|
+
case 'Decimal': return '0.0';
|
|
418
|
+
case 'Json': return '[]';
|
|
419
|
+
case 'Uuid': return "'01h000000000000000000000000'";
|
|
420
|
+
case 'Email': return "'user@example.com'";
|
|
421
|
+
case 'Url': return "'https://example.com'";
|
|
422
|
+
case 'Date': return "'2026-01-01'";
|
|
423
|
+
case 'DateTime':
|
|
424
|
+
case 'Timestamp': return "'2026-01-01 00:00:00'";
|
|
425
|
+
case 'Time': return "'12:00:00'";
|
|
426
|
+
case 'File': return '$uploadedFile';
|
|
427
|
+
default:
|
|
428
|
+
// Use the column name itself as a hint for string-like fields
|
|
429
|
+
if (colName === 'slug')
|
|
430
|
+
return "'example-slug'";
|
|
431
|
+
if (colName === 'password')
|
|
432
|
+
return "'hunter2'";
|
|
433
|
+
if (colName.endsWith('_id'))
|
|
434
|
+
return "'01h000000000000000000000000'";
|
|
435
|
+
return `'example-${colName}'`;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
/** Property inclusion filter for @param shapes on write methods. */
|
|
439
|
+
function isWriteMethodDataField(propName, prop) {
|
|
440
|
+
// Skip auto-managed / system columns
|
|
441
|
+
if (propName === 'id' || propName === 'created_at' || propName === 'updated_at' || propName === 'deleted_at') {
|
|
442
|
+
return false;
|
|
443
|
+
}
|
|
444
|
+
if (propName === 'created_by_id' || propName === 'updated_by_id' || propName === 'deleted_by_id') {
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
// Skip Associations — they represent relations, not insertable columns
|
|
448
|
+
if (prop.type === 'Association')
|
|
449
|
+
return false;
|
|
450
|
+
// Skip files — they flow through a separate upload path
|
|
451
|
+
if (prop.type === 'File')
|
|
452
|
+
return false;
|
|
453
|
+
return true;
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Build the `@param array{...} $data` shape for create()/update().
|
|
457
|
+
* Enumerates every schema property that is insertable, with typed values.
|
|
458
|
+
* Translatable fields expand into bare + `attr:locale` variants so the
|
|
459
|
+
* consumer gets autocomplete on every valid key Astrotomic::fill() accepts.
|
|
460
|
+
* The deprecated `translations` wrapper is still listed (with a comment)
|
|
461
|
+
* so readers see both paths until v3.21.0 drops the wrapper entirely.
|
|
462
|
+
*/
|
|
463
|
+
function buildWriteDataShape(schema, reader, translatableFields, locales) {
|
|
464
|
+
const properties = schema.properties ?? {};
|
|
465
|
+
const order = schema.propertyOrder ?? Object.keys(properties);
|
|
466
|
+
const lines = [];
|
|
467
|
+
const translatableSet = new Set(translatableFields);
|
|
468
|
+
for (const propName of order) {
|
|
469
|
+
const prop = properties[propName];
|
|
470
|
+
if (!prop || !isWriteMethodDataField(propName, prop))
|
|
471
|
+
continue;
|
|
472
|
+
const colName = toSnakeCase(propName);
|
|
473
|
+
const phpType = mapPropertyToPhpType(prop, reader);
|
|
474
|
+
// Emit the bare column (Astrotomic falls back to it for the default locale)
|
|
475
|
+
lines.push(` * ${colName}?: ${phpType},`);
|
|
476
|
+
// For translatable fields, also emit 'attr:locale' variants per configured locale
|
|
477
|
+
if (translatableSet.has(colName) || translatableSet.has(propName)) {
|
|
478
|
+
for (const loc of locales) {
|
|
479
|
+
lines.push(` * '${colName}:${loc}'?: ${phpType},`);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
// #78 Step 3: legacy 'translations' wrapper is removed — only flat
|
|
484
|
+
// 'attr:locale' keys are accepted.
|
|
485
|
+
return lines.join('\n');
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Build the `@param array{...} $filters` shape for list()/lookup(). Enumerates
|
|
489
|
+
* every filterable column with its typed value (enum union where applicable),
|
|
490
|
+
* plus the standard control keys (search, sort, pagination, trash toggles).
|
|
491
|
+
*/
|
|
492
|
+
function buildListFilterShape(filterableFields, allowedSortColumns, hasSoftDelete, reader) {
|
|
493
|
+
const lines = [];
|
|
494
|
+
for (const f of filterableFields) {
|
|
495
|
+
const phpType = mapPropertyToPhpType(f.prop, reader);
|
|
496
|
+
lines.push(` * ${f.colName}?: ${phpType},`);
|
|
497
|
+
}
|
|
498
|
+
lines.push(` * search?: string,`);
|
|
499
|
+
const sortUnion = allowedSortColumns.length > 0
|
|
500
|
+
? allowedSortColumns.flatMap(c => [`'${c}'`, `'-${c}'`]).join('|')
|
|
501
|
+
: 'string';
|
|
502
|
+
lines.push(` * sort?: ${sortUnion},`);
|
|
503
|
+
lines.push(` * per_page?: int,`);
|
|
504
|
+
if (hasSoftDelete) {
|
|
505
|
+
lines.push(` * with_trashed?: bool,`);
|
|
506
|
+
lines.push(` * only_trashed?: bool,`);
|
|
507
|
+
}
|
|
508
|
+
return lines.join('\n');
|
|
509
|
+
}
|
|
510
|
+
function buildLookupFilterShape(filterableFields, reader, hasSoftDelete) {
|
|
511
|
+
const lines = [];
|
|
512
|
+
for (const f of filterableFields) {
|
|
513
|
+
const phpType = mapPropertyToPhpType(f.prop, reader);
|
|
514
|
+
lines.push(` * ${f.colName}?: ${phpType},`);
|
|
515
|
+
}
|
|
516
|
+
lines.push(` * limit?: int,`);
|
|
517
|
+
if (hasSoftDelete) {
|
|
518
|
+
// #79: lookup() now mirrors list()'s soft-delete filter contract so
|
|
519
|
+
// trashed-row admin flows can resolve options through the same method.
|
|
520
|
+
lines.push(` * with_trashed?: bool,`);
|
|
521
|
+
lines.push(` * only_trashed?: bool,`);
|
|
522
|
+
}
|
|
523
|
+
return lines.join('\n');
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* @example block body for create()/update(): one flat-format example plus a
|
|
527
|
+
* translatable-locale variant when the schema has translatable fields.
|
|
528
|
+
*/
|
|
529
|
+
/** Format a key+value pair with the arrow aligned across rows. */
|
|
530
|
+
function formatPhpArrayRow(key, value, arrowColumn) {
|
|
531
|
+
const padding = ' '.repeat(Math.max(1, arrowColumn - key.length));
|
|
532
|
+
return ` * ${key}${padding}=> ${value},`;
|
|
533
|
+
}
|
|
534
|
+
function buildWriteExampleBlock(methodName, modelName, schema, reader, translatableFields, locales) {
|
|
535
|
+
const properties = schema.properties ?? {};
|
|
536
|
+
const order = schema.propertyOrder ?? Object.keys(properties);
|
|
537
|
+
const translatableSet = new Set(translatableFields);
|
|
538
|
+
// Pass 1: collect (key, value) tuples so we can align the arrow across rows.
|
|
539
|
+
const rows = [];
|
|
540
|
+
for (const propName of order) {
|
|
541
|
+
const prop = properties[propName];
|
|
542
|
+
if (!prop || !isWriteMethodDataField(propName, prop))
|
|
543
|
+
continue;
|
|
544
|
+
const colName = toSnakeCase(propName);
|
|
545
|
+
if (translatableSet.has(colName) || translatableSet.has(propName)) {
|
|
546
|
+
for (const loc of locales) {
|
|
547
|
+
rows.push({ key: `'${colName}:${loc}'`, value: `'example-${colName}-${loc}'` });
|
|
548
|
+
}
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
rows.push({ key: `'${colName}'`, value: samplePhpValueForProperty(prop, colName, reader) });
|
|
552
|
+
}
|
|
553
|
+
const arrowColumn = Math.max(0, ...rows.map(r => r.key.length)) + 1;
|
|
554
|
+
const keyLines = rows.map(r => formatPhpArrayRow(r.key, r.value, arrowColumn));
|
|
555
|
+
const invocation = methodName === 'create'
|
|
556
|
+
? `$service->create([`
|
|
557
|
+
: `$service->update($existing${modelName}, [`;
|
|
558
|
+
return ` * @example ${methodName === 'create' ? `Create a ${modelName} with per-locale translations (flat form)` : `Update a ${modelName} (same shape as create)`}
|
|
559
|
+
* ${invocation}
|
|
560
|
+
${keyLines.join('\n')}
|
|
561
|
+
* ]);`;
|
|
562
|
+
}
|
|
563
|
+
function buildListExampleBlock(modelName, filterableFields, defaultSort, reader) {
|
|
564
|
+
const rows = [];
|
|
565
|
+
for (const f of filterableFields) {
|
|
566
|
+
rows.push({ key: `'${f.colName}'`, value: samplePhpValueForProperty(f.prop, f.colName, reader) });
|
|
567
|
+
}
|
|
568
|
+
rows.push({ key: "'search'", value: "'pho'" });
|
|
569
|
+
rows.push({ key: "'sort'", value: `'${defaultSort}'` });
|
|
570
|
+
rows.push({ key: "'per_page'", value: '25' });
|
|
571
|
+
const arrowColumn = Math.max(0, ...rows.map(r => r.key.length)) + 1;
|
|
572
|
+
const keyLines = rows.map(r => formatPhpArrayRow(r.key, r.value, arrowColumn));
|
|
573
|
+
return ` * @example List ${modelName}s with every supported filter key
|
|
574
|
+
* $paginator = $service->list([
|
|
575
|
+
${keyLines.join('\n')}
|
|
576
|
+
* ]);`;
|
|
577
|
+
}
|
|
578
|
+
function buildLookupExampleBlock(modelName, filterableFields, reader) {
|
|
579
|
+
const rows = [];
|
|
580
|
+
for (const f of filterableFields) {
|
|
581
|
+
rows.push({ key: `'${f.colName}'`, value: samplePhpValueForProperty(f.prop, f.colName, reader) });
|
|
582
|
+
}
|
|
583
|
+
rows.push({ key: "'limit'", value: '50' });
|
|
584
|
+
const arrowColumn = Math.max(0, ...rows.map(r => r.key.length)) + 1;
|
|
585
|
+
const keyLines = rows.map(r => formatPhpArrayRow(r.key, r.value, arrowColumn));
|
|
586
|
+
return ` * @example Fetch ${modelName} options for a type-ahead / dropdown
|
|
587
|
+
* $rows = $service->lookup([
|
|
588
|
+
${keyLines.join('\n')}
|
|
589
|
+
* ]);`;
|
|
590
|
+
}
|
|
344
591
|
function buildClassDocblock(c) {
|
|
345
592
|
const lines = [];
|
|
346
593
|
lines.push('/**');
|
|
@@ -386,13 +633,15 @@ function buildClassDocblock(c) {
|
|
|
386
633
|
lines.push(` * Locales: ${c.locales.join(', ')}`);
|
|
387
634
|
lines.push(` * Sidecar: ${toSnakeCase(c.schemaName)}_translations (synced in create/update)`);
|
|
388
635
|
lines.push(' *');
|
|
389
|
-
lines.push(' * Input format (
|
|
636
|
+
lines.push(' * Input format (Astrotomic-native flat — #78 Step 3):');
|
|
390
637
|
const sampleField = c.translatableFields[0] ?? 'name';
|
|
391
638
|
const sampleLocale = c.locales[0] ?? 'ja';
|
|
392
639
|
lines.push(` * [ "${sampleField}:${sampleLocale}" => "...", "${sampleField}:${c.locales[1] ?? 'en'}" => "..." ]`);
|
|
393
640
|
lines.push(' *');
|
|
394
|
-
lines.push(' *
|
|
395
|
-
lines.push(' *
|
|
641
|
+
lines.push(' * The old "translations" wrapper format was removed in v3.21.0 (#78).');
|
|
642
|
+
lines.push(' * Astrotomic Translatable::fill() routes these keys natively; this base');
|
|
643
|
+
lines.push(' * class just calls flushTranslations() after save() to persist sidecar');
|
|
644
|
+
lines.push(' * rows under seeder paths that suppress model events.');
|
|
396
645
|
lines.push(' *');
|
|
397
646
|
lines.push(' * Lookup/sort on translatable columns rewrites to a LEFT JOIN on the');
|
|
398
647
|
lines.push(' * sidecar for the request locale + fallback (see #76).');
|
|
@@ -461,10 +710,12 @@ function buildAllowedSortColumns(searchableFields, filterableFields, sortableFie
|
|
|
461
710
|
set.add(defaultCol);
|
|
462
711
|
return Array.from(set).sort();
|
|
463
712
|
}
|
|
464
|
-
function buildListMethod(modelName, searchableFields, filterableFields, sortableFields, hasSoftDelete, eagerLoad, eagerCount, defaultSort, perPage, allowedSortColumns, tx) {
|
|
713
|
+
function buildListMethod(modelName, searchableFields, filterableFields, sortableFields, hasSoftDelete, eagerLoad, eagerCount, defaultSort, perPage, allowedSortColumns, tx, reader) {
|
|
465
714
|
const lines = [];
|
|
466
|
-
//
|
|
467
|
-
|
|
715
|
+
// #77 follow-up: strict multi-line array-shape enumerating every filter
|
|
716
|
+
// key explicitly (no catch-all). Sort union includes -prefixed DESC variants.
|
|
717
|
+
const filterShape = buildListFilterShape(filterableFields, allowedSortColumns, hasSoftDelete, reader);
|
|
718
|
+
const listExample = buildListExampleBlock(modelName, filterableFields, defaultSort, reader);
|
|
468
719
|
lines.push(`
|
|
469
720
|
/**
|
|
470
721
|
* List ${modelName} records with filters, search, sort and pagination.
|
|
@@ -473,7 +724,11 @@ function buildListMethod(modelName, searchableFields, filterableFields, sortable
|
|
|
473
724
|
* unknown keys are ignored. The sort column is whitelisted against
|
|
474
725
|
* searchable + filterable + standard audit columns (#75.2).
|
|
475
726
|
*
|
|
476
|
-
* @param
|
|
727
|
+
* @param array{
|
|
728
|
+
${filterShape}
|
|
729
|
+
* } $filters
|
|
730
|
+
*
|
|
731
|
+
${listExample}
|
|
477
732
|
*/
|
|
478
733
|
public function list(array $filters = []): LengthAwarePaginator
|
|
479
734
|
{
|
|
@@ -631,23 +886,62 @@ function buildSortSection(_sortableFields, defaultSort, allowedSortColumns, tx)
|
|
|
631
886
|
}`;
|
|
632
887
|
}
|
|
633
888
|
// ── findById() ──────────────────────────────────────────────────────────────
|
|
634
|
-
function buildFindByIdMethod(modelName, eagerLoad, eagerCount) {
|
|
889
|
+
function buildFindByIdMethod(modelName, eagerLoad, eagerCount, hasSoftDelete) {
|
|
635
890
|
const eagerChain = buildEagerLoadChain(eagerLoad, eagerCount);
|
|
636
891
|
const chain = eagerChain ? `\n${eagerChain}\n ` : '';
|
|
637
|
-
|
|
892
|
+
if (!hasSoftDelete) {
|
|
893
|
+
return `
|
|
638
894
|
/**
|
|
639
895
|
* Load a single ${modelName} by primary key with the configured eager
|
|
640
896
|
* loads applied. Throws ModelNotFoundException (→ HTTP 404) on miss.
|
|
641
897
|
*
|
|
642
898
|
* @throws \\Illuminate\\Database\\Eloquent\\ModelNotFoundException
|
|
899
|
+
*
|
|
900
|
+
* @example Fetch a single ${modelName} by id
|
|
901
|
+
* $model = $service->findById('01h000000000000000000000000');
|
|
643
902
|
*/
|
|
644
903
|
public function findById(string $id): ${modelName}
|
|
645
904
|
{
|
|
646
905
|
return $this->model::query()${chain}->findOrFail($id);
|
|
647
906
|
}`;
|
|
907
|
+
}
|
|
908
|
+
// #79: soft-deletable schemas accept an opt-in `$withTrashed` flag so admin
|
|
909
|
+
// restore / audit workflows can resolve a trashed row through the base
|
|
910
|
+
// instead of bypassing it with Product::withTrashed()->findOrFail($id).
|
|
911
|
+
const queryAssignment = eagerChain
|
|
912
|
+
? ` $query = $this->model::query()\n${eagerChain};`
|
|
913
|
+
: ` $query = $this->model::query();`;
|
|
914
|
+
return `
|
|
915
|
+
/**
|
|
916
|
+
* Load a single ${modelName} by primary key with the configured eager
|
|
917
|
+
* loads applied. Throws ModelNotFoundException (→ HTTP 404) on miss.
|
|
918
|
+
*
|
|
919
|
+
* Pass \`\\$withTrashed = true\` to include soft-deleted rows — required
|
|
920
|
+
* for admin "view trashed item" / restore flows that need to resolve a
|
|
921
|
+
* row to hand to restore() or forceDelete() without bypassing this base
|
|
922
|
+
* class (#79).
|
|
923
|
+
*
|
|
924
|
+
* @throws \\Illuminate\\Database\\Eloquent\\ModelNotFoundException
|
|
925
|
+
*
|
|
926
|
+
* @example Fetch an active ${modelName}
|
|
927
|
+
* $model = $service->findById('01h000000000000000000000000');
|
|
928
|
+
*
|
|
929
|
+
* @example Resolve a trashed ${modelName} for restore() / forceDelete()
|
|
930
|
+
* $trashed = $service->findById('01h000000000000000000000000', withTrashed: true);
|
|
931
|
+
* $service->restore($trashed);
|
|
932
|
+
*/
|
|
933
|
+
public function findById(string $id, bool $withTrashed = false): ${modelName}
|
|
934
|
+
{
|
|
935
|
+
${queryAssignment}
|
|
936
|
+
if ($withTrashed) {
|
|
937
|
+
$query->withTrashed();
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
return $query->findOrFail($id);
|
|
941
|
+
}`;
|
|
648
942
|
}
|
|
649
943
|
// ── create() ────────────────────────────────────────────────────────────────
|
|
650
|
-
function buildCreateMethod(modelName, hasAuditCreatedBy, hasTranslatable, translatableFields, eagerLoad, eagerCount, defaults) {
|
|
944
|
+
function buildCreateMethod(modelName, schemaName, schema, reader, hasAuditCreatedBy, hasTranslatable, translatableFields, eagerLoad, eagerCount, defaults, locales) {
|
|
651
945
|
const bodyLines = [];
|
|
652
946
|
// Audit (#75.3: always overwrite — never trust client-supplied audit columns)
|
|
653
947
|
if (hasAuditCreatedBy) {
|
|
@@ -666,19 +960,15 @@ function buildCreateMethod(modelName, hasAuditCreatedBy, hasTranslatable, transl
|
|
|
666
960
|
}
|
|
667
961
|
bodyLines.push('');
|
|
668
962
|
}
|
|
669
|
-
//
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
`);
|
|
674
|
-
}
|
|
963
|
+
// #78 Step 3: compose on Astrotomic's native fill() so 'attr:locale'
|
|
964
|
+
// keys route through Translatable::fill() directly. flushTranslations()
|
|
965
|
+
// below persists any dirty translation rows even when the `saved` event
|
|
966
|
+
// hook is suppressed (seeders under WithoutModelEvents).
|
|
675
967
|
bodyLines.push(` $model = $this->model::create($data);`);
|
|
676
968
|
if (hasTranslatable) {
|
|
677
969
|
bodyLines.push(`
|
|
678
|
-
// -- Translatable:
|
|
679
|
-
|
|
680
|
-
$this->syncTranslations($model, $translations);
|
|
681
|
-
}`);
|
|
970
|
+
// -- Translatable: persist sidecar rows (#78 Step 3) --
|
|
971
|
+
$this->flushTranslations($model);`);
|
|
682
972
|
}
|
|
683
973
|
// Return with eager load
|
|
684
974
|
const loadChain = buildLoadChain(eagerLoad, eagerCount);
|
|
@@ -691,6 +981,8 @@ function buildCreateMethod(modelName, hasAuditCreatedBy, hasTranslatable, transl
|
|
|
691
981
|
bodyLines.push(`
|
|
692
982
|
return $model;`);
|
|
693
983
|
}
|
|
984
|
+
const dataShape = buildWriteDataShape(schema, reader, translatableFields, locales);
|
|
985
|
+
const exampleBlock = buildWriteExampleBlock('create', modelName, schema, reader, translatableFields, locales);
|
|
694
986
|
return `
|
|
695
987
|
/**
|
|
696
988
|
* Create a new ${modelName}.
|
|
@@ -700,13 +992,19 @@ function buildCreateMethod(modelName, hasAuditCreatedBy, hasTranslatable, transl
|
|
|
700
992
|
* - created_by_id is forced from Auth::id(); any client-supplied value
|
|
701
993
|
* is discarded before the insert (#75 item 3 — prevents audit forgery).` : ''}${defaults.length > 0 ? `
|
|
702
994
|
* - Schema defaults are applied for fields not present in $data.` : ''}${hasTranslatable ? `
|
|
703
|
-
* - Translatable fields
|
|
704
|
-
*
|
|
705
|
-
*
|
|
995
|
+
* - Translatable fields use Astrotomic's native 'attr:locale' routing
|
|
996
|
+
* (#78 Step 3 — legacy 'translations' wrapper removed in v3.21.0).
|
|
997
|
+
* flushTranslations() persists sidecar rows even under seeders that
|
|
998
|
+
* run with WithoutModelEvents.` : ''}${eagerLoad.length + eagerCount.length > 0 ? `
|
|
706
999
|
* - Eager relations are loaded onto the returned model so the caller
|
|
707
1000
|
* can render it directly.` : ''}
|
|
708
1001
|
*
|
|
709
|
-
* @param
|
|
1002
|
+
* @param array{
|
|
1003
|
+
${dataShape}
|
|
1004
|
+
* } $data
|
|
1005
|
+
*
|
|
1006
|
+
${exampleBlock}
|
|
1007
|
+
*
|
|
710
1008
|
* @throws \\Illuminate\\Database\\QueryException on constraint violation
|
|
711
1009
|
*/
|
|
712
1010
|
public function create(array $data): ${modelName}
|
|
@@ -717,7 +1015,7 @@ ${bodyLines.join('\n')}
|
|
|
717
1015
|
}`;
|
|
718
1016
|
}
|
|
719
1017
|
// ── update() ────────────────────────────────────────────────────────────────
|
|
720
|
-
function buildUpdateMethod(modelName, hasAuditUpdatedBy, hasTranslatable, eagerLoad, eagerCount) {
|
|
1018
|
+
function buildUpdateMethod(modelName, schemaName, schema, reader, hasAuditUpdatedBy, hasTranslatable, eagerLoad, eagerCount, translatableFields, locales) {
|
|
721
1019
|
const bodyLines = [];
|
|
722
1020
|
if (hasAuditUpdatedBy) {
|
|
723
1021
|
bodyLines.push(` // -- Audit: force updated_by from Auth (#75.3) --
|
|
@@ -727,17 +1025,14 @@ function buildUpdateMethod(modelName, hasAuditUpdatedBy, hasTranslatable, eagerL
|
|
|
727
1025
|
}
|
|
728
1026
|
`);
|
|
729
1027
|
}
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
`);
|
|
734
|
-
}
|
|
1028
|
+
// #78 Step 3: Astrotomic's fill() (called by ->update()) natively routes
|
|
1029
|
+
// 'attr:locale' keys. flushTranslations() below persists sidecar rows so
|
|
1030
|
+
// seeder-style WithoutModelEvents paths still write translation tables.
|
|
735
1031
|
bodyLines.push(` $model->update($data);`);
|
|
736
1032
|
if (hasTranslatable) {
|
|
737
1033
|
bodyLines.push(`
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
}`);
|
|
1034
|
+
// -- Translatable: persist sidecar rows (#78 Step 3) --
|
|
1035
|
+
$this->flushTranslations($model);`);
|
|
741
1036
|
}
|
|
742
1037
|
const loadChain = buildLoadChain(eagerLoad, eagerCount);
|
|
743
1038
|
if (loadChain) {
|
|
@@ -749,16 +1044,24 @@ function buildUpdateMethod(modelName, hasAuditUpdatedBy, hasTranslatable, eagerL
|
|
|
749
1044
|
bodyLines.push(`
|
|
750
1045
|
return $model;`);
|
|
751
1046
|
}
|
|
1047
|
+
const dataShape = buildWriteDataShape(schema, reader, translatableFields, locales);
|
|
1048
|
+
const exampleBlock = buildWriteExampleBlock('update', modelName, schema, reader, translatableFields, locales);
|
|
752
1049
|
return `
|
|
753
1050
|
/**
|
|
754
1051
|
* Update an existing ${modelName}.
|
|
755
1052
|
*
|
|
756
1053
|
* Same transaction + cascade contract as create():${hasAuditUpdatedBy ? `
|
|
757
1054
|
* - updated_by_id is forced from Auth::id() (#75 item 3).` : ''}${hasTranslatable ? `
|
|
758
|
-
* -
|
|
759
|
-
* locales present in $data are touched
|
|
1055
|
+
* - Astrotomic's fill() routes 'attr:locale' keys through translateOrNew,
|
|
1056
|
+
* so only the locales present in $data are touched. flushTranslations()
|
|
1057
|
+
* then persists sidecar rows (seeder-safe — see #78 Step 3).` : ''}
|
|
1058
|
+
*
|
|
1059
|
+
* @param array{
|
|
1060
|
+
${dataShape}
|
|
1061
|
+
* } $data
|
|
1062
|
+
*
|
|
1063
|
+
${exampleBlock}
|
|
760
1064
|
*
|
|
761
|
-
* @param array<string, mixed> $data
|
|
762
1065
|
* @throws \\Illuminate\\Database\\QueryException on constraint violation
|
|
763
1066
|
*/
|
|
764
1067
|
public function update(${modelName} $model, array $data): ${modelName}
|
|
@@ -802,6 +1105,13 @@ function buildRestoreMethod(modelName, eagerLoad, eagerCount) {
|
|
|
802
1105
|
return `
|
|
803
1106
|
/**
|
|
804
1107
|
* Restore a soft-deleted ${modelName} and return it with eager loads applied.
|
|
1108
|
+
*
|
|
1109
|
+
* Operates on a pre-resolved model. To resolve a trashed id first,
|
|
1110
|
+
* call \`findById(\\$id, withTrashed: true)\` — see #79.
|
|
1111
|
+
*
|
|
1112
|
+
* @example Restore a trashed ${modelName} by id
|
|
1113
|
+
* $trashed = $service->findById('01h000000000000000000000000', withTrashed: true);
|
|
1114
|
+
* $restored = $service->restore($trashed);
|
|
805
1115
|
*/
|
|
806
1116
|
public function restore(${modelName} $model): ${modelName}
|
|
807
1117
|
{
|
|
@@ -832,6 +1142,9 @@ function buildForceDeleteMethod(modelName, hasTranslatable) {
|
|
|
832
1142
|
* relying on the FK's ON DELETE behaviour — the migration may or may
|
|
833
1143
|
* not emit CASCADE, and we don't want forceDelete()'s correctness to
|
|
834
1144
|
* depend on schema quirks (#75.7).
|
|
1145
|
+
*
|
|
1146
|
+
* Operates on a pre-resolved model. To resolve a trashed id first,
|
|
1147
|
+
* call \`findById(\\$id, withTrashed: true)\` — see #79.
|
|
835
1148
|
*/
|
|
836
1149
|
public function forceDelete(${modelName} $model): bool
|
|
837
1150
|
{
|
|
@@ -881,7 +1194,7 @@ function buildEmptyTrashMethod(hasTranslatable) {
|
|
|
881
1194
|
}`;
|
|
882
1195
|
}
|
|
883
1196
|
// ── lookup() ────────────────────────────────────────────────────────────────
|
|
884
|
-
function buildLookupMethod(
|
|
1197
|
+
function buildLookupMethod(modelName, lookupFields, filterableFields, defaultSort, tx, reader, hasSoftDelete) {
|
|
885
1198
|
const snakeLookup = lookupFields.map(f => toSnakeCase(f));
|
|
886
1199
|
const returnShape = snakeLookup.map(f => `${f}: mixed`).join(', ');
|
|
887
1200
|
const sortCol = defaultSort.replace(/^-/, '');
|
|
@@ -898,6 +1211,19 @@ function buildLookupMethod(_modelName, lookupFields, filterableFields, defaultSo
|
|
|
898
1211
|
// entire table. Caller can override via ?limit=... up to a hard ceiling.
|
|
899
1212
|
$limit = min((int) ($filters['limit'] ?? 100), 500);
|
|
900
1213
|
$query->limit($limit);`;
|
|
1214
|
+
const lookupFilterShape = buildLookupFilterShape(filterableFields, reader, hasSoftDelete);
|
|
1215
|
+
const lookupExample = buildLookupExampleBlock(modelName, filterableFields, reader);
|
|
1216
|
+
// #79: soft-delete filter block for lookup() mirrors the list() logic.
|
|
1217
|
+
const softDeleteBlock = hasSoftDelete
|
|
1218
|
+
? `
|
|
1219
|
+
// -- SoftDelete filters (#79: mirrors list()) --
|
|
1220
|
+
if (! empty($filters['only_trashed'])) {
|
|
1221
|
+
$query->onlyTrashed();
|
|
1222
|
+
} elseif (! empty($filters['with_trashed'])) {
|
|
1223
|
+
$query->withTrashed();
|
|
1224
|
+
}
|
|
1225
|
+
`
|
|
1226
|
+
: '';
|
|
901
1227
|
if (!hasTranslatableLookup) {
|
|
902
1228
|
// Fast path — no translatable columns involved.
|
|
903
1229
|
const selectFields = snakeLookup.map(f => `'${f}'`).join(', ');
|
|
@@ -909,8 +1235,12 @@ function buildLookupMethod(_modelName, lookupFields, filterableFields, defaultSo
|
|
|
909
1235
|
* every key that list() honours for filterable columns. Default row cap
|
|
910
1236
|
* is 100 (caller override via ?limit=..., hard ceiling 500 — see #75.8).
|
|
911
1237
|
*
|
|
912
|
-
* @param
|
|
1238
|
+
* @param array{
|
|
1239
|
+
${lookupFilterShape}
|
|
1240
|
+
* } $filters
|
|
913
1241
|
* @return array<int, array{${returnShape}}>
|
|
1242
|
+
*
|
|
1243
|
+
${lookupExample}
|
|
914
1244
|
*/
|
|
915
1245
|
public function lookup(array $filters = []): array
|
|
916
1246
|
{
|
|
@@ -918,7 +1248,7 @@ function buildLookupMethod(_modelName, lookupFields, filterableFields, defaultSo
|
|
|
918
1248
|
->select([${selectFields}])
|
|
919
1249
|
->orderBy('${sortCol}', '${sortDir}');
|
|
920
1250
|
|
|
921
|
-
${filterBlock}${limitBlock}
|
|
1251
|
+
${filterBlock}${softDeleteBlock}${limitBlock}
|
|
922
1252
|
|
|
923
1253
|
return $query->get()->toArray();
|
|
924
1254
|
}`;
|
|
@@ -959,8 +1289,12 @@ ${filterBlock}${limitBlock}
|
|
|
959
1289
|
* Default row cap is 100 (caller override via ?limit=..., hard ceiling
|
|
960
1290
|
* 500 — see #75.8).
|
|
961
1291
|
*
|
|
962
|
-
* @param
|
|
1292
|
+
* @param array{
|
|
1293
|
+
${lookupFilterShape}
|
|
1294
|
+
* } $filters
|
|
963
1295
|
* @return array<int, array{${returnShape}}>
|
|
1296
|
+
*
|
|
1297
|
+
${lookupExample}
|
|
964
1298
|
*/
|
|
965
1299
|
public function lookup(array $filters = []): array
|
|
966
1300
|
{
|
|
@@ -984,82 +1318,65 @@ ${filterBlock}${limitBlock}
|
|
|
984
1318
|
})
|
|
985
1319
|
${orderByLine};
|
|
986
1320
|
|
|
987
|
-
${filterBlock}${limitBlock}
|
|
1321
|
+
${filterBlock}${softDeleteBlock}${limitBlock}
|
|
988
1322
|
|
|
989
1323
|
return $query->get()->toArray();
|
|
990
1324
|
}`;
|
|
991
1325
|
}
|
|
992
|
-
// ──
|
|
993
|
-
function
|
|
994
|
-
|
|
995
|
-
|
|
1326
|
+
// ── flushTranslations() ────────────────────────────────────────────────────
|
|
1327
|
+
function buildFlushTranslationsMethod(modelName) {
|
|
1328
|
+
// #78 Step 3: replaces the old extractTranslations + syncTranslations pair.
|
|
1329
|
+
// Astrotomic's Translatable::fill() natively routes 'attr:locale' keys into
|
|
1330
|
+
// translateOrNew($locale)->fill($attrs) so by the time we reach this helper
|
|
1331
|
+
// the dirty translation models are already attached to $model->translations.
|
|
1332
|
+
// Astrotomic's own `saved` event hook normally persists them, but under
|
|
1333
|
+
// DatabaseSeeder::WithoutModelEvents or any code that calls
|
|
1334
|
+
// Model::withoutEvents(...) that hook doesn't fire. Walking the collection
|
|
1335
|
+
// and calling save() on dirty rows makes the persistence path explicit and
|
|
1336
|
+
// seeder-safe.
|
|
1337
|
+
//
|
|
1338
|
+
// #78 Step 3 followup (v3.21.1): mirror Astrotomic's own saveTranslations()
|
|
1339
|
+
// FK-assignment step (Translatable::saveTranslations:382-401) — when the
|
|
1340
|
+
// parent row is created via Model::create() inside WithoutModelEvents, the
|
|
1341
|
+
// new translation models are queued before the parent INSERT runs, so the
|
|
1342
|
+
// belongsTo backref never fires. We must explicitly stamp the FK from the
|
|
1343
|
+
// saved parent before calling $translation->save() or MySQL rejects the
|
|
1344
|
+
// row with "Column 'product_id' cannot be null". Same applies to the
|
|
1345
|
+
// connection name for multi-connection projects.
|
|
996
1346
|
return `
|
|
997
1347
|
/**
|
|
998
|
-
*
|
|
999
|
-
*
|
|
1000
|
-
* Preferred format (Astrotomic-native, flat):
|
|
1001
|
-
* [ "name:ja" => "テスト", "name:en" => "Test" ]
|
|
1002
|
-
*
|
|
1003
|
-
* Legacy format (DEPRECATED — will be removed in a future major release,
|
|
1004
|
-
* see #78):
|
|
1005
|
-
* [ "translations" => [ "ja" => [ "name" => "テスト" ] ] ]
|
|
1348
|
+
* Persist pending translation rows (seeder-safe).
|
|
1006
1349
|
*
|
|
1007
|
-
*
|
|
1008
|
-
*
|
|
1009
|
-
*
|
|
1350
|
+
* Astrotomic's Translatable::fill() (invoked by ${modelName}::create()
|
|
1351
|
+
* and ${modelName}::update()) natively routes 'attr:locale' keys into
|
|
1352
|
+
* translateOrNew(\\$locale)->fill(\\$attrs), leaving the dirty translation
|
|
1353
|
+
* models queued on \\$model->translations. Astrotomic's own \`saved\` event
|
|
1354
|
+
* hook flushes them at runtime — but that hook is suppressed under
|
|
1355
|
+
* DatabaseSeeder::WithoutModelEvents or any call to Model::withoutEvents.
|
|
1356
|
+
* This helper walks the collection and saves dirty/new rows explicitly
|
|
1357
|
+
* (mirroring Astrotomic's own saveTranslations()) so both runtime and
|
|
1358
|
+
* seeder paths write to the sidecar table.
|
|
1010
1359
|
*
|
|
1011
|
-
* @
|
|
1360
|
+
* @see https://github.com/Astrotomic/laravel-translatable/blob/main/src/Translatable/Translatable.php Translatable::saveTranslations()
|
|
1012
1361
|
*/
|
|
1013
|
-
protected function
|
|
1362
|
+
protected function flushTranslations(${modelName} $model): void
|
|
1014
1363
|
{
|
|
1015
|
-
$
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
@trigger_error(
|
|
1024
|
-
'The "translations" wrapper input format is deprecated (omnify #78). '
|
|
1025
|
-
. 'Use the flat "attr:locale" form (e.g. "name:ja" => "..." ) which '
|
|
1026
|
-
. 'Astrotomic Translatable consumes natively. This path will be '
|
|
1027
|
-
. 'removed in omnify v3.21.0.',
|
|
1028
|
-
E_USER_DEPRECATED
|
|
1029
|
-
);
|
|
1030
|
-
$translations = $data['translations'];
|
|
1031
|
-
unset($data['translations']);
|
|
1032
|
-
|
|
1033
|
-
return $translations;
|
|
1034
|
-
}
|
|
1035
|
-
|
|
1036
|
-
// Preferred: flat "attr:locale" keys
|
|
1037
|
-
foreach ($translatedAttributes as $attr) {
|
|
1038
|
-
foreach ($locales as $locale) {
|
|
1039
|
-
$key = "{$attr}:{$locale}";
|
|
1040
|
-
if (isset($data[$key])) {
|
|
1041
|
-
$translations[$locale][$attr] = $data[$key];
|
|
1042
|
-
unset($data[$key]);
|
|
1364
|
+
foreach ($model->translations as $translation) {
|
|
1365
|
+
if (! $translation->exists || $translation->isDirty()) {
|
|
1366
|
+
// Mirror Astrotomic\\Translatable\\Translatable::saveTranslations() —
|
|
1367
|
+
// explicitly stamp the FK and (when set) the connection so the
|
|
1368
|
+
// save works even inside Model::withoutEvents(), where the
|
|
1369
|
+
// belongsTo backref never fires.
|
|
1370
|
+
if (! empty($connectionName = $model->getConnectionName())) {
|
|
1371
|
+
$translation->setConnection($connectionName);
|
|
1043
1372
|
}
|
|
1373
|
+
$translation->setAttribute(
|
|
1374
|
+
$model->getTranslationRelationKey(),
|
|
1375
|
+
$model->getKey()
|
|
1376
|
+
);
|
|
1377
|
+
$translation->save();
|
|
1044
1378
|
}
|
|
1045
1379
|
}
|
|
1046
|
-
|
|
1047
|
-
return $translations;
|
|
1048
|
-
}`;
|
|
1049
|
-
}
|
|
1050
|
-
// ── syncTranslations() ─────────────────────────────────────────────────────
|
|
1051
|
-
function buildSyncTranslationsMethod(modelName) {
|
|
1052
|
-
return `
|
|
1053
|
-
/**
|
|
1054
|
-
* Sync translations for all provided locales.
|
|
1055
|
-
*/
|
|
1056
|
-
protected function syncTranslations(${modelName} $model, array $translations): void
|
|
1057
|
-
{
|
|
1058
|
-
foreach ($translations as $locale => $attrs) {
|
|
1059
|
-
$translation = $model->translateOrNew($locale);
|
|
1060
|
-
$translation->fill($attrs);
|
|
1061
|
-
$translation->save();
|
|
1062
|
-
}
|
|
1063
1380
|
}`;
|
|
1064
1381
|
}
|
|
1065
1382
|
// ============================================================================
|