diff --git a/agents/drivers/oracle-go/main.go b/agents/drivers/oracle-go/main.go index 6d973fab9..5ac0b1160 100644 --- a/agents/drivers/oracle-go/main.go +++ b/agents/drivers/oracle-go/main.go @@ -31,13 +31,26 @@ const defaultMaxRows = 1000 const oracleCharsetZHS32GB18030 = 854 const legacyAgentSessionID = "__legacy__" const maxAgentSessions = 256 +const oracleLegacyLOBMaxMajorVersion = 11 +const oracleDatabaseVersionProbeTimeout = 3 * time.Second + +const oracleDatabaseVersionSQL = ` +SELECT VERSION +FROM PRODUCT_COMPONENT_VERSION +WHERE PRODUCT LIKE 'Oracle Database%' + AND ROWNUM = 1` var ( oraclePlSQLBlockStartRegexp = regexp.MustCompile(`(?is)^\s*(?:DECLARE|BEGIN|CREATE\s+(?:OR\s+REPLACE\s+)?(?:(?:EDITIONABLE|NONEDITIONABLE)\s+)?(?:FUNCTION|PROCEDURE|TRIGGER|PACKAGE(?:\s+BODY)?|TYPE(?:\s+BODY)?))\b`) oraclePlSQLBlockEndRegexp = regexp.MustCompile(`(?is)\bEND\s*;\s*$`) oracleNamedPlSQLBlockEndRegexp = regexp.MustCompile(`(?is)\bEND\s+([A-Z0-9_$#]+)\s*;\s*$`) oracleUnsupportedServerCharsetRegexp = regexp.MustCompile(`server use charset with id: ([0-9]+).*not supported by the driver`) - oracleStringConverters = map[int]converters.IStringConverter{ + oracleVersionNumberRegexp = regexp.MustCompile(`(?:^|[^0-9])([0-9]+)\.[0-9]+`) + oracleDatabaseVersionQueries = []string{ + oracleDatabaseVersionSQL, + `SELECT BANNER FROM V$VERSION WHERE BANNER LIKE 'Oracle Database%' AND ROWNUM = 1`, + } + oracleStringConverters = map[int]converters.IStringConverter{ oracleCharsetZHS32GB18030: oracleGB18030Converter{}, } ) @@ -793,7 +806,7 @@ func (s *server) dispatch(method string, params map[string]json.RawMessage) (any func (s *server) connect(params connectParams) error { _ = s.disconnect() - db, err := openAndPingDB(params, 15*time.Second) + db, effectiveParams, err := openSessionDB(params, 15*time.Second) if err != nil { return err } @@ -804,10 +817,109 @@ func (s *server) connect(params connectParams) error { return err } s.db = db - s.params = params + s.params = effectiveParams return nil } +func openSessionDB(params connectParams, timeout time.Duration) (*sql.DB, connectParams, error) { + db, err := openAndPingDB(params, timeout) + if err != nil { + return nil, params, err + } + if hasOracleLOBFetchOption(params) { + return db, params, nil + } + majorVersion, ok := oracleServerMajorVersion(db, timeout) + if !shouldUseLegacyOracleLOBFetch(params, majorVersion, ok) { + return db, params, nil + } + + effectiveParams := withOracleLOBFetchPost(params) + if effectiveParams == params { + return db, params, nil + } + if err := db.Close(); err != nil { + return nil, params, err + } + db, err = openAndPingDB(effectiveParams, timeout) + if err != nil { + return nil, params, err + } + return db, effectiveParams, nil +} + +func oracleServerMajorVersion(db *sql.DB, timeout time.Duration) (int, bool) { + if timeout > oracleDatabaseVersionProbeTimeout { + timeout = oracleDatabaseVersionProbeTimeout + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + for _, query := range oracleDatabaseVersionQueries { + var version string + err := db.QueryRowContext(ctx, query).Scan(&version) + if err == nil { + if majorVersion, ok := parseOracleMajorVersion(version); ok { + return majorVersion, true + } + } + } + return 0, false +} + +func parseOracleMajorVersion(version string) (int, bool) { + match := oracleVersionNumberRegexp.FindStringSubmatch(strings.TrimSpace(version)) + if len(match) != 2 { + return 0, false + } + value, err := strconv.Atoi(match[1]) + return value, err == nil && value > 0 +} + +func shouldUseLegacyOracleLOBFetch(params connectParams, majorVersion int, versionKnown bool) bool { + return versionKnown && majorVersion <= oracleLegacyLOBMaxMajorVersion && !hasOracleLOBFetchOption(params) +} + +func hasOracleLOBFetchOption(params connectParams) bool { + connectionString := strings.TrimSpace(params.ConnectionString) + if strings.HasPrefix(strings.ToLower(connectionString), "oracle://") { + parsed, err := url.Parse(connectionString) + return err == nil && hasURLValueKey(parsed.Query(), "LOB FETCH") + } + values, err := url.ParseQuery(params.URLParams) + return err == nil && hasURLValueKey(values, "LOB FETCH") +} + +func hasURLValueKey(values url.Values, target string) bool { + for key := range values { + if strings.EqualFold(strings.TrimSpace(key), target) { + return true + } + } + return false +} + +func withOracleLOBFetchPost(params connectParams) connectParams { + connectionString := strings.TrimSpace(params.ConnectionString) + if strings.HasPrefix(strings.ToLower(connectionString), "oracle://") { + parsed, err := url.Parse(connectionString) + if err != nil { + return params + } + values := parsed.Query() + values.Set("LOB FETCH", "POST") + parsed.RawQuery = values.Encode() + params.ConnectionString = parsed.String() + return params + } + values, err := url.ParseQuery(params.URLParams) + if err != nil { + return params + } + values.Set("LOB FETCH", "POST") + params.URLParams = values.Encode() + return params +} + func (s *server) disconnect() error { s.closeAllQuerySessions() if s.db == nil { diff --git a/agents/drivers/oracle-go/main_test.go b/agents/drivers/oracle-go/main_test.go index b42770df8..532d12e12 100644 --- a/agents/drivers/oracle-go/main_test.go +++ b/agents/drivers/oracle-go/main_test.go @@ -13,6 +13,8 @@ import ( "strings" "testing" "time" + + "github.com/sijms/go-ora/v2/configurations" ) func TestHandshakeResponse(t *testing.T) { @@ -647,6 +649,154 @@ func TestBuildDSNAddsSysDbaOption(t *testing.T) { } } +func TestParseOracleMajorVersion(t *testing.T) { + tests := []struct { + version string + major int + ok bool + }{ + {version: "10.2.0.4.0", major: 10, ok: true}, + {version: "11.2.0.4.0 Production", major: 11, ok: true}, + {version: "Oracle Database 10g Enterprise Edition Release 10.2.0.4.0 - 64bit Production", major: 10, ok: true}, + {version: "19.0.0.0.0", major: 19, ok: true}, + {version: "", ok: false}, + {version: "Oracle Database 10g", ok: false}, + } + + for _, tt := range tests { + t.Run(tt.version, func(t *testing.T) { + major, ok := parseOracleMajorVersion(tt.version) + if major != tt.major || ok != tt.ok { + t.Fatalf("parseOracleMajorVersion(%q) = (%d, %t), want (%d, %t)", tt.version, major, ok, tt.major, tt.ok) + } + }) + } +} + +func TestOracleServerMajorVersionUsesProductComponentVersion(t *testing.T) { + db, scripted := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{ + { + queryContains: "PRODUCT_COMPONENT_VERSION", + args: []driver.Value{}, + rows: [][]driver.Value{{"10.2.0.4.0"}}, + }, + }) + + major, ok := oracleServerMajorVersion(db, time.Second) + if !ok || major != 10 { + t.Fatalf("oracleServerMajorVersion() = (%d, %t), want (10, true)", major, ok) + } + if scripted.next != 1 { + t.Fatalf("expected one version query, got %d", scripted.next) + } +} + +func TestOracleServerMajorVersionFallsBackToVersionBanner(t *testing.T) { + db, scripted := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{ + { + queryContains: "PRODUCT_COMPONENT_VERSION", + args: []driver.Value{}, + err: errors.New("view unavailable"), + }, + { + queryContains: "V$VERSION", + args: []driver.Value{}, + rows: [][]driver.Value{{ + "Oracle Database 11g Enterprise Edition Release 11.2.0.4.0 - 64bit Production", + }}, + }, + }) + + major, ok := oracleServerMajorVersion(db, time.Second) + if !ok || major != 11 { + t.Fatalf("oracleServerMajorVersion() = (%d, %t), want (11, true)", major, ok) + } + if scripted.next != 2 { + t.Fatalf("expected both version queries, got %d", scripted.next) + } +} + +func TestWithOracleLOBFetchPostUsesURLParamsForGeneratedDSN(t *testing.T) { + params := withOracleLOBFetchPost(connectParams{ + Host: "db.example.com", + Port: 1521, + Database: "ORCL", + Username: "scott", + Password: "tiger", + URLParams: "CHARSET=ZHS16GBK", + }) + + values, err := url.ParseQuery(params.URLParams) + if err != nil { + t.Fatal(err) + } + if values.Get("LOB FETCH") != "POST" || values.Get("CHARSET") != "ZHS16GBK" { + t.Fatalf("legacy LOB mode should preserve URL parameters, got: %s", params.URLParams) + } + config, err := configurations.ParseConfig(buildDSN(params)) + if err != nil { + t.Fatal(err) + } + if config.Lob != configurations.STREAM { + t.Fatalf("generated DSN should enable streamed LOB reads, got: %s", buildDSN(params)) + } +} + +func TestWithOracleLOBFetchPostUpdatesRawOracleURL(t *testing.T) { + params := withOracleLOBFetchPost(connectParams{ + ConnectionString: "oracle://scott:tiger@db.example.com:1521/ORCL?CHARSET=ZHS16GBK", + }) + parsed, err := url.Parse(params.ConnectionString) + if err != nil { + t.Fatal(err) + } + if parsed.Query().Get("LOB FETCH") != "POST" || parsed.Query().Get("CHARSET") != "ZHS16GBK" { + t.Fatalf("raw Oracle URL should preserve query parameters, got: %s", params.ConnectionString) + } + config, err := configurations.ParseConfig(params.ConnectionString) + if err != nil { + t.Fatal(err) + } + if config.Lob != configurations.STREAM { + t.Fatalf("raw Oracle URL should enable streamed LOB reads, got: %s", params.ConnectionString) + } +} + +func TestHasOracleLOBFetchOptionHonorsExplicitModes(t *testing.T) { + tests := []connectParams{ + {URLParams: "lob+fetch=inline"}, + {URLParams: "LOB%20FETCH=POST"}, + {ConnectionString: "oracle://scott:tiger@db.example.com:1521/ORCL?lob+fetch=stream"}, + } + for _, params := range tests { + if !hasOracleLOBFetchOption(params) { + t.Fatalf("explicit LOB fetch mode should be detected: %+v", params) + } + } + if hasOracleLOBFetchOption(connectParams{URLParams: "CHARSET=ZHS16GBK"}) { + t.Fatal("unrelated URL parameters should not be treated as an explicit LOB fetch mode") + } +} + +func TestShouldUseLegacyOracleLOBFetchOnlyForLegacyServers(t *testing.T) { + params := connectParams{URLParams: "CHARSET=ZHS16GBK"} + if !shouldUseLegacyOracleLOBFetch(params, 10, true) { + t.Fatal("Oracle 10g should use streamed LOB reads") + } + if !shouldUseLegacyOracleLOBFetch(params, 11, true) { + t.Fatal("Oracle 11g should use streamed LOB reads") + } + if shouldUseLegacyOracleLOBFetch(params, 12, true) || shouldUseLegacyOracleLOBFetch(params, 19, true) { + t.Fatal("modern Oracle versions should retain the driver's default LOB mode") + } + if shouldUseLegacyOracleLOBFetch(params, 0, false) { + t.Fatal("unknown Oracle versions should retain the driver's default LOB mode") + } + if shouldUseLegacyOracleLOBFetch(connectParams{URLParams: "LOB+FETCH=INLINE"}, 10, true) { + t.Fatal("an explicit user LOB mode should not be overridden") + } +} + func TestOracleGB18030ConverterRoundTrip(t *testing.T) { converter := oracleGB18030Converter{} input := "DBX \u4e2d\u6587 \U00020000"