From 13a209d4c2ba63c555aa322e87a2628d0af4f029 Mon Sep 17 00:00:00 2001 From: Hand Sonic <8078023+HandSonic@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:36:11 +0800 Subject: [PATCH] feat(transfer): support h2 table data transfer --- .../main/java/com/dbx/agent/h2/H2Agent.java | 85 ++++- .../java/com/dbx/agent/h2/H2AgentTest.java | 122 +++++++ .../agent/test/JdbcMetadataBehaviorTest.java | 2 +- .../__tests__/transferConnections.spec.ts | 103 ++++++ .../__tests__/transferObjectKinds.spec.ts | 2 +- .../assets/database-drivers.manifest.json | 2 +- crates/dbx-core/src/transfer.rs | 342 +++++++++++++++++- crates/dbx-core/src/transfer/ddl_plan.rs | 30 +- .../public/screenshots/h2-transfer-target.png | Bin 0 -> 55726 bytes plugins/connection-types/h2.yaml | 2 +- 10 files changed, 665 insertions(+), 25 deletions(-) create mode 100644 apps/desktop/src/components/transfer/__tests__/transferConnections.spec.ts create mode 100644 docs/public/screenshots/h2-transfer-target.png diff --git a/agents/drivers/h2/src/main/java/com/dbx/agent/h2/H2Agent.java b/agents/drivers/h2/src/main/java/com/dbx/agent/h2/H2Agent.java index e3c64be7a8..ce2abcc751 100644 --- a/agents/drivers/h2/src/main/java/com/dbx/agent/h2/H2Agent.java +++ b/agents/drivers/h2/src/main/java/com/dbx/agent/h2/H2Agent.java @@ -28,8 +28,20 @@ import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.regex.Pattern; public class H2Agent extends AbstractJdbcAgent { + private static final String QUOTED_IDENTIFIER = "\"(?:\"\"|[^\"])*\""; + private static final String QUALIFIED_IDENTIFIER = QUOTED_IDENTIFIER + "\\." + QUOTED_IDENTIFIER; + private static final Pattern CREATE_SEQUENCE = Pattern.compile("^CREATE SEQUENCE (" + QUALIFIED_IDENTIFIER + ")\\s"); + private static final Pattern SEQUENCE_REFERENCE = Pattern.compile( + "'(?:''|[^'])*'|" + QUOTED_IDENTIFIER + "|--[^\\r\\n]*|/\\*.*?\\*/|\\b(?:NEXT|CURRENT)\\s+VALUE\\s+FOR\\s+(" + QUALIFIED_IDENTIFIER + ")", + Pattern.DOTALL + ); + private static final Pattern CREATE_INDEX = Pattern.compile( + "^CREATE (?:UNIQUE(?: NULLS (?:DISTINCT|NOT DISTINCT|ALL DISTINCT))? )?(?:HASH |SPATIAL )?INDEX .*", + Pattern.DOTALL + ); private String databaseName = ""; private H2DriverLoader.LoadedDriver loadedDriver; private H2DriverVersion driverVersion = H2DriverVersion.V3; @@ -322,6 +334,55 @@ public class H2Agent extends AbstractJdbcAgent { }); } + @Override + public String getTableDdl(String schema, String table) { + return unchecked(() -> { + String tableRef = JdbcIdentifiers.INSTANCE.doubleQuote(resolveSchema(schema)) + "." + + JdbcIdentifiers.INSTANCE.doubleQuote(table); + List statements = new ArrayList<>(); + List sequences = new ArrayList<>(); + // Let H2 preserve identity/computed columns, constraints and index ordering. + // Exclude database-wide settings, users, schemas, grants and triggers. + try (var stmt = requireConnected().createStatement(); + ResultSet rs = stmt.executeQuery("SCRIPT NODATA NOPASSWORDS NOSETTINGS TABLE " + tableRef)) { + while (rs.next()) { + String sql = rs.getString(1).trim(); + if (sql.startsWith("CREATE SEQUENCE ")) { + sequences.add(sql); + } else if (sql.startsWith("CREATE MEMORY TABLE ") || sql.startsWith("CREATE CACHED TABLE ") + || sql.startsWith("CREATE TABLE ") || sql.startsWith("ALTER TABLE ") + || CREATE_INDEX.matcher(sql).matches()) { + statements.add(sql); + } + } + } + if (statements.isEmpty()) { + throw new IllegalStateException("H2 returned no table DDL for " + tableRef); + } + // SCRIPT TABLE can include every sequence in the schema. Keep only real + // references, skipping quoted strings, identifiers and comments as tokens. + Set referenced = new HashSet<>(); + for (String statement : statements) { + var references = SEQUENCE_REFERENCE.matcher(statement); + while (references.find()) { + if (references.group(1) != null) { + referenced.add(references.group(1)); + } + } + } + List ddl = new ArrayList<>(); + for (String sequence : sequences) { + var declaration = CREATE_SEQUENCE.matcher(sequence); + if (declaration.find() && referenced.contains(declaration.group(1))) { + // Several selected tables may share one explicit sequence. + ddl.add("CREATE SEQUENCE IF NOT EXISTS " + sequence.substring("CREATE SEQUENCE ".length())); + } + } + ddl.addAll(statements); + return String.join("\n", ddl); + }); + } + @Override public List getColumns(String schema, String table) { return unchecked(() -> { @@ -349,7 +410,10 @@ public class H2Agent extends AbstractJdbcAgent { List result = new ArrayList<>(); String typeColumn = isVersion2OrLater() ? "DATA_TYPE" : "TYPE_NAME"; - String columnSql = "SELECT COLUMN_NAME, " + typeColumn + " AS DBX_DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT, " + String extraColumns = isVersion2OrLater() + ? "IS_GENERATED, IS_IDENTITY, IDENTITY_GENERATION, " + : "IS_COMPUTED, SEQUENCE_NAME, "; + String columnSql = "SELECT COLUMN_NAME, " + typeColumn + " AS DBX_DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT, " + extraColumns + "NUMERIC_PRECISION, NUMERIC_SCALE, CHARACTER_MAXIMUM_LENGTH, REMARKS " + "FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION"; try (var stmt = requireConnected().prepareStatement(columnSql)) { @@ -358,14 +422,26 @@ public class H2Agent extends AbstractJdbcAgent { try (ResultSet rs = stmt.executeQuery()) { while (rs.next()) { String columnName = rs.getString("COLUMN_NAME"); + String extra = null; + if (isVersion2OrLater()) { + if ("ALWAYS".equals(rs.getString("IS_GENERATED"))) { + extra = "computed"; + } else if ("YES".equals(rs.getString("IS_IDENTITY"))) { + extra = "generated " + rs.getString("IDENTITY_GENERATION").toLowerCase(Locale.ROOT) + " as identity"; + } + } else if (rs.getBoolean("IS_COMPUTED")) { + extra = "computed"; + } else if (rs.getString("SEQUENCE_NAME") != null) { + extra = "identity"; + } result.add(new ColumnInfo( columnName, rs.getString("DBX_DATA_TYPE"), "YES".equals(rs.getString("IS_NULLABLE")), rs.getString("COLUMN_DEFAULT"), primaryKeys.contains(columnName), + extra, rs.getString("REMARKS"), - null, intOrNull(rs, "NUMERIC_PRECISION"), intOrNull(rs, "NUMERIC_SCALE"), intOrNull(rs, "CHARACTER_MAXIMUM_LENGTH") @@ -587,10 +663,7 @@ public class H2Agent extends AbstractJdbcAgent { } private static String resolveSchema(String schema) { - if ("PUBLIC".equalsIgnoreCase(schema) || "INFORMATION_SCHEMA".equalsIgnoreCase(schema)) { - return schema.toUpperCase(Locale.ROOT); - } - return "PUBLIC"; + return schema == null || schema.isBlank() ? "PUBLIC" : schema; } private static Integer intOrNull(ResultSet rs, String column) throws Exception { diff --git a/agents/drivers/h2/src/test/java/com/dbx/agent/h2/H2AgentTest.java b/agents/drivers/h2/src/test/java/com/dbx/agent/h2/H2AgentTest.java index 1079849654..50829aadde 100644 --- a/agents/drivers/h2/src/test/java/com/dbx/agent/h2/H2AgentTest.java +++ b/agents/drivers/h2/src/test/java/com/dbx/agent/h2/H2AgentTest.java @@ -355,6 +355,128 @@ class H2AgentMigrationTest { } class H2VersionMatrixTest { + @Test + void tableDdlPreservesGeneratedColumnsConstraintsAndComments() { + for (String profile : List.of("h2-v1", "h2-v2", "h2-v3")) { + ConnectParams params = new ConnectParams("", 0, "mem:ddl-" + profile, "sa", "", "", "", false); + params.setDriver_profile(profile); + H2Agent agent = new H2Agent(); + agent.connect(params); + try { + ExecuteQueryOptions options = new ExecuteQueryOptions(); + agent.executeQuery("CREATE SCHEMA \"Source\"; CREATE SCHEMA \"Target\"", null, options); + agent.executeQuery("CREATE SEQUENCE \"Source\".UNUSED; CREATE SEQUENCE \"Source\".SHARED START WITH 10", null, options); + agent.executeQuery("CREATE TABLE \"Source\".PARENT (ID INT PRIMARY KEY, TOKEN BIGINT DEFAULT NEXT VALUE FOR \"Source\".SHARED)", null, options); + agent.executeQuery("CREATE TABLE \"Source\".CHILD (ID INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, " + + "PARENT_ID INT, QTY INT DEFAULT 1 CHECK (QTY > 0), TOTAL INT AS (QTY * 2), RAW_BYTES BINARY(8), " + + "TOKEN BIGINT DEFAULT NEXT VALUE FOR \"Source\".SHARED, NOTE VARCHAR(128) DEFAULT 'NEXT VALUE FOR \"Source\".\"UNUSED\"', " + + "CONSTRAINT FK_PARENT FOREIGN KEY (PARENT_ID) REFERENCES \"Source\".PARENT(ID) ON DELETE CASCADE)", null, options); + agent.executeQuery("COMMENT ON COLUMN \"Source\".CHILD.QTY IS 'quantity'; COMMENT ON TABLE \"Source\".CHILD IS 'child table'", null, options); + agent.executeQuery("CREATE INDEX \"Source\".IDX_QTY ON \"Source\".CHILD(QTY DESC)", null, options); + List columns = agent.getColumns("Source", "CHILD"); + Assertions.assertTrue(columns.get(0).getExtra().contains("identity"), profile); + Assertions.assertEquals("computed", columns.get(3).getExtra(), profile); + Assertions.assertEquals("quantity", columns.get(2).getComment(), profile); + + for (String table : List.of("PARENT", "CHILD")) { + String ddl = agent.getTableDdl("Source", table); + Assertions.assertFalse(ddl.contains("CREATE USER"), ddl); + Assertions.assertFalse(ddl.contains("CREATE SCHEMA"), ddl); + Assertions.assertFalse(ddl.contains("INSERT INTO"), ddl); + Assertions.assertFalse(ddl.contains("CREATE SEQUENCE IF NOT EXISTS \"Source\".\"UNUSED\""), ddl); + if (table.equals("PARENT")) { + Assertions.assertFalse(ddl.contains("SYSTEM_SEQUENCE_"), ddl); + } + agent.executeQuery(ddl.replace("\"Source\".", "\"Target\"."), null, options); + } + agent.executeQuery("INSERT INTO \"Target\".PARENT(ID) VALUES (1)", null, options); + agent.executeQuery("INSERT INTO \"Target\".CHILD(PARENT_ID) VALUES (1)", null, options); + Assertions.assertEquals(List.of(List.of(1, 1, 2)), agent.executeQuery("SELECT ID, QTY, TOTAL FROM \"Target\".CHILD", null, options).getRows(), profile); + Assertions.assertEquals("11", agent.executeQuery("SELECT TOKEN FROM \"Target\".CHILD", null, options).getRows().get(0).get(0).toString(), profile); + Assertions.assertThrows(RuntimeException.class, () -> agent.executeQuery("INSERT INTO \"Target\".CHILD(QTY) VALUES (-1)", null, options), profile); + Assertions.assertTrue(agent.getTableDdl("Target", "CHILD").contains("\"QTY\" DESC"), profile); + Assertions.assertEquals("quantity", agent.getColumns("Target", "CHILD").get(2).getComment(), profile); + agent.executeQuery("DELETE FROM \"Target\".PARENT", null, options); + Assertions.assertTrue(agent.executeQuery("SELECT * FROM \"Target\".CHILD", null, options).getRows().isEmpty(), profile); + } finally { + agent.disconnect(); + } + } + } + + @Test + void generatedAlwaysIdentityAcceptsExplicitInsertWithOverride() { + for (String profile : List.of("h2-v2", "h2-v3")) { + H2Agent agent = connect(profile); + try { + ExecuteQueryOptions options = new ExecuteQueryOptions(); + agent.executeQuery("CREATE TABLE ALWAYS_ID (ID INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, V INT)", null, options); + Assertions.assertEquals("generated always as identity", agent.getColumns("PUBLIC", "ALWAYS_ID").get(0).getExtra()); + agent.executeQuery("INSERT INTO ALWAYS_ID(ID, V) OVERRIDING SYSTEM VALUE VALUES (42, 7)", null, options); + Assertions.assertEquals(List.of(List.of(42, 7)), agent.executeQuery("SELECT ID, V FROM ALWAYS_ID", null, options).getRows()); + } finally { + agent.disconnect(); + } + } + } + + @Test + void transferMetadataAndWritesRespectCustomSchemasAcrossBundledVersions() { + for (String profile : List.of("h2-v1", "h2-v2", "h2-v3")) { + ConnectParams params = new ConnectParams("", 0, "mem:transfer-" + profile, "sa", "", "", "", false); + params.setDriver_profile(profile); + H2Agent agent = new H2Agent(); + agent.connect(params); + try { + ExecuteQueryOptions options = new ExecuteQueryOptions(); + agent.executeQuery("CREATE SCHEMA \"TransferTarget\"", null, options); + agent.executeQuery("CREATE TABLE PUBLIC.ITEMS (PUBLIC_ONLY INT)", null, options); + agent.executeQuery( + "CREATE TABLE \"TransferTarget\".ITEMS (ID INT, PART INT, NAME VARCHAR(1024), AMOUNT DECIMAL(12, 2), PRIMARY KEY (ID, PART))", + null, options + ); + agent.executeQuery("CREATE INDEX \"TransferTarget\".IDX_NAME ON \"TransferTarget\".ITEMS(NAME)", null, options); + agent.executeQuery("CREATE UNIQUE HASH INDEX \"TransferTarget\".IDX_PART ON \"TransferTarget\".ITEMS(PART)", null, options); + + Assertions.assertTrue(agent.listSchemas().contains("TransferTarget"), profile); + Assertions.assertTrue(agent.listTables("TransferTarget").stream().anyMatch(table -> table.getName().equals("ITEMS")), profile); + Assertions.assertTrue(agent.listTables("MISSING_SCHEMA").isEmpty(), profile); + Assertions.assertEquals(List.of("PUBLIC_ONLY"), agent.getColumns(null, "ITEMS").stream().map(ColumnInfo::getName).toList(), profile); + List columns = agent.getColumns("TransferTarget", "ITEMS"); + Assertions.assertEquals(List.of("ID", "PART", "NAME", "AMOUNT"), columns.stream().map(ColumnInfo::getName).toList(), profile); + Assertions.assertEquals(1024, columns.get(2).getCharacter_maximum_length(), profile); + Assertions.assertEquals(12, columns.get(3).getNumeric_precision(), profile); + Assertions.assertEquals(2, columns.get(3).getNumeric_scale(), profile); + Assertions.assertTrue(agent.listIndexes("TransferTarget", "ITEMS").stream().anyMatch(index -> index.getName().equals("IDX_NAME")), profile); + agent.executeQuery("CREATE SCHEMA \"TransferCopy\"", null, options); + String ddl = agent.getTableDdl("TransferTarget", "ITEMS"); + agent.executeQuery(ddl.replace("\"TransferTarget\".", "\"TransferCopy\"."), null, options); + Assertions.assertEquals(1024, agent.getColumns("TransferCopy", "ITEMS").get(2).getCharacter_maximum_length(), profile); + Assertions.assertTrue(agent.listIndexes("TransferCopy", "ITEMS").stream().anyMatch(index -> index.getName().equals("IDX_NAME")), profile); + Assertions.assertTrue(agent.listIndexes("TransferCopy", "ITEMS").stream().anyMatch(index -> index.getName().equals("IDX_PART") && index.getIs_unique()), profile); + agent.executeQuery("INSERT INTO \"TransferCopy\".ITEMS VALUES (1, 1, 'first', 1.00)", null, options); + Assertions.assertThrows(RuntimeException.class, () -> agent.executeQuery("INSERT INTO \"TransferCopy\".ITEMS VALUES (2, 1, 'duplicate', 2.00)", null, options), profile); + + agent.executeQuery("INSERT INTO \"TransferTarget\".ITEMS VALUES (1, 1, 'old', 1.00)", null, options); + agent.executeQuery( + "MERGE INTO \"TransferTarget\".\"ITEMS\" (\"ID\", \"PART\", \"NAME\", \"AMOUNT\") KEY (\"ID\", \"PART\") VALUES " + + "(1, 1, 'updated', 12.34), (1, 2, 'new', 56.78)", + null, options + ); + QueryResult result = agent.executeQuery("SELECT NAME, CAST(AMOUNT AS VARCHAR) FROM \"TransferTarget\".ITEMS ORDER BY PART", null, options); + Assertions.assertEquals(List.of(List.of("updated", "12.34"), List.of("new", "56.78")), result.getRows(), profile); + agent.executeQuery("TRUNCATE TABLE \"TransferTarget\".ITEMS", null, options); + Assertions.assertTrue(agent.executeQuery("SELECT * FROM \"TransferTarget\".ITEMS", null, options).getRows().isEmpty(), profile); + agent.executeQuery("CREATE TABLE \"TransferTarget\".PAYLOADS (LABEL VARCHAR(128), DATA JSON, RAW_BYTES VARBINARY)", null, options); + agent.executeQuery("INSERT INTO \"TransferTarget\".PAYLOADS VALUES ('C:\\tmp\\o''clock', JSON '[1,2]', X'00ff')", null, options); + QueryResult payload = agent.executeQuery("SELECT * FROM \"TransferTarget\".PAYLOADS", null, options); + Assertions.assertEquals(List.of(List.of("C:\\tmp\\o'clock", "[1,2]", "0x00ff")), payload.getRows(), profile); + } finally { + agent.disconnect(); + } + } + } + @Test void metadataAndExecutionWorkAcrossBundledVersions() { for (String profile : List.of("h2-v1", "h2-v2", "h2-v3")) { diff --git a/agents/test-support/src/main/java/com/dbx/agent/test/JdbcMetadataBehaviorTest.java b/agents/test-support/src/main/java/com/dbx/agent/test/JdbcMetadataBehaviorTest.java index 9ef0b26669..b7ab16b9a7 100644 --- a/agents/test-support/src/main/java/com/dbx/agent/test/JdbcMetadataBehaviorTest.java +++ b/agents/test-support/src/main/java/com/dbx/agent/test/JdbcMetadataBehaviorTest.java @@ -60,7 +60,7 @@ public abstract class JdbcMetadataBehaviorTest extends JdbcConnectedAgentTest { for (String column : expectedColumnsInOrder()) { assertTrue(ddl.contains("\"" + column + "\""), "DDL should include column " + column + ": " + ddl); } - assertTrue(ddl.contains("CREATE TABLE"), "DDL should include CREATE TABLE: " + ddl); + assertTrue(ddl.matches("(?s).*\\bCREATE (?:MEMORY |CACHED )?TABLE\\b.*"), "DDL should include CREATE TABLE: " + ddl); assertTrue(ddl.contains(metadataColumnsTable()), "DDL should include table name: " + ddl); }); } diff --git a/apps/desktop/src/components/transfer/__tests__/transferConnections.spec.ts b/apps/desktop/src/components/transfer/__tests__/transferConnections.spec.ts new file mode 100644 index 0000000000..1a113c0566 --- /dev/null +++ b/apps/desktop/src/components/transfer/__tests__/transferConnections.spec.ts @@ -0,0 +1,103 @@ +// @vitest-environment happy-dom + +import { createApp, defineComponent, h, nextTick, reactive, type App } from "vue"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import ConnectionTreeSelect from "@/components/connection/ConnectionTreeSelect.vue"; +import { supportsTransfer } from "@/lib/database/databaseCapabilities"; +import { transferDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; +import type { ConnectionConfig, SidebarLayout } from "@/types/database"; + +vi.mock("@/components/ui/button", async () => { + const { createPassthroughStub } = await import("@/components/grid/__tests__/vueHostHarness"); + return { Button: createPassthroughStub("Button", "button") }; +}); + +vi.mock("@/components/ui/popover", async () => { + const { createPassthroughStub } = await import("@/components/grid/__tests__/vueHostHarness"); + return { + Popover: createPassthroughStub("Popover"), + PopoverContent: createPassthroughStub("PopoverContent"), + PopoverTrigger: createPassthroughStub("PopoverTrigger"), + }; +}); + +vi.mock("@/components/icons/DatabaseIcon.vue", () => ({ default: defineComponent({ setup: () => () => h("span") }) })); + +const connections: ConnectionConfig[] = [ + { id: "mysql-source", name: "MySQL Source", db_type: "mysql", host: "localhost", port: 3306, username: "test", password: "" }, + { id: "pg-source", name: "PostgreSQL Source", db_type: "postgres", host: "localhost", port: 5432, username: "test", password: "" }, + { id: "h2-local-id", name: "Local H2", db_type: "h2", driver_profile: "h2-v3", host: "", port: 0, username: "sa", password: "", database: "mem:transfer-test" }, + { id: "h2-jdbc-id", name: "JDBC H2", db_type: "jdbc", connection_string: "jdbc:h2:mem:transfer-test", host: "", port: 0, username: "sa", password: "" }, + { id: "redis-id", name: "Redis", db_type: "redis", host: "localhost", port: 6379, username: "", password: "" }, + { id: "unknown-jdbc-id", name: "Unknown JDBC", db_type: "jdbc", host: "localhost", port: 0, username: "", password: "" }, +]; +const layout: SidebarLayout = { + groups: [{ id: "embedded", name: "Embedded", collapsed: false }], + order: [{ type: "group", id: "embedded", children: [{ type: "connection", id: "h2-local-id" }] }], +}; +const mountedApps: App[] = []; + +async function mountTransferPickers(source: string) { + const selection = reactive({ source, target: "" }); + const eligible = connections.filter((connection) => supportsTransfer(transferDatabaseTypeForConnection(connection))); + const container = document.createElement("div"); + document.body.append(container); + const app = createApp( + defineComponent({ + setup: () => () => + h( + "div", + (["source", "target"] as const).map((side) => + h("section", { "data-side": side }, [ + h(ConnectionTreeSelect, { + modelValue: selection[side], + connections: eligible, + layout, + placeholder: "Select connection", + searchPlaceholder: "Search", + emptyText: "No connections", + "onUpdate:modelValue": (value: string) => { + selection[side] = value; + }, + }), + ]), + ), + ), + }), + ); + mountedApps.push(app); + app.mount(container); + await nextTick(); + return { container, selection }; +} + +afterEach(() => { + for (const app of mountedApps.splice(0)) app.unmount(); + document.body.innerHTML = ""; +}); + +describe("data transfer connection choices", () => { + it.each(["mysql-source", "pg-source"])("offers H2 on both sides when opened from %s", async (source) => { + const { container, selection } = await mountTransferPickers(source); + for (const side of ["source", "target"]) { + const picker = container.querySelector(`[data-side="${side}"]`)!; + expect(picker.querySelector('[data-picker-connection="h2-local-id"]')?.textContent).toContain("Local H2"); + expect(picker.querySelector('[data-picker-connection="h2-jdbc-id"]')?.textContent).toContain("JDBC H2"); + expect(picker.querySelector('[data-picker-connection="redis-id"]')).toBeNull(); + expect(picker.querySelector('[data-picker-connection="unknown-jdbc-id"]')).toBeNull(); + } + const target = container.querySelector('[data-side="target"]')!; + (target.querySelector('[data-picker-connection="h2-local-id"]') as HTMLButtonElement).click(); + await nextTick(); + expect(selection.target).toBe("h2-local-id"); + expect(target.querySelector("button")?.textContent).toContain("Local H2"); + expect(selection.source).toBe(source); + }); + + it.each(["h2-local-id", "h2-jdbc-id"])("resolves the prefilled %s to its connection name", async (source) => { + const { container } = await mountTransferPickers(source); + const trigger = container.querySelector('[data-side="source"] button'); + expect(trigger?.textContent).toContain(connections.find((connection) => connection.id === source)!.name); + expect(trigger?.textContent).not.toContain(source); + }); +}); diff --git a/apps/desktop/src/lib/database/__tests__/transferObjectKinds.spec.ts b/apps/desktop/src/lib/database/__tests__/transferObjectKinds.spec.ts index 9308f95f3e..920ca0dd59 100644 --- a/apps/desktop/src/lib/database/__tests__/transferObjectKinds.spec.ts +++ b/apps/desktop/src/lib/database/__tests__/transferObjectKinds.spec.ts @@ -4,7 +4,7 @@ import { manifestDatabaseTypes } from "@/lib/database/databaseDriverManifest"; import { supportsTransfer } from "@/lib/database/databaseFeatureSupport"; import type { DatabaseType } from "@/types/database"; -const TABLE_ONLY_TRANSFER_DATABASES: DatabaseType[] = ["sqlite", "rqlite", "turso", "cloudflare-d1", "duckdb", "clickhouse", "mongodb", "highgo", "goldendb", "questdb", "hive", "argo", "kyuubi", "impala", "spark"]; +const TABLE_ONLY_TRANSFER_DATABASES: DatabaseType[] = ["sqlite", "rqlite", "turso", "cloudflare-d1", "duckdb", "clickhouse", "mongodb", "highgo", "goldendb", "questdb", "h2", "hive", "argo", "kyuubi", "impala", "spark"]; describe("transferObjectKinds", () => { it("groups databases into transfer families", () => { diff --git a/crates/dbx-core/assets/database-drivers.manifest.json b/crates/dbx-core/assets/database-drivers.manifest.json index ae7994b534..8e484d4067 100644 --- a/crates/dbx-core/assets/database-drivers.manifest.json +++ b/crates/dbx-core/assets/database-drivers.manifest.json @@ -1692,7 +1692,7 @@ "tableDataEdit": true, "tableStructureEdit": true, "tableImport": false, - "dataTransfer": false, + "dataTransfer": true, "sqlFileExecution": true, "databaseCreate": false, "fieldLineage": false, diff --git a/crates/dbx-core/src/transfer.rs b/crates/dbx-core/src/transfer.rs index 27879cd0f3..65cf6d767e 100644 --- a/crates/dbx-core/src/transfer.rs +++ b/crates/dbx-core/src/transfer.rs @@ -1293,6 +1293,27 @@ fn rewrite_double_quoted_schema_qualifier(ddl: &str, source_schema: &str, target map_sql_code_spans(ddl, false, |code| code.replace(&source, &target)) } +fn rewrite_h2_schema_qualifier(ddl: &str, source_schema: &str, target_schema: &str) -> String { + if source_schema == target_schema || source_schema.is_empty() { + return ddl.to_string(); + } + let source = format!("\"{}\"", source_schema.replace('"', "\"\"")); + let target = format!("\"{}\"", target_schema.replace('"', "\"\"")); + let identifiers = Regex::new(r#""(?:""|[^"])*""#).expect("valid quoted identifier regex"); + map_sql_code_spans(ddl, false, |code| { + identifiers + .replace_all(code, |captures: ®ex::Captures| { + let identifier = captures.get(0).unwrap(); + if identifier.as_str() == source && code[identifier.end()..].trim_start().starts_with('.') { + target.clone() + } else { + identifier.as_str().to_string() + } + }) + .into_owned() + }) +} + pub fn rewrite_oracle_schema_qualifier(ddl: &str, source_schema: &str, target_schema: &str) -> String { rewrite_double_quoted_schema_qualifier(ddl, source_schema, target_schema) } @@ -1685,6 +1706,34 @@ fn target_column_can_be_omitted(column: &db::ColumnInfo, target_db_type: &Databa || (matches!(target_db_type, DatabaseType::SqlServer) && is_sqlserver_rowversion_type(&column.data_type)) } +fn h2_writable_transfer_columns( + source_columns: &[db::ColumnInfo], + target_columns: &[db::ColumnInfo], + mode: &TransferMode, + target_table: &str, +) -> Result, String> { + let mut columns = Vec::new(); + for source in source_columns { + let target = target_columns.iter().find(|target| target.name == source.name); + if target.is_some_and(|target| target.extra.as_deref() == Some("computed")) { + continue; + } + if *mode == TransferMode::Upsert + && target.is_some_and(|target| is_postgres_generated_always_identity_extra(target.extra.as_deref())) + { + return Err(format!("H2 Upsert cannot assign GENERATED ALWAYS identity column '{}'. Use append/overwrite or change the target column to GENERATED BY DEFAULT.", source.name)); + } + columns.push(source.clone()); + } + if columns.is_empty() { + return Err("No writable H2 target columns found".to_string()); + } + // Data-only overwrite must also fail before TRUNCATE, not after losing target rows. + let names = columns.iter().map(|column| column.name.clone()).collect::>(); + validate_preexisting_target_columns(target_columns, &names, &DatabaseType::H2, true, target_table)?; + Ok(columns) +} + fn required_unmapped_transfer_target_columns( target_columns: &[db::ColumnInfo], col_names: &[String], @@ -1889,9 +1938,33 @@ fn postgres_column_type_sql( return rewrite_postgres_schema_qualified_references(trimmed, source_schema, target_schema); } } + if matches!(source_db, DatabaseType::H2) { + return map_column_type(&h2_transfer_column_type(column), source_db, target_db); + } map_column_type(&column.data_type, source_db, target_db) } +fn h2_transfer_column_type(column: &db::ColumnInfo) -> String { + let data_type = column.data_type.trim(); + if !data_type.contains('(') { + match data_type.to_ascii_lowercase().as_str() { + "varchar" | "character varying" | "char" | "character" | "binary varying" | "varbinary" | "binary" => { + if let Some(length) = column.character_maximum_length.filter(|length| *length > 0) { + return format!("{data_type}({length})"); + } + } + "decimal" | "numeric" => { + if let Some(precision) = column.numeric_precision.filter(|precision| *precision > 0) { + let scale = column.numeric_scale.unwrap_or(0); + return format!("{data_type}({precision},{scale})"); + } + } + _ => {} + } + } + data_type.to_string() +} + fn clickhouse_temporal_column_type( column: &db::ColumnInfo, source_db: &DatabaseType, @@ -2699,6 +2772,13 @@ pub fn escape_value(val: &serde_json::Value, db_type: &DatabaseType) -> String { } pub fn escape_value_typed(val: &serde_json::Value, db_type: &DatabaseType, column_type: Option<&str>) -> String { + if *db_type == DatabaseType::H2 + && !val.is_null() + && column_type.is_some_and(|kind| kind.trim().eq_ignore_ascii_case("json")) + { + let json = val.as_str().map(str::to_string).unwrap_or_else(|| val.to_string()); + return format!("JSON {}", quote_string_literal(&json)); + } match val { serde_json::Value::Null => "NULL".to_string(), serde_json::Value::Bool(b) => match db_type { @@ -2769,12 +2849,21 @@ pub fn escape_value_typed(val: &serde_json::Value, db_type: &DatabaseType, colum if let Some(temporal_literal) = format_oracle_temporal_sql_literal(s, db_type, column_type) { return temporal_literal; } + if *db_type == DatabaseType::H2 && column_type.is_some_and(is_binary_transfer_column_type) { + if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) { + if hex.len() % 2 == 0 && hex.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return format!("X'{hex}'"); + } + } + } let literal = format_literal_string(s, db_type, column_type); if *db_type == DatabaseType::Postgres { return quote_postgres_string_literal(&literal); } - let escaped = if is_postgres_family_target(db_type) || *db_type == DatabaseType::SqlServer { + let escaped = if is_postgres_family_target(db_type) + || matches!(db_type, DatabaseType::SqlServer | DatabaseType::H2) + { literal.replace('\'', "''") } else { literal.replace('\\', "\\\\").replace('\'', "''") @@ -2800,6 +2889,9 @@ pub fn escape_value_typed(val: &serde_json::Value, db_type: &DatabaseType, colum } _ => { let s = val.to_string(); + if *db_type == DatabaseType::H2 { + return quote_string_literal(&s); + } format!("'{}'", s.replace('\\', "\\\\").replace('\'', "''")) } } @@ -3287,6 +3379,15 @@ pub fn map_column_type(source_type: &str, source_db: &DatabaseType, target_db: & } let t = source_type.to_lowercase(); let mut base = t.split('(').next().unwrap_or(&t).trim(); + if matches!(source_db, DatabaseType::H2) { + base = match base { + "character varying" => "varchar", + "character large object" => "clob", + "binary varying" => "varbinary", + "binary large object" => "blob", + _ => base, + }; + } // Extract basic type, `bigint unsigned` -> `bigint` base = base.split(' ').next().unwrap_or(base).trim(); @@ -3350,7 +3451,7 @@ pub fn map_column_type(source_type: &str, source_db: &DatabaseType, target_db: & "decimal" | "numeric" | "number" => { if t.contains('(') { match target_db { - DatabaseType::Mysql | DatabaseType::SqlServer | DatabaseType::Oracle => { + DatabaseType::Mysql | DatabaseType::SqlServer | DatabaseType::Oracle | DatabaseType::H2 => { format!("DECIMAL{}", &t[t.find('(').unwrap()..]) } target_db if is_postgres_transfer_dialect(target_db) => { @@ -3751,11 +3852,12 @@ impl InsertSqlTemplate { .map(|column| transfer_column_identifier(column, db_type, quote_target_column_names)) .collect::>() .join(", "); - let overriding = if overrides_postgres_system_values && matches!(db_type, DatabaseType::Postgres) { - " OVERRIDING SYSTEM VALUE" - } else { - "" - }; + let overriding = + if overrides_postgres_system_values && matches!(db_type, DatabaseType::Postgres | DatabaseType::H2) { + " OVERRIDING SYSTEM VALUE" + } else { + "" + }; Self { standard_prefix: format!("INSERT INTO {full_table} ({col_list}){overriding} VALUES\n"), oracle_into_prefix: matches!(db_type, DatabaseType::Oracle) @@ -3967,6 +4069,14 @@ fn generate_upsert_typed_for_transfer( } sql } + DatabaseType::H2 => { + let pk_list = pk_columns + .iter() + .map(|column| transfer_column_identifier(column, db_type, quote_target_column_names)) + .collect::>() + .join(", "); + format!("MERGE INTO {full_table} ({col_list}) KEY ({pk_list}) VALUES\n{}", value_rows.join(",\n")) + } db_type if uses_mysql_style_upsert(db_type) => { let mut sql = format!("INSERT INTO {full_table} ({col_list}) VALUES\n{}", value_rows.join(",\n")); if non_pk_columns.is_empty() { @@ -4254,6 +4364,10 @@ fn rewrite_transfer_source_table_ddl( Some(rewrite_postgres_schema_qualified_references(sql, source_schema, target_schema)) } else if matches!((source_db_type, target_db_type), (DatabaseType::Dameng, DatabaseType::Dameng)) { Some(strip_dameng_storage_clauses(&rewrite_double_quoted_schema_qualifier(sql, source_schema, target_schema))) + } else if matches!((source_db_type, target_db_type), (DatabaseType::H2, DatabaseType::H2)) { + let source_schema = if source_schema.trim().is_empty() { "PUBLIC" } else { source_schema }; + let target_schema = if target_schema.trim().is_empty() { "PUBLIC" } else { target_schema }; + Some(rewrite_h2_schema_qualifier(sql, source_schema, target_schema)) } else if is_mysql_family_target(source_db_type) && is_mysql_family_target(target_db_type) { // The reused SHOW CREATE TABLE DDL carries the source table name; rewrite the // CREATE TABLE header when the transfer renames the table (name case conversion). @@ -6099,6 +6213,16 @@ fn effective_transfer_database_type(config: &ConnectionConfig) -> DatabaseType { return DatabaseType::Oracle; } + let is_h2 = config.connection_string.as_deref().is_some_and(|url| url.to_ascii_lowercase().starts_with("jdbc:h2:")) + || config.jdbc_driver_class.as_deref().is_some_and(|class| class.eq_ignore_ascii_case("org.h2.Driver")) + || config.driver_profile.as_deref().is_some_and(|profile| { + let profile = profile.to_ascii_lowercase(); + profile == "h2" || profile.starts_with("h2-") + }); + if is_h2 { + return DatabaseType::H2; + } + let jdbc_identity = [ config.driver_profile.as_deref().unwrap_or(""), config.connection_string.as_deref().unwrap_or(""), @@ -9019,8 +9143,8 @@ where return Err(format!("No writable columns found for table {table}")); } - let col_names: Vec = writable_columns.iter().map(|c| c.name.clone()).collect(); - let col_types: Vec> = writable_columns.iter().map(|c| Some(c.data_type.clone())).collect(); + let mut col_names: Vec = writable_columns.iter().map(|c| c.name.clone()).collect(); + let mut col_types: Vec> = writable_columns.iter().map(|c| Some(c.data_type.clone())).collect(); let primary_key_columns = transfer_key_columns(&writable_columns, source_db_type); if should_copy_data(&request.content) { log::info!("[transfer] {} has {} columns, counting rows...", table, columns.len()); @@ -9148,7 +9272,7 @@ where | DatabaseType::Impala | DatabaseType::Argo )) - || matches!(target_db_type, DatabaseType::Postgres | DatabaseType::Dameng); + || matches!(target_db_type, DatabaseType::Postgres | DatabaseType::Dameng | DatabaseType::H2); let target_columns = if needs_target_columns { get_columns_for_transfer( state, @@ -9184,6 +9308,18 @@ where )?; } + if *target_db_type == DatabaseType::H2 { + let columns = h2_writable_transfer_columns(&writable_columns, &target_columns, &request.mode, &target_table)?; + col_names = columns.iter().map(|column| column.name.clone()).collect(); + col_types = columns + .iter() + .map(|column| { + let target = target_columns.iter().find(|target| target.name == column.name).unwrap_or(column); + Some(target.data_type.clone()) + }) + .collect(); + } + // Truncate target if overwrite mode (only when not rebuilding the table). // When drop_target_before_create is true, the target table was just created // and is already empty, so TRUNCATE is unnecessary. @@ -9229,7 +9365,7 @@ where let writes_identity_insert_columns = matches!(target_db_type, DatabaseType::Dameng | DatabaseType::SqlServer) && selected_columns_include_identity_columns(&col_names, &target_columns); - let overrides_postgres_system_values = matches!(target_db_type, DatabaseType::Postgres) + let overrides_postgres_system_values = matches!(target_db_type, DatabaseType::Postgres | DatabaseType::H2) && selected_columns_include_postgres_generated_always_identity_columns(&col_names, &target_columns); // Transfer data in batches let batch_size = if request.batch_size == 0 { 1000 } else { request.batch_size }; @@ -10469,6 +10605,190 @@ mod tests { use serde_json::json; + #[test] + fn h2_jdbc_uses_native_transfer_dialect() { + for config in [ + jdbc_transfer_config("jdbc:h2:mem:transfer", "", ""), + jdbc_transfer_config("JDBC:H2:tcp://localhost/test", "", ""), + jdbc_transfer_config("", "org.h2.Driver", ""), + jdbc_transfer_config("", "", "h2-v3"), + ] { + assert_eq!(effective_transfer_database_type(&config), DatabaseType::H2); + } + assert_eq!( + effective_transfer_database_type(&jdbc_transfer_config("jdbc:other:test", "", "")), + DatabaseType::Jdbc + ); + } + + #[test] + fn h2_transfer_preserves_character_and_numeric_metadata() { + let columns = vec![ + db::ColumnInfo { character_maximum_length: Some(1024), ..test_column("NAME", "CHARACTER VARYING") }, + db::ColumnInfo { numeric_precision: Some(12), numeric_scale: Some(2), ..test_column("AMOUNT", "NUMERIC") }, + ]; + for target in [DatabaseType::Mysql, DatabaseType::Postgres, DatabaseType::H2] { + let ddl = generate_create_table_ddl( + &columns, + "ITEMS", + "PUBLIC", + "target", + &target, + &DatabaseType::H2, + None, + None, + ); + assert!(ddl.contains("(1024)"), "{ddl}"); + assert!(ddl.contains("(12,2)"), "{ddl}"); + assert!(!ddl.contains("CHAR(1)"), "{ddl}"); + } + assert_eq!( + map_column_type("CHARACTER VARYING(1024)", &DatabaseType::H2, &DatabaseType::Mysql), + "VARCHAR(1024)" + ); + assert_eq!(map_column_type("CHARACTER LARGE OBJECT", &DatabaseType::H2, &DatabaseType::Postgres), "TEXT"); + assert_eq!(map_column_type("BINARY LARGE OBJECT", &DatabaseType::H2, &DatabaseType::Postgres), "BYTEA"); + assert_eq!(map_column_type("BINARY VARYING(64)", &DatabaseType::H2, &DatabaseType::Mysql), "BLOB"); + assert_eq!(map_column_type("DECIMAL(12,2)", &DatabaseType::Mysql, &DatabaseType::H2), "DECIMAL(12,2)"); + } + + #[test] + fn h2_transfer_rewrites_schema_without_changing_literals_or_identifiers() { + let sql = r#"CREATE TABLE "Source"."ITEMS" ("NAME" VARCHAR(1024) DEFAULT '"Source".ITEMS'); +CREATE INDEX "IDX" ON "Source"."ITEMS" ("NAME"); +-- "Source".ITEMS stays in the comment +/* "Source".ITEMS */ +COMMENT ON TABLE "Source"."ITEMS" IS '"Source".ITEMS'; +CREATE TABLE "Other"."prefix""Source"."NAME" ("ID" INT);"#; + let rewritten = rewrite_transfer_source_table_ddl( + sql, + "Source", + "Target", + &DatabaseType::H2, + &DatabaseType::H2, + "ITEMS", + "ITEMS", + ) + .unwrap(); + assert!(rewritten.contains(r#"CREATE TABLE "Target"."ITEMS""#)); + assert!(rewritten.contains(r#"ON "Target"."ITEMS""#)); + assert!(rewritten.contains(r#"DEFAULT '"Source".ITEMS'"#)); + assert!(rewritten.contains(r#"-- "Source".ITEMS"#)); + assert!(rewritten.contains(r#"/* "Source".ITEMS */"#)); + assert!(rewritten.contains(r#""Other"."prefix""Source"."NAME""#)); + assert_eq!(rewrite_h2_schema_qualifier(r#""A""B" . "T""#, "A\"B", "C\"D"), r#""C""D" . "T""#); + assert!(can_reuse_source_table_ddl(&DatabaseType::H2, &DatabaseType::H2, None, None, true)); + assert!(!can_reuse_source_table_ddl(&DatabaseType::H2, &DatabaseType::H2, None, None, false)); + } + + #[test] + fn h2_upsert_merges_by_composite_primary_key() { + let columns = vec!["ID".into(), "PART".into(), "NAME".into()]; + let rows = vec![vec![json!(1), json!(1), json!("o'clock")], vec![json!(1), json!(2), json!(null)]]; + assert_eq!( + generate_upsert(&columns, &rows, "ITEMS", "TransferTarget", &DatabaseType::H2, &["ID".into(), "PART".into()]), + "MERGE INTO \"TransferTarget\".\"ITEMS\" (\"ID\", \"PART\", \"NAME\") KEY (\"ID\", \"PART\") VALUES\n(1, 1, 'o''clock'),\n(1, 2, NULL)" + ); + assert_eq!( + generate_upsert(&["ID".into()], &[vec![json!(1)]], "ITEMS", "PUBLIC", &DatabaseType::H2, &["ID".into()]), + "MERGE INTO \"PUBLIC\".\"ITEMS\" (\"ID\") KEY (\"ID\") VALUES\n(1)" + ); + } + + #[test] + fn h2_transfer_skips_target_computed_columns_and_guards_always_identity_upsert() { + let source = vec![test_column("ID", "INTEGER"), test_column("QTY", "INTEGER"), test_column("TOTAL", "INTEGER")]; + let mut target = source.clone(); + target[0].extra = Some("generated always as identity".into()); + target[2].extra = Some("computed".into()); + for mode in [TransferMode::Append, TransferMode::Overwrite] { + assert_eq!( + h2_writable_transfer_columns(&source, &target, &mode, "T") + .unwrap() + .iter() + .map(|column| column.name.as_str()) + .collect::>(), + vec!["ID", "QTY"] + ); + } + assert!(h2_writable_transfer_columns(&source, &target, &TransferMode::Upsert, "T") + .unwrap_err() + .contains("GENERATED ALWAYS")); + target[0].extra = Some("generated by default as identity".into()); + assert!(h2_writable_transfer_columns(&source, &target, &TransferMode::Upsert, "T").is_ok()); + // A generated source value can still be copied into an ordinary target column. + assert_eq!(h2_writable_transfer_columns(&target, &source, &TransferMode::Append, "T").unwrap().len(), 3); + assert_eq!( + InsertSqlTemplate::new(&["ID".into()], "T", "PUBLIC", &DatabaseType::H2, None, true) + .build(&["(42)".into()]), + "INSERT INTO \"PUBLIC\".\"T\" (\"ID\") OVERRIDING SYSTEM VALUE VALUES\n(42)" + ); + } + + #[test] + fn h2_transfer_defaults_empty_schemas_to_public() { + assert_eq!( + rewrite_transfer_source_table_ddl( + r#"CREATE TABLE "PUBLIC"."T" ("ID" INT)"#, + "", + "Target", + &DatabaseType::H2, + &DatabaseType::H2, + "T", + "T" + ) + .unwrap(), + r#"CREATE TABLE "Target"."T" ("ID" INT)"# + ); + assert_eq!( + rewrite_transfer_source_table_ddl( + r#"CREATE TABLE "Source"."T" ("ID" INT)"#, + "Source", + "", + &DatabaseType::H2, + &DatabaseType::H2, + "T", + "T" + ) + .unwrap(), + r#"CREATE TABLE "PUBLIC"."T" ("ID" INT)"# + ); + } + + #[test] + fn h2_write_preflight_rejects_missing_and_required_target_columns() { + let source = vec![test_column("ID", "INTEGER"), test_column("NAME", "VARCHAR(32)")]; + for mode in [TransferMode::Append, TransferMode::Overwrite, TransferMode::Upsert] { + let error = h2_writable_transfer_columns(&source, &source[..1], &mode, "ITEMS").unwrap_err(); + assert!(error.contains("NAME"), "{error}"); + assert!(error.contains("ITEMS"), "{error}"); + let mut target = source.clone(); + target.push(db::ColumnInfo { is_nullable: false, ..test_column("REQUIRED", "INTEGER") }); + let error = h2_writable_transfer_columns(&source, &target, &mode, "ITEMS").unwrap_err(); + assert!(error.contains("REQUIRED"), "{error}"); + target[2].column_default = Some("1".into()); + assert!(h2_writable_transfer_columns(&source, &target, &mode, "ITEMS").is_ok()); + } + } + + #[test] + fn h2_transfer_literals_preserve_json_binary_and_backslashes() { + assert_eq!( + escape_value_typed(&json!(r"C:\tmp\o'clock"), &DatabaseType::H2, Some("CHARACTER VARYING")), + r"'C:\tmp\o''clock'" + ); + assert_eq!(escape_value_typed(&json!("0x00ff"), &DatabaseType::H2, Some("BINARY VARYING")), "X'00ff'"); + assert_eq!(escape_value_typed(&json!("0x"), &DatabaseType::H2, Some("BLOB")), "X''"); + assert_eq!(escape_value_typed(&json!("0x00ff"), &DatabaseType::H2, Some("VARCHAR")), "'0x00ff'"); + assert_eq!( + escape_value_typed(&json!(r#"{"name":"o'clock"}"#), &DatabaseType::H2, Some("JSON")), + r#"JSON '{"name":"o''clock"}'"# + ); + assert_eq!(escape_value_typed(&json!([1, 2]), &DatabaseType::H2, Some("JSON")), "JSON '[1,2]'"); + assert_eq!(escape_value_typed(&json!(null), &DatabaseType::H2, Some("JSON")), "NULL"); + assert_eq!(escape_value_typed(&json!("null"), &DatabaseType::H2, Some("JSON")), "JSON 'null'"); + } + fn test_column(name: &str, data_type: &str) -> db::ColumnInfo { db::ColumnInfo { name: name.to_string(), diff --git a/crates/dbx-core/src/transfer/ddl_plan.rs b/crates/dbx-core/src/transfer/ddl_plan.rs index 9d5c217647..8ea74ef67c 100644 --- a/crates/dbx-core/src/transfer/ddl_plan.rs +++ b/crates/dbx-core/src/transfer/ddl_plan.rs @@ -145,10 +145,12 @@ pub(super) async fn prepare_table_ddl( .await { Ok(ddl) => (ddl, true), - Err(e) if rebuild => { - // A rebuild cannot fall back to generated DDL when the source DDL - // it promised to reproduce cannot be read. - return Err(format!("Failed to read source DDL for table '{table}' before rebuilding: {e}")); + Err(e) + if rebuild || matches!((source_db_type, target_db_type), (DatabaseType::H2, DatabaseType::H2)) => + { + // Rebuilds and H2-to-H2 transfers must not silently discard source + // constraints or generated columns when native DDL cannot be read. + return Err(format!("Failed to read source DDL for table '{table}' before creating target: {e}")); } Err(_) => ( generate_create_table_ddl_with_column_quoting( @@ -375,6 +377,26 @@ mod tests { assert!(prepared.ddl.contains("CREATE TABLE")); } + #[tokio::test] + async fn h2_native_ddl_read_failure_does_not_fall_back_to_lossy_metadata() { + let (state, _directory) = state_fixture().await; + let error = prepare_table_ddl( + &state, + &request(false), + "child", + "child", + &DatabaseType::H2, + &DatabaseType::H2, + "missing-source-pool", + &columns(), + None, + &HashMap::new(), + ) + .await + .unwrap_err(); + assert!(error.contains("Failed to read source DDL"), "{error}"); + } + #[tokio::test] async fn deferred_foreign_key_uses_actual_target_and_case_converted_parent() { let (state, _directory) = state_fixture().await; diff --git a/docs/public/screenshots/h2-transfer-target.png b/docs/public/screenshots/h2-transfer-target.png new file mode 100644 index 0000000000000000000000000000000000000000..159a7895d2e4deac4124b206623cb010985f89ef GIT binary patch literal 55726 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 c&@fz&FKP>E>U!_WLdSzjqTe5Lz80KIlOSO5S3 literal 0 HcmV?d00001 diff --git a/plugins/connection-types/h2.yaml b/plugins/connection-types/h2.yaml index 449e1b6cfe..9361a96d54 100644 --- a/plugins/connection-types/h2.yaml +++ b/plugins/connection-types/h2.yaml @@ -50,7 +50,7 @@ capabilities: tableDataEdit: true tableStructureEdit: true tableImport: false - dataTransfer: false + dataTransfer: true sqlFileExecution: true databaseCreate: false fieldLineage: false