@omnifyjp/ts 2.1.11 → 3.0.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.
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Generates base + editable controller classes for schemas with `options.api`.
3
+ */
4
+ import { SchemaReader } from './schema-reader.js';
5
+ import type { GeneratedFile, PhpConfig } from './types.js';
6
+ /** Generate controller classes for all schemas with api config. */
7
+ export declare function generateControllers(reader: SchemaReader, config: PhpConfig): GeneratedFile[];
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Generates base + editable controller classes for schemas with `options.api`.
3
+ */
4
+ import { toPascalCase } from './naming-helper.js';
5
+ import { baseFile, userFile } from './types.js';
6
+ const DEFAULT_ACTIONS = ['index', 'store', 'show', 'update', 'destroy'];
7
+ /** Generate controller classes for all schemas with api config. */
8
+ export function generateControllers(reader, config) {
9
+ const files = [];
10
+ for (const [name, schema] of Object.entries(reader.getSchemasWithApi())) {
11
+ files.push(...generateForSchema(name, schema, reader, config));
12
+ }
13
+ return files;
14
+ }
15
+ function generateForSchema(name, schema, reader, config) {
16
+ return [
17
+ generateBaseController(name, schema, reader, config),
18
+ generateUserController(name, schema, config),
19
+ ];
20
+ }
21
+ function generateBaseController(name, schema, reader, config) {
22
+ const modelName = toPascalCase(name);
23
+ const api = schema.options?.api ?? {};
24
+ const actions = [...(api.actions ?? DEFAULT_ACTIONS)];
25
+ const lookup = api.lookup ?? true;
26
+ const bulkDelete = api.bulkDelete ?? false;
27
+ const restore = api.restore ?? (schema.options?.softDelete ?? false);
28
+ const baseNs = config.controllers.baseNamespace;
29
+ const modelNs = config.models.namespace;
30
+ const serviceBaseNs = config.services.baseNamespace;
31
+ const requestNs = config.requests.namespace;
32
+ const resourceNs = config.resources.namespace;
33
+ // Build imports
34
+ const imports = [
35
+ `use App\\Http\\Controllers\\Controller;`,
36
+ `use ${serviceBaseNs}\\${modelName}ServiceBase;`,
37
+ ];
38
+ if (actions.includes('store')) {
39
+ imports.push(`use ${requestNs}\\${modelName}StoreRequest;`);
40
+ }
41
+ if (actions.includes('update')) {
42
+ imports.push(`use ${requestNs}\\${modelName}UpdateRequest;`);
43
+ }
44
+ if (actions.includes('show') || actions.includes('update') || actions.includes('destroy') || restore) {
45
+ imports.push(`use ${resourceNs}\\${modelName}Resource;`);
46
+ }
47
+ imports.push(`use ${modelNs}\\${modelName};`);
48
+ imports.push(`use Illuminate\\Http\\JsonResponse;`);
49
+ imports.push(`use Illuminate\\Http\\Request;`);
50
+ if (actions.includes('index') || lookup) {
51
+ imports.push(`use Illuminate\\Http\\Resources\\Json\\AnonymousResourceCollection;`);
52
+ }
53
+ // Build methods
54
+ const methods = [];
55
+ if (actions.includes('index')) {
56
+ methods.push(`
57
+ public function index(Request $request): AnonymousResourceCollection
58
+ {
59
+ $result = $this->service->list($request->all());
60
+
61
+ return ${modelName}Resource::collection($result);
62
+ }`);
63
+ }
64
+ if (actions.includes('store')) {
65
+ methods.push(`
66
+ public function store(${modelName}StoreRequest $request): JsonResponse
67
+ {
68
+ $model = $this->service->create($request->validated());
69
+
70
+ return (new ${modelName}Resource($model))
71
+ ->response()
72
+ ->setStatusCode(201);
73
+ }`);
74
+ }
75
+ if (actions.includes('show')) {
76
+ methods.push(`
77
+ public function show(${modelName} $model): ${modelName}Resource
78
+ {
79
+ return new ${modelName}Resource($model);
80
+ }`);
81
+ }
82
+ if (actions.includes('update')) {
83
+ methods.push(`
84
+ public function update(${modelName}UpdateRequest $request, ${modelName} $model): ${modelName}Resource
85
+ {
86
+ $model = $this->service->update($model, $request->validated());
87
+
88
+ return new ${modelName}Resource($model);
89
+ }`);
90
+ }
91
+ if (actions.includes('destroy')) {
92
+ methods.push(`
93
+ public function destroy(${modelName} $model): JsonResponse
94
+ {
95
+ $this->service->delete($model);
96
+
97
+ return response()->json(null, 204);
98
+ }`);
99
+ }
100
+ if (lookup) {
101
+ methods.push(`
102
+ public function lookup(Request $request): AnonymousResourceCollection
103
+ {
104
+ $result = $this->service->lookup($request->all());
105
+
106
+ return ${modelName}Resource::collection($result);
107
+ }`);
108
+ }
109
+ if (bulkDelete) {
110
+ methods.push(`
111
+ public function bulkDelete(Request $request): JsonResponse
112
+ {
113
+ $this->service->bulkDelete($request->input('ids', []));
114
+
115
+ return response()->json(null, 204);
116
+ }`);
117
+ }
118
+ if (restore) {
119
+ methods.push(`
120
+ public function restore(string $id): ${modelName}Resource
121
+ {
122
+ $model = $this->service->restore($id);
123
+
124
+ return new ${modelName}Resource($model);
125
+ }`);
126
+ }
127
+ const content = `<?php
128
+
129
+ namespace ${baseNs};
130
+
131
+ /**
132
+ * DO NOT EDIT - This file is auto-generated by Omnify.
133
+ * Any changes will be overwritten on next generation.
134
+ *
135
+ * @generated by omnify
136
+ */
137
+
138
+ ${imports.join('\n')}
139
+
140
+ abstract class ${modelName}ControllerBase extends Controller
141
+ {
142
+ public function __construct(
143
+ protected readonly ${modelName}ServiceBase $service,
144
+ ) {}
145
+ ${methods.join('\n')}
146
+ }
147
+ `;
148
+ return baseFile(`${config.controllers.basePath}/${modelName}ControllerBase.php`, content);
149
+ }
150
+ function generateUserController(name, schema, config) {
151
+ const modelName = toPascalCase(name);
152
+ const group = schema.group ? toPascalCase(schema.group) : null;
153
+ const baseNs = config.controllers.baseNamespace;
154
+ const userNs = group
155
+ ? `${config.controllers.namespace}\\Api\\V1\\${group}`
156
+ : `${config.controllers.namespace}\\Api\\V1`;
157
+ const userPath = group
158
+ ? `${config.controllers.path}/Api/V1/${group}`
159
+ : `${config.controllers.path}/Api/V1`;
160
+ const content = `<?php
161
+
162
+ /**
163
+ * ${modelName} Controller
164
+ *
165
+ * SAFE TO EDIT - This file is never overwritten by Omnify.
166
+ */
167
+
168
+ namespace ${userNs};
169
+
170
+ use ${baseNs}\\${modelName}ControllerBase;
171
+
172
+ class ${modelName}Controller extends ${modelName}ControllerBase
173
+ {
174
+ //
175
+ }
176
+ `;
177
+ return userFile(`${userPath}/${modelName}Controller.php`, content);
178
+ }
package/dist/php/index.js CHANGED
@@ -25,6 +25,10 @@ import { generatePolicies } from './policy-generator.js';
25
25
  import { generateFileModels } from './file-model-generator.js';
26
26
  import { generateFileTrait } from './file-trait-generator.js';
27
27
  import { generateFileCleanup } from './file-cleanup-generator.js';
28
+ import { generateSchemaConfig } from './schema-config-generator.js';
29
+ import { generateControllers } from './controller-generator.js';
30
+ import { generateServices } from './service-generator.js';
31
+ import { generateRoutes } from './route-generator.js';
28
32
  export { derivePhpConfig } from './types.js';
29
33
  /** Generate all PHP files from schemas.json data. */
30
34
  export function generatePhp(data, overrides) {
@@ -42,6 +46,13 @@ export function generatePhp(data, overrides) {
42
46
  files.push(...generateFileFactory(reader, config));
43
47
  files.push(...generateFileCleanup(reader, config));
44
48
  }
49
+ // CRUD API files (only for schemas with options.api)
50
+ if (reader.hasApiSchemas()) {
51
+ files.push(...generateSchemaConfig(reader, config));
52
+ files.push(...generateControllers(reader, config));
53
+ files.push(...generateServices(reader, config));
54
+ files.push(...generateRoutes(reader, config));
55
+ }
45
56
  // Per-schema files
46
57
  files.push(...generateLocales(reader, config));
47
58
  files.push(...generateModels(reader, config));
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Generates route files for schemas with `options.api`.
3
+ *
4
+ * Each schema gets its own route file at routes/api/omnify/{snake_name}.php.
5
+ * Always overwritten.
6
+ */
7
+ import { SchemaReader } from './schema-reader.js';
8
+ import type { GeneratedFile, PhpConfig } from './types.js';
9
+ /** Generate route files for all schemas with api config. */
10
+ export declare function generateRoutes(reader: SchemaReader, config: PhpConfig): GeneratedFile[];
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Generates route files for schemas with `options.api`.
3
+ *
4
+ * Each schema gets its own route file at routes/api/omnify/{snake_name}.php.
5
+ * Always overwritten.
6
+ */
7
+ import { toPascalCase, toCamelCase, toSnakeCase, pluralize } from './naming-helper.js';
8
+ import { baseFile } from './types.js';
9
+ const DEFAULT_ACTIONS = ['index', 'store', 'show', 'update', 'destroy'];
10
+ /** Generate route files for all schemas with api config. */
11
+ export function generateRoutes(reader, config) {
12
+ const files = [];
13
+ for (const [name, schema] of Object.entries(reader.getSchemasWithApi())) {
14
+ files.push(generateRouteFile(name, schema, config));
15
+ }
16
+ return files;
17
+ }
18
+ function generateRouteFile(name, schema, config) {
19
+ const modelName = toPascalCase(name);
20
+ const api = schema.options?.api ?? {};
21
+ const actions = [...(api.actions ?? DEFAULT_ACTIONS)];
22
+ const lookup = api.lookup ?? true;
23
+ const bulkDelete = api.bulkDelete ?? false;
24
+ const restore = api.restore ?? (schema.options?.softDelete ?? false);
25
+ const middleware = api.middleware ?? [];
26
+ // Route prefix: use api.prefix or default to snake_case plural
27
+ const prefix = api.prefix ?? pluralize(toSnakeCase(name));
28
+ // Route param name: camelCase singular
29
+ const routeParam = toCamelCase(name);
30
+ // Controller namespace
31
+ const group = schema.group ? toPascalCase(schema.group) : null;
32
+ const controllerNs = group
33
+ ? `${config.controllers.namespace}\\Api\\V1\\${group}`
34
+ : `${config.controllers.namespace}\\Api\\V1`;
35
+ // Build use statements
36
+ const useStatements = [
37
+ `use ${controllerNs}\\${modelName}Controller;`,
38
+ `use Illuminate\\Support\\Facades\\Route;`,
39
+ ];
40
+ // Build route lines
41
+ const routeLines = [];
42
+ // Lookup must come before resource-like routes (before {param})
43
+ if (lookup) {
44
+ routeLines.push(` Route::get('lookup', [${modelName}Controller::class, 'lookup'])->name('lookup');`);
45
+ }
46
+ // Bulk delete (POST to avoid conflicts with single delete)
47
+ if (bulkDelete) {
48
+ routeLines.push(` Route::post('bulk-delete', [${modelName}Controller::class, 'bulkDelete'])->name('bulkDelete');`);
49
+ }
50
+ // Standard CRUD routes
51
+ if (actions.includes('index')) {
52
+ routeLines.push(` Route::get('/', [${modelName}Controller::class, 'index'])->name('index');`);
53
+ }
54
+ if (actions.includes('store')) {
55
+ routeLines.push(` Route::post('/', [${modelName}Controller::class, 'store'])->name('store');`);
56
+ }
57
+ if (actions.includes('show')) {
58
+ routeLines.push(` Route::get('{${routeParam}}', [${modelName}Controller::class, 'show'])->name('show');`);
59
+ }
60
+ if (actions.includes('update')) {
61
+ routeLines.push(` Route::put('{${routeParam}}', [${modelName}Controller::class, 'update'])->name('update');`);
62
+ }
63
+ if (actions.includes('destroy')) {
64
+ routeLines.push(` Route::delete('{${routeParam}}', [${modelName}Controller::class, 'destroy'])->name('destroy');`);
65
+ }
66
+ // Restore (soft delete)
67
+ if (restore) {
68
+ routeLines.push(` Route::post('{${routeParam}}/restore', [${modelName}Controller::class, 'restore'])->name('restore');`);
69
+ }
70
+ // Build middleware block
71
+ const middlewareChain = middleware.length > 0
72
+ ? `->middleware([${middleware.map(m => `'${m}'`).join(', ')}])`
73
+ : '';
74
+ const content = `<?php
75
+
76
+ // Auto-generated by Omnify. DO NOT EDIT.
77
+
78
+ ${useStatements.join('\n')}
79
+
80
+ Route::prefix('${prefix}')->name('api.v1.${prefix}.')${middlewareChain}->group(function () {
81
+ ${routeLines.join('\n')}
82
+ });
83
+ `;
84
+ const snakeName = toSnakeCase(name);
85
+ return baseFile(`${config.routes.path}/${snakeName}.php`, content);
86
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Generates config/omnify-schemas.php — schema metadata for the Query Engine.
3
+ *
4
+ * Only includes schemas with `options.api` set.
5
+ * Only includes properties that have searchable/filterable/sortable flags.
6
+ */
7
+ import { SchemaReader } from './schema-reader.js';
8
+ import type { GeneratedFile, PhpConfig } from './types.js';
9
+ /** Generate the omnify-schemas config file. */
10
+ export declare function generateSchemaConfig(reader: SchemaReader, config: PhpConfig): GeneratedFile[];
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Generates config/omnify-schemas.php — schema metadata for the Query Engine.
3
+ *
4
+ * Only includes schemas with `options.api` set.
5
+ * Only includes properties that have searchable/filterable/sortable flags.
6
+ */
7
+ import { toPascalCase, toSnakeCase } from './naming-helper.js';
8
+ import { baseFile } from './types.js';
9
+ /** Generate the omnify-schemas config file. */
10
+ export function generateSchemaConfig(reader, config) {
11
+ const apiSchemas = reader.getSchemasWithApi();
12
+ if (Object.keys(apiSchemas).length === 0)
13
+ return [];
14
+ const entries = [];
15
+ for (const [name, schema] of Object.entries(apiSchemas)) {
16
+ entries.push(generateSchemaEntry(name, schema, reader, config));
17
+ }
18
+ const content = `<?php
19
+
20
+ // Auto-generated by Omnify. DO NOT EDIT.
21
+
22
+ return [
23
+ ${entries.join('\n')}];
24
+ `;
25
+ return [baseFile('config/omnify-schemas.php', content)];
26
+ }
27
+ function generateSchemaEntry(name, schema, reader, config) {
28
+ const modelName = toPascalCase(name);
29
+ const tableName = reader.getTableName(name);
30
+ const softDelete = schema.options?.softDelete ?? false;
31
+ const perPage = schema.options?.api?.perPage ?? 15;
32
+ const properties = schema.properties ?? {};
33
+ const propertyOrder = reader.getPropertyOrder(name);
34
+ const propLines = [];
35
+ for (const propName of propertyOrder) {
36
+ const prop = properties[propName];
37
+ if (!prop)
38
+ continue;
39
+ const searchable = prop.searchable ?? false;
40
+ const filterable = prop.filterable ?? false;
41
+ const sortable = prop.sortable ?? false;
42
+ // Only include properties with at least one query flag
43
+ if (!searchable && !filterable && !sortable)
44
+ continue;
45
+ const colName = toSnakeCase(propName);
46
+ const attrs = [];
47
+ attrs.push(`'type' => '${prop.type}'`);
48
+ if (searchable)
49
+ attrs.push(`'searchable' => true`);
50
+ if (filterable)
51
+ attrs.push(`'filterable' => true`);
52
+ if (sortable)
53
+ attrs.push(`'sortable' => true`);
54
+ // Include enum values for Enum types
55
+ if ((prop.type === 'Enum' || prop.type === 'EnumRef') && prop.enum) {
56
+ const enumArr = typeof prop.enum === 'string' ? [prop.enum] : [...prop.enum];
57
+ if (enumArr.length > 0) {
58
+ const enumValues = enumArr.map(v => `'${v}'`).join(', ');
59
+ attrs.push(`'enum' => [${enumValues}]`);
60
+ }
61
+ }
62
+ propLines.push(` '${colName}' => [${attrs.join(', ')}],`);
63
+ }
64
+ const propsBlock = propLines.length > 0
65
+ ? `\n 'properties' => [\n${propLines.join('\n')}\n ],`
66
+ : `\n 'properties' => [],`;
67
+ return ` '${modelName}' => [
68
+ 'table' => '${tableName}',
69
+ 'model' => \\${config.models.namespace}\\${modelName}::class,
70
+ 'softDelete' => ${softDelete ? 'true' : 'false'},
71
+ 'perPage' => ${perPage},${propsBlock}
72
+ ],
73
+ `;
74
+ }
@@ -47,6 +47,10 @@ export declare class SchemaReader {
47
47
  getExpandedProperties(schemaName: string): Record<string, ExpandedProperty>;
48
48
  getPropertyOrder(schemaName: string): string[];
49
49
  getTableName(schemaName: string): string;
50
+ /** Get schemas that have api options configured. */
51
+ getSchemasWithApi(): Record<string, SchemaDefinition>;
52
+ /** Check if any schema has api options. */
53
+ hasApiSchemas(): boolean;
50
54
  /** Check if any schema has File-type properties. */
51
55
  hasFileProperties(): boolean;
52
56
  /** Get the file config from schemas.json. */
@@ -158,6 +158,20 @@ export class SchemaReader {
158
158
  const schema = this.getSchema(schemaName);
159
159
  return schema?.tableName ?? '';
160
160
  }
161
+ /** Get schemas that have api options configured. */
162
+ getSchemasWithApi() {
163
+ const result = {};
164
+ for (const [name, schema] of Object.entries(this.getProjectVisibleObjectSchemas())) {
165
+ if (schema.options?.api) {
166
+ result[name] = schema;
167
+ }
168
+ }
169
+ return result;
170
+ }
171
+ /** Check if any schema has api options. */
172
+ hasApiSchemas() {
173
+ return Object.keys(this.getSchemasWithApi()).length > 0;
174
+ }
161
175
  /** Check if any schema has File-type properties. */
162
176
  hasFileProperties() {
163
177
  for (const schema of Object.values(this.getObjectSchemas())) {
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Generates base + editable service classes for schemas with `options.api`.
3
+ */
4
+ import { SchemaReader } from './schema-reader.js';
5
+ import type { GeneratedFile, PhpConfig } from './types.js';
6
+ /** Generate service classes for all schemas with api config. */
7
+ export declare function generateServices(reader: SchemaReader, config: PhpConfig): GeneratedFile[];
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Generates base + editable service classes for schemas with `options.api`.
3
+ */
4
+ import { toPascalCase, toSnakeCase } from './naming-helper.js';
5
+ import { baseFile, userFile } from './types.js';
6
+ /** Generate service classes for all schemas with api config. */
7
+ export function generateServices(reader, config) {
8
+ const files = [];
9
+ for (const [name, schema] of Object.entries(reader.getSchemasWithApi())) {
10
+ files.push(...generateForSchema(name, schema, reader, config));
11
+ }
12
+ return files;
13
+ }
14
+ function generateForSchema(name, schema, reader, config) {
15
+ return [
16
+ generateBaseService(name, schema, reader, config),
17
+ generateUserService(name, schema, config),
18
+ ];
19
+ }
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 = config.services.baseNamespace;
31
+ const modelNs = config.models.namespace;
32
+ const properties = schema.properties ?? {};
33
+ const propertyOrder = reader.getPropertyOrder(name);
34
+ // Collect searchable, filterable, sortable fields
35
+ const searchableFields = [];
36
+ const filterableFields = [];
37
+ const sortableFields = [];
38
+ for (const propName of propertyOrder) {
39
+ const prop = properties[propName];
40
+ if (!prop)
41
+ continue;
42
+ const colName = toSnakeCase(propName);
43
+ if (prop.searchable) {
44
+ searchableFields.push({ propName, colName });
45
+ }
46
+ if (prop.filterable) {
47
+ filterableFields.push({ propName, colName, type: prop.type, prop });
48
+ }
49
+ if (prop.sortable) {
50
+ sortableFields.push(colName);
51
+ }
52
+ }
53
+ // Build search block
54
+ const searchBlock = buildSearchBlock(searchableFields);
55
+ // Build filter block
56
+ const filterBlock = buildFilterBlock(filterableFields);
57
+ // Build sort block
58
+ const sortBlock = buildSortBlock(sortableFields);
59
+ // Build additional imports
60
+ const imports = [
61
+ `use ${modelNs}\\${modelName};`,
62
+ `use Illuminate\\Contracts\\Pagination\\LengthAwarePaginator;`,
63
+ ];
64
+ if (restore) {
65
+ imports.push(`use Illuminate\\Database\\Eloquent\\Collection;`);
66
+ }
67
+ // Build methods
68
+ const methods = [];
69
+ // list method
70
+ methods.push(`
71
+ /**
72
+ * List ${modelName} records with search, filter, and sort.
73
+ *
74
+ * @param array<string, mixed> $filters
75
+ */
76
+ public function list(array $filters): LengthAwarePaginator
77
+ {
78
+ $query = ${modelName}::query();
79
+
80
+ // Organization scope
81
+ if (isset($filters['organization_id'])) {
82
+ $query->where('organization_id', $filters['organization_id']);
83
+ }
84
+ ${searchBlock}${filterBlock}${sortBlock}
85
+ return $query->paginate($filters['per_page'] ?? ${perPage});
86
+ }`);
87
+ // lookup method
88
+ if (lookup) {
89
+ methods.push(`
90
+ /**
91
+ * Lookup ${modelName} records for select/combobox (lightweight).
92
+ *
93
+ * @param array<string, mixed> $filters
94
+ */
95
+ public function lookup(array $filters): LengthAwarePaginator
96
+ {
97
+ $query = ${modelName}::query();
98
+
99
+ if (isset($filters['organization_id'])) {
100
+ $query->where('organization_id', $filters['organization_id']);
101
+ }
102
+
103
+ if ($search = $filters['search'] ?? null) {
104
+ $query->where(function ($q) use ($search) {${searchableFields.length > 0 ? `\n${searchableFields.map((f, i) => ` $q->${i === 0 ? 'where' : 'orWhere'}('${f.colName}', 'like', "%{$search}%");`).join('\n')}` : `\n // No searchable fields defined`}
105
+ });
106
+ }
107
+
108
+ return $query->paginate($filters['per_page'] ?? 50);
109
+ }`);
110
+ }
111
+ // create method
112
+ methods.push(`
113
+ /**
114
+ * Create a new ${modelName}.
115
+ *
116
+ * @param array<string, mixed> $data
117
+ */
118
+ public function create(array $data): ${modelName}
119
+ {
120
+ return ${modelName}::create($data);
121
+ }`);
122
+ // update method
123
+ methods.push(`
124
+ /**
125
+ * Update an existing ${modelName}.
126
+ *
127
+ * @param array<string, mixed> $data
128
+ */
129
+ public function update(${modelName} $model, array $data): ${modelName}
130
+ {
131
+ $model->update($data);
132
+
133
+ return $model->fresh();
134
+ }`);
135
+ // delete method
136
+ methods.push(`
137
+ /**
138
+ * Delete a ${modelName}.
139
+ */
140
+ public function delete(${modelName} $model): void
141
+ {
142
+ $model->delete();
143
+ }`);
144
+ // bulkDelete method
145
+ if (bulkDelete) {
146
+ methods.push(`
147
+ /**
148
+ * Delete multiple ${modelName} records by IDs.
149
+ *
150
+ * @param array<int|string> $ids
151
+ */
152
+ public function bulkDelete(array $ids): void
153
+ {
154
+ ${modelName}::whereIn('id', $ids)->delete();
155
+ }`);
156
+ }
157
+ // restore method
158
+ if (restore) {
159
+ methods.push(`
160
+ /**
161
+ * Restore a soft-deleted ${modelName}.
162
+ */
163
+ public function restore(string $id): ${modelName}
164
+ {
165
+ $model = ${modelName}::withTrashed()->findOrFail($id);
166
+ $model->restore();
167
+
168
+ return $model->fresh();
169
+ }`);
170
+ }
171
+ const content = `<?php
172
+
173
+ namespace ${baseNs};
174
+
175
+ /**
176
+ * DO NOT EDIT - This file is auto-generated by Omnify.
177
+ * Any changes will be overwritten on next generation.
178
+ *
179
+ * @generated by omnify
180
+ */
181
+
182
+ ${imports.join('\n')}
183
+
184
+ class ${modelName}ServiceBase
185
+ {
186
+ ${methods.join('\n')}
187
+ }
188
+ `;
189
+ return baseFile(`${config.services.basePath}/${modelName}ServiceBase.php`, content);
190
+ }
191
+ // ============================================================================
192
+ // Search / Filter / Sort block builders
193
+ // ============================================================================
194
+ function buildSearchBlock(fields) {
195
+ if (fields.length === 0)
196
+ return '';
197
+ const clauses = fields.map((f, i) => {
198
+ const method = i === 0 ? 'where' : 'orWhere';
199
+ return ` $q->${method}('${f.colName}', 'like', "%{$search}%");`;
200
+ });
201
+ return `
202
+ // Search
203
+ if ($search = $filters['search'] ?? null) {
204
+ $query->where(function ($q) use ($search) {
205
+ ${clauses.join('\n')}
206
+ });
207
+ }
208
+ `;
209
+ }
210
+ function buildFilterBlock(fields) {
211
+ if (fields.length === 0)
212
+ return '';
213
+ const lines = [];
214
+ lines.push('');
215
+ lines.push(' // Filters');
216
+ for (const f of fields) {
217
+ const { colName, type } = f;
218
+ switch (type) {
219
+ case 'Decimal':
220
+ case 'Int':
221
+ case 'Float':
222
+ case 'Integer':
223
+ // Range filters: field_min, field_max
224
+ lines.push(` if (isset($filters['${colName}_min'])) {`);
225
+ lines.push(` $query->where('${colName}', '>=', $filters['${colName}_min']);`);
226
+ lines.push(` }`);
227
+ lines.push(` if (isset($filters['${colName}_max'])) {`);
228
+ lines.push(` $query->where('${colName}', '<=', $filters['${colName}_max']);`);
229
+ lines.push(` }`);
230
+ break;
231
+ case 'Date':
232
+ case 'Timestamp':
233
+ case 'DateTime':
234
+ // Date range filters: field_from, field_to
235
+ lines.push(` if (isset($filters['${colName}_from'])) {`);
236
+ lines.push(` $query->where('${colName}', '>=', $filters['${colName}_from']);`);
237
+ lines.push(` }`);
238
+ lines.push(` if (isset($filters['${colName}_to'])) {`);
239
+ lines.push(` $query->where('${colName}', '<=', $filters['${colName}_to']);`);
240
+ lines.push(` }`);
241
+ break;
242
+ case 'Boolean':
243
+ lines.push(` if (isset($filters['${colName}'])) {`);
244
+ lines.push(` $query->where('${colName}', filter_var($filters['${colName}'], FILTER_VALIDATE_BOOLEAN));`);
245
+ lines.push(` }`);
246
+ break;
247
+ case 'Enum':
248
+ case 'EnumRef':
249
+ case 'String':
250
+ default:
251
+ // Exact match
252
+ lines.push(` if (isset($filters['${colName}'])) {`);
253
+ lines.push(` $query->where('${colName}', $filters['${colName}']);`);
254
+ lines.push(` }`);
255
+ break;
256
+ }
257
+ }
258
+ return lines.join('\n') + '\n';
259
+ }
260
+ function buildSortBlock(sortableFields) {
261
+ if (sortableFields.length === 0) {
262
+ return `
263
+ // Sort
264
+ $query->orderBy('created_at', 'desc');
265
+ `;
266
+ }
267
+ const allowedList = sortableFields.map(f => `'${f}'`).join(', ');
268
+ return `
269
+ // Sort
270
+ $sortParam = $filters['sort'] ?? '-created_at';
271
+ $allowedSorts = [${allowedList}];
272
+
273
+ foreach (explode(',', $sortParam) as $sortField) {
274
+ $sortField = trim($sortField);
275
+ $direction = 'asc';
276
+
277
+ if (str_starts_with($sortField, '-')) {
278
+ $direction = 'desc';
279
+ $sortField = substr($sortField, 1);
280
+ }
281
+
282
+ if (in_array($sortField, $allowedSorts, true) || $sortField === 'created_at' || $sortField === 'updated_at') {
283
+ $query->orderBy($sortField, $direction);
284
+ }
285
+ }
286
+ `;
287
+ }
288
+ // ============================================================================
289
+ // User service
290
+ // ============================================================================
291
+ function generateUserService(name, schema, config) {
292
+ const modelName = toPascalCase(name);
293
+ const group = schema.group ? toPascalCase(schema.group) : null;
294
+ const baseNs = config.services.baseNamespace;
295
+ const userNs = group
296
+ ? `${config.services.namespace}\\${group}`
297
+ : config.services.namespace;
298
+ const userPath = group
299
+ ? `${config.services.path}/${group}`
300
+ : config.services.path;
301
+ const content = `<?php
302
+
303
+ /**
304
+ * ${modelName} Service
305
+ *
306
+ * SAFE TO EDIT - This file is never overwritten by Omnify.
307
+ */
308
+
309
+ namespace ${userNs};
310
+
311
+ use ${baseNs}\\${modelName}ServiceBase;
312
+
313
+ class ${modelName}Service extends ${modelName}ServiceBase
314
+ {
315
+ //
316
+ }
317
+ `;
318
+ return userFile(`${userPath}/${modelName}Service.php`, content);
319
+ }
@@ -29,6 +29,9 @@ export interface LaravelCodegenOverrides {
29
29
  factory?: LaravelPathOverride;
30
30
  provider?: LaravelPathOverride;
31
31
  policy?: LaravelPathOverride;
32
+ controller?: LaravelPathOverride;
33
+ service?: LaravelPathOverride;
34
+ route?: LaravelPathOverride;
32
35
  nestedset?: NestedSetOverride;
33
36
  }
34
37
  /** PHP codegen configuration (resolved with defaults). */
@@ -65,6 +68,21 @@ export interface PhpConfig {
65
68
  path: string;
66
69
  basePath: string;
67
70
  };
71
+ controllers: {
72
+ namespace: string;
73
+ baseNamespace: string;
74
+ path: string;
75
+ basePath: string;
76
+ };
77
+ services: {
78
+ namespace: string;
79
+ baseNamespace: string;
80
+ path: string;
81
+ basePath: string;
82
+ };
83
+ routes: {
84
+ path: string;
85
+ };
68
86
  nestedset: {
69
87
  namespace: string;
70
88
  };
package/dist/php/types.js CHANGED
@@ -23,6 +23,9 @@ const DEFAULT_RESOURCE_PATH = 'app/Http/Resources';
23
23
  const DEFAULT_FACTORY_PATH = 'database/factories';
24
24
  const DEFAULT_PROVIDER_PATH = 'app/Providers';
25
25
  const DEFAULT_POLICY_PATH = 'app/Policies/Omnify';
26
+ const DEFAULT_CONTROLLER_PATH = 'app/Http/Controllers';
27
+ const DEFAULT_SERVICE_PATH = 'app/Services';
28
+ const DEFAULT_ROUTE_PATH = 'routes/api/omnify';
26
29
  /**
27
30
  * Derive full PHP config from optional overrides.
28
31
  * All paths and namespaces fall back to sensible defaults.
@@ -40,6 +43,11 @@ export function derivePhpConfig(overrides) {
40
43
  const providerNs = overrides?.provider?.namespace ?? pathToNamespace(providerPath);
41
44
  const policyPath = overrides?.policy?.path ?? DEFAULT_POLICY_PATH;
42
45
  const policyNs = overrides?.policy?.namespace ?? pathToNamespace(policyPath);
46
+ const controllerPath = overrides?.controller?.path ?? DEFAULT_CONTROLLER_PATH;
47
+ const controllerNs = overrides?.controller?.namespace ?? pathToNamespace(controllerPath);
48
+ const servicePath = overrides?.service?.path ?? DEFAULT_SERVICE_PATH;
49
+ const serviceNs = overrides?.service?.namespace ?? pathToNamespace(servicePath);
50
+ const routePath = overrides?.route?.path ?? DEFAULT_ROUTE_PATH;
43
51
  const nestedsetNs = overrides?.nestedset?.namespace ?? 'Aimeos\\Nestedset';
44
52
  return {
45
53
  models: {
@@ -74,6 +82,21 @@ export function derivePhpConfig(overrides) {
74
82
  path: policyPath,
75
83
  basePath: `${policyPath}/Base`,
76
84
  },
85
+ controllers: {
86
+ namespace: controllerNs,
87
+ baseNamespace: `${controllerNs}\\OmnifyBase`,
88
+ path: controllerPath,
89
+ basePath: `${controllerPath}/OmnifyBase`,
90
+ },
91
+ services: {
92
+ namespace: serviceNs,
93
+ baseNamespace: `${serviceNs}\\OmnifyBase`,
94
+ path: servicePath,
95
+ basePath: `${servicePath}/OmnifyBase`,
96
+ },
97
+ routes: {
98
+ path: routePath,
99
+ },
77
100
  nestedset: {
78
101
  namespace: nestedsetNs,
79
102
  },
package/dist/types.d.ts CHANGED
@@ -91,6 +91,16 @@ export interface ExpandedColumn {
91
91
  readonly nullable?: boolean;
92
92
  readonly enum?: readonly string[];
93
93
  }
94
+ /** API generation options for a schema. */
95
+ export interface ApiOptions {
96
+ readonly prefix?: string;
97
+ readonly actions?: readonly string[];
98
+ readonly lookup?: boolean;
99
+ readonly bulkDelete?: boolean;
100
+ readonly restore?: boolean;
101
+ readonly middleware?: readonly string[];
102
+ readonly perPage?: number;
103
+ }
94
104
  /** Schema options. */
95
105
  export interface SchemaOptions {
96
106
  readonly id?: boolean | string;
@@ -101,6 +111,7 @@ export interface SchemaOptions {
101
111
  readonly tableName?: string;
102
112
  readonly indexes?: readonly unknown[];
103
113
  readonly unique?: readonly unknown[];
114
+ readonly api?: ApiOptions;
104
115
  }
105
116
  /** Schema definition from schemas.json. */
106
117
  export interface SchemaDefinition {
@@ -212,6 +223,9 @@ export interface PropertyDefinition {
212
223
  readonly joinTable?: string;
213
224
  readonly mappedBy?: string;
214
225
  readonly useCurrent?: boolean;
226
+ readonly searchable?: boolean;
227
+ readonly filterable?: boolean;
228
+ readonly sortable?: boolean;
215
229
  readonly fields?: Record<string, FieldOverride>;
216
230
  }
217
231
  /** Field override for compound type properties. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnifyjp/ts",
3
- "version": "2.1.11",
3
+ "version": "3.0.0",
4
4
  "description": "TypeScript model type generator from Omnify schemas.json",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",