@omnifyjp/omnify 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.
- package/package.json +6 -6
- package/ts-dist/php/service-generator.js +692 -102
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@omnifyjp/omnify",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.21.0",
|
|
4
4
|
"description": "Schema-driven code generation for Laravel, TypeScript, and SQL",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -36,10 +36,10 @@
|
|
|
36
36
|
"zod": "^3.24.0"
|
|
37
37
|
},
|
|
38
38
|
"optionalDependencies": {
|
|
39
|
-
"@omnifyjp/omnify-darwin-arm64": "3.
|
|
40
|
-
"@omnifyjp/omnify-darwin-x64": "3.
|
|
41
|
-
"@omnifyjp/omnify-linux-x64": "3.
|
|
42
|
-
"@omnifyjp/omnify-linux-arm64": "3.
|
|
43
|
-
"@omnifyjp/omnify-win32-x64": "3.
|
|
39
|
+
"@omnifyjp/omnify-darwin-arm64": "3.21.0",
|
|
40
|
+
"@omnifyjp/omnify-darwin-x64": "3.21.0",
|
|
41
|
+
"@omnifyjp/omnify-linux-x64": "3.21.0",
|
|
42
|
+
"@omnifyjp/omnify-linux-arm64": "3.21.0",
|
|
43
|
+
"@omnifyjp/omnify-win32-x64": "3.21.0"
|
|
44
44
|
}
|
|
45
45
|
}
|
|
@@ -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(
|
|
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
|
-
//
|
|
373
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
480
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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:
|
|
530
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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
|
-
|
|
576
|
-
|
|
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
|
-
*
|
|
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(
|
|
685
|
-
const
|
|
686
|
-
const returnShape =
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
${
|
|
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
|
-
//
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
const
|
|
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
|
-
*
|
|
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
|
-
*
|
|
723
|
-
|
|
724
|
-
*
|
|
1292
|
+
* @param array{
|
|
1293
|
+
${lookupFilterShape}
|
|
1294
|
+
* } $filters
|
|
1295
|
+
* @return array<int, array{${returnShape}}>
|
|
725
1296
|
*
|
|
726
|
-
|
|
1297
|
+
${lookupExample}
|
|
727
1298
|
*/
|
|
728
|
-
|
|
1299
|
+
public function lookup(array $filters = []): array
|
|
729
1300
|
{
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 $
|
|
1323
|
+
return $query->get()->toArray();
|
|
754
1324
|
}`;
|
|
755
1325
|
}
|
|
756
|
-
// ──
|
|
757
|
-
function
|
|
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
|
-
*
|
|
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
|
|
1352
|
+
protected function flushTranslations(${modelName} $model): void
|
|
763
1353
|
{
|
|
764
|
-
foreach ($translations as $
|
|
765
|
-
$translation
|
|
766
|
-
|
|
767
|
-
|
|
1354
|
+
foreach ($model->translations as $translation) {
|
|
1355
|
+
if (! $translation->exists || $translation->isDirty()) {
|
|
1356
|
+
$translation->save();
|
|
1357
|
+
}
|
|
768
1358
|
}
|
|
769
1359
|
}`;
|
|
770
1360
|
}
|