Skip to content

Commit fc823a0

Browse files
yahondaclaude
andcommitted
Resolve describe() via DBMS_UTILITY.NAME_RESOLVE
Replaces the catalog-based `resolve_data_source_name` (single `all_objects` query + secondary `all_synonyms` lookup since rsim#2521) with a `DBMS_UTILITY.NAME_RESOLVE` call. NAME_RESOLVE chases private and public synonyms inside the PL/SQL engine and returns `[owner, object_name]` in one round trip, so the work does not depend on `ALL_*` dictionary-view cost — the failure mode that rsim#2521's secondary `all_synonyms` lookup leaves behind on synonym-heavy schemas. Now that PR rsim#2513 has landed `DBMS_METADATA.GET_DDL` in the adapter, using another `DBMS_*` package is no longer a style break. Benchmark on Oracle 23ai with a 1000-object fixture (700 tables + 100 views + 100 private synonyms + 100 public synonyms): | case | master | this PR | speedup | |------------------|---------:|---------:|--------:| | tables | 0.384ms | 0.300ms | 1.3x | | views | 0.364ms | 0.303ms | 1.2x | | private synonyms | 13.031ms | 0.447ms | 29x | | public synonyms | 1.723ms | 0.454ms | 3.8x | | all mixed | 0.578ms | 0.265ms | 2.2x | Numbers measured against an out-of-tree benchmark script (kept on the POC branch rsim#2566); the gem itself does not ship a benchmark file. Implementation: - OUT-bind plumbing on both the OCI8 path (anonymous PL/SQL block with named binds) and the JDBC path (`CallableStatement` + `registerOutParameter`). Both wrap the call in `with_retry` so a stale connection is marked dead consistently with the rest of the adapter. - `normalize_name_for_name_resolve` preserves case for already-quoted identifiers (e.g. `"test_Mixed_Case_Desc"`) and doubles embedded `"` characters in unquoted parts (Oracle's `""` escape). Without this, `%("#{part}")` would re-quote an already-quoted part, producing `""test_Mixed""` and raising ORA-01741. - `split_dotted_name` is a quote-aware splitter so `"Foo.Bar"` (quoted dot) survives intact, including the `""` escape sequence inside a quoted identifier. - Malformed identifiers raise `ArgumentError` up-front instead of being silently mangled and rejected by Oracle as an unhelpful `ConnectionException`. Three shapes are caught: * empty parts (`schema..table`, `.foo`, `foo.`) * three or more parts (`schema."table".extra` — NAME_RESOLVE with `context = 0` only accepts `schema.object`) * half-quoted parts (`"foo`, `foo"` — opens a quoted identifier that never closes, or vice versa). Embedded `"` mid-part (`Test"x`) is still allowed; it goes through the quote-and- double-escape branch. - DB-link names (`name@db_link`) are rejected up-front with `ArgumentError` — `data_source_exists?` is not the right entry point for resolving remote objects. - Circular synonyms surface as ORA-00980 from NAME_RESOLVE, wrapped as `OracleEnhanced::ConnectionException`. No Ruby-side stack overflow, clean error. - `resolve_data_source_name` keeps master's contract: raise `OracleEnhanced::ConnectionException` for a non-existent name so `data_source_exists?`'s `rescue` returns false. - The new path runs through an anonymous PL/SQL block (OCI) / `CallableStatement` (JDBC), bypassing `select_one`. The `sql.active_record` SCHEMA notification is preserved via explicit `instrumenter.instrument`, so logging and other subscribers keep seeing the describe call. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 958d1f2 commit fc823a0

4 files changed

Lines changed: 238 additions & 63 deletions

File tree

lib/active_record/connection_adapters/oracle_enhanced/jdbc_connection.rb

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,30 @@ def database_version
325325
end
326326
end
327327

328+
# Resolve a schema object (table, view, synonym) to [owner, object_name]
329+
# in a single round trip via DBMS_UTILITY.NAME_RESOLVE. Private and
330+
# public synonyms are chased server-side, and Oracle raises ORA-00980
331+
# ("synonym translation is no longer valid") natively on a looping chain.
332+
def name_resolve(name)
333+
with_retry do
334+
# Parameters 4-7 are required by NAME_RESOLVE's signature but unused here.
335+
cs = @raw_connection.prepareCall("BEGIN DBMS_UTILITY.NAME_RESOLVE(?, 0, ?, ?, ?, ?, ?, ?); END;")
336+
begin
337+
cs.setString(1, name)
338+
cs.registerOutParameter(2, java.sql.Types::VARCHAR) # schema
339+
cs.registerOutParameter(3, java.sql.Types::VARCHAR) # part1 (object name)
340+
cs.registerOutParameter(4, java.sql.Types::VARCHAR) # part2
341+
cs.registerOutParameter(5, java.sql.Types::VARCHAR) # dblink
342+
cs.registerOutParameter(6, java.sql.Types::NUMERIC) # part1_type
343+
cs.registerOutParameter(7, java.sql.Types::NUMERIC) # object_number
344+
cs.execute
345+
[cs.getString(2), cs.getString(3)]
346+
ensure
347+
cs&.close
348+
end
349+
end
350+
end
351+
328352
class Cursor
329353
def initialize(connection, raw_statement, exec_sql = nil)
330354
@raw_connection = connection

lib/active_record/connection_adapters/oracle_enhanced/oci_connection.rb

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,36 @@ def prepare(sql)
109109
Cursor.new(self, @raw_connection.parse(sql))
110110
end
111111

112+
# Resolve a schema object (table, view, synonym) to [owner, object_name]
113+
# in a single round trip via DBMS_UTILITY.NAME_RESOLVE. Private and
114+
# public synonyms are chased server-side, and Oracle raises ORA-00980
115+
# ("synonym translation is no longer valid") natively on a looping chain.
116+
def name_resolve(name)
117+
with_retry do
118+
# l_part2 / l_dblink / l_type / l_obj_num are required by NAME_RESOLVE's signature but unused here.
119+
cursor = @raw_connection.parse(<<~PLSQL)
120+
DECLARE
121+
l_part2 VARCHAR2(128);
122+
l_dblink VARCHAR2(128);
123+
l_type NUMBER;
124+
l_obj_num NUMBER;
125+
BEGIN
126+
DBMS_UTILITY.NAME_RESOLVE(:name, 0, :out_schema, :out_name,
127+
l_part2, l_dblink, l_type, l_obj_num);
128+
END;
129+
PLSQL
130+
begin
131+
cursor.bind_param(":name", name)
132+
cursor.bind_param(":out_schema", nil, String, 128)
133+
cursor.bind_param(":out_name", nil, String, 128)
134+
cursor.exec
135+
[cursor[":out_schema"], cursor[":out_name"]]
136+
ensure
137+
cursor&.close
138+
end
139+
end
140+
end
141+
112142
class Cursor
113143
def initialize(connection, raw_cursor)
114144
@raw_connection = connection

lib/active_record/connection_adapters/oracle_enhanced/schema_statements.rb

Lines changed: 87 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -967,64 +967,99 @@ def rebuild_primary_key_index_to_default_tablespace(table_name, options)
967967
end
968968

969969
# Resolves an Oracle data-source name to its underlying [owner, table_name]
970-
# by following synonyms through the catalog. Defaults the schema to
971-
# `_connection.owner` (the adapter's configured default schema, taken
972-
# from `config[:schema]` or `config[:username]`) when the name is not
973-
# schema-qualified. This is distinct from
974-
# `SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA')`, which can differ after
975-
# `ALTER SESSION SET CURRENT_SCHEMA`.
976-
# Raises OracleEnhanced::ConnectionException if the object does not
977-
# exist or if synonym resolution produces a looping chain.
970+
# via DBMS_UTILITY.NAME_RESOLVE, which chases private and public
971+
# synonyms server-side in a single round trip. NAME_RESOLVE surfaces
972+
# circular synonym chains as ORA-00980 ("synonym translation is no
973+
# longer valid"), which we let propagate as
974+
# OracleEnhanced::ConnectionException.
975+
#
976+
# The PL/SQL call bypasses the adapter's select_one path, so we wrap
977+
# it in a sql.active_record SCHEMA notification to keep describe
978+
# visible to logging and instrumentation subscribers.
978979
def resolve_data_source_name(name)
979-
visited = Set.new
980-
loop do
981-
schema, identifier = extract_schema_qualified_name(name)
982-
real_name = schema ? "#{schema}.#{identifier}" : identifier
983-
owner = schema || _connection.owner
984-
985-
unless visited.add?([owner, identifier])
986-
raise OracleEnhanced::ConnectionException,
987-
%Q{"DESC #{name}" failed; looping chain of synonyms}
980+
real_name = normalize_name_for_name_resolve(name)
981+
instrumenter.instrument(
982+
"sql.active_record",
983+
sql: "DBMS_UTILITY.NAME_RESOLVE(#{real_name.inspect}, 0, ...)",
984+
name: "SCHEMA",
985+
connection: self,
986+
) do
987+
_connection.name_resolve(real_name)
988+
end
989+
rescue OracleEnhanced::ConnectionException, ArgumentError
990+
raise
991+
rescue => e
992+
raise OracleEnhanced::ConnectionException,
993+
%Q{"DESC #{name}" failed; does it exist? (#{e.message})}
994+
end
995+
996+
# Normalize a data-source name for DBMS_UTILITY.NAME_RESOLVE.
997+
# NAME_RESOLVE uppercases unquoted identifiers, so mixed-case
998+
# identifiers like `test_Mixed` must be wrapped in double quotes to
999+
# preserve their case. Normalization is per-dotted-part: a valid
1000+
# unquoted identifier (all upper, no spaces, etc.) is upcased in
1001+
# place; any other part is wrapped in quotes. This lets
1002+
# `sys.test_Mixed` become `SYS."test_Mixed"` rather than the
1003+
# all-quoted `"sys"."test_Mixed"` (which would send Oracle hunting
1004+
# for a lowercase schema and miss SYS).
1005+
def normalize_name_for_name_resolve(name)
1006+
name = name.to_s
1007+
raise ArgumentError, "db link is not supported" if name.include?("@")
1008+
1009+
limit = max_identifier_length
1010+
return name.upcase if OracleEnhanced::Quoting.valid_table_name?(name, max_identifier_length: limit)
1011+
1012+
parts = split_dotted_name(name)
1013+
if parts.empty? || parts.any?(&:empty?) || parts.length > 2
1014+
raise ArgumentError, "malformed identifier: #{name.inspect}"
1015+
end
1016+
1017+
parts.map do |part|
1018+
if part.start_with?('"') && part.end_with?('"') && part.size >= 2
1019+
part
1020+
elsif part.start_with?('"') || part.end_with?('"')
1021+
# Half-quoted: opens with `"` but never closes (or vice versa).
1022+
# `Test"x` (embedded `"` mid-identifier) is allowed via the
1023+
# quote-and-double-escape branch below; `"foo` is not.
1024+
raise ArgumentError, "malformed identifier: #{name.inspect}"
1025+
elsif OracleEnhanced::Quoting.valid_table_name?(part, max_identifier_length: limit)
1026+
part.upcase
1027+
else
1028+
# Oracle quoted identifier syntax: an embedded `"` must be doubled.
1029+
%("#{part.gsub('"', '""')}")
9881030
end
1031+
end.join(".")
1032+
end
9891033

990-
binds = [
991-
bind_string("table_owner", owner),
992-
bind_string("table_name", identifier),
993-
bind_string("real_name", real_name),
994-
]
995-
# Single-pass lookup against all_objects, ordered so the first row
996-
# is the one the legacy 4-way UNION ALL (all_tables, all_views,
997-
# owner synonym, public synonym) would have returned: prefer a
998-
# match in the caller's schema over PUBLIC, and within a schema
999-
# prefer TABLE over VIEW over SYNONYM.
1000-
result = select_one(<<~SQL.squish, "SCHEMA", binds)
1001-
SELECT owner, object_name table_name, object_type name_type
1002-
FROM all_objects
1003-
WHERE ((owner = :table_owner AND object_name = :table_name)
1004-
OR (owner = 'PUBLIC' AND object_name = :real_name))
1005-
AND object_type IN ('TABLE', 'VIEW', 'SYNONYM')
1006-
ORDER BY DECODE(owner, 'PUBLIC', 2, 1),
1007-
DECODE(object_type, 'TABLE', 1, 'VIEW', 2, 'SYNONYM', 3)
1008-
SQL
1009-
1010-
raise OracleEnhanced::ConnectionException, %Q{"DESC #{name}" failed; does it exist?} unless result
1011-
1012-
if result["name_type"] == "SYNONYM"
1013-
synonym_binds = [
1014-
bind_string("owner", result["owner"]),
1015-
bind_string("synonym_name", result["table_name"]),
1016-
]
1017-
syn = select_one(<<~SQL.squish, "SCHEMA", synonym_binds)
1018-
SELECT table_owner, table_name
1019-
FROM all_synonyms
1020-
WHERE owner = :owner AND synonym_name = :synonym_name
1021-
SQL
1022-
raise OracleEnhanced::ConnectionException, %Q{"DESC #{name}" failed; does it exist?} unless syn
1023-
name = "#{syn['table_owner'] && "#{syn['table_owner']}."}#{syn['table_name']}"
1034+
# Splits a dotted Oracle name on `.` boundaries while leaving dots
1035+
# that appear inside double-quoted identifiers untouched. Honours
1036+
# the `""` escape sequence Oracle uses for an embedded `"` inside
1037+
# a quoted identifier.
1038+
def split_dotted_name(name)
1039+
parts = []
1040+
current = +""
1041+
in_quotes = false
1042+
i = 0
1043+
while i < name.length
1044+
char = name[i]
1045+
if char == '"'
1046+
if in_quotes && name[i + 1] == '"'
1047+
current << '""'
1048+
i += 2
1049+
next
1050+
end
1051+
in_quotes = !in_quotes
1052+
current << char
1053+
elsif char == "." && !in_quotes
1054+
parts << current
1055+
current = +""
10241056
else
1025-
return [result["owner"], result["table_name"]]
1057+
current << char
10261058
end
1059+
i += 1
10271060
end
1061+
parts << current
1062+
parts
10281063
end
10291064

10301065
# Splits "schema.identifier" into its parts, returning [schema, identifier].

spec/active_record/connection_adapters/oracle_enhanced/connection_spec.rb

Lines changed: 97 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -972,10 +972,10 @@ def resolve(name)
972972
# Exercises all five catalog paths (table, view, materialized view,
973973
# private synonym, public synonym) for one underlying table in a single
974974
# run. The individual cases above use disjoint fixtures; this one proves
975-
# the DECODE-ordered all_objects lookup + synonym follow-through stays
976-
# consistent when a private and a public synonym to the same table
977-
# coexist, and that a materialized view created on the same base table
978-
# resolves to the MV name (not the base table) as a sibling data source.
975+
# NAME_RESOLVE stays consistent when a private and a public synonym to
976+
# the same table coexist, and that a materialized view created on the
977+
# same base table resolves to the MV name (not the base table) as a
978+
# sibling data source.
979979
it "resolves table, view, materialized view, private synonym and public synonym for the same underlying table" do
980980
@conn.execute "CREATE TABLE test_describe_all (id NUMBER)"
981981
@conn.execute "CREATE VIEW test_describe_all_v AS SELECT * FROM test_describe_all"
@@ -996,12 +996,81 @@ def resolve(name)
996996
@conn.drop_table("test_describe_all", if_exists: true)
997997
end
998998

999+
# Mixed-case quoted identifiers need per-dotted-part quoting before
1000+
# reaching DBMS_UTILITY.NAME_RESOLVE: the schema should be upcased and
1001+
# left unquoted, but the case-preserving table name must be wrapped in
1002+
# double quotes so Oracle doesn't uppercase it away.
1003+
it "should resolve a mixed-case quoted table qualified with its owner" do
1004+
@conn.execute %{CREATE TABLE "test_Mixed_Case_Desc" (id NUMBER)}
1005+
expect(resolve(%{#{@owner}.test_Mixed_Case_Desc})).to eq([@owner, "test_Mixed_Case_Desc"])
1006+
ensure
1007+
@conn.drop_table "test_Mixed_Case_Desc", if_exists: true
1008+
end
1009+
1010+
# Local objects shadow same-named public synonyms — the precedence the
1011+
# legacy SQL encoded explicitly (ORDER BY DECODE(owner, 'PUBLIC', 2, 1)).
1012+
# NAME_RESOLVE relies on Oracle's parser rules for the same outcome.
1013+
# CREATE statements are not rescued so a future identifier-too-long
1014+
# regression cannot silently turn this into a no-op (see the sibling
1015+
# private-vs-public spec for the full story).
1016+
it "prefers a local table over a same-named public synonym" do
1017+
@conn.execute "CREATE TABLE test_prec_local (id NUMBER)"
1018+
@conn.execute "CREATE PUBLIC SYNONYM test_prec_local FOR sys.dual"
1019+
expect(resolve("test_prec_local")).to eq([@owner, "TEST_PREC_LOCAL"])
1020+
ensure
1021+
@conn.drop_if_exists("PUBLIC SYNONYM", "test_prec_local")
1022+
@conn.drop_table("test_prec_local", if_exists: true)
1023+
end
1024+
1025+
# Local objects (and private synonyms in the current schema) shadow
1026+
# same-named public synonyms — the precedence the legacy SQL encoded
1027+
# explicitly. NAME_RESOLVE relies on Oracle's parser rules for the
1028+
# same outcome.
1029+
#
1030+
# Identifiers are kept under 30 characters so the spec works on Oracle
1031+
# 11g XE (identifier max length 30) as well as 12.2+ (max 128). CREATE
1032+
# statements are not rescued — a `rescue nil` here previously hid a
1033+
# silent ORA-00972 (identifier too long) on 11g, which left only the
1034+
# PUBLIC SYNONYM in place and made resolve return [SYS, DUAL].
1035+
it "prefers a private synonym over a same-named public synonym" do
1036+
@conn.execute "CREATE TABLE test_prec_target (id NUMBER)"
1037+
@conn.execute "CREATE SYNONYM test_prec_syn FOR test_prec_target"
1038+
@conn.execute "CREATE PUBLIC SYNONYM test_prec_syn FOR sys.dual"
1039+
expect(resolve("test_prec_syn")).to eq([@owner, "TEST_PREC_TARGET"])
1040+
ensure
1041+
@conn.drop_if_exists("PUBLIC SYNONYM", "test_prec_syn")
1042+
@conn.drop_if_exists("SYNONYM", "test_prec_syn")
1043+
@conn.drop_table("test_prec_target", if_exists: true)
1044+
end
1045+
1046+
it "raises ArgumentError for malformed identifiers with empty parts" do
1047+
expect { resolve("schema..table") }.to raise_error(ArgumentError, /malformed identifier/)
1048+
end
1049+
1050+
it "raises ArgumentError for an unbalanced double quote" do
1051+
expect { resolve(%{"foo}) }.to raise_error(ArgumentError, /malformed identifier/)
1052+
end
1053+
1054+
it "raises ArgumentError for a three-part name" do
1055+
expect { resolve(%{schema."table".extra}) }.to raise_error(ArgumentError, /malformed identifier/)
1056+
end
1057+
1058+
it "doubles embedded double-quote characters when wrapping an unquoted identifier" do
1059+
result = @conn.send(:normalize_name_for_name_resolve, %{Test"x})
1060+
expect(result).to eq(%{"Test""x"})
1061+
end
1062+
1063+
# DBMS_UTILITY.NAME_RESOLVE chases synonyms server-side, so a circular
1064+
# chain surfaces as ORA-00980 ("synonym translation is no longer valid")
1065+
# rather than SQL's ORA-01775 ("looping chain of synonyms"). The thing
1066+
# that matters either way: no Ruby-side stack overflow, a clean
1067+
# ConnectionException.
9991068
it "raises when synonym resolution produces a looping chain" do
10001069
@conn.execute "CREATE SYNONYM test_cycle_a FOR test_cycle_b"
10011070
@conn.execute "CREATE SYNONYM test_cycle_b FOR test_cycle_a"
10021071
expect { resolve("test_cycle_a") }.to raise_error(
10031072
ActiveRecord::ConnectionAdapters::OracleEnhanced::ConnectionException,
1004-
/looping chain of synonyms/
1073+
/ORA-00980/
10051074
)
10061075
ensure
10071076
@conn.drop_if_exists("SYNONYM", "test_cycle_a")
@@ -1014,7 +1083,7 @@ def resolve(name)
10141083
@conn.execute "CREATE SYNONYM test_cycle_c FOR test_cycle_a"
10151084
expect { resolve("test_cycle_a") }.to raise_error(
10161085
ActiveRecord::ConnectionAdapters::OracleEnhanced::ConnectionException,
1017-
/looping chain of synonyms/
1086+
/ORA-00980/
10181087
)
10191088
ensure
10201089
@conn.drop_if_exists("SYNONYM", "test_cycle_a")
@@ -1026,11 +1095,28 @@ def resolve(name)
10261095
expect { resolve("test@db_link") }.to raise_error(ArgumentError, /db link is not supported/)
10271096
end
10281097

1029-
# The previous Connection#describe path bypassed the adapter's query machinery
1030-
# by driving a raw cursor, so its catalog lookup produced no sql.active_record
1031-
# event. Routing through select_one(..., "SCHEMA", ...) makes the lookup
1032-
# participate in logging, instrumentation, and the query cache. Lock that in
1033-
# so a future refactor can't silently regress to the raw-cursor path.
1098+
it "raises ConnectionException for a non-existent object" do
1099+
expect { resolve("test_nonexistent_object_xyz") }.to raise_error(
1100+
ActiveRecord::ConnectionAdapters::OracleEnhanced::ConnectionException,
1101+
/does it exist/
1102+
)
1103+
end
1104+
1105+
it "preserves dots inside a quoted identifier" do
1106+
result = @conn.send(:normalize_name_for_name_resolve, %{"Foo.Bar"})
1107+
expect(result).to eq(%{"Foo.Bar"})
1108+
end
1109+
1110+
it "preserves dots inside the quoted part of a schema-qualified name" do
1111+
result = @conn.send(:normalize_name_for_name_resolve, %{SCHEMA."Foo.Bar"})
1112+
expect(result).to eq(%{SCHEMA."Foo.Bar"})
1113+
end
1114+
1115+
# The lookup runs DBMS_UTILITY.NAME_RESOLVE wrapped in
1116+
# `instrumenter.instrument` (sql.active_record, name: "SCHEMA"),
1117+
# so it shows up in logging and instrumentation subscribers.
1118+
# Lock that in so a future refactor can't silently drop the
1119+
# instrumentation.
10341120
it "emits a SCHEMA sql.active_record event for the catalog lookup" do
10351121
@conn.execute "CREATE TABLE test_employees (first_name VARCHAR2(20))"
10361122
events = []

0 commit comments

Comments
 (0)