@omnifyjp/ts 6.0.7 → 6.3.0

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
@@ -29,6 +29,7 @@ import { Command } from 'commander';
29
29
  import { parse as parseYaml } from 'yaml';
30
30
  import { generateTypeScript } from './generator.js';
31
31
  import { generatePhp, derivePhpConfig } from './php/index.js';
32
+ import { stripPhpComments } from './php/types.js';
32
33
  import { pruneOrphanServiceFilesIfEnabled } from './php/orphan-cleanup.js';
33
34
  import { findSiblingWithSameBasename, findAppRootAncestor } from './php/sibling-skip.js';
34
35
  import { runPintFormat } from './php/pint-format.js';
@@ -100,6 +101,11 @@ function resolveFromConfig(configPath) {
100
101
  route: laravelConfig?.route,
101
102
  config: laravelConfig?.config,
102
103
  nestedset: laravelConfig?.nestedset,
104
+ modules: laravelConfig?.modules,
105
+ shared: laravelConfig?.shared,
106
+ enums: laravelConfig?.enums,
107
+ traits: laravelConfig?.traits,
108
+ openapi: laravelConfig?.openapi,
103
109
  } : undefined;
104
110
  return {
105
111
  inputSpec,
@@ -226,6 +232,7 @@ program
226
232
  let phpOverwritten = 0;
227
233
  let phpSkipped = 0;
228
234
  let phpSkippedSibling = 0;
235
+ let phpShadowed = 0;
229
236
  const skippedSiblingExamples = [];
230
237
  const writtenPaths = [];
231
238
  for (const file of phpFiles) {
@@ -233,6 +240,24 @@ program
233
240
  mkdirSync(dirname(filePath), { recursive: true });
234
241
  if (!file.overwrite && existsSync(filePath)) {
235
242
  phpSkipped++;
243
+ const existing = file.expects || (file.conflicts && file.conflicts.length > 0)
244
+ ? readFileSync(filePath, 'utf-8')
245
+ : '';
246
+ // The file predates something the template now needs. Say so once,
247
+ // naming the file and the line, rather than rewriting user code.
248
+ if (file.expects && !existing.includes(file.expects.needle)) {
249
+ console.warn(`[omnify-ts] ${relative(configDir, filePath)}: ${file.expects.hint}`);
250
+ }
251
+ // The mirror: the file contains an edit that silently disables what
252
+ // the base generates. #171 — a redeclared $fillable shadows the
253
+ // parent's, so every property added afterwards is stripped on mass
254
+ // assignment with no error anywhere.
255
+ for (const conflict of file.conflicts ?? []) {
256
+ if (new RegExp(conflict.pattern).test(stripPhpComments(existing))) {
257
+ phpShadowed++;
258
+ console.warn(`[omnify-ts] ${relative(configDir, filePath)}: ${conflict.hint}`);
259
+ }
260
+ }
236
261
  continue;
237
262
  }
238
263
  // Issue #98 v5.8.18 / #99 v5.8.19: skip user-editable stub
@@ -280,6 +305,10 @@ program
280
305
  console.log(` ${phpCreated} files created (user-editable)`);
281
306
  if (phpSkipped > 0)
282
307
  console.log(` ${phpSkipped} files skipped (already exist)`);
308
+ if (phpShadowed > 0) {
309
+ console.warn(` ${phpShadowed} editable file(s) shadow a generated property — ` +
310
+ `schema changes will NOT reach them until that is fixed (see warnings above).`);
311
+ }
283
312
  if (phpSkippedSibling > 0) {
284
313
  console.log(` ${phpSkippedSibling} user-editable stub(s) skipped (project sibling with same name found in subfolder)`);
285
314
  for (const ex of skippedSiblingExamples) {
@@ -5,7 +5,7 @@ import { toPascalCase, toSnakeCase, toCamelCase, toFkColumnName, pluralize } fro
5
5
  import { resolveTimestamps } from './timestamps.js';
6
6
  import { toCast, toPhpDocType, isHiddenByDefault } from './type-mapper.js';
7
7
  import { buildRelation } from './relation-builder.js';
8
- import { baseFile, userFile, resolveModularBasePath, resolveModularBaseNamespace, resolveSharedBaseNamespace, resolveGlobalTraitNamespace, resolveGlobalEnumNamespace, resolveBaseClass, resolveEditableClass, nestByGroup, nestEditableByGroup, } from './types.js';
8
+ import { baseFile, userFile, shadowingPropertyConflicts, resolveModularBasePath, resolveModularBaseNamespace, resolveSharedBaseNamespace, resolveGlobalTraitNamespace, resolveGlobalEnumNamespace, resolveBaseClass, resolveEditableClass, nestByGroup, nestEditableByGroup, } from './types.js';
9
9
  import { enumClassName } from './enum-generator.js';
10
10
  /** Generate base model and user model for all project-owned object schemas,
11
11
  * plus user models for package schemas (extending the package model). */
@@ -15,15 +15,105 @@ export function generateModels(reader, config) {
15
15
  for (const [name, schema] of Object.entries(reader.getProjectObjectSchemas())) {
16
16
  files.push(...generateForSchema(name, schema, reader, config));
17
17
  }
18
- // Package schemas: user model only (extends package model)
18
+ // Package schemas: user model only (extends package model), plus a trait
19
+ // carrying anything a project-level `kind: extend` added to them (#164).
19
20
  for (const [name, schema] of Object.entries(reader.getPackageObjectSchemas())) {
20
21
  const pkgNs = reader.resolveModelNamespace(name, config.models.namespace);
21
22
  if (pkgNs !== config.models.namespace) {
22
- files.push(generatePackageUserModel(name, pkgNs, config));
23
+ const extended = extendedPropertyNames(schema);
24
+ if (extended.length > 0) {
25
+ files.push(generateExtendAttributesTrait(name, schema, extended, reader, config));
26
+ }
27
+ files.push(generatePackageUserModel(name, pkgNs, config, extended.length > 0));
23
28
  }
24
29
  }
25
30
  return files;
26
31
  }
32
+ /**
33
+ * The properties a project-level `kind: extend` added to a schema, in merge
34
+ * order, keeping only the ones the schema still declares.
35
+ */
36
+ function extendedPropertyNames(schema) {
37
+ const declared = schema.properties ?? {};
38
+ return (schema.extendedProperties ?? []).filter(name => declared[name] !== undefined);
39
+ }
40
+ /**
41
+ * A trait carrying the columns a project-level `kind: extend` added to a
42
+ * PACKAGE-owned schema (#164).
43
+ *
44
+ * The package's own generate run writes that model from the package's schemas
45
+ * alone; it cannot see a consumer's extend, so `$fillable` and `casts()` there
46
+ * are missing the added columns. The migration has them — the column exists —
47
+ * so `$model->new_column = x` is silently dropped by mass-assignment guarding
48
+ * and a Json column reads back as a raw string. The extend is only half
49
+ * applied, and nothing says so.
50
+ *
51
+ * It is written here, in the CONSUMER's own namespace, rather than back into
52
+ * the package's model. Writing into the package directory would work in a
53
+ * monorepo and silently do nothing once that package is installed under
54
+ * `vendor/`, where the next `composer install` removes it.
55
+ *
56
+ * `initialize<TraitName>()` is Laravel's own hook: `Model::__construct` calls
57
+ * `initializeTraits()`, which invokes it on every used trait. `mergeFillable` /
58
+ * `mergeCasts` add to what the parent declares instead of replacing it, which a
59
+ * redeclared `protected $fillable` in the child would do.
60
+ */
61
+ function generateExtendAttributesTrait(name, schema, extended, reader, config) {
62
+ const modelName = toPascalCase(name);
63
+ const traitName = extendAttributesTraitName(name);
64
+ const properties = (schema.properties ?? {});
65
+ const expandedProperties = reader.getExpandedProperties(name);
66
+ // The same two builders the BaseModel uses, over the extended properties
67
+ // only. Sharing them is the point: a column the BaseModel would have made
68
+ // fillable, or cast, is made fillable and cast the same way here.
69
+ const fillable = buildFillable(properties, expandedProperties, extended)
70
+ .split('\n')
71
+ .filter(line => line.trim() !== '')
72
+ .map(line => ` ${line.trim()}`)
73
+ .join('\n');
74
+ const casts = buildCasts(properties, expandedProperties, extended, reader, config)
75
+ .split('\n')
76
+ .filter(line => line.trim() !== '')
77
+ .map(line => ` ${line.trim()}`)
78
+ .join('\n');
79
+ const body = [];
80
+ if (fillable !== '') {
81
+ body.push(` $this->mergeFillable([\n${fillable}\n ]);`);
82
+ }
83
+ if (casts !== '') {
84
+ body.push(` $this->mergeCasts([\n${casts}\n ]);`);
85
+ }
86
+ const content = `<?php
87
+
88
+ namespace ${config.models.namespace}\\Concerns;
89
+
90
+ /**
91
+ * DO NOT EDIT - This file is auto-generated by Omnify.
92
+ * Any changes will be overwritten on next generation.
93
+ *
94
+ * @generated by omnify
95
+ */
96
+
97
+ /**
98
+ * Columns this project's \`kind: extend\` added to the ${modelName} schema.
99
+ *
100
+ * ${modelName} is owned by a package, so the package's own model cannot know
101
+ * about them. Used by ${config.models.namespace}\\${modelName}.
102
+ */
103
+ trait ${traitName}
104
+ {
105
+ /** Called by Laravel's Model::initializeTraits() from the constructor. */
106
+ public function initialize${traitName}(): void
107
+ {
108
+ ${body.join('\n\n')}
109
+ }
110
+ }
111
+ `;
112
+ return baseFile(`${config.models.path}/Concerns/${traitName}.php`, content);
113
+ }
114
+ function extendAttributesTraitName(name) {
115
+ return `${toPascalCase(name)}ExtendAttributes`;
116
+ }
27
117
  function generateForSchema(name, schema, reader, config) {
28
118
  const properties = (schema.properties ?? {});
29
119
  const options = schema.options ?? {};
@@ -104,9 +194,22 @@ function generateBaseModel(name, schema, reader, config) {
104
194
  const nestedSetParentColumn = null;
105
195
  const translatableFields = reader.getTranslatableFields(name);
106
196
  const hasTranslatable = translatableFields.length > 0;
107
- const primaryKey = options['primaryKey'] ?? 'id';
197
+ // A schema may name its primary key three mutually-exclusive ways: the
198
+ // `id:` shortcut (always column "id"), `options.primaryKey` (composite),
199
+ // or a property carrying `primary: true` (custom-named single-column PK
200
+ // — omnify-go#167). The schema validator rejects any schema declaring
201
+ // more than one of these, so checking them in this order is safe.
202
+ const primaryKeyOption = options['primaryKey'];
203
+ const customPrimaryEntry = primaryKeyOption
204
+ ? undefined
205
+ : propertyOrder
206
+ .map((p) => [p, properties[p]])
207
+ .find(([, prop]) => prop?.['primary'] === true);
208
+ const primaryKey = primaryKeyOption ?? (customPrimaryEntry ? toSnakeCase(customPrimaryEntry[0]) : 'id');
108
209
  const rawId = options.id;
109
- const idType = typeof rawId === 'string' ? rawId : 'BigInt';
210
+ const idType = customPrimaryEntry
211
+ ? (customPrimaryEntry[1]['type'] ?? 'String')
212
+ : (typeof rawId === 'string' ? rawId : 'BigInt');
110
213
  const isCompositeKey = primaryKey.includes(',');
111
214
  const needsUuidTrait = !isCompositeKey && idType === 'Uuid';
112
215
  const needsUlidTrait = !isCompositeKey && idType === 'Ulid';
@@ -168,6 +271,17 @@ function generateBaseModel(name, schema, reader, config) {
168
271
  */
169
272
  protected $keyType = 'string';
170
273
 
274
+ /**
275
+ * Indicates if the IDs are auto-incrementing.
276
+ */
277
+ public $incrementing = false;`;
278
+ }
279
+ else if (customPrimaryEntry && customPrimaryEntry[1]['autoIncrement'] !== true) {
280
+ // Integer-typed custom primary key not declared `autoIncrement: true`
281
+ // (omnify-go#167). Eloquent's Model default is $incrementing = true,
282
+ // which makes it omit the column on insert and read the value back via
283
+ // lastInsertId() — wrong for a key the caller assigns itself.
284
+ keyTypeSection = `
171
285
  /**
172
286
  * Indicates if the IDs are auto-incrementing.
173
287
  */
@@ -360,7 +474,11 @@ ${traits.join('\n')}
360
474
  //
361
475
  }
362
476
  `;
363
- return userFile(`${editable.path}/${editable.fileName}`, content);
477
+ // #171: this subclass is created once and then owned by the project. The
478
+ // most common edit to it — redeclaring $fillable — silently disables every
479
+ // property omnify adds to the base afterwards. We cannot prevent the edit,
480
+ // but we can stop it from being invisible.
481
+ return userFile(`${editable.path}/${editable.fileName}`, content, undefined, shadowingPropertyConflicts(baseAlias));
364
482
  }
365
483
  function buildImports(baseNamespace, modelName, hasSoftDelete, isAuthenticatable, hasTranslatable, properties, needsUuidTrait = false, needsUlidTrait = false, hasNestedSet = false, nestedSetNamespace = 'Aimeos\\Nestedset', hasFiles = false, modelNamespace = '', localesNamespace = '', traitsNamespace = '', sharedModelsNamespace = '', config, hasAuditLog = false) {
366
484
  const lines = [];
@@ -448,7 +566,11 @@ function buildDocProperties(properties, expandedProperties, propertyOrder, reade
448
566
  if (type === 'Association') {
449
567
  const relation = prop['relation'] ?? '';
450
568
  if (relation === 'ManyToOne') {
451
- const snakeName = toFkColumnName(propName);
569
+ // omnify-go#166: honor the `column:` override (ForeignColumn) the
570
+ // migration generator already applies — falling back to the
571
+ // {property}_id convention here documents a column the table
572
+ // doesn't actually have.
573
+ const snakeName = prop['column'] || toFkColumnName(propName);
452
574
  const nullable = prop['nullable'] ?? false;
453
575
  const phpType = nullable ? 'int|null' : 'int';
454
576
  lines.push(` * @property ${phpType} $${snakeName}`);
@@ -525,7 +647,8 @@ function buildFillable(properties, expandedProperties, propertyOrder) {
525
647
  if (type === 'Association') {
526
648
  const relation = prop['relation'] ?? '';
527
649
  if (relation === 'ManyToOne') {
528
- fields.push(toFkColumnName(propName));
650
+ // omnify-go#166: honor the `column:` override (ForeignColumn).
651
+ fields.push(prop['column'] || toFkColumnName(propName));
529
652
  }
530
653
  else if (relation === 'MorphTo') {
531
654
  const morphName = prop['morphName'] || toSnakeCase(propName);
@@ -633,7 +756,8 @@ function buildCasts(properties, expandedProperties, propertyOrder, reader, confi
633
756
  ? targetSchema.options.id
634
757
  : 'BigInt';
635
758
  if (targetIdType !== 'Uuid' && targetIdType !== 'Ulid') {
636
- casts.push(`'${toFkColumnName(propName)}' => 'integer',`);
759
+ // omnify-go#166: honor the `column:` override (ForeignColumn).
760
+ casts.push(`'${prop['column'] || toFkColumnName(propName)}' => 'integer',`);
637
761
  }
638
762
  }
639
763
  continue;
@@ -678,7 +802,9 @@ function buildCasts(properties, expandedProperties, propertyOrder, reader, confi
678
802
  * 4. Default — flat editable namespace (Laravel-canonical
679
803
  * `App\Models\<Name>`).
680
804
  */
681
- function resolveRelatedModelNamespace(target, modelNamespace, config, reader) {
805
+ function resolveRelatedModelNamespace(target, modelNamespace, config, reader,
806
+ /** The property's `targetNamespace`, for a target with no schema. */
807
+ externalNamespace) {
682
808
  // Package schemas use their own model namespace (we don't generate
683
809
  // editable stubs for them — relations dispatch to the package class).
684
810
  const packageNs = reader.resolveModelNamespace(target, modelNamespace);
@@ -689,8 +815,16 @@ function resolveRelatedModelNamespace(target, modelNamespace, config, reader) {
689
815
  return config.models.namespace;
690
816
  }
691
817
  const targetSchema = reader.getSchema(target);
692
- if (!targetSchema)
693
- return config.models.userEditableNamespace;
818
+ if (!targetSchema) {
819
+ // A target outside the schema set is an external model, and
820
+ // `targetNamespace` is the documented way to say where it lives
821
+ // ("Target model namespace for external packages"). Ignoring it and
822
+ // falling back to this project's editable namespace emits
823
+ // `\App\Models\User` for a model that is not there — the relation
824
+ // resolves to a class that does not exist. The fallback still applies
825
+ // when the field is absent, which is every schema that does not use it.
826
+ return externalNamespace || config.models.userEditableNamespace;
827
+ }
694
828
  const editableNs = config.models.userEditableNamespace;
695
829
  if (targetSchema.kind === 'pivot') {
696
830
  return `${editableNs}\\Pivot`;
@@ -717,7 +851,9 @@ function buildRelations(schemaName, properties, propertyOrder, modelNamespace, r
717
851
  // Issue #98 v5.8.6: also nest by target's group / Pivot/ so the
718
852
  // FQCN matches the target file's actual location.
719
853
  const target = prop['target'] ?? '';
720
- const ns = target ? resolveRelatedModelNamespace(target, modelNamespace, config, reader) : modelNamespace;
854
+ const ns = target
855
+ ? resolveRelatedModelNamespace(target, modelNamespace, config, reader, prop['targetNamespace'])
856
+ : modelNamespace;
721
857
  const targetTableName = target ? reader.getTableName(target) : '';
722
858
  // For ManyToMany, look up the explicit `kind: pivot` schema by pivotFor
723
859
  // pair (user-authored filenames may predate the sorted-name convention)
@@ -845,7 +981,11 @@ function buildInverseRelations(parentName, modelNamespace, reader, declaredMetho
845
981
  // generate a child-side accessor, not a parent-side one.
846
982
  // Here we're emitting the *parent* accessor, so the child's ManyToOne
847
983
  // (or owning OneToOne) is what we react to.
848
- const fkColumn = toFkColumnName(childPropName);
984
+ // omnify-go#166: honor the child property's `column:` override
985
+ // (ForeignColumn) — the physical FK column lives on the child's
986
+ // table, so it must match whatever the child's own migration/model
987
+ // used, not the {property}_id convention.
988
+ const fkColumn = childProp['column'] || toFkColumnName(childPropName);
849
989
  // Issue #98 v5.8.6: nest the inverse-side FQCN by the CHILD's
850
990
  // group / Pivot/ so the relation resolves to the child's actual
851
991
  // file location (which lives in `<childGroup>/<Child>.php` or
@@ -890,16 +1030,28 @@ function buildAccessors(expandedProperties) {
890
1030
  return methods.join('\n');
891
1031
  }
892
1032
  /** Generate a user model for a package schema that extends the package's model class. */
893
- function generatePackageUserModel(name, packageNamespace, config) {
1033
+ function generatePackageUserModel(name, packageNamespace, config, hasExtendAttributes = false) {
894
1034
  const modelName = toPascalCase(name);
895
1035
  const modelNamespace = config.models.namespace;
896
1036
  const packageModelClass = `${packageNamespace}\\${modelName}`;
1037
+ const traitName = extendAttributesTraitName(name);
1038
+ // This file is created once and never rewritten, so the `use` line can only
1039
+ // be put here for a model that does not exist yet. A project whose model
1040
+ // predates its extend keeps the file it has, and `generateModels` warns with
1041
+ // the one line to add — overwriting somebody's model to add an import is the
1042
+ // worse of the two.
1043
+ const traitImport = hasExtendAttributes
1044
+ ? `use ${modelNamespace}\\Concerns\\${traitName};\n`
1045
+ : '';
1046
+ const traitUse = hasExtendAttributes
1047
+ ? ` use ${traitName};\n\n`
1048
+ : '';
897
1049
  const content = `<?php
898
1050
 
899
1051
  namespace ${modelNamespace};
900
1052
 
901
1053
  use ${packageModelClass} as Package${modelName};
902
-
1054
+ ${traitImport}
903
1055
  /**
904
1056
  * ${modelName} Model (extends package model)
905
1057
  *
@@ -908,10 +1060,19 @@ use ${packageModelClass} as Package${modelName};
908
1060
  */
909
1061
  class ${modelName} extends Package${modelName}
910
1062
  {
911
- // Add your custom methods here
1063
+ ${traitUse} // Add your custom methods here
912
1064
  }
913
1065
  `;
914
- return userFile(`${config.models.path}/${modelName}.php`, content);
1066
+ return userFile(`${config.models.path}/${modelName}.php`, content, hasExtendAttributes
1067
+ ? {
1068
+ needle: `use ${traitName};`,
1069
+ hint: `this project's \`kind: extend\` adds columns to the package-owned `
1070
+ + `${modelName} schema, but this model predates the trait that carries `
1071
+ + `them, so those columns are still not fillable or cast. Add `
1072
+ + `\`use ${config.models.namespace}\\Concerns\\${traitName};\` and `
1073
+ + `\`use ${traitName};\` inside the class (#164).`,
1074
+ }
1075
+ : undefined);
915
1076
  }
916
1077
  /**
917
1078
  * Detect whether a schema uses nested set (tree structure).
@@ -10,7 +10,7 @@ export function buildRelation(propName, property, modelNamespace, context) {
10
10
  let method = null;
11
11
  switch (relation) {
12
12
  case 'ManyToOne':
13
- method = belongsTo(propName, target, modelNamespace);
13
+ method = belongsTo(propName, property, target, modelNamespace);
14
14
  break;
15
15
  case 'OneToOne':
16
16
  method = oneToOne(propName, property, target, modelNamespace);
@@ -60,8 +60,12 @@ export function getReturnType(relation) {
60
60
  default: return 'BelongsTo';
61
61
  }
62
62
  }
63
- function belongsTo(propName, target, ns) {
64
- const foreignKey = toFkColumnName(propName);
63
+ function belongsTo(propName, property, target, ns) {
64
+ // omnify-go#166: honor the `column:` override (ForeignColumn) — the
65
+ // migration generator already names the physical column from it, so
66
+ // falling back to the {property}_id convention here points the relation
67
+ // at a column the table doesn't have.
68
+ const foreignKey = property['column'] || toFkColumnName(propName);
65
69
  const methodName = toCamelCase(propName);
66
70
  const fqcn = `\\${ns}\\${target}`;
67
71
  return ` /**
@@ -86,7 +90,7 @@ function oneToOne(propName, property, target, ns) {
86
90
  return $this->hasOne(${fqcn}::class, '${foreignKey}');
87
91
  }`;
88
92
  }
89
- return belongsTo(propName, target, ns);
93
+ return belongsTo(propName, property, target, ns);
90
94
  }
91
95
  function hasMany(propName, property, target, ns) {
92
96
  const inverse = property['mappedBy'] ?? property['inversedBy'] ?? null;
@@ -18,13 +18,41 @@ import { baseFile, userFile, resolveModularBasePath, resolveModularBaseNamespace
18
18
  export function generateResources(reader, config) {
19
19
  const files = [];
20
20
  for (const [name, schema] of Object.entries(reader.getProjectVisibleObjectSchemas())) {
21
- if (schema.kind === 'extend')
21
+ if (!generatesOwnResource(schema))
22
22
  continue;
23
23
  files.push(generateBaseResource(name, schema, reader, config));
24
24
  files.push(generateUserResource(name, schema, config));
25
25
  }
26
26
  return files;
27
27
  }
28
+ /**
29
+ * Whether THIS generator writes a Resource for a project-owned schema.
30
+ *
31
+ * The loop above and the cross-resource reference below have to agree about
32
+ * this, and when they were two separate conditions they did not: the loop
33
+ * skipped `kind: extend`, and the reference did not ask at all — so a relation
34
+ * pointing at one named a class the loop had just declined to write.
35
+ */
36
+ function generatesOwnResource(schema) {
37
+ return schema.kind !== 'extend' && schema.options?.hidden !== true;
38
+ }
39
+ /**
40
+ * Whether a Resource class exists for an association target — ours or a
41
+ * package's. `false` means there is no class to name, and the caller must fall
42
+ * back to a bare `whenLoaded()`.
43
+ */
44
+ function hasResourceClass(target, reader) {
45
+ const schema = reader.getSchema(target);
46
+ // Not a schema at all: an external model reached through `targetNamespace`,
47
+ // e.g. the app's own User. Omnify generates nothing for it.
48
+ if (!schema)
49
+ return false;
50
+ // A package ships its own resources; whether they are reachable is the
51
+ // namespace question, which the caller answers separately.
52
+ if (schema.package != null)
53
+ return true;
54
+ return generatesOwnResource(schema);
55
+ }
28
56
  function generateBaseResource(name, schema, reader, config) {
29
57
  const modelName = toPascalCase(name);
30
58
  // Issue #98 v5.8.2: nest namespace by group in legacy structure so the
@@ -242,8 +270,23 @@ function addAssociationFields(propName, prop, fields, resourceNamespace, modelNa
242
270
  targetResNs = editableNs;
243
271
  }
244
272
  }
245
- // If target is a package schema without a resource namespace, use simple whenLoaded
246
- if (!targetResNs && relation !== 'MorphTo') {
273
+ // The namespace resolving is not the same question as the class existing.
274
+ // `resolveResourceNamespace()` only returns null for a PACKAGE schema whose
275
+ // package declares no resource namespace — a target that is not a schema at
276
+ // all (an external model reached through `targetNamespace`, e.g. the app's
277
+ // own User), or one this generator declines to write a Resource for, falls
278
+ // through and is treated as project-owned. The reference then names
279
+ // `\<projectResourceNs>\<Target>Resource`, a class nothing declares, and
280
+ // `whenLoaded(..., fn () => new UserResource(...))` fatals the moment that
281
+ // relation is loaded and the resource is rendered.
282
+ //
283
+ // This is the same rule `interface-generator.ts` already applies on the TS
284
+ // side (`resolveTargetName` → `unknown` for a target outside the schema set),
285
+ // arriving late on the PHP side.
286
+ const targetHasResource = target ? hasResourceClass(target, reader) : false;
287
+ // If target is a package schema without a resource namespace, or a model no
288
+ // Resource is written for, use simple whenLoaded
289
+ if ((!targetResNs || !targetHasResource) && relation !== 'MorphTo') {
247
290
  if (relation === 'ManyToOne') {
248
291
  const snakeName = toFkColumnName(propName);
249
292
  fields.push(` '${snakeName}' => $this->${snakeName},`);
@@ -7,11 +7,66 @@ export interface GeneratedFile {
7
7
  readonly content: string;
8
8
  readonly overwrite: boolean;
9
9
  readonly type: 'base' | 'user';
10
+ /**
11
+ * For a user file only: something the file has to contain that a version
12
+ * written by an EARLIER omnify would not have.
13
+ *
14
+ * A user file is created once and never rewritten, so a template that grows a
15
+ * new line reaches new projects and silently misses every existing one. The
16
+ * writer checks this against the file it skipped and prints `hint` when it is
17
+ * absent — telling the one project that needs the edit, rather than warning
18
+ * everybody on every run or overwriting somebody's code to add a line.
19
+ */
20
+ readonly expects?: {
21
+ readonly needle: string;
22
+ readonly hint: string;
23
+ };
24
+ /**
25
+ * For a user file only: things the file must NOT contain, and why.
26
+ *
27
+ * The mirror of `expects`. Some edits to a user-editable subclass silently
28
+ * disable what the base class generates — the worst being a redeclared
29
+ * `protected $fillable`, which in PHP REPLACES the parent's rather than
30
+ * merging, so every property added to the schema afterwards is stripped by
31
+ * Eloquent on mass assignment. Nothing throws; `save()` returns true and the
32
+ * column stays NULL (issue #171).
33
+ *
34
+ * Each entry is matched against the content of the file we skipped, and
35
+ * `hint` is printed when it matches.
36
+ */
37
+ readonly conflicts?: readonly {
38
+ readonly pattern: string;
39
+ readonly hint: string;
40
+ }[];
10
41
  }
11
42
  /** Create a base file (always overwritten). */
12
43
  export declare function baseFile(path: string, content: string): GeneratedFile;
13
44
  /** Create a user file (created once, skip if exists). */
14
- export declare function userFile(path: string, content: string): GeneratedFile;
45
+ export declare function userFile(path: string, content: string, expects?: {
46
+ needle: string;
47
+ hint: string;
48
+ }, conflicts?: readonly {
49
+ pattern: string;
50
+ hint: string;
51
+ }[]): GeneratedFile;
52
+ /**
53
+ * Eloquent array properties that REPLACE the parent's when redeclared in a
54
+ * subclass, rather than merging. Redeclaring any of these in a user-editable
55
+ * model shadows whatever the generated base puts there, and the failure is
56
+ * completely silent. Issue #171.
57
+ */
58
+ export declare const SHADOWING_ELOQUENT_PROPERTIES: readonly ["fillable", "guarded", "casts", "hidden", "visible", "appends", "dates", "touches", "with"];
59
+ /**
60
+ * Build the `conflicts` entries for a generated model's editable subclass.
61
+ *
62
+ * `baseClass` is named in the hint because the whole point is that the base is
63
+ * still correct — the generator did emit the property — and the reader needs to
64
+ * know which file they are shadowing.
65
+ */
66
+ export declare function shadowingPropertyConflicts(baseClass: string): {
67
+ pattern: string;
68
+ hint: string;
69
+ }[];
15
70
  /** Category of base file for modular path resolution. */
16
71
  export type BaseCategory = 'Models' | 'Controllers' | 'Requests' | 'Resources' | 'Services' | 'Enums' | 'Traits' | 'Locales' | 'Policies';
17
72
  /**
@@ -401,3 +456,15 @@ export declare function nestEditableByGroup(layer: BaseEditableLayer, loc: {
401
456
  * All paths and namespaces fall back to sensible defaults.
402
457
  */
403
458
  export declare function derivePhpConfig(overrides?: LaravelCodegenOverrides): PhpConfig;
459
+ /**
460
+ * Strip PHP comments so a commented-out declaration does not read as a real
461
+ * one. Without this, the very common
462
+ *
463
+ * // protected $fillable = ['name']; // left from a refactor
464
+ *
465
+ * would be reported as shadowing the base — and a warning that fires on code
466
+ * that is not running is worse than no warning, because people learn to ignore
467
+ * it. Naive on purpose: it can strip a `//` inside a string literal, which only
468
+ * ever costs us a warning we would otherwise have printed, never a false one.
469
+ */
470
+ export declare function stripPhpComments(source: string): string;
package/dist/php/types.js CHANGED
@@ -6,8 +6,53 @@ export function baseFile(path, content) {
6
6
  return { path, content, overwrite: true, type: 'base' };
7
7
  }
8
8
  /** Create a user file (created once, skip if exists). */
9
- export function userFile(path, content) {
10
- return { path, content, overwrite: false, type: 'user' };
9
+ export function userFile(path, content, expects, conflicts) {
10
+ return {
11
+ path,
12
+ content,
13
+ overwrite: false,
14
+ type: 'user',
15
+ ...(expects ? { expects } : {}),
16
+ ...(conflicts && conflicts.length > 0 ? { conflicts } : {}),
17
+ };
18
+ }
19
+ /**
20
+ * Eloquent array properties that REPLACE the parent's when redeclared in a
21
+ * subclass, rather than merging. Redeclaring any of these in a user-editable
22
+ * model shadows whatever the generated base puts there, and the failure is
23
+ * completely silent. Issue #171.
24
+ */
25
+ export const SHADOWING_ELOQUENT_PROPERTIES = [
26
+ 'fillable',
27
+ 'guarded',
28
+ 'casts',
29
+ 'hidden',
30
+ 'visible',
31
+ 'appends',
32
+ 'dates',
33
+ 'touches',
34
+ 'with',
35
+ ];
36
+ /**
37
+ * Build the `conflicts` entries for a generated model's editable subclass.
38
+ *
39
+ * `baseClass` is named in the hint because the whole point is that the base is
40
+ * still correct — the generator did emit the property — and the reader needs to
41
+ * know which file they are shadowing.
42
+ */
43
+ export function shadowingPropertyConflicts(baseClass) {
44
+ return SHADOWING_ELOQUENT_PROPERTIES.map((prop) => ({
45
+ // Matches `protected $fillable`, `public array $casts`, `protected static
46
+ // $with` — any redeclaration, whatever the visibility or type hint.
47
+ pattern: String.raw `(?:public|protected|private)\s+(?:static\s+)?(?:array\s+)?\$` + prop + String.raw `\s*=`,
48
+ hint: `redeclares $${prop}, which REPLACES ` +
49
+ `${baseClass}::$${prop} rather than merging with it (PHP does not merge ` +
50
+ `redeclared properties). Anything omnify adds to $${prop} from now on is ` +
51
+ `silently dropped: mass assignment strips it, save() still returns true, ` +
52
+ `and the column stays NULL. Fix: delete the redeclaration and let the base ` +
53
+ `provide it, or build it from the parent, e.g. ` +
54
+ `__construct() { $this->${prop} = array_merge(parent::$${prop} ?? [], [...]); }`,
55
+ }));
11
56
  }
12
57
  /**
13
58
  * Resolve base file path for a schema based on structure.
@@ -382,3 +427,20 @@ export function derivePhpConfig(overrides) {
382
427
  },
383
428
  };
384
429
  }
430
+ /**
431
+ * Strip PHP comments so a commented-out declaration does not read as a real
432
+ * one. Without this, the very common
433
+ *
434
+ * // protected $fillable = ['name']; // left from a refactor
435
+ *
436
+ * would be reported as shadowing the base — and a warning that fires on code
437
+ * that is not running is worse than no warning, because people learn to ignore
438
+ * it. Naive on purpose: it can strip a `//` inside a string literal, which only
439
+ * ever costs us a warning we would otherwise have printed, never a false one.
440
+ */
441
+ export function stripPhpComments(source) {
442
+ return source
443
+ .replace(/\/\*[\s\S]*?\*\//g, '')
444
+ .replace(/(^|\s)\/\/[^\n]*/g, '$1')
445
+ .replace(/(^|\s)#[^\n]*/g, '$1');
446
+ }
package/dist/types.d.ts CHANGED
@@ -240,6 +240,14 @@ export interface SchemaDefinition {
240
240
  readonly properties?: Record<string, PropertyDefinition>;
241
241
  readonly propertyOrder?: readonly string[];
242
242
  readonly expandedProperties?: Record<string, ExpandedProperty>;
243
+ /**
244
+ * Properties a project-level `kind: extend` merged into this schema, in
245
+ * merge order. The Go loader folds an extend into its target and deletes
246
+ * the extend, so this is the only remaining trace of where they came from
247
+ * — which a PACKAGE-owned target needs, because the package's own generate
248
+ * run never saw them (#164).
249
+ */
250
+ readonly extendedProperties?: readonly string[];
243
251
  readonly values?: readonly EnumValueDefinition[];
244
252
  readonly pivotFor?: readonly string[];
245
253
  readonly policies?: readonly PolicyDefinition[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnifyjp/ts",
3
- "version": "6.0.7",
3
+ "version": "6.3.0",
4
4
  "description": "TypeScript model type generator from Omnify schemas.json",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",