@omnifyjp/ts 5.8.3 → 5.8.5

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.
@@ -26,11 +26,28 @@ function generateBaseController(name, schema, reader, config) {
26
26
  const lookup = api.lookup ?? true;
27
27
  const bulkDelete = api.bulkDelete ?? false;
28
28
  const restore = api.restore ?? (schema.options?.softDelete ?? false);
29
- const baseNs = resolveModularBaseNamespace(config, name, 'Controllers', config.controllers.baseNamespace);
30
- const modelNs = config.models.namespace;
31
- const serviceBaseNs = resolveModularBaseNamespace(config, name, 'Services', config.services.baseNamespace);
32
- const requestNs = config.requests.namespace;
33
- const resourceNs = config.resources.namespace;
29
+ // Issue #98 v5.8.4 / v5.8.5: cross-layer imports must mirror where
30
+ // the imported file actually lives. BASE layers (Models, Services
31
+ // base, Controllers base) are ALWAYS group-nested in legacy structure.
32
+ // EDITABLE layers (Requests, Resources) are nested only when the
33
+ // target layer's `userEditableGroupByFolder` is `true` — Laravel-
34
+ // canonical defaults to flat. Modular structure already isolates
35
+ // per-schema so all paths skip group nesting.
36
+ const groupNestBase = (ns) => config.structure === 'modular'
37
+ ? ns
38
+ : nestByGroup({ path: '', namespace: ns }, schema.group).namespace;
39
+ const groupNestEditable = (ns, layer) => config.structure === 'modular'
40
+ ? ns
41
+ : (layer.userEditableGroupByFolder ? nestByGroup({ path: '', namespace: ns }, schema.group).namespace : ns);
42
+ const baseNs = config.structure === 'modular'
43
+ ? resolveModularBaseNamespace(config, name, 'Controllers', config.controllers.baseNamespace)
44
+ : groupNestBase(config.controllers.baseNamespace);
45
+ const modelNs = groupNestBase(config.models.namespace);
46
+ const serviceBaseNs = config.structure === 'modular'
47
+ ? resolveModularBaseNamespace(config, name, 'Services', config.services.baseNamespace)
48
+ : groupNestBase(config.services.baseNamespace);
49
+ const requestNs = groupNestEditable(config.requests.namespace, config.requests);
50
+ const resourceNs = groupNestEditable(config.resources.namespace, config.resources);
34
51
  // Build imports
35
52
  const imports = [
36
53
  `use App\\Http\\Controllers\\Controller;`,
@@ -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, nestByGroup } 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 = [];
@@ -17,7 +17,14 @@ export function generateFactories(reader, config) {
17
17
  }
18
18
  function generateFactory(name, schema, reader, schemas, config) {
19
19
  const modelName = toPascalCase(name);
20
- const modelNamespace = config.models.namespace;
20
+ // Issue #98 v5.8.4: legacy structure groups Models by folder, so the
21
+ // factory's `use ${modelNamespace}\\${modelName}` import must include
22
+ // the schema's group too — otherwise the factory references a flat
23
+ // path that no longer holds the model file. Modular structure
24
+ // already isolates per-schema so it skips the nesting.
25
+ const modelNamespace = config.structure === 'modular'
26
+ ? config.models.namespace
27
+ : nestByGroup({ path: '', namespace: config.models.namespace }, schema.group).namespace;
21
28
  const factoryNamespace = config.factories.namespace;
22
29
  const properties = (schema.properties ?? {});
23
30
  const expandedProperties = reader.getExpandedProperties(name);
@@ -4,7 +4,7 @@
4
4
  import { toPascalCase, toSnakeCase, toCamelCase, pluralize } from './naming-helper.js';
5
5
  import { toCast, toPhpDocType, isHiddenByDefault } from './type-mapper.js';
6
6
  import { buildRelation } from './relation-builder.js';
7
- import { baseFile, userFile, resolveModularBasePath, resolveModularBaseNamespace, resolveSharedBaseNamespace, resolveGlobalTraitNamespace, resolveGlobalEnumNamespace, resolveBaseClass, resolveEditableClass, nestByGroup, } from './types.js';
7
+ import { baseFile, userFile, resolveModularBasePath, resolveModularBaseNamespace, resolveSharedBaseNamespace, resolveGlobalTraitNamespace, resolveGlobalEnumNamespace, resolveBaseClass, resolveEditableClass, nestByGroup, nestEditableByGroup, } from './types.js';
8
8
  import { enumClassName } from './enum-generator.js';
9
9
  /** Generate base model and user model for all project-owned object schemas,
10
10
  * plus user models for package schemas (extending the package model). */
@@ -42,6 +42,13 @@ function generateBaseModel(name, schema, reader, config) {
42
42
  // below) is the PARENT class string (BaseModel / Authenticatable).
43
43
  // Issue #98 v5.8+: apply group-by-folder nesting in legacy structure
44
44
  // so 100+ schemas produce a navigable tree mirroring schemas/<group>/.
45
+ // v5.8.5: pivot schemas (`kind: pivot`) override group → all pivot
46
+ // models live under a dedicated `Pivot/` subfolder regardless of the
47
+ // YAML group, mirroring the dedicated `Translation/` subfolder. Devs
48
+ // rarely interact with pivot models directly (Eloquent uses them
49
+ // implicitly), so consolidating them keeps the main model namespace
50
+ // clean.
51
+ const isPivot = schema.kind === 'pivot';
45
52
  let baseDescriptor;
46
53
  let baseNamespace;
47
54
  if (config.structure === 'modular') {
@@ -54,7 +61,9 @@ function generateBaseModel(name, schema, reader, config) {
54
61
  }
55
62
  else {
56
63
  const raw = resolveBaseClass(config.models, modelName, 'BaseModel');
57
- const nested = nestByGroup({ path: raw.path, namespace: raw.fqn.substring(0, raw.fqn.lastIndexOf('\\')) }, schema.group);
64
+ const nested = isPivot
65
+ ? { path: `${raw.path}/Pivot`, namespace: `${raw.fqn.substring(0, raw.fqn.lastIndexOf('\\'))}\\Pivot` }
66
+ : nestByGroup({ path: raw.path, namespace: raw.fqn.substring(0, raw.fqn.lastIndexOf('\\')) }, schema.group);
58
67
  baseDescriptor = { className: raw.className, path: nested.path, fqn: `${nested.namespace}\\${raw.className}` };
59
68
  baseNamespace = nested.namespace;
60
69
  }
@@ -102,7 +111,7 @@ function generateBaseModel(name, schema, reader, config) {
102
111
  const hidden = buildHidden(properties, expandedProperties, propertyOrder);
103
112
  const appends = buildAppends(expandedProperties);
104
113
  const casts = buildCasts(properties, expandedProperties, propertyOrder, reader, config);
105
- const relations = buildRelations(name, properties, propertyOrder, modelNamespace, reader);
114
+ const relations = buildRelations(name, properties, propertyOrder, modelNamespace, reader, config);
106
115
  const accessors = buildAccessors(expandedProperties);
107
116
  const fileAccessors = hasFiles ? buildFileAccessors(properties, propertyOrder, modelNamespace) : '';
108
117
  const auditSection = buildAuditSection(options, reader, modelNamespace);
@@ -221,7 +230,11 @@ function generateUserModel(name, schema, config, hasNestedSet = false) {
221
230
  // than the base (e.g. `app/Models/<Name>.php` while base is at
222
231
  // `app/Omnify/Models/<Name>.php`). resolveEditableClass +
223
232
  // resolveBaseClass keep the extends-clause + use-import in sync.
224
- // Issue #98 v5.8+: same group-by-folder nesting on the editable side.
233
+ // Issue #98 v5.8.5: pivot schemas always nest under `Pivot/` on
234
+ // both base AND editable sides regardless of `userEditableGroupByFolder`.
235
+ // Pivots are infrastructure — keeping them out of the main namespace
236
+ // mirrors how Laravel devs treat `Illuminate\Database\Eloquent\Relations\Pivot`.
237
+ const isPivot = schema.kind === 'pivot';
225
238
  let editable;
226
239
  let base;
227
240
  if (config.structure === 'modular') {
@@ -230,10 +243,14 @@ function generateUserModel(name, schema, config, hasNestedSet = false) {
230
243
  }
231
244
  else {
232
245
  const editRaw = resolveEditableClass(config.models, modelName);
233
- const editNested = nestByGroup({ path: editRaw.path, namespace: editRaw.namespace }, schema.group);
246
+ const editNested = isPivot
247
+ ? { path: `${editRaw.path}/Pivot`, namespace: `${editRaw.namespace}\\Pivot` }
248
+ : nestEditableByGroup(config.models, { path: editRaw.path, namespace: editRaw.namespace }, schema.group);
234
249
  editable = { className: editRaw.className, fileName: editRaw.fileName, namespace: editNested.namespace, path: editNested.path, fqn: `${editNested.namespace}\\${editRaw.className}` };
235
250
  const baseRaw = resolveBaseClass(config.models, modelName, 'BaseModel');
236
- const baseNs = nestByGroup({ path: baseRaw.path, namespace: baseRaw.fqn.substring(0, baseRaw.fqn.lastIndexOf('\\')) }, schema.group).namespace;
251
+ const baseNs = isPivot
252
+ ? `${baseRaw.fqn.substring(0, baseRaw.fqn.lastIndexOf('\\'))}\\Pivot`
253
+ : nestByGroup({ path: baseRaw.path, namespace: baseRaw.fqn.substring(0, baseRaw.fqn.lastIndexOf('\\')) }, schema.group).namespace;
237
254
  base = { className: baseRaw.className, fqn: `${baseNs}\\${baseRaw.className}` };
238
255
  }
239
256
  const factoryNamespace = config.factories.namespace;
@@ -561,7 +578,42 @@ function buildCasts(properties, expandedProperties, propertyOrder, reader, confi
561
578
  return '';
562
579
  return casts.map(c => ` ${c}`).join('\n') + '\n';
563
580
  }
564
- function buildRelations(schemaName, properties, propertyOrder, modelNamespace, reader) {
581
+ /**
582
+ * Issue #98 v5.8.6: resolve the FQCN namespace of a related Model
583
+ * for inline `belongsTo / hasMany / belongsToMany / morphTo` references.
584
+ * The namespace must match the TARGET's actual file location, not the
585
+ * source schema's. Three knobs:
586
+ * 1. Package schemas — return the package's namespace verbatim
587
+ * (already handled by reader.resolveModelNamespace).
588
+ * 2. Pivot schemas — always nest under `Pivot/` regardless of any
589
+ * group / userEditableGroupByFolder setting.
590
+ * 3. Group nesting — only when `userEditableGroupByFolder: true`
591
+ * (matches where the editable lives so the relation resolves to
592
+ * the user's customizable class). Default off → flat FQCN
593
+ * matches the Laravel-canonical flat editable layout.
594
+ *
595
+ * Pre-fix every relation emitted `\${modelNamespace}\${target}::class`
596
+ * (flat FQCN) regardless of where the target's file actually lived.
597
+ * For grouped projects this dispatched to a non-existent class at
598
+ * runtime — `ErrorException: include(... no such file)`.
599
+ */
600
+ function resolveRelatedModelNamespace(target, modelNamespace, config, reader) {
601
+ const baseNs = reader.resolveModelNamespace(target, modelNamespace);
602
+ // Package schemas — leave as-is.
603
+ if (baseNs !== modelNamespace)
604
+ return baseNs;
605
+ const targetSchema = reader.getSchema(target);
606
+ if (!targetSchema)
607
+ return baseNs;
608
+ if (targetSchema.kind === 'pivot') {
609
+ return `${baseNs}\\Pivot`;
610
+ }
611
+ if (config.models.userEditableGroupByFolder && targetSchema.group) {
612
+ return `${baseNs}\\${toPascalCase(targetSchema.group)}`;
613
+ }
614
+ return baseNs;
615
+ }
616
+ function buildRelations(schemaName, properties, propertyOrder, modelNamespace, reader, config) {
565
617
  const methods = [];
566
618
  const sourceTableName = reader.getTableName(schemaName);
567
619
  // Track method names already declared on this model so reverse-relation
@@ -574,9 +626,11 @@ function buildRelations(schemaName, properties, propertyOrder, modelNamespace, r
574
626
  const prop = properties[propName];
575
627
  if (!prop || prop['type'] !== 'Association')
576
628
  continue;
577
- // Resolve namespace per-target (package schemas use their own namespace)
629
+ // Resolve namespace per-target (package schemas use their own namespace).
630
+ // Issue #98 v5.8.6: also nest by target's group / Pivot/ so the
631
+ // FQCN matches the target file's actual location.
578
632
  const target = prop['target'] ?? '';
579
- const ns = target ? reader.resolveModelNamespace(target, modelNamespace) : modelNamespace;
633
+ const ns = target ? resolveRelatedModelNamespace(target, modelNamespace, config, reader) : modelNamespace;
580
634
  const targetTableName = target ? reader.getTableName(target) : '';
581
635
  // For ManyToMany, look up the explicit `kind: pivot` schema (the
582
636
  // sorted PascalCase concat of source + target — auto-created on
@@ -606,7 +660,7 @@ function buildRelations(schemaName, properties, propertyOrder, modelNamespace, r
606
660
  // Auto-generate reverse HasMany/HasOne for any child schema that declares a
607
661
  // ManyToOne/OneToOne pointing at this schema, unless the user has already
608
662
  // declared a relation method with the same name. Issue #39.
609
- const inverseRelations = buildInverseRelations(schemaName, modelNamespace, reader, declaredMethods, explicitInverses);
663
+ const inverseRelations = buildInverseRelations(schemaName, modelNamespace, reader, declaredMethods, explicitInverses, config);
610
664
  if (inverseRelations) {
611
665
  methods.push(inverseRelations);
612
666
  }
@@ -649,7 +703,7 @@ const LARAVEL_RESERVED_RELATION_NAMES = new Set([
649
703
  * a unique method name, or add `mappedBy: <customName>` to the child schema
650
704
  * so the derived method name changes.
651
705
  */
652
- function buildInverseRelations(parentName, modelNamespace, reader, declaredMethods, explicitInverses) {
706
+ function buildInverseRelations(parentName, modelNamespace, reader, declaredMethods, explicitInverses, config) {
653
707
  const methods = [];
654
708
  const allSchemas = reader.getSchemas();
655
709
  // Stable iteration order — sort child names so output is deterministic
@@ -697,7 +751,11 @@ function buildInverseRelations(parentName, modelNamespace, reader, declaredMetho
697
751
  // Here we're emitting the *parent* accessor, so the child's ManyToOne
698
752
  // (or owning OneToOne) is what we react to.
699
753
  const fkColumn = `${toSnakeCase(childPropName)}_id`;
700
- const childNs = reader.resolveModelNamespace(childName, modelNamespace);
754
+ // Issue #98 v5.8.6: nest the inverse-side FQCN by the CHILD's
755
+ // group / Pivot/ so the relation resolves to the child's actual
756
+ // file location (which lives in `<childGroup>/<Child>.php` or
757
+ // `Pivot/<Child>.php`, not flat).
758
+ const childNs = resolveRelatedModelNamespace(childName, modelNamespace, config, reader);
701
759
  const fqcn = `\\${childNs}\\${childName}`;
702
760
  const returnType = isOneToOne ? 'HasOne' : 'HasMany';
703
761
  const eloquentMethod = isOneToOne ? 'hasOne' : 'hasMany';
@@ -5,7 +5,7 @@
5
5
  * Only generates for schemas that have `policies` defined.
6
6
  */
7
7
  import { toPascalCase, toSnakeCase, toCamelCase } from './naming-helper.js';
8
- import { baseFile, userFile, resolveModularBasePath, resolveModularBaseNamespace, resolveBaseClass, resolveEditableClass, nestByGroup } from './types.js';
8
+ import { baseFile, userFile, resolveModularBasePath, resolveModularBaseNamespace, resolveBaseClass, resolveEditableClass, nestByGroup, nestEditableByGroup } from './types.js';
9
9
  // ============================================================================
10
10
  // Action mapping: Omnify → Laravel
11
11
  // ============================================================================
@@ -46,7 +46,13 @@ function generateForSchema(name, schema, reader, config) {
46
46
  function generateBasePolicyClass(name, schema, reader, config) {
47
47
  const modelName = toPascalCase(name);
48
48
  const baseNamespace = resolveModularBaseNamespace(config, name, 'Policies', config.policies.baseNamespace);
49
- const modelNamespace = config.models.namespace;
49
+ // Issue #98 v5.8.4: legacy structure groups Models by folder. The
50
+ // policy's `use ${modelNamespace}\\${modelName}` import must include
51
+ // the schema's group too, otherwise the policy fails to autoload at
52
+ // runtime when Laravel resolves it from the gate registration.
53
+ const modelNamespace = config.structure === 'modular'
54
+ ? config.models.namespace
55
+ : nestByGroup({ path: '', namespace: config.models.namespace }, schema.group).namespace;
50
56
  const policies = schema.policies;
51
57
  const hasSoftDelete = schema.options?.softDelete ?? false;
52
58
  const needsCidrHelper = policiesUseCidr(policies);
@@ -122,7 +128,7 @@ function generateUserPolicyClass(name, schema, config) {
122
128
  const baseNs = nestByGroup({ path: baseRaw.path, namespace: baseRaw.fqn.substring(0, baseRaw.fqn.lastIndexOf('\\')) }, schema.group).namespace;
123
129
  baseDescriptor = { className: baseRaw.className, fqn: `${baseNs}\\${baseRaw.className}` };
124
130
  const editRaw = resolveEditableClass(config.policies, modelName, 'Policy');
125
- const editNested = nestByGroup({ path: editRaw.path, namespace: editRaw.namespace }, schema.group);
131
+ const editNested = nestEditableByGroup(config.policies, { path: editRaw.path, namespace: editRaw.namespace }, schema.group);
126
132
  editable = { className: editRaw.className, fileName: editRaw.fileName, namespace: editNested.namespace, path: editNested.path };
127
133
  }
128
134
  // Issue #98: alias on class-name collision (flatBase + userEditablePath).
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { toPascalCase, toSnakeCase, toCamelCase } from './naming-helper.js';
5
5
  import { toStoreRules, toUpdateRules, formatRules, hasRuleObject } from './type-mapper.js';
6
- import { baseFile, userFile, resolveModularBasePath, resolveModularBaseNamespace, resolveBaseClass, resolveEditableClass, nestByGroup } from './types.js';
6
+ import { baseFile, userFile, resolveModularBasePath, resolveModularBaseNamespace, resolveBaseClass, resolveEditableClass, nestByGroup, nestEditableByGroup } from './types.js';
7
7
  /**
8
8
  * Resolve the Laravel validation rule type ('integer', 'uuid', or 'string') for
9
9
  * a foreign key column based on the target schema's primary key type.
@@ -294,7 +294,7 @@ function generateUserRequest(name, schema, config, action) {
294
294
  const baseNs = nestByGroup({ path: baseRaw.path, namespace: baseRaw.fqn.substring(0, baseRaw.fqn.lastIndexOf('\\')) }, schema.group).namespace;
295
295
  baseDescriptor = { className: baseRaw.className, fqn: `${baseNs}\\${baseRaw.className}` };
296
296
  const editRaw = resolveEditableClass(config.requests, modelName, `${action}Request`);
297
- const editNested = nestByGroup({ path: editRaw.path, namespace: editRaw.namespace }, schema.group);
297
+ const editNested = nestEditableByGroup(config.requests, { path: editRaw.path, namespace: editRaw.namespace }, schema.group);
298
298
  editable = { className: editRaw.className, fileName: editRaw.fileName, namespace: editNested.namespace, path: editNested.path };
299
299
  }
300
300
  // Issue #98: alias on class-name collision (flatBase + userEditablePath).
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { toPascalCase, toSnakeCase, toCamelCase } from './naming-helper.js';
5
5
  import { isHiddenByDefault, toResourceExpression } from './type-mapper.js';
6
- import { baseFile, userFile, resolveModularBasePath, resolveModularBaseNamespace, resolveBaseClass, resolveEditableClass, nestByGroup } from './types.js';
6
+ import { baseFile, userFile, resolveModularBasePath, resolveModularBaseNamespace, resolveBaseClass, resolveEditableClass, nestByGroup, nestEditableByGroup } from './types.js';
7
7
  /** Generate Resource classes for all project-owned visible object schemas. */
8
8
  export function generateResources(reader, config) {
9
9
  const files = [];
@@ -158,7 +158,7 @@ function generateUserResource(name, schema, config) {
158
158
  const baseNs = nestByGroup({ path: baseRaw.path, namespace: baseRaw.fqn.substring(0, baseRaw.fqn.lastIndexOf('\\')) }, schema.group).namespace;
159
159
  baseDescriptor = { className: baseRaw.className, fqn: `${baseNs}\\${baseRaw.className}` };
160
160
  const editRaw = resolveEditableClass(config.resources, modelName, 'Resource');
161
- const editNested = nestByGroup({ path: editRaw.path, namespace: editRaw.namespace }, schema.group);
161
+ const editNested = nestEditableByGroup(config.resources, { path: editRaw.path, namespace: editRaw.namespace }, schema.group);
162
162
  editable = { className: editRaw.className, fileName: editRaw.fileName, namespace: editNested.namespace, path: editNested.path };
163
163
  }
164
164
  // Issue #98: alias on class-name collision (flatBase + userEditablePath).
@@ -5,7 +5,7 @@
5
5
  * Only includes properties that have searchable/filterable/sortable flags.
6
6
  */
7
7
  import { toPascalCase, toSnakeCase } from './naming-helper.js';
8
- import { baseFile } from './types.js';
8
+ import { baseFile, nestByGroup } from './types.js';
9
9
  /** Generate the omnify-schemas config file. */
10
10
  export function generateSchemaConfig(reader, config) {
11
11
  const apiSchemas = reader.getSchemasWithApi();
@@ -64,9 +64,16 @@ function generateSchemaEntry(name, schema, reader, config) {
64
64
  const propsBlock = propLines.length > 0
65
65
  ? `\n 'properties' => [\n${propLines.join('\n')}\n ],`
66
66
  : `\n 'properties' => [],`;
67
+ // Issue #98 v5.8.4: Query Engine resolves `model` via reflection, so
68
+ // the FQN must reflect the actual file location (which now includes
69
+ // the schema's group folder for legacy structure). Modular structure
70
+ // already isolates per-schema so it skips the nesting.
71
+ const modelNs = config.structure === 'modular'
72
+ ? config.models.namespace
73
+ : nestByGroup({ path: '', namespace: config.models.namespace }, schema.group).namespace;
67
74
  return ` '${modelName}' => [
68
75
  'table' => '${tableName}',
69
- 'model' => \\${config.models.namespace}\\${modelName}::class,
76
+ 'model' => \\${modelNs}\\${modelName}::class,
70
77
  'softDelete' => ${softDelete ? 'true' : 'false'},
71
78
  'perPage' => ${perPage},${propsBlock}
72
79
  ],
@@ -13,7 +13,7 @@
13
13
  * eagerCount}` keys are deprecated and emit warnings at generate time.
14
14
  */
15
15
  import { toPascalCase, toSnakeCase } from './naming-helper.js';
16
- import { baseFile, userFile, resolveModularBasePath, resolveModularBaseNamespace, resolveBaseClass, resolveEditableClass, nestByGroup } from './types.js';
16
+ import { baseFile, userFile, resolveModularBasePath, resolveModularBaseNamespace, resolveBaseClass, resolveEditableClass, nestByGroup, nestEditableByGroup } from './types.js';
17
17
  // ============================================================================
18
18
  // Public entry point
19
19
  // ============================================================================
@@ -510,7 +510,15 @@ function generateBaseService(name, schema, reader, config) {
510
510
  const baseNs = config.structure === 'modular'
511
511
  ? resolveModularBaseNamespace(config, name, 'Services', config.services.baseNamespace)
512
512
  : nestByGroup({ path: '', namespace: config.services.baseNamespace }, schema.group).namespace;
513
- const modelNs = config.models.namespace;
513
+ // Issue #98 v5.8.4: cross-layer Model imports must include the
514
+ // schema's group folder so they resolve to where Models actually
515
+ // live now. Pre-fix the service base emitted
516
+ // `use App\Omnify\Models\Banner;` while the model file was at
517
+ // `app/Omnify/Models/Cms/Banner.php` — autoload failed at runtime
518
+ // for every consumer (TypeError + missing-file include).
519
+ const modelNs = config.structure === 'modular'
520
+ ? config.models.namespace
521
+ : nestByGroup({ path: '', namespace: config.models.namespace }, schema.group).namespace;
514
522
  // Build imports
515
523
  const imports = [
516
524
  `use ${modelNs}\\${modelName};`,
@@ -1784,7 +1792,7 @@ function generateUserService(name, schema, config) {
1784
1792
  const baseNs = nestByGroup({ path: baseRaw.path, namespace: baseRaw.fqn.substring(0, baseRaw.fqn.lastIndexOf('\\')) }, schema.group).namespace;
1785
1793
  baseDescriptor = { className: baseRaw.className, fqn: `${baseNs}\\${baseRaw.className}` };
1786
1794
  const editRaw = resolveEditableClass(config.services, modelName, 'Service');
1787
- const editNested = nestByGroup({ path: editRaw.path, namespace: editRaw.namespace }, schema.group);
1795
+ const editNested = nestEditableByGroup(config.services, { path: editRaw.path, namespace: editRaw.namespace }, schema.group);
1788
1796
  editable = { className: editRaw.className, fileName: editRaw.fileName, namespace: editNested.namespace, path: editNested.path };
1789
1797
  }
1790
1798
  // Group nesting already applied via nestByGroup above; no additional
@@ -2,15 +2,23 @@
2
2
  * Port of AppServiceProviderGenerator.php — generates OmnifyServiceProvider.
3
3
  */
4
4
  import { toPascalCase } from './naming-helper.js';
5
- import { baseFile } from './types.js';
5
+ import { baseFile, nestByGroup } from './types.js';
6
6
  /** Generate the OmnifyServiceProvider for the application. */
7
7
  export function generateServiceProvider(reader, config) {
8
8
  const modelNamespace = config.models.namespace;
9
- // Build morph map entries (project-owned schemas only)
9
+ // Build morph map entries (project-owned schemas only). Issue #98 v5.8.4:
10
+ // each schema's actual model FQN includes its group folder, so the
11
+ // morph map must echo that — pre-fix every entry pointed to the flat
12
+ // `App\Omnify\Models\<Name>::class` and Laravel's morphMap resolved to
13
+ // a non-existent class for every grouped schema, breaking polymorphic
14
+ // relationships project-wide.
10
15
  const morphEntries = [];
11
- for (const name of Object.keys(reader.getProjectVisibleObjectSchemas())) {
16
+ for (const [name, schema] of Object.entries(reader.getProjectVisibleObjectSchemas())) {
12
17
  const modelName = toPascalCase(name);
13
- morphEntries.push(` '${modelName}' => \\${modelNamespace}\\${modelName}::class,`);
18
+ const ns = config.structure === 'modular'
19
+ ? modelNamespace
20
+ : nestByGroup({ path: '', namespace: modelNamespace }, schema.group).namespace;
21
+ morphEntries.push(` '${modelName}' => \\${ns}\\${modelName}::class,`);
14
22
  }
15
23
  // Add File to morph map when any schema uses File type
16
24
  if (reader.hasFileProperties()) {
@@ -113,6 +113,26 @@ export interface LaravelPathOverride {
113
113
  * isolates generated code (e.g. `app/Omnify/Models/`).
114
114
  */
115
115
  flatBase?: boolean;
116
+ /**
117
+ * Issue #98 v5.8.5: per-layer toggle for whether the USER-EDITABLE
118
+ * stub is grouped by the schema's parent folder. The base layer is
119
+ * always grouped (canonical layout — domain-organized regenerated
120
+ * code), but Laravel ecosystem code (`app/Models/`, `app/Services/`,
121
+ * `app/Http/Requests/`, `app/Http/Resources/`) is canonically FLAT —
122
+ * `App\Models\User`, not `App\Models\Auth\User`. `config/auth.php`,
123
+ * Sanctum traits, factory discovery and dozens of ecosystem packages
124
+ * assume the flat shape.
125
+ *
126
+ * Default: `false` (Laravel-canonical flat editable). The grouped
127
+ * base + flat editable layout is what new `composer create-project
128
+ * laravel/laravel` apps produce, and the use-statement in the editable
129
+ * stub still aliases the GROUPED base FQN — group is encoded in the
130
+ * `use ... as ...Base` import, not the editable's namespace.
131
+ *
132
+ * Projects that adopted v5.8.x and want to keep their grouped editable
133
+ * layout flip this to `true` per layer.
134
+ */
135
+ userEditableGroupByFolder?: boolean;
116
136
  }
117
137
  /** Nested set package configuration. */
118
138
  export interface NestedSetOverride {
@@ -225,6 +245,15 @@ export interface BaseEditableLayer {
225
245
  * extends `\{namespace}\{Schema}` (no `BaseModel` indirection). Issue #96.
226
246
  */
227
247
  flatBase: boolean;
248
+ /**
249
+ * Issue #98 v5.8.5: when `true`, the user-editable layer is also
250
+ * grouped by the schema's parent folder (mirrors the base layer).
251
+ * Default `false` — Laravel-canonical flat layout (`app/Models/User.php`,
252
+ * not `app/Models/Auth/User.php`). Group is encoded only in the base
253
+ * layer + the `use ... as Base` import inside the editable stub.
254
+ * The base layer is unaffected — it is always grouped.
255
+ */
256
+ userEditableGroupByFolder: boolean;
228
257
  }
229
258
  /** PHP codegen configuration (resolved with defaults). */
230
259
  export interface PhpConfig {
@@ -347,6 +376,21 @@ export declare function resolveEditableClass(layer: BaseEditableLayer, schemaNam
347
376
  path: string;
348
377
  fqn: string;
349
378
  };
379
+ /**
380
+ * Issue #98 v5.8.5: gate group nesting for the user-editable layer on
381
+ * `layer.userEditableGroupByFolder`. Default flat (Laravel-canonical) —
382
+ * `app/Models/User.php`, `App\Models\User`. Opt-in to mirror-base via
383
+ * `userEditableGroupByFolder: true` per layer. Auxiliary subfolders
384
+ * (Translation/, Pivot/, Enum/) are imposed elsewhere and are NOT
385
+ * affected by this flag — they always nest.
386
+ */
387
+ export declare function nestEditableByGroup(layer: BaseEditableLayer, loc: {
388
+ path: string;
389
+ namespace: string;
390
+ }, group: string | undefined): {
391
+ path: string;
392
+ namespace: string;
393
+ };
350
394
  /**
351
395
  * Derive full PHP config from optional overrides.
352
396
  * All paths and namespaces fall back to sensible defaults.
package/dist/php/types.js CHANGED
@@ -197,7 +197,11 @@ nsFromPath) {
197
197
  : path;
198
198
  const userEditableNamespace = override?.userEditableNamespace
199
199
  ?? (override?.userEditablePath ? nsFromPath(userEditablePath) : namespace);
200
- return { namespace, baseNamespace, path, basePath, userEditablePath, userEditableNamespace, flatBase };
200
+ // Issue #98 v5.8.5: the user-editable layer is FLAT (Laravel-canonical)
201
+ // by default. Project explicitly opts back into the v5.8.x grouped
202
+ // editable shape via `userEditableGroupByFolder: true` per layer.
203
+ const userEditableGroupByFolder = override?.userEditableGroupByFolder ?? false;
204
+ return { namespace, baseNamespace, path, basePath, userEditablePath, userEditableNamespace, flatBase, userEditableGroupByFolder };
201
205
  }
202
206
  /**
203
207
  * Class-name + file-name resolver for the BASE class of a layer. Returns
@@ -234,6 +238,19 @@ export function resolveEditableClass(layer, schemaName, classSuffix = '') {
234
238
  fqn: `${layer.userEditableNamespace}\\${className}`,
235
239
  };
236
240
  }
241
+ /**
242
+ * Issue #98 v5.8.5: gate group nesting for the user-editable layer on
243
+ * `layer.userEditableGroupByFolder`. Default flat (Laravel-canonical) —
244
+ * `app/Models/User.php`, `App\Models\User`. Opt-in to mirror-base via
245
+ * `userEditableGroupByFolder: true` per layer. Auxiliary subfolders
246
+ * (Translation/, Pivot/, Enum/) are imposed elsewhere and are NOT
247
+ * affected by this flag — they always nest.
248
+ */
249
+ export function nestEditableByGroup(layer, loc, group) {
250
+ if (!layer.userEditableGroupByFolder)
251
+ return loc;
252
+ return nestByGroup(loc, group);
253
+ }
237
254
  // Default paths.
238
255
  //
239
256
  // Hybrid projects (manual team code + Omnify codegen) need Omnify-generated
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnifyjp/ts",
3
- "version": "5.8.3",
3
+ "version": "5.8.5",
4
4
  "description": "TypeScript model type generator from Omnify schemas.json",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",