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