feat(oracle): add TNS connections and trigger editing

This commit is contained in:
t8y2 2026-07-23 23:03:10 +08:00
parent 9a5edae219
commit 3b004abb1e
23 changed files with 980 additions and 53 deletions

View File

@ -149,6 +149,22 @@ const oracleListObjectsOrderSQL = `ORDER BY CASE OBJECT_TYPE
ELSE 5
END, OBJECT_NAME`
const oracleListObjectsSQL = oracleListObjectsBaseSQL + "\n" + oracleListObjectsOrderSQL
const oracleListTriggersSQL = `
SELECT t.TRIGGER_NAME,
t.TRIGGERING_EVENT,
t.TRIGGER_TYPE,
t.DESCRIPTION,
s.LINE,
s.TEXT
FROM ALL_TRIGGERS t
LEFT JOIN ALL_SOURCE s
ON s.OWNER = t.OWNER
AND s.NAME = t.TRIGGER_NAME
AND s.TYPE = 'TRIGGER'
WHERE t.OWNER = :1
AND t.TABLE_NAME = :2
AND t.BASE_OBJECT_TYPE IN ('TABLE', 'VIEW')
ORDER BY t.TRIGGER_NAME, s.LINE`
type request struct {
ID json.RawMessage `json:"id"`
@ -352,9 +368,10 @@ type foreignKeyInfo struct {
}
type triggerInfo struct {
Name string `json:"name"`
Event string `json:"event"`
Timing string `json:"timing"`
Name string `json:"name"`
Event string `json:"event"`
Timing string `json:"timing"`
Statement *string `json:"statement,omitempty"`
}
type server struct {
@ -806,10 +823,12 @@ func openDB(params connectParams) (*sql.DB, error) {
}
func openDBWithStringConverter(params connectParams, stringConverter converters.IStringConverter) (*sql.DB, error) {
dsn := buildDSN(params)
dsn, err := buildDSNForConnect(params)
if err != nil {
return nil, err
}
var db *sql.DB
if stringConverter == nil {
var err error
db, err = sql.Open("oracle", dsn)
if err != nil {
return nil, err
@ -2008,26 +2027,78 @@ func (s *server) listTriggers(schema, table string) ([]triggerInfo, error) {
return nil, err
}
table = strings.ToUpper(strings.TrimSpace(table))
rows, err := s.queryRows(`
SELECT TRIGGER_NAME, TRIGGERING_EVENT, TRIGGER_TYPE
FROM ALL_TRIGGERS
WHERE OWNER = :1 AND TABLE_NAME = :2
ORDER BY TRIGGER_NAME`, []any{schema, table})
rows, err := s.queryRows(oracleListTriggersSQL, []any{schema, table})
if err != nil {
return nil, err
}
defer s.closeRows(rows)
var result []triggerInfo
var currentName string
var currentDescription string
var source strings.Builder
flush := func() {
if len(result) == 0 || currentName == "" {
return
}
if body, ok := oracleTriggerBody(source.String(), currentDescription); ok {
result[len(result)-1].Statement = &body
}
}
for rows.Next() {
var item triggerInfo
if err := rows.Scan(&item.Name, &item.Event, &item.Timing); err != nil {
var name, event, timing string
var description, lineText sql.NullString
var line sql.NullInt64
if err := rows.Scan(&name, &event, &timing, &description, &line, &lineText); err != nil {
return nil, err
}
result = append(result, item)
if name != currentName {
flush()
currentName = name
currentDescription = description.String
source.Reset()
result = append(result, triggerInfo{Name: name, Event: event, Timing: timing})
}
if line.Valid && lineText.Valid {
source.WriteString(lineText.String)
}
}
flush()
return emptyIfNil(result), rows.Err()
}
func oracleTriggerBody(source, description string) (string, bool) {
source = strings.ReplaceAll(source, "\r\n", "\n")
description = strings.ReplaceAll(description, "\r\n", "\n")
if strings.TrimSpace(source) == "" {
return "", false
}
sourceLines := strings.Split(source, "\n")
descriptionLines := strings.Split(strings.TrimSpace(description), "\n")
for len(descriptionLines) > 0 && strings.TrimSpace(descriptionLines[len(descriptionLines)-1]) == "" {
descriptionLines = descriptionLines[:len(descriptionLines)-1]
}
if len(descriptionLines) > 0 && len(sourceLines) >= len(descriptionLines) {
matches := true
for index, descriptionLine := range descriptionLines {
sourceLine := strings.TrimSpace(sourceLines[index])
if index == 0 && len(sourceLine) >= len("TRIGGER") && strings.EqualFold(sourceLine[:len("TRIGGER")], "TRIGGER") {
sourceLine = strings.TrimSpace(sourceLine[len("TRIGGER"):])
}
if !strings.EqualFold(sourceLine, strings.TrimSpace(descriptionLine)) {
matches = false
break
}
}
if matches {
return strings.TrimSpace(strings.Join(sourceLines[len(descriptionLines):], "\n")), true
}
}
// ALL_SOURCE is still more useful than an empty editor if a database version formats DESCRIPTION differently.
return strings.TrimSpace(source), true
}
func (s *server) getObjectSource(schema, name, objectType string) (map[string]any, error) {
var err error
schema, err = s.normalizeSchema(schema)

View File

@ -932,6 +932,56 @@ func TestListSessionUserObjectsQueryUsesUserDictionary(t *testing.T) {
}
}
func TestOracleListTriggersSQLLoadsSourceWithoutLongColumns(t *testing.T) {
sqlText := strings.ToUpper(oracleListTriggersSQL)
if !strings.Contains(sqlText, "FROM ALL_TRIGGERS") || !strings.Contains(sqlText, "LEFT JOIN ALL_SOURCE") {
t.Fatalf("trigger listing should join metadata with line-based source, got: %s", oracleListTriggersSQL)
}
if !strings.Contains(sqlText, "T.DESCRIPTION") || !strings.Contains(sqlText, "S.TEXT") {
t.Fatalf("trigger listing should load the declaration and source text, got: %s", oracleListTriggersSQL)
}
if strings.Contains(sqlText, "TRIGGER_BODY") {
t.Fatalf("trigger listing should avoid Oracle LONG trigger bodies, got: %s", oracleListTriggersSQL)
}
if !strings.Contains(sqlText, "T.OWNER = :1") || !strings.Contains(sqlText, "T.TABLE_NAME = :2") {
t.Fatalf("trigger listing should stay scoped to the selected schema and table, got: %s", oracleListTriggersSQL)
}
}
func TestOracleTriggerBodyStripsDictionaryDeclaration(t *testing.T) {
source := "TRIGGER DBX_TRIGGER_4320_AUDIT\n" +
"AFTER INSERT OR UPDATE OR DELETE ON DBX_TRIGGER_4320\n" +
"FOR EACH ROW\n" +
"DECLARE\n" +
" V_EVENT VARCHAR2(10);\n" +
"BEGIN\n" +
" V_EVENT := CASE WHEN INSERTING THEN 'INSERT' WHEN UPDATING THEN 'UPDATE' ELSE 'DELETE' END;\n" +
"END;\n"
description := "DBX_TRIGGER_4320_AUDIT\n" +
"AFTER INSERT OR UPDATE OR DELETE ON DBX_TRIGGER_4320\n" +
"FOR EACH ROW\n"
body, ok := oracleTriggerBody(source, description)
if !ok {
t.Fatal("expected Oracle trigger source to produce a body")
}
want := "DECLARE\n V_EVENT VARCHAR2(10);\nBEGIN\n V_EVENT := CASE WHEN INSERTING THEN 'INSERT' WHEN UPDATING THEN 'UPDATE' ELSE 'DELETE' END;\nEND;"
if body != want {
t.Fatalf("trigger body = %q, want %q", body, want)
}
}
func TestOracleTriggerBodyFallsBackToVisibleSource(t *testing.T) {
body, ok := oracleTriggerBody("TRIGGER APP.AUDIT\nBEGIN\n NULL;\nEND;\n", "differently formatted declaration")
if !ok {
t.Fatal("expected differently formatted Oracle source to remain visible")
}
if body != "TRIGGER APP.AUDIT\nBEGIN\n NULL;\nEND;" {
t.Fatalf("unexpected fallback source: %q", body)
}
}
func TestOracleFuzzyLikePatternEscapesSpecialCharacters(t *testing.T) {
got := oracleFuzzyLikePattern(`a_%\b`)
want := `%a%\_%\%%\\%b%`
@ -1154,7 +1204,6 @@ func contains(values []string, target string) bool {
return false
}
// -- fake drivers for timeout tests --
func init() {
@ -1174,7 +1223,7 @@ type oracleDMLConn struct{}
func (c *oracleDMLConn) Prepare(query string) (driver.Stmt, error) {
return nil, errors.New("use ExecContext directly")
}
func (c *oracleDMLConn) Close() error { return nil }
func (c *oracleDMLConn) Close() error { return nil }
func (c *oracleDMLConn) Begin() (driver.Tx, error) { return nil, errors.New("not supported") }
var _ driver.ExecerContext = (*oracleDMLConn)(nil)
@ -1196,13 +1245,13 @@ type oracleFastConn struct{}
func (c *oracleFastConn) Prepare(query string) (driver.Stmt, error) {
return &oracleFastStmt{}, nil
}
func (c *oracleFastConn) Close() error { return nil }
func (c *oracleFastConn) Close() error { return nil }
func (c *oracleFastConn) Begin() (driver.Tx, error) { return nil, errors.New("not supported") }
type oracleFastStmt struct{}
func (s *oracleFastStmt) Close() error { return nil }
func (s *oracleFastStmt) NumInput() int { return -1 }
func (s *oracleFastStmt) Close() error { return nil }
func (s *oracleFastStmt) NumInput() int { return -1 }
func (s *oracleFastStmt) Exec(args []driver.Value) (driver.Result, error) {
return driver.ResultNoRows, nil
}

View File

@ -0,0 +1,224 @@
package main
import (
"bufio"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
)
const oracleJDBCThinPrefix = "jdbc:oracle:thin:@"
type oracleTNSConfig struct {
Alias string
TNSAdmin string
}
func buildDSNForConnect(params connectParams) (string, error) {
config, ok, err := parseOracleTNSJDBCURL(params.ConnectionString)
if err != nil {
return "", err
}
if !ok {
return buildDSN(params), nil
}
descriptor, err := resolveOracleTNSAlias(config)
if err != nil {
return "", err
}
options := parseURLParams(params.URLParams)
if params.SysDBA {
options["AUTH TYPE"] = "SYSDBA"
}
return buildGoOraJDBC(params.Username, params.Password, descriptor, options), nil
}
func parseOracleTNSJDBCURL(value string) (oracleTNSConfig, bool, error) {
source := strings.TrimSpace(value)
if !strings.HasPrefix(strings.ToLower(source), oracleJDBCThinPrefix) {
return oracleTNSConfig{}, false, nil
}
target := strings.TrimSpace(source[len(oracleJDBCThinPrefix):])
if target == "" || strings.HasPrefix(target, "(") || strings.HasPrefix(target, "//") || strings.Contains(strings.SplitN(target, "?", 2)[0], ":") {
return oracleTNSConfig{}, false, nil
}
parts := strings.SplitN(target, "?", 2)
alias, err := url.QueryUnescape(strings.TrimSpace(parts[0]))
if err != nil || strings.TrimSpace(alias) == "" {
return oracleTNSConfig{}, true, fmt.Errorf("Oracle TNS network alias is invalid")
}
if len(parts) == 1 {
return oracleTNSConfig{}, true, fmt.Errorf("Oracle TNS_ADMIN directory is required")
}
query, err := url.ParseQuery(parts[1])
if err != nil {
return oracleTNSConfig{}, true, fmt.Errorf("Oracle TNS connection parameters are invalid: %w", err)
}
tnsAdmin := strings.TrimSpace(query.Get("TNS_ADMIN"))
if tnsAdmin == "" {
return oracleTNSConfig{}, true, fmt.Errorf("Oracle TNS_ADMIN directory is required")
}
return oracleTNSConfig{Alias: strings.TrimSpace(alias), TNSAdmin: tnsAdmin}, true, nil
}
func resolveOracleTNSAlias(config oracleTNSConfig) (string, error) {
tnsNamesPath, err := oracleTNSNamesPath(config.TNSAdmin)
if err != nil {
return "", err
}
aliases, err := readOracleTNSAliases(tnsNamesPath, make(map[string]bool), 0)
if err != nil {
return "", err
}
descriptor, ok := aliases[strings.ToUpper(config.Alias)]
if !ok {
return "", fmt.Errorf("Oracle TNS alias %q was not found in %s", config.Alias, tnsNamesPath)
}
return descriptor, nil
}
func oracleTNSNamesPath(tnsAdmin string) (string, error) {
path := filepath.Clean(strings.TrimSpace(tnsAdmin))
info, err := os.Stat(path)
if err != nil {
return "", fmt.Errorf("Oracle TNS_ADMIN directory is not accessible: %s", path)
}
if !info.IsDir() {
return "", fmt.Errorf("Oracle TNS_ADMIN must be a directory containing tnsnames.ora: %s", path)
}
tnsNamesPath := filepath.Join(path, "tnsnames.ora")
if info, err := os.Stat(tnsNamesPath); err != nil || info.IsDir() {
return "", fmt.Errorf("Oracle tnsnames.ora was not found in TNS_ADMIN directory: %s", path)
}
return tnsNamesPath, nil
}
func readOracleTNSAliases(path string, visited map[string]bool, depth int) (map[string]string, error) {
if depth > 8 {
return nil, fmt.Errorf("Oracle TNS include depth exceeds 8 files")
}
absolutePath, err := filepath.Abs(path)
if err != nil {
return nil, fmt.Errorf("Failed to resolve Oracle TNS file path: %w", err)
}
if visited[absolutePath] {
return map[string]string{}, nil
}
visited[absolutePath] = true
file, err := os.Open(absolutePath)
if err != nil {
return nil, fmt.Errorf("Failed to read Oracle TNS file %s: %w", absolutePath, err)
}
defer file.Close()
aliases := make(map[string]string)
var currentAliases []string
var description strings.Builder
descriptionStarted := false
parenthesisDepth := 0
flush := func() {
if len(currentAliases) == 0 {
return
}
value := strings.Join(strings.Fields(description.String()), " ")
if value != "" {
for _, alias := range currentAliases {
alias = strings.ToUpper(strings.TrimSpace(alias))
if alias != "" {
aliases[alias] = value
}
}
}
currentAliases = nil
description.Reset()
descriptionStarted = false
parenthesisDepth = 0
}
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
for scanner.Scan() {
trimmed := strings.TrimSpace(stripOracleTNSComment(scanner.Text()))
if trimmed == "" {
continue
}
if len(currentAliases) == 0 {
if divider := strings.Index(trimmed, "="); divider >= 0 {
key := strings.TrimSpace(trimmed[:divider])
value := strings.TrimSpace(trimmed[divider+1:])
if strings.EqualFold(key, "IFILE") {
includePath := strings.Trim(value, "\"'")
if !filepath.IsAbs(includePath) {
includePath = filepath.Join(filepath.Dir(absolutePath), includePath)
}
included, includeErr := readOracleTNSAliases(includePath, visited, depth+1)
if includeErr != nil {
return nil, includeErr
}
for alias, descriptor := range included {
aliases[alias] = descriptor
}
continue
}
currentAliases = strings.Split(key, ",")
if value != "" {
descriptionStarted = true
description.WriteString(value)
parenthesisDepth += oracleTNSParenthesisDelta(value)
}
}
} else {
description.WriteByte(' ')
description.WriteString(trimmed)
descriptionStarted = true
parenthesisDepth += oracleTNSParenthesisDelta(trimmed)
}
if len(currentAliases) > 0 && descriptionStarted && parenthesisDepth <= 0 {
flush()
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("Failed to read Oracle TNS file %s: %w", absolutePath, err)
}
flush()
return aliases, nil
}
func stripOracleTNSComment(line string) string {
var quote rune
for index, char := range line {
switch {
case quote != 0 && char == quote:
quote = 0
case quote == 0 && (char == '\'' || char == '"'):
quote = char
case quote == 0 && char == '#':
return line[:index]
}
}
return line
}
func oracleTNSParenthesisDelta(value string) int {
delta := 0
var quote rune
for _, char := range value {
switch {
case quote != 0 && char == quote:
quote = 0
case quote == 0 && (char == '\'' || char == '"'):
quote = char
case quote == 0 && char == '(':
delta++
case quote == 0 && char == ')':
delta--
}
}
return delta
}

View File

@ -0,0 +1,99 @@
package main
import (
"net/url"
"os"
"path/filepath"
"strings"
"testing"
)
func TestBuildDSNForConnectResolvesTNSAlias(t *testing.T) {
tnsAdmin := t.TempDir()
descriptor := `(DESCRIPTION=(FAILOVER=ON)(ADDRESS_LIST=(ADDRESS=(PROTOCOL=TCP)(HOST=db1.example.com)(PORT=1521))(ADDRESS=(PROTOCOL=TCP)(HOST=db2.example.com)(PORT=1521)))(CONNECT_DATA=(SERVICE_NAME=ORCLPDB1)))`
writeTNSNames(t, tnsAdmin, "DBX_FAILOVER =\n "+descriptor+"\n")
dsn, err := buildDSNForConnect(connectParams{
Username: "scott",
Password: "tiger",
ConnectionString: oracleTNSJDBCURL("DBX_FAILOVER", tnsAdmin),
})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(dsn, "connStr=") || !strings.Contains(dsn, "db1.example.com") || !strings.Contains(dsn, "db2.example.com") {
t.Fatalf("TNS descriptor should preserve all failover addresses, got: %s", dsn)
}
}
func TestBuildDSNForConnectRejectsMissingTNSAdmin(t *testing.T) {
_, err := buildDSNForConnect(connectParams{ConnectionString: "jdbc:oracle:thin:@DBX_FAILOVER"})
if err == nil || !strings.Contains(err.Error(), "TNS_ADMIN") {
t.Fatalf("expected a clear TNS_ADMIN error, got: %v", err)
}
}
func TestBuildDSNForConnectRejectsUnknownAlias(t *testing.T) {
tnsAdmin := t.TempDir()
writeTNSNames(t, tnsAdmin, "KNOWN = (DESCRIPTION=(ADDRESS=(HOST=db.example.com)(PORT=1521))(CONNECT_DATA=(SERVICE_NAME=ORCL)))\n")
_, err := buildDSNForConnect(connectParams{ConnectionString: oracleTNSJDBCURL("MISSING", tnsAdmin)})
if err == nil || !strings.Contains(err.Error(), `alias "MISSING" was not found`) {
t.Fatalf("expected an unknown alias error, got: %v", err)
}
}
func TestBuildDSNForConnectRejectsInvalidTNSAdmin(t *testing.T) {
_, err := buildDSNForConnect(connectParams{ConnectionString: oracleTNSJDBCURL("DBX", filepath.Join(t.TempDir(), "missing"))})
if err == nil || !strings.Contains(err.Error(), "not accessible") {
t.Fatalf("expected an invalid directory error, got: %v", err)
}
}
func TestReadOracleTNSAliasesSupportsIFILEAndMultipleAliases(t *testing.T) {
tnsAdmin := t.TempDir()
includePath := filepath.Join(tnsAdmin, "included.ora")
if err := os.WriteFile(includePath, []byte("DBX_A, DBX_B = (DESCRIPTION=(ADDRESS=(HOST=db.example.com)(PORT=1521))(CONNECT_DATA=(SERVICE_NAME=ORCL)))\n"), 0o600); err != nil {
t.Fatal(err)
}
writeTNSNames(t, tnsAdmin, "IFILE = included.ora\n")
aliases, err := readOracleTNSAliases(filepath.Join(tnsAdmin, "tnsnames.ora"), make(map[string]bool), 0)
if err != nil {
t.Fatal(err)
}
if aliases["DBX_A"] == "" || aliases["DBX_B"] == "" {
t.Fatalf("expected both aliases from IFILE, got: %#v", aliases)
}
}
func TestReadOracleTNSAliasesSupportsIndentedEntriesAndInlineComments(t *testing.T) {
tnsAdmin := t.TempDir()
writeTNSNames(t, tnsAdmin, `
DBX_INDENTED =
(DESCRIPTION =
(ADDRESS = (PROTOCOL = TCP)(HOST = db.example.com)(PORT = 1521)) # preferred listener
(CONNECT_DATA = (SERVICE_NAME = ORCL))
)
`)
aliases, err := readOracleTNSAliases(filepath.Join(tnsAdmin, "tnsnames.ora"), make(map[string]bool), 0)
if err != nil {
t.Fatal(err)
}
descriptor := aliases["DBX_INDENTED"]
if !strings.Contains(descriptor, "HOST = db.example.com") || strings.Contains(descriptor, "preferred listener") {
t.Fatalf("expected an indented descriptor without comments, got: %q", descriptor)
}
}
func oracleTNSJDBCURL(alias, tnsAdmin string) string {
return oracleJDBCThinPrefix + alias + "?TNS_ADMIN=" + url.QueryEscape(tnsAdmin)
}
func writeTNSNames(t *testing.T, dir, contents string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, "tnsnames.ora"), []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
}

View File

@ -29,6 +29,7 @@ import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import * as api from "@/lib/backend/api";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { applyParsedConnectionUrl, normalizeMongoConnectionString, parseConnectionUrl } from "@/lib/connection/connectionUrl";
import { buildOracleTnsConnectionString, normalizeOracleTnsAdminPath, parseOracleTnsConnectionString } from "@/lib/connection/oracleTnsConnection";
import { parseConnectionDeepLink, type ConnectionDeepLinkDraft } from "@/lib/connection/connectionDeepLink";
import { connectionUrlPlaceholder as getUrlPlaceholder } from "@/lib/connection/connectionPresentation";
import { h2ConnectionModeForConfig, h2FileJdbcUrlWithPath, h2FilePathFromJdbcUrl, isH2SplitJdbcUrl, type H2ConnectionMode } from "@/lib/database/h2Connection";
@ -494,6 +495,7 @@ const selectedJdbcDriverPath = ref("");
const jdbcManualClasspathOpen = ref(false);
const connectionUrlInput = ref("");
const appliedConnectionUrlInput = ref("");
const oracleTnsAdminPath = ref("");
const oceanbaseSubMode = ref<"mysql" | "oracle">("mysql");
const h2ConnectionMode = ref<H2ConnectionMode>("file");
const dremioConnectionMode = ref<DremioConnectionMode>("legacy");
@ -1764,6 +1766,7 @@ function applyProfile(val: string, preserveConnectionFields = false) {
}
if (!preserveConnectionFields) {
oracleTnsAdminPath.value = "";
form.value.port = profile.port;
setSqlServerPortExplicit(form.value, false);
form.value.username = profile.user;
@ -1910,6 +1913,7 @@ watch(
visible_databases: config.visible_databases,
visible_schemas: config.visible_schemas,
};
oracleTnsAdminPath.value = parseOracleTnsConnectionString(config.connection_string)?.tnsAdmin || "";
productionProtectionEnabled.value = !!config.is_production || (config.production_databases?.length ?? 0) > 0;
connectionUrlInput.value = config.db_type === "h2" && config.connection_string ? config.connection_string : "";
appliedConnectionUrlInput.value = connectionUrlInput.value.trim();
@ -1988,12 +1992,14 @@ watch(
);
const databaseLabel = computed(() => {
if (form.value.db_type === "oracle" && form.value.oracle_connection_type === "tns") return t("connection.oracleTnsAlias");
if (form.value.db_type === "oracle") return t("connection.serviceName");
if (form.value.db_type === "influxdb" && influxDbVersion.value === "2") return "Bucket";
return t("connection.database");
});
const databasePlaceholder = computed(() => {
if (form.value.db_type === "oracle" && form.value.oracle_connection_type === "tns") return t("connection.oracleTnsAliasPlaceholder");
if (form.value.db_type === "kingbase") return t("connection.databasePlaceholderRequired");
const fallback = defaultDatabaseForProfile();
if (!fallback) return t("connection.databasePlaceholder");
@ -2485,7 +2491,7 @@ const zookeeperConnectString = computed({
form.value.connection_string = normalizeZooKeeperConnectString(value);
},
});
const canUseTransportLayers = computed(() => form.value.db_type !== "sqlite" && form.value.db_type !== "access" && !isCloudflareD1Connection(form.value) && !isH2FileMode.value);
const canUseTransportLayers = computed(() => form.value.db_type !== "sqlite" && form.value.db_type !== "access" && !isCloudflareD1Connection(form.value) && !isH2FileMode.value && !(form.value.db_type === "oracle" && form.value.oracle_connection_type === "tns"));
const shouldShowAgentDriverInstallHint = computed(() => showAgentDriverInstallHint(form.value.db_type, agentDrivers.value, form.value.driver_profile));
const h2DriverMissing = computed(() => form.value.db_type === "h2" && isH2FileMode.value && agentDrivers.value.find((d) => d.db_type === "h2")?.installed !== true);
const agentDriverFocus = computed<DriverStoreFocus>(() => ({ target: "driver", driver: agentDriverInstallKey(form.value.db_type, form.value.driver_profile) }));
@ -2812,6 +2818,7 @@ function applyConnectionUrlToForm(input: string): boolean {
const parsed = parseConnectionUrl(input, selectedType.value);
form.value = applyParsedConnectionUrl(form.value, parsed);
oracleTnsAdminPath.value = parseOracleTnsConnectionString(parsed.connectionString)?.tnsAdmin || "";
selectedType.value = parsed.driverProfile;
customDriverName.value = isCustomCompatibleProfile() ? parsed.driverLabel : "";
mongoUseUrl.value = !!parsed.useMongoUrl;
@ -2964,7 +2971,22 @@ function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionCo
normalized.connect_timeout_secs = Number.isFinite(timeout) && timeout > 0 ? timeout : 5;
return { type: "ssh", ...normalized };
});
if (config.db_type === "oracle" && config.oracle_connection_type === "tns" && config.transport_layers.some((layer) => layer.enabled !== false)) {
throw new Error(t("connection.oracleTnsTransportUnsupported"));
}
validateTransportLayers(config);
if (config.db_type === "oracle" && config.oracle_connection_type === "tns") {
const alias = config.database?.trim() || "";
const tnsAdmin = normalizeOracleTnsAdminPath(oracleTnsAdminPath.value);
if (!alias) throw new Error(t("connection.oracleTnsAliasRequired"));
if (!tnsAdmin) throw new Error(t("connection.oracleTnsAdminRequired"));
config.database = alias;
config.connection_string = buildOracleTnsConnectionString(alias, tnsAdmin);
} else if (config.db_type === "oracle" && parseOracleTnsConnectionString(config.connection_string)) {
// Only clear DBX-generated TNS URLs when switching modes; preserve custom
// service, SID, and descriptor JDBC strings exactly as before.
config.connection_string = undefined;
}
const connectTimeout = Number(config.connect_timeout_secs);
config.connect_timeout_secs = Number.isFinite(connectTimeout) && connectTimeout > 0 ? connectTimeout : 10;
const queryTimeout = Number(config.query_timeout_secs);
@ -3774,6 +3796,7 @@ function resetForm() {
selectedJdbcDriverPath.value = "";
connectionUrlInput.value = "";
appliedConnectionUrlInput.value = "";
oracleTnsAdminPath.value = "";
dialogStep.value = "select";
dbSearchQuery.value = "";
selectedDbCategory.value = "sql";
@ -3822,6 +3845,7 @@ function applyConnectionDraftToConfig(config: Omit<ConnectionConfig, "id">, draf
function applyConnectionDraftToForm(draft: ConnectionDeepLinkDraft) {
applyProfile(draft.driverProfile);
form.value = applyConnectionDraftToConfig(form.value, draft);
oracleTnsAdminPath.value = parseOracleTnsConnectionString(form.value.connection_string)?.tnsAdmin || "";
selectedType.value = draft.driverProfile;
if (form.value.db_type === "h2") {
h2ConnectionMode.value = h2ConnectionModeForConfig(form.value);
@ -4240,6 +4264,20 @@ async function browseHiveKerberosFile(target: "krb5" | "jaas") {
}
}
async function browseOracleTnsNamesFile() {
if (!isTauriRuntime()) return;
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({
title: t("connection.oracleTnsAdminBrowse"),
multiple: false,
filters: [{ name: "Oracle TNS names", extensions: ["ora"] }],
});
if (typeof selected === "string") {
oracleTnsAdminPath.value = normalizeOracleTnsAdminPath(selected);
resetTestState();
}
}
async function browseKafkaKerberosFile(target: "keytab" | "krb5") {
if (isTauriRuntime()) {
const { open } = await import("@tauri-apps/plugin-dialog");
@ -5664,7 +5702,7 @@ function openExternalUrl(url: string) {
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<div v-if="form.db_type !== 'oracle' || form.oracle_connection_type !== 'tns'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ form.db_type === "elasticsearch" && elasticsearchConnectionMode === "kibana" ? t("connection.elasticsearchKibanaHost") : t("connection.host") }}</Label>
<Input v-model="form.host" class="col-span-2" />
<Input v-model.number="form.port" type="number" class="col-span-1" @input="markSqlServerPortExplicit" />
@ -5700,6 +5738,26 @@ function openExternalUrl(url: string) {
<Input v-model="form.database" class="col-span-3" :placeholder="databasePlaceholder" />
</div>
<div v-if="form.db_type === 'oracle' && form.oracle_connection_type === 'tns'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">TNS_ADMIN</Label>
<div class="col-span-3 flex items-center gap-1">
<Input v-model="oracleTnsAdminPath" class="flex-1" :placeholder="t('connection.oracleTnsAdminPlaceholder')" />
<Tooltip v-if="isDesktop">
<TooltipTrigger as-child>
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" @click="browseOracleTnsNamesFile">
<FolderOpen class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("connection.oracleTnsAdminBrowse") }}</TooltipContent>
</Tooltip>
</div>
</div>
<div v-if="form.db_type === 'oracle' && form.oracle_connection_type === 'tns'" class="grid grid-cols-4 items-start gap-4">
<span />
<p class="col-span-3 text-xs text-muted-foreground">{{ t("connection.oracleTnsPathHint") }}</p>
</div>
<template v-if="form.db_type === 'hive'">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.hiveAuthMode") }}</Label>
@ -5772,12 +5830,12 @@ function openExternalUrl(url: string) {
<div v-if="form.db_type === 'oracle'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.mode") }}</Label>
<div class="col-span-3 grid h-8 grid-cols-2 overflow-hidden rounded-md border border-input bg-muted/30 p-0.5">
<div class="col-span-3 grid h-8 grid-cols-3 overflow-hidden rounded-md border border-input bg-muted/30 p-0.5">
<button
type="button"
class="h-7 rounded-sm px-3 text-sm transition-colors"
:class="form.oracle_connection_type !== 'sid' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
:aria-pressed="form.oracle_connection_type !== 'sid'"
:class="form.oracle_connection_type === 'service_name' || !form.oracle_connection_type ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
:aria-pressed="form.oracle_connection_type === 'service_name' || !form.oracle_connection_type"
@click="form.oracle_connection_type = 'service_name'"
>
{{ t("connection.serviceNameOnly") }}
@ -5791,6 +5849,15 @@ function openExternalUrl(url: string) {
>
SID
</button>
<button
type="button"
class="h-7 rounded-sm px-3 text-sm transition-colors"
:class="form.oracle_connection_type === 'tns' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
:aria-pressed="form.oracle_connection_type === 'tns'"
@click="form.oracle_connection_type = 'tns'"
>
TNS
</button>
</div>
</div>

View File

@ -2090,7 +2090,8 @@ function canDropIndex(index: EditableStructureIndex): boolean {
}
const canEditForeignKeys = computed(() => structureCapabilities.value.foreignKey);
const canEditMysqlTriggers = computed(() => structureDialect.value === "mysql");
const canEditTriggers = computed(() => structureDialect.value === "mysql" || structureDialect.value === "oracle");
const isOracleTriggerEditor = computed(() => structureDialect.value === "oracle");
function generatedForeignKeyName(column = ""): string {
const table = structureIndexTableName() || "table";
@ -2138,14 +2139,14 @@ function canEditForeignKeyDraft(foreignKey: EditableStructureForeignKey): boolea
}
function addTrigger() {
if (!canEditMysqlTriggers.value || triggersLoading.value) return;
if (!canEditTriggers.value || triggersLoading.value) return;
activeTab.value = "triggers";
triggers.value.push({
id: `new:${uuid()}`,
name: "",
timing: "BEFORE",
timing: isOracleTriggerEditor.value ? "BEFORE EACH ROW" : "BEFORE",
event: "INSERT",
statement: "BEGIN\n \nEND",
statement: isOracleTriggerEditor.value ? "BEGIN\n NULL;\nEND" : "BEGIN\n \nEND",
markedForDrop: false,
});
}
@ -2160,7 +2161,7 @@ function toggleDropTrigger(trigger: EditableStructureTrigger) {
}
function canEditTriggerDraft(trigger: EditableStructureTrigger): boolean {
return !triggersLoading.value && canEditMysqlTriggers.value && !trigger.markedForDrop;
return !triggersLoading.value && canEditTriggers.value && !trigger.markedForDrop;
}
function primarySqlOperation(sql: string): string {
@ -2293,7 +2294,7 @@ function addItemForActiveTab(): boolean {
addForeignKey();
return true;
}
if (activeTab.value === "triggers" && canEditMysqlTriggers.value) {
if (activeTab.value === "triggers" && canEditTriggers.value) {
addTrigger();
return true;
}
@ -2648,7 +2649,7 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
<Plus :class="structureIconClass" />
{{ t("structureEditor.addForeignKey") }}
</Button>
<Button v-if="activeTab === 'triggers'" size="sm" :class="structureToolbarButtonClass" :disabled="!canEditMysqlTriggers || triggersLoading" @click="addTrigger">
<Button v-if="activeTab === 'triggers'" size="sm" :class="structureToolbarButtonClass" :disabled="!canEditTriggers || triggersLoading" @click="addTrigger">
<Plus :class="structureIconClass" />
{{ t("structureEditor.addTrigger") }}
</Button>
@ -3193,9 +3194,10 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
</div>
<div v-else class="space-y-1.5">
<div v-for="trigger in triggers" :key="trigger.id" class="rounded-md border px-[var(--structure-cell-px)] py-[var(--structure-header-py)] text-[length:var(--structure-font-size)]" :class="trigger.markedForDrop ? 'bg-destructive/5 opacity-60' : ''">
<div class="grid grid-cols-[minmax(140px,1fr)_110px_110px_auto] gap-1.5">
<div class="grid grid-cols-[minmax(140px,1fr)_minmax(130px,180px)_minmax(140px,1fr)_auto] gap-1.5">
<Input v-model="trigger.name" :class="structureControlClass" :placeholder="t('structureEditor.triggerName')" :disabled="!canEditTriggerDraft(trigger)" />
<Select v-model="trigger.timing" :disabled="!canEditTriggerDraft(trigger)">
<Input v-if="isOracleTriggerEditor" v-model="trigger.timing" :class="structureControlClass" :disabled="!canEditTriggerDraft(trigger)" />
<Select v-else v-model="trigger.timing" :disabled="!canEditTriggerDraft(trigger)">
<SelectTrigger class="h-[var(--structure-control-height)] rounded-[6px] px-[var(--structure-control-px)] text-[length:var(--structure-font-size)] focus-visible:border-ring/50 focus-visible:ring-1 focus-visible:ring-ring/25">
<SelectValue />
</SelectTrigger>
@ -3203,7 +3205,8 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
<SelectItem v-for="timing in triggerTimingOptions" :key="timing" :value="timing">{{ timing }}</SelectItem>
</SelectContent>
</Select>
<Select v-model="trigger.event" :disabled="!canEditTriggerDraft(trigger)">
<Input v-if="isOracleTriggerEditor" v-model="trigger.event" :class="structureControlClass" :disabled="!canEditTriggerDraft(trigger)" />
<Select v-else v-model="trigger.event" :disabled="!canEditTriggerDraft(trigger)">
<SelectTrigger class="h-[var(--structure-control-height)] rounded-[6px] px-[var(--structure-control-px)] text-[length:var(--structure-font-size)] focus-visible:border-ring/50 focus-visible:ring-1 focus-visible:ring-ring/25">
<SelectValue />
</SelectTrigger>

View File

@ -210,6 +210,14 @@ export default {
mongoDriverLegacy: "Legacy",
serviceName: "Service/SID",
serviceNameOnly: "Service Name",
oracleTnsAlias: "Network Alias",
oracleTnsAliasPlaceholder: "Alias from tnsnames.ora",
oracleTnsAdminPlaceholder: "Directory containing tnsnames.ora",
oracleTnsAdminBrowse: "Select tnsnames.ora",
oracleTnsPathHint: "The DBX backend/Oracle agent reads this path. Desktop paths are local; Web and Docker paths are on the server.",
oracleTnsAliasRequired: "Oracle TNS mode requires a network alias.",
oracleTnsAdminRequired: "Oracle TNS mode requires the TNS_ADMIN directory containing tnsnames.ora.",
oracleTnsTransportUnsupported: "Oracle TNS connections cannot use SSH, proxy, or HTTP tunnel layers. Remove the transport layer or use Service Name/SID mode.",
elasticsearchDirectMode: "Direct",
elasticsearchKibanaProxyMode: "Kibana Proxy",
elasticsearchKibanaHost: "Kibana Host",

View File

@ -212,6 +212,14 @@ export default withEnglishFallback({
mongoDriverLegacy: "Legacy",
serviceName: "Servicio/SID",
serviceNameOnly: "Nombre de servicio",
oracleTnsAlias: "Alias de red",
oracleTnsAliasPlaceholder: "Alias de tnsnames.ora",
oracleTnsAdminPlaceholder: "Directorio que contiene tnsnames.ora",
oracleTnsAdminBrowse: "Seleccionar tnsnames.ora",
oracleTnsPathHint: "La ruta la lee el backend de DBX/Oracle Agent. En escritorio es local; en Web y Docker pertenece al servidor.",
oracleTnsAliasRequired: "El modo Oracle TNS requiere un alias de red.",
oracleTnsAdminRequired: "El modo Oracle TNS requiere el directorio TNS_ADMIN que contiene tnsnames.ora.",
oracleTnsTransportUnsupported: "Oracle TNS no se puede combinar con túneles SSH, proxy o HTTP. Elimine la capa de transporte o use Servicio/SID.",
version: "Versión",
driverInstallHintPrefix: "Instala el driver requerido desde ",
driverInstallHintSuffix: " en la barra superior antes de conectar.",

View File

@ -211,6 +211,14 @@ export default withEnglishFallback({
mongoDriverLegacy: "Legacy",
serviceName: "Servizio/SID",
serviceNameOnly: "Nome Servizio",
oracleTnsAlias: "Alias di rete",
oracleTnsAliasPlaceholder: "Alias da tnsnames.ora",
oracleTnsAdminPlaceholder: "Directory contenente tnsnames.ora",
oracleTnsAdminBrowse: "Seleziona tnsnames.ora",
oracleTnsPathHint: "Il percorso viene letto dal backend DBX/Oracle Agent. Sul desktop è locale; su Web e Docker appartiene al server.",
oracleTnsAliasRequired: "La modalità Oracle TNS richiede un alias di rete.",
oracleTnsAdminRequired: "La modalità Oracle TNS richiede la directory TNS_ADMIN contenente tnsnames.ora.",
oracleTnsTransportUnsupported: "Oracle TNS non può usare tunnel SSH, proxy o HTTP. Rimuovi il livello di trasporto o usa Servizio/SID.",
version: "Versione",
driverInstallHintPrefix: "Installa il driver richiesto da ",
driverInstallHintSuffix: " nella barra degli strumenti superiore prima di connetterti.",

View File

@ -211,6 +211,14 @@ export default withEnglishFallback({
mongoDriverLegacy: "レガシー",
serviceName: "サービス/SID",
serviceNameOnly: "サービス名",
oracleTnsAlias: "ネットワーク・エイリアス",
oracleTnsAliasPlaceholder: "tnsnames.ora のエイリアス",
oracleTnsAdminPlaceholder: "tnsnames.ora を含むディレクトリ",
oracleTnsAdminBrowse: "tnsnames.ora を選択",
oracleTnsPathHint: "このパスは DBX バックエンド/Oracle Agent が読み取ります。デスクトップではローカルパス、Web/Docker ではサーバーパスを指定してください。",
oracleTnsAliasRequired: "Oracle TNS モードではネットワーク・エイリアスが必要です。",
oracleTnsAdminRequired: "Oracle TNS モードでは tnsnames.ora を含む TNS_ADMIN ディレクトリが必要です。",
oracleTnsTransportUnsupported: "Oracle TNS 接続は SSH、プロキシ、HTTP トンネルと併用できません。トランスポート層を削除するか、サービス名/SID モードを使用してください。",
version: "バージョン",
driverInstallHintPrefix: "接続前に上部ツールバーの",
driverInstallHintSuffix: "から必要なドライバーをインストールしてください。",

View File

@ -212,6 +212,14 @@ export default withEnglishFallback({
mongoDriverLegacy: "Legacy",
serviceName: "Service/SID",
serviceNameOnly: "Service Name",
oracleTnsAlias: "Alias de rede",
oracleTnsAliasPlaceholder: "Alias do tnsnames.ora",
oracleTnsAdminPlaceholder: "Diretório que contém tnsnames.ora",
oracleTnsAdminBrowse: "Selecionar tnsnames.ora",
oracleTnsPathHint: "O caminho é lido pelo backend do DBX/Oracle Agent. No desktop é local; no Web e Docker pertence ao servidor.",
oracleTnsAliasRequired: "O modo Oracle TNS requer um alias de rede.",
oracleTnsAdminRequired: "O modo Oracle TNS requer o diretório TNS_ADMIN que contém tnsnames.ora.",
oracleTnsTransportUnsupported: "Oracle TNS não pode usar túneis SSH, proxy ou HTTP. Remova a camada de transporte ou use Serviço/SID.",
version: "Versão",
driverInstallHintPrefix: "Instale o driver necessário em ",
driverInstallHintSuffix: " na barra de ferramentas superior antes de conectar.",

View File

@ -212,6 +212,14 @@ export default withEnglishFallback({
mongoDriverLegacy: "旧版兼容",
serviceName: "服务名/SID",
serviceNameOnly: "服务名",
oracleTnsAlias: "网络别名",
oracleTnsAliasPlaceholder: "tnsnames.ora 中的别名",
oracleTnsAdminPlaceholder: "包含 tnsnames.ora 的目录",
oracleTnsAdminBrowse: "选择 tnsnames.ora",
oracleTnsPathHint: "该路径由 DBX 后端/Oracle Agent 读取。桌面端填写本机路径Web 和 Docker 部署填写服务器路径。",
oracleTnsAliasRequired: "Oracle TNS 模式需要填写网络别名。",
oracleTnsAdminRequired: "Oracle TNS 模式需要填写包含 tnsnames.ora 的 TNS_ADMIN 目录。",
oracleTnsTransportUnsupported: "Oracle TNS 连接不能与 SSH、代理或 HTTP 隧道组合使用。请移除传输层,或改用服务名/SID 模式。",
elasticsearchDirectMode: "直连",
elasticsearchKibanaProxyMode: "Kibana 代理",
elasticsearchKibanaHost: "Kibana 主机",

View File

@ -212,6 +212,14 @@ export default withEnglishFallback({
mongoDriverLegacy: "舊版相容",
serviceName: "服務/SID",
serviceNameOnly: "服務名稱",
oracleTnsAlias: "網路別名",
oracleTnsAliasPlaceholder: "tnsnames.ora 中的別名",
oracleTnsAdminPlaceholder: "包含 tnsnames.ora 的目錄",
oracleTnsAdminBrowse: "選擇 tnsnames.ora",
oracleTnsPathHint: "此路徑由 DBX 後端/Oracle Agent 讀取。桌面版填寫本機路徑Web 和 Docker 部署填寫伺服器路徑。",
oracleTnsAliasRequired: "Oracle TNS 模式需要填寫網路別名。",
oracleTnsAdminRequired: "Oracle TNS 模式需要填寫包含 tnsnames.ora 的 TNS_ADMIN 目錄。",
oracleTnsTransportUnsupported: "Oracle TNS 連線不能與 SSH、代理或 HTTP Tunnel 組合使用。請移除傳輸層,或改用服務名稱/SID 模式。",
version: "版本",
driverInstallHintPrefix: "需要在上方導覽列「",
driverInstallHintSuffix: "」中安裝對應的驅動程式才能連線。",

View File

@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { buildOracleTnsConnectionString, normalizeOracleTnsAdminPath, parseOracleTnsConnectionString } from "./oracleTnsConnection";
describe("Oracle TNS connection settings", () => {
it("round-trips an alias and TNS_ADMIN directory", () => {
const value = buildOracleTnsConnectionString("DBX_FAILOVER", "C:\\oracle network\\admin");
expect(parseOracleTnsConnectionString(value)).toEqual({
alias: "DBX_FAILOVER",
tnsAdmin: "C:\\oracle network\\admin",
});
});
it("normalizes a selected tnsnames.ora file to its parent directory", () => {
expect(normalizeOracleTnsAdminPath("C:\\oracle\\network\\admin\\tnsnames.ora")).toBe("C:\\oracle\\network\\admin");
expect(normalizeOracleTnsAdminPath("C:\\tnsnames.ora")).toBe("C:\\");
expect(normalizeOracleTnsAdminPath("/opt/oracle/network/admin/tnsnames.ora")).toBe("/opt/oracle/network/admin");
});
it("does not reinterpret service, SID, or descriptor JDBC URLs as TNS aliases", () => {
expect(parseOracleTnsConnectionString("jdbc:oracle:thin:@//db.example.com:1521/ORCLPDB1")).toBeNull();
expect(parseOracleTnsConnectionString("jdbc:oracle:thin:@db.example.com:1521:ORCL")).toBeNull();
expect(parseOracleTnsConnectionString("jdbc:oracle:thin:@(DESCRIPTION=(ADDRESS=(HOST=db.example.com)))")).toBeNull();
});
it("ignores malformed encoded aliases instead of breaking connection editing", () => {
expect(parseOracleTnsConnectionString("jdbc:oracle:thin:@DBX%ZZ?TNS_ADMIN=%2Fopt%2Foracle")).toBeNull();
});
});

View File

@ -0,0 +1,43 @@
export interface OracleTnsConnectionConfig {
alias: string;
tnsAdmin: string;
}
const ORACLE_TNS_JDBC_PREFIX = "jdbc:oracle:thin:@";
export function parseOracleTnsConnectionString(value?: string): OracleTnsConnectionConfig | null {
const source = value?.trim() || "";
if (!source.toLowerCase().startsWith(ORACLE_TNS_JDBC_PREFIX)) return null;
const target = source.slice(ORACLE_TNS_JDBC_PREFIX.length);
if (!target || target.startsWith("(") || target.startsWith("//") || target.includes(":")) return null;
const [rawAlias, rawQuery = ""] = target.split("?", 2);
let alias = "";
try {
alias = decodeURIComponent(rawAlias).trim();
} catch {
return null;
}
const tnsAdmin = new URLSearchParams(rawQuery).get("TNS_ADMIN")?.trim() || "";
if (!alias) return null;
return { alias, tnsAdmin };
}
export function buildOracleTnsConnectionString(alias: string, tnsAdmin: string): string {
const normalizedAlias = alias.trim();
const normalizedTnsAdmin = normalizeOracleTnsAdminPath(tnsAdmin);
const query = new URLSearchParams({ TNS_ADMIN: normalizedTnsAdmin });
return `${ORACLE_TNS_JDBC_PREFIX}${encodeURIComponent(normalizedAlias)}?${query.toString()}`;
}
export function normalizeOracleTnsAdminPath(value: string): string {
const path = value.trim();
if (!path) return "";
if (!/(^|[\\/])tnsnames\.ora$/i.test(path)) return path;
const parent = path.replace(/[\\/]tnsnames\.ora$/i, "");
if (/^[A-Za-z]:$/.test(parent)) return `${parent}\\`;
if (parent) return parent;
return path.startsWith("\\") ? "\\" : "/";
}

View File

@ -2,6 +2,7 @@ import { isSchemaAware, isSingleDatabase } from "@/lib/database/databaseFeatureS
import { extractIdentifierPartsAt, isSqlKeyword, sqlObjectNavigationTarget, type SqlObjectNavigationTarget, type SqlObjectNavigationType } from "@/lib/sql/sqlNavigation";
import type { ActiveTabSidebarTarget } from "@/lib/sidebar/sidebarActiveTabTarget";
import type { SqlCompletionTable } from "@/lib/sql/sqlCompletion";
import { sqlSemanticDialectFor } from "@/lib/sql/semantic/dialect";
import type { DatabaseType, QueryTab, TreeNode } from "@/types/database";
export interface QueryCursorTableCandidate {
@ -53,7 +54,9 @@ export function queryCursorTableCandidate(tab: QueryTab | undefined | null, data
}
export function queryTableCandidateAtSqlPosition(input: QueryTableCandidateAtPositionInput): QueryCursorTableCandidate | null {
const parts = extractQualifiedIdentifierPartsAt(input.sql, input.position).map((part) => part.value);
const dialect = sqlSemanticDialectFor({ databaseType: input.databaseType });
// View-data SQL quotes resolved names, so fold only unquoted input exactly as the database does before quoting it again.
const parts = extractQualifiedIdentifierPartsAt(input.sql, input.position).map((part) => dialect.normalizeIdentifier(part.value, part.quoted));
if (parts.length === 0) return null;
const tableName = parts[parts.length - 1];

View File

@ -140,7 +140,7 @@ export interface ConnectionConfig {
client_cert_path?: string;
client_key_path?: string;
sysdba?: boolean;
oracle_connection_type?: "service_name" | "sid";
oracle_connection_type?: "service_name" | "sid" | "tns";
connection_string?: string;
jdbc_driver_class?: string;
jdbc_driver_paths?: string[];

View File

@ -262,6 +262,11 @@ pub fn oracle_alternate_connect_configs(config: &ConnectionConfig, err: &str) ->
if config.db_type != DatabaseType::Oracle {
return Vec::new();
}
if config.oracle_connection_type.as_deref() == Some("tns") {
// TNS owns its complete address/failover descriptor; host-based retries would
// replace the configured alias with unrelated Service Name/SID URLs.
return Vec::new();
}
if config.connection_string.as_deref().is_some_and(|value| !value.trim().is_empty()) {
return Vec::new();
}
@ -804,6 +809,20 @@ mod tests {
assert_eq!(service["connection_string"], "jdbc:oracle:thin:@//oracle.example.com:1521/ORCL");
}
#[test]
fn oracle_tns_does_not_retry_with_host_based_descriptors() {
let mut cfg = config(DatabaseType::Oracle, Some("DBX_FAILOVER"));
cfg.oracle_connection_type = Some("tns".to_string());
cfg.connection_string =
Some("jdbc:oracle:thin:@DBX_FAILOVER?TNS_ADMIN=%2Fopt%2Foracle%2Fnetwork%2Fadmin".to_string());
assert!(oracle_alternate_connect_configs(
&cfg,
"ORA-12514: listener does not currently know of service requested"
)
.is_empty());
}
#[test]
fn oracle_sys_user_connects_as_sysdba_for_agent_protocol() {
let mut cfg = config(DatabaseType::Oracle, Some("ORCLPDB1"));

View File

@ -1796,6 +1796,11 @@ impl AppState {
if transport_layers.is_empty() {
return Ok((config.host.clone(), config.port));
}
if config.uses_oracle_tns() {
// A TNS descriptor may contain several failover addresses, so rewriting it
// through one local tunnel endpoint would silently break Oracle Net routing.
return Err("Oracle TNS connections cannot be combined with SSH, proxy, or HTTP tunnel layers. Remove the transport layer or use Service Name/SID mode.".to_string());
}
let (remote_host, remote_port) = connection_remote_endpoint(config);
let local_port = db::transport_layer_tunnel::start_transport_layers(
@ -5547,6 +5552,20 @@ for line in sys.stdin:
}
}
#[tokio::test]
async fn oracle_tns_connection_rejects_transport_layers() {
let (state, dir) = test_app_state().await;
let mut config = mysql_config(Some("DBX_FAILOVER"));
config.db_type = DatabaseType::Oracle;
config.oracle_connection_type = Some("tns".to_string());
config.transport_layers = vec![TransportLayerConfig::Ssh(ssh_layer("tns-tunnel", ""))];
let error = state.connection_host_port("oracle-tns", &config).await.unwrap_err();
assert!(error.contains("cannot be combined with SSH, proxy, or HTTP tunnel"));
let _ = std::fs::remove_dir_all(dir);
}
#[tokio::test]
async fn resolved_transport_layers_substitutes_shared_profiles() {
let (state, dir) = test_app_state().await;

View File

@ -810,6 +810,11 @@ impl ConnectionConfig {
!self.effective_transport_layers().is_empty()
}
pub fn uses_oracle_tns(&self) -> bool {
self.db_type == DatabaseType::Oracle
&& self.oracle_connection_type.as_deref().is_some_and(|mode| mode.eq_ignore_ascii_case("tns"))
}
pub fn has_effective_ssh_tunnels(&self) -> bool {
self.effective_transport_layers().iter().any(|layer| matches!(layer, TransportLayerConfig::Ssh(_)))
}

View File

@ -3243,6 +3243,115 @@ fn builds_mysql_trigger_changes() {
);
}
#[test]
fn builds_oracle_multi_event_row_trigger_change() {
let mut existing = trigger(
"DBX_TRIGGER_4320_AUDIT",
"AFTER EACH ROW",
"INSERT OR UPDATE OR DELETE",
"DECLARE\n v_event VARCHAR2(10);\nBEGIN\n v_event := CASE WHEN INSERTING THEN 'INSERT' WHEN UPDATING THEN 'UPDATE' ELSE 'DELETE' END;\nEND;",
);
existing.original = Some(TriggerInfo {
name: "DBX_TRIGGER_4320_AUDIT".to_string(),
event: "INSERT OR UPDATE OR DELETE".to_string(),
timing: "AFTER EACH ROW".to_string(),
statement: Some("BEGIN\n NULL;\nEND;".to_string()),
});
let result = build_table_structure_change_sql(TableStructureSqlOptions {
database_type: Some(DatabaseType::Oracle),
schema: Some("APP".to_string()),
table_name: "DBX_TRIGGER_4320".to_string(),
columns: Vec::new(),
indexes: Vec::new(),
foreign_keys: Vec::new(),
triggers: vec![existing],
table_comment: None,
original_table_comment: None,
});
assert_eq!(result.warnings, Vec::<String>::new());
assert_eq!(
result.statements,
vec![
"CREATE OR REPLACE TRIGGER \"APP\".\"DBX_TRIGGER_4320_AUDIT\" AFTER INSERT OR UPDATE OR DELETE ON \"APP\".\"DBX_TRIGGER_4320\"\nFOR EACH ROW\nDECLARE\n v_event VARCHAR2(10);\nBEGIN\n v_event := CASE WHEN INSERTING THEN 'INSERT' WHEN UPDATING THEN 'UPDATE' ELSE 'DELETE' END;\nEND;",
]
);
}
#[test]
fn builds_oracle_statement_trigger_without_row_clause() {
let result = build_table_structure_change_sql(TableStructureSqlOptions {
database_type: Some(DatabaseType::Oracle),
schema: Some("APP".to_string()),
table_name: "ORDERS".to_string(),
columns: Vec::new(),
indexes: Vec::new(),
foreign_keys: Vec::new(),
triggers: vec![trigger("ORDERS_AUDIT", "BEFORE STATEMENT", "UPDATE OF STATUS", "BEGIN\n NULL;\nEND;\n/")],
table_comment: None,
original_table_comment: None,
});
assert_eq!(result.warnings, Vec::<String>::new());
assert_eq!(
result.statements,
vec![
"CREATE OR REPLACE TRIGGER \"APP\".\"ORDERS_AUDIT\" BEFORE UPDATE OF STATUS ON \"APP\".\"ORDERS\"\nBEGIN\n NULL;\nEND;",
]
);
}
#[test]
fn renaming_oracle_trigger_drops_old_name_before_create() {
let mut existing = trigger("ORDERS_AUDIT_V2", "AFTER EACH ROW", "INSERT", "BEGIN\n NULL;\nEND;");
existing.original = Some(TriggerInfo {
name: "ORDERS_AUDIT".to_string(),
event: "INSERT".to_string(),
timing: "AFTER EACH ROW".to_string(),
statement: Some("BEGIN\n NULL;\nEND;".to_string()),
});
let result = build_table_structure_change_sql(TableStructureSqlOptions {
database_type: Some(DatabaseType::Oracle),
schema: Some("APP".to_string()),
table_name: "ORDERS".to_string(),
columns: Vec::new(),
indexes: Vec::new(),
foreign_keys: Vec::new(),
triggers: vec![existing],
table_comment: None,
original_table_comment: None,
});
assert_eq!(result.warnings, Vec::<String>::new());
assert_eq!(
result.statements,
vec![
"DROP TRIGGER \"APP\".\"ORDERS_AUDIT\";",
"CREATE OR REPLACE TRIGGER \"APP\".\"ORDERS_AUDIT_V2\" AFTER INSERT ON \"APP\".\"ORDERS\"\nFOR EACH ROW\nBEGIN\n NULL;\nEND;",
]
);
}
#[test]
fn rejects_unsupported_oracle_compound_trigger_shape() {
let result = build_table_structure_change_sql(TableStructureSqlOptions {
database_type: Some(DatabaseType::Oracle),
schema: Some("APP".to_string()),
table_name: "ORDERS".to_string(),
columns: Vec::new(),
indexes: Vec::new(),
foreign_keys: Vec::new(),
triggers: vec![trigger("ORDERS_CT", "COMPOUND", "UPDATE", "BEGIN\n NULL;\nEND;")],
table_comment: None,
original_table_comment: None,
});
assert!(result.statements.is_empty());
assert_eq!(result.warnings, vec!["Unsupported Oracle trigger timing \"COMPOUND\"."]);
}
#[test]
fn mysql_varchar_default_is_quoted() {
let mut col = column("name");

View File

@ -7,19 +7,20 @@ pub(super) fn build_trigger_sql(options: &TableStructureSqlOptions, warnings: &m
return Vec::new();
}
let dialect = super::dialect::capabilities_for(options.database_type).dialect;
let database_label = database_label(options.database_type);
if super::dialect::capabilities_for(options.database_type).dialect != StructureDialect::Mysql {
if !matches!(dialect, StructureDialect::Mysql | StructureDialect::Oracle) {
warnings.push(format!("Editing triggers is not supported for {database_label} from this editor."));
return Vec::new();
}
let table = qualified_table(StructureDialect::Mysql, options.schema.as_deref(), &options.table_name);
let table = qualified_table(dialect, options.schema.as_deref(), &options.table_name);
let mut statements = Vec::new();
for trigger in &options.triggers {
if trigger.marked_for_drop {
if let Some(original) = &trigger.original {
statements.push(drop_trigger_sql(options.schema.as_deref(), &original.name));
statements.push(drop_trigger_sql(dialect, options.schema.as_deref(), &original.name));
}
continue;
}
@ -28,10 +29,13 @@ pub(super) fn build_trigger_sql(options: &TableStructureSqlOptions, warnings: &m
if !has_trigger_change(trigger, original) {
continue;
}
statements.push(drop_trigger_sql(options.schema.as_deref(), &original.name));
// Oracle can replace a trigger in place; renames still require dropping the old object.
if dialect != StructureDialect::Oracle || clean(&trigger.name) != clean(&original.name) {
statements.push(drop_trigger_sql(dialect, options.schema.as_deref(), &original.name));
}
}
if let Some(sql) = create_trigger_sql(&table, trigger, warnings) {
if let Some(sql) = create_trigger_sql(dialect, options.schema.as_deref(), &table, trigger, warnings) {
statements.push(sql);
}
}
@ -46,45 +50,125 @@ fn has_trigger_change(trigger: &EditableStructureTrigger, original: &TriggerInfo
|| normalize_statement(&trigger.statement) != normalize_statement(original.statement.as_deref().unwrap_or(""))
}
fn drop_trigger_sql(schema: Option<&str>, name: &str) -> String {
fn drop_trigger_sql(dialect: StructureDialect, schema: Option<&str>, name: &str) -> String {
let qualified_name = if schema.is_some_and(|schema| !schema.trim().is_empty()) {
format!(
"{}.{}",
quote_ident(StructureDialect::Mysql, schema.unwrap()),
quote_ident(StructureDialect::Mysql, name)
)
format!("{}.{}", quote_ident(dialect, schema.unwrap()), quote_ident(dialect, name))
} else {
quote_ident(StructureDialect::Mysql, name)
quote_ident(dialect, name)
};
format!("DROP TRIGGER {qualified_name};")
}
fn create_trigger_sql(table: &str, trigger: &EditableStructureTrigger, warnings: &mut Vec<String>) -> Option<String> {
fn create_trigger_sql(
dialect: StructureDialect,
schema: Option<&str>,
table: &str,
trigger: &EditableStructureTrigger,
warnings: &mut Vec<String>,
) -> Option<String> {
let name = clean(&trigger.name);
let timing = normalize_keyword(&trigger.timing);
let event = normalize_keyword(&trigger.event);
let event = clean(&trigger.event);
let statement = clean(&trigger.statement);
if name.is_empty() || timing.is_empty() || event.is_empty() || statement.is_empty() {
warnings.push("Trigger name, timing, event, and statement are required.".to_string());
return None;
}
if !matches!(timing.as_str(), "BEFORE" | "AFTER") {
warnings.push(format!("Unsupported trigger timing \"{}\".", clean(&trigger.timing)));
match dialect {
StructureDialect::Mysql => create_mysql_trigger_sql(table, &name, &timing, &event, &statement, warnings),
StructureDialect::Oracle => {
create_oracle_trigger_sql(schema, table, &name, &timing, &event, &statement, warnings)
}
_ => None,
}
}
fn create_mysql_trigger_sql(
table: &str,
name: &str,
timing: &str,
event: &str,
statement: &str,
warnings: &mut Vec<String>,
) -> Option<String> {
let event = normalize_keyword(event);
if !matches!(timing, "BEFORE" | "AFTER") {
warnings.push(format!("Unsupported trigger timing \"{timing}\"."));
return None;
}
if !matches!(event.as_str(), "INSERT" | "UPDATE" | "DELETE") {
warnings.push(format!("Unsupported trigger event \"{}\".", clean(&trigger.event)));
warnings.push(format!("Unsupported trigger event \"{}\".", clean(&event)));
return None;
}
Some(format!(
"CREATE TRIGGER {} {timing} {event} ON {table} FOR EACH ROW\n{};",
quote_ident(StructureDialect::Mysql, &name),
quote_ident(StructureDialect::Mysql, name),
statement.trim_end_matches(';').trim_end()
))
}
fn create_oracle_trigger_sql(
schema: Option<&str>,
table: &str,
name: &str,
timing: &str,
event: &str,
statement: &str,
warnings: &mut Vec<String>,
) -> Option<String> {
let Some((timing_clause, row_level)) = oracle_trigger_timing(timing) else {
warnings.push(format!("Unsupported Oracle trigger timing \"{timing}\"."));
return None;
};
if !is_supported_oracle_trigger_event(event) {
warnings.push(format!("Unsupported Oracle trigger event \"{}\".", clean(event)));
return None;
}
let trigger_name = if schema.is_some_and(|schema| !schema.trim().is_empty()) {
format!(
"{}.{}",
quote_ident(StructureDialect::Oracle, schema.unwrap()),
quote_ident(StructureDialect::Oracle, name)
)
} else {
quote_ident(StructureDialect::Oracle, name)
};
let row_clause = if row_level { "\nFOR EACH ROW" } else { "" };
let statement = statement.trim_end().trim_end_matches('/').trim_end().trim_end_matches(';').trim_end();
Some(format!(
"CREATE OR REPLACE TRIGGER {trigger_name} {timing_clause} {event} ON {table}{row_clause}\n{statement};"
))
}
fn oracle_trigger_timing(timing: &str) -> Option<(&'static str, bool)> {
match timing {
"BEFORE" | "BEFORE EACH ROW" => Some(("BEFORE", true)),
"AFTER" | "AFTER EACH ROW" => Some(("AFTER", true)),
"INSTEAD OF" | "INSTEAD OF EACH ROW" => Some(("INSTEAD OF", true)),
"BEFORE STATEMENT" => Some(("BEFORE", false)),
"AFTER STATEMENT" => Some(("AFTER", false)),
_ => None,
}
}
fn is_supported_oracle_trigger_event(event: &str) -> bool {
if event.contains([';', '\n', '\r']) || event.contains("--") || event.contains("/*") {
return false;
}
let upper = event.to_ascii_uppercase();
let clauses: Vec<&str> = upper.split(" OR ").map(str::trim).collect();
!clauses.is_empty()
&& clauses.iter().all(|clause| {
matches!(*clause, "INSERT" | "DELETE")
|| *clause == "UPDATE"
|| clause.strip_prefix("UPDATE OF ").is_some_and(|columns| !columns.trim().is_empty())
})
}
fn normalize_keyword(value: &str) -> String {
clean(value).to_ascii_uppercase()
}

View File

@ -11,6 +11,7 @@ import {
resolveQueryContextCandidateDatabase,
resolveQueryContextObjectTarget,
} from "../../apps/desktop/src/lib/sql/queryCursorTableTarget.ts";
import { qualifiedTableName } from "../../apps/desktop/src/lib/table/tableSelectSql.ts";
import type { QueryTab, TreeNode } from "../../apps/desktop/src/types/database.ts";
function queryTab(sql: string, head: number, schema = "public"): QueryTab {
@ -97,6 +98,52 @@ test("builds three-part candidates at an explicit context-menu position", () =>
});
});
test("folds unquoted Oracle-compatible identifiers before view-data quoting", () => {
for (const databaseType of ["oracle", "dameng"] as const) {
const sql = "select * from app.order_items";
const candidate = queryTableCandidateAtSqlPosition({ connectionId: "conn-1", database: "service", databaseType, sql, position: sql.indexOf("order") });
assert.deepEqual(candidate, {
connectionId: "conn-1",
database: "service",
schema: "APP",
tableName: "ORDER_ITEMS",
});
assert.equal(qualifiedTableName({ databaseType, schema: candidate?.schema, tableName: candidate?.tableName ?? "" }), '"APP"."ORDER_ITEMS"');
}
});
test("preserves explicitly quoted Oracle-compatible identifier case", () => {
const sql = 'select * from "App"."Order_Items"';
const candidate = queryTableCandidateAtSqlPosition({ connectionId: "conn-1", database: "service", databaseType: "oracle", sql, position: sql.indexOf("Order") });
assert.deepEqual(candidate, {
connectionId: "conn-1",
database: "service",
schema: "App",
tableName: "Order_Items",
});
assert.equal(qualifiedTableName({ databaseType: "oracle", schema: candidate?.schema, tableName: candidate?.tableName ?? "" }), '"App"."Order_Items"');
});
test("folds PostgreSQL unquoted identifiers without changing quoted names", () => {
const unquotedSql = "select * from Reporting.Users";
const quotedSql = 'select * from "Reporting"."Users"';
assert.deepEqual(queryTableCandidateAtSqlPosition({ connectionId: "conn-1", database: "app", databaseType: "postgres", sql: unquotedSql, position: unquotedSql.indexOf("Users") }), {
connectionId: "conn-1",
database: "app",
schema: "reporting",
tableName: "users",
});
assert.deepEqual(queryTableCandidateAtSqlPosition({ connectionId: "conn-1", database: "app", databaseType: "postgres", sql: quotedSql, position: quotedSql.indexOf("Users") }), {
connectionId: "conn-1",
database: "app",
schema: "Reporting",
tableName: "Users",
});
});
test("parses escaped quotes inside qualified identifiers", () => {
const sql = 'select * from "warehouse"."reporting"."Daily ""Sales"""';