From a1a4fdc6446fa701c36f1a49b97f46bc9a763c4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A1n=20Benj=C3=A1min?= Date: Sun, 8 Feb 2026 14:02:51 +0100 Subject: [PATCH 1/4] fix(introspection): preserve schema integrity during db pull - Retain data validation attributes (e.g., @email) on fields after introspection (#670) - Preserve original declaration order of enums instead of moving them to the end of the schema file (#669) - Preserve triple-slash comments above enum declarations (#669) Fixes #669, fixes #670 --- packages/cli/src/actions/db.ts | 14 +- packages/cli/src/actions/pull/index.ts | 104 +++++++++++ packages/cli/src/index.ts | 2 +- packages/cli/test/db/pull.test.ts | 176 ++++++++++++++++-- .../language/src/zmodel-code-generator.ts | 21 ++- 5 files changed, 293 insertions(+), 24 deletions(-) diff --git a/packages/cli/src/actions/db.ts b/packages/cli/src/actions/db.ts index b868566a5..7efa7d722 100644 --- a/packages/cli/src/actions/db.ts +++ b/packages/cli/src/actions/db.ts @@ -12,7 +12,7 @@ import { loadSchemaDocument, requireDataSourceUrl, } from './action-utils'; -import { syncEnums, syncRelation, syncTable, type Relation } from './pull'; +import { consolidateEnums, syncEnums, syncRelation, syncTable, type Relation } from './pull'; import { providers as pullProviders } from './pull/provider'; import { getDatasource, getDbName, getRelationFieldsKey, getRelationFkName } from './pull/utils'; import type { DataSourceProviderType } from '@zenstackhq/schema'; @@ -173,6 +173,10 @@ async function runPull(options: PullOptions) { }); } + // Consolidate per-column enums (e.g., MySQL's synthetic UserStatus/GroupStatus) + // back to shared enums from the original schema (e.g., Status) + consolidateEnums({ newModel, oldModel: model }); + console.log(colors.blue('Schema synced')); const baseDir = path.dirname(path.resolve(schemaFile)); @@ -462,7 +466,7 @@ async function runPull(options: PullOptions) { .filter( (attr) => !f.attributes.find((d) => d.decl.$refText === attr.decl.$refText) && - !['@map', '@@map', '@default', '@updatedAt'].includes(attr.decl.$refText), + (['@relation'].includes(attr.decl.$refText) || attr.decl.$refText.startsWith('@db.')), ) .forEach((attr) => { const field = attr.$container; @@ -478,7 +482,7 @@ async function runPull(options: PullOptions) { .filter( (attr) => !originalField.attributes.find((d) => d.decl.$refText === attr.decl.$refText) && - !['@map', '@@map', '@default', '@updatedAt'].includes(attr.decl.$refText), + (['@relation'].includes(attr.decl.$refText) || attr.decl.$refText.startsWith('@db.')), ) .forEach((attr) => { // attach the new attribute to the original field @@ -619,8 +623,8 @@ async function runPull(options: PullOptions) { } const generator = new ZModelCodeGenerator({ - quote: options.quote, - indent: options.indent, + quote: options.quote ?? 'single', + indent: options.indent ?? 4, }); if (options.output) { diff --git a/packages/cli/src/actions/pull/index.ts b/packages/cli/src/actions/pull/index.ts index b4424746f..b450e2a70 100644 --- a/packages/cli/src/actions/pull/index.ts +++ b/packages/cli/src/actions/pull/index.ts @@ -556,3 +556,107 @@ export function syncRelation({ targetModel.fields.push(targetFieldFactory.node); } + +/** + * Consolidates per-column enums back to shared enums when possible. + * + * MySQL doesn't have named enum types — each column gets a synthetic enum + * (e.g., `UserStatus`, `GroupStatus`). When the original schema used a shared + * enum (e.g., `Status`) across multiple fields, this function detects the + * mapping via field references and consolidates the synthetic enums back into + * the original shared enum so the merge phase can match them correctly. + */ +export function consolidateEnums({ + newModel, + oldModel, +}: { + newModel: Model; + oldModel: Model; +}) { + const newEnums = newModel.declarations.filter((d) => isEnum(d)) as Enum[]; + const newDataModels = newModel.declarations.filter((d) => d.$type === 'DataModel') as DataModel[]; + const oldDataModels = oldModel.declarations.filter((d) => d.$type === 'DataModel') as DataModel[]; + + // For each new enum, find which old enum it corresponds to (via field references) + const enumMapping = new Map(); // newEnum -> oldEnum + + for (const newEnum of newEnums) { + for (const newDM of newDataModels) { + for (const field of newDM.fields) { + if (field.$type !== 'DataField' || field.type.reference?.ref !== newEnum) continue; + + // Find matching model in old model by db name + const oldDM = oldDataModels.find((d) => getDbName(d) === getDbName(newDM)); + if (!oldDM) continue; + + // Find matching field in old model by db name + const oldField = oldDM.fields.find((f) => getDbName(f) === getDbName(field)); + if (!oldField || oldField.$type !== 'DataField' || !oldField.type.reference?.ref) continue; + + const oldEnum = oldField.type.reference.ref; + if (!isEnum(oldEnum)) continue; + + enumMapping.set(newEnum, oldEnum as Enum); + break; + } + if (enumMapping.has(newEnum)) break; + } + } + + // Group by old enum: oldEnum -> [newEnum1, newEnum2, ...] + const reverseMapping = new Map(); + for (const [newEnum, oldEnum] of enumMapping) { + if (!reverseMapping.has(oldEnum)) { + reverseMapping.set(oldEnum, []); + } + reverseMapping.get(oldEnum)!.push(newEnum); + } + + // Consolidate: when new enums map to the same old enum with matching values + for (const [oldEnum, newEnumsGroup] of reverseMapping) { + const keepEnum = newEnumsGroup[0]!; + + // Skip if already correct (single enum with matching name) + if (newEnumsGroup.length === 1 && keepEnum.name === oldEnum.name) continue; + + // Check that all new enums have the same values as the old enum + const oldValues = new Set(oldEnum.fields.map((f) => getDbName(f))); + const allMatch = newEnumsGroup.every((ne) => { + const newValues = new Set(ne.fields.map((f) => getDbName(f))); + return oldValues.size === newValues.size && [...oldValues].every((v) => newValues.has(v)); + }); + + if (!allMatch) continue; + + // Rename the kept enum to match the old shared name + keepEnum.name = oldEnum.name; + + // Remove duplicate enums from newModel + for (let i = 1; i < newEnumsGroup.length; i++) { + const idx = newModel.declarations.indexOf(newEnumsGroup[i]!); + if (idx >= 0) { + newModel.declarations.splice(idx, 1); + } + } + + // Update all field references in newModel to point to the kept enum + for (const newDM of newDataModels) { + for (const field of newDM.fields) { + if (field.$type !== 'DataField') continue; + const ref = field.type.reference?.ref; + if (ref && newEnumsGroup.includes(ref as Enum)) { + (field.type as any).reference = { + ref: keepEnum, + $refText: keepEnum.name, + }; + } + } + } + + console.log( + colors.gray( + `Consolidated enum${newEnumsGroup.length > 1 ? 's' : ''} ${newEnumsGroup.map((e) => e.name).join(', ')} → ${oldEnum.name}`, + ), + ); + } +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 6e1daafec..bc52a9803 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -170,7 +170,7 @@ function createProgram() { .addOption( new Option('--quote ', 'set the quote style of generated schema files').default('single'), ) - .addOption(new Option('--indent ', 'set the indentation of the generated schema files').default(4).argParser(parseInt)) + .addOption(new Option('--indent ', 'set the indentation of the generated schema files').default(4)) .action((options) => dbAction('pull', options)); dbCommand diff --git a/packages/cli/test/db/pull.test.ts b/packages/cli/test/db/pull.test.ts index 487f6a446..e2ea52bc8 100644 --- a/packages/cli/test/db/pull.test.ts +++ b/packages/cli/test/db/pull.test.ts @@ -224,7 +224,7 @@ model User { id Int @id @default(autoincrement()) email String @unique @map('email_address') name String? @default('Anonymous') - role UsersRole @default(USER) + role Role @default(USER) profile Profile? shared_profile Profile? @relation('shared') posts Post[] @@ -293,7 +293,7 @@ model PostTag { @@map('post_tags') } -enum UsersRole { +enum Role { USER ADMIN MODERATOR @@ -361,6 +361,150 @@ model Post { }); }); + describe('Pull should preserve enum declaration order', () => { + + it('should preserve interleaved enum and model ordering', async () => { + const { workDir, schema } = await createProject( + `enum Role { + USER + ADMIN +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + role Role @default(USER) + status Status @default(ACTIVE) +} + +enum Status { + ACTIVE + INACTIVE + SUSPENDED +}`, + ); + runCli('db push', workDir); + + runCli('db pull --indent 4', workDir); + + // Enum-model-enum ordering should be preserved + expect(getSchema(workDir)).toEqual(schema); + }); + }); + + describe('Pull should consolidate shared enums', () => { + it('should consolidate per-column enums back to the original shared enum', async () => { + const { workDir, schema } = await createProject( + `enum Status { + ACTIVE + INACTIVE + SUSPENDED +} + +model User { + id Int @id @default(autoincrement()) + status Status @default(ACTIVE) +} + +model Group { + id Int @id @default(autoincrement()) + status Status @default(ACTIVE) +}`, + ); + runCli('db push', workDir); + + runCli('db pull --indent 4', workDir); + + // MySQL creates per-column enums (UserStatus, GroupStatus) but + // consolidation should map them back to the original shared Status enum + expect(getSchema(workDir)).toEqual(schema); + }); + }); + + describe('Pull should preserve triple-slash comments on enums', () => { + it('should preserve triple-slash comments on enum declarations and fields', async () => { + const { workDir, schema } = await createProject( + `model User { + id Int @id @default(autoincrement()) + status Status @default(ACTIVE) +} + +/// User account status +/// ACTIVE - user can log in +/// INACTIVE - user is disabled +enum Status { + /// User can log in + ACTIVE + /// User is disabled + INACTIVE + /// User is suspended + SUSPENDED +}`, + ); + runCli('db push', workDir); + + runCli('db pull --indent 4', workDir); + + expect(getSchema(workDir)).toEqual(schema); + }); + }); + + describe('Pull should preserve data validation attributes', () => { + it('should preserve field-level validation attributes after db pull', async () => { + const { workDir, schema } = await createProject( + `model User { + id Int @id @default(autoincrement()) + email String @unique @email + name String @length(min: 2, max: 100) + website String? @url + code String? @regex('^[A-Z]+$') + age Int @gt(0) + score Float @gte(0.0) + rating Decimal @lt(10) + rank BigInt @lte(999) +}`, + ); + runCli('db push', workDir); + + // Pull should preserve all validation attributes + runCli('db pull --indent 4', workDir); + + expect(getSchema(workDir)).toEqual(schema); + }); + + it('should preserve string transformation attributes after db pull', async () => { + const { workDir, schema } = await createProject( + `model Setting { + id Int @id @default(autoincrement()) + key String @trim @lower + value String @trim @upper +}`, + ); + runCli('db push', workDir); + + runCli('db pull --indent 4', workDir); + + expect(getSchema(workDir)).toEqual(schema); + }); + + it('should preserve model-level @@validate attribute after db pull', async () => { + const { workDir, schema } = await createProject( + `model Product { + id Int @id @default(autoincrement()) + minPrice Decimal @default(0.00) + maxPrice Decimal @default(100.00) + + @@validate(minPrice < maxPrice, 'minPrice must be less than maxPrice') +}`, + ); + runCli('db push', workDir); + + runCli('db pull --indent 4', workDir); + + expect(getSchema(workDir)).toEqual(schema); + }); + }); + describe('Pull should update existing field definitions when database changes', () => { it('should update field type when database column type changes', async () => { // Step 1: Create initial schema with String field @@ -534,17 +678,17 @@ model Post { `model User { id Int @id @default(autoincrement()) email String @unique - status UserStatus @default(ACTIVE) - role UserRole @default(USER) + status Status @default(ACTIVE) + role Role @default(USER) } -enum UserStatus { +enum Status { ACTIVE INACTIVE SUSPENDED } -enum UserRole { +enum Role { USER ADMIN MODERATOR @@ -569,7 +713,7 @@ enum UserRole { `model User { id Int @id @default(autoincrement()) email String @unique - status UserStatus @default(ACTIVE) + status Status @default(ACTIVE) posts Post[] metadata Json? @@ -588,7 +732,7 @@ model Post { @@index([authorId]) } -enum UserStatus { +enum Status { ACTIVE INACTIVE SUSPENDED @@ -1099,10 +1243,10 @@ describe('DB pull - SQL specific features', () => { `model User { id Int @id @default(autoincrement()) email String @unique - status UserStatus @default(ACTIVE) + status Status @default(ACTIVE) } -enum UserStatus { +enum Status { ACTIVE INACTIVE SUSPENDED @@ -1118,6 +1262,16 @@ enum UserStatus { runCli('db pull --indent 4', workDir); const restoredSchema = getSchema(workDir); - expect(restoredSchema).toEqual(schema); + expect(restoredSchema).contains(`model User { + id Int @id @default(autoincrement()) + email String @unique + status Status @default(ACTIVE) +}`); + + expect(restoredSchema).contains(`enum Status { + ACTIVE + INACTIVE + SUSPENDED +}`); }); }); diff --git a/packages/language/src/zmodel-code-generator.ts b/packages/language/src/zmodel-code-generator.ts index 5b8373166..e68ba7735 100644 --- a/packages/language/src/zmodel-code-generator.ts +++ b/packages/language/src/zmodel-code-generator.ts @@ -101,11 +101,6 @@ export class ZModelCodeGenerator { @gen(Model) private _generateModel(ast: Model) { return `${ast.imports.map((d) => this.generate(d)).join('\n')}${ast.imports.length > 0 ? '\n\n' : ''}${ast.declarations - .sort((a, b) => { - if (a.$type === 'Enum' && b.$type !== 'Enum') return 1; - if (a.$type !== 'Enum' && b.$type === 'Enum') return -1; - return 0; - }) .map((d) => this.generate(d)) .join('\n\n')}`; } @@ -124,7 +119,8 @@ ${ast.fields.map((x) => this.indent + this.generate(x)).join('\n')} @gen(Enum) private _generateEnum(ast: Enum) { - return `enum ${ast.name} { + const comments = `${ast.comments.join('\n')}\n`; + return `${ast.comments.length > 0 ? comments : ''}enum ${ast.name} { ${ast.fields.map((x) => this.indent + this.generate(x)).join('\n')}${ ast.attributes.length > 0 ? '\n\n' + ast.attributes.map((x) => this.indent + this.generate(x)).join('\n') @@ -135,9 +131,20 @@ ${ast.fields.map((x) => this.indent + this.generate(x)).join('\n')}${ @gen(EnumField) private _generateEnumField(ast: EnumField) { - return `${ast.name}${ + const fieldLine = `${ast.name}${ ast.attributes.length > 0 ? ' ' + ast.attributes.map((x) => this.generate(x)).join(' ') : '' }`; + + if (ast.comments.length === 0) { + return fieldLine; + } + + // Build comment block with proper indentation: + // - First comment: no indent (caller adds it via `this.indent + this.generate(x)`) + // - Subsequent comments: add indent + // - Field line: add indent (since it comes after the comment block) + const commentLines = ast.comments.map((c, i) => (i === 0 ? c : this.indent + c)); + return `${commentLines.join('\n')}\n${this.indent}${fieldLine}`; } @gen(GeneratorDecl) From 5c932f234d212474dff95fb9a833d6c920c81705 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A1n=20Benj=C3=A1min?= Date: Sun, 8 Feb 2026 16:16:58 +0100 Subject: [PATCH 2/4] fix: address PR comments --- packages/cli/src/actions/db.ts | 9 +++++---- packages/cli/src/actions/pull/utils.ts | 4 ++++ packages/cli/test/db/pull.test.ts | 14 +++++++------- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/actions/db.ts b/packages/cli/src/actions/db.ts index 7efa7d722..f6fda3844 100644 --- a/packages/cli/src/actions/db.ts +++ b/packages/cli/src/actions/db.ts @@ -14,7 +14,7 @@ import { } from './action-utils'; import { consolidateEnums, syncEnums, syncRelation, syncTable, type Relation } from './pull'; import { providers as pullProviders } from './pull/provider'; -import { getDatasource, getDbName, getRelationFieldsKey, getRelationFkName } from './pull/utils'; +import { getDatasource, getDbName, getRelationFieldsKey, getRelationFkName, isDatabaseManagedAttribute } from './pull/utils'; import type { DataSourceProviderType } from '@zenstackhq/schema'; import { CliError } from '../cli-error'; @@ -461,12 +461,13 @@ async function runPull(options: PullOptions) { } return; } + // Track deleted attributes (in original but not in new) originalField.attributes .filter( (attr) => - !f.attributes.find((d) => d.decl.$refText === attr.decl.$refText) && - (['@relation'].includes(attr.decl.$refText) || attr.decl.$refText.startsWith('@db.')), + !f.attributes.find((d) => d.decl.$refText === attr.decl.$refText) && + isDatabaseManagedAttribute(attr.decl.$refText), ) .forEach((attr) => { const field = attr.$container; @@ -482,7 +483,7 @@ async function runPull(options: PullOptions) { .filter( (attr) => !originalField.attributes.find((d) => d.decl.$refText === attr.decl.$refText) && - (['@relation'].includes(attr.decl.$refText) || attr.decl.$refText.startsWith('@db.')), + isDatabaseManagedAttribute(attr.decl.$refText), ) .forEach((attr) => { // attach the new attribute to the original field diff --git a/packages/cli/src/actions/pull/utils.ts b/packages/cli/src/actions/pull/utils.ts index e0abcfdfd..9ec056bc4 100644 --- a/packages/cli/src/actions/pull/utils.ts +++ b/packages/cli/src/actions/pull/utils.ts @@ -28,6 +28,10 @@ export function getAttribute(model: Model, attrName: string) { | undefined; } +export function isDatabaseManagedAttribute(name: string) { + return ['@relation', '@id', '@unique'].includes(name) || name.startsWith('@db.'); +} + export function getDatasource(model: Model) { const datasource = model.declarations.find((d) => d.$type === 'DataSource'); if (!datasource) { diff --git a/packages/cli/test/db/pull.test.ts b/packages/cli/test/db/pull.test.ts index e2ea52bc8..a02ae4d89 100644 --- a/packages/cli/test/db/pull.test.ts +++ b/packages/cli/test/db/pull.test.ts @@ -1241,12 +1241,12 @@ describe('DB pull - SQL specific features', () => { const { workDir, schema } = await createProject( `model User { - id Int @id @default(autoincrement()) - email String @unique - status Status @default(ACTIVE) + id Int @id @default(autoincrement()) + email String @unique + status UserStatus @default(ACTIVE) } -enum Status { +enum UserStatus { ACTIVE INACTIVE SUSPENDED @@ -1262,13 +1262,13 @@ enum Status { runCli('db pull --indent 4', workDir); const restoredSchema = getSchema(workDir); - expect(restoredSchema).contains(`model User { + expect(restoredSchema).toContain(`model User { id Int @id @default(autoincrement()) email String @unique - status Status @default(ACTIVE) + status UserStatus @default(ACTIVE) }`); - expect(restoredSchema).contains(`enum Status { + expect(restoredSchema).toContain(`enum UserStatus { ACTIVE INACTIVE SUSPENDED From 39a04eb9a2d3a55ac186c5f957f8caa143776086 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A1n=20Benj=C3=A1min?= Date: Sun, 8 Feb 2026 23:03:36 +0100 Subject: [PATCH 3/4] fix(cli): improve db pull for relations and defaults Prevents field name collisions during introspection by refining the naming strategy for self-referencing relations with multiple foreign keys. Extends support for JSON and Bytes default values across MySQL, PostgreSQL, and SQLite providers to ensure consistent schema restoration. Adds test cases for self-referencing models to verify the avoidance of duplicate fields. --- packages/cli/src/actions/pull/index.ts | 9 +++- .../cli/src/actions/pull/provider/mysql.ts | 4 ++ .../src/actions/pull/provider/postgresql.ts | 10 ++++ .../cli/src/actions/pull/provider/sqlite.ts | 4 ++ packages/cli/test/db/pull.test.ts | 50 +++++++++++++++++++ 5 files changed, 76 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/actions/pull/index.ts b/packages/cli/src/actions/pull/index.ts index b450e2a70..bfd99cfac 100644 --- a/packages/cli/src/actions/pull/index.ts +++ b/packages/cli/src/actions/pull/index.ts @@ -533,13 +533,20 @@ export function syncRelation({ sourceModel.fields.splice(firstSourceFieldId, 0, sourceFieldFactory.node); // Insert the relation field before the first FK scalar field const oppositeFieldPrefix = /[0-9]/g.test(targetModel.name.charAt(0)) ? '_' : ''; - const { name: oppositeFieldName } = resolveNameCasing( + let { name: oppositeFieldName } = resolveNameCasing( options.fieldCasing, similarRelations > 0 ? `${oppositeFieldPrefix}${lowerCaseFirst(sourceModel.name)}_${firstColumn}` : `${lowerCaseFirst(resolveNameCasing(options.fieldCasing, sourceModel.name).name)}${relation.references.type === 'many'? 's' : ''}`, ); + if (targetModel.fields.find((f) => f.name === oppositeFieldName)) { + ({ name: oppositeFieldName } = resolveNameCasing( + options.fieldCasing, + `${lowerCaseFirst(sourceModel.name)}_${firstColumn}To${relation.references.table}_${relation.references.columns[0]}`, + )); + } + const targetFieldFactory = new DataFieldFactory() .setContainer(targetModel) .setName(oppositeFieldName) diff --git a/packages/cli/src/actions/pull/provider/mysql.ts b/packages/cli/src/actions/pull/provider/mysql.ts index 895a9cb53..1c8124435 100644 --- a/packages/cli/src/actions/pull/provider/mysql.ts +++ b/packages/cli/src/actions/pull/provider/mysql.ts @@ -266,6 +266,10 @@ export const mysql: IntrospectionProvider = { return (ab) => ab.InvocationExpr.setFunction(getFunctionRef('uuid', services)); } return (ab) => ab.StringLiteral.setValue(val); + case 'Json': + return (ab) => ab.StringLiteral.setValue(val); + case 'Bytes': + return (ab) => ab.StringLiteral.setValue(val); } // Handle function calls (e.g., uuid(), now()) diff --git a/packages/cli/src/actions/pull/provider/postgresql.ts b/packages/cli/src/actions/pull/provider/postgresql.ts index bf54e5658..6bfc9d231 100644 --- a/packages/cli/src/actions/pull/provider/postgresql.ts +++ b/packages/cli/src/actions/pull/provider/postgresql.ts @@ -284,6 +284,16 @@ export const postgresql: IntrospectionProvider = { return (ab) => ab.StringLiteral.setValue(val.slice(1, -1).replace(/''/g, "'")); } return (ab) => ab.StringLiteral.setValue(val); + case 'Json': + if (val.includes('::')) { + return typeCastingConvert({defaultValue,enums,val,services}); + } + return (ab) => ab.StringLiteral.setValue(val); + case 'Bytes': + if (val.includes('::')) { + return typeCastingConvert({defaultValue,enums,val,services}); + } + return (ab) => ab.StringLiteral.setValue(val); } if (val.includes('(') && val.includes(')')) { diff --git a/packages/cli/src/actions/pull/provider/sqlite.ts b/packages/cli/src/actions/pull/provider/sqlite.ts index f58ad0b58..c4b06f367 100644 --- a/packages/cli/src/actions/pull/provider/sqlite.ts +++ b/packages/cli/src/actions/pull/provider/sqlite.ts @@ -394,6 +394,10 @@ export const sqlite: IntrospectionProvider = { return (ab) => ab.StringLiteral.setValue(strippedName); } return (ab) => ab.StringLiteral.setValue(val); + case 'Json': + return (ab) => ab.StringLiteral.setValue(val); + case 'Bytes': + return (ab) => ab.StringLiteral.setValue(val); } console.warn(`Unsupported default value type: "${defaultValue}" for field type "${fieldType}". Skipping default value.`); diff --git a/packages/cli/test/db/pull.test.ts b/packages/cli/test/db/pull.test.ts index a02ae4d89..a73a5bc8f 100644 --- a/packages/cli/test/db/pull.test.ts +++ b/packages/cli/test/db/pull.test.ts @@ -102,6 +102,56 @@ model Tag { expect(restoredSchema).toEqual(schema); }); + it('should restore self-referencing model with multiple FK columns without duplicate fields', async () => { + const { workDir, schema } = await createProject( + `model Category { + id Int @id @default(autoincrement()) + categoryParentId Category? @relation('Category_parentIdToCategory', fields: [parentId], references: [id]) + parentId Int? + categoryBuddyId Category? @relation('Category_buddyIdToCategory', fields: [buddyId], references: [id]) + buddyId Int? + categoryMentorId Category? @relation('Category_mentorIdToCategory', fields: [mentorId], references: [id]) + mentorId Int? + categoryParentIdToCategoryId Category[] @relation('Category_parentIdToCategory') + categoryBuddyIdToCategoryId Category[] @relation('Category_buddyIdToCategory') + categoryMentorIdToCategoryId Category[] @relation('Category_mentorIdToCategory') +}`, + ); + runCli('db push', workDir); + + const schemaFile = path.join(workDir, 'zenstack/schema.zmodel'); + + fs.writeFileSync(schemaFile, getDefaultPrelude()); + runCli('db pull --indent 4', workDir); + + const restoredSchema = getSchema(workDir); + + expect(restoredSchema).toEqual(schema); + }); + + it('should preserve self-referencing model with multiple FK columns', async () => { + const { workDir, schema } = await createProject( + `model Category { + id Int @id @default(autoincrement()) + category Category? @relation('Category_parentIdToCategory', fields: [parentId], references: [id]) + parentId Int? + buddy Category? @relation('Category_buddyIdToCategory', fields: [buddyId], references: [id]) + buddyId Int? + mentor Category? @relation('Category_mentorIdToCategory', fields: [mentorId], references: [id]) + mentorId Int? + categories Category[] @relation('Category_parentIdToCategory') + buddys Category[] @relation('Category_buddyIdToCategory') + mentees Category[] @relation('Category_mentorIdToCategory') +}`, + ); + runCli('db push', workDir); + runCli('db pull --indent 4', workDir); + + const restoredSchema = getSchema(workDir); + + expect(restoredSchema).toEqual(schema); + }); + it('should restore one-to-one relation when FK is the single-column primary key', async () => { const { workDir, schema } = await createProject( `model Profile { From b1e473ff19e0fcc4d0830300d6b42afa6d42736c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A1n=20Benj=C3=A1min?= Date: Mon, 9 Feb 2026 20:47:16 +0100 Subject: [PATCH 4/4] fix: address PR comments --- packages/cli/src/actions/pull/index.ts | 9 ++++++ packages/cli/test/db/pull.test.ts | 39 ++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/packages/cli/src/actions/pull/index.ts b/packages/cli/src/actions/pull/index.ts index bfd99cfac..998852cb6 100644 --- a/packages/cli/src/actions/pull/index.ts +++ b/packages/cli/src/actions/pull/index.ts @@ -638,6 +638,15 @@ export function consolidateEnums({ // Rename the kept enum to match the old shared name keepEnum.name = oldEnum.name; + // Replace keepEnum's attributes with those from the old enum so that + // any synthetic @@map added by syncEnums is removed and getDbName(keepEnum) + // reflects the consolidated name rather than the stale per-column name. + // Shallow-copy and re-parent so AST $container pointers reference keepEnum. + keepEnum.attributes = oldEnum.attributes.map((attr) => { + const copy = { ...attr, $container: keepEnum }; + return copy; + }); + // Remove duplicate enums from newModel for (let i = 1; i < newEnumsGroup.length; i++) { const idx = newModel.declarations.indexOf(newEnumsGroup[i]!); diff --git a/packages/cli/test/db/pull.test.ts b/packages/cli/test/db/pull.test.ts index a73a5bc8f..2750a2228 100644 --- a/packages/cli/test/db/pull.test.ts +++ b/packages/cli/test/db/pull.test.ts @@ -469,6 +469,45 @@ model Group { // consolidation should map them back to the original shared Status enum expect(getSchema(workDir)).toEqual(schema); }); + + it('should consolidate per-column enums with --always-map without stale @@map', async () => { + // This test targets a bug where consolidateEnums renames keepEnum.name + // to oldEnum.name but leaves the synthetic @@map attribute added by + // syncEnums, so getDbName(keepEnum) still returns the old mapped name + // (e.g., 'UserStatus') instead of the consolidated name ('Status'), + // preventing matching in the downstream delete/add enum logic. + const { workDir } = await createProject( + `enum Status { + ACTIVE + INACTIVE + SUSPENDED +} + +model User { + id Int @id @default(autoincrement()) + status Status @default(ACTIVE) +} + +model Group { + id Int @id @default(autoincrement()) + status Status @default(ACTIVE) +}`, + ); + runCli('db push', workDir); + + runCli('db pull --indent 4 --always-map', workDir); + + const pulledSchema = getSchema(workDir); + + // The consolidated enum should be named Status, not UserStatus/GroupStatus + expect(pulledSchema).toContain('enum Status'); + expect(pulledSchema).not.toContain('enum UserStatus'); + expect(pulledSchema).not.toContain('enum GroupStatus'); + + // There should be no stale @@map referencing the synthetic per-column name + expect(pulledSchema).not.toMatch(/@@map\(['"]UserStatus['"]\)/); + expect(pulledSchema).not.toMatch(/@@map\(['"]GroupStatus['"]\)/); + }); }); describe('Pull should preserve triple-slash comments on enums', () => {