From ca9bfae5b952df2768964771e65b458804fe56d8 Mon Sep 17 00:00:00 2001 From: zipg Date: Thu, 24 Sep 2026 23:48:53 +0800 Subject: [PATCH] fix(oracle): render placeholder for user-defined type columns --- agents/drivers/oracle-go/main.go | 91 +++++++++++++++++- agents/drivers/oracle-go/main_test.go | 131 ++++++++++++++++++++++++++ 2 files changed, 219 insertions(+), 3 deletions(-) diff --git a/agents/drivers/oracle-go/main.go b/agents/drivers/oracle-go/main.go index e003a37b3f..a8560f9268 100644 --- a/agents/drivers/oracle-go/main.go +++ b/agents/drivers/oracle-go/main.go @@ -3866,7 +3866,7 @@ func (s *server) runPagedOracleSelect(sqlText string, opts queryOptions, pageSiz if err != nil { s.closeRows(rows) var panicErr oracleDriverPanicError - if attempt == 0 && errors.As(err, &panicErr) { + if attempt == 0 && !oracleSQLLocksRows(sqlText) && errors.As(err, &panicErr) { if placeholder, ok := oraclePlaceholderRetrySQL(sqlText, s.loadOracleColumnMeta); ok { sqlText = placeholder continue @@ -4211,10 +4211,32 @@ func columnTypeNames(rows *sql.Rows) []string { } func (s *server) queryRowsWithOracleValueRewriteIfNeeded(sqlText string, timeoutSecs int, deferLOBs bool) (*sql.Rows, error) { + // A statement that keeps row locks while it runs is rewritten before the + // first attempt: when such a statement panics after the rows were locked, + // the locks stay behind and the retry below blocks on them until the query + // times out (issues #9344 / #9180). + if oracleSQLLocksRows(sqlText) { + rewritten, err := rewriteOracleSelectSQL(sqlText, s.loadOracleColumnMeta, deferLOBs) + if err == nil && rewritten != sqlText { + return s.queryRowsWithTimeout(rewritten, nil, timeoutSecs) + } + return s.queryRowsWithTimeout(sqlText, nil, timeoutSecs) + } if deferLOBs { rewritten, err := rewriteOracleSelectSQL(sqlText, s.loadOracleColumnMeta, true) if err == nil && rewritten != sqlText { - return s.queryRowsWithTimeout(rewritten, nil, timeoutSecs) + rows, rewrittenErr := s.queryRowsWithTimeout(rewritten, nil, timeoutSecs) + if rewrittenErr == nil { + return rows, nil + } + // A deferred projection can still read a value the driver cannot + // decode (user-defined object/collection columns). Only a panic is + // worth retrying through the value-rewrite fallback below; other + // failures keep their own error. + var panicErr oracleDriverPanicError + if !errors.As(rewrittenErr, &panicErr) { + return nil, rewrittenErr + } } } rows, err := s.queryRowsWithTimeout(sqlText, nil, timeoutSecs) @@ -4589,6 +4611,8 @@ func rewriteOracleSelectItems(items []string, columns []oracleColumnMeta, tableR rewritten = append(rewritten, oracleGeometryExpression(column, columnRef, outputAlias)) } else if isOracleXMLType(column.DataType) && !deferLOBs { rewritten = append(rewritten, oracleXMLSerializeExpression(columnRef, outputAlias)) + } else if isOracleOpaqueObjectType(column) { + rewritten = append(rewritten, oracleOpaqueObjectExpression(column, columnRef, outputAlias)) } else if deferLOBs { if expressions, ok := oracleDeferredLOBExpressions(columnRef, outputAlias, sourceIndex, column); ok { rewritten = append(rewritten, expressions...) @@ -4623,6 +4647,12 @@ func rewriteOracleSelectItems(items []string, columns []oracleColumnMeta, tableR sourceIndex++ continue } + if isOracleOpaqueObjectType(meta) { + rewritten = append(rewritten, oracleOpaqueObjectExpression(meta, columnRef, outputAlias)) + changed = true + sourceIndex++ + continue + } if deferLOBs { if expressions, isLOB := oracleDeferredLOBExpressions(columnRef, outputAlias, sourceIndex, meta); isLOB { rewritten = append(rewritten, expressions...) @@ -4676,6 +4706,41 @@ func oracleGeometryPlaceholder(column oracleColumnMeta) string { return "" } +// oracleOpaqueObjectExpression projects a placeholder instead of the value of a +// user-defined type (object, collection, ANYDATA, ...). go-ora cannot decode +// those values over the wire and panics while scanning them, which fails the +// whole statement and rolls the transaction back (issues #9344 / #9180). The +// placeholder keeps the statement and the remaining columns readable. +func oracleOpaqueObjectExpression(column oracleColumnMeta, columnRef, alias string) string { + return fmt.Sprintf("CASE WHEN %s IS NULL THEN NULL ELSE '%s' END AS %s", columnRef, oracleOpaqueObjectPlaceholder(column), alias) +} + +func oracleOpaqueObjectPlaceholder(column oracleColumnMeta) string { + name := strings.ToUpper(strings.TrimSpace(column.DataType)) + if name == "" { + name = "OBJECT" + } + return "<" + name + ">" +} + +// oracleSQLLocksRows reports whether a statement holds row locks while it runs +// (FOR UPDATE, FOR UPDATE OF ..., NOWAIT / WAIT n / SKIP LOCKED). Such a +// statement must not be retried after a driver panic: the failed attempt keeps +// its locks, so the retry would block on them instead of returning. +func oracleSQLLocksRows(sqlText string) bool { + for searchFrom := 0; searchFrom < len(sqlText); { + pos := findTopLevelSQLKeyword(sqlText, searchFrom, "for") + if pos < 0 { + return false + } + if sqlKeywordAt(sqlText, skipSQLWhitespace(sqlText, pos+len("for")), "update") { + return true + } + searchFrom = pos + len("for") + } + return false +} + func oracleDeferredLOBKind(dataType string) (kind, placeholder string, ok bool) { switch strings.ToUpper(strings.TrimSpace(dataType)) { case "CLOB": @@ -4773,7 +4838,15 @@ func oracleQualifierMatchesTable(qualifier string, tableRef oracleTableRef) bool func oracleColumnsNeedValueRewrite(columns []oracleColumnMeta, deferLOBs bool) bool { for _, column := range columns { - if isOracleGeometryType(column) || isOracleXMLType(column.DataType) || (deferLOBs && isOracleDeferredLOBType(column.DataType)) { + if isOracleGeometryType(column) || isOracleXMLType(column.DataType) { + return true + } + if deferLOBs && isOracleDeferredLOBType(column.DataType) { + return true + } + // Opaque user-defined types are substituted in both the plain and the + // deferred preview projection, so a preview never panics on them. + if isOracleOpaqueObjectType(column) { return true } } @@ -4822,6 +4895,18 @@ func isOracleGeometryType(column oracleColumnMeta) bool { return isOracleSTGeometry(column) || isOracleSDOGeometry(column) } +// isOracleOpaqueObjectType reports whether a column's declared type is a named +// user-defined type that the driver cannot decode. Built-in scalar types report +// no DATA_TYPE_OWNER; named types (object, collection, ANYDATA, ...) do. Types +// DBX is able to render — SDE/SDO geometry, XMLType and deferred LOBs — are +// matched earlier, so they are never treated as opaque here. +func isOracleOpaqueObjectType(column oracleColumnMeta) bool { + if isOracleGeometryType(column) || isOracleXMLType(column.DataType) || isOracleDeferredLOBType(column.DataType) { + return false + } + return strings.TrimSpace(column.DataTypeOwner) != "" +} + func leadingSQLSelectListStart(sqlText string) int { trimmed := trimLeadingSQLComments(sqlText) prefixLen := len(sqlText) - len(trimmed) diff --git a/agents/drivers/oracle-go/main_test.go b/agents/drivers/oracle-go/main_test.go index 31e8c5607e..4f93f37fba 100644 --- a/agents/drivers/oracle-go/main_test.go +++ b/agents/drivers/oracle-go/main_test.go @@ -4095,3 +4095,134 @@ func (r *oracleManualTxRows) Next(dest []driver.Value) error { r.next++ return nil } + +func TestOracleOpaqueObjectTypeDetection(t *testing.T) { + cases := []struct { + name string + column oracleColumnMeta + want bool + }{ + {"object type", oracleColumnMeta{Name: "G", DataType: "GEOM_T", DataTypeOwner: "DBX_TEST"}, true}, + {"collection type", oracleColumnMeta{Name: "V", DataType: "DBX9180_VARRAY", DataTypeOwner: "DBX_TEST"}, true}, + {"anydata", oracleColumnMeta{Name: "V", DataType: "ANYDATA", DataTypeOwner: "SYS"}, true}, + {"number", oracleColumnMeta{Name: "ID", DataType: "NUMBER"}, false}, + {"varchar2", oracleColumnMeta{Name: "NOTE", DataType: "VARCHAR2"}, false}, + {"clob", oracleColumnMeta{Name: "PAYLOAD", DataType: "CLOB"}, false}, + {"xmltype", oracleColumnMeta{Name: "DOC", DataType: "XMLTYPE", DataTypeOwner: "SYS"}, false}, + {"sdo geometry", oracleColumnMeta{Name: "SHAPE", DataType: "SDO_GEOMETRY", DataTypeOwner: "MDSYS"}, false}, + {"sde geometry", oracleColumnMeta{Name: "SHAPE", DataType: "ST_GEOMETRY", DataTypeOwner: "SDE"}, false}, + } + for _, testCase := range cases { + if got := isOracleOpaqueObjectType(testCase.column); got != testCase.want { + t.Fatalf("%s: isOracleOpaqueObjectType() = %v, want %v", testCase.name, got, testCase.want) + } + } +} + +func TestRewriteOracleOpaqueObjectSelectStar(t *testing.T) { + sqlText, err := rewriteOracleSelectSQL( + `SELECT * FROM TEST_UDT`, + func(schema, table string) ([]oracleColumnMeta, error) { + return []oracleColumnMeta{ + {Name: "ID", DataType: "NUMBER"}, + {Name: "G", DataType: "GEOM_T", DataTypeOwner: "DBX_TEST"}, + {Name: "NOTE", DataType: "VARCHAR2"}, + }, nil + }, + false, + ) + if err != nil { + t.Fatal(err) + } + want := `SELECT "ID", CASE WHEN "G" IS NULL THEN NULL ELSE '' END AS "G", "NOTE" FROM TEST_UDT` + if sqlText != want { + t.Fatalf("rewriteOracleSelectSQL() = %s, want %s", sqlText, want) + } +} + +func TestRewriteOracleOpaqueObjectExplicitColumn(t *testing.T) { + sqlText, err := rewriteOracleSelectSQL( + `SELECT t.G AS shape, t.ID FROM TEST_UDT t`, + func(schema, table string) ([]oracleColumnMeta, error) { + return []oracleColumnMeta{ + {Name: "ID", DataType: "NUMBER"}, + {Name: "G", DataType: "GEOM_T", DataTypeOwner: "DBX_TEST"}, + }, nil + }, + false, + ) + if err != nil { + t.Fatal(err) + } + want := `SELECT CASE WHEN t."G" IS NULL THEN NULL ELSE '' END AS shape, t.ID FROM TEST_UDT t` + if sqlText != want { + t.Fatalf("rewriteOracleSelectSQL() = %s, want %s", sqlText, want) + } +} + +func TestRewriteOracleKeepsBuiltinColumnsUntouched(t *testing.T) { + input := `SELECT ID, NOTE FROM TEST_PLAIN` + sqlText, err := rewriteOracleSelectSQL( + input, + func(schema, table string) ([]oracleColumnMeta, error) { + return []oracleColumnMeta{ + {Name: "ID", DataType: "NUMBER"}, + {Name: "NOTE", DataType: "VARCHAR2"}, + {Name: "PAYLOAD", DataType: "CLOB"}, + }, nil + }, + false, + ) + if err != nil { + t.Fatal(err) + } + if sqlText != input { + t.Fatalf("rewriteOracleSelectSQL() = %s, want %s", sqlText, input) + } +} + +func TestOracleSQLLocksRowsDetection(t *testing.T) { + cases := []struct { + name string + sql string + want bool + }{ + {"for update", "SELECT * FROM T FOR UPDATE", true}, + {"for update skip locked", "SELECT * FROM T FOR UPDATE SKIP LOCKED", true}, + {"for update of column", "SELECT ID FROM T FOR UPDATE OF ID NOWAIT", true}, + {"lowercase", "select id from t for update", true}, + {"plain select", "SELECT * FROM T", false}, + {"string literal", "SELECT 'FOR UPDATE' FROM T", false}, + {"comment", "SELECT * FROM T -- FOR UPDATE", false}, + {"subquery for update", "SELECT * FROM (SELECT ID FROM T FOR UPDATE) X", false}, + {"column named update", "SELECT FOR_UPDATE FROM T", false}, + } + for _, testCase := range cases { + if got := oracleSQLLocksRows(testCase.sql); got != testCase.want { + t.Fatalf("%s: oracleSQLLocksRows(%q) = %v, want %v", testCase.name, testCase.sql, got, testCase.want) + } + } +} + +func TestRewriteOracleOpaqueObjectInDeferredProjection(t *testing.T) { + sqlText, err := rewriteOracleSelectSQL( + `SELECT * FROM TEST_UDT_LOB`, + func(schema, table string) ([]oracleColumnMeta, error) { + return []oracleColumnMeta{ + {Name: "ID", DataType: "NUMBER"}, + {Name: "DOC", DataType: "CLOB"}, + {Name: "G", DataType: "GEOM_T", DataTypeOwner: "DBX_TEST"}, + }, nil + }, + true, + ) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(sqlText, `CASE WHEN "G" IS NULL THEN NULL ELSE '' END AS "G"`) { + t.Fatalf("deferred rewrite did not substitute the opaque column: %s", sqlText) + } + if !strings.Contains(sqlText, `"DOC"`) { + t.Fatalf("deferred rewrite dropped the LOB column: %s", sqlText) + } +}