@omnifyjp/ts 3.19.2 → 3.19.4

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.
@@ -141,6 +141,38 @@ function resolveFieldLists(schema, reader, name) {
141
141
  }
142
142
  return { searchableFields, filterableFields, sortableFields };
143
143
  }
144
+ /**
145
+ * Reconstruct `relation:col1,col2` tokens after YAML flow-array parsing (#75.1).
146
+ *
147
+ * `eagerLoad: [productType:id,name, categories:id,name]` lands in JS as
148
+ * `['productType:id', 'name', 'categories:id', 'name']` because YAML flow
149
+ * arrays split on commas. Users expect Eloquent's `with('rel:col,col')`
150
+ * column-list shorthand to survive. Rejoin any token without a `:` onto the
151
+ * previous token that does have one.
152
+ */
153
+ function reconstructRelationTokens(items) {
154
+ const result = [];
155
+ let current = null;
156
+ for (const raw of items) {
157
+ const item = raw.trim();
158
+ if (!item)
159
+ continue;
160
+ if (item.includes(':')) {
161
+ if (current !== null)
162
+ result.push(current);
163
+ current = item;
164
+ }
165
+ else if (current !== null) {
166
+ current += ',' + item;
167
+ }
168
+ else {
169
+ result.push(item);
170
+ }
171
+ }
172
+ if (current !== null)
173
+ result.push(current);
174
+ return result;
175
+ }
144
176
  /** Collect properties that have non-null defaults (for create() method). */
145
177
  function resolveDefaults(schema, reader, name) {
146
178
  const properties = schema.properties ?? {};
@@ -189,8 +221,8 @@ function generateBaseService(name, schema, reader, config) {
189
221
  // Translatable fields
190
222
  const translatableFields = reader.getTranslatableFields(name);
191
223
  const hasTranslatable = translatableFields.length > 0;
192
- // Eager load / count
193
- const eagerLoad = svc.eagerLoad ?? [];
224
+ // Eager load / count (#75.1: rejoin YAML-split `rel:col,col` tokens)
225
+ const eagerLoad = reconstructRelationTokens(svc.eagerLoad ?? []);
194
226
  const eagerCount = svc.eagerCount ?? [];
195
227
  // Default sort
196
228
  const defaultSort = svc.defaultSort ?? '-created_at';
@@ -220,8 +252,10 @@ function generateBaseService(name, schema, reader, config) {
220
252
  // ── Query section ─────────────────────────────────────────────────────────
221
253
  sections.push('');
222
254
  sections.push(buildSectionComment('Query'));
255
+ // Allowed sort columns whitelist (#75.2: prevent SQL injection via sort filter)
256
+ const allowedSortColumns = buildAllowedSortColumns(searchableFields, filterableFields, sortableFields, defaultSort, hasSoftDelete);
223
257
  // list() method
224
- sections.push(buildListMethod(modelName, searchableFields, filterableFields, sortableFields, hasSoftDelete, eagerLoad, eagerCount, defaultSort, perPage));
258
+ sections.push(buildListMethod(modelName, searchableFields, filterableFields, sortableFields, hasSoftDelete, eagerLoad, eagerCount, defaultSort, perPage, allowedSortColumns));
225
259
  // findById() method
226
260
  sections.push(buildFindByIdMethod(modelName, eagerLoad, eagerCount));
227
261
  // ── Create section ────────────────────────────────────────────────────────
@@ -243,8 +277,8 @@ function generateBaseService(name, schema, reader, config) {
243
277
  sections.push(buildDeleteMethod(modelName, hasSoftDelete, hasAuditDeletedBy));
244
278
  if (hasSoftDelete) {
245
279
  sections.push(buildRestoreMethod(modelName, eagerLoad, eagerCount));
246
- sections.push(buildForceDeleteMethod(modelName));
247
- sections.push(buildEmptyTrashMethod());
280
+ sections.push(buildForceDeleteMethod(modelName, hasTranslatable));
281
+ sections.push(buildEmptyTrashMethod(hasTranslatable));
248
282
  }
249
283
  // ── Lookup section ────────────────────────────────────────────────────────
250
284
  if (lookup) {
@@ -313,7 +347,27 @@ function buildLoadChain(eagerLoad, eagerCount) {
313
347
  return parts.join('\n ');
314
348
  }
315
349
  // ── list() ──────────────────────────────────────────────────────────────────
316
- function buildListMethod(modelName, searchableFields, filterableFields, sortableFields, hasSoftDelete, eagerLoad, eagerCount, defaultSort, perPage) {
350
+ function buildAllowedSortColumns(searchableFields, filterableFields, sortableFields, defaultSort, hasSoftDelete) {
351
+ const set = new Set();
352
+ for (const f of searchableFields)
353
+ set.add(f.colName);
354
+ for (const f of filterableFields)
355
+ set.add(f.colName);
356
+ for (const f of sortableFields)
357
+ set.add(f.colName);
358
+ // Standard audit/timestamp columns
359
+ set.add('id');
360
+ set.add('created_at');
361
+ set.add('updated_at');
362
+ if (hasSoftDelete)
363
+ set.add('deleted_at');
364
+ // defaultSort must always be allowed even if not otherwise listed
365
+ const defaultCol = defaultSort.replace(/^-/, '');
366
+ if (defaultCol)
367
+ set.add(defaultCol);
368
+ return Array.from(set).sort();
369
+ }
370
+ function buildListMethod(modelName, searchableFields, filterableFields, sortableFields, hasSoftDelete, eagerLoad, eagerCount, defaultSort, perPage, allowedSortColumns) {
317
371
  const lines = [];
318
372
  // PHPDoc with filter shape
319
373
  const filterShape = buildFilterDocShape(filterableFields, hasSoftDelete);
@@ -359,7 +413,7 @@ function buildListMethod(modelName, searchableFields, filterableFields, sortable
359
413
  }
360
414
  // Sort
361
415
  lines.push('');
362
- lines.push(buildSortSection(sortableFields, defaultSort));
416
+ lines.push(buildSortSection(sortableFields, defaultSort, allowedSortColumns));
363
417
  // Paginate
364
418
  lines.push(`
365
419
  return $query->paginate($filters['per_page'] ?? ${perPage});
@@ -419,11 +473,18 @@ ${clauses.join('\n')}
419
473
  });
420
474
  });`;
421
475
  }
422
- function buildSortSection(sortableFields, defaultSort) {
423
- return ` // -- Sort --
476
+ function buildSortSection(_sortableFields, defaultSort, allowedSortColumns) {
477
+ const defaultCol = defaultSort.replace(/^-/, '');
478
+ const allowedList = allowedSortColumns.map(c => `'${c}'`).join(', ');
479
+ // #75.2: whitelist sort column to prevent SQL injection / arbitrary column access
480
+ return ` // -- Sort (#75.2: whitelist guards against SQL injection) --
424
481
  $sort = $filters['sort'] ?? '${defaultSort}';
425
482
  $direction = str_starts_with($sort, '-') ? 'desc' : 'asc';
426
483
  $column = ltrim($sort, '-');
484
+ $allowedSortColumns = [${allowedList}];
485
+ if (! in_array($column, $allowedSortColumns, true)) {
486
+ $column = '${defaultCol}';
487
+ }
427
488
  $query->orderBy($column, $direction);`;
428
489
  }
429
490
  // ── findById() ──────────────────────────────────────────────────────────────
@@ -439,11 +500,12 @@ function buildFindByIdMethod(modelName, eagerLoad, eagerCount) {
439
500
  // ── create() ────────────────────────────────────────────────────────────────
440
501
  function buildCreateMethod(modelName, hasAuditCreatedBy, hasTranslatable, translatableFields, eagerLoad, eagerCount, defaults) {
441
502
  const bodyLines = [];
442
- // Audit
503
+ // Audit (#75.3: always overwrite — never trust client-supplied audit columns)
443
504
  if (hasAuditCreatedBy) {
444
- bodyLines.push(` // -- Audit: inject created_by --
505
+ bodyLines.push(` // -- Audit: force created_by from Auth (client value discarded) --
506
+ unset($data['created_by_id']);
445
507
  if (Auth::check()) {
446
- $data['created_by_id'] = $data['created_by_id'] ?? Auth::id();
508
+ $data['created_by_id'] = Auth::id();
447
509
  }
448
510
  `);
449
511
  }
@@ -495,9 +557,10 @@ ${bodyLines.join('\n')}
495
557
  function buildUpdateMethod(modelName, hasAuditUpdatedBy, hasTranslatable, eagerLoad, eagerCount) {
496
558
  const bodyLines = [];
497
559
  if (hasAuditUpdatedBy) {
498
- bodyLines.push(` // -- Audit: inject updated_by --
560
+ bodyLines.push(` // -- Audit: force updated_by from Auth (#75.3) --
561
+ unset($data['updated_by_id']);
499
562
  if (Auth::check()) {
500
- $data['updated_by_id'] = $data['updated_by_id'] ?? Auth::id();
563
+ $data['updated_by_id'] = Auth::id();
501
564
  }
502
565
  `);
503
566
  }
@@ -568,32 +631,69 @@ ${returnLine}
568
631
  }`;
569
632
  }
570
633
  // ── forceDelete() ───────────────────────────────────────────────────────────
571
- function buildForceDeleteMethod(modelName) {
572
- return `
634
+ function buildForceDeleteMethod(modelName, hasTranslatable) {
635
+ if (!hasTranslatable) {
636
+ return `
573
637
  public function forceDelete(${modelName} $model): bool
574
638
  {
575
639
  return DB::transaction(fn () => $model->forceDelete());
576
640
  }`;
641
+ }
642
+ // #75.7: explicitly drop translations inside the same transaction so the
643
+ // service contract does not rely on the FK's ON DELETE behaviour.
644
+ return `
645
+ public function forceDelete(${modelName} $model): bool
646
+ {
647
+ return DB::transaction(function () use ($model) {
648
+ // -- Cascade translations explicitly (#75.7) --
649
+ $model->translations()->delete();
650
+
651
+ return $model->forceDelete();
652
+ });
653
+ }`;
577
654
  }
578
655
  // ── emptyTrash() ────────────────────────────────────────────────────────────
579
- function buildEmptyTrashMethod() {
580
- return `
656
+ function buildEmptyTrashMethod(hasTranslatable) {
657
+ if (!hasTranslatable) {
658
+ return `
581
659
  public function emptyTrash(): int
582
660
  {
583
661
  return $this->model::onlyTrashed()->forceDelete();
584
662
  }`;
663
+ }
664
+ // #75 followup: query-builder forceDelete bypasses per-model logic,
665
+ // so translations would be orphaned. Chunk through trashed rows and
666
+ // delegate to forceDelete() which cascades explicitly.
667
+ return `
668
+ public function emptyTrash(): int
669
+ {
670
+ // #75 followup: delegate to per-model forceDelete so the translation
671
+ // cascade in forceDelete() runs. A query-builder DELETE would skip it.
672
+ $count = 0;
673
+ $this->model::onlyTrashed()->chunkById(100, function ($batch) use (&$count) {
674
+ foreach ($batch as $model) {
675
+ $this->forceDelete($model);
676
+ $count++;
677
+ }
678
+ });
679
+
680
+ return $count;
681
+ }`;
585
682
  }
586
683
  // ── lookup() ────────────────────────────────────────────────────────────────
587
- function buildLookupMethod(modelName, lookupFields, filterableFields, defaultSort) {
684
+ function buildLookupMethod(_modelName, lookupFields, filterableFields, defaultSort) {
588
685
  const selectFields = lookupFields.map(f => `'${toSnakeCase(f)}'`).join(', ');
589
686
  const returnShape = lookupFields.map(f => `${toSnakeCase(f)}: mixed`).join(', ');
590
687
  // Sort by first non-id field or default
591
688
  const sortCol = defaultSort.replace(/^-/, '');
592
689
  const sortDir = defaultSort.startsWith('-') ? 'desc' : 'asc';
593
- // Apply filterable columns to lookup too
594
- const filterLines = filterableFields.map(f => ` $query->when($filters['${f.colName}'] ?? null, fn ($q, $v) => $q->where('${f.colName}', $v));`);
690
+ // Apply filterable columns reuse buildFilterLine so Boolean handling
691
+ // matches list() (#75.4: `?? null` collapses `false` so lookup() silently
692
+ // dropped `is_hidden=false`; list() uses isset() which is correct).
693
+ const filterLines = filterableFields.map(f => buildFilterLine(f));
595
694
  return `
596
695
  /**
696
+ * @param array<string, mixed> $filters
597
697
  * @return array<int, array{${returnShape}}>
598
698
  */
599
699
  public function lookup(array $filters = []): array
@@ -603,6 +703,11 @@ function buildLookupMethod(modelName, lookupFields, filterableFields, defaultSor
603
703
  ->orderBy('${sortCol}', '${sortDir}');
604
704
 
605
705
  ${filterLines.length > 0 ? filterLines.join('\n') + '\n' : ''}
706
+ // #75.8: cap results so type-ahead / dropdown endpoints never load the
707
+ // entire table. Caller can override via ?limit=... up to a hard ceiling.
708
+ $limit = min((int) ($filters['limit'] ?? 100), 500);
709
+ $query->limit($limit);
710
+
606
711
  return $query->get()->toArray();
607
712
  }`;
608
713
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnifyjp/ts",
3
- "version": "3.19.2",
3
+ "version": "3.19.4",
4
4
  "description": "TypeScript model type generator from Omnify schemas.json",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",