@omnifyjp/ts 3.19.3 → 3.20.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,7 +262,7 @@ 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));
259
266
  // findById() method
260
267
  sections.push(buildFindByIdMethod(modelName, eagerLoad, eagerCount));
261
268
  // ── Create section ────────────────────────────────────────────────────────
@@ -278,13 +285,13 @@ function generateBaseService(name, schema, reader, config) {
278
285
  if (hasSoftDelete) {
279
286
  sections.push(buildRestoreMethod(modelName, eagerLoad, eagerCount));
280
287
  sections.push(buildForceDeleteMethod(modelName, hasTranslatable));
281
- sections.push(buildEmptyTrashMethod());
288
+ sections.push(buildEmptyTrashMethod(hasTranslatable));
282
289
  }
283
290
  // ── Lookup section ────────────────────────────────────────────────────────
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));
288
295
  }
289
296
  // ── Translatable helpers ──────────────────────────────────────────────────
290
297
  if (hasTranslatable) {
@@ -294,19 +301,31 @@ function generateBaseService(name, schema, reader, config) {
294
301
  sections.push(buildSyncTranslationsMethod(modelName));
295
302
  }
296
303
  // ── Assemble file ─────────────────────────────────────────────────────────
304
+ const classDoc = buildClassDocblock({
305
+ modelName,
306
+ schemaName: name,
307
+ modelFqcn: `${modelNs}\\${modelName}`,
308
+ searchableFields,
309
+ filterableFields,
310
+ allowedSortColumns,
311
+ defaultSort,
312
+ perPage,
313
+ eagerLoad,
314
+ eagerCount,
315
+ translatableFields,
316
+ hasSoftDelete,
317
+ hasAnyAudit,
318
+ lookup,
319
+ lookupFields,
320
+ locales,
321
+ });
297
322
  const content = `<?php
298
323
 
299
324
  namespace ${baseNs};
300
325
 
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
326
  ${imports.join('\n')}
309
327
 
328
+ ${classDoc}
310
329
  class ${modelName}ServiceBase
311
330
  {
312
331
  ${sections.join('\n')}
@@ -322,6 +341,81 @@ function buildSectionComment(title) {
322
341
  // ${title}
323
342
  // =========================================================================`;
324
343
  }
344
+ function buildClassDocblock(c) {
345
+ const lines = [];
346
+ lines.push('/**');
347
+ lines.push(` * ${c.modelName}ServiceBase — auto-generated service layer for the ${c.schemaName} schema.`);
348
+ lines.push(' *');
349
+ lines.push(` * Model: ${c.modelFqcn}`);
350
+ lines.push(' * Extends: (none) — consumers extend this via the sibling non-Base file,');
351
+ lines.push(' * which is never overwritten.');
352
+ lines.push(' *');
353
+ lines.push(' * === list() filter contract ===');
354
+ if (c.searchableFields.length > 0) {
355
+ const searchCols = c.searchableFields
356
+ .map(f => f.translatable ? `${f.colName} (translated)` : f.colName)
357
+ .join(', ');
358
+ lines.push(` * search (LIKE) — against: ${searchCols}`);
359
+ }
360
+ for (const f of c.filterableFields) {
361
+ const phpType = resolvePhpFilterType(f.type);
362
+ const note = f.translatable ? ' (translatable)' : '';
363
+ lines.push(` * ${f.colName.padEnd(13)} (${phpType})${note} — from options.service.filterable`);
364
+ }
365
+ lines.push(` * sort (col) — whitelisted: ${c.allowedSortColumns.join(', ')}.`);
366
+ lines.push(` * Default: ${c.defaultSort}. Prefix '-' for DESC.`);
367
+ if (c.hasSoftDelete) {
368
+ lines.push(' * only_trashed (bool) — wins over with_trashed when both set.');
369
+ lines.push(' * with_trashed (bool)');
370
+ }
371
+ lines.push(` * per_page (int) — default ${c.perPage}.`);
372
+ lines.push(' *');
373
+ if (c.eagerLoad.length > 0 || c.eagerCount.length > 0) {
374
+ lines.push(' * === Relations ===');
375
+ if (c.eagerLoad.length > 0) {
376
+ lines.push(` * Eager loads: ${c.eagerLoad.join(', ')}`);
377
+ }
378
+ if (c.eagerCount.length > 0) {
379
+ lines.push(` * Eager counts: ${c.eagerCount.join(', ')}`);
380
+ }
381
+ lines.push(' *');
382
+ }
383
+ if (c.translatableFields.length > 0) {
384
+ lines.push(' * === Translatable ===');
385
+ lines.push(` * Fields: ${c.translatableFields.join(', ')}`);
386
+ lines.push(` * Locales: ${c.locales.join(', ')}`);
387
+ lines.push(` * Sidecar: ${toSnakeCase(c.schemaName)}_translations (synced in create/update)`);
388
+ lines.push(' *');
389
+ lines.push(' * Input format (preferred — Astrotomic-native flat, see #78):');
390
+ const sampleField = c.translatableFields[0] ?? 'name';
391
+ const sampleLocale = c.locales[0] ?? 'ja';
392
+ lines.push(` * [ "${sampleField}:${sampleLocale}" => "...", "${sampleField}:${c.locales[1] ?? 'en'}" => "..." ]`);
393
+ lines.push(' *');
394
+ lines.push(' * Legacy "translations" wrapper is accepted but deprecated — it emits');
395
+ lines.push(' * a E_USER_DEPRECATED notice and will be removed in omnify v3.21.0.');
396
+ lines.push(' *');
397
+ lines.push(' * Lookup/sort on translatable columns rewrites to a LEFT JOIN on the');
398
+ lines.push(' * sidecar for the request locale + fallback (see #76).');
399
+ lines.push(' *');
400
+ }
401
+ if (c.hasSoftDelete) {
402
+ lines.push(' * Soft delete: enabled (delete → trash, forceDelete → hard delete, restore → untrash).');
403
+ }
404
+ if (c.hasAnyAudit) {
405
+ lines.push(' * Audit: created_by/updated_by/deleted_by forced from Auth::id() (client');
406
+ lines.push(' * values are discarded — see #75 item 3).');
407
+ }
408
+ if (c.lookup) {
409
+ lines.push(` * Lookup: returns ${c.lookupFields.join(', ')} (default limit 100, hard cap 500 — #75.8).`);
410
+ }
411
+ lines.push(' *');
412
+ lines.push(' * DO NOT EDIT - This file is auto-generated by Omnify.');
413
+ lines.push(' * Any changes will be overwritten on next generation.');
414
+ lines.push(' *');
415
+ lines.push(' * @generated by omnify');
416
+ lines.push(' */');
417
+ return lines.join('\n');
418
+ }
325
419
  function buildEagerLoadChain(eagerLoad, eagerCount) {
326
420
  const parts = [];
327
421
  if (eagerLoad.length > 0) {
@@ -367,12 +461,18 @@ function buildAllowedSortColumns(searchableFields, filterableFields, sortableFie
367
461
  set.add(defaultCol);
368
462
  return Array.from(set).sort();
369
463
  }
370
- function buildListMethod(modelName, searchableFields, filterableFields, sortableFields, hasSoftDelete, eagerLoad, eagerCount, defaultSort, perPage, allowedSortColumns) {
464
+ function buildListMethod(modelName, searchableFields, filterableFields, sortableFields, hasSoftDelete, eagerLoad, eagerCount, defaultSort, perPage, allowedSortColumns, tx) {
371
465
  const lines = [];
372
- // PHPDoc with filter shape
466
+ // PHPDoc with filter shape (#77: documented, @throws, intent)
373
467
  const filterShape = buildFilterDocShape(filterableFields, hasSoftDelete);
374
468
  lines.push(`
375
469
  /**
470
+ * List ${modelName} records with filters, search, sort and pagination.
471
+ *
472
+ * Filter keys are whitelisted from the schema's options.service config;
473
+ * unknown keys are ignored. The sort column is whitelisted against
474
+ * searchable + filterable + standard audit columns (#75.2).
475
+ *
376
476
  * @param array{${filterShape}} $filters
377
477
  */
378
478
  public function list(array $filters = []): LengthAwarePaginator
@@ -413,7 +513,7 @@ function buildListMethod(modelName, searchableFields, filterableFields, sortable
413
513
  }
414
514
  // Sort
415
515
  lines.push('');
416
- lines.push(buildSortSection(sortableFields, defaultSort, allowedSortColumns));
516
+ lines.push(buildSortSection(sortableFields, defaultSort, allowedSortColumns, tx));
417
517
  // Paginate
418
518
  lines.push(`
419
519
  return $query->paginate($filters['per_page'] ?? ${perPage});
@@ -473,11 +573,14 @@ ${clauses.join('\n')}
473
573
  });
474
574
  });`;
475
575
  }
476
- function buildSortSection(_sortableFields, defaultSort, allowedSortColumns) {
576
+ function buildSortSection(_sortableFields, defaultSort, allowedSortColumns, tx) {
477
577
  const defaultCol = defaultSort.replace(/^-/, '');
478
578
  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) --
579
+ const hasTranslatableSortable = tx.translatableFields.length > 0 &&
580
+ allowedSortColumns.some(c => tx.translatableFields.includes(c));
581
+ if (!hasTranslatableSortable) {
582
+ // Fast path: nothing translatable in the allowlist, plain orderBy works.
583
+ return ` // -- Sort (#75.2: whitelist guards against SQL injection) --
481
584
  $sort = $filters['sort'] ?? '${defaultSort}';
482
585
  $direction = str_starts_with($sort, '-') ? 'desc' : 'asc';
483
586
  $column = ltrim($sort, '-');
@@ -486,12 +589,58 @@ function buildSortSection(_sortableFields, defaultSort, allowedSortColumns) {
486
589
  $column = '${defaultCol}';
487
590
  }
488
591
  $query->orderBy($column, $direction);`;
592
+ }
593
+ // #76: one or more whitelisted columns live in the translation sidecar.
594
+ // Runtime-branch on $column: translatable → leftJoin + orderByRaw with
595
+ // COALESCE(locale, fallback); non-translatable → plain orderBy.
596
+ const translatablePhpList = tx.translatableFields
597
+ .filter(f => allowedSortColumns.includes(f))
598
+ .map(f => `'${f}'`)
599
+ .join(', ');
600
+ const mainTable = tx.mainTable;
601
+ const trTable = tx.translationTable;
602
+ const fk = tx.fkColumn;
603
+ return ` // -- Sort (#75.2 whitelist + #76 translatable join rewrite) --
604
+ $sort = $filters['sort'] ?? '${defaultSort}';
605
+ $direction = str_starts_with($sort, '-') ? 'desc' : 'asc';
606
+ $column = ltrim($sort, '-');
607
+ $allowedSortColumns = [${allowedList}];
608
+ if (! in_array($column, $allowedSortColumns, true)) {
609
+ $column = '${defaultCol}';
610
+ }
611
+ $translatableSortColumns = [${translatablePhpList}];
612
+ if (in_array($column, $translatableSortColumns, true)) {
613
+ // #76: sort column lives in '${trTable}'. LEFT JOIN once on the
614
+ // request locale and once on the fallback locale, then COALESCE
615
+ // so missing translations don't push rows to the top/bottom.
616
+ $locale = app()->getLocale();
617
+ $fallback = config('translatable.fallback_locale', 'en');
618
+ $query
619
+ ->leftJoin('${trTable} as tr_sort', function ($j) use ($locale) {
620
+ $j->on('tr_sort.${fk}', '=', '${mainTable}.id')
621
+ ->where('tr_sort.locale', $locale);
622
+ })
623
+ ->leftJoin('${trTable} as tr_sort_fb', function ($j) use ($fallback) {
624
+ $j->on('tr_sort_fb.${fk}', '=', '${mainTable}.id')
625
+ ->where('tr_sort_fb.locale', $fallback);
626
+ })
627
+ ->select('${mainTable}.*')
628
+ ->orderByRaw("COALESCE(tr_sort.\`{$column}\`, tr_sort_fb.\`{$column}\`) " . strtoupper($direction));
629
+ } else {
630
+ $query->orderBy($column, $direction);
631
+ }`;
489
632
  }
490
633
  // ── findById() ──────────────────────────────────────────────────────────────
491
634
  function buildFindByIdMethod(modelName, eagerLoad, eagerCount) {
492
635
  const eagerChain = buildEagerLoadChain(eagerLoad, eagerCount);
493
636
  const chain = eagerChain ? `\n${eagerChain}\n ` : '';
494
637
  return `
638
+ /**
639
+ * Load a single ${modelName} by primary key with the configured eager
640
+ * loads applied. Throws ModelNotFoundException (→ HTTP 404) on miss.
641
+ *
642
+ * @throws \\Illuminate\\Database\\Eloquent\\ModelNotFoundException
643
+ */
495
644
  public function findById(string $id): ${modelName}
496
645
  {
497
646
  return $this->model::query()${chain}->findOrFail($id);
@@ -544,7 +693,21 @@ function buildCreateMethod(modelName, hasAuditCreatedBy, hasTranslatable, transl
544
693
  }
545
694
  return `
546
695
  /**
696
+ * Create a new ${modelName}.
697
+ *
698
+ * Runs inside a DB transaction so partial failures roll back cleanly.
699
+ * Behaviour derived from the schema:${hasAuditCreatedBy ? `
700
+ * - created_by_id is forced from Auth::id(); any client-supplied value
701
+ * is discarded before the insert (#75 item 3 — prevents audit forgery).` : ''}${defaults.length > 0 ? `
702
+ * - Schema defaults are applied for fields not present in $data.` : ''}${hasTranslatable ? `
703
+ * - Translatable fields are extracted from either flat ("name:ja") or
704
+ * nested ("translations.ja.name") input and persisted to the sidecar
705
+ * translation table after the main row is inserted.` : ''}${eagerLoad.length + eagerCount.length > 0 ? `
706
+ * - Eager relations are loaded onto the returned model so the caller
707
+ * can render it directly.` : ''}
708
+ *
547
709
  * @param array<string, mixed> $data
710
+ * @throws \\Illuminate\\Database\\QueryException on constraint violation
548
711
  */
549
712
  public function create(array $data): ${modelName}
550
713
  {
@@ -588,7 +751,15 @@ function buildUpdateMethod(modelName, hasAuditUpdatedBy, hasTranslatable, eagerL
588
751
  }
589
752
  return `
590
753
  /**
754
+ * Update an existing ${modelName}.
755
+ *
756
+ * Same transaction + cascade contract as create():${hasAuditUpdatedBy ? `
757
+ * - updated_by_id is forced from Auth::id() (#75 item 3).` : ''}${hasTranslatable ? `
758
+ * - Translatable fields are re-synced via translateOrNew() so only the
759
+ * locales present in $data are touched; other locales remain intact.` : ''}
760
+ *
591
761
  * @param array<string, mixed> $data
762
+ * @throws \\Illuminate\\Database\\QueryException on constraint violation
592
763
  */
593
764
  public function update(${modelName} $model, array $data): ${modelName}
594
765
  {
@@ -609,6 +780,12 @@ function buildDeleteMethod(modelName, hasSoftDelete, hasAuditDeletedBy) {
609
780
  }
610
781
  bodyLines.push(` return $model->delete();`);
611
782
  return `
783
+ /**
784
+ * ${hasSoftDelete ? 'Soft-delete' : 'Delete'} a ${modelName}${hasSoftDelete ? ' (moves to trash, restorable via restore())' : ''}.${hasAuditDeletedBy && hasSoftDelete ? `
785
+ *
786
+ * deleted_by_id is stamped from Auth::id() inside the same transaction
787
+ * so audit trails stay consistent even if the delete fails.` : ''}
788
+ */
612
789
  public function delete(${modelName} $model): bool
613
790
  {
614
791
  return DB::transaction(function () use ($model) {
@@ -623,6 +800,9 @@ function buildRestoreMethod(modelName, eagerLoad, eagerCount) {
623
800
  ? ` $model->restore();\n\n return $model\n ${loadChain};`
624
801
  : ` $model->restore();\n\n return $model;`;
625
802
  return `
803
+ /**
804
+ * Restore a soft-deleted ${modelName} and return it with eager loads applied.
805
+ */
626
806
  public function restore(${modelName} $model): ${modelName}
627
807
  {
628
808
  return DB::transaction(function () use ($model) {
@@ -634,6 +814,9 @@ ${returnLine}
634
814
  function buildForceDeleteMethod(modelName, hasTranslatable) {
635
815
  if (!hasTranslatable) {
636
816
  return `
817
+ /**
818
+ * Hard-delete a ${modelName} (bypasses soft-delete, removes the row).
819
+ */
637
820
  public function forceDelete(${modelName} $model): bool
638
821
  {
639
822
  return DB::transaction(fn () => $model->forceDelete());
@@ -642,6 +825,14 @@ function buildForceDeleteMethod(modelName, hasTranslatable) {
642
825
  // #75.7: explicitly drop translations inside the same transaction so the
643
826
  // service contract does not rely on the FK's ON DELETE behaviour.
644
827
  return `
828
+ /**
829
+ * Hard-delete a ${modelName} and its translation rows.
830
+ *
831
+ * We explicitly delete translations inside the transaction instead of
832
+ * relying on the FK's ON DELETE behaviour — the migration may or may
833
+ * not emit CASCADE, and we don't want forceDelete()'s correctness to
834
+ * depend on schema quirks (#75.7).
835
+ */
645
836
  public function forceDelete(${modelName} $model): bool
646
837
  {
647
838
  return DB::transaction(function () use ($model) {
@@ -653,26 +844,71 @@ function buildForceDeleteMethod(modelName, hasTranslatable) {
653
844
  }`;
654
845
  }
655
846
  // ── emptyTrash() ────────────────────────────────────────────────────────────
656
- function buildEmptyTrashMethod() {
657
- return `
847
+ function buildEmptyTrashMethod(hasTranslatable) {
848
+ if (!hasTranslatable) {
849
+ return `
850
+ /**
851
+ * Permanently delete every trashed row. Returns rows-affected count.
852
+ */
658
853
  public function emptyTrash(): int
659
854
  {
660
855
  return $this->model::onlyTrashed()->forceDelete();
661
856
  }`;
857
+ }
858
+ // #75 followup: query-builder forceDelete bypasses per-model logic,
859
+ // so translations would be orphaned. Chunk through trashed rows and
860
+ // delegate to forceDelete() which cascades explicitly.
861
+ return `
862
+ /**
863
+ * Permanently delete every trashed row, cascading translations.
864
+ *
865
+ * Iterates via chunkById(100) and delegates to forceDelete() so the
866
+ * translation cascade in forceDelete() runs per row. A query-builder
867
+ * DELETE would be faster but would skip that cascade and orphan
868
+ * translation rows — see #75 followup comment.
869
+ */
870
+ public function emptyTrash(): int
871
+ {
872
+ $count = 0;
873
+ $this->model::onlyTrashed()->chunkById(100, function ($batch) use (&$count) {
874
+ foreach ($batch as $model) {
875
+ $this->forceDelete($model);
876
+ $count++;
877
+ }
878
+ });
879
+
880
+ return $count;
881
+ }`;
662
882
  }
663
883
  // ── lookup() ────────────────────────────────────────────────────────────────
664
- function buildLookupMethod(_modelName, lookupFields, filterableFields, defaultSort) {
665
- const selectFields = lookupFields.map(f => `'${toSnakeCase(f)}'`).join(', ');
666
- const returnShape = lookupFields.map(f => `${toSnakeCase(f)}: mixed`).join(', ');
667
- // Sort by first non-id field or default
884
+ function buildLookupMethod(_modelName, lookupFields, filterableFields, defaultSort, tx) {
885
+ const snakeLookup = lookupFields.map(f => toSnakeCase(f));
886
+ const returnShape = snakeLookup.map(f => `${f}: mixed`).join(', ');
668
887
  const sortCol = defaultSort.replace(/^-/, '');
669
888
  const sortDir = defaultSort.startsWith('-') ? 'desc' : 'asc';
889
+ const translatableSet = new Set(tx.translatableFields);
890
+ const hasTranslatableLookup = snakeLookup.some(f => translatableSet.has(f)) ||
891
+ (translatableSet.has(sortCol) && tx.translationTable.length > 0);
670
892
  // Apply filterable columns — reuse buildFilterLine so Boolean handling
671
- // matches list() (#75.4: `?? null` collapses `false` so lookup() silently
672
- // dropped `is_hidden=false`; list() uses isset() which is correct).
893
+ // matches list() (#75.4).
673
894
  const filterLines = filterableFields.map(f => buildFilterLine(f));
674
- return `
895
+ const filterBlock = filterLines.length > 0 ? filterLines.join('\n') + '\n\n' : '';
896
+ // #75.8: cap results (shared by both branches)
897
+ const limitBlock = ` // #75.8: cap results so type-ahead / dropdown endpoints never load the
898
+ // entire table. Caller can override via ?limit=... up to a hard ceiling.
899
+ $limit = min((int) ($filters['limit'] ?? 100), 500);
900
+ $query->limit($limit);`;
901
+ if (!hasTranslatableLookup) {
902
+ // Fast path — no translatable columns involved.
903
+ const selectFields = snakeLookup.map(f => `'${f}'`).join(', ');
904
+ return `
675
905
  /**
906
+ * Lightweight list-projection for dropdowns, type-aheads and autocomplete.
907
+ *
908
+ * Returns a flat array of {${returnShape}} rows (never a paginator). Honours
909
+ * every key that list() honours for filterable columns. Default row cap
910
+ * is 100 (caller override via ?limit=..., hard ceiling 500 — see #75.8).
911
+ *
676
912
  * @param array<string, mixed> $filters
677
913
  * @return array<int, array{${returnShape}}>
678
914
  */
@@ -682,11 +918,73 @@ function buildLookupMethod(_modelName, lookupFields, filterableFields, defaultSo
682
918
  ->select([${selectFields}])
683
919
  ->orderBy('${sortCol}', '${sortDir}');
684
920
 
685
- ${filterLines.length > 0 ? filterLines.join('\n') + '\n' : ''}
686
- // #75.8: cap results so type-ahead / dropdown endpoints never load the
687
- // entire table. Caller can override via ?limit=... up to a hard ceiling.
688
- $limit = min((int) ($filters['limit'] ?? 100), 500);
689
- $query->limit($limit);
921
+ ${filterBlock}${limitBlock}
922
+
923
+ return $query->get()->toArray();
924
+ }`;
925
+ }
926
+ // #76: translatable branch — leftJoin the sidecar keyed by
927
+ // request locale + fallback locale, COALESCE the translated columns so
928
+ // missing translations fall back gracefully.
929
+ const translatedCols = snakeLookup.filter(f => translatableSet.has(f));
930
+ const plainCols = snakeLookup.filter(f => !translatableSet.has(f));
931
+ const mainTable = tx.mainTable;
932
+ const trTable = tx.translationTable;
933
+ const fk = tx.fkColumn;
934
+ const selectParts = [];
935
+ selectParts.push(`'${mainTable}.id'`);
936
+ for (const col of plainCols) {
937
+ if (col !== 'id')
938
+ selectParts.push(`'${mainTable}.${col}'`);
939
+ }
940
+ // COALESCE for translatable columns
941
+ for (const col of translatedCols) {
942
+ selectParts.push(`DB::raw('COALESCE(tr_current.\`${col}\`, tr_fallback.\`${col}\`) AS \`${col}\`')`);
943
+ }
944
+ const selectList = selectParts.join(',\n ');
945
+ const sortIsTranslatable = translatableSet.has(sortCol);
946
+ const orderByLine = sortIsTranslatable
947
+ ? `->orderByRaw("COALESCE(tr_current.\`${sortCol}\`, tr_fallback.\`${sortCol}\`) ${sortDir.toUpperCase()}")`
948
+ : `->orderBy('${mainTable}.${sortCol}', '${sortDir}')`;
949
+ return `
950
+ /**
951
+ * Lightweight list-projection for dropdowns, type-aheads and autocomplete.
952
+ *
953
+ * Returns a flat array of {${returnShape}} rows (never a paginator). One or
954
+ * more of the returned columns is translatable, so this method LEFT JOINs
955
+ * the sidecar ${trTable} on both the request locale and the fallback
956
+ * locale, then COALESCEs the translated column so missing translations
957
+ * degrade gracefully instead of surfacing as NULL (#76).
958
+ *
959
+ * Default row cap is 100 (caller override via ?limit=..., hard ceiling
960
+ * 500 — see #75.8).
961
+ *
962
+ * @param array<string, mixed> $filters
963
+ * @return array<int, array{${returnShape}}>
964
+ */
965
+ public function lookup(array $filters = []): array
966
+ {
967
+ // #76: translatable columns live in '${trTable}'. Join once on the
968
+ // request locale and once on the configured fallback so missing
969
+ // translations degrade gracefully instead of returning NULL.
970
+ $locale = app()->getLocale();
971
+ $fallback = config('translatable.fallback_locale', 'en');
972
+
973
+ $query = $this->model::query()
974
+ ->select([
975
+ ${selectList},
976
+ ])
977
+ ->leftJoin('${trTable} as tr_current', function ($j) use ($locale) {
978
+ $j->on('tr_current.${fk}', '=', '${mainTable}.id')
979
+ ->where('tr_current.locale', $locale);
980
+ })
981
+ ->leftJoin('${trTable} as tr_fallback', function ($j) use ($fallback) {
982
+ $j->on('tr_fallback.${fk}', '=', '${mainTable}.id')
983
+ ->where('tr_fallback.locale', $fallback);
984
+ })
985
+ ${orderByLine};
986
+
987
+ ${filterBlock}${limitBlock}
690
988
 
691
989
  return $query->get()->toArray();
692
990
  }`;
@@ -699,9 +997,16 @@ function buildExtractTranslationsMethod(translatableFields, locales) {
699
997
  /**
700
998
  * Extract translation data from input.
701
999
  *
702
- * Supports two formats:
703
- * 1. Flat: { "name:ja": "テスト", "name:en": "Test" }
704
- * 2. Nested: { "translations": { "ja": { "name": "テスト" }, "en": { "name": "Test" } } }
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" => "テスト" ] ] ]
1006
+ *
1007
+ * The flat "attr:locale" form is what Astrotomic's own Translatable::fill()
1008
+ * consumes natively (see Translatable::getAttributeAndLocale()), so sticking
1009
+ * to it keeps request bodies, FormRequest rules and OpenAPI schemas flat.
705
1010
  *
706
1011
  * @return array<string, array<string, string>> locale => [attr => value]
707
1012
  */
@@ -711,15 +1016,24 @@ function buildExtractTranslationsMethod(translatableFields, locales) {
711
1016
  $locales = [${localesArray}];
712
1017
  $translations = [];
713
1018
 
714
- // Format 2: nested "translations" key
1019
+ // Legacy wrapper format (#78 — deprecated). Still accepted for backward
1020
+ // compat, but emits a deprecation notice so consumers migrate ahead of
1021
+ // the v3.21.0 removal.
715
1022
  if (isset($data['translations']) && is_array($data['translations'])) {
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
+ );
716
1030
  $translations = $data['translations'];
717
1031
  unset($data['translations']);
718
1032
 
719
1033
  return $translations;
720
1034
  }
721
1035
 
722
- // Format 1: flat "attr:locale" keys
1036
+ // Preferred: flat "attr:locale" keys
723
1037
  foreach ($translatedAttributes as $attr) {
724
1038
  foreach ($locales as $locale) {
725
1039
  $key = "{$attr}:{$locale}";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnifyjp/ts",
3
- "version": "3.19.3",
3
+ "version": "3.20.0",
4
4
  "description": "TypeScript model type generator from Omnify schemas.json",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",