Skip to content

Commit 8c3c2f7

Browse files
yahondaclaude
andcommitted
POC: resolve describe() via DBMS_UTILITY.NAME_RESOLVE
Replaces the UNION ALL catalog query inside SchemaStatements#resolve_data_source_name with a single DBMS_UTILITY.NAME_RESOLVE PL/SQL call. Private and public synonyms are chased server-side, so the iterative loop + visited Set used to guard against circular synonyms is no longer needed -- Oracle reports a cycle natively as ORA-00980 ("synonym translation is no longer valid"). DBMS_UTILITY.NAME_RESOLVE is documented for Oracle 8i (released 1999), so version support is not a concern on any supported release. See the Oracle8i Supplied PL/SQL Packages Reference, Release 2 (8.1.6), A76936-01: https://docs.oracle.com/cd/A87860_01/doc/appdev.817/a76936/dbms_uti.htm Design notes 1. Driver-layer helpers. OCIConnection#name_resolve uses an anonymous PL/SQL block with named binds. JDBCConnection#name_resolve uses CallableStatement + registerOutParameter. Both return [schema, object_name]; the caller is the adapter's SchemaStatements mix-in, not the raw driver. 2. Instrumentation preserved. NAME_RESOLVE bypasses select_one entirely, so we wrap the call in an explicit sql.active_record SCHEMA notification. The regression spec that subscribes to that event and was introduced alongside the move-describe refactor stays green. 3. Case handling. NAME_RESOLVE uppercases unquoted input, so mixed-case identifiers like "test_Mixed" fail with ORA-06564 unless quoted. normalize_name_for_name_resolve upcases each dotted part when the part is a valid unquoted identifier and wraps it in double quotes otherwise; "sys.test_Mixed" becomes SYS."test_Mixed" rather than the all-quoted "sys"."test_Mixed" that would send Oracle hunting for a lowercase schema. 4. context=0. NAME_RESOLVE's context argument matters: context=1 covers PL/SQL objects only and raises ORA-04047 for tables/views. context=0 covers tables, views, and synonyms. Trade-off: the select_one-based resolve_data_source_name benefits from the AR query cache on repeated describes in the same scope; NAME_RESOLVE always hits the server. The win is round-trip count (one call instead of UNION ALL + synonym loop), which dominates on cold-cache / synonym-heavy schemas -- see benchmark results in PR rsim#2531. The benchmark script (script/benchmark_describe.rb) is retargeted to adapter.send(:resolve_data_source_name, name) so all three implementations (master UNION ALL, PR rsim#2521 all_objects, this POC) can be measured against a shared 1000-object fixture. A before(:suite) PURGE RECYCLEBIN hook keeps the suite from accumulating stale BIN$... dictionary rows across rspec runs. create_oracle_enhanced_users.sql adds CREATE/DROP PUBLIC SYNONYM grants required by the benchmark's public-synonym fixtures. Full rspec suite: - CRuby 4.0.2: 453 examples, 0 failures, 6 pending - JRuby 10.0.5.0: 458 examples, 0 failures, 6 pending Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a703b91 commit 8c3c2f7

5 files changed

Lines changed: 240 additions & 62 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
@@ -306,6 +306,25 @@ def database_version
306306
@database_version ||= (md = raw_connection.getMetaData) && [md.getDatabaseMajorVersion, md.getDatabaseMinorVersion]
307307
end
308308

309+
# Resolve a schema object (table, view, synonym) to [owner, object_name]
310+
# in a single round trip via DBMS_UTILITY.NAME_RESOLVE. Private and
311+
# public synonyms are chased server-side, and Oracle raises ORA-01775
312+
# natively on a looping chain.
313+
def name_resolve(name)
314+
cs = @raw_connection.prepareCall("BEGIN DBMS_UTILITY.NAME_RESOLVE(?, 0, ?, ?, ?, ?, ?, ?); END;")
315+
cs.setString(1, name)
316+
cs.registerOutParameter(2, java.sql.Types::VARCHAR) # schema
317+
cs.registerOutParameter(3, java.sql.Types::VARCHAR) # part1 (object name)
318+
cs.registerOutParameter(4, java.sql.Types::VARCHAR) # part2
319+
cs.registerOutParameter(5, java.sql.Types::VARCHAR) # dblink
320+
cs.registerOutParameter(6, java.sql.Types::NUMERIC) # part1_type
321+
cs.registerOutParameter(7, java.sql.Types::NUMERIC) # object_number
322+
cs.execute
323+
[cs.getString(2), cs.getString(3)]
324+
ensure
325+
cs&.close
326+
end
327+
309328
class Cursor
310329
def initialize(connection, raw_statement, exec_sql = nil)
311330
@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: 42 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -912,64 +912,50 @@ def rebuild_primary_key_index_to_default_tablespace(table_name, options)
912912
end
913913

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

975961
# 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
@@ -799,10 +799,10 @@ def resolve(name)
799799
# Exercises all five catalog paths (table, view, materialized view,
800800
# private synonym, public synonym) for one underlying table in a single
801801
# run. The individual cases above use disjoint fixtures; this one proves
802-
# the DECODE-ordered all_objects lookup + synonym follow-through stays
803-
# consistent when a private and a public synonym to the same table
804-
# coexist, and that a materialized view created on the same base table
805-
# resolves to the MV name (not the base table) as a sibling data source.
802+
# NAME_RESOLVE stays consistent when a private and a public synonym to
803+
# the same table coexist, and that a materialized view created on the
804+
# same base table resolves to the MV name (not the base table) as a
805+
# sibling data source.
806806
it "resolves table, view, materialized view, private synonym and public synonym for the same underlying table" do
807807
@conn.execute "CREATE TABLE test_describe_all (id NUMBER)" rescue nil
808808
@conn.execute "CREATE VIEW test_describe_all_v AS SELECT * FROM test_describe_all" rescue nil
@@ -823,12 +823,28 @@ def resolve(name)
823823
@conn.execute "DROP TABLE test_describe_all" rescue nil
824824
end
825825

826+
# Mixed-case quoted identifiers need per-dotted-part quoting before
827+
# reaching DBMS_UTILITY.NAME_RESOLVE: the schema should be upcased and
828+
# left unquoted, but the case-preserving table name must be wrapped in
829+
# double quotes so Oracle doesn't uppercase it away.
830+
it "should resolve a mixed-case quoted table qualified with its owner" do
831+
@conn.execute %{CREATE TABLE "test_Mixed_Case_Desc" (id NUMBER)} rescue nil
832+
expect(resolve(%{#{@owner}.test_Mixed_Case_Desc})).to eq([@owner, "test_Mixed_Case_Desc"])
833+
ensure
834+
@conn.execute %{DROP TABLE "test_Mixed_Case_Desc"} rescue nil
835+
end
836+
837+
# DBMS_UTILITY.NAME_RESOLVE chases synonyms server-side, so a circular
838+
# chain surfaces as ORA-00980 ("synonym translation is no longer valid")
839+
# rather than SQL's ORA-01775 ("looping chain of synonyms"). The thing
840+
# that matters either way: no Ruby-side stack overflow, a clean
841+
# ConnectionException.
826842
it "raises when synonym resolution produces a looping chain" do
827843
@conn.execute "CREATE SYNONYM test_cycle_a FOR test_cycle_b" rescue nil
828844
@conn.execute "CREATE SYNONYM test_cycle_b FOR test_cycle_a" rescue nil
829845
expect { resolve("test_cycle_a") }.to raise_error(
830846
ActiveRecord::ConnectionAdapters::OracleEnhanced::ConnectionException,
831-
/looping chain of synonyms/
847+
/ORA-00980/
832848
)
833849
ensure
834850
@conn.execute "DROP SYNONYM test_cycle_a" rescue nil
@@ -841,7 +857,7 @@ def resolve(name)
841857
@conn.execute "CREATE SYNONYM test_cycle_c FOR test_cycle_a" rescue nil
842858
expect { resolve("test_cycle_a") }.to raise_error(
843859
ActiveRecord::ConnectionAdapters::OracleEnhanced::ConnectionException,
844-
/looping chain of synonyms/
860+
/ORA-00980/
845861
)
846862
ensure
847863
@conn.execute "DROP SYNONYM test_cycle_a" rescue nil

0 commit comments

Comments
 (0)