@omnifyjp/ts 6.3.3 → 6.3.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.
@@ -62,6 +62,31 @@ function getIdType(options) {
62
62
  return id;
63
63
  return 'BigInt';
64
64
  }
65
+ /**
66
+ * TS type for a MorphTo `<name>_id` column. It was hardcoded to `number`, so a
67
+ * morph over Uuid or Ulid targets — whose ids are strings at runtime — was typed
68
+ * as a number for every consumer (#176, the TS half of the same wrong
69
+ * assumption that emitted `unsignedBigInteger` for an all-Ulid morph).
70
+ *
71
+ * `idType:` on the property wins, as it does on the migration side; otherwise
72
+ * the targets decide, and a set mixing numeric and textual keys is honestly
73
+ * `string | number`.
74
+ */
75
+ function morphIdTsType(property, allSchemas) {
76
+ const override = property.idType;
77
+ if (override)
78
+ return PK_TYPE_MAP[override] ?? 'number';
79
+ const types = new Set();
80
+ for (const target of property.targets ?? []) {
81
+ const schema = allSchemas[target];
82
+ if (!schema)
83
+ continue;
84
+ types.add(PK_TYPE_MAP[getIdType(schema.options)] ?? 'number');
85
+ }
86
+ if (types.size === 0)
87
+ return 'number';
88
+ return [...types].sort().join(' | ');
89
+ }
65
90
  /** Gets TypeScript type for a property. */
66
91
  /**
67
92
  * The interface name to write for a relation target, or `unknown` when the
@@ -193,7 +218,7 @@ export function propertyToTSProperties(propertyName, property, schema, allSchema
193
218
  },
194
219
  {
195
220
  name: `${propertyName}_id`,
196
- type: 'number',
221
+ type: morphIdTsType(property, allSchemas),
197
222
  optional: true,
198
223
  readonly: isReadonly,
199
224
  },
@@ -24,7 +24,7 @@ export function generateModels(reader, config) {
24
24
  if (extended.length > 0) {
25
25
  files.push(generateExtendAttributesTrait(name, schema, extended, reader, config));
26
26
  }
27
- files.push(generatePackageUserModel(name, pkgNs, config, extended.length > 0));
27
+ files.push(generatePackageUserModel(name, pkgNs, config, extended.length > 0, schema.kind === 'pivot'));
28
28
  }
29
29
  }
30
30
  return files;
@@ -550,6 +550,29 @@ function buildImports(baseNamespace, modelName, hasSoftDelete, isAuthenticatable
550
550
  }
551
551
  return lines.join('\n') + '\n';
552
552
  }
553
+ /**
554
+ * PHP type for a MorphTo `<name>_id` column: `string` for Uuid/Ulid/String
555
+ * targets, `int` for integer ones, `int|string` when the targets disagree
556
+ * (#176). `idType:` on the property wins, matching the migration side.
557
+ */
558
+ function morphIdPhpType(prop, reader) {
559
+ const phpFor = (idType) => idType === 'Int' || idType === 'BigInt' ? 'int' : 'string';
560
+ const override = prop['idType'];
561
+ if (typeof override === 'string' && override !== '')
562
+ return phpFor(override);
563
+ const targets = prop['targets'] ?? [];
564
+ const kinds = new Set();
565
+ for (const target of targets) {
566
+ const schema = reader.getSchema(target);
567
+ if (!schema)
568
+ continue;
569
+ const id = schema.options?.id;
570
+ kinds.add(phpFor(typeof id === 'string' ? id : 'BigInt'));
571
+ }
572
+ if (kinds.size === 0)
573
+ return 'int';
574
+ return [...kinds].sort().join('|');
575
+ }
553
576
  function buildDocProperties(properties, expandedProperties, propertyOrder, reader, config) {
554
577
  const lines = [];
555
578
  // Mirror buildCasts: EnumRef columns are cast to their generated enum class,
@@ -580,7 +603,10 @@ function buildDocProperties(properties, expandedProperties, propertyOrder, reade
580
603
  const nullable = prop['nullable'] ?? true;
581
604
  const nullSuffix = nullable ? '|null' : '';
582
605
  lines.push(` * @property string${nullSuffix} $${morphName}_type`);
583
- lines.push(` * @property string${nullSuffix} $${morphName}_id`);
606
+ // The id column follows the targets' key types (#176) — documenting it
607
+ // as a string when the morph is over integer keys tells static analysis
608
+ // the opposite of what Eloquent returns.
609
+ lines.push(` * @property ${morphIdPhpType(prop, reader)}${nullSuffix} $${morphName}_id`);
584
610
  }
585
611
  continue;
586
612
  }
@@ -1031,10 +1057,17 @@ function buildAccessors(expandedProperties) {
1031
1057
  return methods.join('\n');
1032
1058
  }
1033
1059
  /** Generate a user model for a package schema that extends the package's model class. */
1034
- function generatePackageUserModel(name, packageNamespace, config, hasExtendAttributes = false) {
1060
+ function generatePackageUserModel(name, packageNamespace, config, hasExtendAttributes = false, isPivot = false) {
1035
1061
  const modelName = toPascalCase(name);
1036
1062
  const modelNamespace = config.models.namespace;
1037
- const packageModelClass = `${packageNamespace}\\${modelName}`;
1063
+ // #178: a `kind: pivot` schema's editable model is emitted under the
1064
+ // dedicated `Pivot/` subfolder on the package side too (v5.8.5), so the
1065
+ // parent this stub extends is `<Pkg>\Models\Pivot\<Name>`. Naming the
1066
+ // flat `<Pkg>\Models\<Name>` pointed at a class no generator ever writes,
1067
+ // and the stub fataled with "Class not found" the moment it autoloaded.
1068
+ const packageModelClass = isPivot
1069
+ ? `${packageNamespace}\\Pivot\\${modelName}`
1070
+ : `${packageNamespace}\\${modelName}`;
1038
1071
  const traitName = extendAttributesTraitName(name);
1039
1072
  // This file is created once and never rewritten, so the `use` line can only
1040
1073
  // be put here for a model that does not exist yet. A project whose model
@@ -19,6 +19,8 @@ const ACTION_METHOD_MAP = {
19
19
  };
20
20
  /** All standard actions (used for wildcard expansion). */
21
21
  const ALL_ACTIONS = ['view', 'list', 'create', 'edit', 'delete'];
22
+ /** FQCN of the acting user every policy method takes. */
23
+ const ACTOR_FQN = 'App\\Models\\User';
22
24
  /** Actions that receive both $user and $record parameters. */
23
25
  const RECORD_ACTIONS = new Set(['view', 'edit', 'delete']);
24
26
  // ============================================================================
@@ -93,6 +95,19 @@ function generateBasePolicyClass(name, schema, reader, config) {
93
95
  : (config.models.userEditableGroupByFolder
94
96
  ? nestByGroup({ path: '', namespace: config.models.userEditableNamespace }, schema.group).namespace
95
97
  : config.models.userEditableNamespace);
98
+ // Issue #177: the acting user is always `App\\Models\\User`, but the policed
99
+ // model is imported from its own namespace — and for a schema named `User`
100
+ // outside `App\\Models` the two short names collide. PHP rejects the file at
101
+ // compile time (`Cannot use ... as User because the name is already in use`),
102
+ // which is a fatal, not a catchable Throwable. Alias the actor so both stay
103
+ // distinct, and keep the signatures pointing at the class each parameter
104
+ // actually needs. When the policed model IS `App\\Models\\User` the two are
105
+ // the same class: one import, no alias.
106
+ const modelFqn = `${modelNamespace}\\${modelName}`;
107
+ const actorClass = modelName === 'User' && modelFqn !== ACTOR_FQN ? 'ActingUser' : 'User';
108
+ const actorImport = actorClass === 'User'
109
+ ? `use ${ACTOR_FQN};`
110
+ : `use ${ACTOR_FQN} as ${actorClass};`;
96
111
  // Issue #98 v5.8.14: when no Cedar-style `policies:` array is defined,
97
112
  // emit the standard CRUD scaffold with `return true;` bodies.
98
113
  // The project secures the policy by overriding methods in the
@@ -112,23 +127,23 @@ function generateBasePolicyClass(name, schema, reader, config) {
112
127
  const body = hasExplicitPolicies
113
128
  ? generateMethodBody(action, expandedPolicies, schema, reader, hasRecord)
114
129
  : ' return true;';
115
- methods.push(buildMethod(methodName, modelName, hasRecord, body));
130
+ methods.push(buildMethod(methodName, modelName, hasRecord, body, actorClass));
116
131
  }
117
132
  // SoftDelete: restore + forceDelete
118
133
  if (hasSoftDelete) {
119
134
  const restoreBody = hasExplicitPolicies
120
135
  ? generateMethodBody('delete', expandedPolicies, schema, reader)
121
136
  : ' return true;';
122
- methods.push(buildMethod('restore', modelName, true, restoreBody));
137
+ methods.push(buildMethod('restore', modelName, true, restoreBody, actorClass));
123
138
  const forceDeleteBody = hasExplicitPolicies
124
139
  ? generateMethodBody('delete', expandedPolicies, schema, reader)
125
140
  : ' return true;';
126
- methods.push(buildMethod('forceDelete', modelName, true, forceDeleteBody));
141
+ methods.push(buildMethod('forceDelete', modelName, true, forceDeleteBody, actorClass));
127
142
  }
128
143
  const cidrHelper = needsCidrHelper ? buildCidrHelper() : '';
129
144
  const imports = [...new Set([
130
- `use ${modelNamespace}\\${modelName};`,
131
- 'use App\\Models\\User;',
145
+ `use ${modelFqn};`,
146
+ actorImport,
132
147
  'use Illuminate\\Auth\\Access\\HandlesAuthorization;',
133
148
  ])].join('\n');
134
149
  const content = `<?php
@@ -280,14 +295,16 @@ function generateMethodBody(action, policies, schema, reader, hasRecord = true)
280
295
  // ============================================================================
281
296
  // Method builder
282
297
  // ============================================================================
283
- function buildMethod(methodName, modelName, hasRecord, body) {
298
+ function buildMethod(methodName, modelName, hasRecord, body,
299
+ /** Short name the acting user is imported under — aliased on collision (#177). */
300
+ actorClass = 'User') {
284
301
  const recordParam = hasRecord ? `, ${modelName} $record` : '';
285
302
  const returnType = 'bool';
286
303
  return `
287
304
  /**
288
305
  * Determine whether the user can ${methodName}.
289
306
  */
290
- public function ${methodName}(User $user${recordParam}): ${returnType}
307
+ public function ${methodName}(${actorClass} $user${recordParam}): ${returnType}
291
308
  {
292
309
  ${body}
293
310
  }`;
@@ -72,12 +72,23 @@ function generateBaseResource(name, schema, reader, config) {
72
72
  const timestamps = resolveTimestamps(options);
73
73
  const hasSoftDelete = options.softDelete ?? false;
74
74
  const hasId = options.id ?? true;
75
+ // #179: the class this file declares owns its own short name. With
76
+ // `flatBase` the base class is `<Name>Resource`, the same short name a
77
+ // self-referencing association (`parent: Department`) wants to import from
78
+ // the editable namespace — and `use App\\Http\\Resources\\DepartmentResource;`
79
+ // next to `class DepartmentResource` is a compile-time fatal. Reserving the
80
+ // declared FQN makes the importer fall back to the inline FQCN, which is the
81
+ // collision handling #174 already specified.
82
+ const declaredClassName = config.structure === 'modular'
83
+ ? `${modelName}ResourceBase`
84
+ : resolveBaseClass(config.resources, `${modelName}Resource`, 'Base').className;
75
85
  const fields = [];
76
86
  // #174: reference other Resources by short name + `use` import, the way Pint
77
87
  // would rewrite them, so `generate` and the formatter stop disagreeing.
78
88
  const importer = createClassImporter(baseNamespace, [
79
89
  'Illuminate\\Http\\Request',
80
90
  'Illuminate\\Http\\Resources\\Json\\JsonResource',
91
+ `${baseNamespace}\\${declaredClassName}`,
81
92
  ]);
82
93
  if (hasId) {
83
94
  fields.push(" 'id' => $this->id,");
@@ -34,6 +34,19 @@
34
34
  * malformed input.
35
35
  */
36
36
  export declare function findAppRootAncestor(filePath: string): string | null;
37
+ /**
38
+ * The omnify root that governs `filePath`: the closest ancestor directory
39
+ * holding an `omnify.yaml`. Null when there is none within 12 levels.
40
+ *
41
+ * Issue #178: a project with omnify packages inside `app/` (`app/Idp/`,
42
+ * `app/Auth/`, each its own omnify root) runs `generate` once per root, and
43
+ * the `app/`-wide basename scan reaches straight across those boundaries. The
44
+ * root run then skipped `app/Models/<Pivot>.php` because the package had
45
+ * emitted `app/Idp/Models/Pivot/<Pivot>.php`, and the package run skipped its
46
+ * own stub because the root had emitted the flat one — two different classes
47
+ * suppressing each other, permanently, since neither is ever rewritten.
48
+ */
49
+ export declare function findOmnifyRoot(filePath: string): string | null;
37
50
  /**
38
51
  * Scan `rootDir` recursively for a file whose basename matches
39
52
  * `baseName`, excluding `excludePath` itself. Returns the first
@@ -48,6 +48,30 @@ export function findAppRootAncestor(filePath) {
48
48
  }
49
49
  return null;
50
50
  }
51
+ /**
52
+ * The omnify root that governs `filePath`: the closest ancestor directory
53
+ * holding an `omnify.yaml`. Null when there is none within 12 levels.
54
+ *
55
+ * Issue #178: a project with omnify packages inside `app/` (`app/Idp/`,
56
+ * `app/Auth/`, each its own omnify root) runs `generate` once per root, and
57
+ * the `app/`-wide basename scan reaches straight across those boundaries. The
58
+ * root run then skipped `app/Models/<Pivot>.php` because the package had
59
+ * emitted `app/Idp/Models/Pivot/<Pivot>.php`, and the package run skipped its
60
+ * own stub because the root had emitted the flat one — two different classes
61
+ * suppressing each other, permanently, since neither is ever rewritten.
62
+ */
63
+ export function findOmnifyRoot(filePath) {
64
+ let cur = dirname(filePath);
65
+ for (let i = 0; i < 12; i++) {
66
+ if (existsSync(join(cur, 'omnify.yaml')))
67
+ return cur;
68
+ const parent = dirname(cur);
69
+ if (parent === cur)
70
+ break;
71
+ cur = parent;
72
+ }
73
+ return null;
74
+ }
51
75
  /**
52
76
  * Scan `rootDir` recursively for a file whose basename matches
53
77
  * `baseName`, excluding `excludePath` itself. Returns the first
@@ -64,6 +88,11 @@ export function findAppRootAncestor(filePath) {
64
88
  * codegen-owned", which matches `Omnify/`, `OmnifyBase/`, etc.
65
89
  */
66
90
  export function findSiblingWithSameBasename(rootDir, baseName, excludePath) {
91
+ // #178: only a file generated from the SAME omnify root can be a relocated
92
+ // version of this stub. One from another root is a different class that
93
+ // merely shares a filename, and skipping on it leaves the class nothing
94
+ // declares.
95
+ const ownerRoot = findOmnifyRoot(excludePath);
67
96
  let scanned = 0;
68
97
  const stack = [rootDir];
69
98
  while (stack.length > 0) {
@@ -104,6 +133,8 @@ export function findSiblingWithSameBasename(rootDir, baseName, excludePath) {
104
133
  stack.push(fullPath);
105
134
  }
106
135
  else if (entry === baseName && fullPath !== excludePath) {
136
+ if (findOmnifyRoot(fullPath) !== ownerRoot)
137
+ continue;
107
138
  return fullPath;
108
139
  }
109
140
  }
package/dist/types.d.ts CHANGED
@@ -335,6 +335,11 @@ export interface PropertyDefinition {
335
335
  * integer primary key WITHOUT it gets `$incrementing = false`.
336
336
  */
337
337
  readonly autoIncrement?: boolean;
338
+ /**
339
+ * Target primary key type for a relation's FK column. Honoured on MorphTo as
340
+ * the explicit override for the `<name>_id` column's type (#176).
341
+ */
342
+ readonly idType?: string;
338
343
  readonly precision?: number;
339
344
  readonly scale?: number;
340
345
  readonly pattern?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnifyjp/ts",
3
- "version": "6.3.3",
3
+ "version": "6.3.5",
4
4
  "description": "TypeScript model type generator from Omnify schemas.json",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",