@omnifyjp/ts 8.0.3 → 8.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/php/audit-observer-generator.js +6 -0
- package/dist/php/audit-trait-generator.js +16 -9
- package/dist/php/controller-generator.js +61 -25
- package/dist/php/factory-generator.js +5 -2
- package/dist/php/faker-mapper.js +13 -7
- package/dist/php/file-model-generator.js +21 -1
- package/dist/php/file-trait-generator.js +12 -1
- package/dist/php/model-generator.js +32 -13
- package/dist/php/route-generator.js +11 -3
- package/package.json +1 -1
|
@@ -167,6 +167,8 @@ class Audit extends Model
|
|
|
167
167
|
/**
|
|
168
168
|
* The audited record. \`auditable_id\` is unconstrained so the audit
|
|
169
169
|
* row survives a hard-delete of the source row.
|
|
170
|
+
*
|
|
171
|
+
* @return MorphTo<\\Illuminate\\Database\\Eloquent\\Model, $this>
|
|
170
172
|
*/
|
|
171
173
|
public function auditable(): MorphTo
|
|
172
174
|
{
|
|
@@ -176,6 +178,8 @@ class Audit extends Model
|
|
|
176
178
|
/**
|
|
177
179
|
* The actor who triggered the change. Null \`user_*\` columns
|
|
178
180
|
* indicate a system-driven write (CLI, scheduled job, internal API).
|
|
181
|
+
*
|
|
182
|
+
* @return MorphTo<\\Illuminate\\Database\\Eloquent\\Model, $this>
|
|
179
183
|
*/
|
|
180
184
|
public function user(): MorphTo
|
|
181
185
|
{
|
|
@@ -192,6 +196,8 @@ class Audit extends Model
|
|
|
192
196
|
* - \`1y\` → 1 year
|
|
193
197
|
*
|
|
194
198
|
* Empty / unparseable retention → matches no rows (kept forever).
|
|
199
|
+
*
|
|
200
|
+
* @return Builder<static>
|
|
195
201
|
*/
|
|
196
202
|
public function prunable(): Builder
|
|
197
203
|
{
|
|
@@ -50,6 +50,7 @@ namespace ${traitsNamespace};
|
|
|
50
50
|
*/
|
|
51
51
|
|
|
52
52
|
use ${modelNamespace}\\Audit;
|
|
53
|
+
use Illuminate\\Database\\Eloquent\\SoftDeletes;
|
|
53
54
|
use Illuminate\\Support\\Facades\\Auth;
|
|
54
55
|
use Illuminate\\Support\\Str;
|
|
55
56
|
|
|
@@ -84,16 +85,18 @@ trait HasOmnifyAuditLog
|
|
|
84
85
|
$model->writeOmnifyAudit('updated', $old, $dirty);
|
|
85
86
|
});
|
|
86
87
|
static::deleted(function ($model) {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
// class_uses_recursive, not method_exists: PHPStan evaluates the
|
|
89
|
+
// trait per using class and reports method_exists() as always
|
|
90
|
+
// true (or false) there.
|
|
91
|
+
$softDeletes = in_array(SoftDeletes::class, class_uses_recursive($model), true);
|
|
92
|
+
$event = $softDeletes && ! $model->isForceDeleting() ? 'soft_deleted' : 'deleted';
|
|
90
93
|
$model->writeOmnifyAudit($event, $model->getOriginal(), null);
|
|
91
94
|
});
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
95
|
+
// \`restored\` only fires for SoftDeletes models; registering the
|
|
96
|
+
// listener on any other model is a no-op.
|
|
97
|
+
static::registerModelEvent('restored', function ($model) {
|
|
98
|
+
$model->writeOmnifyAudit('restored', null, $model->getAttributes());
|
|
99
|
+
});
|
|
97
100
|
}
|
|
98
101
|
|
|
99
102
|
/**
|
|
@@ -187,7 +190,11 @@ trait HasOmnifyAuditLog
|
|
|
187
190
|
$this->writeOmnifyAudit($event, $oldValues, $newValues);
|
|
188
191
|
}
|
|
189
192
|
|
|
190
|
-
/**
|
|
193
|
+
/**
|
|
194
|
+
* Eloquent relation back to every audit row that targets this model.
|
|
195
|
+
*
|
|
196
|
+
* @return \\Illuminate\\Database\\Eloquent\\Relations\\MorphMany<Audit, $this>
|
|
197
|
+
*/
|
|
191
198
|
public function audits(): \\Illuminate\\Database\\Eloquent\\Relations\\MorphMany
|
|
192
199
|
{
|
|
193
200
|
return $this->morphMany(Audit::class, 'auditable');
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Generates base + editable controller classes for schemas with `options.api`.
|
|
3
3
|
*/
|
|
4
4
|
import { toPascalCase } from './naming-helper.js';
|
|
5
|
-
import { baseFile, userFile, resolveModularBasePath, resolveModularBaseNamespace, resolveBaseClass, resolveEditableClass, nestByGroup } from './types.js';
|
|
5
|
+
import { baseFile, userFile, resolveModularBasePath, resolveModularBaseNamespace, resolveBaseClass, resolveEditableClass, nestByGroup, nestEditableByGroup } from './types.js';
|
|
6
6
|
import { buildControllerMethodAttributes, openApiControllerImports } from './openapi-generator.js';
|
|
7
7
|
const DEFAULT_ACTIONS = ['index', 'store', 'show', 'update', 'destroy'];
|
|
8
8
|
/** Generate controller classes for all schemas with api config. */
|
|
@@ -27,7 +27,14 @@ function generateBaseController(name, schema, reader, config) {
|
|
|
27
27
|
const actions = [...(api.actions ?? DEFAULT_ACTIONS)];
|
|
28
28
|
const lookup = api.lookup ?? true;
|
|
29
29
|
const bulkDelete = api.bulkDelete ?? false;
|
|
30
|
-
|
|
30
|
+
// Restoring needs soft deletes: without them the service has no restore()
|
|
31
|
+
// or withTrashed lookup, so `api.restore: true` alone cannot be honoured.
|
|
32
|
+
const softDelete = schema.options?.softDelete ?? false;
|
|
33
|
+
const restore = softDelete && (api.restore ?? true);
|
|
34
|
+
if (api.restore === true && !softDelete) {
|
|
35
|
+
console.warn(`[omnify-ts] ${name}.options.api.restore is ignored (#301) — ${name} has no ` +
|
|
36
|
+
`softDelete, so there is nothing to restore. No restore action or route is generated.`);
|
|
37
|
+
}
|
|
31
38
|
// Issue #98 v5.8.4 / v5.8.5: cross-layer imports must mirror where
|
|
32
39
|
// the imported file actually lives. BASE layers (Models, Services
|
|
33
40
|
// base, Controllers base) are ALWAYS group-nested in legacy structure.
|
|
@@ -51,24 +58,26 @@ function generateBaseController(name, schema, reader, config) {
|
|
|
51
58
|
// and every project-owned service / policy that takes the
|
|
52
59
|
// editable type fails with TypeError.
|
|
53
60
|
const modelNs = groupNestEditable(config.models.userEditableNamespace, config.models);
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
const
|
|
61
|
+
// Each class the controller names must be one a generator actually writes,
|
|
62
|
+
// at the namespace and short name THAT generator uses: services and requests
|
|
63
|
+
// ship only their base (#101), resources also ship an editable stub (#114).
|
|
64
|
+
const service = baseClassRef(config, name, schema, 'services', 'Services', `${modelName}Service`);
|
|
65
|
+
const storeRequest = baseClassRef(config, name, schema, 'requests', 'Requests', `${modelName}StoreRequest`);
|
|
66
|
+
const updateRequest = baseClassRef(config, name, schema, 'requests', 'Requests', `${modelName}UpdateRequest`);
|
|
67
|
+
const resource = editableResourceRef(config, schema, modelName);
|
|
59
68
|
// Build imports
|
|
60
69
|
const imports = [
|
|
61
70
|
`use App\\Http\\Controllers\\Controller;`,
|
|
62
|
-
`use ${
|
|
71
|
+
`use ${service.fqn};`,
|
|
63
72
|
];
|
|
64
73
|
if (actions.includes('store')) {
|
|
65
|
-
imports.push(`use ${
|
|
74
|
+
imports.push(`use ${storeRequest.fqn};`);
|
|
66
75
|
}
|
|
67
76
|
if (actions.includes('update')) {
|
|
68
|
-
imports.push(`use ${
|
|
77
|
+
imports.push(`use ${updateRequest.fqn};`);
|
|
69
78
|
}
|
|
70
|
-
if (actions.includes('show') || actions.includes('
|
|
71
|
-
imports.push(`use ${
|
|
79
|
+
if (actions.includes('index') || lookup || actions.includes('show') || actions.includes('store') || actions.includes('update') || restore) {
|
|
80
|
+
imports.push(`use ${resource.fqn};`);
|
|
72
81
|
}
|
|
73
82
|
imports.push(`use ${modelNs}\\${modelName};`);
|
|
74
83
|
imports.push(`use Illuminate\\Http\\JsonResponse;`);
|
|
@@ -95,34 +104,34 @@ function generateBaseController(name, schema, reader, config) {
|
|
|
95
104
|
{
|
|
96
105
|
$result = $this->service->list($request->all());
|
|
97
106
|
|
|
98
|
-
return ${
|
|
107
|
+
return ${resource.className}::collection($result);
|
|
99
108
|
}`);
|
|
100
109
|
}
|
|
101
110
|
if (actions.includes('store')) {
|
|
102
111
|
methods.push(`${attrFor('store')}
|
|
103
|
-
public function store(${
|
|
112
|
+
public function store(${storeRequest.className} $request): JsonResponse
|
|
104
113
|
{
|
|
105
114
|
$model = $this->service->create($request->validated());
|
|
106
115
|
|
|
107
|
-
return (new ${
|
|
116
|
+
return (new ${resource.className}($model))
|
|
108
117
|
->response()
|
|
109
118
|
->setStatusCode(201);
|
|
110
119
|
}`);
|
|
111
120
|
}
|
|
112
121
|
if (actions.includes('show')) {
|
|
113
122
|
methods.push(`${attrFor('show')}
|
|
114
|
-
public function show(${modelName} $model): ${
|
|
123
|
+
public function show(${modelName} $model): ${resource.className}
|
|
115
124
|
{
|
|
116
|
-
return new ${
|
|
125
|
+
return new ${resource.className}($model);
|
|
117
126
|
}`);
|
|
118
127
|
}
|
|
119
128
|
if (actions.includes('update')) {
|
|
120
129
|
methods.push(`${attrFor('update')}
|
|
121
|
-
public function update(${
|
|
130
|
+
public function update(${updateRequest.className} $request, ${modelName} $model): ${resource.className}
|
|
122
131
|
{
|
|
123
132
|
$model = $this->service->update($model, $request->validated());
|
|
124
133
|
|
|
125
|
-
return new ${
|
|
134
|
+
return new ${resource.className}($model);
|
|
126
135
|
}`);
|
|
127
136
|
}
|
|
128
137
|
if (actions.includes('destroy')) {
|
|
@@ -140,25 +149,29 @@ function generateBaseController(name, schema, reader, config) {
|
|
|
140
149
|
{
|
|
141
150
|
$result = $this->service->lookup($request->all());
|
|
142
151
|
|
|
143
|
-
return ${
|
|
152
|
+
return ${resource.className}::collection($result);
|
|
144
153
|
}`);
|
|
145
154
|
}
|
|
146
155
|
if (bulkDelete) {
|
|
147
156
|
methods.push(`${attrFor('bulkDelete')}
|
|
148
157
|
public function bulkDelete(Request $request): JsonResponse
|
|
149
158
|
{
|
|
150
|
-
|
|
159
|
+
// The service has no bulk method: delete each record through it, so
|
|
160
|
+
// soft deletes, model events and auditing apply as for destroy().
|
|
161
|
+
foreach ((array) $request->input('ids', []) as $id) {
|
|
162
|
+
$this->service->delete($this->service->findById((string) $id));
|
|
163
|
+
}
|
|
151
164
|
|
|
152
165
|
return response()->json(null, 204);
|
|
153
166
|
}`);
|
|
154
167
|
}
|
|
155
168
|
if (restore) {
|
|
156
169
|
methods.push(`${attrFor('restore')}
|
|
157
|
-
public function restore(string $id): ${
|
|
170
|
+
public function restore(string $id): ${resource.className}
|
|
158
171
|
{
|
|
159
|
-
$model = $this->service->restore($id);
|
|
172
|
+
$model = $this->service->restore($this->service->findById($id, withTrashed: true));
|
|
160
173
|
|
|
161
|
-
return new ${
|
|
174
|
+
return new ${resource.className}($model);
|
|
162
175
|
}`);
|
|
163
176
|
}
|
|
164
177
|
const content = `<?php
|
|
@@ -177,7 +190,7 @@ ${imports.join('\n')}
|
|
|
177
190
|
abstract class ${modelName}ControllerBase extends Controller
|
|
178
191
|
{
|
|
179
192
|
public function __construct(
|
|
180
|
-
protected readonly ${
|
|
193
|
+
protected readonly ${service.className} $service,
|
|
181
194
|
) {}
|
|
182
195
|
${methods.join('\n')}
|
|
183
196
|
}
|
|
@@ -254,3 +267,26 @@ function legacyGroupedBase(config, className, group) {
|
|
|
254
267
|
const { namespace } = nestByGroup({ path: '', namespace: raw.namespace }, group);
|
|
255
268
|
return { className: raw.className, fqn: `${namespace}\\${raw.className}` };
|
|
256
269
|
}
|
|
270
|
+
/**
|
|
271
|
+
* The base class a scaffold generator writes for `schemaName`, as that
|
|
272
|
+
* generator names and places it: modular → `<Name>Base` in the module, legacy
|
|
273
|
+
* → `resolveBaseClass` (flatBase drops the suffix) nested under the group.
|
|
274
|
+
*/
|
|
275
|
+
function baseClassRef(config, name, schema, layer, modularFolder, className) {
|
|
276
|
+
if (config.structure === 'modular') {
|
|
277
|
+
const ns = resolveModularBaseNamespace(config, name, modularFolder, config[layer].baseNamespace);
|
|
278
|
+
return { className: `${className}Base`, fqn: `${ns}\\${className}Base` };
|
|
279
|
+
}
|
|
280
|
+
const raw = resolveBaseClass(config[layer], className, 'Base');
|
|
281
|
+
const { namespace } = nestByGroup({ path: '', namespace: raw.namespace }, schema.group);
|
|
282
|
+
return { className: raw.className, fqn: `${namespace}\\${raw.className}` };
|
|
283
|
+
}
|
|
284
|
+
/** The editable Resource stub the resource generator writes (#114). */
|
|
285
|
+
function editableResourceRef(config, schema, modelName) {
|
|
286
|
+
if (config.structure === 'modular') {
|
|
287
|
+
return { className: `${modelName}Resource`, fqn: `${config.resources.namespace}\\${modelName}Resource` };
|
|
288
|
+
}
|
|
289
|
+
const raw = resolveEditableClass(config.resources, modelName, 'Resource');
|
|
290
|
+
const { namespace } = nestEditableByGroup(config.resources, { path: raw.path, namespace: raw.namespace }, schema.group);
|
|
291
|
+
return { className: raw.className, fqn: `${namespace}\\${raw.className}` };
|
|
292
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { toPascalCase } from './naming-helper.js';
|
|
5
5
|
import { toFaker, compoundFaker, associationFaker } from './faker-mapper.js';
|
|
6
|
-
import { userFile } from './types.js';
|
|
6
|
+
import { userFile, resolveGlobalEnumNamespace } from './types.js';
|
|
7
7
|
/** Generate Factory classes for all project-owned visible object schemas. */
|
|
8
8
|
export function generateFactories(reader, config) {
|
|
9
9
|
const files = [];
|
|
@@ -148,6 +148,9 @@ export function generateFileFactory(reader, config) {
|
|
|
148
148
|
return [];
|
|
149
149
|
const modelNamespace = config.models.namespace;
|
|
150
150
|
const factoryNamespace = config.factories.namespace;
|
|
151
|
+
// FileStatusEnum is a global enum (file-model-generator casts it from here),
|
|
152
|
+
// not a class in the models namespace.
|
|
153
|
+
const enumNamespace = resolveGlobalEnumNamespace(config, config.models.namespace);
|
|
151
154
|
const content = `<?php
|
|
152
155
|
|
|
153
156
|
/**
|
|
@@ -159,7 +162,7 @@ export function generateFileFactory(reader, config) {
|
|
|
159
162
|
namespace ${factoryNamespace};
|
|
160
163
|
|
|
161
164
|
use ${modelNamespace}\\File;
|
|
162
|
-
use ${
|
|
165
|
+
use ${enumNamespace}\\FileStatusEnum;
|
|
163
166
|
use Illuminate\\Database\\Eloquent\\Factories\\Factory;
|
|
164
167
|
use Illuminate\\Support\\Str;
|
|
165
168
|
|
package/dist/php/faker-mapper.js
CHANGED
|
@@ -40,14 +40,17 @@ export function toFaker(propName, property, schemas = {}) {
|
|
|
40
40
|
/** Generate faker expressions for compound type expanded columns. */
|
|
41
41
|
export function compoundFaker(propName, expansion, sourceType) {
|
|
42
42
|
const snakeName = toSnakeCase(propName);
|
|
43
|
+
// Locale-only providers (ja_JP kana names, ward, en secondaryAddress) are
|
|
44
|
+
// called through format(): Faker's Generator declares no @method for them,
|
|
45
|
+
// so a direct call is "undefined method" to static analysis.
|
|
43
46
|
const jaFaker = "fake('ja_JP')";
|
|
44
47
|
switch (sourceType) {
|
|
45
48
|
case 'JapaneseName':
|
|
46
49
|
return [
|
|
47
50
|
`'${snakeName}_lastname' => ${jaFaker}->lastName(),`,
|
|
48
51
|
`'${snakeName}_firstname' => ${jaFaker}->firstName(),`,
|
|
49
|
-
`'${snakeName}_kana_lastname' => ${jaFaker}->lastKanaName
|
|
50
|
-
`'${snakeName}_kana_firstname' => ${jaFaker}->firstKanaName
|
|
52
|
+
`'${snakeName}_kana_lastname' => ${jaFaker}->format('lastKanaName'),`,
|
|
53
|
+
`'${snakeName}_kana_firstname' => ${jaFaker}->format('firstKanaName'),`,
|
|
51
54
|
];
|
|
52
55
|
case 'JapaneseAddress':
|
|
53
56
|
return japaneseAddressFaker(snakeName, expansion);
|
|
@@ -57,7 +60,7 @@ export function compoundFaker(propName, expansion, sourceType) {
|
|
|
57
60
|
`'${snakeName}_branch_code' => ${jaFaker}->regexify('[0-9]{3}'),`,
|
|
58
61
|
`'${snakeName}_account_type' => ${jaFaker}->randomElement(['1', '2', '4']),`,
|
|
59
62
|
`'${snakeName}_account_number' => ${jaFaker}->regexify('[0-9]{7}'),`,
|
|
60
|
-
`'${snakeName}_account_holder' => ${jaFaker}->lastKanaName
|
|
63
|
+
`'${snakeName}_account_holder' => ${jaFaker}->format('lastKanaName').' '.${jaFaker}->format('firstKanaName'),`,
|
|
61
64
|
];
|
|
62
65
|
default:
|
|
63
66
|
return genericCompoundFaker(snakeName, expansion);
|
|
@@ -73,7 +76,8 @@ export function associationFaker(propName, property, modelNamespace, currentSche
|
|
|
73
76
|
const nullable = property['nullable'] ?? false;
|
|
74
77
|
const fake = nullable
|
|
75
78
|
? `'${foreignKey}' => ${target}::query()->inRandomOrder()->first()?->id,`
|
|
76
|
-
|
|
79
|
+
// `->id ??`, not `?->id ??`: `??` already reads a property of null as null.
|
|
80
|
+
: `'${foreignKey}' => ${target}::query()->inRandomOrder()->first()->id ?? ${target}::factory()->create()->id,`;
|
|
77
81
|
let importLine = null;
|
|
78
82
|
if (target !== currentSchema) {
|
|
79
83
|
const targetNs = property['targetNamespace'] ?? modelNamespace;
|
|
@@ -136,8 +140,10 @@ function stringFakerExpr(propName, snakeName, property) {
|
|
|
136
140
|
if (lower.includes('code'))
|
|
137
141
|
return `fake()->unique()->regexify('[A-Z0-9]{8}')`;
|
|
138
142
|
const length = property['length'];
|
|
143
|
+
// text(), not words(3, true): words() is typed array|string, and every
|
|
144
|
+
// String column is wrapped in mb_substr(), which needs a string.
|
|
139
145
|
if (length != null && length <= 50)
|
|
140
|
-
return `fake()->
|
|
146
|
+
return `fake()->text(50)`;
|
|
141
147
|
return `fake()->sentence()`;
|
|
142
148
|
}
|
|
143
149
|
/** Largest value a `Decimal(precision, scale)` column accepts (all-9s at that precision/scale). */
|
|
@@ -221,9 +227,9 @@ function japaneseAddressFaker(snakeName, expansion) {
|
|
|
221
227
|
else {
|
|
222
228
|
lines.push(`'${snakeName}_prefecture' => fake()->randomElement(['hokkaido', 'aomori', 'iwate', 'miyagi', 'akita', 'yamagata', 'fukushima', 'ibaraki', 'tochigi', 'gunma', 'saitama', 'chiba', 'tokyo', 'kanagawa', 'niigata', 'toyama', 'ishikawa', 'fukui', 'yamanashi', 'nagano', 'gifu', 'shizuoka', 'aichi', 'mie', 'shiga', 'kyoto', 'osaka', 'hyogo', 'nara', 'wakayama', 'tottori', 'shimane', 'okayama', 'hiroshima', 'yamaguchi', 'tokushima', 'kagawa', 'ehime', 'kochi', 'fukuoka', 'saga', 'nagasaki', 'kumamoto', 'oita', 'miyazaki', 'kagoshima', 'okinawa']),`);
|
|
223
229
|
}
|
|
224
|
-
lines.push(`'${snakeName}_address1' => ${jaFaker}->city().${jaFaker}->ward
|
|
230
|
+
lines.push(`'${snakeName}_address1' => ${jaFaker}->city().${jaFaker}->format('ward'),`);
|
|
225
231
|
lines.push(`'${snakeName}_address2' => ${jaFaker}->streetAddress(),`);
|
|
226
|
-
lines.push(`'${snakeName}_address3' => ${jaFaker}->optional()->secondaryAddress
|
|
232
|
+
lines.push(`'${snakeName}_address3' => ${jaFaker}->optional()->format('secondaryAddress'),`);
|
|
227
233
|
return lines;
|
|
228
234
|
}
|
|
229
235
|
function genericCompoundFaker(snakeName, expansion) {
|
|
@@ -39,6 +39,7 @@ namespace ${baseNamespace};
|
|
|
39
39
|
use Illuminate\\Http\\Request;
|
|
40
40
|
use Illuminate\\Http\\Resources\\Json\\JsonResource;
|
|
41
41
|
|
|
42
|
+
/** @mixin \\${config.models.namespace}\\File */
|
|
42
43
|
class FileResourceBase extends JsonResource
|
|
43
44
|
{
|
|
44
45
|
/**
|
|
@@ -56,7 +57,7 @@ class FileResourceBase extends JsonResource
|
|
|
56
57
|
'original_name' => $this->original_name,
|
|
57
58
|
'mime_type' => $this->mime_type,
|
|
58
59
|
'size' => $this->size,
|
|
59
|
-
'status' => $this->status
|
|
60
|
+
'status' => $this->status->value,
|
|
60
61
|
'url' => $this->url ?? null,
|
|
61
62
|
'expires_at' => $this->expires_at?->toISOString(),
|
|
62
63
|
'sort_order' => $this->sort_order,
|
|
@@ -167,11 +168,15 @@ class FileBaseModel extends BaseModel
|
|
|
167
168
|
|
|
168
169
|
/**
|
|
169
170
|
* Localized display names for this model.
|
|
171
|
+
*
|
|
172
|
+
* @var array<string, string>
|
|
170
173
|
*/
|
|
171
174
|
protected static array $localizedDisplayNames = FileLocales::DISPLAY_NAMES;
|
|
172
175
|
|
|
173
176
|
/**
|
|
174
177
|
* Localized display names for properties.
|
|
178
|
+
*
|
|
179
|
+
* @var array<string, array<string, string>>
|
|
175
180
|
*/
|
|
176
181
|
protected static array $localizedPropertyDisplayNames = FileLocales::PROPERTY_DISPLAY_NAMES;
|
|
177
182
|
|
|
@@ -212,6 +217,8 @@ class FileBaseModel extends BaseModel
|
|
|
212
217
|
|
|
213
218
|
/**
|
|
214
219
|
* Get the parent fileable model.
|
|
220
|
+
*
|
|
221
|
+
* @return MorphTo<\\Illuminate\\Database\\Eloquent\\Model, $this>
|
|
215
222
|
*/
|
|
216
223
|
public function fileable(): MorphTo
|
|
217
224
|
{
|
|
@@ -224,6 +231,9 @@ class FileBaseModel extends BaseModel
|
|
|
224
231
|
|
|
225
232
|
/**
|
|
226
233
|
* Scope to a specific collection.
|
|
234
|
+
*
|
|
235
|
+
* @param Builder<self> $query
|
|
236
|
+
* @return Builder<self>
|
|
227
237
|
*/
|
|
228
238
|
public function scopeInCollection(Builder $query, string $collection): Builder
|
|
229
239
|
{
|
|
@@ -232,6 +242,9 @@ class FileBaseModel extends BaseModel
|
|
|
232
242
|
|
|
233
243
|
/**
|
|
234
244
|
* Scope to permanent files.
|
|
245
|
+
*
|
|
246
|
+
* @param Builder<self> $query
|
|
247
|
+
* @return Builder<self>
|
|
235
248
|
*/
|
|
236
249
|
public function scopePermanent(Builder $query): Builder
|
|
237
250
|
{
|
|
@@ -240,6 +253,9 @@ class FileBaseModel extends BaseModel
|
|
|
240
253
|
|
|
241
254
|
/**
|
|
242
255
|
* Scope to temporary files.
|
|
256
|
+
*
|
|
257
|
+
* @param Builder<self> $query
|
|
258
|
+
* @return Builder<self>
|
|
243
259
|
*/
|
|
244
260
|
public function scopeTemporary(Builder $query): Builder
|
|
245
261
|
{
|
|
@@ -248,6 +264,9 @@ class FileBaseModel extends BaseModel
|
|
|
248
264
|
|
|
249
265
|
/**
|
|
250
266
|
* Scope to expired files (temporary + past expires_at).
|
|
267
|
+
*
|
|
268
|
+
* @param Builder<self> $query
|
|
269
|
+
* @return Builder<self>
|
|
251
270
|
*/
|
|
252
271
|
public function scopeExpired(Builder $query): Builder
|
|
253
272
|
{
|
|
@@ -317,6 +336,7 @@ use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
|
|
|
317
336
|
*/
|
|
318
337
|
class File extends FileBaseModel
|
|
319
338
|
{
|
|
339
|
+
/** @use HasFactory<FileFactory> */
|
|
320
340
|
use HasFactory;
|
|
321
341
|
|
|
322
342
|
/**
|
|
@@ -30,6 +30,8 @@ trait HasFiles
|
|
|
30
30
|
{
|
|
31
31
|
/**
|
|
32
32
|
* Get all files attached to this model.
|
|
33
|
+
*
|
|
34
|
+
* @return MorphMany<File, $this>
|
|
33
35
|
*/
|
|
34
36
|
public function files(): MorphMany
|
|
35
37
|
{
|
|
@@ -38,6 +40,8 @@ trait HasFiles
|
|
|
38
40
|
|
|
39
41
|
/**
|
|
40
42
|
* Get files in a specific collection.
|
|
43
|
+
*
|
|
44
|
+
* @return MorphMany<File, $this>
|
|
41
45
|
*/
|
|
42
46
|
public function filesInCollection(string $collection): MorphMany
|
|
43
47
|
{
|
|
@@ -46,6 +50,8 @@ trait HasFiles
|
|
|
46
50
|
|
|
47
51
|
/**
|
|
48
52
|
* Get all permanent files.
|
|
53
|
+
*
|
|
54
|
+
* @return MorphMany<File, $this>
|
|
49
55
|
*/
|
|
50
56
|
public function permanentFiles(): MorphMany
|
|
51
57
|
{
|
|
@@ -69,8 +75,13 @@ trait HasFiles
|
|
|
69
75
|
'collection' => $collection,
|
|
70
76
|
'status' => FileStatusEnum::Permanent,
|
|
71
77
|
'expires_at' => null,
|
|
72
|
-
'sort_order' => \\Illuminate\\Support\\Facades\\DB::raw('sort_order + '.$startOrder),
|
|
73
78
|
]);
|
|
79
|
+
|
|
80
|
+
// increment(), not DB::raw('sort_order + '.$startOrder): raw SQL is
|
|
81
|
+
// typed literal-string, and a column offset needs no SQL of our own.
|
|
82
|
+
if ($startOrder !== 0) {
|
|
83
|
+
File::whereIn('id', $fileIds)->increment('sort_order', $startOrder);
|
|
84
|
+
}
|
|
74
85
|
}
|
|
75
86
|
|
|
76
87
|
/**
|
|
@@ -443,15 +443,20 @@ function generateUserModel(name, schema, reader, config, hasNestedSet = false) {
|
|
|
443
443
|
const baseImport = baseAlias === base.className
|
|
444
444
|
? `use ${base.fqn};`
|
|
445
445
|
: `use ${base.fqn} as ${baseAlias};`;
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
446
|
+
// The factory generator skips hidden schemas, so a hidden schema's stub
|
|
447
|
+
// must not name a factory that is never written.
|
|
448
|
+
const hasFactory = !schema.options?.hidden;
|
|
449
|
+
const imports = hasFactory
|
|
450
|
+
? [
|
|
451
|
+
baseImport,
|
|
452
|
+
`use ${factoryNamespace}\\${modelName}Factory;`,
|
|
453
|
+
`use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;`,
|
|
454
|
+
]
|
|
455
|
+
: [baseImport];
|
|
451
456
|
if (hasNestedSet) {
|
|
452
457
|
imports.push(`use ${nestedSetNamespace}\\NodeTrait;`);
|
|
453
458
|
}
|
|
454
|
-
const traits = [` /** @use HasFactory<${modelName}Factory> */\n use HasFactory;`];
|
|
459
|
+
const traits = hasFactory ? [` /** @use HasFactory<${modelName}Factory> */\n use HasFactory;`] : [];
|
|
455
460
|
if (hasNestedSet) {
|
|
456
461
|
traits.push(' use NodeTrait;');
|
|
457
462
|
}
|
|
@@ -481,7 +486,7 @@ ${imports.join('\n')}
|
|
|
481
486
|
*/
|
|
482
487
|
class ${modelName} extends ${baseAlias}
|
|
483
488
|
{
|
|
484
|
-
${traits.join('\n')}
|
|
489
|
+
${hasFactory ? `${traits.join('\n')}
|
|
485
490
|
|
|
486
491
|
/**
|
|
487
492
|
* Create a new factory instance for the model.
|
|
@@ -491,7 +496,9 @@ ${traits.join('\n')}
|
|
|
491
496
|
return ${modelName}Factory::new();
|
|
492
497
|
}
|
|
493
498
|
|
|
494
|
-
|
|
499
|
+
` : traits.length > 0 ? `${traits.join('\n')}
|
|
500
|
+
|
|
501
|
+
` : ''} //
|
|
495
502
|
}
|
|
496
503
|
`;
|
|
497
504
|
// #171: this subclass is created once and then owned by the project. The
|
|
@@ -1087,19 +1094,24 @@ function buildAccessors(expandedProperties) {
|
|
|
1087
1094
|
for (const [propName, expansion] of Object.entries(expandedProperties)) {
|
|
1088
1095
|
const sourceType = expansion.sourceType ?? '';
|
|
1089
1096
|
const snakeName = toSnakeCase(propName);
|
|
1097
|
+
// A column absent from the expansion is treated as nullable (safe side).
|
|
1098
|
+
const nullable = (column) => {
|
|
1099
|
+
const col = expansion.columns?.find((c) => c.name === column);
|
|
1100
|
+
return col === undefined ? true : (col.nullable ?? false);
|
|
1101
|
+
};
|
|
1090
1102
|
switch (sourceType) {
|
|
1091
1103
|
case 'JapaneseName':
|
|
1092
1104
|
methods.push(fullNameAccessor(snakeName, 'FullName', [
|
|
1093
1105
|
`${snakeName}_lastname`, `${snakeName}_firstname`,
|
|
1094
|
-
], ' '));
|
|
1106
|
+
], ' ', nullable));
|
|
1095
1107
|
methods.push(fullNameAccessor(snakeName, 'FullNameKana', [
|
|
1096
1108
|
`${snakeName}_kana_lastname`, `${snakeName}_kana_firstname`,
|
|
1097
|
-
], ' '));
|
|
1109
|
+
], ' ', nullable));
|
|
1098
1110
|
break;
|
|
1099
1111
|
case 'JapaneseAddress':
|
|
1100
1112
|
methods.push(fullNameAccessor(snakeName, 'FullAddress', [
|
|
1101
1113
|
`${snakeName}_address1`, `${snakeName}_address2`, `${snakeName}_address3`,
|
|
1102
|
-
], ''));
|
|
1114
|
+
], '', nullable));
|
|
1103
1115
|
break;
|
|
1104
1116
|
}
|
|
1105
1117
|
}
|
|
@@ -1347,6 +1359,8 @@ function buildFileAccessors(properties, propertyOrder, modelNamespace) {
|
|
|
1347
1359
|
methods.push(`
|
|
1348
1360
|
/**
|
|
1349
1361
|
* Get ${propName} files (collection: ${collection}).
|
|
1362
|
+
*
|
|
1363
|
+
* @return MorphMany<File, $this>
|
|
1350
1364
|
*/
|
|
1351
1365
|
public function ${methodName}(): MorphMany
|
|
1352
1366
|
{
|
|
@@ -1357,6 +1371,8 @@ function buildFileAccessors(properties, propertyOrder, modelNamespace) {
|
|
|
1357
1371
|
methods.push(`
|
|
1358
1372
|
/**
|
|
1359
1373
|
* Get ${propName} file (collection: ${collection}).
|
|
1374
|
+
*
|
|
1375
|
+
* @return MorphOne<File, $this>
|
|
1360
1376
|
*/
|
|
1361
1377
|
public function ${methodName}(): MorphOne
|
|
1362
1378
|
{
|
|
@@ -1367,17 +1383,20 @@ function buildFileAccessors(properties, propertyOrder, modelNamespace) {
|
|
|
1367
1383
|
}
|
|
1368
1384
|
return methods.join('\n');
|
|
1369
1385
|
}
|
|
1370
|
-
function fullNameAccessor(prefix, suffix, fields, separator) {
|
|
1386
|
+
function fullNameAccessor(prefix, suffix, fields, separator, isNullable) {
|
|
1371
1387
|
const methodName = 'get' + toPascalCase(prefix) + suffix + 'Attribute';
|
|
1372
1388
|
const fieldAccess = fields.map(f => `$this->${f}`).join(', ');
|
|
1373
1389
|
const separatorArg = separator === '' ? "''" : `'${separator}'`;
|
|
1390
|
+
// `$v !== null` on parts that are all non-nullable columns is dead code
|
|
1391
|
+
// (PHPStan notIdentical.alwaysTrue); keep it only when a part can be null.
|
|
1392
|
+
const keep = fields.some(isNullable) ? "$v !== null && $v !== ''" : "$v !== ''";
|
|
1374
1393
|
return `
|
|
1375
1394
|
/**
|
|
1376
1395
|
* Get the ${prefix} ${suffix} attribute.
|
|
1377
1396
|
*/
|
|
1378
1397
|
public function ${methodName}(): ?string
|
|
1379
1398
|
{
|
|
1380
|
-
$parts = array_filter([${fieldAccess}], fn($v) => $
|
|
1399
|
+
$parts = array_filter([${fieldAccess}], fn($v) => ${keep});
|
|
1381
1400
|
return count($parts) > 0 ? implode(${separatorArg}, $parts) : null;
|
|
1382
1401
|
}`;
|
|
1383
1402
|
}
|
|
@@ -23,7 +23,10 @@ function generateRouteFile(name, schema, config) {
|
|
|
23
23
|
const actions = [...(api.actions ?? DEFAULT_ACTIONS)];
|
|
24
24
|
const lookup = api.lookup ?? true;
|
|
25
25
|
const bulkDelete = api.bulkDelete ?? false;
|
|
26
|
-
|
|
26
|
+
// Restoring needs soft deletes: without them the service has no restore()
|
|
27
|
+
// or withTrashed lookup, so `api.restore: true` alone cannot be honoured.
|
|
28
|
+
const softDelete = schema.options?.softDelete ?? false;
|
|
29
|
+
const restore = softDelete && (api.restore ?? true);
|
|
27
30
|
const middleware = api.middleware ?? [];
|
|
28
31
|
// Route prefix: use api.prefix or default to snake_case plural
|
|
29
32
|
const prefix = api.prefix ?? pluralize(toSnakeCase(name));
|
|
@@ -31,9 +34,14 @@ function generateRouteFile(name, schema, config) {
|
|
|
31
34
|
const routeParam = toCamelCase(name);
|
|
32
35
|
// Controller namespace
|
|
33
36
|
const group = schema.group ? toPascalCase(schema.group) : null;
|
|
37
|
+
// The editable controller lives where generateUserController writes it:
|
|
38
|
+
// under userEditableNamespace (legacy), not the base layer's namespace.
|
|
39
|
+
const controllerRoot = config.structure === 'modular'
|
|
40
|
+
? config.controllers.namespace
|
|
41
|
+
: config.controllers.userEditableNamespace;
|
|
34
42
|
const controllerNs = group
|
|
35
|
-
? `${
|
|
36
|
-
: `${
|
|
43
|
+
? `${controllerRoot}\\Api\\V1\\${group}`
|
|
44
|
+
: `${controllerRoot}\\Api\\V1`;
|
|
37
45
|
// Build use statements
|
|
38
46
|
const useStatements = [
|
|
39
47
|
`use ${controllerNs}\\${modelName}Controller;`,
|