diff --git a/apps/desktop/src/lib/__tests__/database/dbAdminSql.spec.ts b/apps/desktop/src/lib/__tests__/database/dbAdminSql.spec.ts index 527279adba..8f454876c1 100644 --- a/apps/desktop/src/lib/__tests__/database/dbAdminSql.spec.ts +++ b/apps/desktop/src/lib/__tests__/database/dbAdminSql.spec.ts @@ -23,6 +23,8 @@ import { damengDuplicateTableCreateOptions, duplicateTableStructureRequiresScript, oracleDuplicateTableCreateOptions, + sqlServerClonePrimaryKeyConstraintName, + SQLSERVER_IDENTIFIER_MAX_LENGTH, supportsNativeMysqlAutoIncrement, } from "@/lib/database/dbAdminSql"; @@ -442,10 +444,52 @@ describe("buildDuplicateTableStructurePlan", () => { }); expect(apiMock.listIndexes).toHaveBeenCalledWith("mssql-1", "app", "dbo", "orders", undefined); - expect(apiMock.buildDuplicateTableStructureSql).toHaveBeenCalledWith(expect.objectContaining({ databaseType: "sqlserver", primaryKeyColumns: ["ID", "SEQ"] })); + expect(apiMock.buildDuplicateTableStructureSql).toHaveBeenCalledWith(expect.objectContaining({ databaseType: "sqlserver", primaryKeyColumns: ["ID", "SEQ"], primaryKeyConstraintName: "PK_orders_copy" })); expect(plan.executeAsScript).toBe(true); }); + it("renames the SQL Server clone PK when it would collide with a source index", async () => { + apiMock.listIndexes.mockResolvedValue([ + { name: "PK_ORDERS", columns: ["ID"], is_unique: true, is_primary: true }, + { name: "PK_orders_copy", columns: ["FLAG"], is_unique: false, is_primary: false }, + ]); + apiMock.buildDuplicateTableStructureSql.mockResolvedValue(""); + + await buildDuplicateTableStructurePlan({ + connectionId: "mssql-1", + database: "app", + databaseType: "sqlserver", + schema: "dbo", + sourceName: "orders", + targetName: "orders_copy", + }); + + expect(apiMock.buildDuplicateTableStructureSql).toHaveBeenCalledWith(expect.objectContaining({ primaryKeyConstraintName: "PK_orders_copy_2" })); + }); + + it("caps the SQL Server clone PK name at the 128-character identifier limit", () => { + const indexes = [{ name: "IDX", columns: ["ID"], is_unique: false, is_primary: false }]; + const longTarget = `orders_${"x".repeat(200)}`; + const name = sqlServerClonePrimaryKeyConstraintName(indexes, longTarget); + + expect(name.length).toBeLessThanOrEqual(SQLSERVER_IDENTIFIER_MAX_LENGTH); + expect(name.startsWith("PK_orders_")).toBe(true); + }); + + it("keeps the dedup suffix within the identifier limit for long targets", () => { + const longTarget = `orders_${"x".repeat(200)}`; + const colliding = `PK_${longTarget}`.slice(0, SQLSERVER_IDENTIFIER_MAX_LENGTH); + const indexes = [ + { name: "IDX", columns: ["ID"], is_unique: false, is_primary: false }, + { name: colliding, columns: ["FLAG"], is_unique: false, is_primary: false }, + ]; + + const name = sqlServerClonePrimaryKeyConstraintName(indexes as never, longTarget); + + expect(name.length).toBeLessThanOrEqual(SQLSERVER_IDENTIFIER_MAX_LENGTH); + expect(name.endsWith("_2")).toBe(true); + }); + it("keeps primary-key-free SQL Server clones on a single statement", async () => { apiMock.listIndexes.mockResolvedValue([{ name: "IDX_ORDERS_CUSTOMER", columns: ["CUSTOMER"], is_unique: false, is_primary: false }]); apiMock.buildDuplicateTableStructureSql.mockResolvedValue("SELECT TOP 0 * INTO [orders_copy] FROM [orders];"); diff --git a/apps/desktop/src/lib/database/dbAdminSql.ts b/apps/desktop/src/lib/database/dbAdminSql.ts index e3cf63487c..f5a1658e92 100644 --- a/apps/desktop/src/lib/database/dbAdminSql.ts +++ b/apps/desktop/src/lib/database/dbAdminSql.ts @@ -86,6 +86,9 @@ export interface DuplicateTableStructureSqlOptions { /** SQL Server only: source primary-key columns recreated on the clone, because * `SELECT ... INTO` copies columns (and IDENTITY) but drops constraints. */ primaryKeyColumns?: string[]; + /** SQL Server only: pre-computed PK constraint name for the clone (respecting + * the 128-character identifier limit and source-table index names). */ + primaryKeyConstraintName?: string; /** Quote character reported by the connected server, for types whose quote is not fixed by the * database type alone (Cloud Spanner's two dialects differ). Mirrors `identifierQuote` on the * table-data SQL options. */ @@ -215,6 +218,29 @@ export function damengDuplicateTableCreateOptions(options: { schema?: string | n }; } +/** SQL Server caps identifiers at 128 characters. */ +export const SQLSERVER_IDENTIFIER_MAX_LENGTH = 128; + +/** + * Derives the clone's primary-key constraint name from `PK_{target}`, capped at + * SQL Server's 128-character identifier limit and de-duplicated against the + * source table's index names (schema-wide constraint names cannot be listed + * through the table-level index metadata, so a name taken by a *different* + * table still surfaces as a server-side error the user can act on). + */ +export function sqlServerClonePrimaryKeyConstraintName(indexes: IndexInfo[], targetName: string): string { + const base = `PK_${targetName}`; + const taken = new Set(indexes.map((index) => index.name.toLowerCase())); + let name = base.slice(0, SQLSERVER_IDENTIFIER_MAX_LENGTH); + let suffix = 2; + while (taken.has(name.toLowerCase())) { + const tail = `_${suffix}`; + name = `${base.slice(0, SQLSERVER_IDENTIFIER_MAX_LENGTH - tail.length)}${tail}`; + suffix += 1; + } + return name; +} + export async function buildDuplicateTableStructurePlan(options: DuplicateTableStructurePlanOptions): Promise { if (options.databaseType === "oracle") { const columnsPromise = options.sourceColumns ? Promise.resolve(options.sourceColumns) : api.getColumns(options.connectionId, options.database, options.schema || "", options.sourceName, options.catalog); @@ -273,6 +299,7 @@ export async function buildDuplicateTableStructurePlan(options: DuplicateTableSt if (options.databaseType === "sqlserver") { const indexes = await api.listIndexes(options.connectionId, options.database, options.schema || "", options.sourceName, options.catalog); const primaryKeyColumns = indexes.find((index) => index.is_primary && index.columns.length > 0)?.columns ?? []; + const primaryKeyConstraintName = sqlServerClonePrimaryKeyConstraintName(indexes, options.targetName); const sql = await buildDuplicateTableStructureSql({ databaseType: options.databaseType, schema: options.schema, @@ -281,6 +308,7 @@ export async function buildDuplicateTableStructurePlan(options: DuplicateTableSt tableComment: options.tableComment, columnComments: [], primaryKeyColumns, + primaryKeyConstraintName, identifierQuote: options.identifierQuote, }); return { sql, sourceColumns: options.sourceColumns, executeAsScript: primaryKeyColumns.length > 0 || duplicateTableStructureRequiresScript(sql) }; diff --git a/crates/dbx-core/src/db_admin_sql.rs b/crates/dbx-core/src/db_admin_sql.rs index e469e2ddbc..6cdeeadf74 100644 --- a/crates/dbx-core/src/db_admin_sql.rs +++ b/crates/dbx-core/src/db_admin_sql.rs @@ -208,6 +208,12 @@ pub struct DuplicateTableStructureSqlOptions { /// needs an explicit `ALTER TABLE ... ADD CONSTRAINT ... PRIMARY KEY`. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub primary_key_columns: Vec, + /// Pre-computed primary-key constraint name for the clone. Callers derive + /// it from the source index names so the generated `PK_{target}` respects + /// SQL Server's 128-character identifier limit and avoids a name that + /// already exists on the source table. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub primary_key_constraint_name: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub identifier_quote: Option, } @@ -785,7 +791,11 @@ pub fn build_duplicate_table_structure_sql(options: DuplicateTableStructureSqlOp // would silently lose its primary key (t8y2/dbx#8931). Recreate it from the source metadata. let mut constraint_sql = Vec::new(); if options.database_type == Some(DatabaseType::SqlServer) && !options.primary_key_columns.is_empty() { - let constraint_name = quote_table_identifier(options.database_type, &format!("PK_{}", options.target_name)); + let raw_constraint_name: String = match options.primary_key_constraint_name.as_deref() { + Some(name) => name.to_string(), + None => format!("PK_{}", options.target_name), + }; + let constraint_name = quote_table_identifier(options.database_type, &raw_constraint_name); let key_columns = options .primary_key_columns .iter() @@ -2144,6 +2154,7 @@ mod tests { table_comment: None, column_comments: vec![], primary_key_columns: vec![], + primary_key_constraint_name: None, identifier_quote: None, }), "CREATE TABLE `users_copy` LIKE `users`;" @@ -2157,6 +2168,7 @@ mod tests { table_comment: None, column_comments: vec![], primary_key_columns: vec![], + primary_key_constraint_name: None, identifier_quote: None, }), "CREATE TABLE \"public\".\"users_copy\" (LIKE \"public\".\"users\" INCLUDING ALL);" @@ -2170,6 +2182,7 @@ mod tests { table_comment: None, column_comments: vec![], primary_key_columns: vec![], + primary_key_constraint_name: None, identifier_quote: None, }), "CREATE TABLE `dbx_demo`.`connection_test_copy` LIKE `dbx_demo`.`connection_test`;" @@ -2183,6 +2196,7 @@ mod tests { table_comment: None, column_comments: vec![], primary_key_columns: vec![], + primary_key_constraint_name: None, identifier_quote: None, }), "CREATE TABLE `dbx_demo`.`orders_copy` LIKE `dbx_demo`.`orders`;" @@ -2196,6 +2210,7 @@ mod tests { table_comment: Some(" Customer's orders; archive ".to_string()), column_comments: vec![], primary_key_columns: vec![], + primary_key_constraint_name: None, identifier_quote: None, }), "CREATE TABLE \"public\".\"customer_orders_copy\" (LIKE \"public\".\"customer_orders\" INCLUDING ALL);\nCOMMENT ON TABLE \"public\".\"customer_orders_copy\" IS ' Customer''s orders; archive ';" @@ -2209,6 +2224,7 @@ mod tests { table_comment: None, column_comments: vec![], primary_key_columns: vec![], + primary_key_constraint_name: None, identifier_quote: None, }), "CREATE TABLE \"public\".\"users_copy\" (LIKE \"public\".\"users\" INCLUDING ALL);" @@ -2222,6 +2238,7 @@ mod tests { table_comment: None, column_comments: vec![], primary_key_columns: vec![], + primary_key_constraint_name: None, identifier_quote: None, }), "SELECT TOP 0 * INTO [dbo].[users_copy] FROM [dbo].[users];" @@ -2235,6 +2252,7 @@ mod tests { table_comment: None, column_comments: vec![], primary_key_columns: vec![], + primary_key_constraint_name: None, identifier_quote: None, }), "CREATE TABLE \"HR\".USERS_COPY AS SELECT * FROM \"HR\".\"USERS\" WHERE 1=0" @@ -2248,6 +2266,7 @@ mod tests { table_comment: None, column_comments: vec![], primary_key_columns: vec![], + primary_key_constraint_name: None, identifier_quote: None, }), "SELECT TOP 0 * INTO [dbo].[users_copy] FROM [dbo].[users];" @@ -2261,10 +2280,25 @@ mod tests { table_comment: None, column_comments: vec![], primary_key_columns: vec!["id".to_string(), "seq no".to_string()], + primary_key_constraint_name: None, identifier_quote: None, }), "SELECT TOP 0 * INTO [users_copy] FROM [users];\nALTER TABLE [users_copy] ADD CONSTRAINT [PK_users_copy] PRIMARY KEY ([id], [seq no]);" ); + assert_eq!( + build_duplicate_table_structure_sql(DuplicateTableStructureSqlOptions { + database_type: Some(DatabaseType::SqlServer), + schema: None, + source_name: "users".to_string(), + target_name: "users_copy".to_string(), + table_comment: None, + column_comments: vec![], + primary_key_columns: vec!["id".to_string()], + primary_key_constraint_name: Some("PK_users_copy_2".to_string()), + identifier_quote: None, + }), + "SELECT TOP 0 * INTO [users_copy] FROM [users];\nALTER TABLE [users_copy] ADD CONSTRAINT [PK_users_copy_2] PRIMARY KEY ([id]);" + ); let dameng_sql = build_duplicate_table_structure_sql(DuplicateTableStructureSqlOptions { database_type: Some(DatabaseType::Dameng), schema: Some("APP".to_string()), @@ -2280,6 +2314,7 @@ mod tests { DuplicateTableColumnComment { name: "EMPTY".to_string(), comment: " \t\n".to_string() }, ], primary_key_columns: vec![], + primary_key_constraint_name: None, identifier_quote: None, }); assert_eq!( @@ -2304,6 +2339,7 @@ mod tests { table_comment: Some("line1\\path\nline2".to_string()), column_comments: vec![], primary_key_columns: vec![], + primary_key_constraint_name: None, identifier_quote: None, }); assert_eq!( @@ -2320,6 +2356,7 @@ mod tests { table_comment: None, column_comments: vec![], primary_key_columns: vec![], + primary_key_constraint_name: None, identifier_quote: None, }), "CREATE TABLE \"APP\".\"UsersCopy\" AS SELECT * FROM \"APP\".\"USERS\" WHERE 1=0" @@ -2339,6 +2376,7 @@ mod tests { table_comment: Some("owner\\'s; archive".to_string()), column_comments: vec![], primary_key_columns: vec![], + primary_key_constraint_name: None, identifier_quote: None, }); let expected_literal = if database_type == DatabaseType::Redshift { @@ -2364,6 +2402,7 @@ mod tests { table_comment: None, column_comments: vec![], primary_key_columns: vec![], + primary_key_constraint_name: None, identifier_quote: None, }), "CREATE TABLE \"SQLUSER\".\"tb_a_copy\" AS SELECT * FROM \"SQLUSER\".\"tb_a\" WHERE 1=0" @@ -2377,6 +2416,7 @@ mod tests { table_comment: Some("ignored by QuestDB".to_string()), column_comments: vec![], primary_key_columns: vec![], + primary_key_constraint_name: None, identifier_quote: None, }), "CREATE TABLE `users_copy` (LIKE `users`);"