Skip to content

Commit 1cfdc0b

Browse files
committed
fix(cli): add missing opposite relation fields during db pull when multiple FKs target the same model
Back-reference relation fields (the opposite side of a relation, with @relation but no `fields` arg) were silently skipped during the merge phase of `db pull` when no matching field existed in the original schema. This caused models like `Users` that are referenced by many tables (e.g., via `user_created`/`user_updated` FKs) to be missing their back-reference fields after pulling. The fix adds relation-name-based matching as a new step in the field matching algorithm, and removes the blanket early-skip that discarded all unmatched back-references. Named back-references that don't match any existing field are now correctly added as new fields.
1 parent 08c11e7 commit 1cfdc0b

3 files changed

Lines changed: 114 additions & 11 deletions

File tree

packages/cli/src/actions/db.ts

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
} from './action-utils';
1515
import { consolidateEnums, syncEnums, syncRelation, syncTable, type Relation } from './pull';
1616
import { providers as pullProviders } from './pull/provider';
17-
import { getDatasource, getDbName, getRelationFieldsKey, getRelationFkName, isDatabaseManagedAttribute } from './pull/utils';
17+
import { getDatasource, getDbName, getRelationFieldsKey, getRelationFkName, getRelationName, isDatabaseManagedAttribute } from './pull/utils';
1818
import type { DataSourceProviderType } from '@zenstackhq/schema';
1919
import { CliError } from '../cli-error';
2020

@@ -283,16 +283,9 @@ async function runPull(options: PullOptions) {
283283
}
284284

285285
newDataModel.fields.forEach((f) => {
286-
// Prioritized matching: exact db name > relation fields key > relation FK name > type reference
286+
// Prioritized matching: exact db name > relation fields key > relation FK name > relation name > type reference
287287
let originalFields = originalDataModel.fields.filter((d) => getDbName(d) === getDbName(f));
288288

289-
// If this is a back-reference relation field (has @relation but no `fields` arg), silently skip
290-
const isRelationField =
291-
f.$type === 'DataField' && !!(f as any).attributes?.some((a: any) => a?.decl?.ref?.name === '@relation');
292-
if (originalFields.length === 0 && isRelationField && !getRelationFieldsKey(f as any)) {
293-
return;
294-
}
295-
296289
if (originalFields.length === 0) {
297290
// Try matching by relation fields key (the `fields` attribute in @relation)
298291
// This matches relation fields by their FK field references
@@ -314,11 +307,21 @@ async function runPull(options: PullOptions) {
314307
);
315308
}
316309

310+
if (originalFields.length === 0) {
311+
// Try matching by relation name (the first positional arg in @relation)
312+
// This is essential for back-reference fields that only have a relation name
313+
const newRelName = getRelationName(f as any);
314+
if (newRelName) {
315+
originalFields = originalDataModel.fields.filter(
316+
(d) => d.$type === 'DataField' && getRelationName(d as any) === newRelName,
317+
);
318+
}
319+
}
320+
317321
if (originalFields.length === 0) {
318322
// Try matching by type reference
319323
// We need this because for relations that don't have @relation, we can only check if the original exists by the field type.
320324
// Yes, in this case it can potentially result in multiple original fields, but we only want to ensure that at least one relation exists.
321-
// In the future, we might implement some logic to detect how many of these types of relations we need and add/remove fields based on this.
322325
originalFields = originalDataModel.fields.filter(
323326
(d) =>
324327
f.$type === 'DataField' &&
@@ -499,7 +502,7 @@ async function runPull(options: PullOptions) {
499502
});
500503
originalDataModel.fields
501504
.filter((f) => {
502-
// Prioritized matching: exact db name > relation fields key > relation FK name > type reference
505+
// Prioritized matching: exact db name > relation fields key > relation FK name > relation name > type reference
503506
const matchByDbName = newDataModel.fields.find((d) => getDbName(d) === getDbName(f));
504507
if (matchByDbName) return false;
505508

@@ -520,6 +523,15 @@ async function runPull(options: PullOptions) {
520523
);
521524
if (matchByFkName) return false;
522525

526+
// Try matching by relation name (for named back-reference fields)
527+
const originalRelName = getRelationName(f as any);
528+
if (originalRelName) {
529+
const matchByRelName = newDataModel.fields.find(
530+
(d) => d.$type === 'DataField' && getRelationName(d as any) === originalRelName,
531+
);
532+
if (matchByRelName) return false;
533+
}
534+
523535
const matchByTypeRef = newDataModel.fields.find(
524536
(d) =>
525537
f.$type === 'DataField' &&

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,20 @@ export function getRelationFkName(decl: DataField): string | undefined {
122122
return schemaAttrValue?.value;
123123
}
124124

125+
/**
126+
* Gets the relation name from the @relation attribute's first positional argument.
127+
* e.g., @relation('myRelation', fields: [...], references: [...]) -> "myRelation"
128+
* e.g., @relation(fields: [...], references: [...]) -> undefined
129+
* e.g., @relation('backRef') -> "backRef"
130+
*/
131+
export function getRelationName(decl: DataField): string | undefined {
132+
const relationAttr = decl?.attributes?.find((a) => a.decl?.ref?.name === '@relation');
133+
if (!relationAttr) return undefined;
134+
const firstPositionalArg = relationAttr.args.find((a) => !a.name);
135+
if (!firstPositionalArg || firstPositionalArg.value?.$type !== 'StringLiteral') return undefined;
136+
return (firstPositionalArg.value as StringLiteral).value;
137+
}
138+
125139
/**
126140
* Gets the FK field names from the @relation attribute's `fields` argument.
127141
* Returns a sorted, comma-separated string of field names for comparison.

packages/cli/test/db/pull.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,83 @@ model Tag {
152152
expect(restoredSchema).toEqual(schema);
153153
});
154154

155+
it('should restore opposite relation fields when multiple models have FKs to the same target', async () => {
156+
const { workDir, schema } = await createProject(
157+
`model Comment {
158+
id Int @id @default(autoincrement())
159+
text String
160+
commentCreatedBy User? @relation('Comment_createdByToUser', fields: [createdBy], references: [id])
161+
createdBy Int?
162+
commentUpdatedBy User? @relation('Comment_updatedByToUser', fields: [updatedBy], references: [id])
163+
updatedBy Int?
164+
}
165+
166+
model Post {
167+
id Int @id @default(autoincrement())
168+
title String
169+
postCreatedBy User? @relation('Post_createdByToUser', fields: [createdBy], references: [id])
170+
createdBy Int?
171+
postUpdatedBy User? @relation('Post_updatedByToUser', fields: [updatedBy], references: [id])
172+
updatedBy Int?
173+
}
174+
175+
model User {
176+
id Int @id @default(autoincrement())
177+
email String @unique
178+
commentCreatedBy Comment[] @relation('Comment_createdByToUser')
179+
commentUpdatedBy Comment[] @relation('Comment_updatedByToUser')
180+
postCreatedBy Post[] @relation('Post_createdByToUser')
181+
postUpdatedBy Post[] @relation('Post_updatedByToUser')
182+
}`,
183+
);
184+
runCli('db push', workDir);
185+
186+
const schemaFile = path.join(workDir, 'zenstack/schema.zmodel');
187+
188+
fs.writeFileSync(schemaFile, getDefaultPrelude());
189+
runCli('db pull --indent 4', workDir);
190+
191+
const restoredSchema = getSchema(workDir);
192+
expect(restoredSchema).toEqual(schema);
193+
});
194+
195+
it('should preserve opposite relation fields when multiple models have FKs to the same target', async () => {
196+
const { workDir, schema } = await createProject(
197+
`model Comment {
198+
id Int @id @default(autoincrement())
199+
text String
200+
commentCreatedBy User? @relation('Comment_createdByToUser', fields: [createdBy], references: [id])
201+
createdBy Int?
202+
commentUpdatedBy User? @relation('Comment_updatedByToUser', fields: [updatedBy], references: [id])
203+
updatedBy Int?
204+
}
205+
206+
model Post {
207+
id Int @id @default(autoincrement())
208+
title String
209+
postCreatedBy User? @relation('Post_createdByToUser', fields: [createdBy], references: [id])
210+
createdBy Int?
211+
postUpdatedBy User? @relation('Post_updatedByToUser', fields: [updatedBy], references: [id])
212+
updatedBy Int?
213+
}
214+
215+
model User {
216+
id Int @id @default(autoincrement())
217+
email String @unique
218+
commentCreatedBy Comment[] @relation('Comment_createdByToUser')
219+
commentUpdatedBy Comment[] @relation('Comment_updatedByToUser')
220+
postCreatedBy Post[] @relation('Post_createdByToUser')
221+
postUpdatedBy Post[] @relation('Post_updatedByToUser')
222+
}`,
223+
);
224+
runCli('db push', workDir);
225+
226+
runCli('db pull --indent 4', workDir);
227+
228+
const restoredSchema = getSchema(workDir);
229+
expect(restoredSchema).toEqual(schema);
230+
});
231+
155232
it('should restore one-to-one relation when FK is the single-column primary key', async () => {
156233
const { workDir, schema } = await createProject(
157234
`model Profile {

0 commit comments

Comments
 (0)