Skip to content

Commit 2e9f613

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 afb820a commit 2e9f613

7 files changed

Lines changed: 246 additions & 53 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: 41 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -735,57 +735,49 @@ def rebuild_primary_key_index_to_default_tablespace(table_name, options)
735735
end
736736

737737
# Resolves an Oracle data-source name to its underlying [owner, table_name]
738-
# by following synonyms through the catalog. Defaults the schema to
739-
# `_connection.owner` (the adapter's configured default schema, taken
740-
# from `config[:schema]` or `config[:username]`) when the name is not
741-
# schema-qualified. This is distinct from
742-
# `SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA')`, which can differ after
743-
# `ALTER SESSION SET CURRENT_SCHEMA`.
744-
# Raises OracleEnhanced::ConnectionException if the object does not
745-
# exist or if synonym resolution produces a looping chain.
738+
# via DBMS_UTILITY.NAME_RESOLVE, which chases private and public
739+
# synonyms server-side in a single round trip. NAME_RESOLVE raises
740+
# ORA-01775 ("looping chain of synonyms") natively on a cycle, which
741+
# we let propagate as OracleEnhanced::ConnectionException.
742+
#
743+
# The PL/SQL call bypasses the adapter's select_one path, so we wrap
744+
# it in a sql.active_record SCHEMA notification to keep describe
745+
# visible to logging and instrumentation subscribers.
746746
def resolve_data_source_name(name)
747-
visited = Set.new
748-
loop do
749-
schema, identifier = extract_schema_qualified_name(name)
750-
real_name = schema ? "#{schema}.#{identifier}" : identifier
751-
owner = schema || _connection.owner
752-
753-
unless visited.add?([owner, identifier])
754-
raise OracleEnhanced::ConnectionException,
755-
%Q{"DESC #{name}" failed; looping chain of synonyms}
756-
end
757-
758-
binds = [
759-
bind_string("table_owner", owner),
760-
bind_string("table_name", identifier),
761-
bind_string("table_owner", owner),
762-
bind_string("table_name", identifier),
763-
bind_string("table_owner", owner),
764-
bind_string("table_name", identifier),
765-
bind_string("real_name", real_name),
766-
]
767-
result = select_one(<<~SQL.squish, "SCHEMA", binds)
768-
SELECT owner, table_name, 'TABLE' name_type
769-
FROM all_tables WHERE owner = :table_owner AND table_name = :table_name
770-
UNION ALL
771-
SELECT owner, view_name table_name, 'VIEW' name_type
772-
FROM all_views WHERE owner = :table_owner AND view_name = :table_name
773-
UNION ALL
774-
SELECT table_owner, table_name, 'SYNONYM' name_type
775-
FROM all_synonyms WHERE owner = :table_owner AND synonym_name = :table_name
776-
UNION ALL
777-
SELECT table_owner, table_name, 'SYNONYM' name_type
778-
FROM all_synonyms WHERE owner = 'PUBLIC' AND synonym_name = :real_name
779-
SQL
780-
781-
raise OracleEnhanced::ConnectionException, %Q{"DESC #{name}" failed; does it exist?} unless result
782-
783-
if result["name_type"] == "SYNONYM"
784-
name = "#{result['owner'] && "#{result['owner']}."}#{result['table_name']}"
785-
else
786-
return [result["owner"], result["table_name"]]
787-
end
747+
real_name = normalize_name_for_name_resolve(name)
748+
instrumenter.instrument(
749+
"sql.active_record",
750+
sql: "DBMS_UTILITY.NAME_RESOLVE(#{real_name.inspect}, 0, ...)",
751+
name: "SCHEMA",
752+
connection: self,
753+
) do
754+
_connection.name_resolve(real_name)
788755
end
756+
rescue OracleEnhanced::ConnectionException, ArgumentError
757+
raise
758+
rescue => e
759+
raise OracleEnhanced::ConnectionException,
760+
%Q{"DESC #{name}" failed; does it exist? (#{e.message})}
761+
end
762+
763+
# Normalize a data-source name for DBMS_UTILITY.NAME_RESOLVE.
764+
# NAME_RESOLVE uppercases unquoted identifiers, so mixed-case
765+
# identifiers like `test_Mixed` must be wrapped in double quotes to
766+
# preserve their case. Normalization is per-dotted-part: a valid
767+
# unquoted identifier (all upper, no spaces, etc.) is upcased in
768+
# place; any other part is wrapped in quotes. This lets
769+
# `sys.test_Mixed` become `SYS."test_Mixed"` rather than the
770+
# all-quoted `"sys"."test_Mixed"` (which would send Oracle hunting
771+
# for a lowercase schema and miss SYS).
772+
def normalize_name_for_name_resolve(name)
773+
name = name.to_s
774+
raise ArgumentError, "db link is not supported" if name.include?("@")
775+
776+
return name.upcase if OracleEnhanced::Quoting.valid_table_name?(name)
777+
778+
name.split(".").map do |part|
779+
OracleEnhanced::Quoting.valid_table_name?(part) ? part.upcase : %("#{part}")
780+
end.join(".")
789781
end
790782

791783
# 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: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -734,12 +734,28 @@ def resolve(name)
734734
expect(resolve("all_tables")).to eq(["SYS", "ALL_TABLES"])
735735
end
736736

737+
# Mixed-case quoted identifiers need per-dotted-part quoting before
738+
# reaching DBMS_UTILITY.NAME_RESOLVE: the schema should be upcased and
739+
# left unquoted, but the case-preserving table name must be wrapped in
740+
# double quotes so Oracle doesn't uppercase it away.
741+
it "should resolve a mixed-case quoted table qualified with its owner" do
742+
@conn.execute %{CREATE TABLE "test_Mixed_Case_Desc" (id NUMBER)} rescue nil
743+
expect(resolve(%{#{@owner}.test_Mixed_Case_Desc})).to eq([@owner, "test_Mixed_Case_Desc"])
744+
ensure
745+
@conn.execute %{DROP TABLE "test_Mixed_Case_Desc"} rescue nil
746+
end
747+
748+
# DBMS_UTILITY.NAME_RESOLVE chases synonyms server-side, so a circular
749+
# chain surfaces as ORA-00980 ("synonym translation is no longer valid")
750+
# rather than SQL's ORA-01775 ("looping chain of synonyms"). The thing
751+
# that matters either way: no Ruby-side stack overflow, a clean
752+
# ConnectionException.
737753
it "raises when synonym resolution produces a looping chain" do
738754
@conn.execute "CREATE SYNONYM test_cycle_a FOR test_cycle_b" rescue nil
739755
@conn.execute "CREATE SYNONYM test_cycle_b FOR test_cycle_a" rescue nil
740756
expect { resolve("test_cycle_a") }.to raise_error(
741757
ActiveRecord::ConnectionAdapters::OracleEnhanced::ConnectionException,
742-
/looping chain of synonyms/
758+
/ORA-00980/
743759
)
744760
ensure
745761
@conn.execute "DROP SYNONYM test_cycle_a" rescue nil
@@ -752,7 +768,7 @@ def resolve(name)
752768
@conn.execute "CREATE SYNONYM test_cycle_c FOR test_cycle_a" rescue nil
753769
expect { resolve("test_cycle_a") }.to raise_error(
754770
ActiveRecord::ConnectionAdapters::OracleEnhanced::ConnectionException,
755-
/looping chain of synonyms/
771+
/ORA-00980/
756772
)
757773
ensure
758774
@conn.execute "DROP SYNONYM test_cycle_a" rescue nil

spec/spec_helper.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,3 +205,10 @@ def dump_table_schema(table, connection = ActiveRecord::Base.connection)
205205
ENV["TZ"] ||= config["timezone"] || "Europe/Riga"
206206

207207
ActiveRecord::Base.logger = ActiveSupport::Logger.new("debug.log", 0, 100 * 1024 * 1024)
208+
209+
RSpec.configure do |config|
210+
config.before(:suite) do
211+
ActiveRecord::Base.establish_connection(CONNECTION_PARAMS)
212+
ActiveRecord::Base.connection.execute("PURGE RECYCLEBIN")
213+
end
214+
end

spec/support/create_oracle_enhanced_users.sql

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ CREATE USER oracle_enhanced IDENTIFIED BY oracle_enhanced;
44

55
GRANT unlimited tablespace, create session, create table, create sequence,
66
create procedure, create trigger, create view, create materialized view,
7-
create database link, create synonym, create type, ctxapp TO oracle_enhanced;
7+
create database link, create synonym, create public synonym, create type, ctxapp TO oracle_enhanced;
8+
GRANT drop public synonym TO oracle_enhanced;
89

910
CREATE USER oracle_enhanced_schema IDENTIFIED BY oracle_enhanced_schema;
1011

1112
GRANT unlimited tablespace, create session, create table, create sequence,
1213
create procedure, create trigger, create view, create materialized view,
13-
create database link, create synonym, create type, ctxapp TO oracle_enhanced_schema;
14+
create database link, create synonym, create public synonym, create type, ctxapp TO oracle_enhanced_schema;
15+
GRANT drop public synonym TO oracle_enhanced_schema;

0 commit comments

Comments
 (0)