@omnifyjp/ts 3.19.4 → 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.
- package/dist/php/service-generator.js +329 -35
- package/package.json +1 -1
|
@@ -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 ────────────────────────────────────────────────────────
|
|
@@ -284,7 +291,7 @@ 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));
|
|
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
|
-
|
|
480
|
-
|
|
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) {
|
|
@@ -656,6 +847,9 @@ function buildForceDeleteMethod(modelName, hasTranslatable) {
|
|
|
656
847
|
function buildEmptyTrashMethod(hasTranslatable) {
|
|
657
848
|
if (!hasTranslatable) {
|
|
658
849
|
return `
|
|
850
|
+
/**
|
|
851
|
+
* Permanently delete every trashed row. Returns rows-affected count.
|
|
852
|
+
*/
|
|
659
853
|
public function emptyTrash(): int
|
|
660
854
|
{
|
|
661
855
|
return $this->model::onlyTrashed()->forceDelete();
|
|
@@ -665,10 +859,16 @@ function buildEmptyTrashMethod(hasTranslatable) {
|
|
|
665
859
|
// so translations would be orphaned. Chunk through trashed rows and
|
|
666
860
|
// delegate to forceDelete() which cascades explicitly.
|
|
667
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
|
+
*/
|
|
668
870
|
public function emptyTrash(): int
|
|
669
871
|
{
|
|
670
|
-
// #75 followup: delegate to per-model forceDelete so the translation
|
|
671
|
-
// cascade in forceDelete() runs. A query-builder DELETE would skip it.
|
|
672
872
|
$count = 0;
|
|
673
873
|
$this->model::onlyTrashed()->chunkById(100, function ($batch) use (&$count) {
|
|
674
874
|
foreach ($batch as $model) {
|
|
@@ -681,18 +881,34 @@ function buildEmptyTrashMethod(hasTranslatable) {
|
|
|
681
881
|
}`;
|
|
682
882
|
}
|
|
683
883
|
// ── lookup() ────────────────────────────────────────────────────────────────
|
|
684
|
-
function buildLookupMethod(_modelName, lookupFields, filterableFields, defaultSort) {
|
|
685
|
-
const
|
|
686
|
-
const returnShape =
|
|
687
|
-
// 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(', ');
|
|
688
887
|
const sortCol = defaultSort.replace(/^-/, '');
|
|
689
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);
|
|
690
892
|
// Apply filterable columns — reuse buildFilterLine so Boolean handling
|
|
691
|
-
// matches list() (#75.4
|
|
692
|
-
// dropped `is_hidden=false`; list() uses isset() which is correct).
|
|
893
|
+
// matches list() (#75.4).
|
|
693
894
|
const filterLines = filterableFields.map(f => buildFilterLine(f));
|
|
694
|
-
|
|
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 `
|
|
695
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
|
+
*
|
|
696
912
|
* @param array<string, mixed> $filters
|
|
697
913
|
* @return array<int, array{${returnShape}}>
|
|
698
914
|
*/
|
|
@@ -702,11 +918,73 @@ function buildLookupMethod(_modelName, lookupFields, filterableFields, defaultSo
|
|
|
702
918
|
->select([${selectFields}])
|
|
703
919
|
->orderBy('${sortCol}', '${sortDir}');
|
|
704
920
|
|
|
705
|
-
${
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
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}
|
|
710
988
|
|
|
711
989
|
return $query->get()->toArray();
|
|
712
990
|
}`;
|
|
@@ -719,9 +997,16 @@ function buildExtractTranslationsMethod(translatableFields, locales) {
|
|
|
719
997
|
/**
|
|
720
998
|
* Extract translation data from input.
|
|
721
999
|
*
|
|
722
|
-
*
|
|
723
|
-
*
|
|
724
|
-
*
|
|
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.
|
|
725
1010
|
*
|
|
726
1011
|
* @return array<string, array<string, string>> locale => [attr => value]
|
|
727
1012
|
*/
|
|
@@ -731,15 +1016,24 @@ function buildExtractTranslationsMethod(translatableFields, locales) {
|
|
|
731
1016
|
$locales = [${localesArray}];
|
|
732
1017
|
$translations = [];
|
|
733
1018
|
|
|
734
|
-
//
|
|
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.
|
|
735
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
|
+
);
|
|
736
1030
|
$translations = $data['translations'];
|
|
737
1031
|
unset($data['translations']);
|
|
738
1032
|
|
|
739
1033
|
return $translations;
|
|
740
1034
|
}
|
|
741
1035
|
|
|
742
|
-
//
|
|
1036
|
+
// Preferred: flat "attr:locale" keys
|
|
743
1037
|
foreach ($translatedAttributes as $attr) {
|
|
744
1038
|
foreach ($locales as $locale) {
|
|
745
1039
|
$key = "{$attr}:{$locale}";
|