Skip to content

Commit 77fec0e

Browse files
yahondaclaude
andcommitted
Add DBMS_METADATA structure_dump backend, selectable via cattr toggle
Closes rsim#2513. Adds a second `structure_dump` implementation built on Oracle's `DBMS_METADATA.GET_DDL` / `GET_DEPENDENT_DDL` alongside the existing data-dictionary (`ALL_*`) one. The new backend lives in a separate file and installs via `prepend DbmsMetadata`, so the legacy path in `structure_dump.rb` is untouched. Selectable globally via `OracleEnhancedAdapter.structure_dump_method`: - `:auto` (default) — `:dbms_metadata` on Oracle 12.1+, `:data_dictionary` otherwise. The 12.1 floor is a project-policy version gate (the IDENTITY / EDITIONABLE / modern SET_TRANSFORM_PARAM era), exposed as `use_dbms_metadata_dump?`, not a strict database capability check. - `:dbms_metadata` — force the new path; raises `ArgumentError` on pre-12.1 (mirrors PR rsim#2576's `identifier_max_length: :long` fail-fast policy). - `:data_dictionary` — force the original implementation. Remains explicitly selectable on 12.1+ as well. Output is informed by `mysqldump --no-data` / `pg_dump --schema-only`: - `STORAGE` / `TABLESPACE` / `SEGMENT_ATTRIBUTES` / `EMIT_SCHEMA` suppressed via `DBMS_METADATA.SET_TRANSFORM_PARAM` so the dump is portable across installations and schemas. - `REF_CONSTRAINTS` emitted as separate `ALTER TABLE … ADD CONSTRAINT` statements after all tables. - Inline constraints via Oracle's `CONSTRAINTS=TRUE` default; UNIQUE backing indexes that DBMS_METADATA already inlines into the table DDL are filtered out so they aren't emitted twice. - `STATEMENT_TOKEN` separation preserved (Oracle's `SQLTERMINATOR=FALSE` default) so `execute_structure_dump` splits as before. - `primary_key_trigger:` (rsim#2615) row triggers picked up via `GET_DEPENDENT_DDL("TRIGGER", table_name)`. - COMMENT ON TABLE / COMMENT ON COLUMN queried directly because `GET_DEPENDENT_DDL("COMMENT")` is unreliable. - `structure_dump_db_stored_code` selects `'PROCEDURE', 'PACKAGE', 'FUNCTION', 'TRIGGER', 'TYPE'` only — `GET_DDL("PACKAGE", ...)` already returns spec + body, so selecting `'PACKAGE BODY'` would emit the body twice. Specs follow the `pg_dump` / `mysqldump` testing convention: assert output shape (`CREATE TABLE` / `CREATE INDEX` / `ALTER TABLE … ADD CONSTRAINT` / `COMMENT` / no `STORAGE` / `TABLESPACE` / `PCTFREE`) rather than exact byte-level DDL. The existing `structure_dump_spec` pins `:data_dictionary` in `before(:all)` so its exact-DDL assertions still apply to the legacy backend. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 120cbc3 commit 77fec0e

4 files changed

Lines changed: 559 additions & 0 deletions

File tree

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
# frozen_string_literal: true
2+
3+
require "active_record/connection_adapters/oracle_enhanced/structure_dump"
4+
5+
module ActiveRecord # :nodoc:
6+
module ConnectionAdapters # :nodoc:
7+
module OracleEnhanced # :nodoc:
8+
module StructureDump # :nodoc:
9+
# DBMS_METADATA-backed implementation of `structure_dump`,
10+
# `structure_dump_db_stored_code`, and `structure_dump_synonyms`.
11+
# Selected by `OracleEnhancedAdapter.structure_dump_method`:
12+
#
13+
# * `:auto` (default) — `:dbms_metadata` on Oracle 12.1+, otherwise
14+
# `:data_dictionary`.
15+
# * `:dbms_metadata` — force the new path; raises `ArgumentError`
16+
# on pre-12.1 servers (mirrors PR #2576's `identifier_max_length:
17+
# :long` policy).
18+
# * `:data_dictionary` — delegate to `super` (the original `ALL_*`
19+
# implementation).
20+
#
21+
# Output shape is informed by mysqldump / pg_dump --schema-only:
22+
# statements are reloadable on a fresh database, machine-specific
23+
# noise (storage / tablespace / segment attributes / schema qualifiers)
24+
# is suppressed, and statements are separated by the same
25+
# `STATEMENT_TOKEN` the dictionary-views implementation uses so that
26+
# `execute_structure_dump` can split and apply them.
27+
module DbmsMetadata # :nodoc:
28+
def structure_dump
29+
case resolved_structure_dump_method
30+
when :dbms_metadata then dbms_metadata_structure_dump
31+
when :data_dictionary then super
32+
end
33+
end
34+
35+
def structure_dump_db_stored_code
36+
case resolved_structure_dump_method
37+
when :dbms_metadata then dbms_metadata_structure_dump_db_stored_code
38+
when :data_dictionary then super
39+
end
40+
end
41+
42+
def structure_dump_synonyms
43+
case resolved_structure_dump_method
44+
when :dbms_metadata then dbms_metadata_structure_dump_synonyms
45+
when :data_dictionary then super
46+
end
47+
end
48+
49+
private
50+
def resolved_structure_dump_method
51+
case OracleEnhancedAdapter.structure_dump_method
52+
when :auto
53+
use_dbms_metadata_dump? ? :dbms_metadata : :data_dictionary
54+
when :dbms_metadata
55+
unless use_dbms_metadata_dump?
56+
raise ArgumentError,
57+
"structure_dump_method: :dbms_metadata requires Oracle 12.1 or later " \
58+
"(connected server reports #{database_version}). " \
59+
"Use :auto to fall back to :data_dictionary on older releases."
60+
end
61+
:dbms_metadata
62+
when :data_dictionary
63+
:data_dictionary
64+
else
65+
raise ArgumentError,
66+
"Unknown structure_dump_method " \
67+
"#{OracleEnhancedAdapter.structure_dump_method.inspect}; " \
68+
"expected :auto, :dbms_metadata, or :data_dictionary."
69+
end
70+
end
71+
72+
def dbms_metadata_structure_dump
73+
configure_dbms_metadata_transforms
74+
structure = []
75+
76+
sequence_names = select_values(<<~SQL.squish, "SCHEMA")
77+
SELECT sequence_name FROM all_sequences
78+
WHERE sequence_owner = SYS_CONTEXT('userenv', 'current_schema')
79+
ORDER BY 1
80+
SQL
81+
sequence_names.each do |seq_name|
82+
ddl = dbms_metadata_get_ddl("SEQUENCE", seq_name)
83+
structure << ddl if ddl
84+
end
85+
86+
tables = select_values(<<~SQL.squish, "SCHEMA")
87+
SELECT table_name FROM all_tables t
88+
WHERE owner = SYS_CONTEXT('userenv', 'current_schema') AND secondary = 'N'
89+
AND NOT EXISTS (SELECT mv.mview_name FROM all_mviews mv
90+
WHERE mv.owner = t.owner AND mv.mview_name = t.table_name)
91+
AND NOT EXISTS (SELECT mvl.log_table FROM all_mview_logs mvl
92+
WHERE mvl.log_owner = t.owner AND mvl.log_table = t.table_name)
93+
ORDER BY 1
94+
SQL
95+
tables.each do |table_name|
96+
ddl = dbms_metadata_get_ddl("TABLE", table_name)
97+
structure << ddl if ddl
98+
99+
# `CONSTRAINTS=TRUE` already inlines PK/UNIQUE constraint DDL
100+
# (and their backing indexes) into the table DDL, so emitting
101+
# `GET_DDL("INDEX", ...)` for those would duplicate them.
102+
constraint_index_names = constraint_backed_index_names(table_name)
103+
indexes(table_name).each do |idx|
104+
next if constraint_index_names.include?(idx.name.upcase)
105+
idx_ddl = dbms_metadata_get_ddl("INDEX", idx.name.upcase)
106+
structure << idx_ddl if idx_ddl
107+
end
108+
109+
# `primary_key_trigger:` (#2615) emits a row trigger named
110+
# `<table>_pkt`; pick those up via GET_DEPENDENT_DDL('TRIGGER').
111+
trg_ddl = dbms_metadata_get_dependent_ddl("TRIGGER", table_name)
112+
structure.concat(split_dbms_metadata_ddl(trg_ddl)) if trg_ddl
113+
114+
structure.concat(dbms_metadata_structure_dump_table_comments(table_name))
115+
structure.concat(dbms_metadata_structure_dump_column_comments(table_name))
116+
end
117+
118+
fk_statements = []
119+
tables.each do |table_name|
120+
fk_ddl = dbms_metadata_get_dependent_ddl("REF_CONSTRAINT", table_name)
121+
fk_statements.concat(split_dbms_metadata_ddl(fk_ddl)) if fk_ddl
122+
end
123+
124+
view_names = select_values(<<~SQL.squish, "SCHEMA")
125+
SELECT view_name FROM all_views
126+
WHERE owner = SYS_CONTEXT('userenv', 'current_schema')
127+
ORDER BY view_name ASC
128+
SQL
129+
view_names.each do |view_name|
130+
ddl = dbms_metadata_get_ddl("VIEW", view_name)
131+
structure << ddl if ddl
132+
end
133+
134+
join_with_statement_token(structure) <<
135+
join_with_statement_token(fk_statements)
136+
ensure
137+
reset_dbms_metadata_transforms
138+
end
139+
140+
def dbms_metadata_structure_dump_db_stored_code
141+
configure_dbms_metadata_transforms
142+
structure = []
143+
144+
# `GET_DDL("PACKAGE", ...)` returns *both* the spec and body, and
145+
# likewise for TYPE. Selecting 'PACKAGE BODY' / 'TYPE BODY' here
146+
# would dump the body a second time.
147+
all_source = select_all(<<~SQL.squish, "SCHEMA")
148+
SELECT DISTINCT name, type
149+
FROM all_source
150+
WHERE type IN ('PROCEDURE', 'PACKAGE', 'FUNCTION', 'TRIGGER', 'TYPE')
151+
AND name NOT LIKE 'BIN$%'
152+
AND owner = SYS_CONTEXT('userenv', 'current_schema') ORDER BY type
153+
SQL
154+
all_source.each do |source|
155+
ddl = dbms_metadata_get_ddl(source["type"], source["name"])
156+
structure << ddl if ddl
157+
end
158+
159+
structure << dbms_metadata_structure_dump_synonyms
160+
161+
join_with_statement_token(structure)
162+
ensure
163+
reset_dbms_metadata_transforms
164+
end
165+
166+
def dbms_metadata_structure_dump_synonyms
167+
configure_dbms_metadata_transforms
168+
structure = []
169+
synonym_names = select_values(<<~SQL.squish, "SCHEMA")
170+
SELECT synonym_name FROM all_synonyms
171+
WHERE owner = SYS_CONTEXT('userenv', 'current_schema')
172+
SQL
173+
synonym_names.each do |synonym_name|
174+
ddl = dbms_metadata_get_ddl("SYNONYM", synonym_name)
175+
structure << ddl if ddl
176+
end
177+
join_with_statement_token(structure)
178+
ensure
179+
reset_dbms_metadata_transforms
180+
end
181+
182+
def constraint_backed_index_names(table_name)
183+
select_values(<<~SQL.squish, "SCHEMA", [bind_string("table_name", table_name)])
184+
SELECT index_name FROM all_constraints
185+
WHERE owner = SYS_CONTEXT('userenv', 'current_schema')
186+
AND table_name = :table_name
187+
AND constraint_type IN ('P', 'U')
188+
AND index_name IS NOT NULL
189+
SQL
190+
end
191+
192+
# COMMENT ON is not reliably available via
193+
# DBMS_METADATA.GET_DEPENDENT_DDL('COMMENT'), so query directly.
194+
def dbms_metadata_structure_dump_table_comments(table_name)
195+
comment = table_comment(table_name)
196+
return [] if comment.nil?
197+
["COMMENT ON TABLE #{quote_table_name(table_name)} IS '#{quote_string(comment)}'"]
198+
end
199+
200+
def dbms_metadata_structure_dump_column_comments(table_name)
201+
comments = []
202+
columns = select_values(<<~SQL.squish, "SCHEMA", [bind_string("table_name", table_name)])
203+
SELECT column_name FROM all_tab_columns
204+
WHERE owner = SYS_CONTEXT('userenv', 'current_schema')
205+
AND table_name = :table_name ORDER BY column_id
206+
SQL
207+
columns.each do |column|
208+
comment = column_comment(table_name, column)
209+
unless comment.nil?
210+
comments << "COMMENT ON COLUMN #{quote_table_name(table_name)}.#{quote_column_name(column)} IS '#{quote_string(comment)}'"
211+
end
212+
end
213+
comments
214+
end
215+
216+
# Suppress installation-specific output, in spirit of
217+
# `pg_dump --schema-only --no-owner --no-tablespaces`.
218+
def configure_dbms_metadata_transforms
219+
execute(<<~SQL)
220+
BEGIN
221+
DBMS_METADATA.SET_TRANSFORM_PARAM(DBMS_METADATA.SESSION_TRANSFORM, 'STORAGE', FALSE);
222+
DBMS_METADATA.SET_TRANSFORM_PARAM(DBMS_METADATA.SESSION_TRANSFORM, 'TABLESPACE', FALSE);
223+
DBMS_METADATA.SET_TRANSFORM_PARAM(DBMS_METADATA.SESSION_TRANSFORM, 'SEGMENT_ATTRIBUTES', FALSE);
224+
DBMS_METADATA.SET_TRANSFORM_PARAM(DBMS_METADATA.SESSION_TRANSFORM, 'EMIT_SCHEMA', FALSE);
225+
DBMS_METADATA.SET_TRANSFORM_PARAM(DBMS_METADATA.SESSION_TRANSFORM, 'REF_CONSTRAINTS', FALSE);
226+
END;
227+
SQL
228+
end
229+
230+
def reset_dbms_metadata_transforms
231+
execute(<<~SQL)
232+
BEGIN
233+
DBMS_METADATA.SET_TRANSFORM_PARAM(DBMS_METADATA.SESSION_TRANSFORM, 'DEFAULT');
234+
END;
235+
SQL
236+
end
237+
238+
def dbms_metadata_get_ddl(object_type, object_name)
239+
result = select_value(
240+
"SELECT DBMS_METADATA.GET_DDL(#{quote(object_type)}, #{quote(object_name)}) FROM DUAL",
241+
"SCHEMA"
242+
)
243+
clean_dbms_metadata_ddl(result)
244+
rescue ActiveRecord::StatementInvalid => e
245+
# ORA-31603: object not found — e.g., constraint-backing index names
246+
# returned by `indexes()` that DBMS_METADATA classifies as
247+
# CONSTRAINT, not INDEX. The constraint DDL is already inlined in
248+
# the parent TABLE GET_DDL output, so silently skipping is correct.
249+
raise unless e.message.include?("ORA-31603")
250+
nil
251+
end
252+
253+
def dbms_metadata_get_dependent_ddl(dependent_type, base_object_name)
254+
result = select_value(
255+
"SELECT DBMS_METADATA.GET_DEPENDENT_DDL(#{quote(dependent_type)}, #{quote(base_object_name)}) FROM DUAL",
256+
"SCHEMA"
257+
)
258+
clean_dbms_metadata_ddl(result)
259+
rescue ActiveRecord::StatementInvalid => e
260+
# ORA-31608: dependent object not found (e.g., no triggers / no FKs).
261+
raise unless e.message.include?("ORA-31608")
262+
nil
263+
end
264+
265+
def clean_dbms_metadata_ddl(ddl)
266+
return nil if ddl.nil?
267+
result = ddl.to_s.strip
268+
result.empty? ? nil : result
269+
end
270+
271+
# GET_DEPENDENT_DDL can return multiple DDL statements concatenated
272+
# in a single CLOB. Split on blank-line boundaries.
273+
def split_dbms_metadata_ddl(ddl)
274+
return [] if ddl.nil?
275+
ddl.split(/\n\s*\n/).map(&:strip).reject(&:empty?)
276+
end
277+
end
278+
279+
prepend DbmsMetadata
280+
end
281+
end
282+
end
283+
end

lib/active_record/connection_adapters/oracle_enhanced_adapter.rb

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
require "active_record/connection_adapters/oracle_enhanced/dbms_output"
5050
require "active_record/connection_adapters/oracle_enhanced/type_metadata"
5151
require "active_record/connection_adapters/oracle_enhanced/structure_dump"
52+
require "active_record/connection_adapters/oracle_enhanced/structure_dump/dbms_metadata"
5253
require "active_record/connection_adapters/oracle_enhanced/lob"
5354

5455
require "active_record/type/oracle_enhanced/raw"
@@ -357,6 +358,43 @@ def use_shorter_identifier=(value)
357358
cattr_accessor :permissions
358359
self.permissions = ["unlimited tablespace", "create session", "create table", "create view", "create sequence"]
359360

361+
##
362+
# :singleton-method:
363+
# Selects which `structure_dump` backend the adapter uses. Accepts:
364+
#
365+
# * +:auto+ (default) — pick +:dbms_metadata+ on Oracle 12.1 and later,
366+
# fall back to +:data_dictionary+ on earlier releases. The
367+
# +:dbms_metadata+ path relies on constructs Oracle only emits
368+
# completely from 12.1 onward (notably +IDENTITY+ columns and
369+
# +EDITIONABLE+ keywords); +:auto+ keeps the new backend opt-in by
370+
# capability, not by user action.
371+
# * +:dbms_metadata+ — force Oracle's
372+
# <tt>DBMS_METADATA.GET_DDL</tt> / <tt>GET_DEPENDENT_DDL</tt>. The
373+
# Oracle-native equivalent of <tt>pg_dump --schema-only</tt> /
374+
# <tt>mysqldump --no-data</tt>. Raises +ArgumentError+ at dump time
375+
# when the connected server is older than 12.1.
376+
# * +:data_dictionary+ — force the original implementation that
377+
# assembles DDL from the <tt>ALL_*</tt> static data dictionary views
378+
# in Ruby. Retained as a fallback while the +:dbms_metadata+ backend
379+
# stabilises; may be removed in a future release. If
380+
# <tt>DBMS_METADATA</tt> does not produce usable DDL for your schema,
381+
# please open an issue at https://github.com/rsim/oracle-enhanced/issues
382+
# so the new backend can be fixed before the +:data_dictionary+ one
383+
# is removed.
384+
#
385+
# Set globally (e.g. in <tt>config/initializers/oracle_enhanced.rb</tt>):
386+
#
387+
# ActiveRecord::ConnectionAdapters::OracleEnhancedAdapter.structure_dump_method = :data_dictionary
388+
#
389+
# The toggle is intentionally a Rails-app-global setting rather than a
390+
# per-connection +database.yml+ key — the choice of structure-dump
391+
# backend is an implementation strategy, not something that varies
392+
# across the databases an app connects to.
393+
STRUCTURE_DUMP_METHODS = %i[auto dbms_metadata data_dictionary].freeze
394+
395+
cattr_accessor :structure_dump_method
396+
self.structure_dump_method = :auto
397+
360398
##
361399
# :singleton-method:
362400
# Specify default sequence start with value (by default 1 if not explicitly set), e.g.:
@@ -480,6 +518,18 @@ def supports_identity_columns? # :nodoc:
480518
database_version >= "12"
481519
end
482520

521+
# Whether `structure_dump_method: :auto` resolves to +:dbms_metadata+
522+
# on this connection. `DBMS_METADATA.GET_DDL` itself exists since
523+
# Oracle 9i, but the constructs the backend depends on (`IDENTITY`
524+
# columns, `EDITIONABLE` keywords, the modern set of
525+
# `SET_TRANSFORM_PARAM` options) only land in Oracle 12.1, so older
526+
# releases fall back to +:data_dictionary+ under +:auto+. The 12.1
527+
# floor is a project policy choice, not a strict database capability
528+
# check; +:data_dictionary+ remains explicitly selectable on 12.1+.
529+
def use_dbms_metadata_dump? # :nodoc:
530+
database_version >= "12.1"
531+
end
532+
483533
def supports_json?
484534
# Oracle Database 12.1 or higher version supports JSON.
485535
# However, Oracle enhanced adapter has limited support for JSON data type.

0 commit comments

Comments
 (0)