import fs from 'node:fs'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; import { createProject, getDefaultPrelude, getTestDbName, getTestDbUrl, runCli } from '../utils'; import { formatDocument } from '@zenstackhq/language'; import { getTestDbProvider } from '@zenstackhq/testtools'; const getSchema = (workDir: string) => fs.readFileSync(path.join(workDir, 'zenstack/schema.zmodel')).toString(); describe('DB pull - Common features (all providers)', () => { describe('Pull from zero - restore complete schema from database', () => { it('should restore basic schema with all supported types', async () => { const { workDir, schema } = await createProject( `model User { id Int @id @default(autoincrement()) email String @unique name String? age Int @default(0) balance Decimal @default(0.00) isActive Boolean @default(true) bigCounter BigInt @default(0) score Float @default(0.0) bio String? avatar Bytes? metadata Json? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }`, ); runCli('db push', workDir); // Store the schema after db push (this is what provider names will be) const schemaFile = path.join(workDir, 'zenstack/schema.zmodel'); // Remove schema content to simulate restoration from zero fs.writeFileSync(schemaFile, getDefaultPrelude()); // Pull should fully restore the schema runCli('db pull --indent 4', workDir); const restoredSchema = getSchema(workDir); expect(restoredSchema).toEqual(schema); }); it('should restore schema with relations', async () => { const { workDir, schema } = await createProject( `model Post { id Int @id @default(autoincrement()) title String author User @relation(fields: [authorId], references: [id], onDelete: Cascade) authorId Int } model User { id Int @id @default(autoincrement()) email String @unique posts Post[] }`, ); 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 restore schema with many-to-many relations', async () => { const { workDir, schema } = await createProject( `model Post { id Int @id @default(autoincrement()) title String postTags PostTag[] } model PostTag { post Post @relation(fields: [postId], references: [id], onDelete: Cascade) postId Int tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) tagId Int @@id([postId, tagId]) } model Tag { id Int @id @default(autoincrement()) name String @unique postTags PostTag[] }`, ); 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 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 opposite relation fields when multiple models have FKs to the same target', async () => { const { workDir, schema } = await createProject( `model Comment { id Int @id @default(autoincrement()) text String commentCreatedBy User? @relation('Comment_createdByToUser', fields: [createdBy], references: [id]) createdBy Int? commentUpdatedBy User? @relation('Comment_updatedByToUser', fields: [updatedBy], references: [id]) updatedBy Int? } model Post { id Int @id @default(autoincrement()) title String postCreatedBy User? @relation('Post_createdByToUser', fields: [createdBy], references: [id]) createdBy Int? postUpdatedBy User? @relation('Post_updatedByToUser', fields: [updatedBy], references: [id]) updatedBy Int? } model User { id Int @id @default(autoincrement()) email String @unique commentCreatedBy Comment[] @relation('Comment_createdByToUser') commentUpdatedBy Comment[] @relation('Comment_updatedByToUser') postCreatedBy Post[] @relation('Post_createdByToUser') postUpdatedBy Post[] @relation('Post_updatedByToUser') }`, ); 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 opposite relation fields when multiple models have FKs to the same target', async () => { const { workDir, schema } = await createProject( `model Comment { id Int @id @default(autoincrement()) text String commentCreatedBy User? @relation('Comment_createdByToUser', fields: [createdBy], references: [id]) createdBy Int? commentUpdatedBy User? @relation('Comment_updatedByToUser', fields: [updatedBy], references: [id]) updatedBy Int? } model Post { id Int @id @default(autoincrement()) title String postCreatedBy User? @relation('Post_createdByToUser', fields: [createdBy], references: [id]) createdBy Int? postUpdatedBy User? @relation('Post_updatedByToUser', fields: [updatedBy], references: [id]) updatedBy Int? } model User { id Int @id @default(autoincrement()) email String @unique commentCreatedBy Comment[] @relation('Comment_createdByToUser') commentUpdatedBy Comment[] @relation('Comment_updatedByToUser') postCreatedBy Post[] @relation('Post_createdByToUser') postUpdatedBy Post[] @relation('Post_updatedByToUser') }`, ); 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 { user User @relation(fields: [id], references: [id], onDelete: Cascade) id Int @id @default(autoincrement()) bio String? } model User { id Int @id @default(autoincrement()) email String @unique profile Profile? }`, ); 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 restore schema with indexes and unique constraints', async () => { const { workDir, schema } = await createProject( `model User { id Int @id @default(autoincrement()) email String @unique username String firstName String lastName String role String @@unique([username, email]) @@index([role]) @@index([firstName, lastName]) @@index([email, username, role]) }`, ); 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 restore schema with composite primary keys', async () => { const { workDir, schema } = await createProject( `model UserRole { userId String role String grantedAt DateTime @default(now()) @@id([userId, role]) }`, ); 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 Decimal and Float default value precision', async () => { const { workDir, schema } = await createProject( `model Product { id Int @id @default(autoincrement()) price Decimal @default(99.99) discount Decimal @default(0.50) taxRate Decimal @default(7.00) weight Float @default(1.5) rating Float @default(4.0) temperature Float @default(98.6) }`, ); 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); }); }); describe('Pull with existing schema - preserve schema features', () => { it('should preserve field and table mappings', async () => { const { workDir, schema } = await createProject( `model User { id Int @id @default(autoincrement()) email String @unique @map('email_address') firstName String @map('first_name') lastName String @map('last_name') @@map('users') }`, ); runCli('db push', workDir); runCli('db pull --indent 4', workDir); expect(getSchema(workDir)).toEqual(schema); }); it('should not modify a comprehensive schema with all features', async () => { const { workDir, schema } = await createProject(`model User { id Int @id @default(autoincrement()) email String @unique @map('email_address') name String? @default('Anonymous') role Role @default(USER) profile Profile? shared_profile Profile? @relation('shared') posts Post[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt jsonData Json? balance Decimal @default(0.00) isActive Boolean @default(true) bigCounter BigInt @default(0) bytes Bytes? @@index([role]) @@map('users') } model Profile { id Int @id @default(autoincrement()) user User @relation(fields: [userId], references: [id], onDelete: Cascade) userId Int @unique user_shared User @relation('shared', fields: [shared_userId], references: [id], onDelete: Cascade) shared_userId Int @unique bio String? avatarUrl String? @@map('profiles') } model Post { id Int @id @default(autoincrement()) author User @relation(fields: [authorId], references: [id], onDelete: Cascade) authorId Int title String content String? published Boolean @default(false) tags PostTag[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt slug String score Float @default(0.0) metadata Json? @@unique([authorId, slug]) @@index([authorId, published]) @@map('posts') } model Tag { id Int @id @default(autoincrement()) name String @unique posts PostTag[] createdAt DateTime @default(now()) @@index([name], name: 'tag_name_idx') @@map('tags') } model PostTag { post Post @relation(fields: [postId], references: [id], onDelete: Cascade) postId Int tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) tagId Int assignedAt DateTime @default(now()) note String? @default('initial') @@id([postId, tagId]) @@map('post_tags') } 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. ); runCli('db push', workDir); runCli('db pull --indent 4', workDir); expect(getSchema(workDir)).toEqual(schema); }); it('should preserve imports when pulling with multi-file schema', async () => { const { workDir } = await createProject('', { customPrelude: true }); const schemaPath = path.join(workDir, 'zenstack/schema.zmodel'); const modelsDir = path.join(workDir, 'zenstack/models'); fs.mkdirSync(modelsDir, { recursive: true }); // Create main schema with imports const mainSchema = await formatDocument(`import './models/user' import './models/post' ${getDefaultPrelude()}`); fs.writeFileSync(schemaPath, mainSchema); // Create user model const userModel = await formatDocument(`import './post' model User { id Int @id @default(autoincrement()) email String @unique name String? posts Post[] createdAt DateTime @default(now()) }`); fs.writeFileSync(path.join(modelsDir, 'user.zmodel'), userModel); // Create post model const postModel = await formatDocument(`import './user' model Post { id Int @id @default(autoincrement()) title String content String? author User @relation(fields: [authorId], references: [id], onDelete: Cascade) authorId Int createdAt DateTime @default(now()) }`); fs.writeFileSync(path.join(modelsDir, 'post.zmodel'), postModel); runCli('db push', workDir); // Pull and verify imports are preserved runCli('db pull --indent 4', workDir); const pulledMainSchema = fs.readFileSync(schemaPath).toString(); const pulledUserSchema = fs.readFileSync(path.join(modelsDir, 'user.zmodel')).toString(); const pulledPostSchema = fs.readFileSync(path.join(modelsDir, 'post.zmodel')).toString(); expect(pulledMainSchema).toEqual(mainSchema); expect(pulledUserSchema).toEqual(userModel); expect(pulledPostSchema).toEqual(postModel); }); }); 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); }); 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', () => { 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 phone String @phone 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 const { workDir } = await createProject( `model User { id Int @id @default(autoincrement()) email String @unique age String }`, ); runCli('db push', workDir); // Step 2: Modify schema to change age from String to Int const schemaFile = path.join(workDir, 'zenstack/schema.zmodel'); const updatedSchema = await formatDocument(`${getDefaultPrelude()} model User { id Int @id @default(autoincrement()) email String @unique age Int }`); fs.writeFileSync(schemaFile, updatedSchema); runCli('db push', workDir); // Step 3: Revert schema back to original (with String type) const originalSchema = await formatDocument(`${getDefaultPrelude()} model User { id Int @id @default(autoincrement()) email String @unique age String }`); fs.writeFileSync(schemaFile, originalSchema); // Step 4: Pull from database - should detect that age is now Int runCli('db pull --indent 4', workDir); // Step 5: Verify that pulled schema has Int type (matching database) const pulledSchema = getSchema(workDir); expect(pulledSchema).toEqual(updatedSchema); }); it('should update field optionality when database column nullability changes', async () => { // Step 1: Create initial schema with required field const { workDir } = await createProject( `model User { id Int @id @default(autoincrement()) email String @unique name String }`, ); runCli('db push', workDir); // Step 2: Modify schema to make name optional const schemaFile = path.join(workDir, 'zenstack/schema.zmodel'); const updatedSchema = await formatDocument(`${getDefaultPrelude()} model User { id Int @id @default(autoincrement()) email String @unique name String? }`); fs.writeFileSync(schemaFile, updatedSchema); runCli('db push', workDir); // Step 3: Revert schema back to original (with required name) const originalSchema = await formatDocument(`${getDefaultPrelude()} model User { id Int @id @default(autoincrement()) email String @unique name String }`); fs.writeFileSync(schemaFile, originalSchema); // Step 4: Pull from database - should detect that name is now optional runCli('db pull --indent 4', workDir); // Step 5: Verify that pulled schema has optional name (matching database) const pulledSchema = getSchema(workDir); expect(pulledSchema).toEqual(updatedSchema); }); it('should update default value when database default changes', async () => { // Step 1: Create initial schema with default value const { workDir } = await createProject( `model User { id Int @id @default(autoincrement()) email String @unique status String @default('active') }`, ); runCli('db push', workDir); // Step 2: Modify schema to change default value const schemaFile = path.join(workDir, 'zenstack/schema.zmodel'); const updatedSchema = await formatDocument(`${getDefaultPrelude()} model User { id Int @id @default(autoincrement()) email String @unique status String @default('pending') }`); fs.writeFileSync(schemaFile, updatedSchema); runCli('db push', workDir); // Step 3: Revert schema back to original default const originalSchema = await formatDocument(`${getDefaultPrelude()} model User { id Int @id @default(autoincrement()) email String @unique status String @default('active') }`); fs.writeFileSync(schemaFile, originalSchema); // Step 4: Pull from database - should detect that default changed runCli('db pull --indent 4', workDir); // Step 5: Verify that pulled schema has updated default (matching database) const pulledSchema = getSchema(workDir); expect(pulledSchema).toEqual(updatedSchema); }); }); }); describe('DB pull - PostgreSQL specific features', () => { it('should restore schema with multiple database schemas', async ({ skip }) => { const provider = getTestDbProvider(); if (provider !== 'postgresql') { skip(); return; } const { workDir, schema } = await createProject( `model User { id Int @id @default(autoincrement()) email String @unique posts Post[] @@schema('auth') } model Post { id Int @id @default(autoincrement()) title String author User @relation(fields: [authorId], references: [id], onDelete: Cascade) authorId Int @@schema('content') }`, { 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']} })); runCli('db pull --indent 4', workDir); const restoredSchema = getSchema(workDir); expect(restoredSchema).toEqual(schema); }); it('should preserve native PostgreSQL enums when schema exists', async ({ skip }) => { const provider = getTestDbProvider(); if (provider !== 'postgresql') { skip(); return; } const { workDir, schema } = await createProject( `model User { id Int @id @default(autoincrement()) email String @unique status Status @default(ACTIVE) role Role @default(USER) } enum Status { ACTIVE INACTIVE SUSPENDED } enum Role { USER ADMIN MODERATOR }`, { provider: 'postgresql' }, ); runCli('db push', workDir); runCli('db pull --indent 4', workDir); const pulledSchema = getSchema(workDir); expect(pulledSchema).toEqual(schema); }); it('should not modify schema with PostgreSQL-specific features', async ({ skip }) => { const provider = getTestDbProvider(); if (provider !== 'postgresql') { skip(); return; } const { workDir, schema } = await createProject( `model User { id Int @id @default(autoincrement()) email String @unique status Status @default(ACTIVE) posts Post[] metadata Json? @@schema('auth') @@index([status]) } model Post { id Int @id @default(autoincrement()) title String author User @relation(fields: [authorId], references: [id], onDelete: Cascade) authorId Int tags String[] @@schema('content') @@index([authorId]) } enum Status { ACTIVE INACTIVE SUSPENDED }`, { provider: 'postgresql', datasourceFields:{ schemas: ['public', 'content', 'auth'] } }, ); runCli('db push', workDir); runCli('db pull --indent 4', workDir); expect(getSchema(workDir)).toEqual(schema); }); it('should restore native type attributes from PostgreSQL typnames', async ({ skip }) => { const provider = getTestDbProvider(); if (provider !== 'postgresql') { skip(); return; } // PostgreSQL introspection returns typnames like 'int2', 'float8', 'bpchar', // but Prisma/ZenStack attributes are named @db.SmallInt, @db.DoublePrecision, @db.Char, etc. // This test verifies the mapping works correctly. // Note: Default native types (jsonb for Json, bytea for Bytes) are not added when pulling from zero // because they match the default database type for that field type. const { workDir } = await createProject( `model TypeTest { id Int @id @default(autoincrement()) smallNumber Int @db.SmallInt() realNumber Float @db.Real() doubleNum Float @db.DoublePrecision() fixedChar String @db.Char(10) uuid String @db.Uuid() jsonData Json @db.Json() jsonbData Json @db.JsonB() binaryData Bytes @db.ByteA() }`, { provider: 'postgresql' }, ); runCli('db push', workDir); const schemaFile = path.join(workDir, 'zenstack/schema.zmodel'); // Remove schema content to simulate restoration from zero fs.writeFileSync(schemaFile, getDefaultPrelude({ provider: 'postgresql' })); // Pull should restore non-default native type attributes // Default types (jsonb for Json, bytea for Bytes) are not added runCli('db pull --indent 4', workDir); const restoredSchema = getSchema(workDir); // Verify key native type mappings are restored correctly: // - @db.SmallInt for int2 (non-default for Int which defaults to integer/int4) // - @db.Real for float4 (non-default for Float which defaults to double precision/float8) // - @db.Char(10) for bpchar with length (non-default for String which defaults to text) // - @db.Uuid for uuid (non-default for String which defaults to text) // - @db.Json for json (non-default for Json which defaults to jsonb) expect(restoredSchema).toContain('@db.SmallInt'); expect(restoredSchema).toContain('@db.Real'); expect(restoredSchema).toContain('@db.Char(10)'); expect(restoredSchema).toContain('@db.Uuid'); expect(restoredSchema).toContain('@db.Json'); // Default types should NOT be added when pulling from zero expect(restoredSchema).not.toContain('@db.Integer'); // integer is default for Int expect(restoredSchema).not.toContain('@db.DoublePrecision'); // double precision is default for Float expect(restoredSchema).not.toContain('@db.JsonB'); // jsonb is default for Json expect(restoredSchema).not.toContain('@db.ByteA'); // bytea is default for Bytes }); it('should correctly map composite foreign key columns by position', async ({ skip }) => { const provider = getTestDbProvider(); if (provider !== 'postgresql') { skip(); return; } // Composite FK: (tenantId, authorId) REFERENCES Tenant(tenantId, userId) // The introspection must correlate by position, not match each source column // to every target column. Without the fix, tenantId would incorrectly map to // both tenantId AND userId in the target table. const { workDir, schema } = await createProject( `model Post { id Int @id @default(autoincrement()) title String tenant Tenant @relation(fields: [tenantId, authorId], references: [tenantId, userId], onDelete: Cascade) tenantId Int authorId Int @@index([tenantId, authorId]) } model Tenant { tenantId Int userId Int name String posts Post[] @@id([tenantId, userId]) }`, { provider: 'postgresql' }, ); runCli('db push', workDir); const schemaFile = path.join(workDir, 'zenstack/schema.zmodel'); fs.writeFileSync(schemaFile, getDefaultPrelude({ provider: 'postgresql' })); runCli('db pull --indent 4', workDir); const restoredSchema = getSchema(workDir); expect(restoredSchema).toEqual(schema); }); it('should pull stored generated columns as Unsupported with full expression', async ({ skip }) => { const provider = getTestDbProvider(); if (provider !== 'postgresql') { skip(); return; } // PostgreSQL supports GENERATED ALWAYS AS (expr) STORED since PG 12. // The introspection should include the full generation expression in the // datatype so it is rendered as Unsupported("type GENERATED ALWAYS AS (expr) STORED"). // 1. Create a project with a base table (we need the DB to exist first) const { workDir } = await createProject( `model ComputedUsers { id Int @id @default(autoincrement()) firstName String lastName String }`, { provider: 'postgresql' }, ); runCli('db push', workDir); // 2. Add a generated column via raw SQL (can't be defined in ZModel) const { Client } = await import('pg'); const dbName = getTestDbName('postgresql'); const client = new Client({ connectionString: getTestDbUrl('postgresql', dbName) }); await client.connect(); try { await client.query( `ALTER TABLE "ComputedUsers" ADD COLUMN "fullName" text GENERATED ALWAYS AS ("firstName" || ' ' || "lastName") STORED` ); } finally { await client.end(); } // 3. Pull from zero const schemaFile = path.join(workDir, 'zenstack/schema.zmodel'); fs.writeFileSync(schemaFile, getDefaultPrelude({ provider: 'postgresql' })); runCli('db pull --indent 4', workDir); const restoredSchema = getSchema(workDir); // 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' })} 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 }) => { const provider = getTestDbProvider(); if (provider !== 'postgresql') { skip(); return; } // PostgreSQL 17+ supports VIRTUAL generated columns. // For earlier versions, only STORED is supported, so this test may need to be // adapted. We test STORED here since it's universally supported. const { workDir } = await createProject( `model ComputedProducts { id Int @id @default(autoincrement()) price Int @default(0) qty Int @default(0) }`, { provider: 'postgresql' }, ); runCli('db push', workDir); const { Client } = await import('pg'); const dbName = getTestDbName('postgresql'); const client = new Client({ connectionString: getTestDbUrl('postgresql', dbName) }); await client.connect(); try { await client.query( `ALTER TABLE "ComputedProducts" ADD COLUMN "total" integer GENERATED ALWAYS AS ("price" * "qty") STORED` ); } finally { await client.end(); } const schemaFile = path.join(workDir, 'zenstack/schema.zmodel'); fs.writeFileSync(schemaFile, getDefaultPrelude({ provider: 'postgresql' })); runCli('db pull --indent 4', workDir); const restoredSchema = getSchema(workDir); 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')? }`)); }); }); describe('DB pull - MySQL specific features', () => { it('should detect single-column unique indexes via STATISTICS', async ({ skip }) => { const provider = getTestDbProvider(); if (provider !== 'mysql') { skip(); return; } // MySQL's COLUMN_KEY may not reliably reflect unique indexes in all cases. // The introspection should also check INFORMATION_SCHEMA.STATISTICS for // NON_UNIQUE = 0 single-column indexes to correctly detect uniqueness, // so that the index-processing skip logic (which checks index.unique + // single-column) doesn't cause a missing @unique attribute. const { workDir, schema } = await createProject( `model User { id Int @id @default(autoincrement()) email String @unique nickname String? @unique }`, { provider: 'mysql' }, ); runCli('db push', workDir); const schemaFile = path.join(workDir, 'zenstack/schema.zmodel'); // Pull from zero to test introspection detects unique columns correctly fs.writeFileSync(schemaFile, getDefaultPrelude({ provider: 'mysql' })); runCli('db pull --indent 4', workDir); const restoredSchema = getSchema(workDir); expect(restoredSchema).toEqual(schema); }); it('should pull stored generated columns as Unsupported with full expression', async ({ skip }) => { const provider = getTestDbProvider(); if (provider !== 'mysql') { skip(); return; } // MySQL supports both VIRTUAL and STORED generated columns. // The introspection should include the full generation expression in the // datatype so it is rendered as Unsupported("type GENERATED ALWAYS AS (expr) STORED"). // 1. Create a project with a base table (we need the DB to exist first) const { workDir } = await createProject( `model ComputedUsers { id Int @id @default(autoincrement()) firstName String @db.VarChar(255) lastName String @db.VarChar(255) }`, { provider: 'mysql' }, ); runCli('db push', workDir); // 2. Add a generated column via raw SQL (can't be defined in ZModel) const mysql = await import('mysql2/promise'); const dbName = getTestDbName('mysql'); 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" ); } finally { await connection.end(); } // 3. Pull from zero const schemaFile = path.join(workDir, 'zenstack/schema.zmodel'); fs.writeFileSync(schemaFile, getDefaultPrelude({ provider: 'mysql' })); runCli('db pull --indent 4', workDir); const restoredSchema = getSchema(workDir); // 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' })} 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 }) => { const provider = getTestDbProvider(); if (provider !== 'mysql') { skip(); return; } const { workDir } = await createProject( `model ComputedProducts { id Int @id @default(autoincrement()) price Int @default(0) qty Int @default(0) }`, { provider: 'mysql' }, ); runCli('db push', workDir); const mysql = await import('mysql2/promise'); const dbName = getTestDbName('mysql'); 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" ); } finally { await connection.end(); } const schemaFile = path.join(workDir, 'zenstack/schema.zmodel'); fs.writeFileSync(schemaFile, getDefaultPrelude({ provider: 'mysql' })); runCli('db pull --indent 4', workDir); const restoredSchema = getSchema(workDir); 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')? }`)); }); }); describe('DB pull - SQLite specific features', () => { it('should restore composite foreign key relations', async ({ skip }) => { const provider = getTestDbProvider(); if (provider !== 'sqlite') { skip(); return; } // Composite FK: (tenantId, authorId) REFERENCES Tenant(tenantId, userId). // The SQLite introspection extracts FK constraint names by parsing the // CREATE TABLE DDL. The current regex only captures a single column inside // FOREIGN KEY(...), so composite FK constraint names are lost. Without a // constraint name, the downstream relation grouping (pull/index.ts) skips // the FK columns entirely and the relation is not restored. const { workDir, schema } = await createProject( `model Post { id Int @id @default(autoincrement()) title String tenant Tenant @relation(fields: [tenantId, authorId], references: [tenantId, userId], onDelete: Cascade) tenantId Int authorId Int @@index([tenantId, authorId]) } model Tenant { tenantId Int userId Int name String posts Post[] @@id([tenantId, userId]) }`, ); 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 map columns without a declared type to Bytes', async ({ skip }) => { const provider = getTestDbProvider(); if (provider !== 'sqlite') { skip(); return; } // Create a minimal project and push to get the database file. 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, // which per affinity rules means BLOB affinity — should map to Bytes. const dbPath = path.join(workDir, 'zenstack', 'test.db'); const SQLite = (await import('better-sqlite3')).default; const db = new SQLite(dbPath); db.exec('CREATE TABLE "UntypedTest" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "data")'); db.close(); const schemaFile = path.join(workDir, 'zenstack/schema.zmodel'); fs.writeFileSync(schemaFile, getDefaultPrelude()); runCli('db pull --indent 4', workDir); const restoredSchema = getSchema(workDir); // The untyped "data" column should be pulled as Bytes (BLOB affinity), // not as Unsupported. expect(restoredSchema).toContain('data Bytes?'); expect(restoredSchema).not.toContain('Unsupported'); }); it('should pull stored generated columns as Unsupported', async ({ skip }) => { const provider = getTestDbProvider(); if (provider !== 'sqlite') { skip(); return; } // SQLite PRAGMA table_xinfo reports generated columns with hidden values: // hidden = 2 → VIRTUAL generated column // hidden = 3 → STORED generated column // Both types should be pulled as Unsupported("full type definition") // because generated columns are read-only and cannot be written to. const { workDir } = await createProject(''); const dbPath = path.join(workDir, 'zenstack', 'test.db'); const SQLite = (await import('better-sqlite3')).default; const db = new SQLite(dbPath); db.exec(` CREATE TABLE "ComputedUsers" ( "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "firstName" TEXT NOT NULL, "lastName" TEXT NOT NULL, "fullName" TEXT GENERATED ALWAYS AS (firstName || ' ' || lastName) STORED ) `); db.close(); const schemaFile = path.join(workDir, 'zenstack/schema.zmodel'); fs.writeFileSync(schemaFile, getDefaultPrelude()); runCli('db pull --indent 4', workDir); const restoredSchema = getSchema(workDir); // first_name and last_name should be regular String fields 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 }) => { const provider = getTestDbProvider(); if (provider !== 'sqlite') { skip(); return; } const { workDir } = await createProject(''); const dbPath = path.join(workDir, 'zenstack', 'test.db'); const SQLite = (await import('better-sqlite3')).default; const db = new SQLite(dbPath); db.exec(` CREATE TABLE "ComputedProducts" ( "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "price" INTEGER NOT NULL DEFAULT 0, "qty" INTEGER NOT NULL DEFAULT 0, "total" INTEGER GENERATED ALWAYS AS ("price" * "qty") VIRTUAL ) `); db.close(); 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(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')? }`)); }); }); describe('DB pull - SQL specific features', () => { it('should restore enum fields from zero', async ({ skip }) => { const provider = getTestDbProvider(); if (provider !== 'mysql' && provider !== 'postgresql') { skip(); return; } const { workDir, schema } = await createProject( `model User { id Int @id @default(autoincrement()) email String @unique status UserStatus @default(ACTIVE) } enum UserStatus { ACTIVE INACTIVE SUSPENDED }`); runCli('db push', workDir); const schemaFile = path.join(workDir, 'zenstack/schema.zmodel'); // Remove schema content to simulate restoration from zero fs.writeFileSync(schemaFile, getDefaultPrelude()); // Pull should fully restore the schema including enum fields runCli('db pull --indent 4', workDir); const restoredSchema = getSchema(workDir); expect(restoredSchema).toContain(`model User { id Int @id @default(autoincrement()) email String @unique status UserStatus @default(ACTIVE) }`); expect(restoredSchema).toContain(`enum UserStatus { ACTIVE INACTIVE SUSPENDED }`); }); });