diff --git a/agents/drivers/oracle-go/main.go b/agents/drivers/oracle-go/main.go index 5c5ae60376..a0d5be457b 100644 --- a/agents/drivers/oracle-go/main.go +++ b/agents/drivers/oracle-go/main.go @@ -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) diff --git a/agents/drivers/oracle-go/main_test.go b/agents/drivers/oracle-go/main_test.go index b2d8be9495..fe6e26aad7 100644 --- a/agents/drivers/oracle-go/main_test.go +++ b/agents/drivers/oracle-go/main_test.go @@ -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 } diff --git a/agents/drivers/oracle-go/tns.go b/agents/drivers/oracle-go/tns.go new file mode 100644 index 0000000000..978a6d656f --- /dev/null +++ b/agents/drivers/oracle-go/tns.go @@ -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 +} diff --git a/agents/drivers/oracle-go/tns_test.go b/agents/drivers/oracle-go/tns_test.go new file mode 100644 index 0000000000..f854f6a555 --- /dev/null +++ b/agents/drivers/oracle-go/tns_test.go @@ -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) + } +} diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index 35e7bb6de4..43080a4491 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -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("file"); const dremioConnectionMode = ref("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(() => ({ 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, 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) { -
+
@@ -5700,6 +5738,26 @@ function openExternalUrl(url: string) {
+
+ +
+ + + + + + {{ t("connection.oracleTnsAdminBrowse") }} + +
+
+ +
+ +

{{ t("connection.oracleTnsPathHint") }}

+
+