@omnifyjp/ts 8.0.2 → 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/cli.js CHANGED
@@ -31,7 +31,7 @@ import { generateTypeScript } from './generator.js';
31
31
  import { generatePhp, derivePhpConfig } from './php/index.js';
32
32
  import { evaluateShadowingConflicts } from './php/shadowing-conflicts.js';
33
33
  import { collectReferencedClasses, hasEditableSubclassForBase, pruneDisabledScaffoldLayerBases, pruneOrphanServiceFilesIfEnabled } from './php/orphan-cleanup.js';
34
- import { findSiblingWithSameBasename, findAppRootAncestor } from './php/sibling-skip.js';
34
+ import { findSiblingWithSameBasename, findAppRootAncestor, isOmnifyWrapper } from './php/sibling-skip.js';
35
35
  import { resolveInput, sniffInputKind } from './input-resolver.js';
36
36
  function resolveFromConfig(configPath) {
37
37
  const raw = readFileSync(configPath, 'utf-8');
@@ -295,7 +295,9 @@ program
295
295
  // files always regenerate.
296
296
  if (!file.overwrite) {
297
297
  const scanRoot = findAppRootAncestor(filePath) ?? dirname(filePath);
298
- const collision = findSiblingWithSameBasename(scanRoot, basename(filePath), filePath);
298
+ // #299: only an omnify wrapper replaces the stub, never an
299
+ // unrelated class that shares its name (Laravel's stock User.php).
300
+ const collision = findSiblingWithSameBasename(scanRoot, basename(filePath), filePath, (path) => isOmnifyWrapper(readFileSync(path, 'utf-8'), file.content));
299
301
  if (collision) {
300
302
  phpSkippedSibling++;
301
303
  if (skippedSiblingExamples.length < 3) {
@@ -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
  {
@@ -39,7 +39,9 @@ namespace ${traitsNamespace};
39
39
  * Trait that records every Eloquent lifecycle event into the central
40
40
  * audits history table. Apply via \`use HasOmnifyAuditLog;\` on any model
41
41
  * whose schema has \`options.audit.log: true\` (or when the global
42
- * \`audit.log\` flag is enabled).
42
+ * \`audit.log\` flag is enabled). The using class declares
43
+ * \`protected static array $auditExclude\` and \`$auditTags\` (the generated
44
+ * base model always does).
43
45
  *
44
46
  * DO NOT EDIT — auto-generated by Omnify. Any manual changes are lost on
45
47
  * the next \`omnify generate\` run.
@@ -48,6 +50,7 @@ namespace ${traitsNamespace};
48
50
  */
49
51
 
50
52
  use ${modelNamespace}\\Audit;
53
+ use Illuminate\\Database\\Eloquent\\SoftDeletes;
51
54
  use Illuminate\\Support\\Facades\\Auth;
52
55
  use Illuminate\\Support\\Str;
53
56
 
@@ -82,16 +85,18 @@ trait HasOmnifyAuditLog
82
85
  $model->writeOmnifyAudit('updated', $old, $dirty);
83
86
  });
84
87
  static::deleted(function ($model) {
85
- $event = method_exists($model, 'isForceDeleting') && $model->isForceDeleting()
86
- ? 'deleted'
87
- : (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';
88
93
  $model->writeOmnifyAudit($event, $model->getOriginal(), null);
89
94
  });
90
- if (method_exists(static::class, 'restored')) {
91
- static::restored(function ($model) {
92
- $model->writeOmnifyAudit('restored', null, $model->getAttributes());
93
- });
94
- }
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
+ });
95
100
  }
96
101
 
97
102
  /**
@@ -110,7 +115,7 @@ trait HasOmnifyAuditLog
110
115
 
111
116
  $exclude = array_unique(array_merge(
112
117
  (array) config('omnify.audit.logExclude', []),
113
- (array) (static::$auditExclude ?? [])
118
+ static::$auditExclude
114
119
  ));
115
120
  if (!empty($exclude)) {
116
121
  $flip = array_flip($exclude);
@@ -124,7 +129,7 @@ trait HasOmnifyAuditLog
124
129
 
125
130
  $request = function_exists('request') ? request() : null;
126
131
  $user = Auth::user();
127
- $tags = static::$auditTags ?? [];
132
+ $tags = static::$auditTags;
128
133
 
129
134
  $payload = [
130
135
  'auditable_type' => $this->getMorphClass(),
@@ -185,7 +190,11 @@ trait HasOmnifyAuditLog
185
190
  $this->writeOmnifyAudit($event, $oldValues, $newValues);
186
191
  }
187
192
 
188
- /** 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
+ */
189
198
  public function audits(): \\Illuminate\\Database\\Eloquent\\Relations\\MorphMany
190
199
  {
191
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
  }
@@ -1205,8 +1217,8 @@ function buildNestedSetParentIdMethod(parentColumn) {
1205
1217
  * - `$auditTags` is the static tag list applied to every audit row
1206
1218
  * from this model; useful for grouping (e.g. ["billing"]).
1207
1219
  *
1208
- * Empty arrays are omitted entirely so the trait falls through to its
1209
- * own static defaults — keeps the generated PHP minimal.
1220
+ * Both are declared on every audited model, as `[]` when empty: the trait
1221
+ * reads them directly (#298).
1210
1222
  */
1211
1223
  function buildAuditLogProperties(schemaName, reader) {
1212
1224
  if (!reader.isAuditLogEnabled(schemaName))
@@ -1214,9 +1226,13 @@ function buildAuditLogProperties(schemaName, reader) {
1214
1226
  const excludes = reader.getAuditExcludesFor(schemaName);
1215
1227
  const tags = reader.getAuditTagsFor(schemaName);
1216
1228
  const parts = [];
1217
- if (excludes.length > 0) {
1218
- const lines = excludes.map((c) => ` ${JSON.stringify(c).replace(/"/g, "'")},`).join('\n');
1219
- parts.push(`
1229
+ // #298: both statics are always declared on an audited model, empty or not,
1230
+ // so HasOmnifyAuditLog reads them directly instead of `?? []` on a property
1231
+ // that may not exist (PHPStan nullCoalesce.property when it does).
1232
+ const phpList = (items) => items.length === 0
1233
+ ? '[]'
1234
+ : `[\n${items.map((c) => ` ${JSON.stringify(c).replace(/"/g, "'")},`).join('\n')}\n ]`;
1235
+ parts.push(`
1220
1236
  /**
1221
1237
  * Columns scrubbed from old_values / new_values BEFORE the audit row
1222
1238
  * is built. Merged at runtime with the global \`config('omnify.audit.logExclude')\`
@@ -1224,13 +1240,8 @@ function buildAuditLogProperties(schemaName, reader) {
1224
1240
  *
1225
1241
  * @var array<int, string>
1226
1242
  */
1227
- protected static array $auditExclude = [
1228
- ${lines}
1229
- ];`);
1230
- }
1231
- if (tags.length > 0) {
1232
- const lines = tags.map((t) => ` ${JSON.stringify(t).replace(/"/g, "'")},`).join('\n');
1233
- parts.push(`
1243
+ protected static array $auditExclude = ${phpList(excludes)};`);
1244
+ parts.push(`
1234
1245
  /**
1235
1246
  * Static tags applied to every audit row from this model. Surfaces
1236
1247
  * in the audits table's \`tags\` column for grouping in the activity
@@ -1238,11 +1249,8 @@ ${lines}
1238
1249
  *
1239
1250
  * @var array<int, string>
1240
1251
  */
1241
- protected static array $auditTags = [
1242
- ${lines}
1243
- ];`);
1244
- }
1245
- return parts.length === 0 ? '' : parts.join('\n') + '\n';
1252
+ protected static array $auditTags = ${phpList(tags)};`);
1253
+ return parts.join('\n') + '\n';
1246
1254
  }
1247
1255
  function buildAuditSection(options, reader, modelNamespace) {
1248
1256
  const opts = options;
@@ -1351,6 +1359,8 @@ function buildFileAccessors(properties, propertyOrder, modelNamespace) {
1351
1359
  methods.push(`
1352
1360
  /**
1353
1361
  * Get ${propName} files (collection: ${collection}).
1362
+ *
1363
+ * @return MorphMany<File, $this>
1354
1364
  */
1355
1365
  public function ${methodName}(): MorphMany
1356
1366
  {
@@ -1361,6 +1371,8 @@ function buildFileAccessors(properties, propertyOrder, modelNamespace) {
1361
1371
  methods.push(`
1362
1372
  /**
1363
1373
  * Get ${propName} file (collection: ${collection}).
1374
+ *
1375
+ * @return MorphOne<File, $this>
1364
1376
  */
1365
1377
  public function ${methodName}(): MorphOne
1366
1378
  {
@@ -1371,17 +1383,20 @@ function buildFileAccessors(properties, propertyOrder, modelNamespace) {
1371
1383
  }
1372
1384
  return methods.join('\n');
1373
1385
  }
1374
- function fullNameAccessor(prefix, suffix, fields, separator) {
1386
+ function fullNameAccessor(prefix, suffix, fields, separator, isNullable) {
1375
1387
  const methodName = 'get' + toPascalCase(prefix) + suffix + 'Attribute';
1376
1388
  const fieldAccess = fields.map(f => `$this->${f}`).join(', ');
1377
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 !== ''";
1378
1393
  return `
1379
1394
  /**
1380
1395
  * Get the ${prefix} ${suffix} attribute.
1381
1396
  */
1382
1397
  public function ${methodName}(): ?string
1383
1398
  {
1384
- $parts = array_filter([${fieldAccess}], fn($v) => $v !== null && $v !== '');
1399
+ $parts = array_filter([${fieldAccess}], fn($v) => ${keep});
1385
1400
  return count($parts) > 0 ? implode(${separatorArg}, $parts) : null;
1386
1401
  }`;
1387
1402
  }
@@ -361,6 +361,12 @@ function operandToPhp(operand, schema, reader, side) {
361
361
  if (prop?.relation === 'ManyToOne') {
362
362
  return `$record->${toFkColumnName(propName)}`;
363
363
  }
364
+ // #295: an EnumRef column is cast to its backed enum (same predicate as
365
+ // the model's casts), so `$record->status === 'published'` compares an
366
+ // enum instance with a string and is never true. Compare the value.
367
+ if (prop?.type === 'EnumRef' && typeof prop.enum === 'string' && reader.getSchema(prop.enum)?.kind === 'enum') {
368
+ return `$record->${toSnakeCase(propName)}${prop.nullable ? '?->' : '->'}value`;
369
+ }
364
370
  return `$record->${toSnakeCase(propName)}`;
365
371
  }
366
372
  case 'variable': {
@@ -62,7 +62,8 @@ export function getReturnType(relation) {
62
62
  case 'MorphOne': return 'MorphOne';
63
63
  case 'MorphMany': return 'MorphMany';
64
64
  case 'MorphToMany': return 'MorphToMany';
65
- case 'MorphedByMany': return 'MorphedByMany';
65
+ // Laravel has no MorphedByMany class: morphedByMany() returns MorphToMany (#297).
66
+ case 'MorphedByMany': return 'MorphToMany';
66
67
  default: return 'BelongsTo';
67
68
  }
68
69
  }
@@ -255,7 +256,7 @@ function morphedByMany(propName, property, target, ns) {
255
256
  return ` /**
256
257
  * The ${propName} that belong to this model.
257
258
  */
258
- public function ${methodName}(): MorphedByMany
259
+ public function ${methodName}(): MorphToMany
259
260
  {
260
261
  return $this->morphedByMany(${fqcn}::class, '${morphName}')
261
262
  ->withTimestamps();
@@ -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;`,
@@ -95,7 +95,7 @@ class OmnifyServiceProvider extends ServiceProvider
95
95
  // connection via Schema::connection(), so a single migrate run suffices).
96
96
  $omnifyMigrations = database_path('migrations/omnify');
97
97
  $this->loadMigrationsFrom($omnifyMigrations);
98
- foreach (glob($omnifyMigrations.'/*', GLOB_ONLYDIR) as $connectionDir) {
98
+ foreach (glob($omnifyMigrations.'/*', GLOB_ONLYDIR) ?: [] as $connectionDir) {
99
99
  $this->loadMigrationsFrom($connectionDir);
100
100
  }
101
101
  ${packageMigrationsBlock}
@@ -62,4 +62,20 @@ export declare function findOmnifyRoot(filePath: string): string | null;
62
62
  * skip rule is "any directory whose name starts with `Omnify` is
63
63
  * codegen-owned", which matches `Omnify/`, `OmnifyBase/`, etc.
64
64
  */
65
- export declare function findSiblingWithSameBasename(rootDir: string, baseName: string, excludePath: string): string | null;
65
+ export declare function findSiblingWithSameBasename(rootDir: string, baseName: string, excludePath: string,
66
+ /** #299: a same-name file counts only when this accepts it; scanning continues otherwise. */
67
+ accept?: (path: string) => boolean): string | null;
68
+ /**
69
+ * Whether `siblingContent` is an omnify wrapper that may stand in for the stub
70
+ * whose content is `stubContent`. Issue #299.
71
+ *
72
+ * Skipping a stub is only safe on positive evidence: a wrongly skipped model
73
+ * stub leaves every relation, service and policy pointing at a class nothing
74
+ * declares, while a wrongly emitted one is a single unused file. Laravel's
75
+ * stock `app/Models/User.php` shares the User stub's basename and is neither.
76
+ *
77
+ * Evidence is either omnify's own marker (the stub header, `Omnify` /
78
+ * `OmnifyBase` namespaces of v4 wrappers, #99) or a reference to the class the
79
+ * stub extends, by its real short name (#98 domain subfolders).
80
+ */
81
+ export declare function isOmnifyWrapper(siblingContent: string, stubContent: string): boolean;
@@ -87,7 +87,9 @@ export function findOmnifyRoot(filePath) {
87
87
  * skip rule is "any directory whose name starts with `Omnify` is
88
88
  * codegen-owned", which matches `Omnify/`, `OmnifyBase/`, etc.
89
89
  */
90
- export function findSiblingWithSameBasename(rootDir, baseName, excludePath) {
90
+ export function findSiblingWithSameBasename(rootDir, baseName, excludePath,
91
+ /** #299: a same-name file counts only when this accepts it; scanning continues otherwise. */
92
+ accept = () => true) {
91
93
  // #178: only a file generated from the SAME omnify root can be a relocated
92
94
  // version of this stub. One from another root is a different class that
93
95
  // merely shares a filename, and skipping on it leaves the class nothing
@@ -135,9 +137,43 @@ export function findSiblingWithSameBasename(rootDir, baseName, excludePath) {
135
137
  else if (entry === baseName && fullPath !== excludePath) {
136
138
  if (findOmnifyRoot(fullPath) !== ownerRoot)
137
139
  continue;
140
+ if (!accept(fullPath))
141
+ continue;
138
142
  return fullPath;
139
143
  }
140
144
  }
141
145
  }
142
146
  return null;
143
147
  }
148
+ /**
149
+ * Whether `siblingContent` is an omnify wrapper that may stand in for the stub
150
+ * whose content is `stubContent`. Issue #299.
151
+ *
152
+ * Skipping a stub is only safe on positive evidence: a wrongly skipped model
153
+ * stub leaves every relation, service and policy pointing at a class nothing
154
+ * declares, while a wrongly emitted one is a single unused file. Laravel's
155
+ * stock `app/Models/User.php` shares the User stub's basename and is neither.
156
+ *
157
+ * Evidence is either omnify's own marker (the stub header, `Omnify` /
158
+ * `OmnifyBase` namespaces of v4 wrappers, #99) or a reference to the class the
159
+ * stub extends, by its real short name (#98 domain subfolders).
160
+ */
161
+ export function isOmnifyWrapper(siblingContent, stubContent) {
162
+ if (/omnify/i.test(siblingContent))
163
+ return true;
164
+ const stub = stubContent.match(/\bclass\s+(\w+)\s+extends\s+(\w+)/);
165
+ if (!stub)
166
+ return false;
167
+ const [, stubClass, extendsName] = stub;
168
+ let baseClass = extendsName;
169
+ for (const m of stubContent.matchAll(/^use\s+([\w\\]+?)(?:\s+as\s+(\w+))?\s*;/gm)) {
170
+ const short = m[1].slice(m[1].lastIndexOf('\\') + 1);
171
+ if ((m[2] ?? short) === extendsName)
172
+ baseClass = short;
173
+ }
174
+ // With flatBase the base shares the stub's short name, which every
175
+ // same-name file "references" — that says nothing.
176
+ if (baseClass === stubClass)
177
+ return false;
178
+ return new RegExp(`\\b${baseClass}\\b`).test(siblingContent);
179
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnifyjp/ts",
3
- "version": "8.0.2",
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",