Skip to content

Commit 0732c7e

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 0732c7e

5 files changed

Lines changed: 368 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: 80 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -931,64 +931,92 @@ 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 surfaces
936+
# circular synonym chains as ORA-00980 ("synonym translation is no
937+
# longer valid"), which we let propagate as
938+
# OracleEnhanced::ConnectionException.
939+
#
940+
# The PL/SQL call bypasses the adapter's select_one path, so we wrap
941+
# it in a sql.active_record SCHEMA notification to keep describe
942+
# visible to logging and instrumentation subscribers.
942943
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}
944+
real_name = normalize_name_for_name_resolve(name)
945+
instrumenter.instrument(
946+
"sql.active_record",
947+
sql: "DBMS_UTILITY.NAME_RESOLVE(#{real_name.inspect}, 0, ...)",
948+
name: "SCHEMA",
949+
connection: self,
950+
) do
951+
_connection.name_resolve(real_name)
952+
end
953+
rescue OracleEnhanced::ConnectionException, ArgumentError
954+
raise
955+
rescue => e
956+
raise OracleEnhanced::ConnectionException,
957+
%Q{"DESC #{name}" failed; does it exist? (#{e.message})}
958+
end
959+
960+
# Normalize a data-source name for DBMS_UTILITY.NAME_RESOLVE.
961+
# NAME_RESOLVE uppercases unquoted identifiers, so mixed-case
962+
# identifiers like `test_Mixed` must be wrapped in double quotes to
963+
# preserve their case. Normalization is per-dotted-part: a valid
964+
# unquoted identifier (all upper, no spaces, etc.) is upcased in
965+
# place; any other part is wrapped in quotes. This lets
966+
# `sys.test_Mixed` become `SYS."test_Mixed"` rather than the
967+
# all-quoted `"sys"."test_Mixed"` (which would send Oracle hunting
968+
# for a lowercase schema and miss SYS).
969+
def normalize_name_for_name_resolve(name)
970+
name = name.to_s
971+
raise ArgumentError, "db link is not supported" if name.include?("@")
972+
973+
limit = max_identifier_length
974+
return name.upcase if OracleEnhanced::Quoting.valid_table_name?(name, max_identifier_length: limit)
975+
976+
parts = split_dotted_name(name)
977+
raise ArgumentError, "malformed identifier: #{name.inspect}" if parts.empty? || parts.any?(&:empty?)
978+
979+
parts.map do |part|
980+
if part.start_with?('"') && part.end_with?('"') && part.size >= 2
981+
part
982+
elsif OracleEnhanced::Quoting.valid_table_name?(part, max_identifier_length: limit)
983+
part.upcase
984+
else
985+
# Oracle quoted identifier syntax: an embedded `"` must be doubled.
986+
%("#{part.gsub('"', '""')}")
952987
end
988+
end.join(".")
989+
end
953990

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']}"
991+
# Splits a dotted Oracle name on `.` boundaries while leaving dots
992+
# that appear inside double-quoted identifiers untouched. Honours
993+
# the `""` escape sequence Oracle uses for an embedded `"` inside
994+
# a quoted identifier.
995+
def split_dotted_name(name)
996+
parts = []
997+
current = +""
998+
in_quotes = false
999+
i = 0
1000+
while i < name.length
1001+
char = name[i]
1002+
if char == '"'
1003+
if in_quotes && name[i + 1] == '"'
1004+
current << '""'
1005+
i += 2
1006+
next
1007+
end
1008+
in_quotes = !in_quotes
1009+
current << char
1010+
elsif char == "." && !in_quotes
1011+
parts << current
1012+
current = +""
9881013
else
989-
return [result["owner"], result["table_name"]]
1014+
current << char
9901015
end
1016+
i += 1
9911017
end
1018+
parts << current
1019+
parts
9921020
end
9931021

9941022
# 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

0 commit comments

Comments
 (0)