From b5206415aebd5fc9da66df03b2c6d541fc01058d Mon Sep 17 00:00:00 2001 From: hb <34821912+chenhbb@users.noreply.github.com> Date: Sun, 2 Aug 2026 09:04:30 +0800 Subject: [PATCH] fix(informix): resolve metadata owners correctly --- .../com/dbx/agent/informix/InformixAgent.java | 199 +++++++++++++----- .../dbx/agent/informix/InformixAgentTest.java | 134 +++++++++++- .../__tests__/database/jdbcDialect.spec.ts | 19 ++ apps/desktop/src/lib/database/jdbcDialect.ts | 2 + .../__tests__/treeNodeLoadHandle.spec.ts | 14 ++ .../src/lib/metadata/treeNodeLoadHandle.ts | 24 ++- .../connectionStore.metadataLoading.spec.ts | 158 ++++++++++++++ apps/desktop/src/stores/connectionStore.ts | 53 +++-- .../dbx-core/src/sql_dialect/capabilities.rs | 1 + crates/dbx-core/src/sql_dialect/tests.rs | 5 +- 10 files changed, 530 insertions(+), 79 deletions(-) diff --git a/agents/drivers/informix/src/main/java/com/dbx/agent/informix/InformixAgent.java b/agents/drivers/informix/src/main/java/com/dbx/agent/informix/InformixAgent.java index b84908eef..9c15922b9 100644 --- a/agents/drivers/informix/src/main/java/com/dbx/agent/informix/InformixAgent.java +++ b/agents/drivers/informix/src/main/java/com/dbx/agent/informix/InformixAgent.java @@ -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 listSchemas() { - List result = new ArrayList<>(); - for (DatabaseInfo database : listDatabases()) { - result.add(database.getName()); + return unchecked(() -> { + List 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 mergeSchemaOwners(List catalogOwners, String loginOwner) { + Set 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 listTables(String schema) { return unchecked(() -> { List result = new ArrayList<>(); - String sql = """ + List 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 queryConstrainedTables(MetadataListConstraints constraints) { + private List queryConstrainedTables(String schema, MetadataListConstraints constraints) { return unchecked(() -> { List result = new ArrayList<>(); List 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 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 getColumns(String schema, String table) { return unchecked(() -> { Connection conn = requireConnected(); - Set primaryKeyColumns = getPrimaryKeyColumnNumbers(conn, table); + Set primaryKeyColumns = getPrimaryKeyColumnNumbers(conn, schema, table); List result = new ArrayList<>(); - String sql = """ + List 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 getPrimaryKeyColumnNumbers(Connection conn, String table) throws SQLException { - String sql = """ + private Set getPrimaryKeyColumnNumbers(Connection conn, String schema, String table) throws Exception { + List 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 listTriggers(String schema, String table) { return unchecked(() -> { List result = new ArrayList<>(); - String sql = """ + List 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 result, String schema, String routineKind, String objectType) + throws Exception { + List 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 args, String ownerColumn, String schema) { + String owner = metadataOwner(schema); + sql.append(" AND ").append(ownerColumn).append(" = ?"); + args.add(owner); + } + + private void appendRoutineOwnerPredicate(StringBuilder sql, List 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; diff --git a/agents/drivers/informix/src/test/java/com/dbx/agent/informix/InformixAgentTest.java b/agents/drivers/informix/src/test/java/com/dbx/agent/informix/InformixAgentTest.java index 1257f9e63..132fff3f8 100644 --- a/agents/drivers/informix/src/test/java/com/dbx/agent/informix/InformixAgentTest.java +++ b/agents/drivers/informix/src/test/java/com/dbx/agent/informix/InformixAgentTest.java @@ -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)); } } diff --git a/apps/desktop/src/lib/__tests__/database/jdbcDialect.spec.ts b/apps/desktop/src/lib/__tests__/database/jdbcDialect.spec.ts index 018e16e18..150f4bd65 100644 --- a/apps/desktop/src/lib/__tests__/database/jdbcDialect.spec.ts +++ b/apps/desktop/src/lib/__tests__/database/jdbcDialect.spec.ts @@ -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"); + }); }); diff --git a/apps/desktop/src/lib/database/jdbcDialect.ts b/apps/desktop/src/lib/database/jdbcDialect.ts index 801e0e656..7dfeb9554 100644 --- a/apps/desktop/src/lib/database/jdbcDialect.ts +++ b/apps/desktop/src/lib/database/jdbcDialect.ts @@ -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; } diff --git a/apps/desktop/src/lib/metadata/__tests__/treeNodeLoadHandle.spec.ts b/apps/desktop/src/lib/metadata/__tests__/treeNodeLoadHandle.spec.ts index 4d959f2ad..cb7f522a4 100644 --- a/apps/desktop/src/lib/metadata/__tests__/treeNodeLoadHandle.spec.ts +++ b/apps/desktop/src/lib/metadata/__tests__/treeNodeLoadHandle.spec.ts @@ -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: [] }; diff --git a/apps/desktop/src/lib/metadata/treeNodeLoadHandle.ts b/apps/desktop/src/lib/metadata/treeNodeLoadHandle.ts index dc8631487..755cc4d64 100644 --- a/apps/desktop/src/lib/metadata/treeNodeLoadHandle.ts +++ b/apps/desktop/src/lib/metadata/treeNodeLoadHandle.ts @@ -28,12 +28,13 @@ export type TreeNodeLoadEpoch = { export class TreeNodeLoadRegistry { private readonly generations = new Map(); + private readonly cancellationGenerations = new Map(); 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([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); } diff --git a/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts index 34bb77f19..5de310ae8 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts @@ -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((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((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")}`, diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 3c21e71fc..cde022c80 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -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; } diff --git a/crates/dbx-core/src/sql_dialect/capabilities.rs b/crates/dbx-core/src/sql_dialect/capabilities.rs index 41641e162..133c66581 100644 --- a/crates/dbx-core/src/sql_dialect/capabilities.rs +++ b/crates/dbx-core/src/sql_dialect/capabilities.rs @@ -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 diff --git a/crates/dbx-core/src/sql_dialect/tests.rs b/crates/dbx-core/src/sql_dialect/tests.rs index 4a67c84d2..d1c31723a 100644 --- a/crates/dbx-core/src/sql_dialect/tests.rs +++ b/crates/dbx-core/src/sql_dialect/tests.rs @@ -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!(