@omnifyjp/omnify 3.13.1 → 3.14.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.
@@ -1,12 +1,24 @@
1
1
  /**
2
- * Generates base + editable service classes for schemas with `options.api`.
2
+ * Generates base + editable service classes for schemas with `options.api`
3
+ * or `options.service`.
4
+ *
5
+ * Issue #57: Full BaseService codegen with translatable, softDelete, audit,
6
+ * DB::transaction, eagerLoad/eagerCount, lookupFields, defaultSort.
3
7
  */
4
8
  import { toPascalCase, toSnakeCase } from './naming-helper.js';
5
9
  import { baseFile, userFile, resolveModularBasePath, resolveModularBaseNamespace } from './types.js';
6
- /** Generate service classes for all schemas with api config. */
10
+ // ============================================================================
11
+ // Public entry point
12
+ // ============================================================================
13
+ /** Generate service classes for all schemas with api or service config. */
7
14
  export function generateServices(reader, config) {
8
15
  const files = [];
9
- for (const [name, schema] of Object.entries(reader.getSchemasWithApi())) {
16
+ // Merge schemas with api OR service options (union)
17
+ const candidates = {
18
+ ...reader.getSchemasWithApi(),
19
+ ...reader.getSchemasWithService(),
20
+ };
21
+ for (const [name, schema] of Object.entries(candidates)) {
10
22
  files.push(...generateForSchema(name, schema, reader, config));
11
23
  }
12
24
  return files;
@@ -17,194 +29,237 @@ function generateForSchema(name, schema, reader, config) {
17
29
  generateUserService(name, schema, config),
18
30
  ];
19
31
  }
20
- // ============================================================================
21
- // Base service generation
22
- // ============================================================================
23
- function generateBaseService(name, schema, reader, config) {
24
- const modelName = toPascalCase(name);
25
- const api = schema.options?.api ?? {};
26
- const bulkDelete = api.bulkDelete ?? false;
27
- const restore = api.restore ?? (schema.options?.softDelete ?? false);
28
- const perPage = api.perPage ?? 15;
29
- const lookup = api.lookup ?? true;
30
- const baseNs = resolveModularBaseNamespace(config, name, 'Services', config.services.baseNamespace);
31
- const modelNs = config.models.namespace;
32
+ /**
33
+ * Resolve searchable/filterable fields.
34
+ *
35
+ * Priority:
36
+ * 1. options.service.searchable / .filterable (explicit field name arrays)
37
+ * 2. Property-level searchable/filterable flags (backward compat)
38
+ */
39
+ function resolveFieldLists(schema, reader, name) {
32
40
  const properties = schema.properties ?? {};
33
41
  const propertyOrder = reader.getPropertyOrder(name);
34
42
  const expandedProperties = reader.getExpandedProperties(name);
35
- // Collect searchable, filterable, sortable fields (compound types expand to sub-columns)
43
+ const svc = schema.options?.service;
36
44
  const searchableFields = [];
37
45
  const filterableFields = [];
38
46
  const sortableFields = [];
39
- for (const propName of propertyOrder) {
40
- const prop = properties[propName];
41
- if (!prop)
42
- continue;
43
- const colName = toSnakeCase(propName);
44
- const translatable = prop.translatable ?? false;
45
- const expansion = expandedProperties[propName];
46
- if (prop.searchable) {
47
+ // If service config has explicit arrays, use those
48
+ if (svc?.searchable && svc.searchable.length > 0) {
49
+ for (const fieldName of svc.searchable) {
50
+ const prop = properties[fieldName];
51
+ if (!prop)
52
+ continue;
53
+ const colName = toSnakeCase(fieldName);
54
+ const translatable = prop.translatable ?? false;
55
+ const expansion = expandedProperties[fieldName];
47
56
  if (expansion && expansion.columns.length > 0) {
48
- // Compound type: search all sub-columns
49
57
  for (const col of expansion.columns) {
50
- if (col.name) {
51
- searchableFields.push({ propName: col.name, colName: col.name, translatable });
52
- }
58
+ if (col.name)
59
+ searchableFields.push({ propName: col.name, colName: col.name, type: col.type ?? 'String', translatable, prop });
53
60
  }
54
61
  }
55
62
  else {
56
- searchableFields.push({ propName, colName, translatable });
63
+ searchableFields.push({ propName: fieldName, colName, type: prop.type, translatable, prop });
57
64
  }
58
65
  }
59
- if (prop.filterable) {
66
+ }
67
+ else {
68
+ // Fall back to property-level flags
69
+ for (const propName of propertyOrder) {
70
+ const prop = properties[propName];
71
+ if (!prop || !prop.searchable)
72
+ continue;
73
+ const colName = toSnakeCase(propName);
74
+ const translatable = prop.translatable ?? false;
75
+ const expansion = expandedProperties[propName];
60
76
  if (expansion && expansion.columns.length > 0) {
61
- // Compound type: filter on each sub-column
62
77
  for (const col of expansion.columns) {
63
- if (col.name) {
64
- filterableFields.push({ propName: col.name, colName: col.name, type: col.type ?? 'String', prop, translatable });
65
- }
78
+ if (col.name)
79
+ searchableFields.push({ propName: col.name, colName: col.name, type: col.type ?? 'String', translatable, prop });
66
80
  }
67
81
  }
68
82
  else {
69
- filterableFields.push({ propName, colName, type: prop.type, prop, translatable });
83
+ searchableFields.push({ propName, colName, type: prop.type, translatable, prop });
70
84
  }
71
85
  }
72
- if (prop.sortable) {
86
+ }
87
+ if (svc?.filterable && svc.filterable.length > 0) {
88
+ for (const fieldName of svc.filterable) {
89
+ const prop = properties[fieldName];
90
+ if (!prop)
91
+ continue;
92
+ const colName = toSnakeCase(fieldName);
93
+ const translatable = prop.translatable ?? false;
94
+ const expansion = expandedProperties[fieldName];
73
95
  if (expansion && expansion.columns.length > 0) {
74
- // Compound type: sort by primary sub-column (first one)
75
- const primaryCol = expansion.columns[0];
76
- if (primaryCol?.name) {
77
- sortableFields.push({ propName: colName, colName: primaryCol.name, translatable });
96
+ for (const col of expansion.columns) {
97
+ if (col.name)
98
+ filterableFields.push({ propName: col.name, colName: col.name, type: col.type ?? 'String', translatable, prop });
78
99
  }
79
100
  }
80
101
  else {
81
- sortableFields.push({ propName: colName, colName, translatable });
102
+ filterableFields.push({ propName: fieldName, colName, type: prop.type, translatable, prop });
82
103
  }
83
104
  }
84
105
  }
85
- // Build search block
86
- const searchBlock = buildSearchBlock(searchableFields);
87
- // Build filter block
88
- const filterBlock = buildFilterBlock(filterableFields);
89
- // Build sort block
90
- const sortBlock = buildSortBlock(sortableFields);
91
- // Build additional imports
106
+ else {
107
+ for (const propName of propertyOrder) {
108
+ const prop = properties[propName];
109
+ if (!prop || !prop.filterable)
110
+ continue;
111
+ const colName = toSnakeCase(propName);
112
+ const translatable = prop.translatable ?? false;
113
+ const expansion = expandedProperties[propName];
114
+ if (expansion && expansion.columns.length > 0) {
115
+ for (const col of expansion.columns) {
116
+ if (col.name)
117
+ filterableFields.push({ propName: col.name, colName: col.name, type: col.type ?? 'String', translatable, prop });
118
+ }
119
+ }
120
+ else {
121
+ filterableFields.push({ propName, colName, type: prop.type, translatable, prop });
122
+ }
123
+ }
124
+ }
125
+ // Sortable always from property-level flags (no service-level override for this)
126
+ for (const propName of propertyOrder) {
127
+ const prop = properties[propName];
128
+ if (!prop || !prop.sortable)
129
+ continue;
130
+ const colName = toSnakeCase(propName);
131
+ const translatable = prop.translatable ?? false;
132
+ const expansion = expandedProperties[propName];
133
+ if (expansion && expansion.columns.length > 0) {
134
+ const primaryCol = expansion.columns[0];
135
+ if (primaryCol?.name)
136
+ sortableFields.push({ propName: colName, colName: primaryCol.name, type: primaryCol.type ?? 'String', translatable, prop });
137
+ }
138
+ else {
139
+ sortableFields.push({ propName: colName, colName, type: prop.type, translatable, prop });
140
+ }
141
+ }
142
+ return { searchableFields, filterableFields, sortableFields };
143
+ }
144
+ /** Collect properties that have non-null defaults (for create() method). */
145
+ function resolveDefaults(schema, reader, name) {
146
+ const properties = schema.properties ?? {};
147
+ const propertyOrder = reader.getPropertyOrder(name);
148
+ const defaults = [];
149
+ for (const propName of propertyOrder) {
150
+ const prop = properties[propName];
151
+ if (!prop || prop.default === undefined || prop.default === null)
152
+ continue;
153
+ // Skip associations, files
154
+ if (prop.type === 'Association' || prop.type === 'File')
155
+ continue;
156
+ const colName = toSnakeCase(propName);
157
+ const val = prop.default;
158
+ if (typeof val === 'boolean') {
159
+ defaults.push({ colName, value: val ? 'true' : 'false' });
160
+ }
161
+ else if (typeof val === 'number') {
162
+ defaults.push({ colName, value: String(val) });
163
+ }
164
+ else if (typeof val === 'string') {
165
+ defaults.push({ colName, value: `'${val}'` });
166
+ }
167
+ }
168
+ return defaults;
169
+ }
170
+ // ============================================================================
171
+ // Base service generation
172
+ // ============================================================================
173
+ function generateBaseService(name, schema, reader, config) {
174
+ const modelName = toPascalCase(name);
175
+ const options = schema.options ?? {};
176
+ const api = options.api ?? {};
177
+ const svc = options.service ?? {};
178
+ const hasSoftDelete = options.softDelete ?? false;
179
+ const perPage = api.perPage ?? 25;
180
+ const hasApiConfig = options.api != null;
181
+ const lookup = api.lookup ?? (hasApiConfig ? true : (svc.lookupFields != null && svc.lookupFields.length > 0));
182
+ // Audit detection — schema-level then global
183
+ const schemaAudit = options.audit;
184
+ const globalAudit = reader.getAuditConfig();
185
+ const hasAuditCreatedBy = schemaAudit?.createdBy ?? globalAudit?.createdBy ?? false;
186
+ const hasAuditUpdatedBy = schemaAudit?.updatedBy ?? globalAudit?.updatedBy ?? false;
187
+ const hasAuditDeletedBy = schemaAudit?.deletedBy ?? globalAudit?.deletedBy ?? false;
188
+ const hasAnyAudit = hasAuditCreatedBy || hasAuditUpdatedBy || hasAuditDeletedBy;
189
+ // Translatable fields
190
+ const translatableFields = reader.getTranslatableFields(name);
191
+ const hasTranslatable = translatableFields.length > 0;
192
+ // Eager load / count
193
+ const eagerLoad = svc.eagerLoad ?? [];
194
+ const eagerCount = svc.eagerCount ?? [];
195
+ // Default sort
196
+ const defaultSort = svc.defaultSort ?? '-created_at';
197
+ // Lookup fields
198
+ const lookupFields = svc.lookupFields ?? ['id', 'name'];
199
+ // Field lists
200
+ const { searchableFields, filterableFields, sortableFields } = resolveFieldLists(schema, reader, name);
201
+ // Property defaults
202
+ const defaults = resolveDefaults(schema, reader, name);
203
+ // Locales (for translatable)
204
+ const locales = reader.getLocales();
205
+ const baseNs = resolveModularBaseNamespace(config, name, 'Services', config.services.baseNamespace);
206
+ const modelNs = config.models.namespace;
207
+ // Build imports
92
208
  const imports = [
93
209
  `use ${modelNs}\\${modelName};`,
94
210
  `use Illuminate\\Contracts\\Pagination\\LengthAwarePaginator;`,
211
+ `use Illuminate\\Support\\Facades\\DB;`,
95
212
  ];
96
- if (restore) {
97
- imports.push(`use Illuminate\\Database\\Eloquent\\Collection;`);
213
+ if (hasAnyAudit) {
214
+ imports.push(`use Illuminate\\Support\\Facades\\Auth;`);
98
215
  }
99
- // Build methods
100
- const methods = [];
101
- // list method
102
- methods.push(`
103
- /**
104
- * List ${modelName} records with search, filter, and sort.
105
- *
106
- * @param array<string, mixed> $filters
107
- */
108
- public function list(array $filters): LengthAwarePaginator
109
- {
110
- $query = ${modelName}::query();
111
-
112
- // Organization scope
113
- if (isset($filters['organization_id'])) {
114
- $query->where('organization_id', $filters['organization_id']);
115
- }
116
- ${searchBlock}${filterBlock}${sortBlock}
117
- return $query->paginate($filters['per_page'] ?? ${perPage});
118
- }`);
119
- // lookup method
120
- if (lookup) {
121
- methods.push(`
122
- /**
123
- * Lookup ${modelName} records for select/combobox (lightweight).
124
- *
125
- * @param array<string, mixed> $filters
126
- */
127
- public function lookup(array $filters): LengthAwarePaginator
128
- {
129
- $query = ${modelName}::query();
130
-
131
- if (isset($filters['organization_id'])) {
132
- $query->where('organization_id', $filters['organization_id']);
133
- }
134
-
135
- if ($search = $filters['search'] ?? null) {
136
- $query->where(function ($q) use ($search) {${searchableFields.length > 0 ? `\n${searchableFields.map((f, i) => {
137
- if (f.translatable) {
138
- return ` $q->${i === 0 ? 'whereTranslationLike' : 'orWhereTranslationLike'}('${f.colName}', "%{$search}%");`;
139
- }
140
- return ` $q->${i === 0 ? 'where' : 'orWhere'}('${f.colName}', 'like', "%{$search}%");`;
141
- }).join('\n')}` : `\n // No searchable fields defined`}
142
- });
143
- }
144
-
145
- return $query->paginate($filters['per_page'] ?? 50);
146
- }`);
216
+ // ── Build class body ──────────────────────────────────────────────────────
217
+ const sections = [];
218
+ // Model property
219
+ sections.push(` protected string $model = ${modelName}::class;`);
220
+ // ── Query section ─────────────────────────────────────────────────────────
221
+ sections.push('');
222
+ sections.push(buildSectionComment('Query'));
223
+ // list() method
224
+ sections.push(buildListMethod(modelName, searchableFields, filterableFields, sortableFields, hasSoftDelete, eagerLoad, eagerCount, defaultSort, perPage));
225
+ // findById() method
226
+ sections.push(buildFindByIdMethod(modelName, eagerLoad, eagerCount));
227
+ // ── Create section ────────────────────────────────────────────────────────
228
+ sections.push('');
229
+ sections.push(buildSectionComment('Create'));
230
+ sections.push(buildCreateMethod(modelName, hasAuditCreatedBy, hasTranslatable, translatableFields, eagerLoad, eagerCount, defaults));
231
+ // ── Update section ────────────────────────────────────────────────────────
232
+ sections.push('');
233
+ sections.push(buildSectionComment('Update'));
234
+ sections.push(buildUpdateMethod(modelName, hasAuditUpdatedBy, hasTranslatable, eagerLoad, eagerCount));
235
+ // ── Delete & Restore section ──────────────────────────────────────────────
236
+ sections.push('');
237
+ if (hasSoftDelete) {
238
+ sections.push(buildSectionComment('Delete & Restore'));
147
239
  }
148
- // create method
149
- methods.push(`
150
- /**
151
- * Create a new ${modelName}.
152
- *
153
- * @param array<string, mixed> $data
154
- */
155
- public function create(array $data): ${modelName}
156
- {
157
- return ${modelName}::create($data);
158
- }`);
159
- // update method
160
- methods.push(`
161
- /**
162
- * Update an existing ${modelName}.
163
- *
164
- * @param array<string, mixed> $data
165
- */
166
- public function update(${modelName} $model, array $data): ${modelName}
167
- {
168
- $model->update($data);
169
-
170
- return $model->fresh();
171
- }`);
172
- // delete method
173
- methods.push(`
174
- /**
175
- * Delete a ${modelName}.
176
- */
177
- public function delete(${modelName} $model): void
178
- {
179
- $model->delete();
180
- }`);
181
- // bulkDelete method
182
- if (bulkDelete) {
183
- methods.push(`
184
- /**
185
- * Delete multiple ${modelName} records by IDs.
186
- *
187
- * @param array<int|string> $ids
188
- */
189
- public function bulkDelete(array $ids): void
190
- {
191
- ${modelName}::whereIn('id', $ids)->delete();
192
- }`);
240
+ else {
241
+ sections.push(buildSectionComment('Delete'));
193
242
  }
194
- // restore method
195
- if (restore) {
196
- methods.push(`
197
- /**
198
- * Restore a soft-deleted ${modelName}.
199
- */
200
- public function restore(string $id): ${modelName}
201
- {
202
- $model = ${modelName}::withTrashed()->findOrFail($id);
203
- $model->restore();
204
-
205
- return $model->fresh();
206
- }`);
243
+ sections.push(buildDeleteMethod(modelName, hasSoftDelete, hasAuditDeletedBy));
244
+ if (hasSoftDelete) {
245
+ sections.push(buildRestoreMethod(modelName, eagerLoad, eagerCount));
246
+ sections.push(buildForceDeleteMethod(modelName));
247
+ sections.push(buildEmptyTrashMethod());
248
+ }
249
+ // ── Lookup section ────────────────────────────────────────────────────────
250
+ if (lookup) {
251
+ sections.push('');
252
+ sections.push(buildSectionComment('Lookup'));
253
+ sections.push(buildLookupMethod(modelName, lookupFields, filterableFields, defaultSort));
254
+ }
255
+ // ── Translatable helpers ──────────────────────────────────────────────────
256
+ if (hasTranslatable) {
257
+ sections.push('');
258
+ sections.push(buildSectionComment('Translatable Helpers'));
259
+ sections.push(buildExtractTranslationsMethod(translatableFields, locales));
260
+ sections.push(buildSyncTranslationsMethod(modelName));
207
261
  }
262
+ // ── Assemble file ─────────────────────────────────────────────────────────
208
263
  const content = `<?php
209
264
 
210
265
  namespace ${baseNs};
@@ -220,138 +275,393 @@ ${imports.join('\n')}
220
275
 
221
276
  class ${modelName}ServiceBase
222
277
  {
223
- ${methods.join('\n')}
278
+ ${sections.join('\n')}
224
279
  }
225
280
  `;
226
281
  return baseFile(resolveModularBasePath(config, name, 'Services', `${modelName}ServiceBase.php`, config.services.basePath), content);
227
282
  }
228
283
  // ============================================================================
229
- // Search / Filter / Sort block builders
284
+ // Method builders
230
285
  // ============================================================================
286
+ function buildSectionComment(title) {
287
+ return ` // =========================================================================
288
+ // ${title}
289
+ // =========================================================================`;
290
+ }
291
+ function buildEagerLoadChain(eagerLoad, eagerCount) {
292
+ const parts = [];
293
+ if (eagerLoad.length > 0) {
294
+ const list = eagerLoad.map(r => `'${r}'`).join(', ');
295
+ parts.push(` ->with([${list}])`);
296
+ }
297
+ if (eagerCount.length > 0) {
298
+ const list = eagerCount.map(r => `'${r}'`).join(', ');
299
+ parts.push(` ->withCount([${list}])`);
300
+ }
301
+ return parts.join('\n');
302
+ }
303
+ function buildLoadChain(eagerLoad, eagerCount) {
304
+ const parts = [];
305
+ if (eagerLoad.length > 0) {
306
+ const list = eagerLoad.map(r => `'${r}'`).join(', ');
307
+ parts.push(`->load([${list}])`);
308
+ }
309
+ if (eagerCount.length > 0) {
310
+ const list = eagerCount.map(r => `'${r}'`).join(', ');
311
+ parts.push(`->loadCount([${list}])`);
312
+ }
313
+ return parts.join('\n ');
314
+ }
315
+ // ── list() ──────────────────────────────────────────────────────────────────
316
+ function buildListMethod(modelName, searchableFields, filterableFields, sortableFields, hasSoftDelete, eagerLoad, eagerCount, defaultSort, perPage) {
317
+ const lines = [];
318
+ // PHPDoc with filter shape
319
+ const filterShape = buildFilterDocShape(filterableFields, hasSoftDelete);
320
+ lines.push(`
321
+ /**
322
+ * @param array{${filterShape}} $filters
323
+ */
324
+ public function list(array $filters = []): LengthAwarePaginator
325
+ {
326
+ $query = $this->model::query()`);
327
+ // Eager load
328
+ const eagerChain = buildEagerLoadChain(eagerLoad, eagerCount);
329
+ if (eagerChain) {
330
+ lines.push(`${eagerChain};`);
331
+ }
332
+ else {
333
+ // Close the query chain
334
+ lines[lines.length - 1] += ';';
335
+ }
336
+ // Filterable
337
+ if (filterableFields.length > 0) {
338
+ lines.push('');
339
+ lines.push(' // -- Filterable --');
340
+ for (const f of filterableFields) {
341
+ lines.push(buildFilterLine(f));
342
+ }
343
+ }
344
+ // Searchable
345
+ if (searchableFields.length > 0) {
346
+ lines.push('');
347
+ lines.push(' // -- Searchable --');
348
+ lines.push(buildSearchBlock(searchableFields));
349
+ }
350
+ // SoftDelete filters
351
+ if (hasSoftDelete) {
352
+ lines.push('');
353
+ lines.push(` // -- SoftDelete filters --
354
+ if (! empty($filters['only_trashed'])) {
355
+ $query->onlyTrashed();
356
+ } elseif (! empty($filters['with_trashed'])) {
357
+ $query->withTrashed();
358
+ }`);
359
+ }
360
+ // Sort
361
+ lines.push('');
362
+ lines.push(buildSortSection(sortableFields, defaultSort));
363
+ // Paginate
364
+ lines.push(`
365
+ return $query->paginate($filters['per_page'] ?? ${perPage});
366
+ }`);
367
+ return lines.join('\n');
368
+ }
369
+ function buildFilterDocShape(filterableFields, hasSoftDelete) {
370
+ const parts = [];
371
+ for (const f of filterableFields) {
372
+ const phpType = resolvePhpFilterType(f.type);
373
+ parts.push(`${f.colName}?: ${phpType}`);
374
+ }
375
+ parts.push('search?: string');
376
+ if (hasSoftDelete) {
377
+ parts.push('with_trashed?: bool');
378
+ parts.push('only_trashed?: bool');
379
+ }
380
+ parts.push('sort?: string');
381
+ parts.push('per_page?: int');
382
+ return parts.join(', ');
383
+ }
384
+ function resolvePhpFilterType(type) {
385
+ switch (type) {
386
+ case 'Boolean': return 'bool';
387
+ case 'Int':
388
+ case 'BigInt':
389
+ case 'TinyInt':
390
+ case 'Integer': return 'int';
391
+ case 'Float':
392
+ case 'Decimal': return 'float';
393
+ default: return 'string';
394
+ }
395
+ }
396
+ function buildFilterLine(f) {
397
+ if (f.translatable) {
398
+ return ` $query->when(isset($filters['${f.colName}']), fn ($q) => $q->whereTranslation('${f.colName}', $filters['${f.colName}']));`;
399
+ }
400
+ switch (f.type) {
401
+ case 'Boolean':
402
+ return ` $query->when(isset($filters['${f.colName}']), fn ($q) => $q->where('${f.colName}', $filters['${f.colName}']));`;
403
+ default:
404
+ return ` $query->when($filters['${f.colName}'] ?? null, fn ($q, $v) => $q->where('${f.colName}', $v));`;
405
+ }
406
+ }
231
407
  function buildSearchBlock(fields) {
232
- if (fields.length === 0)
233
- return '';
234
408
  const clauses = fields.map((f, i) => {
235
- const method = i === 0 ? 'where' : 'orWhere';
236
409
  if (f.translatable) {
237
- // Astrotomic scope for translatable fields
238
- return ` $q->${i === 0 ? 'whereTranslationLike' : 'orWhereTranslationLike'}('${f.colName}', "%{$search}%");`;
410
+ const method = i === 0 ? 'whereTranslationLike' : 'orWhereTranslationLike';
411
+ return ` $q->${method}('${f.colName}', "%{$search}%");`;
239
412
  }
413
+ const method = i === 0 ? 'where' : 'orWhere';
240
414
  return ` $q->${method}('${f.colName}', 'like', "%{$search}%");`;
241
415
  });
242
- return `
243
- // Search
244
- if ($search = $filters['search'] ?? null) {
245
- $query->where(function ($q) use ($search) {
416
+ return ` $query->when($filters['search'] ?? null, function ($q, $search) {
417
+ $q->where(function ($q) use ($search) {
246
418
  ${clauses.join('\n')}
247
419
  });
248
- }
249
- `;
420
+ });`;
250
421
  }
251
- function buildFilterBlock(fields) {
252
- if (fields.length === 0)
253
- return '';
254
- const lines = [];
255
- lines.push('');
256
- lines.push(' // Filters');
257
- for (const f of fields) {
258
- const { colName, type, translatable } = f;
259
- // Translatable fields use Astrotomic scopes
260
- if (translatable) {
261
- lines.push(` if (isset($filters['${colName}'])) {`);
262
- lines.push(` $query->whereTranslation('${colName}', $filters['${colName}']);`);
263
- lines.push(` }`);
264
- continue;
265
- }
266
- switch (type) {
267
- case 'Decimal':
268
- case 'Int':
269
- case 'Float':
270
- case 'Integer':
271
- // Range filters: field_min, field_max
272
- lines.push(` if (isset($filters['${colName}_min'])) {`);
273
- lines.push(` $query->where('${colName}', '>=', $filters['${colName}_min']);`);
274
- lines.push(` }`);
275
- lines.push(` if (isset($filters['${colName}_max'])) {`);
276
- lines.push(` $query->where('${colName}', '<=', $filters['${colName}_max']);`);
277
- lines.push(` }`);
278
- break;
279
- case 'Date':
280
- case 'Timestamp':
281
- case 'DateTime':
282
- // Date range filters: field_from, field_to
283
- lines.push(` if (isset($filters['${colName}_from'])) {`);
284
- lines.push(` $query->where('${colName}', '>=', $filters['${colName}_from']);`);
285
- lines.push(` }`);
286
- lines.push(` if (isset($filters['${colName}_to'])) {`);
287
- lines.push(` $query->where('${colName}', '<=', $filters['${colName}_to']);`);
288
- lines.push(` }`);
289
- break;
290
- case 'Boolean':
291
- lines.push(` if (isset($filters['${colName}'])) {`);
292
- lines.push(` $query->where('${colName}', filter_var($filters['${colName}'], FILTER_VALIDATE_BOOLEAN));`);
293
- lines.push(` }`);
294
- break;
295
- case 'Enum':
296
- case 'EnumRef':
297
- case 'String':
298
- default:
299
- // Exact match
300
- lines.push(` if (isset($filters['${colName}'])) {`);
301
- lines.push(` $query->where('${colName}', $filters['${colName}']);`);
302
- lines.push(` }`);
303
- break;
422
+ function buildSortSection(sortableFields, defaultSort) {
423
+ return ` // -- Sort --
424
+ $sort = $filters['sort'] ?? '${defaultSort}';
425
+ $direction = str_starts_with($sort, '-') ? 'desc' : 'asc';
426
+ $column = ltrim($sort, '-');
427
+ $query->orderBy($column, $direction);`;
428
+ }
429
+ // ── findById() ──────────────────────────────────────────────────────────────
430
+ function buildFindByIdMethod(modelName, eagerLoad, eagerCount) {
431
+ const eagerChain = buildEagerLoadChain(eagerLoad, eagerCount);
432
+ const chain = eagerChain ? `\n${eagerChain}\n ` : '';
433
+ return `
434
+ public function findById(string $id): ${modelName}
435
+ {
436
+ return $this->model::query()${chain}->findOrFail($id);
437
+ }`;
438
+ }
439
+ // ── create() ────────────────────────────────────────────────────────────────
440
+ function buildCreateMethod(modelName, hasAuditCreatedBy, hasTranslatable, translatableFields, eagerLoad, eagerCount, defaults) {
441
+ const bodyLines = [];
442
+ // Audit
443
+ if (hasAuditCreatedBy) {
444
+ bodyLines.push(` // -- Audit: inject created_by --
445
+ if (Auth::check()) {
446
+ $data['created_by_id'] = $data['created_by_id'] ?? Auth::id();
447
+ }
448
+ `);
449
+ }
450
+ // Defaults
451
+ if (defaults.length > 0) {
452
+ bodyLines.push(' // -- Defaults --');
453
+ for (const d of defaults) {
454
+ bodyLines.push(` $data['${d.colName}'] = $data['${d.colName}'] ?? ${d.value};`);
304
455
  }
456
+ bodyLines.push('');
457
+ }
458
+ // Translatable
459
+ if (hasTranslatable) {
460
+ bodyLines.push(` // -- Translatable: extract locale-keyed data --
461
+ $translations = $this->extractTranslations($data);
462
+ `);
463
+ }
464
+ bodyLines.push(` $model = $this->model::create($data);`);
465
+ if (hasTranslatable) {
466
+ bodyLines.push(`
467
+ // -- Translatable: sync translations for all locales --
468
+ if (! empty($translations)) {
469
+ $this->syncTranslations($model, $translations);
470
+ }`);
305
471
  }
306
- return lines.join('\n') + '\n';
472
+ // Return with eager load
473
+ const loadChain = buildLoadChain(eagerLoad, eagerCount);
474
+ if (loadChain) {
475
+ bodyLines.push(`
476
+ return $model
477
+ ${loadChain};`);
478
+ }
479
+ else {
480
+ bodyLines.push(`
481
+ return $model;`);
482
+ }
483
+ return `
484
+ /**
485
+ * @param array<string, mixed> $data
486
+ */
487
+ public function create(array $data): ${modelName}
488
+ {
489
+ return DB::transaction(function () use ($data) {
490
+ ${bodyLines.join('\n')}
491
+ });
492
+ }`;
307
493
  }
308
- function buildSortBlock(sortableFields) {
309
- if (sortableFields.length === 0) {
310
- return `
311
- // Sort
312
- $query->orderBy('created_at', 'desc');
313
- `;
494
+ // ── update() ────────────────────────────────────────────────────────────────
495
+ function buildUpdateMethod(modelName, hasAuditUpdatedBy, hasTranslatable, eagerLoad, eagerCount) {
496
+ const bodyLines = [];
497
+ if (hasAuditUpdatedBy) {
498
+ bodyLines.push(` // -- Audit: inject updated_by --
499
+ if (Auth::check()) {
500
+ $data['updated_by_id'] = $data['updated_by_id'] ?? Auth::id();
501
+ }
502
+ `);
503
+ }
504
+ if (hasTranslatable) {
505
+ bodyLines.push(` // -- Translatable: extract and sync --
506
+ $translations = $this->extractTranslations($data);
507
+ `);
508
+ }
509
+ bodyLines.push(` $model->update($data);`);
510
+ if (hasTranslatable) {
511
+ bodyLines.push(`
512
+ if (! empty($translations)) {
513
+ $this->syncTranslations($model, $translations);
514
+ }`);
515
+ }
516
+ const loadChain = buildLoadChain(eagerLoad, eagerCount);
517
+ if (loadChain) {
518
+ bodyLines.push(`
519
+ return $model
520
+ ${loadChain};`);
521
+ }
522
+ else {
523
+ bodyLines.push(`
524
+ return $model;`);
525
+ }
526
+ return `
527
+ /**
528
+ * @param array<string, mixed> $data
529
+ */
530
+ public function update(${modelName} $model, array $data): ${modelName}
531
+ {
532
+ return DB::transaction(function () use ($model, $data) {
533
+ ${bodyLines.join('\n')}
534
+ });
535
+ }`;
536
+ }
537
+ // ── delete() ────────────────────────────────────────────────────────────────
538
+ function buildDeleteMethod(modelName, hasSoftDelete, hasAuditDeletedBy) {
539
+ const bodyLines = [];
540
+ if (hasAuditDeletedBy && hasSoftDelete) {
541
+ bodyLines.push(` // -- Audit: inject deleted_by --
542
+ if (Auth::check()) {
543
+ $model->update(['deleted_by_id' => Auth::id()]);
544
+ }
545
+ `);
314
546
  }
315
- // allowedSorts uses logical propName (what user sends in ?sort=)
316
- const allowedList = sortableFields.map(f => `'${f.propName}'`).join(', ');
317
- // sortMap: logical name → actual column name (compound types map to sub-column)
318
- const needsMap = sortableFields.some(f => f.propName !== f.colName);
319
- const sortMapEntries = sortableFields
320
- .filter(f => f.propName !== f.colName)
321
- .map(f => ` '${f.propName}' => '${f.colName}',`)
322
- .join('\n');
323
- const sortMapDecl = needsMap ? `\n $sortMap = [\n${sortMapEntries}\n ];` : '';
324
- const sortColResolve = needsMap ? `\n $sortCol = $sortMap[$sortField] ?? $sortField;` : `\n $sortCol = $sortField;`;
325
- const translatableSorts = sortableFields.filter(f => f.translatable).map(f => `'${f.propName}'`);
326
- const translatableCheck = translatableSorts.length > 0
327
- ? `\n $translatableSorts = [${translatableSorts.join(', ')}];`
328
- : '';
329
- const orderByLine = translatableSorts.length > 0
330
- ? ` if (in_array($sortField, $translatableSorts ?? [], true)) {
331
- $query->orderByTranslation($sortCol, $direction);
332
- } else {
333
- $query->orderBy($sortCol, $direction);
334
- }`
335
- : ` $query->orderBy($sortCol, $direction);`;
547
+ bodyLines.push(` return $model->delete();`);
548
+ return `
549
+ public function delete(${modelName} $model): bool
550
+ {
551
+ return DB::transaction(function () use ($model) {
552
+ ${bodyLines.join('\n')}
553
+ });
554
+ }`;
555
+ }
556
+ // ── restore() ───────────────────────────────────────────────────────────────
557
+ function buildRestoreMethod(modelName, eagerLoad, eagerCount) {
558
+ const loadChain = buildLoadChain(eagerLoad, eagerCount);
559
+ const returnLine = loadChain
560
+ ? ` $model->restore();\n\n return $model\n ${loadChain};`
561
+ : ` $model->restore();\n\n return $model;`;
562
+ return `
563
+ public function restore(${modelName} $model): ${modelName}
564
+ {
565
+ return DB::transaction(function () use ($model) {
566
+ ${returnLine}
567
+ });
568
+ }`;
569
+ }
570
+ // ── forceDelete() ───────────────────────────────────────────────────────────
571
+ function buildForceDeleteMethod(modelName) {
572
+ return `
573
+ public function forceDelete(${modelName} $model): bool
574
+ {
575
+ return DB::transaction(fn () => $model->forceDelete());
576
+ }`;
577
+ }
578
+ // ── emptyTrash() ────────────────────────────────────────────────────────────
579
+ function buildEmptyTrashMethod() {
580
+ return `
581
+ public function emptyTrash(): int
582
+ {
583
+ return $this->model::onlyTrashed()->forceDelete();
584
+ }`;
585
+ }
586
+ // ── lookup() ────────────────────────────────────────────────────────────────
587
+ function buildLookupMethod(modelName, lookupFields, filterableFields, defaultSort) {
588
+ const selectFields = lookupFields.map(f => `'${toSnakeCase(f)}'`).join(', ');
589
+ const returnShape = lookupFields.map(f => `${toSnakeCase(f)}: mixed`).join(', ');
590
+ // Sort by first non-id field or default
591
+ const sortCol = defaultSort.replace(/^-/, '');
592
+ 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));`);
336
595
  return `
337
- // Sort
338
- $sortParam = $filters['sort'] ?? '-created_at';
339
- $allowedSorts = [${allowedList}];${sortMapDecl}${translatableCheck}
596
+ /**
597
+ * @return array<int, array{${returnShape}}>
598
+ */
599
+ public function lookup(array $filters = []): array
600
+ {
601
+ $query = $this->model::query()
602
+ ->select([${selectFields}])
603
+ ->orderBy('${sortCol}', '${sortDir}');
604
+
605
+ ${filterLines.length > 0 ? filterLines.join('\n') + '\n' : ''}
606
+ return $query->get()->toArray();
607
+ }`;
608
+ }
609
+ // ── extractTranslations() ───────────────────────────────────────────────────
610
+ function buildExtractTranslationsMethod(translatableFields, locales) {
611
+ const fieldsArray = translatableFields.map(f => `'${f}'`).join(', ');
612
+ const localesArray = locales.map(l => `'${l}'`).join(', ');
613
+ return `
614
+ /**
615
+ * Extract translation data from input.
616
+ *
617
+ * Supports two formats:
618
+ * 1. Flat: { "name:ja": "テスト", "name:en": "Test" }
619
+ * 2. Nested: { "translations": { "ja": { "name": "テスト" }, "en": { "name": "Test" } } }
620
+ *
621
+ * @return array<string, array<string, string>> locale => [attr => value]
622
+ */
623
+ protected function extractTranslations(array &$data): array
624
+ {
625
+ $translatedAttributes = [${fieldsArray}];
626
+ $locales = [${localesArray}];
627
+ $translations = [];
340
628
 
341
- foreach (explode(',', $sortParam) as $sortField) {
342
- $sortField = trim($sortField);
343
- $direction = 'asc';
629
+ // Format 2: nested "translations" key
630
+ if (isset($data['translations']) && is_array($data['translations'])) {
631
+ $translations = $data['translations'];
632
+ unset($data['translations']);
344
633
 
345
- if (str_starts_with($sortField, '-')) {
346
- $direction = 'desc';
347
- $sortField = substr($sortField, 1);
348
- }
634
+ return $translations;
635
+ }
349
636
 
350
- if (in_array($sortField, $allowedSorts, true) || $sortField === 'created_at' || $sortField === 'updated_at') {${sortColResolve}
351
- ${orderByLine}
637
+ // Format 1: flat "attr:locale" keys
638
+ foreach ($translatedAttributes as $attr) {
639
+ foreach ($locales as $locale) {
640
+ $key = "{$attr}:{$locale}";
641
+ if (isset($data[$key])) {
642
+ $translations[$locale][$attr] = $data[$key];
643
+ unset($data[$key]);
644
+ }
352
645
  }
353
646
  }
354
- `;
647
+
648
+ return $translations;
649
+ }`;
650
+ }
651
+ // ── syncTranslations() ─────────────────────────────────────────────────────
652
+ function buildSyncTranslationsMethod(modelName) {
653
+ return `
654
+ /**
655
+ * Sync translations for all provided locales.
656
+ */
657
+ protected function syncTranslations(${modelName} $model, array $translations): void
658
+ {
659
+ foreach ($translations as $locale => $attrs) {
660
+ $translation = $model->translateOrNew($locale);
661
+ $translation->fill($attrs);
662
+ $translation->save();
663
+ }
664
+ }`;
355
665
  }
356
666
  // ============================================================================
357
667
  // User service