@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.
- package/package.json +6 -6
- package/ts-dist/generator.js +8 -0
- package/ts-dist/metadata-generator.d.ts +2 -0
- package/ts-dist/metadata-generator.js +6 -0
- package/ts-dist/php/index.js +4 -1
- package/ts-dist/php/schema-reader.d.ts +11 -0
- package/ts-dist/php/schema-reader.js +18 -0
- package/ts-dist/php/service-generator.d.ts +6 -2
- package/ts-dist/php/service-generator.js +573 -263
- package/ts-dist/ts-hooks-generator.d.ts +10 -0
- package/ts-dist/ts-hooks-generator.js +131 -0
- package/ts-dist/ts-query-keys-generator.d.ts +9 -0
- package/ts-dist/ts-query-keys-generator.js +52 -0
- package/ts-dist/ts-service-generator.d.ts +10 -0
- package/ts-dist/ts-service-generator.js +190 -0
- package/ts-dist/types.d.ts +12 -0
- package/ts-dist/zod-generator.js +33 -3
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
43
|
+
const svc = schema.options?.service;
|
|
36
44
|
const searchableFields = [];
|
|
37
45
|
const filterableFields = [];
|
|
38
46
|
const sortableFields = [];
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
83
|
+
searchableFields.push({ propName, colName, type: prop.type, translatable, prop });
|
|
70
84
|
}
|
|
71
85
|
}
|
|
72
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
102
|
+
filterableFields.push({ propName: fieldName, colName, type: prop.type, translatable, prop });
|
|
82
103
|
}
|
|
83
104
|
}
|
|
84
105
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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 (
|
|
97
|
-
imports.push(`use Illuminate\\
|
|
213
|
+
if (hasAnyAudit) {
|
|
214
|
+
imports.push(`use Illuminate\\Support\\Facades\\Auth;`);
|
|
98
215
|
}
|
|
99
|
-
// Build
|
|
100
|
-
const
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
if (
|
|
121
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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
|
-
|
|
195
|
-
if (
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
{
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
${
|
|
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
|
-
//
|
|
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
|
-
|
|
238
|
-
return ` $q->${
|
|
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
|
-
|
|
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
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
$
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
$sortField = substr($sortField, 1);
|
|
348
|
-
}
|
|
634
|
+
return $translations;
|
|
635
|
+
}
|
|
349
636
|
|
|
350
|
-
|
|
351
|
-
${
|
|
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
|