From 30afcb8e69229e761339c31d24c08bdda4405c6d Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Sat, 13 Jun 2026 13:40:36 +0800 Subject: [PATCH] fix(sidebar): page large table groups --- .../src/components/sidebar/TreeItem.vue | 16 ++ apps/desktop/src/i18n/locales/en.ts | 1 + apps/desktop/src/i18n/locales/es.ts | 1 + apps/desktop/src/i18n/locales/it.ts | 1 + apps/desktop/src/i18n/locales/pt-BR.ts | 1 + apps/desktop/src/i18n/locales/zh-CN.ts | 1 + apps/desktop/src/i18n/locales/zh-TW.ts | 1 + apps/desktop/src/lib/http.ts | 4 +- apps/desktop/src/lib/sidebarTreeItemLayout.ts | 2 +- apps/desktop/src/lib/tauri.ts | 4 +- apps/desktop/src/stores/connectionStore.ts | 146 ++++++++++++++++-- apps/desktop/src/types/database.ts | 6 + crates/dbx-core/src/agent_loop.rs | 1 + crates/dbx-core/src/agent_tools.rs | 15 +- crates/dbx-core/src/connection.rs | 3 +- crates/dbx-core/src/database_export.rs | 13 +- crates/dbx-core/src/db/postgres.rs | 46 +++++- crates/dbx-core/src/db/sqlserver.rs | 11 +- crates/dbx-core/src/schema.rs | 95 ++++++++---- crates/dbx-core/src/transfer.rs | 2 + crates/dbx-web/src/routes/schema.rs | 2 + src-tauri/src/commands/mcp_bridge.rs | 2 +- src-tauri/src/commands/schema.rs | 4 +- 23 files changed, 318 insertions(+), 60 deletions(-) diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index ad4e07bbee..94ddb6ef2c 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -245,6 +245,8 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null { return { icon: Package, colorClass: "text-cyan-500" }; case "group-partitions": return { icon: node.isExpanded ? FolderOpen : FolderClosed, colorClass: "text-green-400" }; + case "load-more": + return { icon: Plus, colorClass: "text-primary" }; default: return { icon: Database, colorClass: "text-muted-foreground" }; } @@ -258,6 +260,7 @@ function isGroupLabel(node: TreeNode): boolean { } function displayLabel(node: TreeNode): string { + if (node.type === "load-more") return t(node.label); if (node.type === "object-browser") return t(node.label, { count: node.objectCount ?? 0 }); if (node.type === "user-admin") return t(node.label); if (node.label === "tree.defaultDatabase") return t(node.label); @@ -390,6 +393,10 @@ async function toggle() { function runRowClickAction() { const node = props.node; + if (node.type === "load-more") { + void loadMoreObjectGroupChildren(); + return; + } if (node.type === "object-browser") { void openObjectBrowser(); return; @@ -404,6 +411,14 @@ function runRowClickAction() { } } +async function loadMoreObjectGroupChildren() { + try { + await connectionStore.loadMoreObjectGroupChildren(props.node); + } catch (e: any) { + toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000); + } +} + function visibleTreeNodes(): TreeNode[] { if (sidebarTreeContext) return sidebarTreeContext.getVisibleNodes(); return flattenTree(connectionStore.treeNodes).map((item) => item.node); @@ -3068,6 +3083,7 @@ function treeItemMenuItems(): ContextMenuItem[] { + { - return get(`/api/schema/tables?${qs({ connection_id: connectionId, database, schema, filter, limit })}`); +export async function listTables(connectionId: string, database: string, schema: string, filter?: string, limit?: number, offset?: number): Promise { + return get(`/api/schema/tables?${qs({ connection_id: connectionId, database, schema, filter, limit, offset })}`); } export async function listObjects(connectionId: string, database: string, schema: string, objectTypes?: SidebarObjectKind[]): Promise { diff --git a/apps/desktop/src/lib/sidebarTreeItemLayout.ts b/apps/desktop/src/lib/sidebarTreeItemLayout.ts index 291babd0d4..94e57c0b9e 100644 --- a/apps/desktop/src/lib/sidebarTreeItemLayout.ts +++ b/apps/desktop/src/lib/sidebarTreeItemLayout.ts @@ -1,6 +1,6 @@ import type { TreeNodeType } from "@/types/database"; -const leafTypes: Set = new Set(["column", "index", "fkey", "trigger", "procedure", "function", "package", "package-body", "object-browser", "redis-db", "mongo-collection", "elasticsearch-index", "user-admin", "saved-sql-file"]); +const leafTypes: Set = new Set(["column", "index", "fkey", "trigger", "procedure", "function", "package", "package-body", "object-browser", "redis-db", "mongo-collection", "elasticsearch-index", "user-admin", "saved-sql-file", "load-more"]); const fullWidthLabelTypes: Set = new Set(["table", "view", "mongo-collection", "elasticsearch-index"]); diff --git a/apps/desktop/src/lib/tauri.ts b/apps/desktop/src/lib/tauri.ts index 65e4db5e4f..f01b2721f4 100644 --- a/apps/desktop/src/lib/tauri.ts +++ b/apps/desktop/src/lib/tauri.ts @@ -449,8 +449,8 @@ export async function deleteSchemaCachePrefix(prefix: string): Promise { return invoke("delete_schema_cache_prefix", { prefix }); } -export async function listTables(connectionId: string, database: string, schema: string, filter?: string, limit?: number): Promise { - return invoke("list_tables", { connectionId, database, schema, filter, limit }); +export async function listTables(connectionId: string, database: string, schema: string, filter?: string, limit?: number, offset?: number): Promise { + return invoke("list_tables", { connectionId, database, schema, filter, limit, offset }); } export async function listObjects(connectionId: string, database: string, schema: string, objectTypes?: SidebarObjectKind[]): Promise { diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index dc52be7cd2..ed9e276990 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -51,6 +51,7 @@ import { useSettingsStore } from "@/stores/settingsStore"; const PINNED_TREE_NODES_STORAGE_KEY = "dbx-pinned-tree-nodes"; const ACTIVE_CONNECTION_STORAGE_KEY = "dbx-active-connection"; +const SIDEBAR_OBJECT_GROUP_PAGE_SIZE = 500; type ImportSource = "dbx" | "navicat" | "dbeaver" | "datagrip"; // Temporary storage for DataGrip import payload (used to read Keychain passwords after import) @@ -395,6 +396,75 @@ export const useConnectionStore = defineStore("connection", () => { return sidebarObjectKindsForDatabase(dbType); } + function objectGroupCacheKey(node: TreeNode): string { + return schemaCacheKey(node.connectionId || "", node.database || "", node.schema || "", node.type, "objects-v2"); + } + + function buildLoadMoreNode(parent: TreeNode, offset: number, pageSize: number): TreeNode { + return { + id: `${parent.id}:__load_more:${offset}`, + label: "tree.loadMore", + type: "load-more", + connectionId: parent.connectionId, + database: parent.database, + schema: parent.schema, + isLoading: false, + loadMore: { + parentId: parent.id, + offset, + pageSize, + }, + }; + } + + function withoutLoadMoreNodes(children: TreeNode[] | undefined): TreeNode[] { + return (children || []).filter((child) => child.type !== "load-more"); + } + + function objectGroupChildrenFromObjects(options: { node: TreeNode; parentNodeId: string; effectiveSchema?: string; objectTypes: DatabaseObjectTreeKind[]; objects: ObjectInfo[] }): TreeNode[] { + const grouped = buildGroupedObjectTreeNodes({ + nodeId: options.parentNodeId, + connectionId: options.node.connectionId || "", + database: options.node.database || "", + schema: options.effectiveSchema, + objects: options.objects.filter((object) => options.objectTypes.includes(normalizedObjectTreeKind(object.object_type))), + }); + const refreshedGroup = grouped.find((group) => group.type === options.node.type); + return refreshedGroup?.children ?? []; + } + + async function loadPagedTableGroupChildren(options: { + node: TreeNode; + parentNodeId: string; + querySchema: string; + effectiveSchema?: string; + objectTypes: DatabaseObjectTreeKind[]; + offset: number; + pageSize: number; + }): Promise<{ children: TreeNode[]; objectCount: number; hasMore: boolean; nextOffset: number }> { + if (!options.node.connectionId || !options.node.database) { + return { children: [], objectCount: 0, hasMore: false, nextOffset: options.offset }; + } + const fetchLimit = options.pageSize + 1; + const tables = await api.listTables(options.node.connectionId, options.node.database, options.querySchema, undefined, fetchLimit, options.offset); + const hasMore = tables.length > options.pageSize; + const pageTables = hasMore ? tables.slice(0, options.pageSize) : tables; + const objects = mergeTableInfosIntoObjects([], pageTables, options.effectiveSchema); + const visibleObjectCount = objects.filter((object) => options.objectTypes.includes(normalizedObjectTreeKind(object.object_type))).length; + return { + children: objectGroupChildrenFromObjects({ + node: options.node, + parentNodeId: options.parentNodeId, + effectiveSchema: options.effectiveSchema, + objectTypes: options.objectTypes, + objects, + }), + objectCount: visibleObjectCount, + hasMore, + nextOffset: options.offset + pageTables.length, + }; + } + function refreshStaleTreeNode(node: TreeNode) { if (staleTreeRefreshIds.has(node.id)) return; staleTreeRefreshIds.add(node.id); @@ -1190,7 +1260,7 @@ export const useConnectionStore = defineStore("connection", () => { const config = getConfig(node.connectionId); const querySchema = connectionObjectTreeQuerySchema(config, node.database, node.schema); const effectiveSchema = connectionObjectTreeNodeSchema(config, node.database, node.schema); - const cacheKey = schemaCacheKey(node.connectionId, node.database, node.schema || "", node.type, "objects-v1"); + const cacheKey = objectGroupCacheKey(node); if (!options?.force) { const cached = await loadPersistedTreeChildren(node, cacheKey); if (cached.hit) { @@ -1200,17 +1270,30 @@ export const useConnectionStore = defineStore("connection", () => { } const wantsOnlyTablesOrViews = objectTypes.every((objectType) => objectType === "TABLE" || objectType === "VIEW"); - const objects = wantsOnlyTablesOrViews ? mergeTableInfosIntoObjects([], await api.listTables(node.connectionId, node.database, querySchema), effectiveSchema) : await api.listObjects(node.connectionId, node.database, querySchema, objectTypes); - const grouped = buildGroupedObjectTreeNodes({ - nodeId: parentNodeId, - connectionId: node.connectionId, - database: node.database, - schema: effectiveSchema, - objects: objects.filter((object) => objectTypes.includes(normalizedObjectTreeKind(object.object_type))), - }); - const refreshedGroup = grouped.find((group) => group.type === node.type); - const children = refreshedGroup?.children ?? []; - node.objectCount = refreshedGroup?.objectCount ?? children.length; + let children: TreeNode[]; + if (wantsOnlyTablesOrViews) { + const page = await loadPagedTableGroupChildren({ + node, + parentNodeId, + querySchema, + effectiveSchema, + objectTypes, + offset: 0, + pageSize: SIDEBAR_OBJECT_GROUP_PAGE_SIZE, + }); + children = page.hasMore ? [...page.children, buildLoadMoreNode(node, page.nextOffset, SIDEBAR_OBJECT_GROUP_PAGE_SIZE)] : page.children; + node.objectCount = page.objectCount; + } else { + const objects = await api.listObjects(node.connectionId, node.database, querySchema, objectTypes); + children = objectGroupChildrenFromObjects({ + node, + parentNodeId, + effectiveSchema, + objectTypes, + objects, + }); + node.objectCount = children.length; + } setChildren(node, children); await savePersistedTreeChildren(cacheKey, children); node.isExpanded = true; @@ -1222,6 +1305,44 @@ export const useConnectionStore = defineStore("connection", () => { } } + async function loadMoreObjectGroupChildren(node: TreeNode) { + if (node.type !== "load-more" || !node.loadMore) return; + const parent = findNode(treeNodes.value, node.loadMore.parentId); + if (!parent?.connectionId || !hasTreeNodeDatabaseContext(parent)) return; + node.isLoading = true; + try { + await ensureConnected(parent.connectionId); + const objectTypes = objectTypesForGroupNode(parent.type); + const parentNodeId = objectGroupRefreshParentId(parent); + if (!objectTypes || !parentNodeId) return; + if (!objectTypes.every((objectType) => objectType === "TABLE" || objectType === "VIEW")) return; + + const config = getConfig(parent.connectionId); + const querySchema = connectionObjectTreeQuerySchema(config, parent.database, parent.schema); + const effectiveSchema = connectionObjectTreeNodeSchema(config, parent.database, parent.schema); + const page = await loadPagedTableGroupChildren({ + node: parent, + parentNodeId, + querySchema, + effectiveSchema, + objectTypes, + offset: node.loadMore.offset, + pageSize: node.loadMore.pageSize, + }); + const currentChildren = withoutLoadMoreNodes(parent.children); + const nextChildren = page.hasMore ? [...currentChildren, ...page.children, buildLoadMoreNode(parent, page.nextOffset, node.loadMore.pageSize)] : [...currentChildren, ...page.children]; + parent.objectCount = (parent.objectCount ?? currentChildren.length) + page.objectCount; + setChildren(parent, nextChildren); + await savePersistedTreeChildren(objectGroupCacheKey(parent), nextChildren); + parent.isExpanded = true; + } catch (e) { + recordMetadataLoadError(parent.connectionId, e); + throw e; + } finally { + node.isLoading = false; + } + } + function normalizedObjectTreeKind(type: string): DatabaseObjectTreeKind { return normalizeSidebarObjectKind(type); } @@ -2575,6 +2696,7 @@ export const useConnectionStore = defineStore("connection", () => { loadSqlServerDatabaseObjects, loadTables, loadObjectGroupChildren, + loadMoreObjectGroupChildren, loadTableGroups, loadColumns, loadIndexes, diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index f71a164195..e96836f068 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -375,6 +375,7 @@ export type TreeNodeType = | "saved-sql-root" | "saved-sql-folder" | "saved-sql-file" + | "load-more" | "column" | "index" | "fkey" @@ -418,6 +419,11 @@ export interface TreeNode { savedSqlId?: string; savedSqlFolderId?: string; meta?: ColumnInfo | IndexInfo | ForeignKeyInfo | TriggerInfo; + loadMore?: { + parentId: string; + offset: number; + pageSize: number; + }; } export interface QueryTab { diff --git a/crates/dbx-core/src/agent_loop.rs b/crates/dbx-core/src/agent_loop.rs index d3154e0bac..74abda29e3 100644 --- a/crates/dbx-core/src/agent_loop.rs +++ b/crates/dbx-core/src/agent_loop.rs @@ -254,6 +254,7 @@ async fn build_schema_prompt(agent_ctx: &AgentLoopContext, system_prompt: &str) "", None, Some(50), // smaller limit for prompt injection + None, ) .await; diff --git a/crates/dbx-core/src/agent_tools.rs b/crates/dbx-core/src/agent_tools.rs index 4614f3143a..edbd730527 100644 --- a/crates/dbx-core/src/agent_tools.rs +++ b/crates/dbx-core/src/agent_tools.rs @@ -169,10 +169,17 @@ async fn execute_list_tables( let schema = tool_call.arguments.get("schema").and_then(|v| v.as_str()).unwrap_or("").to_string(); // Request one extra to detect whether more tables exist beyond the limit. - let tables = - crate::schema::list_tables_core(state, connection_id, database, &schema, None, Some(LIST_TABLES_LIMIT + 1)) - .await - .map_err(|e| format!("Failed to list tables: {e}"))?; + let tables = crate::schema::list_tables_core( + state, + connection_id, + database, + &schema, + None, + Some(LIST_TABLES_LIMIT + 1), + None, + ) + .await + .map_err(|e| format!("Failed to list tables: {e}"))?; let total = tables.len(); let truncated = total > LIST_TABLES_LIMIT; diff --git a/crates/dbx-core/src/connection.rs b/crates/dbx-core/src/connection.rs index 2ebc59ae83..53fa034842 100644 --- a/crates/dbx-core/src/connection.rs +++ b/crates/dbx-core/src/connection.rs @@ -2286,7 +2286,8 @@ mod tests { let schemas = schema::list_schemas_core(&state, "kwdb-live", &database).await.unwrap(); assert!(schemas.iter().any(|schema| schema == test_schema)); - let tables = schema::list_tables_core(&state, "kwdb-live", &database, test_schema, None, None).await.unwrap(); + let tables = + schema::list_tables_core(&state, "kwdb-live", &database, test_schema, None, None, None).await.unwrap(); assert!(tables.iter().any(|table| table.name == "devices" && table.table_type == "BASE TABLE")); let columns = schema::get_columns_core(&state, "kwdb-live", &database, test_schema, "devices").await.unwrap(); let id_column = columns.iter().find(|column| column.name == "id").expect("id column should be listed"); diff --git a/crates/dbx-core/src/database_export.rs b/crates/dbx-core/src/database_export.rs index d81a96a35a..9302c03e03 100644 --- a/crates/dbx-core/src/database_export.rs +++ b/crates/dbx-core/src/database_export.rs @@ -352,9 +352,16 @@ pub async fn export_database_sql_core( let pool_key = state.get_or_create_pool(&request.connection_id, Some(&request.database)).await?; // 3. List tables - let all_tables = - crate::schema::list_tables_core(state, &request.connection_id, &request.database, &request.schema, None, None) - .await?; + let all_tables = crate::schema::list_tables_core( + state, + &request.connection_id, + &request.database, + &request.schema, + None, + None, + None, + ) + .await?; let all_tables = filter_selected_table_infos(all_tables, &request.selected_tables); // 4. Create file diff --git a/crates/dbx-core/src/db/postgres.rs b/crates/dbx-core/src/db/postgres.rs index c72d255ada..2320517fe3 100644 --- a/crates/dbx-core/src/db/postgres.rs +++ b/crates/dbx-core/src/db/postgres.rs @@ -1093,10 +1093,26 @@ pub async fn list_databases(pool: &Pool) -> Result, String> { } pub async fn list_tables(pool: &Pool, schema: &str) -> Result, String> { + list_tables_filtered(pool, schema, None, None, None).await +} + +pub async fn list_tables_filtered( + pool: &Pool, + schema: &str, + filter: Option<&str>, + limit: Option, + offset: Option, +) -> Result, String> { let schema = if schema.is_empty() { "public" } else { schema }; + let filter_pattern = like_contains_pattern(filter.unwrap_or("").trim()); + let limit_param = limit.and_then(|value| i64::try_from(value).ok()); + let offset_param = offset.and_then(|value| i64::try_from(value).ok()).unwrap_or(0); let client = pool.get().await.map_err(|e| e.to_string())?; let stmt = client.prepare_cached(postgres_tables_sql()).await.map_err(|e| e.to_string())?; - let rows = client.query(&stmt, &[&schema]).await.map_err(|e| e.to_string())?; + let rows = client + .query(&stmt, &[&schema, &filter_pattern, &limit_param, &offset_param]) + .await + .map_err(|e| e.to_string())?; Ok(rows .iter() @@ -1124,7 +1140,26 @@ fn postgres_tables_sql() -> &'static str { LEFT JOIN pg_catalog.pg_class pc ON pc.oid = i.inhparent \ LEFT JOIN pg_catalog.pg_namespace pn ON pn.oid = pc.relnamespace \ WHERE n.nspname = $1 AND c.relkind IN ('r','v','m','f','p') \ - ORDER BY c.relname" + AND ($2 = '%%' OR c.relname ILIKE $2 ESCAPE '\\') \ + ORDER BY c.relname \ + LIMIT $3 OFFSET $4" +} + +fn like_contains_pattern(value: &str) -> String { + if value.is_empty() { + return "%%".to_string(); + } + + let mut pattern = String::with_capacity(value.len() + 2); + pattern.push('%'); + for ch in value.chars() { + if ch == '\\' || ch == '%' || ch == '_' { + pattern.push('\\'); + } + pattern.push(ch); + } + pattern.push('%'); + pattern } fn list_objects_sql(include_timestamps: bool) -> &'static str { @@ -2365,4 +2400,11 @@ mod tests { fn url_with_uppercase_timezone_returns_true() { assert!(pg_url_has_timezone_setting("postgres://localhost/db?options=--TimeZone=UTC")); } + + #[test] + fn like_contains_pattern_escapes_wildcards() { + assert_eq!(like_contains_pattern(""), "%%"); + assert_eq!(like_contains_pattern("order_100%"), "%order\\_100\\%%"); + assert_eq!(like_contains_pattern(r"foo\bar"), r"%foo\\bar%"); + } } diff --git a/crates/dbx-core/src/db/sqlserver.rs b/crates/dbx-core/src/db/sqlserver.rs index 268d72ddf1..e48a0d48c4 100644 --- a/crates/dbx-core/src/db/sqlserver.rs +++ b/crates/dbx-core/src/db/sqlserver.rs @@ -633,15 +633,20 @@ pub async fn list_tables( schema: &str, filter: Option<&str>, limit: Option, + offset: Option, ) -> Result, String> { - let top = limit.map(|value| format!("TOP ({}) ", value.min(1000))).unwrap_or_default(); + let fetch_clause = limit + .map(|value| format!(" OFFSET {} ROWS FETCH NEXT {} ROWS ONLY", offset.unwrap_or(0), value.min(1000))) + .unwrap_or_else(|| { + offset.filter(|value| *value > 0).map(|value| format!(" OFFSET {value} ROWS")).unwrap_or_default() + }); let filter_clause = filter .filter(|value| !value.trim().is_empty()) .map(|value| format!(" AND o.name LIKE '%{}%' ESCAPE '\\' ", escape_like_literal(value.trim()))) .unwrap_or_default(); let schema_escaped = schema.replace('\'', "''"); let sql = format!( - "SELECT {top}o.name, CASE WHEN o.type = 'V' THEN 'VIEW' ELSE 'BASE TABLE' END, \ + "SELECT o.name, CASE WHEN o.type = 'V' THEN 'VIEW' ELSE 'BASE TABLE' END, \ ep.value AS TABLE_COMMENT \ FROM sys.objects o \ JOIN sys.schemas s ON s.schema_id = o.schema_id \ @@ -650,7 +655,7 @@ pub async fn list_tables( AND o.type IN ('U','V') \ AND o.is_ms_shipped = 0 \ {filter_clause}\ - ORDER BY o.name" + ORDER BY o.name{fetch_clause}" ); let stream = client.query(&*sql, &[]).await.map_err(|e| e.to_string())?; let rows = stream.into_first_result().await.map_err(|e| e.to_string())?; diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index 92ef5cb39f..3ee5b9c9f7 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -416,9 +416,10 @@ pub async fn list_tables_core( schema: &str, filter: Option<&str>, limit: Option, + offset: Option, ) -> Result, String> { retry_metadata_connection(state, connection_id, Some(database), || { - list_tables_once(state, connection_id, database, schema, filter, limit) + list_tables_once(state, connection_id, database, schema, filter, limit, offset) }) .await } @@ -430,6 +431,7 @@ async fn list_tables_once( schema: &str, filter: Option<&str>, limit: Option, + offset: Option, ) -> Result, String> { let pool_key = state.get_or_create_pool(connection_id, Some(database)).await?; #[cfg(feature = "duckdb-bundled")] @@ -447,7 +449,8 @@ async fn list_tables_once( duckdb_query_tables(&con) }) .await - .map_err(|e| e.to_string())?; + .map_err(|e| e.to_string())? + .map(|tables| filter_table_infos(tables, filter, limit, offset)); } if let Some(PoolKind::ExternalDriver { config, session, .. }) = connections.get(&pool_key) { let config = config.clone(); @@ -458,34 +461,36 @@ async fn list_tables_once( "listTables", serde_json::json!({ "connection": config.as_ref(), "database": database, "schema": schema }), ) - .await; + .await + .map(|tables| filter_table_infos(tables, filter, limit, offset)); } #[cfg(feature = "duckdb-bundled")] if let Some(con) = extract_pool!(&connections, &pool_key, DuckDb) { drop(connections); let con = con.lock().map_err(|e| e.to_string())?; - return duckdb_query_tables_in_database_with_attached(&con, database, schema, &duckdb_attached_names); + return duckdb_query_tables_in_database_with_attached(&con, database, schema, &duckdb_attached_names) + .map(|tables| filter_table_infos(tables, filter, limit, offset)); } if let Some(client) = extract_pool!(&connections, &pool_key, ClickHouse) { drop(connections); - return db::clickhouse_driver::list_tables(&client, clickhouse_metadata_database(database, schema)).await; + return db::clickhouse_driver::list_tables(&client, clickhouse_metadata_database(database, schema)) + .await + .map(|tables| filter_table_infos(tables, filter, limit, offset)); } - try_sqlserver!(connections, &pool_key, list_tables, schema, filter, limit); + try_sqlserver!(connections, &pool_key, list_tables, schema, filter, limit, offset); if let Some(client) = extract_pool!(&connections, &pool_key, Agent) { let fallback_config = db_config.clone(); drop(connections); let mut client = client.lock().await; match client.list_tables::>(database, schema).await { - Ok(tables) if !tables.is_empty() => return Ok(filter_table_infos(tables, filter, limit)), + Ok(tables) if !tables.is_empty() => return Ok(filter_table_infos(tables, filter, limit, offset)), Ok(tables) => { if let Some(config) = fallback_config.as_ref() { match native_postgres_metadata_pool(state, connection_id, database, config).await { Ok(Some(pool)) => { - return db::postgres::list_tables(&pool, schema) - .await - .map(|tables| filter_table_infos(tables, filter, limit)); + return db::postgres::list_tables_filtered(&pool, schema, filter, limit, offset).await; } - Ok(None) => return Ok(filter_table_infos(tables, filter, limit)), + Ok(None) => return Ok(filter_table_infos(tables, filter, limit, offset)), Err(error) => { log::warn!( "[schema][agent:list_tables:fallback-failed] connection_id={} database={} schema={} error={}", @@ -497,16 +502,15 @@ async fn list_tables_once( } } } - return Ok(filter_table_infos(tables, filter, limit)); + return Ok(filter_table_infos(tables, filter, limit, offset)); } Err(agent_error) => { if let Some(config) = fallback_config.as_ref() { if let Some(pool) = native_postgres_metadata_pool(state, connection_id, database, config).await? { - return db::postgres::list_tables(&pool, schema) + return db::postgres::list_tables_filtered(&pool, schema, filter, limit, offset) .await - .map(|tables| filter_table_infos(tables, filter, limit)) .map_err(|fallback_error| { format!( "{agent_error}\n\nNative PostgreSQL metadata fallback failed: {fallback_error}" @@ -525,29 +529,29 @@ async fn list_tables_once( match pool { PoolKind::Mysql(p, _) if db_config.as_ref().is_some_and(is_doris_family_config) => { - db::mysql::list_tables_show(p, database).await.map(|tables| filter_table_infos(tables, filter, limit)) + db::mysql::list_tables_show(p, database) + .await + .map(|tables| filter_table_infos(tables, filter, limit, offset)) } PoolKind::Mysql(p, mode) => { dispatch_mysql!(p, mode, db::mysql::list_tables, db::ob_oracle::list_tables, schema) - .map(|tables| filter_table_infos(tables, filter, limit)) - } - PoolKind::Postgres(p) => { - db::postgres::list_tables(p, schema).await.map(|tables| filter_table_infos(tables, filter, limit)) + .map(|tables| filter_table_infos(tables, filter, limit, offset)) } + PoolKind::Postgres(p) => db::postgres::list_tables_filtered(p, schema, filter, limit, offset).await, PoolKind::Sqlite(p) => { - db::sqlite::list_tables(p, schema).await.map(|tables| filter_table_infos(tables, filter, limit)) - } - PoolKind::Rqlite(client) => { - db::rqlite_driver::list_tables(client, schema).await.map(|tables| filter_table_infos(tables, filter, limit)) + db::sqlite::list_tables(p, schema).await.map(|tables| filter_table_infos(tables, filter, limit, offset)) } + PoolKind::Rqlite(client) => db::rqlite_driver::list_tables(client, schema) + .await + .map(|tables| filter_table_infos(tables, filter, limit, offset)), PoolKind::MongoDb(client) => db::mongo_driver::list_collections(client, database) .await .map(|names| collection_names_to_tables(names, "COLLECTION")) - .map(|tables| filter_table_infos(tables, filter, limit)), + .map(|tables| filter_table_infos(tables, filter, limit, offset)), PoolKind::Elasticsearch(client) => db::elasticsearch_driver::list_indices(client) .await .map(|names| collection_names_to_tables(names, "INDEX")) - .map(|tables| filter_table_infos(tables, filter, limit)), + .map(|tables| filter_table_infos(tables, filter, limit, offset)), _ => Ok(vec![]), } } @@ -565,19 +569,29 @@ fn collection_names_to_tables(names: Vec, table_type: &str) -> Vec, filter: Option<&str>, limit: Option) -> Vec { +fn filter_table_infos( + tables: Vec, + filter: Option<&str>, + limit: Option, + offset: Option, +) -> Vec { let filter = filter.unwrap_or("").to_lowercase(); let limit = limit.unwrap_or(usize::MAX); + let offset = offset.unwrap_or(0); tables .into_iter() .filter(|table| filter.is_empty() || table.name.to_lowercase().contains(&filter)) + .skip(offset) .take(limit) .collect() } #[cfg(test)] mod tests { - use super::{clickhouse_metadata_database, deduplicate_column_infos, is_agent_postgres_metadata_fallback_config}; + use super::{ + clickhouse_metadata_database, deduplicate_column_infos, filter_table_infos, + is_agent_postgres_metadata_fallback_config, + }; #[cfg(feature = "duckdb-bundled")] use super::{duckdb_attach_database, duckdb_list_databases, duckdb_query_tables_in_database}; use crate::models::connection::{ConnectionConfig, DatabaseType}; @@ -641,6 +655,31 @@ mod tests { } } + fn test_table_info(name: &str) -> super::db::TableInfo { + super::db::TableInfo { + name: name.to_string(), + table_type: "BASE TABLE".to_string(), + comment: None, + parent_schema: None, + parent_name: None, + } + } + + #[test] + fn filter_table_infos_applies_filter_offset_and_limit() { + let tables = vec![ + test_table_info("alpha"), + test_table_info("audit_log"), + test_table_info("audit_record"), + test_table_info("users"), + ]; + + let filtered = filter_table_infos(tables, Some("audit"), Some(1), Some(1)); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].name, "audit_record"); + } + #[cfg(feature = "duckdb-bundled")] #[test] fn duckdb_list_databases_includes_attached_database() { @@ -846,7 +885,7 @@ async fn list_objects_once( PoolKind::Postgres(p) => db::postgres::list_objects(p, schema).await, _ => { drop(connections); - Ok(list_tables_core(state, connection_id, database, schema, None, None) + Ok(list_tables_core(state, connection_id, database, schema, None, None, None) .await? .into_iter() .map(|table| db::ObjectInfo { diff --git a/crates/dbx-core/src/transfer.rs b/crates/dbx-core/src/transfer.rs index 24af584aec..2ee02553fe 100644 --- a/crates/dbx-core/src/transfer.rs +++ b/crates/dbx-core/src/transfer.rs @@ -2726,6 +2726,7 @@ where &request.source_schema, Some(table), Some(1), + None, ) .await .unwrap_or_default() @@ -2740,6 +2741,7 @@ where &request.target_schema, Some(table), Some(1), + None, ) .await .map(|tables| !tables.is_empty()) diff --git a/crates/dbx-web/src/routes/schema.rs b/crates/dbx-web/src/routes/schema.rs index 2a54c93c7f..3b03337a72 100644 --- a/crates/dbx-web/src/routes/schema.rs +++ b/crates/dbx-web/src/routes/schema.rs @@ -15,6 +15,7 @@ pub struct SchemaQuery { pub table: Option, pub filter: Option, pub limit: Option, + pub offset: Option, pub object_type: Option, } @@ -48,6 +49,7 @@ pub async fn list_tables( schema, q.filter.as_deref(), q.limit, + q.offset, ) .await .map_err(AppError)?; diff --git a/src-tauri/src/commands/mcp_bridge.rs b/src-tauri/src/commands/mcp_bridge.rs index e559a3d858..b6ad60a937 100644 --- a/src-tauri/src/commands/mcp_bridge.rs +++ b/src-tauri/src/commands/mcp_bridge.rs @@ -327,7 +327,7 @@ async fn handle_list_tables_data(state: &Arc, body: &str, stream: &mut respond_error(stream, "403 Forbidden", &e).await; return; } - match dbx_core::schema::list_tables_core(state, &config.id, &database, &schema, None, None).await { + match dbx_core::schema::list_tables_core(state, &config.id, &database, &schema, None, None, None).await { Ok(tables) => respond_json(stream, &tables).await, Err(e) => respond_error(stream, "500 Internal Server Error", &e).await, } diff --git a/src-tauri/src/commands/schema.rs b/src-tauri/src/commands/schema.rs index 54e1df9966..fe10c4ea00 100644 --- a/src-tauri/src/commands/schema.rs +++ b/src-tauri/src/commands/schema.rs @@ -29,8 +29,10 @@ pub async fn list_tables( schema: String, filter: Option, limit: Option, + offset: Option, ) -> Result, String> { - dbx_core::schema::list_tables_core(&state, &connection_id, &database, &schema, filter.as_deref(), limit).await + dbx_core::schema::list_tables_core(&state, &connection_id, &database, &schema, filter.as_deref(), limit, offset) + .await } #[tauri::command]