@omnifyjp/ts 3.19.4 → 3.21.0

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.
@@ -218,9 +218,16 @@ function generateBaseService(name, schema, reader, config) {
218
218
  const hasAuditUpdatedBy = schemaAudit?.updatedBy ?? globalAudit?.updatedBy ?? false;
219
219
  const hasAuditDeletedBy = schemaAudit?.deletedBy ?? globalAudit?.deletedBy ?? false;
220
220
  const hasAnyAudit = hasAuditCreatedBy || hasAuditUpdatedBy || hasAuditDeletedBy;
221
- // Translatable fields
221
+ // Translatable fields + sidecar table info (#76: lookup/sort need join rewrite)
222
222
  const translatableFields = reader.getTranslatableFields(name);
223
223
  const hasTranslatable = translatableFields.length > 0;
224
+ const modelSnake = toSnakeCase(name);
225
+ const translationCtx = {
226
+ mainTable: reader.getTableName(name),
227
+ translationTable: `${modelSnake}_translations`,
228
+ fkColumn: `${modelSnake}_id`,
229
+ translatableFields,
230
+ };
224
231
  // Eager load / count (#75.1: rejoin YAML-split `rel:col,col` tokens)
225
232
  const eagerLoad = reconstructRelationTokens(svc.eagerLoad ?? []);
226
233
  const eagerCount = svc.eagerCount ?? [];
@@ -255,17 +262,17 @@ function generateBaseService(name, schema, reader, config) {
255
262
  // Allowed sort columns whitelist (#75.2: prevent SQL injection via sort filter)
256
263
  const allowedSortColumns = buildAllowedSortColumns(searchableFields, filterableFields, sortableFields, defaultSort, hasSoftDelete);
257
264
  // list() method
258
- sections.push(buildListMethod(modelName, searchableFields, filterableFields, sortableFields, hasSoftDelete, eagerLoad, eagerCount, defaultSort, perPage, allowedSortColumns));
265
+ sections.push(buildListMethod(modelName, searchableFields, filterableFields, sortableFields, hasSoftDelete, eagerLoad, eagerCount, defaultSort, perPage, allowedSortColumns, translationCtx, reader));
259
266
  // findById() method
260
- sections.push(buildFindByIdMethod(modelName, eagerLoad, eagerCount));
267
+ sections.push(buildFindByIdMethod(modelName, eagerLoad, eagerCount, hasSoftDelete));
261
268
  // ── Create section ────────────────────────────────────────────────────────
262
269
  sections.push('');
263
270
  sections.push(buildSectionComment('Create'));
264
- 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));
265
272
  // ── Update section ────────────────────────────────────────────────────────
266
273
  sections.push('');
267
274
  sections.push(buildSectionComment('Update'));
268
- sections.push(buildUpdateMethod(modelName, hasAuditUpdatedBy, hasTranslatable, eagerLoad, eagerCount));
275
+ sections.push(buildUpdateMethod(modelName, name, schema, reader, hasAuditUpdatedBy, hasTranslatable, eagerLoad, eagerCount, translatableFields, locales));
269
276
  // ── Delete & Restore section ──────────────────────────────────────────────
270
277
  sections.push('');
271
278
  if (hasSoftDelete) {
@@ -284,29 +291,40 @@ function generateBaseService(name, schema, reader, config) {
284
291
  if (lookup) {
285
292
  sections.push('');
286
293
  sections.push(buildSectionComment('Lookup'));
287
- sections.push(buildLookupMethod(modelName, lookupFields, filterableFields, defaultSort));
294
+ sections.push(buildLookupMethod(modelName, lookupFields, filterableFields, defaultSort, translationCtx, reader, hasSoftDelete));
288
295
  }
289
296
  // ── Translatable helpers ──────────────────────────────────────────────────
290
297
  if (hasTranslatable) {
291
298
  sections.push('');
292
299
  sections.push(buildSectionComment('Translatable Helpers'));
293
- sections.push(buildExtractTranslationsMethod(translatableFields, locales));
294
- sections.push(buildSyncTranslationsMethod(modelName));
300
+ sections.push(buildFlushTranslationsMethod(modelName));
295
301
  }
296
302
  // ── Assemble file ─────────────────────────────────────────────────────────
303
+ const classDoc = buildClassDocblock({
304
+ modelName,
305
+ schemaName: name,
306
+ modelFqcn: `${modelNs}\\${modelName}`,
307
+ searchableFields,
308
+ filterableFields,
309
+ allowedSortColumns,
310
+ defaultSort,
311
+ perPage,
312
+ eagerLoad,
313
+ eagerCount,
314
+ translatableFields,
315
+ hasSoftDelete,
316
+ hasAnyAudit,
317
+ lookup,
318
+ lookupFields,
319
+ locales,
320
+ });
297
321
  const content = `<?php
298
322
 
299
323
  namespace ${baseNs};
300
324
 
301
- /**
302
- * DO NOT EDIT - This file is auto-generated by Omnify.
303
- * Any changes will be overwritten on next generation.
304
- *
305
- * @generated by omnify
306
- */
307
-
308
325
  ${imports.join('\n')}
309
326
 
327
+ ${classDoc}
310
328
  class ${modelName}ServiceBase
311
329
  {
312
330
  ${sections.join('\n')}
@@ -322,6 +340,331 @@ function buildSectionComment(title) {
322
340
  // ${title}
323
341
  // =========================================================================`;
324
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
+ }
591
+ function buildClassDocblock(c) {
592
+ const lines = [];
593
+ lines.push('/**');
594
+ lines.push(` * ${c.modelName}ServiceBase — auto-generated service layer for the ${c.schemaName} schema.`);
595
+ lines.push(' *');
596
+ lines.push(` * Model: ${c.modelFqcn}`);
597
+ lines.push(' * Extends: (none) — consumers extend this via the sibling non-Base file,');
598
+ lines.push(' * which is never overwritten.');
599
+ lines.push(' *');
600
+ lines.push(' * === list() filter contract ===');
601
+ if (c.searchableFields.length > 0) {
602
+ const searchCols = c.searchableFields
603
+ .map(f => f.translatable ? `${f.colName} (translated)` : f.colName)
604
+ .join(', ');
605
+ lines.push(` * search (LIKE) — against: ${searchCols}`);
606
+ }
607
+ for (const f of c.filterableFields) {
608
+ const phpType = resolvePhpFilterType(f.type);
609
+ const note = f.translatable ? ' (translatable)' : '';
610
+ lines.push(` * ${f.colName.padEnd(13)} (${phpType})${note} — from options.service.filterable`);
611
+ }
612
+ lines.push(` * sort (col) — whitelisted: ${c.allowedSortColumns.join(', ')}.`);
613
+ lines.push(` * Default: ${c.defaultSort}. Prefix '-' for DESC.`);
614
+ if (c.hasSoftDelete) {
615
+ lines.push(' * only_trashed (bool) — wins over with_trashed when both set.');
616
+ lines.push(' * with_trashed (bool)');
617
+ }
618
+ lines.push(` * per_page (int) — default ${c.perPage}.`);
619
+ lines.push(' *');
620
+ if (c.eagerLoad.length > 0 || c.eagerCount.length > 0) {
621
+ lines.push(' * === Relations ===');
622
+ if (c.eagerLoad.length > 0) {
623
+ lines.push(` * Eager loads: ${c.eagerLoad.join(', ')}`);
624
+ }
625
+ if (c.eagerCount.length > 0) {
626
+ lines.push(` * Eager counts: ${c.eagerCount.join(', ')}`);
627
+ }
628
+ lines.push(' *');
629
+ }
630
+ if (c.translatableFields.length > 0) {
631
+ lines.push(' * === Translatable ===');
632
+ lines.push(` * Fields: ${c.translatableFields.join(', ')}`);
633
+ lines.push(` * Locales: ${c.locales.join(', ')}`);
634
+ lines.push(` * Sidecar: ${toSnakeCase(c.schemaName)}_translations (synced in create/update)`);
635
+ lines.push(' *');
636
+ lines.push(' * Input format (Astrotomic-native flat — #78 Step 3):');
637
+ const sampleField = c.translatableFields[0] ?? 'name';
638
+ const sampleLocale = c.locales[0] ?? 'ja';
639
+ lines.push(` * [ "${sampleField}:${sampleLocale}" => "...", "${sampleField}:${c.locales[1] ?? 'en'}" => "..." ]`);
640
+ 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.');
645
+ lines.push(' *');
646
+ lines.push(' * Lookup/sort on translatable columns rewrites to a LEFT JOIN on the');
647
+ lines.push(' * sidecar for the request locale + fallback (see #76).');
648
+ lines.push(' *');
649
+ }
650
+ if (c.hasSoftDelete) {
651
+ lines.push(' * Soft delete: enabled (delete → trash, forceDelete → hard delete, restore → untrash).');
652
+ }
653
+ if (c.hasAnyAudit) {
654
+ lines.push(' * Audit: created_by/updated_by/deleted_by forced from Auth::id() (client');
655
+ lines.push(' * values are discarded — see #75 item 3).');
656
+ }
657
+ if (c.lookup) {
658
+ lines.push(` * Lookup: returns ${c.lookupFields.join(', ')} (default limit 100, hard cap 500 — #75.8).`);
659
+ }
660
+ lines.push(' *');
661
+ lines.push(' * DO NOT EDIT - This file is auto-generated by Omnify.');
662
+ lines.push(' * Any changes will be overwritten on next generation.');
663
+ lines.push(' *');
664
+ lines.push(' * @generated by omnify');
665
+ lines.push(' */');
666
+ return lines.join('\n');
667
+ }
325
668
  function buildEagerLoadChain(eagerLoad, eagerCount) {
326
669
  const parts = [];
327
670
  if (eagerLoad.length > 0) {
@@ -367,13 +710,25 @@ function buildAllowedSortColumns(searchableFields, filterableFields, sortableFie
367
710
  set.add(defaultCol);
368
711
  return Array.from(set).sort();
369
712
  }
370
- function buildListMethod(modelName, searchableFields, filterableFields, sortableFields, hasSoftDelete, eagerLoad, eagerCount, defaultSort, perPage, allowedSortColumns) {
713
+ function buildListMethod(modelName, searchableFields, filterableFields, sortableFields, hasSoftDelete, eagerLoad, eagerCount, defaultSort, perPage, allowedSortColumns, tx, reader) {
371
714
  const lines = [];
372
- // PHPDoc with filter shape
373
- const filterShape = buildFilterDocShape(filterableFields, hasSoftDelete);
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);
374
719
  lines.push(`
375
720
  /**
376
- * @param array{${filterShape}} $filters
721
+ * List ${modelName} records with filters, search, sort and pagination.
722
+ *
723
+ * Filter keys are whitelisted from the schema's options.service config;
724
+ * unknown keys are ignored. The sort column is whitelisted against
725
+ * searchable + filterable + standard audit columns (#75.2).
726
+ *
727
+ * @param array{
728
+ ${filterShape}
729
+ * } $filters
730
+ *
731
+ ${listExample}
377
732
  */
378
733
  public function list(array $filters = []): LengthAwarePaginator
379
734
  {
@@ -413,7 +768,7 @@ function buildListMethod(modelName, searchableFields, filterableFields, sortable
413
768
  }
414
769
  // Sort
415
770
  lines.push('');
416
- lines.push(buildSortSection(sortableFields, defaultSort, allowedSortColumns));
771
+ lines.push(buildSortSection(sortableFields, defaultSort, allowedSortColumns, tx));
417
772
  // Paginate
418
773
  lines.push(`
419
774
  return $query->paginate($filters['per_page'] ?? ${perPage});
@@ -473,11 +828,14 @@ ${clauses.join('\n')}
473
828
  });
474
829
  });`;
475
830
  }
476
- function buildSortSection(_sortableFields, defaultSort, allowedSortColumns) {
831
+ function buildSortSection(_sortableFields, defaultSort, allowedSortColumns, tx) {
477
832
  const defaultCol = defaultSort.replace(/^-/, '');
478
833
  const allowedList = allowedSortColumns.map(c => `'${c}'`).join(', ');
479
- // #75.2: whitelist sort column to prevent SQL injection / arbitrary column access
480
- return ` // -- Sort (#75.2: whitelist guards against SQL injection) --
834
+ const hasTranslatableSortable = tx.translatableFields.length > 0 &&
835
+ allowedSortColumns.some(c => tx.translatableFields.includes(c));
836
+ if (!hasTranslatableSortable) {
837
+ // Fast path: nothing translatable in the allowlist, plain orderBy works.
838
+ return ` // -- Sort (#75.2: whitelist guards against SQL injection) --
481
839
  $sort = $filters['sort'] ?? '${defaultSort}';
482
840
  $direction = str_starts_with($sort, '-') ? 'desc' : 'asc';
483
841
  $column = ltrim($sort, '-');
@@ -486,19 +844,104 @@ function buildSortSection(_sortableFields, defaultSort, allowedSortColumns) {
486
844
  $column = '${defaultCol}';
487
845
  }
488
846
  $query->orderBy($column, $direction);`;
847
+ }
848
+ // #76: one or more whitelisted columns live in the translation sidecar.
849
+ // Runtime-branch on $column: translatable → leftJoin + orderByRaw with
850
+ // COALESCE(locale, fallback); non-translatable → plain orderBy.
851
+ const translatablePhpList = tx.translatableFields
852
+ .filter(f => allowedSortColumns.includes(f))
853
+ .map(f => `'${f}'`)
854
+ .join(', ');
855
+ const mainTable = tx.mainTable;
856
+ const trTable = tx.translationTable;
857
+ const fk = tx.fkColumn;
858
+ return ` // -- Sort (#75.2 whitelist + #76 translatable join rewrite) --
859
+ $sort = $filters['sort'] ?? '${defaultSort}';
860
+ $direction = str_starts_with($sort, '-') ? 'desc' : 'asc';
861
+ $column = ltrim($sort, '-');
862
+ $allowedSortColumns = [${allowedList}];
863
+ if (! in_array($column, $allowedSortColumns, true)) {
864
+ $column = '${defaultCol}';
865
+ }
866
+ $translatableSortColumns = [${translatablePhpList}];
867
+ if (in_array($column, $translatableSortColumns, true)) {
868
+ // #76: sort column lives in '${trTable}'. LEFT JOIN once on the
869
+ // request locale and once on the fallback locale, then COALESCE
870
+ // so missing translations don't push rows to the top/bottom.
871
+ $locale = app()->getLocale();
872
+ $fallback = config('translatable.fallback_locale', 'en');
873
+ $query
874
+ ->leftJoin('${trTable} as tr_sort', function ($j) use ($locale) {
875
+ $j->on('tr_sort.${fk}', '=', '${mainTable}.id')
876
+ ->where('tr_sort.locale', $locale);
877
+ })
878
+ ->leftJoin('${trTable} as tr_sort_fb', function ($j) use ($fallback) {
879
+ $j->on('tr_sort_fb.${fk}', '=', '${mainTable}.id')
880
+ ->where('tr_sort_fb.locale', $fallback);
881
+ })
882
+ ->select('${mainTable}.*')
883
+ ->orderByRaw("COALESCE(tr_sort.\`{$column}\`, tr_sort_fb.\`{$column}\`) " . strtoupper($direction));
884
+ } else {
885
+ $query->orderBy($column, $direction);
886
+ }`;
489
887
  }
490
888
  // ── findById() ──────────────────────────────────────────────────────────────
491
- function buildFindByIdMethod(modelName, eagerLoad, eagerCount) {
889
+ function buildFindByIdMethod(modelName, eagerLoad, eagerCount, hasSoftDelete) {
492
890
  const eagerChain = buildEagerLoadChain(eagerLoad, eagerCount);
493
891
  const chain = eagerChain ? `\n${eagerChain}\n ` : '';
494
- return `
892
+ if (!hasSoftDelete) {
893
+ return `
894
+ /**
895
+ * Load a single ${modelName} by primary key with the configured eager
896
+ * loads applied. Throws ModelNotFoundException (→ HTTP 404) on miss.
897
+ *
898
+ * @throws \\Illuminate\\Database\\Eloquent\\ModelNotFoundException
899
+ *
900
+ * @example Fetch a single ${modelName} by id
901
+ * $model = $service->findById('01h000000000000000000000000');
902
+ */
495
903
  public function findById(string $id): ${modelName}
496
904
  {
497
905
  return $this->model::query()${chain}->findOrFail($id);
498
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
+ }`;
499
942
  }
500
943
  // ── create() ────────────────────────────────────────────────────────────────
501
- function buildCreateMethod(modelName, hasAuditCreatedBy, hasTranslatable, translatableFields, eagerLoad, eagerCount, defaults) {
944
+ function buildCreateMethod(modelName, schemaName, schema, reader, hasAuditCreatedBy, hasTranslatable, translatableFields, eagerLoad, eagerCount, defaults, locales) {
502
945
  const bodyLines = [];
503
946
  // Audit (#75.3: always overwrite — never trust client-supplied audit columns)
504
947
  if (hasAuditCreatedBy) {
@@ -517,19 +960,15 @@ function buildCreateMethod(modelName, hasAuditCreatedBy, hasTranslatable, transl
517
960
  }
518
961
  bodyLines.push('');
519
962
  }
520
- // Translatable
521
- if (hasTranslatable) {
522
- bodyLines.push(` // -- Translatable: extract locale-keyed data --
523
- $translations = $this->extractTranslations($data);
524
- `);
525
- }
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).
526
967
  bodyLines.push(` $model = $this->model::create($data);`);
527
968
  if (hasTranslatable) {
528
969
  bodyLines.push(`
529
- // -- Translatable: sync translations for all locales --
530
- if (! empty($translations)) {
531
- $this->syncTranslations($model, $translations);
532
- }`);
970
+ // -- Translatable: persist sidecar rows (#78 Step 3) --
971
+ $this->flushTranslations($model);`);
533
972
  }
534
973
  // Return with eager load
535
974
  const loadChain = buildLoadChain(eagerLoad, eagerCount);
@@ -542,9 +981,31 @@ function buildCreateMethod(modelName, hasAuditCreatedBy, hasTranslatable, transl
542
981
  bodyLines.push(`
543
982
  return $model;`);
544
983
  }
984
+ const dataShape = buildWriteDataShape(schema, reader, translatableFields, locales);
985
+ const exampleBlock = buildWriteExampleBlock('create', modelName, schema, reader, translatableFields, locales);
545
986
  return `
546
987
  /**
547
- * @param array<string, mixed> $data
988
+ * Create a new ${modelName}.
989
+ *
990
+ * Runs inside a DB transaction so partial failures roll back cleanly.
991
+ * Behaviour derived from the schema:${hasAuditCreatedBy ? `
992
+ * - created_by_id is forced from Auth::id(); any client-supplied value
993
+ * is discarded before the insert (#75 item 3 — prevents audit forgery).` : ''}${defaults.length > 0 ? `
994
+ * - Schema defaults are applied for fields not present in $data.` : ''}${hasTranslatable ? `
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 ? `
999
+ * - Eager relations are loaded onto the returned model so the caller
1000
+ * can render it directly.` : ''}
1001
+ *
1002
+ * @param array{
1003
+ ${dataShape}
1004
+ * } $data
1005
+ *
1006
+ ${exampleBlock}
1007
+ *
1008
+ * @throws \\Illuminate\\Database\\QueryException on constraint violation
548
1009
  */
549
1010
  public function create(array $data): ${modelName}
550
1011
  {
@@ -554,7 +1015,7 @@ ${bodyLines.join('\n')}
554
1015
  }`;
555
1016
  }
556
1017
  // ── update() ────────────────────────────────────────────────────────────────
557
- function buildUpdateMethod(modelName, hasAuditUpdatedBy, hasTranslatable, eagerLoad, eagerCount) {
1018
+ function buildUpdateMethod(modelName, schemaName, schema, reader, hasAuditUpdatedBy, hasTranslatable, eagerLoad, eagerCount, translatableFields, locales) {
558
1019
  const bodyLines = [];
559
1020
  if (hasAuditUpdatedBy) {
560
1021
  bodyLines.push(` // -- Audit: force updated_by from Auth (#75.3) --
@@ -564,17 +1025,14 @@ function buildUpdateMethod(modelName, hasAuditUpdatedBy, hasTranslatable, eagerL
564
1025
  }
565
1026
  `);
566
1027
  }
567
- if (hasTranslatable) {
568
- bodyLines.push(` // -- Translatable: extract and sync --
569
- $translations = $this->extractTranslations($data);
570
- `);
571
- }
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.
572
1031
  bodyLines.push(` $model->update($data);`);
573
1032
  if (hasTranslatable) {
574
1033
  bodyLines.push(`
575
- if (! empty($translations)) {
576
- $this->syncTranslations($model, $translations);
577
- }`);
1034
+ // -- Translatable: persist sidecar rows (#78 Step 3) --
1035
+ $this->flushTranslations($model);`);
578
1036
  }
579
1037
  const loadChain = buildLoadChain(eagerLoad, eagerCount);
580
1038
  if (loadChain) {
@@ -586,9 +1044,25 @@ function buildUpdateMethod(modelName, hasAuditUpdatedBy, hasTranslatable, eagerL
586
1044
  bodyLines.push(`
587
1045
  return $model;`);
588
1046
  }
1047
+ const dataShape = buildWriteDataShape(schema, reader, translatableFields, locales);
1048
+ const exampleBlock = buildWriteExampleBlock('update', modelName, schema, reader, translatableFields, locales);
589
1049
  return `
590
1050
  /**
591
- * @param array<string, mixed> $data
1051
+ * Update an existing ${modelName}.
1052
+ *
1053
+ * Same transaction + cascade contract as create():${hasAuditUpdatedBy ? `
1054
+ * - updated_by_id is forced from Auth::id() (#75 item 3).` : ''}${hasTranslatable ? `
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}
1064
+ *
1065
+ * @throws \\Illuminate\\Database\\QueryException on constraint violation
592
1066
  */
593
1067
  public function update(${modelName} $model, array $data): ${modelName}
594
1068
  {
@@ -609,6 +1083,12 @@ function buildDeleteMethod(modelName, hasSoftDelete, hasAuditDeletedBy) {
609
1083
  }
610
1084
  bodyLines.push(` return $model->delete();`);
611
1085
  return `
1086
+ /**
1087
+ * ${hasSoftDelete ? 'Soft-delete' : 'Delete'} a ${modelName}${hasSoftDelete ? ' (moves to trash, restorable via restore())' : ''}.${hasAuditDeletedBy && hasSoftDelete ? `
1088
+ *
1089
+ * deleted_by_id is stamped from Auth::id() inside the same transaction
1090
+ * so audit trails stay consistent even if the delete fails.` : ''}
1091
+ */
612
1092
  public function delete(${modelName} $model): bool
613
1093
  {
614
1094
  return DB::transaction(function () use ($model) {
@@ -623,6 +1103,16 @@ function buildRestoreMethod(modelName, eagerLoad, eagerCount) {
623
1103
  ? ` $model->restore();\n\n return $model\n ${loadChain};`
624
1104
  : ` $model->restore();\n\n return $model;`;
625
1105
  return `
1106
+ /**
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);
1115
+ */
626
1116
  public function restore(${modelName} $model): ${modelName}
627
1117
  {
628
1118
  return DB::transaction(function () use ($model) {
@@ -634,6 +1124,9 @@ ${returnLine}
634
1124
  function buildForceDeleteMethod(modelName, hasTranslatable) {
635
1125
  if (!hasTranslatable) {
636
1126
  return `
1127
+ /**
1128
+ * Hard-delete a ${modelName} (bypasses soft-delete, removes the row).
1129
+ */
637
1130
  public function forceDelete(${modelName} $model): bool
638
1131
  {
639
1132
  return DB::transaction(fn () => $model->forceDelete());
@@ -642,6 +1135,17 @@ function buildForceDeleteMethod(modelName, hasTranslatable) {
642
1135
  // #75.7: explicitly drop translations inside the same transaction so the
643
1136
  // service contract does not rely on the FK's ON DELETE behaviour.
644
1137
  return `
1138
+ /**
1139
+ * Hard-delete a ${modelName} and its translation rows.
1140
+ *
1141
+ * We explicitly delete translations inside the transaction instead of
1142
+ * relying on the FK's ON DELETE behaviour — the migration may or may
1143
+ * not emit CASCADE, and we don't want forceDelete()'s correctness to
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.
1148
+ */
645
1149
  public function forceDelete(${modelName} $model): bool
646
1150
  {
647
1151
  return DB::transaction(function () use ($model) {
@@ -656,6 +1160,9 @@ function buildForceDeleteMethod(modelName, hasTranslatable) {
656
1160
  function buildEmptyTrashMethod(hasTranslatable) {
657
1161
  if (!hasTranslatable) {
658
1162
  return `
1163
+ /**
1164
+ * Permanently delete every trashed row. Returns rows-affected count.
1165
+ */
659
1166
  public function emptyTrash(): int
660
1167
  {
661
1168
  return $this->model::onlyTrashed()->forceDelete();
@@ -665,10 +1172,16 @@ function buildEmptyTrashMethod(hasTranslatable) {
665
1172
  // so translations would be orphaned. Chunk through trashed rows and
666
1173
  // delegate to forceDelete() which cascades explicitly.
667
1174
  return `
1175
+ /**
1176
+ * Permanently delete every trashed row, cascading translations.
1177
+ *
1178
+ * Iterates via chunkById(100) and delegates to forceDelete() so the
1179
+ * translation cascade in forceDelete() runs per row. A query-builder
1180
+ * DELETE would be faster but would skip that cascade and orphan
1181
+ * translation rows — see #75 followup comment.
1182
+ */
668
1183
  public function emptyTrash(): int
669
1184
  {
670
- // #75 followup: delegate to per-model forceDelete so the translation
671
- // cascade in forceDelete() runs. A query-builder DELETE would skip it.
672
1185
  $count = 0;
673
1186
  $this->model::onlyTrashed()->chunkById(100, function ($batch) use (&$count) {
674
1187
  foreach ($batch as $model) {
@@ -681,20 +1194,53 @@ function buildEmptyTrashMethod(hasTranslatable) {
681
1194
  }`;
682
1195
  }
683
1196
  // ── lookup() ────────────────────────────────────────────────────────────────
684
- function buildLookupMethod(_modelName, lookupFields, filterableFields, defaultSort) {
685
- const selectFields = lookupFields.map(f => `'${toSnakeCase(f)}'`).join(', ');
686
- const returnShape = lookupFields.map(f => `${toSnakeCase(f)}: mixed`).join(', ');
687
- // Sort by first non-id field or default
1197
+ function buildLookupMethod(modelName, lookupFields, filterableFields, defaultSort, tx, reader, hasSoftDelete) {
1198
+ const snakeLookup = lookupFields.map(f => toSnakeCase(f));
1199
+ const returnShape = snakeLookup.map(f => `${f}: mixed`).join(', ');
688
1200
  const sortCol = defaultSort.replace(/^-/, '');
689
1201
  const sortDir = defaultSort.startsWith('-') ? 'desc' : 'asc';
1202
+ const translatableSet = new Set(tx.translatableFields);
1203
+ const hasTranslatableLookup = snakeLookup.some(f => translatableSet.has(f)) ||
1204
+ (translatableSet.has(sortCol) && tx.translationTable.length > 0);
690
1205
  // Apply filterable columns — reuse buildFilterLine so Boolean handling
691
- // matches list() (#75.4: `?? null` collapses `false` so lookup() silently
692
- // dropped `is_hidden=false`; list() uses isset() which is correct).
1206
+ // matches list() (#75.4).
693
1207
  const filterLines = filterableFields.map(f => buildFilterLine(f));
694
- return `
1208
+ const filterBlock = filterLines.length > 0 ? filterLines.join('\n') + '\n\n' : '';
1209
+ // #75.8: cap results (shared by both branches)
1210
+ const limitBlock = ` // #75.8: cap results so type-ahead / dropdown endpoints never load the
1211
+ // entire table. Caller can override via ?limit=... up to a hard ceiling.
1212
+ $limit = min((int) ($filters['limit'] ?? 100), 500);
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
+ : '';
1227
+ if (!hasTranslatableLookup) {
1228
+ // Fast path — no translatable columns involved.
1229
+ const selectFields = snakeLookup.map(f => `'${f}'`).join(', ');
1230
+ return `
695
1231
  /**
696
- * @param array<string, mixed> $filters
1232
+ * Lightweight list-projection for dropdowns, type-aheads and autocomplete.
1233
+ *
1234
+ * Returns a flat array of {${returnShape}} rows (never a paginator). Honours
1235
+ * every key that list() honours for filterable columns. Default row cap
1236
+ * is 100 (caller override via ?limit=..., hard ceiling 500 — see #75.8).
1237
+ *
1238
+ * @param array{
1239
+ ${lookupFilterShape}
1240
+ * } $filters
697
1241
  * @return array<int, array{${returnShape}}>
1242
+ *
1243
+ ${lookupExample}
698
1244
  */
699
1245
  public function lookup(array $filters = []): array
700
1246
  {
@@ -702,69 +1248,113 @@ function buildLookupMethod(_modelName, lookupFields, filterableFields, defaultSo
702
1248
  ->select([${selectFields}])
703
1249
  ->orderBy('${sortCol}', '${sortDir}');
704
1250
 
705
- ${filterLines.length > 0 ? filterLines.join('\n') + '\n' : ''}
706
- // #75.8: cap results so type-ahead / dropdown endpoints never load the
707
- // entire table. Caller can override via ?limit=... up to a hard ceiling.
708
- $limit = min((int) ($filters['limit'] ?? 100), 500);
709
- $query->limit($limit);
1251
+ ${filterBlock}${softDeleteBlock}${limitBlock}
710
1252
 
711
1253
  return $query->get()->toArray();
712
1254
  }`;
713
- }
714
- // ── extractTranslations() ───────────────────────────────────────────────────
715
- function buildExtractTranslationsMethod(translatableFields, locales) {
716
- const fieldsArray = translatableFields.map(f => `'${f}'`).join(', ');
717
- const localesArray = locales.map(l => `'${l}'`).join(', ');
1255
+ }
1256
+ // #76: translatable branch — leftJoin the sidecar keyed by
1257
+ // request locale + fallback locale, COALESCE the translated columns so
1258
+ // missing translations fall back gracefully.
1259
+ const translatedCols = snakeLookup.filter(f => translatableSet.has(f));
1260
+ const plainCols = snakeLookup.filter(f => !translatableSet.has(f));
1261
+ const mainTable = tx.mainTable;
1262
+ const trTable = tx.translationTable;
1263
+ const fk = tx.fkColumn;
1264
+ const selectParts = [];
1265
+ selectParts.push(`'${mainTable}.id'`);
1266
+ for (const col of plainCols) {
1267
+ if (col !== 'id')
1268
+ selectParts.push(`'${mainTable}.${col}'`);
1269
+ }
1270
+ // COALESCE for translatable columns
1271
+ for (const col of translatedCols) {
1272
+ selectParts.push(`DB::raw('COALESCE(tr_current.\`${col}\`, tr_fallback.\`${col}\`) AS \`${col}\`')`);
1273
+ }
1274
+ const selectList = selectParts.join(',\n ');
1275
+ const sortIsTranslatable = translatableSet.has(sortCol);
1276
+ const orderByLine = sortIsTranslatable
1277
+ ? `->orderByRaw("COALESCE(tr_current.\`${sortCol}\`, tr_fallback.\`${sortCol}\`) ${sortDir.toUpperCase()}")`
1278
+ : `->orderBy('${mainTable}.${sortCol}', '${sortDir}')`;
718
1279
  return `
719
1280
  /**
720
- * Extract translation data from input.
1281
+ * Lightweight list-projection for dropdowns, type-aheads and autocomplete.
1282
+ *
1283
+ * Returns a flat array of {${returnShape}} rows (never a paginator). One or
1284
+ * more of the returned columns is translatable, so this method LEFT JOINs
1285
+ * the sidecar ${trTable} on both the request locale and the fallback
1286
+ * locale, then COALESCEs the translated column so missing translations
1287
+ * degrade gracefully instead of surfacing as NULL (#76).
1288
+ *
1289
+ * Default row cap is 100 (caller override via ?limit=..., hard ceiling
1290
+ * 500 — see #75.8).
721
1291
  *
722
- * Supports two formats:
723
- * 1. Flat: { "name:ja": "テスト", "name:en": "Test" }
724
- * 2. Nested: { "translations": { "ja": { "name": "テスト" }, "en": { "name": "Test" } } }
1292
+ * @param array{
1293
+ ${lookupFilterShape}
1294
+ * } $filters
1295
+ * @return array<int, array{${returnShape}}>
725
1296
  *
726
- * @return array<string, array<string, string>> locale => [attr => value]
1297
+ ${lookupExample}
727
1298
  */
728
- protected function extractTranslations(array &$data): array
1299
+ public function lookup(array $filters = []): array
729
1300
  {
730
- $translatedAttributes = [${fieldsArray}];
731
- $locales = [${localesArray}];
732
- $translations = [];
733
-
734
- // Format 2: nested "translations" key
735
- if (isset($data['translations']) && is_array($data['translations'])) {
736
- $translations = $data['translations'];
737
- unset($data['translations']);
1301
+ // #76: translatable columns live in '${trTable}'. Join once on the
1302
+ // request locale and once on the configured fallback so missing
1303
+ // translations degrade gracefully instead of returning NULL.
1304
+ $locale = app()->getLocale();
1305
+ $fallback = config('translatable.fallback_locale', 'en');
738
1306
 
739
- return $translations;
740
- }
1307
+ $query = $this->model::query()
1308
+ ->select([
1309
+ ${selectList},
1310
+ ])
1311
+ ->leftJoin('${trTable} as tr_current', function ($j) use ($locale) {
1312
+ $j->on('tr_current.${fk}', '=', '${mainTable}.id')
1313
+ ->where('tr_current.locale', $locale);
1314
+ })
1315
+ ->leftJoin('${trTable} as tr_fallback', function ($j) use ($fallback) {
1316
+ $j->on('tr_fallback.${fk}', '=', '${mainTable}.id')
1317
+ ->where('tr_fallback.locale', $fallback);
1318
+ })
1319
+ ${orderByLine};
741
1320
 
742
- // Format 1: flat "attr:locale" keys
743
- foreach ($translatedAttributes as $attr) {
744
- foreach ($locales as $locale) {
745
- $key = "{$attr}:{$locale}";
746
- if (isset($data[$key])) {
747
- $translations[$locale][$attr] = $data[$key];
748
- unset($data[$key]);
749
- }
750
- }
751
- }
1321
+ ${filterBlock}${softDeleteBlock}${limitBlock}
752
1322
 
753
- return $translations;
1323
+ return $query->get()->toArray();
754
1324
  }`;
755
1325
  }
756
- // ── syncTranslations() ─────────────────────────────────────────────────────
757
- function buildSyncTranslationsMethod(modelName) {
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.
758
1337
  return `
759
1338
  /**
760
- * Sync translations for all provided locales.
1339
+ * Persist pending translation rows (seeder-safe).
1340
+ *
1341
+ * Astrotomic's Translatable::fill() (invoked by ${modelName}::create()
1342
+ * and ${modelName}::update()) natively routes 'attr:locale' keys into
1343
+ * translateOrNew(\\$locale)->fill(\\$attrs), leaving the dirty translation
1344
+ * models queued on \\$model->translations. Astrotomic's own \`saved\` event
1345
+ * hook flushes them at runtime — but that hook is suppressed under
1346
+ * DatabaseSeeder::WithoutModelEvents or any call to Model::withoutEvents.
1347
+ * This helper walks the collection and saves dirty/new rows explicitly so
1348
+ * both runtime and seeder paths write to the sidecar table.
1349
+ *
1350
+ * @see https://github.com/Astrotomic/laravel-translatable/blob/main/src/Translatable/Translatable.php Translatable::fill()
761
1351
  */
762
- protected function syncTranslations(${modelName} $model, array $translations): void
1352
+ protected function flushTranslations(${modelName} $model): void
763
1353
  {
764
- foreach ($translations as $locale => $attrs) {
765
- $translation = $model->translateOrNew($locale);
766
- $translation->fill($attrs);
767
- $translation->save();
1354
+ foreach ($model->translations as $translation) {
1355
+ if (! $translation->exists || $translation->isDirty()) {
1356
+ $translation->save();
1357
+ }
768
1358
  }
769
1359
  }`;
770
1360
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnifyjp/ts",
3
- "version": "3.19.4",
3
+ "version": "3.21.0",
4
4
  "description": "TypeScript model type generator from Omnify schemas.json",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",