From f80fdcdfa7ba383b1bf567ebdc2f4c59de21a2df Mon Sep 17 00:00:00 2001 From: onenewcode Date: Thu, 30 Jul 2026 00:06:48 +0800 Subject: [PATCH] fix(dameng): support primary key editing --- .../TableStructureEditor.primaryKey.spec.ts | 324 +++++++++++ .../table/tableStructureCapabilities.spec.ts | 6 + .../lib/table/tableStructureCapabilities.ts | 9 +- .../src/table_structure_sql/columns.rs | 83 ++- .../src/table_structure_sql/dialect.rs | 1 + .../dbx-core/src/table_structure_sql/tests.rs | 523 +++++++++++++----- 6 files changed, 778 insertions(+), 168 deletions(-) create mode 100644 apps/desktop/src/components/structure/TableStructureEditor.primaryKey.spec.ts diff --git a/apps/desktop/src/components/structure/TableStructureEditor.primaryKey.spec.ts b/apps/desktop/src/components/structure/TableStructureEditor.primaryKey.spec.ts new file mode 100644 index 000000000..ad7e2d0ca --- /dev/null +++ b/apps/desktop/src/components/structure/TableStructureEditor.primaryKey.spec.ts @@ -0,0 +1,324 @@ +// @vitest-environment happy-dom + +import { createApp, nextTick, type App } from "vue"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + connection: { + id: "structure-test", + name: "Dameng", + db_type: "dameng", + driver_label: "Dameng", + }, + ensureConnected: vi.fn(), + listDataTypes: vi.fn(), + buildTableStructureChangeSql: vi.fn(), + updateEditorSettings: vi.fn(), +})); + +vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) })); + +vi.mock("@lucide/vue", async () => { + const { defineComponent, h } = await import("vue"); + const Icon = defineComponent({ name: "Icon", setup: () => () => h("span") }); + return { + AlertTriangle: Icon, + Check: Icon, + ChevronDown: Icon, + ChevronUp: Icon, + Copy: Icon, + Database: Icon, + Info: Icon, + KeyRound: Icon, + ListChevronsUpDown: Icon, + Loader2: Icon, + Maximize2: Icon, + Plus: Icon, + RefreshCw: Icon, + Save: Icon, + Search: Icon, + Settings: Icon, + SlidersHorizontal: Icon, + Trash2: Icon, + X: Icon, + }; +}); + +vi.mock("@/components/ui/button", async () => { + const { defineComponent, h } = await import("vue"); + return { + Button: defineComponent({ + name: "Button", + inheritAttrs: false, + setup: + (_props, { attrs, slots }) => + () => + h("button", attrs, slots.default?.()), + }), + }; +}); +vi.mock("@/components/ui/input", async () => { + const { defineComponent, h } = await import("vue"); + return { + Input: defineComponent({ + name: "Input", + inheritAttrs: false, + setup: + (_props, { attrs }) => + () => + h("input", attrs), + }), + }; +}); +vi.mock("@/components/ui/badge", async () => { + const { defineComponent, h } = await import("vue"); + return { + Badge: defineComponent({ + name: "Badge", + inheritAttrs: false, + setup: + (_props, { attrs, slots }) => + () => + h("span", attrs, slots.default?.()), + }), + }; +}); +vi.mock("@/components/ui/tabs", async () => { + const { defineComponent, h } = await import("vue"); + const Div = defineComponent({ + inheritAttrs: false, + setup: + (_props, { attrs, slots }) => + () => + h("div", attrs, slots.default?.()), + }); + const Button = defineComponent({ + inheritAttrs: false, + setup: + (_props, { attrs, slots }) => + () => + h("button", attrs, slots.default?.()), + }); + return { Tabs: Div, TabsContent: Div, TabsList: Div, TabsTrigger: Button }; +}); +vi.mock("@/components/ui/dropdown-menu", async () => { + const { defineComponent, h } = await import("vue"); + const Div = defineComponent({ + inheritAttrs: false, + setup: + (_props, { attrs, slots }) => + () => + h("div", attrs, slots.default?.()), + }); + const Button = defineComponent({ + inheritAttrs: false, + setup: + (_props, { attrs, slots }) => + () => + h("button", attrs, slots.default?.()), + }); + return { DropdownMenu: Div, DropdownMenuCheckboxItem: Div, DropdownMenuContent: Div, DropdownMenuItem: Button, DropdownMenuTrigger: Div }; +}); +vi.mock("@/components/ui/popover", async () => { + const { defineComponent, h } = await import("vue"); + const Div = defineComponent({ + inheritAttrs: false, + setup: + (_props, { attrs, slots }) => + () => + h("div", attrs, slots.default?.()), + }); + return { Popover: Div, PopoverContent: Div, PopoverTrigger: Div }; +}); +vi.mock("@/components/ui/tooltip", async () => { + const { defineComponent, h } = await import("vue"); + const Div = defineComponent({ + inheritAttrs: false, + setup: + (_props, { attrs, slots }) => + () => + h("div", attrs, slots.default?.()), + }); + return { Tooltip: Div, TooltipContent: Div, TooltipTrigger: Div }; +}); +vi.mock("@/components/ui/searchable-select", async () => { + const { defineComponent, h } = await import("vue"); + return { + SearchableSelect: defineComponent({ + name: "SearchableSelect", + inheritAttrs: false, + setup: + (_props, { attrs }) => + () => + h("div", attrs), + }), + }; +}); +vi.mock("@/components/ui/select", async () => { + const { defineComponent, h } = await import("vue"); + const Div = defineComponent({ + inheritAttrs: false, + setup: + (_props, { attrs, slots }) => + () => + h("div", attrs, slots.default?.()), + }); + return { Select: Div, SelectContent: Div, SelectItem: Div, SelectTrigger: Div, SelectValue: Div }; +}); + +vi.mock("@/stores/connectionStore", () => ({ + useConnectionStore: () => ({ + ensureConnected: mocks.ensureConnected, + getConfig: (connectionId: string) => (connectionId === mocks.connection.id ? mocks.connection : undefined), + }), +})); +vi.mock("@/stores/productionSafetyStore", () => ({ useProductionSafetyStore: () => ({ requestConfirmation: vi.fn() }) })); +vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => ({ tableStructureRefreshVersion: () => 0 }) })); +vi.mock("@/stores/historyStore", () => ({ useHistoryStore: () => ({ add: vi.fn() }) })); +vi.mock("@/stores/settingsStore", () => ({ + useSettingsStore: () => ({ + editorSettings: { structureEditorDensity: "compact", sqlFormatter: {}, tableColumnTemplateFields: [] }, + updateEditorSettings: mocks.updateEditorSettings, + }), +})); +vi.mock("@/composables/useTheme", () => ({ useTheme: () => ({ isDark: { value: false } }) })); +vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) })); +vi.mock("@/lib/sql/sqlHighlighter", () => ({ createShikiSqlHighlighter: vi.fn(async () => (sql: string) => sql) })); +vi.mock("@/lib/backend/api", () => ({ + listDataTypes: mocks.listDataTypes, + buildTableStructureChangeSql: mocks.buildTableStructureChangeSql, +})); + +import TableStructureEditor from "@/components/structure/TableStructureEditor.vue"; + +const mountedApps: App[] = []; + +function draft(isPrimaryKey = false) { + return { + initialized: true, + activeTab: "columns" as const, + newTableName: "", + tableComment: "", + originalTableComment: "", + columns: [ + { + id: "existing:id", + name: "id", + dataType: "INT", + isNullable: !isPrimaryKey, + defaultValue: "", + comment: "", + isPrimaryKey, + extra: {}, + original: { + name: "id", + data_type: "INT", + is_nullable: !isPrimaryKey, + column_default: null, + is_primary_key: isPrimaryKey, + extra: null, + comment: null, + }, + originalPosition: 0, + markedForDrop: false, + }, + ], + indexes: [], + foreignKeys: [], + triggers: [], + }; +} + +async function mountEditor(databaseType: "dameng" | "oracle", isPrimaryKey = false) { + mocks.connection.db_type = databaseType; + mocks.connection.name = databaseType; + mocks.connection.driver_label = databaseType; + mocks.ensureConnected.mockResolvedValue(undefined); + mocks.listDataTypes.mockResolvedValue([]); + mocks.buildTableStructureChangeSql.mockResolvedValue({ statements: [], warnings: [] }); + + const root = document.createElement("div"); + document.body.append(root); + const app = createApp(TableStructureEditor, { + connectionId: mocks.connection.id, + database: "test", + schema: "SYSDBA", + tableName: "users", + draft: draft(isPrimaryKey), + }); + mountedApps.push(app); + app.mount(root); + await nextTick(); + await Promise.resolve(); + await nextTick(); + return root; +} + +function columnCheckbox(root: HTMLElement, header: string): HTMLInputElement { + const headerIndex = Array.from(root.querySelectorAll("thead th")).findIndex((cell) => cell.textContent?.trim() === header); + if (headerIndex < 0) throw new Error(`Missing ${header} column`); + const row = root.querySelector('[data-column-row-index="0"]'); + const cell = row?.querySelectorAll("td")[headerIndex]; + const checkbox = cell?.querySelector('input[type="checkbox"]'); + if (!checkbox) throw new Error(`Missing ${header} checkbox`); + return checkbox; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + for (const app of mountedApps.splice(0)) app.unmount(); + document.body.innerHTML = ""; +}); + +describe("TableStructureEditor primary key editing", () => { + it("enables the primary-key checkbox for an existing Dameng column and makes it not null", async () => { + const root = await mountEditor("dameng"); + const primaryKey = columnCheckbox(root, "structureEditor.primaryKey"); + const nullable = columnCheckbox(root, "structureEditor.nullable"); + + expect(primaryKey.disabled).toBe(false); + expect(nullable.checked).toBe(true); + + primaryKey.checked = true; + primaryKey.dispatchEvent(new Event("change", { bubbles: true })); + await nextTick(); + + expect(primaryKey.checked).toBe(true); + expect(nullable.checked).toBe(false); + await vi.waitFor(() => expect(mocks.buildTableStructureChangeSql).toHaveBeenCalled()); + expect(mocks.buildTableStructureChangeSql).toHaveBeenLastCalledWith( + expect.objectContaining({ + columns: [expect.objectContaining({ isPrimaryKey: true, isNullable: false })], + }), + ); + }); + + it("keeps the primary-key checkbox disabled for an existing Oracle column", async () => { + const root = await mountEditor("oracle"); + + expect(columnCheckbox(root, "structureEditor.primaryKey").disabled).toBe(true); + }); + + it("allows an existing Dameng primary key to be cleared", async () => { + const root = await mountEditor("dameng", true); + const primaryKey = columnCheckbox(root, "structureEditor.primaryKey"); + + expect(primaryKey.disabled).toBe(false); + expect(primaryKey.checked).toBe(true); + + primaryKey.checked = false; + primaryKey.dispatchEvent(new Event("change", { bubbles: true })); + await nextTick(); + + expect(primaryKey.checked).toBe(false); + await vi.waitFor(() => expect(mocks.buildTableStructureChangeSql).toHaveBeenCalled()); + expect(mocks.buildTableStructureChangeSql).toHaveBeenLastCalledWith( + expect.objectContaining({ + columns: [expect.objectContaining({ isPrimaryKey: false })], + }), + ); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/table/tableStructureCapabilities.spec.ts b/apps/desktop/src/lib/__tests__/table/tableStructureCapabilities.spec.ts index 68e96a49f..421612900 100644 --- a/apps/desktop/src/lib/__tests__/table/tableStructureCapabilities.spec.ts +++ b/apps/desktop/src/lib/__tests__/table/tableStructureCapabilities.spec.ts @@ -43,6 +43,12 @@ describe("tableStructureCapabilities", () => { expect(getTableStructureCapabilities("dameng", "dameng").comment).toBe(true); }); + it("enables alter primary key for Dameng without enabling it for Oracle", () => { + expect(getTableStructureCapabilities("dameng", "dameng").alterPrimaryKey).toBe(true); + expect(getTableStructureCapabilities("oracle", "oracle").alterPrimaryKey).toBe(false); + expect(getTableStructureCapabilities("oceanbase-oracle", "oceanbase-oracle").alterPrimaryKey).toBe(false); + }); + it("uses local-only column reordering for editable databases without physical reorder support", () => { for (const databaseType of ["sqlserver", "postgres", "sqlite", "oracle", "dameng", "duckdb", "informix"] as const) { expect(supportsLocalTableColumnReorder(databaseType, databaseType)).toBe(true); diff --git a/apps/desktop/src/lib/table/tableStructureCapabilities.ts b/apps/desktop/src/lib/table/tableStructureCapabilities.ts index 06cee0342..a48822d86 100644 --- a/apps/desktop/src/lib/table/tableStructureCapabilities.ts +++ b/apps/desktop/src/lib/table/tableStructureCapabilities.ts @@ -190,6 +190,13 @@ const oracleCapabilities = capabilities({ indexType: true, }); +// Dameng (DM8): ALTER TABLE ... DROP PRIMARY KEY / ADD PRIMARY KEY is official DDL. +// Keep separate from oracleCapabilities so UI cannot enable PK edit without BE drop SQL. +const damengCapabilities = capabilities({ + ...oracleCapabilities, + alterPrimaryKey: true, +}); + const irisCapabilities = capabilities({ ...oracleCapabilities, // IRIS exposes %DESCRIPTION at definition time but cannot alter persisted descriptions. @@ -323,7 +330,7 @@ const capabilityByType: Partial duckdb: duckdbCapabilities, sqlserver: sqlserverCapabilities, oracle: oracleCapabilities, - dameng: oracleCapabilities, + dameng: damengCapabilities, "oceanbase-oracle": oracleCapabilities, iris: irisCapabilities, yashandb: oracleCapabilities, diff --git a/crates/dbx-core/src/table_structure_sql/columns.rs b/crates/dbx-core/src/table_structure_sql/columns.rs index 620be7c6b..d75722436 100644 --- a/crates/dbx-core/src/table_structure_sql/columns.rs +++ b/crates/dbx-core/src/table_structure_sql/columns.rs @@ -209,12 +209,22 @@ pub(super) fn build_column_sql(options: &TableStructureSqlOptions, warnings: &mu } } - // Emit primary key constraint changes after individual column changes + // Keep the existing key while column DDL validates. This avoids leaving a table + // without a key when an incoming key column cannot be made valid. statements.extend(build_primary_key_sql(options, dialect, &table, warnings)); statements } +fn was_primary_key_column(column: &EditableStructureColumn) -> bool { + column.original.as_ref().is_some_and(|original| original.is_primary_key) +} + +/// Columns that should appear in `ADD PRIMARY KEY (...)` (must remain on the table). +fn appears_in_add_primary_key(column: &EditableStructureColumn) -> bool { + column.is_primary_key && !column.marked_for_drop +} + pub(super) fn build_primary_key_sql( options: &TableStructureSqlOptions, dialect: StructureDialect, @@ -223,17 +233,21 @@ pub(super) fn build_primary_key_sql( ) -> Vec { let capabilities = capabilities_for(options.database_type); - let old_pk_names: Vec<&str> = options - .columns - .iter() - .filter(|c| c.original.as_ref().is_some_and(|o| o.is_primary_key)) - .map(|c| c.name.as_str()) - .collect(); + // A draft cannot drop a primary-key column. The column pass emits the user-facing + // warning; keep the entire PK change empty so another checked column cannot + // turn that invalid draft into a partial DROP/ADD constraint change. + if options.columns.iter().any(|column| column.marked_for_drop && was_primary_key_column(column)) { + return Vec::new(); + } - let new_pk_names: Vec<&str> = - options.columns.iter().filter(|c| !c.marked_for_drop && c.is_primary_key).map(|c| c.name.as_str()).collect(); + // Membership by draft id (set equality): pure rename / local reorder of the same key + // columns is not a PK change. ADD still lists columns in table order. + let old_pk_ids: HashSet<&str> = + options.columns.iter().filter(|c| was_primary_key_column(c)).map(|c| c.id.as_str()).collect(); + let new_pk_ids: HashSet<&str> = + options.columns.iter().filter(|c| appears_in_add_primary_key(c)).map(|c| c.id.as_str()).collect(); - if old_pk_names == new_pk_names { + if old_pk_ids == new_pk_ids { return Vec::new(); } @@ -246,29 +260,52 @@ pub(super) fn build_primary_key_sql( } let mut statements = Vec::new(); - - if !old_pk_names.is_empty() { - match dialect { - StructureDialect::Postgres => { - let raw_table = options.table_name.split('.').next_back().unwrap_or(&options.table_name); - let pk_name = format!("{}_pkey", clean(raw_table)); - statements.push(format!("ALTER TABLE {table} DROP CONSTRAINT {};", quote_ident(dialect, &pk_name))); - } - StructureDialect::Mysql => { - statements.push(format!("ALTER TABLE {table} DROP PRIMARY KEY;")); - } - _ => {} - } + if !old_pk_ids.is_empty() { + let Some(drop_sql) = drop_primary_key_statement(dialect, table, options) else { + warnings.push(format!( + "Changing primary keys is not supported for {} from this editor.", + database_label(options.database_type) + )); + return Vec::new(); + }; + statements.push(drop_sql); } + let new_pk_names: Vec<&str> = + options.columns.iter().filter(|c| appears_in_add_primary_key(c)).map(|c| c.name.as_str()).collect(); if !new_pk_names.is_empty() { let pk_list = new_pk_names.iter().map(|n| quote_ident(dialect, n)).collect::>().join(", "); + // DM8: ADD [CONSTRAINT name] PRIMARY KEY; anonymous form matches Navicat/DBeaver/MySQL editors. statements.push(format!("ALTER TABLE {table} ADD PRIMARY KEY ({pk_list});")); } statements } +/// Dialect-specific DROP for an existing primary key. +/// +/// - MySQL: `DROP PRIMARY KEY` +/// - Dameng (DM8): official `DROP PRIMARY KEY [RESTRICT|CASCADE]`; default RESTRICT +/// (no CASCADE — dependent FKs should not be silently removed). +/// System names (`CONS…`) are not stable; name-based DROP is avoided. +/// Cluster primary keys cannot use this path (DM8 restriction) — left to the server. +/// - Postgres: `DROP CONSTRAINT {table}_pkey` (default naming convention) +fn drop_primary_key_statement( + dialect: StructureDialect, + table: &str, + options: &TableStructureSqlOptions, +) -> Option { + match dialect { + StructureDialect::Postgres => { + let raw_table = options.table_name.split('.').next_back().unwrap_or(&options.table_name); + let pk_name = format!("{}_pkey", clean(raw_table)); + Some(format!("ALTER TABLE {table} DROP CONSTRAINT {};", quote_ident(dialect, &pk_name))) + } + StructureDialect::Mysql | StructureDialect::Dameng => Some(format!("ALTER TABLE {table} DROP PRIMARY KEY;")), + _ => None, + } +} + fn has_sqlserver_identity(column: &EditableStructureColumn) -> bool { column.extra.as_ref().is_some_and(|extra| extra.auto_increment.unwrap_or(false) || extra.identity.is_some()) } diff --git a/crates/dbx-core/src/table_structure_sql/dialect.rs b/crates/dbx-core/src/table_structure_sql/dialect.rs index 483225cef..002fad534 100644 --- a/crates/dbx-core/src/table_structure_sql/dialect.rs +++ b/crates/dbx-core/src/table_structure_sql/dialect.rs @@ -209,6 +209,7 @@ pub(super) fn capabilities_for(database_type: Option) -> TableStru drop_index: true, rebuild_index: true, index_type: true, + alter_primary_key: true, ..base }, Some(DatabaseType::Iris) => TableStructureCapabilities { diff --git a/crates/dbx-core/src/table_structure_sql/tests.rs b/crates/dbx-core/src/table_structure_sql/tests.rs index 83db210e3..e9cf66500 100644 --- a/crates/dbx-core/src/table_structure_sql/tests.rs +++ b/crates/dbx-core/src/table_structure_sql/tests.rs @@ -19,6 +19,49 @@ fn column(name: &str) -> EditableStructureColumn { } } +/// Existing column draft with optional primary-key membership change. +fn existing_pk_column( + name: &str, + data_type: &str, + was_primary_key: bool, + is_primary_key: bool, +) -> EditableStructureColumn { + let mut col = column(name); + col.data_type = data_type.to_string(); + col.is_nullable = false; + col.is_primary_key = is_primary_key; + col.original = Some(ColumnInfo { + name: name.to_string(), + data_type: data_type.to_string(), + is_nullable: false, + column_default: None, + is_primary_key: was_primary_key, + extra: None, + comment: None, + ..Default::default() + }); + col +} + +fn structure_change_options( + database_type: DatabaseType, + schema: Option<&str>, + table_name: &str, + columns: Vec, +) -> TableStructureSqlOptions { + TableStructureSqlOptions { + database_type: Some(database_type), + schema: schema.map(str::to_string), + table_name: table_name.to_string(), + columns, + indexes: Vec::new(), + foreign_keys: Vec::new(), + triggers: Vec::new(), + table_comment: None, + original_table_comment: None, + } +} + fn index(name: &str, columns: &[&str]) -> EditableStructureIndex { EditableStructureIndex { id: name.to_string(), @@ -2547,65 +2590,297 @@ fn builds_h2_schema_qualified_existing_column_statements() { #[test] fn builds_postgres_alter_table_add_primary_key() { - let mut id = column("id"); - id.data_type = "integer".to_string(); - id.is_nullable = false; - id.is_primary_key = true; - id.original = Some(ColumnInfo { - name: "id".to_string(), - data_type: "integer".to_string(), - is_nullable: false, - column_default: None, - is_primary_key: false, - extra: None, - comment: None, - ..Default::default() - }); - - let result = build_table_structure_change_sql(TableStructureSqlOptions { - database_type: Some(DatabaseType::Postgres), - schema: Some("public".to_string()), - table_name: "users".to_string(), - columns: vec![id], - indexes: Vec::new(), - foreign_keys: Vec::new(), - triggers: Vec::new(), - table_comment: None, - original_table_comment: None, - }); + let result = build_table_structure_change_sql(structure_change_options( + DatabaseType::Postgres, + Some("public"), + "users", + vec![existing_pk_column("id", "integer", false, true)], + )); assert_eq!(result.warnings, Vec::::new()); assert_eq!(result.statements, vec!["ALTER TABLE \"public\".\"users\" ADD PRIMARY KEY (\"id\");"]); } #[test] -fn builds_postgres_alter_table_drop_primary_key() { - let mut id = column("id"); - id.data_type = "integer".to_string(); - id.is_nullable = false; - id.is_primary_key = false; - id.original = Some(ColumnInfo { - name: "id".to_string(), - data_type: "integer".to_string(), - is_nullable: false, - column_default: None, - is_primary_key: true, - extra: None, - comment: None, - ..Default::default() - }); +fn builds_dameng_alter_table_add_primary_key() { + // DM8: ADD [CONSTRAINT name] PRIMARY KEY — anonymous form matches DBeaver/MySQL-style editors. + let result = build_table_structure_change_sql(structure_change_options( + DatabaseType::Dameng, + Some("SYSDBA"), + "users", + vec![existing_pk_column("id", "INT", false, true)], + )); - let result = build_table_structure_change_sql(TableStructureSqlOptions { - database_type: Some(DatabaseType::Postgres), - schema: Some("public".to_string()), - table_name: "users".to_string(), - columns: vec![id], - indexes: Vec::new(), - foreign_keys: Vec::new(), - triggers: Vec::new(), - table_comment: None, - original_table_comment: None, - }); + assert_eq!(result.warnings, Vec::::new()); + assert_eq!(result.statements, vec!["ALTER TABLE \"SYSDBA\".\"users\" ADD PRIMARY KEY (\"id\");"]); +} + +#[test] +fn builds_dameng_composite_primary_key_in_draft_order() { + let mut tenant_id = existing_pk_column("tenant_id", "INT", false, true); + tenant_id.id = "tenant_id".to_string(); + let mut code = existing_pk_column("code", "VARCHAR(50)", false, true); + code.id = "code".to_string(); + + let result = build_table_structure_change_sql(structure_change_options( + DatabaseType::Dameng, + Some("SYSDBA"), + "users", + vec![tenant_id, code], + )); + + assert_eq!(result.warnings, Vec::::new()); + assert_eq!(result.statements, vec!["ALTER TABLE \"SYSDBA\".\"users\" ADD PRIMARY KEY (\"tenant_id\", \"code\");"]); +} + +#[test] +fn dameng_reordering_unchanged_composite_primary_key_does_not_emit_primary_key_ddl() { + let mut tenant_id = existing_pk_column("tenant_id", "INT", true, true); + tenant_id.id = "tenant_id".to_string(); + tenant_id.original_position = Some(0); + let mut code = existing_pk_column("code", "VARCHAR(50)", true, true); + code.id = "code".to_string(); + code.original_position = Some(1); + + // Dameng reordering is local-only. Moving these columns must not recreate the key + // merely because the draft order changed. + let result = build_table_structure_change_sql(structure_change_options( + DatabaseType::Dameng, + Some("SYSDBA"), + "users", + vec![code, tenant_id], + )); + + assert_eq!(result.warnings, Vec::::new()); + assert!(result.statements.is_empty()); +} + +#[test] +fn dameng_adds_new_primary_key_column_before_adding_constraint() { + let mut code = column("code"); + code.data_type = "VARCHAR(50)".to_string(); + code.is_nullable = false; + code.is_primary_key = true; + + let result = build_table_structure_change_sql(structure_change_options( + DatabaseType::Dameng, + Some("SYSDBA"), + "users", + vec![code], + )); + + assert_eq!(result.warnings, Vec::::new()); + assert_eq!( + result.statements, + vec![ + "ALTER TABLE \"SYSDBA\".\"users\" ADD (\"code\" VARCHAR(50));", + "ALTER TABLE \"SYSDBA\".\"users\" ADD PRIMARY KEY (\"code\");", + ] + ); +} + +#[test] +fn builds_dameng_alter_table_drop_primary_key() { + // DM8 official: DROP PRIMARY KEY [RESTRICT|CASCADE]; default RESTRICT (no CASCADE from editor). + let result = build_table_structure_change_sql(structure_change_options( + DatabaseType::Dameng, + Some("SYSDBA"), + "users", + vec![existing_pk_column("id", "INT", true, false)], + )); + + assert_eq!(result.warnings, Vec::::new()); + assert_eq!(result.statements, vec!["ALTER TABLE \"SYSDBA\".\"users\" DROP PRIMARY KEY;"]); +} + +#[test] +fn builds_dameng_alter_table_change_primary_key() { + // DBeaver/Navicat-style modify: drop existing key then add the new one (never ADD without DROP). + let mut old_pk = existing_pk_column("id", "INT", true, false); + old_pk.id = "old_id".to_string(); + let mut new_pk = existing_pk_column("code", "VARCHAR(50)", false, true); + new_pk.id = "new_code".to_string(); + + let result = build_table_structure_change_sql(structure_change_options( + DatabaseType::Dameng, + Some("SYSDBA"), + "users", + vec![old_pk, new_pk], + )); + + assert_eq!(result.warnings, Vec::::new()); + assert_eq!( + result.statements, + vec![ + "ALTER TABLE \"SYSDBA\".\"users\" DROP PRIMARY KEY;", + "ALTER TABLE \"SYSDBA\".\"users\" ADD PRIMARY KEY (\"code\");", + ] + ); +} + +#[test] +fn dameng_validates_new_primary_key_column_before_replacing_existing_key() { + let mut old_pk = existing_pk_column("id", "INT", true, false); + old_pk.id = "old_id".to_string(); + let mut code = existing_pk_column("code", "VARCHAR(50)", false, true); + code.id = "new_code".to_string(); + code.original.as_mut().unwrap().is_nullable = true; + + let result = build_table_structure_change_sql(structure_change_options( + DatabaseType::Dameng, + Some("SYSDBA"), + "users", + vec![old_pk, code], + )); + + assert_eq!(result.warnings, Vec::::new()); + assert_eq!( + result.statements, + vec![ + "ALTER TABLE \"SYSDBA\".\"users\" MODIFY (\"code\" VARCHAR(50) NOT NULL);", + "ALTER TABLE \"SYSDBA\".\"users\" DROP PRIMARY KEY;", + "ALTER TABLE \"SYSDBA\".\"users\" ADD PRIMARY KEY (\"code\");", + ] + ); +} + +#[test] +fn dameng_blocks_dropping_former_primary_key_column() { + let mut id = existing_pk_column("id", "INT", true, false); + id.marked_for_drop = true; + let name = existing_pk_column("name", "VARCHAR(50)", false, false); + + let result = build_table_structure_change_sql(structure_change_options( + DatabaseType::Dameng, + Some("SYSDBA"), + "users", + vec![id, name], + )); + + assert!(result.statements.is_empty()); + assert!(result.warnings.iter().any(|warning| warning.contains("Primary key column"))); +} + +#[test] +fn oracle_uncheck_primary_key_and_drop_column_does_not_emit_drop_column() { + // alter_primary_key is false for Oracle: unchecking PK must not unlock DROP COLUMN without a PK drop. + let mut id = existing_pk_column("id", "NUMBER", true, false); + id.marked_for_drop = true; + let name = existing_pk_column("name", "VARCHAR2(50)", false, false); + + let result = build_table_structure_change_sql(structure_change_options( + DatabaseType::Oracle, + Some("HR"), + "users", + vec![id, name], + )); + + assert!( + !result.statements.iter().any(|sql| sql.to_ascii_uppercase().contains("DROP COLUMN")), + "must not DROP COLUMN former PK without DROP PRIMARY KEY; got {:?}", + result.statements + ); + assert!( + !result.statements.iter().any(|sql| sql.to_ascii_uppercase().contains("PRIMARY KEY")), + "Oracle must not emit partial PK DDL; got {:?}", + result.statements + ); + assert!( + result.warnings.iter().any(|w| w.contains("primary key") || w.contains("Primary key")), + "expected primary-key related warning; got {:?}", + result.warnings + ); +} + +#[test] +fn sqlserver_uncheck_primary_key_and_drop_column_does_not_emit_drop_column() { + let mut id = existing_pk_column("id", "int", true, false); + id.marked_for_drop = true; + let name = existing_pk_column("name", "nvarchar(50)", false, false); + + let result = build_table_structure_change_sql(structure_change_options( + DatabaseType::SqlServer, + Some("dbo"), + "users", + vec![id, name], + )); + + assert!( + !result.statements.iter().any(|sql| sql.to_ascii_uppercase().contains("DROP COLUMN")), + "must not DROP COLUMN former PK without PK drop; got {:?}", + result.statements + ); + assert!(result.warnings.iter().any(|w| w.contains("primary key") || w.contains("Primary key"))); +} + +#[test] +fn dameng_set_not_null_before_add_primary_key() { + // DM8: PK columns must be NOT NULL; DM auto-adds NOT NULL but clients still MODIFY first. + // Order: column MODIFY NOT NULL, then ADD PRIMARY KEY. + let mut id = existing_pk_column("id", "INT", false, true); + id.original.as_mut().unwrap().is_nullable = true; + // is_nullable stays false (set when marking PK) so MODIFY ... NOT NULL is emitted. + + let result = build_table_structure_change_sql(structure_change_options( + DatabaseType::Dameng, + Some("SYSDBA"), + "users", + vec![id], + )); + + assert_eq!(result.warnings, Vec::::new()); + assert_eq!( + result.statements, + vec![ + "ALTER TABLE \"SYSDBA\".\"users\" MODIFY (\"id\" INT NOT NULL);", + "ALTER TABLE \"SYSDBA\".\"users\" ADD PRIMARY KEY (\"id\");", + ] + ); +} + +#[test] +fn dameng_blocks_dropping_active_primary_key_column() { + // Keep a non-PK column so we do not hit the "cannot drop all columns" guard first. + let mut id = existing_pk_column("id", "INT", true, true); + id.marked_for_drop = true; + let name = existing_pk_column("name", "VARCHAR(50)", false, false); + + let result = build_table_structure_change_sql(structure_change_options( + DatabaseType::Dameng, + Some("SYSDBA"), + "users", + vec![id, name], + )); + + assert!(!result.statements.iter().any(|sql| sql.contains("DROP COLUMN"))); + assert!(result.warnings.iter().any(|w| w.contains("Primary key column"))); +} + +#[test] +fn dameng_does_not_mutate_primary_key_when_active_key_column_is_marked_for_drop() { + let mut id = existing_pk_column("id", "INT", true, true); + id.marked_for_drop = true; + let code = existing_pk_column("code", "VARCHAR(50)", false, true); + + let result = build_table_structure_change_sql(structure_change_options( + DatabaseType::Dameng, + Some("SYSDBA"), + "users", + vec![id, code], + )); + + assert!(result.statements.is_empty(), "invalid draft must not emit partial PK DDL: {:?}", result.statements); + assert!(result.warnings.iter().any(|warning| warning.contains("Primary key column"))); +} + +#[test] +fn builds_postgres_alter_table_drop_primary_key() { + let result = build_table_structure_change_sql(structure_change_options( + DatabaseType::Postgres, + Some("public"), + "users", + vec![existing_pk_column("id", "integer", true, false)], + )); assert_eq!(result.warnings, Vec::::new()); assert_eq!(result.statements, vec!["ALTER TABLE \"public\".\"users\" DROP CONSTRAINT \"users_pkey\";"]); @@ -2613,49 +2888,17 @@ fn builds_postgres_alter_table_drop_primary_key() { #[test] fn builds_mysql_alter_table_change_primary_key() { - let mut old_pk = column("id"); + let mut old_pk = existing_pk_column("id", "int", true, false); old_pk.id = "old_id".to_string(); - old_pk.data_type = "int".to_string(); - old_pk.is_nullable = false; - old_pk.is_primary_key = false; - old_pk.original = Some(ColumnInfo { - name: "id".to_string(), - data_type: "int".to_string(), - is_nullable: false, - column_default: None, - is_primary_key: true, - extra: None, - comment: None, - ..Default::default() - }); - - let mut new_pk = column("uuid"); + let mut new_pk = existing_pk_column("uuid", "varchar(36)", false, true); new_pk.id = "new_uuid".to_string(); - new_pk.data_type = "varchar(36)".to_string(); - new_pk.is_nullable = false; - new_pk.is_primary_key = true; - new_pk.original = Some(ColumnInfo { - name: "uuid".to_string(), - data_type: "varchar(36)".to_string(), - is_nullable: false, - column_default: None, - is_primary_key: false, - extra: None, - comment: None, - ..Default::default() - }); - let result = build_table_structure_change_sql(TableStructureSqlOptions { - database_type: Some(DatabaseType::Mysql), - schema: None, - table_name: "users".to_string(), - columns: vec![old_pk, new_pk], - indexes: Vec::new(), - foreign_keys: Vec::new(), - triggers: Vec::new(), - table_comment: None, - original_table_comment: None, - }); + let result = build_table_structure_change_sql(structure_change_options( + DatabaseType::Mysql, + None, + "users", + vec![old_pk, new_pk], + )); assert_eq!(result.warnings, Vec::::new()); assert_eq!( @@ -2666,65 +2909,57 @@ fn builds_mysql_alter_table_change_primary_key() { #[test] fn builds_no_statements_when_primary_key_unchanged() { - let mut id = column("id"); - id.data_type = "integer".to_string(); - id.is_nullable = false; - id.is_primary_key = true; - id.original = Some(ColumnInfo { - name: "id".to_string(), - data_type: "integer".to_string(), - is_nullable: false, - column_default: None, - is_primary_key: true, - extra: None, - comment: None, - ..Default::default() - }); - - let result = build_table_structure_change_sql(TableStructureSqlOptions { - database_type: Some(DatabaseType::Postgres), - schema: None, - table_name: "users".to_string(), - columns: vec![id], - indexes: Vec::new(), - foreign_keys: Vec::new(), - triggers: Vec::new(), - table_comment: None, - original_table_comment: None, - }); + let result = build_table_structure_change_sql(structure_change_options( + DatabaseType::Postgres, + None, + "users", + vec![existing_pk_column("id", "integer", true, true)], + )); assert_eq!(result.warnings, Vec::::new()); assert!(result.statements.is_empty()); } #[test] -fn warns_sqlite_cannot_alter_primary_key() { - let mut id = column("id"); - id.data_type = "integer".to_string(); - id.is_nullable = false; - id.is_primary_key = true; - id.original = Some(ColumnInfo { - name: "id".to_string(), - data_type: "integer".to_string(), - is_nullable: false, - column_default: None, - is_primary_key: false, - extra: None, - comment: None, - ..Default::default() - }); +fn rename_only_primary_key_column_does_not_emit_primary_key_ddl() { + let mut id = existing_pk_column("id_new", "integer", true, true); + id.original.as_mut().unwrap().name = "id".to_string(); - let result = build_table_structure_change_sql(TableStructureSqlOptions { - database_type: Some(DatabaseType::Sqlite), - schema: None, - table_name: "users".to_string(), - columns: vec![id], - indexes: Vec::new(), - foreign_keys: Vec::new(), - triggers: Vec::new(), - table_comment: None, - original_table_comment: None, - }); + let result = build_table_structure_change_sql(structure_change_options( + DatabaseType::Dameng, + Some("SYSDBA"), + "users", + vec![id], + )); + + // Membership is tracked by draft id, so rename alone is not a PK change. + assert_eq!(result.warnings, Vec::::new()); + assert!(!result.statements.iter().any(|sql| sql.contains("PRIMARY KEY"))); +} + +#[test] +fn warns_sqlite_cannot_alter_primary_key() { + let result = build_table_structure_change_sql(structure_change_options( + DatabaseType::Sqlite, + None, + "users", + vec![existing_pk_column("id", "integer", false, true)], + )); + + assert_eq!(result.statements, Vec::::new()); + assert_eq!(result.warnings.len(), 1); + assert!(result.warnings[0].contains("primary key")); +} + +#[test] +fn warns_sqlserver_cannot_alter_primary_key_without_drop_strategy() { + // alter_primary_key is false for SQL Server; fail closed (no partial ADD-only SQL). + let result = build_table_structure_change_sql(structure_change_options( + DatabaseType::SqlServer, + Some("dbo"), + "users", + vec![existing_pk_column("id", "int", true, false)], + )); assert_eq!(result.statements, Vec::::new()); assert_eq!(result.warnings.len(), 1);