Skip to content
This repository was archived by the owner on Mar 1, 2026. It is now read-only.

Commit d03c2bc

Browse files
committed
feat(cli): improve enum syncing and relation naming during pull
1 parent 3f0b6f2 commit d03c2bc

1 file changed

Lines changed: 24 additions & 88 deletions

File tree

packages/cli/src/actions/pull/index.ts

Lines changed: 24 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,12 @@ import {
1313
DataModelFactory,
1414
EnumFactory,
1515
} from '@zenstackhq/language/factory';
16+
import { AstUtils, type Reference, type AstNode, type CstNode } from 'langium';
17+
import { lowerCaseFirst } from '@zenstackhq/common-helpers';
1618
import type { PullOptions } from '../db';
1719
import type { Cascade, IntrospectedEnum, IntrospectedTable, IntrospectionProvider } from './provider';
1820
import { getAttributeRef, getDbName, getEnumRef } from './utils';
21+
import { resolveNameCasing } from './casing';
1922
import { CliError } from '../../cli-error';
2023

2124
export function syncEnums({
@@ -73,81 +76,22 @@ export function syncEnums({
7376
model.declarations.push(factory.get({ $container: model }));
7477
}
7578
} else {
79+
// For providers that don't support native enums (e.g., SQLite), carry over
80+
// enum declarations from the existing schema as-is by deep-cloning the AST nodes.
81+
// A dummy buildReference is used since we don't need cross-reference resolution.
82+
const dummyBuildReference = (_node: AstNode, _property: string, _refNode: CstNode | undefined, refText: string): Reference<AstNode> =>
83+
({ $refText: refText }) as Reference<AstNode>;
84+
7685
oldModel.declarations
7786
.filter((d) => isEnum(d))
7887
.forEach((d) => {
79-
const factory = new EnumFactory().setName(d.name);
80-
// Copy enum-level comments
81-
if (d.comments?.length) {
82-
factory.update({ comments: [...d.comments] });
83-
}
84-
// Copy enum-level attributes (@@map, @@schema, etc.)
85-
// Re-parent attributes to the new factory node
86-
if (d.attributes?.length) {
87-
const reparentedAttrs = d.attributes.map((attr) => ({ ...attr, $container: factory.node }));
88-
factory.update({ attributes: reparentedAttrs });
89-
}
90-
// Copy fields with their attributes and comments
91-
d.fields.forEach((v) => {
92-
factory.addField((builder) => {
93-
builder.setName(v.name);
94-
// Copy field-level comments
95-
if (v.comments?.length) {
96-
v.comments.forEach((c) => {
97-
builder.addComment(c);
98-
});
99-
}
100-
// Copy field-level attributes (@map, etc.)
101-
// Re-parent attributes to the new builder node
102-
if (v.attributes?.length) {
103-
const reparentedAttrs = v.attributes.map((attr) => ({ ...attr, $container: builder.node }));
104-
builder.update({ attributes: reparentedAttrs });
105-
}
106-
return builder;
107-
});
108-
});
109-
model.declarations.push(factory.get({ $container: model }));
88+
const copy = AstUtils.copyAstNode(d, dummyBuildReference);
89+
(copy as { $container: unknown }).$container = model;
90+
model.declarations.push(copy);
11091
});
11192
}
11293
}
11394

114-
function resolveNameCasing(casing: 'pascal' | 'camel' | 'snake' | 'none', originalName: string) {
115-
let name = originalName;
116-
const fieldPrefix = /[0-9]/g.test(name.charAt(0)) ? '_' : '';
117-
118-
switch (casing) {
119-
case 'pascal':
120-
name = toPascalCase(originalName);
121-
break;
122-
case 'camel':
123-
name = toCamelCase(originalName);
124-
break;
125-
case 'snake':
126-
name = toSnakeCase(originalName);
127-
break;
128-
}
129-
130-
return {
131-
modified: name !== originalName || fieldPrefix !== '',
132-
name: `${fieldPrefix}${name}`,
133-
};
134-
}
135-
136-
function toPascalCase(str: string): string {
137-
return str.replace(/[_\- ]+(\w)/g, (_, c) => c.toUpperCase()).replace(/^\w/, (c) => c.toUpperCase());
138-
}
139-
140-
function toCamelCase(str: string): string {
141-
return str.replace(/[_\- ]+(\w)/g, (_, c) => c.toUpperCase()).replace(/^\w/, (c) => c.toLowerCase());
142-
}
143-
144-
function toSnakeCase(str: string): string {
145-
return str
146-
.replace(/[- ]+/g, '_')
147-
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
148-
.toLowerCase();
149-
}
150-
15195
export type Relation = {
15296
schema: string;
15397
table: string;
@@ -185,24 +129,10 @@ export function syncTable({
185129
const modelIdAttribute = getAttributeRef('@@id', services);
186130
const uniqueAttribute = getAttributeRef('@unique', services);
187131
const modelUniqueAttribute = getAttributeRef('@@unique', services);
188-
const relationAttribute = getAttributeRef('@relation', services);
189132
const fieldMapAttribute = getAttributeRef('@map', services);
190133
const tableMapAttribute = getAttributeRef('@@map', services);
191134
const modelindexAttribute = getAttributeRef('@@index', services);
192135

193-
if (
194-
!idAttribute ||
195-
!uniqueAttribute ||
196-
!relationAttribute ||
197-
!fieldMapAttribute ||
198-
!tableMapAttribute ||
199-
!modelIdAttribute ||
200-
!modelUniqueAttribute ||
201-
!modelindexAttribute
202-
) {
203-
throw new CliError('Cannot find required attributes in the model.');
204-
}
205-
206136
const relations: Relation[] = [];
207137
const { name, modified } = resolveNameCasing(options.modelCasing, table.name);
208138
const multiPk = table.columns.filter((c) => c.pk).length > 1;
@@ -483,19 +413,25 @@ export function syncRelation({
483413

484414
const relationName = `${relation.table}${similarRelations > 0 ? `_${relation.column}` : ''}To${relation.references.table}`;
485415

486-
const sourceNameFromReference = sourceField.name.toLowerCase().endsWith('id') ? `${resolveNameCasing("camel", sourceField.name.slice(0, -2)).name}${relation.type === 'many'? 's' : ''}` : undefined;
416+
// Derive a relation field name from the FK scalar field: if the field ends with "Id",
417+
// strip the suffix and use the remainder (e.g., "authorId" -> "author").
418+
const sourceNameFromReference = sourceField.name.toLowerCase().endsWith('id') ? `${resolveNameCasing(options.fieldCasing, sourceField.name.slice(0, -2)).name}${relation.type === 'many'? 's' : ''}` : undefined;
487419

420+
// Check if the derived name would clash with an existing field
488421
const sourceFieldFromReference = sourceModel.fields.find((f) => f.name === sourceNameFromReference);
489422

423+
// Determine the relation field name:
424+
// - For ambiguous relations (multiple FKs to the same table), include the source column for disambiguation.
425+
// - Otherwise, prefer the name derived from the FK field (if no clash), falling back to the target model name.
490426
let { name: sourceFieldName } = resolveNameCasing(
491427
options.fieldCasing,
492428
similarRelations > 0
493-
? `${fieldPrefix}${sourceModel.name.charAt(0).toLowerCase()}${sourceModel.name.slice(1)}_${relation.column}`
494-
: `${(!sourceFieldFromReference? sourceNameFromReference : undefined) || resolveNameCasing("camel", targetModel.name).name}${relation.type === 'many'? 's' : ''}`,
429+
? `${fieldPrefix}${lowerCaseFirst(sourceModel.name)}_${relation.column}`
430+
: `${(!sourceFieldFromReference? sourceNameFromReference : undefined) || lowerCaseFirst(resolveNameCasing(options.fieldCasing, targetModel.name).name)}${relation.type === 'many'? 's' : ''}`,
495431
);
496432

497433
if (sourceModel.fields.find((f) => f.name === sourceFieldName)) {
498-
sourceFieldName = `${sourceFieldName}To${targetModel.name.charAt(0).toLowerCase()}${targetModel.name.slice(1)}_${relation.references.column}`;
434+
sourceFieldName = `${sourceFieldName}To${lowerCaseFirst(targetModel.name)}_${relation.references.column}`;
499435
}
500436

501437
const sourceFieldFactory = new DataFieldFactory()
@@ -549,8 +485,8 @@ export function syncRelation({
549485
const { name: oppositeFieldName } = resolveNameCasing(
550486
options.fieldCasing,
551487
similarRelations > 0
552-
? `${oppositeFieldPrefix}${sourceModel.name.charAt(0).toLowerCase()}${sourceModel.name.slice(1)}_${relation.column}`
553-
: `${resolveNameCasing("camel", sourceModel.name).name}${relation.references.type === 'many'? 's' : ''}`,
488+
? `${oppositeFieldPrefix}${lowerCaseFirst(sourceModel.name)}_${relation.column}`
489+
: `${lowerCaseFirst(resolveNameCasing(options.fieldCasing, sourceModel.name).name)}${relation.references.type === 'many'? 's' : ''}`,
554490
);
555491

556492
const targetFieldFactory = new DataFieldFactory()

0 commit comments

Comments
 (0)