diff --git a/apps/desktop/src/lib/__tests__/database/jdbcDialect.spec.ts b/apps/desktop/src/lib/__tests__/database/jdbcDialect.spec.ts index 435a3720fa..1358caa2d8 100644 --- a/apps/desktop/src/lib/__tests__/database/jdbcDialect.spec.ts +++ b/apps/desktop/src/lib/__tests__/database/jdbcDialect.spec.ts @@ -20,6 +20,7 @@ import { gaussdbIdentifierQuoteStyle, gaussdbTargetServerType, inferJdbcDialect, + jdbcConnectionUsesDriverRowOffset, metadataSchemaForConnection, objectListSchemaForConnection, setGaussdbConnectionMode, @@ -328,6 +329,29 @@ describe("jdbc dialect inference", () => { }); }); +describe("JDBC driver row offset", () => { + it("lets the driver skip rows for dialects without SQL offset pagination", () => { + // Unknown vendor dialects stay on the generic JDBC dialect, which emits a + // bare SELECT, so the page offset can only be applied by the driver (#9015). + expect(jdbcConnectionUsesDriverRowOffset({ db_type: "jdbc", connection_string: "jdbc:sybase:Tds:db.example.com:5000/app" }, "jdbc")).toBe(true); + expect(jdbcConnectionUsesDriverRowOffset({ db_type: "jdbc", connection_string: "jdbc:hsqldb:hsql://127.0.0.1:9001/probe" }, "jdbc")).toBe(true); + }); + + it("keeps the Caché/IRIS ResultSet offset behavior", () => { + expect(jdbcConnectionUsesDriverRowOffset({ db_type: "jdbc", connection_string: "jdbc:Cache://localhost:1972/USER" }, "iris")).toBe(true); + }); + + it("leaves SQL-paginated dialects and driver-capped JDBC drivers alone", () => { + expect(jdbcConnectionUsesDriverRowOffset({ db_type: "jdbc", connection_string: "jdbc:mysql://localhost:3306/app" }, "mysql")).toBe(false); + expect(jdbcConnectionUsesDriverRowOffset({ db_type: "jdbc", connection_string: "jdbc:oracle:thin:@localhost:1521:XE" }, "oracle")).toBe(false); + // YashanDB keeps the generic dialect, but its agent applies + // Statement.setMaxRows, which would cap the result set before the skipped rows. + expect(jdbcConnectionUsesDriverRowOffset({ db_type: "jdbc", connection_string: "jdbc:yasdb://localhost:1688/app" }, "jdbc")).toBe(false); + expect(jdbcConnectionUsesDriverRowOffset({ db_type: "postgres" }, "postgres")).toBe(false); + expect(jdbcConnectionUsesDriverRowOffset(undefined, "jdbc")).toBe(false); + }); +}); + describe("GaussDB connection mode", () => { it("keeps native connections compatible and configures M mode for the vendor JDBC driver", () => { const connection = { db_type: "gaussdb", driver_profile: "gaussdb", driver_label: "GaussDB" } as ConnectionConfig; diff --git a/apps/desktop/src/lib/database/jdbcDialect.ts b/apps/desktop/src/lib/database/jdbcDialect.ts index 54924d6b23..a8d890903d 100644 --- a/apps/desktop/src/lib/database/jdbcDialect.ts +++ b/apps/desktop/src/lib/database/jdbcDialect.ts @@ -67,6 +67,29 @@ export function jdbcDriverProfileUsesSchemaQualification(driverProfile?: string) return inferJdbcDialect({ db_type: "jdbc", driver_profile: driverProfile }) === "jdbc"; } +/** + * Whether a JDBC-backed table view must let the driver skip `rowOffset` rows + * instead of expecting SQL pagination. + * + * Generic JDBC (`DatabaseType::Jdbc` → `AgentMaxRows`) and Iris (`IrisTop`) are + * the two dialects that cannot express a page offset in the generated SELECT: + * generic JDBC emits no LIMIT/OFFSET at all and Iris only has `TOP`. Without the + * driver-side offset the agent re-runs the same unbounded statement for every + * page, so the grid keeps rendering page one (#9015). + * + * The Oracle, Dameng and Yashan driver families are excluded because the JDBC + * agent passes `maxRows + 1` to `Statement.setMaxRows` for them, which caps the + * result set *before* the skipped rows — those drivers paginate in SQL instead. + */ +const JDBC_STATEMENT_MAX_ROWS_URL_PREFIXES = ["jdbc:oracle:", "jdbc:dm:", "jdbc:yasdb:"]; + +export function jdbcConnectionUsesDriverRowOffset(connection: JdbcDialectConnection | undefined, effectiveDatabaseType: DatabaseType | undefined): boolean { + if (connection?.db_type !== "jdbc") return false; + if (effectiveDatabaseType !== "iris" && effectiveDatabaseType !== "jdbc") return false; + const url = connection.connection_string?.trim().toLowerCase() ?? ""; + return !JDBC_STATEMENT_MAX_ROWS_URL_PREFIXES.some((prefix) => url.startsWith(prefix)); +} + export function effectiveDatabaseTypeForConnection(connection?: JdbcDialectConnection): DatabaseType | undefined { if (!connection) return undefined; if (connection.db_type === "gbase" && isGbase8sProfile(connection.driver_profile)) return "informix"; diff --git a/apps/desktop/src/stores/__tests__/queryStore.tableDataRefresh.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.tableDataRefresh.spec.ts index 6fcbc1c7df..ef8b1b1e1f 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.tableDataRefresh.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.tableDataRefresh.spec.ts @@ -179,6 +179,75 @@ describe("queryStore table data refresh", () => { expect(mocks.executeMulti).toHaveBeenCalledWith("cache-1", "USER", 'SELECT * FROM "SS"."SS_User" ORDER BY "ID" ASC', undefined, expect.any(String), expect.objectContaining({ maxRows: 100, fetchSize: 100, rowOffset: 100 })); }); + it("uses JDBC ResultSet offset pagination for dialects that cannot paginate in SQL", async () => { + mocks.getConnectionConfig.mockReturnValue({ + id: "sybase-1", + name: "Sybase ASE 16", + db_type: "jdbc", + database: "app", + connection_string: "jdbc:sybase:Tds:db.example.com:5000/app", + query_timeout_secs: 30, + }); + mocks.buildTableSelectSql.mockResolvedValue("SELECT * FROM dbo.users ORDER BY id ASC"); + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabId = store.createTab("sybase-1", "app", "users", "data", "dbo"); + store.setTableMeta(tabId, { + schema: "dbo", + tableName: "users", + tableType: "TABLE", + columns: [{ name: "id", data_type: "int", is_nullable: false, column_default: null, is_primary_key: true, extra: null }], + primaryKeys: ["id"], + }); + const tab = store.tabs.find((candidate) => candidate.id === tabId)!; + tab.orderByInput = "id ASC"; + tab.resultPageLimit = 100; + tab.resultPageOffset = 100; + + await store.refreshDataTab(tabId); + + // Generic JDBC emits a bare SELECT, so the agent has to skip the offset; + // otherwise page 2 re-renders page 1 (#9015). + expect(mocks.buildTableSelectSql).toHaveBeenCalledWith( + expect.objectContaining({ + databaseType: "jdbc", + limit: 100, + offset: 100, + useDriverRowOffset: true, + }), + ); + expect(mocks.executeMulti).toHaveBeenCalledWith("sybase-1", "app", "SELECT * FROM dbo.users ORDER BY id ASC", undefined, expect.any(String), expect.objectContaining({ maxRows: 100, fetchSize: 100, rowOffset: 100 })); + }); + + it("keeps YashanDB JDBC tabs on server-side pagination", async () => { + mocks.getConnectionConfig.mockReturnValue({ + id: "yasdb-1", + name: "YashanDB", + db_type: "jdbc", + database: "app", + connection_string: "jdbc:yasdb://localhost:1688/app", + query_timeout_secs: 30, + }); + mocks.buildTableSelectSql.mockResolvedValue("SELECT * FROM app.users"); + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabId = store.createTab("yasdb-1", "app", "users", "data"); + store.setTableMeta(tabId, { + tableName: "users", + tableType: "TABLE", + columns: [{ name: "id", data_type: "int", is_nullable: false, column_default: null, is_primary_key: true, extra: null }], + primaryKeys: ["id"], + }); + const tab = store.tabs.find((candidate) => candidate.id === tabId)!; + tab.resultPageLimit = 100; + tab.resultPageOffset = 100; + + await store.refreshDataTab(tabId); + + expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.not.objectContaining({ useDriverRowOffset: true })); + expect(mocks.executeMulti).toHaveBeenCalledWith("yasdb-1", "app", "SELECT * FROM app.users", undefined, expect.any(String), expect.not.objectContaining({ rowOffset: expect.anything() })); + }); + it("executes Doris external catalog data tabs against the catalog database, not the connection default database", async () => { mocks.getConnectionConfig.mockReturnValue({ id: "doris-1", diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 7f7c4e341e..00180f81b6 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -55,7 +55,7 @@ import { tableOpenPageLimit } from "@/lib/table/tableOpenPageLimit"; import { getCachedTableMetadata, loadTableColumns, loadTableIndexes, loadTableMetadata, tableMetadataToDataTabMeta, updateCachedTableMetadataType, type TableMetadataRequest } from "@/lib/metadata/tableMetadataCache"; import { MetadataTaskLimiter } from "@/lib/metadata/metadataTaskLimiter"; import { buildTableSelectSql, quoteTableDataIdentifier } from "@/lib/table/tableSelectSql"; -import { connectionObjectTreeNodeSchema, connectionQueryExecutionSchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, gaussdbCountQueryDopHint, metadataSchemaForConnection } from "@/lib/database/jdbcDialect"; +import { connectionObjectTreeNodeSchema, connectionQueryExecutionSchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, gaussdbCountQueryDopHint, jdbcConnectionUsesDriverRowOffset, metadataSchemaForConnection } from "@/lib/database/jdbcDialect"; import { frontendQueryTimeoutDelayMs, frontendQueryTimeoutSecsForSql, queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout"; import { queryResultNameFromPreamble, queryResultSourceLabel } from "@/lib/sql/queryResultSource"; import { sqlServerCountUsesLocalTempTable } from "@/lib/query/queryResultCountSession"; @@ -4546,7 +4546,7 @@ export const useQueryStore = defineStore("query", () => { const orderBy = tab.orderByInput?.trim() || sortOrder; const limit = tab.resultPageLimit ?? tableOpenPageLimit(settingsStore.editorSettings.tableOpenPageSize); const offset = tab.resultPageOffset ?? 0; - const useDriverRowOffset = conn?.db_type === "jdbc" && effectiveDbType === "iris"; + const useDriverRowOffset = jdbcConnectionUsesDriverRowOffset(conn, effectiveDbType); const sql = await buildTableSelectSql({ databaseType: effectiveDbType, @@ -7263,7 +7263,7 @@ export const useQueryStore = defineStore("query", () => { // connection-local state and avoid MySQL pool resets on every refresh. const dataTabMeta = tab.mode === "data" ? tableMetaForDataTab(tab) : undefined; const useTableDataPreview = canUseTableDataLargeValuePreview(effectiveDbType, dataTabMeta?.columns ?? [], dataTabMeta?.primaryKeys ?? []); - const useJdbcDriverRowOffset = tab.mode === "data" && conn?.db_type === "jdbc" && effectiveDbType === "iris"; + const useJdbcDriverRowOffset = tab.mode === "data" && jdbcConnectionUsesDriverRowOffset(conn, effectiveDbType); const executionOptions = { ...(typeof pageLimit === "number" ? useAgentResultSession diff --git a/docs/screenshots/issue-9015-jdbc-page1.png b/docs/screenshots/issue-9015-jdbc-page1.png new file mode 100644 index 0000000000..f106021cef Binary files /dev/null and b/docs/screenshots/issue-9015-jdbc-page1.png differ diff --git a/docs/screenshots/issue-9015-jdbc-page2.png b/docs/screenshots/issue-9015-jdbc-page2.png new file mode 100644 index 0000000000..70ccfae3d0 Binary files /dev/null and b/docs/screenshots/issue-9015-jdbc-page2.png differ diff --git a/docs/screenshots/issue-9015-jdbc-page3.png b/docs/screenshots/issue-9015-jdbc-page3.png new file mode 100644 index 0000000000..98565fe44b Binary files /dev/null and b/docs/screenshots/issue-9015-jdbc-page3.png differ