From eca9601ab0cbf19167007807dbf41e20e09dcae1 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Mon, 15 Jun 2026 12:20:57 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(saved-query):=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=B7=B2=E5=AD=98=E6=9F=A5=E8=AF=A2=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E6=8C=81=E4=B9=85=E5=8C=96=E4=B8=8E=E8=BF=9E=E6=8E=A5=E9=87=8D?= =?UTF-8?q?=E7=BB=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端新增 saved_queries.json 仓库,保存、导入、删除和重绑统一走 Wails 方法 - 启动时导入旧 lite-db-storage 中的 savedQueries 和连接快照,成功后清理旧字段 - 新增连接指纹匹配,唯一强匹配自动重绑,歧义场景保留为未匹配 - 侧边栏新增未匹配已存查询分组,并支持手动绑定到目标连接 - 前端保存、重命名、删除查询改为后端持久化,并补充浏览器 mock - 补充后端与前端迁移回归测试 --- frontend/src/App.tsx | 29 ++ .../QueryEditor.external-sql-save.test.tsx | 1 + frontend/src/components/QueryEditor.tsx | 23 +- frontend/src/components/Sidebar.tsx | 103 ++++- frontend/src/components/sidebarV2Utils.ts | 1 + frontend/src/main.tsx | 51 ++- frontend/src/store.ts | 81 ++-- frontend/src/types.ts | 4 + .../src/utils/savedQueryPersistence.test.ts | 134 ++++++ frontend/src/utils/savedQueryPersistence.ts | 283 ++++++++++++ .../src/utils/secureConfigBootstrap.test.ts | 64 +++ frontend/src/utils/secureConfigBootstrap.ts | 6 +- frontend/wailsjs/go/app/App.d.ts | 12 + frontend/wailsjs/go/app/App.js | 24 + frontend/wailsjs/go/models.ts | 63 ++- internal/app/methods_saved_queries.go | 64 +++ internal/app/saved_queries.go | 287 ++++++++++++ internal/app/saved_queries_test.go | 416 ++++++++++++++++++ internal/app/saved_query_fingerprint.go | 271 ++++++++++++ internal/connection/saved_query.go | 19 + 20 files changed, 1870 insertions(+), 66 deletions(-) create mode 100644 frontend/src/utils/savedQueryPersistence.test.ts create mode 100644 frontend/src/utils/savedQueryPersistence.ts create mode 100644 internal/app/methods_saved_queries.go create mode 100644 internal/app/saved_queries.go create mode 100644 internal/app/saved_queries_test.go create mode 100644 internal/app/saved_query_fingerprint.go create mode 100644 internal/connection/saved_query.go diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5d79a998..d45a75db 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -58,6 +58,7 @@ import { mergeSecurityUpdateStatusWithLegacySource, startSecurityUpdateFromBootstrap, } from './utils/secureConfigBootstrap'; +import { bootstrapSavedQueries } from './utils/savedQueryPersistence'; import { LEGACY_PERSIST_KEY, hasLegacyMigratableSensitiveItems, @@ -225,6 +226,7 @@ function App() { const setGlobalProxy = useStore(state => state.setGlobalProxy); const replaceConnections = useStore(state => state.replaceConnections); const replaceGlobalProxy = useStore(state => state.replaceGlobalProxy); + const replaceSavedQueries = useStore(state => state.replaceSavedQueries); const shortcutOptions = useStore(state => state.shortcutOptions); const updateShortcut = useStore(state => state.updateShortcut); const resetShortcutOptions = useStore(state => state.resetShortcutOptions); @@ -480,6 +482,33 @@ function App() { }; }, [isStoreHydrated]); + useEffect(() => { + if (!isStoreHydrated) { + return; + } + + let cancelled = false; + const loadSavedQueries = async () => { + try { + await bootstrapSavedQueries({ + backend: (window as any).go?.app?.App, + replaceSavedQueries: (queries) => { + if (!cancelled) { + replaceSavedQueries(queries); + } + }, + }); + } catch (err) { + console.warn('Failed to bootstrap saved queries', err); + } + }; + + void loadSavedQueries(); + return () => { + cancelled = true; + }; + }, [isStoreHydrated, replaceSavedQueries]); + const normalizeSecurityUpdateStatus = useCallback((status?: Partial | null): SecurityUpdateStatus => { const fallback = createEmptySecurityUpdateStatus(); return { diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index d8ca8403..c725e116 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -458,6 +458,7 @@ describe('QueryEditor external SQL save', () => { storeState.addTab.mockReset(); storeState.setActiveContext.mockReset(); storeState.saveQuery.mockReset(); + storeState.saveQuery.mockImplementation(async (query: SavedQuery) => query); storeState.savedQueries = []; storeState.activeTabId = 'tab-1'; storeState.queryOptions = { diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index e28db1ee..0b8023a3 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -5192,7 +5192,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc return rawTitle; }; - const persistQuery = (payload: { id: string; name: string; createdAt?: number }) => { + const persistQuery = async (payload: { id: string; name: string; createdAt?: number }) => { const sql = getCurrentQuery(); const saved = { id: payload.id, @@ -5202,16 +5202,16 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc dbName: currentDb || tab.dbName || '', createdAt: payload.createdAt ?? Date.now(), }; - saveQuery(saved); + const persisted = await saveQuery(saved); addTab({ ...tab, - title: payload.name, + title: persisted.name, query: sql, connectionId: currentConnectionId, dbName: currentDb || tab.dbName || '', - savedQueryId: payload.id, + savedQueryId: persisted.id, }); - return saved; + return persisted; }; const openSaveQueryModal = (mode: 'save' | 'rename') => { @@ -5254,8 +5254,12 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc return; } const saveName = existed?.name || resolveDefaultQueryName(); - persistQuery({ id: saveId, name: saveName, createdAt: existed?.createdAt }); - message.success('查询已保存!'); + try { + await persistQuery({ id: saveId, name: saveName, createdAt: existed?.createdAt }); + message.success('查询已保存!'); + } catch (error) { + message.error('保存查询失败: ' + (error instanceof Error ? error.message : String(error))); + } }; const handleRenameQuery = () => { @@ -5384,7 +5388,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const existed = currentSavedQuery || null; const fallbackSavedId = String(tab.savedQueryId || '').trim(); const nextSavedId = existed?.id || fallbackSavedId || `saved-${Date.now()}`; - persistQuery({ + await persistQuery({ id: nextSavedId, name: String(values.name || '').trim() || '未命名查询', createdAt: existed?.createdAt, @@ -5392,6 +5396,9 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc message.success(saveModalMode === 'rename' ? '查询已重命名!' : '查询已保存!'); setIsSaveModalOpen(false); } catch (e) { + if (e instanceof Error) { + message.error('保存查询失败: ' + e.message); + } } }; diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 5688f2ed..336adc9b 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -226,7 +226,7 @@ interface TreeNode { children?: TreeNode[]; icon?: React.ReactNode; dataRef?: any; - type?: 'connection' | 'database' | 'table' | 'view' | 'materialized-view' | 'db-trigger' | 'db-event' | 'routine' | 'object-group' | 'v2-table-section' | 'queries-folder' | 'saved-query' | 'external-sql-root' | 'external-sql-directory' | 'external-sql-folder' | 'external-sql-file' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db' | 'tag' | 'jvm-mode' | 'jvm-resource' | 'jvm-diagnostic' | 'jvm-monitoring'; + type?: 'connection' | 'database' | 'table' | 'view' | 'materialized-view' | 'db-trigger' | 'db-event' | 'routine' | 'object-group' | 'v2-table-section' | 'queries-folder' | 'saved-query' | 'unmatched-saved-queries' | 'external-sql-root' | 'external-sql-directory' | 'external-sql-folder' | 'external-sql-file' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db' | 'tag' | 'jvm-mode' | 'jvm-resource' | 'jvm-diagnostic' | 'jvm-monitoring'; } type BatchTableExportMode = 'schema' | 'backup' | 'dataOnly'; @@ -423,6 +423,11 @@ const Sidebar: React.FC<{ const contextMenuPortalRef = useRef(null); const [v2TableContextMenuStats, setV2TableContextMenuStats] = useState>({}); const connectionIds = useMemo(() => connections.map((conn) => conn.id), [connections]); + const connectionIdSet = useMemo(() => new Set(connectionIds), [connectionIds]); + const unmatchedSavedQueries = useMemo( + () => savedQueries.filter((query) => query.bindingStatus === 'orphan' || !connectionIdSet.has(query.connectionId)), + [connectionIdSet, savedQueries], + ); const v2RailConnectionGroups = useMemo( () => buildV2RailConnectionGroups(connections, connectionTags, sidebarRootOrder), [connections, connectionTags, sidebarRootOrder], @@ -838,10 +843,28 @@ const Sidebar: React.FC<{ orderedNodes.push(...Array.from(tagNodesById.values())); orderedNodes.push(...Array.from(ungroupedNodesById.values())); + if (unmatchedSavedQueries.length > 0) { + orderedNodes.push({ + title: '未匹配已存查询', + key: 'unmatched-saved-queries', + icon: , + type: 'unmatched-saved-queries', + isLeaf: false, + selectable: false, + children: unmatchedSavedQueries.map((query) => ({ + title: query.name, + key: query.id, + icon: , + type: 'saved-query', + dataRef: query, + isLeaf: true, + })), + }); + } const externalSQLRootNode = prev.find((node) => node.type === 'external-sql-root'); return externalSQLRootNode ? [...orderedNodes, externalSQLRootNode] : orderedNodes; }); - }, [connections, connectionTags, sidebarRootOrder]); + }, [connections, connectionTags, sidebarRootOrder, unmatchedSavedQueries]); const handleDuplicateConnection = async (conn: SavedConnection) => { if (!conn?.id) return; @@ -2505,7 +2528,7 @@ const Sidebar: React.FC<{ }, []); const onLoadData = async ({ key, children, dataRef, type }: any) => { - if (type === 'tag') return; + if (type === 'tag' || type === 'unmatched-saved-queries') return; if (hasSidebarLazyChildren(children)) return; if (type === 'connection') { @@ -4642,7 +4665,7 @@ const Sidebar: React.FC<{ return; } - saveQuery({ + const persisted = await saveQuery({ ...renameSavedQueryTarget, name: nextName, }); @@ -4651,8 +4674,8 @@ const Sidebar: React.FC<{ if (node.key === renameSavedQueryTarget.id) { return { ...node, - title: nextName, - dataRef: { ...(node.dataRef || renameSavedQueryTarget), name: nextName }, + title: persisted.name, + dataRef: { ...(node.dataRef || renameSavedQueryTarget), ...persisted }, }; } return node.children ? { ...node, children: updateSavedQueryNode(node.children) } : node; @@ -4662,16 +4685,51 @@ const Sidebar: React.FC<{ setTreeData(nextTreeData); tabs .filter(tab => tab.type === 'query' && (tab.savedQueryId === renameSavedQueryTarget.id || tab.id === renameSavedQueryTarget.id)) - .forEach(tab => updateQueryTabDraft(tab.id, { title: nextName })); + .forEach(tab => updateQueryTabDraft(tab.id, { title: persisted.name })); message.success('查询已重命名'); setIsRenameSavedQueryModalOpen(false); setRenameSavedQueryTarget(null); renameSavedQueryForm.resetFields(); } catch (e) { - // Validate failed + if (e instanceof Error) { + message.error('重命名查询失败: ' + e.message); + } } }; + const isSavedQueryUnmatched = useCallback((query: SavedQuery): boolean => { + return query.bindingStatus === 'orphan' || !connectionIdSet.has(query.connectionId); + }, [connectionIdSet]); + + const handleRebindSavedQuery = useCallback(async (query: SavedQuery, target: SavedConnection) => { + if (!query?.id || !target?.id) return; + try { + const backendApp = (window as any).go?.app?.App; + let persisted: SavedQuery; + if (typeof backendApp?.RebindSavedQuery === 'function') { + persisted = await backendApp.RebindSavedQuery(query.id, target.id); + await saveQuery(persisted); + } else { + persisted = await saveQuery({ + ...query, + connectionId: target.id, + originalConnectionId: query.originalConnectionId || query.connectionId, + bindingStatus: 'active', + }); + } + message.success(`查询已绑定到 ${target.name || target.id}`); + tabs + .filter(tab => tab.type === 'query' && (tab.savedQueryId === query.id || tab.id === query.id)) + .forEach(tab => updateQueryTabDraft(tab.id, { + title: persisted.name, + connectionId: persisted.connectionId, + dbName: persisted.dbName, + })); + } catch (error) { + message.error('绑定查询失败: ' + (error instanceof Error ? error.message : String(error))); + } + }, [saveQuery, tabs, updateQueryTabDraft]); + // --- 函数/存储过程操作 --- const openRoutineDefinition = (node: any) => { const { routineName, routineType, dbName, id } = node.dataRef; @@ -7436,7 +7494,24 @@ const Sidebar: React.FC<{ // 已存查询节点的右键菜单 if (node.type === 'saved-query') { - const q = node.dataRef; + const q = node.dataRef as SavedQuery; + const rebindMenuItems: MenuProps['items'] = isSavedQueryUnmatched(q) + ? [ + { + key: 'rebind-query', + label: '绑定到连接', + icon: , + disabled: connections.length === 0, + children: connections.length > 0 + ? connections.map((conn) => ({ + key: `rebind-query-${conn.id}`, + label: conn.name || conn.id, + onClick: () => void handleRebindSavedQuery(q, conn), + })) + : undefined, + }, + ] + : []; return [ { key: 'open-query', @@ -7454,6 +7529,7 @@ const Sidebar: React.FC<{ }); } }, + ...rebindMenuItems, { type: 'divider' }, { key: 'rename-query', @@ -7471,8 +7547,13 @@ const Sidebar: React.FC<{ title: '确认删除', content: `确定要删除已保存的查询 "${q.name}" 吗?此操作不可恢复。`, okButtonProps: { danger: true }, - onOk: () => { - deleteQuery(q.id); + onOk: async () => { + try { + await deleteQuery(q.id); + } catch (e) { + message.error('删除查询失败: ' + (e instanceof Error ? e.message : String(e))); + throw e; + } // 从树中移除节点 const removeNode = (list: TreeNode[]): TreeNode[] => list diff --git a/frontend/src/components/sidebarV2Utils.ts b/frontend/src/components/sidebarV2Utils.ts index 185a4239..ed9afcb0 100644 --- a/frontend/src/components/sidebarV2Utils.ts +++ b/frontend/src/components/sidebarV2Utils.ts @@ -21,6 +21,7 @@ export type SidebarTreeNodeType = | 'v2-table-section' | 'queries-folder' | 'saved-query' + | 'unmatched-saved-queries' | 'external-sql-root' | 'external-sql-directory' | 'external-sql-folder' diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index be1b050c..6a0b416f 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -24,6 +24,7 @@ const resolveDevHarnessMode = (): string => { if (typeof window !== 'undefined' && (!(window as any).go?.app?.App || !(window as any).go?.aiservice?.Service)) { const mockConnections: any[] = []; + const mockSavedQueries: any[] = []; const mockConnectionSecrets = new Map(); const mockProviders: any[] = []; const mockProviderSecrets = new Map(); @@ -154,6 +155,29 @@ if (typeof window !== 'undefined' && (!(window as any).go?.app?.App || !(window return cloneBrowserMockValue(view); }; + const saveMockQuery = (input: any) => { + const nextId = String(input?.id || `saved-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`); + const view = { + id: nextId, + name: String(input?.name || '未命名查询'), + sql: String(input?.sql || ''), + connectionId: String(input?.connectionId || ''), + dbName: String(input?.dbName || ''), + createdAt: Number.isFinite(Number(input?.createdAt)) ? Number(input.createdAt) : Date.now(), + connectionFingerprint: typeof input?.connectionFingerprint === 'string' ? input.connectionFingerprint : undefined, + fingerprintVersion: typeof input?.fingerprintVersion === 'string' ? input.fingerprintVersion : undefined, + bindingStatus: typeof input?.bindingStatus === 'string' ? input.bindingStatus : undefined, + originalConnectionId: typeof input?.originalConnectionId === 'string' ? input.originalConnectionId : undefined, + }; + const index = mockSavedQueries.findIndex((item) => item.id === nextId); + if (index >= 0) { + mockSavedQueries[index] = view; + } else { + mockSavedQueries.push(view); + } + return cloneBrowserMockValue(view); + }; + const saveMockGlobalProxy = (input: any) => { const nextPassword = String(input?.password ?? ''); mockGlobalProxy = { @@ -251,9 +275,30 @@ if (typeof window !== 'undefined' && (!(window as any).go?.app?.App || !(window GetTableData: async () => ({ columns: [], rows: [], total: 0 }), GetTableColumns: async () => [], ExecuteQuery: async () => ({ columns: [], rows: [], time: 0 }), - GetSavedQueries: async () => [], - SaveQuery: async () => null, - DeleteQuery: async () => null, + GetSavedQueries: async () => cloneBrowserMockValue(mockSavedQueries), + SaveQuery: async (input: any) => saveMockQuery(input), + ImportSavedQueries: async (payload: any) => { + const items = Array.isArray(payload) ? payload : payload?.queries; + (Array.isArray(items) ? items : []).forEach((item) => saveMockQuery(item)); + return cloneBrowserMockValue(mockSavedQueries); + }, + DeleteQuery: async (id: string) => { + const index = mockSavedQueries.findIndex((item) => item.id === id); + if (index >= 0) { + mockSavedQueries.splice(index, 1); + } + return null; + }, + RebindSavedQuery: async (id: string, connectionId: string) => { + const existing = mockSavedQueries.find((item) => item.id === id); + if (!existing) throw new Error('saved query not found'); + return saveMockQuery({ + ...existing, + connectionId, + originalConnectionId: existing.originalConnectionId || existing.connectionId, + bindingStatus: 'active', + }); + }, GetAppInfo: async () => ({}), GetDataRootDirectoryInfo: async () => ({ success: true, data: cloneBrowserMockValue(mockDataRootInfo) }), CheckForUpdates: async () => ({ success: false }), diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 1c883ccf..fefd976c 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -52,6 +52,13 @@ import { sanitizeTabDisplaySettings, type TabDisplaySettings, } from "./utils/tabDisplay"; +import { + captureLegacySavedQueriesSnapshot, + deleteSavedQueryFromBackend, + sanitizeSavedQueries, + saveSavedQueryToBackend, + type SavedQueryBackend, +} from "./utils/savedQueryPersistence"; export interface AppearanceSettings extends DataGridDisplaySettings { uiVersion: "legacy" | "v2"; @@ -111,7 +118,7 @@ const DEFAULT_TIMEOUT_SECONDS = 30; const MAX_TIMEOUT_SECONDS = 3600; const DEFAULT_DIAGNOSTIC_TIMEOUT_SECONDS = 15; const MAX_DIAGNOSTIC_TIMEOUT_SECONDS = 300; -const PERSIST_VERSION = 10; +const PERSIST_VERSION = 11; const PERSIST_STORAGE_KEY = "lite-db-storage"; const PERSIST_WRITE_DEBOUNCE_MS = 160; const MAX_PERSISTED_QUERY_TABS = 20; @@ -138,6 +145,13 @@ const isFrontendTestRuntime = (): boolean => { return env.MODE === "test" || env.VITEST === true || env.VITEST === "true"; }; +const resolveSavedQueryBackend = (): SavedQueryBackend | undefined => { + if (typeof window === "undefined") { + return undefined; + } + return (window as unknown as { go?: { app?: { App?: SavedQueryBackend } } }).go?.app?.App; +}; + const createDebouncedPersistStorage = ( getStorage: () => StateStorage, debounceMs = PERSIST_WRITE_DEBOUNCE_MS, @@ -1282,8 +1296,9 @@ interface AppState { context: { connectionId: string; dbName: string } | null, ) => void; - saveQuery: (query: SavedQuery) => void; - deleteQuery: (id: string) => void; + replaceSavedQueries: (queries: SavedQuery[]) => void; + saveQuery: (query: SavedQuery) => Promise; + deleteQuery: (id: string) => Promise; saveExternalSQLDirectory: (directory: ExternalSQLDirectory) => void; deleteExternalSQLDirectory: (id: string) => void; @@ -1387,33 +1402,6 @@ interface AppState { setAIActiveSessionId: (sessionId: string | null) => void; } -const sanitizeSavedQueries = (value: unknown): SavedQuery[] => { - if (!Array.isArray(value)) return []; - const result: SavedQuery[] = []; - value.forEach((entry, index) => { - if (!entry || typeof entry !== "object") return; - const raw = entry as Record; - const id = - toTrimmedString(raw.id, `query-${index + 1}`) || `query-${index + 1}`; - const sql = toTrimmedString(raw.sql); - const connectionId = toTrimmedString(raw.connectionId); - const dbName = toTrimmedString(raw.dbName); - if (!sql || !connectionId || !dbName) return; - result.push({ - id, - name: - toTrimmedString(raw.name, `查询-${index + 1}`) || `查询-${index + 1}`, - sql, - connectionId, - dbName, - createdAt: Number.isFinite(Number(raw.createdAt)) - ? Number(raw.createdAt) - : Date.now(), - }); - }); - return result; -}; - const sanitizeSqlSnippets = (value: unknown): SqlSnippet[] => { if (!Array.isArray(value)) return DEFAULT_SQL_SNIPPETS; const result: SqlSnippet[] = []; @@ -2745,24 +2733,34 @@ export const useStore = create()( })), setActiveContext: (context) => set({ activeContext: context }), - saveQuery: (query) => + replaceSavedQueries: (queries) => + set({ savedQueries: sanitizeSavedQueries(queries) }), + + saveQuery: async (query) => { + const saved = await saveSavedQueryToBackend( + resolveSavedQueryBackend(), + query, + ); set((state) => { - // If query with same ID exists, update it - const existing = state.savedQueries.find((q) => q.id === query.id); + const existing = state.savedQueries.find((q) => q.id === saved.id); if (existing) { return { savedQueries: state.savedQueries.map((q) => - q.id === query.id ? query : q, + q.id === saved.id ? saved : q, ), }; } - return { savedQueries: [...state.savedQueries, query] }; - }), + return { savedQueries: [...state.savedQueries, saved] }; + }); + return saved; + }, - deleteQuery: (id) => + deleteQuery: async (id) => { + await deleteSavedQueryFromBackend(resolveSavedQueryBackend(), id); set((state) => ({ savedQueries: state.savedQueries.filter((q) => q.id !== id), - })), + })); + }, saveExternalSQLDirectory: (directory) => set((state) => { @@ -3252,6 +3250,7 @@ export const useStore = create()( const state = unwrapPersistedAppState( persistedState, ) as Partial; + captureLegacySavedQueriesSnapshot(state.savedQueries, state.connections); const nextState: Partial = { ...state }; nextState.connections = sanitizeConnections(state.connections); const safeTabs = sanitizeQueryTabs(state.tabs); @@ -3271,7 +3270,7 @@ export const useStore = create()( nextState.connectionTags, nextState.connections, ); - nextState.savedQueries = sanitizeSavedQueries(state.savedQueries); + delete nextState.savedQueries; nextState.externalSQLDirectories = sanitizeExternalSQLDirectories( state.externalSQLDirectories, ); @@ -3340,6 +3339,7 @@ export const useStore = create()( const state = unwrapPersistedAppState( persistedState, ) as Partial; + captureLegacySavedQueriesSnapshot(state.savedQueries, state.connections); const safeTabs = sanitizeQueryTabs(state.tabs); const persistedConnections = state.connections === undefined @@ -3365,7 +3365,7 @@ export const useStore = create()( sidebarRootOrder: persistedSidebarRootOrder, tabs: safeTabs, activeTabId: sanitizeActiveTabId(state.activeTabId, safeTabs), - savedQueries: sanitizeSavedQueries(state.savedQueries), + savedQueries: currentState.savedQueries, externalSQLDirectories: sanitizeExternalSQLDirectories( state.externalSQLDirectories, ), @@ -3416,7 +3416,6 @@ export const useStore = create()( activeTabId: sanitizeActiveTabId(state.activeTabId, tabs), connectionTags: state.connectionTags, sidebarRootOrder: state.sidebarRootOrder, - savedQueries: state.savedQueries, externalSQLDirectories: state.externalSQLDirectories, theme: state.theme, appearance: state.appearance, diff --git a/frontend/src/types.ts b/frontend/src/types.ts index a57b2e26..4fcd731b 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -466,6 +466,10 @@ export interface SavedQuery { connectionId: string; dbName: string; createdAt: number; + connectionFingerprint?: string; + fingerprintVersion?: string; + bindingStatus?: "active" | "rebound" | "orphan" | string; + originalConnectionId?: string; } export interface SqlSnippet { diff --git a/frontend/src/utils/savedQueryPersistence.test.ts b/frontend/src/utils/savedQueryPersistence.test.ts new file mode 100644 index 00000000..98140df5 --- /dev/null +++ b/frontend/src/utils/savedQueryPersistence.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { SavedQuery } from '../types'; +import { LEGACY_PERSIST_KEY } from './legacyConnectionStorage'; +import { + bootstrapSavedQueries, + readLegacySavedQueriesFromPayload, + stripLegacySavedQueries, +} from './savedQueryPersistence'; + +const createMemoryStorage = () => { + const data = new Map(); + return { + getItem: (key: string) => data.get(key) ?? null, + setItem: (key: string, value: string) => { + data.set(key, value); + }, + }; +}; + +describe('saved query persistence', () => { + it('imports legacy localStorage queries into backend and clears the legacy field', async () => { + const storage = createMemoryStorage(); + storage.setItem(LEGACY_PERSIST_KEY, JSON.stringify({ + state: { + connections: [ + { + id: 'conn-1', + name: 'Primary', + config: { + id: 'conn-1', + type: 'postgres', + host: 'db.local', + port: 5432, + user: 'app', + password: 'secret', + }, + }, + ], + theme: 'dark', + savedQueries: [ + { + id: 'saved-1', + name: 'Orders', + sql: ' select * from orders;\n', + connectionId: 'conn-1', + dbName: 'app', + createdAt: 100, + }, + ], + }, + version: 10, + })); + + let backendQueries: SavedQuery[] = []; + const ImportSavedQueries = vi.fn(async (payload: { queries: SavedQuery[] }) => { + backendQueries = payload.queries; + return backendQueries; + }); + const GetSavedQueries = vi.fn(async () => backendQueries); + const replaceSavedQueries = vi.fn(); + + const result = await bootstrapSavedQueries({ + storage, + replaceSavedQueries, + backend: { + ImportSavedQueries, + GetSavedQueries, + }, + }); + + expect(result).toEqual({ importedLegacyCount: 1, loadedCount: 1 }); + expect(ImportSavedQueries).toHaveBeenCalledWith(expect.objectContaining({ + queries: [ + expect.objectContaining({ + id: 'saved-1', + sql: ' select * from orders;\n', + }), + ], + legacyConnections: [ + expect.objectContaining({ + id: 'conn-1', + config: expect.objectContaining({ + host: 'db.local', + }), + }), + ], + })); + expect(replaceSavedQueries).toHaveBeenCalledWith([ + expect.objectContaining({ + id: 'saved-1', + sql: ' select * from orders;\n', + }), + ]); + + const cleanedPayload = JSON.parse(storage.getItem(LEGACY_PERSIST_KEY) || '{}'); + expect(cleanedPayload.state.theme).toBe('dark'); + expect(cleanedPayload.state.savedQueries).toBeUndefined(); + }); + + it('reads and strips legacy saved queries without altering other persisted state', () => { + const payload = JSON.stringify({ + state: { + savedQueries: [ + { + id: 'saved-1', + name: 'Analytics', + sql: '\nselect 1;', + connectionId: 'conn-1', + dbName: 'warehouse', + createdAt: 200, + }, + { + id: 'invalid', + name: 'Missing context', + sql: 'select 2;', + }, + ], + sidebarWidth: 320, + }, + }); + + expect(readLegacySavedQueriesFromPayload(payload)).toEqual([ + expect.objectContaining({ + id: 'saved-1', + sql: '\nselect 1;', + }), + ]); + + const stripped = JSON.parse(stripLegacySavedQueries(payload)); + expect(stripped.state.savedQueries).toBeUndefined(); + expect(stripped.state.sidebarWidth).toBe(320); + }); +}); diff --git a/frontend/src/utils/savedQueryPersistence.ts b/frontend/src/utils/savedQueryPersistence.ts new file mode 100644 index 00000000..d202ce84 --- /dev/null +++ b/frontend/src/utils/savedQueryPersistence.ts @@ -0,0 +1,283 @@ +import type { SavedConnection, SavedQuery } from '../types'; +import { LEGACY_PERSIST_KEY } from './legacyConnectionStorage'; + +type StorageLike = Pick; + +export interface SavedQueryImportPayload { + queries: SavedQuery[]; + legacyConnections?: SavedConnection[]; +} + +export interface SavedQueryBackend { + GetSavedQueries?: () => Promise; + SaveQuery?: (query: SavedQuery) => Promise; + ImportSavedQueries?: (payload: SavedQueryImportPayload) => Promise; + DeleteQuery?: (id: string) => Promise; + RebindSavedQuery?: (id: string, connectionId: string) => Promise; +} + +export interface SavedQueryBootstrapArgs { + backend?: SavedQueryBackend; + replaceSavedQueries: (queries: SavedQuery[]) => void; + storage?: StorageLike; +} + +export interface SavedQueryBootstrapResult { + importedLegacyCount: number; + loadedCount: number; +} + +let capturedLegacySavedQuerySource: SavedQueryImportPayload = { queries: [] }; + +const toTrimmedString = (value: unknown, fallback = ''): string => { + if (typeof value === 'string') { + return value.trim(); + } + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value).trim(); + } + return fallback; +}; + +const unwrapPersistedAppState = (payload: unknown): Record => { + if (!payload || typeof payload !== 'object') { + return {}; + } + const raw = payload as Record; + if (raw.state && typeof raw.state === 'object') { + return raw.state as Record; + } + return raw; +}; + +const sanitizeSavedQuery = (value: unknown, index: number): SavedQuery | null => { + if (!value || typeof value !== 'object') { + return null; + } + const raw = value as Record; + const id = toTrimmedString(raw.id, `query-${index + 1}`) || `query-${index + 1}`; + const sql = typeof raw.sql === 'string' ? raw.sql : toTrimmedString(raw.sql); + const connectionId = toTrimmedString(raw.connectionId); + const dbName = toTrimmedString(raw.dbName); + if (!sql.trim() || !connectionId || !dbName) { + return null; + } + const query: SavedQuery = { + id, + name: toTrimmedString(raw.name, `查询-${index + 1}`) || `查询-${index + 1}`, + sql, + connectionId, + dbName, + createdAt: Number.isFinite(Number(raw.createdAt)) ? Number(raw.createdAt) : Date.now(), + }; + const connectionFingerprint = toTrimmedString(raw.connectionFingerprint); + const fingerprintVersion = toTrimmedString(raw.fingerprintVersion); + const bindingStatus = toTrimmedString(raw.bindingStatus); + const originalConnectionId = toTrimmedString(raw.originalConnectionId); + if (connectionFingerprint) query.connectionFingerprint = connectionFingerprint; + if (fingerprintVersion) query.fingerprintVersion = fingerprintVersion; + if (bindingStatus) query.bindingStatus = bindingStatus; + if (originalConnectionId) query.originalConnectionId = originalConnectionId; + return query; +}; + +export const sanitizeSavedQueries = (value: unknown): SavedQuery[] => { + if (!Array.isArray(value)) { + return []; + } + const result: SavedQuery[] = []; + const seen = new Set(); + value.forEach((item, index) => { + const query = sanitizeSavedQuery(item, index); + if (!query || seen.has(query.id)) { + return; + } + seen.add(query.id); + result.push(query); + }); + return result; +}; + +const mergeSavedQueriesById = (...groups: SavedQuery[][]): SavedQuery[] => { + const result: SavedQuery[] = []; + const indexById = new Map(); + groups.flat().forEach((query) => { + if (!query.id) { + return; + } + const existingIndex = indexById.get(query.id); + if (existingIndex === undefined) { + indexById.set(query.id, result.length); + result.push(query); + return; + } + result[existingIndex] = query; + }); + return result; +}; + +const sanitizeLegacyConnections = (value: unknown): SavedConnection[] => { + if (!Array.isArray(value)) { + return []; + } + return value.filter((item): item is SavedConnection => !!item && typeof item === 'object'); +}; + +const mergeLegacyConnectionsById = (...groups: SavedConnection[][]): SavedConnection[] => { + const result: SavedConnection[] = []; + const indexById = new Map(); + groups.flat().forEach((connection) => { + const id = toTrimmedString((connection as { id?: unknown }).id); + if (!id) { + return; + } + const existingIndex = indexById.get(id); + if (existingIndex === undefined) { + indexById.set(id, result.length); + result.push(connection); + return; + } + result[existingIndex] = connection; + }); + return result; +}; + +const mergeSavedQueryImportSources = (...sources: SavedQueryImportPayload[]): SavedQueryImportPayload => { + return { + queries: mergeSavedQueriesById(...sources.map((source) => source.queries || [])), + legacyConnections: mergeLegacyConnectionsById(...sources.map((source) => source.legacyConnections || [])), + }; +}; + +export const captureLegacySavedQueriesSnapshot = (value: unknown, legacyConnections?: unknown): void => { + const nextQueries = sanitizeSavedQueries(value); + if (nextQueries.length === 0) { + return; + } + capturedLegacySavedQuerySource = mergeSavedQueryImportSources(capturedLegacySavedQuerySource, { + queries: nextQueries, + legacyConnections: sanitizeLegacyConnections(legacyConnections), + }); +}; + +export const readLegacySavedQuerySourceFromPayload = (payload: string | null | undefined): SavedQueryImportPayload => { + if (!payload || typeof payload !== 'string') { + return { queries: [] }; + } + try { + const parsed = JSON.parse(payload) as Record; + const state = unwrapPersistedAppState(parsed); + return { + queries: sanitizeSavedQueries(state.savedQueries), + legacyConnections: sanitizeLegacyConnections(state.connections), + }; + } catch { + return { queries: [] }; + } +}; + +export const readLegacySavedQueriesFromPayload = (payload: string | null | undefined): SavedQuery[] => ( + readLegacySavedQuerySourceFromPayload(payload).queries +); + +export const stripLegacySavedQueries = (payload: string | null | undefined): string => { + if (!payload || typeof payload !== 'string') { + return ''; + } + try { + const parsed = JSON.parse(payload) as Record; + const state = unwrapPersistedAppState(parsed); + if (state.savedQueries === undefined) { + return payload; + } + delete state.savedQueries; + return JSON.stringify(parsed); + } catch { + return payload; + } +}; + +const resolveStorage = (storage?: StorageLike): StorageLike | undefined => { + if (storage) { + return storage; + } + if (typeof window === 'undefined') { + return undefined; + } + return window.localStorage; +}; + +const readLegacySavedQuerySourceFromStorage = (storage?: StorageLike): SavedQueryImportPayload => { + const rawPayload = storage?.getItem(LEGACY_PERSIST_KEY) ?? null; + return readLegacySavedQuerySourceFromPayload(rawPayload); +}; + +const cleanupLegacySavedQueriesFromStorage = (storage?: StorageLike): void => { + const rawPayload = storage?.getItem(LEGACY_PERSIST_KEY) ?? null; + const sanitizedPayload = stripLegacySavedQueries(rawPayload); + if (sanitizedPayload && sanitizedPayload !== rawPayload) { + storage?.setItem(LEGACY_PERSIST_KEY, sanitizedPayload); + } +}; + +export const saveSavedQueryToBackend = async ( + backend: SavedQueryBackend | undefined, + query: SavedQuery, +): Promise => { + const sanitized = sanitizeSavedQuery(query, 0); + if (!sanitized) { + throw new Error('保存查询缺少 SQL、连接或数据库上下文'); + } + if (typeof backend?.SaveQuery !== 'function') { + return sanitized; + } + const saved = await backend.SaveQuery(sanitized); + return sanitizeSavedQuery(saved || sanitized, 0) || sanitized; +}; + +export const deleteSavedQueryFromBackend = async ( + backend: SavedQueryBackend | undefined, + id: string, +): Promise => { + if (typeof backend?.DeleteQuery === 'function') { + await backend.DeleteQuery(id); + } +}; + +export async function bootstrapSavedQueries(args: SavedQueryBootstrapArgs): Promise { + const storage = resolveStorage(args.storage); + const storageLegacySource = readLegacySavedQuerySourceFromStorage(storage); + const legacySource = mergeSavedQueryImportSources(capturedLegacySavedQuerySource, storageLegacySource); + const legacyQueries = legacySource.queries; + let importedLegacyCount = 0; + + if (legacyQueries.length > 0) { + if (typeof args.backend?.ImportSavedQueries === 'function') { + await args.backend.ImportSavedQueries(legacySource); + importedLegacyCount = legacyQueries.length; + capturedLegacySavedQuerySource = { queries: [] }; + cleanupLegacySavedQueriesFromStorage(storage); + } else if (typeof args.backend?.SaveQuery === 'function') { + for (const query of legacyQueries) { + await args.backend.SaveQuery(query); + } + importedLegacyCount = legacyQueries.length; + capturedLegacySavedQuerySource = { queries: [] }; + cleanupLegacySavedQueriesFromStorage(storage); + } + } + + let loadedQueries: SavedQuery[] = []; + if (typeof args.backend?.GetSavedQueries === 'function') { + loadedQueries = sanitizeSavedQueries(await args.backend.GetSavedQueries()); + } + if (loadedQueries.length === 0 && importedLegacyCount === 0 && legacyQueries.length > 0) { + loadedQueries = legacyQueries; + } + args.replaceSavedQueries(loadedQueries); + + return { + importedLegacyCount, + loadedCount: loadedQueries.length, + }; +} diff --git a/frontend/src/utils/secureConfigBootstrap.test.ts b/frontend/src/utils/secureConfigBootstrap.test.ts index 7f7b0cd8..65ac6d2e 100644 --- a/frontend/src/utils/secureConfigBootstrap.test.ts +++ b/frontend/src/utils/secureConfigBootstrap.test.ts @@ -8,6 +8,7 @@ import { startSecurityUpdateFromBootstrap, } from './secureConfigBootstrap'; import { stripLegacyPersistedConnectionById } from './legacyConnectionStorage'; +import { stripLegacySavedQueries } from './savedQueryPersistence'; const legacyPayload = JSON.stringify({ state: { @@ -536,6 +537,69 @@ describe('secureConfigBootstrap', () => { ); }); + it('does not restore legacy saved queries when security cleanup runs after saved-query cleanup', async () => { + const args = createBaseArgs(); + args.storage.setItem(LEGACY_PERSIST_KEY, JSON.stringify({ + state: { + connections: [ + { + id: 'legacy-1', + name: 'Legacy', + config: { + id: 'legacy-1', + type: 'postgres', + host: 'db.local', + port: 5432, + user: 'postgres', + password: 'secret', + }, + }, + ], + globalProxy: { + enabled: true, + type: 'http', + host: '127.0.0.1', + port: 8080, + user: 'ops', + password: 'proxy-secret', + }, + savedQueries: [ + { + id: 'saved-1', + name: 'Orders', + sql: 'select * from orders', + connectionId: 'legacy-1', + dbName: 'app', + createdAt: 100, + }, + ], + }, + })); + + await startSecurityUpdateFromBootstrap({ + ...args, + backend: { + StartSecurityUpdate: vi.fn().mockImplementation(async () => { + args.storage.setItem( + LEGACY_PERSIST_KEY, + stripLegacySavedQueries(args.storage.getItem(LEGACY_PERSIST_KEY)), + ); + return { + overallStatus: 'completed', + summary: { total: 3, updated: 3, pending: 0, skipped: 0, failed: 0 }, + issues: [], + }; + }), + GetSavedConnections: vi.fn().mockResolvedValue([]), + }, + }); + + const cleaned = JSON.parse(args.storage.getItem(LEGACY_PERSIST_KEY) || '{}'); + expect(cleaned.state.savedQueries).toBeUndefined(); + expect(cleaned.state.connections).toEqual([]); + expect(cleaned.state.globalProxy).toBeUndefined(); + }); + it('refreshes backend config and strips source-side secrets when a later round finishes as completed', async () => { const args = createBaseArgs(); diff --git a/frontend/src/utils/secureConfigBootstrap.ts b/frontend/src/utils/secureConfigBootstrap.ts index cd26ef2a..5a66d673 100644 --- a/frontend/src/utils/secureConfigBootstrap.ts +++ b/frontend/src/utils/secureConfigBootstrap.ts @@ -12,6 +12,7 @@ import { readLegacyPersistedSecrets, stripLegacyPersistedSecrets, } from './legacyConnectionStorage'; +import { stripLegacySavedQueries } from './savedQueryPersistence'; type StorageLike = Pick; @@ -312,8 +313,9 @@ const cleanupLegacySourceIfCompleted = ( if (!storage || !rawPayload || status.overallStatus !== 'completed') { return; } - const sanitizedPayload = stripLegacyPersistedSecrets(rawPayload); - if (sanitizedPayload && sanitizedPayload !== rawPayload) { + const currentPayload = storage.getItem(LEGACY_PERSIST_KEY) ?? rawPayload; + const sanitizedPayload = stripLegacySavedQueries(stripLegacyPersistedSecrets(currentPayload)); + if (sanitizedPayload && sanitizedPayload !== currentPayload) { storage.setItem(LEGACY_PERSIST_KEY, sanitizedPayload); } }; diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index 386c854c..fc00ea31 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -76,6 +76,8 @@ export function DataSyncPreview(arg1:sync.SyncConfig,arg2:string,arg3:number):Pr export function DeleteConnection(arg1:string):Promise; +export function DeleteQuery(arg1:string):Promise; + export function DeleteSQLDirectory(arg1:string):Promise; export function DeleteSQLFile(arg1:string):Promise; @@ -136,8 +138,12 @@ export function GetGlobalProxyConfig():Promise; export function GetSavedConnections():Promise>; +export function GetSavedQueries():Promise>; + export function GetSecurityUpdateStatus():Promise; +export function GetUnboundSavedQueries():Promise>; + export function ImportConfigFile():Promise; export function ImportConnectionsPayload(arg1:string,arg2:string):Promise>; @@ -150,6 +156,8 @@ export function ImportLegacyConnections(arg1:Array; +export function ImportSavedQueries(arg1:connection.SavedQueryImportPayload):Promise>; + export function InstallLocalDriverPackage(arg1:string,arg2:string,arg3:string,arg4:string):Promise; export function InstallUpdateAndRestart():Promise; @@ -216,6 +224,8 @@ export function ReadAppLogTail(arg1:number,arg2:string):Promise; +export function RebindSavedQuery(arg1:string,arg2:string):Promise; + export function RedisConnect(arg1:connection.ConnectionConfig):Promise; export function RedisDeleteHashField(arg1:connection.ConnectionConfig,arg2:string,arg3:any):Promise; @@ -294,6 +304,8 @@ export function SaveConnection(arg1:connection.SavedConnectionInput):Promise; +export function SaveQuery(arg1:connection.SavedQuery):Promise; + export function SelectCertificateFile(arg1:string,arg2:string):Promise; export function SelectDataRootDirectory(arg1:string):Promise; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index 8b7f9b7a..76c6ca67 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -142,6 +142,10 @@ export function DeleteConnection(arg1) { return window['go']['app']['App']['DeleteConnection'](arg1); } +export function DeleteQuery(arg1) { + return window['go']['app']['App']['DeleteQuery'](arg1); +} + export function DeleteSQLDirectory(arg1) { return window['go']['app']['App']['DeleteSQLDirectory'](arg1); } @@ -262,10 +266,18 @@ export function GetSavedConnections() { return window['go']['app']['App']['GetSavedConnections'](); } +export function GetSavedQueries() { + return window['go']['app']['App']['GetSavedQueries'](); +} + export function GetSecurityUpdateStatus() { return window['go']['app']['App']['GetSecurityUpdateStatus'](); } +export function GetUnboundSavedQueries() { + return window['go']['app']['App']['GetUnboundSavedQueries'](); +} + export function ImportConfigFile() { return window['go']['app']['App']['ImportConfigFile'](); } @@ -290,6 +302,10 @@ export function ImportLegacyGlobalProxy(arg1) { return window['go']['app']['App']['ImportLegacyGlobalProxy'](arg1); } +export function ImportSavedQueries(arg1) { + return window['go']['app']['App']['ImportSavedQueries'](arg1); +} + export function InstallLocalDriverPackage(arg1, arg2, arg3, arg4) { return window['go']['app']['App']['InstallLocalDriverPackage'](arg1, arg2, arg3, arg4); } @@ -422,6 +438,10 @@ export function ReadSQLFile(arg1) { return window['go']['app']['App']['ReadSQLFile'](arg1); } +export function RebindSavedQuery(arg1, arg2) { + return window['go']['app']['App']['RebindSavedQuery'](arg1, arg2); +} + export function RedisConnect(arg1) { return window['go']['app']['App']['RedisConnect'](arg1); } @@ -578,6 +598,10 @@ export function SaveGlobalProxy(arg1) { return window['go']['app']['App']['SaveGlobalProxy'](arg1); } +export function SaveQuery(arg1) { + return window['go']['app']['App']['SaveQuery'](arg1); +} + export function SelectCertificateFile(arg1, arg2) { return window['go']['app']['App']['SelectCertificateFile'](arg1, arg2); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 6b7f70a3..1a09d53b 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1199,6 +1199,68 @@ export namespace connection { return a; } } + export class SavedQuery { + id: string; + name: string; + sql: string; + connectionId: string; + dbName: string; + createdAt: number; + connectionFingerprint?: string; + fingerprintVersion?: string; + bindingStatus?: string; + originalConnectionId?: string; + + static createFrom(source: any = {}) { + return new SavedQuery(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.id = source["id"]; + this.name = source["name"]; + this.sql = source["sql"]; + this.connectionId = source["connectionId"]; + this.dbName = source["dbName"]; + this.createdAt = source["createdAt"]; + this.connectionFingerprint = source["connectionFingerprint"]; + this.fingerprintVersion = source["fingerprintVersion"]; + this.bindingStatus = source["bindingStatus"]; + this.originalConnectionId = source["originalConnectionId"]; + } + } + export class SavedQueryImportPayload { + queries: SavedQuery[]; + legacyConnections?: SavedConnectionInput[]; + + static createFrom(source: any = {}) { + return new SavedQueryImportPayload(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.queries = this.convertValues(source["queries"], SavedQuery); + this.legacyConnections = this.convertValues(source["legacyConnections"], SavedConnectionInput); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } } @@ -1392,4 +1454,3 @@ export namespace sync { } } - diff --git a/internal/app/methods_saved_queries.go b/internal/app/methods_saved_queries.go new file mode 100644 index 00000000..839308aa --- /dev/null +++ b/internal/app/methods_saved_queries.go @@ -0,0 +1,64 @@ +package app + +import "GoNavi-Wails/internal/connection" + +func (a *App) savedQueryRepository() *savedQueryRepository { + return newSavedQueryRepository(a.configDir) +} + +func (a *App) GetSavedQueries() ([]connection.SavedQuery, error) { + savedQueriesMu.Lock() + defer savedQueriesMu.Unlock() + + queries, err := a.savedQueryRepository().load() + if err != nil { + return nil, err + } + currentConnections, err := a.savedConnectionRepository().List() + if err != nil { + return queries, nil + } + return resolveSavedQueryBindings(queries, currentConnections, nil), nil +} + +func (a *App) SaveQuery(input connection.SavedQuery) (connection.SavedQuery, error) { + currentConnections, err := a.savedConnectionRepository().List() + if err == nil { + input = resolveSavedQueryBindings([]connection.SavedQuery{input}, currentConnections, nil)[0] + } + return a.savedQueryRepository().Save(input) +} + +func (a *App) ImportSavedQueries(payload connection.SavedQueryImportPayload) ([]connection.SavedQuery, error) { + currentConnections, err := a.savedConnectionRepository().List() + if err != nil { + currentConnections = nil + } + return a.savedQueryRepository().Import(payload, currentConnections) +} + +func (a *App) DeleteQuery(id string) error { + return a.savedQueryRepository().Delete(id) +} + +func (a *App) RebindSavedQuery(id string, connectionID string) (connection.SavedQuery, error) { + target, err := a.savedConnectionRepository().Find(connectionID) + if err != nil { + return connection.SavedQuery{}, err + } + return a.savedQueryRepository().Rebind(id, target) +} + +func (a *App) GetUnboundSavedQueries() ([]connection.SavedQuery, error) { + queries, err := a.GetSavedQueries() + if err != nil { + return nil, err + } + result := make([]connection.SavedQuery, 0) + for _, query := range queries { + if query.BindingStatus == savedQueryBindingOrphan { + result = append(result, query) + } + } + return result, nil +} diff --git a/internal/app/saved_queries.go b/internal/app/saved_queries.go new file mode 100644 index 00000000..ca4a714b --- /dev/null +++ b/internal/app/saved_queries.go @@ -0,0 +1,287 @@ +package app + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "GoNavi-Wails/internal/connection" + "github.com/google/uuid" +) + +const savedQueriesFileName = "saved_queries.json" + +var savedQueriesMu sync.Mutex + +type savedQueriesFile struct { + Queries []connection.SavedQuery `json:"queries"` +} + +type savedQueryRepository struct { + configDir string +} + +func newSavedQueryRepository(configDir string) *savedQueryRepository { + if strings.TrimSpace(configDir) == "" { + configDir = resolveAppConfigDir() + } + return &savedQueryRepository{configDir: configDir} +} + +func (r *savedQueryRepository) queriesPath() string { + return filepath.Join(r.configDir, savedQueriesFileName) +} + +func (r *savedQueryRepository) load() ([]connection.SavedQuery, error) { + data, err := os.ReadFile(r.queriesPath()) + if err != nil { + if os.IsNotExist(err) { + return []connection.SavedQuery{}, nil + } + return nil, err + } + + var file savedQueriesFile + if err := json.Unmarshal(data, &file); err != nil { + return nil, err + } + if file.Queries == nil { + return []connection.SavedQuery{}, nil + } + return sanitizeSavedQueries(file.Queries), nil +} + +func (r *savedQueryRepository) saveAll(queries []connection.SavedQuery) error { + if err := os.MkdirAll(r.configDir, 0o755); err != nil { + return err + } + payload, err := json.MarshalIndent(savedQueriesFile{Queries: sanitizeSavedQueries(queries)}, "", " ") + if err != nil { + return err + } + return writeSavedQueriesFileAtomic(r.queriesPath(), payload) +} + +func writeSavedQueriesFileAtomic(targetPath string, payload []byte) error { + dir := filepath.Dir(targetPath) + temp, err := os.CreateTemp(dir, ".saved_queries_*.tmp") + if err != nil { + return err + } + tempPath := temp.Name() + cleanup := true + defer func() { + if cleanup { + _ = os.Remove(tempPath) + } + }() + + if _, err := temp.Write(payload); err != nil { + _ = temp.Close() + return err + } + if err := temp.Sync(); err != nil { + _ = temp.Close() + return err + } + if err := temp.Close(); err != nil { + return err + } + if err := os.Chmod(tempPath, 0o644); err != nil { + return err + } + if err := os.Rename(tempPath, targetPath); err != nil { + if removeErr := os.Remove(targetPath); removeErr != nil && !os.IsNotExist(removeErr) { + return err + } + if retryErr := os.Rename(tempPath, targetPath); retryErr != nil { + return retryErr + } + } + cleanup = false + return nil +} + +func (r *savedQueryRepository) Save(input connection.SavedQuery) (connection.SavedQuery, error) { + savedQueriesMu.Lock() + defer savedQueriesMu.Unlock() + + query, ok := sanitizeSavedQuery(input, 0, true) + if !ok { + return connection.SavedQuery{}, fmt.Errorf("saved query requires sql, connectionId and dbName") + } + + queries, err := r.load() + if err != nil { + return connection.SavedQuery{}, err + } + + replaced := false + for index, item := range queries { + if item.ID == query.ID { + queries[index] = query + replaced = true + break + } + } + if !replaced { + queries = append(queries, query) + } + if err := r.saveAll(queries); err != nil { + return connection.SavedQuery{}, err + } + return query, nil +} + +func (r *savedQueryRepository) Import(payload connection.SavedQueryImportPayload, currentConnections []connection.SavedConnectionView) ([]connection.SavedQuery, error) { + savedQueriesMu.Lock() + defer savedQueriesMu.Unlock() + + existing, err := r.load() + if err != nil { + return nil, err + } + + byID := make(map[string]int, len(existing)+len(payload.Queries)) + for index, item := range existing { + byID[item.ID] = index + } + + imported := resolveSavedQueryBindings(payload.Queries, currentConnections, payload.LegacyConnections) + for index, item := range imported { + query, ok := sanitizeSavedQuery(item, index, true) + if !ok { + continue + } + if existingIndex, found := byID[query.ID]; found { + existing[existingIndex] = query + continue + } + byID[query.ID] = len(existing) + existing = append(existing, query) + } + + if err := r.saveAll(existing); err != nil { + return nil, err + } + return existing, nil +} + +func (r *savedQueryRepository) Rebind(id string, target connection.SavedConnectionView) (connection.SavedQuery, error) { + savedQueriesMu.Lock() + defer savedQueriesMu.Unlock() + + targetID := strings.TrimSpace(id) + if targetID == "" || strings.TrimSpace(target.ID) == "" { + return connection.SavedQuery{}, fmt.Errorf("saved query and target connection are required") + } + + queries, err := r.load() + if err != nil { + return connection.SavedQuery{}, err + } + + for index, item := range queries { + if item.ID != targetID { + continue + } + if strings.TrimSpace(item.OriginalConnectionID) == "" && strings.TrimSpace(item.ConnectionID) != strings.TrimSpace(target.ID) { + item.OriginalConnectionID = item.ConnectionID + } + item.ConnectionID = target.ID + item = applySavedQueryActiveBinding(item, target) + query, ok := sanitizeSavedQuery(item, index, false) + if !ok { + return connection.SavedQuery{}, fmt.Errorf("saved query is invalid: %s", targetID) + } + queries[index] = query + if err := r.saveAll(queries); err != nil { + return connection.SavedQuery{}, err + } + return query, nil + } + + return connection.SavedQuery{}, fmt.Errorf("saved query not found: %s", targetID) +} + +func (r *savedQueryRepository) Delete(id string) error { + savedQueriesMu.Lock() + defer savedQueriesMu.Unlock() + + targetID := strings.TrimSpace(id) + if targetID == "" { + return nil + } + + queries, err := r.load() + if err != nil { + return err + } + filtered := queries[:0] + for _, item := range queries { + if item.ID != targetID { + filtered = append(filtered, item) + } + } + return r.saveAll(filtered) +} + +func sanitizeSavedQueries(items []connection.SavedQuery) []connection.SavedQuery { + result := make([]connection.SavedQuery, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for index, item := range items { + query, ok := sanitizeSavedQuery(item, index, false) + if !ok { + continue + } + if _, exists := seen[query.ID]; exists { + continue + } + seen[query.ID] = struct{}{} + result = append(result, query) + } + return result +} + +func sanitizeSavedQuery(input connection.SavedQuery, index int, allowGeneratedID bool) (connection.SavedQuery, bool) { + id := strings.TrimSpace(input.ID) + if id == "" && allowGeneratedID { + id = "saved-" + uuid.NewString() + } + if id == "" { + return connection.SavedQuery{}, false + } + + sqlText := input.SQL + connectionID := strings.TrimSpace(input.ConnectionID) + dbName := strings.TrimSpace(input.DBName) + if strings.TrimSpace(sqlText) == "" || connectionID == "" || dbName == "" { + return connection.SavedQuery{}, false + } + + name := strings.TrimSpace(input.Name) + if name == "" { + name = fmt.Sprintf("查询-%d", index+1) + } + createdAt := input.CreatedAt + if createdAt <= 0 { + createdAt = time.Now().UnixMilli() + } + + return connection.SavedQuery{ + ID: id, + Name: name, + SQL: sqlText, + ConnectionID: connectionID, + DBName: dbName, + CreatedAt: createdAt, + ConnectionFingerprint: strings.TrimSpace(input.ConnectionFingerprint), + FingerprintVersion: strings.TrimSpace(input.FingerprintVersion), + BindingStatus: strings.TrimSpace(input.BindingStatus), + OriginalConnectionID: strings.TrimSpace(input.OriginalConnectionID), + }, true +} diff --git a/internal/app/saved_queries_test.go b/internal/app/saved_queries_test.go new file mode 100644 index 00000000..d9d0ed81 --- /dev/null +++ b/internal/app/saved_queries_test.go @@ -0,0 +1,416 @@ +package app + +import ( + "fmt" + "os" + "path/filepath" + "sync" + "testing" + + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/secretstore" +) + +func TestSavedQueryRepositorySaveUpdateAndDelete(t *testing.T) { + app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test")) + app.configDir = t.TempDir() + + query := connection.SavedQuery{ + ID: "saved-1", + Name: "Orders", + SQL: "select * from orders", + ConnectionID: "conn-1", + DBName: "app", + CreatedAt: 100, + } + if _, err := app.SaveQuery(query); err != nil { + t.Fatalf("SaveQuery returned error: %v", err) + } + + saved, err := app.GetSavedQueries() + if err != nil { + t.Fatalf("GetSavedQueries returned error: %v", err) + } + if len(saved) != 1 || saved[0].Name != "Orders" { + t.Fatalf("expected saved query to be persisted, got %#v", saved) + } + + query.Name = "Orders Updated" + query.SQL = "select id from orders" + if _, err := app.SaveQuery(query); err != nil { + t.Fatalf("SaveQuery update returned error: %v", err) + } + saved, err = app.GetSavedQueries() + if err != nil { + t.Fatalf("GetSavedQueries after update returned error: %v", err) + } + if len(saved) != 1 || saved[0].Name != "Orders Updated" || saved[0].SQL != "select id from orders" { + t.Fatalf("expected saved query to be updated in place, got %#v", saved) + } + + if err := app.DeleteQuery("saved-1"); err != nil { + t.Fatalf("DeleteQuery returned error: %v", err) + } + saved, err = app.GetSavedQueries() + if err != nil { + t.Fatalf("GetSavedQueries after delete returned error: %v", err) + } + if len(saved) != 0 { + t.Fatalf("expected saved query to be deleted, got %#v", saved) + } + if _, err := os.Stat(filepath.Join(app.configDir, savedQueriesFileName)); err != nil { + t.Fatalf("expected saved query file to remain readable after delete: %v", err) + } +} + +func TestImportSavedQueriesUpsertsAndSkipsInvalidItems(t *testing.T) { + app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test")) + app.configDir = t.TempDir() + + if _, err := app.SaveQuery(connection.SavedQuery{ + ID: "saved-1", + Name: "Old", + SQL: "select 1", + ConnectionID: "conn-1", + DBName: "app", + CreatedAt: 100, + }); err != nil { + t.Fatalf("seed SaveQuery returned error: %v", err) + } + + imported, err := app.ImportSavedQueries(connection.SavedQueryImportPayload{ + Queries: []connection.SavedQuery{ + { + ID: "saved-1", + Name: "New", + SQL: "select 2", + ConnectionID: "conn-1", + DBName: "app", + CreatedAt: 100, + }, + { + ID: "saved-2", + Name: "Second", + SQL: "select 3", + ConnectionID: "conn-2", + DBName: "analytics", + CreatedAt: 200, + }, + { + ID: "invalid", + Name: "Missing SQL", + ConnectionID: "conn-3", + DBName: "app", + }, + }, + }) + if err != nil { + t.Fatalf("ImportSavedQueries returned error: %v", err) + } + if len(imported) != 2 { + t.Fatalf("expected 2 valid saved queries after import, got %#v", imported) + } + if imported[0].Name != "New" || imported[0].SQL != "select 2" { + t.Fatalf("expected import to upsert saved-1, got %#v", imported[0]) + } + if imported[1].ID != "saved-2" { + t.Fatalf("expected import to append saved-2, got %#v", imported[1]) + } +} + +func TestSavedQueryRepositoryPreservesSQLWhitespace(t *testing.T) { + app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test")) + app.configDir = t.TempDir() + + query := connection.SavedQuery{ + ID: "saved-whitespace", + Name: "Formatted", + SQL: "\n select * from orders;\n", + ConnectionID: "conn-1", + DBName: "app", + CreatedAt: 100, + } + persisted, err := app.SaveQuery(query) + if err != nil { + t.Fatalf("SaveQuery returned error: %v", err) + } + if persisted.SQL != query.SQL { + t.Fatalf("expected saved SQL whitespace to be preserved, got %q", persisted.SQL) + } +} + +func TestSavedQueryRepositorySerializesConcurrentWrites(t *testing.T) { + app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test")) + app.configDir = t.TempDir() + + const count = 24 + var wg sync.WaitGroup + errCh := make(chan error, count) + for index := 0; index < count; index++ { + index := index + wg.Add(1) + go func() { + defer wg.Done() + _, err := app.SaveQuery(connection.SavedQuery{ + ID: fmt.Sprintf("saved-%02d", index), + Name: fmt.Sprintf("Query %02d", index), + SQL: fmt.Sprintf("select %d", index), + ConnectionID: "conn-1", + DBName: "app", + CreatedAt: int64(index + 1), + }) + if err != nil { + errCh <- err + } + }() + } + wg.Wait() + close(errCh) + + for err := range errCh { + t.Fatalf("SaveQuery returned error during concurrent write: %v", err) + } + + saved, err := app.GetSavedQueries() + if err != nil { + t.Fatalf("GetSavedQueries returned error: %v", err) + } + if len(saved) != count { + t.Fatalf("expected %d saved queries after concurrent writes, got %d: %#v", count, len(saved), saved) + } +} + +func TestImportSavedQueriesRebindsLegacyConnectionByUniqueFingerprint(t *testing.T) { + app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test")) + app.configDir = t.TempDir() + + current, err := app.SaveConnection(connection.SavedConnectionInput{ + ID: "conn-current", + Name: "Current", + Config: connection.ConnectionConfig{ + ID: "conn-current", + Type: "postgres", + Host: "db.local", + Port: 5432, + User: "app", + Database: "app", + Password: "new-secret", + }, + }) + if err != nil { + t.Fatalf("SaveConnection returned error: %v", err) + } + + imported, err := app.ImportSavedQueries(connection.SavedQueryImportPayload{ + Queries: []connection.SavedQuery{ + { + ID: "saved-legacy", + Name: "Legacy", + SQL: "select 1", + ConnectionID: "conn-old", + DBName: "app", + CreatedAt: 100, + }, + }, + LegacyConnections: []connection.SavedConnectionInput{ + { + ID: "conn-old", + Name: "Old", + Config: connection.ConnectionConfig{ + ID: "conn-old", + Type: "postgres", + Host: "DB.LOCAL", + Port: 5432, + User: "app", + Database: "app", + Password: "old-secret", + }, + }, + }, + }) + if err != nil { + t.Fatalf("ImportSavedQueries returned error: %v", err) + } + if len(imported) != 1 { + t.Fatalf("expected 1 imported query, got %#v", imported) + } + query := imported[0] + if query.ConnectionID != current.ID { + t.Fatalf("expected query to rebind to %s, got %#v", current.ID, query) + } + if query.OriginalConnectionID != "conn-old" { + t.Fatalf("expected original connection id to be preserved, got %#v", query) + } + if query.BindingStatus != savedQueryBindingRebound { + t.Fatalf("expected rebound binding status, got %#v", query) + } + if query.ConnectionFingerprint == "" || query.FingerprintVersion != savedQueryFingerprintVersion { + t.Fatalf("expected fingerprint metadata, got %#v", query) + } +} + +func TestImportSavedQueriesDoesNotRebindAmbiguousFingerprint(t *testing.T) { + app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test")) + app.configDir = t.TempDir() + + for _, id := range []string{"conn-a", "conn-b"} { + if _, err := app.SaveConnection(connection.SavedConnectionInput{ + ID: id, + Name: id, + Config: connection.ConnectionConfig{ + ID: id, + Type: "mysql", + Host: "db.local", + Port: 3306, + User: "app", + Database: "app", + }, + }); err != nil { + t.Fatalf("SaveConnection %s returned error: %v", id, err) + } + } + + imported, err := app.ImportSavedQueries(connection.SavedQueryImportPayload{ + Queries: []connection.SavedQuery{ + { + ID: "saved-ambiguous", + Name: "Ambiguous", + SQL: "select 1", + ConnectionID: "conn-old", + DBName: "app", + CreatedAt: 100, + }, + }, + LegacyConnections: []connection.SavedConnectionInput{ + { + ID: "conn-old", + Name: "Old", + Config: connection.ConnectionConfig{ + ID: "conn-old", + Type: "mysql", + Host: "db.local", + Port: 3306, + User: "app", + Database: "app", + }, + }, + }, + }) + if err != nil { + t.Fatalf("ImportSavedQueries returned error: %v", err) + } + if len(imported) != 1 { + t.Fatalf("expected 1 imported query, got %#v", imported) + } + query := imported[0] + if query.ConnectionID != "conn-old" { + t.Fatalf("expected ambiguous query to keep old connection id, got %#v", query) + } + if query.BindingStatus != savedQueryBindingOrphan { + t.Fatalf("expected orphan binding status, got %#v", query) + } +} + +func TestGetSavedQueriesRebindsStoredFingerprint(t *testing.T) { + app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test")) + app.configDir = t.TempDir() + + current, err := app.SaveConnection(connection.SavedConnectionInput{ + ID: "conn-current", + Name: "Current", + Config: connection.ConnectionConfig{ + ID: "conn-current", + Type: "postgres", + Host: "db.local", + Port: 5432, + User: "app", + Database: "app", + }, + }) + if err != nil { + t.Fatalf("SaveConnection returned error: %v", err) + } + fingerprint, ok := buildSavedConnectionFingerprint(current) + if !ok { + t.Fatal("expected current connection fingerprint to be available") + } + if err := app.savedQueryRepository().saveAll([]connection.SavedQuery{ + { + ID: "saved-stored-fingerprint", + Name: "Stored Fingerprint", + SQL: "select 1", + ConnectionID: "conn-old", + DBName: "app", + CreatedAt: 100, + ConnectionFingerprint: fingerprint, + FingerprintVersion: savedQueryFingerprintVersion, + }, + }); err != nil { + t.Fatalf("saveAll returned error: %v", err) + } + + queries, err := app.GetSavedQueries() + if err != nil { + t.Fatalf("GetSavedQueries returned error: %v", err) + } + if len(queries) != 1 { + t.Fatalf("expected 1 query, got %#v", queries) + } + if queries[0].ConnectionID != current.ID { + t.Fatalf("expected query to rebind to %s, got %#v", current.ID, queries[0]) + } + if queries[0].OriginalConnectionID != "conn-old" { + t.Fatalf("expected original connection id to be preserved, got %#v", queries[0]) + } + if queries[0].BindingStatus != savedQueryBindingRebound { + t.Fatalf("expected rebound binding status, got %#v", queries[0]) + } +} + +func TestRebindSavedQueryUpdatesConnectionAndFingerprint(t *testing.T) { + app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test")) + app.configDir = t.TempDir() + + target, err := app.SaveConnection(connection.SavedConnectionInput{ + ID: "conn-target", + Name: "Target", + Config: connection.ConnectionConfig{ + ID: "conn-target", + Type: "postgres", + Host: "db.local", + Port: 5432, + User: "app", + Database: "app", + }, + }) + if err != nil { + t.Fatalf("SaveConnection returned error: %v", err) + } + if _, err := app.SaveQuery(connection.SavedQuery{ + ID: "saved-orphan", + Name: "Orphan", + SQL: "select 1", + ConnectionID: "conn-old", + DBName: "app", + CreatedAt: 100, + }); err != nil { + t.Fatalf("SaveQuery returned error: %v", err) + } + + rebound, err := app.RebindSavedQuery("saved-orphan", target.ID) + if err != nil { + t.Fatalf("RebindSavedQuery returned error: %v", err) + } + if rebound.ConnectionID != target.ID { + t.Fatalf("expected query to bind to target connection, got %#v", rebound) + } + if rebound.OriginalConnectionID != "conn-old" { + t.Fatalf("expected original connection id to be retained, got %#v", rebound) + } + if rebound.BindingStatus != savedQueryBindingActive { + t.Fatalf("expected active binding status, got %#v", rebound) + } + if rebound.ConnectionFingerprint == "" { + t.Fatalf("expected fingerprint to be stored, got %#v", rebound) + } +} diff --git a/internal/app/saved_query_fingerprint.go b/internal/app/saved_query_fingerprint.go new file mode 100644 index 00000000..59f0cdef --- /dev/null +++ b/internal/app/saved_query_fingerprint.go @@ -0,0 +1,271 @@ +package app + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "sort" + "strings" + + "GoNavi-Wails/internal/connection" +) + +const ( + savedQueryFingerprintVersion = "connection-v1" + savedQueryBindingActive = "active" + savedQueryBindingRebound = "rebound" + savedQueryBindingOrphan = "orphan" +) + +type savedQueryConnectionFingerprintPayload struct { + Version string `json:"version"` + Type string `json:"type"` + Driver string `json:"driver,omitempty"` + Host string `json:"host,omitempty"` + Port int `json:"port,omitempty"` + Hosts []string `json:"hosts,omitempty"` + User string `json:"user,omitempty"` + Database string `json:"database,omitempty"` + UseSSL bool `json:"useSSL,omitempty"` + SSLMode string `json:"sslMode,omitempty"` + UseSSH bool `json:"useSSH,omitempty"` + SSHHost string `json:"sshHost,omitempty"` + SSHPort int `json:"sshPort,omitempty"` + SSHUser string `json:"sshUser,omitempty"` + UseHTTPTunnel bool `json:"useHttpTunnel,omitempty"` + HTTPTunnelHost string `json:"httpTunnelHost,omitempty"` + HTTPTunnelPort int `json:"httpTunnelPort,omitempty"` + HTTPTunnelUser string `json:"httpTunnelUser,omitempty"` + ClickHouseProtocol string `json:"clickHouseProtocol,omitempty"` + OceanBaseProtocol string `json:"oceanBaseProtocol,omitempty"` + Topology string `json:"topology,omitempty"` + ReplicaSet string `json:"replicaSet,omitempty"` + AuthSource string `json:"authSource,omitempty"` + ReadPreference string `json:"readPreference,omitempty"` + MongoSRV bool `json:"mongoSrv,omitempty"` + MongoAuthMechanism string `json:"mongoAuthMechanism,omitempty"` + RedisMaster string `json:"redisMaster,omitempty"` + RedisUser string `json:"redisUser,omitempty"` + MySQLReplicaUser string `json:"mysqlReplicaUser,omitempty"` + MongoReplicaUser string `json:"mongoReplicaUser,omitempty"` +} + +func normalizeFingerprintText(value string) string { + return strings.TrimSpace(value) +} + +func normalizeFingerprintLower(value string) string { + return strings.ToLower(normalizeFingerprintText(value)) +} + +func normalizeFingerprintHosts(values []string) []string { + result := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + host := normalizeFingerprintLower(value) + if host == "" { + continue + } + if _, exists := seen[host]; exists { + continue + } + seen[host] = struct{}{} + result = append(result, host) + } + sort.Strings(result) + return result +} + +func buildConnectionFingerprint(config connection.ConnectionConfig) (string, bool) { + payload := savedQueryConnectionFingerprintPayload{ + Version: savedQueryFingerprintVersion, + Type: normalizeFingerprintLower(config.Type), + Driver: normalizeFingerprintLower(config.Driver), + Host: normalizeFingerprintLower(config.Host), + Port: config.Port, + Hosts: normalizeFingerprintHosts(config.Hosts), + User: normalizeFingerprintText(config.User), + Database: normalizeFingerprintText(config.Database), + UseSSL: config.UseSSL, + SSLMode: normalizeFingerprintLower(config.SSLMode), + UseSSH: config.UseSSH, + SSHHost: normalizeFingerprintLower(config.SSH.Host), + SSHPort: config.SSH.Port, + SSHUser: normalizeFingerprintText(config.SSH.User), + UseHTTPTunnel: config.UseHTTPTunnel, + HTTPTunnelHost: normalizeFingerprintLower(config.HTTPTunnel.Host), + HTTPTunnelPort: config.HTTPTunnel.Port, + HTTPTunnelUser: normalizeFingerprintText(config.HTTPTunnel.User), + ClickHouseProtocol: normalizeFingerprintLower(config.ClickHouseProtocol), + OceanBaseProtocol: normalizeFingerprintLower(config.OceanBaseProtocol), + Topology: normalizeFingerprintLower(config.Topology), + ReplicaSet: normalizeFingerprintText(config.ReplicaSet), + AuthSource: normalizeFingerprintText(config.AuthSource), + ReadPreference: normalizeFingerprintLower(config.ReadPreference), + MongoSRV: config.MongoSRV, + MongoAuthMechanism: normalizeFingerprintLower(config.MongoAuthMechanism), + RedisMaster: normalizeFingerprintText(config.RedisSentinelMaster), + RedisUser: normalizeFingerprintText(config.RedisSentinelUser), + MySQLReplicaUser: normalizeFingerprintText(config.MySQLReplicaUser), + MongoReplicaUser: normalizeFingerprintText(config.MongoReplicaUser), + } + if payload.Type == "" { + return "", false + } + if payload.Host == "" && len(payload.Hosts) == 0 && payload.Database == "" { + return "", false + } + + data, err := json.Marshal(payload) + if err != nil { + return "", false + } + sum := sha256.Sum256(data) + return savedQueryFingerprintVersion + ":" + hex.EncodeToString(sum[:]), true +} + +func buildSavedConnectionFingerprint(view connection.SavedConnectionView) (string, bool) { + return buildConnectionFingerprint(view.Config) +} + +func buildLegacyConnectionFingerprint(input connection.SavedConnectionInput) (string, bool) { + config := input.Config + if strings.TrimSpace(config.ID) == "" { + config.ID = strings.TrimSpace(input.ID) + } + return buildConnectionFingerprint(config) +} + +func indexSavedConnectionsByID(connections []connection.SavedConnectionView) map[string]connection.SavedConnectionView { + result := make(map[string]connection.SavedConnectionView, len(connections)) + for _, item := range connections { + id := strings.TrimSpace(item.ID) + if id == "" { + continue + } + result[id] = item + } + return result +} + +func indexLegacyConnectionsByID(connections []connection.SavedConnectionInput) map[string]connection.SavedConnectionInput { + result := make(map[string]connection.SavedConnectionInput, len(connections)) + for _, item := range connections { + id := strings.TrimSpace(item.ID) + if id == "" { + id = strings.TrimSpace(item.Config.ID) + } + if id == "" { + continue + } + result[id] = item + } + return result +} + +func indexSavedConnectionsByFingerprint(connections []connection.SavedConnectionView) map[string][]connection.SavedConnectionView { + result := make(map[string][]connection.SavedConnectionView) + for _, item := range connections { + fingerprint, ok := buildSavedConnectionFingerprint(item) + if !ok { + continue + } + result[fingerprint] = append(result[fingerprint], item) + } + return result +} + +func applySavedQueryActiveBinding(query connection.SavedQuery, current connection.SavedConnectionView) connection.SavedQuery { + if fingerprint, ok := buildSavedConnectionFingerprint(current); ok { + query.ConnectionFingerprint = fingerprint + query.FingerprintVersion = savedQueryFingerprintVersion + } + query.BindingStatus = savedQueryBindingActive + if strings.TrimSpace(query.OriginalConnectionID) == strings.TrimSpace(query.ConnectionID) { + query.OriginalConnectionID = "" + } + return query +} + +func applySavedQueryOrphanBinding(query connection.SavedQuery, originalConnectionID string, fingerprint string) connection.SavedQuery { + if strings.TrimSpace(query.OriginalConnectionID) == "" { + query.OriginalConnectionID = strings.TrimSpace(originalConnectionID) + } + if strings.TrimSpace(fingerprint) != "" { + query.ConnectionFingerprint = fingerprint + query.FingerprintVersion = savedQueryFingerprintVersion + } + query.BindingStatus = savedQueryBindingOrphan + return query +} + +func rebindSavedQueryByFingerprint( + query connection.SavedQuery, + connectionID string, + fingerprint string, + currentByFingerprint map[string][]connection.SavedConnectionView, +) (connection.SavedQuery, bool) { + fingerprint = strings.TrimSpace(fingerprint) + if fingerprint == "" { + return query, false + } + + matches := currentByFingerprint[fingerprint] + if len(matches) != 1 { + return applySavedQueryOrphanBinding(query, connectionID, fingerprint), true + } + + if strings.TrimSpace(query.OriginalConnectionID) == "" { + query.OriginalConnectionID = connectionID + } + query.ConnectionID = matches[0].ID + query.ConnectionFingerprint = fingerprint + query.FingerprintVersion = savedQueryFingerprintVersion + query.BindingStatus = savedQueryBindingRebound + return query, true +} + +func resolveSavedQueryBinding( + query connection.SavedQuery, + currentByID map[string]connection.SavedConnectionView, + currentByFingerprint map[string][]connection.SavedConnectionView, + legacyByID map[string]connection.SavedConnectionInput, +) connection.SavedQuery { + connectionID := strings.TrimSpace(query.ConnectionID) + if current, found := currentByID[connectionID]; found { + return applySavedQueryActiveBinding(query, current) + } + + if rebound, resolved := rebindSavedQueryByFingerprint(query, connectionID, query.ConnectionFingerprint, currentByFingerprint); resolved { + return rebound + } + + legacy, found := legacyByID[connectionID] + if !found { + return applySavedQueryOrphanBinding(query, connectionID, query.ConnectionFingerprint) + } + + fingerprint, ok := buildLegacyConnectionFingerprint(legacy) + if !ok { + return applySavedQueryOrphanBinding(query, connectionID, query.ConnectionFingerprint) + } + + rebound, _ := rebindSavedQueryByFingerprint(query, connectionID, fingerprint, currentByFingerprint) + return rebound +} + +func resolveSavedQueryBindings( + queries []connection.SavedQuery, + currentConnections []connection.SavedConnectionView, + legacyConnections []connection.SavedConnectionInput, +) []connection.SavedQuery { + currentByID := indexSavedConnectionsByID(currentConnections) + currentByFingerprint := indexSavedConnectionsByFingerprint(currentConnections) + legacyByID := indexLegacyConnectionsByID(legacyConnections) + + result := make([]connection.SavedQuery, 0, len(queries)) + for _, query := range queries { + result = append(result, resolveSavedQueryBinding(query, currentByID, currentByFingerprint, legacyByID)) + } + return result +} diff --git a/internal/connection/saved_query.go b/internal/connection/saved_query.go new file mode 100644 index 00000000..24e245b1 --- /dev/null +++ b/internal/connection/saved_query.go @@ -0,0 +1,19 @@ +package connection + +type SavedQuery struct { + ID string `json:"id"` + Name string `json:"name"` + SQL string `json:"sql"` + ConnectionID string `json:"connectionId"` + DBName string `json:"dbName"` + CreatedAt int64 `json:"createdAt"` + ConnectionFingerprint string `json:"connectionFingerprint,omitempty"` + FingerprintVersion string `json:"fingerprintVersion,omitempty"` + BindingStatus string `json:"bindingStatus,omitempty"` + OriginalConnectionID string `json:"originalConnectionId,omitempty"` +} + +type SavedQueryImportPayload struct { + Queries []SavedQuery `json:"queries"` + LegacyConnections []SavedConnectionInput `json:"legacyConnections,omitempty"` +}