From 5c5256036854798b9bf7970242202acee13ec987 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Sat, 9 May 2026 17:36:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(schema):=20=E5=A2=9E=E5=8A=A0=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=BA=93=E5=AF=B9=E8=B1=A1=E5=88=97=E8=A1=A8=E7=BC=93?= =?UTF-8?q?=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/dbx-core/src/storage.rs | 48 +++++++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/schema_cache.rs | 26 +++++ src-tauri/src/lib.rs | 3 + src-web/src/main.rs | 5 + src-web/src/routes/mod.rs | 1 + src-web/src/routes/schema_cache.rs | 49 +++++++++ src/components/sidebar/TreeItem.vue | 16 +-- src/lib/api.ts | 3 + src/lib/http.ts | 12 +++ src/lib/tauri.ts | 12 +++ src/stores/connectionStore.ts | 135 +++++++++++++++++-------- 12 files changed, 261 insertions(+), 50 deletions(-) create mode 100644 src-tauri/src/commands/schema_cache.rs create mode 100644 src-web/src/routes/schema_cache.rs diff --git a/crates/dbx-core/src/storage.rs b/crates/dbx-core/src/storage.rs index 125a065609..e8058a5de9 100644 --- a/crates/dbx-core/src/storage.rs +++ b/crates/dbx-core/src/storage.rs @@ -53,6 +53,11 @@ const SCHEMA_STATEMENTS: &[&str] = &[ id INTEGER PRIMARY KEY CHECK (id = 1), settings_json TEXT NOT NULL )", + "CREATE TABLE IF NOT EXISTS schema_cache ( + cache_key TEXT PRIMARY KEY, + payload_json TEXT NOT NULL, + updated_at TEXT NOT NULL + )", ]; // --------------------------------------------------------------------------- @@ -444,6 +449,49 @@ impl Storage { } } +// --------------------------------------------------------------------------- +// Schema cache +// --------------------------------------------------------------------------- + +impl Storage { + pub async fn save_schema_cache(&self, cache_key: &str, payload: &serde_json::Value) -> Result<(), String> { + let json = serde_json::to_string(payload).map_err(|e| e.to_string())?; + sqlx::query( + "INSERT OR REPLACE INTO schema_cache (cache_key, payload_json, updated_at) \ + VALUES (?, ?, datetime('now'))", + ) + .bind(cache_key) + .bind(&json) + .execute(&self.db) + .await + .map_err(|e| e.to_string())?; + Ok(()) + } + + pub async fn load_schema_cache(&self, cache_key: &str) -> Result, String> { + let row: Option<(String,)> = sqlx::query_as("SELECT payload_json FROM schema_cache WHERE cache_key = ?") + .bind(cache_key) + .fetch_optional(&self.db) + .await + .map_err(|e| e.to_string())?; + match row { + Some((json,)) => serde_json::from_str(&json).map(Some).map_err(|e| e.to_string()), + None => Ok(None), + } + } + + pub async fn delete_schema_cache_prefix(&self, prefix: &str) -> Result<(), String> { + sqlx::query("DELETE FROM schema_cache WHERE cache_key = ? OR substr(cache_key, 1, ?) = ?") + .bind(prefix) + .bind(prefix.len() as i64) + .bind(prefix) + .execute(&self.db) + .await + .map_err(|e| e.to_string())?; + Ok(()) + } +} + // --------------------------------------------------------------------------- // JSON migration // --------------------------------------------------------------------------- diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index d4cbef3d59..25acd55c5a 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -9,6 +9,7 @@ pub mod query; pub mod query_cancel; pub mod redis_cmd; pub mod schema; +pub mod schema_cache; pub mod sql_file; pub mod table_import; pub mod transfer; diff --git a/src-tauri/src/commands/schema_cache.rs b/src-tauri/src/commands/schema_cache.rs new file mode 100644 index 0000000000..c92ea82ef2 --- /dev/null +++ b/src-tauri/src/commands/schema_cache.rs @@ -0,0 +1,26 @@ +use std::sync::Arc; +use tauri::State; + +use dbx_core::connection::AppState; + +#[tauri::command] +pub async fn save_schema_cache( + state: State<'_, Arc>, + cache_key: String, + payload: serde_json::Value, +) -> Result<(), String> { + state.storage.save_schema_cache(&cache_key, &payload).await +} + +#[tauri::command] +pub async fn load_schema_cache( + state: State<'_, Arc>, + cache_key: String, +) -> Result, String> { + state.storage.load_schema_cache(&cache_key).await +} + +#[tauri::command] +pub async fn delete_schema_cache_prefix(state: State<'_, Arc>, prefix: String) -> Result<(), String> { + state.storage.delete_schema_cache_prefix(&prefix).await +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 915dd85d05..03437166c0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -81,6 +81,9 @@ pub fn run() { commands::schema::list_foreign_keys, commands::schema::list_triggers, commands::schema::get_table_ddl, + commands::schema_cache::save_schema_cache, + commands::schema_cache::load_schema_cache, + commands::schema_cache::delete_schema_cache_prefix, commands::query::execute_query, commands::query::execute_multi, commands::query::cancel_query, diff --git a/src-web/src/main.rs b/src-web/src/main.rs index 94ccbcb786..1c35fa10f5 100644 --- a/src-web/src/main.rs +++ b/src-web/src/main.rs @@ -89,6 +89,11 @@ async fn main() { .route("/schema/foreign-keys", get(routes::schema::list_foreign_keys)) .route("/schema/triggers", get(routes::schema::list_triggers)) .route("/schema/ddl", get(routes::schema::get_ddl)) + .route( + "/schema/cache", + post(routes::schema_cache::save_schema_cache).get(routes::schema_cache::load_schema_cache), + ) + .route("/schema/cache-prefix", delete(routes::schema_cache::delete_schema_cache_prefix)) // Query .route("/query/execute", post(routes::query::execute_query)) .route("/query/execute-multi", post(routes::query::execute_multi)) diff --git a/src-web/src/routes/mod.rs b/src-web/src/routes/mod.rs index 835bef78e4..4589bb3903 100644 --- a/src-web/src/routes/mod.rs +++ b/src-web/src/routes/mod.rs @@ -6,6 +6,7 @@ pub mod mongo; pub mod query; pub mod redis; pub mod schema; +pub mod schema_cache; pub mod sql_file; pub mod table_import; pub mod transfer; diff --git a/src-web/src/routes/schema_cache.rs b/src-web/src/routes/schema_cache.rs new file mode 100644 index 0000000000..829df00ffe --- /dev/null +++ b/src-web/src/routes/schema_cache.rs @@ -0,0 +1,49 @@ +use std::sync::Arc; + +use axum::extract::{Query, State}; +use axum::Json; +use serde::Deserialize; + +use crate::error::AppError; +use crate::state::WebState; + +#[derive(Deserialize)] +pub struct SchemaCacheKeyQuery { + pub cache_key: String, +} + +#[derive(Deserialize)] +pub struct SchemaCachePrefixQuery { + pub prefix: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SaveSchemaCacheRequest { + pub cache_key: String, + pub payload: serde_json::Value, +} + +pub async fn save_schema_cache( + State(state): State>, + Json(body): Json, +) -> Result, AppError> { + state.app.storage.save_schema_cache(&body.cache_key, &body.payload).await.map_err(AppError)?; + Ok(Json(())) +} + +pub async fn load_schema_cache( + State(state): State>, + Query(query): Query, +) -> Result, AppError> { + let payload = state.app.storage.load_schema_cache(&query.cache_key).await.map_err(AppError)?; + Ok(Json(payload.unwrap_or(serde_json::json!(null)))) +} + +pub async fn delete_schema_cache_prefix( + State(state): State>, + Query(query): Query, +) -> Result, AppError> { + state.app.storage.delete_schema_cache_prefix(&query.prefix).await.map_err(AppError)?; + Ok(Json(())) +} diff --git a/src/components/sidebar/TreeItem.vue b/src/components/sidebar/TreeItem.vue index 6ed41ee3d4..5002d464c7 100644 --- a/src/components/sidebar/TreeItem.vue +++ b/src/components/sidebar/TreeItem.vue @@ -451,9 +451,9 @@ async function confirmDropTable() { await api.executeQuery(node.connectionId, node.database, buildDropTableSql(), node.schema); toast(t("contextMenu.dropTableSuccess", { name: node.label }), 3000); if (node.schema) { - await connectionStore.loadTables(node.connectionId, node.database, node.schema); + await connectionStore.loadTables(node.connectionId, node.database, node.schema, { force: true }); } else { - await connectionStore.loadTables(node.connectionId, node.database); + await connectionStore.loadTables(node.connectionId, node.database, undefined, { force: true }); } } catch (e: any) { toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000); @@ -518,7 +518,7 @@ async function confirmCreateDatabase() { const sql = `CREATE DATABASE ${quoteIdent(name)};`; await api.executeQuery(node.connectionId, "", sql); toast(t("contextMenu.createDatabaseSuccess", { name }), 3000); - await connectionStore.loadDatabases(node.connectionId); + await connectionStore.loadDatabases(node.connectionId, { force: true }); } catch (e: any) { toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000); } @@ -535,7 +535,7 @@ async function confirmDropDatabase() { await connectionStore.ensureConnected(node.connectionId); await api.executeQuery(node.connectionId, "", buildDropDatabaseSql()); toast(t("contextMenu.dropDatabaseSuccess", { name: node.label }), 3000); - await connectionStore.loadDatabases(node.connectionId); + await connectionStore.loadDatabases(node.connectionId, { force: true }); } catch (e: any) { toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000); } @@ -556,7 +556,7 @@ async function confirmCreateSchema() { const sql = `CREATE SCHEMA ${quoteIdent(name)};`; await api.executeQuery(node.connectionId, node.database, sql); toast(t("contextMenu.createSchemaSuccess", { name }), 3000); - await connectionStore.loadSchemas(node.connectionId, node.database); + await connectionStore.loadSchemas(node.connectionId, node.database, { force: true }); } catch (e: any) { toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000); } @@ -573,7 +573,7 @@ async function confirmDropSchema() { await connectionStore.ensureConnected(node.connectionId); await api.executeQuery(node.connectionId, node.database, buildDropSchemaSql()); toast(t("contextMenu.dropSchemaSuccess", { name: node.label }), 3000); - await connectionStore.loadSchemas(node.connectionId, node.database); + await connectionStore.loadSchemas(node.connectionId, node.database, { force: true }); } catch (e: any) { toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000); } @@ -609,9 +609,9 @@ async function confirmDuplicateStructure() { await api.executeQuery(node.connectionId, node.database, sql, node.schema); toast(t("contextMenu.duplicateStructureSuccess", { name: newName }), 3000); if (node.schema) { - await connectionStore.loadTables(node.connectionId, node.database, node.schema); + await connectionStore.loadTables(node.connectionId, node.database, node.schema, { force: true }); } else { - await connectionStore.loadTables(node.connectionId, node.database); + await connectionStore.loadTables(node.connectionId, node.database, undefined, { force: true }); } } catch (e: any) { toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000); diff --git a/src/lib/api.ts b/src/lib/api.ts index c58894f68b..7a54bdcafe 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -39,6 +39,9 @@ export const loadConnections = forward("loadConnections"); // Schema export const listDatabases = forward("listDatabases"); +export const saveSchemaCache = forward("saveSchemaCache"); +export const loadSchemaCache = forward("loadSchemaCache"); +export const deleteSchemaCachePrefix = forward("deleteSchemaCachePrefix"); export const listSchemas = forward("listSchemas"); export const listTables = forward("listTables"); export const getColumns = forward("getColumns"); diff --git a/src/lib/http.ts b/src/lib/http.ts index 2baee753c2..72811955ad 100644 --- a/src/lib/http.ts +++ b/src/lib/http.ts @@ -96,6 +96,18 @@ export async function listDatabases(connectionId: string): Promise { + return post("/api/schema/cache", { cacheKey, payload }); +} + +export async function loadSchemaCache(cacheKey: string): Promise { + return get(`/api/schema/cache?${qs({ cache_key: cacheKey })}`); +} + +export async function deleteSchemaCachePrefix(prefix: string): Promise { + return del(`/api/schema/cache-prefix?${qs({ prefix })}`); +} + export async function listSchemas(connectionId: string, database: string): Promise { return get(`/api/schema/schemas?${qs({ connection_id: connectionId, database })}`); } diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 0eb6a29310..a04b72a8f0 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -117,6 +117,18 @@ export async function listDatabases(connectionId: string): Promise { + return invoke("save_schema_cache", { cacheKey, payload }); +} + +export async function loadSchemaCache(cacheKey: string): Promise { + return invoke("load_schema_cache", { cacheKey }); +} + +export async function deleteSchemaCachePrefix(prefix: string): Promise { + return invoke("delete_schema_cache_prefix", { prefix }); +} + export async function listTables(connectionId: string, database: string, schema: string): Promise { return invoke("list_tables", { connectionId, database, schema }); } diff --git a/src/stores/connectionStore.ts b/src/stores/connectionStore.ts index 9db311b344..59aebcc3d0 100644 --- a/src/stores/connectionStore.ts +++ b/src/stores/connectionStore.ts @@ -25,6 +25,10 @@ import { buildDatabaseTreeNodes } from "@/lib/databaseTree"; const PINNED_TREE_NODES_STORAGE_KEY = "dbx-pinned-tree-nodes"; +interface LoadTreeOptions { + force?: boolean; +} + export const useConnectionStore = defineStore("connection", () => { const connections = ref([]); const isDesktop = isTauriRuntime(); @@ -38,6 +42,7 @@ export const useConnectionStore = defineStore("connection", () => { const treeNodes = ref([]); const pinnedTreeNodeIds = ref>(loadPinnedTreeNodeIds()); const connectedIds = ref>(new Set()); + const loadedTreeNodeChildrenIds = ref>(new Set()); const connectionErrors = ref>({}); const editingConnectionId = ref(null); const newConnectionGroupId = ref(null); @@ -188,6 +193,38 @@ export const useConnectionStore = defineStore("connection", () => { function setChildren(parent: TreeNode, children: TreeNode[]) { parent.children = orderPinnedFirst(children.map(pinTreeNode), (node) => !!node.pinned); + loadedTreeNodeChildrenIds.value.add(parent.id); + } + + function schemaCacheKey(...parts: string[]): string { + return parts.map((part) => encodeURIComponent(part)).join(":"); + } + + async function loadPersistedTreeChildren(node: TreeNode, cacheKey: string): Promise { + const children = await api.loadSchemaCache(cacheKey).catch(() => null); + if (!children) return false; + setChildren(node, children); + node.isExpanded = true; + return true; + } + + async function savePersistedTreeChildren(cacheKey: string, children: TreeNode[]) { + await api.saveSchemaCache(cacheKey, children).catch(() => undefined); + } + + function useCachedChildren(node: TreeNode, options?: LoadTreeOptions): boolean { + if (options?.force || !loadedTreeNodeChildrenIds.value.has(node.id)) return false; + node.isExpanded = true; + return true; + } + + function clearLoadedChildrenCache(prefix: string) { + for (const id of loadedTreeNodeChildrenIds.value) { + if (id === prefix || id.startsWith(`${prefix}:`)) { + loadedTreeNodeChildrenIds.value.delete(id); + } + } + api.deleteSchemaCachePrefix(`${schemaCacheKey(prefix)}:`).catch(() => undefined); } function findParentNode(nodes: TreeNode[], id: string, parent: TreeNode | null = null): TreeNode | null { @@ -263,6 +300,7 @@ export const useConnectionStore = defineStore("connection", () => { activeConnectionId.value = null; } invalidateCompletionCache(id); + clearLoadedChildrenCache(id); } async function updateConnection(config: ConnectionConfig) { @@ -276,6 +314,7 @@ export const useConnectionStore = defineStore("connection", () => { rebuildTreeNodes(); connectedIds.value.delete(config.id); invalidateCompletionCache(config.id); + clearLoadedChildrenCache(config.id); } async function setDefaultDatabase(connectionId: string, database: string) { @@ -343,6 +382,7 @@ export const useConnectionStore = defineStore("connection", () => { node.isExpanded = false; node.children = []; } + clearLoadedChildrenCache(connectionId); if (activeConnectionId.value === connectionId) { activeConnectionId.value = null; } @@ -368,15 +408,20 @@ export const useConnectionStore = defineStore("connection", () => { } } - async function loadDatabases(connectionId: string) { + async function loadDatabases(connectionId: string, options?: LoadTreeOptions) { const node = findNode(treeNodes.value, connectionId); if (!node) return; + if (useCachedChildren(node, options)) return; + const cacheKey = schemaCacheKey(connectionId, "databases"); + if (!options?.force && (await loadPersistedTreeChildren(node, cacheKey))) return; node.isLoading = true; try { await ensureConnected(connectionId); const databases = await api.listDatabases(connectionId); - setChildren(node, buildDatabaseTreeNodes(connectionId, databases)); + const children = buildDatabaseTreeNodes(connectionId, databases); + setChildren(node, children); + await savePersistedTreeChildren(cacheKey, children); node.isExpanded = true; } finally { node.isLoading = false; @@ -460,57 +505,63 @@ export const useConnectionStore = defineStore("connection", () => { } } - async function loadSchemas(connectionId: string, database: string) { + async function loadSchemas(connectionId: string, database: string, options?: LoadTreeOptions) { const nodeId = `${connectionId}:${database}`; const node = findNode(treeNodes.value, nodeId); if (!node) return; + if (useCachedChildren(node, options)) return; + const cacheKey = schemaCacheKey(connectionId, database, "schemas"); + if (!options?.force && (await loadPersistedTreeChildren(node, cacheKey))) return; node.isLoading = true; try { + await ensureConnected(connectionId); const schemas = await api.listSchemas(connectionId, database); - setChildren( - node, - schemas.map((s) => ({ - id: `${connectionId}:${database}:${s}`, - label: s, - type: "schema" as const, - connectionId, - database, - schema: s, - isExpanded: false, - children: [], - })), - ); + const children = schemas.map((s) => ({ + id: `${connectionId}:${database}:${s}`, + label: s, + type: "schema" as const, + connectionId, + database, + schema: s, + isExpanded: false, + children: [], + })); + setChildren(node, children); + await savePersistedTreeChildren(cacheKey, children); node.isExpanded = true; } finally { node.isLoading = false; } } - async function loadTables(connectionId: string, database: string, schema?: string) { + async function loadTables(connectionId: string, database: string, schema?: string, options?: LoadTreeOptions) { const nodeId = schema ? `${connectionId}:${database}:${schema}` : `${connectionId}:${database}`; const node = findNode(treeNodes.value, nodeId); if (!node) return; + if (useCachedChildren(node, options)) return; + const cacheKey = schemaCacheKey(connectionId, database, schema || "", "tables"); + if (!options?.force && (await loadPersistedTreeChildren(node, cacheKey))) return; node.isLoading = true; try { + await ensureConnected(connectionId); const querySchema = schema || database; const tables = await api.listTables(connectionId, database, querySchema); const config = getConfig(connectionId); const effectiveSchema = schema || (config?.db_type && isSchemaAware(config.db_type) ? database : undefined); - setChildren( - node, - tables.map((t) => ({ - id: `${nodeId}:${t.name}`, - label: t.name, - type: (t.table_type === "VIEW" ? "view" : "table") as "view" | "table", - connectionId, - database, - schema: effectiveSchema, - isExpanded: false, - children: [], - })), - ); + const children = tables.map((t) => ({ + id: `${nodeId}:${t.name}`, + label: t.name, + type: (t.table_type === "VIEW" ? "view" : "table") as "view" | "table", + connectionId, + database, + schema: effectiveSchema, + isExpanded: false, + children: [], + })); + setChildren(node, children); + await savePersistedTreeChildren(cacheKey, children); node.isExpanded = true; } finally { node.isLoading = false; @@ -696,7 +747,7 @@ export const useConnectionStore = defineStore("connection", () => { return ids; } - async function loadTreeNodeChildren(node: TreeNode) { + async function loadTreeNodeChildren(node: TreeNode, options?: LoadTreeOptions) { if (node.type === "connection" && node.connectionId) { const config = getConfig(node.connectionId); if (config?.db_type === "redis") { @@ -704,19 +755,19 @@ export const useConnectionStore = defineStore("connection", () => { } else if (config?.db_type === "mongodb" || config?.db_type === "elasticsearch") { await loadMongoDatabases(node.connectionId); } else { - await loadDatabases(node.connectionId); + await loadDatabases(node.connectionId, options); } } else if (node.type === "mongo-db" && node.connectionId && node.database) { await loadMongoCollections(node.connectionId, node.database); } else if (node.type === "database" && node.connectionId && node.database) { const config = getConfig(node.connectionId); if (config?.db_type && TREE_SCHEMA_TYPES.has(config.db_type)) { - await loadSchemas(node.connectionId, node.database); + await loadSchemas(node.connectionId, node.database, options); } else { - await loadTables(node.connectionId, node.database); + await loadTables(node.connectionId, node.database, undefined, options); } } else if (node.type === "schema" && node.connectionId && node.database && node.schema) { - await loadTables(node.connectionId, node.database, node.schema); + await loadTables(node.connectionId, node.database, node.schema, options); } else if ((node.type === "table" || node.type === "view") && node.connectionId && node.database) { await loadTableGroups(node.connectionId, node.database, node.label, node.schema); } else if (node.type === "group-columns" && node.connectionId && node.database && node.tableName) { @@ -730,12 +781,12 @@ export const useConnectionStore = defineStore("connection", () => { } } - async function restoreExpandedChildren(node: TreeNode, expandedIds: Set) { + async function restoreExpandedChildren(node: TreeNode, expandedIds: Set, options?: LoadTreeOptions) { if (!node.children) return; for (const child of node.children) { if (!expandedIds.has(child.id)) continue; - await loadTreeNodeChildren(child); - await restoreExpandedChildren(child, expandedIds); + await loadTreeNodeChildren(child, options); + await restoreExpandedChildren(child, expandedIds, options); } } @@ -745,8 +796,8 @@ export const useConnectionStore = defineStore("connection", () => { if (node.type !== "connection-group") { node.children = []; } - await loadTreeNodeChildren(node); - await restoreExpandedChildren(node, expandedIds); + await loadTreeNodeChildren(node, { force: true }); + await restoreExpandedChildren(node, expandedIds, { force: true }); } function isSchemaAwareDatabase(connectionId: string): boolean { @@ -874,8 +925,8 @@ export const useConnectionStore = defineStore("connection", () => { } if (!expandedIds.has(node.id)) continue; node.children = []; - await loadTreeNodeChildren(node); - await restoreExpandedChildren(node, expandedIds); + await loadTreeNodeChildren(node, { force: true }); + await restoreExpandedChildren(node, expandedIds, { force: true }); } }; await refreshExpandedNodes(treeNodes.value);