feat(desktop): add database info and SQL statement markers
This commit is contained in:
60 files changed
+2403
-134
No files matched your search
+56
-1
@@ -1,7 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { connectionGroupDisplayName, middleEllipsis, queryResultBaseSql, queryResultExecutionSql, resultSourceRange, tabTooltipLines, tabularResultItems } from "@/lib/tabs/tabPresentation";
|
||||
import { connectionGroupDisplayName, middleEllipsis, queryResultBaseSql, queryResultExecutionSql, resultSourceRange, statementExecutionMarkers, tabTooltipLines, tabularResultItems } from "@/lib/tabs/tabPresentation";
|
||||
import type { ConnectionConfig, QueryTab } from "@/types/database";
|
||||
|
||||
const translations: Record<string, string> = {
|
||||
@@ -222,3 +222,58 @@ describe("query result source ranges", () => {
|
||||
expect(resultSourceRange("SELECT * FROM users; SELECT * FROM users;", { sourceStatement: "SELECT * FROM users" }, undefined, "mysql")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("statement execution markers", () => {
|
||||
it("projects explicit statement indexes to current editor lines", () => {
|
||||
const sql = "SELECT 1;\nSELECT * FROM missing;\nSELECT 3;";
|
||||
const secondFrom = sql.indexOf("SELECT *");
|
||||
const markers = statementExecutionMarkers(
|
||||
sql,
|
||||
[
|
||||
{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1, statement_index: 0, sourceStatement: "SELECT 1", sourceFrom: 0, sourceTo: 8 },
|
||||
{ columns: ["Error"], rows: [["no such table"]], affected_rows: 0, execution_time_ms: 1, execution_error: true, statement_index: 1, sourceStatement: "SELECT * FROM missing", sourceFrom: secondFrom, sourceTo: secondFrom + "SELECT * FROM missing".length },
|
||||
],
|
||||
"sqlite",
|
||||
sql,
|
||||
);
|
||||
|
||||
expect(markers).toEqual([
|
||||
{ from: 0, status: "success", successCount: 1, errorCount: 0 },
|
||||
{ from: secondFrom, status: "error", successCount: 0, errorCount: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits unindexed query-level errors and single-statement executions", () => {
|
||||
expect(statementExecutionMarkers("SELECT 1; SELECT 2;", [{ columns: ["Error"], rows: [["pool failed"]], affected_rows: 0, execution_time_ms: 1, execution_error: true }], "mysql", "SELECT 1; SELECT 2;")).toEqual([]);
|
||||
expect(statementExecutionMarkers("SELECT 1", [{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1, statement_index: 0, sourceStatement: "SELECT 1", sourceFrom: 0, sourceTo: 8 }], "mysql", "SELECT 1")).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps duplicate statements scoped by preserved absolute ranges", () => {
|
||||
const sql = "SELECT * FROM users;\nSELECT * FROM users;";
|
||||
const from = sql.lastIndexOf("SELECT");
|
||||
|
||||
expect(statementExecutionMarkers(sql, [{ columns: ["id"], rows: [[1]], affected_rows: 0, execution_time_ms: 1, statement_index: 1, sourceStatement: "SELECT * FROM users", sourceFrom: from, sourceTo: sql.length - 1 }], "mysql", sql)).toEqual([
|
||||
{ from, status: "success", successCount: 1, errorCount: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("aggregates same-line statements with error precedence", () => {
|
||||
const sql = "SELECT 1; SELECT bad;";
|
||||
expect(
|
||||
statementExecutionMarkers(
|
||||
sql,
|
||||
[
|
||||
{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1, statement_index: 0, sourceStatement: "SELECT 1", sourceFrom: 0, sourceTo: 8 },
|
||||
{ columns: ["Error"], rows: [["bad"]], affected_rows: 0, execution_time_ms: 1, execution_error: true, statement_index: 1, sourceStatement: "SELECT bad", sourceFrom: 10, sourceTo: 20 },
|
||||
],
|
||||
"mysql",
|
||||
sql,
|
||||
),
|
||||
).toEqual([{ from: 0, status: "error", successCount: 1, errorCount: 1 }]);
|
||||
});
|
||||
|
||||
it("invalidates every marker after the editor document changes", () => {
|
||||
const executedSql = "SELECT 1;\nSELECT 2;";
|
||||
expect(statementExecutionMarkers(`-- edited\n${executedSql}`, [{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1, statement_index: 0, sourceStatement: "SELECT 1" }], "mysql", "stale-editor-fingerprint", executedSql)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { decodeTabResultSnapshot, encodeTabResultSnapshot } from "@/lib/tabs/tabResultCache";
|
||||
|
||||
describe("tab result cache statement execution metadata", () => {
|
||||
it("preserves statement identity and the editor fingerprint", () => {
|
||||
const encoded = encodeTabResultSnapshot({
|
||||
results: [
|
||||
{
|
||||
columns: ["value"],
|
||||
rows: [[1]],
|
||||
affected_rows: 0,
|
||||
execution_time_ms: 1,
|
||||
statement_index: 0,
|
||||
sourceStatement: "SELECT 1",
|
||||
},
|
||||
{
|
||||
columns: ["Error"],
|
||||
rows: [["failed"]],
|
||||
affected_rows: 0,
|
||||
execution_time_ms: 1,
|
||||
execution_error: true,
|
||||
statement_index: 1,
|
||||
sourceStatement: "SELECT bad",
|
||||
},
|
||||
],
|
||||
resultEditorFingerprint: "15:0123456789abcdef",
|
||||
cachedAt: 1,
|
||||
});
|
||||
|
||||
const restored = decodeTabResultSnapshot(encoded);
|
||||
|
||||
expect(restored?.resultEditorFingerprint).toBe("15:0123456789abcdef");
|
||||
expect(restored?.results?.map((result) => ({ statementIndex: result.statement_index, error: result.execution_error }))).toEqual([
|
||||
{ statementIndex: 0, error: undefined },
|
||||
{ statementIndex: 1, error: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -51,7 +51,10 @@ function forward<K extends keyof Backend>(name: K): Backend[K] {
|
||||
|
||||
// Connection
|
||||
export const testConnection = forward("testConnection");
|
||||
export const testConnectionWithInfo = forward("testConnectionWithInfo");
|
||||
export const connectDb = forward("connectDb");
|
||||
export const connectionDatabaseInfo = forward("connectionDatabaseInfo");
|
||||
export const saveConnectionDatabaseInfo = forward("saveConnectionDatabaseInfo");
|
||||
export const connectionFinalProxyPort = forward("connectionFinalProxyPort");
|
||||
export const disconnectDb = forward("disconnectDb");
|
||||
export const checkConnectionHealth = forward("checkConnectionHealth");
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type {
|
||||
ConnectionConfig,
|
||||
ConnectionTestResult,
|
||||
DatabaseConnectionInfo,
|
||||
DatabaseInfo,
|
||||
SchemaInfo,
|
||||
LinkedServerInfo,
|
||||
@@ -150,6 +152,7 @@ import type {
|
||||
NacosServiceQuery,
|
||||
} from "@/types/nacos";
|
||||
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
|
||||
import { normalizeConnectionTestResult } from "@/lib/connection/connectionDatabaseInfo";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
@@ -209,10 +212,32 @@ export async function testConnection(config: ConnectionConfig): Promise<string>
|
||||
return post("/api/connection/test", { config });
|
||||
}
|
||||
|
||||
export async function testConnectionWithInfo(config: ConnectionConfig): Promise<ConnectionTestResult> {
|
||||
const response = await fetch(apiUrl("/api/connection/test-info"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ config }),
|
||||
});
|
||||
if (response.status === 404) {
|
||||
return normalizeConnectionTestResult(await testConnection(config), config);
|
||||
}
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
return normalizeConnectionTestResult(await response.json(), config);
|
||||
}
|
||||
|
||||
export async function connectDb(config: ConnectionConfig, clientAttempt?: number): Promise<string> {
|
||||
return post("/api/connection/connect", { config, clientAttempt });
|
||||
}
|
||||
|
||||
export async function connectionDatabaseInfo(connectionId: string, database?: string): Promise<DatabaseConnectionInfo | undefined> {
|
||||
const info = await post<DatabaseConnectionInfo | null>("/api/connection/database-info", { connectionId, database });
|
||||
return info ?? undefined;
|
||||
}
|
||||
|
||||
export async function saveConnectionDatabaseInfo(connectionId: string, databaseInfo: DatabaseConnectionInfo): Promise<void> {
|
||||
return post("/api/connection/database-info/save", { connectionId, databaseInfo });
|
||||
}
|
||||
|
||||
export async function connectionFinalProxyPort(config: ConnectionConfig): Promise<number> {
|
||||
return post("/api/connection/final-proxy-port", { config });
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import type {
|
||||
ConnectionConfig,
|
||||
ConnectionTestResult,
|
||||
DatabaseConnectionInfo,
|
||||
DatabaseInfo,
|
||||
SchemaInfo,
|
||||
LinkedServerInfo,
|
||||
@@ -36,6 +38,7 @@ import type {
|
||||
SshConfigHostEntry,
|
||||
TunnelProfile,
|
||||
} from "@/types/database";
|
||||
import { isTauriCommandUnavailable, normalizeConnectionTestResult } from "@/lib/connection/connectionDatabaseInfo";
|
||||
import type { CollectionInfo } from "@/types/database";
|
||||
import type { SidebarObjectKind } from "@/lib/database/databaseObjectCapabilities";
|
||||
import type { AiConfig, AiTestConnectionResult } from "@/stores/settingsStore";
|
||||
@@ -636,10 +639,29 @@ export async function testConnection(config: ConnectionConfig): Promise<string>
|
||||
return invoke("test_connection", { config });
|
||||
}
|
||||
|
||||
export async function testConnectionWithInfo(config: ConnectionConfig): Promise<ConnectionTestResult> {
|
||||
try {
|
||||
const result = await invoke<unknown>("test_connection_with_info", { config });
|
||||
return normalizeConnectionTestResult(result, config);
|
||||
} catch (error) {
|
||||
if (!isTauriCommandUnavailable(error, "test_connection_with_info")) throw error;
|
||||
return normalizeConnectionTestResult(await testConnection(config), config);
|
||||
}
|
||||
}
|
||||
|
||||
export async function connectDb(config: ConnectionConfig, clientAttempt?: number): Promise<string> {
|
||||
return invoke("connect_db", { config, clientAttempt });
|
||||
}
|
||||
|
||||
export async function connectionDatabaseInfo(connectionId: string, database?: string): Promise<DatabaseConnectionInfo | undefined> {
|
||||
const info = await invoke<DatabaseConnectionInfo | null>("connection_database_info", { connectionId, database });
|
||||
return info ?? undefined;
|
||||
}
|
||||
|
||||
export async function saveConnectionDatabaseInfo(connectionId: string, databaseInfo: DatabaseConnectionInfo): Promise<void> {
|
||||
return invoke("save_connection_database_info", { connectionId, databaseInfo });
|
||||
}
|
||||
|
||||
export async function connectionFinalProxyPort(config: ConnectionConfig): Promise<number> {
|
||||
return invoke("connection_final_proxy_port", { config });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
import { connectionConfigFingerprint, databaseInfoCopyText, databaseInfoRows, isTauriCommandUnavailable, normalizeConnectionTestResult } from "@/lib/connection/connectionDatabaseInfo";
|
||||
|
||||
function config(overrides: Partial<ConnectionConfig> = {}): ConnectionConfig {
|
||||
return {
|
||||
id: "connection-1",
|
||||
name: "Local H2",
|
||||
db_type: "h2",
|
||||
driver_label: "H2",
|
||||
host: "127.0.0.1",
|
||||
port: 9092,
|
||||
username: "sa",
|
||||
password: "secret",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("connectionDatabaseInfo", () => {
|
||||
it("normalizes structured and legacy responses with a configured product fallback", () => {
|
||||
expect(normalizeConnectionTestResult("Connection successful", config())).toEqual({
|
||||
message: "Connection successful",
|
||||
databaseInfo: { productName: "H2" },
|
||||
});
|
||||
expect(normalizeConnectionTestResult("Connection successful", config({ database: "app" })).databaseInfo).toMatchObject({
|
||||
productName: "H2",
|
||||
currentDatabase: "app",
|
||||
});
|
||||
expect(
|
||||
normalizeConnectionTestResult(
|
||||
{
|
||||
message: "Connection successful",
|
||||
databaseInfo: {
|
||||
productName: " H2 ",
|
||||
productVersion: " 2.2.224 ",
|
||||
currentDatabase: " testdb ",
|
||||
serverCharset: " utf8mb4 ",
|
||||
driverName: " ",
|
||||
unquotedIdentifierCase: "UPPER",
|
||||
},
|
||||
},
|
||||
config(),
|
||||
).databaseInfo,
|
||||
).toEqual({
|
||||
productName: "H2",
|
||||
productVersion: "2.2.224",
|
||||
currentDatabase: "testdb",
|
||||
serverComment: undefined,
|
||||
serverCharset: "utf8mb4",
|
||||
serverCollation: undefined,
|
||||
unquotedIdentifierCase: "upper",
|
||||
quotedIdentifierCase: undefined,
|
||||
driverName: undefined,
|
||||
driverVersion: undefined,
|
||||
jdbcVersion: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("fingerprints the complete submitted config without depending on object key order", () => {
|
||||
const original = config({ transport_layers: [{ id: "ssh", type: "ssh", host: "jump", port: 22, user: "root", password: "hop-secret" }] });
|
||||
const reordered = Object.fromEntries(Object.entries(original).reverse()) as unknown as ConnectionConfig;
|
||||
expect(connectionConfigFingerprint(reordered)).toBe(connectionConfigFingerprint(original));
|
||||
expect(connectionConfigFingerprint({ ...original, password: "changed" })).not.toBe(connectionConfigFingerprint(original));
|
||||
expect(connectionConfigFingerprint({ ...original, name: "Renamed" })).not.toBe(connectionConfigFingerprint(original));
|
||||
expect(connectionConfigFingerprint({ ...original, transport_layers: [{ ...original.transport_layers![0], host: "other-jump" }] })).not.toBe(connectionConfigFingerprint(original));
|
||||
expect(connectionConfigFingerprint(original, "")).not.toBe(connectionConfigFingerprint(original, original.name));
|
||||
expect(connectionConfigFingerprint({ ...original, database_info: { productName: "MySQL", productVersion: "8.4.0" } })).toBe(connectionConfigFingerprint(original));
|
||||
});
|
||||
|
||||
it("formats only database metadata for rows and copied text", () => {
|
||||
const info = normalizeConnectionTestResult(
|
||||
{
|
||||
message: "ok",
|
||||
databaseInfo: { productName: "H2", currentDatabase: "app", driverName: "H2 JDBC Driver", jdbcVersion: "4.2" },
|
||||
},
|
||||
config(),
|
||||
).databaseInfo!;
|
||||
const copy = databaseInfoCopyText(
|
||||
info,
|
||||
(field) => field,
|
||||
(value) => value,
|
||||
);
|
||||
|
||||
expect(databaseInfoRows(info).map((row) => row.key)).toEqual(["productName", "currentDatabase", "driverName", "jdbcVersion"]);
|
||||
expect(copy).toContain("productName: H2");
|
||||
expect(copy).toContain("currentDatabase: app");
|
||||
expect(copy).not.toContain("127.0.0.1");
|
||||
expect(copy).not.toContain("secret");
|
||||
expect(copy).not.toContain("connection_string");
|
||||
});
|
||||
|
||||
it("recognizes only explicit missing-command errors for Tauri fallback", () => {
|
||||
expect(isTauriCommandUnavailable("Command test_connection_with_info not found", "test_connection_with_info")).toBe(true);
|
||||
expect(isTauriCommandUnavailable("unknown command 'test_connection_with_info'", "test_connection_with_info")).toBe(true);
|
||||
expect(isTauriCommandUnavailable("Authentication failed", "test_connection_with_info")).toBe(false);
|
||||
expect(isTauriCommandUnavailable("Command test_connection not found", "test_connection_with_info")).toBe(false);
|
||||
expect(isTauriCommandUnavailable("Database not found while invoking command test_connection_with_info", "test_connection_with_info")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { ConnectionConfig, ConnectionTestResult, DatabaseConnectionInfo, IdentifierCase } from "@/types/database";
|
||||
|
||||
export type DatabaseInfoField = keyof DatabaseConnectionInfo;
|
||||
|
||||
export interface DatabaseInfoRow {
|
||||
key: DatabaseInfoField;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const DATABASE_INFO_FIELDS: readonly DatabaseInfoField[] = ["productName", "productVersion", "currentDatabase", "serverComment", "serverCharset", "serverCollation", "unquotedIdentifierCase", "quotedIdentifierCase", "driverName", "driverVersion", "jdbcVersion"];
|
||||
const IDENTIFIER_CASES = new Set<IdentifierCase>(["lower", "upper", "mixed"]);
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function nonBlankString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function identifierCase(value: unknown): IdentifierCase | undefined {
|
||||
const normalized = nonBlankString(value)?.toLowerCase() as IdentifierCase | undefined;
|
||||
return normalized && IDENTIFIER_CASES.has(normalized) ? normalized : undefined;
|
||||
}
|
||||
|
||||
export function configuredDatabaseProductName(config: Pick<ConnectionConfig, "db_type" | "driver_label">): string {
|
||||
return config.driver_label?.trim() || config.db_type;
|
||||
}
|
||||
|
||||
export function normalizeDatabaseConnectionInfo(value: unknown, fallbackProductName?: string, fallbackCurrentDatabase?: string): DatabaseConnectionInfo | undefined {
|
||||
const source = isRecord(value) ? value : {};
|
||||
const result: DatabaseConnectionInfo = {
|
||||
productName: nonBlankString(source.productName) ?? nonBlankString(fallbackProductName),
|
||||
productVersion: nonBlankString(source.productVersion),
|
||||
currentDatabase: nonBlankString(source.currentDatabase) ?? nonBlankString(fallbackCurrentDatabase),
|
||||
serverComment: nonBlankString(source.serverComment),
|
||||
serverCharset: nonBlankString(source.serverCharset),
|
||||
serverCollation: nonBlankString(source.serverCollation),
|
||||
unquotedIdentifierCase: identifierCase(source.unquotedIdentifierCase),
|
||||
quotedIdentifierCase: identifierCase(source.quotedIdentifierCase),
|
||||
driverName: nonBlankString(source.driverName),
|
||||
driverVersion: nonBlankString(source.driverVersion),
|
||||
jdbcVersion: nonBlankString(source.jdbcVersion),
|
||||
};
|
||||
return DATABASE_INFO_FIELDS.some((key) => result[key] !== undefined) ? result : undefined;
|
||||
}
|
||||
|
||||
export function normalizeConnectionTestResult(value: unknown, config: ConnectionConfig): ConnectionTestResult {
|
||||
const fallbackProductName = configuredDatabaseProductName(config);
|
||||
const fallbackCurrentDatabase = config.database;
|
||||
if (typeof value === "string") {
|
||||
return {
|
||||
message: value,
|
||||
databaseInfo: normalizeDatabaseConnectionInfo(undefined, fallbackProductName, fallbackCurrentDatabase),
|
||||
};
|
||||
}
|
||||
if (!isRecord(value) || typeof value.message !== "string") {
|
||||
throw new Error("Invalid connection test response");
|
||||
}
|
||||
return {
|
||||
message: value.message,
|
||||
databaseInfo: normalizeDatabaseConnectionInfo(value.databaseInfo, fallbackProductName, fallbackCurrentDatabase),
|
||||
};
|
||||
}
|
||||
|
||||
function stableValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(stableValue);
|
||||
if (!isRecord(value)) return value;
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(value).sort()) {
|
||||
const child = value[key];
|
||||
if (child !== undefined) result[key] = stableValue(child);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function fnv1a(value: string, seed: number): number {
|
||||
let hash = seed >>> 0;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash ^= value.charCodeAt(index);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
export function connectionConfigFingerprint(config: ConnectionConfig, sourceName = config.name): string {
|
||||
const { database_info: _databaseInfo, ...submittedConfig } = config;
|
||||
const serialized = JSON.stringify(stableValue({ config: submittedConfig, sourceName }));
|
||||
const first = fnv1a(serialized, 0x811c9dc5).toString(16).padStart(8, "0");
|
||||
const second = fnv1a(serialized, 0x9e3779b9).toString(16).padStart(8, "0");
|
||||
return `${first}${second}`;
|
||||
}
|
||||
|
||||
export function databaseInfoRows(info: DatabaseConnectionInfo): DatabaseInfoRow[] {
|
||||
return DATABASE_INFO_FIELDS.flatMap((key) => {
|
||||
const value = info[key];
|
||||
return value ? [{ key, value }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
export function databaseInfoSummary(info: DatabaseConnectionInfo): string {
|
||||
return [info.productName, info.productVersion].filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export function databaseInfoCopyText(info: DatabaseConnectionInfo, fieldLabel: (field: DatabaseInfoField) => string, caseLabel: (value: IdentifierCase) => string): string {
|
||||
return databaseInfoRows(info)
|
||||
.map((row) => {
|
||||
const value = row.key === "unquotedIdentifierCase" || row.key === "quotedIdentifierCase" ? caseLabel(row.value as IdentifierCase) : row.value;
|
||||
return `${fieldLabel(row.key)}: ${value}`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function isTauriCommandUnavailable(error: unknown, command: string): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const escapedCommand = command.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return new RegExp(`unknown command\\s*[:=]?\\s*['"]?${escapedCommand}['"]?`, "i").test(message) || new RegExp(`command\\s+['"]?${escapedCommand}['"]?\\s+(?:was\\s+)?not found`, "i").test(message);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
function fnv1a(value: string, seed: number): number {
|
||||
let hash = seed >>> 0;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash ^= value.charCodeAt(index);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
export function sqlTextFingerprint(sql: string): string {
|
||||
const first = fnv1a(sql, 0x811c9dc5).toString(16).padStart(8, "0");
|
||||
const second = fnv1a(sql, 0x9e3779b9).toString(16).padStart(8, "0");
|
||||
return `${sql.length.toString(16)}:${first}${second}`;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { findConnectionGroupPath } from "@/lib/sidebar/sidebarLayout";
|
||||
import { splitMongoCommandRanges } from "@/lib/mongo/mongoShellCommand";
|
||||
import { executableStatementRanges, splitSqlStatementRanges, type SqlTextRange } from "@/lib/sql/sqlStatementRanges";
|
||||
import { sqlTextFingerprint } from "@/lib/sql/sqlTextFingerprint";
|
||||
import type { ConnectionConfig, DatabaseType, QueryResult, QueryTab } from "@/types/database";
|
||||
|
||||
type Translate = (key: string, params?: Record<string, unknown>) => string;
|
||||
@@ -179,7 +180,7 @@ export function resultSourceRange(editorSql: string, result: Pick<QueryResult, "
|
||||
return { from: result.sourceFrom, to: result.sourceTo, sql: sourceStatement };
|
||||
}
|
||||
|
||||
const statements = databaseType === "redis" ? executableStatementRanges(editorSql, databaseType) : databaseType === "mongodb" ? splitMongoCommandRanges(editorSql).map(({ from, to, text }) => ({ from, to, sql: text })) : splitSqlStatementRanges(editorSql, databaseType);
|
||||
const statements = statementRanges(editorSql, databaseType);
|
||||
const indexed = typeof resultIndex === "number" ? statements[resultIndex] : undefined;
|
||||
if (indexed?.sql === sourceStatement) {
|
||||
return { from: indexed.from, to: indexed.to, sql: indexed.sql };
|
||||
@@ -191,6 +192,63 @@ export function resultSourceRange(editorSql: string, result: Pick<QueryResult, "
|
||||
return { from: match.from, to: match.to, sql: match.sql };
|
||||
}
|
||||
|
||||
export type StatementExecutionMarkerStatus = "success" | "error";
|
||||
|
||||
export interface StatementExecutionMarker {
|
||||
from: number;
|
||||
status: StatementExecutionMarkerStatus;
|
||||
successCount: number;
|
||||
errorCount: number;
|
||||
}
|
||||
|
||||
function lineStartOffset(sql: string, from: number): number {
|
||||
return sql.lastIndexOf("\n", Math.max(0, from - 1)) + 1;
|
||||
}
|
||||
|
||||
function statementRanges(sql: string, databaseType?: DatabaseType): SqlTextRange[] {
|
||||
if (databaseType === "redis") return executableStatementRanges(sql, databaseType);
|
||||
if (databaseType === "mongodb") return splitMongoCommandRanges(sql).map(({ from, to, text }) => ({ from, to, sql: text }));
|
||||
return splitSqlStatementRanges(sql, databaseType);
|
||||
}
|
||||
|
||||
export function statementExecutionMarkers(editorSql: string, results: QueryResult[] | undefined, databaseType?: DatabaseType, submittedSql = editorSql, executionEditorFingerprint = sqlTextFingerprint(editorSql)): StatementExecutionMarker[] {
|
||||
if (!results?.length || sqlTextFingerprint(editorSql) !== executionEditorFingerprint) return [];
|
||||
const submittedStatements = statementRanges(submittedSql, databaseType);
|
||||
if (submittedStatements.length <= 1) return [];
|
||||
const editorStatements = submittedSql === editorSql ? submittedStatements : statementRanges(editorSql, databaseType);
|
||||
|
||||
const byLine = new Map<number, { success: number; error: number }>();
|
||||
for (const result of results) {
|
||||
if (!Number.isInteger(result.statement_index) || result.statement_index! < 0) continue;
|
||||
const statementIndex = result.statement_index!;
|
||||
const submittedStatement = submittedStatements[statementIndex];
|
||||
if (!submittedStatement || submittedStatement.sql !== result.sourceStatement) continue;
|
||||
const range =
|
||||
typeof result.sourceFrom === "number" && typeof result.sourceTo === "number" && editorSql.slice(result.sourceFrom, result.sourceTo) === result.sourceStatement
|
||||
? { from: result.sourceFrom, to: result.sourceTo, sql: result.sourceStatement }
|
||||
: editorStatements[statementIndex]?.sql === result.sourceStatement
|
||||
? editorStatements[statementIndex]
|
||||
: undefined;
|
||||
if (!range) continue;
|
||||
const from = lineStartOffset(editorSql, range.from);
|
||||
const current = byLine.get(from) ?? { success: 0, error: 0 };
|
||||
if (result.execution_error === true) current.error += 1;
|
||||
else current.success += 1;
|
||||
byLine.set(from, current);
|
||||
}
|
||||
|
||||
return [...byLine.entries()]
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([from, counts]) => {
|
||||
return {
|
||||
from,
|
||||
status: counts.error > 0 ? "error" : "success",
|
||||
successCount: counts.success,
|
||||
errorCount: counts.error,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function queryResultBaseSql(tab: Pick<QueryTab, "result" | "resultBaseSql" | "lastExecutedSql" | "sql">): string {
|
||||
return resultSqlForGrid(tab);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface TabResultSnapshot {
|
||||
result?: QueryResult;
|
||||
results?: QueryResult[];
|
||||
activeResultIndex?: number;
|
||||
resultEditorFingerprint?: string;
|
||||
/**
|
||||
* Source ordering retained while a local grid sort is active. It must travel
|
||||
* with the snapshot so clearing the sort after a cache/archive restore can
|
||||
@@ -41,6 +42,7 @@ export interface TabResultSnapshot {
|
||||
interface ColumnarQueryResult {
|
||||
columns: string[];
|
||||
execution_error?: true;
|
||||
statement_index?: number;
|
||||
column_types?: string[];
|
||||
columnValues: CellValue[][];
|
||||
rowCount: number;
|
||||
@@ -138,6 +140,7 @@ function stripSessionIds(result: QueryResult | undefined): QueryResult | undefin
|
||||
return {
|
||||
columns: [...result.columns],
|
||||
execution_error: result.execution_error,
|
||||
statement_index: result.statement_index,
|
||||
column_types: result.column_types ? [...result.column_types] : undefined,
|
||||
rows: result.rows.map((row) => [...row]),
|
||||
mongo_documents: result.mongo_documents ? clonePlain(result.mongo_documents) : undefined,
|
||||
@@ -174,6 +177,7 @@ function toColumnarResult(result: QueryResult | undefined): ColumnarQueryResult
|
||||
return removeUndefinedFields({
|
||||
columns: [...result.columns],
|
||||
execution_error: result.execution_error,
|
||||
statement_index: result.statement_index,
|
||||
column_types: result.column_types ? [...result.column_types] : undefined,
|
||||
columnValues,
|
||||
rowCount: result.rows.length,
|
||||
@@ -195,6 +199,7 @@ function fromColumnarResult(result: ColumnarQueryResult | undefined): QueryResul
|
||||
return {
|
||||
columns: [...result.columns],
|
||||
execution_error: result.execution_error,
|
||||
statement_index: result.statement_index,
|
||||
column_types: result.column_types ? [...result.column_types] : undefined,
|
||||
rows,
|
||||
mongo_documents: result.mongo_documents ? clonePlain(result.mongo_documents) : undefined,
|
||||
@@ -389,6 +394,7 @@ export function buildTabResultSnapshot(tab: QueryTab): TabResultSnapshot | undef
|
||||
result: stripSessionIds(tab.result),
|
||||
results: stripResultSessionIds(tab.results),
|
||||
activeResultIndex: tab.activeResultIndex,
|
||||
resultEditorFingerprint: tab.resultEditorFingerprint,
|
||||
resultLocalSortOriginalRows: tab.resultLocalSortOriginalRows?.map((row) => [...row]),
|
||||
resultLocalSortOriginalMongoDocuments: tab.resultLocalSortOriginalMongoDocuments ? clonePlain(tab.resultLocalSortOriginalMongoDocuments) : undefined,
|
||||
resultRuns: stripResultRunSessionIds(tab.resultRuns),
|
||||
|
||||
Reference in new issue
Block a user