feat(oracle): add TNS connections and trigger editing
This commit is contained in:
parent
9a5edae219
commit
3b004abb1e
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { applyParsedConnectionUrl, normalizeMongoConnectionString, parseConnectionUrl } from "@/lib/connection/connectionUrl";
|
||||
import { buildOracleTnsConnectionString, normalizeOracleTnsAdminPath, parseOracleTnsConnectionString } from "@/lib/connection/oracleTnsConnection";
|
||||
import { parseConnectionDeepLink, type ConnectionDeepLinkDraft } from "@/lib/connection/connectionDeepLink";
|
||||
import { connectionUrlPlaceholder as getUrlPlaceholder } from "@/lib/connection/connectionPresentation";
|
||||
import { h2ConnectionModeForConfig, h2FileJdbcUrlWithPath, h2FilePathFromJdbcUrl, isH2SplitJdbcUrl, type H2ConnectionMode } from "@/lib/database/h2Connection";
|
||||
|
|
@ -494,6 +495,7 @@ const selectedJdbcDriverPath = ref("");
|
|||
const jdbcManualClasspathOpen = ref(false);
|
||||
const connectionUrlInput = ref("");
|
||||
const appliedConnectionUrlInput = ref("");
|
||||
const oracleTnsAdminPath = ref("");
|
||||
const oceanbaseSubMode = ref<"mysql" | "oracle">("mysql");
|
||||
const h2ConnectionMode = ref<H2ConnectionMode>("file");
|
||||
const dremioConnectionMode = ref<DremioConnectionMode>("legacy");
|
||||
|
|
@ -1764,6 +1766,7 @@ function applyProfile(val: string, preserveConnectionFields = false) {
|
|||
}
|
||||
|
||||
if (!preserveConnectionFields) {
|
||||
oracleTnsAdminPath.value = "";
|
||||
form.value.port = profile.port;
|
||||
setSqlServerPortExplicit(form.value, false);
|
||||
form.value.username = profile.user;
|
||||
|
|
@ -1910,6 +1913,7 @@ watch(
|
|||
visible_databases: config.visible_databases,
|
||||
visible_schemas: config.visible_schemas,
|
||||
};
|
||||
oracleTnsAdminPath.value = parseOracleTnsConnectionString(config.connection_string)?.tnsAdmin || "";
|
||||
productionProtectionEnabled.value = !!config.is_production || (config.production_databases?.length ?? 0) > 0;
|
||||
connectionUrlInput.value = config.db_type === "h2" && config.connection_string ? config.connection_string : "";
|
||||
appliedConnectionUrlInput.value = connectionUrlInput.value.trim();
|
||||
|
|
@ -1988,12 +1992,14 @@ watch(
|
|||
);
|
||||
|
||||
const databaseLabel = computed(() => {
|
||||
if (form.value.db_type === "oracle" && form.value.oracle_connection_type === "tns") return t("connection.oracleTnsAlias");
|
||||
if (form.value.db_type === "oracle") return t("connection.serviceName");
|
||||
if (form.value.db_type === "influxdb" && influxDbVersion.value === "2") return "Bucket";
|
||||
return t("connection.database");
|
||||
});
|
||||
|
||||
const databasePlaceholder = computed(() => {
|
||||
if (form.value.db_type === "oracle" && form.value.oracle_connection_type === "tns") return t("connection.oracleTnsAliasPlaceholder");
|
||||
if (form.value.db_type === "kingbase") return t("connection.databasePlaceholderRequired");
|
||||
const fallback = defaultDatabaseForProfile();
|
||||
if (!fallback) return t("connection.databasePlaceholder");
|
||||
|
|
@ -2485,7 +2491,7 @@ const zookeeperConnectString = computed({
|
|||
form.value.connection_string = normalizeZooKeeperConnectString(value);
|
||||
},
|
||||
});
|
||||
const canUseTransportLayers = computed(() => form.value.db_type !== "sqlite" && form.value.db_type !== "access" && !isCloudflareD1Connection(form.value) && !isH2FileMode.value);
|
||||
const canUseTransportLayers = computed(() => form.value.db_type !== "sqlite" && form.value.db_type !== "access" && !isCloudflareD1Connection(form.value) && !isH2FileMode.value && !(form.value.db_type === "oracle" && form.value.oracle_connection_type === "tns"));
|
||||
const shouldShowAgentDriverInstallHint = computed(() => showAgentDriverInstallHint(form.value.db_type, agentDrivers.value, form.value.driver_profile));
|
||||
const h2DriverMissing = computed(() => form.value.db_type === "h2" && isH2FileMode.value && agentDrivers.value.find((d) => d.db_type === "h2")?.installed !== true);
|
||||
const agentDriverFocus = computed<DriverStoreFocus>(() => ({ target: "driver", driver: agentDriverInstallKey(form.value.db_type, form.value.driver_profile) }));
|
||||
|
|
@ -2812,6 +2818,7 @@ function applyConnectionUrlToForm(input: string): boolean {
|
|||
|
||||
const parsed = parseConnectionUrl(input, selectedType.value);
|
||||
form.value = applyParsedConnectionUrl(form.value, parsed);
|
||||
oracleTnsAdminPath.value = parseOracleTnsConnectionString(parsed.connectionString)?.tnsAdmin || "";
|
||||
selectedType.value = parsed.driverProfile;
|
||||
customDriverName.value = isCustomCompatibleProfile() ? parsed.driverLabel : "";
|
||||
mongoUseUrl.value = !!parsed.useMongoUrl;
|
||||
|
|
@ -2964,7 +2971,22 @@ function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionCo
|
|||
normalized.connect_timeout_secs = Number.isFinite(timeout) && timeout > 0 ? timeout : 5;
|
||||
return { type: "ssh", ...normalized };
|
||||
});
|
||||
if (config.db_type === "oracle" && config.oracle_connection_type === "tns" && config.transport_layers.some((layer) => layer.enabled !== false)) {
|
||||
throw new Error(t("connection.oracleTnsTransportUnsupported"));
|
||||
}
|
||||
validateTransportLayers(config);
|
||||
if (config.db_type === "oracle" && config.oracle_connection_type === "tns") {
|
||||
const alias = config.database?.trim() || "";
|
||||
const tnsAdmin = normalizeOracleTnsAdminPath(oracleTnsAdminPath.value);
|
||||
if (!alias) throw new Error(t("connection.oracleTnsAliasRequired"));
|
||||
if (!tnsAdmin) throw new Error(t("connection.oracleTnsAdminRequired"));
|
||||
config.database = alias;
|
||||
config.connection_string = buildOracleTnsConnectionString(alias, tnsAdmin);
|
||||
} else if (config.db_type === "oracle" && parseOracleTnsConnectionString(config.connection_string)) {
|
||||
// Only clear DBX-generated TNS URLs when switching modes; preserve custom
|
||||
// service, SID, and descriptor JDBC strings exactly as before.
|
||||
config.connection_string = undefined;
|
||||
}
|
||||
const connectTimeout = Number(config.connect_timeout_secs);
|
||||
config.connect_timeout_secs = Number.isFinite(connectTimeout) && connectTimeout > 0 ? connectTimeout : 10;
|
||||
const queryTimeout = Number(config.query_timeout_secs);
|
||||
|
|
@ -3774,6 +3796,7 @@ function resetForm() {
|
|||
selectedJdbcDriverPath.value = "";
|
||||
connectionUrlInput.value = "";
|
||||
appliedConnectionUrlInput.value = "";
|
||||
oracleTnsAdminPath.value = "";
|
||||
dialogStep.value = "select";
|
||||
dbSearchQuery.value = "";
|
||||
selectedDbCategory.value = "sql";
|
||||
|
|
@ -3822,6 +3845,7 @@ function applyConnectionDraftToConfig(config: Omit<ConnectionConfig, "id">, draf
|
|||
function applyConnectionDraftToForm(draft: ConnectionDeepLinkDraft) {
|
||||
applyProfile(draft.driverProfile);
|
||||
form.value = applyConnectionDraftToConfig(form.value, draft);
|
||||
oracleTnsAdminPath.value = parseOracleTnsConnectionString(form.value.connection_string)?.tnsAdmin || "";
|
||||
selectedType.value = draft.driverProfile;
|
||||
if (form.value.db_type === "h2") {
|
||||
h2ConnectionMode.value = h2ConnectionModeForConfig(form.value);
|
||||
|
|
@ -4240,6 +4264,20 @@ async function browseHiveKerberosFile(target: "krb5" | "jaas") {
|
|||
}
|
||||
}
|
||||
|
||||
async function browseOracleTnsNamesFile() {
|
||||
if (!isTauriRuntime()) return;
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const selected = await open({
|
||||
title: t("connection.oracleTnsAdminBrowse"),
|
||||
multiple: false,
|
||||
filters: [{ name: "Oracle TNS names", extensions: ["ora"] }],
|
||||
});
|
||||
if (typeof selected === "string") {
|
||||
oracleTnsAdminPath.value = normalizeOracleTnsAdminPath(selected);
|
||||
resetTestState();
|
||||
}
|
||||
}
|
||||
|
||||
async function browseKafkaKerberosFile(target: "keytab" | "krb5") {
|
||||
if (isTauriRuntime()) {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
|
|
@ -5664,7 +5702,7 @@ function openExternalUrl(url: string) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<div v-if="form.db_type !== 'oracle' || form.oracle_connection_type !== 'tns'" class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">{{ form.db_type === "elasticsearch" && elasticsearchConnectionMode === "kibana" ? t("connection.elasticsearchKibanaHost") : t("connection.host") }}</Label>
|
||||
<Input v-model="form.host" class="col-span-2" />
|
||||
<Input v-model.number="form.port" type="number" class="col-span-1" @input="markSqlServerPortExplicit" />
|
||||
|
|
@ -5700,6 +5738,26 @@ function openExternalUrl(url: string) {
|
|||
<Input v-model="form.database" class="col-span-3" :placeholder="databasePlaceholder" />
|
||||
</div>
|
||||
|
||||
<div v-if="form.db_type === 'oracle' && form.oracle_connection_type === 'tns'" class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelSmallClass">TNS_ADMIN</Label>
|
||||
<div class="col-span-3 flex items-center gap-1">
|
||||
<Input v-model="oracleTnsAdminPath" class="flex-1" :placeholder="t('connection.oracleTnsAdminPlaceholder')" />
|
||||
<Tooltip v-if="isDesktop">
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" @click="browseOracleTnsNamesFile">
|
||||
<FolderOpen class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t("connection.oracleTnsAdminBrowse") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="form.db_type === 'oracle' && form.oracle_connection_type === 'tns'" class="grid grid-cols-4 items-start gap-4">
|
||||
<span />
|
||||
<p class="col-span-3 text-xs text-muted-foreground">{{ t("connection.oracleTnsPathHint") }}</p>
|
||||
</div>
|
||||
|
||||
<template v-if="form.db_type === 'hive'">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.hiveAuthMode") }}</Label>
|
||||
|
|
@ -5772,12 +5830,12 @@ function openExternalUrl(url: string) {
|
|||
|
||||
<div v-if="form.db_type === 'oracle'" class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelSmallClass">{{ t("connection.mode") }}</Label>
|
||||
<div class="col-span-3 grid h-8 grid-cols-2 overflow-hidden rounded-md border border-input bg-muted/30 p-0.5">
|
||||
<div class="col-span-3 grid h-8 grid-cols-3 overflow-hidden rounded-md border border-input bg-muted/30 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
class="h-7 rounded-sm px-3 text-sm transition-colors"
|
||||
:class="form.oracle_connection_type !== 'sid' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
|
||||
:aria-pressed="form.oracle_connection_type !== 'sid'"
|
||||
:class="form.oracle_connection_type === 'service_name' || !form.oracle_connection_type ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
|
||||
:aria-pressed="form.oracle_connection_type === 'service_name' || !form.oracle_connection_type"
|
||||
@click="form.oracle_connection_type = 'service_name'"
|
||||
>
|
||||
{{ t("connection.serviceNameOnly") }}
|
||||
|
|
@ -5791,6 +5849,15 @@ function openExternalUrl(url: string) {
|
|||
>
|
||||
SID
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="h-7 rounded-sm px-3 text-sm transition-colors"
|
||||
:class="form.oracle_connection_type === 'tns' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
|
||||
:aria-pressed="form.oracle_connection_type === 'tns'"
|
||||
@click="form.oracle_connection_type = 'tns'"
|
||||
>
|
||||
TNS
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -2090,7 +2090,8 @@ function canDropIndex(index: EditableStructureIndex): boolean {
|
|||
}
|
||||
|
||||
const canEditForeignKeys = computed(() => structureCapabilities.value.foreignKey);
|
||||
const canEditMysqlTriggers = computed(() => structureDialect.value === "mysql");
|
||||
const canEditTriggers = computed(() => structureDialect.value === "mysql" || structureDialect.value === "oracle");
|
||||
const isOracleTriggerEditor = computed(() => structureDialect.value === "oracle");
|
||||
|
||||
function generatedForeignKeyName(column = ""): string {
|
||||
const table = structureIndexTableName() || "table";
|
||||
|
|
@ -2138,14 +2139,14 @@ function canEditForeignKeyDraft(foreignKey: EditableStructureForeignKey): boolea
|
|||
}
|
||||
|
||||
function addTrigger() {
|
||||
if (!canEditMysqlTriggers.value || triggersLoading.value) return;
|
||||
if (!canEditTriggers.value || triggersLoading.value) return;
|
||||
activeTab.value = "triggers";
|
||||
triggers.value.push({
|
||||
id: `new:${uuid()}`,
|
||||
name: "",
|
||||
timing: "BEFORE",
|
||||
timing: isOracleTriggerEditor.value ? "BEFORE EACH ROW" : "BEFORE",
|
||||
event: "INSERT",
|
||||
statement: "BEGIN\n \nEND",
|
||||
statement: isOracleTriggerEditor.value ? "BEGIN\n NULL;\nEND" : "BEGIN\n \nEND",
|
||||
markedForDrop: false,
|
||||
});
|
||||
}
|
||||
|
|
@ -2160,7 +2161,7 @@ function toggleDropTrigger(trigger: EditableStructureTrigger) {
|
|||
}
|
||||
|
||||
function canEditTriggerDraft(trigger: EditableStructureTrigger): boolean {
|
||||
return !triggersLoading.value && canEditMysqlTriggers.value && !trigger.markedForDrop;
|
||||
return !triggersLoading.value && canEditTriggers.value && !trigger.markedForDrop;
|
||||
}
|
||||
|
||||
function primarySqlOperation(sql: string): string {
|
||||
|
|
@ -2293,7 +2294,7 @@ function addItemForActiveTab(): boolean {
|
|||
addForeignKey();
|
||||
return true;
|
||||
}
|
||||
if (activeTab.value === "triggers" && canEditMysqlTriggers.value) {
|
||||
if (activeTab.value === "triggers" && canEditTriggers.value) {
|
||||
addTrigger();
|
||||
return true;
|
||||
}
|
||||
|
|
@ -2648,7 +2649,7 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
|
|||
<Plus :class="structureIconClass" />
|
||||
{{ t("structureEditor.addForeignKey") }}
|
||||
</Button>
|
||||
<Button v-if="activeTab === 'triggers'" size="sm" :class="structureToolbarButtonClass" :disabled="!canEditMysqlTriggers || triggersLoading" @click="addTrigger">
|
||||
<Button v-if="activeTab === 'triggers'" size="sm" :class="structureToolbarButtonClass" :disabled="!canEditTriggers || triggersLoading" @click="addTrigger">
|
||||
<Plus :class="structureIconClass" />
|
||||
{{ t("structureEditor.addTrigger") }}
|
||||
</Button>
|
||||
|
|
@ -3193,9 +3194,10 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
|
|||
</div>
|
||||
<div v-else class="space-y-1.5">
|
||||
<div v-for="trigger in triggers" :key="trigger.id" class="rounded-md border px-[var(--structure-cell-px)] py-[var(--structure-header-py)] text-[length:var(--structure-font-size)]" :class="trigger.markedForDrop ? 'bg-destructive/5 opacity-60' : ''">
|
||||
<div class="grid grid-cols-[minmax(140px,1fr)_110px_110px_auto] gap-1.5">
|
||||
<div class="grid grid-cols-[minmax(140px,1fr)_minmax(130px,180px)_minmax(140px,1fr)_auto] gap-1.5">
|
||||
<Input v-model="trigger.name" :class="structureControlClass" :placeholder="t('structureEditor.triggerName')" :disabled="!canEditTriggerDraft(trigger)" />
|
||||
<Select v-model="trigger.timing" :disabled="!canEditTriggerDraft(trigger)">
|
||||
<Input v-if="isOracleTriggerEditor" v-model="trigger.timing" :class="structureControlClass" :disabled="!canEditTriggerDraft(trigger)" />
|
||||
<Select v-else v-model="trigger.timing" :disabled="!canEditTriggerDraft(trigger)">
|
||||
<SelectTrigger class="h-[var(--structure-control-height)] rounded-[6px] px-[var(--structure-control-px)] text-[length:var(--structure-font-size)] focus-visible:border-ring/50 focus-visible:ring-1 focus-visible:ring-ring/25">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
|
@ -3203,7 +3205,8 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
|
|||
<SelectItem v-for="timing in triggerTimingOptions" :key="timing" :value="timing">{{ timing }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select v-model="trigger.event" :disabled="!canEditTriggerDraft(trigger)">
|
||||
<Input v-if="isOracleTriggerEditor" v-model="trigger.event" :class="structureControlClass" :disabled="!canEditTriggerDraft(trigger)" />
|
||||
<Select v-else v-model="trigger.event" :disabled="!canEditTriggerDraft(trigger)">
|
||||
<SelectTrigger class="h-[var(--structure-control-height)] rounded-[6px] px-[var(--structure-control-px)] text-[length:var(--structure-font-size)] focus-visible:border-ring/50 focus-visible:ring-1 focus-visible:ring-ring/25">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
|
|
|||
|
|
@ -210,6 +210,14 @@ export default {
|
|||
mongoDriverLegacy: "Legacy",
|
||||
serviceName: "Service/SID",
|
||||
serviceNameOnly: "Service Name",
|
||||
oracleTnsAlias: "Network Alias",
|
||||
oracleTnsAliasPlaceholder: "Alias from tnsnames.ora",
|
||||
oracleTnsAdminPlaceholder: "Directory containing tnsnames.ora",
|
||||
oracleTnsAdminBrowse: "Select tnsnames.ora",
|
||||
oracleTnsPathHint: "The DBX backend/Oracle agent reads this path. Desktop paths are local; Web and Docker paths are on the server.",
|
||||
oracleTnsAliasRequired: "Oracle TNS mode requires a network alias.",
|
||||
oracleTnsAdminRequired: "Oracle TNS mode requires the TNS_ADMIN directory containing tnsnames.ora.",
|
||||
oracleTnsTransportUnsupported: "Oracle TNS connections cannot use SSH, proxy, or HTTP tunnel layers. Remove the transport layer or use Service Name/SID mode.",
|
||||
elasticsearchDirectMode: "Direct",
|
||||
elasticsearchKibanaProxyMode: "Kibana Proxy",
|
||||
elasticsearchKibanaHost: "Kibana Host",
|
||||
|
|
|
|||
|
|
@ -212,6 +212,14 @@ export default withEnglishFallback({
|
|||
mongoDriverLegacy: "Legacy",
|
||||
serviceName: "Servicio/SID",
|
||||
serviceNameOnly: "Nombre de servicio",
|
||||
oracleTnsAlias: "Alias de red",
|
||||
oracleTnsAliasPlaceholder: "Alias de tnsnames.ora",
|
||||
oracleTnsAdminPlaceholder: "Directorio que contiene tnsnames.ora",
|
||||
oracleTnsAdminBrowse: "Seleccionar tnsnames.ora",
|
||||
oracleTnsPathHint: "La ruta la lee el backend de DBX/Oracle Agent. En escritorio es local; en Web y Docker pertenece al servidor.",
|
||||
oracleTnsAliasRequired: "El modo Oracle TNS requiere un alias de red.",
|
||||
oracleTnsAdminRequired: "El modo Oracle TNS requiere el directorio TNS_ADMIN que contiene tnsnames.ora.",
|
||||
oracleTnsTransportUnsupported: "Oracle TNS no se puede combinar con túneles SSH, proxy o HTTP. Elimine la capa de transporte o use Servicio/SID.",
|
||||
version: "Versión",
|
||||
driverInstallHintPrefix: "Instala el driver requerido desde ",
|
||||
driverInstallHintSuffix: " en la barra superior antes de conectar.",
|
||||
|
|
|
|||
|
|
@ -211,6 +211,14 @@ export default withEnglishFallback({
|
|||
mongoDriverLegacy: "Legacy",
|
||||
serviceName: "Servizio/SID",
|
||||
serviceNameOnly: "Nome Servizio",
|
||||
oracleTnsAlias: "Alias di rete",
|
||||
oracleTnsAliasPlaceholder: "Alias da tnsnames.ora",
|
||||
oracleTnsAdminPlaceholder: "Directory contenente tnsnames.ora",
|
||||
oracleTnsAdminBrowse: "Seleziona tnsnames.ora",
|
||||
oracleTnsPathHint: "Il percorso viene letto dal backend DBX/Oracle Agent. Sul desktop è locale; su Web e Docker appartiene al server.",
|
||||
oracleTnsAliasRequired: "La modalità Oracle TNS richiede un alias di rete.",
|
||||
oracleTnsAdminRequired: "La modalità Oracle TNS richiede la directory TNS_ADMIN contenente tnsnames.ora.",
|
||||
oracleTnsTransportUnsupported: "Oracle TNS non può usare tunnel SSH, proxy o HTTP. Rimuovi il livello di trasporto o usa Servizio/SID.",
|
||||
version: "Versione",
|
||||
driverInstallHintPrefix: "Installa il driver richiesto da ",
|
||||
driverInstallHintSuffix: " nella barra degli strumenti superiore prima di connetterti.",
|
||||
|
|
|
|||
|
|
@ -211,6 +211,14 @@ export default withEnglishFallback({
|
|||
mongoDriverLegacy: "レガシー",
|
||||
serviceName: "サービス/SID",
|
||||
serviceNameOnly: "サービス名",
|
||||
oracleTnsAlias: "ネットワーク・エイリアス",
|
||||
oracleTnsAliasPlaceholder: "tnsnames.ora のエイリアス",
|
||||
oracleTnsAdminPlaceholder: "tnsnames.ora を含むディレクトリ",
|
||||
oracleTnsAdminBrowse: "tnsnames.ora を選択",
|
||||
oracleTnsPathHint: "このパスは DBX バックエンド/Oracle Agent が読み取ります。デスクトップではローカルパス、Web/Docker ではサーバーパスを指定してください。",
|
||||
oracleTnsAliasRequired: "Oracle TNS モードではネットワーク・エイリアスが必要です。",
|
||||
oracleTnsAdminRequired: "Oracle TNS モードでは tnsnames.ora を含む TNS_ADMIN ディレクトリが必要です。",
|
||||
oracleTnsTransportUnsupported: "Oracle TNS 接続は SSH、プロキシ、HTTP トンネルと併用できません。トランスポート層を削除するか、サービス名/SID モードを使用してください。",
|
||||
version: "バージョン",
|
||||
driverInstallHintPrefix: "接続前に上部ツールバーの",
|
||||
driverInstallHintSuffix: "から必要なドライバーをインストールしてください。",
|
||||
|
|
|
|||
|
|
@ -212,6 +212,14 @@ export default withEnglishFallback({
|
|||
mongoDriverLegacy: "Legacy",
|
||||
serviceName: "Service/SID",
|
||||
serviceNameOnly: "Service Name",
|
||||
oracleTnsAlias: "Alias de rede",
|
||||
oracleTnsAliasPlaceholder: "Alias do tnsnames.ora",
|
||||
oracleTnsAdminPlaceholder: "Diretório que contém tnsnames.ora",
|
||||
oracleTnsAdminBrowse: "Selecionar tnsnames.ora",
|
||||
oracleTnsPathHint: "O caminho é lido pelo backend do DBX/Oracle Agent. No desktop é local; no Web e Docker pertence ao servidor.",
|
||||
oracleTnsAliasRequired: "O modo Oracle TNS requer um alias de rede.",
|
||||
oracleTnsAdminRequired: "O modo Oracle TNS requer o diretório TNS_ADMIN que contém tnsnames.ora.",
|
||||
oracleTnsTransportUnsupported: "Oracle TNS não pode usar túneis SSH, proxy ou HTTP. Remova a camada de transporte ou use Serviço/SID.",
|
||||
version: "Versão",
|
||||
driverInstallHintPrefix: "Instale o driver necessário em ",
|
||||
driverInstallHintSuffix: " na barra de ferramentas superior antes de conectar.",
|
||||
|
|
|
|||
|
|
@ -212,6 +212,14 @@ export default withEnglishFallback({
|
|||
mongoDriverLegacy: "旧版兼容",
|
||||
serviceName: "服务名/SID",
|
||||
serviceNameOnly: "服务名",
|
||||
oracleTnsAlias: "网络别名",
|
||||
oracleTnsAliasPlaceholder: "tnsnames.ora 中的别名",
|
||||
oracleTnsAdminPlaceholder: "包含 tnsnames.ora 的目录",
|
||||
oracleTnsAdminBrowse: "选择 tnsnames.ora",
|
||||
oracleTnsPathHint: "该路径由 DBX 后端/Oracle Agent 读取。桌面端填写本机路径,Web 和 Docker 部署填写服务器路径。",
|
||||
oracleTnsAliasRequired: "Oracle TNS 模式需要填写网络别名。",
|
||||
oracleTnsAdminRequired: "Oracle TNS 模式需要填写包含 tnsnames.ora 的 TNS_ADMIN 目录。",
|
||||
oracleTnsTransportUnsupported: "Oracle TNS 连接不能与 SSH、代理或 HTTP 隧道组合使用。请移除传输层,或改用服务名/SID 模式。",
|
||||
elasticsearchDirectMode: "直连",
|
||||
elasticsearchKibanaProxyMode: "Kibana 代理",
|
||||
elasticsearchKibanaHost: "Kibana 主机",
|
||||
|
|
|
|||
|
|
@ -212,6 +212,14 @@ export default withEnglishFallback({
|
|||
mongoDriverLegacy: "舊版相容",
|
||||
serviceName: "服務/SID",
|
||||
serviceNameOnly: "服務名稱",
|
||||
oracleTnsAlias: "網路別名",
|
||||
oracleTnsAliasPlaceholder: "tnsnames.ora 中的別名",
|
||||
oracleTnsAdminPlaceholder: "包含 tnsnames.ora 的目錄",
|
||||
oracleTnsAdminBrowse: "選擇 tnsnames.ora",
|
||||
oracleTnsPathHint: "此路徑由 DBX 後端/Oracle Agent 讀取。桌面版填寫本機路徑,Web 和 Docker 部署填寫伺服器路徑。",
|
||||
oracleTnsAliasRequired: "Oracle TNS 模式需要填寫網路別名。",
|
||||
oracleTnsAdminRequired: "Oracle TNS 模式需要填寫包含 tnsnames.ora 的 TNS_ADMIN 目錄。",
|
||||
oracleTnsTransportUnsupported: "Oracle TNS 連線不能與 SSH、代理或 HTTP Tunnel 組合使用。請移除傳輸層,或改用服務名稱/SID 模式。",
|
||||
version: "版本",
|
||||
driverInstallHintPrefix: "需要在上方導覽列「",
|
||||
driverInstallHintSuffix: "」中安裝對應的驅動程式才能連線。",
|
||||
|
|
|
|||
|
|
@ -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 type { ActiveTabSidebarTarget } from "@/lib/sidebar/sidebarActiveTabTarget";
|
||||
import type { SqlCompletionTable } from "@/lib/sql/sqlCompletion";
|
||||
import { sqlSemanticDialectFor } from "@/lib/sql/semantic/dialect";
|
||||
import type { DatabaseType, QueryTab, TreeNode } from "@/types/database";
|
||||
|
||||
export interface QueryCursorTableCandidate {
|
||||
|
|
@ -53,7 +54,9 @@ export function queryCursorTableCandidate(tab: QueryTab | undefined | null, data
|
|||
}
|
||||
|
||||
export function queryTableCandidateAtSqlPosition(input: QueryTableCandidateAtPositionInput): QueryCursorTableCandidate | null {
|
||||
const parts = extractQualifiedIdentifierPartsAt(input.sql, input.position).map((part) => part.value);
|
||||
const dialect = sqlSemanticDialectFor({ databaseType: input.databaseType });
|
||||
// View-data SQL quotes resolved names, so fold only unquoted input exactly as the database does before quoting it again.
|
||||
const parts = extractQualifiedIdentifierPartsAt(input.sql, input.position).map((part) => dialect.normalizeIdentifier(part.value, part.quoted));
|
||||
if (parts.length === 0) return null;
|
||||
|
||||
const tableName = parts[parts.length - 1];
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ export interface ConnectionConfig {
|
|||
client_cert_path?: string;
|
||||
client_key_path?: string;
|
||||
sysdba?: boolean;
|
||||
oracle_connection_type?: "service_name" | "sid";
|
||||
oracle_connection_type?: "service_name" | "sid" | "tns";
|
||||
connection_string?: string;
|
||||
jdbc_driver_class?: string;
|
||||
jdbc_driver_paths?: string[];
|
||||
|
|
|
|||
|
|
@ -262,6 +262,11 @@ pub fn oracle_alternate_connect_configs(config: &ConnectionConfig, err: &str) ->
|
|||
if config.db_type != DatabaseType::Oracle {
|
||||
return Vec::new();
|
||||
}
|
||||
if config.oracle_connection_type.as_deref() == Some("tns") {
|
||||
// TNS owns its complete address/failover descriptor; host-based retries would
|
||||
// replace the configured alias with unrelated Service Name/SID URLs.
|
||||
return Vec::new();
|
||||
}
|
||||
if config.connection_string.as_deref().is_some_and(|value| !value.trim().is_empty()) {
|
||||
return Vec::new();
|
||||
}
|
||||
|
|
@ -804,6 +809,20 @@ mod tests {
|
|||
assert_eq!(service["connection_string"], "jdbc:oracle:thin:@//oracle.example.com:1521/ORCL");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oracle_tns_does_not_retry_with_host_based_descriptors() {
|
||||
let mut cfg = config(DatabaseType::Oracle, Some("DBX_FAILOVER"));
|
||||
cfg.oracle_connection_type = Some("tns".to_string());
|
||||
cfg.connection_string =
|
||||
Some("jdbc:oracle:thin:@DBX_FAILOVER?TNS_ADMIN=%2Fopt%2Foracle%2Fnetwork%2Fadmin".to_string());
|
||||
|
||||
assert!(oracle_alternate_connect_configs(
|
||||
&cfg,
|
||||
"ORA-12514: listener does not currently know of service requested"
|
||||
)
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oracle_sys_user_connects_as_sysdba_for_agent_protocol() {
|
||||
let mut cfg = config(DatabaseType::Oracle, Some("ORCLPDB1"));
|
||||
|
|
|
|||
|
|
@ -1796,6 +1796,11 @@ impl AppState {
|
|||
if transport_layers.is_empty() {
|
||||
return Ok((config.host.clone(), config.port));
|
||||
}
|
||||
if config.uses_oracle_tns() {
|
||||
// A TNS descriptor may contain several failover addresses, so rewriting it
|
||||
// through one local tunnel endpoint would silently break Oracle Net routing.
|
||||
return Err("Oracle TNS connections cannot be combined with SSH, proxy, or HTTP tunnel layers. Remove the transport layer or use Service Name/SID mode.".to_string());
|
||||
}
|
||||
|
||||
let (remote_host, remote_port) = connection_remote_endpoint(config);
|
||||
let local_port = db::transport_layer_tunnel::start_transport_layers(
|
||||
|
|
@ -5547,6 +5552,20 @@ for line in sys.stdin:
|
|||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oracle_tns_connection_rejects_transport_layers() {
|
||||
let (state, dir) = test_app_state().await;
|
||||
let mut config = mysql_config(Some("DBX_FAILOVER"));
|
||||
config.db_type = DatabaseType::Oracle;
|
||||
config.oracle_connection_type = Some("tns".to_string());
|
||||
config.transport_layers = vec![TransportLayerConfig::Ssh(ssh_layer("tns-tunnel", ""))];
|
||||
|
||||
let error = state.connection_host_port("oracle-tns", &config).await.unwrap_err();
|
||||
assert!(error.contains("cannot be combined with SSH, proxy, or HTTP tunnel"));
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolved_transport_layers_substitutes_shared_profiles() {
|
||||
let (state, dir) = test_app_state().await;
|
||||
|
|
|
|||
|
|
@ -810,6 +810,11 @@ impl ConnectionConfig {
|
|||
!self.effective_transport_layers().is_empty()
|
||||
}
|
||||
|
||||
pub fn uses_oracle_tns(&self) -> bool {
|
||||
self.db_type == DatabaseType::Oracle
|
||||
&& self.oracle_connection_type.as_deref().is_some_and(|mode| mode.eq_ignore_ascii_case("tns"))
|
||||
}
|
||||
|
||||
pub fn has_effective_ssh_tunnels(&self) -> bool {
|
||||
self.effective_transport_layers().iter().any(|layer| matches!(layer, TransportLayerConfig::Ssh(_)))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3243,6 +3243,115 @@ fn builds_mysql_trigger_changes() {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_oracle_multi_event_row_trigger_change() {
|
||||
let mut existing = trigger(
|
||||
"DBX_TRIGGER_4320_AUDIT",
|
||||
"AFTER EACH ROW",
|
||||
"INSERT OR UPDATE OR DELETE",
|
||||
"DECLARE\n v_event VARCHAR2(10);\nBEGIN\n v_event := CASE WHEN INSERTING THEN 'INSERT' WHEN UPDATING THEN 'UPDATE' ELSE 'DELETE' END;\nEND;",
|
||||
);
|
||||
existing.original = Some(TriggerInfo {
|
||||
name: "DBX_TRIGGER_4320_AUDIT".to_string(),
|
||||
event: "INSERT OR UPDATE OR DELETE".to_string(),
|
||||
timing: "AFTER EACH ROW".to_string(),
|
||||
statement: Some("BEGIN\n NULL;\nEND;".to_string()),
|
||||
});
|
||||
|
||||
let result = build_table_structure_change_sql(TableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::Oracle),
|
||||
schema: Some("APP".to_string()),
|
||||
table_name: "DBX_TRIGGER_4320".to_string(),
|
||||
columns: Vec::new(),
|
||||
indexes: Vec::new(),
|
||||
foreign_keys: Vec::new(),
|
||||
triggers: vec![existing],
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
assert_eq!(
|
||||
result.statements,
|
||||
vec![
|
||||
"CREATE OR REPLACE TRIGGER \"APP\".\"DBX_TRIGGER_4320_AUDIT\" AFTER INSERT OR UPDATE OR DELETE ON \"APP\".\"DBX_TRIGGER_4320\"\nFOR EACH ROW\nDECLARE\n v_event VARCHAR2(10);\nBEGIN\n v_event := CASE WHEN INSERTING THEN 'INSERT' WHEN UPDATING THEN 'UPDATE' ELSE 'DELETE' END;\nEND;",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_oracle_statement_trigger_without_row_clause() {
|
||||
let result = build_table_structure_change_sql(TableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::Oracle),
|
||||
schema: Some("APP".to_string()),
|
||||
table_name: "ORDERS".to_string(),
|
||||
columns: Vec::new(),
|
||||
indexes: Vec::new(),
|
||||
foreign_keys: Vec::new(),
|
||||
triggers: vec![trigger("ORDERS_AUDIT", "BEFORE STATEMENT", "UPDATE OF STATUS", "BEGIN\n NULL;\nEND;\n/")],
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
assert_eq!(
|
||||
result.statements,
|
||||
vec![
|
||||
"CREATE OR REPLACE TRIGGER \"APP\".\"ORDERS_AUDIT\" BEFORE UPDATE OF STATUS ON \"APP\".\"ORDERS\"\nBEGIN\n NULL;\nEND;",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renaming_oracle_trigger_drops_old_name_before_create() {
|
||||
let mut existing = trigger("ORDERS_AUDIT_V2", "AFTER EACH ROW", "INSERT", "BEGIN\n NULL;\nEND;");
|
||||
existing.original = Some(TriggerInfo {
|
||||
name: "ORDERS_AUDIT".to_string(),
|
||||
event: "INSERT".to_string(),
|
||||
timing: "AFTER EACH ROW".to_string(),
|
||||
statement: Some("BEGIN\n NULL;\nEND;".to_string()),
|
||||
});
|
||||
|
||||
let result = build_table_structure_change_sql(TableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::Oracle),
|
||||
schema: Some("APP".to_string()),
|
||||
table_name: "ORDERS".to_string(),
|
||||
columns: Vec::new(),
|
||||
indexes: Vec::new(),
|
||||
foreign_keys: Vec::new(),
|
||||
triggers: vec![existing],
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
assert_eq!(
|
||||
result.statements,
|
||||
vec![
|
||||
"DROP TRIGGER \"APP\".\"ORDERS_AUDIT\";",
|
||||
"CREATE OR REPLACE TRIGGER \"APP\".\"ORDERS_AUDIT_V2\" AFTER INSERT ON \"APP\".\"ORDERS\"\nFOR EACH ROW\nBEGIN\n NULL;\nEND;",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsupported_oracle_compound_trigger_shape() {
|
||||
let result = build_table_structure_change_sql(TableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::Oracle),
|
||||
schema: Some("APP".to_string()),
|
||||
table_name: "ORDERS".to_string(),
|
||||
columns: Vec::new(),
|
||||
indexes: Vec::new(),
|
||||
foreign_keys: Vec::new(),
|
||||
triggers: vec![trigger("ORDERS_CT", "COMPOUND", "UPDATE", "BEGIN\n NULL;\nEND;")],
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert!(result.statements.is_empty());
|
||||
assert_eq!(result.warnings, vec!["Unsupported Oracle trigger timing \"COMPOUND\"."]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_varchar_default_is_quoted() {
|
||||
let mut col = column("name");
|
||||
|
|
|
|||
|
|
@ -7,19 +7,20 @@ pub(super) fn build_trigger_sql(options: &TableStructureSqlOptions, warnings: &m
|
|||
return Vec::new();
|
||||
}
|
||||
|
||||
let dialect = super::dialect::capabilities_for(options.database_type).dialect;
|
||||
let database_label = database_label(options.database_type);
|
||||
if super::dialect::capabilities_for(options.database_type).dialect != StructureDialect::Mysql {
|
||||
if !matches!(dialect, StructureDialect::Mysql | StructureDialect::Oracle) {
|
||||
warnings.push(format!("Editing triggers is not supported for {database_label} from this editor."));
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let table = qualified_table(StructureDialect::Mysql, options.schema.as_deref(), &options.table_name);
|
||||
let table = qualified_table(dialect, options.schema.as_deref(), &options.table_name);
|
||||
let mut statements = Vec::new();
|
||||
|
||||
for trigger in &options.triggers {
|
||||
if trigger.marked_for_drop {
|
||||
if let Some(original) = &trigger.original {
|
||||
statements.push(drop_trigger_sql(options.schema.as_deref(), &original.name));
|
||||
statements.push(drop_trigger_sql(dialect, options.schema.as_deref(), &original.name));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
|
@ -28,10 +29,13 @@ pub(super) fn build_trigger_sql(options: &TableStructureSqlOptions, warnings: &m
|
|||
if !has_trigger_change(trigger, original) {
|
||||
continue;
|
||||
}
|
||||
statements.push(drop_trigger_sql(options.schema.as_deref(), &original.name));
|
||||
// Oracle can replace a trigger in place; renames still require dropping the old object.
|
||||
if dialect != StructureDialect::Oracle || clean(&trigger.name) != clean(&original.name) {
|
||||
statements.push(drop_trigger_sql(dialect, options.schema.as_deref(), &original.name));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(sql) = create_trigger_sql(&table, trigger, warnings) {
|
||||
if let Some(sql) = create_trigger_sql(dialect, options.schema.as_deref(), &table, trigger, warnings) {
|
||||
statements.push(sql);
|
||||
}
|
||||
}
|
||||
|
|
@ -46,45 +50,125 @@ fn has_trigger_change(trigger: &EditableStructureTrigger, original: &TriggerInfo
|
|||
|| normalize_statement(&trigger.statement) != normalize_statement(original.statement.as_deref().unwrap_or(""))
|
||||
}
|
||||
|
||||
fn drop_trigger_sql(schema: Option<&str>, name: &str) -> String {
|
||||
fn drop_trigger_sql(dialect: StructureDialect, schema: Option<&str>, name: &str) -> String {
|
||||
let qualified_name = if schema.is_some_and(|schema| !schema.trim().is_empty()) {
|
||||
format!(
|
||||
"{}.{}",
|
||||
quote_ident(StructureDialect::Mysql, schema.unwrap()),
|
||||
quote_ident(StructureDialect::Mysql, name)
|
||||
)
|
||||
format!("{}.{}", quote_ident(dialect, schema.unwrap()), quote_ident(dialect, name))
|
||||
} else {
|
||||
quote_ident(StructureDialect::Mysql, name)
|
||||
quote_ident(dialect, name)
|
||||
};
|
||||
format!("DROP TRIGGER {qualified_name};")
|
||||
}
|
||||
|
||||
fn create_trigger_sql(table: &str, trigger: &EditableStructureTrigger, warnings: &mut Vec<String>) -> Option<String> {
|
||||
fn create_trigger_sql(
|
||||
dialect: StructureDialect,
|
||||
schema: Option<&str>,
|
||||
table: &str,
|
||||
trigger: &EditableStructureTrigger,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> Option<String> {
|
||||
let name = clean(&trigger.name);
|
||||
let timing = normalize_keyword(&trigger.timing);
|
||||
let event = normalize_keyword(&trigger.event);
|
||||
let event = clean(&trigger.event);
|
||||
let statement = clean(&trigger.statement);
|
||||
|
||||
if name.is_empty() || timing.is_empty() || event.is_empty() || statement.is_empty() {
|
||||
warnings.push("Trigger name, timing, event, and statement are required.".to_string());
|
||||
return None;
|
||||
}
|
||||
if !matches!(timing.as_str(), "BEFORE" | "AFTER") {
|
||||
warnings.push(format!("Unsupported trigger timing \"{}\".", clean(&trigger.timing)));
|
||||
match dialect {
|
||||
StructureDialect::Mysql => create_mysql_trigger_sql(table, &name, &timing, &event, &statement, warnings),
|
||||
StructureDialect::Oracle => {
|
||||
create_oracle_trigger_sql(schema, table, &name, &timing, &event, &statement, warnings)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn create_mysql_trigger_sql(
|
||||
table: &str,
|
||||
name: &str,
|
||||
timing: &str,
|
||||
event: &str,
|
||||
statement: &str,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> Option<String> {
|
||||
let event = normalize_keyword(event);
|
||||
if !matches!(timing, "BEFORE" | "AFTER") {
|
||||
warnings.push(format!("Unsupported trigger timing \"{timing}\"."));
|
||||
return None;
|
||||
}
|
||||
if !matches!(event.as_str(), "INSERT" | "UPDATE" | "DELETE") {
|
||||
warnings.push(format!("Unsupported trigger event \"{}\".", clean(&trigger.event)));
|
||||
warnings.push(format!("Unsupported trigger event \"{}\".", clean(&event)));
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(format!(
|
||||
"CREATE TRIGGER {} {timing} {event} ON {table} FOR EACH ROW\n{};",
|
||||
quote_ident(StructureDialect::Mysql, &name),
|
||||
quote_ident(StructureDialect::Mysql, name),
|
||||
statement.trim_end_matches(';').trim_end()
|
||||
))
|
||||
}
|
||||
|
||||
fn create_oracle_trigger_sql(
|
||||
schema: Option<&str>,
|
||||
table: &str,
|
||||
name: &str,
|
||||
timing: &str,
|
||||
event: &str,
|
||||
statement: &str,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> Option<String> {
|
||||
let Some((timing_clause, row_level)) = oracle_trigger_timing(timing) else {
|
||||
warnings.push(format!("Unsupported Oracle trigger timing \"{timing}\"."));
|
||||
return None;
|
||||
};
|
||||
if !is_supported_oracle_trigger_event(event) {
|
||||
warnings.push(format!("Unsupported Oracle trigger event \"{}\".", clean(event)));
|
||||
return None;
|
||||
}
|
||||
|
||||
let trigger_name = if schema.is_some_and(|schema| !schema.trim().is_empty()) {
|
||||
format!(
|
||||
"{}.{}",
|
||||
quote_ident(StructureDialect::Oracle, schema.unwrap()),
|
||||
quote_ident(StructureDialect::Oracle, name)
|
||||
)
|
||||
} else {
|
||||
quote_ident(StructureDialect::Oracle, name)
|
||||
};
|
||||
let row_clause = if row_level { "\nFOR EACH ROW" } else { "" };
|
||||
let statement = statement.trim_end().trim_end_matches('/').trim_end().trim_end_matches(';').trim_end();
|
||||
|
||||
Some(format!(
|
||||
"CREATE OR REPLACE TRIGGER {trigger_name} {timing_clause} {event} ON {table}{row_clause}\n{statement};"
|
||||
))
|
||||
}
|
||||
|
||||
fn oracle_trigger_timing(timing: &str) -> Option<(&'static str, bool)> {
|
||||
match timing {
|
||||
"BEFORE" | "BEFORE EACH ROW" => Some(("BEFORE", true)),
|
||||
"AFTER" | "AFTER EACH ROW" => Some(("AFTER", true)),
|
||||
"INSTEAD OF" | "INSTEAD OF EACH ROW" => Some(("INSTEAD OF", true)),
|
||||
"BEFORE STATEMENT" => Some(("BEFORE", false)),
|
||||
"AFTER STATEMENT" => Some(("AFTER", false)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_supported_oracle_trigger_event(event: &str) -> bool {
|
||||
if event.contains([';', '\n', '\r']) || event.contains("--") || event.contains("/*") {
|
||||
return false;
|
||||
}
|
||||
let upper = event.to_ascii_uppercase();
|
||||
let clauses: Vec<&str> = upper.split(" OR ").map(str::trim).collect();
|
||||
!clauses.is_empty()
|
||||
&& clauses.iter().all(|clause| {
|
||||
matches!(*clause, "INSERT" | "DELETE")
|
||||
|| *clause == "UPDATE"
|
||||
|| clause.strip_prefix("UPDATE OF ").is_some_and(|columns| !columns.trim().is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_keyword(value: &str) -> String {
|
||||
clean(value).to_ascii_uppercase()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
resolveQueryContextCandidateDatabase,
|
||||
resolveQueryContextObjectTarget,
|
||||
} from "../../apps/desktop/src/lib/sql/queryCursorTableTarget.ts";
|
||||
import { qualifiedTableName } from "../../apps/desktop/src/lib/table/tableSelectSql.ts";
|
||||
import type { QueryTab, TreeNode } from "../../apps/desktop/src/types/database.ts";
|
||||
|
||||
function queryTab(sql: string, head: number, schema = "public"): QueryTab {
|
||||
|
|
@ -97,6 +98,52 @@ test("builds three-part candidates at an explicit context-menu position", () =>
|
|||
});
|
||||
});
|
||||
|
||||
test("folds unquoted Oracle-compatible identifiers before view-data quoting", () => {
|
||||
for (const databaseType of ["oracle", "dameng"] as const) {
|
||||
const sql = "select * from app.order_items";
|
||||
const candidate = queryTableCandidateAtSqlPosition({ connectionId: "conn-1", database: "service", databaseType, sql, position: sql.indexOf("order") });
|
||||
|
||||
assert.deepEqual(candidate, {
|
||||
connectionId: "conn-1",
|
||||
database: "service",
|
||||
schema: "APP",
|
||||
tableName: "ORDER_ITEMS",
|
||||
});
|
||||
assert.equal(qualifiedTableName({ databaseType, schema: candidate?.schema, tableName: candidate?.tableName ?? "" }), '"APP"."ORDER_ITEMS"');
|
||||
}
|
||||
});
|
||||
|
||||
test("preserves explicitly quoted Oracle-compatible identifier case", () => {
|
||||
const sql = 'select * from "App"."Order_Items"';
|
||||
const candidate = queryTableCandidateAtSqlPosition({ connectionId: "conn-1", database: "service", databaseType: "oracle", sql, position: sql.indexOf("Order") });
|
||||
|
||||
assert.deepEqual(candidate, {
|
||||
connectionId: "conn-1",
|
||||
database: "service",
|
||||
schema: "App",
|
||||
tableName: "Order_Items",
|
||||
});
|
||||
assert.equal(qualifiedTableName({ databaseType: "oracle", schema: candidate?.schema, tableName: candidate?.tableName ?? "" }), '"App"."Order_Items"');
|
||||
});
|
||||
|
||||
test("folds PostgreSQL unquoted identifiers without changing quoted names", () => {
|
||||
const unquotedSql = "select * from Reporting.Users";
|
||||
const quotedSql = 'select * from "Reporting"."Users"';
|
||||
|
||||
assert.deepEqual(queryTableCandidateAtSqlPosition({ connectionId: "conn-1", database: "app", databaseType: "postgres", sql: unquotedSql, position: unquotedSql.indexOf("Users") }), {
|
||||
connectionId: "conn-1",
|
||||
database: "app",
|
||||
schema: "reporting",
|
||||
tableName: "users",
|
||||
});
|
||||
assert.deepEqual(queryTableCandidateAtSqlPosition({ connectionId: "conn-1", database: "app", databaseType: "postgres", sql: quotedSql, position: quotedSql.indexOf("Users") }), {
|
||||
connectionId: "conn-1",
|
||||
database: "app",
|
||||
schema: "Reporting",
|
||||
tableName: "Users",
|
||||
});
|
||||
});
|
||||
|
||||
test("parses escaped quotes inside qualified identifiers", () => {
|
||||
const sql = 'select * from "warehouse"."reporting"."Daily ""Sales"""';
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue