feat(desktop): add database info and SQL statement markers
This commit is contained in:
parent
83f7604a11
commit
3748d83cde
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 "";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<>();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type { ConnectionConfig, DatabaseType, HttpTunnelConfig, JdbcDriverInfo, JdbcLocalBundleInfo, JdbcMavenBundleInfo, ProxyTunnelConfig, SshConfigHostEntry, SshTunnelConfig, TransportLayerConfig } from "@/types/database";
|
||||
import type { ConnectionConfig, ConnectionTestResult, DatabaseConnectionInfo, DatabaseType, HttpTunnelConfig, IdentifierCase, JdbcDriverInfo, JdbcLocalBundleInfo, JdbcMavenBundleInfo, ProxyTunnelConfig, SshConfigHostEntry, SshTunnelConfig, TransportLayerConfig } from "@/types/database";
|
||||
import type { InfluxDbExternalConfig, InfluxDbVersion } from "@/types/influxdb";
|
||||
import type { MqAdminConfig, MqAuth, MqSystemKind } from "@/types/mq";
|
||||
import type { NacosAdminConfig, NacosAuthConfig } from "@/types/nacos";
|
||||
|
|
@ -37,6 +37,7 @@ import { MQ_PINNED_VERSION_OPTIONS, pinnedVersionToSelection, selectionToPinnedV
|
|||
import { mongodbAuthFailureHint, mongoUrlParam, mongoUrlParamIsTrue, normalizeMongoTlsFormState, setMongoUrlParam, setMongoUrlParamBoolean } from "@/lib/mongo/mongoConnectionOptions";
|
||||
import { mysqlCleartextPasswordAuthEnabled, setMysqlCleartextPasswordAuthEnabled } from "@/lib/database/mysqlConnectionOptions";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
import { configuredDatabaseProductName, connectionConfigFingerprint, databaseInfoCopyText, databaseInfoRows, normalizeDatabaseConnectionInfo, type DatabaseInfoField } from "@/lib/connection/connectionDatabaseInfo";
|
||||
import { agentDriverInstallKey, appendAgentDriverUpdateHint, hasAgentDriverUpdate, showAgentDriverInstallHint, type AgentDriverInstallState, type DriverStoreFocus } from "@/lib/connection/agentDriverInstallHint";
|
||||
import { prestoSqlBuiltinDriverPaths } from "@/lib/database/prestoSqlBuiltinDriver";
|
||||
import { SQLITE_DATABASE_FILE_EXTENSIONS } from "@/lib/database/databaseFileDetection";
|
||||
|
|
@ -46,7 +47,35 @@ import { normalizeKafkaBootstrapServers } from "@/lib/connection/kafkaBootstrapS
|
|||
import { detectMqUiAuthKind, isMqAuthKindAllowedForSystem, type MqUiAuthKind } from "@/lib/connection/mqAuth";
|
||||
import { driverInstallProgressPercent, type DriverInstallProgress } from "@/lib/connection/driverInstallProgressUi";
|
||||
import { isSqlServerLegacyCompatibilityMode, requiresSqlServerLegacyCompatibilityComponent, setSqlServerLegacyCompatibilityMode, SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY } from "@/lib/connection/sqlServerLegacyCompatibility";
|
||||
import { ArrowLeft, ArrowDown, ArrowUp, CheckSquare, ChevronRight, CircleHelp, Copy, ExternalLink, FilePlus2, FolderOpen, GripVertical, Grid3X3, KeyRound, Link2, List, ListFilter, Loader2, Pencil, Pipette, Plus, RefreshCw, Search, ShieldAlert, ShieldCheck, Square, Trash2 } from "@lucide/vue";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
CheckSquare,
|
||||
ChevronRight,
|
||||
CircleHelp,
|
||||
Copy,
|
||||
Database as DatabaseLucide,
|
||||
ExternalLink,
|
||||
FilePlus2,
|
||||
FolderOpen,
|
||||
GripVertical,
|
||||
Grid3X3,
|
||||
KeyRound,
|
||||
Link2,
|
||||
List,
|
||||
ListFilter,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Pipette,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
Square,
|
||||
Trash2,
|
||||
} from "@lucide/vue";
|
||||
import { buildDraftVisibleDatabasesConnectionId, connectionCanChooseVisibleDatabases, initialVisibleDatabaseSelection, visibleDatabaseSelectionIsStale } from "@/lib/connection/connectionVisibleDatabases";
|
||||
import { canSaveVisibleDatabaseSelection, connectionUsesVisibleSchemaFilter, filterDatabaseNamesForVisiblePicker, isSystemDatabaseName, normalizeVisibleDatabaseSelection, buildDraftVisibleSchemasConnectionId, normalizeVisibleSchemaSelection } from "@/lib/database/visibleDatabases";
|
||||
import { isSchemaAware, isSingleDatabase } from "@/lib/database/databaseFeatureSupport";
|
||||
|
|
@ -101,6 +130,8 @@ type LegacyTransportFields = {
|
|||
};
|
||||
type LegacyConnectionConfig = ConnectionConfig & LegacyTransportFields;
|
||||
type ConnectionForm = Omit<ConnectionConfig, "id">;
|
||||
type ConnectionTestState = ConnectionTestResult & { ok: boolean };
|
||||
type SuccessfulConnectionTest = { result: ConnectionTestResult; config: ConnectionConfig };
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
|
|
@ -126,7 +157,13 @@ const store = useConnectionStore();
|
|||
const tunnelProfileStore = useTunnelProfileStore();
|
||||
const isTesting = ref(false);
|
||||
const isSaving = ref(false);
|
||||
const testResult = ref<{ ok: boolean; message: string } | null>(null);
|
||||
const testResult = ref<ConnectionTestState | null>(null);
|
||||
const testedConfigFingerprint = ref("");
|
||||
const testedConfigId = ref("");
|
||||
const testedGeneratedName = ref("");
|
||||
const savedDatabaseInfo = ref<DatabaseConnectionInfo | null>(null);
|
||||
const savedDatabaseInfoFingerprint = ref("");
|
||||
const savedConnectionConfigFingerprint = ref("");
|
||||
const showAgentInstallDialog = ref(false);
|
||||
const agentInstallRunning = ref(false);
|
||||
const agentInstallDriverKey = ref("");
|
||||
|
|
@ -1038,7 +1075,7 @@ function isNacosAdminEndpointNotFound(message: string): boolean {
|
|||
return /Nacos admin endpoint was not found/i.test(message);
|
||||
}
|
||||
|
||||
async function tryNacosDockerConsoleFallback(config: ConnectionConfig, originalError: string, runId: number): Promise<string | null> {
|
||||
async function tryNacosDockerConsoleFallback(config: ConnectionConfig, originalError: string, runId: number): Promise<SuccessfulConnectionTest | null> {
|
||||
if (config.db_type !== "nacos" || !isNacosAdminEndpointNotFound(originalError)) return null;
|
||||
const fallbackUrl = dockerNacosConsoleFallbackUrl(nacosServerAddr.value);
|
||||
if (!fallbackUrl || fallbackUrl === nacosServerAddr.value.trim()) return null;
|
||||
|
|
@ -1046,9 +1083,15 @@ async function tryNacosDockerConsoleFallback(config: ConnectionConfig, originalE
|
|||
const previousUrl = nacosServerAddr.value;
|
||||
nacosServerAddr.value = fallbackUrl;
|
||||
try {
|
||||
const fallbackConfig = connectionConfigForSubmit(config.id);
|
||||
const message = await testConnectionWithTimeout(fallbackConfig, runId);
|
||||
return `${message} ${t("connection.nacosConsoleUrlAutoAdjusted", { from: previousUrl.trim(), to: fallbackUrl })}`;
|
||||
const fallbackConfig = connectionConfigForSubmit(config.id, config.name);
|
||||
const result = await testConnectionWithTimeout(fallbackConfig, runId);
|
||||
return {
|
||||
config: fallbackConfig,
|
||||
result: {
|
||||
...result,
|
||||
message: `${result.message} ${t("connection.nacosConsoleUrlAutoAdjusted", { from: previousUrl.trim(), to: fallbackUrl })}`,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
nacosServerAddr.value = previousUrl;
|
||||
return null;
|
||||
|
|
@ -1199,15 +1242,63 @@ function isSqlServerTlsHandshakeFailure(message: string): boolean {
|
|||
return text.includes("sql server") && text.includes("tls") && (text.includes("handshake") || text.includes("eof") || text.includes("performing i/o"));
|
||||
}
|
||||
|
||||
async function testConnectionWithTimeout(config: ConnectionConfig, runId: number): Promise<string> {
|
||||
function clearTestedConnectionInfo() {
|
||||
testedConfigFingerprint.value = "";
|
||||
testedConfigId.value = "";
|
||||
testedGeneratedName.value = "";
|
||||
}
|
||||
|
||||
function clearSavedDatabaseInfo() {
|
||||
savedDatabaseInfo.value = null;
|
||||
savedDatabaseInfoFingerprint.value = "";
|
||||
savedConnectionConfigFingerprint.value = "";
|
||||
}
|
||||
|
||||
function applySavedDatabaseInfo(config: ConnectionConfig) {
|
||||
clearSavedDatabaseInfo();
|
||||
try {
|
||||
const current = connectionConfigForSubmit(config.id, config.name);
|
||||
savedConnectionConfigFingerprint.value = connectionConfigFingerprint(current, form.value.name);
|
||||
const info = normalizeDatabaseConnectionInfo(config.database_info);
|
||||
if (info) {
|
||||
savedDatabaseInfo.value = info;
|
||||
savedDatabaseInfoFingerprint.value = savedConnectionConfigFingerprint.value;
|
||||
}
|
||||
} catch {
|
||||
clearSavedDatabaseInfo();
|
||||
}
|
||||
}
|
||||
|
||||
function applySuccessfulConnectionTest(result: ConnectionTestResult, config: ConnectionConfig, sourceName: string) {
|
||||
testResult.value = { ok: true, ...result };
|
||||
testedConfigFingerprint.value = connectionConfigFingerprint(config, sourceName);
|
||||
testedConfigId.value = config.id;
|
||||
testedGeneratedName.value = config.name;
|
||||
}
|
||||
|
||||
async function persistSuccessfulConnectionTest(result: ConnectionTestResult, config: ConnectionConfig, sourceName: string) {
|
||||
if (!editingId.value || !result.databaseInfo || !savedConnectionConfigFingerprint.value) return;
|
||||
const fingerprint = connectionConfigFingerprint(config, sourceName);
|
||||
if (fingerprint !== savedConnectionConfigFingerprint.value) return;
|
||||
try {
|
||||
await store.updateConnectionDatabaseInfo(editingId.value, result.databaseInfo);
|
||||
savedDatabaseInfo.value = { ...result.databaseInfo };
|
||||
savedDatabaseInfoFingerprint.value = fingerprint;
|
||||
} catch {
|
||||
// The successful test remains valid even when optional metadata persistence fails.
|
||||
}
|
||||
}
|
||||
|
||||
async function testConnectionWithTimeout(config: ConnectionConfig, runId: number): Promise<ConnectionTestResult> {
|
||||
const timeoutMs = connectionAttemptTimeoutMs(config);
|
||||
const timeoutMessage = connectionAttemptTimeoutMessage(timeoutMs);
|
||||
const promise = api.testConnection(config);
|
||||
const promise = api.testConnectionWithInfo(config);
|
||||
let timedOut = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
void promise.catch((error) => {
|
||||
if (!timedOut) return;
|
||||
if (runId !== testRunId) return;
|
||||
clearTestedConnectionInfo();
|
||||
testResult.value = {
|
||||
ok: false,
|
||||
message: connectionErrorWithDriverUpdateHint(config, connectionAttemptOriginalErrorMessage(timeoutMessage, errorMessage(error))),
|
||||
|
|
@ -1216,7 +1307,7 @@ async function testConnectionWithTimeout(config: ConnectionConfig, runId: number
|
|||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<string>((_, reject) => {
|
||||
new Promise<ConnectionTestResult>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
reject(new Error(timeoutMessage));
|
||||
|
|
@ -1436,9 +1527,11 @@ function switchGbaseProfile(profile: "gbase8a" | "gbase8s") {
|
|||
}
|
||||
|
||||
watch(
|
||||
() => props.editConfig,
|
||||
(config) => {
|
||||
[() => props.editConfig, open],
|
||||
([config, isOpen]) => {
|
||||
if (!isOpen) return;
|
||||
if (config) {
|
||||
clearSavedDatabaseInfo();
|
||||
const legacyConfig = config as LegacyConnectionConfig;
|
||||
const profile = profileForConfig(config);
|
||||
const oceanbaseMode = profile === "oceanbase" ? oceanbaseSubModeFromConfig(config) : "mysql";
|
||||
|
|
@ -1528,7 +1621,14 @@ watch(
|
|||
customDriverName.value = isCustomCompatibleProfile() ? config.driver_label || "" : "";
|
||||
dialogStep.value = "config";
|
||||
configTab.value = initialConfigTab();
|
||||
// Form/profile watchers normalize derived fields in this flush. Capture
|
||||
// the saved baseline afterwards so those initial changes are not treated
|
||||
// as user edits that invalidate persisted database metadata.
|
||||
void nextTick(() => {
|
||||
if (open.value && props.editConfig?.id === config.id) applySavedDatabaseInfo(config);
|
||||
});
|
||||
} else {
|
||||
clearSavedDatabaseInfo();
|
||||
editingId.value = null;
|
||||
form.value = defaultForm();
|
||||
productionProtectionEnabled.value = false;
|
||||
|
|
@ -2050,6 +2150,71 @@ const visibleObjectSelectedCountKey = computed(() => (visibleFilterUsesSchemas.v
|
|||
const visibleObjectEmptySelectionKey = computed(() => (visibleFilterUsesSchemas.value ? "visibleSchemas.emptySelection" : "visibleDatabases.emptySelection"));
|
||||
const visibleObjectLoadFailedKey = computed(() => (visibleFilterUsesSchemas.value ? "visibleSchemas.loadFailed" : "visibleDatabases.loadFailed"));
|
||||
const visibleObjectSaveKey = computed(() => (visibleFilterUsesSchemas.value ? "visibleSchemas.save" : "visibleDatabases.save"));
|
||||
const databaseInfoLabelKeys: Record<DatabaseInfoField, string> = {
|
||||
productName: "connection.databaseInfo.productName",
|
||||
productVersion: "connection.databaseInfo.productVersion",
|
||||
currentDatabase: "connection.databaseInfo.currentDatabase",
|
||||
serverComment: "connection.databaseInfo.serverComment",
|
||||
serverCharset: "connection.databaseInfo.serverCharset",
|
||||
serverCollation: "connection.databaseInfo.serverCollation",
|
||||
unquotedIdentifierCase: "connection.databaseInfo.unquotedIdentifierCase",
|
||||
quotedIdentifierCase: "connection.databaseInfo.quotedIdentifierCase",
|
||||
driverName: "connection.databaseInfo.driverName",
|
||||
driverVersion: "connection.databaseInfo.driverVersion",
|
||||
jdbcVersion: "connection.databaseInfo.jdbcVersion",
|
||||
};
|
||||
function databaseInfoFieldLabel(field: DatabaseInfoField): string {
|
||||
return t(databaseInfoLabelKeys[field]);
|
||||
}
|
||||
function databaseIdentifierCaseLabel(value: IdentifierCase): string {
|
||||
return t(`connection.databaseInfo.identifierCase.${value}`);
|
||||
}
|
||||
const visibleTestDatabaseInfo = computed<DatabaseConnectionInfo | null>(() => {
|
||||
const result = testResult.value;
|
||||
if (!result?.ok || !result.databaseInfo || !testedConfigFingerprint.value || !testedConfigId.value) return null;
|
||||
try {
|
||||
const current = connectionConfigForSubmit(testedConfigId.value, testedGeneratedName.value);
|
||||
return connectionConfigFingerprint(current, form.value.name) === testedConfigFingerprint.value ? result.databaseInfo : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const visibleSavedDatabaseInfo = computed<DatabaseConnectionInfo | null>(() => {
|
||||
if (!savedDatabaseInfo.value || !savedDatabaseInfoFingerprint.value || !editingId.value) return null;
|
||||
try {
|
||||
const current = connectionConfigForSubmit(editingId.value, form.value.name);
|
||||
return connectionConfigFingerprint(current, form.value.name) === savedDatabaseInfoFingerprint.value ? savedDatabaseInfo.value : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const configuredDatabaseInfo = computed<DatabaseConnectionInfo | null>(() => {
|
||||
const productName = configuredDatabaseProductName({
|
||||
db_type: form.value.db_type,
|
||||
driver_label: form.value.driver_label,
|
||||
});
|
||||
return normalizeDatabaseConnectionInfo(undefined, productName, form.value.database) ?? null;
|
||||
});
|
||||
const visibleDatabaseInfo = computed<DatabaseConnectionInfo | null>(() => visibleTestDatabaseInfo.value ?? visibleSavedDatabaseInfo.value ?? configuredDatabaseInfo.value);
|
||||
const databaseInfoVerified = computed(() => !!visibleTestDatabaseInfo.value || !!visibleSavedDatabaseInfo.value);
|
||||
const databaseInfoStatusLabel = computed(() => (databaseInfoVerified.value ? t("connection.databaseInfo.sourceTested") : t("connection.databaseInfo.sourceConfigured")));
|
||||
const databaseInfoDescription = computed(() => (databaseInfoVerified.value ? t("connection.databaseInfo.testedDescription") : t("connection.databaseInfo.configuredDescription")));
|
||||
const databaseInfoDisplayRows = computed(() =>
|
||||
visibleDatabaseInfo.value
|
||||
? databaseInfoRows(visibleDatabaseInfo.value).map((row) => ({
|
||||
...row,
|
||||
label: databaseInfoFieldLabel(row.key),
|
||||
displayValue: row.key === "unquotedIdentifierCase" || row.key === "quotedIdentifierCase" ? databaseIdentifierCaseLabel(row.value as IdentifierCase) : row.value,
|
||||
}))
|
||||
: [],
|
||||
);
|
||||
const databaseInfoCompactLabel = computed(() =>
|
||||
databaseInfoDisplayRows.value
|
||||
.filter((row) => row.key === "productName" || row.key === "productVersion" || row.key === "currentDatabase")
|
||||
.slice(0, 3)
|
||||
.map((row) => row.displayValue)
|
||||
.join(" · "),
|
||||
);
|
||||
const testResultMessage = computed(() => {
|
||||
if (!testResult.value) return "";
|
||||
return testResult.value.ok ? t("connection.testSuccess") : translateBackendError(t, testResult.value.message);
|
||||
|
|
@ -2147,30 +2312,37 @@ async function testConnection() {
|
|||
isTesting.value = true;
|
||||
testResult.value = null;
|
||||
let config: ConnectionConfig | null = null;
|
||||
const submittedSourceName = form.value.name;
|
||||
try {
|
||||
config = connectionConfigForSubmit(editingId.value || draftTestConnectionId.value);
|
||||
await ensureRequiredAgentDriverInstalled(config);
|
||||
const msg = await testConnectionWithTimeout(config, runId);
|
||||
const result = await testConnectionWithTimeout(config, runId);
|
||||
if (runId !== testRunId) return;
|
||||
if (config.db_type === "mongodb" && /legacy driver/i.test(msg)) {
|
||||
let successfulConfig = config;
|
||||
if (config.db_type === "mongodb" && /legacy driver/i.test(result.message)) {
|
||||
mongoDriverMode.value = "legacy";
|
||||
successfulConfig = connectionConfigForSubmit(config.id, config.name);
|
||||
}
|
||||
testResult.value = { ok: true, message: msg };
|
||||
applySuccessfulConnectionTest(result, successfulConfig, submittedSourceName);
|
||||
void persistSuccessfulConnectionTest(result, successfulConfig, submittedSourceName);
|
||||
clearEditedConnectionErrorAfterSuccessfulTest();
|
||||
} catch (e: any) {
|
||||
if (runId !== testRunId) return;
|
||||
const rawMessage = mongodbAuthFailureHint(errorMessage(e));
|
||||
const message = config ? connectionErrorWithDriverUpdateHint(config, rawMessage) : rawMessage;
|
||||
const fallbackMessage = config ? await tryNacosDockerConsoleFallback(config, message, runId) : null;
|
||||
const fallback = config ? await tryNacosDockerConsoleFallback(config, message, runId) : null;
|
||||
if (runId !== testRunId) return;
|
||||
const shouldShowSqlServerLegacyMode = !fallbackMessage && config?.db_type === "sqlserver" && !isSqlServerLegacyCompatibilityMode(config.url_params) && isSqlServerTlsHandshakeFailure(message);
|
||||
const shouldShowSqlServerLegacyMode = !fallback && config?.db_type === "sqlserver" && !isSqlServerLegacyCompatibilityMode(config.url_params) && isSqlServerTlsHandshakeFailure(message);
|
||||
if (shouldShowSqlServerLegacyMode) {
|
||||
configTab.value = "advanced";
|
||||
}
|
||||
testResult.value = fallbackMessage ? { ok: true, message: fallbackMessage } : { ok: false, message };
|
||||
if (fallbackMessage) {
|
||||
if (fallback) {
|
||||
applySuccessfulConnectionTest(fallback.result, fallback.config, submittedSourceName);
|
||||
void persistSuccessfulConnectionTest(fallback.result, fallback.config, submittedSourceName);
|
||||
clearEditedConnectionErrorAfterSuccessfulTest();
|
||||
} else {
|
||||
clearTestedConnectionInfo();
|
||||
testResult.value = { ok: false, message };
|
||||
showConnectionError(message);
|
||||
}
|
||||
} finally {
|
||||
|
|
@ -2319,13 +2491,14 @@ function generateConnectionName(): string {
|
|||
return `${label}_${rand}`;
|
||||
}
|
||||
|
||||
function connectionConfigForSubmit(id: string): ConnectionConfig {
|
||||
function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionConfig {
|
||||
const config = { ...formValueForSubmit(), id } as LegacyConnectionConfig;
|
||||
config.database_info = undefined;
|
||||
if (selectedType.value === "oceanbase" && (config.driver_profile === "oceanbase" || config.driver_profile === "oceanbase-oracle")) {
|
||||
Object.assign(config, oceanbaseModeConnectionPatch(oceanbaseSubMode.value));
|
||||
}
|
||||
if (!config.name?.trim()) {
|
||||
config.name = generateConnectionName();
|
||||
config.name = generatedName.trim() || generateConnectionName();
|
||||
}
|
||||
if (config.db_type === "kingbase") {
|
||||
config.database = config.database?.trim() || undefined;
|
||||
|
|
@ -2602,6 +2775,13 @@ function connectionConfigForSubmit(id: string): ConnectionConfig {
|
|||
return config as ConnectionConfig;
|
||||
}
|
||||
|
||||
function withSavedDatabaseInfo(config: ConnectionConfig, databaseInfo: DatabaseConnectionInfo | null): ConnectionConfig {
|
||||
return {
|
||||
...config,
|
||||
database_info: databaseInfo ? { ...databaseInfo } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function connectionConfigSnapshotForVisibleDatabases(): ConnectionConfig {
|
||||
return {
|
||||
...(form.value as ConnectionConfig),
|
||||
|
|
@ -2813,6 +2993,7 @@ function resetTestState() {
|
|||
testRunId += 1;
|
||||
isTesting.value = false;
|
||||
testResult.value = null;
|
||||
clearTestedConnectionInfo();
|
||||
showConnectionErrorDialog.value = false;
|
||||
connectionErrorDetail.value = "";
|
||||
}
|
||||
|
|
@ -3098,6 +3279,17 @@ async function copyTestResult() {
|
|||
}
|
||||
}
|
||||
|
||||
async function copyDatabaseInfo() {
|
||||
const info = visibleDatabaseInfo.value;
|
||||
if (!info) return;
|
||||
try {
|
||||
await copyToClipboard(databaseInfoCopyText(info, databaseInfoFieldLabel, databaseIdentifierCaseLabel));
|
||||
toast(t("grid.copied"));
|
||||
} catch (e: any) {
|
||||
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyAgentInstallError() {
|
||||
if (!agentInstallError.value) return;
|
||||
try {
|
||||
|
|
@ -3431,16 +3623,16 @@ function validateTransportLayers(config: LegacyConnectionConfig) {
|
|||
async function save() {
|
||||
if (!ensureConnectionHostResolvedFromUrl()) return;
|
||||
if (isSaving.value) return;
|
||||
const databaseInfoForSave = visibleTestDatabaseInfo.value ?? visibleSavedDatabaseInfo.value;
|
||||
isSaving.value = true;
|
||||
resetTestState();
|
||||
try {
|
||||
if (editingId.value) {
|
||||
const updated = connectionConfigForSubmit(editingId.value);
|
||||
const updated = withSavedDatabaseInfo(connectionConfigForSubmit(editingId.value), databaseInfoForSave);
|
||||
await ensureRequiredAgentDriverInstalled(updated);
|
||||
await store.updateConnection(updated);
|
||||
store.stopEditing();
|
||||
} else {
|
||||
const config = connectionConfigForSubmit(draftTestConnectionId.value);
|
||||
const config = withSavedDatabaseInfo(connectionConfigForSubmit(draftTestConnectionId.value), databaseInfoForSave);
|
||||
await ensureRequiredAgentDriverInstalled(config);
|
||||
await store.addConnection(config);
|
||||
draftTestConnectionId.value = uuid();
|
||||
|
|
@ -3817,7 +4009,7 @@ function openExternalUrl(url: string) {
|
|||
</DialogHeader>
|
||||
|
||||
<template v-if="dialogStep === 'select'">
|
||||
<div class="space-y-4">
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-4 overflow-hidden">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex shrink-0 rounded-lg border bg-muted/40 p-0.5">
|
||||
|
|
@ -3839,7 +4031,7 @@ function openExternalUrl(url: string) {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="max-h-[58vh] space-y-5 overflow-y-auto pr-2">
|
||||
<div class="min-h-0 flex-1 space-y-5 overflow-y-auto pr-2">
|
||||
<section v-for="category in filteredDbCategories" :key="category.key" class="space-y-2">
|
||||
<div class="flex items-center">
|
||||
<h3 v-if="category.title" class="text-sm font-medium">{{ category.title }}</h3>
|
||||
|
|
@ -3887,7 +4079,7 @@ function openExternalUrl(url: string) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter class="flex items-center gap-2">
|
||||
<DialogFooter class="flex shrink-0 items-center gap-2">
|
||||
<div class="mr-auto flex min-w-0 items-center gap-2 text-sm text-muted-foreground">
|
||||
<DatabaseIcon :db-type="selectedDbIcon" class="h-4 w-4 shrink-0" />
|
||||
<span class="truncate">{{ t("connection.selectedDatabase") }}: {{ selectedProfile().label }}</span>
|
||||
|
|
@ -3900,8 +4092,8 @@ function openExternalUrl(url: string) {
|
|||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="space-y-3">
|
||||
<Tabs v-model="configTab" class="min-h-0">
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-4 overflow-hidden">
|
||||
<Tabs v-model="configTab" class="flex min-h-0 flex-1 flex-col">
|
||||
<div class="flex items-center justify-between border-b pb-2">
|
||||
<TabsList>
|
||||
<TabsTrigger value="connection">{{ t("connection.basicTab") }}</TabsTrigger>
|
||||
|
|
@ -3911,8 +4103,8 @@ function openExternalUrl(url: string) {
|
|||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="connection" class="m-0">
|
||||
<div class="connection-form-body grid gap-4 py-4 pr-2 max-h-[65vh] overflow-y-auto">
|
||||
<TabsContent value="connection" class="m-0 min-h-0 flex-1 overflow-hidden">
|
||||
<div class="connection-form-body grid h-full min-h-0 gap-4 overflow-y-auto pt-4 pr-2">
|
||||
<div v-if="!isJdbcConnection" class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.connectionUrlOptional") }}</Label>
|
||||
<div class="col-span-3 flex items-center gap-1">
|
||||
|
|
@ -5059,11 +5251,49 @@ function openExternalUrl(url: string) {
|
|||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<div v-if="visibleDatabaseInfo" class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.databaseInfo.title") }}</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="col-span-3 flex h-9 min-w-0 items-center gap-2 rounded-md border bg-muted/20 px-2.5 text-left text-xs transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:title="databaseInfoCompactLabel"
|
||||
:aria-label="t('connection.databaseInfo.open', { database: databaseInfoCompactLabel })"
|
||||
>
|
||||
<DatabaseLucide class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span class="rounded-full bg-background px-1.5 py-0.5 text-[11px] text-muted-foreground">{{ databaseInfoStatusLabel }}</span>
|
||||
<span class="min-w-0 flex-1 truncate text-muted-foreground">{{ databaseInfoCompactLabel }}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="top" align="start" class="w-[360px] max-w-[calc(100vw-24px)] gap-3 p-3" @click.stop @keydown.stop>
|
||||
<div class="flex min-w-0 items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<DatabaseLucide class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div class="min-w-0 text-sm font-medium">{{ t("connection.databaseInfo.title") }}</div>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-muted-foreground">{{ databaseInfoDescription }}</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon-xs" class="h-7 w-7 shrink-0" :title="t('connection.databaseInfo.copy')" :aria-label="t('connection.databaseInfo.copy')" @click="copyDatabaseInfo">
|
||||
<Copy class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<dl class="mt-3 grid min-w-0 grid-cols-[minmax(7.5rem,auto)_minmax(0,1fr)] gap-x-4 gap-y-2 text-xs">
|
||||
<template v-for="row in databaseInfoDisplayRows" :key="row.key">
|
||||
<dt class="text-muted-foreground">{{ row.label }}</dt>
|
||||
<dd class="min-w-0 break-words text-right font-medium">{{ row.displayValue }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent v-if="supportsTlsToggle" value="tls" class="m-0">
|
||||
<div class="connection-form-body grid gap-4 py-4 pr-2 max-h-[65vh] overflow-y-auto overflow-x-hidden">
|
||||
<TabsContent v-if="supportsTlsToggle" value="tls" class="m-0 min-h-0 flex-1 overflow-hidden">
|
||||
<div class="connection-form-body grid h-full min-h-0 gap-4 overflow-y-auto overflow-x-hidden pt-4 pr-2">
|
||||
<div v-if="!supportsPostgresTlsOptions && !supportsMysqlTlsOptions" class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelSmallClass">SSL/TLS</Label>
|
||||
<label class="col-span-3 flex items-center gap-2 cursor-pointer">
|
||||
|
|
@ -5327,8 +5557,8 @@ function openExternalUrl(url: string) {
|
|||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="advanced" class="m-0">
|
||||
<div class="connection-form-body grid gap-4 py-4 pr-2 max-h-[65vh] overflow-y-auto">
|
||||
<TabsContent value="advanced" class="m-0 min-h-0 flex-1 overflow-hidden">
|
||||
<div class="connection-form-body grid h-full min-h-0 gap-4 overflow-y-auto pt-4 pr-2">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelSmallClass">{{ t("connection.connectTimeout") }}</Label>
|
||||
<Input v-model.number="form.connect_timeout_secs" type="number" min="1" max="300" step="1" class="col-span-3" />
|
||||
|
|
@ -5419,8 +5649,8 @@ function openExternalUrl(url: string) {
|
|||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent v-if="canUseTransportLayers" value="transport" class="m-0">
|
||||
<div class="connection-form-body grid gap-4 py-4 pr-2 max-h-[65vh] overflow-y-auto overflow-x-hidden">
|
||||
<TabsContent v-if="canUseTransportLayers" value="transport" class="m-0 min-h-0 flex-1 overflow-hidden">
|
||||
<div class="connection-form-body grid h-full min-h-0 gap-4 overflow-y-auto overflow-x-hidden pt-4 pr-2">
|
||||
<div class="connection-label-wide-grid grid min-w-0 grid-cols-4 items-start gap-4">
|
||||
<Label :class="connectionLabelSmallPaddedClass">{{ t("connection.sshHops") }}</Label>
|
||||
<div class="col-span-3 grid min-w-0 gap-3">
|
||||
|
|
@ -5663,7 +5893,7 @@ function openExternalUrl(url: string) {
|
|||
</Tabs>
|
||||
</div>
|
||||
|
||||
<DialogFooter class="flex min-w-0 items-center gap-2 sm:flex-nowrap">
|
||||
<DialogFooter class="flex min-w-0 shrink-0 items-center gap-2 sm:flex-nowrap">
|
||||
<div class="mr-auto flex min-w-0 flex-1 basis-0 items-center gap-2 overflow-hidden">
|
||||
<Button v-if="!editingId" variant="outline" class="shrink-0" :disabled="isSaving" @click="backToDatabasePicker">
|
||||
<ArrowLeft class="h-4 w-4" />
|
||||
|
|
@ -5673,7 +5903,7 @@ function openExternalUrl(url: string) {
|
|||
<span class="block min-w-0 flex-1 basis-0 truncate text-xs" :class="testResult.ok ? 'text-green-600' : 'text-red-600'" :title="testResultMessage" role="status" aria-live="polite">
|
||||
{{ testResultMessage }}
|
||||
</span>
|
||||
<Button variant="ghost" size="icon-xs" class="h-5 w-5 shrink-0" :title="t('connection.copyTestResult')" :aria-label="t('connection.copyTestResult')" @click="copyTestResult">
|
||||
<Button v-if="!testResult.ok" variant="ghost" size="icon-xs" class="h-5 w-5 shrink-0" :title="t('connection.copyTestResult')" :aria-label="t('connection.copyTestResult')" @click="copyTestResult">
|
||||
<Copy class="h-3 w-3" />
|
||||
</Button>
|
||||
</template>
|
||||
|
|
@ -5921,6 +6151,12 @@ function openExternalUrl(url: string) {
|
|||
</template>
|
||||
|
||||
<style>
|
||||
.connection-dialog-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100dvh - 2rem);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.connection-db-picker-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr)) !important;
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ import { createInsertValueHintsExtension, requestInsertValueHintsRefresh } from
|
|||
import { focusEditorView } from "@/lib/editor/queryEditorFocus";
|
||||
import { createDbxCodeMirrorSqlDialect } from "@/lib/editor/codemirrorSqlDialect";
|
||||
import { startsQueryEditorRectangularSelection } from "@/lib/editor/queryEditorPointerSelection";
|
||||
import type { StatementExecutionMarker } from "@/lib/tabs/tabPresentation";
|
||||
import { isSchemaAware, isSingleDatabase, supportsSqlInListPaste } from "@/lib/database/databaseFeatureSupport";
|
||||
import { usesLocalOnlyEditorCompletionMetadata, usesOnDemandOnlyEditorColumnMetadata } from "@/lib/metadata/completionMetadataPolicy";
|
||||
import { qualifiedTableNameAtSqlPosition } from "@/lib/sql/queryCursorTableTarget";
|
||||
|
|
@ -89,6 +90,7 @@ const props = defineProps<{
|
|||
hideExecutionControls?: boolean;
|
||||
initialViewport?: { scrollTop: number; scrollLeft: number };
|
||||
initialSelection?: { anchor: number; head: number };
|
||||
statementExecutionMarkers?: StatementExecutionMarker[];
|
||||
}>();
|
||||
|
||||
const COMPLETION_REMOTE_LATENCY_BUDGET_MS = 120;
|
||||
|
|
@ -217,6 +219,7 @@ let codeMirrorCloseBrackets: typeof import("@codemirror/autocomplete").closeBrac
|
|||
let codeMirrorCloseBracketsKeymap: readonly import("@codemirror/view").KeyBinding[] | null = null;
|
||||
let readOnlyComp: import("@codemirror/state").Compartment | null = null;
|
||||
let runGutterComp: import("@codemirror/state").Compartment | null = null;
|
||||
let statementExecutionGutterComp: import("@codemirror/state").Compartment | null = null;
|
||||
let runKeymapComp: import("@codemirror/state").Compartment | null = null;
|
||||
let completionComp: import("@codemirror/state").Compartment | null = null;
|
||||
let diagnosticComp: import("@codemirror/state").Compartment | null = null;
|
||||
|
|
@ -250,9 +253,11 @@ let codeMirrorToggleLineComment: typeof import("@codemirror/commands").toggleLin
|
|||
let setSqlDiagnosticsEffect: import("@codemirror/state").StateEffectType<SqlSemanticDiagnostic[]> | null = null;
|
||||
let setPreviewRangeEffect: import("@codemirror/state").StateEffectType<{ from: number; to: number } | null> | null = null;
|
||||
let setResultSourceRangeEffect: import("@codemirror/state").StateEffectType<{ from: number; to: number } | null> | null = null;
|
||||
let setStatementExecutionMarkersEffect: import("@codemirror/state").StateEffectType<StatementExecutionMarker[]> | null = null;
|
||||
let previewRangeComp: import("@codemirror/state").Compartment | null = null;
|
||||
let buildPreviewRangeExtension: (() => import("@codemirror/state").Extension) | null = null;
|
||||
let buildResultSourceRangeExtension: (() => import("@codemirror/state").Extension) | null = null;
|
||||
let buildStatementExecutionMarkersExtension: (() => import("@codemirror/state").Extension) | null = null;
|
||||
let buildRunStatementGutterExtension: (() => import("@codemirror/state").Extension) | null = null;
|
||||
let indentComp: import("@codemirror/state").Compartment | null = null;
|
||||
let codeMirrorIndentUnit: typeof import("@codemirror/language").indentUnit | null = null;
|
||||
|
|
@ -2673,6 +2678,7 @@ onMounted(async () => {
|
|||
codeMirrorCloseBracketsKeymap = closeBracketsKeymap;
|
||||
readOnlyComp = new Compartment();
|
||||
runGutterComp = new Compartment();
|
||||
statementExecutionGutterComp = new Compartment();
|
||||
runKeymapComp = new Compartment();
|
||||
completionComp = new Compartment();
|
||||
diagnosticComp = new Compartment();
|
||||
|
|
@ -2801,6 +2807,85 @@ onMounted(async () => {
|
|||
return field;
|
||||
};
|
||||
|
||||
class StatementExecutionStatusMarker extends GutterMarker {
|
||||
constructor(readonly marker: StatementExecutionMarker) {
|
||||
super();
|
||||
}
|
||||
|
||||
eq(other: import("@codemirror/view").GutterMarker): boolean {
|
||||
return other instanceof StatementExecutionStatusMarker && other.marker.status === this.marker.status && other.marker.successCount === this.marker.successCount && other.marker.errorCount === this.marker.errorCount;
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
const element = document.createElement("span");
|
||||
const title = statementExecutionMarkerTitle(this.marker);
|
||||
element.className = `cm-statement-execution-marker cm-statement-execution-marker--${this.marker.status}`;
|
||||
element.title = title;
|
||||
element.setAttribute("aria-label", title);
|
||||
element.appendChild(createStatementExecutionStatusIconDom(this.marker.status));
|
||||
return element;
|
||||
}
|
||||
}
|
||||
|
||||
function statementExecutionMarkerTitle(marker: StatementExecutionMarker) {
|
||||
const parts = [];
|
||||
if (marker.successCount > 0) parts.push(t("editor.statementExecutionSucceeded", { count: marker.successCount }));
|
||||
if (marker.errorCount > 0) parts.push(t("editor.statementExecutionFailed", { count: marker.errorCount }));
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
function createStatementExecutionStatusIconDom(status: StatementExecutionMarker["status"]) {
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
svg.setAttribute("viewBox", "0 0 24 24");
|
||||
svg.setAttribute("fill", "none");
|
||||
svg.setAttribute("stroke", "currentColor");
|
||||
svg.setAttribute("stroke-width", "2");
|
||||
svg.setAttribute("stroke-linecap", "round");
|
||||
svg.setAttribute("stroke-linejoin", "round");
|
||||
svg.setAttribute("aria-hidden", "true");
|
||||
|
||||
const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
|
||||
circle.setAttribute("cx", "12");
|
||||
circle.setAttribute("cy", "12");
|
||||
circle.setAttribute("r", "10");
|
||||
const mark = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
mark.setAttribute("d", status === "error" ? "m15 9-6 6m0-6 6 6" : "m9 12 2 2 4-4");
|
||||
svg.append(circle, mark);
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
setStatementExecutionMarkersEffect = StateEffect.define<StatementExecutionMarker[]>();
|
||||
buildStatementExecutionMarkersExtension = () => {
|
||||
const effectType = setStatementExecutionMarkersEffect!;
|
||||
const markersForState = (state: import("@codemirror/state").EditorState, markers: readonly StatementExecutionMarker[]) => {
|
||||
const ranges = markers.map((marker) => {
|
||||
const from = Math.max(0, Math.min(marker.from, state.doc.length));
|
||||
return new StatementExecutionStatusMarker(marker).range(state.doc.lineAt(from).from);
|
||||
});
|
||||
return RangeSet.of(ranges, true);
|
||||
};
|
||||
|
||||
const field = StateField.define({
|
||||
create(state) {
|
||||
return markersForState(state, props.statementExecutionMarkers ?? []);
|
||||
},
|
||||
update(markers, transaction) {
|
||||
for (const effect of transaction.effects) {
|
||||
if (effect.is(effectType)) return markersForState(transaction.state, effect.value);
|
||||
}
|
||||
if (transaction.docChanged) return RangeSet.empty;
|
||||
return markers;
|
||||
},
|
||||
provide: (field) =>
|
||||
gutter({
|
||||
class: "cm-statement-execution-gutter",
|
||||
markers: (currentView) => currentView.state.field(field),
|
||||
}),
|
||||
});
|
||||
return field;
|
||||
};
|
||||
|
||||
buildSqlSignatureExtension = () =>
|
||||
showTooltip.compute(["doc", "selection"], (currentState) => {
|
||||
const signature = getSqlFunctionSignatureHelp(currentState.doc.toString(), currentState.selection.main.head);
|
||||
|
|
@ -2992,6 +3077,7 @@ onMounted(async () => {
|
|||
}),
|
||||
previewRangeComp.of(buildPreviewRangeExtension()),
|
||||
buildResultSourceRangeExtension(),
|
||||
statementExecutionGutterComp.of(buildStatementExecutionMarkersExtension()),
|
||||
Prec.highest(
|
||||
keymap.of([
|
||||
{ key: "'", run: handleSqlSingleQuote },
|
||||
|
|
@ -3287,6 +3373,15 @@ watch(
|
|||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.statementExecutionMarkers ?? [],
|
||||
(markers) => {
|
||||
if (!view.value || !setStatementExecutionMarkersEffect) return;
|
||||
view.value.dispatch({ effects: setStatementExecutionMarkersEffect.of(markers) });
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.connectionId,
|
||||
() => {
|
||||
|
|
@ -3658,6 +3753,65 @@ defineExpose({ openSearch, openReplace, scrollCursorIntoView, requestExecute, pa
|
|||
padding: 0 5px;
|
||||
}
|
||||
|
||||
:deep(.cm-statement-execution-gutter) {
|
||||
min-width: 34px;
|
||||
}
|
||||
|
||||
:deep(.cm-statement-execution-gutter .cm-gutterElement) {
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
min-width: 34px;
|
||||
padding: 0 5px;
|
||||
}
|
||||
|
||||
:deep(.cm-statement-execution-marker) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
width: min(24px, calc(var(--dbx-editor-font-size, 13px) * 1.6));
|
||||
height: min(24px, calc(var(--dbx-editor-font-size, 13px) * 1.6));
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
color 0.15s,
|
||||
background-color 0.15s;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:deep(.cm-statement-execution-marker--success) {
|
||||
background: rgb(16 185 129 / 0.1);
|
||||
color: rgb(4 120 87);
|
||||
}
|
||||
|
||||
:deep(.cm-statement-execution-marker--error) {
|
||||
background: rgb(239 68 68 / 0.1);
|
||||
color: rgb(185 28 28);
|
||||
}
|
||||
|
||||
:deep(.dark .cm-statement-execution-marker--success) {
|
||||
color: rgb(110 231 183);
|
||||
}
|
||||
|
||||
:deep(.dark .cm-statement-execution-marker--error) {
|
||||
color: rgb(252 165 165);
|
||||
}
|
||||
|
||||
:deep(.cm-statement-execution-marker svg) {
|
||||
display: block;
|
||||
width: min(14px, 70%);
|
||||
height: min(14px, 70%);
|
||||
pointer-events: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:deep(.cm-run-statement-marker) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ import { useConnectionStore } from "@/stores/connectionStore";
|
|||
import { TABLE_FONT_SIZE_MAX, TABLE_FONT_SIZE_MIN, useSettingsStore, type DataGridSearchMode } from "@/stores/settingsStore";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { canCancelQueryExecution, queryExecutionLabelKey } from "@/lib/sql/queryExecutionState";
|
||||
import { databaseDisplayNameForTab, executionSummaryItems, queryResultExecutionSql, resultGridCacheKey, resultRunItems, resultSourceRange, resultSqlForGrid, tabularResultItems } from "@/lib/tabs/tabPresentation";
|
||||
import { databaseDisplayNameForTab, executionSummaryItems, queryResultExecutionSql, resultGridCacheKey, resultRunItems, resultSourceRange, resultSqlForGrid, statementExecutionMarkers, tabularResultItems } from "@/lib/tabs/tabPresentation";
|
||||
import { defaultQueryResultArchiveFileName } from "@/lib/query/queryResultArchive";
|
||||
import { saveQueryResultArchiveFile } from "@/lib/query/queryResultArchiveFile";
|
||||
import { isTableDataEditable } from "@/lib/table/tableEditing";
|
||||
|
|
@ -329,6 +329,15 @@ const activeResultRunItem = computed(() => resultRuns.value.find((run) => run.ac
|
|||
const activeResultGridCacheKey = computed(() => resultGridCacheKey(props.activeTab));
|
||||
const activeResultSql = computed(() => resultSqlForGrid(props.activeTab));
|
||||
const activeResultExportSql = computed(() => queryResultExecutionSql(props.activeTab));
|
||||
const activeStatementExecutionMarkers = computed(() =>
|
||||
statementExecutionMarkers(
|
||||
props.activeTab.sql,
|
||||
props.activeTab.results ?? (props.activeTab.result ? [props.activeTab.result] : undefined),
|
||||
activeEffectiveDatabaseType.value,
|
||||
props.activeTab.resultBaseSql || props.activeTab.lastExecutedSql || props.activeTab.sql,
|
||||
props.activeTab.resultEditorFingerprint ?? "",
|
||||
),
|
||||
);
|
||||
const activeElasticsearchJsonResponse = computed(() => elasticsearchJsonResponseForResult(activeEffectiveDatabaseType.value, activeResultSql.value, props.activeTab.result));
|
||||
const resultArchiveExporting = ref(false);
|
||||
const canExportResultArchive = computed(() => props.activeTab.mode === "query" && (!!props.activeTab.result || !!props.activeTab.results?.length || !!props.activeTab.resultRuns?.length));
|
||||
|
|
@ -800,6 +809,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
|
|||
:format-request-id="formatSqlRequest?.tabId === activeTab.id ? formatSqlRequest.id : undefined"
|
||||
:execution-error="activeQueryError"
|
||||
:execution-error-sql="activeTab.lastExecutedSql"
|
||||
:statement-execution-markers="activeStatementExecutionMarkers"
|
||||
:initial-viewport="activeTab.editorViewport"
|
||||
:initial-selection="activeTab.editorSelection"
|
||||
@update:model-value="emit('editorUpdate', activeTab.id, $event)"
|
||||
|
|
|
|||
|
|
@ -633,6 +633,7 @@ const connectionInfoTooltip = computed(() => {
|
|||
{ label: t("connection.database"), value: cleanTooltipValue(config.database) },
|
||||
{ label: t("connection.user"), value: cleanTooltipValue(config.username) },
|
||||
{ label: t("connection.type"), value: config.driver_label || config.driver_profile || config.db_type },
|
||||
{ label: t("connection.databaseInfo.productVersion"), value: cleanTooltipValue(config.database_info?.productVersion) },
|
||||
].filter((row) => row.value);
|
||||
|
||||
return { rows };
|
||||
|
|
|
|||
|
|
@ -342,6 +342,31 @@ export default {
|
|||
save: "Save",
|
||||
editTitle: "Edit Connection",
|
||||
testSuccess: "Connection successful",
|
||||
databaseInfo: {
|
||||
title: "Database information",
|
||||
open: "Open database information for {database}",
|
||||
copy: "Copy database information",
|
||||
sourceConfigured: "Current configuration",
|
||||
sourceTested: "Tested",
|
||||
configuredDescription: "Shows information known from the form first. A successful test adds server version and driver metadata.",
|
||||
testedDescription: "This information comes from the connection used by the latest successful test.",
|
||||
productName: "DBMS",
|
||||
productVersion: "DBMS version",
|
||||
currentDatabase: "Current database",
|
||||
serverComment: "Server comment",
|
||||
serverCharset: "Server charset",
|
||||
serverCollation: "Server collation",
|
||||
unquotedIdentifierCase: "Unquoted identifiers",
|
||||
quotedIdentifierCase: "Quoted identifiers",
|
||||
driverName: "Driver",
|
||||
driverVersion: "Driver version",
|
||||
jdbcVersion: "JDBC version",
|
||||
identifierCase: {
|
||||
lower: "Lowercase",
|
||||
upper: "Uppercase",
|
||||
mixed: "Mixed case",
|
||||
},
|
||||
},
|
||||
connecting: "Connecting to {name}...",
|
||||
connectSuccess: "Connected to {name}",
|
||||
connectFailed: "Connection failed: {message}",
|
||||
|
|
@ -468,6 +493,8 @@ export default {
|
|||
connectCancelled: "Connection cancelled",
|
||||
},
|
||||
editor: {
|
||||
statementExecutionSucceeded: "{count} statement succeeded | {count} statements succeeded",
|
||||
statementExecutionFailed: "{count} statement failed | {count} statements failed",
|
||||
pressToExecute: "Press {mod}+Enter to execute",
|
||||
pressToSaveSql: "Press {mod}+S to save SQL",
|
||||
queryTimeoutError: "Query timed out ({seconds}s). Check whether the database connection is healthy.",
|
||||
|
|
|
|||
|
|
@ -449,6 +449,31 @@ export default withEnglishFallback({
|
|||
kafkaKerberosKrb5ConfPlaceholder: "Opcional, ruta en la máquina donde se ejecuta DBX Agent, por ejemplo /etc/krb5.conf",
|
||||
kafkaKerberosPathHint: "Las rutas de keytab y krb5.conf son leídas por DBX Agent y deben existir en la máquina donde se ejecuta DBX Agent; no se subirán archivos desde el navegador actual.",
|
||||
kafkaKerberosAuthHint: "Iniciar sesión con GSSAPI + keytab. Si el servidor requiere transmisión cifrada, configure Security como SASL_SSL; de lo contrario, puede usar Auto o SASL_PLAINTEXT.",
|
||||
databaseInfo: {
|
||||
title: "Información de la base de datos",
|
||||
open: "Abrir información de la base de datos {database}",
|
||||
copy: "Copiar información de la base de datos",
|
||||
sourceConfigured: "Configuración actual",
|
||||
sourceTested: "Probada",
|
||||
configuredDescription: "Muestra primero la información que se puede determinar desde el formulario; después de una prueba exitosa, se complementa con la versión devuelta por el servidor y los metadatos del controlador.",
|
||||
testedDescription: "La siguiente información proviene de la conexión utilizada para esta prueba exitosa.",
|
||||
productName: "Sistema de gestión de bases de datos",
|
||||
productVersion: "Versión de la base de datos",
|
||||
currentDatabase: "Base de datos actual",
|
||||
serverComment: "Comentario del servidor",
|
||||
serverCharset: "Conjunto de caracteres del servidor",
|
||||
serverCollation: "Collation del servidor",
|
||||
unquotedIdentifierCase: "Identificador sin comillas",
|
||||
quotedIdentifierCase: "Identificador entrecomillado",
|
||||
driverName: "Controlador",
|
||||
driverVersion: "Versión del controlador",
|
||||
jdbcVersion: "Versión de JDBC",
|
||||
identifierCase: {
|
||||
lower: "Minúsculas",
|
||||
upper: "Mayúsculas",
|
||||
mixed: "Mixto",
|
||||
},
|
||||
},
|
||||
},
|
||||
editor: {
|
||||
pressToExecute: "Presiona {mod}+Enter para ejecutar",
|
||||
|
|
@ -561,6 +586,8 @@ export default withEnglishFallback({
|
|||
currentCommand: "Current command",
|
||||
allCommands: "All commands",
|
||||
},
|
||||
statementExecutionSucceeded: "{count} sentencias exitosas",
|
||||
statementExecutionFailed: "{count} sentencias fallidas",
|
||||
},
|
||||
tabs: {
|
||||
sql: "SQL",
|
||||
|
|
|
|||
|
|
@ -447,6 +447,31 @@ export default withEnglishFallback({
|
|||
kafkaKerberosKrb5ConfPlaceholder: "Opzionale, percorso sulla macchina DBX Agent, ad esempio /etc/krb5.conf",
|
||||
kafkaKerberosPathHint: "I percorsi di keytab e krb5.conf vengono letti da DBX Agent e devono esistere sulla macchina in cui è in esecuzione DBX Agent; non vengono caricati file dal browser corrente.",
|
||||
kafkaKerberosAuthHint: "Accedi con GSSAPI + keytab. Se il server richiede trasmissione crittografata, imposta Security su SASL_SSL; altrimenti puoi utilizzare Auto o SASL_PLAINTEXT.",
|
||||
databaseInfo: {
|
||||
title: "Informazioni sul database",
|
||||
open: "Apri informazioni database {database}",
|
||||
copy: "Copia informazioni database",
|
||||
sourceConfigured: "Configurazione corrente",
|
||||
sourceTested: "Testato",
|
||||
configuredDescription: "Mostra prima le informazioni determinabili dal modulo; dopo un test riuscito, vengono aggiunte la versione restituita dal server e i metadati del driver.",
|
||||
testedDescription: "Le seguenti informazioni provengono dalla connessione utilizzata per questo test riuscito.",
|
||||
productName: "Sistema di gestione database",
|
||||
productVersion: "Versione database",
|
||||
currentDatabase: "Database corrente",
|
||||
serverComment: "Commento server",
|
||||
serverCharset: "Set di caratteri del server",
|
||||
serverCollation: "Regole di ordinamento del server",
|
||||
unquotedIdentifierCase: "Identificatore senza virgolette",
|
||||
quotedIdentifierCase: "Identificatore con virgolette",
|
||||
driverName: "Driver",
|
||||
driverVersion: "Versione driver",
|
||||
jdbcVersion: "Versione JDBC",
|
||||
identifierCase: {
|
||||
lower: "Minuscolo",
|
||||
upper: "Maiuscolo",
|
||||
mixed: "Misto",
|
||||
},
|
||||
},
|
||||
},
|
||||
editor: {
|
||||
pressToExecute: "Premi {mod}+Enter per eseguire",
|
||||
|
|
@ -559,6 +584,8 @@ export default withEnglishFallback({
|
|||
currentCommand: "Current command",
|
||||
allCommands: "All commands",
|
||||
},
|
||||
statementExecutionSucceeded: "{count} istruzioni eseguite con successo",
|
||||
statementExecutionFailed: "{count} istruzioni fallite",
|
||||
},
|
||||
tabs: {
|
||||
sql: "SQL",
|
||||
|
|
|
|||
|
|
@ -447,6 +447,31 @@ export default withEnglishFallback({
|
|||
kafkaKerberosKrb5ConfPlaceholder: "オプション。DBX Agent が動作するマシンのパス。例: /etc/krb5.conf",
|
||||
kafkaKerberosPathHint: "keytab と krb5.conf のパスは DBX Agent が読み取るため、DBX Agent が動作するマシン上に存在する必要があります。ブラウザからファイルをアップロードすることはありません。",
|
||||
kafkaKerberosAuthHint: "GSSAPI + keytab を使用してログインします。サーバーが暗号化転送を要求する場合は、Security を SASL_SSL に設定してください。それ以外の場合は Auto または SASL_PLAINTEXT を使用できます。",
|
||||
databaseInfo: {
|
||||
title: "データベース情報",
|
||||
open: "{database} データベース情報を開く",
|
||||
copy: "データベース情報をコピー",
|
||||
sourceConfigured: "現在の設定",
|
||||
sourceTested: "テスト済み",
|
||||
configuredDescription: "まずフォームで確定可能な情報を表示します。テスト成功後、サーバーから返されたバージョンとドライバーのメタデータが補足されます。",
|
||||
testedDescription: "以下の情報は、今回の成功したテストで使用された接続からのものです。",
|
||||
productName: "データベース管理システム",
|
||||
productVersion: "データベースバージョン",
|
||||
currentDatabase: "現在のデータベース",
|
||||
serverComment: "サーバーコメント",
|
||||
serverCharset: "サーバー文字セット",
|
||||
serverCollation: "サーバー照合順序",
|
||||
unquotedIdentifierCase: "引用符なし識別子",
|
||||
quotedIdentifierCase: "引用符付き識別子",
|
||||
driverName: "ドライバー",
|
||||
driverVersion: "ドライバーバージョン",
|
||||
jdbcVersion: "JDBC バージョン",
|
||||
identifierCase: {
|
||||
lower: "小文字",
|
||||
upper: "大文字",
|
||||
mixed: "大文字小文字混在",
|
||||
},
|
||||
},
|
||||
},
|
||||
editor: {
|
||||
pressToExecute: "{mod}+Enter で実行",
|
||||
|
|
@ -559,6 +584,8 @@ export default withEnglishFallback({
|
|||
allCommands: "All commands",
|
||||
},
|
||||
selectDatabaseRequired: "先にデータベースを選択してください",
|
||||
statementExecutionSucceeded: "{count} 件のステートメントが成功しました",
|
||||
statementExecutionFailed: "{count} 件のステートメントが失敗しました",
|
||||
},
|
||||
tabs: {
|
||||
sql: "SQL",
|
||||
|
|
|
|||
|
|
@ -448,6 +448,31 @@ export default withEnglishFallback({
|
|||
kafkaKerberosKrb5ConfPlaceholder: "Opcional, caminho na máquina do DBX Agent, por exemplo /etc/krb5.conf",
|
||||
kafkaKerberosPathHint: "Os caminhos do keytab e do krb5.conf são lidos pelo DBX Agent e devem existir na máquina onde o DBX Agent é executado; os arquivos não são enviados do navegador atual.",
|
||||
kafkaKerberosAuthHint: "Faça login usando GSSAPI + keytab. Se o servidor exigir transmissão criptografada, defina Security como SASL_SSL; caso contrário, você pode usar Auto ou SASL_PLAINTEXT.",
|
||||
databaseInfo: {
|
||||
title: "Informações do Banco de Dados",
|
||||
open: "Abrir informações do banco de dados {database}",
|
||||
copy: "Copiar informações do banco de dados",
|
||||
sourceConfigured: "Configuração atual",
|
||||
sourceTested: "Testado",
|
||||
configuredDescription: "Exibe as informações que podem ser determinadas no formulário; após um teste bem-sucedido, serão complementadas com a versão e os metadados do driver retornados pelo servidor.",
|
||||
testedDescription: "As informações abaixo são da conexão usada neste teste bem-sucedido.",
|
||||
productName: "Sistema de Gerenciamento de Banco de Dados",
|
||||
productVersion: "Versão do banco de dados",
|
||||
currentDatabase: "Banco de dados atual",
|
||||
serverComment: "Descrição do servidor",
|
||||
serverCharset: "Charset do servidor",
|
||||
serverCollation: "Collation do servidor",
|
||||
unquotedIdentifierCase: "Identificador sem aspas",
|
||||
quotedIdentifierCase: "Identificador com aspas",
|
||||
driverName: "Driver",
|
||||
driverVersion: "Versão do driver",
|
||||
jdbcVersion: "Versão do JDBC",
|
||||
identifierCase: {
|
||||
lower: "Minúsculas",
|
||||
upper: "Maiúsculas",
|
||||
mixed: "Maiúsculas e minúsculas",
|
||||
},
|
||||
},
|
||||
},
|
||||
editor: {
|
||||
pressToExecute: "Pressione {mod}+Enter para executar",
|
||||
|
|
@ -560,6 +585,8 @@ export default withEnglishFallback({
|
|||
currentCommand: "Current command",
|
||||
allCommands: "All commands",
|
||||
},
|
||||
statementExecutionSucceeded: "{count} instrução(ões) executada(s) com sucesso",
|
||||
statementExecutionFailed: "{count} instrução(ões) com falha",
|
||||
},
|
||||
tabs: {
|
||||
sql: "SQL",
|
||||
|
|
|
|||
|
|
@ -344,6 +344,31 @@ export default withEnglishFallback({
|
|||
save: "保存",
|
||||
editTitle: "编辑连接",
|
||||
testSuccess: "连接成功",
|
||||
databaseInfo: {
|
||||
title: "数据库信息",
|
||||
open: "打开 {database} 数据库信息",
|
||||
copy: "复制数据库信息",
|
||||
sourceConfigured: "当前配置",
|
||||
sourceTested: "已测试",
|
||||
configuredDescription: "先显示表单中可确定的信息;测试成功后会补充服务器返回的版本和驱动元数据。",
|
||||
testedDescription: "以下信息来自本次成功测试使用的连接。",
|
||||
productName: "数据库管理系统",
|
||||
productVersion: "数据库版本",
|
||||
currentDatabase: "当前数据库",
|
||||
serverComment: "服务器说明",
|
||||
serverCharset: "服务器字符集",
|
||||
serverCollation: "服务器排序规则",
|
||||
unquotedIdentifierCase: "普通标识符",
|
||||
quotedIdentifierCase: "引用标识符",
|
||||
driverName: "驱动",
|
||||
driverVersion: "驱动版本",
|
||||
jdbcVersion: "JDBC 版本",
|
||||
identifierCase: {
|
||||
lower: "小写",
|
||||
upper: "大写",
|
||||
mixed: "混合大小写",
|
||||
},
|
||||
},
|
||||
connecting: "正在连接 {name}...",
|
||||
cancelConnecting: "取消连接",
|
||||
connectCancelled: "连接已取消",
|
||||
|
|
@ -470,6 +495,8 @@ export default withEnglishFallback({
|
|||
colorCustom: "自定义颜色",
|
||||
},
|
||||
editor: {
|
||||
statementExecutionSucceeded: "{count} 条语句成功",
|
||||
statementExecutionFailed: "{count} 条语句失败",
|
||||
pressToExecute: "按 {mod}+Enter 执行查询",
|
||||
pressToSaveSql: "按 {mod}+S 保存 SQL",
|
||||
queryTimeoutError: "查询超时 ({seconds}s),请检查数据库连接是否正常",
|
||||
|
|
|
|||
|
|
@ -448,6 +448,31 @@ export default withEnglishFallback({
|
|||
kafkaKerberosKrb5ConfPlaceholder: "可選,DBX Agent 所在機器路徑,例如 /etc/krb5.conf",
|
||||
kafkaKerberosPathHint: "keytab 和 krb5.conf 路徑由 DBX Agent 讀取,必須存在於執行 DBX Agent 的機器上;不會從當前瀏覽器上傳檔案。",
|
||||
kafkaKerberosAuthHint: "使用 GSSAPI + keytab 登入。若伺服器端要求加密傳輸,請將 Security 設為 SASL_SSL;否則可使用 Auto 或 SASL_PLAINTEXT。",
|
||||
databaseInfo: {
|
||||
title: "資料庫資訊",
|
||||
open: "開啟 {database} 資料庫資訊",
|
||||
copy: "複製資料庫資訊",
|
||||
sourceConfigured: "目前設定",
|
||||
sourceTested: "已測試",
|
||||
configuredDescription: "先顯示表單中可確定的資訊;測試成功後會補充伺服器回傳的版本和驅動元數據。",
|
||||
testedDescription: "以下資訊來自本次成功測試使用的連線。",
|
||||
productName: "資料庫管理系統",
|
||||
productVersion: "資料庫版本",
|
||||
currentDatabase: "目前資料庫",
|
||||
serverComment: "伺服器說明",
|
||||
serverCharset: "伺服器字元集",
|
||||
serverCollation: "伺服器排序規則",
|
||||
unquotedIdentifierCase: "一般識別碼",
|
||||
quotedIdentifierCase: "引號識別碼",
|
||||
driverName: "驅動",
|
||||
driverVersion: "驅動版本",
|
||||
jdbcVersion: "JDBC 版本",
|
||||
identifierCase: {
|
||||
lower: "小寫",
|
||||
upper: "大寫",
|
||||
mixed: "混合大小寫",
|
||||
},
|
||||
},
|
||||
},
|
||||
editor: {
|
||||
pressToExecute: "按 {mod}+Enter 執行查詢",
|
||||
|
|
@ -560,6 +585,8 @@ export default withEnglishFallback({
|
|||
currentCommand: "目前命令",
|
||||
allCommands: "全部命令",
|
||||
},
|
||||
statementExecutionSucceeded: "{count} 條陳述式成功",
|
||||
statementExecutionFailed: "{count} 條陳述式失敗",
|
||||
},
|
||||
tabs: {
|
||||
sql: "SQL",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { connectionGroupDisplayName, middleEllipsis, queryResultBaseSql, queryResultExecutionSql, resultSourceRange, tabTooltipLines, tabularResultItems } from "@/lib/tabs/tabPresentation";
|
||||
import { connectionGroupDisplayName, middleEllipsis, queryResultBaseSql, queryResultExecutionSql, resultSourceRange, statementExecutionMarkers, tabTooltipLines, tabularResultItems } from "@/lib/tabs/tabPresentation";
|
||||
import type { ConnectionConfig, QueryTab } from "@/types/database";
|
||||
|
||||
const translations: Record<string, string> = {
|
||||
|
|
@ -222,3 +222,58 @@ describe("query result source ranges", () => {
|
|||
expect(resultSourceRange("SELECT * FROM users; SELECT * FROM users;", { sourceStatement: "SELECT * FROM users" }, undefined, "mysql")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("statement execution markers", () => {
|
||||
it("projects explicit statement indexes to current editor lines", () => {
|
||||
const sql = "SELECT 1;\nSELECT * FROM missing;\nSELECT 3;";
|
||||
const secondFrom = sql.indexOf("SELECT *");
|
||||
const markers = statementExecutionMarkers(
|
||||
sql,
|
||||
[
|
||||
{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1, statement_index: 0, sourceStatement: "SELECT 1", sourceFrom: 0, sourceTo: 8 },
|
||||
{ columns: ["Error"], rows: [["no such table"]], affected_rows: 0, execution_time_ms: 1, execution_error: true, statement_index: 1, sourceStatement: "SELECT * FROM missing", sourceFrom: secondFrom, sourceTo: secondFrom + "SELECT * FROM missing".length },
|
||||
],
|
||||
"sqlite",
|
||||
sql,
|
||||
);
|
||||
|
||||
expect(markers).toEqual([
|
||||
{ from: 0, status: "success", successCount: 1, errorCount: 0 },
|
||||
{ from: secondFrom, status: "error", successCount: 0, errorCount: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits unindexed query-level errors and single-statement executions", () => {
|
||||
expect(statementExecutionMarkers("SELECT 1; SELECT 2;", [{ columns: ["Error"], rows: [["pool failed"]], affected_rows: 0, execution_time_ms: 1, execution_error: true }], "mysql", "SELECT 1; SELECT 2;")).toEqual([]);
|
||||
expect(statementExecutionMarkers("SELECT 1", [{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1, statement_index: 0, sourceStatement: "SELECT 1", sourceFrom: 0, sourceTo: 8 }], "mysql", "SELECT 1")).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps duplicate statements scoped by preserved absolute ranges", () => {
|
||||
const sql = "SELECT * FROM users;\nSELECT * FROM users;";
|
||||
const from = sql.lastIndexOf("SELECT");
|
||||
|
||||
expect(statementExecutionMarkers(sql, [{ columns: ["id"], rows: [[1]], affected_rows: 0, execution_time_ms: 1, statement_index: 1, sourceStatement: "SELECT * FROM users", sourceFrom: from, sourceTo: sql.length - 1 }], "mysql", sql)).toEqual([
|
||||
{ from, status: "success", successCount: 1, errorCount: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("aggregates same-line statements with error precedence", () => {
|
||||
const sql = "SELECT 1; SELECT bad;";
|
||||
expect(
|
||||
statementExecutionMarkers(
|
||||
sql,
|
||||
[
|
||||
{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1, statement_index: 0, sourceStatement: "SELECT 1", sourceFrom: 0, sourceTo: 8 },
|
||||
{ columns: ["Error"], rows: [["bad"]], affected_rows: 0, execution_time_ms: 1, execution_error: true, statement_index: 1, sourceStatement: "SELECT bad", sourceFrom: 10, sourceTo: 20 },
|
||||
],
|
||||
"mysql",
|
||||
sql,
|
||||
),
|
||||
).toEqual([{ from: 0, status: "error", successCount: 1, errorCount: 1 }]);
|
||||
});
|
||||
|
||||
it("invalidates every marker after the editor document changes", () => {
|
||||
const executedSql = "SELECT 1;\nSELECT 2;";
|
||||
expect(statementExecutionMarkers(`-- edited\n${executedSql}`, [{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1, statement_index: 0, sourceStatement: "SELECT 1" }], "mysql", "stale-editor-fingerprint", executedSql)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { decodeTabResultSnapshot, encodeTabResultSnapshot } from "@/lib/tabs/tabResultCache";
|
||||
|
||||
describe("tab result cache statement execution metadata", () => {
|
||||
it("preserves statement identity and the editor fingerprint", () => {
|
||||
const encoded = encodeTabResultSnapshot({
|
||||
results: [
|
||||
{
|
||||
columns: ["value"],
|
||||
rows: [[1]],
|
||||
affected_rows: 0,
|
||||
execution_time_ms: 1,
|
||||
statement_index: 0,
|
||||
sourceStatement: "SELECT 1",
|
||||
},
|
||||
{
|
||||
columns: ["Error"],
|
||||
rows: [["failed"]],
|
||||
affected_rows: 0,
|
||||
execution_time_ms: 1,
|
||||
execution_error: true,
|
||||
statement_index: 1,
|
||||
sourceStatement: "SELECT bad",
|
||||
},
|
||||
],
|
||||
resultEditorFingerprint: "15:0123456789abcdef",
|
||||
cachedAt: 1,
|
||||
});
|
||||
|
||||
const restored = decodeTabResultSnapshot(encoded);
|
||||
|
||||
expect(restored?.resultEditorFingerprint).toBe("15:0123456789abcdef");
|
||||
expect(restored?.results?.map((result) => ({ statementIndex: result.statement_index, error: result.execution_error }))).toEqual([
|
||||
{ statementIndex: 0, error: undefined },
|
||||
{ statementIndex: 1, error: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -51,7 +51,10 @@ function forward<K extends keyof Backend>(name: K): Backend[K] {
|
|||
|
||||
// Connection
|
||||
export const testConnection = forward("testConnection");
|
||||
export const testConnectionWithInfo = forward("testConnectionWithInfo");
|
||||
export const connectDb = forward("connectDb");
|
||||
export const connectionDatabaseInfo = forward("connectionDatabaseInfo");
|
||||
export const saveConnectionDatabaseInfo = forward("saveConnectionDatabaseInfo");
|
||||
export const connectionFinalProxyPort = forward("connectionFinalProxyPort");
|
||||
export const disconnectDb = forward("disconnectDb");
|
||||
export const checkConnectionHealth = forward("checkConnectionHealth");
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import type {
|
||||
ConnectionConfig,
|
||||
ConnectionTestResult,
|
||||
DatabaseConnectionInfo,
|
||||
DatabaseInfo,
|
||||
SchemaInfo,
|
||||
LinkedServerInfo,
|
||||
|
|
@ -150,6 +152,7 @@ import type {
|
|||
NacosServiceQuery,
|
||||
} from "@/types/nacos";
|
||||
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
|
||||
import { normalizeConnectionTestResult } from "@/lib/connection/connectionDatabaseInfo";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
|
|
@ -209,10 +212,32 @@ export async function testConnection(config: ConnectionConfig): Promise<string>
|
|||
return post("/api/connection/test", { config });
|
||||
}
|
||||
|
||||
export async function testConnectionWithInfo(config: ConnectionConfig): Promise<ConnectionTestResult> {
|
||||
const response = await fetch(apiUrl("/api/connection/test-info"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ config }),
|
||||
});
|
||||
if (response.status === 404) {
|
||||
return normalizeConnectionTestResult(await testConnection(config), config);
|
||||
}
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
return normalizeConnectionTestResult(await response.json(), config);
|
||||
}
|
||||
|
||||
export async function connectDb(config: ConnectionConfig, clientAttempt?: number): Promise<string> {
|
||||
return post("/api/connection/connect", { config, clientAttempt });
|
||||
}
|
||||
|
||||
export async function connectionDatabaseInfo(connectionId: string, database?: string): Promise<DatabaseConnectionInfo | undefined> {
|
||||
const info = await post<DatabaseConnectionInfo | null>("/api/connection/database-info", { connectionId, database });
|
||||
return info ?? undefined;
|
||||
}
|
||||
|
||||
export async function saveConnectionDatabaseInfo(connectionId: string, databaseInfo: DatabaseConnectionInfo): Promise<void> {
|
||||
return post("/api/connection/database-info/save", { connectionId, databaseInfo });
|
||||
}
|
||||
|
||||
export async function connectionFinalProxyPort(config: ConnectionConfig): Promise<number> {
|
||||
return post("/api/connection/final-proxy-port", { config });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { invoke } from "@tauri-apps/api/core";
|
|||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import type {
|
||||
ConnectionConfig,
|
||||
ConnectionTestResult,
|
||||
DatabaseConnectionInfo,
|
||||
DatabaseInfo,
|
||||
SchemaInfo,
|
||||
LinkedServerInfo,
|
||||
|
|
@ -36,6 +38,7 @@ import type {
|
|||
SshConfigHostEntry,
|
||||
TunnelProfile,
|
||||
} from "@/types/database";
|
||||
import { isTauriCommandUnavailable, normalizeConnectionTestResult } from "@/lib/connection/connectionDatabaseInfo";
|
||||
import type { CollectionInfo } from "@/types/database";
|
||||
import type { SidebarObjectKind } from "@/lib/database/databaseObjectCapabilities";
|
||||
import type { AiConfig, AiTestConnectionResult } from "@/stores/settingsStore";
|
||||
|
|
@ -636,10 +639,29 @@ export async function testConnection(config: ConnectionConfig): Promise<string>
|
|||
return invoke("test_connection", { config });
|
||||
}
|
||||
|
||||
export async function testConnectionWithInfo(config: ConnectionConfig): Promise<ConnectionTestResult> {
|
||||
try {
|
||||
const result = await invoke<unknown>("test_connection_with_info", { config });
|
||||
return normalizeConnectionTestResult(result, config);
|
||||
} catch (error) {
|
||||
if (!isTauriCommandUnavailable(error, "test_connection_with_info")) throw error;
|
||||
return normalizeConnectionTestResult(await testConnection(config), config);
|
||||
}
|
||||
}
|
||||
|
||||
export async function connectDb(config: ConnectionConfig, clientAttempt?: number): Promise<string> {
|
||||
return invoke("connect_db", { config, clientAttempt });
|
||||
}
|
||||
|
||||
export async function connectionDatabaseInfo(connectionId: string, database?: string): Promise<DatabaseConnectionInfo | undefined> {
|
||||
const info = await invoke<DatabaseConnectionInfo | null>("connection_database_info", { connectionId, database });
|
||||
return info ?? undefined;
|
||||
}
|
||||
|
||||
export async function saveConnectionDatabaseInfo(connectionId: string, databaseInfo: DatabaseConnectionInfo): Promise<void> {
|
||||
return invoke("save_connection_database_info", { connectionId, databaseInfo });
|
||||
}
|
||||
|
||||
export async function connectionFinalProxyPort(config: ConnectionConfig): Promise<number> {
|
||||
return invoke("connection_final_proxy_port", { config });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
import { connectionConfigFingerprint, databaseInfoCopyText, databaseInfoRows, isTauriCommandUnavailable, normalizeConnectionTestResult } from "@/lib/connection/connectionDatabaseInfo";
|
||||
|
||||
function config(overrides: Partial<ConnectionConfig> = {}): ConnectionConfig {
|
||||
return {
|
||||
id: "connection-1",
|
||||
name: "Local H2",
|
||||
db_type: "h2",
|
||||
driver_label: "H2",
|
||||
host: "127.0.0.1",
|
||||
port: 9092,
|
||||
username: "sa",
|
||||
password: "secret",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("connectionDatabaseInfo", () => {
|
||||
it("normalizes structured and legacy responses with a configured product fallback", () => {
|
||||
expect(normalizeConnectionTestResult("Connection successful", config())).toEqual({
|
||||
message: "Connection successful",
|
||||
databaseInfo: { productName: "H2" },
|
||||
});
|
||||
expect(normalizeConnectionTestResult("Connection successful", config({ database: "app" })).databaseInfo).toMatchObject({
|
||||
productName: "H2",
|
||||
currentDatabase: "app",
|
||||
});
|
||||
expect(
|
||||
normalizeConnectionTestResult(
|
||||
{
|
||||
message: "Connection successful",
|
||||
databaseInfo: {
|
||||
productName: " H2 ",
|
||||
productVersion: " 2.2.224 ",
|
||||
currentDatabase: " testdb ",
|
||||
serverCharset: " utf8mb4 ",
|
||||
driverName: " ",
|
||||
unquotedIdentifierCase: "UPPER",
|
||||
},
|
||||
},
|
||||
config(),
|
||||
).databaseInfo,
|
||||
).toEqual({
|
||||
productName: "H2",
|
||||
productVersion: "2.2.224",
|
||||
currentDatabase: "testdb",
|
||||
serverComment: undefined,
|
||||
serverCharset: "utf8mb4",
|
||||
serverCollation: undefined,
|
||||
unquotedIdentifierCase: "upper",
|
||||
quotedIdentifierCase: undefined,
|
||||
driverName: undefined,
|
||||
driverVersion: undefined,
|
||||
jdbcVersion: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("fingerprints the complete submitted config without depending on object key order", () => {
|
||||
const original = config({ transport_layers: [{ id: "ssh", type: "ssh", host: "jump", port: 22, user: "root", password: "hop-secret" }] });
|
||||
const reordered = Object.fromEntries(Object.entries(original).reverse()) as unknown as ConnectionConfig;
|
||||
expect(connectionConfigFingerprint(reordered)).toBe(connectionConfigFingerprint(original));
|
||||
expect(connectionConfigFingerprint({ ...original, password: "changed" })).not.toBe(connectionConfigFingerprint(original));
|
||||
expect(connectionConfigFingerprint({ ...original, name: "Renamed" })).not.toBe(connectionConfigFingerprint(original));
|
||||
expect(connectionConfigFingerprint({ ...original, transport_layers: [{ ...original.transport_layers![0], host: "other-jump" }] })).not.toBe(connectionConfigFingerprint(original));
|
||||
expect(connectionConfigFingerprint(original, "")).not.toBe(connectionConfigFingerprint(original, original.name));
|
||||
expect(connectionConfigFingerprint({ ...original, database_info: { productName: "MySQL", productVersion: "8.4.0" } })).toBe(connectionConfigFingerprint(original));
|
||||
});
|
||||
|
||||
it("formats only database metadata for rows and copied text", () => {
|
||||
const info = normalizeConnectionTestResult(
|
||||
{
|
||||
message: "ok",
|
||||
databaseInfo: { productName: "H2", currentDatabase: "app", driverName: "H2 JDBC Driver", jdbcVersion: "4.2" },
|
||||
},
|
||||
config(),
|
||||
).databaseInfo!;
|
||||
const copy = databaseInfoCopyText(
|
||||
info,
|
||||
(field) => field,
|
||||
(value) => value,
|
||||
);
|
||||
|
||||
expect(databaseInfoRows(info).map((row) => row.key)).toEqual(["productName", "currentDatabase", "driverName", "jdbcVersion"]);
|
||||
expect(copy).toContain("productName: H2");
|
||||
expect(copy).toContain("currentDatabase: app");
|
||||
expect(copy).not.toContain("127.0.0.1");
|
||||
expect(copy).not.toContain("secret");
|
||||
expect(copy).not.toContain("connection_string");
|
||||
});
|
||||
|
||||
it("recognizes only explicit missing-command errors for Tauri fallback", () => {
|
||||
expect(isTauriCommandUnavailable("Command test_connection_with_info not found", "test_connection_with_info")).toBe(true);
|
||||
expect(isTauriCommandUnavailable("unknown command 'test_connection_with_info'", "test_connection_with_info")).toBe(true);
|
||||
expect(isTauriCommandUnavailable("Authentication failed", "test_connection_with_info")).toBe(false);
|
||||
expect(isTauriCommandUnavailable("Command test_connection not found", "test_connection_with_info")).toBe(false);
|
||||
expect(isTauriCommandUnavailable("Database not found while invoking command test_connection_with_info", "test_connection_with_info")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
import type { ConnectionConfig, ConnectionTestResult, DatabaseConnectionInfo, IdentifierCase } from "@/types/database";
|
||||
|
||||
export type DatabaseInfoField = keyof DatabaseConnectionInfo;
|
||||
|
||||
export interface DatabaseInfoRow {
|
||||
key: DatabaseInfoField;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const DATABASE_INFO_FIELDS: readonly DatabaseInfoField[] = ["productName", "productVersion", "currentDatabase", "serverComment", "serverCharset", "serverCollation", "unquotedIdentifierCase", "quotedIdentifierCase", "driverName", "driverVersion", "jdbcVersion"];
|
||||
const IDENTIFIER_CASES = new Set<IdentifierCase>(["lower", "upper", "mixed"]);
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function nonBlankString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function identifierCase(value: unknown): IdentifierCase | undefined {
|
||||
const normalized = nonBlankString(value)?.toLowerCase() as IdentifierCase | undefined;
|
||||
return normalized && IDENTIFIER_CASES.has(normalized) ? normalized : undefined;
|
||||
}
|
||||
|
||||
export function configuredDatabaseProductName(config: Pick<ConnectionConfig, "db_type" | "driver_label">): string {
|
||||
return config.driver_label?.trim() || config.db_type;
|
||||
}
|
||||
|
||||
export function normalizeDatabaseConnectionInfo(value: unknown, fallbackProductName?: string, fallbackCurrentDatabase?: string): DatabaseConnectionInfo | undefined {
|
||||
const source = isRecord(value) ? value : {};
|
||||
const result: DatabaseConnectionInfo = {
|
||||
productName: nonBlankString(source.productName) ?? nonBlankString(fallbackProductName),
|
||||
productVersion: nonBlankString(source.productVersion),
|
||||
currentDatabase: nonBlankString(source.currentDatabase) ?? nonBlankString(fallbackCurrentDatabase),
|
||||
serverComment: nonBlankString(source.serverComment),
|
||||
serverCharset: nonBlankString(source.serverCharset),
|
||||
serverCollation: nonBlankString(source.serverCollation),
|
||||
unquotedIdentifierCase: identifierCase(source.unquotedIdentifierCase),
|
||||
quotedIdentifierCase: identifierCase(source.quotedIdentifierCase),
|
||||
driverName: nonBlankString(source.driverName),
|
||||
driverVersion: nonBlankString(source.driverVersion),
|
||||
jdbcVersion: nonBlankString(source.jdbcVersion),
|
||||
};
|
||||
return DATABASE_INFO_FIELDS.some((key) => result[key] !== undefined) ? result : undefined;
|
||||
}
|
||||
|
||||
export function normalizeConnectionTestResult(value: unknown, config: ConnectionConfig): ConnectionTestResult {
|
||||
const fallbackProductName = configuredDatabaseProductName(config);
|
||||
const fallbackCurrentDatabase = config.database;
|
||||
if (typeof value === "string") {
|
||||
return {
|
||||
message: value,
|
||||
databaseInfo: normalizeDatabaseConnectionInfo(undefined, fallbackProductName, fallbackCurrentDatabase),
|
||||
};
|
||||
}
|
||||
if (!isRecord(value) || typeof value.message !== "string") {
|
||||
throw new Error("Invalid connection test response");
|
||||
}
|
||||
return {
|
||||
message: value.message,
|
||||
databaseInfo: normalizeDatabaseConnectionInfo(value.databaseInfo, fallbackProductName, fallbackCurrentDatabase),
|
||||
};
|
||||
}
|
||||
|
||||
function stableValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(stableValue);
|
||||
if (!isRecord(value)) return value;
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(value).sort()) {
|
||||
const child = value[key];
|
||||
if (child !== undefined) result[key] = stableValue(child);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function fnv1a(value: string, seed: number): number {
|
||||
let hash = seed >>> 0;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash ^= value.charCodeAt(index);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
export function connectionConfigFingerprint(config: ConnectionConfig, sourceName = config.name): string {
|
||||
const { database_info: _databaseInfo, ...submittedConfig } = config;
|
||||
const serialized = JSON.stringify(stableValue({ config: submittedConfig, sourceName }));
|
||||
const first = fnv1a(serialized, 0x811c9dc5).toString(16).padStart(8, "0");
|
||||
const second = fnv1a(serialized, 0x9e3779b9).toString(16).padStart(8, "0");
|
||||
return `${first}${second}`;
|
||||
}
|
||||
|
||||
export function databaseInfoRows(info: DatabaseConnectionInfo): DatabaseInfoRow[] {
|
||||
return DATABASE_INFO_FIELDS.flatMap((key) => {
|
||||
const value = info[key];
|
||||
return value ? [{ key, value }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
export function databaseInfoSummary(info: DatabaseConnectionInfo): string {
|
||||
return [info.productName, info.productVersion].filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export function databaseInfoCopyText(info: DatabaseConnectionInfo, fieldLabel: (field: DatabaseInfoField) => string, caseLabel: (value: IdentifierCase) => string): string {
|
||||
return databaseInfoRows(info)
|
||||
.map((row) => {
|
||||
const value = row.key === "unquotedIdentifierCase" || row.key === "quotedIdentifierCase" ? caseLabel(row.value as IdentifierCase) : row.value;
|
||||
return `${fieldLabel(row.key)}: ${value}`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function isTauriCommandUnavailable(error: unknown, command: string): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const escapedCommand = command.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return new RegExp(`unknown command\\s*[:=]?\\s*['"]?${escapedCommand}['"]?`, "i").test(message) || new RegExp(`command\\s+['"]?${escapedCommand}['"]?\\s+(?:was\\s+)?not found`, "i").test(message);
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
function fnv1a(value: string, seed: number): number {
|
||||
let hash = seed >>> 0;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash ^= value.charCodeAt(index);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
export function sqlTextFingerprint(sql: string): string {
|
||||
const first = fnv1a(sql, 0x811c9dc5).toString(16).padStart(8, "0");
|
||||
const second = fnv1a(sql, 0x9e3779b9).toString(16).padStart(8, "0");
|
||||
return `${sql.length.toString(16)}:${first}${second}`;
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { useSettingsStore } from "@/stores/settingsStore";
|
|||
import { findConnectionGroupPath } from "@/lib/sidebar/sidebarLayout";
|
||||
import { splitMongoCommandRanges } from "@/lib/mongo/mongoShellCommand";
|
||||
import { executableStatementRanges, splitSqlStatementRanges, type SqlTextRange } from "@/lib/sql/sqlStatementRanges";
|
||||
import { sqlTextFingerprint } from "@/lib/sql/sqlTextFingerprint";
|
||||
import type { ConnectionConfig, DatabaseType, QueryResult, QueryTab } from "@/types/database";
|
||||
|
||||
type Translate = (key: string, params?: Record<string, unknown>) => string;
|
||||
|
|
@ -179,7 +180,7 @@ export function resultSourceRange(editorSql: string, result: Pick<QueryResult, "
|
|||
return { from: result.sourceFrom, to: result.sourceTo, sql: sourceStatement };
|
||||
}
|
||||
|
||||
const statements = databaseType === "redis" ? executableStatementRanges(editorSql, databaseType) : databaseType === "mongodb" ? splitMongoCommandRanges(editorSql).map(({ from, to, text }) => ({ from, to, sql: text })) : splitSqlStatementRanges(editorSql, databaseType);
|
||||
const statements = statementRanges(editorSql, databaseType);
|
||||
const indexed = typeof resultIndex === "number" ? statements[resultIndex] : undefined;
|
||||
if (indexed?.sql === sourceStatement) {
|
||||
return { from: indexed.from, to: indexed.to, sql: indexed.sql };
|
||||
|
|
@ -191,6 +192,63 @@ export function resultSourceRange(editorSql: string, result: Pick<QueryResult, "
|
|||
return { from: match.from, to: match.to, sql: match.sql };
|
||||
}
|
||||
|
||||
export type StatementExecutionMarkerStatus = "success" | "error";
|
||||
|
||||
export interface StatementExecutionMarker {
|
||||
from: number;
|
||||
status: StatementExecutionMarkerStatus;
|
||||
successCount: number;
|
||||
errorCount: number;
|
||||
}
|
||||
|
||||
function lineStartOffset(sql: string, from: number): number {
|
||||
return sql.lastIndexOf("\n", Math.max(0, from - 1)) + 1;
|
||||
}
|
||||
|
||||
function statementRanges(sql: string, databaseType?: DatabaseType): SqlTextRange[] {
|
||||
if (databaseType === "redis") return executableStatementRanges(sql, databaseType);
|
||||
if (databaseType === "mongodb") return splitMongoCommandRanges(sql).map(({ from, to, text }) => ({ from, to, sql: text }));
|
||||
return splitSqlStatementRanges(sql, databaseType);
|
||||
}
|
||||
|
||||
export function statementExecutionMarkers(editorSql: string, results: QueryResult[] | undefined, databaseType?: DatabaseType, submittedSql = editorSql, executionEditorFingerprint = sqlTextFingerprint(editorSql)): StatementExecutionMarker[] {
|
||||
if (!results?.length || sqlTextFingerprint(editorSql) !== executionEditorFingerprint) return [];
|
||||
const submittedStatements = statementRanges(submittedSql, databaseType);
|
||||
if (submittedStatements.length <= 1) return [];
|
||||
const editorStatements = submittedSql === editorSql ? submittedStatements : statementRanges(editorSql, databaseType);
|
||||
|
||||
const byLine = new Map<number, { success: number; error: number }>();
|
||||
for (const result of results) {
|
||||
if (!Number.isInteger(result.statement_index) || result.statement_index! < 0) continue;
|
||||
const statementIndex = result.statement_index!;
|
||||
const submittedStatement = submittedStatements[statementIndex];
|
||||
if (!submittedStatement || submittedStatement.sql !== result.sourceStatement) continue;
|
||||
const range =
|
||||
typeof result.sourceFrom === "number" && typeof result.sourceTo === "number" && editorSql.slice(result.sourceFrom, result.sourceTo) === result.sourceStatement
|
||||
? { from: result.sourceFrom, to: result.sourceTo, sql: result.sourceStatement }
|
||||
: editorStatements[statementIndex]?.sql === result.sourceStatement
|
||||
? editorStatements[statementIndex]
|
||||
: undefined;
|
||||
if (!range) continue;
|
||||
const from = lineStartOffset(editorSql, range.from);
|
||||
const current = byLine.get(from) ?? { success: 0, error: 0 };
|
||||
if (result.execution_error === true) current.error += 1;
|
||||
else current.success += 1;
|
||||
byLine.set(from, current);
|
||||
}
|
||||
|
||||
return [...byLine.entries()]
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([from, counts]) => {
|
||||
return {
|
||||
from,
|
||||
status: counts.error > 0 ? "error" : "success",
|
||||
successCount: counts.success,
|
||||
errorCount: counts.error,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function queryResultBaseSql(tab: Pick<QueryTab, "result" | "resultBaseSql" | "lastExecutedSql" | "sql">): string {
|
||||
return resultSqlForGrid(tab);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export interface TabResultSnapshot {
|
|||
result?: QueryResult;
|
||||
results?: QueryResult[];
|
||||
activeResultIndex?: number;
|
||||
resultEditorFingerprint?: string;
|
||||
/**
|
||||
* Source ordering retained while a local grid sort is active. It must travel
|
||||
* with the snapshot so clearing the sort after a cache/archive restore can
|
||||
|
|
@ -41,6 +42,7 @@ export interface TabResultSnapshot {
|
|||
interface ColumnarQueryResult {
|
||||
columns: string[];
|
||||
execution_error?: true;
|
||||
statement_index?: number;
|
||||
column_types?: string[];
|
||||
columnValues: CellValue[][];
|
||||
rowCount: number;
|
||||
|
|
@ -138,6 +140,7 @@ function stripSessionIds(result: QueryResult | undefined): QueryResult | undefin
|
|||
return {
|
||||
columns: [...result.columns],
|
||||
execution_error: result.execution_error,
|
||||
statement_index: result.statement_index,
|
||||
column_types: result.column_types ? [...result.column_types] : undefined,
|
||||
rows: result.rows.map((row) => [...row]),
|
||||
mongo_documents: result.mongo_documents ? clonePlain(result.mongo_documents) : undefined,
|
||||
|
|
@ -174,6 +177,7 @@ function toColumnarResult(result: QueryResult | undefined): ColumnarQueryResult
|
|||
return removeUndefinedFields({
|
||||
columns: [...result.columns],
|
||||
execution_error: result.execution_error,
|
||||
statement_index: result.statement_index,
|
||||
column_types: result.column_types ? [...result.column_types] : undefined,
|
||||
columnValues,
|
||||
rowCount: result.rows.length,
|
||||
|
|
@ -195,6 +199,7 @@ function fromColumnarResult(result: ColumnarQueryResult | undefined): QueryResul
|
|||
return {
|
||||
columns: [...result.columns],
|
||||
execution_error: result.execution_error,
|
||||
statement_index: result.statement_index,
|
||||
column_types: result.column_types ? [...result.column_types] : undefined,
|
||||
rows,
|
||||
mongo_documents: result.mongo_documents ? clonePlain(result.mongo_documents) : undefined,
|
||||
|
|
@ -389,6 +394,7 @@ export function buildTabResultSnapshot(tab: QueryTab): TabResultSnapshot | undef
|
|||
result: stripSessionIds(tab.result),
|
||||
results: stripResultSessionIds(tab.results),
|
||||
activeResultIndex: tab.activeResultIndex,
|
||||
resultEditorFingerprint: tab.resultEditorFingerprint,
|
||||
resultLocalSortOriginalRows: tab.resultLocalSortOriginalRows?.map((row) => [...row]),
|
||||
resultLocalSortOriginalMongoDocuments: tab.resultLocalSortOriginalMongoDocuments ? clonePlain(tab.resultLocalSortOriginalMongoDocuments) : undefined,
|
||||
resultRuns: stripResultRunSessionIds(tab.resultRuns),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
|
||||
function installLocalStorage() {
|
||||
const data = new Map<string, string>();
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: vi.fn((key: string) => data.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => data.set(key, value)),
|
||||
removeItem: vi.fn((key: string) => data.delete(key)),
|
||||
});
|
||||
}
|
||||
|
||||
function mysqlConnection(): ConnectionConfig {
|
||||
return {
|
||||
id: "mysql-info",
|
||||
name: "MySQL",
|
||||
db_type: "mysql",
|
||||
driver_profile: "mysql",
|
||||
driver_label: "MySQL",
|
||||
host: "127.0.0.1",
|
||||
port: 3306,
|
||||
username: "root",
|
||||
password: "secret",
|
||||
database: "app",
|
||||
};
|
||||
}
|
||||
|
||||
describe("connectionStore database info", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.unstubAllGlobals();
|
||||
installLocalStorage();
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
it("persists metadata from a successful live connection without marking it disconnected", async () => {
|
||||
const config = mysqlConnection();
|
||||
const saveConnections = vi.fn().mockResolvedValue(undefined);
|
||||
const saveConnectionDatabaseInfo = vi.fn().mockResolvedValue(undefined);
|
||||
const connectionDatabaseInfo = vi.fn().mockResolvedValue({ productName: "MySQL", productVersion: "8.0.34" });
|
||||
|
||||
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
vi.doMock("@/lib/backend/api", () => ({
|
||||
connectDb: vi.fn().mockResolvedValue(config.id),
|
||||
connectionDatabaseInfo,
|
||||
saveConnectionDatabaseInfo,
|
||||
saveConnections,
|
||||
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
|
||||
connectionIdentifierQuote: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||
const store = useConnectionStore();
|
||||
await store.addConnection(config);
|
||||
await store.connect(config);
|
||||
|
||||
await vi.waitFor(() => expect(saveConnectionDatabaseInfo).toHaveBeenCalled());
|
||||
expect(connectionDatabaseInfo).toHaveBeenCalledWith(config.id);
|
||||
expect(saveConnectionDatabaseInfo).toHaveBeenCalledWith(config.id, {
|
||||
productName: "MySQL",
|
||||
productVersion: "8.0.34",
|
||||
currentDatabase: "app",
|
||||
serverComment: undefined,
|
||||
serverCharset: undefined,
|
||||
serverCollation: undefined,
|
||||
unquotedIdentifierCase: undefined,
|
||||
quotedIdentifierCase: undefined,
|
||||
driverName: undefined,
|
||||
driverVersion: undefined,
|
||||
jdbcVersion: undefined,
|
||||
});
|
||||
expect(store.getConfig(config.id)?.database_info?.productVersion).toBe("8.0.34");
|
||||
expect(store.connectedIds.has(config.id)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not delay connection success while optional metadata is loading", async () => {
|
||||
const config = mysqlConnection();
|
||||
let resolveDatabaseInfo!: (value: { productName: string; productVersion: string }) => void;
|
||||
const databaseInfo = new Promise<{ productName: string; productVersion: string }>((resolve) => {
|
||||
resolveDatabaseInfo = resolve;
|
||||
});
|
||||
|
||||
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
vi.doMock("@/lib/backend/api", () => ({
|
||||
connectDb: vi.fn().mockResolvedValue(config.id),
|
||||
connectionDatabaseInfo: vi.fn(() => databaseInfo),
|
||||
saveConnectionDatabaseInfo: vi.fn().mockResolvedValue(undefined),
|
||||
saveConnections: vi.fn().mockResolvedValue(undefined),
|
||||
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
|
||||
connectionIdentifierQuote: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||
const store = useConnectionStore();
|
||||
await store.addConnection(config);
|
||||
await expect(store.connect(config)).resolves.toBe(config.id);
|
||||
expect(store.connectedIds.has(config.id)).toBe(true);
|
||||
|
||||
resolveDatabaseInfo({ productName: "MySQL", productVersion: "8.0.34" });
|
||||
await vi.waitFor(() => expect(store.getConfig(config.id)?.database_info?.productVersion).toBe("8.0.34"));
|
||||
});
|
||||
|
||||
it("keeps a successful connection when optional metadata refresh fails", async () => {
|
||||
const config = mysqlConnection();
|
||||
|
||||
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
vi.doMock("@/lib/backend/api", () => ({
|
||||
connectDb: vi.fn().mockResolvedValue(config.id),
|
||||
connectionDatabaseInfo: vi.fn().mockRejectedValue(new Error("metadata unavailable")),
|
||||
saveConnections: vi.fn().mockResolvedValue(undefined),
|
||||
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
|
||||
connectionIdentifierQuote: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||
const store = useConnectionStore();
|
||||
await store.addConnection(config);
|
||||
await expect(store.connect(config)).resolves.toBe(config.id);
|
||||
expect(store.connectedIds.has(config.id)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -99,6 +99,34 @@ describe("queryStore multi-statement errors", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("uses explicit statement indexes for selected multi-statement ranges", async () => {
|
||||
mocks.executeMulti.mockResolvedValue([
|
||||
{ columns: ["value"], rows: [[2]], affected_rows: 0, execution_time_ms: 1, statement_index: 1 },
|
||||
{ columns: ["Error"], rows: [["failed"]], affected_rows: 0, execution_time_ms: 1, execution_error: true, statement_index: 2 },
|
||||
]);
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const tabId = store.createTab("mysql-1", "app", "Query");
|
||||
const selectedSql = "SELECT 1; SELECT 2; SELECT bad";
|
||||
|
||||
await store.executeTabSql(tabId, selectedSql, { sourceOffset: 10 });
|
||||
|
||||
const tab = store.tabs.find((item) => item.id === tabId)!;
|
||||
expect(tab.results?.[0]).toMatchObject({
|
||||
sourceStatement: "SELECT 2",
|
||||
sourceFrom: 20,
|
||||
sourceTo: 28,
|
||||
statement_index: 1,
|
||||
});
|
||||
expect(tab.results?.[1]).toMatchObject({
|
||||
sourceStatement: "SELECT bad",
|
||||
sourceFrom: 30,
|
||||
sourceTo: 40,
|
||||
statement_index: 2,
|
||||
execution_error: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not promote an unmarked Error alias without type metadata as a batch failure", async () => {
|
||||
mocks.executeMulti.mockResolvedValue([
|
||||
{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 },
|
||||
|
|
|
|||
|
|
@ -1,7 +1,23 @@
|
|||
import { defineStore } from "pinia";
|
||||
import { uuid } from "@/lib/common/utils";
|
||||
import { ref, computed, watch, markRaw } from "vue";
|
||||
import type { ColumnInfo, CompletionAssistantCandidate, CompletionAssistantObjectKind, CompletionAssistantRequest, ConnectionConfig, CatalogInfo, ForeignKeyInfo, ObjectInfo, SchemaInfo, SidebarLayout, TableInfo, TreeNode, TunnelProfile, VectorCollectionMeta } from "@/types/database";
|
||||
import type {
|
||||
ColumnInfo,
|
||||
CompletionAssistantCandidate,
|
||||
CompletionAssistantObjectKind,
|
||||
CompletionAssistantRequest,
|
||||
ConnectionConfig,
|
||||
DatabaseConnectionInfo,
|
||||
CatalogInfo,
|
||||
ForeignKeyInfo,
|
||||
ObjectInfo,
|
||||
SchemaInfo,
|
||||
SidebarLayout,
|
||||
TableInfo,
|
||||
TreeNode,
|
||||
TunnelProfile,
|
||||
VectorCollectionMeta,
|
||||
} from "@/types/database";
|
||||
import { applyPinnedTreeNodeState, inheritNaturalTreeNodeOrder, migrateLegacyPinnedTreeNodeIds, syncPinnedTreeNodeStateInPlace, treeNodePinKey } from "@/lib/app/pinnedItems";
|
||||
import {
|
||||
reconcileLayout,
|
||||
|
|
@ -67,6 +83,7 @@ import { REDIS_SCAN_PAGE_SIZE_DEFAULT } from "@/lib/redis/redisKeyPattern";
|
|||
import { appendAgentDriverUpdateHint, hasAgentDriverUpdate, type AgentDriverInstallState } from "@/lib/connection/agentDriverInstallHint";
|
||||
import { appendConnectionErrorHints } from "@/lib/connection/connectionErrorHints";
|
||||
import { appendVisibleDatabaseSelection } from "@/lib/connection/connectionVisibleDatabases";
|
||||
import { configuredDatabaseProductName, connectionConfigFingerprint, normalizeDatabaseConnectionInfo } from "@/lib/connection/connectionDatabaseInfo";
|
||||
import { createMetadataLoadTrace, logMetadataLoadTrace, MetadataLoadCoordinator, type MetadataLoadTraceLogger } from "@/lib/metadata/metadataLoadCoordinator";
|
||||
import type { MetadataScopeInput } from "@/lib/metadata/metadataLoadScope";
|
||||
import { MetadataResultCache, type MetadataCacheInvalidation } from "@/lib/metadata/metadataResultCache";
|
||||
|
|
@ -872,6 +889,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
query_timeout_secs: config.query_timeout_secs ?? 30,
|
||||
idle_timeout_secs: config.idle_timeout_secs ?? 60,
|
||||
keepalive_interval_secs: config.keepalive_interval_secs ?? DEFAULT_KEEPALIVE_INTERVAL_SECS,
|
||||
database_info: normalizeDatabaseConnectionInfo(config.database_info),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1767,6 +1785,35 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
}
|
||||
}
|
||||
|
||||
async function updateConnectionDatabaseInfo(connectionId: string, databaseInfo: DatabaseConnectionInfo, expectedConfigFingerprint?: string): Promise<void> {
|
||||
const normalized = normalizeDatabaseConnectionInfo(databaseInfo);
|
||||
if (!normalized) return;
|
||||
const current = connections.value.find((connection) => connection.id === connectionId);
|
||||
if (!current) return;
|
||||
if (expectedConfigFingerprint && connectionConfigFingerprint(current) !== expectedConfigFingerprint) return;
|
||||
if (JSON.stringify(current.database_info) === JSON.stringify(normalized)) return;
|
||||
|
||||
await api.saveConnectionDatabaseInfo(connectionId, normalized);
|
||||
const index = connections.value.findIndex((connection) => connection.id === connectionId);
|
||||
if (index < 0) return;
|
||||
if (expectedConfigFingerprint && connectionConfigFingerprint(connections.value[index]) !== expectedConfigFingerprint) return;
|
||||
const nextConnections = [...connections.value];
|
||||
nextConnections[index] = { ...nextConnections[index], database_info: normalized };
|
||||
connections.value = nextConnections;
|
||||
rebuildTreeNodes();
|
||||
}
|
||||
|
||||
async function refreshConnectedDatabaseInfo(connectionId: string, config: ConnectionConfig): Promise<void> {
|
||||
const expectedConfigFingerprint = connectionConfigFingerprint(config);
|
||||
try {
|
||||
const detected = await api.connectionDatabaseInfo(connectionId);
|
||||
const normalized = normalizeDatabaseConnectionInfo(detected, configuredDatabaseProductName(config), config.database);
|
||||
if (normalized) await updateConnectionDatabaseInfo(connectionId, normalized, expectedConfigFingerprint);
|
||||
} catch {
|
||||
// Database metadata is optional and must not turn a successful connection into a failure.
|
||||
}
|
||||
}
|
||||
|
||||
async function syncMongoLegacyDriverFallback(connectionId: string, previousConfig: ConnectionConfig) {
|
||||
if (!isDesktop || previousConfig.db_type !== "mongodb" || previousConfig.driver_profile === MONGO_LEGACY_DRIVER_PROFILE) {
|
||||
return;
|
||||
|
|
@ -1951,6 +1998,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
await ensureLocalConnectionAttemptActiveAfterConnectResult(config.id, localAttempt, id);
|
||||
activeConnectionId.value = id;
|
||||
connectedIds.value.add(id);
|
||||
void refreshConnectedDatabaseInfo(id, { ...config, id });
|
||||
await refreshConnectionIdentifierQuote(id, { ...config, id });
|
||||
if (id !== config.id) markSuccessfulLocalConnectionAttempt(config.id, localAttempt);
|
||||
markSuccessfulLocalConnectionAttempt(id, localAttempt);
|
||||
|
|
@ -2115,6 +2163,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
await syncMongoLegacyDriverFallback(connectionId, config);
|
||||
await ensureLocalConnectionAttemptActiveAfterConnectResult(connectionId, localAttempt, id);
|
||||
connectedIds.value.add(connectionId);
|
||||
void refreshConnectedDatabaseInfo(connectionId, config);
|
||||
await refreshConnectionIdentifierQuote(connectionId, config);
|
||||
markSuccessfulLocalConnectionAttempt(connectionId, localAttempt);
|
||||
markConnectionHealthChecked(connectionId);
|
||||
|
|
@ -5278,6 +5327,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
pasteConnectionClipboard,
|
||||
addEphemeralConnection,
|
||||
updateConnection,
|
||||
updateConnectionDatabaseInfo,
|
||||
setDefaultDatabase,
|
||||
clearDefaultDatabase,
|
||||
isDefaultDatabase,
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ import { useSavedSqlStore } from "@/stores/savedSqlStore";
|
|||
import { createSavedSqlEditorPosition, initSavedSqlEditorPositions, restoreSavedSqlEditorPosition, saveSavedSqlEditorPosition } from "@/lib/app/savedSqlEditorPosition";
|
||||
import { ensureSqlExtension } from "@/lib/savedSql/savedSqlFileName";
|
||||
import { safeLocalStorageGet, safeLocalStorageRemove } from "@/lib/backend/safeStorage";
|
||||
import { sqlTextFingerprint } from "@/lib/sql/sqlTextFingerprint";
|
||||
import type { SavedSqlFile } from "@/types/database";
|
||||
|
||||
const ORACLE_LIKE_METADATA_TYPES = new Set<string>(["oracle", "dameng", "oceanbase-oracle"]);
|
||||
|
|
@ -143,7 +144,10 @@ function annotateQueryResultSources(results: QueryResult[], sql: string, databas
|
|||
const statements = splitSqlStatementRanges(sql, databaseType);
|
||||
let statementIndex = 0;
|
||||
for (const result of results) {
|
||||
const statement = statements[statementIndex++];
|
||||
const explicitIndex = Number.isInteger(result.statement_index) && result.statement_index! >= 0 ? result.statement_index : undefined;
|
||||
const sourceIndex = explicitIndex ?? statementIndex;
|
||||
statementIndex = Math.max(statementIndex, sourceIndex + 1);
|
||||
const statement = statements[sourceIndex];
|
||||
if (!statement) continue;
|
||||
annotateQueryResultSource(result, statement.sql, database, databaseType, sourceOffset === undefined ? undefined : { from: sourceOffset + statement.from, to: sourceOffset + statement.to });
|
||||
}
|
||||
|
|
@ -454,6 +458,7 @@ export const useQueryStore = defineStore("query", () => {
|
|||
tab.result = undefined;
|
||||
tab.results = undefined;
|
||||
tab.activeResultIndex = undefined;
|
||||
tab.resultEditorFingerprint = undefined;
|
||||
tab.resultLocalSortOriginalRows = undefined;
|
||||
tab.resultLocalSortOriginalMongoDocuments = undefined;
|
||||
tab.resultSortMode = undefined;
|
||||
|
|
@ -500,6 +505,7 @@ export const useQueryStore = defineStore("query", () => {
|
|||
tab.results = run.results;
|
||||
tab.activeResultIndex = run.activeResultIndex;
|
||||
tab.resultBaseSql = run.resultBaseSql;
|
||||
tab.resultEditorFingerprint = run.resultEditorFingerprint;
|
||||
tab.resultSortedSql = run.resultSortedSql;
|
||||
tab.resultSortColumn = run.resultSortColumn;
|
||||
tab.resultSortColumnIndex = run.resultSortColumnIndex;
|
||||
|
|
@ -598,6 +604,7 @@ export const useQueryStore = defineStore("query", () => {
|
|||
result: run.result,
|
||||
results: run.results,
|
||||
activeResultIndex: run.activeResultIndex,
|
||||
resultEditorFingerprint: run.resultEditorFingerprint,
|
||||
resultRuns: [run],
|
||||
activeResultRunId: run.id,
|
||||
queryAnalysis: run.queryAnalysis,
|
||||
|
|
@ -642,6 +649,7 @@ export const useQueryStore = defineStore("query", () => {
|
|||
results: tab.results,
|
||||
activeResultIndex: tab.activeResultIndex,
|
||||
resultBaseSql: tab.resultBaseSql,
|
||||
resultEditorFingerprint: tab.resultEditorFingerprint,
|
||||
resultSortedSql: tab.resultSortedSql,
|
||||
resultSortColumn: tab.resultSortColumn,
|
||||
resultSortColumnIndex: tab.resultSortColumnIndex,
|
||||
|
|
@ -693,6 +701,7 @@ export const useQueryStore = defineStore("query", () => {
|
|||
results: tab.results,
|
||||
activeResultIndex: tab.activeResultIndex,
|
||||
resultBaseSql: tab.resultBaseSql,
|
||||
resultEditorFingerprint: tab.resultEditorFingerprint,
|
||||
resultSortedSql: tab.resultSortedSql,
|
||||
resultSortColumn: tab.resultSortColumn,
|
||||
resultSortColumnIndex: tab.resultSortColumnIndex,
|
||||
|
|
@ -2597,6 +2606,7 @@ export const useQueryStore = defineStore("query", () => {
|
|||
if (!tab || !sql.trim()) return;
|
||||
|
||||
const executionId = uuid();
|
||||
const executionEditorFingerprint = tab.mode === "query" ? sqlTextFingerprint(tab.sql) : undefined;
|
||||
const traceId = executionId.slice(0, 8);
|
||||
const startedAt = performance.now();
|
||||
const elapsed = () => `${Math.round(performance.now() - startedAt)}ms`;
|
||||
|
|
@ -3133,6 +3143,7 @@ export const useQueryStore = defineStore("query", () => {
|
|||
current.result = results[0];
|
||||
}
|
||||
current.resultBaseSql = shouldReplaceActiveResultInGroup ? (current.resultBaseSql ?? queryBaseSql) : queryBaseSql;
|
||||
current.resultEditorFingerprint = shouldReplaceActiveResultInGroup ? (current.resultEditorFingerprint ?? executionEditorFingerprint) : executionEditorFingerprint;
|
||||
current.resultSortedSql = resultSortedSql;
|
||||
current.resultPageSql = pageSql;
|
||||
current.resultPageLimit = pageLimit;
|
||||
|
|
@ -3644,6 +3655,7 @@ export const useQueryStore = defineStore("query", () => {
|
|||
const activeIndex = snapshot.activeResultIndex ?? 0;
|
||||
tab.results = results;
|
||||
tab.activeResultIndex = snapshot.activeResultIndex;
|
||||
tab.resultEditorFingerprint = snapshot.resultEditorFingerprint;
|
||||
tab.result = snapshot.result ? markQueryResultRowsRaw(snapshot.result) : results?.[activeIndex] ? markQueryResultRowsRaw(results[activeIndex]) : undefined;
|
||||
tab.resultLocalSortOriginalRows = snapshot.resultLocalSortOriginalRows ? markRaw(snapshot.resultLocalSortOriginalRows) : undefined;
|
||||
tab.resultLocalSortOriginalMongoDocuments = snapshot.resultLocalSortOriginalMongoDocuments ? markRaw(snapshot.resultLocalSortOriginalMongoDocuments) : undefined;
|
||||
|
|
|
|||
|
|
@ -163,6 +163,29 @@ export interface ConnectionConfig {
|
|||
is_production?: boolean;
|
||||
/** Database-level production markers for multi-database connections. */
|
||||
production_databases?: string[];
|
||||
/** Metadata captured from the latest successful connection test for the saved config. */
|
||||
database_info?: DatabaseConnectionInfo;
|
||||
}
|
||||
|
||||
export type IdentifierCase = "lower" | "upper" | "mixed";
|
||||
|
||||
export interface DatabaseConnectionInfo {
|
||||
productName?: string;
|
||||
productVersion?: string;
|
||||
currentDatabase?: string;
|
||||
serverComment?: string;
|
||||
serverCharset?: string;
|
||||
serverCollation?: string;
|
||||
unquotedIdentifierCase?: IdentifierCase;
|
||||
quotedIdentifierCase?: IdentifierCase;
|
||||
driverName?: string;
|
||||
driverVersion?: string;
|
||||
jdbcVersion?: string;
|
||||
}
|
||||
|
||||
export interface ConnectionTestResult {
|
||||
message: string;
|
||||
databaseInfo?: DatabaseConnectionInfo;
|
||||
}
|
||||
|
||||
export type TransportLayerConfig = ({ type: "ssh" } & SshTunnelConfig) | ({ type: "proxy" } & ProxyTunnelConfig) | ({ type: "http_tunnel" } & HttpTunnelConfig);
|
||||
|
|
@ -471,6 +494,8 @@ export interface QueryResult {
|
|||
columns: string[];
|
||||
/** Set for synthesized query execution failures. */
|
||||
execution_error?: true;
|
||||
/** Zero-based index of the submitted statement that produced this result. */
|
||||
statement_index?: number;
|
||||
/** Internal row identifiers appended to editable query results. */
|
||||
hidden_column_indexes?: number[];
|
||||
/**
|
||||
|
|
@ -512,6 +537,8 @@ export interface QueryResultRun {
|
|||
results?: QueryResult[];
|
||||
activeResultIndex?: number;
|
||||
resultBaseSql?: string;
|
||||
/** Fingerprint of the complete editor document when this result run started. */
|
||||
resultEditorFingerprint?: string;
|
||||
resultSortedSql?: string;
|
||||
resultSortColumn?: string;
|
||||
resultSortColumnIndex?: number;
|
||||
|
|
@ -733,6 +760,8 @@ export interface QueryTab {
|
|||
originalSql?: string;
|
||||
lastExecutedSql?: string;
|
||||
resultBaseSql?: string;
|
||||
/** Fingerprint of the complete editor document when the displayed result started. */
|
||||
resultEditorFingerprint?: string;
|
||||
resultSortedSql?: string;
|
||||
resultSortColumn?: string;
|
||||
resultSortColumnIndex?: number;
|
||||
|
|
|
|||
|
|
@ -624,6 +624,7 @@ mod tests {
|
|||
read_only: false,
|
||||
is_production: false,
|
||||
production_databases: vec![],
|
||||
database_info: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -782,6 +782,7 @@ mod tests {
|
|||
read_only: false,
|
||||
is_production: false,
|
||||
production_databases: vec![],
|
||||
database_info: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -599,6 +599,7 @@ mod tests {
|
|||
read_only,
|
||||
is_production: false,
|
||||
production_databases: Vec::new(),
|
||||
database_info: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -172,6 +172,7 @@ mod tests {
|
|||
read_only: false,
|
||||
is_production: false,
|
||||
production_databases: vec![],
|
||||
database_info: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -612,6 +612,7 @@ mod tests {
|
|||
read_only: false,
|
||||
is_production: false,
|
||||
production_databases: vec!["prod_app".to_string()],
|
||||
database_info: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -2501,6 +2501,7 @@ mod tests {
|
|||
read_only: false,
|
||||
is_production: false,
|
||||
production_databases: vec![],
|
||||
database_info: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -191,6 +191,7 @@ mod tests {
|
|||
read_only: false,
|
||||
is_production: false,
|
||||
production_databases: vec![],
|
||||
database_info: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -4789,6 +4789,7 @@ mod tests {
|
|||
read_only: false,
|
||||
is_production: false,
|
||||
production_databases: vec![],
|
||||
database_info: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ fn live_postgres_config(
|
|||
read_only: false,
|
||||
is_production: false,
|
||||
production_databases: vec![],
|
||||
database_info: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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", """
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue