@omnifyjp/omnify 3.7.2 → 3.9.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnifyjp/omnify",
3
- "version": "3.7.2",
3
+ "version": "3.9.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": "3.7.2",
40
- "@omnifyjp/omnify-darwin-x64": "3.7.2",
41
- "@omnifyjp/omnify-linux-x64": "3.7.2",
42
- "@omnifyjp/omnify-linux-arm64": "3.7.2",
43
- "@omnifyjp/omnify-win32-x64": "3.7.2"
39
+ "@omnifyjp/omnify-darwin-arm64": "3.9.0",
40
+ "@omnifyjp/omnify-darwin-x64": "3.9.0",
41
+ "@omnifyjp/omnify-linux-x64": "3.9.0",
42
+ "@omnifyjp/omnify-linux-arm64": "3.9.0",
43
+ "@omnifyjp/omnify-win32-x64": "3.9.0"
44
44
  }
45
45
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Port of ModelGenerator.php — generates Eloquent model base + user classes.
3
3
  */
4
- import { toPascalCase, toSnakeCase, toCamelCase } from './naming-helper.js';
4
+ import { toPascalCase, toSnakeCase, toCamelCase, pluralize } from './naming-helper.js';
5
5
  import { toCast, toPhpDocType, isHiddenByDefault } from './type-mapper.js';
6
6
  import { buildRelation } from './relation-builder.js';
7
7
  import { baseFile, userFile, resolveModularBasePath, resolveModularBaseNamespace, resolveSharedBaseNamespace, resolveGlobalTraitNamespace, resolveGlobalEnumNamespace, } from './types.js';
@@ -490,6 +490,12 @@ function buildCasts(properties, expandedProperties, propertyOrder, reader, confi
490
490
  function buildRelations(schemaName, properties, propertyOrder, modelNamespace, reader) {
491
491
  const methods = [];
492
492
  const sourceTableName = reader.getTableName(schemaName);
493
+ // Track method names already declared on this model so reverse-relation
494
+ // inference (#39) can skip any name the user has explicitly defined.
495
+ const declaredMethods = new Set();
496
+ // Track explicit OneToMany/HasOne declarations by `<targetSchema>.<mappedBy>`
497
+ // so the auto-inverse pass can skip child relations the user already covered.
498
+ const explicitInverses = new Set();
493
499
  for (const propName of propertyOrder) {
494
500
  const prop = properties[propName];
495
501
  if (!prop || prop['type'] !== 'Association')
@@ -501,6 +507,78 @@ function buildRelations(schemaName, properties, propertyOrder, modelNamespace, r
501
507
  const result = buildRelation(propName, prop, ns, { sourceTableName, targetTableName });
502
508
  if (result) {
503
509
  methods.push('\n' + result.method);
510
+ declaredMethods.add(toCamelCase(propName));
511
+ const relation = prop['relation'] ?? '';
512
+ const mappedBy = prop['mappedBy'] ?? '';
513
+ if ((relation === 'OneToMany' || relation === 'OneToOne') && target && mappedBy) {
514
+ explicitInverses.add(`${target}.${mappedBy}`);
515
+ }
516
+ }
517
+ }
518
+ // Auto-generate reverse HasMany/HasOne for any child schema that declares a
519
+ // ManyToOne/OneToOne pointing at this schema, unless the user has already
520
+ // declared a relation method with the same name. Issue #39.
521
+ const inverseRelations = buildInverseRelations(schemaName, modelNamespace, reader, declaredMethods, explicitInverses);
522
+ if (inverseRelations) {
523
+ methods.push(inverseRelations);
524
+ }
525
+ return methods.join('\n');
526
+ }
527
+ /**
528
+ * Discover child schemas that point at `parentName` via ManyToOne/OneToOne and
529
+ * generate the corresponding HasMany/HasOne accessor on the parent. Skips any
530
+ * inverse relation whose derived method name collides with a user-declared
531
+ * relation. Issue #39.
532
+ */
533
+ function buildInverseRelations(parentName, modelNamespace, reader, declaredMethods, explicitInverses) {
534
+ const methods = [];
535
+ const allSchemas = reader.getSchemas();
536
+ // Stable iteration order — sort child names so output is deterministic
537
+ // regardless of how schemas were loaded into the map.
538
+ const childNames = Object.keys(allSchemas).sort();
539
+ for (const childName of childNames) {
540
+ if (childName === parentName)
541
+ continue;
542
+ const childSchema = allSchemas[childName];
543
+ if (!childSchema)
544
+ continue;
545
+ const childProps = (childSchema.properties ?? {});
546
+ for (const [childPropName, childProp] of Object.entries(childProps)) {
547
+ if (childProp['type'] !== 'Association')
548
+ continue;
549
+ const relation = childProp['relation'] ?? '';
550
+ if (relation !== 'ManyToOne' && relation !== 'OneToOne')
551
+ continue;
552
+ if (childProp['target'] !== parentName)
553
+ continue;
554
+ // Skip when an explicit OneToMany/HasOne on the parent already covers
555
+ // this child relation (matched by `<childSchema>.<childPropName>`).
556
+ if (explicitInverses.has(`${childName}.${childPropName}`))
557
+ continue;
558
+ const isOneToOne = relation === 'OneToOne';
559
+ const methodName = isOneToOne
560
+ ? toCamelCase(childName)
561
+ : toCamelCase(pluralize(childName));
562
+ if (declaredMethods.has(methodName))
563
+ continue;
564
+ declaredMethods.add(methodName);
565
+ // OneToOne with mappedBy is the reverse side already — skip; it would
566
+ // generate a child-side accessor, not a parent-side one.
567
+ // Here we're emitting the *parent* accessor, so the child's ManyToOne
568
+ // (or owning OneToOne) is what we react to.
569
+ const fkColumn = `${toSnakeCase(childPropName)}_id`;
570
+ const childNs = reader.resolveModelNamespace(childName, modelNamespace);
571
+ const fqcn = `\\${childNs}\\${childName}`;
572
+ const returnType = isOneToOne ? 'HasOne' : 'HasMany';
573
+ const eloquentMethod = isOneToOne ? 'hasOne' : 'hasMany';
574
+ methods.push(`
575
+ /**
576
+ * Auto-generated inverse of ${childName}.${childPropName} (${relation}).
577
+ */
578
+ public function ${methodName}(): ${returnType}
579
+ {
580
+ return $this->${eloquentMethod}(${fqcn}::class, '${fkColumn}');
581
+ }`);
504
582
  }
505
583
  }
506
584
  return methods.join('\n');
@@ -599,12 +677,12 @@ function buildNestedSetParentIdMethod(parentColumn) {
599
677
  function buildAuditSection(options, reader, modelNamespace) {
600
678
  const opts = options;
601
679
  const audit = opts?.['audit'];
602
- if (!audit)
603
- return '';
604
- const hasCreatedBy = audit['createdBy'] ?? false;
605
- const hasUpdatedBy = audit['updatedBy'] ?? false;
606
- const hasDeletedBy = audit['deletedBy'] ?? false;
607
- if (!hasCreatedBy && !hasUpdatedBy && !hasDeletedBy)
680
+ const defaultOrder = opts?.['defaultOrder'];
681
+ const hasCreatedBy = audit?.['createdBy'] ?? false;
682
+ const hasUpdatedBy = audit?.['updatedBy'] ?? false;
683
+ const hasDeletedBy = audit?.['deletedBy'] ?? false;
684
+ const hasDefaultOrder = Array.isArray(defaultOrder) && defaultOrder.length > 0;
685
+ if (!hasCreatedBy && !hasUpdatedBy && !hasDeletedBy && !hasDefaultOrder)
608
686
  return '';
609
687
  // Get audit model name from schemas.json auditConfig
610
688
  const auditModel = reader.data?.auditConfig?.model ?? 'User';
@@ -612,6 +690,17 @@ function buildAuditSection(options, reader, modelNamespace) {
612
690
  const parts = [];
613
691
  // Model events (booted)
614
692
  const events = [];
693
+ // defaultOrder global scope (issue #40 phase 2). Bypass with
694
+ // ->withoutGlobalScope('defaultOrder') in queries that need a different sort.
695
+ if (hasDefaultOrder) {
696
+ const chain = defaultOrder
697
+ .map(o => ` ->orderBy('${o.column}', '${(o.direction ?? 'asc').toLowerCase()}')`)
698
+ .join('\n');
699
+ events.push(` static::addGlobalScope('defaultOrder', function ($query) {
700
+ $query
701
+ ${chain};
702
+ });`);
703
+ }
615
704
  if (hasCreatedBy) {
616
705
  events.push(` static::creating(function ($model) {
617
706
  if (auth()->check() && !$model->created_by_id) {
@@ -15,4 +15,9 @@ export declare function buildRelation(propName: string, property: Record<string,
15
15
  export declare function generatePivotTableName(sourceTable: string, targetTable: string): string;
16
16
  /** Get the return type hint for a relation. */
17
17
  export declare function getReturnType(relation: string): string;
18
+ /**
19
+ * Build a chained `->orderBy('col', 'dir')` PHP fragment from an OrderByList.
20
+ * Returns an empty string if no ordering is configured. Issue #40.
21
+ */
22
+ export declare function formatOrderByChain(orderBy: unknown): string;
18
23
  export {};
@@ -95,14 +95,33 @@ function hasMany(propName, property, target, ns) {
95
95
  const foreignKey = inverse ? `${toSnakeCase(inverse)}_id` : `${toSnakeCase(propName)}_id`;
96
96
  const methodName = toCamelCase(propName);
97
97
  const fqcn = `\\${ns}\\${target}`;
98
+ const orderChain = formatOrderByChain(property['orderBy']);
98
99
  return ` /**
99
100
  * Get the ${propName} for this model.
100
101
  */
101
102
  public function ${methodName}(): HasMany
102
103
  {
103
- return $this->hasMany(${fqcn}::class, '${foreignKey}');
104
+ return $this->hasMany(${fqcn}::class, '${foreignKey}')${orderChain};
104
105
  }`;
105
106
  }
107
+ /**
108
+ * Build a chained `->orderBy('col', 'dir')` PHP fragment from an OrderByList.
109
+ * Returns an empty string if no ordering is configured. Issue #40.
110
+ */
111
+ export function formatOrderByChain(orderBy) {
112
+ if (!Array.isArray(orderBy) || orderBy.length === 0)
113
+ return '';
114
+ return orderBy
115
+ .map(item => {
116
+ const col = item.column ?? '';
117
+ if (!col)
118
+ return '';
119
+ const dir = (item.direction ?? 'asc').toLowerCase();
120
+ return `\n ->orderBy('${col}', '${dir}')`;
121
+ })
122
+ .filter(Boolean)
123
+ .join('');
124
+ }
106
125
  function belongsToMany(propName, property, target, ns, context) {
107
126
  let joinTable = property['joinTable'] ?? '';
108
127
  if (!joinTable && context?.sourceTableName && context?.targetTableName) {
@@ -118,6 +137,7 @@ function belongsToMany(propName, property, target, ns, context) {
118
137
  chain += `\n ->withPivot(${fields})`;
119
138
  }
120
139
  chain += `\n ->withTimestamps()`;
140
+ chain += formatOrderByChain(property['orderBy']);
121
141
  return ` /**
122
142
  * The ${propName} that belong to this model.
123
143
  */
@@ -153,12 +173,13 @@ function morphMany(propName, property, target, ns) {
153
173
  const morphName = property['morphName'] ?? toSnakeCase(propName);
154
174
  const methodName = toCamelCase(propName);
155
175
  const fqcn = `\\${ns}\\${target}`;
176
+ const orderChain = formatOrderByChain(property['orderBy']);
156
177
  return ` /**
157
178
  * Get the ${propName} for this model.
158
179
  */
159
180
  public function ${methodName}(): MorphMany
160
181
  {
161
- return $this->morphMany(${fqcn}::class, '${morphName}');
182
+ return $this->morphMany(${fqcn}::class, '${morphName}')${orderChain};
162
183
  }`;
163
184
  }
164
185
  function morphToMany(propName, property, target, ns, context) {
@@ -123,6 +123,8 @@ export interface SchemaOptions {
123
123
  readonly updatedBy?: boolean;
124
124
  readonly deletedBy?: boolean;
125
125
  };
126
+ /** Schema-level default ordering — generates a global Eloquent scope. Issue #40. */
127
+ readonly defaultOrder?: readonly OrderByItem[];
126
128
  }
127
129
  /** Schema definition from schemas.json. */
128
130
  export interface SchemaDefinition {
@@ -233,12 +235,18 @@ export interface PropertyDefinition {
233
235
  readonly morphName?: string;
234
236
  readonly joinTable?: string;
235
237
  readonly mappedBy?: string;
238
+ readonly orderBy?: readonly OrderByItem[];
236
239
  readonly useCurrent?: boolean;
237
240
  readonly searchable?: boolean;
238
241
  readonly filterable?: boolean;
239
242
  readonly sortable?: boolean;
240
243
  readonly fields?: Record<string, FieldOverride>;
241
244
  }
245
+ /** Single column ordering. Direction defaults to 'asc'. Issue #40. */
246
+ export interface OrderByItem {
247
+ readonly column: string;
248
+ readonly direction?: string;
249
+ }
242
250
  /** Field override for compound type properties. */
243
251
  export interface FieldOverride {
244
252
  readonly nullable?: boolean;