fix(informix): resolve metadata owners correctly

This commit is contained in:
hb 2026-08-02 09:04:30 +08:00 committed by GitHub
parent 4ee1f2b946
commit b5206415ae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 530 additions and 79 deletions

View File

@ -27,8 +27,11 @@ import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.TreeSet;
public final class InformixAgent extends AbstractJdbcAgent {
private String loginOwner = "";
@Override
protected String driverClass() {
return "com.informix.jdbc.IfxDriver";
@ -39,6 +42,16 @@ public final class InformixAgent extends AbstractJdbcAgent {
return jdbcUrl(params);
}
@Override
protected void afterConnect(ConnectParams params, Connection connection) {
loginOwner = normalizeOwner(params.getUsername());
}
@Override
protected void afterDisconnect() {
loginOwner = "";
}
public static String jdbcUrl(ConnectParams params) {
String rawUrlParams = params.getUrl_params();
String extraParams = rawUrlParams == null ? "" : trimEnd(trimStart(rawUrlParams.trim(), ':', ';'), ';');
@ -163,28 +176,61 @@ public final class InformixAgent extends AbstractJdbcAgent {
@Override
public List<String> listSchemas() {
List<String> result = new ArrayList<>();
for (DatabaseInfo database : listDatabases()) {
result.add(database.getName());
return unchecked(() -> {
List<String> catalogOwners = new ArrayList<>();
try (java.sql.Statement stmt = requireConnected().createStatement();
ResultSet rs = stmt.executeQuery(schemaCatalogSql())) {
while (rs.next()) {
String owner = normalizeOwner(rs.getString(1));
if (!owner.isEmpty()) {
catalogOwners.add(owner);
}
}
}
return mergeSchemaOwners(catalogOwners, loginOwner);
});
}
static String schemaCatalogSql() {
// Informix JDBC catalogs are databases; schemas are the object owners in the current database.
return "SELECT owner FROM systables WHERE tabid >= 100 AND owner IS NOT NULL "
+ "UNION SELECT owner FROM sysprocedures WHERE owner IS NOT NULL ORDER BY owner";
}
static List<String> mergeSchemaOwners(List<String> catalogOwners, String loginOwner) {
Set<String> owners = new TreeSet<>();
for (String owner : catalogOwners) {
String normalized = normalizeOwner(owner);
if (!normalized.isEmpty()) {
owners.add(normalized);
}
}
return result;
String normalizedLoginOwner = normalizeOwner(loginOwner);
if (!normalizedLoginOwner.isEmpty()) {
owners.add(normalizedLoginOwner);
}
return new ArrayList<>(owners);
}
@Override
public List<TableInfo> listTables(String schema) {
return unchecked(() -> {
List<TableInfo> result = new ArrayList<>();
String sql = """
List<Object> args = new ArrayList<>();
StringBuilder sql = new StringBuilder("""
SELECT tabname,
CASE tabtype WHEN 'T' THEN 'TABLE' WHEN 'V' THEN 'VIEW' ELSE tabtype END
FROM systables
WHERE tabid >= 100
ORDER BY tabname
""";
try (java.sql.Statement stmt = requireConnected().createStatement();
ResultSet rs = stmt.executeQuery(sql.stripIndent().trim())) {
while (rs.next()) {
result.add(new TableInfo(rs.getString(1).trim(), rs.getString(2).trim(), null));
""".stripIndent().trim());
appendOwnerPredicate(sql, args, "owner", schema);
sql.append(" ORDER BY tabname");
try (PreparedStatement stmt = requireConnected().prepareStatement(sql.toString())) {
MetadataSqlSupport.bind(stmt, args);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
result.add(new TableInfo(rs.getString(1).trim(), rs.getString(2).trim(), null));
}
}
}
return result;
@ -201,13 +247,13 @@ public final class InformixAgent extends AbstractJdbcAgent {
return List.of();
}
try {
return queryConstrainedTables(normalized);
return queryConstrainedTables(schema, normalized);
} catch (RuntimeException e) {
return normalized.filterTables(listTables(schema));
}
}
private List<TableInfo> queryConstrainedTables(MetadataListConstraints constraints) {
private List<TableInfo> queryConstrainedTables(String schema, MetadataListConstraints constraints) {
return unchecked(() -> {
List<TableInfo> result = new ArrayList<>();
List<Object> args = new ArrayList<>();
@ -216,6 +262,7 @@ public final class InformixAgent extends AbstractJdbcAgent {
sql.append("tabname, CASE tabtype WHEN 'T' THEN 'TABLE' WHEN 'V' THEN 'VIEW' ELSE tabtype END ")
.append("FROM systables WHERE tabid >= 100");
appendInformixTableTypePredicate(sql, constraints);
appendOwnerPredicate(sql, args, "owner", schema);
MetadataSqlSupport.appendNameFilter(sql, args, "tabname", constraints);
sql.append(" ORDER BY tabname");
try (PreparedStatement stmt = requireConnected().prepareStatement(sql.toString())) {
@ -238,23 +285,8 @@ public final class InformixAgent extends AbstractJdbcAgent {
result.add(new ObjectInfo(table.getName(), table.getTable_type(), schema, table.getComment()));
}
try (java.sql.Statement stmt = requireConnected().createStatement();
ResultSet rs = stmt.executeQuery(
"SELECT procname FROM sysprocedures WHERE owner != 'informix' AND isproc = 'f' ORDER BY procname"
)) {
while (rs.next()) {
result.add(new ObjectInfo(rs.getString(1).trim(), "FUNCTION", schema, null));
}
}
try (java.sql.Statement stmt = requireConnected().createStatement();
ResultSet rs = stmt.executeQuery(
"SELECT procname FROM sysprocedures WHERE owner != 'informix' AND isproc = 't' ORDER BY procname"
)) {
while (rs.next()) {
result.add(new ObjectInfo(rs.getString(1).trim(), "PROCEDURE", schema, null));
}
}
appendRoutineObjects(result, schema, "f", "FUNCTION");
appendRoutineObjects(result, schema, "t", "PROCEDURE");
return result;
});
}
@ -283,16 +315,19 @@ public final class InformixAgent extends AbstractJdbcAgent {
if (constraints.includesTableLikeTypes()) {
StringBuilder tableSql = new StringBuilder("SELECT tabname AS object_name, CASE tabtype WHEN 'T' THEN 'TABLE' WHEN 'V' THEN 'VIEW' ELSE tabtype END AS object_type, 0 AS object_order FROM systables WHERE tabid >= 100");
appendInformixTableTypePredicate(tableSql, constraints);
appendOwnerPredicate(tableSql, args, "owner", schema);
MetadataSqlSupport.appendNameFilter(tableSql, args, "tabname", constraints);
branches.add(tableSql.toString());
}
if (constraints.objectTypeAllowed("FUNCTION")) {
StringBuilder functionSql = new StringBuilder("SELECT procname AS object_name, 'FUNCTION' AS object_type, 1 AS object_order FROM sysprocedures WHERE owner != 'informix' AND isproc = 'f'");
StringBuilder functionSql = new StringBuilder("SELECT procname AS object_name, 'FUNCTION' AS object_type, 1 AS object_order FROM sysprocedures WHERE isproc = 'f'");
appendRoutineOwnerPredicate(functionSql, args, schema);
MetadataSqlSupport.appendNameFilter(functionSql, args, "procname", constraints);
branches.add(functionSql.toString());
}
if (constraints.objectTypeAllowed("PROCEDURE")) {
StringBuilder procedureSql = new StringBuilder("SELECT procname AS object_name, 'PROCEDURE' AS object_type, 2 AS object_order FROM sysprocedures WHERE owner != 'informix' AND isproc = 't'");
StringBuilder procedureSql = new StringBuilder("SELECT procname AS object_name, 'PROCEDURE' AS object_type, 2 AS object_order FROM sysprocedures WHERE isproc = 't'");
appendRoutineOwnerPredicate(procedureSql, args, schema);
MetadataSqlSupport.appendNameFilter(procedureSql, args, "procname", constraints);
branches.add(procedureSql.toString());
}
@ -319,15 +354,18 @@ public final class InformixAgent extends AbstractJdbcAgent {
@Override
public ObjectSource getObjectSource(String schema, String name, String objectType) {
return unchecked(() -> {
String sql = """
List<Object> args = new ArrayList<>();
args.add(name);
StringBuilder sql = new StringBuilder("""
SELECT b.data FROM sysprocbody b
JOIN sysprocedures p ON b.procid = p.procid
WHERE p.procname = ? AND b.datakey = 'T'
ORDER BY b.seqno
""";
""".stripIndent().trim());
appendOwnerPredicate(sql, args, "p.owner", schema);
sql.append(" ORDER BY b.seqno");
StringBuilder sb = new StringBuilder();
try (java.sql.PreparedStatement stmt = requireConnected().prepareStatement(sql.stripIndent().trim())) {
stmt.setString(1, name);
try (PreparedStatement stmt = requireConnected().prepareStatement(sql.toString())) {
MetadataSqlSupport.bind(stmt, args);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
String value = rs.getString(1);
@ -343,16 +381,20 @@ public final class InformixAgent extends AbstractJdbcAgent {
public List<ColumnInfo> getColumns(String schema, String table) {
return unchecked(() -> {
Connection conn = requireConnected();
Set<Integer> primaryKeyColumns = getPrimaryKeyColumnNumbers(conn, table);
Set<Integer> primaryKeyColumns = getPrimaryKeyColumnNumbers(conn, schema, table);
List<ColumnInfo> result = new ArrayList<>();
String sql = """
List<Object> args = new ArrayList<>();
args.add(table);
StringBuilder sql = new StringBuilder("""
SELECT c.colname, c.coltype, c.colno
FROM syscolumns c
WHERE c.tabid = (SELECT tabid FROM systables WHERE tabname = ?)
ORDER BY c.colno
""";
try (java.sql.PreparedStatement stmt = conn.prepareStatement(sql.stripIndent().trim())) {
stmt.setString(1, table);
JOIN systables t ON t.tabid = c.tabid
WHERE t.tabname = ?
""".stripIndent().trim());
appendOwnerPredicate(sql, args, "t.owner", schema);
sql.append(" ORDER BY c.colno");
try (PreparedStatement stmt = conn.prepareStatement(sql.toString())) {
MetadataSqlSupport.bind(stmt, args);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
String colname = rs.getString(1).trim();
@ -376,18 +418,21 @@ public final class InformixAgent extends AbstractJdbcAgent {
});
}
private Set<Integer> getPrimaryKeyColumnNumbers(Connection conn, String table) throws SQLException {
String sql = """
private Set<Integer> getPrimaryKeyColumnNumbers(Connection conn, String schema, String table) throws Exception {
List<Object> args = new ArrayList<>();
args.add(table);
StringBuilder sql = new StringBuilder("""
SELECT i.part1, i.part2, i.part3, i.part4, i.part5, i.part6, i.part7, i.part8,
i.part9, i.part10, i.part11, i.part12, i.part13, i.part14, i.part15, i.part16
FROM sysconstraints c
JOIN sysindexes i ON i.idxname = c.idxname AND i.tabid = c.tabid
JOIN systables t ON t.tabid = c.tabid
WHERE t.tabname = ? AND c.constrtype = 'P'
""";
""".stripIndent().trim());
appendOwnerPredicate(sql, args, "t.owner", schema);
try (java.sql.PreparedStatement stmt = conn.prepareStatement(sql.stripIndent().trim())) {
stmt.setString(1, table);
try (PreparedStatement stmt = conn.prepareStatement(sql.toString())) {
MetadataSqlSupport.bind(stmt, args);
try (ResultSet rs = stmt.executeQuery()) {
if (!rs.next()) {
return Collections.emptySet();
@ -416,14 +461,17 @@ public final class InformixAgent extends AbstractJdbcAgent {
public List<TriggerInfo> listTriggers(String schema, String table) {
return unchecked(() -> {
List<TriggerInfo> result = new ArrayList<>();
String sql = """
List<Object> args = new ArrayList<>();
args.add(table);
StringBuilder sql = new StringBuilder("""
SELECT t.trigname, t.event, 'TRIGGER'
FROM systriggers t
JOIN systables s ON t.tabid = s.tabid
WHERE s.tabname = ?
""";
try (java.sql.PreparedStatement stmt = requireConnected().prepareStatement(sql.stripIndent().trim())) {
stmt.setString(1, table);
""".stripIndent().trim());
appendOwnerPredicate(sql, args, "s.owner", schema);
try (PreparedStatement stmt = requireConnected().prepareStatement(sql.toString())) {
MetadataSqlSupport.bind(stmt, args);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
result.add(new TriggerInfo(rs.getString(1).trim(), rs.getString(2).trim(), rs.getString(3).trim()));
@ -477,6 +525,51 @@ public final class InformixAgent extends AbstractJdbcAgent {
|| constraints.objectTypeAllowed("FUNCTION");
}
private void appendRoutineObjects(List<ObjectInfo> result, String schema, String routineKind, String objectType)
throws Exception {
List<Object> args = new ArrayList<>();
StringBuilder sql = new StringBuilder("SELECT procname FROM sysprocedures WHERE isproc = ?");
args.add(routineKind);
appendRoutineOwnerPredicate(sql, args, schema);
sql.append(" ORDER BY procname");
try (PreparedStatement stmt = requireConnected().prepareStatement(sql.toString())) {
MetadataSqlSupport.bind(stmt, args);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
result.add(new ObjectInfo(rs.getString(1).trim(), objectType, schema, null));
}
}
}
}
private void appendOwnerPredicate(StringBuilder sql, List<Object> args, String ownerColumn, String schema) {
String owner = metadataOwner(schema);
sql.append(" AND ").append(ownerColumn).append(" = ?");
args.add(owner);
}
private void appendRoutineOwnerPredicate(StringBuilder sql, List<Object> args, String schema) {
String owner = metadataOwner(schema);
sql.append(" AND owner = ?");
args.add(owner);
}
private String metadataOwner(String schema) {
String owner = normalizeOwner(schema);
if (!owner.isEmpty()) {
return owner;
}
owner = normalizeOwner(loginOwner);
if (!owner.isEmpty()) {
return owner;
}
throw new IllegalStateException("Informix metadata owner is unavailable for an unqualified request");
}
private static String normalizeOwner(String schema) {
return schema == null ? "" : schema.trim();
}
private static void appendInformixTableTypePredicate(StringBuilder sql, MetadataListConstraints constraints) {
if (!constraints.hasObjectTypes()) {
return;

View File

@ -172,18 +172,97 @@ class InformixAgentTest {
Assertions.assertEquals("SELECT name FROM sysmaster:sysdatabases ORDER BY name", InformixAgent.databaseCatalogSql());
}
@Test
void listsSchemasFromTableRoutineAndCurrentLoginOwners() {
InformixAgent agent = new InformixAgent();
java.sql.Connection connection = JdbcMetadataSqlFake.connection();
TestSupport.setPrivateConnection(agent, connection);
ConnectParams params = new ConnectParams();
params.setUsername("current_owner");
agent.afterConnect(params, connection);
Assertions.assertEquals(List.of("current_owner"), agent.listSchemas());
Assertions.assertEquals(
List.of("SELECT owner FROM systables WHERE tabid >= 100 AND owner IS NOT NULL "
+ "UNION SELECT owner FROM sysprocedures WHERE owner IS NOT NULL ORDER BY owner"),
JdbcMetadataSqlFake.statements
);
Assertions.assertEquals(
List.of("current_owner", "routine_owner", "table_owner"),
InformixAgent.mergeSchemaOwners(
List.of("table_owner", "routine_owner", "routine_owner", " "),
"current_owner"
)
);
Assertions.assertNotEquals(InformixAgent.databaseCatalogSql(), InformixAgent.schemaCatalogSql());
}
@Test
void unqualifiedMetadataUsesCurrentLoginOwnerWithoutCrossOwnerFallback() {
InformixAgent agent = new InformixAgent();
java.sql.Connection connection = JdbcMetadataSqlFake.connection();
TestSupport.setPrivateConnection(agent, connection);
ConnectParams params = new ConnectParams();
params.setUsername("app_owner");
agent.afterConnect(params, connection);
agent.listTables("");
agent.listObjects(null, new MetadataListConstraints("sync", 10, null, List.of("PROCEDURE", "FUNCTION")));
Assertions.assertTrue(JdbcMetadataSqlFake.statements.get(0).contains("AND owner = ?"));
Assertions.assertEquals("param:1=app_owner", JdbcMetadataSqlFake.statements.get(1));
String objectSql = JdbcMetadataSqlFake.statements.get(2);
Assertions.assertTrue(objectSql.contains("isproc = 'f' AND owner = ?"), objectSql);
Assertions.assertTrue(objectSql.contains("isproc = 't' AND owner = ?"), objectSql);
Assertions.assertFalse(objectSql.contains("owner <> 'informix'"), objectSql);
Assertions.assertEquals("param:1=app_owner", JdbcMetadataSqlFake.statements.get(3));
Assertions.assertEquals("param:3=app_owner", JdbcMetadataSqlFake.statements.get(5));
}
@Test
void unqualifiedMetadataFailsClosedWithoutALoginOwner() {
InformixAgent agent = new InformixAgent();
TestSupport.setPrivateConnection(agent, JdbcMetadataSqlFake.connection());
IllegalStateException error = Assertions.assertThrows(
IllegalStateException.class,
() -> agent.listTables("")
);
Assertions.assertTrue(error.getMessage().contains("metadata owner is unavailable"), error.getMessage());
}
@Test
void unconstrainedTableMetadataFiltersByRequestedOwner() {
InformixAgent agent = new InformixAgent();
TestSupport.setPrivateConnection(agent, JdbcMetadataSqlFake.connection());
agent.listTables("xtdpcky");
String sql = JdbcMetadataSqlFake.statements.get(0);
Assertions.assertTrue(sql.contains("FROM systables"), sql);
Assertions.assertTrue(sql.contains("AND owner = ?"), sql);
Assertions.assertEquals("param:1=xtdpcky", JdbcMetadataSqlFake.statements.get(1));
}
@Test
void constrainedTableMetadataUsesInformixSkipFirstPushdown() {
InformixAgent agent = new InformixAgent();
TestSupport.setPrivateConnection(agent, JdbcMetadataSqlFake.connection());
agent.listTables("stores", new MetadataListConstraints("ord", 25, 50, List.of("TABLE")));
agent.listTables("xtdpcky", new MetadataListConstraints("ord", 25, 50, List.of("TABLE")));
String sql = JdbcMetadataSqlFake.statements.get(0);
Assertions.assertTrue(sql.startsWith("SELECT SKIP 50 FIRST 25 tabname"), sql);
Assertions.assertTrue(sql.contains("tabtype IN ('T')"), sql);
Assertions.assertTrue(sql.contains("AND owner = ?"), sql);
Assertions.assertTrue(sql.contains("UPPER(tabname) LIKE ? ESCAPE '\\\\'"), sql);
Assertions.assertTrue(sql.endsWith("ORDER BY tabname"), sql);
Assertions.assertEquals(
List.of("param:1=xtdpcky", "param:2=%O%R%D%"),
JdbcMetadataSqlFake.statements.subList(1, 3)
);
}
@Test
@ -191,13 +270,60 @@ class InformixAgentTest {
InformixAgent agent = new InformixAgent();
TestSupport.setPrivateConnection(agent, JdbcMetadataSqlFake.connection());
agent.listObjects("stores", new MetadataListConstraints("sync", 10, null, List.of("PROCEDURE", "FUNCTION")));
agent.listObjects("xtdpcky", new MetadataListConstraints("sync", 10, null, List.of("PROCEDURE", "FUNCTION")));
String sql = JdbcMetadataSqlFake.statements.get(0);
Assertions.assertTrue(sql.startsWith("SELECT FIRST 10 object_name, object_type FROM ("), sql);
Assertions.assertTrue(sql.contains("FROM sysprocedures"), sql);
Assertions.assertTrue(sql.contains("isproc = 'f'"), sql);
Assertions.assertTrue(sql.contains("isproc = 't'"), sql);
Assertions.assertTrue(sql.contains("isproc = 'f' AND owner = ?"), sql);
Assertions.assertTrue(sql.contains("isproc = 't' AND owner = ?"), sql);
Assertions.assertTrue(sql.endsWith("ORDER BY object_order, object_name"), sql);
Assertions.assertEquals(
List.of("param:1=xtdpcky", "param:2=%S%Y%N%C%", "param:3=xtdpcky", "param:4=%S%Y%N%C%"),
JdbcMetadataSqlFake.statements.subList(1, 5)
);
}
@Test
void columnAndPrimaryKeyMetadataFilterDuplicateTableNamesByOwner() {
InformixAgent agent = new InformixAgent();
TestSupport.setPrivateConnection(agent, JdbcMetadataSqlFake.connection());
agent.getColumns("xtdpcky", "orders");
String primaryKeySql = JdbcMetadataSqlFake.statements.get(0);
String columnsSql = JdbcMetadataSqlFake.statements.get(3);
Assertions.assertTrue(primaryKeySql.contains("WHERE t.tabname = ? AND c.constrtype = 'P' AND t.owner = ?"), primaryKeySql);
Assertions.assertTrue(columnsSql.contains("WHERE t.tabname = ? AND t.owner = ?"), columnsSql);
Assertions.assertEquals(
List.of(
"param:1=orders",
"param:2=xtdpcky",
"param:1=orders",
"param:2=xtdpcky"
),
List.of(
JdbcMetadataSqlFake.statements.get(1),
JdbcMetadataSqlFake.statements.get(2),
JdbcMetadataSqlFake.statements.get(4),
JdbcMetadataSqlFake.statements.get(5)
)
);
}
@Test
void routineSourceAndTriggerMetadataUseRequestedOwner() {
InformixAgent agent = new InformixAgent();
TestSupport.setPrivateConnection(agent, JdbcMetadataSqlFake.connection());
agent.getObjectSource("xtdpcky", "sync_orders", "PROCEDURE");
agent.listTriggers("xtdpcky", "orders");
String sourceSql = JdbcMetadataSqlFake.statements.get(0);
String triggerSql = JdbcMetadataSqlFake.statements.get(3);
Assertions.assertTrue(sourceSql.contains("AND p.owner = ?"), sourceSql);
Assertions.assertTrue(triggerSql.contains("WHERE s.tabname = ? AND s.owner = ?"), triggerSql);
Assertions.assertEquals("param:2=xtdpcky", JdbcMetadataSqlFake.statements.get(2));
Assertions.assertEquals("param:2=xtdpcky", JdbcMetadataSqlFake.statements.get(5));
}
}

View File

@ -3,6 +3,7 @@ import type { ConnectionConfig } from "@/types/database";
import {
GAUSSDB_M_JDBC_DRIVER_CLASS,
connectionObjectTreeNodeSchema,
connectionObjectTreeQuerySchema,
connectionQueryExecutionSchema,
connectionShouldLoadIdentifierQuote,
connectionUsesDatabaseObjectTreeMode,
@ -223,4 +224,22 @@ describe("object tree node schema", () => {
it("uses the SQLite database alias to qualify attached tables", () => {
expect(connectionObjectTreeNodeSchema({ db_type: "sqlite" }, "analytics")).toBe("analytics");
});
it("keeps unqualified Informix metadata on the login owner", () => {
expect(connectionObjectTreeQuerySchema({ db_type: "informix" }, "prulife")).toBe("");
expect(connectionObjectTreeNodeSchema({ db_type: "informix" }, "prulife")).toBeUndefined();
});
it.each([
{ db_type: "jdbc" as const, connection_string: "jdbc:informix-sqli://localhost:9088/prulife" },
{ db_type: "gbase" as const, driver_profile: "gbase8s" },
])("keeps compatible Informix metadata on the login owner", (connection) => {
expect(connectionObjectTreeQuerySchema(connection, "prulife")).toBe("");
expect(connectionObjectTreeNodeSchema(connection, "prulife")).toBeUndefined();
});
it("preserves explicit Informix owners", () => {
expect(connectionObjectTreeQuerySchema({ db_type: "informix" }, "prulife", "xtdpcky")).toBe("xtdpcky");
expect(connectionObjectTreeNodeSchema({ db_type: "informix" }, "prulife", "xtdpcky")).toBe("xtdpcky");
});
});

View File

@ -162,6 +162,7 @@ export function connectionQueryExecutionSchema(connection: JdbcDialectConnection
export function connectionObjectTreeQuerySchema(connection: JdbcDialectConnection | undefined, database: string, schema?: string): string {
if (connection?.db_type === "jdbc" && inferJdbcDialect(connection) === "databend") return schema || database;
if (connectionUsesDatabaseObjectTreeMode(connection)) return "";
if (effectiveDatabaseTypeForConnection(connection) === "informix") return schema || "";
return schema || database;
}
@ -176,6 +177,7 @@ export function connectionObjectTreeNodeSchema(connection: JdbcDialectConnection
if (connectionUsesDatabaseObjectTreeMode(connection)) return undefined;
if (schema) return schema;
const type = effectiveDatabaseTypeForConnection(connection);
if (type === "informix") return undefined;
if (type === "sqlite") return database;
return isSchemaAware(type) ? database : undefined;
}

View File

@ -46,6 +46,20 @@ describe("TreeNodeLoadRegistry", () => {
).toBe(db);
});
it("does not let a user-cancelled load reclaim ownership", () => {
const registry = new TreeNodeLoadRegistry();
const node: TreeNodeLike = { id: "c1:db:schema", connectionId: "c1", isLoading: false, children: [] };
const load = registry.begin(node);
registry.cancelPrefix("c1:db");
node.isLoading = false;
const reclaimed = load.reclaim(node);
expect(reclaimed).toBe(load);
expect(reclaimed.isCurrent()).toBe(false);
expect(node.isLoading).toBe(false);
});
it("invalidates pruned descendant generations even when root no longer contains them", () => {
const registry = new TreeNodeLoadRegistry();
const db: TreeNodeLike = { id: "c1:db", connectionId: "c1", isLoading: false, children: [] };

View File

@ -28,12 +28,13 @@ export type TreeNodeLoadEpoch = {
export class TreeNodeLoadRegistry {
private readonly generations = new Map<string, number>();
private readonly cancellationGenerations = new Map<string, number>();
begin(node: TreeNodeLike): TreeNodeLoadHandle {
const generation = (this.generations.get(node.id) ?? 0) + 1;
this.generations.set(node.id, generation);
node.isLoading = true;
return this.createHandle(node.id, generation);
return this.createHandle(node.id, generation, this.cancellationGenerations.get(node.id) ?? 0);
}
/** Snapshot the current generation without claiming ownership (no spinner). */
@ -61,6 +62,22 @@ export class TreeNodeLoadRegistry {
}
}
/**
* User cancellation is stronger than ordinary invalidation: a load waiting for
* connection recovery must not reclaim ownership after its node was collapsed.
*/
cancelPrefix(nodeId: string): void {
const prefix = `${nodeId}:`;
const affectedIds = new Set<string>([nodeId]);
for (const id of this.generations.keys()) {
if (id.startsWith(prefix)) affectedIds.add(id);
}
for (const id of affectedIds) {
this.cancellationGenerations.set(id, (this.cancellationGenerations.get(id) ?? 0) + 1);
}
this.invalidatePrefix(nodeId);
}
/** Bump recorded generations for descendants only (not the parent load itself). */
invalidateDescendants(parentId: string): void {
const prefix = `${parentId}:`;
@ -90,7 +107,7 @@ export class TreeNodeLoadRegistry {
return this.generations.get(nodeId) === generation;
}
private createHandle(nodeId: string, generation: number): TreeNodeLoadHandle {
private createHandle(nodeId: string, generation: number, cancellationGeneration: number): TreeNodeLoadHandle {
const registry = this;
return {
nodeId,
@ -99,6 +116,9 @@ export class TreeNodeLoadRegistry {
return registry.isCurrent(nodeId, generation);
},
reclaim(node: TreeNodeLike) {
if (!registry.isCurrent(nodeId, generation) && (registry.cancellationGenerations.get(nodeId) ?? 0) !== cancellationGeneration) {
return this;
}
if (node.id !== nodeId) {
return registry.begin(node);
}

View File

@ -75,6 +75,19 @@ function genericJdbcConnection(): ConnectionConfig {
} as ConnectionConfig;
}
function informixConnection(): ConnectionConfig {
return {
id: "informix-1",
name: "Informix",
db_type: "informix",
host: "127.0.0.1",
port: 9088,
username: "informix",
password: "",
database: "prulife",
} as ConnectionConfig;
}
function procedure(name: string): ObjectInfo {
return {
name,
@ -2245,6 +2258,151 @@ describe("connectionStore metadata loading", () => {
expect(dbNode.isExpanded).toBe(false);
});
it("does not re-expand an Informix schema collapsed while its metadata cache is being saved", async () => {
let resolveCacheSave!: () => void;
const saveSchemaCache = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveCacheSave = resolve;
}),
);
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
loadSchemaCache: vi.fn().mockResolvedValue(null),
saveSchemaCache,
saveConnections: vi.fn().mockResolvedValue(undefined),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const { useSettingsStore } = await import("@/stores/settingsStore");
const store = useConnectionStore();
useSettingsStore().editorSettings.sidebarObjectDisplay = "grouped";
const connection = informixConnection();
const databaseNode: TreeNode = {
id: `${connection.id}:prulife`,
label: "prulife",
type: "database",
connectionId: connection.id,
database: "prulife",
isExpanded: true,
children: [],
};
const schemaNode: TreeNode = {
id: `${connection.id}:prulife:xtdpcky`,
label: "xtdpcky",
type: "schema",
connectionId: connection.id,
database: "prulife",
schema: "xtdpcky",
isExpanded: true,
children: [],
};
databaseNode.children = [schemaNode];
store.connections = [connection];
store.connectedIds.add(connection.id);
store.treeNodes = [
{
id: connection.id,
label: connection.name,
type: "connection",
connectionId: connection.id,
isExpanded: true,
children: [databaseNode],
},
];
const loadPromise = store.loadTables(connection.id, "prulife", "xtdpcky", { force: true });
await vi.waitFor(() => expect(saveSchemaCache).toHaveBeenCalledTimes(1));
expect(saveSchemaCache.mock.calls[0]?.[0]).toBe(`${connection.id}:prulife:xtdpcky:objects-grouped-v7-informix-owner-v2`);
expect(schemaNode.isLoading).toBe(true);
schemaNode.isExpanded = false;
store.cancelTreeNodeLoad(schemaNode.id);
resolveCacheSave();
await loadPromise;
expect(schemaNode.isLoading).toBe(false);
expect(schemaNode.isExpanded).toBe(false);
});
it("does not let an Informix schema load reclaim ownership after collapse during a health check", async () => {
let resolveHealthCheck!: () => void;
const checkConnectionHealth = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveHealthCheck = resolve;
}),
);
const saveSchemaCache = vi.fn().mockResolvedValue(undefined);
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
checkConnectionHealth,
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
loadSchemaCache: vi.fn().mockResolvedValue(null),
saveSchemaCache,
saveConnections: vi.fn().mockResolvedValue(undefined),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const { useSettingsStore } = await import("@/stores/settingsStore");
const store = useConnectionStore();
useSettingsStore().editorSettings.sidebarObjectDisplay = "grouped";
const connection = informixConnection();
const schemaNode: TreeNode = {
id: `${connection.id}:prulife:xtdpcky`,
label: "xtdpcky",
type: "schema",
connectionId: connection.id,
database: "prulife",
schema: "xtdpcky",
isExpanded: true,
children: [],
};
store.connections = [connection];
store.connectedIds.add(connection.id);
store.treeNodes = [
{
id: connection.id,
label: connection.name,
type: "connection",
connectionId: connection.id,
isExpanded: true,
children: [
{
id: `${connection.id}:prulife`,
label: "prulife",
type: "database",
connectionId: connection.id,
database: "prulife",
isExpanded: true,
children: [schemaNode],
},
],
},
];
const loadPromise = store.loadTables(connection.id, "prulife", "xtdpcky", { force: true });
await vi.waitFor(() => expect(checkConnectionHealth).toHaveBeenCalledTimes(1));
expect(schemaNode.isLoading).toBe(true);
schemaNode.isExpanded = false;
store.cancelTreeNodeLoad(schemaNode.id);
resolveHealthCheck();
await loadPromise;
expect(saveSchemaCache).not.toHaveBeenCalled();
expect(schemaNode.isLoading).toBe(false);
expect(schemaNode.isExpanded).toBe(false);
});
it("does not apply load-more results after the parent generation is invalidated", async () => {
const firstPage = Array.from({ length: 201 }, (_, index) => ({
name: `t_${String(index + 1).padStart(4, "0")}`,

View File

@ -1372,6 +1372,10 @@ export const useConnectionStore = defineStore("connection", () => {
return parts.map((part) => encodeURIComponent(part)).join(":");
}
function ownerAwareMetadataCacheVersion(config: ConnectionConfig | undefined, version: string): string {
return config?.db_type === "informix" ? `${version}-informix-owner-v2` : version;
}
function supportedSidebarObjectTypes(config?: ConnectionConfig): DatabaseObjectTreeKind[] {
const dbType = effectiveDatabaseTypeForConnection(config);
return sidebarObjectKindsForDatabase(dbType);
@ -1401,7 +1405,7 @@ export const useConnectionStore = defineStore("connection", () => {
function objectGroupCacheKey(node: TreeNode): string {
const config = node.connectionId ? getConfig(node.connectionId) : undefined;
const cacheVersion = config?.db_type === "oracle" ? "objects-v7" : "objects-v6";
const cacheVersion = ownerAwareMetadataCacheVersion(config, config?.db_type === "oracle" ? "objects-v7" : "objects-v6");
return schemaCacheKey(node.connectionId || "", node.database || "", node.schema || "", node.type, cacheVersion);
}
@ -1917,7 +1921,8 @@ export const useConnectionStore = defineStore("connection", () => {
if (parent.type === "group-tables") return objectGroupCacheKey(parent);
if (parent.type !== "database" && parent.type !== "schema" && parent.type !== "linked-server-schema") return null;
const simpleObjectDisplay = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple";
return schemaCacheKey(parent.connectionId, parent.database, parent.schema || "", simpleObjectDisplay ? "objects-simple-v6" : "objects-grouped-v7");
const cacheVersion = ownerAwareMetadataCacheVersion(getConfig(parent.connectionId), simpleObjectDisplay ? "objects-simple-v6" : "objects-grouped-v7");
return schemaCacheKey(parent.connectionId, parent.database, parent.schema || "", cacheVersion);
}
function sidebarTableSearchIndexCacheKey(parent: TreeNode): string | null {
@ -3659,8 +3664,10 @@ export const useConnectionStore = defineStore("connection", () => {
await ensureConnected(connectionId);
load = reclaimTreeNodeLoad(load, node);
if (useCachedChildren(node, options, load)) return;
const showSystemSchemas = getConfig(connectionId)?.show_system_schemas === true;
const cacheKey = schemaCacheKey(connectionId, database, "schemas-v3", showSystemSchemas ? "show-system" : "hide-system");
const config = getConfig(connectionId);
const showSystemSchemas = config?.show_system_schemas === true;
const cacheVersion = ownerAwareMetadataCacheVersion(config, "schemas-v3");
const cacheKey = schemaCacheKey(connectionId, database, cacheVersion, showSystemSchemas ? "show-system" : "hide-system");
if (!options?.force) {
const cached = await loadPersistedTreeChildren(node, cacheKey, load);
if (cached.hit) {
@ -3709,7 +3716,8 @@ export const useConnectionStore = defineStore("connection", () => {
if (!targetNode) return;
setChildren(targetNode, children);
await savePersistedTreeChildren(cacheKey, children);
targetNode.isExpanded = true;
const currentTargetNode = treeNodeLoadTarget(load);
if (currentTargetNode) currentTargetNode.isExpanded = true;
} catch (e) {
recordMetadataLoadError(connectionId, e, load);
throw e;
@ -3748,7 +3756,8 @@ export const useConnectionStore = defineStore("connection", () => {
if (!targetNode) return;
setChildren(targetNode, children);
await savePersistedTreeChildren(cacheKey, children);
targetNode.isExpanded = true;
const currentTargetNode = treeNodeLoadTarget(load);
if (currentTargetNode) currentTargetNode.isExpanded = true;
} catch (e) {
recordMetadataLoadError(connectionId, e, load);
throw e;
@ -4005,7 +4014,8 @@ export const useConnectionStore = defineStore("connection", () => {
if (!searchFilter && !options?.sidebarTableSearchParentId && !tableNameFilter) {
await savePersistedTreeChildren(cacheKey, children);
}
targetNode.isExpanded = true;
const currentTargetNode = treeNodeLoadTarget(load);
if (currentTargetNode) currentTargetNode.isExpanded = true;
} catch (e) {
recordMetadataLoadError(connectionId, e, load);
throw e;
@ -4032,7 +4042,7 @@ export const useConnectionStore = defineStore("connection", () => {
});
if (!options?.force && simpleObjectDisplayForScope && !searchFilter && !options?.sidebarTableSearchParentId && !tableNameFilterForScope) {
const nodeId = schema ? `${connectionId}:${database}:${schema}` : `${connectionId}:${database}`;
const cacheKey = schemaCacheKey(connectionId, database, schema || "", "objects-simple-v6");
const cacheKey = schemaCacheKey(connectionId, database, schema || "", ownerAwareMetadataCacheVersion(configForScope, "objects-simple-v6"));
if (await hydrateTreeNodeFromCache(findNode(treeNodes.value, nodeId), cacheKey)) {
void loadTables(connectionId, database, schema, { ...options, force: true }).catch(() => undefined);
return;
@ -4063,9 +4073,10 @@ export const useConnectionStore = defineStore("connection", () => {
load = reclaimTreeNodeLoad(load, node);
if (useCachedChildren(node, options, load)) return;
const simpleObjectDisplay = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple";
const cacheKey = schemaCacheKey(connectionId, database, schema || "", simpleObjectDisplay ? "objects-simple-v6" : "objects-grouped-v7");
const searchFilter = activeTreeLoadSearchFilter(options);
const config = getConfig(connectionId);
const cacheVersion = ownerAwareMetadataCacheVersion(config, simpleObjectDisplay ? "objects-simple-v6" : "objects-grouped-v7");
const cacheKey = schemaCacheKey(connectionId, database, schema || "", cacheVersion);
const querySchema = connectionObjectTreeQuerySchema(config, database, schema);
const effectiveSchema = connectionObjectTreeNodeSchema(config, database, schema);
const tableNameFilter = activeTableNameFilterForScope({
@ -4123,10 +4134,12 @@ export const useConnectionStore = defineStore("connection", () => {
if (!searchFilter && !isSidebarTableSearch && !tableNameFilter) {
await savePersistedTreeChildren(cacheKey, children);
}
targetNode.isExpanded = true;
const currentTargetNode = treeNodeLoadTarget(load);
if (!currentTargetNode) return;
currentTargetNode.isExpanded = true;
if (simpleObjectDisplay && !searchFilter && !isSidebarTableSearch && nonTableObjectTypes.length > 0) {
void loadSimpleSupplementalObjectChildren({
node: targetNode,
node: currentTargetNode,
nodeId,
connectionId,
database,
@ -4258,7 +4271,8 @@ export const useConnectionStore = defineStore("connection", () => {
if (!searchFilter && !isSidebarTableSearch && !tableNameFilter) {
await savePersistedTreeChildren(cacheKey, children);
}
targetNode.isExpanded = true;
const currentTargetNode = treeNodeLoadTarget(load);
if (currentTargetNode) currentTargetNode.isExpanded = true;
} catch (e) {
recordMetadataLoadError(node.connectionId, e, load);
throw e;
@ -4323,8 +4337,9 @@ export const useConnectionStore = defineStore("connection", () => {
const nextChildren = page.hasMore ? appendTableTreeLoadMoreNode(mergedChildren, buildLoadMoreNode(targetParent, page.nextOffset, loadMore.pageSize), page.loadMoreParent) : mergedChildren;
targetParent.objectCount = mergedChildren.length;
setChildren(targetParent, nextChildren);
await savePersistedTreeChildren(schemaCacheKey(parentConnectionId, parentDatabase, parent.schema || "", "objects-simple-v6"), nextChildren);
targetParent.isExpanded = true;
await savePersistedTreeChildren(schemaCacheKey(parentConnectionId, parentDatabase, parent.schema || "", ownerAwareMetadataCacheVersion(config, "objects-simple-v6")), nextChildren);
const currentTargetParent = treeNodeLoadRelatedTarget(load, parent);
if (currentTargetParent && parentEpoch.isCurrent()) currentTargetParent.isExpanded = true;
return;
}
const objectTypes = objectTypesForGroupNode(parent.type);
@ -4374,7 +4389,8 @@ export const useConnectionStore = defineStore("connection", () => {
targetParent.objectCount = mergedChildren.length;
setChildren(targetParent, nextChildren);
await savePersistedTreeChildren(objectGroupCacheKey(targetParent), nextChildren);
targetParent.isExpanded = true;
const currentTargetParent = treeNodeLoadRelatedTarget(load, parent);
if (currentTargetParent && parentEpoch.isCurrent()) currentTargetParent.isExpanded = true;
return;
}
const targetParent = treeNodeLoadRelatedTarget(load, parent);
@ -4382,7 +4398,8 @@ export const useConnectionStore = defineStore("connection", () => {
targetParent.objectCount = mergedChildren.length;
setChildren(targetParent, nextChildren);
await savePersistedTreeChildren(objectGroupCacheKey(targetParent), nextChildren);
targetParent.isExpanded = true;
const currentTargetParent = treeNodeLoadRelatedTarget(load, parent);
if (currentTargetParent && parentEpoch.isCurrent()) currentTargetParent.isExpanded = true;
} catch (e) {
recordMetadataLoadError(parentConnectionId, e, load);
throw e;
@ -6695,8 +6712,8 @@ export const useConnectionStore = defineStore("connection", () => {
function cancelTreeNodeLoad(nodeId: string): void {
// Supersede any in-flight loader for this node so a collapse issued while
// the load is still running (or a loader that never resolves) cannot
// re-expand the node via its trailing `targetNode.isExpanded = true`.
treeNodeLoads.invalidatePrefix(nodeId);
// reclaim ownership after connection recovery or re-expand the node.
treeNodeLoads.cancelPrefix(nodeId);
const node = findNode(treeNodes.value, nodeId);
if (node) node.isLoading = false;
}

View File

@ -55,6 +55,7 @@ pub fn is_schema_aware(database_type: DatabaseType) -> bool {
| DatabaseType::Hive
| DatabaseType::Spark
| DatabaseType::Db2
| DatabaseType::Informix
| DatabaseType::Tdengine
| DatabaseType::Xugu
| DatabaseType::Sqlite

View File

@ -77,6 +77,7 @@ fn qualifies_schema_only_for_schema_aware_databases() {
"\"DBX_TEST\".\"PRODUCTS\""
);
assert_eq!(qualified_table_name(Some(DatabaseType::Oscar), Some("SYSDBA"), "EMPLOYEE"), "\"SYSDBA\".\"EMPLOYEE\"");
assert_eq!(qualified_table_name(Some(DatabaseType::Informix), Some("xtdpcky"), "users"), "xtdpcky.users");
assert_eq!(qualified_table_name(Some(DatabaseType::Sqlite), Some("analytics"), "users"), "\"analytics\".\"users\"");
assert_eq!(qualified_table_name(Some(DatabaseType::Jdbc), Some("cbsdw_dwd"), "dwd_test_df"), "dwd_test_df");
assert_eq!(qualified_table_name(Some(DatabaseType::Iotdb), Some("root.test"), "device2"), "root.test.device2");
@ -622,7 +623,7 @@ fn builds_informix_table_data_with_skip_first_pagination() {
assert_eq!(
build_table_data_select_sql(TableDataSelectSqlOptions {
database_type: Some(DatabaseType::Informix),
schema: Some("ignored".to_string()),
schema: Some("xtdpcky".to_string()),
table_name: "users".to_string(),
table_type: None,
primary_keys: vec!["id".to_string()],
@ -635,7 +636,7 @@ fn builds_informix_table_data_with_skip_first_pagination() {
include_row_id: false,
..Default::default()
}),
"SELECT SKIP 100 FIRST 50 * FROM users WHERE (active = 1)"
"SELECT SKIP 100 FIRST 50 * FROM xtdpcky.users WHERE (active = 1)"
);
assert_eq!(