feat(oracle): add TNS connections and trigger editing

This commit is contained in:
t8y2 committed 2026-07-23 23:03:45 +08:00
1 parent 9a5edae219
commit 3b004abb1e
23 files changed
+980 -53

No files matched your search

+84 -13
View File
@@ -149,6 +149,22 @@ const oracleListObjectsOrderSQL = `ORDER BY CASE OBJECT_TYPE
ELSE 5 ELSE 5
END, OBJECT_NAME` END, OBJECT_NAME`
const oracleListObjectsSQL = oracleListObjectsBaseSQL + "\n" + oracleListObjectsOrderSQL 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 { type request struct {
ID json.RawMessage `json:"id"` ID json.RawMessage `json:"id"`
@@ -352,9 +368,10 @@ type foreignKeyInfo struct {
} }
type triggerInfo struct { type triggerInfo struct {
Name string `json:"name"` Name string `json:"name"`
Event string `json:"event"` Event string `json:"event"`
Timing string `json:"timing"` Timing string `json:"timing"`
Statement *string `json:"statement,omitempty"`
} }
type server struct { type server struct {
@@ -806,10 +823,12 @@ func openDB(params connectParams) (*sql.DB, error) {
} }
func openDBWithStringConverter(params connectParams, stringConverter converters.IStringConverter) (*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 var db *sql.DB
if stringConverter == nil { if stringConverter == nil {
var err error
db, err = sql.Open("oracle", dsn) db, err = sql.Open("oracle", dsn)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -2008,26 +2027,78 @@ func (s *server) listTriggers(schema, table string) ([]triggerInfo, error) {
return nil, err return nil, err
} }
table = strings.ToUpper(strings.TrimSpace(table)) table = strings.ToUpper(strings.TrimSpace(table))
rows, err := s.queryRows(` rows, err := s.queryRows(oracleListTriggersSQL, []any{schema, table})
SELECT TRIGGER_NAME, TRIGGERING_EVENT, TRIGGER_TYPE
FROM ALL_TRIGGERS
WHERE OWNER = :1 AND TABLE_NAME = :2
ORDER BY TRIGGER_NAME`, []any{schema, table})
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer s.closeRows(rows) defer s.closeRows(rows)
var result []triggerInfo 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() { for rows.Next() {
var item triggerInfo var name, event, timing string
if err := rows.Scan(&item.Name, &item.Event, &item.Timing); err != nil { var description, lineText sql.NullString
var line sql.NullInt64
if err := rows.Scan(&name, &event, &timing, &description, &line, &lineText); err != nil {
return nil, err 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() 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) { func (s *server) getObjectSource(schema, name, objectType string) (map[string]any, error) {
var err error var err error
schema, err = s.normalizeSchema(schema) schema, err = s.normalizeSchema(schema)
+54 -5
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) { func TestOracleFuzzyLikePatternEscapesSpecialCharacters(t *testing.T) {
got := oracleFuzzyLikePattern(`a_%\b`) got := oracleFuzzyLikePattern(`a_%\b`)
want := `%a%\_%\%%\\%b%` want := `%a%\_%\%%\\%b%`
@@ -1154,7 +1204,6 @@ func contains(values []string, target string) bool {
return false return false
} }
// -- fake drivers for timeout tests -- // -- fake drivers for timeout tests --
func init() { func init() {
@@ -1174,7 +1223,7 @@ type oracleDMLConn struct{}
func (c *oracleDMLConn) Prepare(query string) (driver.Stmt, error) { func (c *oracleDMLConn) Prepare(query string) (driver.Stmt, error) {
return nil, errors.New("use ExecContext directly") 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") } func (c *oracleDMLConn) Begin() (driver.Tx, error) { return nil, errors.New("not supported") }
var _ driver.ExecerContext = (*oracleDMLConn)(nil) var _ driver.ExecerContext = (*oracleDMLConn)(nil)
@@ -1196,13 +1245,13 @@ type oracleFastConn struct{}
func (c *oracleFastConn) Prepare(query string) (driver.Stmt, error) { func (c *oracleFastConn) Prepare(query string) (driver.Stmt, error) {
return &oracleFastStmt{}, nil 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") } func (c *oracleFastConn) Begin() (driver.Tx, error) { return nil, errors.New("not supported") }
type oracleFastStmt struct{} type oracleFastStmt struct{}
func (s *oracleFastStmt) Close() error { return nil } func (s *oracleFastStmt) Close() error { return nil }
func (s *oracleFastStmt) NumInput() int { return -1 } func (s *oracleFastStmt) NumInput() int { return -1 }
func (s *oracleFastStmt) Exec(args []driver.Value) (driver.Result, error) { func (s *oracleFastStmt) Exec(args []driver.Value) (driver.Result, error) {
return driver.ResultNoRows, nil return driver.ResultNoRows, nil
} }
+224
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
}
+99
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)
}
}
@@ -29,6 +29,7 @@ import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import * as api from "@/lib/backend/api"; import * as api from "@/lib/backend/api";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { applyParsedConnectionUrl, normalizeMongoConnectionString, parseConnectionUrl } from "@/lib/connection/connectionUrl"; 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 { parseConnectionDeepLink, type ConnectionDeepLinkDraft } from "@/lib/connection/connectionDeepLink";
import { connectionUrlPlaceholder as getUrlPlaceholder } from "@/lib/connection/connectionPresentation"; import { connectionUrlPlaceholder as getUrlPlaceholder } from "@/lib/connection/connectionPresentation";
import { h2ConnectionModeForConfig, h2FileJdbcUrlWithPath, h2FilePathFromJdbcUrl, isH2SplitJdbcUrl, type H2ConnectionMode } from "@/lib/database/h2Connection"; import { h2ConnectionModeForConfig, h2FileJdbcUrlWithPath, h2FilePathFromJdbcUrl, isH2SplitJdbcUrl, type H2ConnectionMode } from "@/lib/database/h2Connection";
@@ -494,6 +495,7 @@ const selectedJdbcDriverPath = ref("");
const jdbcManualClasspathOpen = ref(false); const jdbcManualClasspathOpen = ref(false);
const connectionUrlInput = ref(""); const connectionUrlInput = ref("");
const appliedConnectionUrlInput = ref(""); const appliedConnectionUrlInput = ref("");
const oracleTnsAdminPath = ref("");
const oceanbaseSubMode = ref<"mysql" | "oracle">("mysql"); const oceanbaseSubMode = ref<"mysql" | "oracle">("mysql");
const h2ConnectionMode = ref<H2ConnectionMode>("file"); const h2ConnectionMode = ref<H2ConnectionMode>("file");
const dremioConnectionMode = ref<DremioConnectionMode>("legacy"); const dremioConnectionMode = ref<DremioConnectionMode>("legacy");
@@ -1764,6 +1766,7 @@ function applyProfile(val: string, preserveConnectionFields = false) {
} }
if (!preserveConnectionFields) { if (!preserveConnectionFields) {
oracleTnsAdminPath.value = "";
form.value.port = profile.port; form.value.port = profile.port;
setSqlServerPortExplicit(form.value, false); setSqlServerPortExplicit(form.value, false);
form.value.username = profile.user; form.value.username = profile.user;
@@ -1910,6 +1913,7 @@ watch(
visible_databases: config.visible_databases, visible_databases: config.visible_databases,
visible_schemas: config.visible_schemas, visible_schemas: config.visible_schemas,
}; };
oracleTnsAdminPath.value = parseOracleTnsConnectionString(config.connection_string)?.tnsAdmin || "";
productionProtectionEnabled.value = !!config.is_production || (config.production_databases?.length ?? 0) > 0; productionProtectionEnabled.value = !!config.is_production || (config.production_databases?.length ?? 0) > 0;
connectionUrlInput.value = config.db_type === "h2" && config.connection_string ? config.connection_string : ""; connectionUrlInput.value = config.db_type === "h2" && config.connection_string ? config.connection_string : "";
appliedConnectionUrlInput.value = connectionUrlInput.value.trim(); appliedConnectionUrlInput.value = connectionUrlInput.value.trim();
@@ -1988,12 +1992,14 @@ watch(
); );
const databaseLabel = computed(() => { 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 === "oracle") return t("connection.serviceName");
if (form.value.db_type === "influxdb" && influxDbVersion.value === "2") return "Bucket"; if (form.value.db_type === "influxdb" && influxDbVersion.value === "2") return "Bucket";
return t("connection.database"); return t("connection.database");
}); });
const databasePlaceholder = computed(() => { 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"); if (form.value.db_type === "kingbase") return t("connection.databasePlaceholderRequired");
const fallback = defaultDatabaseForProfile(); const fallback = defaultDatabaseForProfile();
if (!fallback) return t("connection.databasePlaceholder"); if (!fallback) return t("connection.databasePlaceholder");
@@ -2485,7 +2491,7 @@ const zookeeperConnectString = computed({
form.value.connection_string = normalizeZooKeeperConnectString(value); 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 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 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) })); 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); const parsed = parseConnectionUrl(input, selectedType.value);
form.value = applyParsedConnectionUrl(form.value, parsed); form.value = applyParsedConnectionUrl(form.value, parsed);
oracleTnsAdminPath.value = parseOracleTnsConnectionString(parsed.connectionString)?.tnsAdmin || "";
selectedType.value = parsed.driverProfile; selectedType.value = parsed.driverProfile;
customDriverName.value = isCustomCompatibleProfile() ? parsed.driverLabel : ""; customDriverName.value = isCustomCompatibleProfile() ? parsed.driverLabel : "";
mongoUseUrl.value = !!parsed.useMongoUrl; 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; normalized.connect_timeout_secs = Number.isFinite(timeout) && timeout > 0 ? timeout : 5;
return { type: "ssh", ...normalized }; 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); 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); const connectTimeout = Number(config.connect_timeout_secs);
config.connect_timeout_secs = Number.isFinite(connectTimeout) && connectTimeout > 0 ? connectTimeout : 10; config.connect_timeout_secs = Number.isFinite(connectTimeout) && connectTimeout > 0 ? connectTimeout : 10;
const queryTimeout = Number(config.query_timeout_secs); const queryTimeout = Number(config.query_timeout_secs);
@@ -3774,6 +3796,7 @@ function resetForm() {
selectedJdbcDriverPath.value = ""; selectedJdbcDriverPath.value = "";
connectionUrlInput.value = ""; connectionUrlInput.value = "";
appliedConnectionUrlInput.value = ""; appliedConnectionUrlInput.value = "";
oracleTnsAdminPath.value = "";
dialogStep.value = "select"; dialogStep.value = "select";
dbSearchQuery.value = ""; dbSearchQuery.value = "";
selectedDbCategory.value = "sql"; selectedDbCategory.value = "sql";
@@ -3822,6 +3845,7 @@ function applyConnectionDraftToConfig(config: Omit<ConnectionConfig, "id">, draf
function applyConnectionDraftToForm(draft: ConnectionDeepLinkDraft) { function applyConnectionDraftToForm(draft: ConnectionDeepLinkDraft) {
applyProfile(draft.driverProfile); applyProfile(draft.driverProfile);
form.value = applyConnectionDraftToConfig(form.value, draft); form.value = applyConnectionDraftToConfig(form.value, draft);
oracleTnsAdminPath.value = parseOracleTnsConnectionString(form.value.connection_string)?.tnsAdmin || "";
selectedType.value = draft.driverProfile; selectedType.value = draft.driverProfile;
if (form.value.db_type === "h2") { if (form.value.db_type === "h2") {
h2ConnectionMode.value = h2ConnectionModeForConfig(form.value); 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") { async function browseKafkaKerberosFile(target: "keytab" | "krb5") {
if (isTauriRuntime()) { if (isTauriRuntime()) {
const { open } = await import("@tauri-apps/plugin-dialog"); const { open } = await import("@tauri-apps/plugin-dialog");
@@ -5664,7 +5702,7 @@ function openExternalUrl(url: string) {
</div> </div>
</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> <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="form.host" class="col-span-2" />
<Input v-model.number="form.port" type="number" class="col-span-1" @input="markSqlServerPortExplicit" /> <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" /> <Input v-model="form.database" class="col-span-3" :placeholder="databasePlaceholder" />
</div> </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'"> <template v-if="form.db_type === 'hive'">
<div class="grid grid-cols-4 items-center gap-4"> <div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.hiveAuthMode") }}</Label> <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"> <div v-if="form.db_type === 'oracle'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.mode") }}</Label> <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 <button
type="button" type="button"
class="h-7 rounded-sm px-3 text-sm transition-colors" 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'" :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 !== 'sid'" :aria-pressed="form.oracle_connection_type === 'service_name' || !form.oracle_connection_type"
@click="form.oracle_connection_type = 'service_name'" @click="form.oracle_connection_type = 'service_name'"
> >
{{ t("connection.serviceNameOnly") }} {{ t("connection.serviceNameOnly") }}
@@ -5791,6 +5849,15 @@ function openExternalUrl(url: string) {
> >
SID SID
</button> </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>
</div> </div>
@@ -2090,7 +2090,8 @@ function canDropIndex(index: EditableStructureIndex): boolean {
} }
const canEditForeignKeys = computed(() => structureCapabilities.value.foreignKey); 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 { function generatedForeignKeyName(column = ""): string {
const table = structureIndexTableName() || "table"; const table = structureIndexTableName() || "table";
@@ -2138,14 +2139,14 @@ function canEditForeignKeyDraft(foreignKey: EditableStructureForeignKey): boolea
} }
function addTrigger() { function addTrigger() {
if (!canEditMysqlTriggers.value || triggersLoading.value) return; if (!canEditTriggers.value || triggersLoading.value) return;
activeTab.value = "triggers"; activeTab.value = "triggers";
triggers.value.push({ triggers.value.push({
id: `new:${uuid()}`, id: `new:${uuid()}`,
name: "", name: "",
timing: "BEFORE", timing: isOracleTriggerEditor.value ? "BEFORE EACH ROW" : "BEFORE",
event: "INSERT", event: "INSERT",
statement: "BEGIN\n \nEND", statement: isOracleTriggerEditor.value ? "BEGIN\n NULL;\nEND" : "BEGIN\n \nEND",
markedForDrop: false, markedForDrop: false,
}); });
} }
@@ -2160,7 +2161,7 @@ function toggleDropTrigger(trigger: EditableStructureTrigger) {
} }
function canEditTriggerDraft(trigger: EditableStructureTrigger): boolean { function canEditTriggerDraft(trigger: EditableStructureTrigger): boolean {
return !triggersLoading.value && canEditMysqlTriggers.value && !trigger.markedForDrop; return !triggersLoading.value && canEditTriggers.value && !trigger.markedForDrop;
} }
function primarySqlOperation(sql: string): string { function primarySqlOperation(sql: string): string {
@@ -2293,7 +2294,7 @@ function addItemForActiveTab(): boolean {
addForeignKey(); addForeignKey();
return true; return true;
} }
if (activeTab.value === "triggers" && canEditMysqlTriggers.value) { if (activeTab.value === "triggers" && canEditTriggers.value) {
addTrigger(); addTrigger();
return true; return true;
} }
@@ -2648,7 +2649,7 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
<Plus :class="structureIconClass" /> <Plus :class="structureIconClass" />
{{ t("structureEditor.addForeignKey") }} {{ t("structureEditor.addForeignKey") }}
</Button> </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" /> <Plus :class="structureIconClass" />
{{ t("structureEditor.addTrigger") }} {{ t("structureEditor.addTrigger") }}
</Button> </Button>
@@ -3193,9 +3194,10 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
</div> </div>
<div v-else class="space-y-1.5"> <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 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)" /> <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"> <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 /> <SelectValue />
</SelectTrigger> </SelectTrigger>
@@ -3203,7 +3205,8 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
<SelectItem v-for="timing in triggerTimingOptions" :key="timing" :value="timing">{{ timing }}</SelectItem> <SelectItem v-for="timing in triggerTimingOptions" :key="timing" :value="timing">{{ timing }}</SelectItem>
</SelectContent> </SelectContent>
</Select> </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"> <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 /> <SelectValue />
</SelectTrigger> </SelectTrigger>
+8
View File
@@ -210,6 +210,14 @@ export default {
mongoDriverLegacy: "Legacy", mongoDriverLegacy: "Legacy",
serviceName: "Service/SID", serviceName: "Service/SID",
serviceNameOnly: "Service Name", 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", elasticsearchDirectMode: "Direct",
elasticsearchKibanaProxyMode: "Kibana Proxy", elasticsearchKibanaProxyMode: "Kibana Proxy",
elasticsearchKibanaHost: "Kibana Host", elasticsearchKibanaHost: "Kibana Host",
+8
View File
@@ -212,6 +212,14 @@ export default withEnglishFallback({
mongoDriverLegacy: "Legacy", mongoDriverLegacy: "Legacy",
serviceName: "Servicio/SID", serviceName: "Servicio/SID",
serviceNameOnly: "Nombre de servicio", 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", version: "Versión",
driverInstallHintPrefix: "Instala el driver requerido desde ", driverInstallHintPrefix: "Instala el driver requerido desde ",
driverInstallHintSuffix: " en la barra superior antes de conectar.", driverInstallHintSuffix: " en la barra superior antes de conectar.",
+8
View File
@@ -211,6 +211,14 @@ export default withEnglishFallback({
mongoDriverLegacy: "Legacy", mongoDriverLegacy: "Legacy",
serviceName: "Servizio/SID", serviceName: "Servizio/SID",
serviceNameOnly: "Nome Servizio", 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", version: "Versione",
driverInstallHintPrefix: "Installa il driver richiesto da ", driverInstallHintPrefix: "Installa il driver richiesto da ",
driverInstallHintSuffix: " nella barra degli strumenti superiore prima di connetterti.", driverInstallHintSuffix: " nella barra degli strumenti superiore prima di connetterti.",
+8
View File
@@ -211,6 +211,14 @@ export default withEnglishFallback({
mongoDriverLegacy: "レガシー", mongoDriverLegacy: "レガシー",
serviceName: "サービス/SID", serviceName: "サービス/SID",
serviceNameOnly: "サービス名", 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: "バージョン", version: "バージョン",
driverInstallHintPrefix: "接続前に上部ツールバーの", driverInstallHintPrefix: "接続前に上部ツールバーの",
driverInstallHintSuffix: "から必要なドライバーをインストールしてください。", driverInstallHintSuffix: "から必要なドライバーをインストールしてください。",
+8
View File
@@ -212,6 +212,14 @@ export default withEnglishFallback({
mongoDriverLegacy: "Legacy", mongoDriverLegacy: "Legacy",
serviceName: "Service/SID", serviceName: "Service/SID",
serviceNameOnly: "Service Name", 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", version: "Versão",
driverInstallHintPrefix: "Instale o driver necessário em ", driverInstallHintPrefix: "Instale o driver necessário em ",
driverInstallHintSuffix: " na barra de ferramentas superior antes de conectar.", driverInstallHintSuffix: " na barra de ferramentas superior antes de conectar.",
+8
View File
@@ -212,6 +212,14 @@ export default withEnglishFallback({
mongoDriverLegacy: "旧版兼容", mongoDriverLegacy: "旧版兼容",
serviceName: "服务名/SID", serviceName: "服务名/SID",
serviceNameOnly: "服务名", 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: "直连", elasticsearchDirectMode: "直连",
elasticsearchKibanaProxyMode: "Kibana 代理", elasticsearchKibanaProxyMode: "Kibana 代理",
elasticsearchKibanaHost: "Kibana 主机", elasticsearchKibanaHost: "Kibana 主机",
+8
View File
@@ -212,6 +212,14 @@ export default withEnglishFallback({
mongoDriverLegacy: "舊版相容", mongoDriverLegacy: "舊版相容",
serviceName: "服務/SID", serviceName: "服務/SID",
serviceNameOnly: "服務名稱", 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: "版本", version: "版本",
driverInstallHintPrefix: "需要在上方導覽列「", driverInstallHintPrefix: "需要在上方導覽列「",
driverInstallHintSuffix: "」中安裝對應的驅動程式才能連線。", driverInstallHintSuffix: "」中安裝對應的驅動程式才能連線。",
@@ -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();
});
});
@@ -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("\\") ? "\\" : "/";
}
@@ -2,6 +2,7 @@ import { isSchemaAware, isSingleDatabase } from "@/lib/database/databaseFeatureS
import { extractIdentifierPartsAt, isSqlKeyword, sqlObjectNavigationTarget, type SqlObjectNavigationTarget, type SqlObjectNavigationType } from "@/lib/sql/sqlNavigation"; import { extractIdentifierPartsAt, isSqlKeyword, sqlObjectNavigationTarget, type SqlObjectNavigationTarget, type SqlObjectNavigationType } from "@/lib/sql/sqlNavigation";
import type { ActiveTabSidebarTarget } from "@/lib/sidebar/sidebarActiveTabTarget"; import type { ActiveTabSidebarTarget } from "@/lib/sidebar/sidebarActiveTabTarget";
import type { SqlCompletionTable } from "@/lib/sql/sqlCompletion"; import type { SqlCompletionTable } from "@/lib/sql/sqlCompletion";
import { sqlSemanticDialectFor } from "@/lib/sql/semantic/dialect";
import type { DatabaseType, QueryTab, TreeNode } from "@/types/database"; import type { DatabaseType, QueryTab, TreeNode } from "@/types/database";
export interface QueryCursorTableCandidate { export interface QueryCursorTableCandidate {
@@ -53,7 +54,9 @@ export function queryCursorTableCandidate(tab: QueryTab | undefined | null, data
} }
export function queryTableCandidateAtSqlPosition(input: QueryTableCandidateAtPositionInput): QueryCursorTableCandidate | null { 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; if (parts.length === 0) return null;
const tableName = parts[parts.length - 1]; const tableName = parts[parts.length - 1];
+1 -1
View File
@@ -140,7 +140,7 @@ export interface ConnectionConfig {
client_cert_path?: string; client_cert_path?: string;
client_key_path?: string; client_key_path?: string;
sysdba?: boolean; sysdba?: boolean;
oracle_connection_type?: "service_name" | "sid"; oracle_connection_type?: "service_name" | "sid" | "tns";
connection_string?: string; connection_string?: string;
jdbc_driver_class?: string; jdbc_driver_class?: string;
jdbc_driver_paths?: string[]; jdbc_driver_paths?: string[];
+19
View File
@@ -262,6 +262,11 @@ pub fn oracle_alternate_connect_configs(config: &ConnectionConfig, err: &str) ->
if config.db_type != DatabaseType::Oracle { if config.db_type != DatabaseType::Oracle {
return Vec::new(); 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()) { if config.connection_string.as_deref().is_some_and(|value| !value.trim().is_empty()) {
return Vec::new(); return Vec::new();
} }
@@ -804,6 +809,20 @@ mod tests {
assert_eq!(service["connection_string"], "jdbc:oracle:thin:@//oracle.example.com:1521/ORCL"); 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] #[test]
fn oracle_sys_user_connects_as_sysdba_for_agent_protocol() { fn oracle_sys_user_connects_as_sysdba_for_agent_protocol() {
let mut cfg = config(DatabaseType::Oracle, Some("ORCLPDB1")); let mut cfg = config(DatabaseType::Oracle, Some("ORCLPDB1"));
+19
View File
@@ -1796,6 +1796,11 @@ impl AppState {
if transport_layers.is_empty() { if transport_layers.is_empty() {
return Ok((config.host.clone(), config.port)); 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 (remote_host, remote_port) = connection_remote_endpoint(config);
let local_port = db::transport_layer_tunnel::start_transport_layers( 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] #[tokio::test]
async fn resolved_transport_layers_substitutes_shared_profiles() { async fn resolved_transport_layers_substitutes_shared_profiles() {
let (state, dir) = test_app_state().await; let (state, dir) = test_app_state().await;
+5
View File
@@ -810,6 +810,11 @@ impl ConnectionConfig {
!self.effective_transport_layers().is_empty() !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 { pub fn has_effective_ssh_tunnels(&self) -> bool {
self.effective_transport_layers().iter().any(|layer| matches!(layer, TransportLayerConfig::Ssh(_))) self.effective_transport_layers().iter().any(|layer| matches!(layer, TransportLayerConfig::Ssh(_)))
} }
@@ -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] #[test]
fn mysql_varchar_default_is_quoted() { fn mysql_varchar_default_is_quoted() {
let mut col = column("name"); let mut col = column("name");
@@ -7,19 +7,20 @@ pub(super) fn build_trigger_sql(options: &TableStructureSqlOptions, warnings: &m
return Vec::new(); return Vec::new();
} }
let dialect = super::dialect::capabilities_for(options.database_type).dialect;
let database_label = database_label(options.database_type); 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.")); warnings.push(format!("Editing triggers is not supported for {database_label} from this editor."));
return Vec::new(); 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(); let mut statements = Vec::new();
for trigger in &options.triggers { for trigger in &options.triggers {
if trigger.marked_for_drop { if trigger.marked_for_drop {
if let Some(original) = &trigger.original { 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; continue;
} }
@@ -28,10 +29,13 @@ pub(super) fn build_trigger_sql(options: &TableStructureSqlOptions, warnings: &m
if !has_trigger_change(trigger, original) { if !has_trigger_change(trigger, original) {
continue; 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); 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("")) || 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()) { let qualified_name = if schema.is_some_and(|schema| !schema.trim().is_empty()) {
format!( format!("{}.{}", quote_ident(dialect, schema.unwrap()), quote_ident(dialect, name))
"{}.{}",
quote_ident(StructureDialect::Mysql, schema.unwrap()),
quote_ident(StructureDialect::Mysql, name)
)
} else { } else {
quote_ident(StructureDialect::Mysql, name) quote_ident(dialect, name)
}; };
format!("DROP TRIGGER {qualified_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 name = clean(&trigger.name);
let timing = normalize_keyword(&trigger.timing); let timing = normalize_keyword(&trigger.timing);
let event = normalize_keyword(&trigger.event); let event = clean(&trigger.event);
let statement = clean(&trigger.statement); let statement = clean(&trigger.statement);
if name.is_empty() || timing.is_empty() || event.is_empty() || statement.is_empty() { 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()); warnings.push("Trigger name, timing, event, and statement are required.".to_string());
return None; return None;
} }
if !matches!(timing.as_str(), "BEFORE" | "AFTER") { match dialect {
warnings.push(format!("Unsupported trigger timing \"{}\".", clean(&trigger.timing))); 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; return None;
} }
if !matches!(event.as_str(), "INSERT" | "UPDATE" | "DELETE") { 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; return None;
} }
Some(format!( Some(format!(
"CREATE TRIGGER {} {timing} {event} ON {table} FOR EACH ROW\n{};", "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() 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 { fn normalize_keyword(value: &str) -> String {
clean(value).to_ascii_uppercase() clean(value).to_ascii_uppercase()
} }
+47
View File
@@ -11,6 +11,7 @@ import {
resolveQueryContextCandidateDatabase, resolveQueryContextCandidateDatabase,
resolveQueryContextObjectTarget, resolveQueryContextObjectTarget,
} from "../../apps/desktop/src/lib/sql/queryCursorTableTarget.ts"; } 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"; import type { QueryTab, TreeNode } from "../../apps/desktop/src/types/database.ts";
function queryTab(sql: string, head: number, schema = "public"): QueryTab { 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", () => { test("parses escaped quotes inside qualified identifiers", () => {
const sql = 'select * from "warehouse"."reporting"."Daily ""Sales"""'; const sql = 'select * from "warehouse"."reporting"."Daily ""Sales"""';