diff --git a/agents/.gitignore b/agents/.gitignore index bd3f45fe20..14a6cc0a46 100644 --- a/agents/.gitignore +++ b/agents/.gitignore @@ -1,4 +1,5 @@ **/libs/*.jar +!drivers/cache/libs/CacheDB.jar !drivers/oscar/libs/oscarJDBC8.jar !drivers/uxdb/libs/uxdbjdbc-2.1.2.3p.jre8.jar gradle.properties diff --git a/agents/README.md b/agents/README.md index e422f9964f..3643bf3cff 100644 --- a/agents/README.md +++ b/agents/README.md @@ -50,6 +50,8 @@ Each agent runs as a standalone process and communicates with DBX via stdin/stdo | zookeeper | Apache ZooKeeper | go-zookeeper native agent | | rabbitmq | RabbitMQ | amqp091-go native agent | | rocketmq | Apache RocketMQ | rocketmq-admin-go native agent | +| iris | InterSystems IRIS | InterSystems IRIS JDBC | +| cache | InterSystems Caché | InterSystems Caché JDBC (CacheDB) | ## Multi-JRE Support diff --git a/agents/README.zh-CN.md b/agents/README.zh-CN.md index cfd3eb9ffa..f5b950db45 100644 --- a/agents/README.zh-CN.md +++ b/agents/README.zh-CN.md @@ -49,6 +49,8 @@ DBX 的 Agent 驱动 —— 通过 JDBC 和原生数据库驱动支持各种数 | zookeeper | Apache ZooKeeper | go-zookeeper 原生 Agent | | rabbitmq | RabbitMQ | amqp091-go 原生 agent | | rocketmq | Apache RocketMQ | rocketmq-admin-go 原生 agent | +| iris | InterSystems IRIS | InterSystems IRIS JDBC | +| cache | InterSystems Caché | InterSystems Caché JDBC (CacheDB) | ## 多 JRE 支持 diff --git a/agents/build.gradle b/agents/build.gradle index 6b5db590f2..a70aba47fb 100644 --- a/agents/build.gradle +++ b/agents/build.gradle @@ -7,7 +7,7 @@ def legacyStandaloneProjects = ['mongodb', 'kafka'] as Set def pooledJdbcProjects = [ 'access', 'bigquery', 'dameng', 'databend', 'databricks', 'db2', 'exasol', 'firebird', 'gbase8a', 'gbase8s', 'goldendb', 'h2', 'h2-legacy', 'highgo', - 'ignite', 'ignite3', 'informix', 'iris', 'kylin', 'oceanbase-oracle', 'oscar', 'saphana', + 'ignite', 'ignite3', 'informix', 'iris', 'cache', 'kylin', 'oceanbase-oracle', 'oscar', 'saphana', 'snowflake', 'spanner', 'spark', 'sqlserver-legacy', 'sundb', 'teradata', 'trino', 'uxdb', 'vertica', 'yashandb' ] as Set diff --git a/agents/drivers/cache/build.gradle b/agents/drivers/cache/build.gradle new file mode 100644 index 0000000000..2a5e14dd4d --- /dev/null +++ b/agents/drivers/cache/build.gradle @@ -0,0 +1,9 @@ +dependencies { + implementation files('libs/CacheDB.jar') +} + +tasks.named('shadowJar') { + manifest { + attributes('Agent-Label': 'InterSystems Cach\u00e9', 'Main-Class': 'com.dbx.agent.cache.CacheAgent') + } +} diff --git a/agents/drivers/cache/libs/CacheDB.jar b/agents/drivers/cache/libs/CacheDB.jar new file mode 100644 index 0000000000..a512901355 Binary files /dev/null and b/agents/drivers/cache/libs/CacheDB.jar differ diff --git a/agents/drivers/cache/src/main/java/com/dbx/agent/cache/CacheAgent.java b/agents/drivers/cache/src/main/java/com/dbx/agent/cache/CacheAgent.java new file mode 100644 index 0000000000..d8915fa19f --- /dev/null +++ b/agents/drivers/cache/src/main/java/com/dbx/agent/cache/CacheAgent.java @@ -0,0 +1,230 @@ +package com.dbx.agent.cache; + +import com.dbx.agent.ConfiguredJdbcAgent; +import com.dbx.agent.ColumnInfo; +import com.dbx.agent.ConnectParams; +import com.dbx.agent.JdbcAgentProfile; +import com.dbx.agent.JdbcExecutor; +import com.dbx.agent.MultiSessionJsonRpcServer; +import com.dbx.agent.StandardJdbcMetadata; + +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.Types; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +public final class CacheAgent extends ConfiguredJdbcAgent { + public static final JdbcAgentProfile CACHE_PROFILE = new JdbcAgentProfile( + "com.intersys.jdbc.CacheDriver", + "jdbc:Cache://{host}:{port}/{database}", + 1972, + true + ); + + private String configuredDatabase = ""; + + public CacheAgent() { + super(CACHE_PROFILE); + } + + @Override + protected void afterConnect(ConnectParams params, Connection connection) { + configuredDatabase = params.getDatabase(); + } + + @Override + protected Object resultValue(ResultSet rs, int index, int sqlType) { + if (sqlType == Types.OTHER) { + return unchecked(() -> JdbcExecutor.normalizeResultValue(rs.getObject(index))); + } + return super.resultValue(rs, index, sqlType); + } + + @Override + public List listSchemas() { + return dedupeCaseInsensitiveSchemas(StandardJdbcMetadata.INSTANCE.listSchemas(requireConnection(), CACHE_PROFILE)); + } + + @Override + public List getColumns(String schema, String table) { + return cacheColumns(requireConnection(), getProfile(), configuredDatabase, schema, table); + } + + static List cacheColumns(Connection conn, JdbcAgentProfile profile, String configuredDatabase, String schema, String table) { + List columns; + try { + columns = informationSchemaColumns(conn, schema, table); + } catch (Exception informationSchemaUnavailable) { + // Caché releases before 2012.1 do not expose INFORMATION_SCHEMA; + // plain JDBC metadata keeps those servers browsable. + return StandardJdbcMetadata.INSTANCE.getColumns(conn, profile, configuredDatabase, schema, table); + } + if (!hasPrimaryKey(columns)) { + markPrimaryKeys(columns, cachePrimaryKeys(conn, schema, table)); + } + return columns; + } + + static List informationSchemaColumns(Connection conn, String schema, String table) { + return unchecked(() -> { + List columns = new ArrayList<>(); + try (PreparedStatement stmt = conn.prepareStatement(columnSql(schema))) { + int index = 1; + if (!isBlank(schema)) { + stmt.setString(index++, schema.trim()); + } + stmt.setString(index, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String name = rs.getString("COLUMN_NAME"); + if (isBlank(name)) { + continue; + } + columns.add(new ColumnInfo( + name, + stringOrDefault(rs, "DATA_TYPE", "Unknown"), + !"NO".equalsIgnoreCase(stringOrNull(rs, "IS_NULLABLE")), + stringOrNull(rs, "COLUMN_DEFAULT"), + "YES".equalsIgnoreCase(stringOrNull(rs, "PRIMARY_KEY")), + null, + stringOrNull(rs, "DESCRIPTION"), + intOrNull(rs, "NUMERIC_PRECISION"), + intOrNull(rs, "NUMERIC_SCALE"), + intOrNull(rs, "CHARACTER_MAXIMUM_LENGTH") + )); + } + } + } + return columns; + }); + } + + static List dedupeCaseInsensitiveSchemas(List schemas) { + Map byNormalizedName = new LinkedHashMap<>(); + for (String schema : schemas) { + if (schema == null || schema.trim().isEmpty()) { + continue; + } + String name = schema.trim(); + byNormalizedName.putIfAbsent(name.toUpperCase(Locale.ROOT), name); + } + return new ArrayList<>(byNormalizedName.values()); + } + + private static Set cachePrimaryKeys(Connection conn, String schema, String table) { + Set keys = jdbcPrimaryKeys(conn, schema, table); + return keys.isEmpty() ? nativePrimaryKeys(conn, schema, table) : keys; + } + + private static Set jdbcPrimaryKeys(Connection conn, String schema, String table) { + Set keys = new LinkedHashSet<>(); + try { + DatabaseMetaData meta = conn.getMetaData(); + try (ResultSet rs = meta.getPrimaryKeys(null, isBlank(schema) ? null : schema.trim(), table)) { + addPrimaryKeys(keys, rs); + } + } catch (Exception ignored) { + } + return keys; + } + + private static Set nativePrimaryKeys(Connection conn, String schema, String table) { + Set keys = new LinkedHashSet<>(); + try (PreparedStatement stmt = conn.prepareStatement(primaryKeySql(schema))) { + int index = 1; + if (!isBlank(schema)) { + stmt.setString(index++, schema.trim()); + } + stmt.setString(index, table); + try (ResultSet rs = stmt.executeQuery()) { + addPrimaryKeys(keys, rs); + } + } catch (Exception ignored) { + // Primary key metadata is optional for display; column listing must keep working. + } + return keys; + } + + private static String columnSql(String schema) { + StringBuilder sql = new StringBuilder( + "SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT, " + + "PRIMARY_KEY, DESCRIPTION, NUMERIC_PRECISION, NUMERIC_SCALE, CHARACTER_MAXIMUM_LENGTH " + + "FROM INFORMATION_SCHEMA.COLUMNS WHERE " + ); + if (!isBlank(schema)) { + sql.append("UPPER(TABLE_SCHEMA) = UPPER(?) AND "); + } + sql.append("UPPER(TABLE_NAME) = UPPER(?) ORDER BY ORDINAL_POSITION"); + return sql.toString(); + } + + private static void addPrimaryKeys(Set keys, ResultSet rs) throws Exception { + while (rs.next()) { + String column = rs.getString("COLUMN_NAME"); + if (!isBlank(column)) { + keys.add(column.toUpperCase(Locale.ROOT)); + } + } + } + + private static boolean hasPrimaryKey(List columns) { + for (ColumnInfo column : columns) { + if (column.getIs_primary_key()) { + return true; + } + } + return false; + } + + private static void markPrimaryKeys(List columns, Set primaryKeys) { + for (ColumnInfo column : columns) { + if (primaryKeys.contains(column.getName().toUpperCase(Locale.ROOT))) { + column.setIs_primary_key(true); + } + } + } + + private static String primaryKeySql(String schema) { + StringBuilder sql = new StringBuilder( + "SELECT k.COLUMN_NAME " + + "FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE k " + + "WHERE k.CONSTRAINT_TYPE = 'PRIMARY KEY' AND " + ); + if (!isBlank(schema)) { + sql.append("UPPER(k.TABLE_SCHEMA) = UPPER(?) AND "); + } + sql.append("UPPER(k.TABLE_NAME) = UPPER(?) ORDER BY k.ORDINAL_POSITION"); + return sql.toString(); + } + + private static String stringOrDefault(ResultSet rs, String column, String fallback) throws Exception { + String value = stringOrNull(rs, column); + return isBlank(value) ? fallback : value; + } + + private static String stringOrNull(ResultSet rs, String column) throws Exception { + String value = rs.getString(column); + return rs.wasNull() ? null : value; + } + + private static Integer intOrNull(ResultSet rs, String column) throws Exception { + Object value = rs.getObject(column); + return value instanceof Number ? ((Number) value).intValue() : null; + } + + private static boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); + } + + public static void main(String[] args) { + new MultiSessionJsonRpcServer(CacheAgent::new).run(); + } +} diff --git a/agents/drivers/cache/src/test/java/com/dbx/agent/cache/CacheAgentTest.java b/agents/drivers/cache/src/test/java/com/dbx/agent/cache/CacheAgentTest.java new file mode 100644 index 0000000000..235a23d26f --- /dev/null +++ b/agents/drivers/cache/src/test/java/com/dbx/agent/cache/CacheAgentTest.java @@ -0,0 +1,427 @@ +package com.dbx.agent.cache; + +import com.dbx.agent.ColumnInfo; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Types; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CacheAgentTest { + @Test + void skipsSchemaSwitchingBecauseCacheRejectsSetSchemaContext() { + CacheAgent agent = new CacheAgent(); + + assertEquals("", agent.setSchemaSQL("SQLUser")); + } + + @Test + void buildsCacheJdbcUrlWithDefaultPort() { + assertEquals( + "jdbc:Cache://db.local:1972/USER", + CacheAgent.CACHE_PROFILE.getUrlTemplate() + .replace("{host}", "db.local") + .replace("{port}", "1972") + .replace("{database}", "USER") + ); + assertEquals("com.intersys.jdbc.CacheDriver", CacheAgent.CACHE_PROFILE.getDriverClass()); + } + + @Test + void dedupesSchemasCaseInsensitively() { + assertEquals( + Arrays.asList("APP", "SQLUSER", "z_user"), + CacheAgent.dedupeCaseInsensitiveSchemas(Arrays.asList("APP", "SQLUSER", "SQLUser", "app", "z_user")) + ); + } + + @Test + void ignoresBlankSchemasWhenDeduping() { + assertEquals( + Collections.singletonList("SQLUSER"), + CacheAgent.dedupeCaseInsensitiveSchemas(Arrays.asList("", " ", null, "SQLUSER")) + ); + } + + @Test + void readsVendorOtherValuesThroughGetObject() { + List calls = new ArrayList<>(); + ResultSet resultSet = proxy(ResultSet.class, (method, args) -> { + if ("getObject".equals(method.getName())) { + calls.add("getObject"); + return new Object() { + @Override + public String toString() { + return "%List(1,2)"; + } + }; + } + if ("getString".equals(method.getName())) { + calls.add("getString"); + throw new AssertionError("Caché %LIST must not use getString"); + } + if ("wasNull".equals(method.getName())) { + return false; + } + return defaultValue(method.getReturnType()); + }); + + Object value = new CacheAgent().resultValue(resultSet, 1, Types.OTHER); + + assertEquals("%List(1,2)", value); + assertEquals(Collections.singletonList("getObject"), calls); + } + + @Test + void preservesStringPathForStandardValues() { + List calls = new ArrayList<>(); + ResultSet resultSet = proxy(ResultSet.class, (method, args) -> { + if ("getString".equals(method.getName())) { + calls.add("getString"); + return "ordinary"; + } + if ("wasNull".equals(method.getName())) { + return false; + } + return defaultValue(method.getReturnType()); + }); + + Object value = new CacheAgent().resultValue(resultSet, 1, Types.VARCHAR); + + assertEquals("ordinary", value); + assertEquals(Collections.singletonList("getString"), calls); + } + + @Test + void preservesNullForVendorOtherValues() { + List calls = new ArrayList<>(); + ResultSet resultSet = proxy(ResultSet.class, (method, args) -> { + if ("getObject".equals(method.getName())) { + calls.add("getObject"); + return null; + } + if ("wasNull".equals(method.getName())) { + return true; + } + return defaultValue(method.getReturnType()); + }); + + Object value = new CacheAgent().resultValue(resultSet, 1, Types.OTHER); + + assertNull(value); + assertEquals(Collections.singletonList("getObject"), calls); + } + + @Test + void readsColumnsAndUsesInlinePrimaryKey() { + List calls = new ArrayList<>(); + Connection conn = fakeConnection( + calls, + true, + Collections.emptyList(), + null, + Collections.emptyList() + ); + + List columns = CacheAgent.cacheColumns(conn, CacheAgent.CACHE_PROFILE, "USER", "SQLUser", "People"); + + assertEquals(2, columns.size()); + assertEquals("ID", columns.get(0).getName()); + assertEquals("INTEGER", columns.get(0).getData_type()); + assertFalse(columns.get(0).getIs_nullable()); + assertTrue(columns.get(0).getIs_primary_key()); + assertEquals("42", columns.get(0).getColumn_default()); + assertEquals("identifier", columns.get(0).getComment()); + assertEquals(Integer.valueOf(10), columns.get(0).getNumeric_precision()); + assertEquals(Integer.valueOf(0), columns.get(0).getNumeric_scale()); + assertNull(columns.get(0).getCharacter_maximum_length()); + assertEquals("NAME", columns.get(1).getName()); + assertEquals("VARCHAR", columns.get(1).getData_type()); + assertTrue(columns.get(1).getIs_nullable()); + assertNull(columns.get(1).getColumn_default()); + assertNull(columns.get(1).getComment()); + assertNull(columns.get(1).getNumeric_precision()); + assertNull(columns.get(1).getNumeric_scale()); + assertEquals(Integer.valueOf(64), columns.get(1).getCharacter_maximum_length()); + assertEquals(1, calls.size()); + assertFalse(calls.get(0).contains("LIMIT")); + assertTrue(calls.get(0).contains("PRIMARY_KEY")); + assertTrue(calls.get(0).contains("DESCRIPTION")); + assertTrue(calls.get(0).contains("INFORMATION_SCHEMA.COLUMNS")); + assertTrue(calls.get(0).contains("UPPER(TABLE_SCHEMA) = UPPER(?)")); + } + + @Test + void readsDescriptionsWithoutSchemaFilter() { + List calls = new ArrayList<>(); + Connection conn = fakeConnection( + calls, + true, + Collections.emptyList(), + null, + Collections.emptyList() + ); + + List columns = CacheAgent.cacheColumns(conn, CacheAgent.CACHE_PROFILE, "", null, "People"); + + assertEquals("identifier", columns.get(0).getComment()); + assertNull(columns.get(1).getComment()); + assertEquals(1, calls.size()); + assertTrue(calls.get(0).contains("DESCRIPTION")); + assertFalse(calls.get(0).contains("TABLE_SCHEMA")); + } + + @Test + void usesJdbcPrimaryKeysWhenColumnRowsHaveNoPrimaryKey() { + List calls = new ArrayList<>(); + Connection conn = fakeConnection( + calls, + false, + List.of(Map.of("COLUMN_NAME", "ID")), + null, + Collections.emptyList() + ); + + List columns = CacheAgent.cacheColumns(conn, CacheAgent.CACHE_PROFILE, "USER", "SQLUser", "People"); + + assertTrue(columns.get(0).getIs_primary_key()); + assertEquals(2, calls.size()); + assertTrue(calls.get(0).contains("INFORMATION_SCHEMA.COLUMNS")); + assertEquals("JDBC_PRIMARY_KEYS", calls.get(1)); + } + + @Test + void fallsBackToNativePrimaryKeysWhenJdbcMetadataFails() { + List calls = new ArrayList<>(); + Connection conn = fakeConnection( + calls, + false, + Collections.emptyList(), + new SQLException("Caché metadata unavailable"), + List.of(Map.of("COLUMN_NAME", "ID")) + ); + + List columns = CacheAgent.cacheColumns(conn, CacheAgent.CACHE_PROFILE, "USER", "SQLUser", "People"); + + assertTrue(columns.get(0).getIs_primary_key()); + assertTrue(calls.get(0).contains("INFORMATION_SCHEMA.COLUMNS")); + assertEquals("JDBC_PRIMARY_KEYS", calls.get(1)); + assertTrue(calls.get(2).contains("INFORMATION_SCHEMA.KEY_COLUMN_USAGE")); + } + + @Test + void leavesColumnsWithoutPrimaryKeysWhenBothSourcesAreEmpty() { + List calls = new ArrayList<>(); + Connection conn = fakeConnection( + calls, + false, + Collections.emptyList(), + null, + Collections.emptyList() + ); + + List columns = CacheAgent.cacheColumns(conn, CacheAgent.CACHE_PROFILE, "USER", "SQLUser", "People"); + + assertFalse(columns.get(0).getIs_primary_key()); + assertFalse(columns.get(1).getIs_primary_key()); + assertTrue(calls.get(2).contains("INFORMATION_SCHEMA.KEY_COLUMN_USAGE")); + } + + @Test + void fallsBackToJdbcMetadataWhenInformationSchemaIsUnavailable() { + List calls = new ArrayList<>(); + Connection conn = proxy(Connection.class, (method, args) -> { + if ("prepareStatement".equals(method.getName())) { + calls.add((String) args[0]); + throw new SQLException("SQLCODE -361: unknown table INFORMATION_SCHEMA.COLUMNS"); + } + if ("getMetaData".equals(method.getName())) { + return jdbcFallbackMetadata(calls); + } + return defaultValue(method.getReturnType()); + }); + + List columns = CacheAgent.cacheColumns(conn, CacheAgent.CACHE_PROFILE, "USER", "SQLUser", "People"); + + assertEquals(1, columns.size()); + assertEquals("ID", columns.get(0).getName()); + assertEquals("INTEGER", columns.get(0).getData_type()); + assertTrue(columns.get(0).getIs_primary_key()); + assertEquals(3, calls.size()); + assertTrue(calls.get(0).contains("INFORMATION_SCHEMA.COLUMNS")); + assertEquals("JDBC_PRIMARY_KEYS", calls.get(1)); + assertEquals("JDBC_COLUMNS", calls.get(2)); + } + + private static DatabaseMetaData jdbcFallbackMetadata(List calls) { + return proxy(DatabaseMetaData.class, (method, args) -> { + String name = method.getName(); + if ("getPrimaryKeys".equals(name)) { + calls.add("JDBC_PRIMARY_KEYS"); + return fakeResultSet(List.of(Map.of("COLUMN_NAME", "ID"))); + } + if ("getColumns".equals(name)) { + calls.add("JDBC_COLUMNS"); + return fakeResultSet(List.of(Map.of( + "COLUMN_NAME", "ID", + "TYPE_NAME", "INTEGER", + "NULLABLE", 0, + "COLUMN_DEF", "0", + "REMARKS", "identifier", + "COLUMN_SIZE", 10, + "DECIMAL_DIGITS", 0 + ))); + } + return defaultValue(method.getReturnType()); + }); + } + + private static Connection fakeConnection( + List callLog, + boolean inlinePrimaryKey, + List> jdbcPrimaryKeys, + SQLException jdbcPrimaryKeyFailure, + List> nativePrimaryKeys + ) { + return proxy(Connection.class, (method, args) -> { + if ("prepareStatement".equals(method.getName())) { + String sql = (String) args[0]; + callLog.add(sql); + return fakeStatement(sql, inlinePrimaryKey, nativePrimaryKeys); + } + if ("getMetaData".equals(method.getName())) { + return fakeMetadata(callLog, jdbcPrimaryKeys, jdbcPrimaryKeyFailure); + } + return defaultValue(method.getReturnType()); + }); + } + + private static DatabaseMetaData fakeMetadata( + List callLog, + List> jdbcPrimaryKeys, + SQLException jdbcPrimaryKeyFailure + ) { + return proxy(DatabaseMetaData.class, (method, args) -> { + if ("getPrimaryKeys".equals(method.getName())) { + callLog.add("JDBC_PRIMARY_KEYS"); + assertEquals(null, args[0]); + assertEquals("SQLUser", args[1]); + assertEquals("People", args[2]); + if (jdbcPrimaryKeyFailure != null) { + throw jdbcPrimaryKeyFailure; + } + return fakeResultSet(jdbcPrimaryKeys); + } + if ("getColumns".equals(method.getName())) { + throw new AssertionError("Caché 2012+ columns must not use JDBC DatabaseMetaData.getColumns()"); + } + return defaultValue(method.getReturnType()); + }); + } + + private static PreparedStatement fakeStatement( + String sql, + boolean inlinePrimaryKey, + List> nativePrimaryKeys + ) { + return proxy(PreparedStatement.class, (method, args) -> { + if ("executeQuery".equals(method.getName())) { + if (sql.contains("KEY_COLUMN_USAGE")) { + return fakeResultSet(nativePrimaryKeys); + } + return fakeResultSet(List.of( + Map.of( + "COLUMN_NAME", "ID", + "DATA_TYPE", "INTEGER", + "IS_NULLABLE", "NO", + "COLUMN_DEFAULT", "42", + "PRIMARY_KEY", inlinePrimaryKey ? "YES" : "NO", + "DESCRIPTION", "identifier", + "NUMERIC_PRECISION", 10, + "NUMERIC_SCALE", 0 + ), + Map.of( + "COLUMN_NAME", "NAME", + "DATA_TYPE", "VARCHAR", + "IS_NULLABLE", "YES", + "PRIMARY_KEY", "NO", + "CHARACTER_MAXIMUM_LENGTH", 64 + ) + )); + } + return defaultValue(method.getReturnType()); + }); + } + + private static ResultSet fakeResultSet(List> rows) { + final int[] index = {-1}; + final boolean[] wasNull = {false}; + return proxy(ResultSet.class, (method, args) -> { + String name = method.getName(); + if ("next".equals(name)) { + index[0] += 1; + return index[0] < rows.size(); + } + if ("getString".equals(name)) { + Object value = rows.get(index[0]).get((String) args[0]); + wasNull[0] = value == null; + return value == null ? null : String.valueOf(value); + } + if ("getInt".equals(name)) { + Object value = rows.get(index[0]).get((String) args[0]); + wasNull[0] = value == null; + return value instanceof Number ? ((Number) value).intValue() : 0; + } + if ("getObject".equals(name)) { + Object value = rows.get(index[0]).get((String) args[0]); + wasNull[0] = value == null; + return value; + } + if ("wasNull".equals(name)) { + return wasNull[0]; + } + return defaultValue(method.getReturnType()); + }); + } + + @SuppressWarnings("unchecked") + private static T proxy(Class type, MethodHandler handler) { + InvocationHandler invocationHandler = new InvocationHandler() { + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + return handler.handle(method, args == null ? new Object[0] : args); + } + }; + return (T) Proxy.newProxyInstance(type.getClassLoader(), new Class[]{type}, invocationHandler); + } + + private static Object defaultValue(Class type) { + if (Boolean.TYPE.equals(type)) { + return false; + } + if (Integer.TYPE.equals(type)) { + return 0; + } + return null; + } + + private interface MethodHandler { + Object handle(Method method, Object[] args) throws Throwable; + } +} diff --git a/agents/metadata-constraint-coverage.tsv b/agents/metadata-constraint-coverage.tsv index 41246c025a..0453cef886 100644 --- a/agents/metadata-constraint-coverage.tsv +++ b/agents/metadata-constraint-coverage.tsv @@ -9,6 +9,8 @@ db2 native-pushdown java-sql Uses SYSCAT metadata with type, filter, stable orde gbase8a native-pushdown java-sql Uses information_schema with type, filter, stable order, and LIMIT/OFFSET. gbase8s native-pushdown java-sql Uses systables with type, filter, stable order, and SKIP/FIRST. goldendb native-pushdown java-sql Uses information_schema with type, filter, stable order, and LIMIT/OFFSET. +iris native-pushdown java-sql Tables via JDBC metadata; columns and primary keys via INFORMATION_SCHEMA.COLUMNS/KEY_COLUMN_USAGE with JDBC fallback. +cache native-pushdown java-sql Mirrors iris; plain JDBC metadata fallback covers Caché releases before 2012.1 that lack INFORMATION_SCHEMA. h2 native-pushdown java-sql Uses INFORMATION_SCHEMA with type, filter, stable order, and LIMIT/OFFSET. hive-go shared-fallback native-go Uses HiveServer2 metadata APIs, then applies stable filtering and paging in the native agent with a SHOW TABLES fallback. ignite intentional-fallback java-jdbc-metadata Uses JDBC metadata from the Ignite thin driver; no portable server-side paging API, common constraints filter locally. diff --git a/agents/settings.gradle b/agents/settings.gradle index 724b68acc5..98e416f23e 100644 --- a/agents/settings.gradle +++ b/agents/settings.gradle @@ -6,7 +6,7 @@ def driverModules = [ 'teradata', 'vertica', 'firebird', 'exasol', 'oceanbase-oracle', 'gbase8a', 'gbase8s', 'bigquery', 'kylin', 'ignite', 'ignite3', 'spanner', 'sundb', 'h2', 'h2-legacy', 'snowflake', 'trino', 'spark', 'db2', 'informix', 'mongodb', 'highgo', 'uxdb', 'yashandb', 'oscar', - 'iris', 'kafka', 'sqlserver-legacy' + 'iris', 'cache', 'kafka', 'sqlserver-legacy' ] include(*(infrastructureModules + driverModules)) diff --git a/agents/versions.json b/agents/versions.json index dc0864bfce..9db473333b 100644 --- a/agents/versions.json +++ b/agents/versions.json @@ -1,6 +1,7 @@ { "access": "0.1.52", "bigquery": "0.1.56", + "cache": "0.1.0", "cassandra": "0.1.47", "dameng": "0.1.62", "databend": "0.1.43", diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index 9af03a9c90..91872f78ac 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -2971,7 +2971,7 @@ const isH2FileMode = computed(() => form.value.db_type === "h2" && h2ConnectionM 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)); +const connectionUrlPlaceholder = computed(() => getUrlPlaceholder(form.value.db_type, form.value.driver_profile)); const jdbcUsernamePlaceholder = computed(() => (form.value.driver_profile === "dremio" || isJdbcProductConnection.value ? "" : "sa")); const filePathPlaceholder = computed(() => { if (form.value.db_type === "duckdb") return "/path/to/database.duckdb or :memory:"; diff --git a/apps/desktop/src/components/icons/DatabaseIcon.vue b/apps/desktop/src/components/icons/DatabaseIcon.vue index 4a6a21266b..fe60885964 100644 --- a/apps/desktop/src/components/icons/DatabaseIcon.vue +++ b/apps/desktop/src/components/icons/DatabaseIcon.vue @@ -113,6 +113,7 @@ const assetIcons: Record = { nacos: "nacos.png", consul: "consul", iris: "iris", + cache: "iris", influxdb: "influxdb", influxdb3: "influxdb", victoriametrics: "victoriametrics.png", diff --git a/apps/desktop/src/lib/connection/connectionPresentation.ts b/apps/desktop/src/lib/connection/connectionPresentation.ts index d3b10d4f09..86b50a632f 100644 --- a/apps/desktop/src/lib/connection/connectionPresentation.ts +++ b/apps/desktop/src/lib/connection/connectionPresentation.ts @@ -161,7 +161,7 @@ export function connectionDisplayUrlScheme(connection: Pick