@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.
@@ -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
- $event = method_exists($model, 'isForceDeleting') && $model->isForceDeleting()
88
- ? 'deleted'
89
- : (method_exists($model, 'trashed') ? 'soft_deleted' : 'deleted');
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
- if (method_exists(static::class, 'restored')) {
93
- static::restored(function ($model) {
94
- $model->writeOmnifyAudit('restored', null, $model->getAttributes());
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
- /** Eloquent relation back to every audit row that targets this model. */
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
- const restore = api.restore ?? (schema.options?.softDelete ?? false);
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
- const serviceBaseNs = config.structure === 'modular'
55
- ? resolveModularBaseNamespace(config, name, 'Services', config.services.baseNamespace)
56
- : groupNestBase(config.services.baseNamespace);
57
- const requestNs = groupNestEditable(config.requests.namespace, config.requests);
58
- const resourceNs = groupNestEditable(config.resources.namespace, config.resources);
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 ${serviceBaseNs}\\${modelName}ServiceBase;`,
71
+ `use ${service.fqn};`,
63
72
  ];
64
73
  if (actions.includes('store')) {
65
- imports.push(`use ${requestNs}\\${modelName}StoreRequest;`);
74
+ imports.push(`use ${storeRequest.fqn};`);
66
75
  }
67
76
  if (actions.includes('update')) {
68
- imports.push(`use ${requestNs}\\${modelName}UpdateRequest;`);
77
+ imports.push(`use ${updateRequest.fqn};`);
69
78
  }
70
- if (actions.includes('show') || actions.includes('update') || actions.includes('destroy') || restore) {
71
- imports.push(`use ${resourceNs}\\${modelName}Resource;`);
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 ${modelName}Resource::collection($result);
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(${modelName}StoreRequest $request): JsonResponse
112
+ public function store(${storeRequest.className} $request): JsonResponse
104
113
  {
105
114
  $model = $this->service->create($request->validated());
106
115
 
107
- return (new ${modelName}Resource($model))
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): ${modelName}Resource
123
+ public function show(${modelName} $model): ${resource.className}
115
124
  {
116
- return new ${modelName}Resource($model);
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(${modelName}UpdateRequest $request, ${modelName} $model): ${modelName}Resource
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 ${modelName}Resource($model);
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 ${modelName}Resource::collection($result);
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
- $this->service->bulkDelete($request->input('ids', []));
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): ${modelName}Resource
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 ${modelName}Resource($model);
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 ${modelName}ServiceBase $service,
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 ${modelNamespace}\\FileStatusEnum;
165
+ use ${enumNamespace}\\FileStatusEnum;
163
166
  use Illuminate\\Database\\Eloquent\\Factories\\Factory;
164
167
  use Illuminate\\Support\\Str;
165
168
 
@@ -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().' '.${jaFaker}->firstKanaName(),`,
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
- : `'${foreignKey}' => ${target}::query()->inRandomOrder()->first()?->id ?? ${target}::factory()->create()->id,`;
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()->words(3, true)`;
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?->value,
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
- const imports = [
447
- baseImport,
448
- `use ${factoryNamespace}\\${modelName}Factory;`,
449
- `use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;`,
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) => $v !== null && $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
- const restore = api.restore ?? (schema.options?.softDelete ?? false);
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
- ? `${config.controllers.namespace}\\Api\\V1\\${group}`
36
- : `${config.controllers.namespace}\\Api\\V1`;
43
+ ? `${controllerRoot}\\Api\\V1\\${group}`
44
+ : `${controllerRoot}\\Api\\V1`;
37
45
  // Build use statements
38
46
  const useStatements = [
39
47
  `use ${controllerNs}\\${modelName}Controller;`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnifyjp/ts",
3
- "version": "8.0.3",
3
+ "version": "8.0.4",
4
4
  "description": "TypeScript model type generator from Omnify schemas.json",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",