diff --git a/frontend/packages/schema/src/parser/drizzle/__tests__/unified.test.ts b/frontend/packages/schema/src/parser/drizzle/__tests__/unified.test.ts index c9890b5c30..3a94f59a71 100644 --- a/frontend/packages/schema/src/parser/drizzle/__tests__/unified.test.ts +++ b/frontend/packages/schema/src/parser/drizzle/__tests__/unified.test.ts @@ -1117,6 +1117,199 @@ describe.each(Object.entries(dbConfigs))( expect(foreignKeyCount).toBe(2) }) + it('table-level foreignKey() with array syntax (v1)', async () => { + const schema = ` + import { ${config.functions.table}, ${config.types.id}, ${config.types.integer}, varchar, foreignKey } from '${config.imports.core}'; + + export const users = ${config.functions.table}('users', { + id: ${config.types.idColumn()}, + name: varchar('name', { length: 255 }), + }); + + export const posts = ${config.functions.table}('posts', { + id: ${config.types.idColumn()}, + authorId: ${config.types.integer}('author_id').notNull(), + title: varchar('title', { length: 255 }), + }, (table) => [ + foreignKey({ + columns: [table.authorId], + foreignColumns: [users.id], + }) + ]); + ` + + const { value } = await config.processor(schema) + + expect(value.tables['posts']?.constraints).toHaveProperty( + 'posts_author_id_users_id_fk', + ) + expect( + value.tables['posts']?.constraints['posts_author_id_users_id_fk'], + ).toEqual({ + type: 'FOREIGN KEY', + name: 'posts_author_id_users_id_fk', + columnNames: ['author_id'], + targetTableName: 'users', + targetColumnNames: ['id'], + updateConstraint: 'NO_ACTION', + deleteConstraint: 'NO_ACTION', + }) + }) + + it('table-level foreignKey() with custom name (v1)', async () => { + const schema = ` + import { ${config.functions.table}, ${config.types.id}, ${config.types.integer}, varchar, foreignKey } from '${config.imports.core}'; + + export const users = ${config.functions.table}('users', { + id: ${config.types.idColumn()}, + name: varchar('name', { length: 255 }), + }); + + export const posts = ${config.functions.table}('posts', { + id: ${config.types.idColumn()}, + authorId: ${config.types.integer}('author_id').notNull(), + title: varchar('title', { length: 255 }), + }, (table) => [ + foreignKey({ + columns: [table.authorId], + foreignColumns: [users.id], + name: 'custom_fk', + }) + ]); + ` + + const { value } = await config.processor(schema) + + expect(value.tables['posts']?.constraints).toHaveProperty('custom_fk') + expect(value.tables['posts']?.constraints['custom_fk']).toEqual({ + type: 'FOREIGN KEY', + name: 'custom_fk', + columnNames: ['author_id'], + targetTableName: 'users', + targetColumnNames: ['id'], + updateConstraint: 'NO_ACTION', + deleteConstraint: 'NO_ACTION', + }) + }) + + it('table-level foreignKey() with composite columns (v1)', async () => { + const schema = ` + import { ${config.functions.table}, ${config.types.id}, ${config.types.integer}, varchar, foreignKey } from '${config.imports.core}'; + + export const users = ${config.functions.table}('users', { + id: ${config.types.idColumn()}, + firstName: varchar('first_name', { length: 255 }).notNull(), + lastName: varchar('last_name', { length: 255 }).notNull(), + }); + + export const posts = ${config.functions.table}('posts', { + id: ${config.types.idColumn()}, + authorFirstName: varchar('author_first_name', { length: 255 }).notNull(), + authorLastName: varchar('author_last_name', { length: 255 }).notNull(), + title: varchar('title', { length: 255 }), + }, (table) => [ + foreignKey({ + columns: [table.authorFirstName, table.authorLastName], + foreignColumns: [users.firstName, users.lastName], + }) + ]); + ` + + const { value } = await config.processor(schema) + + const fkConstraint = Object.values( + value.tables['posts']?.constraints ?? {}, + ).find((c) => c.type === 'FOREIGN KEY') + expect(fkConstraint).toBeDefined() + if (fkConstraint && fkConstraint.type === 'FOREIGN KEY') { + expect(fkConstraint.columnNames).toEqual([ + 'author_first_name', + 'author_last_name', + ]) + expect(fkConstraint.targetColumnNames).toEqual([ + 'first_name', + 'last_name', + ]) + expect(fkConstraint.targetTableName).toBe('users') + } + }) + + it('table-level foreignKey() coexisting with index() in array syntax (v1)', async () => { + const schema = ` + import { ${config.functions.table}, ${config.types.id}, ${config.types.integer}, varchar, foreignKey, ${config.functions.index} } from '${config.imports.core}'; + + export const users = ${config.functions.table}('users', { + id: ${config.types.idColumn()}, + name: varchar('name', { length: 255 }), + }); + + export const posts = ${config.functions.table}('posts', { + id: ${config.types.idColumn()}, + authorId: ${config.types.integer}('author_id').notNull(), + title: varchar('title', { length: 255 }), + }, (table) => [ + foreignKey({ + columns: [table.authorId], + foreignColumns: [users.id], + }), + ${config.functions.index}('posts_author_idx').on(table.authorId), + ]); + ` + + const { value } = await config.processor(schema) + + // Verify foreign key constraint + const fkConstraint = Object.values( + value.tables['posts']?.constraints ?? {}, + ).find((c) => c.type === 'FOREIGN KEY') + expect(fkConstraint).toBeDefined() + + // Verify index + expect(value.tables['posts']?.indexes).toHaveProperty( + 'posts_author_idx', + ) + }) + + it('table-level foreignKey() where variable name differs from table name (v1)', async () => { + const schema = ` + import { ${config.functions.table}, ${config.types.id}, ${config.types.integer}, varchar, foreignKey } from '${config.imports.core}'; + + export const usersTable = ${config.functions.table}('users', { + id: ${config.types.idColumn()}, + name: varchar('name', { length: 255 }), + }); + + export const posts = ${config.functions.table}('posts', { + id: ${config.types.idColumn()}, + authorId: ${config.types.integer}('author_id').notNull(), + title: varchar('title', { length: 255 }), + }, (table) => [ + foreignKey({ + columns: [table.authorId], + foreignColumns: [usersTable.id], + }) + ]); + ` + + const { value } = await config.processor(schema) + + // The auto-generated constraint name uses the JS variable name ('usersTable'), + // not the DB table name ('users'). This is consistent with inline .references() FK behavior. + expect(value.tables['posts']?.constraints).toHaveProperty( + 'posts_author_id_usersTable_id_fk', + ) + + const fkConstraint = + value.tables['posts']?.constraints['posts_author_id_usersTable_id_fk'] + expect(fkConstraint).toBeDefined() + if (fkConstraint && fkConstraint.type === 'FOREIGN KEY') { + // targetTableName resolves to the actual DB table name via variableToTableMapping + expect(fkConstraint.targetTableName).toBe('users') + expect(fkConstraint.columnNames).toEqual(['author_id']) + expect(fkConstraint.targetColumnNames).toEqual(['id']) + } + }) + // table-level unique constraints test // TODO: postgres - add support for unique() function to enable this test for PostgreSQL if (dbType === 'mysql') { @@ -1160,6 +1353,221 @@ describe.each(Object.entries(dbConfigs))( }) }) } + + it('table-level foreignKey() with onDelete/onUpdate in array syntax (v1)', async () => { + const schema = ` + import { ${config.functions.table}, ${config.types.id}, ${config.types.integer}, varchar, foreignKey } from '${config.imports.core}'; + + export const users = ${config.functions.table}('users', { + id: ${config.types.idColumn()}, + name: varchar('name', { length: 255 }), + }); + + export const posts = ${config.functions.table}('posts', { + id: ${config.types.idColumn()}, + authorId: ${config.types.integer}('author_id').notNull(), + title: varchar('title', { length: 255 }), + }, (table) => [ + foreignKey({ + columns: [table.authorId], + foreignColumns: [users.id], + }).onDelete('cascade').onUpdate('restrict') + ]); + ` + + const { value } = await config.processor(schema) + + const fkConstraint = Object.values( + value.tables['posts']?.constraints ?? {}, + ).find((c) => c.type === 'FOREIGN KEY') + expect(fkConstraint).toBeDefined() + if (fkConstraint && fkConstraint.type === 'FOREIGN KEY') { + expect(fkConstraint.deleteConstraint).toBe('CASCADE') + expect(fkConstraint.updateConstraint).toBe('RESTRICT') + } + }) + + // MySQL and PostgreSQL: check() and unique() in array syntax + it('index() in array syntax (v1)', async () => { + const schema = ` + import { ${config.functions.table}, ${config.types.id}, varchar, ${config.functions.index} } from '${config.imports.core}'; + + export const users = ${config.functions.table}('users', { + id: ${config.types.idColumn()}, + firstName: varchar('first_name', { length: 255 }), + lastName: varchar('last_name', { length: 255 }), + email: varchar('email', { length: 255 }), + }, (table) => [ + ${config.functions.index}('name_idx').on(table.firstName, table.lastName), + ${config.functions.index}('email_idx').on(table.email), + ]); + ` + + const { value } = await config.processor(schema) + + expect(value.tables['users']?.indexes).toHaveProperty('name_idx') + expect(value.tables['users']?.indexes['name_idx']).toEqual( + anIndex({ + name: 'name_idx', + columns: ['first_name', 'last_name'], + unique: false, + }), + ) + expect(value.tables['users']?.indexes).toHaveProperty('email_idx') + expect(value.tables['users']?.indexes['email_idx']).toEqual( + anIndex({ + name: 'email_idx', + columns: ['email'], + unique: false, + }), + ) + }) + + it('uniqueIndex() in array syntax (v1)', async () => { + const schema = ` + import { ${config.functions.table}, ${config.types.id}, varchar, ${config.functions.uniqueIndex} } from '${config.imports.core}'; + + export const users = ${config.functions.table}('users', { + id: ${config.types.idColumn()}, + email: varchar('email', { length: 255 }), + }, (table) => [ + ${config.functions.uniqueIndex}('email_unique_idx').on(table.email), + ]); + ` + + const { value } = await config.processor(schema) + + expect(value.tables['users']?.indexes['email_unique_idx']).toEqual( + anIndex({ + name: 'email_unique_idx', + columns: ['email'], + unique: true, + }), + ) + }) + + it('composite primaryKey() in array syntax (v1)', async () => { + const schema = ` + import { ${config.functions.table}, ${config.types.integer}, ${config.functions.primaryKey} } from '${config.imports.core}'; + + export const orderItems = ${config.functions.table}('order_items', { + orderId: ${config.types.integer}('order_id').notNull(), + itemId: ${config.types.integer}('item_id').notNull(), + quantity: ${config.types.integer}('quantity'), + }, (table) => [ + ${config.functions.primaryKey}({ columns: [table.orderId, table.itemId] }), + ]); + ` + + const { value } = await config.processor(schema) + + expect(value.tables['order_items']?.constraints).toHaveProperty( + 'order_items_pkey', + ) + expect( + value.tables['order_items']?.constraints['order_items_pkey'], + ).toEqual({ + type: 'PRIMARY KEY', + name: 'order_items_pkey', + columnNames: ['order_id', 'item_id'], + }) + }) + + it('composite primaryKey() with custom name in array syntax (v1)', async () => { + const schema = ` + import { ${config.functions.table}, ${config.types.integer}, ${config.functions.primaryKey} } from '${config.imports.core}'; + + export const booksToAuthors = ${config.functions.table}('books_to_authors', { + authorId: ${config.types.integer}('author_id').notNull(), + bookId: ${config.types.integer}('book_id').notNull(), + }, (table) => [ + ${config.functions.primaryKey}({ name: 'books_authors_pk', columns: [table.bookId, table.authorId] }), + ]); + ` + + const { value } = await config.processor(schema) + + expect(value.tables['books_to_authors']?.constraints).toHaveProperty( + 'books_authors_pk', + ) + expect( + value.tables['books_to_authors']?.constraints['books_authors_pk'], + ).toEqual({ + type: 'PRIMARY KEY', + name: 'books_authors_pk', + columnNames: ['book_id', 'author_id'], + }) + }) + + if (dbType === 'mysql' || dbType === 'postgres') { + it('table-level check() in array syntax (v1)', async () => { + const schema = ` + import { ${config.functions.table}, ${config.types.id}, ${config.types.integer}, ${config.functions.check}, sql } from '${config.imports.core}'; + + export const users = ${config.functions.table}('users', { + id: ${config.types.idColumn()}, + age: ${config.types.integer}('age').notNull(), + }, (table) => [ + ${config.functions.check}('age_check', sql\`age >= 0 AND age <= 150\`), + ]); + ` + + const { value } = await config.processor(schema) + + expect(value.tables['users']?.constraints).toHaveProperty('age_check') + expect(value.tables['users']?.constraints['age_check']).toEqual({ + type: 'CHECK', + name: 'age_check', + detail: 'age >= 0 AND age <= 150', + }) + }) + + it('table-level check() with column interpolation in sql template (v1)', async () => { + const schema = ` + import { ${config.functions.table}, ${config.types.id}, ${config.types.integer}, ${config.functions.check}, sql } from '${config.imports.core}'; + + export const users = ${config.functions.table}('users', { + id: ${config.types.idColumn()}, + age: ${config.types.integer}('age').notNull(), + }, (table) => [ + ${config.functions.check}('age_check', sql\`\${table.age} >= 0 AND \${table.age} <= 150\`), + ]); + ` + + const { value } = await config.processor(schema) + + expect(value.tables['users']?.constraints).toHaveProperty('age_check') + expect(value.tables['users']?.constraints['age_check']).toEqual({ + type: 'CHECK', + name: 'age_check', + detail: 'age >= 0 AND age <= 150', + }) + }) + + it('table-level unique() in array syntax (v1)', async () => { + const schema = ` + import { ${config.functions.table}, ${config.types.id}, varchar, unique } from '${config.imports.core}'; + + export const users = ${config.functions.table}('users', { + id: ${config.types.idColumn()}, + email: varchar('email', { length: 255 }), + }, (table) => [ + unique('email_unique').on(table.email), + ]); + ` + + const { value } = await config.processor(schema) + + expect(value.tables['users']?.constraints).toHaveProperty( + 'email_unique', + ) + expect(value.tables['users']?.constraints['email_unique']).toEqual({ + type: 'UNIQUE', + name: 'email_unique', + columnNames: ['email'], + }) + }) + } }) }, ) diff --git a/frontend/packages/schema/src/parser/drizzle/mysql/astUtils.ts b/frontend/packages/schema/src/parser/drizzle/mysql/astUtils.ts index 5c5f693f19..2c83055623 100644 --- a/frontend/packages/schema/src/parser/drizzle/mysql/astUtils.ts +++ b/frontend/packages/schema/src/parser/drizzle/mysql/astUtils.ts @@ -9,6 +9,7 @@ import type { Import, ObjectExpression, Super, + TemplateLiteral, } from '@swc/core' import { getPropertyValue, hasProperty, isObject } from './types.js' @@ -153,6 +154,113 @@ export const getIdentifierName = (node: Expression): string | null => { return null } +/** + * Check if a call expression calls a function with a specific name + */ +export const isCallToFunction = ( + callExpr: CallExpression, + functionName: string, +): boolean => { + return ( + isIdentifier(callExpr.callee) && + callExpr.callee.value === functionName && + callExpr.arguments.length > 0 + ) +} + +/** + * Extract column names from an array expression of member expressions + * Handles patterns like: [table.column1, table.column2] + */ +export const extractColumnNames = (expr: Expression): string[] => { + if (!isArrayExpression(expr)) return [] + + const columns: string[] = [] + + for (const elem of expr.elements) { + if (!elem) continue + const elemExpr = getArgumentExpression(elem) + if (!elemExpr) continue + if (!isMemberExpression(elemExpr)) continue + if (!isIdentifier(elemExpr.property)) continue + columns.push(elemExpr.property.value) + } + + return columns +} + +/** + * Extract table and column names from member expressions in an array + * Returns the first table name found and all column names + */ +export const extractTableAndColumns = ( + expr: Expression, +): { tableName: string | null; columnNames: string[] } => { + if (!isArrayExpression(expr)) { + return { tableName: null, columnNames: [] } + } + + const columnNames: string[] = [] + let tableName: string | null = null + + for (const elem of expr.elements) { + if (!elem) continue + const elemExpr = getArgumentExpression(elem) + + if ( + elemExpr && + isMemberExpression(elemExpr) && + isIdentifier(elemExpr.object) && + isIdentifier(elemExpr.property) + ) { + tableName = tableName ?? elemExpr.object.value + columnNames.push(elemExpr.property.value) + } + } + + return { tableName, columnNames } +} + +/** + * Reconstruct a sql template literal condition string from quasis and expressions. + * For MemberExpression like table.age, extracts the JS property name (e.g., "age"). + * Falls back to "?" for other expression types. + * e.g. sql`${table.age} >= 0` → "age >= 0" + */ +export const reconstructSqlTemplate = (template: TemplateLiteral): string => { + const parts: string[] = [] + for (let i = 0; i < template.quasis.length; i++) { + const quasi = template.quasis[i] + if (quasi) parts.push(quasi.raw) + if (i < template.expressions.length) { + const expr = template.expressions[i] + if ( + expr && + expr.type === 'MemberExpression' && + expr.property.type === 'Identifier' + ) { + parts.push(expr.property.value) + } else { + parts.push('?') + } + } + } + return parts.join('') +} + +/** + * Extract configuration object from a call expression's first argument + */ +export const extractConfigObject = ( + callExpr: CallExpression, +): ObjectExpression | null => { + if (callExpr.arguments.length === 0) return null + + const configArg = callExpr.arguments[0] + const configExpr = getArgumentExpression(configArg) + return isObjectExpression(configExpr) ? configExpr : null +} + /** * Parse method call chain from a call expression */ diff --git a/frontend/packages/schema/src/parser/drizzle/mysql/converter.ts b/frontend/packages/schema/src/parser/drizzle/mysql/converter.ts index fe6774fbd7..512e276a8b 100644 --- a/frontend/packages/schema/src/parser/drizzle/mysql/converter.ts +++ b/frontend/packages/schema/src/parser/drizzle/mysql/converter.ts @@ -137,7 +137,8 @@ const convertToTable = ( .filter((name) => name && name.length > 0) // Create composite primary key constraint - const constraintName = `${tableDef.name}_pkey` + const constraintName = + tableDef.compositePrimaryKey.name ?? `${tableDef.name}_pkey` constraints[constraintName] = { type: 'PRIMARY KEY', name: constraintName, @@ -153,6 +154,35 @@ const convertToTable = ( } } + // Handle table-level foreign key definitions + if (tableDef.foreignKeys) { + for (const fkDef of tableDef.foreignKeys) { + const targetTableName = + variableToTableMapping[fkDef.targetTable] ?? fkDef.targetTable + const columnNames = fkDef.columns.map( + (jsName) => tableDef.columns[jsName]?.name ?? jsName, + ) + const constraintName = + fkDef.name ?? + `${tableDef.name}_${columnNames.join('_')}_${fkDef.targetTable}_${fkDef.targetColumns.join('_')}_fk` + + const constraint: ForeignKeyConstraint = { + type: 'FOREIGN KEY', + name: constraintName, + columnNames, + targetTableName, + targetColumnNames: fkDef.targetColumns, + updateConstraint: fkDef.onUpdate + ? convertReferenceOption(fkDef.onUpdate) + : 'NO_ACTION', + deleteConstraint: fkDef.onDelete + ? convertReferenceOption(fkDef.onDelete) + : 'NO_ACTION', + } + constraints[constraintName] = constraint + } + } + // Convert indexes for (const [_, indexDef] of Object.entries(tableDef.indexes)) { // Map JS property names to actual column names diff --git a/frontend/packages/schema/src/parser/drizzle/mysql/tableParser.ts b/frontend/packages/schema/src/parser/drizzle/mysql/tableParser.ts index 5407c22475..2814fff878 100644 --- a/frontend/packages/schema/src/parser/drizzle/mysql/tableParser.ts +++ b/frontend/packages/schema/src/parser/drizzle/mysql/tableParser.ts @@ -5,13 +5,18 @@ import type { CallExpression, Expression, ObjectExpression } from '@swc/core' import type { Constraint } from '../../../schema/index.js' import { + extractColumnNames, + extractConfigObject, + extractTableAndColumns, getArgumentExpression, getIdentifierName, getStringValue, + isCallToFunction, isMysqlTableCall, isObjectExpression, isSchemaTableCall, isStringLiteral, + reconstructSqlTemplate, } from './astUtils.js' import { parseColumnFromProperty } from './columnParser.js' import { parseObjectExpression } from './expressionParser.js' @@ -20,6 +25,7 @@ import type { DrizzleCheckConstraintDefinition, DrizzleColumnDefinition, DrizzleEnumDefinition, + DrizzleForeignKeyDefinition, DrizzleIndexDefinition, DrizzleTableDefinition, } from './types.js' @@ -61,12 +67,14 @@ const parseTableExtensions = ( indexes: Record constraints?: Record compositePrimaryKey?: CompositePrimaryKeyDefinition + foreignKeys?: DrizzleForeignKeyDefinition[] // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: TODO: Refactor to reduce complexity } => { const result: { indexes: Record constraints?: Record compositePrimaryKey?: CompositePrimaryKeyDefinition + foreignKeys?: DrizzleForeignKeyDefinition[] } = { indexes: {}, } @@ -123,6 +131,48 @@ const parseTableExtensions = ( } } } + } else if (returnExpr.type === 'ArrayExpression') { + for (const element of returnExpr.elements) { + if (!element) continue + const elemExpr = getArgumentExpression(element) + if (elemExpr?.type === 'CallExpression') { + const fkDef = parseForeignKeyDefinition(elemExpr) + if (fkDef) { + result.foreignKeys = result.foreignKeys ?? [] + result.foreignKeys.push(fkDef) + } + const indexDef = parseIndexDefinition(elemExpr, 'auto') + if (indexDef) { + if (isCompositePrimaryKey(indexDef)) { + result.compositePrimaryKey = indexDef + } else if (isDrizzleIndex(indexDef)) { + result.indexes[indexDef.name] = indexDef + } + } + const checkConstraint = parseCheckConstraint(elemExpr, 'auto') + if (checkConstraint) { + result.constraints = result.constraints ?? {} + result.constraints[checkConstraint.name] = { + type: 'CHECK', + name: checkConstraint.name, + detail: checkConstraint.condition, + } + } + const uniqueConstraint = parseUniqueConstraint( + elemExpr, + 'auto', + tableColumns, + ) + if (uniqueConstraint) { + result.constraints = result.constraints ?? {} + result.constraints[uniqueConstraint.name] = { + type: 'UNIQUE', + name: uniqueConstraint.name, + columnNames: uniqueConstraint.columnNames, + } + } + } + } } } @@ -208,6 +258,9 @@ export const parseMysqlTableCall = ( if (extensions.compositePrimaryKey) { table.compositePrimaryKey = extensions.compositePrimaryKey } + if (extensions.foreignKeys) { + table.foreignKeys = extensions.foreignKeys + } } } @@ -266,12 +319,118 @@ export const parseSchemaTableCall = ( if (extensions.compositePrimaryKey) { table.compositePrimaryKey = extensions.compositePrimaryKey } + if (extensions.foreignKeys) { + table.foreignKeys = extensions.foreignKeys + } } } return table } +/** + * Parse foreignKey() call expression + */ +const parseForeignKeyDefinition = ( + callExpr: CallExpression, +): DrizzleForeignKeyDefinition | null => { + let currentExpr: Expression = callExpr + const methodCalls: Array<{ method: string; expr: CallExpression }> = [] + + // Traverse the method chain to find foreignKey(), onDelete(), and onUpdate() calls + while ( + currentExpr.type === 'CallExpression' && + currentExpr.callee.type === 'MemberExpression' && + currentExpr.callee.property.type === 'Identifier' + ) { + const methodName = currentExpr.callee.property.value + methodCalls.unshift({ method: methodName, expr: currentExpr }) + currentExpr = currentExpr.callee.object + } + + if (currentExpr.type !== 'CallExpression') return null + if (!isCallToFunction(currentExpr, 'foreignKey')) return null + + const configExpr = extractConfigObject(currentExpr) + if (!configExpr) return null + + const definition: Partial = + parseForeignKeyConfig(configExpr) + + for (const { method, expr } of methodCalls) { + const arg = expr.arguments[0] + ? getArgumentExpression(expr.arguments[0]) + : null + if ( + (method === 'onDelete' || method === 'onUpdate') && + arg && + isStringLiteral(arg) + ) { + definition[method] = arg.value + } + } + + return isValidForeignKeyDefinition(definition) ? definition : null +} + +/** + * Parse foreignKey configuration object + */ +const parseForeignKeyProp = ( + propName: string, + propValue: Expression, +): Partial => { + switch (propName) { + case 'name': + return isStringLiteral(propValue) ? { name: propValue.value } : {} + case 'columns': + return { columns: extractColumnNames(propValue) } + case 'foreignColumns': { + const { tableName, columnNames } = extractTableAndColumns(propValue) + return { + ...(tableName !== null ? { targetTable: tableName } : {}), + targetColumns: columnNames, + } + } + default: + return {} + } +} + +const parseForeignKeyConfig = ( + configExpr: ObjectExpression, +): Partial => { + let definition: Partial = { + columns: [], + targetColumns: [], + targetTable: '', + } + + for (const prop of configExpr.properties) { + if (prop.type !== 'KeyValueProperty') continue + + const propName = prop.key.type === 'Identifier' ? prop.key.value : null + if (!propName) continue + + definition = { ...definition, ...parseForeignKeyProp(propName, prop.value) } + } + + return definition +} + +/** + * Validate foreign key definition + */ +const isValidForeignKeyDefinition = ( + definition: Partial, +): definition is DrizzleForeignKeyDefinition => { + return !!( + definition.targetTable && + definition.columns?.length && + definition.targetColumns?.length + ) +} + /** * Parse index or primary key definition */ @@ -294,8 +453,11 @@ const parseIndexDefinition = ( const columns = config['columns'].filter( (col): col is string => typeof col === 'string', ) + const pkName = + typeof config['name'] === 'string' ? config['name'] : undefined return { type: 'primaryKey', + ...(pkName !== undefined && { name: pkName }), columns, } } @@ -419,28 +581,7 @@ const parseCheckConstraint = ( conditionExpr.tag.type === 'Identifier' && conditionExpr.tag.value === 'sql' ) { - // Extract the condition from template literal - if ( - conditionExpr.template.type === 'TemplateLiteral' && - conditionExpr.template.quasis.length > 0 - ) { - const firstQuasi = conditionExpr.template.quasis[0] - if (firstQuasi && firstQuasi.type === 'TemplateElement') { - // SWC TemplateElement has different structure than TypeScript's - // We need to access the raw string from the SWC AST structure - // Use property access with type checking to avoid type assertions - const hasRaw = - 'raw' in firstQuasi && typeof firstQuasi.raw === 'string' - const hasCooked = - 'cooked' in firstQuasi && typeof firstQuasi.cooked === 'string' - - if (hasRaw) { - condition = firstQuasi.raw || '' - } else if (hasCooked) { - condition = firstQuasi.cooked || '' - } - } - } + condition = reconstructSqlTemplate(conditionExpr.template) } // Handle direct function call like sql('condition') else if ( diff --git a/frontend/packages/schema/src/parser/drizzle/mysql/types.ts b/frontend/packages/schema/src/parser/drizzle/mysql/types.ts index cf88caa0d5..785136a64a 100644 --- a/frontend/packages/schema/src/parser/drizzle/mysql/types.ts +++ b/frontend/packages/schema/src/parser/drizzle/mysql/types.ts @@ -10,6 +10,7 @@ export type DrizzleTableDefinition = { indexes: Record constraints?: Record compositePrimaryKey?: CompositePrimaryKeyDefinition + foreignKeys?: DrizzleForeignKeyDefinition[] comment?: string | undefined schemaName?: string // Schema name for namespace handling } @@ -58,9 +59,19 @@ export type DrizzleSchemaDefinition = { export type CompositePrimaryKeyDefinition = { type: 'primaryKey' + name?: string columns: string[] } +export type DrizzleForeignKeyDefinition = { + name?: string + columns: string[] + targetTable: string + targetColumns: string[] + onDelete?: string + onUpdate?: string +} + /** * Type guard to check if a value is an object */ diff --git a/frontend/packages/schema/src/parser/drizzle/postgres/astUtils.ts b/frontend/packages/schema/src/parser/drizzle/postgres/astUtils.ts index eee0914683..42ecd2b498 100644 --- a/frontend/packages/schema/src/parser/drizzle/postgres/astUtils.ts +++ b/frontend/packages/schema/src/parser/drizzle/postgres/astUtils.ts @@ -9,6 +9,7 @@ import type { Import, ObjectExpression, Super, + TemplateLiteral, } from '@swc/core' import { getPropertyValue, hasProperty, isObject } from './types.js' @@ -195,3 +196,112 @@ export const parseMethodChain = ( return methods } + +/** + * Check if a call expression calls a function with a specific name + */ +export const isCallToFunction = ( + callExpr: CallExpression, + functionName: string, +): boolean => { + return ( + isIdentifier(callExpr.callee) && + callExpr.callee.value === functionName && + callExpr.arguments.length > 0 + ) +} + +/** + * Extract column names from an array expression of member expressions + * Handles patterns like: [table.column1, table.column2] + */ +export const extractColumnNames = (expr: Expression): string[] => { + if (!isArrayExpression(expr)) return [] + + const columns: string[] = [] + + for (const elem of expr.elements) { + if (!elem) continue + const elemExpr = getArgumentExpression(elem) + if (!elemExpr) continue + if (!isMemberExpression(elemExpr)) continue + if (!isIdentifier(elemExpr.property)) continue + + columns.push(elemExpr.property.value) + } + + return columns +} + +/** + * Extract table and column names from member expressions in an array + * Returns the first table name found and all column names + * Handles patterns like: [table.col1, table.col2] or mixed [table1.col1, table2.col2] + */ +export const extractTableAndColumns = ( + expr: Expression, +): { tableName: string | null; columnNames: string[] } => { + if (!isArrayExpression(expr)) { + return { tableName: null, columnNames: [] } + } + + const columnNames: string[] = [] + let tableName: string | null = null + + for (const elem of expr.elements) { + if (!elem) continue + const elemExpr = getArgumentExpression(elem) + + if ( + elemExpr && + isMemberExpression(elemExpr) && + isIdentifier(elemExpr.object) && + isIdentifier(elemExpr.property) + ) { + tableName = tableName ?? elemExpr.object.value + columnNames.push(elemExpr.property.value) + } + } + + return { tableName, columnNames } +} + +/** + * Reconstruct a sql template literal condition string from quasis and expressions. + * For MemberExpression like table.age, extracts the JS property name (e.g., "age"). + * Falls back to "?" for other expression types. + * e.g. sql`${table.age} >= 0` → "age >= 0" + */ +export const reconstructSqlTemplate = (template: TemplateLiteral): string => { + const parts: string[] = [] + for (let i = 0; i < template.quasis.length; i++) { + const quasi = template.quasis[i] + if (quasi) parts.push(quasi.raw) + if (i < template.expressions.length) { + const expr = template.expressions[i] + if ( + expr && + expr.type === 'MemberExpression' && + expr.property.type === 'Identifier' + ) { + parts.push(expr.property.value) + } else { + parts.push('?') + } + } + } + return parts.join('') +} + +/** + * Extract configuration object from a call expression's first argument + */ +export const extractConfigObject = ( + callExpr: CallExpression, +): ObjectExpression | null => { + if (callExpr.arguments.length === 0) return null + + const configArg = callExpr.arguments[0] + const configExpr = getArgumentExpression(configArg) + return isObjectExpression(configExpr) ? configExpr : null +} diff --git a/frontend/packages/schema/src/parser/drizzle/postgres/converter.ts b/frontend/packages/schema/src/parser/drizzle/postgres/converter.ts index 265552bdef..32d7f9c571 100644 --- a/frontend/packages/schema/src/parser/drizzle/postgres/converter.ts +++ b/frontend/packages/schema/src/parser/drizzle/postgres/converter.ts @@ -135,7 +135,8 @@ const convertToTable = ( .filter((name) => name && name.length > 0) // Create composite primary key constraint - const constraintName = `${tableDef.name}_pkey` + const constraintName = + tableDef.compositePrimaryKey.name ?? `${tableDef.name}_pkey` constraints[constraintName] = { type: 'PRIMARY KEY', name: constraintName, @@ -151,6 +152,44 @@ const convertToTable = ( } } + // Handle table-level foreign key definitions + if (tableDef.foreignKeys) { + for (const fkDef of tableDef.foreignKeys) { + const targetTableName = + variableToTableMapping[fkDef.targetTable] ?? fkDef.targetTable + const columnNames = fkDef.columns.map( + (jsName) => tableDef.columns[jsName]?.name ?? jsName, + ) + const constraintName = + fkDef.name ?? + `${tableDef.name}_${columnNames.join('_')}_${fkDef.targetTable}_${fkDef.targetColumns.join('_')}_fk` + + const constraint: ForeignKeyConstraint = { + type: 'FOREIGN KEY', + name: constraintName, + columnNames, + targetTableName, + targetColumnNames: fkDef.targetColumns, + updateConstraint: fkDef.onUpdate + ? convertReferenceOption(fkDef.onUpdate) + : 'NO_ACTION', + deleteConstraint: fkDef.onDelete + ? convertReferenceOption(fkDef.onDelete) + : 'NO_ACTION', + } + constraints[constraintName] = constraint + } + } + + // Convert constraints from Drizzle table definition + if (tableDef.constraints) { + for (const [constraintName, constraintDef] of Object.entries( + tableDef.constraints, + )) { + constraints[constraintName] = constraintDef + } + } + // Convert indexes for (const [_, indexDef] of Object.entries(tableDef.indexes)) { // Map JS property names to actual column names diff --git a/frontend/packages/schema/src/parser/drizzle/postgres/tableParser.ts b/frontend/packages/schema/src/parser/drizzle/postgres/tableParser.ts index d086146ee8..ce4042fdd2 100644 --- a/frontend/packages/schema/src/parser/drizzle/postgres/tableParser.ts +++ b/frontend/packages/schema/src/parser/drizzle/postgres/tableParser.ts @@ -2,20 +2,28 @@ * Table structure parsing for Drizzle ORM schema parsing */ -import type { CallExpression, Expression } from '@swc/core' +import type { CallExpression, Expression, ObjectExpression } from '@swc/core' import { + extractColumnNames, + extractConfigObject, extractPgTableFromChain, + extractTableAndColumns, getArgumentExpression, getIdentifierName, getStringValue, + isCallToFunction, isObjectExpression, isSchemaTableCall, isStringLiteral, + reconstructSqlTemplate, } from './astUtils.js' import { parseColumnFromProperty } from './columnParser.js' import { parseObjectExpression } from './expressionParser.js' import type { CompositePrimaryKeyDefinition, + DrizzleCheckConstraintDefinition, + DrizzleColumnDefinition, + DrizzleForeignKeyDefinition, DrizzleIndexDefinition, DrizzleTableDefinition, } from './types.js' @@ -131,6 +139,48 @@ export const parsePgTableCall = ( } } } + } else if (returnExpr.type === 'ArrayExpression') { + for (const element of returnExpr.elements) { + if (!element) continue + const elemExpr = getArgumentExpression(element) + if (elemExpr?.type === 'CallExpression') { + const fkDef = parseForeignKeyDefinition(elemExpr) + if (fkDef) { + table.foreignKeys = table.foreignKeys ?? [] + table.foreignKeys.push(fkDef) + } + const indexDef = parseIndexDefinition(elemExpr, 'auto') + if (indexDef) { + if (isCompositePrimaryKey(indexDef)) { + table.compositePrimaryKey = indexDef + } else if (isDrizzleIndex(indexDef)) { + table.indexes[indexDef.name] = indexDef + } + } + const checkConstraint = parseCheckConstraint(elemExpr, 'auto') + if (checkConstraint) { + table.constraints = table.constraints ?? {} + table.constraints[checkConstraint.name] = { + type: 'CHECK', + name: checkConstraint.name, + detail: checkConstraint.condition, + } + } + const uniqueConstraint = parseUniqueConstraint( + elemExpr, + 'auto', + table.columns, + ) + if (uniqueConstraint) { + table.constraints = table.constraints ?? {} + table.constraints[uniqueConstraint.name] = { + type: 'UNIQUE', + name: uniqueConstraint.name, + columnNames: uniqueConstraint.columnNames, + } + } + } + } } } } @@ -214,6 +264,48 @@ export const parseSchemaTableCall = ( } } } + } else if (returnExpr.type === 'ArrayExpression') { + for (const element of returnExpr.elements) { + if (!element) continue + const elemExpr = getArgumentExpression(element) + if (elemExpr?.type === 'CallExpression') { + const fkDef = parseForeignKeyDefinition(elemExpr) + if (fkDef) { + table.foreignKeys = table.foreignKeys ?? [] + table.foreignKeys.push(fkDef) + } + const indexDef = parseIndexDefinition(elemExpr, 'auto') + if (indexDef) { + if (isCompositePrimaryKey(indexDef)) { + table.compositePrimaryKey = indexDef + } else if (isDrizzleIndex(indexDef)) { + table.indexes[indexDef.name] = indexDef + } + } + const checkConstraint = parseCheckConstraint(elemExpr, 'auto') + if (checkConstraint) { + table.constraints = table.constraints ?? {} + table.constraints[checkConstraint.name] = { + type: 'CHECK', + name: checkConstraint.name, + detail: checkConstraint.condition, + } + } + const uniqueConstraint = parseUniqueConstraint( + elemExpr, + 'auto', + table.columns, + ) + if (uniqueConstraint) { + table.constraints = table.constraints ?? {} + table.constraints[uniqueConstraint.name] = { + type: 'UNIQUE', + name: uniqueConstraint.name, + columnNames: uniqueConstraint.columnNames, + } + } + } + } } } } @@ -221,6 +313,109 @@ export const parseSchemaTableCall = ( return table } +/** + * Parse foreignKey() call expression + */ +const parseForeignKeyDefinition = ( + callExpr: CallExpression, +): DrizzleForeignKeyDefinition | null => { + let currentExpr: Expression = callExpr + const methodCalls: Array<{ method: string; expr: CallExpression }> = [] + + // Traverse the method chain to find foreignKey(), onDelete(), and onUpdate() calls + while ( + currentExpr.type === 'CallExpression' && + currentExpr.callee.type === 'MemberExpression' && + currentExpr.callee.property.type === 'Identifier' + ) { + const methodName = currentExpr.callee.property.value + methodCalls.unshift({ method: methodName, expr: currentExpr }) + currentExpr = currentExpr.callee.object + } + + if (currentExpr.type !== 'CallExpression') return null + if (!isCallToFunction(currentExpr, 'foreignKey')) return null + + const configExpr = extractConfigObject(currentExpr) + if (!configExpr) return null + + const definition: Partial = + parseForeignKeyConfig(configExpr) + + for (const { method, expr } of methodCalls) { + const arg = expr.arguments[0] + ? getArgumentExpression(expr.arguments[0]) + : null + if ( + (method === 'onDelete' || method === 'onUpdate') && + arg && + isStringLiteral(arg) + ) { + definition[method] = arg.value + } + } + + return isValidForeignKeyDefinition(definition) ? definition : null +} + +/** + * Parse foreignKey configuration object + */ +const parseForeignKeyProp = ( + propName: string, + propValue: Expression, +): Partial => { + switch (propName) { + case 'name': + return isStringLiteral(propValue) ? { name: propValue.value } : {} + case 'columns': + return { columns: extractColumnNames(propValue) } + case 'foreignColumns': { + const { tableName, columnNames } = extractTableAndColumns(propValue) + return { + ...(tableName !== null ? { targetTable: tableName } : {}), + targetColumns: columnNames, + } + } + default: + return {} + } +} + +const parseForeignKeyConfig = ( + configExpr: ObjectExpression, +): Partial => { + let definition: Partial = { + columns: [], + targetColumns: [], + targetTable: '', + } + + for (const prop of configExpr.properties) { + if (prop.type !== 'KeyValueProperty') continue + + const propName = prop.key.type === 'Identifier' ? prop.key.value : null + if (!propName) continue + + definition = { ...definition, ...parseForeignKeyProp(propName, prop.value) } + } + + return definition +} + +/** + * Validate foreign key definition + */ +const isValidForeignKeyDefinition = ( + definition: Partial, +): definition is DrizzleForeignKeyDefinition => { + return !!( + definition.targetTable && + definition.columns?.length && + definition.targetColumns?.length + ) +} + /** * Parse index or primary key definition */ @@ -243,8 +438,11 @@ const parseIndexDefinition = ( const columns = config['columns'].filter( (col): col is string => typeof col === 'string', ) + const pkName = + typeof config['name'] === 'string' ? config['name'] : undefined return { type: 'primaryKey', + ...(pkName !== undefined && { name: pkName }), columns, } } @@ -344,3 +542,148 @@ const parseIndexDefinition = ( return null } + +/** + * Parse check constraint definition + */ +const parseCheckConstraint = ( + callExpr: CallExpression, + name: string, + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: TODO: Refactor to reduce complexity +): DrizzleCheckConstraintDefinition | null => { + // Handle check('constraint_name', sql`condition`) + if ( + callExpr.callee.type === 'Identifier' && + callExpr.callee.value === 'check' + ) { + // Extract the constraint name from the first argument + let constraintName = name + if (callExpr.arguments.length > 0) { + const nameArg = callExpr.arguments[0] + const nameExpr = getArgumentExpression(nameArg) + if (nameExpr && isStringLiteral(nameExpr)) { + constraintName = nameExpr.value + } + } + + // Extract the condition from the second argument (sql template literal) + let condition = 'true' // Default condition + if (callExpr.arguments.length > 1) { + const conditionArg = callExpr.arguments[1] + const conditionExpr = getArgumentExpression(conditionArg) + + if (conditionExpr) { + // Handle sql`condition` template literal + if ( + conditionExpr.type === 'TaggedTemplateExpression' && + conditionExpr.tag.type === 'Identifier' && + conditionExpr.tag.value === 'sql' + ) { + condition = reconstructSqlTemplate(conditionExpr.template) + } + // Handle direct function call like sql('condition') + else if ( + conditionExpr.type === 'CallExpression' && + conditionExpr.callee.type === 'Identifier' && + conditionExpr.callee.value === 'sql' && + conditionExpr.arguments.length > 0 + ) { + const sqlArg = getArgumentExpression(conditionExpr.arguments[0]) + if (sqlArg && isStringLiteral(sqlArg)) { + condition = sqlArg.value + } + } + } + } + + return { + type: 'check', + name: constraintName, + condition, + } + } + + return null +} + +/** + * Parse unique constraint definition + */ +const parseUniqueConstraint = ( + callExpr: CallExpression, + name: string, + tableColumns: Record, + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: TODO: Refactor to reduce complexity +): { type: 'UNIQUE'; name: string; columnNames: string[] } | null => { + // Handle unique('constraint_name').on(...) method chain + let constraintName = name + let currentExpr: Expression = callExpr + const columns: string[] = [] + + // First check if we have a method chain ending with .on(...) + const methodCalls: Array<{ method: string; expr: CallExpression }> = [] + + // Traverse method chain to collect all calls + while ( + currentExpr.type === 'CallExpression' && + currentExpr.callee.type === 'MemberExpression' && + currentExpr.callee.property.type === 'Identifier' + ) { + const methodName = currentExpr.callee.property.value + methodCalls.unshift({ method: methodName, expr: currentExpr }) + currentExpr = currentExpr.callee.object + } + + // The base should be unique() + if ( + currentExpr.type === 'CallExpression' && + currentExpr.callee.type === 'Identifier' && + currentExpr.callee.value === 'unique' + ) { + // Get the constraint name from the first argument + if (currentExpr.arguments.length > 0) { + const nameArg = currentExpr.arguments[0] + const nameExpr = getArgumentExpression(nameArg) + if (nameExpr && isStringLiteral(nameExpr)) { + constraintName = nameExpr.value + } + } + + // Find the .on() method call and parse columns + for (const { method, expr } of methodCalls) { + if (method === 'on') { + // Parse column references from .on(...) arguments + for (const arg of expr.arguments) { + const argExpr = getArgumentExpression(arg) + if ( + argExpr && + argExpr.type === 'MemberExpression' && + argExpr.object.type === 'Identifier' && + argExpr.property.type === 'Identifier' + ) { + // Get the JavaScript property name + const jsPropertyName = argExpr.property.value + // Find the actual database column name from the table columns + const column = tableColumns[jsPropertyName] + if (column) { + columns.push(column.name) // Use database column name + } else { + columns.push(jsPropertyName) // Fallback to JS property name + } + } + } + break + } + } + + if (columns.length > 0) { + return { + type: 'UNIQUE', + name: constraintName, + columnNames: columns, + } + } + } + + return null +} diff --git a/frontend/packages/schema/src/parser/drizzle/postgres/types.ts b/frontend/packages/schema/src/parser/drizzle/postgres/types.ts index a05bf83fe9..a376fbacf7 100644 --- a/frontend/packages/schema/src/parser/drizzle/postgres/types.ts +++ b/frontend/packages/schema/src/parser/drizzle/postgres/types.ts @@ -2,11 +2,15 @@ * Type definitions for Drizzle ORM schema parsing */ +import type { Constraint } from '../../../schema/index.js' + export type DrizzleTableDefinition = { name: string columns: Record indexes: Record compositePrimaryKey?: CompositePrimaryKeyDefinition + foreignKeys?: DrizzleForeignKeyDefinition[] + constraints?: Record comment?: string | undefined } @@ -41,9 +45,25 @@ export type DrizzleEnumDefinition = { values: string[] } +export type DrizzleCheckConstraintDefinition = { + type: 'check' + name: string + condition: string +} + export type CompositePrimaryKeyDefinition = { type: 'primaryKey' + name?: string + columns: string[] +} + +export type DrizzleForeignKeyDefinition = { + name?: string columns: string[] + targetTable: string + targetColumns: string[] + onDelete?: string + onUpdate?: string } /**