feat(desktop): add database info and SQL statement markers
This commit is contained in:
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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user