@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.
- package/dist/php/service-generator.js +126 -21
- package/package.json +1 -1
|
@@ -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
|
|
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(
|
|
423
|
-
|
|
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:
|
|
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'] =
|
|
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:
|
|
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'] =
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
594
|
-
|
|
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
|
}
|