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

This commit is contained in:
shen 2026-07-15 18:35:34 +08:00 committed by GitHub
parent 83f7604a11
commit 3748d83cde
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
60 changed files with 2403 additions and 134 deletions

View File

@ -9,7 +9,9 @@ import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.Types;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
private Connection connection;
@ -42,14 +44,33 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
@Override
public final boolean testConnection(ConnectParams params) {
return Boolean.TRUE.equals(testConnectionWithInfo(params).get("ok"));
}
@Override
public final Map<String, Object> testConnectionWithInfo(ConnectParams params) {
return unchecked(() -> {
loadDriver(params);
try (Connection conn = openConnection(params)) {
return conn.isValid(5);
boolean valid = conn.isValid(5);
Map<String, Object> result = new LinkedHashMap<>();
result.put("ok", valid);
if (valid) {
Map<String, String> databaseInfo = JdbcDatabaseInfo.from(conn);
if (!databaseInfo.isEmpty()) {
result.put("databaseInfo", databaseInfo);
}
}
return result;
}
});
}
@Override
public final Map<String, String> getDatabaseInfo() {
return JdbcDatabaseInfo.from(getConnection());
}
private void loadDriver(ConnectParams params) throws Exception {
List<String> driverPaths = params.getJdbc_driver_paths();
String driverClass = params.getJdbc_driver_class();

View File

@ -4,7 +4,9 @@ import java.sql.Connection;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@ -13,6 +15,16 @@ public interface DatabaseAgent {
boolean testConnection(ConnectParams params);
default Map<String, Object> testConnectionWithInfo(ConnectParams params) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("ok", testConnection(params));
return result;
}
default Map<String, String> getDatabaseInfo() {
return Collections.emptyMap();
}
default String getIdentifierQuote() {
return "";
}

View File

@ -0,0 +1,95 @@
package com.dbx.agent;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.SQLException;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
final class JdbcDatabaseInfo {
private JdbcDatabaseInfo() {
}
static Map<String, String> from(Connection connection) {
if (connection == null) {
return Collections.emptyMap();
}
final DatabaseMetaData metadata;
try {
metadata = connection.getMetaData();
} catch (SQLException | AbstractMethodError | UnsupportedOperationException ignored) {
return Collections.emptyMap();
}
if (metadata == null) {
return Collections.emptyMap();
}
Map<String, String> info = new LinkedHashMap<>();
putText(info, "productName", () -> metadata.getDatabaseProductName());
putText(info, "productVersion", () -> metadata.getDatabaseProductVersion());
putIdentifierCase(
info,
"unquotedIdentifierCase",
() -> metadata.storesLowerCaseIdentifiers(),
() -> metadata.storesUpperCaseIdentifiers(),
() -> metadata.storesMixedCaseIdentifiers()
);
putIdentifierCase(
info,
"quotedIdentifierCase",
() -> metadata.storesLowerCaseQuotedIdentifiers(),
() -> metadata.storesUpperCaseQuotedIdentifiers(),
() -> metadata.storesMixedCaseQuotedIdentifiers()
);
putText(info, "driverName", () -> metadata.getDriverName());
putText(info, "driverVersion", () -> metadata.getDriverVersion());
Integer jdbcMajor = readInteger(() -> metadata.getJDBCMajorVersion());
Integer jdbcMinor = readInteger(() -> metadata.getJDBCMinorVersion());
if (jdbcMajor != null && jdbcMinor != null && jdbcMajor >= 0 && jdbcMinor >= 0) {
info.put("jdbcVersion", jdbcMajor + "." + jdbcMinor);
}
return info;
}
private static void putText(Map<String, String> target, String key, SqlSupplier<String> supplier) {
String value = read(supplier);
if (value != null && !value.trim().isEmpty()) {
target.put(key, value.trim());
}
}
private static void putIdentifierCase(
Map<String, String> target,
String key,
SqlSupplier<Boolean> lower,
SqlSupplier<Boolean> upper,
SqlSupplier<Boolean> mixed
) {
if (Boolean.TRUE.equals(read(lower))) {
target.put(key, "lower");
} else if (Boolean.TRUE.equals(read(upper))) {
target.put(key, "upper");
} else if (Boolean.TRUE.equals(read(mixed))) {
target.put(key, "mixed");
}
}
private static Integer readInteger(SqlSupplier<Integer> supplier) {
return read(supplier);
}
private static <T> T read(SqlSupplier<T> supplier) {
try {
return supplier.get();
} catch (SQLException | AbstractMethodError | UnsupportedOperationException ignored) {
return null;
}
}
private interface SqlSupplier<T> {
T get() throws SQLException;
}
}

View File

@ -17,7 +17,9 @@ import java.math.BigDecimal;
import java.math.BigInteger;
import java.sql.Connection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public final class JsonRpcServer {
private static final long CONNECTION_VALIDATION_INTERVAL_MILLIS = 5_000L;
@ -105,10 +107,11 @@ public final class JsonRpcServer {
return Collections.singletonMap("ok", true);
}
if (AgentProtocol.METHOD_TEST_CONNECTION.equals(method)) {
if (!agent.testConnection(gson.fromJson(params, ConnectParams.class))) {
Map<String, Object> result = agent.testConnectionWithInfo(gson.fromJson(params, ConnectParams.class));
if (!Boolean.TRUE.equals(result.get("ok"))) {
throw new RuntimeException("Connection failed");
}
return Collections.singletonMap("ok", true);
return result;
}
if (AgentProtocol.METHOD_VALIDATE_CONNECTION.equals(method)) {
Connection conn = agent.getConnection();
@ -126,7 +129,13 @@ public final class JsonRpcServer {
}
ensureLiveConnection(method);
if (AgentProtocol.METHOD_CONNECTION_INFO.equals(method)) {
return Collections.singletonMap("identifierQuote", agent.getIdentifierQuote());
Map<String, Object> result = new LinkedHashMap<>();
result.put("identifierQuote", agent.getIdentifierQuote());
Map<String, String> databaseInfo = agent.getDatabaseInfo();
if (databaseInfo != null && !databaseInfo.isEmpty()) {
result.put("databaseInfo", databaseInfo);
}
return result;
}
if (AgentProtocol.METHOD_LIST_DATABASES.equals(method)) {
return agent.listDatabases();

View File

@ -14,6 +14,7 @@ import java.sql.Types;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Map;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
@ -56,6 +57,47 @@ class AbstractJdbcAgentTest {
assertEquals(1, tracking.closeCount);
}
@Test
void databaseInfoKeepsSupportedFieldsWhenOneMetadataGetterFails() {
DatabaseMetaData metadata = proxy(DatabaseMetaData.class, (method, args) -> {
switch (method.getName()) {
case "getDatabaseProductName":
return "ExampleDB";
case "getDatabaseProductVersion":
throw new UnsupportedOperationException("version unavailable");
case "storesLowerCaseIdentifiers":
throw new UnsupportedOperationException("case unavailable");
case "storesUpperCaseIdentifiers":
return true;
case "storesMixedCaseQuotedIdentifiers":
return true;
case "getDriverName":
return "Example JDBC";
case "getDriverVersion":
return "1.2.3";
case "getJDBCMajorVersion":
return 4;
case "getJDBCMinorVersion":
return 2;
default:
return defaultValue(method.getReturnType());
}
});
Connection connection = proxy(Connection.class, (method, args) ->
"getMetaData".equals(method.getName()) ? metadata : defaultValue(method.getReturnType())
);
Map<String, String> info = JdbcDatabaseInfo.from(connection);
assertEquals("ExampleDB", info.get("productName"));
assertFalse(info.containsKey("productVersion"));
assertEquals("upper", info.get("unquotedIdentifierCase"));
assertEquals("mixed", info.get("quotedIdentifierCase"));
assertEquals("Example JDBC", info.get("driverName"));
assertEquals("1.2.3", info.get("driverVersion"));
assertEquals("4.2", info.get("jdbcVersion"));
}
@Test
void delegatesQueryExecutionWithSchemaAndValueReader() {
TrackingConnection tracking = new TrackingConnection();

View File

@ -17,7 +17,9 @@ import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
@ -83,6 +85,49 @@ class CommonJavaCompatibilityTest {
assertTrue(containsCapability(result.getAsJsonArray("capabilities"), "metadata"));
}
@Test
void jsonRpcConnectionTestAddsOptionalDatabaseInfoWithoutChangingLegacySuccess() {
JsonRpcServer legacyServer = new JsonRpcServer(new MinimalAgent());
JsonObject legacyResult = JsonParser.parseString(legacyServer.handleRequest(
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"test_connection\",\"params\":{}}"
)).getAsJsonObject().getAsJsonObject("result");
assertTrue(legacyResult.get("ok").getAsBoolean());
assertFalse(legacyResult.has("databaseInfo"));
JsonRpcServer detailedServer = new JsonRpcServer(new MinimalAgent() {
@Override
public Map<String, Object> testConnectionWithInfo(ConnectParams params) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("ok", true);
result.put("databaseInfo", Collections.singletonMap("productName", "ExampleDB"));
return result;
}
});
JsonObject detailedResult = JsonParser.parseString(detailedServer.handleRequest(
"{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"test_connection\",\"params\":{}}"
)).getAsJsonObject().getAsJsonObject("result");
assertEquals("ExampleDB", detailedResult.getAsJsonObject("databaseInfo").get("productName").getAsString());
}
@Test
void multiSessionConnectionTestDelegatesOptionalDatabaseInfo() {
MultiSessionJsonRpcServer server = new MultiSessionJsonRpcServer(() -> new MinimalAgent() {
@Override
public Map<String, Object> testConnectionWithInfo(ConnectParams params) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("ok", true);
result.put("databaseInfo", Collections.singletonMap("driverName", "Example JDBC"));
return result;
}
});
JsonObject result = JsonParser.parseString(server.handleRequest(
"{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"test_connection\",\"params\":{}}"
)).getAsJsonObject().getAsJsonObject("result");
assertEquals("Example JDBC", result.getAsJsonObject("databaseInfo").get("driverName").getAsString());
}
@Test
void multiSessionServerCreatesAndClosesIndependentAgents() {
java.util.List<TrackingAgent> created = new java.util.ArrayList<>();

View File

@ -10,6 +10,7 @@ import com.dbx.agent.TableInfo;
import com.dbx.agent.test.JdbcExecutionBehaviorTest;
import com.dbx.agent.test.JdbcMetadataBehaviorTest;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
@ -41,6 +42,35 @@ class H2AgentMigrationTest {
Assertions.assertEquals("jdbc:h2:tcp://127.0.0.1:9092/test", H2Agent.buildUrl(params));
}
@Test
@SuppressWarnings("unchecked")
void exposesDatabaseMetadataForTestAndConnectedConnection() {
ConnectParams params = new ConnectParams("", 0, "mem:dbx-agent-info;DB_CLOSE_DELAY=-1", "sa", "", "", "", false);
H2Agent agent = new H2Agent();
Map<String, Object> result = agent.testConnectionWithInfo(params);
Assertions.assertEquals(true, result.get("ok"));
Map<String, String> testedInfo = (Map<String, String>) result.get("databaseInfo");
assertH2DatabaseInfo(testedInfo);
agent.connect(params);
try {
assertH2DatabaseInfo(agent.getDatabaseInfo());
} finally {
agent.disconnect();
}
}
private static void assertH2DatabaseInfo(Map<String, String> info) {
Assertions.assertEquals("H2", info.get("productName"));
Assertions.assertFalse(info.get("productVersion").isEmpty());
Assertions.assertFalse(info.get("driverName").isEmpty());
Assertions.assertFalse(info.get("driverVersion").isEmpty());
Assertions.assertFalse(info.get("jdbcVersion").isEmpty());
Assertions.assertEquals("upper", info.get("unquotedIdentifierCase"));
Assertions.assertFalse(info.containsKey("quotedIdentifierCase"));
}
}
class H2ExecutionBehaviorTest extends JdbcExecutionBehaviorTest {

View File

@ -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;

View File

@ -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;

View File

@ -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)"

View File

@ -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 };

View File

@ -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.",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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),请检查数据库连接是否正常",

View File

@ -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",

View File

@ -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([]);
});
});

View File

@ -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 },
]);
});
});

View File

@ -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");

View File

@ -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 });
}

View File

@ -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 });
}

View File

@ -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);
});
});

View File

@ -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);
}

View File

@ -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}`;
}

View File

@ -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);
}

View File

@ -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),

View File

@ -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);
});
});

View File

@ -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 },

View File

@ -1,7 +1,23 @@
import { defineStore } from "pinia";
import { uuid } from "@/lib/common/utils";
import { ref, computed, watch, markRaw } from "vue";
import type { ColumnInfo, CompletionAssistantCandidate, CompletionAssistantObjectKind, CompletionAssistantRequest, ConnectionConfig, CatalogInfo, ForeignKeyInfo, ObjectInfo, SchemaInfo, SidebarLayout, TableInfo, TreeNode, TunnelProfile, VectorCollectionMeta } from "@/types/database";
import type {
ColumnInfo,
CompletionAssistantCandidate,
CompletionAssistantObjectKind,
CompletionAssistantRequest,
ConnectionConfig,
DatabaseConnectionInfo,
CatalogInfo,
ForeignKeyInfo,
ObjectInfo,
SchemaInfo,
SidebarLayout,
TableInfo,
TreeNode,
TunnelProfile,
VectorCollectionMeta,
} from "@/types/database";
import { applyPinnedTreeNodeState, inheritNaturalTreeNodeOrder, migrateLegacyPinnedTreeNodeIds, syncPinnedTreeNodeStateInPlace, treeNodePinKey } from "@/lib/app/pinnedItems";
import {
reconcileLayout,
@ -67,6 +83,7 @@ import { REDIS_SCAN_PAGE_SIZE_DEFAULT } from "@/lib/redis/redisKeyPattern";
import { appendAgentDriverUpdateHint, hasAgentDriverUpdate, type AgentDriverInstallState } from "@/lib/connection/agentDriverInstallHint";
import { appendConnectionErrorHints } from "@/lib/connection/connectionErrorHints";
import { appendVisibleDatabaseSelection } from "@/lib/connection/connectionVisibleDatabases";
import { configuredDatabaseProductName, connectionConfigFingerprint, normalizeDatabaseConnectionInfo } from "@/lib/connection/connectionDatabaseInfo";
import { createMetadataLoadTrace, logMetadataLoadTrace, MetadataLoadCoordinator, type MetadataLoadTraceLogger } from "@/lib/metadata/metadataLoadCoordinator";
import type { MetadataScopeInput } from "@/lib/metadata/metadataLoadScope";
import { MetadataResultCache, type MetadataCacheInvalidation } from "@/lib/metadata/metadataResultCache";
@ -872,6 +889,7 @@ export const useConnectionStore = defineStore("connection", () => {
query_timeout_secs: config.query_timeout_secs ?? 30,
idle_timeout_secs: config.idle_timeout_secs ?? 60,
keepalive_interval_secs: config.keepalive_interval_secs ?? DEFAULT_KEEPALIVE_INTERVAL_SECS,
database_info: normalizeDatabaseConnectionInfo(config.database_info),
};
}
@ -1767,6 +1785,35 @@ export const useConnectionStore = defineStore("connection", () => {
}
}
async function updateConnectionDatabaseInfo(connectionId: string, databaseInfo: DatabaseConnectionInfo, expectedConfigFingerprint?: string): Promise<void> {
const normalized = normalizeDatabaseConnectionInfo(databaseInfo);
if (!normalized) return;
const current = connections.value.find((connection) => connection.id === connectionId);
if (!current) return;
if (expectedConfigFingerprint && connectionConfigFingerprint(current) !== expectedConfigFingerprint) return;
if (JSON.stringify(current.database_info) === JSON.stringify(normalized)) return;
await api.saveConnectionDatabaseInfo(connectionId, normalized);
const index = connections.value.findIndex((connection) => connection.id === connectionId);
if (index < 0) return;
if (expectedConfigFingerprint && connectionConfigFingerprint(connections.value[index]) !== expectedConfigFingerprint) return;
const nextConnections = [...connections.value];
nextConnections[index] = { ...nextConnections[index], database_info: normalized };
connections.value = nextConnections;
rebuildTreeNodes();
}
async function refreshConnectedDatabaseInfo(connectionId: string, config: ConnectionConfig): Promise<void> {
const expectedConfigFingerprint = connectionConfigFingerprint(config);
try {
const detected = await api.connectionDatabaseInfo(connectionId);
const normalized = normalizeDatabaseConnectionInfo(detected, configuredDatabaseProductName(config), config.database);
if (normalized) await updateConnectionDatabaseInfo(connectionId, normalized, expectedConfigFingerprint);
} catch {
// Database metadata is optional and must not turn a successful connection into a failure.
}
}
async function syncMongoLegacyDriverFallback(connectionId: string, previousConfig: ConnectionConfig) {
if (!isDesktop || previousConfig.db_type !== "mongodb" || previousConfig.driver_profile === MONGO_LEGACY_DRIVER_PROFILE) {
return;
@ -1951,6 +1998,7 @@ export const useConnectionStore = defineStore("connection", () => {
await ensureLocalConnectionAttemptActiveAfterConnectResult(config.id, localAttempt, id);
activeConnectionId.value = id;
connectedIds.value.add(id);
void refreshConnectedDatabaseInfo(id, { ...config, id });
await refreshConnectionIdentifierQuote(id, { ...config, id });
if (id !== config.id) markSuccessfulLocalConnectionAttempt(config.id, localAttempt);
markSuccessfulLocalConnectionAttempt(id, localAttempt);
@ -2115,6 +2163,7 @@ export const useConnectionStore = defineStore("connection", () => {
await syncMongoLegacyDriverFallback(connectionId, config);
await ensureLocalConnectionAttemptActiveAfterConnectResult(connectionId, localAttempt, id);
connectedIds.value.add(connectionId);
void refreshConnectedDatabaseInfo(connectionId, config);
await refreshConnectionIdentifierQuote(connectionId, config);
markSuccessfulLocalConnectionAttempt(connectionId, localAttempt);
markConnectionHealthChecked(connectionId);
@ -5278,6 +5327,7 @@ export const useConnectionStore = defineStore("connection", () => {
pasteConnectionClipboard,
addEphemeralConnection,
updateConnection,
updateConnectionDatabaseInfo,
setDefaultDatabase,
clearDefaultDatabase,
isDefaultDatabase,

View File

@ -55,6 +55,7 @@ import { useSavedSqlStore } from "@/stores/savedSqlStore";
import { createSavedSqlEditorPosition, initSavedSqlEditorPositions, restoreSavedSqlEditorPosition, saveSavedSqlEditorPosition } from "@/lib/app/savedSqlEditorPosition";
import { ensureSqlExtension } from "@/lib/savedSql/savedSqlFileName";
import { safeLocalStorageGet, safeLocalStorageRemove } from "@/lib/backend/safeStorage";
import { sqlTextFingerprint } from "@/lib/sql/sqlTextFingerprint";
import type { SavedSqlFile } from "@/types/database";
const ORACLE_LIKE_METADATA_TYPES = new Set<string>(["oracle", "dameng", "oceanbase-oracle"]);
@ -143,7 +144,10 @@ function annotateQueryResultSources(results: QueryResult[], sql: string, databas
const statements = splitSqlStatementRanges(sql, databaseType);
let statementIndex = 0;
for (const result of results) {
const statement = statements[statementIndex++];
const explicitIndex = Number.isInteger(result.statement_index) && result.statement_index! >= 0 ? result.statement_index : undefined;
const sourceIndex = explicitIndex ?? statementIndex;
statementIndex = Math.max(statementIndex, sourceIndex + 1);
const statement = statements[sourceIndex];
if (!statement) continue;
annotateQueryResultSource(result, statement.sql, database, databaseType, sourceOffset === undefined ? undefined : { from: sourceOffset + statement.from, to: sourceOffset + statement.to });
}
@ -454,6 +458,7 @@ export const useQueryStore = defineStore("query", () => {
tab.result = undefined;
tab.results = undefined;
tab.activeResultIndex = undefined;
tab.resultEditorFingerprint = undefined;
tab.resultLocalSortOriginalRows = undefined;
tab.resultLocalSortOriginalMongoDocuments = undefined;
tab.resultSortMode = undefined;
@ -500,6 +505,7 @@ export const useQueryStore = defineStore("query", () => {
tab.results = run.results;
tab.activeResultIndex = run.activeResultIndex;
tab.resultBaseSql = run.resultBaseSql;
tab.resultEditorFingerprint = run.resultEditorFingerprint;
tab.resultSortedSql = run.resultSortedSql;
tab.resultSortColumn = run.resultSortColumn;
tab.resultSortColumnIndex = run.resultSortColumnIndex;
@ -598,6 +604,7 @@ export const useQueryStore = defineStore("query", () => {
result: run.result,
results: run.results,
activeResultIndex: run.activeResultIndex,
resultEditorFingerprint: run.resultEditorFingerprint,
resultRuns: [run],
activeResultRunId: run.id,
queryAnalysis: run.queryAnalysis,
@ -642,6 +649,7 @@ export const useQueryStore = defineStore("query", () => {
results: tab.results,
activeResultIndex: tab.activeResultIndex,
resultBaseSql: tab.resultBaseSql,
resultEditorFingerprint: tab.resultEditorFingerprint,
resultSortedSql: tab.resultSortedSql,
resultSortColumn: tab.resultSortColumn,
resultSortColumnIndex: tab.resultSortColumnIndex,
@ -693,6 +701,7 @@ export const useQueryStore = defineStore("query", () => {
results: tab.results,
activeResultIndex: tab.activeResultIndex,
resultBaseSql: tab.resultBaseSql,
resultEditorFingerprint: tab.resultEditorFingerprint,
resultSortedSql: tab.resultSortedSql,
resultSortColumn: tab.resultSortColumn,
resultSortColumnIndex: tab.resultSortColumnIndex,
@ -2597,6 +2606,7 @@ export const useQueryStore = defineStore("query", () => {
if (!tab || !sql.trim()) return;
const executionId = uuid();
const executionEditorFingerprint = tab.mode === "query" ? sqlTextFingerprint(tab.sql) : undefined;
const traceId = executionId.slice(0, 8);
const startedAt = performance.now();
const elapsed = () => `${Math.round(performance.now() - startedAt)}ms`;
@ -3133,6 +3143,7 @@ export const useQueryStore = defineStore("query", () => {
current.result = results[0];
}
current.resultBaseSql = shouldReplaceActiveResultInGroup ? (current.resultBaseSql ?? queryBaseSql) : queryBaseSql;
current.resultEditorFingerprint = shouldReplaceActiveResultInGroup ? (current.resultEditorFingerprint ?? executionEditorFingerprint) : executionEditorFingerprint;
current.resultSortedSql = resultSortedSql;
current.resultPageSql = pageSql;
current.resultPageLimit = pageLimit;
@ -3644,6 +3655,7 @@ export const useQueryStore = defineStore("query", () => {
const activeIndex = snapshot.activeResultIndex ?? 0;
tab.results = results;
tab.activeResultIndex = snapshot.activeResultIndex;
tab.resultEditorFingerprint = snapshot.resultEditorFingerprint;
tab.result = snapshot.result ? markQueryResultRowsRaw(snapshot.result) : results?.[activeIndex] ? markQueryResultRowsRaw(results[activeIndex]) : undefined;
tab.resultLocalSortOriginalRows = snapshot.resultLocalSortOriginalRows ? markRaw(snapshot.resultLocalSortOriginalRows) : undefined;
tab.resultLocalSortOriginalMongoDocuments = snapshot.resultLocalSortOriginalMongoDocuments ? markRaw(snapshot.resultLocalSortOriginalMongoDocuments) : undefined;

View File

@ -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;

View File

@ -624,6 +624,7 @@ mod tests {
read_only: false,
is_production: false,
production_databases: vec![],
database_info: None,
}
}

View File

@ -1099,6 +1099,7 @@ mod tests {
read_only: false,
is_production: false,
production_databases: vec![],
database_info: None,
}
}
@ -1160,6 +1161,7 @@ mod tests {
read_only: false,
is_production: false,
production_databases: vec![],
database_info: None,
}
}
@ -1269,6 +1271,7 @@ mod tests {
read_only: false,
is_production: false,
production_databases: vec![],
database_info: None,
};
scrub_connection_secrets(&mut config);
assert!(config.password.is_empty());

View File

@ -22,8 +22,8 @@ use crate::db::http_tunnel::HttpTunnelManager;
use crate::db::proxy_tunnel::ProxyTunnelManager;
use crate::db::ssh_tunnel::TunnelManager;
use crate::models::connection::{
parse_jdbc_host_port, parse_mongo_first_host, rewrite_jdbc_url_host, ConnectionConfig, DatabaseType,
TransportLayerConfig,
database_info_from_protocol_value, parse_jdbc_host_port, parse_mongo_first_host, rewrite_jdbc_url_host,
ConnectionConfig, ConnectionTestResult, DatabaseConnectionInfo, DatabaseType, TransportLayerConfig,
};
use crate::path_utils::expand_tilde;
use crate::plugins::{PluginDriverSession, PluginRegistry, PluginRuntimeEnv};
@ -110,6 +110,12 @@ pub enum PoolKind {
Nacos,
}
enum ConnectionDatabaseInfoSource {
Agent(Arc<tokio::sync::Mutex<db::agent_driver::AgentDriverClient>>),
ExternalDriver { config: Arc<ConnectionConfig>, session: Arc<PluginDriverSession> },
NativeMysql(db::mysql::MySqlPool),
}
/// Held connection for a manual transaction session
pub enum TxnConnection {
Postgres(Box<deadpool_postgres::Object>),
@ -654,9 +660,18 @@ impl AppState {
}
pub async fn test_external_driver(&self, driver_id: &str, config: &ConnectionConfig) -> Result<String, String> {
self.test_external_driver_with_info(driver_id, config).await.map(|result| result.message)
}
pub async fn test_external_driver_with_info(
&self,
driver_id: &str,
config: &ConnectionConfig,
) -> Result<ConnectionTestResult, String> {
let params = serde_json::json!({ "connection": config });
let env = self.external_driver_runtime_env(driver_id)?;
self.plugins
let response = self
.plugins
.invoke_driver_with_env_and_timeout::<serde_json::Value>(
driver_id,
"testConnection",
@ -665,7 +680,8 @@ impl AppState {
Some(external_driver_connect_timeout(config)),
)
.await?;
Ok("Connection successful".to_string())
Ok(ConnectionTestResult::success("Connection successful")
.with_database_info(database_info_from_protocol_value(&response)))
}
pub async fn external_driver_pool(&self, driver_id: &str, config: &ConnectionConfig) -> Result<PoolKind, String> {
@ -685,6 +701,18 @@ impl AppState {
port: u16,
connect_timeout: Duration,
) -> Result<String, String> {
self.test_sqlserver_connection_with_legacy_fallback_with_info(config, host, port, connect_timeout)
.await
.map(|result| result.message)
}
pub async fn test_sqlserver_connection_with_legacy_fallback_with_info(
&self,
config: &ConnectionConfig,
host: &str,
port: u16,
connect_timeout: Duration,
) -> Result<ConnectionTestResult, String> {
match db::sqlserver::connect_with_port_explicit(
host,
port,
@ -696,7 +724,7 @@ impl AppState {
)
.await
{
Ok(_) => Ok("Connection successful".to_string()),
Ok(_) => Ok(ConnectionTestResult::success("Connection successful")),
Err(native_error)
if db::sqlserver::sqlserver_legacy_compatibility_enabled(config.url_params.as_deref()) =>
{
@ -708,7 +736,7 @@ impl AppState {
.spawn(&legacy_config.db_type, legacy_config.driver_profile.as_deref())
.await
.map_err(|err| sqlserver_legacy_agent_error(&native_error, &err))?;
client
let response = client
.call_method_with_timeout::<serde_json::Value>(
AgentMethod::TestConnection,
connect_params,
@ -717,7 +745,8 @@ impl AppState {
.await
.map_err(|err| sqlserver_legacy_agent_error(&native_error, &err))?;
client.disconnect().await.ok();
Ok("Connection successful (via SQL Server legacy compatibility driver)".to_string())
Ok(ConnectionTestResult::success("Connection successful (via SQL Server legacy compatibility driver)")
.with_database_info(database_info_from_protocol_value(&response)))
}
Err(err) => Err(err),
}
@ -2346,6 +2375,68 @@ impl AppState {
Ok(Some(info.identifier_quote))
}
pub async fn connection_database_info(
&self,
connection_id: &str,
database: Option<&str>,
) -> Result<Option<DatabaseConnectionInfo>, String> {
let config = self
.configs
.read()
.await
.get(connection_id)
.cloned()
.ok_or_else(|| format!("Connection config not found: {connection_id}"))?;
let pool_key = self.get_or_create_pool(connection_id, database).await?;
let source = {
let connections = self.connections.read().await;
match connections.get(&pool_key) {
Some(PoolKind::Agent(client)) => Some(ConnectionDatabaseInfoSource::Agent(client.clone())),
Some(PoolKind::ExternalDriver { config, session, .. }) => {
Some(ConnectionDatabaseInfoSource::ExternalDriver {
config: config.clone(),
session: session.clone(),
})
}
Some(PoolKind::Mysql(pool, _)) => Some(ConnectionDatabaseInfoSource::NativeMysql(pool.clone())),
_ => None,
}
};
match source {
Some(ConnectionDatabaseInfoSource::Agent(client)) => {
let mut agent = client.lock().await;
Ok(agent.connection_info(Some(db::connection_timeout())).await?.database_info)
}
Some(ConnectionDatabaseInfoSource::ExternalDriver { config, session }) => {
let response = session
.invoke_with_timeout::<serde_json::Value>(
"connectionInfo",
serde_json::json!({ "connection": config.as_ref() }),
Some(db::connection_timeout()),
)
.await?;
Ok(database_info_from_protocol_value(&response))
}
Some(ConnectionDatabaseInfoSource::NativeMysql(pool)) => {
db::mysql::database_connection_info(&pool, db::mysql::protocol_product_name(&config)).await.map(Some)
}
None => Ok(None),
}
}
pub async fn save_connection_database_info(
&self,
connection_id: &str,
database_info: Option<DatabaseConnectionInfo>,
) -> Result<(), String> {
self.storage.save_connection_database_info(connection_id, database_info.clone()).await?;
if let Some(config) = self.configs.write().await.get_mut(connection_id) {
config.database_info = database_info;
}
Ok(())
}
pub async fn reset_connection_transport(&self, connection_id: &str) {
let layer_count = {
let configs = self.configs.read().await;
@ -3382,6 +3473,7 @@ mod tests {
read_only: false,
is_production: false,
production_databases: vec![],
database_info: None,
}
}

View File

@ -782,6 +782,7 @@ mod tests {
read_only: false,
is_production: false,
production_databases: vec![],
database_info: None,
}
}

View File

@ -11,6 +11,8 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
use tokio_util::sync::CancellationToken;
use crate::models::connection::DatabaseConnectionInfo;
type PendingAgentResponse = tokio::sync::oneshot::Sender<Result<Value, String>>;
pub struct AgentRuntimeClient {
@ -481,7 +483,10 @@ impl AgentMethod {
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentConnectionInfo {
#[serde(default)]
pub identifier_quote: String,
#[serde(default)]
pub database_info: Option<DatabaseConnectionInfo>,
}
#[derive(Debug, Clone, Serialize)]

View File

@ -12,7 +12,7 @@ use std::time::Duration;
use std::time::Instant;
use tokio_util::sync::CancellationToken;
use crate::models::connection::DatabaseType;
use crate::models::connection::{ConnectionConfig, DatabaseConnectionInfo, DatabaseType};
use crate::sql::starts_with_executable_sql_keyword;
use crate::types::{
ColumnInfo, CompletionAssistantCandidate, CompletionAssistantCandidateKind, CompletionAssistantMatchMode,
@ -103,6 +103,55 @@ fn first_nonempty_str_by_name(row: &mysql_async::Row, names: &[&str]) -> String
String::new()
}
fn nonblank(value: String) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
async fn query_first_nonblank_string(conn: &mut mysql_async::Conn, sql: &str) -> Option<String> {
match conn.query_first::<String, _>(sql).await {
Ok(Some(value)) => nonblank(value),
Ok(None) => None,
Err(error) => {
log::debug!("Failed to read optional MySQL database information with `{sql}`: {error}");
None
}
}
}
pub async fn database_connection_info(
pool: &MySqlPool,
product_name: impl Into<String>,
) -> Result<DatabaseConnectionInfo, String> {
let product_name = nonblank(product_name.into()).unwrap_or_else(|| "MySQL".to_string());
let mut conn = get_conn_with_health_check(pool).await?;
Ok(DatabaseConnectionInfo {
product_name: Some(product_name),
product_version: query_first_nonblank_string(&mut conn, "SELECT VERSION()").await,
current_database: query_first_nonblank_string(&mut conn, "SELECT COALESCE(DATABASE(), '')").await,
server_comment: query_first_nonblank_string(&mut conn, "SELECT @@version_comment").await,
server_charset: query_first_nonblank_string(&mut conn, "SELECT @@character_set_server").await,
server_collation: query_first_nonblank_string(&mut conn, "SELECT @@collation_server").await,
..DatabaseConnectionInfo::default()
})
}
pub fn protocol_product_name(config: &ConnectionConfig) -> String {
config.driver_label.as_deref().map(str::trim).filter(|value| !value.is_empty()).map(str::to_string).unwrap_or_else(
|| match config.db_type {
DatabaseType::Doris => "Doris".to_string(),
DatabaseType::StarRocks => "StarRocks".to_string(),
DatabaseType::ManticoreSearch => "Manticore Search".to_string(),
_ => "MySQL".to_string(),
},
)
}
fn get_opt_metadata_string(row: &mysql_async::Row, name: &str) -> Option<String> {
get_opt_str(row, name)
.or_else(|| row_get::<NaiveDateTime, _>(row, name).map(|value| value.to_string()))

View File

@ -3204,6 +3204,7 @@ mod tests {
read_only: false,
is_production: false,
production_databases: vec![],
database_info: None,
};
assert_eq!(redis_database_index(&config), 4);

View File

@ -3,6 +3,71 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum IdentifierCase {
Lower,
Upper,
Mixed,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct DatabaseConnectionInfo {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub product_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub product_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_database: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub server_comment: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub server_charset: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub server_collation: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unquoted_identifier_case: Option<IdentifierCase>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub quoted_identifier_case: Option<IdentifierCase>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub driver_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub driver_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub jdbc_version: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ConnectionTestResult {
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub database_info: Option<DatabaseConnectionInfo>,
}
impl ConnectionTestResult {
pub fn success(message: impl Into<String>) -> Self {
Self { message: message.into(), database_info: None }
}
pub fn with_database_info(mut self, database_info: Option<DatabaseConnectionInfo>) -> Self {
self.database_info = database_info;
self
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct DatabaseInfoEnvelope {
#[serde(default)]
database_info: Option<DatabaseConnectionInfo>,
}
pub fn database_info_from_protocol_value(value: &Value) -> Option<DatabaseConnectionInfo> {
serde_json::from_value::<DatabaseInfoEnvelope>(value.clone()).ok()?.database_info
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct ConnectionConfig {
pub id: String,
@ -100,6 +165,9 @@ pub struct ConnectionConfig {
/// Database-level production markers for multi-database connections.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub production_databases: Vec<String>,
/// Metadata captured from the latest successful connection test for this saved config.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub database_info: Option<DatabaseConnectionInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@ -546,6 +614,8 @@ struct ConnectionConfigData {
pub is_production: bool,
#[serde(default)]
pub production_databases: Vec<String>,
#[serde(default)]
pub database_info: Option<DatabaseConnectionInfo>,
}
impl From<ConnectionConfigData> for ConnectionConfig {
@ -599,6 +669,7 @@ impl From<ConnectionConfigData> for ConnectionConfig {
read_only: data.read_only,
is_production: data.is_production,
production_databases: data.production_databases,
database_info: data.database_info,
}
}
}
@ -1889,8 +1960,9 @@ fn bracket_ipv6(host: &str) -> String {
#[cfg(test)]
mod tests {
use super::{
default_query_timeout_secs, default_redis_key_separator, default_ssh_connect_timeout_secs, ConnectionConfig,
DatabaseType, ProxyTunnelConfig, ProxyType, TransportLayerConfig,
database_info_from_protocol_value, default_query_timeout_secs, default_redis_key_separator,
default_ssh_connect_timeout_secs, ConnectionConfig, ConnectionTestResult, DatabaseConnectionInfo, DatabaseType,
IdentifierCase, ProxyTunnelConfig, ProxyType, TransportLayerConfig,
};
use std::str::FromStr;
@ -1899,6 +1971,41 @@ mod tests {
assert_eq!(default_query_timeout_secs(), 60);
}
#[test]
fn connection_test_result_uses_camel_case_and_omits_missing_details() {
let result =
ConnectionTestResult::success("Connection successful").with_database_info(Some(DatabaseConnectionInfo {
product_name: Some("H2".to_string()),
unquoted_identifier_case: Some(IdentifierCase::Upper),
..DatabaseConnectionInfo::default()
}));
let value = serde_json::to_value(result).unwrap();
assert_eq!(value["message"], "Connection successful");
assert_eq!(value["databaseInfo"]["productName"], "H2");
assert_eq!(value["databaseInfo"]["unquotedIdentifierCase"], "upper");
assert!(value["databaseInfo"].get("driverName").is_none());
}
#[test]
fn protocol_database_info_parser_accepts_details_and_legacy_responses() {
let parsed = database_info_from_protocol_value(&serde_json::json!({
"ok": true,
"databaseInfo": {
"productName": "H2",
"currentDatabase": "app",
"serverCharset": "utf8mb4",
"jdbcVersion": "4.2"
}
}))
.unwrap();
assert_eq!(parsed.product_name.as_deref(), Some("H2"));
assert_eq!(parsed.current_database.as_deref(), Some("app"));
assert_eq!(parsed.server_charset.as_deref(), Some("utf8mb4"));
assert_eq!(parsed.jdbc_version.as_deref(), Some("4.2"));
assert_eq!(database_info_from_protocol_value(&serde_json::json!({ "ok": true })), None);
}
fn mysql_config(username: &str, password: &str, database: Option<&str>) -> ConnectionConfig {
ConnectionConfig {
id: "id".to_string(),
@ -1949,6 +2056,7 @@ mod tests {
read_only: false,
is_production: false,
production_databases: vec![],
database_info: None,
}
}
@ -2020,6 +2128,25 @@ mod tests {
.unwrap();
assert!(config.agent_java_options.is_empty());
assert_eq!(config.database_info, None);
}
#[test]
fn connection_config_database_info_survives_json_round_trip() {
let mut config = mysql_config("root", "secret", Some("app"));
config.database_info = Some(DatabaseConnectionInfo {
product_name: Some("MySQL".to_string()),
product_version: Some("8.4.0".to_string()),
current_database: Some("app".to_string()),
server_charset: Some("utf8mb4".to_string()),
..DatabaseConnectionInfo::default()
});
let value = serde_json::to_value(&config).unwrap();
assert_eq!(value["database_info"]["productVersion"], "8.4.0");
let restored: ConnectionConfig = serde_json::from_value(value).unwrap();
assert_eq!(restored.database_info, config.database_info);
}
#[test]

View File

@ -147,6 +147,7 @@ mod tests {
read_only: false,
is_production: false,
production_databases: vec![],
database_info: None,
};
cfg.redis_key_separator = ":".to_string();
cfg

View File

@ -599,6 +599,7 @@ mod tests {
read_only,
is_production: false,
production_databases: Vec::new(),
database_info: None,
}
}

View File

@ -172,6 +172,7 @@ mod tests {
read_only: false,
is_production: false,
production_databases: vec![],
database_info: None,
}
}

View File

@ -216,6 +216,7 @@ mod tests {
read_only: true,
is_production: false,
production_databases: Vec::new(),
database_info: None,
};
cfg.read_only = true;
state.configs.write().await.insert(cfg.id.clone(), cfg);
@ -284,6 +285,7 @@ mod tests {
read_only: true,
is_production: false,
production_databases: Vec::new(),
database_info: None,
};
state.configs.write().await.insert(cfg.id.clone(), cfg);
let err = nacos_rollback_config_core(

View File

@ -612,6 +612,7 @@ mod tests {
read_only: false,
is_production: false,
production_databases: vec!["prod_app".to_string()],
database_info: None,
}
}

View File

@ -46,19 +46,30 @@ pub enum PoolErrorAction {
/// A multi-statement result with metadata intended for query clients.
///
/// `execution_error` is emitted only for synthesized MySQL-protocol errors so
/// clients can distinguish them from a successful result column named `Error`.
/// `execution_error` is emitted for synthesized per-statement errors so clients
/// can distinguish them from a successful result column named `Error`.
/// `statement_index` is emitted only after a concrete statement starts running.
#[derive(Debug, Clone, Serialize)]
pub struct ExecuteMultiResult {
#[serde(flatten)]
pub result: db::QueryResult,
#[serde(skip_serializing_if = "is_false")]
pub execution_error: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub statement_index: Option<usize>,
}
impl ExecuteMultiResult {
fn execution_error(result: db::QueryResult) -> Self {
Self { result, execution_error: true }
Self { result, execution_error: true, statement_index: None }
}
fn execution_error_with_index(result: db::QueryResult, statement_index: usize) -> Self {
Self { result, execution_error: true, statement_index: Some(statement_index) }
}
fn success_with_index(result: db::QueryResult, statement_index: usize) -> Self {
Self { result, execution_error: false, statement_index: Some(statement_index) }
}
fn into_query_result(self) -> db::QueryResult {
@ -68,7 +79,7 @@ impl ExecuteMultiResult {
impl From<db::QueryResult> for ExecuteMultiResult {
fn from(result: db::QueryResult) -> Self {
Self { result, execution_error: false }
Self { result, execution_error: false, statement_index: None }
}
}
@ -1918,9 +1929,9 @@ pub async fn execute_multi_core_with_options_for_client(
}
let mut results = Vec::with_capacity(statements.len());
for stmt in &statements {
for (statement_index, stmt) in statements.iter().enumerate() {
if is_canceled(&cancel_token) {
results.push(error_query_result(canceled_error()));
results.push(ExecuteMultiResult::execution_error(error_query_result(canceled_error())));
break;
}
match execute_sql_statement_with_options(
@ -1934,10 +1945,10 @@ pub async fn execute_multi_core_with_options_for_client(
)
.await
{
Ok(r) => results.push(r),
Ok(r) => results.push(ExecuteMultiResult::success_with_index(r, statement_index)),
Err(e) => {
let action = query_pool_error_action(db_type, stmt, &e);
results.push(error_query_result(e));
results.push(ExecuteMultiResult::execution_error_with_index(error_query_result(e), statement_index));
if !should_continue_batch_after_error(options.continue_on_error, action) {
break;
}
@ -1945,7 +1956,7 @@ pub async fn execute_multi_core_with_options_for_client(
}
}
Ok(results.into_iter().map(Into::into).collect())
Ok(results)
}
trait MysqlBatchStatementExecutor {
@ -1989,17 +2000,17 @@ where
E: MysqlBatchStatementExecutor,
{
let mut results = Vec::with_capacity(statements.len());
for statement in statements {
for (statement_index, statement) in statements.iter().enumerate() {
if is_canceled(&cancel_token) {
results.push(ExecuteMultiResult::execution_error(error_query_result(canceled_error())));
return (results, None);
}
match executor.execute_statement(statement).await {
Ok(result) => results.push(result.into()),
Ok(result) => results.push(ExecuteMultiResult::success_with_index(result, statement_index)),
Err(err) => {
let action = pool_error_action(db_type, &err);
results.push(ExecuteMultiResult::execution_error(error_query_result(err)));
results.push(ExecuteMultiResult::execution_error_with_index(error_query_result(err), statement_index));
// Statement errors are safe to collect, but connection-level failures leave
// the protocol state unusable and must still trigger pool cleanup.
if !should_continue_batch_after_error(continue_on_error, action) {
@ -3209,6 +3220,7 @@ mod tests {
read_only: false,
is_production: false,
production_databases: vec![],
database_info: None,
}
}
@ -3336,6 +3348,8 @@ mod tests {
assert_eq!(executor.executed, vec!["first", "fails"]);
assert_eq!(results.len(), 2);
assert_eq!(results[0].statement_index, Some(0));
assert_eq!(results[1].statement_index, Some(1));
assert!(results[1].execution_error);
assert_eq!(error_action, Some(PoolErrorAction::Keep));
}
@ -3374,6 +3388,10 @@ mod tests {
assert_eq!(executor.executed, statements);
assert_eq!(results.len(), 3);
assert_eq!(
results.iter().map(|result| result.statement_index).collect::<Vec<_>>(),
vec![Some(0), Some(1), Some(2)]
);
assert!(results[1].execution_error);
assert_eq!(error_action, None);
}
@ -3412,19 +3430,24 @@ mod tests {
assert_eq!(executor.executed, vec!["first", "disconnects"]);
assert_eq!(results.len(), 2);
assert_eq!(results.iter().map(|result| result.statement_index).collect::<Vec<_>>(), vec![Some(0), Some(1)]);
assert!(results[1].execution_error);
assert_eq!(error_action, Some(PoolErrorAction::ReconnectAndRetry));
}
#[test]
fn execute_multi_result_serializes_error_marker_only_for_synthesized_errors() {
fn execute_multi_result_serializes_client_metadata_only_when_present() {
let success = serde_json::to_value(ExecuteMultiResult::from(empty_query_result(0))).unwrap();
assert!(success.get("execution_error").is_none());
assert!(success.get("statement_index").is_none());
let failure =
serde_json::to_value(ExecuteMultiResult::execution_error(error_query_result("failed".to_string())))
.unwrap();
let failure = serde_json::to_value(ExecuteMultiResult::execution_error_with_index(
error_query_result("failed".to_string()),
2,
))
.unwrap();
assert_eq!(failure.get("execution_error"), Some(&serde_json::Value::Bool(true)));
assert_eq!(failure.get("statement_index"), Some(&serde_json::json!(2)));
assert_eq!(failure.get("columns"), Some(&serde_json::json!(["Error"])));
}
@ -4222,6 +4245,7 @@ mod tests {
read_only: false,
is_production: false,
production_databases: vec![],
database_info: None,
};
let params = external_driver_query_params(

View File

@ -2501,6 +2501,7 @@ mod tests {
read_only: false,
is_production: false,
production_databases: vec![],
database_info: None,
}
}

View File

@ -191,6 +191,7 @@ mod tests {
read_only: false,
is_production: false,
production_databases: vec![],
database_info: None,
}
}

View File

@ -14,7 +14,7 @@ use crate::connection_secrets::{
};
use crate::db::sqlite::{connect_path_create_if_missing, SqliteHandle};
use crate::history::{HistoryEntry, MAX_HISTORY};
use crate::models::connection::{ConnectionConfig, DatabaseType, TransportLayerConfig};
use crate::models::connection::{ConnectionConfig, DatabaseConnectionInfo, DatabaseType, TransportLayerConfig};
use crate::saved_sql::{SavedSqlFile, SavedSqlFolder, SavedSqlLibrary};
const SSH_TUNNEL_SECRET_PREFIX: &str = "ssh_tunnels.";
@ -1390,6 +1390,30 @@ impl Storage {
.await
}
pub async fn save_connection_database_info(
&self,
connection_id: &str,
database_info: Option<DatabaseConnectionInfo>,
) -> Result<(), String> {
let connection_id = connection_id.to_string();
self.with_conn(move |conn| {
let json = conn
.query_row("SELECT config_json FROM connections WHERE id = ?1", [&connection_id], |row| {
row.get::<_, String>(0)
})
.optional()
.map_err(|error| error.to_string())?
.ok_or_else(|| format!("Connection config not found: {connection_id}"))?;
let mut config: ConnectionConfig = serde_json::from_str(&json).map_err(|error| error.to_string())?;
config.database_info = database_info;
let json = serde_json::to_string(&config).map_err(|error| error.to_string())?;
conn.execute("UPDATE connections SET config_json = ?1 WHERE id = ?2", params![json, connection_id])
.map(|_| ())
.map_err(|error| error.to_string())
})
.await
}
pub async fn load_connections(&self) -> Result<Vec<ConnectionConfig>, String> {
let rows: Vec<(String, String)> = self
.with_conn(|conn| {
@ -2507,7 +2531,9 @@ mod tests {
use crate::connection_secrets::{
MQ_AUTH_PASSWORD_KEY, MQ_AUTH_TOKEN_KEY, MQ_TOKEN_SIGNING_KEY, NACOS_AUTH_PASSWORD_KEY,
};
use crate::models::connection::{ConnectionConfig, DatabaseType, SshTunnelConfig, TransportLayerConfig};
use crate::models::connection::{
ConnectionConfig, DatabaseConnectionInfo, DatabaseType, SshTunnelConfig, TransportLayerConfig,
};
use crate::saved_sql::SavedSqlFile;
use std::time::{SystemTime, UNIX_EPOCH};
@ -2637,6 +2663,7 @@ mod tests {
read_only: false,
is_production: false,
production_databases: vec![],
database_info: None,
}
}
@ -2698,6 +2725,7 @@ mod tests {
read_only: false,
is_production: false,
production_databases: vec![],
database_info: None,
}
}
@ -2824,6 +2852,38 @@ mod tests {
assert!(!target_dir.join("dbx.db").exists());
}
#[tokio::test]
async fn save_connections_preserves_database_info() {
let path = temp_db_path("database-info");
let storage = Storage::open(&path).await.unwrap();
let mut config = mq_connection("database-info", "mq-secret");
config.database_info = Some(DatabaseConnectionInfo {
product_name: Some("MySQL".to_string()),
product_version: Some("8.4.0".to_string()),
current_database: Some("app".to_string()),
..DatabaseConnectionInfo::default()
});
storage.save_connections(std::slice::from_ref(&config)).await.unwrap();
let raw_json = raw_connection_json(&storage, "database-info").await;
assert!(raw_json.contains("8.4.0"));
let loaded = storage.load_connections().await.unwrap();
assert_eq!(loaded[0].database_info, config.database_info);
let updated_info = DatabaseConnectionInfo {
product_name: Some("MySQL".to_string()),
product_version: Some("8.4.1".to_string()),
..DatabaseConnectionInfo::default()
};
storage.save_connection_database_info("database-info", Some(updated_info.clone())).await.unwrap();
let loaded = storage.load_connections().await.unwrap();
assert_eq!(loaded[0].database_info, Some(updated_info));
assert_eq!(mq_token(&loaded[0]), Some("mq-secret"));
let _ = std::fs::remove_file(path);
}
#[tokio::test]
async fn save_connections_moves_mq_auth_token_to_secret_table_and_restores_it() {
let path = temp_db_path("mq-token-secrets");

View File

@ -4789,6 +4789,7 @@ mod tests {
read_only: false,
is_production: false,
production_databases: vec![],
database_info: None,
}
}

View File

@ -64,6 +64,7 @@ fn live_postgres_config(
read_only: false,
is_production: false,
production_databases: vec![],
database_info: None,
}
}

View File

@ -58,6 +58,7 @@ fn postgres_test_config(id: &str, database: &str) -> ConnectionConfig {
read_only: false,
is_production: false,
production_databases: vec![],
database_info: None,
}
}

View File

@ -61,6 +61,7 @@ fn live_sqlserver_config(id: &str, database: &str) -> dbx_core::models::connecti
read_only: false,
is_production: false,
production_databases: vec![],
database_info: None,
}
}

View File

@ -218,7 +218,10 @@ async fn main() {
.route("/auth/logout", post(auth::logout))
// Connection
.route("/connection/test", post(routes::connection::test_connection))
.route("/connection/test-info", post(routes::connection::test_connection_with_info))
.route("/connection/connect", post(routes::connection::connect_db))
.route("/connection/database-info", post(routes::connection::connected_database_info))
.route("/connection/database-info/save", post(routes::connection::save_connection_database_info))
.route("/connection/final-proxy-port", post(routes::connection::connection_final_proxy_port))
.route("/connection/disconnect", post(routes::connection::disconnect_db))
.route("/connection/check-health", post(routes::connection::check_connection_health))

View File

@ -3,7 +3,8 @@ use std::sync::Arc;
use axum::extract::State;
use axum::Json;
use dbx_core::models::connection::ConnectionConfig;
use dbx_core::connection::AppState;
use dbx_core::models::connection::{ConnectionConfig, ConnectionTestResult, DatabaseConnectionInfo};
use serde::Deserialize;
use crate::error::AppError;
@ -37,36 +38,75 @@ pub struct ConnectionIdentifierQuoteRequest {
pub database: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SaveConnectionDatabaseInfoRequest {
pub connection_id: String,
pub database_info: Option<DatabaseConnectionInfo>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SaveConnectionsRequest {
pub configs: Vec<ConnectionConfig>,
}
fn is_connection_info_capability_unsupported(error: &str) -> bool {
let error = error.to_ascii_lowercase();
error.contains("connectioninfo")
&& (error.contains("unsupported") || error.contains("unknown method") || error.contains("method not found"))
}
async fn run_temporary_connection_test(
app: &Arc<AppState>,
config: ConnectionConfig,
include_database_info: bool,
) -> Result<ConnectionTestResult, String> {
let temp_id = format!("__test_{}", uuid::Uuid::new_v4());
app.configs.write().await.insert(temp_id.clone(), config.clone());
let pool_result = app.get_or_create_pool(&temp_id, config.database.as_deref()).await;
let database_info = if include_database_info {
match &pool_result {
Ok(_) => match app.connection_database_info(&temp_id, config.database.as_deref()).await {
Ok(info) => info,
Err(error) if is_connection_info_capability_unsupported(&error) => {
log::debug!("Connection information capability is unavailable: {error}");
None
}
Err(error) => {
log::warn!("Failed to read optional connection information: {error}");
None
}
},
Err(_) => None,
}
} else {
None
};
app.remove_connection_pools(&temp_id).await;
app.reset_connection_transport_for_config(&temp_id, &config).await;
app.configs.write().await.remove(&temp_id);
pool_result.map(|_| ConnectionTestResult::success("Connection successful").with_database_info(database_info))
}
pub async fn test_connection(
State(state): State<Arc<WebState>>,
Json(body): Json<ConnectRequest>,
) -> Result<Json<String>, AppError> {
let config = body.config;
let app = &state.app;
run_temporary_connection_test(&state.app, body.config, false)
.await
.map(|result| Json(result.message))
.map_err(AppError)
}
// Store config temporarily
let temp_id = format!("__test_{}", uuid::Uuid::new_v4());
app.configs.write().await.insert(temp_id.clone(), config.clone());
// Try to connect
let result = app.get_or_create_pool(&temp_id, config.database.as_deref()).await;
// Clean up any pool keys created for the temporary connection, including
// database-scoped keys like "__test_uuid:database".
app.remove_connection_pools(&temp_id).await;
app.reset_connection_transport_for_config(&temp_id, &config).await;
app.configs.write().await.remove(&temp_id);
match result {
Ok(_) => Ok(Json("Connection successful".to_string())),
Err(e) => Err(AppError(e)),
}
pub async fn test_connection_with_info(
State(state): State<Arc<WebState>>,
Json(body): Json<ConnectRequest>,
) -> Result<Json<ConnectionTestResult>, AppError> {
run_temporary_connection_test(&state.app, body.config, true).await.map(Json).map_err(AppError)
}
pub async fn connect_db(
@ -87,6 +127,25 @@ pub async fn connect_db(
Ok(Json(connection_id))
}
pub async fn connected_database_info(
State(state): State<Arc<WebState>>,
Json(body): Json<ConnectionIdentifierQuoteRequest>,
) -> Result<Json<Option<DatabaseConnectionInfo>>, AppError> {
state.app.connection_database_info(&body.connection_id, body.database.as_deref()).await.map(Json).map_err(AppError)
}
pub async fn save_connection_database_info(
State(state): State<Arc<WebState>>,
Json(body): Json<SaveConnectionDatabaseInfoRequest>,
) -> Result<Json<()>, AppError> {
state
.app
.save_connection_database_info(&body.connection_id, body.database_info)
.await
.map(|_| Json(()))
.map_err(AppError)
}
pub async fn connection_final_proxy_port(
State(state): State<Arc<WebState>>,
Json(body): Json<ConnectRequest>,
@ -262,19 +321,24 @@ async fn remove_connection_pools_for_connection_ids(state: &WebState, connection
#[cfg(test)]
mod tests {
#[cfg(feature = "mq-admin")]
use super::connect_db;
use super::{
connect_db, disconnect_db, load_connections, save_connections, ConnectRequest, DisconnectRequest,
disconnect_db, load_connections, save_connection_database_info, save_connections, test_connection,
test_connection_with_info, ConnectRequest, DisconnectRequest, SaveConnectionDatabaseInfoRequest,
SaveConnectionsRequest,
};
use crate::state::{LoginRateLimit, WebState};
use axum::extract::State;
use axum::Json;
use dbx_core::connection::{AppState, PoolKind};
use dbx_core::models::connection::{ConnectionConfig, DatabaseType};
use dbx_core::models::connection::{ConnectionConfig, DatabaseConnectionInfo, DatabaseType};
use dbx_core::storage::Storage;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
#[cfg(feature = "mq-admin")]
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[cfg(feature = "mq-admin")]
use tokio::net::TcpListener;
use tokio::sync::{Mutex, RwLock};
@ -328,6 +392,7 @@ mod tests {
read_only: false,
is_production: false,
production_databases: vec![],
database_info: None,
}
}
@ -364,6 +429,34 @@ mod tests {
(state, dir)
}
#[tokio::test]
async fn connection_test_info_preserves_legacy_string_and_cleans_up_temporary_state() {
let (state, dir) = test_web_state().await;
let db_path = dir.join("test-info.db");
std::fs::File::create(&db_path).unwrap();
let config = sqlite_config("sqlite-test", &db_path.to_string_lossy());
let legacy = test_connection(
State(state.clone()),
Json(ConnectRequest { config: config.clone(), client_attempt: None }),
)
.await
.unwrap_or_else(|error| panic!("{}", error.0));
assert_eq!(legacy.0, "Connection successful");
let detailed =
test_connection_with_info(State(state.clone()), Json(ConnectRequest { config, client_attempt: None }))
.await
.unwrap_or_else(|error| panic!("{}", error.0));
assert_eq!(detailed.0.message, "Connection successful");
assert_eq!(detailed.0.database_info, None);
assert!(state.app.configs.read().await.keys().all(|key| !key.starts_with("__test_")));
assert!(state.app.connections.read().await.keys().all(|key| !key.starts_with("__test_")));
let _ = std::fs::remove_dir_all(dir);
}
#[cfg(feature = "mq-admin")]
async fn spawn_pulsar_clusters_server() -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
@ -409,6 +502,36 @@ mod tests {
let _ = std::fs::remove_dir_all(dir);
}
#[tokio::test]
async fn save_connection_database_info_preserves_connected_pool() {
let (state, dir) = test_web_state().await;
let config = mq_config("mq-info", "http://127.0.0.1:8080");
state.app.storage.save_connections(std::slice::from_ref(&config)).await.unwrap();
state.app.configs.write().await.insert(config.id.clone(), config.clone());
state.app.connections.write().await.insert(config.id.clone(), PoolKind::MessageQueue);
let database_info = DatabaseConnectionInfo {
product_name: Some("Apache Pulsar".to_string()),
product_version: Some("3.3.0".to_string()),
..DatabaseConnectionInfo::default()
};
let result = save_connection_database_info(
State(state.clone()),
Json(SaveConnectionDatabaseInfoRequest {
connection_id: config.id.clone(),
database_info: Some(database_info.clone()),
}),
)
.await;
assert!(result.is_ok());
assert!(state.app.connections.read().await.contains_key(&config.id));
assert_eq!(state.app.configs.read().await[&config.id].database_info, Some(database_info.clone()));
assert_eq!(state.app.storage.load_connections().await.unwrap()[0].database_info, Some(database_info));
let _ = std::fs::remove_dir_all(dir);
}
#[tokio::test]
async fn save_connections_drops_cached_mq_adapter_for_updated_config() {
let (state, dir) = test_web_state().await;

View File

@ -235,12 +235,14 @@ public final class DbxJdbcPlugin {
private static JsonNode handle(String method, JsonNode params, JsonNode connection) throws Exception {
return switch (method) {
case "testConnection", "connect" -> {
case "testConnection" -> connectionTestResult(openConnection(connection));
case "connect" -> {
openConnection(connection);
ObjectNode result = MAPPER.createObjectNode();
result.put("ok", true);
yield result;
}
case "connectionInfo" -> databaseInfoResult(openConnection(connection));
case "executeQuery" -> executeQuery(
connection,
requireText(params, "sql"),
@ -311,6 +313,98 @@ public final class DbxJdbcPlugin {
};
}
private static ObjectNode connectionTestResult(Connection connection) {
ObjectNode result = MAPPER.createObjectNode();
result.put("ok", true);
ObjectNode databaseInfo = databaseInfo(connection);
if (!databaseInfo.isEmpty()) {
result.set("databaseInfo", databaseInfo);
}
return result;
}
private static ObjectNode databaseInfoResult(Connection connection) {
ObjectNode result = MAPPER.createObjectNode();
ObjectNode databaseInfo = databaseInfo(connection);
if (!databaseInfo.isEmpty()) {
result.set("databaseInfo", databaseInfo);
}
return result;
}
private static ObjectNode databaseInfo(Connection connection) {
try {
DatabaseMetaData metadata = connection.getMetaData();
return metadata == null ? MAPPER.createObjectNode() : databaseInfo(metadata);
} catch (SQLException | AbstractMethodError | UnsupportedOperationException ignored) {
return MAPPER.createObjectNode();
}
}
private static ObjectNode databaseInfo(DatabaseMetaData metadata) {
ObjectNode info = MAPPER.createObjectNode();
putMetadataText(info, "productName", metadata::getDatabaseProductName);
putMetadataText(info, "productVersion", metadata::getDatabaseProductVersion);
putIdentifierCase(
info,
"unquotedIdentifierCase",
metadata::storesLowerCaseIdentifiers,
metadata::storesUpperCaseIdentifiers,
metadata::storesMixedCaseIdentifiers
);
putIdentifierCase(
info,
"quotedIdentifierCase",
metadata::storesLowerCaseQuotedIdentifiers,
metadata::storesUpperCaseQuotedIdentifiers,
metadata::storesMixedCaseQuotedIdentifiers
);
putMetadataText(info, "driverName", metadata::getDriverName);
putMetadataText(info, "driverVersion", metadata::getDriverVersion);
Integer jdbcMajor = readMetadata(metadata::getJDBCMajorVersion);
Integer jdbcMinor = readMetadata(metadata::getJDBCMinorVersion);
if (jdbcMajor != null && jdbcMinor != null && jdbcMajor >= 0 && jdbcMinor >= 0) {
info.put("jdbcVersion", jdbcMajor + "." + jdbcMinor);
}
return info;
}
private static void putMetadataText(ObjectNode target, String key, SqlSupplier<String> supplier) {
String value = readMetadata(supplier);
if (value != null && !value.trim().isEmpty()) {
target.put(key, value.trim());
}
}
private static void putIdentifierCase(
ObjectNode target,
String key,
SqlSupplier<Boolean> lower,
SqlSupplier<Boolean> upper,
SqlSupplier<Boolean> mixed
) {
if (Boolean.TRUE.equals(readMetadata(lower))) {
target.put(key, "lower");
} else if (Boolean.TRUE.equals(readMetadata(upper))) {
target.put(key, "upper");
} else if (Boolean.TRUE.equals(readMetadata(mixed))) {
target.put(key, "mixed");
}
}
private static <T> T readMetadata(SqlSupplier<T> supplier) {
try {
return supplier.get();
} catch (SQLException | AbstractMethodError | UnsupportedOperationException ignored) {
return null;
}
}
private interface SqlSupplier<T> {
T get() throws SQLException;
}
private static void registerDrivers(JsonNode connection) throws Exception {
String driverKey = driverKey(connection);
if (driverKey.equals(registeredDriverKey)) {

View File

@ -19,6 +19,7 @@ import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.SQLException;
import java.sql.SQLFeatureNotSupportedException;
import java.sql.Statement;
import java.sql.Timestamp;
import java.sql.Types;
@ -81,6 +82,64 @@ final class DbxJdbcPluginTest {
assertEquals("linkage boom", response.path("error").path("message").asText());
}
@Test
void testConnectionAndConnectionInfoExposeH2Metadata() throws Exception {
JsonNode tested = request("testConnection", """
{ "connection": %s }
""".formatted(CONNECTION));
assertDatabaseInfo(tested.path("result").path("databaseInfo"));
request("connect", """
{ "connection": %s }
""".formatted(CONNECTION));
JsonNode connected = request("connectionInfo", """
{ "connection": %s }
""".formatted(CONNECTION));
assertDatabaseInfo(connected.path("result").path("databaseInfo"));
}
@Test
void databaseInfoKeepsSupportedFieldsWhenOneMetadataGetterFails() throws Exception {
DatabaseMetaData metadata = (DatabaseMetaData) Proxy.newProxyInstance(
DatabaseMetaData.class.getClassLoader(),
new Class<?>[]{DatabaseMetaData.class},
(proxy, method, args) -> switch (method.getName()) {
case "getDatabaseProductName" -> "ExampleDB";
case "getDatabaseProductVersion" -> throw new SQLFeatureNotSupportedException("version unavailable");
case "storesLowerCaseIdentifiers" -> throw new UnsupportedOperationException("case unavailable");
case "storesUpperCaseIdentifiers" -> true;
case "storesMixedCaseQuotedIdentifiers" -> true;
case "getDriverName" -> "Example JDBC";
case "getDriverVersion" -> "1.2.3";
case "getJDBCMajorVersion" -> 4;
case "getJDBCMinorVersion" -> 2;
default -> defaultValue(method.getReturnType());
}
);
Method method = DbxJdbcPlugin.class.getDeclaredMethod("databaseInfo", DatabaseMetaData.class);
method.setAccessible(true);
JsonNode info = MAPPER.valueToTree(method.invoke(null, metadata));
assertEquals("ExampleDB", info.path("productName").asText());
assertFalse(info.has("productVersion"));
assertEquals("upper", info.path("unquotedIdentifierCase").asText());
assertEquals("mixed", info.path("quotedIdentifierCase").asText());
assertEquals("Example JDBC", info.path("driverName").asText());
assertEquals("1.2.3", info.path("driverVersion").asText());
assertEquals("4.2", info.path("jdbcVersion").asText());
}
private static void assertDatabaseInfo(JsonNode info) {
assertEquals("H2", info.path("productName").asText());
assertFalse(info.path("productVersion").asText().isEmpty());
assertEquals("upper", info.path("unquotedIdentifierCase").asText());
assertFalse(info.has("quotedIdentifierCase"));
assertFalse(info.path("driverName").asText().isEmpty());
assertFalse(info.path("driverVersion").asText().isEmpty());
assertFalse(info.path("jdbcVersion").asText().isEmpty());
}
@Test
void executeQueryTrimsSingleTrailingSemicolon() throws Exception {
JsonNode response = request("executeQuery", """

View File

@ -14,7 +14,10 @@ pub use dbx_core::connection::{
use dbx_core::database_capabilities;
use dbx_core::db;
use dbx_core::db::agent_driver::AgentMethod;
use dbx_core::models::connection::{rewrite_jdbc_url_host, ConnectionConfig, DatabaseType};
use dbx_core::models::connection::{
database_info_from_protocol_value, rewrite_jdbc_url_host, ConnectionConfig, ConnectionTestResult,
DatabaseConnectionInfo, DatabaseType,
};
pub use dbx_core::path_utils::expand_tilde;
const MONGO_LEGACY_DRIVER_PROFILE: &str = "mongodb-legacy";
@ -58,7 +61,7 @@ async fn test_agent_connection(
config: &ConnectionConfig,
host: &str,
port: u16,
) -> Result<String, String> {
) -> Result<ConnectionTestResult, String> {
let connect_params = agent_connect_params(config, host, port, config.database.as_deref().unwrap_or(""));
let result = state
.agent_manager
@ -71,32 +74,49 @@ async fn test_agent_connection(
)
.await;
if let Err(err) = result {
if let Some(alternate_config) = oracle_alternate_connect_config(config, &err) {
state
.agent_manager
.call_daemon_method_with_timeout::<serde_json::Value>(
&alternate_config.db_type,
alternate_config.driver_profile.as_deref(),
AgentMethod::TestConnection,
agent_connect_params(
&alternate_config,
host,
port,
alternate_config.database.as_deref().unwrap_or(""),
),
Some(agent_connect_timeout(&alternate_config)),
)
.await
.map_err(|alternate_err| {
format!("{err}\n\nFallback with alternate Oracle descriptor failed: {alternate_err}")
})?;
} else {
return Err(oracle_error_with_driver_hint(config, &err));
let response = match result {
Ok(response) => response,
Err(err) => {
if let Some(alternate_config) = oracle_alternate_connect_config(config, &err) {
state
.agent_manager
.call_daemon_method_with_timeout::<serde_json::Value>(
&alternate_config.db_type,
alternate_config.driver_profile.as_deref(),
AgentMethod::TestConnection,
agent_connect_params(
&alternate_config,
host,
port,
alternate_config.database.as_deref().unwrap_or(""),
),
Some(agent_connect_timeout(&alternate_config)),
)
.await
.map_err(|alternate_err| {
format!("{err}\n\nFallback with alternate Oracle descriptor failed: {alternate_err}")
})?
} else {
return Err(oracle_error_with_driver_hint(config, &err));
}
}
};
Ok(ConnectionTestResult::success("Connection successful")
.with_database_info(database_info_from_protocol_value(&response)))
}
async fn optional_mysql_database_info(
pool: &db::mysql::MySqlPool,
config: &ConnectionConfig,
) -> Option<DatabaseConnectionInfo> {
match db::mysql::database_connection_info(pool, db::mysql::protocol_product_name(config)).await {
Ok(info) => Some(info),
Err(error) => {
log::warn!("Failed to read optional MySQL database information: {error}");
None
}
}
Ok("Connection successful".to_string())
}
async fn connect_agent_pool(
@ -207,6 +227,7 @@ mod tests {
read_only: false,
is_production: false,
production_databases: vec![],
database_info: None,
}
}
@ -577,6 +598,21 @@ async fn connect_sqlite_from_config(config: &ConnectionConfig) -> Result<db::sql
#[tauri::command]
pub async fn test_connection(state: State<'_, Arc<AppState>>, config: ConnectionConfig) -> Result<String, String> {
test_connection_with_info_inner(state.inner(), config).await.map(|result| result.message)
}
#[tauri::command]
pub async fn test_connection_with_info(
state: State<'_, Arc<AppState>>,
config: ConnectionConfig,
) -> Result<ConnectionTestResult, String> {
test_connection_with_info_inner(state.inner(), config).await
}
async fn test_connection_with_info_inner(
state: &Arc<AppState>,
config: ConnectionConfig,
) -> Result<ConnectionTestResult, String> {
let tunnel_id = format!("{}:test", config.id);
let has_transport_layers = config.has_effective_transport_layers();
let connection_id = if has_transport_layers { tunnel_id.as_str() } else { config.id.as_str() };
@ -587,12 +623,14 @@ pub async fn test_connection(state: State<'_, Arc<AppState>>, config: Connection
let connect_timeout = std::time::Duration::from_secs(config.effective_connect_timeout_secs());
let idle_timeout = std::time::Duration::from_secs(config.idle_timeout_secs);
log::info!("[test_connection] db_type={:?} target={}", config.db_type, target);
let mut database_info = None;
let result = match probe_result {
Err(e) => Err(e),
Ok(()) => match config.db_type {
DatabaseType::Mysql if config.needs_bare_mysql() && !config.bare_mysql_uses_tls() => {
match db::mysql::connect_bare(&url, connect_timeout).await {
Ok(pool) => {
database_info = optional_mysql_database_info(&pool, &config).await;
let _ = pool.disconnect().await;
Ok("Connection successful".to_string())
}
@ -611,6 +649,7 @@ pub async fn test_connection(state: State<'_, Arc<AppState>>, config: Connection
.await
{
Ok(pool) => {
database_info = optional_mysql_database_info(&pool, &config).await;
let _ = pool.disconnect().await;
Ok("Connection successful".to_string())
}
@ -620,6 +659,7 @@ pub async fn test_connection(state: State<'_, Arc<AppState>>, config: Connection
DatabaseType::Mysql => {
match db::mysql::connect_with_ca_cert(&url, Some(&config.ca_cert_path), connect_timeout).await {
Ok(pool) => {
database_info = optional_mysql_database_info(&pool, &config).await;
let _ = pool.disconnect().await;
Ok("Connection successful".to_string())
}
@ -629,6 +669,7 @@ pub async fn test_connection(state: State<'_, Arc<AppState>>, config: Connection
DatabaseType::Doris | DatabaseType::ManticoreSearch => {
match db::mysql::connect_bare(&url, connect_timeout).await {
Ok(pool) => {
database_info = optional_mysql_database_info(&pool, &config).await;
let _ = pool.disconnect().await;
Ok("Connection successful".to_string())
}
@ -651,6 +692,7 @@ pub async fn test_connection(state: State<'_, Arc<AppState>>, config: Connection
};
match connect {
Ok(pool) => {
database_info = optional_mysql_database_info(&pool, &config).await;
let _ = pool.disconnect().await;
Ok("Connection successful".to_string())
}
@ -676,10 +718,10 @@ pub async fn test_connection(state: State<'_, Arc<AppState>>, config: Connection
DatabaseType::Redis => {
let con = if config.uses_redis_cluster() {
state.connect_redis_cluster(&tunnel_id, &config).await?;
return Ok("Connection successful".to_string());
return Ok(ConnectionTestResult::success("Connection successful"));
} else if config.uses_redis_sentinel() {
state.connect_redis_sentinel(&tunnel_id, &config).await?;
return Ok("Connection successful".to_string());
return Ok(ConnectionTestResult::success("Connection successful"));
} else {
db::redis_driver::connect(&url, connect_timeout).await?
};
@ -702,7 +744,7 @@ pub async fn test_connection(state: State<'_, Arc<AppState>>, config: Connection
.await
.map_err(|err| mongo_legacy_error_with_auth_hint(&err))?;
client.disconnect().await.ok();
return Ok("Connection successful (via legacy driver)".to_string());
return Ok(ConnectionTestResult::success("Connection successful (via legacy driver)"));
}
let native_err = match db::mongo_driver::connect(&url, connect_timeout, idle_timeout).await {
@ -710,7 +752,7 @@ pub async fn test_connection(state: State<'_, Arc<AppState>>, config: Connection
match db::mongo_driver::test_connection(&client, connect_timeout, config.effective_database())
.await
{
Ok(()) => return Ok("Connection successful".to_string()),
Ok(()) => return Ok(ConnectionTestResult::success("Connection successful")),
Err(e) => e,
}
}
@ -746,7 +788,16 @@ pub async fn test_connection(state: State<'_, Arc<AppState>>, config: Connection
.map(|_| "Connection successful".to_string())
}
DatabaseType::SqlServer => {
state.test_sqlserver_connection_with_legacy_fallback(&config, &host, port, connect_timeout).await
match state
.test_sqlserver_connection_with_legacy_fallback_with_info(&config, &host, port, connect_timeout)
.await
{
Ok(details) => {
database_info = details.database_info;
Ok(details.message)
}
Err(err) => Err(err),
}
}
DatabaseType::Elasticsearch => {
let mut client = db::elasticsearch_driver::EsClient::from_config(
@ -857,11 +908,23 @@ pub async fn test_connection(state: State<'_, Arc<AppState>>, config: Connection
.to_string())
}
db_type if database_capabilities::is_agent_type(&db_type) => {
test_agent_connection(state.inner(), &config, &host, port).await
match test_agent_connection(state, &config, &host, port).await {
Ok(details) => {
database_info = details.database_info;
Ok(details.message)
}
Err(err) => Err(err),
}
}
DatabaseType::PrestoSql => {
let jdbc_config = prestosql_jdbc_config_for_endpoint(&config, &host, port);
state.test_external_driver("jdbc", &jdbc_config).await
match state.test_external_driver_with_info("jdbc", &jdbc_config).await {
Ok(details) => {
database_info = details.database_info;
Ok(details.message)
}
Err(err) => Err(err),
}
}
DatabaseType::Jdbc => {
let mut jdbc_config = config.clone();
@ -870,7 +933,13 @@ pub async fn test_connection(state: State<'_, Arc<AppState>>, config: Connection
jdbc_config.connection_string = Some(rewrite_jdbc_url_host(url, &host, port));
}
}
state.test_external_driver("jdbc", &jdbc_config).await
match state.test_external_driver_with_info("jdbc", &jdbc_config).await {
Ok(details) => {
database_info = details.database_info;
Ok(details.message)
}
Err(err) => Err(err),
}
}
db_type => Err(format!("Unsupported database type: {db_type:?}")),
},
@ -880,7 +949,7 @@ pub async fn test_connection(state: State<'_, Arc<AppState>>, config: Connection
state.reset_connection_transport_for_config(&tunnel_id, &config).await;
}
result
result.map(|message| ConnectionTestResult::success(message).with_database_info(database_info))
}
#[tauri::command]
@ -1254,6 +1323,24 @@ pub async fn connection_identifier_quote(
state.connection_identifier_quote(&connection_id, database.as_deref()).await
}
#[tauri::command]
pub async fn connection_database_info(
state: State<'_, Arc<AppState>>,
connection_id: String,
database: Option<String>,
) -> Result<Option<DatabaseConnectionInfo>, String> {
state.connection_database_info(&connection_id, database.as_deref()).await
}
#[tauri::command]
pub async fn save_connection_database_info(
state: State<'_, Arc<AppState>>,
connection_id: String,
database_info: Option<DatabaseConnectionInfo>,
) -> Result<(), String> {
state.save_connection_database_info(&connection_id, database_info).await
}
/// Check whether a connection has read-only protection enabled.
/// Returns an error if the connection is read-only, preventing write operations.
pub async fn ensure_connection_writable(

View File

@ -1066,6 +1066,7 @@ pub fn run() {
commands::cloud_sync::snippet_sync_upload,
commands::cloud_sync::snippet_sync_download,
commands::connection::test_connection,
commands::connection::test_connection_with_info,
commands::connection::connect_db,
commands::connection::connection_final_proxy_port,
commands::connection::disconnect_db,
@ -1073,6 +1074,8 @@ pub fn run() {
commands::connection::refresh_connections,
commands::connection::check_connection_health,
commands::connection::connection_identifier_quote,
commands::connection::connection_database_info,
commands::connection::save_connection_database_info,
commands::connection::save_connections,
commands::connection::load_connections,
commands::connection::save_sidebar_layout,