From fc6c5d4aa6958ac20bafeb9b5ebf5688dc66bee2 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Thu, 13 Aug 2026 20:50:07 +0800 Subject: [PATCH] =?UTF-8?q?fix(jdbc):=20support=20Cach=C3=A9=20data=20grid?= =?UTF-8?q?=20pagination?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #3423 --- apps/desktop/src/lib/backend/http.ts | 3 ++ apps/desktop/src/lib/backend/tauri.ts | 3 ++ apps/desktop/src/lib/table/tableSelectSql.ts | 1 + .../queryStore.tableDataRefresh.spec.ts | 38 +++++++++++++++++++ apps/desktop/src/stores/queryStore.ts | 4 ++ crates/dbx-core/src/data_grid_sql.rs | 16 ++++++++ crates/dbx-core/src/query.rs | 23 +++++++++++ .../dbx-core/src/sql_dialect/table_select.rs | 7 +++- crates/dbx-core/src/sql_dialect/tests.rs | 19 ++++++++++ crates/dbx-core/src/sql_dialect/types.rs | 5 +++ crates/dbx-web/src/routes/query.rs | 3 ++ .../main/java/app/dbx/jdbc/DbxJdbcPlugin.java | 6 +++ .../java/app/dbx/jdbc/DbxJdbcPluginTest.java | 29 ++++++++++++++ src-tauri/src/commands/query.rs | 4 ++ 14 files changed, 160 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index bee1b5472d..cf1b544dce 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -900,6 +900,7 @@ export async function executeQuery( catalog?: string; fetchSize?: number; pageSize?: number; + rowOffset?: number; resultSessionId?: string; clientSessionId?: string; timeoutSecs?: number; @@ -927,6 +928,7 @@ export async function executeMulti( catalog?: string; fetchSize?: number; pageSize?: number; + rowOffset?: number; maxResultBytes?: number; resultKeyColumns?: string[]; tableDataPreview?: boolean; @@ -970,6 +972,7 @@ export async function executeMultiWithProgress( catalog?: string; fetchSize?: number; pageSize?: number; + rowOffset?: number; maxResultBytes?: number; resultKeyColumns?: string[]; tableDataPreview?: boolean; diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 949620db76..7ab7ca231c 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -1115,6 +1115,7 @@ export async function executeQuery( catalog?: string; fetchSize?: number; pageSize?: number; + rowOffset?: number; resultSessionId?: string; clientSessionId?: string; timeoutSecs?: number; @@ -1146,6 +1147,7 @@ export async function executeMulti( catalog?: string; fetchSize?: number; pageSize?: number; + rowOffset?: number; maxResultBytes?: number; resultKeyColumns?: string[]; tableDataPreview?: boolean; @@ -1193,6 +1195,7 @@ export async function executeMultiWithProgress( catalog?: string; fetchSize?: number; pageSize?: number; + rowOffset?: number; maxResultBytes?: number; resultKeyColumns?: string[]; tableDataPreview?: boolean; diff --git a/apps/desktop/src/lib/table/tableSelectSql.ts b/apps/desktop/src/lib/table/tableSelectSql.ts index 75182527f9..b0cf1921cd 100644 --- a/apps/desktop/src/lib/table/tableSelectSql.ts +++ b/apps/desktop/src/lib/table/tableSelectSql.ts @@ -19,6 +19,7 @@ export interface BuildTableSelectSqlOptions { orderBy?: string; limit?: number; offset?: number; + useDriverRowOffset?: boolean; whereInput?: string; includeRowId?: boolean; catalog?: string; diff --git a/apps/desktop/src/stores/__tests__/queryStore.tableDataRefresh.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.tableDataRefresh.spec.ts index e5bd6f0dda..eaa5dfd76f 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.tableDataRefresh.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.tableDataRefresh.spec.ts @@ -128,6 +128,44 @@ describe("queryStore table data refresh", () => { expect(store.tabs.find((tab) => tab.id === archiveTabId)?.result).toBeUndefined(); }); + it("uses JDBC ResultSet offset pagination for Caché data tabs", async () => { + mocks.getConnectionConfig.mockReturnValue({ + id: "cache-1", + name: "Caché 2016", + db_type: "jdbc", + database: "USER", + connection_string: "jdbc:Cache://localhost:1972/USER", + query_timeout_secs: 30, + }); + mocks.buildTableSelectSql.mockResolvedValue('SELECT * FROM "SS"."SS_User" ORDER BY "ID" ASC'); + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabId = store.createTab("cache-1", "USER", "SS_User", "data", "SS"); + store.setTableMeta(tabId, { + schema: "SS", + tableName: "SS_User", + tableType: "TABLE", + columns: [{ name: "ID", data_type: "%Library.Integer", 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); + + expect(mocks.buildTableSelectSql).toHaveBeenCalledWith( + expect.objectContaining({ + databaseType: "iris", + limit: 100, + offset: 100, + useDriverRowOffset: true, + }), + ); + 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("refreshes one targeted tab while preserving its query context", async () => { const { useQueryStore } = await import("@/stores/queryStore"); const store = useQueryStore(); diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 3a0d1db442..a504156cf3 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -2766,6 +2766,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 refreshPreparationId = uuid(); // Reserve the tab synchronously before SQL construction yields so repeated @@ -2789,6 +2790,7 @@ export const useQueryStore = defineStore("query", () => { orderBy, limit, offset, + ...(useDriverRowOffset ? { useDriverRowOffset: true } : {}), }); if (!sql.trim()) throw new Error("Failed to build table refresh SQL"); const current = tabs.value.find((candidate) => candidate.id === id); @@ -4577,6 +4579,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 executionOptions = { ...(typeof pageLimit === "number" ? useAgentResultSession @@ -4589,6 +4592,7 @@ export const useQueryStore = defineStore("query", () => { } : { maxRows: pageLimit, fetchSize: pageLimit } : { maxRows: agentProtocolQueryResultMaxRows(queryResultMaxRows) }), + ...(useJdbcDriverRowOffset && typeof pageOffset === "number" && pageOffset > 0 ? { rowOffset: pageOffset } : {}), ...(executionClientSessionId ? { clientSessionId: executionClientSessionId } : {}), ...(tab.mode === "data" && (effectiveDbType === "mysql" || effectiveDbType === "postgres") ? { diff --git a/crates/dbx-core/src/data_grid_sql.rs b/crates/dbx-core/src/data_grid_sql.rs index da3c1b8d39..64a2005ce8 100644 --- a/crates/dbx-core/src/data_grid_sql.rs +++ b/crates/dbx-core/src/data_grid_sql.rs @@ -3093,6 +3093,22 @@ mod tests { } } + #[test] + fn iris_data_grid_count_queries_the_table_without_wrapping_top_sql() { + assert_eq!( + build_data_grid_count_sql(DataGridCountSqlOptions { + database_type: Some(DatabaseType::Iris), + identifier_quote: Some("\"".to_string()), + catalog: None, + database: None, + schema: Some("SS".to_string()), + table_name: "SS_User".to_string(), + where_input: Some("SSUSR_IsActive = 'Y'".to_string()), + }), + "SELECT COUNT(*) AS cnt FROM \"SS\".\"SS_User\" WHERE (SSUSR_IsActive = 'Y')" + ); + } + #[test] fn postgres_keyless_update_preserves_jsonb_array_elements() { let endpoints = diff --git a/crates/dbx-core/src/query.rs b/crates/dbx-core/src/query.rs index 569076ff8f..1b6d7c6d46 100644 --- a/crates/dbx-core/src/query.rs +++ b/crates/dbx-core/src/query.rs @@ -885,6 +885,7 @@ pub struct QueryExecutionOptions { pub max_rows: Option, pub fetch_size: Option, pub page_size: Option, + pub row_offset: Option, pub max_result_bytes: Option, /// Result columns that must stay exact because clients use them as stable /// row identifiers when fetching full large-cell values on demand. @@ -975,6 +976,9 @@ pub fn agent_execute_query_params( if let Some(fetch_size) = options.fetch_size { params["fetchSize"] = serde_json::json!(agent_protocol_row_count(fetch_size)); } + if let Some(row_offset) = options.row_offset { + params["rowOffset"] = serde_json::json!(agent_protocol_row_offset(row_offset)); + } if let Some(timeout_secs) = options.timeout_secs { params["timeoutSecs"] = serde_json::json!(timeout_secs); } @@ -1001,6 +1005,9 @@ pub fn agent_execute_query_page_params( if let Some(fetch_size) = options.fetch_size { params["fetchSize"] = serde_json::json!(agent_protocol_row_count(fetch_size)); } + if let Some(row_offset) = options.row_offset { + params["rowOffset"] = serde_json::json!(agent_protocol_row_offset(row_offset)); + } if let Some(timeout_secs) = options.timeout_secs { params["timeoutSecs"] = serde_json::json!(timeout_secs); } @@ -1018,6 +1025,10 @@ fn agent_protocol_row_count(value: usize) -> usize { value.clamp(1, AGENT_PROTOCOL_MAX_ROWS) } +fn agent_protocol_row_offset(value: usize) -> usize { + value.min(AGENT_PROTOCOL_MAX_ROWS) +} + pub fn agent_close_query_session_params(session_id: &str) -> serde_json::Value { serde_json::json!({ "sessionId": session_id, @@ -2100,6 +2111,9 @@ fn external_driver_query_params( if let Some(fetch_size) = options.fetch_size { params["fetchSize"] = serde_json::json!(fetch_size); } + if let Some(row_offset) = options.row_offset { + params["rowOffset"] = serde_json::json!(agent_protocol_row_offset(row_offset)); + } if let Some(timeout_secs) = options.timeout_secs { params["timeoutSecs"] = serde_json::json!(timeout_secs); } @@ -6800,6 +6814,7 @@ for line in sys.stdin: &QueryExecutionOptions { max_rows: Some(500), fetch_size: Some(250), + row_offset: Some(100), timeout_secs: Some(600), ..Default::default() }, @@ -6811,6 +6826,7 @@ for line in sys.stdin: assert_eq!(params["schema"], "app"); assert_eq!(params["maxRows"], 500); assert_eq!(params["fetchSize"], 250); + assert_eq!(params["rowOffset"], 100); assert_eq!(params["timeoutSecs"], 600); } @@ -6823,6 +6839,7 @@ for line in sys.stdin: QueryExecutionOptions { max_rows: Some(500), fetch_size: Some(250), + row_offset: Some(100), timeout_secs: Some(600), ..Default::default() }, @@ -6833,6 +6850,7 @@ for line in sys.stdin: assert_eq!(params["schema"], "app"); assert_eq!(params["maxRows"], 500); assert_eq!(params["fetchSize"], 250); + assert_eq!(params["rowOffset"], 100); assert_eq!(params["timeoutSecs"], 600); } @@ -6988,6 +7006,7 @@ for line in sys.stdin: assert!(params.get("schema").is_none()); assert_eq!(params["maxRows"], MAX_ROWS); assert!(params.get("fetchSize").is_none()); + assert!(params.get("rowOffset").is_none()); assert!(params.get("timeoutSecs").is_none()); } @@ -7000,6 +7019,7 @@ for line in sys.stdin: QueryExecutionOptions { page_size: Some(500), fetch_size: Some(250), + row_offset: Some(100), timeout_secs: Some(600), ..Default::default() }, @@ -7010,6 +7030,7 @@ for line in sys.stdin: assert_eq!(params["schema"], "app"); assert_eq!(params["pageSize"], 500); assert_eq!(params["fetchSize"], 250); + assert_eq!(params["rowOffset"], 100); assert_eq!(params["timeoutSecs"], 600); assert_eq!(params["maxRows"], MAX_ROWS); } @@ -7025,6 +7046,7 @@ for line in sys.stdin: page_size: Some(oversized), fetch_size: Some(oversized), max_rows: Some(oversized), + row_offset: Some(oversized), ..Default::default() }, ); @@ -7032,6 +7054,7 @@ for line in sys.stdin: assert_eq!(params["pageSize"], AGENT_PROTOCOL_MAX_ROWS); assert_eq!(params["fetchSize"], AGENT_PROTOCOL_MAX_ROWS); assert_eq!(params["maxRows"], AGENT_PROTOCOL_MAX_ROWS); + assert_eq!(params["rowOffset"], AGENT_PROTOCOL_MAX_ROWS); assert_eq!(agent_fetch_query_page_params("session-1", oversized)["pageSize"], AGENT_PROTOCOL_MAX_ROWS); } diff --git a/crates/dbx-core/src/sql_dialect/table_select.rs b/crates/dbx-core/src/sql_dialect/table_select.rs index 09684fb83d..86c04d495a 100644 --- a/crates/dbx-core/src/sql_dialect/table_select.rs +++ b/crates/dbx-core/src/sql_dialect/table_select.rs @@ -225,7 +225,11 @@ pub fn build_table_data_select_sql(options: TableDataSelectSqlOptions) -> String match table_pagination_strategy(database_type) { TablePaginationStrategy::IrisTop => { - format!("SELECT TOP {limit} {select_columns} FROM {table_alias}{where_clause}{order}") + if options.use_driver_row_offset { + format!("SELECT {select_columns} FROM {table_alias}{where_clause}{order}") + } else { + format!("SELECT TOP {limit} {select_columns} FROM {table_alias}{where_clause}{order}") + } } TablePaginationStrategy::InformixFirst => { let row_limit = informix_row_limit_clause(limit, options.offset.unwrap_or(0)); @@ -654,6 +658,7 @@ mod tests { order_by: None, limit: Some(10), offset: None, + use_driver_row_offset: false, where_input: None, include_row_id: false, } diff --git a/crates/dbx-core/src/sql_dialect/tests.rs b/crates/dbx-core/src/sql_dialect/tests.rs index 3683b5be11..e2477a2072 100644 --- a/crates/dbx-core/src/sql_dialect/tests.rs +++ b/crates/dbx-core/src/sql_dialect/tests.rs @@ -621,6 +621,25 @@ fn builds_table_data_where_and_schema_queries() { }), "SELECT TOP 100 * FROM \"Ens\".\"AlarmResponse\"" ); + assert_eq!( + build_table_data_select_sql(TableDataSelectSqlOptions { + database_type: Some(DatabaseType::Iris), + schema: Some("Ens".to_string()), + table_name: "AlarmResponse".to_string(), + table_type: None, + primary_keys: Vec::new(), + columns: Vec::new(), + fallback_order_columns: Vec::new(), + order_by: Some("\"ID\" ASC".to_string()), + limit: Some(100), + offset: Some(100), + use_driver_row_offset: true, + where_input: Some("Status = 'Open'".to_string()), + include_row_id: false, + ..Default::default() + }), + "SELECT * FROM \"Ens\".\"AlarmResponse\" WHERE (Status = 'Open') ORDER BY \"ID\" ASC" + ); assert_eq!( build_table_data_select_sql(TableDataSelectSqlOptions { database_type: Some(DatabaseType::Iotdb), diff --git a/crates/dbx-core/src/sql_dialect/types.rs b/crates/dbx-core/src/sql_dialect/types.rs index 5bb652b9c1..5da315d6ac 100644 --- a/crates/dbx-core/src/sql_dialect/types.rs +++ b/crates/dbx-core/src/sql_dialect/types.rs @@ -61,6 +61,11 @@ pub struct TableDataSelectSqlOptions { pub limit: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub offset: Option, + /// The JDBC driver advances the ResultSet before collecting the page. + /// This keeps database-specific identifier rules while avoiding SQL + /// pagination syntax that the selected driver does not support. + #[serde(default)] + pub use_driver_row_offset: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub where_input: Option, #[serde(default)] diff --git a/crates/dbx-web/src/routes/query.rs b/crates/dbx-web/src/routes/query.rs index a8e285f3eb..8c9f4a18c1 100644 --- a/crates/dbx-web/src/routes/query.rs +++ b/crates/dbx-web/src/routes/query.rs @@ -21,6 +21,7 @@ pub struct ExecuteQueryRequest { pub max_rows: Option, pub fetch_size: Option, pub page_size: Option, + pub row_offset: Option, pub max_result_bytes: Option, #[serde(default)] pub result_key_columns: Vec, @@ -363,6 +364,7 @@ pub async fn execute_query( max_rows: req.max_rows, fetch_size: req.fetch_size, page_size: req.page_size, + row_offset: req.row_offset, max_result_bytes: req.max_result_bytes, result_key_columns: req.result_key_columns, table_data_preview: req.table_data_preview, @@ -412,6 +414,7 @@ pub async fn execute_multi( max_rows: req.max_rows, fetch_size: req.fetch_size, page_size: req.page_size, + row_offset: req.row_offset, max_result_bytes: req.max_result_bytes, result_key_columns: req.result_key_columns, table_data_preview: req.table_data_preview, diff --git a/plugins/jdbc/src/main/java/app/dbx/jdbc/DbxJdbcPlugin.java b/plugins/jdbc/src/main/java/app/dbx/jdbc/DbxJdbcPlugin.java index 7dc3afc64c..044c813a15 100644 --- a/plugins/jdbc/src/main/java/app/dbx/jdbc/DbxJdbcPlugin.java +++ b/plugins/jdbc/src/main/java/app/dbx/jdbc/DbxJdbcPlugin.java @@ -310,6 +310,7 @@ public final class DbxJdbcPlugin { optionalText(params, "schema"), positiveInt(params, "maxRows", MAX_ROWS), nonNegativeInt(params, "fetchSize", 0), + nonNegativeInt(params, "rowOffset", 0), nonNegativeInt(params, "timeoutSecs", -1) ); case "executeQueryPage", "execute_query_page" -> executeQueryPage( @@ -665,6 +666,7 @@ public final class DbxJdbcPlugin { String schema, int maxRows, int fetchSize, + int rowOffset, int timeoutSecs ) throws Exception { long start = System.nanoTime(); @@ -689,6 +691,10 @@ public final class DbxJdbcPlugin { String label = meta.getColumnLabel(i); columns.add(label == null || label.isBlank() ? meta.getColumnName(i) : label); } + for (int skipped = 0; skipped < rowOffset && rs.next(); skipped++) { + // Caché/IRIS does not support SQL offset pagination; advance + // the forward-only JDBC cursor before collecting this page. + } while (rs.next()) { if (rows.size() >= maxRows) { truncated = true; diff --git a/plugins/jdbc/src/test/java/app/dbx/jdbc/DbxJdbcPluginTest.java b/plugins/jdbc/src/test/java/app/dbx/jdbc/DbxJdbcPluginTest.java index 226b4fab2d..bec27bc607 100644 --- a/plugins/jdbc/src/test/java/app/dbx/jdbc/DbxJdbcPluginTest.java +++ b/plugins/jdbc/src/test/java/app/dbx/jdbc/DbxJdbcPluginTest.java @@ -71,6 +71,35 @@ final class DbxJdbcPluginTest { assertEquals("APP", response.path("result").path("rows").path(0).path(0).asText()); } + @Test + void executeQuerySkipsRowsBeforeCollectingThePage() throws Exception { + request("executeQuery", """ + { + "connection": %s, + "sql": "CREATE TABLE IF NOT EXISTS page_rows(id INT PRIMARY KEY)" + } + """.formatted(CONNECTION)); + request("executeQuery", """ + { + "connection": %s, + "sql": "MERGE INTO page_rows KEY(id) VALUES (1), (2), (3)" + } + """.formatted(CONNECTION)); + + JsonNode response = request("executeQuery", """ + { + "connection": %s, + "sql": "SELECT id FROM page_rows ORDER BY id", + "rowOffset": 1, + "maxRows": 1 + } + """.formatted(CONNECTION)); + + assertFalse(response.has("error"), response.toString()); + assertEquals(2, response.path("result").path("rows").path(0).path(0).asInt()); + assertEquals(1, response.path("result").path("rows").size()); + } + @Test void reportsDriverLinkageErrorsWithoutTerminatingThePlugin() throws Exception { JsonNode response = request("testConnection", """ diff --git a/src-tauri/src/commands/query.rs b/src-tauri/src/commands/query.rs index 5964ebb01c..2ab477b8bd 100644 --- a/src-tauri/src/commands/query.rs +++ b/src-tauri/src/commands/query.rs @@ -36,6 +36,7 @@ pub async fn execute_query( max_rows: Option, fetch_size: Option, page_size: Option, + row_offset: Option, result_session_id: Option, client_session_id: Option, timeout_secs: Option, @@ -61,6 +62,7 @@ pub async fn execute_query( max_rows, fetch_size, page_size, + row_offset, catalog, result_session_id, client_session_id, @@ -88,6 +90,7 @@ pub async fn execute_multi( max_rows: Option, fetch_size: Option, page_size: Option, + row_offset: Option, max_result_bytes: Option, result_key_columns: Option>, table_data_preview: Option, @@ -147,6 +150,7 @@ pub async fn execute_multi( max_rows, fetch_size, page_size, + row_offset, max_result_bytes, result_key_columns: result_key_columns.unwrap_or_default(), table_data_preview: table_data_preview.unwrap_or(false),