@omnifyjp/omnify 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/package.json +6 -6
- package/ts-dist/cli.js +29 -0
- package/ts-dist/php/model-generator.js +179 -18
- package/ts-dist/php/relation-builder.js +8 -4
- package/ts-dist/php/resource-generator.js +46 -3
- package/ts-dist/php/types.d.ts +68 -1
- package/ts-dist/php/types.js +64 -2
- package/ts-dist/types.d.ts +8 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@omnifyjp/omnify",
|
|
3
|
-
"version": "6.0
|
|
3
|
+
"version": "6.3.0",
|
|
4
4
|
"description": "Schema-driven code generation for Laravel, TypeScript, and SQL",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -36,10 +36,10 @@
|
|
|
36
36
|
"zod": "^3.24.0"
|
|
37
37
|
},
|
|
38
38
|
"optionalDependencies": {
|
|
39
|
-
"@omnifyjp/omnify-darwin-arm64": "6.0
|
|
40
|
-
"@omnifyjp/omnify-darwin-x64": "6.0
|
|
41
|
-
"@omnifyjp/omnify-linux-x64": "6.0
|
|
42
|
-
"@omnifyjp/omnify-linux-arm64": "6.0
|
|
43
|
-
"@omnifyjp/omnify-win32-x64": "6.0
|
|
39
|
+
"@omnifyjp/omnify-darwin-arm64": "6.3.0",
|
|
40
|
+
"@omnifyjp/omnify-darwin-x64": "6.3.0",
|
|
41
|
+
"@omnifyjp/omnify-linux-x64": "6.3.0",
|
|
42
|
+
"@omnifyjp/omnify-linux-arm64": "6.3.0",
|
|
43
|
+
"@omnifyjp/omnify-win32-x64": "6.3.0"
|
|
44
44
|
}
|
|
45
45
|
}
|
package/ts-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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
246
|
-
|
|
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},`);
|
package/ts-dist/php/types.d.ts
CHANGED
|
@@ -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
|
|
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/ts-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 {
|
|
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/ts-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[];
|