Skip to content

Commit 4a29929

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 | Implementation: - OUT-bind plumbing on both the OCI8 path (anonymous PL/SQL block with named binds) and the JDBC path (`CallableStatement` + `registerOutParameter`). - `normalize_name_for_name_resolve` preserves case for already-quoted identifiers (e.g. `"test_Mixed"`). Previously `%("#{part}")` would re-quote an already-quoted part, producing `""test_Mixed""` and raising ORA-01741. - `resolve_data_source_name` keeps master's contract: raise `OracleEnhanced::ConnectionException` for a non-existent name so `data_source_exists?`'s `rescue` returns false. Benchmark script (`script/benchmark_describe.rb`) carried over from the POC reference at rsim#2566. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 9c25e2c commit 4a29929

5 files changed

Lines changed: 245 additions & 61 deletions

File tree

lib/active_record/connection_adapters/oracle_enhanced/jdbc_connection.rb

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,25 @@ 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-01775
331+
# natively on a looping chain.
332+
def name_resolve(name)
333+
cs = @raw_connection.prepareCall("BEGIN DBMS_UTILITY.NAME_RESOLVE(?, 0, ?, ?, ?, ?, ?, ?); END;")
334+
cs.setString(1, name)
335+
cs.registerOutParameter(2, java.sql.Types::VARCHAR) # schema
336+
cs.registerOutParameter(3, java.sql.Types::VARCHAR) # part1 (object name)
337+
cs.registerOutParameter(4, java.sql.Types::VARCHAR) # part2
338+
cs.registerOutParameter(5, java.sql.Types::VARCHAR) # dblink
339+
cs.registerOutParameter(6, java.sql.Types::NUMERIC) # part1_type
340+
cs.registerOutParameter(7, java.sql.Types::NUMERIC) # object_number
341+
cs.execute
342+
[cs.getString(2), cs.getString(3)]
343+
ensure
344+
cs&.close
345+
end
346+
328347
class Cursor
329348
def initialize(connection, raw_statement, exec_sql = nil)
330349
@raw_connection = connection

lib/active_record/connection_adapters/oracle_enhanced/oci_connection.rb

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,31 @@ 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-01775
115+
# natively on a looping chain.
116+
def name_resolve(name)
117+
cursor = @raw_connection.parse(<<~PLSQL)
118+
DECLARE
119+
l_part2 VARCHAR2(128);
120+
l_dblink VARCHAR2(128);
121+
l_type NUMBER;
122+
l_obj_num NUMBER;
123+
BEGIN
124+
DBMS_UTILITY.NAME_RESOLVE(:name, 0, :out_schema, :out_name,
125+
l_part2, l_dblink, l_type, l_obj_num);
126+
END;
127+
PLSQL
128+
cursor.bind_param(":name", name)
129+
cursor.bind_param(":out_schema", nil, String, 128)
130+
cursor.bind_param(":out_name", nil, String, 128)
131+
cursor.exec
132+
[cursor[":out_schema"], cursor[":out_name"]]
133+
ensure
134+
cursor&.close
135+
end
136+
112137
class Cursor
113138
def initialize(connection, raw_cursor)
114139
@raw_connection = connection

lib/active_record/connection_adapters/oracle_enhanced/schema_statements.rb

Lines changed: 47 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -931,64 +931,56 @@ def rebuild_primary_key_index_to_default_tablespace(table_name, options)
931931
end
932932

933933
# Resolves an Oracle data-source name to its underlying [owner, table_name]
934-
# by following synonyms through the catalog. Defaults the schema to
935-
# `_connection.owner` (the adapter's configured default schema, taken
936-
# from `config[:schema]` or `config[:username]`) when the name is not
937-
# schema-qualified. This is distinct from
938-
# `SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA')`, which can differ after
939-
# `ALTER SESSION SET CURRENT_SCHEMA`.
940-
# Raises OracleEnhanced::ConnectionException if the object does not
941-
# exist or if synonym resolution produces a looping chain.
934+
# via DBMS_UTILITY.NAME_RESOLVE, which chases private and public
935+
# synonyms server-side in a single round trip. NAME_RESOLVE raises
936+
# ORA-01775 ("looping chain of synonyms") natively on a cycle, which
937+
# we let propagate as OracleEnhanced::ConnectionException.
938+
#
939+
# The PL/SQL call bypasses the adapter's select_one path, so we wrap
940+
# it in a sql.active_record SCHEMA notification to keep describe
941+
# visible to logging and instrumentation subscribers.
942942
def resolve_data_source_name(name)
943-
visited = Set.new
944-
loop do
945-
schema, identifier = extract_schema_qualified_name(name)
946-
real_name = schema ? "#{schema}.#{identifier}" : identifier
947-
owner = schema || _connection.owner
948-
949-
unless visited.add?([owner, identifier])
950-
raise OracleEnhanced::ConnectionException,
951-
%Q{"DESC #{name}" failed; looping chain of synonyms}
952-
end
953-
954-
binds = [
955-
bind_string("table_owner", owner),
956-
bind_string("table_name", identifier),
957-
bind_string("real_name", real_name),
958-
]
959-
# Single-pass lookup against all_objects, ordered so the first row
960-
# is the one the legacy 4-way UNION ALL (all_tables, all_views,
961-
# owner synonym, public synonym) would have returned: prefer a
962-
# match in the caller's schema over PUBLIC, and within a schema
963-
# prefer TABLE over VIEW over SYNONYM.
964-
result = select_one(<<~SQL.squish, "SCHEMA", binds)
965-
SELECT owner, object_name table_name, object_type name_type
966-
FROM all_objects
967-
WHERE ((owner = :table_owner AND object_name = :table_name)
968-
OR (owner = 'PUBLIC' AND object_name = :real_name))
969-
AND object_type IN ('TABLE', 'VIEW', 'SYNONYM')
970-
ORDER BY DECODE(owner, 'PUBLIC', 2, 1),
971-
DECODE(object_type, 'TABLE', 1, 'VIEW', 2, 'SYNONYM', 3)
972-
SQL
973-
974-
raise OracleEnhanced::ConnectionException, %Q{"DESC #{name}" failed; does it exist?} unless result
975-
976-
if result["name_type"] == "SYNONYM"
977-
synonym_binds = [
978-
bind_string("owner", result["owner"]),
979-
bind_string("synonym_name", result["table_name"]),
980-
]
981-
syn = select_one(<<~SQL.squish, "SCHEMA", synonym_binds)
982-
SELECT table_owner, table_name
983-
FROM all_synonyms
984-
WHERE owner = :owner AND synonym_name = :synonym_name
985-
SQL
986-
raise OracleEnhanced::ConnectionException, %Q{"DESC #{name}" failed; does it exist?} unless syn
987-
name = "#{syn['table_owner'] && "#{syn['table_owner']}."}#{syn['table_name']}"
943+
real_name = normalize_name_for_name_resolve(name)
944+
instrumenter.instrument(
945+
"sql.active_record",
946+
sql: "DBMS_UTILITY.NAME_RESOLVE(#{real_name.inspect}, 0, ...)",
947+
name: "SCHEMA",
948+
connection: self,
949+
) do
950+
_connection.name_resolve(real_name)
951+
end
952+
rescue OracleEnhanced::ConnectionException, ArgumentError
953+
raise
954+
rescue => e
955+
raise OracleEnhanced::ConnectionException,
956+
%Q{"DESC #{name}" failed; does it exist? (#{e.message})}
957+
end
958+
959+
# Normalize a data-source name for DBMS_UTILITY.NAME_RESOLVE.
960+
# NAME_RESOLVE uppercases unquoted identifiers, so mixed-case
961+
# identifiers like `test_Mixed` must be wrapped in double quotes to
962+
# preserve their case. Normalization is per-dotted-part: a valid
963+
# unquoted identifier (all upper, no spaces, etc.) is upcased in
964+
# place; any other part is wrapped in quotes. This lets
965+
# `sys.test_Mixed` become `SYS."test_Mixed"` rather than the
966+
# all-quoted `"sys"."test_Mixed"` (which would send Oracle hunting
967+
# for a lowercase schema and miss SYS).
968+
def normalize_name_for_name_resolve(name)
969+
name = name.to_s
970+
raise ArgumentError, "db link is not supported" if name.include?("@")
971+
972+
limit = max_identifier_length
973+
return name.upcase if OracleEnhanced::Quoting.valid_table_name?(name, max_identifier_length: limit)
974+
975+
name.split(".").map do |part|
976+
if part.start_with?('"') && part.end_with?('"') && part.size >= 2
977+
part
978+
elsif OracleEnhanced::Quoting.valid_table_name?(part, max_identifier_length: limit)
979+
part.upcase
988980
else
989-
return [result["owner"], result["table_name"]]
981+
%("#{part}")
990982
end
991-
end
983+
end.join(".")
992984
end
993985

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

script/benchmark_describe.rb

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
# frozen_string_literal: true
2+
3+
# Benchmark for OracleEnhanced::SchemaStatements#resolve_data_source_name
4+
# (the method adapters use to resolve a table / view / synonym name to its
5+
# underlying [owner, object_name] pair -- formerly Connection#describe).
6+
#
7+
# Intent: compare the resolve_data_source_name implementations across
8+
# - master: UNION ALL over all_tables/all_views/all_synonyms (select_one)
9+
# - PR #2521 branch (add-describe-regression-test): single all_objects query
10+
# - poc-dbms-utility-name-resolve: DBMS_UTILITY.NAME_RESOLVE
11+
#
12+
# The fixture models the production scenario from #2429: ~1000 objects in
13+
# the schema (700 tables, 100 views, 100 private synonyms, 100 public
14+
# synonyms).
15+
#
16+
# Usage (from repo root):
17+
# bundle exec ruby script/benchmark_describe.rb # setup + run + teardown
18+
# SKIP_SETUP=1 bundle exec ruby script/benchmark_describe.rb # reuse previous fixtures
19+
# SKIP_TEARDOWN=1 bundle exec ruby script/benchmark_describe.rb # keep fixtures for next run
20+
#
21+
# Environment variables honored (same defaults as spec_helper.rb):
22+
# DATABASE_NAME, DATABASE_HOST, DATABASE_PORT, DATABASE_USER,
23+
# DATABASE_PASSWORD, ITERATIONS, TABLE_COUNT, VIEW_COUNT, SYNONYM_COUNT.
24+
25+
require "bundler/setup"
26+
$LOAD_PATH.unshift File.expand_path("../lib", __dir__)
27+
require "oci8" if RUBY_ENGINE == "ruby"
28+
require "active_record"
29+
require "active_record/connection_adapters/oracle_enhanced_adapter"
30+
31+
TABLE_COUNT = Integer(ENV["TABLE_COUNT"] || 700)
32+
VIEW_COUNT = Integer(ENV["VIEW_COUNT"] || 100)
33+
SYNONYM_COUNT = Integer(ENV["SYNONYM_COUNT"] || 200) # half private, half public
34+
ITERATIONS = Integer(ENV["ITERATIONS"] || 1)
35+
SKIP_SETUP = ENV["SKIP_SETUP"] == "1"
36+
SKIP_TEARDOWN = ENV["SKIP_TEARDOWN"] == "1"
37+
38+
ActiveRecord::Base.establish_connection(
39+
adapter: "oracle_enhanced",
40+
database: ENV["DATABASE_NAME"] || "XEPDB1",
41+
host: ENV["DATABASE_HOST"] || "127.0.0.1",
42+
port: Integer(ENV["DATABASE_PORT"] || 1521),
43+
username: ENV["DATABASE_USER"] || "oracle_enhanced",
44+
password: ENV["DATABASE_PASSWORD"] || "oracle_enhanced"
45+
)
46+
47+
conn = ActiveRecord::Base.connection
48+
owner = (ENV["DATABASE_USER"] || "oracle_enhanced").upcase
49+
50+
def t(label)
51+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
52+
yield
53+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
54+
printf("%-20s %8.2fs\n", label, elapsed)
55+
end
56+
57+
def safe_exec(conn, sql)
58+
conn.execute(sql)
59+
rescue ActiveRecord::StatementInvalid
60+
# idempotent: ignore "already exists" / "does not exist"
61+
end
62+
63+
table_names = (1..TABLE_COUNT).map { |i| "bench_tbl_%04d" % i }
64+
view_names = (1..VIEW_COUNT).map { |i| "bench_vw_%04d" % i }
65+
priv_syn_half = SYNONYM_COUNT / 2
66+
pub_syn_half = SYNONYM_COUNT - priv_syn_half
67+
priv_synonyms = (1..priv_syn_half).map { |i| "bench_syn_%04d" % i }
68+
pub_synonyms = (1..pub_syn_half).map { |i| "bench_pub_%04d" % i }
69+
70+
unless SKIP_SETUP
71+
puts "==> Creating #{TABLE_COUNT} tables, #{VIEW_COUNT} views, " \
72+
"#{priv_syn_half} private + #{pub_syn_half} public synonyms"
73+
t("create tables") do
74+
table_names.each { |n| safe_exec conn, "CREATE TABLE #{n} (id NUMBER)" }
75+
end
76+
t("create views") do
77+
view_names.each_with_index do |vn, i|
78+
safe_exec conn, "CREATE VIEW #{vn} AS SELECT * FROM #{table_names[i % TABLE_COUNT]}"
79+
end
80+
end
81+
t("create synonyms") do
82+
priv_synonyms.each_with_index do |sn, i|
83+
safe_exec conn, "CREATE SYNONYM #{sn} FOR #{table_names[i % TABLE_COUNT]}"
84+
end
85+
pub_synonyms.each_with_index do |sn, i|
86+
safe_exec conn, "CREATE PUBLIC SYNONYM #{sn} FOR #{owner}.#{table_names[i % TABLE_COUNT]}"
87+
end
88+
end
89+
end
90+
91+
begin
92+
all_names = table_names + view_names + priv_synonyms + pub_synonyms
93+
94+
# Warm-up pass so dictionary-cache / shared-pool state is primed; the
95+
# first resolve_data_source_name after login otherwise dominates wall clock.
96+
all_names.first(50).each { |n| conn.send(:resolve_data_source_name, n) }
97+
98+
head_branch = `git rev-parse --abbrev-ref HEAD`.strip
99+
head_sha = `git rev-parse --short HEAD`.strip
100+
puts
101+
puts "==> branch: #{head_branch} (#{head_sha})"
102+
puts "==> fixtures: #{TABLE_COUNT} tables, #{VIEW_COUNT} views, " \
103+
"#{priv_syn_half} private synonyms, #{pub_syn_half} public synonyms"
104+
puts "==> resolve_data_source_name calls per pass: #{all_names.size}"
105+
puts "==> passes: #{ITERATIONS}"
106+
puts
107+
108+
printf("%-20s %10s %10s\n", "case", "wall(s)", "avg(ms)")
109+
cases = {
110+
"tables" => table_names,
111+
"views" => view_names,
112+
"private synonyms" => priv_synonyms,
113+
"public synonyms" => pub_synonyms,
114+
"all mixed" => all_names.shuffle,
115+
}
116+
cases.each do |label, names|
117+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
118+
ITERATIONS.times { names.each { |n| conn.send(:resolve_data_source_name, n) } }
119+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
120+
per_call_ms = (elapsed * 1000.0) / (ITERATIONS * names.size)
121+
printf("%-20s %10.3f %10.3f\n", label, elapsed, per_call_ms)
122+
end
123+
ensure
124+
unless SKIP_TEARDOWN
125+
puts
126+
puts "==> Dropping fixtures"
127+
t("drop public syns") { pub_synonyms.each { |n| safe_exec conn, "DROP PUBLIC SYNONYM #{n}" } }
128+
t("drop private syns") { priv_synonyms.each { |n| safe_exec conn, "DROP SYNONYM #{n}" } }
129+
t("drop views") { view_names.each { |n| safe_exec conn, "DROP VIEW #{n}" } }
130+
t("drop tables") { table_names.each { |n| safe_exec conn, "DROP TABLE #{n} PURGE" } }
131+
end
132+
end

spec/active_record/connection_adapters/oracle_enhanced/connection_spec.rb

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -961,10 +961,10 @@ def resolve(name)
961961
# Exercises all five catalog paths (table, view, materialized view,
962962
# private synonym, public synonym) for one underlying table in a single
963963
# run. The individual cases above use disjoint fixtures; this one proves
964-
# the DECODE-ordered all_objects lookup + synonym follow-through stays
965-
# consistent when a private and a public synonym to the same table
966-
# coexist, and that a materialized view created on the same base table
967-
# resolves to the MV name (not the base table) as a sibling data source.
964+
# NAME_RESOLVE stays consistent when a private and a public synonym to
965+
# the same table coexist, and that a materialized view created on the
966+
# same base table resolves to the MV name (not the base table) as a
967+
# sibling data source.
968968
it "resolves table, view, materialized view, private synonym and public synonym for the same underlying table" do
969969
@conn.execute "CREATE TABLE test_describe_all (id NUMBER)" rescue nil
970970
@conn.execute "CREATE VIEW test_describe_all_v AS SELECT * FROM test_describe_all" rescue nil
@@ -985,12 +985,28 @@ def resolve(name)
985985
@conn.execute "DROP TABLE test_describe_all" rescue nil
986986
end
987987

988+
# Mixed-case quoted identifiers need per-dotted-part quoting before
989+
# reaching DBMS_UTILITY.NAME_RESOLVE: the schema should be upcased and
990+
# left unquoted, but the case-preserving table name must be wrapped in
991+
# double quotes so Oracle doesn't uppercase it away.
992+
it "should resolve a mixed-case quoted table qualified with its owner" do
993+
@conn.execute %{CREATE TABLE "test_Mixed_Case_Desc" (id NUMBER)} rescue nil
994+
expect(resolve(%{#{@owner}.test_Mixed_Case_Desc})).to eq([@owner, "test_Mixed_Case_Desc"])
995+
ensure
996+
@conn.execute %{DROP TABLE "test_Mixed_Case_Desc"} rescue nil
997+
end
998+
999+
# DBMS_UTILITY.NAME_RESOLVE chases synonyms server-side, so a circular
1000+
# chain surfaces as ORA-00980 ("synonym translation is no longer valid")
1001+
# rather than SQL's ORA-01775 ("looping chain of synonyms"). The thing
1002+
# that matters either way: no Ruby-side stack overflow, a clean
1003+
# ConnectionException.
9881004
it "raises when synonym resolution produces a looping chain" do
9891005
@conn.execute "CREATE SYNONYM test_cycle_a FOR test_cycle_b" rescue nil
9901006
@conn.execute "CREATE SYNONYM test_cycle_b FOR test_cycle_a" rescue nil
9911007
expect { resolve("test_cycle_a") }.to raise_error(
9921008
ActiveRecord::ConnectionAdapters::OracleEnhanced::ConnectionException,
993-
/looping chain of synonyms/
1009+
/ORA-00980/
9941010
)
9951011
ensure
9961012
@conn.execute "DROP SYNONYM test_cycle_a" rescue nil
@@ -1003,7 +1019,7 @@ def resolve(name)
10031019
@conn.execute "CREATE SYNONYM test_cycle_c FOR test_cycle_a" rescue nil
10041020
expect { resolve("test_cycle_a") }.to raise_error(
10051021
ActiveRecord::ConnectionAdapters::OracleEnhanced::ConnectionException,
1006-
/looping chain of synonyms/
1022+
/ORA-00980/
10071023
)
10081024
ensure
10091025
@conn.execute "DROP SYNONYM test_cycle_a" rescue nil

0 commit comments

Comments
 (0)