@omnifyjp/omnify 5.0.0 → 5.0.2

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.0.0",
3
+ "version": "5.0.2",
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.0.0",
40
- "@omnifyjp/omnify-darwin-x64": "5.0.0",
41
- "@omnifyjp/omnify-linux-x64": "5.0.0",
42
- "@omnifyjp/omnify-linux-arm64": "5.0.0",
43
- "@omnifyjp/omnify-win32-x64": "5.0.0"
39
+ "@omnifyjp/omnify-darwin-arm64": "5.0.2",
40
+ "@omnifyjp/omnify-darwin-x64": "5.0.2",
41
+ "@omnifyjp/omnify-linux-x64": "5.0.2",
42
+ "@omnifyjp/omnify-linux-arm64": "5.0.2",
43
+ "@omnifyjp/omnify-win32-x64": "5.0.2"
44
44
  }
45
45
  }
@@ -504,7 +504,21 @@ function buildRelations(schemaName, properties, propertyOrder, modelNamespace, r
504
504
  const target = prop['target'] ?? '';
505
505
  const ns = target ? reader.resolveModelNamespace(target, modelNamespace) : modelNamespace;
506
506
  const targetTableName = target ? reader.getTableName(target) : '';
507
- const result = buildRelation(propName, prop, ns, { sourceTableName, targetTableName });
507
+ // For ManyToMany, look up the explicit `kind: pivot` schema (the
508
+ // sorted PascalCase concat of source + target — auto-created on
509
+ // first generate) and pass its property names so belongsToMany
510
+ // emits `->withPivot('col1', 'col2')`. Without this, custom pivot
511
+ // columns the user added are unreachable through the relation.
512
+ const relation = prop['relation'] ?? '';
513
+ let pivotProperties;
514
+ if (relation === 'ManyToMany' && target) {
515
+ const pivotName = [schemaName, target].sort().join('');
516
+ const pivotSchema = reader.getSchema(pivotName);
517
+ if (pivotSchema?.properties) {
518
+ pivotProperties = Object.keys(pivotSchema.properties).map(toSnakeCase);
519
+ }
520
+ }
521
+ const result = buildRelation(propName, prop, ns, { sourceTableName, targetTableName, pivotProperties });
508
522
  if (result) {
509
523
  methods.push('\n' + result.method);
510
524
  declaredMethods.add(toCamelCase(propName));
@@ -8,6 +8,12 @@ interface RelationResult {
8
8
  interface RelationContext {
9
9
  sourceTableName: string;
10
10
  targetTableName: string;
11
+ /** Property names declared on the explicit `kind: pivot` schema for
12
+ * this M2M (e.g. `["position", "notes"]`). Surfaced in the generated
13
+ * `belongsToMany()` chain via `->withPivot(...)` so consumers can
14
+ * read those columns through the relation. Empty / undefined when
15
+ * the user has not added custom columns to the pivot schema. */
16
+ pivotProperties?: string[];
11
17
  }
12
18
  /** Build Eloquent relation method PHP code. */
13
19
  export declare function buildRelation(propName: string, property: Record<string, unknown>, modelNamespace: string, context?: RelationContext): RelationResult | null;
@@ -128,12 +128,15 @@ function belongsToMany(propName, property, target, ns, context) {
128
128
  joinTable = generatePivotTableName(context.sourceTableName, context.targetTableName);
129
129
  }
130
130
  const methodName = toCamelCase(propName);
131
- const pivotFields = property['pivotFields'] ?? {};
132
131
  const fqcn = `\\${ns}\\${target}`;
133
132
  let chain = `$this->belongsToMany(${fqcn}::class, '${joinTable}')`;
134
- const fieldKeys = Object.keys(pivotFields);
135
- if (fieldKeys.length > 0) {
136
- const fields = fieldKeys.map(k => `'${toSnakeCase(k)}'`).join(', ');
133
+ // withPivot for every column declared on the explicit `kind: pivot`
134
+ // schema (resolved upstream and passed via context.pivotProperties).
135
+ // The legacy `pivotFields:` block on the M2M property was removed in
136
+ // v5.0.0; pivot columns now live on the pivot schema itself.
137
+ const pivotCols = context?.pivotProperties ?? [];
138
+ if (pivotCols.length > 0) {
139
+ const fields = pivotCols.map(k => `'${toSnakeCase(k)}'`).join(', ');
137
140
  chain += `\n ->withPivot(${fields})`;
138
141
  }
139
142
  chain += `\n ->withTimestamps()`;
@@ -6,6 +6,9 @@ export declare class SchemaReader {
6
6
  private data;
7
7
  constructor(data: SchemasJson);
8
8
  static fromData(data: SchemasJson): SchemaReader;
9
+ /** Returns the raw root document — needed by generators that read
10
+ * top-level config like locale.enforceLanguageFk. */
11
+ getRoot(): SchemasJson;
9
12
  getSchemas(): Record<string, SchemaDefinition>;
10
13
  getSchema(name: string): SchemaDefinition | undefined;
11
14
  getObjectSchemas(): Record<string, SchemaDefinition>;
@@ -41,6 +44,7 @@ export declare class SchemaReader {
41
44
  readonly locales: string[];
42
45
  readonly defaultLocale: string;
43
46
  readonly fallbackLocale: string;
47
+ readonly enforceLanguageFk?: boolean;
44
48
  };
45
49
  getLocales(): string[];
46
50
  getDefaultLocale(): string;
@@ -72,4 +76,15 @@ export declare class SchemaReader {
72
76
  hasServiceSchemas(): boolean;
73
77
  /** Get translatable field names (snake_case) for a schema. */
74
78
  getTranslatableFields(schemaName: string): string[];
79
+ /**
80
+ * Get translatable fields with type info (snake_case name + the
81
+ * declared property type). Used by translation-model-generator to
82
+ * emit `$casts` for non-string types — without these the model
83
+ * returns raw strings for Json / Boolean / Int columns and Eloquent
84
+ * accessors break at runtime.
85
+ */
86
+ getTranslatableFieldDetails(schemaName: string): Array<{
87
+ name: string;
88
+ type: string;
89
+ }>;
75
90
  }
@@ -9,6 +9,11 @@ export class SchemaReader {
9
9
  static fromData(data) {
10
10
  return new SchemaReader(data);
11
11
  }
12
+ /** Returns the raw root document — needed by generators that read
13
+ * top-level config like locale.enforceLanguageFk. */
14
+ getRoot() {
15
+ return this.data;
16
+ }
12
17
  // ---------------------------------------------------------------------------
13
18
  // All schemas (project + package)
14
19
  // ---------------------------------------------------------------------------
@@ -225,6 +230,16 @@ export class SchemaReader {
225
230
  }
226
231
  /** Get translatable field names (snake_case) for a schema. */
227
232
  getTranslatableFields(schemaName) {
233
+ return this.getTranslatableFieldDetails(schemaName).map((f) => f.name);
234
+ }
235
+ /**
236
+ * Get translatable fields with type info (snake_case name + the
237
+ * declared property type). Used by translation-model-generator to
238
+ * emit `$casts` for non-string types — without these the model
239
+ * returns raw strings for Json / Boolean / Int columns and Eloquent
240
+ * accessors break at runtime.
241
+ */
242
+ getTranslatableFieldDetails(schemaName) {
228
243
  const schema = this.getSchema(schemaName);
229
244
  if (!schema?.properties)
230
245
  return [];
@@ -233,8 +248,8 @@ export class SchemaReader {
233
248
  for (const propName of propertyOrder) {
234
249
  const prop = schema.properties[propName];
235
250
  if (prop?.translatable) {
236
- // Convert to snake_case
237
- fields.push(propName.replace(/([A-Z])/g, (_, c, i) => (i > 0 ? '_' : '') + c.toLowerCase()));
251
+ const snake = propName.replace(/([A-Z])/g, (_, c, i) => (i > 0 ? '_' : '') + c.toLowerCase());
252
+ fields.push({ name: snake, type: prop.type ?? 'String' });
238
253
  }
239
254
  }
240
255
  return fields;
@@ -12,23 +12,77 @@ import { baseFile, userFile, resolveModularBasePath, resolveModularBaseNamespace
12
12
  /** Generate translation model files for all project schemas with translatable fields. */
13
13
  export function generateTranslationModels(reader, config) {
14
14
  const files = [];
15
+ const enforceLanguageFk = reader.getRoot().locale?.enforceLanguageFk === true;
15
16
  for (const [name] of Object.entries(reader.getProjectObjectSchemas())) {
16
- const translatableFields = reader.getTranslatableFields(name);
17
+ const translatableFields = reader.getTranslatableFieldDetails(name);
17
18
  if (translatableFields.length === 0)
18
19
  continue;
19
- files.push(generateTranslationBaseModel(name, translatableFields, config));
20
+ files.push(generateTranslationBaseModel(name, translatableFields, enforceLanguageFk, config));
20
21
  files.push(generateTranslationUserModel(name, config));
21
22
  }
22
23
  return files;
23
24
  }
24
- function generateTranslationBaseModel(name, translatableFields, config) {
25
+ /**
26
+ * Map an omnify property type to its Eloquent `$casts` value. Returns
27
+ * `undefined` for plain string types (no cast needed). Without these
28
+ * casts, fetching a translation row returns the raw string from the
29
+ * DB for Json / Boolean / Int / DateTime columns and any consumer code
30
+ * that assumed a typed value (e.g. `$translation->metadata['key']`)
31
+ * fails at runtime.
32
+ */
33
+ function castFor(type) {
34
+ switch (type) {
35
+ case 'Json':
36
+ return 'array';
37
+ case 'Boolean':
38
+ return 'boolean';
39
+ case 'Int':
40
+ case 'TinyInt':
41
+ case 'BigInt':
42
+ return 'integer';
43
+ case 'Float':
44
+ return 'float';
45
+ case 'Decimal':
46
+ return 'decimal:2';
47
+ case 'Date':
48
+ return 'date';
49
+ case 'DateTime':
50
+ case 'Timestamp':
51
+ return 'datetime';
52
+ default:
53
+ return undefined;
54
+ }
55
+ }
56
+ function generateTranslationBaseModel(name, translatableFields, enforceLanguageFk, config) {
25
57
  const modelName = toPascalCase(name);
26
58
  const modelSnake = toSnakeCase(name);
27
59
  const baseNamespace = resolveModularBaseNamespace(config, name, 'Models', config.models.baseNamespace);
28
60
  const tableName = `${modelSnake}_translations`;
29
- const fillableLines = translatableFields
30
- .map(f => ` '${f}',`)
31
- .join('\n');
61
+ // Fillable: every translatable column + the optional `language_id`
62
+ // FK column (added by the migration when locale.enforceLanguageFk is
63
+ // on). Without including `language_id` in $fillable, Eloquent mass
64
+ // assignment via Astrotomic silently drops the value and the FK is
65
+ // never populated even though the column exists.
66
+ const fillableNames = translatableFields.map((f) => f.name);
67
+ if (enforceLanguageFk)
68
+ fillableNames.push('language_id');
69
+ const fillableLines = fillableNames.map((f) => ` '${f}',`).join('\n');
70
+ // Casts: only emit for fields whose type has a non-string Eloquent
71
+ // representation. Plain String / Text / Email / EnumRef stay as-is.
72
+ const castEntries = [];
73
+ for (const f of translatableFields) {
74
+ const c = castFor(f.type);
75
+ if (c)
76
+ castEntries.push(` '${f.name}' => '${c}',`);
77
+ }
78
+ const castsBlock = castEntries.length === 0 ? '' : `
79
+
80
+ /**
81
+ * The attributes that should be cast to native types.
82
+ */
83
+ protected $casts = [
84
+ ${castEntries.join('\n')}
85
+ ];`;
32
86
  const content = `<?php
33
87
 
34
88
  namespace ${baseNamespace};
@@ -62,7 +116,7 @@ class ${modelName}TranslationBaseModel extends Model
62
116
  */
63
117
  protected $fillable = [
64
118
  ${fillableLines}
65
- ];
119
+ ];${castsBlock}
66
120
  }
67
121
  `;
68
122
  return baseFile(resolveModularBasePath(config, name, 'Models', `${modelName}TranslationBaseModel.php`, config.models.basePath), content);
@@ -31,6 +31,7 @@ export interface SchemasJson {
31
31
  readonly locales: string[];
32
32
  readonly defaultLocale: string;
33
33
  readonly fallbackLocale: string;
34
+ readonly enforceLanguageFk?: boolean;
34
35
  };
35
36
  readonly customTypes: {
36
37
  readonly compound: Record<string, CompoundTypeDefinition>;
package/types/schema.d.ts CHANGED
@@ -85,7 +85,16 @@ export interface CompoundFieldOverride {
85
85
  placeholder?: LocalizedString;
86
86
  }
87
87
 
88
- /** Field definition on a pivot table. */
88
+ /**
89
+ * Field definition on a pivot table.
90
+ *
91
+ * @deprecated Removed in v5.0.0 — `pivotFields:` on a M2M association
92
+ * is rejected by validation. Use an explicit `kind: pivot` schema
93
+ * (auto-created by `omnify generate`) and put extra columns under its
94
+ * `properties:` block. The interface is retained for IDE auto-complete
95
+ * compatibility on legacy YAML so users see the deprecation in their
96
+ * editor, then get the actionable hard error from the CLI.
97
+ */
89
98
  export interface PivotFieldDefinition {
90
99
  type: BuiltInPropertyType;
91
100
  nullable?: boolean;
@@ -239,7 +248,14 @@ export interface PropertyDefinition {
239
248
  onUpdate?: ReferentialAction;
240
249
  owning?: boolean;
241
250
  joinTable?: string;
251
+ /** @deprecated Removed in v5.0.0 — declare extra pivot columns on the
252
+ * explicit `kind: pivot` schema (auto-created by `omnify generate`)
253
+ * instead. Validation rejects YAML that still uses this field. */
242
254
  pivotFields?: Record<string, PivotFieldDefinition>;
255
+ /** @deprecated Removed in v5.0.0 — set `options.id` on the explicit
256
+ * `kind: pivot` schema instead. Validation rejects YAML that still
257
+ * uses this field. */
258
+ pivotId?: IdType;
243
259
  targetNamespace?: string;
244
260
  idType?: IdType;
245
261
  }