feat(cache): add built-in InterSystems Caché agent driver

Closes #7972
This commit is contained in:
t8y2 committed 2026-09-04 23:40:07 +08:00
1 parent 6bdfd72809
commit 8b8c0b8075
19 files changed
+716 -5

No files matched your search

@@ -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<String> listSchemas() {
return dedupeCaseInsensitiveSchemas(StandardJdbcMetadata.INSTANCE.listSchemas(requireConnection(), CACHE_PROFILE));
}
@Override
public List<ColumnInfo> getColumns(String schema, String table) {
return cacheColumns(requireConnection(), getProfile(), configuredDatabase, schema, table);
}
static List<ColumnInfo> cacheColumns(Connection conn, JdbcAgentProfile profile, String configuredDatabase, String schema, String table) {
List<ColumnInfo> 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<ColumnInfo> informationSchemaColumns(Connection conn, String schema, String table) {
return unchecked(() -> {
List<ColumnInfo> 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<String> dedupeCaseInsensitiveSchemas(List<String> schemas) {
Map<String, String> 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<String> cachePrimaryKeys(Connection conn, String schema, String table) {
Set<String> keys = jdbcPrimaryKeys(conn, schema, table);
return keys.isEmpty() ? nativePrimaryKeys(conn, schema, table) : keys;
}
private static Set<String> jdbcPrimaryKeys(Connection conn, String schema, String table) {
Set<String> 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<String> nativePrimaryKeys(Connection conn, String schema, String table) {
Set<String> 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<String> 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<ColumnInfo> columns) {
for (ColumnInfo column : columns) {
if (column.getIs_primary_key()) {
return true;
}
}
return false;
}
private static void markPrimaryKeys(List<ColumnInfo> columns, Set<String> 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();
}
}
@@ -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<String> 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<String> 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<String> 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<String> calls = new ArrayList<>();
Connection conn = fakeConnection(
calls,
true,
Collections.emptyList(),
null,
Collections.emptyList()
);
List<ColumnInfo> 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<String> calls = new ArrayList<>();
Connection conn = fakeConnection(
calls,
true,
Collections.emptyList(),
null,
Collections.emptyList()
);
List<ColumnInfo> 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<String> calls = new ArrayList<>();
Connection conn = fakeConnection(
calls,
false,
List.of(Map.of("COLUMN_NAME", "ID")),
null,
Collections.emptyList()
);
List<ColumnInfo> 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<String> calls = new ArrayList<>();
Connection conn = fakeConnection(
calls,
false,
Collections.emptyList(),
new SQLException("Caché metadata unavailable"),
List.of(Map.of("COLUMN_NAME", "ID"))
);
List<ColumnInfo> 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<String> calls = new ArrayList<>();
Connection conn = fakeConnection(
calls,
false,
Collections.emptyList(),
null,
Collections.emptyList()
);
List<ColumnInfo> 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<String> 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<ColumnInfo> 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<String> 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<String> callLog,
boolean inlinePrimaryKey,
List<Map<String, Object>> jdbcPrimaryKeys,
SQLException jdbcPrimaryKeyFailure,
List<Map<String, Object>> 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<String> callLog,
List<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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> T proxy(Class<T> 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;
}
}