diff --git a/agents/common/src/main/java/com/dbx/agent/AbstractJdbcAgent.java b/agents/common/src/main/java/com/dbx/agent/AbstractJdbcAgent.java index 5eea0403bb..0c5044e414 100644 --- a/agents/common/src/main/java/com/dbx/agent/AbstractJdbcAgent.java +++ b/agents/common/src/main/java/com/dbx/agent/AbstractJdbcAgent.java @@ -650,6 +650,7 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent { appendPoolIdentity(identity, "driverPath" + index, driverPaths.get(index)); } } + appendPoolIdentity(identity, "driverProfile", params.getDriver_profile()); return identity.toString(); } diff --git a/agents/common/src/main/java/com/dbx/agent/ConnectParams.java b/agents/common/src/main/java/com/dbx/agent/ConnectParams.java index 2ba4994c62..87c3bd776e 100644 --- a/agents/common/src/main/java/com/dbx/agent/ConnectParams.java +++ b/agents/common/src/main/java/com/dbx/agent/ConnectParams.java @@ -23,6 +23,7 @@ public final class ConnectParams { private String gbase_server; private String informix_server; private String sessionRole; + private String driver_profile; public ConnectParams() { this("", 0, "", "", "", "", "", false, "", Collections.emptyList()); @@ -209,6 +210,14 @@ public final class ConnectParams { this.sessionRole = sessionRole; } + public String getDriver_profile() { + return driver_profile; + } + + public void setDriver_profile(String driver_profile) { + this.driver_profile = driver_profile; + } + @Override public boolean equals(Object other) { if (this == other) return true; @@ -231,13 +240,14 @@ public final class ConnectParams { && Objects.equals(client_key_path, that.client_key_path) && Objects.equals(gbase_server, that.gbase_server) && Objects.equals(informix_server, that.informix_server) - && Objects.equals(sessionRole, that.sessionRole); + && Objects.equals(sessionRole, that.sessionRole) + && Objects.equals(driver_profile, that.driver_profile); } @Override public int hashCode() { return Objects.hash(host, port, database, username, password, url_params, connection_string, - port_explicit, mysql_compat_mode, jdbc_driver_class, jdbc_driver_paths, ssl, ca_cert_path, client_cert_path, client_key_path, gbase_server, informix_server, sessionRole); + port_explicit, mysql_compat_mode, jdbc_driver_class, jdbc_driver_paths, ssl, ca_cert_path, client_cert_path, client_key_path, gbase_server, informix_server, sessionRole, driver_profile); } @Override @@ -260,6 +270,7 @@ public final class ConnectParams { + ", gbase_server=" + gbase_server + ", informix_server=" + informix_server + ", sessionRole=" + sessionRole + + ", driver_profile=" + driver_profile + ")"; } } diff --git a/agents/drivers/h2-legacy/build.gradle b/agents/drivers/h2-legacy/build.gradle index bed868230a..11d2136229 100644 --- a/agents/drivers/h2-legacy/build.gradle +++ b/agents/drivers/h2-legacy/build.gradle @@ -2,8 +2,31 @@ sourceSets { main.java.srcDirs = ['src/main/java', '../h2/src/main/java'] } +configurations { + h2V1 + h2V2 + h2V3 +} + dependencies { - implementation 'com.h2database:h2:2.1.214' + h2V1 'com.h2database:h2:1.4.200' + h2V2 'com.h2database:h2:2.1.214' + h2V3 'com.h2database:h2:2.4.240' +} + +tasks.named('processResources') { + from(configurations.h2V1) { + into 'drivers' + rename { 'h2-1.4.200.jar' } + } + from(configurations.h2V2) { + into 'drivers' + rename { 'h2-2.1.214.jar' } + } + from(configurations.h2V3) { + into 'drivers' + rename { 'h2-2.4.240.jar' } + } } tasks.named('shadowJar') { diff --git a/agents/drivers/h2/build.gradle b/agents/drivers/h2/build.gradle index 070380fd1e..01248b4295 100644 --- a/agents/drivers/h2/build.gradle +++ b/agents/drivers/h2/build.gradle @@ -1,6 +1,29 @@ +configurations { + h2V1 + h2V2 + h2V3 +} + dependencies { - implementation fileTree(dir: 'libs', include: ['*.jar']) - implementation 'com.h2database:h2:2.3.232' + h2V1 'com.h2database:h2:1.4.200' + h2V2 'com.h2database:h2:2.1.214' + h2V3 'com.h2database:h2:2.4.240' + testImplementation 'com.google.code.gson:gson:2.12.1' +} + +tasks.named('processResources') { + from(configurations.h2V1) { + into 'drivers' + rename { 'h2-1.4.200.jar' } + } + from(configurations.h2V2) { + into 'drivers' + rename { 'h2-2.1.214.jar' } + } + from(configurations.h2V3) { + into 'drivers' + rename { 'h2-2.4.240.jar' } + } } tasks.named('shadowJar') { @@ -8,3 +31,10 @@ tasks.named('shadowJar') { attributes('Agent-Label': 'H2', 'Main-Class': 'com.dbx.agent.h2.H2Agent') } } + +tasks.named('test') { + dependsOn tasks.named('shadowJar') + doFirst { + systemProperty 'dbx.h2.agent.jar', tasks.named('shadowJar').get().archiveFile.get().asFile.absolutePath + } +} 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 eb1e8238b8..5e8e8df9d5 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 @@ -19,6 +19,7 @@ import com.dbx.agent.TriggerInfo; import java.nio.charset.StandardCharsets; import java.sql.Connection; import java.sql.ResultSet; +import java.sql.SQLException; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; @@ -30,6 +31,9 @@ import java.util.Set; public class H2Agent extends AbstractJdbcAgent { private String databaseName = ""; + private H2DriverLoader.LoadedDriver loadedDriver; + private H2DriverVersion driverVersion = H2DriverVersion.V3; + private int databaseMajorVersion = 2; @Override protected String driverClass() { @@ -41,9 +45,62 @@ public class H2Agent extends AbstractJdbcAgent { return buildUrl(params); } + @Override + protected void loadDriver(ConnectParams params) throws Exception { + H2DriverVersion selected = H2DriverVersion.select(params); + if (selected == H2DriverVersion.CUSTOM) { + H2DriverLoader.LoadedDriver external = H2DriverLoader.loadExternal( + params.getJdbc_driver_paths(), + params.getJdbc_driver_class() + ); + if (loadedDriver == null || !loadedDriver.identity().equals(external.identity())) { + replaceLoadedDriver(external); + } else { + external.classLoader().close(); + } + } else if (loadedDriver == null || loadedDriver.version() != selected) { + replaceLoadedDriver(H2DriverLoader.load(selected)); + } + driverVersion = selected; + } + + private void replaceLoadedDriver(H2DriverLoader.LoadedDriver replacement) throws Exception { + H2DriverLoader.LoadedDriver previous = loadedDriver; + if (previous != null) { + try { + previous.classLoader().close(); + } catch (Exception error) { + replacement.classLoader().close(); + throw error; + } + } + loadedDriver = replacement; + } + + @Override + protected Connection openConnection(ConnectParams params) throws Exception { + if (loadedDriver == null) { + throw new IllegalStateException("H2 JDBC driver was not loaded"); + } + Connection opened = loadedDriver.driver().connect(buildJdbcUrl(params), buildConnectionProperties(params)); + if (opened == null) { + throw new SQLException("H2 JDBC driver rejected URL: " + buildJdbcUrl(params)); + } + return opened; + } + @Override protected void afterConnect(ConnectParams params, Connection connection) { databaseName = params.getDatabase(); + databaseMajorVersion = unchecked(() -> connection.getMetaData().getDatabaseMajorVersion()); + } + + H2DriverVersion driverVersion() { + return driverVersion; + } + + boolean isVersion2OrLater() { + return databaseMajorVersion >= 2; } @Override @@ -118,7 +175,7 @@ public class H2Agent extends AbstractJdbcAgent { List args = new ArrayList<>(); StringBuilder sql = new StringBuilder("SELECT TABLE_NAME, TABLE_TYPE FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ?"); args.add(effectiveSchema); - appendH2TableTypePredicate(sql, args, constraints); + appendH2TableTypePredicate(sql, args, constraints, isVersion2OrLater()); MetadataSqlSupport.appendNameFilter(sql, args, "TABLE_NAME", constraints); sql.append(" ORDER BY TABLE_NAME"); MetadataSqlSupport.appendLiteralLimitOffset(sql, constraints); @@ -143,9 +200,10 @@ public class H2Agent extends AbstractJdbcAgent { result.add(new ObjectInfo(table.getName(), table.getTable_type(), schema, table.getComment())); } - try (var stmt = requireConnected().prepareStatement( - "SELECT ROUTINE_NAME, ROUTINE_TYPE FROM INFORMATION_SCHEMA.ROUTINES WHERE ROUTINE_SCHEMA = ? ORDER BY ROUTINE_NAME" - )) { + String sql = isVersion2OrLater() + ? "SELECT ROUTINE_NAME, ROUTINE_TYPE FROM INFORMATION_SCHEMA.ROUTINES WHERE ROUTINE_SCHEMA = ? ORDER BY ROUTINE_NAME" + : "SELECT ALIAS_NAME, CASE WHEN RETURNS_RESULT = 2 THEN 'FUNCTION' ELSE 'PROCEDURE' END FROM INFORMATION_SCHEMA.FUNCTION_ALIASES WHERE ALIAS_SCHEMA = ? ORDER BY ALIAS_NAME"; + try (var stmt = requireConnected().prepareStatement(sql)) { stmt.setString(1, effectiveSchema); try (ResultSet rs = stmt.executeQuery()) { while (rs.next()) { @@ -174,6 +232,9 @@ public class H2Agent extends AbstractJdbcAgent { } private List queryConstrainedObjects(String schema, MetadataListConstraints constraints) { + if (!isVersion2OrLater()) { + return constraints.filterObjects(listObjects(schema)); + } return unchecked(() -> { String effectiveSchema = resolveSchema(schema); List result = new ArrayList<>(); @@ -182,7 +243,7 @@ public class H2Agent extends AbstractJdbcAgent { if (constraints.includesTableLikeTypes()) { StringBuilder tableSql = new StringBuilder("SELECT TABLE_NAME AS OBJECT_NAME, TABLE_TYPE AS OBJECT_TYPE FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ?"); args.add(effectiveSchema); - appendH2TableTypePredicate(tableSql, args, constraints); + appendH2TableTypePredicate(tableSql, args, constraints, true); MetadataSqlSupport.appendNameFilter(tableSql, args, "TABLE_NAME", constraints); branches.add(tableSql.toString()); } @@ -218,7 +279,9 @@ public class H2Agent extends AbstractJdbcAgent { String effectiveSchema = resolveSchema(schema); String sql = switch (objectType.toUpperCase(Locale.ROOT)) { case "VIEW" -> "SELECT VIEW_DEFINITION FROM INFORMATION_SCHEMA.VIEWS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?"; - case "FUNCTION", "PROCEDURE" -> "SELECT ROUTINE_DEFINITION FROM INFORMATION_SCHEMA.ROUTINES WHERE ROUTINE_SCHEMA = ? AND ROUTINE_NAME = ?"; + case "FUNCTION", "PROCEDURE" -> isVersion2OrLater() + ? "SELECT ROUTINE_DEFINITION FROM INFORMATION_SCHEMA.ROUTINES WHERE ROUTINE_SCHEMA = ? AND ROUTINE_NAME = ?" + : "SELECT SOURCE FROM INFORMATION_SCHEMA.FUNCTION_ALIASES WHERE ALIAS_SCHEMA = ? AND ALIAS_NAME = ?"; default -> throw new IllegalArgumentException("Unsupported object type: " + objectType); }; @@ -242,16 +305,17 @@ public class H2Agent extends AbstractJdbcAgent { return unchecked(() -> { String effectiveSchema = resolveSchema(schema); Set primaryKeys = new HashSet<>(); - try (var stmt = requireConnected().prepareStatement( - """ - SELECT ic.COLUMN_NAME - FROM INFORMATION_SCHEMA.INDEX_COLUMNS ic - JOIN INFORMATION_SCHEMA.INDEXES i - ON ic.INDEX_SCHEMA = i.INDEX_SCHEMA AND ic.INDEX_NAME = i.INDEX_NAME - WHERE ic.TABLE_SCHEMA = ? AND ic.TABLE_NAME = ? - AND i.INDEX_TYPE_NAME = 'PRIMARY KEY' - """ - )) { + String primaryKeySql = isVersion2OrLater() + ? """ + SELECT ic.COLUMN_NAME + FROM INFORMATION_SCHEMA.INDEX_COLUMNS ic + JOIN INFORMATION_SCHEMA.INDEXES i + ON ic.INDEX_SCHEMA = i.INDEX_SCHEMA AND ic.INDEX_NAME = i.INDEX_NAME + WHERE ic.TABLE_SCHEMA = ? AND ic.TABLE_NAME = ? + AND i.INDEX_TYPE_NAME = 'PRIMARY KEY' + """ + : "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.INDEXES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND PRIMARY_KEY = TRUE"; + try (var stmt = requireConnected().prepareStatement(primaryKeySql)) { stmt.setString(1, effectiveSchema); stmt.setString(2, table); try (ResultSet rs = stmt.executeQuery()) { @@ -262,15 +326,11 @@ public class H2Agent extends AbstractJdbcAgent { } List result = new ArrayList<>(); - try (var stmt = requireConnected().prepareStatement( - """ - SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT, - NUMERIC_PRECISION, NUMERIC_SCALE, CHARACTER_MAXIMUM_LENGTH - FROM INFORMATION_SCHEMA.COLUMNS - WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? - ORDER BY ORDINAL_POSITION - """ - )) { + String typeColumn = isVersion2OrLater() ? "DATA_TYPE" : "TYPE_NAME"; + String columnSql = "SELECT COLUMN_NAME, " + typeColumn + " AS DBX_DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT, " + + "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)) { stmt.setString(1, effectiveSchema); stmt.setString(2, table); try (ResultSet rs = stmt.executeQuery()) { @@ -278,11 +338,11 @@ public class H2Agent extends AbstractJdbcAgent { String columnName = rs.getString("COLUMN_NAME"); result.add(new ColumnInfo( columnName, - rs.getString("DATA_TYPE"), + rs.getString("DBX_DATA_TYPE"), "YES".equals(rs.getString("IS_NULLABLE")), rs.getString("COLUMN_DEFAULT"), primaryKeys.contains(columnName), - null, + rs.getString("REMARKS"), null, intOrNull(rs, "NUMERIC_PRECISION"), intOrNull(rs, "NUMERIC_SCALE"), @@ -304,16 +364,24 @@ public class H2Agent extends AbstractJdbcAgent { Map primaryMap = new HashMap<>(); Map typeMap = new HashMap<>(); - try (var stmt = requireConnected().prepareStatement( - """ - SELECT i.INDEX_NAME, ic.COLUMN_NAME, ic.IS_UNIQUE, i.INDEX_TYPE_NAME - FROM INFORMATION_SCHEMA.INDEX_COLUMNS ic - JOIN INFORMATION_SCHEMA.INDEXES i - ON ic.INDEX_SCHEMA = i.INDEX_SCHEMA AND ic.INDEX_NAME = i.INDEX_NAME - WHERE ic.TABLE_SCHEMA = ? AND ic.TABLE_NAME = ? - ORDER BY i.INDEX_NAME, ic.ORDINAL_POSITION - """ - )) { + String indexSql = isVersion2OrLater() + ? """ + SELECT i.INDEX_NAME, ic.COLUMN_NAME, ic.IS_UNIQUE, i.INDEX_TYPE_NAME, + CASE WHEN i.INDEX_TYPE_NAME = 'PRIMARY KEY' THEN TRUE ELSE FALSE END AS IS_PRIMARY + FROM INFORMATION_SCHEMA.INDEX_COLUMNS ic + JOIN INFORMATION_SCHEMA.INDEXES i + ON ic.INDEX_SCHEMA = i.INDEX_SCHEMA AND ic.INDEX_NAME = i.INDEX_NAME + WHERE ic.TABLE_SCHEMA = ? AND ic.TABLE_NAME = ? + ORDER BY i.INDEX_NAME, ic.ORDINAL_POSITION + """ + : """ + SELECT INDEX_NAME, COLUMN_NAME, CASE WHEN NON_UNIQUE THEN FALSE ELSE TRUE END AS IS_UNIQUE, + INDEX_TYPE_NAME, PRIMARY_KEY AS IS_PRIMARY + FROM INFORMATION_SCHEMA.INDEXES + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? + ORDER BY INDEX_NAME, ORDINAL_POSITION + """; + try (var stmt = requireConnected().prepareStatement(indexSql)) { stmt.setString(1, effectiveSchema); stmt.setString(2, table); try (ResultSet rs = stmt.executeQuery()) { @@ -324,7 +392,7 @@ public class H2Agent extends AbstractJdbcAgent { indexMap.computeIfAbsent(indexName, ignored -> new ArrayList<>()).add(columnName); uniqueMap.put(indexName, rs.getBoolean("IS_UNIQUE")); - primaryMap.put(indexName, "PRIMARY KEY".equals(indexType)); + primaryMap.put(indexName, rs.getBoolean("IS_PRIMARY")); typeMap.put(indexName, indexType == null ? "" : indexType); } } @@ -353,27 +421,17 @@ public class H2Agent extends AbstractJdbcAgent { return unchecked(() -> { String effectiveSchema = resolveSchema(schema); List result = new ArrayList<>(); - try (var stmt = requireConnected().prepareStatement( - """ - SELECT FK_NAME, FKCOLUMN_NAME, PKTABLE_NAME, PKCOLUMN_NAME - FROM INFORMATION_SCHEMA.CROSS_REFERENCES - WHERE FKTABLE_SCHEMA = ? AND FKTABLE_NAME = ? - ORDER BY FK_NAME - """ - )) { - stmt.setString(1, effectiveSchema); - stmt.setString(2, table); - try (ResultSet rs = stmt.executeQuery()) { - while (rs.next()) { - result.add(new ForeignKeyInfo( - rs.getString("FK_NAME"), - rs.getString("FKCOLUMN_NAME"), - rs.getString("PKTABLE_NAME"), - rs.getString("PKCOLUMN_NAME") - )); - } + try (ResultSet rs = requireConnected().getMetaData().getImportedKeys(null, effectiveSchema, table)) { + while (rs.next()) { + result.add(new ForeignKeyInfo( + rs.getString("FK_NAME"), + rs.getString("FKCOLUMN_NAME"), + rs.getString("PKTABLE_NAME"), + rs.getString("PKCOLUMN_NAME") + )); } } + result.sort(java.util.Comparator.comparing(ForeignKeyInfo::getName, java.util.Comparator.nullsLast(String::compareTo))); return result; }); } @@ -383,14 +441,21 @@ public class H2Agent extends AbstractJdbcAgent { return unchecked(() -> { String effectiveSchema = resolveSchema(schema); List result = new ArrayList<>(); - try (var stmt = requireConnected().prepareStatement( - """ - SELECT TRIGGER_NAME, EVENT_MANIPULATION, ACTION_TIMING - FROM INFORMATION_SCHEMA.TRIGGERS - WHERE TRIGGER_SCHEMA = ? AND EVENT_OBJECT_TABLE = ? - ORDER BY TRIGGER_NAME - """ - )) { + String triggerSql = isVersion2OrLater() + ? """ + SELECT TRIGGER_NAME, EVENT_MANIPULATION, ACTION_TIMING + FROM INFORMATION_SCHEMA.TRIGGERS + WHERE TRIGGER_SCHEMA = ? AND EVENT_OBJECT_TABLE = ? + ORDER BY TRIGGER_NAME + """ + : """ + SELECT TRIGGER_NAME, TRIGGER_TYPE AS EVENT_MANIPULATION, + CASE WHEN BEFORE THEN 'BEFORE' ELSE 'AFTER' END AS ACTION_TIMING + FROM INFORMATION_SCHEMA.TRIGGERS + WHERE TRIGGER_SCHEMA = ? AND TABLE_NAME = ? + ORDER BY TRIGGER_NAME + """; + try (var stmt = requireConnected().prepareStatement(triggerSql)) { stmt.setString(1, effectiveSchema); stmt.setString(2, table); try (ResultSet rs = stmt.executeQuery()) { @@ -452,13 +517,18 @@ public class H2Agent extends AbstractJdbcAgent { || constraints.objectTypeAllowed("FUNCTION"); } - private static void appendH2TableTypePredicate(StringBuilder sql, List args, MetadataListConstraints constraints) { + private static void appendH2TableTypePredicate( + StringBuilder sql, + List args, + MetadataListConstraints constraints, + boolean version2OrLater + ) { if (!constraints.hasObjectTypes()) { return; } List types = new ArrayList<>(); if (constraints.tableTypeAllowed("TABLE")) { - types.add("BASE TABLE"); + types.add(version2OrLater ? "BASE TABLE" : "TABLE"); } if (constraints.tableTypeAllowed("VIEW")) { types.add("VIEW"); diff --git a/agents/drivers/h2/src/main/java/com/dbx/agent/h2/H2DriverLoader.java b/agents/drivers/h2/src/main/java/com/dbx/agent/h2/H2DriverLoader.java new file mode 100644 index 0000000000..9b23386a85 --- /dev/null +++ b/agents/drivers/h2/src/main/java/com/dbx/agent/h2/H2DriverLoader.java @@ -0,0 +1,127 @@ +package com.dbx.agent.h2; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.sql.Driver; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; +import java.util.UUID; + +final class H2DriverLoader { + private static final Object EXTRACTION_LOCK = new Object(); + private static final Path CACHE_ROOT = Path.of(System.getProperty("java.io.tmpdir"), "dbx-h2-drivers"); + + private H2DriverLoader() { + } + + static LoadedDriver load(H2DriverVersion version) throws Exception { + Path jar = extract(version); + URLClassLoader classLoader = new URLClassLoader( + new URL[]{jar.toUri().toURL()}, + H2DriverLoader.class.getClassLoader() + ); + try { + Driver driver = (Driver) Class.forName("org.h2.Driver", true, classLoader).getDeclaredConstructor().newInstance(); + return new LoadedDriver(version, version.version(), driver, classLoader); + } catch (Exception error) { + closeAfterFailure(classLoader, error); + throw error; + } catch (LinkageError error) { + closeAfterFailure(classLoader, error); + throw error; + } + } + + static LoadedDriver loadExternal(List driverPaths, String driverClass) throws Exception { + if (driverPaths == null || driverPaths.isEmpty()) { + throw new IllegalArgumentException("Custom H2 driver profile requires at least one JDBC JAR path"); + } + List urls = new ArrayList<>(); + List identities = new ArrayList<>(); + for (String driverPath : driverPaths) { + Path path = Path.of(driverPath).toAbsolutePath().normalize(); + if (!Files.isRegularFile(path)) { + throw new IOException("Custom H2 JDBC JAR does not exist: " + path); + } + urls.add(path.toUri().toURL()); + identities.add(path + ":" + sha256(path)); + } + String effectiveDriverClass = driverClass == null || driverClass.isBlank() ? "org.h2.Driver" : driverClass.trim(); + URLClassLoader classLoader = new URLClassLoader( + urls.toArray(new URL[0]), + H2DriverLoader.class.getClassLoader() + ); + try { + Driver driver = (Driver) Class.forName(effectiveDriverClass, true, classLoader).getDeclaredConstructor().newInstance(); + return new LoadedDriver(H2DriverVersion.CUSTOM, effectiveDriverClass + "|" + String.join("|", identities), driver, classLoader); + } catch (Exception error) { + closeAfterFailure(classLoader, error); + throw error; + } catch (LinkageError error) { + closeAfterFailure(classLoader, error); + throw error; + } + } + + private static Path extract(H2DriverVersion version) throws Exception { + byte[] bytes; + try (InputStream input = H2DriverLoader.class.getResourceAsStream(version.resourcePath())) { + if (input == null) { + throw new IOException("Bundled H2 driver is missing: " + version.resourcePath()); + } + bytes = input.readAllBytes(); + } + String digest = sha256(bytes); + Path directory = CACHE_ROOT.resolve(version.version() + "-" + digest.substring(0, 16)); + Path target = directory.resolve("h2.jar"); + synchronized (EXTRACTION_LOCK) { + Files.createDirectories(directory); + if (!Files.isRegularFile(target) || Files.size(target) != bytes.length || !digest.equals(sha256(target))) { + Path temporary = directory.resolve("h2.jar.tmp-" + UUID.randomUUID()); + Files.write(temporary, bytes); + try { + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (java.nio.file.AtomicMoveNotSupportedException ignored) { + Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING); + } + } + } + return target; + } + + private static String sha256(Path path) throws Exception { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + try (InputStream input = Files.newInputStream(path)) { + byte[] buffer = new byte[8192]; + int read; + while ((read = input.read(buffer)) >= 0) { + if (read > 0) { + digest.update(buffer, 0, read); + } + } + } + return HexFormat.of().formatHex(digest.digest()); + } + + private static String sha256(byte[] bytes) throws Exception { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); + } + + private static void closeAfterFailure(URLClassLoader classLoader, Throwable error) { + try { + classLoader.close(); + } catch (IOException closeError) { + error.addSuppressed(closeError); + } + } + + record LoadedDriver(H2DriverVersion version, String identity, Driver driver, URLClassLoader classLoader) { + } +} diff --git a/agents/drivers/h2/src/main/java/com/dbx/agent/h2/H2DriverVersion.java b/agents/drivers/h2/src/main/java/com/dbx/agent/h2/H2DriverVersion.java new file mode 100644 index 0000000000..1bd4e9656b --- /dev/null +++ b/agents/drivers/h2/src/main/java/com/dbx/agent/h2/H2DriverVersion.java @@ -0,0 +1,65 @@ +package com.dbx.agent.h2; + +import com.dbx.agent.ConnectParams; + +import java.util.Locale; + +enum H2DriverVersion { + V1("h2-v1", "1.4.200", "/drivers/h2-1.4.200.jar", 1), + V2("h2-v2", "2.1.214", "/drivers/h2-2.1.214.jar", 2), + V3("h2-v3", "2.4.240", "/drivers/h2-2.4.240.jar", 3), + CUSTOM("h2-custom", "custom", "", 0); + + private final String profile; + private final String version; + private final String resourcePath; + private final int storageFormat; + + H2DriverVersion(String profile, String version, String resourcePath, int storageFormat) { + this.profile = profile; + this.version = version; + this.resourcePath = resourcePath; + this.storageFormat = storageFormat; + } + + String profile() { + return profile; + } + + String version() { + return version; + } + + String resourcePath() { + return resourcePath; + } + + int storageFormat() { + return storageFormat; + } + + static H2DriverVersion select(ConnectParams params) throws Exception { + String profile = params.getDriver_profile(); + String normalized = profile == null ? "" : profile.trim().toLowerCase(Locale.ROOT); + return switch (normalized) { + case "h2-v1" -> V1; + case "h2-v2", "h2-legacy" -> V2; + case "h2-v3" -> V3; + case "h2-custom" -> CUSTOM; + case "", "h2", "h2-auto", "h2_embedded", "h2_server" -> { + java.util.OptionalInt detected = H2FileFormatDetector.detect(H2Agent.buildUrl(params)); + yield detected.isPresent() ? fromStorageFormat(detected.getAsInt()) : V3; + } + default -> throw new IllegalArgumentException("Unsupported H2 driver profile: " + profile); + }; + } + + private static H2DriverVersion fromStorageFormat(int format) { + return switch (format) { + case 1 -> V1; + case 2 -> V2; + case 3 -> V3; + default -> throw new IllegalArgumentException("Unsupported H2 database file format: " + format); + }; + } +} diff --git a/agents/drivers/h2/src/main/java/com/dbx/agent/h2/H2FileFormatDetector.java b/agents/drivers/h2/src/main/java/com/dbx/agent/h2/H2FileFormatDetector.java new file mode 100644 index 0000000000..aa0ccd3449 --- /dev/null +++ b/agents/drivers/h2/src/main/java/com/dbx/agent/h2/H2FileFormatDetector.java @@ -0,0 +1,187 @@ +package com.dbx.agent.h2; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.OptionalInt; + +final class H2FileFormatDetector { + private static final int BLOCK_SIZE = 4096; + + private H2FileFormatDetector() { + } + + static OptionalInt detect(String jdbcUrl) throws IOException { + Path base = localDatabaseBasePath(jdbcUrl); + if (base == null) { + return OptionalInt.empty(); + } + Path pageStore = withSuffix(base, ".h2.db"); + Path mvStore = withSuffix(base, ".mv.db"); + boolean pageStoreExists = Files.isRegularFile(pageStore); + boolean mvStoreExists = Files.isRegularFile(mvStore); + if (pageStoreExists && mvStoreExists) { + throw new IOException("Both H2 PageStore and MVStore files exist for " + base + "; choose an explicit H2 driver profile"); + } + if (pageStoreExists) { + return OptionalInt.of(1); + } + if (!mvStoreExists) { + return OptionalInt.empty(); + } + return OptionalInt.of(readMvStoreFormat(mvStore)); + } + + private static int readMvStoreFormat(Path file) throws IOException { + byte[] bytes; + try (InputStream input = Files.newInputStream(file)) { + bytes = input.readNBytes(BLOCK_SIZE * 2); + } + if (bytes.length < BLOCK_SIZE * 2) { + throw new IOException("H2 MVStore file is too small to contain valid headers: " + file); + } + List headers = new ArrayList<>(); + for (int block = 0; block < 2; block++) { + StoreHeader header = parseHeader(bytes, block * BLOCK_SIZE); + if (header != null) { + headers.add(header); + } + } + return headers.stream() + .max(Comparator.comparingLong(StoreHeader::version)) + .orElseThrow(() -> new IOException("Cannot determine the H2 MVStore format for " + file + "; choose an explicit H2 driver profile")) + .format(); + } + + private static StoreHeader parseHeader(byte[] fileBytes, int offset) { + byte[] block = java.util.Arrays.copyOfRange(fileBytes, offset, offset + BLOCK_SIZE); + int start = 0; + int end = block.length; + while (start < end && block[start] <= ' ') { + start++; + } + while (start < end && block[end - 1] <= ' ') { + end--; + } + if (start >= end) { + return null; + } + String text = new String(block, start, end - start, StandardCharsets.ISO_8859_1); + int fletcherIndex = text.indexOf("fletcher:"); + if (fletcherIndex <= 0 || text.charAt(fletcherIndex - 1) != ',') { + return null; + } + int checksumEnd = text.indexOf(',', fletcherIndex); + if (checksumEnd < 0) { + checksumEnd = text.length(); + } + try { + long expected = Long.parseUnsignedLong(text.substring(fletcherIndex + "fletcher:".length(), checksumEnd), 16); + int actual = fletcher32(block, start, fletcherIndex - 1); + if (Integer.toUnsignedLong(actual) != expected) { + return null; + } + long version = readHexValue(text, "version", 0); + int format = Math.toIntExact(readHexValue(text, "format", 1)); + return new StoreHeader(version, format); + } catch (RuntimeException ignored) { + return null; + } + } + + private static long readHexValue(String text, String key, long defaultValue) { + String marker = key + ':'; + int index = text.indexOf(marker); + if (index < 0 || (index > 0 && text.charAt(index - 1) != ',')) { + return defaultValue; + } + int start = index + marker.length(); + int end = text.indexOf(',', start); + if (end < 0) { + end = text.length(); + } + return Long.parseUnsignedLong(text.substring(start, end), 16); + } + + private static int fletcher32(byte[] bytes, int offset, int length) { + int s1 = 0xffff; + int s2 = 0xffff; + int index = offset; + int evenEnd = offset + (length & ~1); + while (index < evenEnd) { + int batchEnd = Math.min(index + 720, evenEnd); + while (index < batchEnd) { + int value = ((bytes[index++] & 0xff) << 8) | (bytes[index++] & 0xff); + s2 += s1 += value; + } + s1 = (s1 & 0xffff) + (s1 >>> 16); + s2 = (s2 & 0xffff) + (s2 >>> 16); + } + if ((length & 1) != 0) { + int value = (bytes[index] & 0xff) << 8; + s2 += s1 += value; + } + s1 = (s1 & 0xffff) + (s1 >>> 16); + s2 = (s2 & 0xffff) + (s2 >>> 16); + return (s2 << 16) | s1; + } + + private static Path localDatabaseBasePath(String jdbcUrl) { + String prefix = "jdbc:h2:"; + if (jdbcUrl == null || !jdbcUrl.regionMatches(true, 0, prefix, 0, prefix.length())) { + return null; + } + String target = jdbcUrl.substring(prefix.length()); + String lower = target.toLowerCase(java.util.Locale.ROOT); + if (lower.startsWith("tcp:") || lower.startsWith("ssl:") || lower.startsWith("mem:") + || lower.startsWith("zip:") || lower.startsWith("split:")) { + return null; + } + if (lower.startsWith("file:")) { + target = target.substring("file:".length()); + } + target = beforeOptions(target); + if (target.startsWith("~" + java.io.File.separator) || target.startsWith("~/") || target.startsWith("~\\")) { + target = System.getProperty("user.home") + target.substring(1); + } + if (target.isBlank()) { + return null; + } + Path path = Path.of(target).toAbsolutePath().normalize(); + String value = path.toString().toLowerCase(java.util.Locale.ROOT); + if (value.endsWith(".mv.db")) { + return Path.of(path.toString().substring(0, path.toString().length() - ".mv.db".length())); + } + if (value.endsWith(".h2.db")) { + return Path.of(path.toString().substring(0, path.toString().length() - ".h2.db".length())); + } + return path; + } + + private static String beforeOptions(String value) { + boolean escaped = false; + for (int index = 0; index < value.length(); index++) { + char current = value.charAt(index); + if (current == ';' && !escaped) { + return value.substring(0, index); + } + escaped = current == '\\' && !escaped; + if (current != '\\') { + escaped = false; + } + } + return value; + } + + private static Path withSuffix(Path base, String suffix) { + return Path.of(base.toString() + suffix); + } + + private record StoreHeader(long version, int format) { + } +} diff --git a/agents/drivers/h2/src/test/java/com/dbx/agent/h2/H2AgentProcessTest.java b/agents/drivers/h2/src/test/java/com/dbx/agent/h2/H2AgentProcessTest.java new file mode 100644 index 0000000000..640d183844 --- /dev/null +++ b/agents/drivers/h2/src/test/java/com/dbx/agent/h2/H2AgentProcessTest.java @@ -0,0 +1,262 @@ +package com.dbx.agent.h2; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +class H2AgentProcessTest { + @Test + @Timeout(45) + void shadedAgentSupportsIsolatedMultiVersionSessions() throws Exception { + Path agentJar = Path.of(System.getProperty("dbx.h2.agent.jar")); + Assertions.assertTrue(Files.isRegularFile(agentJar), () -> "Missing shaded H2 Agent: " + agentJar); + + try (RpcProcess rpc = new RpcProcess(agentJar)) { + JsonObject handshake = rpc.result(rpc.request("handshake", new JsonObject())); + Assertions.assertEquals(2, handshake.get("protocolVersion").getAsInt()); + + Map versions = new LinkedHashMap<>(); + versions.put("h2-v1", "1.4.200"); + versions.put("h2-v2", "2.1.214"); + versions.put("h2-v3", "2.4.240"); + + int index = 0; + for (Map.Entry entry : versions.entrySet()) { + String sessionId = "session-" + entry.getKey(); + JsonObject connected = rpc.result(rpc.request("open_session", connectParams(sessionId, entry.getKey()))); + Assertions.assertTrue(connected.get("ok").getAsBoolean(), entry.getKey()); + + JsonObject info = rpc.result(rpc.request("connection_info", sessionParams(sessionId))); + String driverVersion = info.getAsJsonObject("databaseInfo").get("driverVersion").getAsString(); + Assertions.assertTrue(driverVersion.startsWith(entry.getValue()), entry.getKey() + ": " + driverVersion); + + String table = "RPC_TABLE_" + (++index); + rpc.result(rpc.request("execute_query", queryParams(sessionId, "CREATE TABLE " + table + " (ID INT PRIMARY KEY, TEXT_VALUE VARCHAR(32))"))); + rpc.result(rpc.request("execute_query", queryParams(sessionId, "INSERT INTO " + table + " VALUES (1, '" + entry.getKey() + "')"))); + JsonArray tables = rpc.resultArray(rpc.request("list_tables", metadataParams(sessionId, "PUBLIC"))); + Assertions.assertTrue(tables.asList().stream().anyMatch(value -> table.equals(value.getAsJsonObject().get("name").getAsString())), entry.getKey()); + Assertions.assertEquals(entry.getKey(), firstCell(rpc.result(rpc.request("execute_query", queryParams(sessionId, "SELECT TEXT_VALUE FROM " + table))))); + } + + JsonObject transaction = sessionParams("session-h2-v2"); + JsonArray statements = new JsonArray(); + statements.add("CREATE TABLE TX_PROBE (ID INT PRIMARY KEY)"); + statements.add("INSERT INTO TX_PROBE VALUES (1)"); + statements.add("INSERT INTO TX_PROBE VALUES (2)"); + transaction.add("statements", statements); + Assertions.assertEquals(2, rpc.result(rpc.request("execute_transaction", transaction)).get("affected_rows").getAsLong()); + Assertions.assertEquals("2", firstCell(rpc.result(rpc.request("execute_query", queryParams("session-h2-v2", "SELECT COUNT(*) FROM TX_PROBE"))))); + + JsonObject firstPageParams = queryParams("session-h2-v3", "SELECT X FROM SYSTEM_RANGE(1, 5)"); + firstPageParams.addProperty("pageSize", 2); + JsonObject firstPage = rpc.result(rpc.request("execute_query_page", firstPageParams)); + Assertions.assertEquals(2, firstPage.getAsJsonArray("rows").size()); + Assertions.assertTrue(firstPage.get("has_more").getAsBoolean()); + String querySessionId = firstPage.get("session_id").getAsString(); + + JsonObject fetchParams = sessionParams("session-h2-v3"); + fetchParams.addProperty("sessionId", querySessionId); + fetchParams.addProperty("pageSize", 2); + JsonObject secondPage = rpc.result(rpc.request("fetch_query_page", fetchParams)); + Assertions.assertEquals(2, secondPage.getAsJsonArray("rows").size()); + Assertions.assertTrue(secondPage.get("has_more").getAsBoolean()); + JsonObject finalPage = rpc.result(rpc.request("fetch_query_page", fetchParams)); + Assertions.assertEquals(1, finalPage.getAsJsonArray("rows").size()); + Assertions.assertFalse(finalPage.get("has_more").getAsBoolean()); + + int slowQueryId = rpc.send("execute_query", queryParams("session-h2-v3", "SELECT SUM(SIN(X)) FROM SYSTEM_RANGE(1, 1000000000)")); + Thread.sleep(100); + JsonObject cancelled = rpc.result(rpc.request("cancel_session", sessionParams("session-h2-v3"))); + Assertions.assertTrue(cancelled.get("ok").getAsBoolean()); + JsonObject slowResponse = rpc.await(slowQueryId, Duration.ofSeconds(15)); + Assertions.assertTrue(slowResponse.has("error"), () -> "Expected cancelled query error, got " + slowResponse); + Assertions.assertTrue(rpc.result(rpc.request("validate_session", sessionParams("session-h2-v3"))).get("ok").getAsBoolean()); + + for (String profile : versions.keySet()) { + String sessionId = "session-" + profile; + Assertions.assertTrue(rpc.result(rpc.request("close_session", sessionParams(sessionId))).get("ok").getAsBoolean()); + Assertions.assertTrue(rpc.request("validate_session", sessionParams(sessionId)).has("error")); + } + } + } + + private static JsonObject connectParams(String sessionId, String profile) { + JsonObject params = sessionParams(sessionId); + params.addProperty("host", ""); + params.addProperty("port", 0); + params.addProperty("database", "mem:rpc-" + profile + ";DB_CLOSE_DELAY=-1"); + params.addProperty("username", "sa"); + params.addProperty("password", ""); + params.addProperty("connection_string", ""); + params.addProperty("driver_profile", profile); + params.addProperty("ssl", false); + return params; + } + + private static JsonObject sessionParams(String sessionId) { + JsonObject params = new JsonObject(); + params.addProperty("agentSessionId", sessionId); + return params; + } + + private static JsonObject queryParams(String sessionId, String sql) { + JsonObject params = sessionParams(sessionId); + params.addProperty("sql", sql); + return params; + } + + private static JsonObject metadataParams(String sessionId, String schema) { + JsonObject params = sessionParams(sessionId); + params.addProperty("schema", schema); + return params; + } + + private static String firstCell(JsonObject result) { + return result.getAsJsonArray("rows").get(0).getAsJsonArray().get(0).getAsString(); + } + + private static final class RpcProcess implements AutoCloseable { + private final Process process; + private final BufferedWriter input; + private final BlockingQueue responses = new LinkedBlockingQueue<>(); + private final Map pending = new ConcurrentHashMap<>(); + private final CompletableFuture ready = new CompletableFuture<>(); + private final AtomicInteger requestIds = new AtomicInteger(); + private final StringBuilder stderr = new StringBuilder(); + + private RpcProcess(Path agentJar) throws Exception { + Path java = Path.of(System.getProperty("java.home"), "bin", "java"); + process = new ProcessBuilder(java.toString(), "-jar", agentJar.toString()).start(); + input = new BufferedWriter(new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8)); + startStdoutReader(); + startStderrReader(); + ready.get(10, TimeUnit.SECONDS); + } + + private void startStdoutReader() { + Thread thread = new Thread(() -> { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + JsonObject message = JsonParser.parseString(line).getAsJsonObject(); + if (message.has("ready")) { + ready.complete(null); + } else { + responses.add(message); + } + } + } catch (Throwable error) { + ready.completeExceptionally(error); + } + }, "h2-agent-rpc-stdout"); + thread.setDaemon(true); + thread.start(); + } + + private void startStderrReader() { + Thread thread = new Thread(() -> { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + synchronized (stderr) { + stderr.append(line).append('\n'); + } + } + } catch (Exception ignored) { + } + }, "h2-agent-rpc-stderr"); + thread.setDaemon(true); + thread.start(); + } + + private JsonObject request(String method, JsonObject params) throws Exception { + return await(send(method, params), Duration.ofSeconds(15)); + } + + private int send(String method, JsonObject params) throws Exception { + int id = requestIds.incrementAndGet(); + JsonObject request = new JsonObject(); + request.addProperty("jsonrpc", "2.0"); + request.addProperty("id", id); + request.addProperty("method", method); + request.add("params", params); + synchronized (input) { + input.write(request.toString()); + input.newLine(); + input.flush(); + } + return id; + } + + private JsonObject await(int id, Duration timeout) throws Exception { + JsonObject buffered = pending.remove(id); + if (buffered != null) { + return buffered; + } + long deadline = System.nanoTime() + timeout.toNanos(); + while (System.nanoTime() < deadline) { + long remaining = deadline - System.nanoTime(); + JsonObject response = responses.poll(Math.max(1, TimeUnit.NANOSECONDS.toMillis(remaining)), TimeUnit.MILLISECONDS); + if (response == null) { + break; + } + int responseId = response.get("id").getAsInt(); + if (responseId == id) { + return response; + } + pending.put(responseId, response); + } + throw new AssertionError("Timed out waiting for H2 Agent response " + id + "; stderr=" + stderrText()); + } + + private JsonObject result(JsonObject response) { + Assertions.assertFalse(response.has("error"), () -> response + "\nstderr=" + stderrText()); + return response.getAsJsonObject("result"); + } + + private JsonArray resultArray(JsonObject response) { + Assertions.assertFalse(response.has("error"), () -> response + "\nstderr=" + stderrText()); + return response.getAsJsonArray("result"); + } + + private String stderrText() { + synchronized (stderr) { + return stderr.toString(); + } + } + + @Override + public void close() throws Exception { + if (process.isAlive()) { + try { + result(request("shutdown", new JsonObject())); + } catch (Throwable ignored) { + } + } + input.close(); + if (!process.waitFor(5, TimeUnit.SECONDS)) { + process.destroyForcibly(); + process.waitFor(5, TimeUnit.SECONDS); + } + } + } +} 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 986fe0689c..1079849654 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 @@ -1,20 +1,34 @@ package com.dbx.agent.h2; import com.dbx.agent.BaseDatabaseAgent; +import com.dbx.agent.ColumnInfo; import com.dbx.agent.ConnectParams; import com.dbx.agent.DatabaseAgent; import com.dbx.agent.ExecuteQueryOptions; +import com.dbx.agent.ForeignKeyInfo; +import com.dbx.agent.IndexInfo; import com.dbx.agent.MetadataListConstraints; +import com.dbx.agent.ObjectInfo; import com.dbx.agent.QueryResult; import com.dbx.agent.TableInfo; import com.dbx.agent.test.JdbcExecutionBehaviorTest; import com.dbx.agent.test.JdbcMetadataBehaviorTest; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.FileTime; import java.util.List; import java.util.Map; +import java.util.jar.JarOutputStream; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; class H2AgentMigrationTest { + @TempDir + Path tempDirectory; + @Test void agentExtendsBaseDatabaseAgent() { Assertions.assertTrue(BaseDatabaseAgent.class.isAssignableFrom(H2Agent.class)); @@ -62,6 +76,273 @@ class H2AgentMigrationTest { } } + @Test + void loadsEveryBundledH2Driver() { + assertBundledDriver("h2-v1", H2DriverVersion.V1, "1.4.200"); + assertBundledDriver("h2-v2", H2DriverVersion.V2, "2.1.214"); + assertBundledDriver("h2-v3", H2DriverVersion.V3, "2.4.240"); + assertBundledDriver("h2-legacy", H2DriverVersion.V2, "2.1.214"); + } + + @Test + void autoSelectsBundledDriverFromMvStoreFormat() { + assertAutoDetectedFileDriver(H2DriverVersion.V1); + assertAutoDetectedFileDriver(H2DriverVersion.V2); + assertAutoDetectedFileDriver(H2DriverVersion.V3); + } + + @Test + void autoSelectsH2V1ForPageStoreDatabase() { + Path base = tempDirectory.resolve("pagestore"); + H2Agent creator = new H2Agent(); + creator.connect(profileParams("h2-v1", "file:" + base + ";MV_STORE=FALSE")); + try { + creator.executeQuery("CREATE TABLE PAGESTORE_PROBE (ID INT PRIMARY KEY)", null, new ExecuteQueryOptions()); + } finally { + creator.disconnect(); + } + Assertions.assertTrue(Files.isRegularFile(Path.of(base + ".h2.db"))); + + H2Agent detected = new H2Agent(); + detected.connect(profileParams("h2", "file:" + base + ";MV_STORE=FALSE;IFEXISTS=TRUE")); + try { + Assertions.assertEquals(H2DriverVersion.V1, detected.driverVersion()); + Assertions.assertEquals(List.of("PAGESTORE_PROBE"), detected.listTables("PUBLIC").stream().map(TableInfo::getName).toList()); + } finally { + detected.disconnect(); + } + } + + @Test + void autoUsesLatestDriverForNewLocalDatabase() throws Exception { + Path base = tempDirectory.resolve("new-auto"); + H2Agent agent = new H2Agent(); + agent.connect(profileParams("h2", "file:" + base)); + try { + Assertions.assertEquals(H2DriverVersion.V3, agent.driverVersion()); + agent.executeQuery("CREATE TABLE LATEST_PROBE (ID INT PRIMARY KEY)", null, new ExecuteQueryOptions()); + } finally { + agent.disconnect(); + } + Assertions.assertEquals(3, H2FileFormatDetector.detect("jdbc:h2:file:" + base).orElseThrow()); + } + + @Test + void autoRejectsCorruptMvStoreWithoutModifyingIt() throws Exception { + Path base = tempDirectory.resolve("corrupt"); + Path file = Path.of(base + ".mv.db"); + byte[] corrupt = new byte[8192]; + java.util.Arrays.fill(corrupt, (byte) 0x5a); + Files.write(file, corrupt); + FileTime modified = Files.getLastModifiedTime(file); + + RuntimeException error = Assertions.assertThrows( + RuntimeException.class, + () -> new H2Agent().connect(profileParams("h2", "file:" + base + ";IFEXISTS=TRUE")) + ); + + Assertions.assertTrue(hasCauseMessage(error, "Cannot determine the H2 MVStore format")); + Assertions.assertArrayEquals(corrupt, Files.readAllBytes(file)); + Assertions.assertEquals(modified, Files.getLastModifiedTime(file)); + } + + @Test + void autoRejectsAmbiguousPageStoreAndMvStoreFiles() throws Exception { + Path base = tempDirectory.resolve("ambiguous"); + Files.write(Path.of(base + ".h2.db"), new byte[]{1}); + Files.write(Path.of(base + ".mv.db"), new byte[8192]); + + RuntimeException error = Assertions.assertThrows( + RuntimeException.class, + () -> new H2Agent().connect(profileParams("h2", "file:" + base + ";IFEXISTS=TRUE")) + ); + + Assertions.assertTrue(hasCauseMessage(error, "Both H2 PageStore and MVStore files exist")); + } + + @Test + void autoHonorsIfExistsWithoutCreatingMissingDatabase() { + Path base = tempDirectory.resolve("missing-ifexists"); + + Assertions.assertThrows( + RuntimeException.class, + () -> new H2Agent().connect(profileParams("h2", "file:" + base + ";IFEXISTS=TRUE")) + ); + + Assertions.assertFalse(Files.exists(Path.of(base + ".mv.db"))); + Assertions.assertFalse(Files.exists(Path.of(base + ".h2.db"))); + } + + @Test + void autoOpensExistingMvStoreReadOnlyWithoutChangingFile() throws Exception { + Path base = tempDirectory.resolve("readonly"); + H2Agent creator = new H2Agent(); + creator.connect(profileParams("h2-v2", "file:" + base)); + try { + creator.executeQuery("CREATE TABLE READONLY_PROBE (ID INT PRIMARY KEY)", null, new ExecuteQueryOptions()); + } finally { + creator.disconnect(); + } + Path file = Path.of(base + ".mv.db"); + byte[] before = Files.readAllBytes(file); + + H2Agent reader = new H2Agent(); + reader.connect(profileParams("h2", "file:" + base + ";ACCESS_MODE_DATA=r;IFEXISTS=TRUE")); + try { + Assertions.assertEquals(H2DriverVersion.V2, reader.driverVersion()); + Assertions.assertEquals(0, reader.executeQuery("SELECT * FROM READONLY_PROBE", null, new ExecuteQueryOptions()).getRows().size()); + Assertions.assertThrows( + RuntimeException.class, + () -> reader.executeQuery("INSERT INTO READONLY_PROBE VALUES (1)", null, new ExecuteQueryOptions()) + ); + } finally { + reader.disconnect(); + } + Assertions.assertArrayEquals(before, Files.readAllBytes(file)); + } + + @Test + void encryptedMvStoreRequiresExplicitDriverProfile() { + Path base = tempDirectory.resolve("encrypted"); + ConnectParams createParams = profileParams("h2-v3", "file:" + base + ";CIPHER=AES"); + createParams.setPassword("file-secret user-secret"); + H2Agent creator = new H2Agent(); + creator.connect(createParams); + try { + creator.executeQuery("CREATE TABLE ENCRYPTED_PROBE (ID INT PRIMARY KEY)", null, new ExecuteQueryOptions()); + } finally { + creator.disconnect(); + } + + ConnectParams autoParams = profileParams("h2", "file:" + base + ";CIPHER=AES;IFEXISTS=TRUE"); + autoParams.setPassword("file-secret user-secret"); + RuntimeException error = Assertions.assertThrows(RuntimeException.class, () -> new H2Agent().connect(autoParams)); + Assertions.assertTrue(hasCauseMessage(error, "choose an explicit H2 driver profile")); + + ConnectParams explicitParams = profileParams("h2-v3", "file:" + base + ";CIPHER=AES;IFEXISTS=TRUE"); + explicitParams.setPassword("file-secret user-secret"); + H2Agent detected = new H2Agent(); + detected.connect(explicitParams); + try { + Assertions.assertEquals(H2DriverVersion.V3, detected.driverVersion()); + Assertions.assertEquals(List.of("ENCRYPTED_PROBE"), detected.listTables("PUBLIC").stream().map(TableInfo::getName).toList()); + } finally { + detected.disconnect(); + } + } + + @Test + void loadsCustomH2DriverFromExternalClasspath() throws Exception { + Path helperJar = tempDirectory.resolve("helper.jar"); + try (JarOutputStream ignored = new JarOutputStream(Files.newOutputStream(helperJar))) { + } + Path driverJar = copyBundledDriver("h2-2.1.214.jar"); + ConnectParams params = profileParams("h2-custom", "mem:dbx-h2-custom;DB_CLOSE_DELAY=-1"); + params.setJdbc_driver_paths(List.of(helperJar.toString(), driverJar.toString())); + params.setJdbc_driver_class("org.h2.Driver"); + + H2Agent agent = new H2Agent(); + agent.connect(params); + try { + Assertions.assertEquals(H2DriverVersion.CUSTOM, agent.driverVersion()); + Assertions.assertTrue(agent.getDatabaseInfo().get("driverVersion").startsWith("2.1.214")); + agent.executeQuery("CREATE TABLE CUSTOM_DRIVER_PROBE (ID INT PRIMARY KEY)", null, new ExecuteQueryOptions()); + Assertions.assertEquals(List.of("CUSTOM_DRIVER_PROBE"), agent.listTables("PUBLIC").stream().map(TableInfo::getName).toList()); + } finally { + agent.disconnect(); + } + } + + @Test + void rejectsCustomH2DriverWithoutClasspath() { + ConnectParams params = profileParams("h2-custom", "mem:dbx-h2-custom-missing;DB_CLOSE_DELAY=-1"); + + RuntimeException error = Assertions.assertThrows(RuntimeException.class, () -> new H2Agent().connect(params)); + + Assertions.assertTrue(hasCauseMessage(error, "requires at least one JDBC JAR path")); + } + + @Test + void rejectsMissingCustomH2DriverJar() { + ConnectParams params = profileParams("h2-custom", "mem:dbx-h2-custom-missing-file;DB_CLOSE_DELAY=-1"); + params.setJdbc_driver_paths(List.of(tempDirectory.resolve("missing-h2.jar").toString())); + + RuntimeException error = Assertions.assertThrows(RuntimeException.class, () -> new H2Agent().connect(params)); + + Assertions.assertTrue(hasCauseMessage(error, "Custom H2 JDBC JAR does not exist")); + } + + @Test + void rejectsInvalidCustomH2DriverClass() throws Exception { + ConnectParams params = profileParams("h2-custom", "mem:dbx-h2-custom-invalid-class;DB_CLOSE_DELAY=-1"); + params.setJdbc_driver_paths(List.of(copyBundledDriver("h2-2.4.240.jar").toString())); + params.setJdbc_driver_class("example.MissingH2Driver"); + + RuntimeException error = Assertions.assertThrows(RuntimeException.class, () -> new H2Agent().connect(params)); + + Assertions.assertTrue(hasCauseMessage(error, "example.MissingH2Driver")); + } + + private void assertBundledDriver(String profile, H2DriverVersion expected, String expectedVersion) { + ConnectParams params = profileParams(profile, "mem:dbx-" + profile + ";DB_CLOSE_DELAY=-1"); + H2Agent agent = new H2Agent(); + agent.connect(params); + try { + Assertions.assertEquals(expected, agent.driverVersion()); + Assertions.assertTrue(agent.getDatabaseInfo().get("driverVersion").startsWith(expectedVersion)); + } finally { + agent.disconnect(); + } + } + + private void assertAutoDetectedFileDriver(H2DriverVersion expected) { + Path base = tempDirectory.resolve("format-" + expected.storageFormat()); + ConnectParams createParams = profileParams(expected.profile(), "file:" + base); + H2Agent creator = new H2Agent(); + creator.connect(createParams); + try { + creator.executeQuery("CREATE TABLE VERSION_PROBE (ID INT PRIMARY KEY)", null, new ExecuteQueryOptions()); + } finally { + creator.disconnect(); + } + + ConnectParams autoParams = profileParams("h2", "file:" + base + ";IFEXISTS=TRUE"); + H2Agent detected = new H2Agent(); + detected.connect(autoParams); + try { + Assertions.assertEquals(expected, detected.driverVersion()); + Assertions.assertEquals(List.of("VERSION_PROBE"), detected.listTables("PUBLIC").stream().map(TableInfo::getName).toList()); + } finally { + detected.disconnect(); + } + } + + private static ConnectParams profileParams(String profile, String database) { + ConnectParams params = new ConnectParams("", 0, database, "sa", "", "", "", false); + params.setDriver_profile(profile); + return params; + } + + private Path copyBundledDriver(String name) throws Exception { + Path target = tempDirectory.resolve(name); + try (InputStream input = H2AgentMigrationTest.class.getResourceAsStream("/drivers/" + name)) { + Assertions.assertNotNull(input, "Missing bundled test driver " + name); + Files.copy(input, target, StandardCopyOption.REPLACE_EXISTING); + } + return target; + } + + private static boolean hasCauseMessage(Throwable error, String expected) { + Throwable current = error; + while (current != null) { + if (current.getMessage() != null && current.getMessage().contains(expected)) { + return true; + } + current = current.getCause(); + } + return false; + } + private static void assertH2DatabaseInfo(Map info) { Assertions.assertEquals("H2", info.get("productName")); Assertions.assertFalse(info.get("productVersion").isEmpty()); @@ -73,6 +354,60 @@ class H2AgentMigrationTest { } } +class H2VersionMatrixTest { + @Test + void metadataAndExecutionWorkAcrossBundledVersions() { + for (String profile : List.of("h2-v1", "h2-v2", "h2-v3")) { + H2Agent agent = connect(profile); + try { + agent.executeQuery("CREATE TABLE PARENT_TABLE (ID INT PRIMARY KEY, NAME VARCHAR(64))", null, new ExecuteQueryOptions()); + agent.executeQuery( + "CREATE TABLE CHILD_TABLE (ID INT PRIMARY KEY, PARENT_ID INT, AMOUNT DECIMAL(12, 2), " + + "CONSTRAINT FK_CHILD_PARENT FOREIGN KEY (PARENT_ID) REFERENCES PARENT_TABLE(ID))", + null, + new ExecuteQueryOptions() + ); + agent.executeQuery("CREATE INDEX IDX_CHILD_AMOUNT ON CHILD_TABLE(AMOUNT)", null, new ExecuteQueryOptions()); + agent.executeQuery("CREATE VIEW CHILD_VIEW AS SELECT ID, AMOUNT FROM CHILD_TABLE", null, new ExecuteQueryOptions()); + agent.executeQuery("CREATE ALIAS DBX_REVERSE FOR \"java.lang.Integer.reverse\"", null, new ExecuteQueryOptions()); + agent.executeQuery("INSERT INTO PARENT_TABLE VALUES (1, 'parent')", null, new ExecuteQueryOptions()); + agent.executeQuery("INSERT INTO CHILD_TABLE VALUES (1, 1, 12.34)", null, new ExecuteQueryOptions()); + + Assertions.assertTrue(agent.listSchemas().contains("PUBLIC"), profile); + Assertions.assertTrue(agent.listTables("PUBLIC").stream().map(TableInfo::getName).toList().containsAll(List.of("PARENT_TABLE", "CHILD_TABLE", "CHILD_VIEW")), profile); + + List objects = agent.listObjects("PUBLIC"); + Assertions.assertTrue(objects.stream().anyMatch(object -> object.getName().equals("DBX_REVERSE")), profile); + Assertions.assertTrue(agent.getObjectSource("PUBLIC", "CHILD_VIEW", "VIEW").getSource().contains("CHILD_TABLE"), profile); + + List columns = agent.getColumns("PUBLIC", "CHILD_TABLE"); + Assertions.assertEquals(List.of("ID", "PARENT_ID", "AMOUNT"), columns.stream().map(ColumnInfo::getName).toList(), profile); + Assertions.assertTrue(columns.stream().filter(column -> column.getName().equals("ID")).findFirst().orElseThrow().getIs_primary_key(), profile); + + List indexes = agent.listIndexes("PUBLIC", "CHILD_TABLE"); + Assertions.assertTrue(indexes.stream().anyMatch(index -> index.getName().equals("IDX_CHILD_AMOUNT")), profile); + Assertions.assertTrue(indexes.stream().anyMatch(IndexInfo::getIs_primary), profile); + + List foreignKeys = agent.listForeignKeys("PUBLIC", "CHILD_TABLE"); + Assertions.assertTrue(foreignKeys.stream().anyMatch(key -> key.getName().equals("FK_CHILD_PARENT")), profile); + + QueryResult result = agent.executeQuery("SELECT DBX_REVERSE(1), AMOUNT FROM CHILD_TABLE", null, new ExecuteQueryOptions()); + Assertions.assertEquals(1, result.getRows().size(), profile); + } finally { + agent.disconnect(); + } + } + } + + private static H2Agent connect(String profile) { + ConnectParams params = new ConnectParams("", 0, "mem:matrix-" + profile + ";DB_CLOSE_DELAY=-1", "sa", "", "", "", false); + params.setDriver_profile(profile); + H2Agent agent = new H2Agent(); + agent.connect(params); + return agent; + } +} + class H2ExecutionBehaviorTest extends JdbcExecutionBehaviorTest { @Override protected DatabaseAgent createConnectedAgent(String databaseName) { diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index 30a59443fd..a5fd18ae62 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -1024,13 +1024,14 @@ const jdbcDriverSelectItems = computed(() => { }); const jdbcDriverSelectItemById = computed(() => new Map(jdbcDriverSelectItems.value.map((item) => [item.id, item]))); -const jdbcManualClasspathCount = computed( - () => - jdbcDriverPathsInput.value - .split(/\r?\n/) - .map((value) => value.trim()) - .filter(Boolean).length, -); +const jdbcManualClasspathCount = computed(() => parsedJdbcDriverPaths().length); + +function parsedJdbcDriverPaths(): string[] { + return jdbcDriverPathsInput.value + .split(/\r?\n/) + .map((value) => value.trim()) + .filter(Boolean); +} function applyCustomColor(value: string) { form.value.color = value; @@ -2897,6 +2898,21 @@ function switchH2ConnectionMode(mode: H2ConnectionMode) { resetTestState(); } +function switchH2DriverProfile(profile: "h2" | "h2-v1" | "h2-v2" | "h2-v3" | "h2-custom") { + form.value.driver_profile = profile; + if (profile === "h2-custom") { + form.value.jdbc_driver_class = form.value.jdbc_driver_class?.trim() || "org.h2.Driver"; + jdbcManualClasspathOpen.value = true; + } else { + form.value.jdbc_driver_class = undefined; + form.value.jdbc_driver_paths = []; + jdbcDriverPathsInput.value = ""; + selectedJdbcDriverPath.value = ""; + jdbcManualClasspathOpen.value = false; + } + resetTestState(); +} + function isH2FileJdbcUrlLikePath(value: string): boolean { return /\.(mv|h2)\.db$/i.test(value.trim()) || value.includes("/") || value.includes("\\"); } @@ -3234,6 +3250,7 @@ const jdbcxHighPrivilegeExtensionsAllowed = computed({ }); const supportsNativeAgentJdbcDriverConfig = computed(() => supportsNativeAgentJdbcDriverConfigType(form.value.db_type)); const isH2FileMode = computed(() => form.value.db_type === "h2" && h2ConnectionMode.value === "file"); +const isH2CustomDriver = computed(() => form.value.db_type === "h2" && form.value.driver_profile === "h2-custom"); const usesLocalFilePathInput = computed(() => isLocalFileTypeDb(form.value.db_type) && (form.value.db_type !== "h2" || isH2FileMode.value)); const connectionUrlPlaceholder = computed(() => getUrlPlaceholder(form.value.db_type)); @@ -4296,16 +4313,20 @@ function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionCo config.jdbc_driver_class = GAUSSDB_M_JDBC_DRIVER_CLASS; } config.jdbc_driver_class = config.jdbc_driver_class?.trim() || undefined; - config.jdbc_driver_paths = jdbcDriverPathsInput.value - .split(/\r?\n/) - .map((path) => path.trim()) - .filter(Boolean); + config.jdbc_driver_paths = parsedJdbcDriverPaths(); } else if (config.db_type === "gaussdb") { config.connection_string = undefined; config.jdbc_driver_class = undefined; config.jdbc_driver_paths = []; } if (config.db_type === "h2") { + if (config.driver_profile === "h2-custom") { + config.jdbc_driver_class = config.jdbc_driver_class?.trim() || "org.h2.Driver"; + config.jdbc_driver_paths = parsedJdbcDriverPaths(); + } else { + config.jdbc_driver_class = undefined; + config.jdbc_driver_paths = []; + } const h2Mode = connectionUrlInput.value.trim() ? h2ConnectionModeForConfig(config) : h2ConnectionMode.value; if (h2Mode === "file") { const jdbcFilePath = h2FilePathFromJdbcUrl(config.connection_string); @@ -6032,28 +6053,52 @@ function openExternalUrl(url: string) {
-
- - +
+ + + + +
+