From b6e08f11d6a90326eeea56c4f2719a3a8c9c4d09 Mon Sep 17 00:00:00 2001 From: jiasheng Date: Sun, 19 Jul 2026 20:55:28 +0800 Subject: [PATCH 1/2] fix(cli): db pull missing relation name if one model has both to-one and to-many relation for another model --- packages/cli/src/actions/db.ts | 2 +- packages/cli/test/db/pull.test.ts | 91 ++++++++++++++++++++++--------- 2 files changed, 65 insertions(+), 28 deletions(-) diff --git a/packages/cli/src/actions/db.ts b/packages/cli/src/actions/db.ts index 1a3325d73..fbc916735 100644 --- a/packages/cli/src/actions/db.ts +++ b/packages/cli/src/actions/db.ts @@ -178,7 +178,7 @@ async function runPull(options: PullOptions) { rr.references.schema === relation.references.schema && rr.references.table === relation.references.table) || (rr.schema === relation.references.schema && - rr.columns[0] === relation.references.columns[0] && + rr.table === relation.references.table && rr.references.schema === relation.schema && rr.references.table === relation.table)) ); diff --git a/packages/cli/test/db/pull.test.ts b/packages/cli/test/db/pull.test.ts index ec34c66c3..920332208 100644 --- a/packages/cli/test/db/pull.test.ts +++ b/packages/cli/test/db/pull.test.ts @@ -324,7 +324,6 @@ model User { const restoredSchema = getSchema(workDir); expect(restoredSchema).toEqual(schema); }); - }); describe('Pull with existing schema - preserve schema features', () => { @@ -424,10 +423,9 @@ enum Role { USER ADMIN MODERATOR -}`, -// When using MySQL, the introspection simply overrides the enum and cannot detect if it exists with the same name because it only stores the values. -// TODO: Create a better way to handle this, possibly by finding enums by their values as well if the schema exists. - ); +}`); + // When using MySQL, the introspection simply overrides the enum and cannot detect if it exists with the same name because it only stores the values. + // TODO: Create a better way to handle this, possibly by finding enums by their values as well if the schema exists. runCli('db push', workDir); runCli('db pull --indent 4', workDir); @@ -486,10 +484,33 @@ model Post { expect(pulledUserSchema).toEqual(userModel); expect(pulledPostSchema).toEqual(postModel); }); + + it('should preserve opposite-direction relations between the same two tables', async () => { + const { workDir, schema } = await createProject( + `model User { + id Int @id @default(autoincrement()) + postId Int? @unique + favPost Post? @relation('FavPost', fields: [postId], references: [id]) + posts Post[] @relation('UserPosts') +} + +model Post { + id Int @id @default(autoincrement()) + userId Int + author User @relation('UserPosts', fields: [userId], references: [id]) + favUser User? @relation('FavPost') +}`, + ); + runCli('db push', workDir); + + runCli('db pull --indent 4', workDir); + + const preservedSchema = getSchema(workDir); + expect(preservedSchema).toEqual(schema); + }); }); describe('Pull should preserve enum declaration order', () => { - it('should preserve interleaved enum and model ordering', async () => { const { workDir, schema } = await createProject( `enum Role { @@ -824,13 +845,16 @@ model Post { @@schema('content') }`, - { provider: 'postgresql', datasourceFields:{ schemas: ['public', 'content', 'auth'] } }, + { provider: 'postgresql', datasourceFields: { schemas: ['public', 'content', 'auth'] } }, ); runCli('db push', workDir); const schemaFile = path.join(workDir, 'zenstack/schema.zmodel'); - fs.writeFileSync(schemaFile, getDefaultPrelude({ provider: 'postgresql', datasourceFields:{ schemas: ['public', 'content', 'auth']} })); + fs.writeFileSync( + schemaFile, + getDefaultPrelude({ provider: 'postgresql', datasourceFields: { schemas: ['public', 'content', 'auth'] } }), + ); runCli('db pull --indent 4', workDir); const restoredSchema = getSchema(workDir); @@ -906,7 +930,7 @@ enum Status { INACTIVE SUSPENDED }`, - { provider: 'postgresql', datasourceFields:{ schemas: ['public', 'content', 'auth'] } }, + { provider: 'postgresql', datasourceFields: { schemas: ['public', 'content', 'auth'] } }, ); runCli('db push', workDir); @@ -1040,7 +1064,7 @@ model Tenant { await client.connect(); try { await client.query( - `ALTER TABLE "ComputedUsers" ADD COLUMN "fullName" text GENERATED ALWAYS AS ("firstName" || ' ' || "lastName") STORED` + `ALTER TABLE "ComputedUsers" ADD COLUMN "fullName" text GENERATED ALWAYS AS ("firstName" || ' ' || "lastName") STORED`, ); } finally { await client.end(); @@ -1055,14 +1079,16 @@ model Tenant { // The generated column should be pulled as Unsupported with the full expression. // format_type returns 'text', and pg_get_expr returns the expression. - expect(restoredSchema).toEqual(await formatDocument(`${getDefaultPrelude({ provider: 'postgresql' })} + expect(restoredSchema).toEqual( + await formatDocument(`${getDefaultPrelude({ provider: 'postgresql' })} model ComputedUsers { id Int @id @default(autoincrement()) firstName String lastName String fullName Unsupported('text GENERATED ALWAYS AS ((("firstName" || \\' \\'::text) || "lastName")) STORED')? -}`)); +}`), + ); }); it('should pull virtual generated columns as Unsupported with full expression', async ({ skip }) => { @@ -1091,7 +1117,7 @@ model ComputedUsers { await client.connect(); try { await client.query( - `ALTER TABLE "ComputedProducts" ADD COLUMN "total" integer GENERATED ALWAYS AS ("price" * "qty") STORED` + `ALTER TABLE "ComputedProducts" ADD COLUMN "total" integer GENERATED ALWAYS AS ("price" * "qty") STORED`, ); } finally { await client.end(); @@ -1103,14 +1129,16 @@ model ComputedUsers { const restoredSchema = getSchema(workDir); - expect(restoredSchema).toEqual(await formatDocument(`${getDefaultPrelude({ provider: 'postgresql' })} + expect(restoredSchema).toEqual( + await formatDocument(`${getDefaultPrelude({ provider: 'postgresql' })} model ComputedProducts { id Int @id @default(autoincrement()) price Int @default(0) qty Int @default(0) total Unsupported('integer GENERATED ALWAYS AS ((price * qty)) STORED')? -}`)); +}`), + ); }); }); @@ -1173,7 +1201,7 @@ describe('DB pull - MySQL specific features', () => { const connection = await mysql.createConnection(getTestDbUrl('mysql', dbName)); try { await connection.execute( - "ALTER TABLE `ComputedUsers` ADD COLUMN `fullName` varchar(511) GENERATED ALWAYS AS (CONCAT(`firstName`, ' ', `lastName`)) STORED" + "ALTER TABLE `ComputedUsers` ADD COLUMN `fullName` varchar(511) GENERATED ALWAYS AS (CONCAT(`firstName`, ' ', `lastName`)) STORED", ); } finally { await connection.end(); @@ -1189,14 +1217,16 @@ describe('DB pull - MySQL specific features', () => { // The generated column should be pulled as Unsupported with the full expression. // MySQL uses COLUMN_TYPE (e.g., 'varchar(511)') and GENERATION_EXPRESSION for the expr, // and EXTRA contains 'STORED GENERATED' or 'VIRTUAL GENERATED'. - expect(restoredSchema).toEqual(await formatDocument(`${getDefaultPrelude({ provider: 'mysql' })} + expect(restoredSchema).toEqual( + await formatDocument(`${getDefaultPrelude({ provider: 'mysql' })} model ComputedUsers { id Int @id @default(autoincrement()) firstName String @db.VarChar(255) lastName String @db.VarChar(255) fullName Unsupported('varchar(511) GENERATED ALWAYS AS (concat(\`firstName\`,\\' \\',\`lastName\`)) STORED')? -}`)); +}`), + ); }); it('should pull virtual generated columns as Unsupported with full expression', async ({ skip }) => { @@ -1221,7 +1251,7 @@ model ComputedUsers { const connection = await mysql.createConnection(getTestDbUrl('mysql', dbName)); try { await connection.execute( - "ALTER TABLE `ComputedProducts` ADD COLUMN `total` int GENERATED ALWAYS AS (`price` * `qty`) VIRTUAL" + 'ALTER TABLE `ComputedProducts` ADD COLUMN `total` int GENERATED ALWAYS AS (`price` * `qty`) VIRTUAL', ); } finally { await connection.end(); @@ -1233,14 +1263,16 @@ model ComputedUsers { const restoredSchema = getSchema(workDir); - expect(restoredSchema).toEqual(await formatDocument(`${getDefaultPrelude({ provider: 'mysql' })} + expect(restoredSchema).toEqual( + await formatDocument(`${getDefaultPrelude({ provider: 'mysql' })} model ComputedProducts { id Int @id @default(autoincrement()) price Int @default(0) qty Int @default(0) total Unsupported('int GENERATED ALWAYS AS ((\`price\` * \`qty\`)) VIRTUAL')? -}`)); +}`), + ); }); }); @@ -1295,7 +1327,7 @@ model Tenant { return; } // Create a minimal project and push to get the database file. - const { workDir } = await createProject(""); + const { workDir } = await createProject(''); // Open the SQLite database directly and add a table with an untyped column. // In SQLite, CREATE TABLE t("data") gives column "data" no declared type, @@ -1351,14 +1383,16 @@ model Tenant { const restoredSchema = getSchema(workDir); // first_name and last_name should be regular String fields - expect(restoredSchema).toEqual(await formatDocument(`${getDefaultPrelude()} + expect(restoredSchema).toEqual( + await formatDocument(`${getDefaultPrelude()} model ComputedUsers { id Int @id @default(autoincrement()) firstName String lastName String fullName Unsupported('TEXT GENERATED ALWAYS AS (firstName || \\' \\' || lastName) STORED')? -}`)); +}`), + ); }); it('should pull virtual generated columns as Unsupported', async ({ skip }) => { @@ -1389,14 +1423,16 @@ model ComputedUsers { const restoredSchema = getSchema(workDir); - expect(restoredSchema).toEqual(await formatDocument(`${getDefaultPrelude()} + expect(restoredSchema).toEqual( + await formatDocument(`${getDefaultPrelude()} model ComputedProducts { id Int @id @default(autoincrement()) price Int @default(0) qty Int @default(0) total Unsupported('INTEGER GENERATED ALWAYS AS ("price" * "qty") VIRTUAL')? -}`)); +}`), + ); }); }); @@ -1419,7 +1455,8 @@ enum UserStatus { ACTIVE INACTIVE SUSPENDED -}`); +}`, + ); runCli('db push', workDir); const schemaFile = path.join(workDir, 'zenstack/schema.zmodel'); From 8e5664dc74eb7d58c62a41d0ac54aa53f7dea2d8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:36:45 +0000 Subject: [PATCH 2/2] fix(cli): normalize schema comparisons to handle MySQL null vs empty string --- packages/cli/src/actions/db.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/actions/db.ts b/packages/cli/src/actions/db.ts index fbc916735..c8e0f8204 100644 --- a/packages/cli/src/actions/db.ts +++ b/packages/cli/src/actions/db.ts @@ -168,23 +168,29 @@ async function runPull(options: PullOptions) { }); resolvedRelations.push(...relations); } + // Normalize schema values: MySQL uses null for references.schema but '' for table.schema; + // treat null, undefined, and '' as equivalent when comparing schemas. + const schemasMatch = (a: string | null | undefined, b: string | null | undefined) => + (a ?? '') === (b ?? ''); + // sync relation fields for (const relation of resolvedRelations) { const similarRelations = resolvedRelations.filter((rr) => { return ( rr !== relation && - ((rr.schema === relation.schema && + ((schemasMatch(rr.schema, relation.schema) && rr.table === relation.table && - rr.references.schema === relation.references.schema && + schemasMatch(rr.references.schema, relation.references.schema) && rr.references.table === relation.references.table) || - (rr.schema === relation.references.schema && + (schemasMatch(rr.schema, relation.references.schema) && rr.table === relation.references.table && - rr.references.schema === relation.schema && + schemasMatch(rr.references.schema, relation.schema) && rr.references.table === relation.table)) ); }).length; const selfRelation = - relation.references.schema === relation.schema && relation.references.table === relation.table; + schemasMatch(relation.references.schema, relation.schema) && + relation.references.table === relation.table; syncRelation({ model: newModel, relation,