feat(desktop): add database info and SQL statement markers

This commit is contained in:
shen authored and GitHub committed 2026-07-15 18:35:34 +08:00
1 parent 83f7604a11
commit 3748d83cde
60 files changed
+2403 -134

No files matched your search

@@ -0,0 +1,122 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ConnectionConfig } from "@/types/database";
function installLocalStorage() {
const data = new Map<string, string>();
vi.stubGlobal("localStorage", {
getItem: vi.fn((key: string) => data.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => data.set(key, value)),
removeItem: vi.fn((key: string) => data.delete(key)),
});
}
function mysqlConnection(): ConnectionConfig {
return {
id: "mysql-info",
name: "MySQL",
db_type: "mysql",
driver_profile: "mysql",
driver_label: "MySQL",
host: "127.0.0.1",
port: 3306,
username: "root",
password: "secret",
database: "app",
};
}
describe("connectionStore database info", () => {
beforeEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
installLocalStorage();
setActivePinia(createPinia());
});
it("persists metadata from a successful live connection without marking it disconnected", async () => {
const config = mysqlConnection();
const saveConnections = vi.fn().mockResolvedValue(undefined);
const saveConnectionDatabaseInfo = vi.fn().mockResolvedValue(undefined);
const connectionDatabaseInfo = vi.fn().mockResolvedValue({ productName: "MySQL", productVersion: "8.0.34" });
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
connectDb: vi.fn().mockResolvedValue(config.id),
connectionDatabaseInfo,
saveConnectionDatabaseInfo,
saveConnections,
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
connectionIdentifierQuote: vi.fn().mockResolvedValue(undefined),
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
await store.addConnection(config);
await store.connect(config);
await vi.waitFor(() => expect(saveConnectionDatabaseInfo).toHaveBeenCalled());
expect(connectionDatabaseInfo).toHaveBeenCalledWith(config.id);
expect(saveConnectionDatabaseInfo).toHaveBeenCalledWith(config.id, {
productName: "MySQL",
productVersion: "8.0.34",
currentDatabase: "app",
serverComment: undefined,
serverCharset: undefined,
serverCollation: undefined,
unquotedIdentifierCase: undefined,
quotedIdentifierCase: undefined,
driverName: undefined,
driverVersion: undefined,
jdbcVersion: undefined,
});
expect(store.getConfig(config.id)?.database_info?.productVersion).toBe("8.0.34");
expect(store.connectedIds.has(config.id)).toBe(true);
});
it("does not delay connection success while optional metadata is loading", async () => {
const config = mysqlConnection();
let resolveDatabaseInfo!: (value: { productName: string; productVersion: string }) => void;
const databaseInfo = new Promise<{ productName: string; productVersion: string }>((resolve) => {
resolveDatabaseInfo = resolve;
});
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
connectDb: vi.fn().mockResolvedValue(config.id),
connectionDatabaseInfo: vi.fn(() => databaseInfo),
saveConnectionDatabaseInfo: vi.fn().mockResolvedValue(undefined),
saveConnections: vi.fn().mockResolvedValue(undefined),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
connectionIdentifierQuote: vi.fn().mockResolvedValue(undefined),
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
await store.addConnection(config);
await expect(store.connect(config)).resolves.toBe(config.id);
expect(store.connectedIds.has(config.id)).toBe(true);
resolveDatabaseInfo({ productName: "MySQL", productVersion: "8.0.34" });
await vi.waitFor(() => expect(store.getConfig(config.id)?.database_info?.productVersion).toBe("8.0.34"));
});
it("keeps a successful connection when optional metadata refresh fails", async () => {
const config = mysqlConnection();
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
connectDb: vi.fn().mockResolvedValue(config.id),
connectionDatabaseInfo: vi.fn().mockRejectedValue(new Error("metadata unavailable")),
saveConnections: vi.fn().mockResolvedValue(undefined),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
connectionIdentifierQuote: vi.fn().mockResolvedValue(undefined),
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
await store.addConnection(config);
await expect(store.connect(config)).resolves.toBe(config.id);
expect(store.connectedIds.has(config.id)).toBe(true);
});
});
@@ -99,6 +99,34 @@ describe("queryStore multi-statement errors", () => {
});
});
it("uses explicit statement indexes for selected multi-statement ranges", async () => {
mocks.executeMulti.mockResolvedValue([
{ columns: ["value"], rows: [[2]], affected_rows: 0, execution_time_ms: 1, statement_index: 1 },
{ columns: ["Error"], rows: [["failed"]], affected_rows: 0, execution_time_ms: 1, execution_error: true, statement_index: 2 },
]);
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("mysql-1", "app", "Query");
const selectedSql = "SELECT 1; SELECT 2; SELECT bad";
await store.executeTabSql(tabId, selectedSql, { sourceOffset: 10 });
const tab = store.tabs.find((item) => item.id === tabId)!;
expect(tab.results?.[0]).toMatchObject({
sourceStatement: "SELECT 2",
sourceFrom: 20,
sourceTo: 28,
statement_index: 1,
});
expect(tab.results?.[1]).toMatchObject({
sourceStatement: "SELECT bad",
sourceFrom: 30,
sourceTo: 40,
statement_index: 2,
execution_error: true,
});
});
it("does not promote an unmarked Error alias without type metadata as a batch failure", async () => {
mocks.executeMulti.mockResolvedValue([
{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 },
+51 -1
View File
@@ -1,7 +1,23 @@
import { defineStore } from "pinia";
import { uuid } from "@/lib/common/utils";
import { ref, computed, watch, markRaw } from "vue";
import type { ColumnInfo, CompletionAssistantCandidate, CompletionAssistantObjectKind, CompletionAssistantRequest, ConnectionConfig, CatalogInfo, ForeignKeyInfo, ObjectInfo, SchemaInfo, SidebarLayout, TableInfo, TreeNode, TunnelProfile, VectorCollectionMeta } from "@/types/database";
import type {
ColumnInfo,
CompletionAssistantCandidate,
CompletionAssistantObjectKind,
CompletionAssistantRequest,
ConnectionConfig,
DatabaseConnectionInfo,
CatalogInfo,
ForeignKeyInfo,
ObjectInfo,
SchemaInfo,
SidebarLayout,
TableInfo,
TreeNode,
TunnelProfile,
VectorCollectionMeta,
} from "@/types/database";
import { applyPinnedTreeNodeState, inheritNaturalTreeNodeOrder, migrateLegacyPinnedTreeNodeIds, syncPinnedTreeNodeStateInPlace, treeNodePinKey } from "@/lib/app/pinnedItems";
import {
reconcileLayout,
@@ -67,6 +83,7 @@ import { REDIS_SCAN_PAGE_SIZE_DEFAULT } from "@/lib/redis/redisKeyPattern";
import { appendAgentDriverUpdateHint, hasAgentDriverUpdate, type AgentDriverInstallState } from "@/lib/connection/agentDriverInstallHint";
import { appendConnectionErrorHints } from "@/lib/connection/connectionErrorHints";
import { appendVisibleDatabaseSelection } from "@/lib/connection/connectionVisibleDatabases";
import { configuredDatabaseProductName, connectionConfigFingerprint, normalizeDatabaseConnectionInfo } from "@/lib/connection/connectionDatabaseInfo";
import { createMetadataLoadTrace, logMetadataLoadTrace, MetadataLoadCoordinator, type MetadataLoadTraceLogger } from "@/lib/metadata/metadataLoadCoordinator";
import type { MetadataScopeInput } from "@/lib/metadata/metadataLoadScope";
import { MetadataResultCache, type MetadataCacheInvalidation } from "@/lib/metadata/metadataResultCache";
@@ -872,6 +889,7 @@ export const useConnectionStore = defineStore("connection", () => {
query_timeout_secs: config.query_timeout_secs ?? 30,
idle_timeout_secs: config.idle_timeout_secs ?? 60,
keepalive_interval_secs: config.keepalive_interval_secs ?? DEFAULT_KEEPALIVE_INTERVAL_SECS,
database_info: normalizeDatabaseConnectionInfo(config.database_info),
};
}
@@ -1767,6 +1785,35 @@ export const useConnectionStore = defineStore("connection", () => {
}
}
async function updateConnectionDatabaseInfo(connectionId: string, databaseInfo: DatabaseConnectionInfo, expectedConfigFingerprint?: string): Promise<void> {
const normalized = normalizeDatabaseConnectionInfo(databaseInfo);
if (!normalized) return;
const current = connections.value.find((connection) => connection.id === connectionId);
if (!current) return;
if (expectedConfigFingerprint && connectionConfigFingerprint(current) !== expectedConfigFingerprint) return;
if (JSON.stringify(current.database_info) === JSON.stringify(normalized)) return;
await api.saveConnectionDatabaseInfo(connectionId, normalized);
const index = connections.value.findIndex((connection) => connection.id === connectionId);
if (index < 0) return;
if (expectedConfigFingerprint && connectionConfigFingerprint(connections.value[index]) !== expectedConfigFingerprint) return;
const nextConnections = [...connections.value];
nextConnections[index] = { ...nextConnections[index], database_info: normalized };
connections.value = nextConnections;
rebuildTreeNodes();
}
async function refreshConnectedDatabaseInfo(connectionId: string, config: ConnectionConfig): Promise<void> {
const expectedConfigFingerprint = connectionConfigFingerprint(config);
try {
const detected = await api.connectionDatabaseInfo(connectionId);
const normalized = normalizeDatabaseConnectionInfo(detected, configuredDatabaseProductName(config), config.database);
if (normalized) await updateConnectionDatabaseInfo(connectionId, normalized, expectedConfigFingerprint);
} catch {
// Database metadata is optional and must not turn a successful connection into a failure.
}
}
async function syncMongoLegacyDriverFallback(connectionId: string, previousConfig: ConnectionConfig) {
if (!isDesktop || previousConfig.db_type !== "mongodb" || previousConfig.driver_profile === MONGO_LEGACY_DRIVER_PROFILE) {
return;
@@ -1951,6 +1998,7 @@ export const useConnectionStore = defineStore("connection", () => {
await ensureLocalConnectionAttemptActiveAfterConnectResult(config.id, localAttempt, id);
activeConnectionId.value = id;
connectedIds.value.add(id);
void refreshConnectedDatabaseInfo(id, { ...config, id });
await refreshConnectionIdentifierQuote(id, { ...config, id });
if (id !== config.id) markSuccessfulLocalConnectionAttempt(config.id, localAttempt);
markSuccessfulLocalConnectionAttempt(id, localAttempt);
@@ -2115,6 +2163,7 @@ export const useConnectionStore = defineStore("connection", () => {
await syncMongoLegacyDriverFallback(connectionId, config);
await ensureLocalConnectionAttemptActiveAfterConnectResult(connectionId, localAttempt, id);
connectedIds.value.add(connectionId);
void refreshConnectedDatabaseInfo(connectionId, config);
await refreshConnectionIdentifierQuote(connectionId, config);
markSuccessfulLocalConnectionAttempt(connectionId, localAttempt);
markConnectionHealthChecked(connectionId);
@@ -5278,6 +5327,7 @@ export const useConnectionStore = defineStore("connection", () => {
pasteConnectionClipboard,
addEphemeralConnection,
updateConnection,
updateConnectionDatabaseInfo,
setDefaultDatabase,
clearDefaultDatabase,
isDefaultDatabase,
+13 -1
View File
@@ -55,6 +55,7 @@ import { useSavedSqlStore } from "@/stores/savedSqlStore";
import { createSavedSqlEditorPosition, initSavedSqlEditorPositions, restoreSavedSqlEditorPosition, saveSavedSqlEditorPosition } from "@/lib/app/savedSqlEditorPosition";
import { ensureSqlExtension } from "@/lib/savedSql/savedSqlFileName";
import { safeLocalStorageGet, safeLocalStorageRemove } from "@/lib/backend/safeStorage";
import { sqlTextFingerprint } from "@/lib/sql/sqlTextFingerprint";
import type { SavedSqlFile } from "@/types/database";
const ORACLE_LIKE_METADATA_TYPES = new Set<string>(["oracle", "dameng", "oceanbase-oracle"]);
@@ -143,7 +144,10 @@ function annotateQueryResultSources(results: QueryResult[], sql: string, databas
const statements = splitSqlStatementRanges(sql, databaseType);
let statementIndex = 0;
for (const result of results) {
const statement = statements[statementIndex++];
const explicitIndex = Number.isInteger(result.statement_index) && result.statement_index! >= 0 ? result.statement_index : undefined;
const sourceIndex = explicitIndex ?? statementIndex;
statementIndex = Math.max(statementIndex, sourceIndex + 1);
const statement = statements[sourceIndex];
if (!statement) continue;
annotateQueryResultSource(result, statement.sql, database, databaseType, sourceOffset === undefined ? undefined : { from: sourceOffset + statement.from, to: sourceOffset + statement.to });
}
@@ -454,6 +458,7 @@ export const useQueryStore = defineStore("query", () => {
tab.result = undefined;
tab.results = undefined;
tab.activeResultIndex = undefined;
tab.resultEditorFingerprint = undefined;
tab.resultLocalSortOriginalRows = undefined;
tab.resultLocalSortOriginalMongoDocuments = undefined;
tab.resultSortMode = undefined;
@@ -500,6 +505,7 @@ export const useQueryStore = defineStore("query", () => {
tab.results = run.results;
tab.activeResultIndex = run.activeResultIndex;
tab.resultBaseSql = run.resultBaseSql;
tab.resultEditorFingerprint = run.resultEditorFingerprint;
tab.resultSortedSql = run.resultSortedSql;
tab.resultSortColumn = run.resultSortColumn;
tab.resultSortColumnIndex = run.resultSortColumnIndex;
@@ -598,6 +604,7 @@ export const useQueryStore = defineStore("query", () => {
result: run.result,
results: run.results,
activeResultIndex: run.activeResultIndex,
resultEditorFingerprint: run.resultEditorFingerprint,
resultRuns: [run],
activeResultRunId: run.id,
queryAnalysis: run.queryAnalysis,
@@ -642,6 +649,7 @@ export const useQueryStore = defineStore("query", () => {
results: tab.results,
activeResultIndex: tab.activeResultIndex,
resultBaseSql: tab.resultBaseSql,
resultEditorFingerprint: tab.resultEditorFingerprint,
resultSortedSql: tab.resultSortedSql,
resultSortColumn: tab.resultSortColumn,
resultSortColumnIndex: tab.resultSortColumnIndex,
@@ -693,6 +701,7 @@ export const useQueryStore = defineStore("query", () => {
results: tab.results,
activeResultIndex: tab.activeResultIndex,
resultBaseSql: tab.resultBaseSql,
resultEditorFingerprint: tab.resultEditorFingerprint,
resultSortedSql: tab.resultSortedSql,
resultSortColumn: tab.resultSortColumn,
resultSortColumnIndex: tab.resultSortColumnIndex,
@@ -2597,6 +2606,7 @@ export const useQueryStore = defineStore("query", () => {
if (!tab || !sql.trim()) return;
const executionId = uuid();
const executionEditorFingerprint = tab.mode === "query" ? sqlTextFingerprint(tab.sql) : undefined;
const traceId = executionId.slice(0, 8);
const startedAt = performance.now();
const elapsed = () => `${Math.round(performance.now() - startedAt)}ms`;
@@ -3133,6 +3143,7 @@ export const useQueryStore = defineStore("query", () => {
current.result = results[0];
}
current.resultBaseSql = shouldReplaceActiveResultInGroup ? (current.resultBaseSql ?? queryBaseSql) : queryBaseSql;
current.resultEditorFingerprint = shouldReplaceActiveResultInGroup ? (current.resultEditorFingerprint ?? executionEditorFingerprint) : executionEditorFingerprint;
current.resultSortedSql = resultSortedSql;
current.resultPageSql = pageSql;
current.resultPageLimit = pageLimit;
@@ -3644,6 +3655,7 @@ export const useQueryStore = defineStore("query", () => {
const activeIndex = snapshot.activeResultIndex ?? 0;
tab.results = results;
tab.activeResultIndex = snapshot.activeResultIndex;
tab.resultEditorFingerprint = snapshot.resultEditorFingerprint;
tab.result = snapshot.result ? markQueryResultRowsRaw(snapshot.result) : results?.[activeIndex] ? markQueryResultRowsRaw(results[activeIndex]) : undefined;
tab.resultLocalSortOriginalRows = snapshot.resultLocalSortOriginalRows ? markRaw(snapshot.resultLocalSortOriginalRows) : undefined;
tab.resultLocalSortOriginalMongoDocuments = snapshot.resultLocalSortOriginalMongoDocuments ? markRaw(snapshot.resultLocalSortOriginalMongoDocuments) : undefined;