@omnifyjp/ts 3.19.2 → 3.19.3
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 +103 -18
- 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,7 +277,7 @@ 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));
|
|
280
|
+
sections.push(buildForceDeleteMethod(modelName, hasTranslatable));
|
|
247
281
|
sections.push(buildEmptyTrashMethod());
|
|
248
282
|
}
|
|
249
283
|
// ── Lookup section ────────────────────────────────────────────────────────
|
|
@@ -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,12 +631,26 @@ ${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
656
|
function buildEmptyTrashMethod() {
|
|
@@ -584,16 +661,19 @@ function buildEmptyTrashMethod() {
|
|
|
584
661
|
}`;
|
|
585
662
|
}
|
|
586
663
|
// ── lookup() ────────────────────────────────────────────────────────────────
|
|
587
|
-
function buildLookupMethod(
|
|
664
|
+
function buildLookupMethod(_modelName, lookupFields, filterableFields, defaultSort) {
|
|
588
665
|
const selectFields = lookupFields.map(f => `'${toSnakeCase(f)}'`).join(', ');
|
|
589
666
|
const returnShape = lookupFields.map(f => `${toSnakeCase(f)}: mixed`).join(', ');
|
|
590
667
|
// Sort by first non-id field or default
|
|
591
668
|
const sortCol = defaultSort.replace(/^-/, '');
|
|
592
669
|
const sortDir = defaultSort.startsWith('-') ? 'desc' : 'asc';
|
|
593
|
-
// Apply filterable columns
|
|
594
|
-
|
|
670
|
+
// Apply filterable columns — reuse buildFilterLine so Boolean handling
|
|
671
|
+
// matches list() (#75.4: `?? null` collapses `false` so lookup() silently
|
|
672
|
+
// dropped `is_hidden=false`; list() uses isset() which is correct).
|
|
673
|
+
const filterLines = filterableFields.map(f => buildFilterLine(f));
|
|
595
674
|
return `
|
|
596
675
|
/**
|
|
676
|
+
* @param array<string, mixed> $filters
|
|
597
677
|
* @return array<int, array{${returnShape}}>
|
|
598
678
|
*/
|
|
599
679
|
public function lookup(array $filters = []): array
|
|
@@ -603,6 +683,11 @@ function buildLookupMethod(modelName, lookupFields, filterableFields, defaultSor
|
|
|
603
683
|
->orderBy('${sortCol}', '${sortDir}');
|
|
604
684
|
|
|
605
685
|
${filterLines.length > 0 ? filterLines.join('\n') + '\n' : ''}
|
|
686
|
+
// #75.8: cap results so type-ahead / dropdown endpoints never load the
|
|
687
|
+
// entire table. Caller can override via ?limit=... up to a hard ceiling.
|
|
688
|
+
$limit = min((int) ($filters['limit'] ?? 100), 500);
|
|
689
|
+
$query->limit($limit);
|
|
690
|
+
|
|
606
691
|
return $query->get()->toArray();
|
|
607
692
|
}`;
|
|
608
693
|
}
|