@omnifyjp/omnify 5.9.18 → 5.9.19

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnifyjp/omnify",
3
- "version": "5.9.18",
3
+ "version": "5.9.19",
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": "5.9.18",
40
- "@omnifyjp/omnify-darwin-x64": "5.9.18",
41
- "@omnifyjp/omnify-linux-x64": "5.9.18",
42
- "@omnifyjp/omnify-linux-arm64": "5.9.18",
43
- "@omnifyjp/omnify-win32-x64": "5.9.18"
39
+ "@omnifyjp/omnify-darwin-arm64": "5.9.19",
40
+ "@omnifyjp/omnify-darwin-x64": "5.9.19",
41
+ "@omnifyjp/omnify-linux-x64": "5.9.19",
42
+ "@omnifyjp/omnify-linux-arm64": "5.9.19",
43
+ "@omnifyjp/omnify-win32-x64": "5.9.19"
44
44
  }
45
45
  }
@@ -8,6 +8,15 @@
8
8
  import type { SchemaDefinition, PropertyDefinition, TSInterface, TSProperty, GeneratorOptions } from './types.js';
9
9
  /** Convert PascalCase/camelCase to snake_case. */
10
10
  export declare function toSnakeCase(str: string): string;
11
+ /**
12
+ * Physical FK column name for an owning-side association property.
13
+ *
14
+ * The convention is `<property_snake>_id`, but a property already named with
15
+ * that suffix IS the column — `table_session_id` is what Laravel's `belongsTo`
16
+ * convention names it and what a legacy table already has. Appending
17
+ * unconditionally produced `table_session_id_id` (issue #149).
18
+ */
19
+ export declare function toFkColumnName(propertyName: string): string;
11
20
  /** Gets TypeScript type for a property. */
12
21
  export declare function getPropertyType(property: PropertyDefinition, allSchemas: Record<string, SchemaDefinition>): string;
13
22
  /**
@@ -12,6 +12,18 @@ export function toSnakeCase(str) {
12
12
  .replace(/^_/, '')
13
13
  .toLowerCase();
14
14
  }
15
+ /**
16
+ * Physical FK column name for an owning-side association property.
17
+ *
18
+ * The convention is `<property_snake>_id`, but a property already named with
19
+ * that suffix IS the column — `table_session_id` is what Laravel's `belongsTo`
20
+ * convention names it and what a legacy table already has. Appending
21
+ * unconditionally produced `table_session_id_id` (issue #149).
22
+ */
23
+ export function toFkColumnName(propertyName) {
24
+ const snake = toSnakeCase(propertyName);
25
+ return snake.endsWith('_id') ? snake : `${snake}_id`;
26
+ }
15
27
  /** Maps Omnify property types to TypeScript types. */
16
28
  const TYPE_MAP = {
17
29
  String: 'string',
@@ -184,7 +196,7 @@ export function propertyToTSProperties(propertyName, property, schema, allSchema
184
196
  }
185
197
  return [
186
198
  {
187
- name: `${propertyName}_id`,
199
+ name: toFkColumnName(propertyName),
188
200
  type: fkType,
189
201
  optional: isNullable,
190
202
  nullable: isNullable,
@@ -13,7 +13,7 @@
13
13
  * The metadata is a *new* artifact emitted in addition to the existing
14
14
  * interface, zod schemas, and i18n object — none of those are modified.
15
15
  */
16
- import { toSnakeCase } from './interface-generator.js';
16
+ import { toSnakeCase, toFkColumnName } from './interface-generator.js';
17
17
  // ---------------------------------------------------------------------------
18
18
  // Type classification — single source of truth so #55 (payload builder)
19
19
  // can reuse the same field-type decisions.
@@ -178,7 +178,7 @@ export function buildSchemaMetadata(schema, allSchemas, options, displayNames) {
178
178
  (prop.relation === 'ManyToOne' || prop.relation === 'OneToOne') &&
179
179
  !prop.mappedBy;
180
180
  const fieldKey = isOwningAssoc
181
- ? `${toSnakeCase(propName)}_id`
181
+ ? toFkColumnName(propName)
182
182
  : toSnakeCase(propName);
183
183
  const fieldType = classifyFieldType(prop, allSchemas, options.customTypes.simple);
184
184
  const required = isFieldRequired(prop);
@@ -20,7 +20,7 @@
20
20
  * codegen output root in `payload-helpers.ts` so per-model files can
21
21
  * import it.
22
22
  */
23
- import { toSnakeCase } from './interface-generator.js';
23
+ import { toSnakeCase, toFkColumnName } from './interface-generator.js';
24
24
  import { classifyFieldType } from './metadata-generator.js';
25
25
  import { toEnumMemberName } from './enum-generator.js';
26
26
  // ---------------------------------------------------------------------------
@@ -186,7 +186,7 @@ export function buildFormShape(schema, allSchemas, options) {
186
186
  (prop.relation === 'ManyToOne' || prop.relation === 'OneToOne') &&
187
187
  !prop.mappedBy;
188
188
  const fieldName = isOwningAssoc
189
- ? `${toSnakeCase(propName)}_id`
189
+ ? toFkColumnName(propName)
190
190
  : toSnakeCase(propName);
191
191
  // rules.required overrides nullable inference
192
192
  const required = prop.rules?.required === false
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Port of FakerMapper.php — generates Faker expressions for factories.
3
3
  */
4
- import { toSnakeCase } from './naming-helper.js';
4
+ import { toSnakeCase, toFkColumnName } from './naming-helper.js';
5
5
  import { resolveEnumValues } from './type-mapper.js';
6
6
  /** Generate faker expression for a property. */
7
7
  export function toFaker(propName, property, schemas = {}) {
@@ -69,8 +69,7 @@ export function associationFaker(propName, property, modelNamespace, currentSche
69
69
  const target = property['target'] ?? '';
70
70
  if (relation !== 'ManyToOne' || !target)
71
71
  return null;
72
- const snakeName = toSnakeCase(propName);
73
- const foreignKey = `${snakeName}_id`;
72
+ const foreignKey = toFkColumnName(propName);
74
73
  const nullable = property['nullable'] ?? false;
75
74
  const fake = nullable
76
75
  ? `'${foreignKey}' => ${target}::query()->inRandomOrder()->first()?->id,`
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Port of ModelGenerator.php — generates Eloquent model base + user classes.
3
3
  */
4
- import { toPascalCase, toSnakeCase, toCamelCase, pluralize } from './naming-helper.js';
4
+ import { toPascalCase, toSnakeCase, toCamelCase, toFkColumnName, pluralize } from './naming-helper.js';
5
+ import { resolveTimestamps } from './timestamps.js';
5
6
  import { toCast, toPhpDocType, isHiddenByDefault } from './type-mapper.js';
6
7
  import { buildRelation } from './relation-builder.js';
7
8
  import { baseFile, userFile, resolveModularBasePath, resolveModularBaseNamespace, resolveSharedBaseNamespace, resolveGlobalTraitNamespace, resolveGlobalEnumNamespace, resolveBaseClass, resolveEditableClass, nestByGroup, nestEditableByGroup, } from './types.js';
@@ -97,23 +98,7 @@ function generateBaseModel(name, schema, reader, config) {
97
98
  // $timestamps = true and nulls the constant for the absent column — otherwise
98
99
  // Eloquent writes a column the table does not have and every insert dies with
99
100
  // "Unknown column 'created_at'" (omnify-go#143).
100
- const timestampsOption = options.timestamps ?? false;
101
- const resolveTimestampColumn = (camel, snake) => {
102
- if (typeof timestampsOption === 'boolean')
103
- return timestampsOption;
104
- if (timestampsOption && typeof timestampsOption === 'object') {
105
- const map = timestampsOption;
106
- if (typeof map[camel] === 'boolean')
107
- return map[camel];
108
- if (typeof map[snake] === 'boolean')
109
- return map[snake];
110
- return false;
111
- }
112
- return false;
113
- };
114
- const hasCreatedAt = resolveTimestampColumn('createdAt', 'created_at');
115
- const hasUpdatedAt = resolveTimestampColumn('updatedAt', 'updated_at');
116
- const hasTimestamps = hasCreatedAt || hasUpdatedAt;
101
+ const { createdAt: hasCreatedAt, updatedAt: hasUpdatedAt, any: hasTimestamps } = resolveTimestamps(options);
117
102
  // NodeTrait is added to the user-editable Model, not BaseModel
118
103
  const hasNestedSet = false;
119
104
  const nestedSetParentColumn = null;
@@ -456,7 +441,7 @@ function buildDocProperties(properties, expandedProperties, propertyOrder, reade
456
441
  if (type === 'Association') {
457
442
  const relation = prop['relation'] ?? '';
458
443
  if (relation === 'ManyToOne') {
459
- const snakeName = toSnakeCase(propName) + '_id';
444
+ const snakeName = toFkColumnName(propName);
460
445
  const nullable = prop['nullable'] ?? false;
461
446
  const phpType = nullable ? 'int|null' : 'int';
462
447
  lines.push(` * @property ${phpType} $${snakeName}`);
@@ -533,7 +518,7 @@ function buildFillable(properties, expandedProperties, propertyOrder) {
533
518
  if (type === 'Association') {
534
519
  const relation = prop['relation'] ?? '';
535
520
  if (relation === 'ManyToOne') {
536
- fields.push(toSnakeCase(propName) + '_id');
521
+ fields.push(toFkColumnName(propName));
537
522
  }
538
523
  else if (relation === 'MorphTo') {
539
524
  const morphName = prop['morphName'] || toSnakeCase(propName);
@@ -641,8 +626,7 @@ function buildCasts(properties, expandedProperties, propertyOrder, reader, confi
641
626
  ? targetSchema.options.id
642
627
  : 'BigInt';
643
628
  if (targetIdType !== 'Uuid' && targetIdType !== 'Ulid') {
644
- const snakeName = toSnakeCase(propName);
645
- casts.push(`'${snakeName}_id' => 'integer',`);
629
+ casts.push(`'${toFkColumnName(propName)}' => 'integer',`);
646
630
  }
647
631
  }
648
632
  continue;
@@ -846,7 +830,7 @@ function buildInverseRelations(parentName, modelNamespace, reader, declaredMetho
846
830
  // generate a child-side accessor, not a parent-side one.
847
831
  // Here we're emitting the *parent* accessor, so the child's ManyToOne
848
832
  // (or owning OneToOne) is what we react to.
849
- const fkColumn = `${toSnakeCase(childPropName)}_id`;
833
+ const fkColumn = toFkColumnName(childPropName);
850
834
  // Issue #98 v5.8.6: nest the inverse-side FQCN by the CHILD's
851
835
  // group / Pivot/ so the relation resolves to the child's actual
852
836
  // file location (which lives in `<childGroup>/<Child>.php` or
@@ -3,6 +3,15 @@
3
3
  */
4
4
  /** Convert camelCase/PascalCase to snake_case. */
5
5
  export declare function toSnakeCase(value: string): string;
6
+ /**
7
+ * Physical FK column name for an owning-side association property.
8
+ *
9
+ * The convention is `<property_snake>_id`, but a property already named with
10
+ * that suffix IS the column — `table_session_id` is what Laravel's `belongsTo`
11
+ * convention names it and what a legacy table already has. Appending
12
+ * unconditionally produced `table_session_id_id` (issue #149).
13
+ */
14
+ export declare function toFkColumnName(propertyName: string): string;
6
15
  /** Convert any string to PascalCase. */
7
16
  export declare function toPascalCase(value: string): string;
8
17
  /** Convert any string to camelCase. */
@@ -8,6 +8,18 @@ export function toSnakeCase(value) {
8
8
  .replace(/^_/, '')
9
9
  .toLowerCase();
10
10
  }
11
+ /**
12
+ * Physical FK column name for an owning-side association property.
13
+ *
14
+ * The convention is `<property_snake>_id`, but a property already named with
15
+ * that suffix IS the column — `table_session_id` is what Laravel's `belongsTo`
16
+ * convention names it and what a legacy table already has. Appending
17
+ * unconditionally produced `table_session_id_id` (issue #149).
18
+ */
19
+ export function toFkColumnName(propertyName) {
20
+ const snake = toSnakeCase(propertyName);
21
+ return snake.endsWith('_id') ? snake : `${snake}_id`;
22
+ }
11
23
  /** Convert any string to PascalCase. */
12
24
  export function toPascalCase(value) {
13
25
  return value
@@ -4,7 +4,7 @@
4
4
  * Generates Policy classes from Cedar-style ABAC policy definitions.
5
5
  * Only generates for schemas that have `policies` defined.
6
6
  */
7
- import { toPascalCase, toSnakeCase, toCamelCase } from './naming-helper.js';
7
+ import { toPascalCase, toSnakeCase, toCamelCase, toFkColumnName } from './naming-helper.js';
8
8
  import { baseFile, userFile, resolveModularBasePath, resolveModularBaseNamespace, resolveBaseClass, resolveEditableClass, nestByGroup, nestEditableByGroup } from './types.js';
9
9
  // ============================================================================
10
10
  // Action mapping: Omnify → Laravel
@@ -337,7 +337,7 @@ function operandToPhp(operand, schema, reader, side) {
337
337
  // Check if it's an association (ManyToOne) → use _id
338
338
  const prop = schema.properties?.[propName];
339
339
  if (prop?.relation === 'ManyToOne') {
340
- return `$record->${toSnakeCase(propName)}_id`;
340
+ return `$record->${toFkColumnName(propName)}`;
341
341
  }
342
342
  return `$record->${toSnakeCase(propName)}`;
343
343
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Port of RelationBuilder.php — generates Eloquent relation methods.
3
3
  */
4
- import { toSnakeCase, toCamelCase, singularize } from './naming-helper.js';
4
+ import { toSnakeCase, toCamelCase, toFkColumnName, singularize } from './naming-helper.js';
5
5
  /** Build Eloquent relation method PHP code. */
6
6
  export function buildRelation(propName, property, modelNamespace, context) {
7
7
  const relation = property['relation'] ?? '';
@@ -62,8 +62,7 @@ export function getReturnType(relation) {
62
62
  }
63
63
  }
64
64
  function belongsTo(propName, target, ns) {
65
- const snakeName = toSnakeCase(propName);
66
- const foreignKey = `${snakeName}_id`;
65
+ const foreignKey = toFkColumnName(propName);
67
66
  const methodName = toCamelCase(propName);
68
67
  const fqcn = `\\${ns}\\${target}`;
69
68
  return ` /**
@@ -79,7 +78,7 @@ function oneToOne(propName, property, target, ns) {
79
78
  const methodName = toCamelCase(propName);
80
79
  const fqcn = `\\${ns}\\${target}`;
81
80
  if (mappedBy) {
82
- const foreignKey = `${toSnakeCase(mappedBy)}_id`;
81
+ const foreignKey = toFkColumnName(mappedBy);
83
82
  return ` /**
84
83
  * Get the ${propName} associated with this model.
85
84
  */
@@ -92,7 +91,7 @@ function oneToOne(propName, property, target, ns) {
92
91
  }
93
92
  function hasMany(propName, property, target, ns) {
94
93
  const inverse = property['mappedBy'] ?? property['inversedBy'] ?? null;
95
- const foreignKey = inverse ? `${toSnakeCase(inverse)}_id` : `${toSnakeCase(propName)}_id`;
94
+ const foreignKey = inverse ? toFkColumnName(inverse) : toFkColumnName(propName);
96
95
  const methodName = toCamelCase(propName);
97
96
  const fqcn = `\\${ns}\\${target}`;
98
97
  const orderChain = formatOrderByChain(property['orderBy']);
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Port of RequestGenerator.php — generates Store/Update request classes.
3
3
  */
4
- import { toPascalCase, toSnakeCase, toCamelCase } from './naming-helper.js';
4
+ import { toPascalCase, toSnakeCase, toCamelCase, toFkColumnName } from './naming-helper.js';
5
5
  import { toStoreRules, toUpdateRules, formatRules, hasRuleObject } from './type-mapper.js';
6
6
  import { baseFile, userFile, resolveModularBasePath, resolveModularBaseNamespace, resolveBaseClass, resolveEditableClass, nestByGroup, nestEditableByGroup } from './types.js';
7
7
  /**
@@ -106,7 +106,7 @@ function generateBaseRequest(name, schema, reader, config, action) {
106
106
  if (type === 'Association') {
107
107
  const relation = prop['relation'] ?? '';
108
108
  if (relation === 'ManyToOne') {
109
- const snakeName = toSnakeCase(propName) + '_id';
109
+ const snakeName = toFkColumnName(propName);
110
110
  const target = prop['target'] ?? '';
111
111
  const targetIdType = resolveFkRuleType(target, reader);
112
112
  const rules = isUpdate
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Port of ResourceGenerator.php — generates API Resource classes.
3
3
  */
4
- import { toPascalCase, toSnakeCase, toCamelCase } from './naming-helper.js';
4
+ import { toPascalCase, toSnakeCase, toCamelCase, toFkColumnName } from './naming-helper.js';
5
+ import { resolveTimestamps } from './timestamps.js';
5
6
  import { isHiddenByDefault, toResourceExpression } from './type-mapper.js';
6
7
  import { baseFile, userFile, resolveModularBasePath, resolveModularBaseNamespace, resolveBaseClass, resolveEditableClass, nestByGroup, nestEditableByGroup } from './types.js';
7
8
  /** Generate Resource classes for all project-owned visible object schemas.
@@ -39,7 +40,7 @@ function generateBaseResource(name, schema, reader, config) {
39
40
  const expandedProperties = reader.getExpandedProperties(name);
40
41
  const propertyOrder = reader.getPropertyOrder(name);
41
42
  const options = schema.options ?? {};
42
- const hasTimestamps = options.timestamps ?? false;
43
+ const timestamps = resolveTimestamps(options);
43
44
  const hasSoftDelete = options.softDelete ?? false;
44
45
  const hasId = options.id ?? true;
45
46
  const fields = [];
@@ -87,8 +88,13 @@ function generateBaseResource(name, schema, reader, config) {
87
88
  if (translatableFields.length > 0) {
88
89
  fields.push(" 'translations' => $this->getTranslationsArray(),");
89
90
  }
90
- if (hasTimestamps) {
91
+ // Emit each column on its own condition — a table declaring only one of
92
+ // the two would otherwise expose a field the model never fills, or hide one
93
+ // it does (omnify-go#151).
94
+ if (timestamps.createdAt) {
91
95
  fields.push(" 'created_at' => $this->created_at?->toISOString(),");
96
+ }
97
+ if (timestamps.updatedAt) {
92
98
  fields.push(" 'updated_at' => $this->updated_at?->toISOString(),");
93
99
  }
94
100
  if (hasSoftDelete) {
@@ -239,7 +245,7 @@ function addAssociationFields(propName, prop, fields, resourceNamespace, modelNa
239
245
  // If target is a package schema without a resource namespace, use simple whenLoaded
240
246
  if (!targetResNs && relation !== 'MorphTo') {
241
247
  if (relation === 'ManyToOne') {
242
- const snakeName = toSnakeCase(propName) + '_id';
248
+ const snakeName = toFkColumnName(propName);
243
249
  fields.push(` '${snakeName}' => $this->${snakeName},`);
244
250
  }
245
251
  fields.push(` '${methodName}' => $this->whenLoaded('${methodName}'),`);
@@ -248,7 +254,7 @@ function addAssociationFields(propName, prop, fields, resourceNamespace, modelNa
248
254
  const targetResource = toPascalCase(target) + 'Resource';
249
255
  switch (relation) {
250
256
  case 'ManyToOne': {
251
- const snakeName = toSnakeCase(propName) + '_id';
257
+ const snakeName = toFkColumnName(propName);
252
258
  fields.push(` '${snakeName}' => $this->${snakeName},`);
253
259
  fields.push(` '${methodName}' => $this->whenLoaded('${methodName}', fn() => new \\${targetResNs}\\${targetResource}($this->${methodName})),`);
254
260
  break;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Timestamp column resolution — the single source of truth for "does this
3
+ * table have created_at / updated_at", shared by every PHP generator.
4
+ *
5
+ * This MUST agree with the Go side (`SchemaOptions.HasCreatedAt` /
6
+ * `HasUpdatedAt` in internal/schema/types.go), because the Go side is what
7
+ * writes the migration. When the two disagree the failure is silent: the
8
+ * column exists in the database and the model refuses to fill it (or the
9
+ * reverse, and every insert dies on an unknown column).
10
+ *
11
+ * The rules, in Go's words:
12
+ * - option absent → BOTH columns (the long-standing default)
13
+ * - `timestamps: false` → neither
14
+ * - `timestamps: true` → both
15
+ * - object form → only the keys explicitly set to true
16
+ * (a key absent from the map means that column is absent — this is the
17
+ * one place where "unset" does NOT mean "default on", because naming the
18
+ * map at all is the author declaring the columns individually)
19
+ */
20
+ export interface TimestampColumns {
21
+ createdAt: boolean;
22
+ updatedAt: boolean;
23
+ /** True when the model should carry `$timestamps = true`. */
24
+ any: boolean;
25
+ }
26
+ export declare function resolveTimestamps(options: {
27
+ timestamps?: unknown;
28
+ } | undefined | null): TimestampColumns;
@@ -0,0 +1,23 @@
1
+ export function resolveTimestamps(options) {
2
+ // Absent means both — matching the Go default. Reading it as `false` made
3
+ // every schema that never mentioned `timestamps` generate
4
+ // `public $timestamps = false;` against a table the migration gave both
5
+ // columns, so Eloquent silently stopped stamping them (omnify-go#151).
6
+ const raw = (options?.timestamps ?? true);
7
+ const column = (camel, snake) => {
8
+ if (typeof raw === 'boolean')
9
+ return raw;
10
+ if (raw && typeof raw === 'object') {
11
+ const map = raw;
12
+ if (typeof map[camel] === 'boolean')
13
+ return map[camel];
14
+ if (typeof map[snake] === 'boolean')
15
+ return map[snake];
16
+ return false;
17
+ }
18
+ return true;
19
+ };
20
+ const createdAt = column('createdAt', 'created_at');
21
+ const updatedAt = column('updatedAt', 'updated_at');
22
+ return { createdAt, updatedAt, any: createdAt || updatedAt };
23
+ }