feat(desktop): add database info and SQL statement markers
This commit is contained in:
60 files changed
+2403
-134
No files matched your search
@@ -13,7 +13,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type { ConnectionConfig, DatabaseType, HttpTunnelConfig, JdbcDriverInfo, JdbcLocalBundleInfo, JdbcMavenBundleInfo, ProxyTunnelConfig, SshConfigHostEntry, SshTunnelConfig, TransportLayerConfig } from "@/types/database";
|
||||
import type { ConnectionConfig, ConnectionTestResult, DatabaseConnectionInfo, DatabaseType, HttpTunnelConfig, IdentifierCase, JdbcDriverInfo, JdbcLocalBundleInfo, JdbcMavenBundleInfo, ProxyTunnelConfig, SshConfigHostEntry, SshTunnelConfig, TransportLayerConfig } from "@/types/database";
|
||||
import type { InfluxDbExternalConfig, InfluxDbVersion } from "@/types/influxdb";
|
||||
import type { MqAdminConfig, MqAuth, MqSystemKind } from "@/types/mq";
|
||||
import type { NacosAdminConfig, NacosAuthConfig } from "@/types/nacos";
|
||||
@@ -37,6 +37,7 @@ import { MQ_PINNED_VERSION_OPTIONS, pinnedVersionToSelection, selectionToPinnedV
|
||||
import { mongodbAuthFailureHint, mongoUrlParam, mongoUrlParamIsTrue, normalizeMongoTlsFormState, setMongoUrlParam, setMongoUrlParamBoolean } from "@/lib/mongo/mongoConnectionOptions";
|
||||
import { mysqlCleartextPasswordAuthEnabled, setMysqlCleartextPasswordAuthEnabled } from "@/lib/database/mysqlConnectionOptions";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
import { configuredDatabaseProductName, connectionConfigFingerprint, databaseInfoCopyText, databaseInfoRows, normalizeDatabaseConnectionInfo, type DatabaseInfoField } from "@/lib/connection/connectionDatabaseInfo";
|
||||
import { agentDriverInstallKey, appendAgentDriverUpdateHint, hasAgentDriverUpdate, showAgentDriverInstallHint, type AgentDriverInstallState, type DriverStoreFocus } from "@/lib/connection/agentDriverInstallHint";
|
||||
import { prestoSqlBuiltinDriverPaths } from "@/lib/database/prestoSqlBuiltinDriver";
|
||||
import { SQLITE_DATABASE_FILE_EXTENSIONS } from "@/lib/database/databaseFileDetection";
|
||||
@@ -46,7 +47,35 @@ import { normalizeKafkaBootstrapServers } from "@/lib/connection/kafkaBootstrapS
|
||||
import { detectMqUiAuthKind, isMqAuthKindAllowedForSystem, type MqUiAuthKind } from "@/lib/connection/mqAuth";
|
||||
import { driverInstallProgressPercent, type DriverInstallProgress } from "@/lib/connection/driverInstallProgressUi";
|
||||
import { isSqlServerLegacyCompatibilityMode, requiresSqlServerLegacyCompatibilityComponent, setSqlServerLegacyCompatibilityMode, SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY } from "@/lib/connection/sqlServerLegacyCompatibility";
|
||||
import { ArrowLeft, ArrowDown, ArrowUp, CheckSquare, ChevronRight, CircleHelp, Copy, ExternalLink, FilePlus2, FolderOpen, GripVertical, Grid3X3, KeyRound, Link2, List, ListFilter, Loader2, Pencil, Pipette, Plus, RefreshCw, Search, ShieldAlert, ShieldCheck, Square, Trash2 } from "@lucide/vue";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
CheckSquare,
|
||||
ChevronRight,
|
||||
CircleHelp,
|
||||
Copy,
|
||||
Database as DatabaseLucide,
|
||||
ExternalLink,
|
||||
FilePlus2,
|
||||
FolderOpen,
|
||||
GripVertical,
|
||||
Grid3X3,
|
||||
KeyRound,
|
||||
Link2,
|
||||
List,
|
||||
ListFilter,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Pipette,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
Square,
|
||||
Trash2,
|
||||
} from "@lucide/vue";
|
||||
import { buildDraftVisibleDatabasesConnectionId, connectionCanChooseVisibleDatabases, initialVisibleDatabaseSelection, visibleDatabaseSelectionIsStale } from "@/lib/connection/connectionVisibleDatabases";
|
||||
import { canSaveVisibleDatabaseSelection, connectionUsesVisibleSchemaFilter, filterDatabaseNamesForVisiblePicker, isSystemDatabaseName, normalizeVisibleDatabaseSelection, buildDraftVisibleSchemasConnectionId, normalizeVisibleSchemaSelection } from "@/lib/database/visibleDatabases";
|
||||
import { isSchemaAware, isSingleDatabase } from "@/lib/database/databaseFeatureSupport";
|
||||
@@ -101,6 +130,8 @@ type LegacyTransportFields = {
|
||||
};
|
||||
type LegacyConnectionConfig = ConnectionConfig & LegacyTransportFields;
|
||||
type ConnectionForm = Omit<ConnectionConfig, "id">;
|
||||
type ConnectionTestState = ConnectionTestResult & { ok: boolean };
|
||||
type SuccessfulConnectionTest = { result: ConnectionTestResult; config: ConnectionConfig };
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
@@ -126,7 +157,13 @@ const store = useConnectionStore();
|
||||
const tunnelProfileStore = useTunnelProfileStore();
|
||||
const isTesting = ref(false);
|
||||
const isSaving = ref(false);
|
||||
const testResult = ref<{ ok: boolean; message: string } | null>(null);
|
||||
const testResult = ref<ConnectionTestState | null>(null);
|
||||
const testedConfigFingerprint = ref("");
|
||||
const testedConfigId = ref("");
|
||||
const testedGeneratedName = ref("");
|
||||
const savedDatabaseInfo = ref<DatabaseConnectionInfo | null>(null);
|
||||
const savedDatabaseInfoFingerprint = ref("");
|
||||
const savedConnectionConfigFingerprint = ref("");
|
||||
const showAgentInstallDialog = ref(false);
|
||||
const agentInstallRunning = ref(false);
|
||||
const agentInstallDriverKey = ref("");
|
||||
@@ -1038,7 +1075,7 @@ function isNacosAdminEndpointNotFound(message: string): boolean {
|
||||
return /Nacos admin endpoint was not found/i.test(message);
|
||||
}
|
||||
|
||||
async function tryNacosDockerConsoleFallback(config: ConnectionConfig, originalError: string, runId: number): Promise<string | null> {
|
||||
async function tryNacosDockerConsoleFallback(config: ConnectionConfig, originalError: string, runId: number): Promise<SuccessfulConnectionTest | null> {
|
||||
if (config.db_type !== "nacos" || !isNacosAdminEndpointNotFound(originalError)) return null;
|
||||
const fallbackUrl = dockerNacosConsoleFallbackUrl(nacosServerAddr.value);
|
||||
if (!fallbackUrl || fallbackUrl === nacosServerAddr.value.trim()) return null;
|
||||
@@ -1046,9 +1083,15 @@ async function tryNacosDockerConsoleFallback(config: ConnectionConfig, originalE
|
||||
const previousUrl = nacosServerAddr.value;
|
||||
nacosServerAddr.value = fallbackUrl;
|
||||
try {
|
||||
const fallbackConfig = connectionConfigForSubmit(config.id);
|
||||
const message = await testConnectionWithTimeout(fallbackConfig, runId);
|
||||
return `${message} ${t("connection.nacosConsoleUrlAutoAdjusted", { from: previousUrl.trim(), to: fallbackUrl })}`;
|
||||
const fallbackConfig = connectionConfigForSubmit(config.id, config.name);
|
||||
const result = await testConnectionWithTimeout(fallbackConfig, runId);
|
||||
return {
|
||||
config: fallbackConfig,
|
||||
result: {
|
||||
...result,
|
||||
message: `${result.message} ${t("connection.nacosConsoleUrlAutoAdjusted", { from: previousUrl.trim(), to: fallbackUrl })}`,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
nacosServerAddr.value = previousUrl;
|
||||
return null;
|
||||
@@ -1199,15 +1242,63 @@ function isSqlServerTlsHandshakeFailure(message: string): boolean {
|
||||
return text.includes("sql server") && text.includes("tls") && (text.includes("handshake") || text.includes("eof") || text.includes("performing i/o"));
|
||||
}
|
||||
|
||||
async function testConnectionWithTimeout(config: ConnectionConfig, runId: number): Promise<string> {
|
||||
function clearTestedConnectionInfo() {
|
||||
testedConfigFingerprint.value = "";
|
||||
testedConfigId.value = "";
|
||||
testedGeneratedName.value = "";
|
||||
}
|
||||
|
||||
function clearSavedDatabaseInfo() {
|
||||
savedDatabaseInfo.value = null;
|
||||
savedDatabaseInfoFingerprint.value = "";
|
||||
savedConnectionConfigFingerprint.value = "";
|
||||
}
|
||||
|
||||
function applySavedDatabaseInfo(config: ConnectionConfig) {
|
||||
clearSavedDatabaseInfo();
|
||||
try {
|
||||
const current = connectionConfigForSubmit(config.id, config.name);
|
||||
savedConnectionConfigFingerprint.value = connectionConfigFingerprint(current, form.value.name);
|
||||
const info = normalizeDatabaseConnectionInfo(config.database_info);
|
||||
if (info) {
|
||||
savedDatabaseInfo.value = info;
|
||||
savedDatabaseInfoFingerprint.value = savedConnectionConfigFingerprint.value;
|
||||
}
|
||||
} catch {
|
||||
clearSavedDatabaseInfo();
|
||||
}
|
||||
}
|
||||
|
||||
function applySuccessfulConnectionTest(result: ConnectionTestResult, config: ConnectionConfig, sourceName: string) {
|
||||
testResult.value = { ok: true, ...result };
|
||||
testedConfigFingerprint.value = connectionConfigFingerprint(config, sourceName);
|
||||
testedConfigId.value = config.id;
|
||||
testedGeneratedName.value = config.name;
|
||||
}
|
||||
|
||||
async function persistSuccessfulConnectionTest(result: ConnectionTestResult, config: ConnectionConfig, sourceName: string) {
|
||||
if (!editingId.value || !result.databaseInfo || !savedConnectionConfigFingerprint.value) return;
|
||||
const fingerprint = connectionConfigFingerprint(config, sourceName);
|
||||
if (fingerprint !== savedConnectionConfigFingerprint.value) return;
|
||||
try {
|
||||
await store.updateConnectionDatabaseInfo(editingId.value, result.databaseInfo);
|
||||
savedDatabaseInfo.value = { ...result.databaseInfo };
|
||||
savedDatabaseInfoFingerprint.value = fingerprint;
|
||||
} catch {
|
||||
// The successful test remains valid even when optional metadata persistence fails.
|
||||
}
|
||||
}
|
||||
|
||||
async function testConnectionWithTimeout(config: ConnectionConfig, runId: number): Promise<ConnectionTestResult> {
|
||||
const timeoutMs = connectionAttemptTimeoutMs(config);
|
||||
const timeoutMessage = connectionAttemptTimeoutMessage(timeoutMs);
|
||||
const promise = api.testConnection(config);
|
||||
const promise = api.testConnectionWithInfo(config);
|
||||
let timedOut = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
void promise.catch((error) => {
|
||||
if (!timedOut) return;
|
||||
if (runId !== testRunId) return;
|
||||
clearTestedConnectionInfo();
|
||||
testResult.value = {
|
||||
ok: false,
|
||||
message: connectionErrorWithDriverUpdateHint(config, connectionAttemptOriginalErrorMessage(timeoutMessage, errorMessage(error))),
|
||||
@@ -1216,7 +1307,7 @@ async function testConnectionWithTimeout(config: ConnectionConfig, runId: number
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<string>((_, reject) => {
|
||||
new Promise<ConnectionTestResult>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
reject(new Error(timeoutMessage));
|
||||
@@ -1436,9 +1527,11 @@ function switchGbaseProfile(profile: "gbase8a" | "gbase8s") {
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.editConfig,
|
||||
(config) => {
|
||||
[() => props.editConfig, open],
|
||||
([config, isOpen]) => {
|
||||
if (!isOpen) return;
|
||||
if (config) {
|
||||
clearSavedDatabaseInfo();
|
||||
const legacyConfig = config as LegacyConnectionConfig;
|
||||
const profile = profileForConfig(config);
|
||||
const oceanbaseMode = profile === "oceanbase" ? oceanbaseSubModeFromConfig(config) : "mysql";
|
||||
@@ -1528,7 +1621,14 @@ watch(
|
||||
customDriverName.value = isCustomCompatibleProfile() ? config.driver_label || "" : "";
|
||||
dialogStep.value = "config";
|
||||
configTab.value = initialConfigTab();
|
||||
// Form/profile watchers normalize derived fields in this flush. Capture
|
||||
// the saved baseline afterwards so those initial changes are not treated
|
||||
// as user edits that invalidate persisted database metadata.
|
||||
void nextTick(() => {
|
||||
if (open.value && props.editConfig?.id === config.id) applySavedDatabaseInfo(config);
|
||||
});
|
||||
} else {
|
||||
clearSavedDatabaseInfo();
|
||||
editingId.value = null;
|
||||
form.value = defaultForm();
|
||||
productionProtectionEnabled.value = false;
|
||||
@@ -2050,6 +2150,71 @@ const visibleObjectSelectedCountKey = computed(() => (visibleFilterUsesSchemas.v
|
||||
const visibleObjectEmptySelectionKey = computed(() => (visibleFilterUsesSchemas.value ? "visibleSchemas.emptySelection" : "visibleDatabases.emptySelection"));
|
||||
const visibleObjectLoadFailedKey = computed(() => (visibleFilterUsesSchemas.value ? "visibleSchemas.loadFailed" : "visibleDatabases.loadFailed"));
|
||||
const visibleObjectSaveKey = computed(() => (visibleFilterUsesSchemas.value ? "visibleSchemas.save" : "visibleDatabases.save"));
|
||||
const databaseInfoLabelKeys: Record<DatabaseInfoField, string> = {
|
||||
productName: "connection.databaseInfo.productName",
|
||||
productVersion: "connection.databaseInfo.productVersion",
|
||||
currentDatabase: "connection.databaseInfo.currentDatabase",
|
||||
serverComment: "connection.databaseInfo.serverComment",
|
||||
serverCharset: "connection.databaseInfo.serverCharset",
|
||||
serverCollation: "connection.databaseInfo.serverCollation",
|
||||
unquotedIdentifierCase: "connection.databaseInfo.unquotedIdentifierCase",
|
||||
quotedIdentifierCase: "connection.databaseInfo.quotedIdentifierCase",
|
||||
driverName: "connection.databaseInfo.driverName",
|
||||
driverVersion: "connection.databaseInfo.driverVersion",
|
||||
jdbcVersion: "connection.databaseInfo.jdbcVersion",
|
||||
};
|
||||
function databaseInfoFieldLabel(field: DatabaseInfoField): string {
|
||||
return t(databaseInfoLabelKeys[field]);
|
||||
}
|
||||
function databaseIdentifierCaseLabel(value: IdentifierCase): string {
|
||||
return t(`connection.databaseInfo.identifierCase.${value}`);
|
||||
}
|
||||
const visibleTestDatabaseInfo = computed<DatabaseConnectionInfo | null>(() => {
|
||||
const result = testResult.value;
|
||||
if (!result?.ok || !result.databaseInfo || !testedConfigFingerprint.value || !testedConfigId.value) return null;
|
||||
try {
|
||||
const current = connectionConfigForSubmit(testedConfigId.value, testedGeneratedName.value);
|
||||
return connectionConfigFingerprint(current, form.value.name) === testedConfigFingerprint.value ? result.databaseInfo : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const visibleSavedDatabaseInfo = computed<DatabaseConnectionInfo | null>(() => {
|
||||
if (!savedDatabaseInfo.value || !savedDatabaseInfoFingerprint.value || !editingId.value) return null;
|
||||
try {
|
||||
const current = connectionConfigForSubmit(editingId.value, form.value.name);
|
||||
return connectionConfigFingerprint(current, form.value.name) === savedDatabaseInfoFingerprint.value ? savedDatabaseInfo.value : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const configuredDatabaseInfo = computed<DatabaseConnectionInfo | null>(() => {
|
||||
const productName = configuredDatabaseProductName({
|
||||
db_type: form.value.db_type,
|
||||
driver_label: form.value.driver_label,
|
||||
});
|
||||
return normalizeDatabaseConnectionInfo(undefined, productName, form.value.database) ?? null;
|
||||
});
|
||||
const visibleDatabaseInfo = computed<DatabaseConnectionInfo | null>(() => visibleTestDatabaseInfo.value ?? visibleSavedDatabaseInfo.value ?? configuredDatabaseInfo.value);
|
||||
const databaseInfoVerified = computed(() => !!visibleTestDatabaseInfo.value || !!visibleSavedDatabaseInfo.value);
|
||||
const databaseInfoStatusLabel = computed(() => (databaseInfoVerified.value ? t("connection.databaseInfo.sourceTested") : t("connection.databaseInfo.sourceConfigured")));
|
||||
const databaseInfoDescription = computed(() => (databaseInfoVerified.value ? t("connection.databaseInfo.testedDescription") : t("connection.databaseInfo.configuredDescription")));
|
||||
const databaseInfoDisplayRows = computed(() =>
|
||||
visibleDatabaseInfo.value
|
||||
? databaseInfoRows(visibleDatabaseInfo.value).map((row) => ({
|
||||
...row,
|
||||
label: databaseInfoFieldLabel(row.key),
|
||||
displayValue: row.key === "unquotedIdentifierCase" || row.key === "quotedIdentifierCase" ? databaseIdentifierCaseLabel(row.value as IdentifierCase) : row.value,
|
||||
}))
|
||||
: [],
|
||||
);
|
||||
const databaseInfoCompactLabel = computed(() =>
|
||||
databaseInfoDisplayRows.value
|
||||
.filter((row) => row.key === "productName" || row.key === "productVersion" || row.key === "currentDatabase")
|
||||
.slice(0, 3)
|
||||
.map((row) => row.displayValue)
|
||||
.join(" · "),
|
||||
);
|
||||
const testResultMessage = computed(() => {
|
||||
if (!testResult.value) return "";
|
||||
return testResult.value.ok ? t("connection.testSuccess") : translateBackendError(t, testResult.value.message);
|
||||
@@ -2147,30 +2312,37 @@ async function testConnection() {
|
||||
isTesting.value = true;
|
||||
testResult.value = null;
|
||||
let config: ConnectionConfig | null = null;
|
||||
const submittedSourceName = form.value.name;
|
||||
try {
|
||||
config = connectionConfigForSubmit(editingId.value || draftTestConnectionId.value);
|
||||
await ensureRequiredAgentDriverInstalled(config);
|
||||
const msg = await testConnectionWithTimeout(config, runId);
|
||||
const result = await testConnectionWithTimeout(config, runId);
|
||||
if (runId !== testRunId) return;
|
||||
if (config.db_type === "mongodb" && /legacy driver/i.test(msg)) {
|
||||
let successfulConfig = config;
|
||||
if (config.db_type === "mongodb" && /legacy driver/i.test(result.message)) {
|
||||
mongoDriverMode.value = "legacy";
|
||||
successfulConfig = connectionConfigForSubmit(config.id, config.name);
|
||||
}
|
||||
testResult.value = { ok: true, message: msg };
|
||||
applySuccessfulConnectionTest(result, successfulConfig, submittedSourceName);
|
||||
void persistSuccessfulConnectionTest(result, successfulConfig, submittedSourceName);
|
||||
clearEditedConnectionErrorAfterSuccessfulTest();
|
||||
} catch (e: any) {
|
||||
if (runId !== testRunId) return;
|
||||
const rawMessage = mongodbAuthFailureHint(errorMessage(e));
|
||||
const message = config ? connectionErrorWithDriverUpdateHint(config, rawMessage) : rawMessage;
|
||||
const fallbackMessage = config ? await tryNacosDockerConsoleFallback(config, message, runId) : null;
|
||||
const fallback = config ? await tryNacosDockerConsoleFallback(config, message, runId) : null;
|
||||
if (runId !== testRunId) return;
|
||||
const shouldShowSqlServerLegacyMode = !fallbackMessage && config?.db_type === "sqlserver" && !isSqlServerLegacyCompatibilityMode(config.url_params) && isSqlServerTlsHandshakeFailure(message);
|
||||
const shouldShowSqlServerLegacyMode = !fallback && config?.db_type === "sqlserver" && !isSqlServerLegacyCompatibilityMode(config.url_params) && isSqlServerTlsHandshakeFailure(message);
|
||||
if (shouldShowSqlServerLegacyMode) {
|
||||
configTab.value = "advanced";
|
||||
}
|
||||
testResult.value = fallbackMessage ? { ok: true, message: fallbackMessage } : { ok: false, message };
|
||||
if (fallbackMessage) {
|
||||
if (fallback) {
|
||||
applySuccessfulConnectionTest(fallback.result, fallback.config, submittedSourceName);
|
||||
void persistSuccessfulConnectionTest(fallback.result, fallback.config, submittedSourceName);
|
||||
clearEditedConnectionErrorAfterSuccessfulTest();
|
||||
} else {
|
||||
clearTestedConnectionInfo();
|
||||
testResult.value = { ok: false, message };
|
||||
showConnectionError(message);
|
||||
}
|
||||
} finally {
|
||||
@@ -2319,13 +2491,14 @@ function generateConnectionName(): string {
|
||||
return `${label}_${rand}`;
|
||||
}
|
||||
|
||||
function connectionConfigForSubmit(id: string): ConnectionConfig {
|
||||
function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionConfig {
|
||||
const config = { ...formValueForSubmit(), id } as LegacyConnectionConfig;
|
||||
config.database_info = undefined;
|
||||
if (selectedType.value === "oceanbase" && (config.driver_profile === "oceanbase" || config.driver_profile === "oceanbase-oracle")) {
|
||||
Object.assign(config, oceanbaseModeConnectionPatch(oceanbaseSubMode.value));
|
||||
}
|
||||
if (!config.name?.trim()) {
|
||||
config.name = generateConnectionName();
|
||||
config.name = generatedName.trim() || generateConnectionName();
|
||||
}
|
||||
if (config.db_type === "kingbase") {
|
||||
config.database = config.database?.trim() || undefined;
|
||||
@@ -2602,6 +2775,13 @@ function connectionConfigForSubmit(id: string): ConnectionConfig {
|
||||
return config as ConnectionConfig;
|
||||
}
|
||||
|
||||
function withSavedDatabaseInfo(config: ConnectionConfig, databaseInfo: DatabaseConnectionInfo | null): ConnectionConfig {
|
||||
return {
|
||||
...config,
|
||||
database_info: databaseInfo ? { ...databaseInfo } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function connectionConfigSnapshotForVisibleDatabases(): ConnectionConfig {
|
||||
return {
|
||||
...(form.value as ConnectionConfig),
|
||||
@@ -2813,6 +2993,7 @@ function resetTestState() {
|
||||
testRunId += 1;
|
||||
isTesting.value = false;
|
||||
testResult.value = null;
|
||||
clearTestedConnectionInfo();
|
||||
showConnectionErrorDialog.value = false;
|
||||
connectionErrorDetail.value = "";
|
||||
}
|
||||
@@ -3098,6 +3279,17 @@ async function copyTestResult() {
|
||||
}
|
||||
}
|
||||
|
||||
async function copyDatabaseInfo() {
|
||||
const info = visibleDatabaseInfo.value;
|
||||
if (!info) return;
|
||||
try {
|
||||
await copyToClipboard(databaseInfoCopyText(info, databaseInfoFieldLabel, databaseIdentifierCaseLabel));
|
||||
toast(t("grid.copied"));
|
||||
} catch (e: any) {
|
||||
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyAgentInstallError() {
|
||||
if (!agentInstallError.value) return;
|
||||
try {
|
||||
@@ -3431,16 +3623,16 @@ function validateTransportLayers(config: LegacyConnectionConfig) {
|
||||
async function save() {
|
||||
if (!ensureConnectionHostResolvedFromUrl()) return;
|
||||
if (isSaving.value) return;
|
||||
const databaseInfoForSave = visibleTestDatabaseInfo.value ?? visibleSavedDatabaseInfo.value;
|
||||
isSaving.value = true;
|
||||
resetTestState();
|
||||
try {
|
||||
if (editingId.value) {
|
||||
const updated = connectionConfigForSubmit(editingId.value);
|
||||
const updated = withSavedDatabaseInfo(connectionConfigForSubmit(editingId.value), databaseInfoForSave);
|
||||
await ensureRequiredAgentDriverInstalled(updated);
|
||||
await store.updateConnection(updated);
|
||||
store.stopEditing();
|
||||
} else {
|
||||
const config = connectionConfigForSubmit(draftTestConnectionId.value);
|
||||
const config = withSavedDatabaseInfo(connectionConfigForSubmit(draftTestConnectionId.value), databaseInfoForSave);
|
||||
await ensureRequiredAgentDriverInstalled(config);
|
||||
await store.addConnection(config);
|
||||
draftTestConnectionId.value = uuid();
|
||||
@@ -3817,7 +4009,7 @@ function openExternalUrl(url: string) {
|
||||
</DialogHeader>
|
||||
|
||||
<template v-if="dialogStep === 'select'">
|
||||
<div class="space-y-4">
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-4 overflow-hidden">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex shrink-0 rounded-lg border bg-muted/40 p-0.5">
|
||||
@@ -3839,7 +4031,7 @@ function openExternalUrl(url: string) {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="max-h-[58vh] space-y-5 overflow-y-auto pr-2">
|
||||
<div class="min-h-0 flex-1 space-y-5 overflow-y-auto pr-2">
|
||||
<section v-for="category in filteredDbCategories" :key="category.key" class="space-y-2">
|
||||
<div class="flex items-center">
|
||||
<h3 v-if="category.title" class="text-sm font-medium">{{ category.title }}</h3>
|
||||
@@ -3887,7 +4079,7 @@ function openExternalUrl(url: string) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter class="flex items-center gap-2">
|
||||
<DialogFooter class="flex shrink-0 items-center gap-2">
|
||||
<div class="mr-auto flex min-w-0 items-center gap-2 text-sm text-muted-foreground">
|
||||
<DatabaseIcon :db-type="selectedDbIcon" class="h-4 w-4 shrink-0" />
|
||||
<span class="truncate">{{ t("connection.selectedDatabase") }}: {{ selectedProfile().label }}</span>
|
||||
@@ -3900,8 +4092,8 @@ function openExternalUrl(url: string) {
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="space-y-3">
|
||||
<Tabs v-model="configTab" class="min-h-0">
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-4 overflow-hidden">
|
||||
<Tabs v-model="configTab" class="flex min-h-0 flex-1 flex-col">
|
||||
<div class="flex items-center justify-between border-b pb-2">
|
||||
<TabsList>
|
||||
<TabsTrigger value="connection">{{ t("connection.basicTab") }}</TabsTrigger>
|
||||
@@ -3911,8 +4103,8 @@ function openExternalUrl(url: string) {
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="connection" class="m-0">
|
||||
<div class="connection-form-body grid gap-4 py-4 pr-2 max-h-[65vh] overflow-y-auto">
|
||||
<TabsContent value="connection" class="m-0 min-h-0 flex-1 overflow-hidden">
|
||||
<div class="connection-form-body grid h-full min-h-0 gap-4 overflow-y-auto pt-4 pr-2">
|
||||
<div v-if="!isJdbcConnection" class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.connectionUrlOptional") }}</Label>
|
||||
<div class="col-span-3 flex items-center gap-1">
|
||||
@@ -5059,11 +5251,49 @@ function openExternalUrl(url: string) {
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<div v-if="visibleDatabaseInfo" class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.databaseInfo.title") }}</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="col-span-3 flex h-9 min-w-0 items-center gap-2 rounded-md border bg-muted/20 px-2.5 text-left text-xs transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:title="databaseInfoCompactLabel"
|
||||
:aria-label="t('connection.databaseInfo.open', { database: databaseInfoCompactLabel })"
|
||||
>
|
||||
<DatabaseLucide class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span class="rounded-full bg-background px-1.5 py-0.5 text-[11px] text-muted-foreground">{{ databaseInfoStatusLabel }}</span>
|
||||
<span class="min-w-0 flex-1 truncate text-muted-foreground">{{ databaseInfoCompactLabel }}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="top" align="start" class="w-[360px] max-w-[calc(100vw-24px)] gap-3 p-3" @click.stop @keydown.stop>
|
||||
<div class="flex min-w-0 items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<DatabaseLucide class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div class="min-w-0 text-sm font-medium">{{ t("connection.databaseInfo.title") }}</div>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-muted-foreground">{{ databaseInfoDescription }}</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon-xs" class="h-7 w-7 shrink-0" :title="t('connection.databaseInfo.copy')" :aria-label="t('connection.databaseInfo.copy')" @click="copyDatabaseInfo">
|
||||
<Copy class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<dl class="mt-3 grid min-w-0 grid-cols-[minmax(7.5rem,auto)_minmax(0,1fr)] gap-x-4 gap-y-2 text-xs">
|
||||
<template v-for="row in databaseInfoDisplayRows" :key="row.key">
|
||||
<dt class="text-muted-foreground">{{ row.label }}</dt>
|
||||
<dd class="min-w-0 break-words text-right font-medium">{{ row.displayValue }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent v-if="supportsTlsToggle" value="tls" class="m-0">
|
||||
<div class="connection-form-body grid gap-4 py-4 pr-2 max-h-[65vh] overflow-y-auto overflow-x-hidden">
|
||||
<TabsContent v-if="supportsTlsToggle" value="tls" class="m-0 min-h-0 flex-1 overflow-hidden">
|
||||
<div class="connection-form-body grid h-full min-h-0 gap-4 overflow-y-auto overflow-x-hidden pt-4 pr-2">
|
||||
<div v-if="!supportsPostgresTlsOptions && !supportsMysqlTlsOptions" class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelSmallClass">SSL/TLS</Label>
|
||||
<label class="col-span-3 flex items-center gap-2 cursor-pointer">
|
||||
@@ -5327,8 +5557,8 @@ function openExternalUrl(url: string) {
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="advanced" class="m-0">
|
||||
<div class="connection-form-body grid gap-4 py-4 pr-2 max-h-[65vh] overflow-y-auto">
|
||||
<TabsContent value="advanced" class="m-0 min-h-0 flex-1 overflow-hidden">
|
||||
<div class="connection-form-body grid h-full min-h-0 gap-4 overflow-y-auto pt-4 pr-2">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelSmallClass">{{ t("connection.connectTimeout") }}</Label>
|
||||
<Input v-model.number="form.connect_timeout_secs" type="number" min="1" max="300" step="1" class="col-span-3" />
|
||||
@@ -5419,8 +5649,8 @@ function openExternalUrl(url: string) {
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent v-if="canUseTransportLayers" value="transport" class="m-0">
|
||||
<div class="connection-form-body grid gap-4 py-4 pr-2 max-h-[65vh] overflow-y-auto overflow-x-hidden">
|
||||
<TabsContent v-if="canUseTransportLayers" value="transport" class="m-0 min-h-0 flex-1 overflow-hidden">
|
||||
<div class="connection-form-body grid h-full min-h-0 gap-4 overflow-y-auto overflow-x-hidden pt-4 pr-2">
|
||||
<div class="connection-label-wide-grid grid min-w-0 grid-cols-4 items-start gap-4">
|
||||
<Label :class="connectionLabelSmallPaddedClass">{{ t("connection.sshHops") }}</Label>
|
||||
<div class="col-span-3 grid min-w-0 gap-3">
|
||||
@@ -5663,7 +5893,7 @@ function openExternalUrl(url: string) {
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<DialogFooter class="flex min-w-0 items-center gap-2 sm:flex-nowrap">
|
||||
<DialogFooter class="flex min-w-0 shrink-0 items-center gap-2 sm:flex-nowrap">
|
||||
<div class="mr-auto flex min-w-0 flex-1 basis-0 items-center gap-2 overflow-hidden">
|
||||
<Button v-if="!editingId" variant="outline" class="shrink-0" :disabled="isSaving" @click="backToDatabasePicker">
|
||||
<ArrowLeft class="h-4 w-4" />
|
||||
@@ -5673,7 +5903,7 @@ function openExternalUrl(url: string) {
|
||||
<span class="block min-w-0 flex-1 basis-0 truncate text-xs" :class="testResult.ok ? 'text-green-600' : 'text-red-600'" :title="testResultMessage" role="status" aria-live="polite">
|
||||
{{ testResultMessage }}
|
||||
</span>
|
||||
<Button variant="ghost" size="icon-xs" class="h-5 w-5 shrink-0" :title="t('connection.copyTestResult')" :aria-label="t('connection.copyTestResult')" @click="copyTestResult">
|
||||
<Button v-if="!testResult.ok" variant="ghost" size="icon-xs" class="h-5 w-5 shrink-0" :title="t('connection.copyTestResult')" :aria-label="t('connection.copyTestResult')" @click="copyTestResult">
|
||||
<Copy class="h-3 w-3" />
|
||||
</Button>
|
||||
</template>
|
||||
@@ -5921,6 +6151,12 @@ function openExternalUrl(url: string) {
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.connection-dialog-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100dvh - 2rem);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.connection-db-picker-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr)) !important;
|
||||
|
||||
@@ -62,6 +62,7 @@ import { createInsertValueHintsExtension, requestInsertValueHintsRefresh } from
|
||||
import { focusEditorView } from "@/lib/editor/queryEditorFocus";
|
||||
import { createDbxCodeMirrorSqlDialect } from "@/lib/editor/codemirrorSqlDialect";
|
||||
import { startsQueryEditorRectangularSelection } from "@/lib/editor/queryEditorPointerSelection";
|
||||
import type { StatementExecutionMarker } from "@/lib/tabs/tabPresentation";
|
||||
import { isSchemaAware, isSingleDatabase, supportsSqlInListPaste } from "@/lib/database/databaseFeatureSupport";
|
||||
import { usesLocalOnlyEditorCompletionMetadata, usesOnDemandOnlyEditorColumnMetadata } from "@/lib/metadata/completionMetadataPolicy";
|
||||
import { qualifiedTableNameAtSqlPosition } from "@/lib/sql/queryCursorTableTarget";
|
||||
@@ -89,6 +90,7 @@ const props = defineProps<{
|
||||
hideExecutionControls?: boolean;
|
||||
initialViewport?: { scrollTop: number; scrollLeft: number };
|
||||
initialSelection?: { anchor: number; head: number };
|
||||
statementExecutionMarkers?: StatementExecutionMarker[];
|
||||
}>();
|
||||
|
||||
const COMPLETION_REMOTE_LATENCY_BUDGET_MS = 120;
|
||||
@@ -217,6 +219,7 @@ let codeMirrorCloseBrackets: typeof import("@codemirror/autocomplete").closeBrac
|
||||
let codeMirrorCloseBracketsKeymap: readonly import("@codemirror/view").KeyBinding[] | null = null;
|
||||
let readOnlyComp: import("@codemirror/state").Compartment | null = null;
|
||||
let runGutterComp: import("@codemirror/state").Compartment | null = null;
|
||||
let statementExecutionGutterComp: import("@codemirror/state").Compartment | null = null;
|
||||
let runKeymapComp: import("@codemirror/state").Compartment | null = null;
|
||||
let completionComp: import("@codemirror/state").Compartment | null = null;
|
||||
let diagnosticComp: import("@codemirror/state").Compartment | null = null;
|
||||
@@ -250,9 +253,11 @@ let codeMirrorToggleLineComment: typeof import("@codemirror/commands").toggleLin
|
||||
let setSqlDiagnosticsEffect: import("@codemirror/state").StateEffectType<SqlSemanticDiagnostic[]> | null = null;
|
||||
let setPreviewRangeEffect: import("@codemirror/state").StateEffectType<{ from: number; to: number } | null> | null = null;
|
||||
let setResultSourceRangeEffect: import("@codemirror/state").StateEffectType<{ from: number; to: number } | null> | null = null;
|
||||
let setStatementExecutionMarkersEffect: import("@codemirror/state").StateEffectType<StatementExecutionMarker[]> | null = null;
|
||||
let previewRangeComp: import("@codemirror/state").Compartment | null = null;
|
||||
let buildPreviewRangeExtension: (() => import("@codemirror/state").Extension) | null = null;
|
||||
let buildResultSourceRangeExtension: (() => import("@codemirror/state").Extension) | null = null;
|
||||
let buildStatementExecutionMarkersExtension: (() => import("@codemirror/state").Extension) | null = null;
|
||||
let buildRunStatementGutterExtension: (() => import("@codemirror/state").Extension) | null = null;
|
||||
let indentComp: import("@codemirror/state").Compartment | null = null;
|
||||
let codeMirrorIndentUnit: typeof import("@codemirror/language").indentUnit | null = null;
|
||||
@@ -2673,6 +2678,7 @@ onMounted(async () => {
|
||||
codeMirrorCloseBracketsKeymap = closeBracketsKeymap;
|
||||
readOnlyComp = new Compartment();
|
||||
runGutterComp = new Compartment();
|
||||
statementExecutionGutterComp = new Compartment();
|
||||
runKeymapComp = new Compartment();
|
||||
completionComp = new Compartment();
|
||||
diagnosticComp = new Compartment();
|
||||
@@ -2801,6 +2807,85 @@ onMounted(async () => {
|
||||
return field;
|
||||
};
|
||||
|
||||
class StatementExecutionStatusMarker extends GutterMarker {
|
||||
constructor(readonly marker: StatementExecutionMarker) {
|
||||
super();
|
||||
}
|
||||
|
||||
eq(other: import("@codemirror/view").GutterMarker): boolean {
|
||||
return other instanceof StatementExecutionStatusMarker && other.marker.status === this.marker.status && other.marker.successCount === this.marker.successCount && other.marker.errorCount === this.marker.errorCount;
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
const element = document.createElement("span");
|
||||
const title = statementExecutionMarkerTitle(this.marker);
|
||||
element.className = `cm-statement-execution-marker cm-statement-execution-marker--${this.marker.status}`;
|
||||
element.title = title;
|
||||
element.setAttribute("aria-label", title);
|
||||
element.appendChild(createStatementExecutionStatusIconDom(this.marker.status));
|
||||
return element;
|
||||
}
|
||||
}
|
||||
|
||||
function statementExecutionMarkerTitle(marker: StatementExecutionMarker) {
|
||||
const parts = [];
|
||||
if (marker.successCount > 0) parts.push(t("editor.statementExecutionSucceeded", { count: marker.successCount }));
|
||||
if (marker.errorCount > 0) parts.push(t("editor.statementExecutionFailed", { count: marker.errorCount }));
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
function createStatementExecutionStatusIconDom(status: StatementExecutionMarker["status"]) {
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
svg.setAttribute("viewBox", "0 0 24 24");
|
||||
svg.setAttribute("fill", "none");
|
||||
svg.setAttribute("stroke", "currentColor");
|
||||
svg.setAttribute("stroke-width", "2");
|
||||
svg.setAttribute("stroke-linecap", "round");
|
||||
svg.setAttribute("stroke-linejoin", "round");
|
||||
svg.setAttribute("aria-hidden", "true");
|
||||
|
||||
const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
|
||||
circle.setAttribute("cx", "12");
|
||||
circle.setAttribute("cy", "12");
|
||||
circle.setAttribute("r", "10");
|
||||
const mark = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
mark.setAttribute("d", status === "error" ? "m15 9-6 6m0-6 6 6" : "m9 12 2 2 4-4");
|
||||
svg.append(circle, mark);
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
setStatementExecutionMarkersEffect = StateEffect.define<StatementExecutionMarker[]>();
|
||||
buildStatementExecutionMarkersExtension = () => {
|
||||
const effectType = setStatementExecutionMarkersEffect!;
|
||||
const markersForState = (state: import("@codemirror/state").EditorState, markers: readonly StatementExecutionMarker[]) => {
|
||||
const ranges = markers.map((marker) => {
|
||||
const from = Math.max(0, Math.min(marker.from, state.doc.length));
|
||||
return new StatementExecutionStatusMarker(marker).range(state.doc.lineAt(from).from);
|
||||
});
|
||||
return RangeSet.of(ranges, true);
|
||||
};
|
||||
|
||||
const field = StateField.define({
|
||||
create(state) {
|
||||
return markersForState(state, props.statementExecutionMarkers ?? []);
|
||||
},
|
||||
update(markers, transaction) {
|
||||
for (const effect of transaction.effects) {
|
||||
if (effect.is(effectType)) return markersForState(transaction.state, effect.value);
|
||||
}
|
||||
if (transaction.docChanged) return RangeSet.empty;
|
||||
return markers;
|
||||
},
|
||||
provide: (field) =>
|
||||
gutter({
|
||||
class: "cm-statement-execution-gutter",
|
||||
markers: (currentView) => currentView.state.field(field),
|
||||
}),
|
||||
});
|
||||
return field;
|
||||
};
|
||||
|
||||
buildSqlSignatureExtension = () =>
|
||||
showTooltip.compute(["doc", "selection"], (currentState) => {
|
||||
const signature = getSqlFunctionSignatureHelp(currentState.doc.toString(), currentState.selection.main.head);
|
||||
@@ -2992,6 +3077,7 @@ onMounted(async () => {
|
||||
}),
|
||||
previewRangeComp.of(buildPreviewRangeExtension()),
|
||||
buildResultSourceRangeExtension(),
|
||||
statementExecutionGutterComp.of(buildStatementExecutionMarkersExtension()),
|
||||
Prec.highest(
|
||||
keymap.of([
|
||||
{ key: "'", run: handleSqlSingleQuote },
|
||||
@@ -3287,6 +3373,15 @@ watch(
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.statementExecutionMarkers ?? [],
|
||||
(markers) => {
|
||||
if (!view.value || !setStatementExecutionMarkersEffect) return;
|
||||
view.value.dispatch({ effects: setStatementExecutionMarkersEffect.of(markers) });
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.connectionId,
|
||||
() => {
|
||||
@@ -3658,6 +3753,65 @@ defineExpose({ openSearch, openReplace, scrollCursorIntoView, requestExecute, pa
|
||||
padding: 0 5px;
|
||||
}
|
||||
|
||||
:deep(.cm-statement-execution-gutter) {
|
||||
min-width: 34px;
|
||||
}
|
||||
|
||||
:deep(.cm-statement-execution-gutter .cm-gutterElement) {
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
min-width: 34px;
|
||||
padding: 0 5px;
|
||||
}
|
||||
|
||||
:deep(.cm-statement-execution-marker) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
width: min(24px, calc(var(--dbx-editor-font-size, 13px) * 1.6));
|
||||
height: min(24px, calc(var(--dbx-editor-font-size, 13px) * 1.6));
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
color 0.15s,
|
||||
background-color 0.15s;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:deep(.cm-statement-execution-marker--success) {
|
||||
background: rgb(16 185 129 / 0.1);
|
||||
color: rgb(4 120 87);
|
||||
}
|
||||
|
||||
:deep(.cm-statement-execution-marker--error) {
|
||||
background: rgb(239 68 68 / 0.1);
|
||||
color: rgb(185 28 28);
|
||||
}
|
||||
|
||||
:deep(.dark .cm-statement-execution-marker--success) {
|
||||
color: rgb(110 231 183);
|
||||
}
|
||||
|
||||
:deep(.dark .cm-statement-execution-marker--error) {
|
||||
color: rgb(252 165 165);
|
||||
}
|
||||
|
||||
:deep(.cm-statement-execution-marker svg) {
|
||||
display: block;
|
||||
width: min(14px, 70%);
|
||||
height: min(14px, 70%);
|
||||
pointer-events: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:deep(.cm-run-statement-marker) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -64,7 +64,7 @@ import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { TABLE_FONT_SIZE_MAX, TABLE_FONT_SIZE_MIN, useSettingsStore, type DataGridSearchMode } from "@/stores/settingsStore";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { canCancelQueryExecution, queryExecutionLabelKey } from "@/lib/sql/queryExecutionState";
|
||||
import { databaseDisplayNameForTab, executionSummaryItems, queryResultExecutionSql, resultGridCacheKey, resultRunItems, resultSourceRange, resultSqlForGrid, tabularResultItems } from "@/lib/tabs/tabPresentation";
|
||||
import { databaseDisplayNameForTab, executionSummaryItems, queryResultExecutionSql, resultGridCacheKey, resultRunItems, resultSourceRange, resultSqlForGrid, statementExecutionMarkers, tabularResultItems } from "@/lib/tabs/tabPresentation";
|
||||
import { defaultQueryResultArchiveFileName } from "@/lib/query/queryResultArchive";
|
||||
import { saveQueryResultArchiveFile } from "@/lib/query/queryResultArchiveFile";
|
||||
import { isTableDataEditable } from "@/lib/table/tableEditing";
|
||||
@@ -329,6 +329,15 @@ const activeResultRunItem = computed(() => resultRuns.value.find((run) => run.ac
|
||||
const activeResultGridCacheKey = computed(() => resultGridCacheKey(props.activeTab));
|
||||
const activeResultSql = computed(() => resultSqlForGrid(props.activeTab));
|
||||
const activeResultExportSql = computed(() => queryResultExecutionSql(props.activeTab));
|
||||
const activeStatementExecutionMarkers = computed(() =>
|
||||
statementExecutionMarkers(
|
||||
props.activeTab.sql,
|
||||
props.activeTab.results ?? (props.activeTab.result ? [props.activeTab.result] : undefined),
|
||||
activeEffectiveDatabaseType.value,
|
||||
props.activeTab.resultBaseSql || props.activeTab.lastExecutedSql || props.activeTab.sql,
|
||||
props.activeTab.resultEditorFingerprint ?? "",
|
||||
),
|
||||
);
|
||||
const activeElasticsearchJsonResponse = computed(() => elasticsearchJsonResponseForResult(activeEffectiveDatabaseType.value, activeResultSql.value, props.activeTab.result));
|
||||
const resultArchiveExporting = ref(false);
|
||||
const canExportResultArchive = computed(() => props.activeTab.mode === "query" && (!!props.activeTab.result || !!props.activeTab.results?.length || !!props.activeTab.resultRuns?.length));
|
||||
@@ -800,6 +809,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
|
||||
:format-request-id="formatSqlRequest?.tabId === activeTab.id ? formatSqlRequest.id : undefined"
|
||||
:execution-error="activeQueryError"
|
||||
:execution-error-sql="activeTab.lastExecutedSql"
|
||||
:statement-execution-markers="activeStatementExecutionMarkers"
|
||||
:initial-viewport="activeTab.editorViewport"
|
||||
:initial-selection="activeTab.editorSelection"
|
||||
@update:model-value="emit('editorUpdate', activeTab.id, $event)"
|
||||
|
||||
@@ -633,6 +633,7 @@ const connectionInfoTooltip = computed(() => {
|
||||
{ label: t("connection.database"), value: cleanTooltipValue(config.database) },
|
||||
{ label: t("connection.user"), value: cleanTooltipValue(config.username) },
|
||||
{ label: t("connection.type"), value: config.driver_label || config.driver_profile || config.db_type },
|
||||
{ label: t("connection.databaseInfo.productVersion"), value: cleanTooltipValue(config.database_info?.productVersion) },
|
||||
].filter((row) => row.value);
|
||||
|
||||
return { rows };
|
||||
|
||||
@@ -342,6 +342,31 @@ export default {
|
||||
save: "Save",
|
||||
editTitle: "Edit Connection",
|
||||
testSuccess: "Connection successful",
|
||||
databaseInfo: {
|
||||
title: "Database information",
|
||||
open: "Open database information for {database}",
|
||||
copy: "Copy database information",
|
||||
sourceConfigured: "Current configuration",
|
||||
sourceTested: "Tested",
|
||||
configuredDescription: "Shows information known from the form first. A successful test adds server version and driver metadata.",
|
||||
testedDescription: "This information comes from the connection used by the latest successful test.",
|
||||
productName: "DBMS",
|
||||
productVersion: "DBMS version",
|
||||
currentDatabase: "Current database",
|
||||
serverComment: "Server comment",
|
||||
serverCharset: "Server charset",
|
||||
serverCollation: "Server collation",
|
||||
unquotedIdentifierCase: "Unquoted identifiers",
|
||||
quotedIdentifierCase: "Quoted identifiers",
|
||||
driverName: "Driver",
|
||||
driverVersion: "Driver version",
|
||||
jdbcVersion: "JDBC version",
|
||||
identifierCase: {
|
||||
lower: "Lowercase",
|
||||
upper: "Uppercase",
|
||||
mixed: "Mixed case",
|
||||
},
|
||||
},
|
||||
connecting: "Connecting to {name}...",
|
||||
connectSuccess: "Connected to {name}",
|
||||
connectFailed: "Connection failed: {message}",
|
||||
@@ -468,6 +493,8 @@ export default {
|
||||
connectCancelled: "Connection cancelled",
|
||||
},
|
||||
editor: {
|
||||
statementExecutionSucceeded: "{count} statement succeeded | {count} statements succeeded",
|
||||
statementExecutionFailed: "{count} statement failed | {count} statements failed",
|
||||
pressToExecute: "Press {mod}+Enter to execute",
|
||||
pressToSaveSql: "Press {mod}+S to save SQL",
|
||||
queryTimeoutError: "Query timed out ({seconds}s). Check whether the database connection is healthy.",
|
||||
|
||||
@@ -449,6 +449,31 @@ export default withEnglishFallback({
|
||||
kafkaKerberosKrb5ConfPlaceholder: "Opcional, ruta en la máquina donde se ejecuta DBX Agent, por ejemplo /etc/krb5.conf",
|
||||
kafkaKerberosPathHint: "Las rutas de keytab y krb5.conf son leídas por DBX Agent y deben existir en la máquina donde se ejecuta DBX Agent; no se subirán archivos desde el navegador actual.",
|
||||
kafkaKerberosAuthHint: "Iniciar sesión con GSSAPI + keytab. Si el servidor requiere transmisión cifrada, configure Security como SASL_SSL; de lo contrario, puede usar Auto o SASL_PLAINTEXT.",
|
||||
databaseInfo: {
|
||||
title: "Información de la base de datos",
|
||||
open: "Abrir información de la base de datos {database}",
|
||||
copy: "Copiar información de la base de datos",
|
||||
sourceConfigured: "Configuración actual",
|
||||
sourceTested: "Probada",
|
||||
configuredDescription: "Muestra primero la información que se puede determinar desde el formulario; después de una prueba exitosa, se complementa con la versión devuelta por el servidor y los metadatos del controlador.",
|
||||
testedDescription: "La siguiente información proviene de la conexión utilizada para esta prueba exitosa.",
|
||||
productName: "Sistema de gestión de bases de datos",
|
||||
productVersion: "Versión de la base de datos",
|
||||
currentDatabase: "Base de datos actual",
|
||||
serverComment: "Comentario del servidor",
|
||||
serverCharset: "Conjunto de caracteres del servidor",
|
||||
serverCollation: "Collation del servidor",
|
||||
unquotedIdentifierCase: "Identificador sin comillas",
|
||||
quotedIdentifierCase: "Identificador entrecomillado",
|
||||
driverName: "Controlador",
|
||||
driverVersion: "Versión del controlador",
|
||||
jdbcVersion: "Versión de JDBC",
|
||||
identifierCase: {
|
||||
lower: "Minúsculas",
|
||||
upper: "Mayúsculas",
|
||||
mixed: "Mixto",
|
||||
},
|
||||
},
|
||||
},
|
||||
editor: {
|
||||
pressToExecute: "Presiona {mod}+Enter para ejecutar",
|
||||
@@ -561,6 +586,8 @@ export default withEnglishFallback({
|
||||
currentCommand: "Current command",
|
||||
allCommands: "All commands",
|
||||
},
|
||||
statementExecutionSucceeded: "{count} sentencias exitosas",
|
||||
statementExecutionFailed: "{count} sentencias fallidas",
|
||||
},
|
||||
tabs: {
|
||||
sql: "SQL",
|
||||
|
||||
@@ -447,6 +447,31 @@ export default withEnglishFallback({
|
||||
kafkaKerberosKrb5ConfPlaceholder: "Opzionale, percorso sulla macchina DBX Agent, ad esempio /etc/krb5.conf",
|
||||
kafkaKerberosPathHint: "I percorsi di keytab e krb5.conf vengono letti da DBX Agent e devono esistere sulla macchina in cui è in esecuzione DBX Agent; non vengono caricati file dal browser corrente.",
|
||||
kafkaKerberosAuthHint: "Accedi con GSSAPI + keytab. Se il server richiede trasmissione crittografata, imposta Security su SASL_SSL; altrimenti puoi utilizzare Auto o SASL_PLAINTEXT.",
|
||||
databaseInfo: {
|
||||
title: "Informazioni sul database",
|
||||
open: "Apri informazioni database {database}",
|
||||
copy: "Copia informazioni database",
|
||||
sourceConfigured: "Configurazione corrente",
|
||||
sourceTested: "Testato",
|
||||
configuredDescription: "Mostra prima le informazioni determinabili dal modulo; dopo un test riuscito, vengono aggiunte la versione restituita dal server e i metadati del driver.",
|
||||
testedDescription: "Le seguenti informazioni provengono dalla connessione utilizzata per questo test riuscito.",
|
||||
productName: "Sistema di gestione database",
|
||||
productVersion: "Versione database",
|
||||
currentDatabase: "Database corrente",
|
||||
serverComment: "Commento server",
|
||||
serverCharset: "Set di caratteri del server",
|
||||
serverCollation: "Regole di ordinamento del server",
|
||||
unquotedIdentifierCase: "Identificatore senza virgolette",
|
||||
quotedIdentifierCase: "Identificatore con virgolette",
|
||||
driverName: "Driver",
|
||||
driverVersion: "Versione driver",
|
||||
jdbcVersion: "Versione JDBC",
|
||||
identifierCase: {
|
||||
lower: "Minuscolo",
|
||||
upper: "Maiuscolo",
|
||||
mixed: "Misto",
|
||||
},
|
||||
},
|
||||
},
|
||||
editor: {
|
||||
pressToExecute: "Premi {mod}+Enter per eseguire",
|
||||
@@ -559,6 +584,8 @@ export default withEnglishFallback({
|
||||
currentCommand: "Current command",
|
||||
allCommands: "All commands",
|
||||
},
|
||||
statementExecutionSucceeded: "{count} istruzioni eseguite con successo",
|
||||
statementExecutionFailed: "{count} istruzioni fallite",
|
||||
},
|
||||
tabs: {
|
||||
sql: "SQL",
|
||||
|
||||
@@ -447,6 +447,31 @@ export default withEnglishFallback({
|
||||
kafkaKerberosKrb5ConfPlaceholder: "オプション。DBX Agent が動作するマシンのパス。例: /etc/krb5.conf",
|
||||
kafkaKerberosPathHint: "keytab と krb5.conf のパスは DBX Agent が読み取るため、DBX Agent が動作するマシン上に存在する必要があります。ブラウザからファイルをアップロードすることはありません。",
|
||||
kafkaKerberosAuthHint: "GSSAPI + keytab を使用してログインします。サーバーが暗号化転送を要求する場合は、Security を SASL_SSL に設定してください。それ以外の場合は Auto または SASL_PLAINTEXT を使用できます。",
|
||||
databaseInfo: {
|
||||
title: "データベース情報",
|
||||
open: "{database} データベース情報を開く",
|
||||
copy: "データベース情報をコピー",
|
||||
sourceConfigured: "現在の設定",
|
||||
sourceTested: "テスト済み",
|
||||
configuredDescription: "まずフォームで確定可能な情報を表示します。テスト成功後、サーバーから返されたバージョンとドライバーのメタデータが補足されます。",
|
||||
testedDescription: "以下の情報は、今回の成功したテストで使用された接続からのものです。",
|
||||
productName: "データベース管理システム",
|
||||
productVersion: "データベースバージョン",
|
||||
currentDatabase: "現在のデータベース",
|
||||
serverComment: "サーバーコメント",
|
||||
serverCharset: "サーバー文字セット",
|
||||
serverCollation: "サーバー照合順序",
|
||||
unquotedIdentifierCase: "引用符なし識別子",
|
||||
quotedIdentifierCase: "引用符付き識別子",
|
||||
driverName: "ドライバー",
|
||||
driverVersion: "ドライバーバージョン",
|
||||
jdbcVersion: "JDBC バージョン",
|
||||
identifierCase: {
|
||||
lower: "小文字",
|
||||
upper: "大文字",
|
||||
mixed: "大文字小文字混在",
|
||||
},
|
||||
},
|
||||
},
|
||||
editor: {
|
||||
pressToExecute: "{mod}+Enter で実行",
|
||||
@@ -559,6 +584,8 @@ export default withEnglishFallback({
|
||||
allCommands: "All commands",
|
||||
},
|
||||
selectDatabaseRequired: "先にデータベースを選択してください",
|
||||
statementExecutionSucceeded: "{count} 件のステートメントが成功しました",
|
||||
statementExecutionFailed: "{count} 件のステートメントが失敗しました",
|
||||
},
|
||||
tabs: {
|
||||
sql: "SQL",
|
||||
|
||||
@@ -448,6 +448,31 @@ export default withEnglishFallback({
|
||||
kafkaKerberosKrb5ConfPlaceholder: "Opcional, caminho na máquina do DBX Agent, por exemplo /etc/krb5.conf",
|
||||
kafkaKerberosPathHint: "Os caminhos do keytab e do krb5.conf são lidos pelo DBX Agent e devem existir na máquina onde o DBX Agent é executado; os arquivos não são enviados do navegador atual.",
|
||||
kafkaKerberosAuthHint: "Faça login usando GSSAPI + keytab. Se o servidor exigir transmissão criptografada, defina Security como SASL_SSL; caso contrário, você pode usar Auto ou SASL_PLAINTEXT.",
|
||||
databaseInfo: {
|
||||
title: "Informações do Banco de Dados",
|
||||
open: "Abrir informações do banco de dados {database}",
|
||||
copy: "Copiar informações do banco de dados",
|
||||
sourceConfigured: "Configuração atual",
|
||||
sourceTested: "Testado",
|
||||
configuredDescription: "Exibe as informações que podem ser determinadas no formulário; após um teste bem-sucedido, serão complementadas com a versão e os metadados do driver retornados pelo servidor.",
|
||||
testedDescription: "As informações abaixo são da conexão usada neste teste bem-sucedido.",
|
||||
productName: "Sistema de Gerenciamento de Banco de Dados",
|
||||
productVersion: "Versão do banco de dados",
|
||||
currentDatabase: "Banco de dados atual",
|
||||
serverComment: "Descrição do servidor",
|
||||
serverCharset: "Charset do servidor",
|
||||
serverCollation: "Collation do servidor",
|
||||
unquotedIdentifierCase: "Identificador sem aspas",
|
||||
quotedIdentifierCase: "Identificador com aspas",
|
||||
driverName: "Driver",
|
||||
driverVersion: "Versão do driver",
|
||||
jdbcVersion: "Versão do JDBC",
|
||||
identifierCase: {
|
||||
lower: "Minúsculas",
|
||||
upper: "Maiúsculas",
|
||||
mixed: "Maiúsculas e minúsculas",
|
||||
},
|
||||
},
|
||||
},
|
||||
editor: {
|
||||
pressToExecute: "Pressione {mod}+Enter para executar",
|
||||
@@ -560,6 +585,8 @@ export default withEnglishFallback({
|
||||
currentCommand: "Current command",
|
||||
allCommands: "All commands",
|
||||
},
|
||||
statementExecutionSucceeded: "{count} instrução(ões) executada(s) com sucesso",
|
||||
statementExecutionFailed: "{count} instrução(ões) com falha",
|
||||
},
|
||||
tabs: {
|
||||
sql: "SQL",
|
||||
|
||||
@@ -344,6 +344,31 @@ export default withEnglishFallback({
|
||||
save: "保存",
|
||||
editTitle: "编辑连接",
|
||||
testSuccess: "连接成功",
|
||||
databaseInfo: {
|
||||
title: "数据库信息",
|
||||
open: "打开 {database} 数据库信息",
|
||||
copy: "复制数据库信息",
|
||||
sourceConfigured: "当前配置",
|
||||
sourceTested: "已测试",
|
||||
configuredDescription: "先显示表单中可确定的信息;测试成功后会补充服务器返回的版本和驱动元数据。",
|
||||
testedDescription: "以下信息来自本次成功测试使用的连接。",
|
||||
productName: "数据库管理系统",
|
||||
productVersion: "数据库版本",
|
||||
currentDatabase: "当前数据库",
|
||||
serverComment: "服务器说明",
|
||||
serverCharset: "服务器字符集",
|
||||
serverCollation: "服务器排序规则",
|
||||
unquotedIdentifierCase: "普通标识符",
|
||||
quotedIdentifierCase: "引用标识符",
|
||||
driverName: "驱动",
|
||||
driverVersion: "驱动版本",
|
||||
jdbcVersion: "JDBC 版本",
|
||||
identifierCase: {
|
||||
lower: "小写",
|
||||
upper: "大写",
|
||||
mixed: "混合大小写",
|
||||
},
|
||||
},
|
||||
connecting: "正在连接 {name}...",
|
||||
cancelConnecting: "取消连接",
|
||||
connectCancelled: "连接已取消",
|
||||
@@ -470,6 +495,8 @@ export default withEnglishFallback({
|
||||
colorCustom: "自定义颜色",
|
||||
},
|
||||
editor: {
|
||||
statementExecutionSucceeded: "{count} 条语句成功",
|
||||
statementExecutionFailed: "{count} 条语句失败",
|
||||
pressToExecute: "按 {mod}+Enter 执行查询",
|
||||
pressToSaveSql: "按 {mod}+S 保存 SQL",
|
||||
queryTimeoutError: "查询超时 ({seconds}s),请检查数据库连接是否正常",
|
||||
|
||||
@@ -448,6 +448,31 @@ export default withEnglishFallback({
|
||||
kafkaKerberosKrb5ConfPlaceholder: "可選,DBX Agent 所在機器路徑,例如 /etc/krb5.conf",
|
||||
kafkaKerberosPathHint: "keytab 和 krb5.conf 路徑由 DBX Agent 讀取,必須存在於執行 DBX Agent 的機器上;不會從當前瀏覽器上傳檔案。",
|
||||
kafkaKerberosAuthHint: "使用 GSSAPI + keytab 登入。若伺服器端要求加密傳輸,請將 Security 設為 SASL_SSL;否則可使用 Auto 或 SASL_PLAINTEXT。",
|
||||
databaseInfo: {
|
||||
title: "資料庫資訊",
|
||||
open: "開啟 {database} 資料庫資訊",
|
||||
copy: "複製資料庫資訊",
|
||||
sourceConfigured: "目前設定",
|
||||
sourceTested: "已測試",
|
||||
configuredDescription: "先顯示表單中可確定的資訊;測試成功後會補充伺服器回傳的版本和驅動元數據。",
|
||||
testedDescription: "以下資訊來自本次成功測試使用的連線。",
|
||||
productName: "資料庫管理系統",
|
||||
productVersion: "資料庫版本",
|
||||
currentDatabase: "目前資料庫",
|
||||
serverComment: "伺服器說明",
|
||||
serverCharset: "伺服器字元集",
|
||||
serverCollation: "伺服器排序規則",
|
||||
unquotedIdentifierCase: "一般識別碼",
|
||||
quotedIdentifierCase: "引號識別碼",
|
||||
driverName: "驅動",
|
||||
driverVersion: "驅動版本",
|
||||
jdbcVersion: "JDBC 版本",
|
||||
identifierCase: {
|
||||
lower: "小寫",
|
||||
upper: "大寫",
|
||||
mixed: "混合大小寫",
|
||||
},
|
||||
},
|
||||
},
|
||||
editor: {
|
||||
pressToExecute: "按 {mod}+Enter 執行查詢",
|
||||
@@ -560,6 +585,8 @@ export default withEnglishFallback({
|
||||
currentCommand: "目前命令",
|
||||
allCommands: "全部命令",
|
||||
},
|
||||
statementExecutionSucceeded: "{count} 條陳述式成功",
|
||||
statementExecutionFailed: "{count} 條陳述式失敗",
|
||||
},
|
||||
tabs: {
|
||||
sql: "SQL",
|
||||
|
||||
+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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -163,6 +163,29 @@ export interface ConnectionConfig {
|
||||
is_production?: boolean;
|
||||
/** Database-level production markers for multi-database connections. */
|
||||
production_databases?: string[];
|
||||
/** Metadata captured from the latest successful connection test for the saved config. */
|
||||
database_info?: DatabaseConnectionInfo;
|
||||
}
|
||||
|
||||
export type IdentifierCase = "lower" | "upper" | "mixed";
|
||||
|
||||
export interface DatabaseConnectionInfo {
|
||||
productName?: string;
|
||||
productVersion?: string;
|
||||
currentDatabase?: string;
|
||||
serverComment?: string;
|
||||
serverCharset?: string;
|
||||
serverCollation?: string;
|
||||
unquotedIdentifierCase?: IdentifierCase;
|
||||
quotedIdentifierCase?: IdentifierCase;
|
||||
driverName?: string;
|
||||
driverVersion?: string;
|
||||
jdbcVersion?: string;
|
||||
}
|
||||
|
||||
export interface ConnectionTestResult {
|
||||
message: string;
|
||||
databaseInfo?: DatabaseConnectionInfo;
|
||||
}
|
||||
|
||||
export type TransportLayerConfig = ({ type: "ssh" } & SshTunnelConfig) | ({ type: "proxy" } & ProxyTunnelConfig) | ({ type: "http_tunnel" } & HttpTunnelConfig);
|
||||
@@ -471,6 +494,8 @@ export interface QueryResult {
|
||||
columns: string[];
|
||||
/** Set for synthesized query execution failures. */
|
||||
execution_error?: true;
|
||||
/** Zero-based index of the submitted statement that produced this result. */
|
||||
statement_index?: number;
|
||||
/** Internal row identifiers appended to editable query results. */
|
||||
hidden_column_indexes?: number[];
|
||||
/**
|
||||
@@ -512,6 +537,8 @@ export interface QueryResultRun {
|
||||
results?: QueryResult[];
|
||||
activeResultIndex?: number;
|
||||
resultBaseSql?: string;
|
||||
/** Fingerprint of the complete editor document when this result run started. */
|
||||
resultEditorFingerprint?: string;
|
||||
resultSortedSql?: string;
|
||||
resultSortColumn?: string;
|
||||
resultSortColumnIndex?: number;
|
||||
@@ -733,6 +760,8 @@ export interface QueryTab {
|
||||
originalSql?: string;
|
||||
lastExecutedSql?: string;
|
||||
resultBaseSql?: string;
|
||||
/** Fingerprint of the complete editor document when the displayed result started. */
|
||||
resultEditorFingerprint?: string;
|
||||
resultSortedSql?: string;
|
||||
resultSortColumn?: string;
|
||||
resultSortColumnIndex?: number;
|
||||
|
||||
Reference in new issue
Block a user