Skip to content

Commit be13c01

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?`. - `: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. Implementation walks `ALL_OBJECTS` per object_type rather than hand-rolling per-kind queries; this naturally picks up MATERIALIZED VIEW / TYPE / TRIGGER / SYNONYM / etc. without separate code paths. FK constraints (REF_CONSTRAINT) are emitted in a single trailing block; cross-table dependency order is left to the user (or to a fresh-schema reload) rather than tracked here. 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. Oracle honors these for TABLE / INDEX / SEQUENCE. - `MATERIALIZED VIEW` DDL is an exception: Oracle does not honour the same suppression on MV (object_type-specific override does not work either), so MV DDL retains physical attributes (PCTFREE, TABLESPACE, SEGMENT CREATION, etc.) as Oracle emits them. Since MVs aren't creatable through Rails' standard migration helpers anyway, no post-processing pass is added — Oracle's output is used as-is. - `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 the TRIGGER object_type pass. - COMMENT ON TABLE / COMMENT ON COLUMN queried directly because `GET_DEPENDENT_DDL("COMMENT")` is unreliable. - `structure_dump_db_stored_code` selects 'PROCEDURE', 'PACKAGE', 'FUNCTION', 'TYPE' — `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 rather than exact byte-level DDL. A schema-option spec verifies that `:schema` connection-time switching is honored (structure_dump walks `SYS_CONTEXT('userenv', 'current_schema')`, not the connecting user). 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 be13c01

5 files changed

Lines changed: 604 additions & 0 deletions

File tree

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
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+
module DbmsMetadata # :nodoc:
10+
STRUCTURE_OBJECT_TYPES = [
11+
"SEQUENCE",
12+
"TABLE",
13+
"INDEX",
14+
"VIEW",
15+
"MATERIALIZED VIEW",
16+
"TRIGGER"
17+
].freeze
18+
19+
STORED_CODE_OBJECT_TYPES = [
20+
"FUNCTION",
21+
"PROCEDURE",
22+
"PACKAGE",
23+
"TYPE"
24+
].freeze
25+
26+
private_constant :STRUCTURE_OBJECT_TYPES, :STORED_CODE_OBJECT_TYPES
27+
28+
private
29+
def dbms_metadata_structure_dump
30+
dbms_metadata_with_transforms do
31+
structure = []
32+
table_names = list_schema_objects("TABLE", non_mview_only: true)
33+
skip_indexes = constraint_backed_index_names
34+
35+
STRUCTURE_OBJECT_TYPES.each do |object_type|
36+
names = if object_type == "TABLE"
37+
table_names
38+
else
39+
list_schema_objects(object_type)
40+
end
41+
names.each do |name|
42+
next if object_type == "INDEX" && skip_indexes.include?(name.upcase)
43+
ddl = dbms_metadata_get_ddl(object_type.tr(" ", "_"), name)
44+
structure << ddl if ddl
45+
end
46+
end
47+
48+
table_names.each do |table_name|
49+
structure.concat(dbms_metadata_structure_dump_table_comments(table_name))
50+
structure.concat(dbms_metadata_structure_dump_column_comments(table_name))
51+
end
52+
53+
fk_statements = table_names.flat_map do |table_name|
54+
fk_ddl = dbms_metadata_get_dependent_ddl("REF_CONSTRAINT", table_name)
55+
fk_ddl ? split_dbms_metadata_ddl(fk_ddl) : []
56+
end
57+
58+
join_with_statement_token(structure) << join_with_statement_token(fk_statements)
59+
end
60+
end
61+
62+
def dbms_metadata_structure_dump_db_stored_code
63+
dbms_metadata_with_transforms do
64+
structure = STORED_CODE_OBJECT_TYPES.flat_map do |object_type|
65+
list_schema_objects(object_type).filter_map do |name|
66+
dbms_metadata_get_ddl(object_type, name)
67+
end
68+
end
69+
structure << dbms_metadata_structure_dump_synonyms
70+
join_with_statement_token(structure)
71+
end
72+
end
73+
74+
def dbms_metadata_structure_dump_synonyms
75+
dbms_metadata_with_transforms do
76+
structure = list_schema_objects("SYNONYM").filter_map do |synonym_name|
77+
dbms_metadata_get_ddl("SYNONYM", synonym_name)
78+
end
79+
join_with_statement_token(structure)
80+
end
81+
end
82+
83+
def dbms_metadata_with_transforms
84+
configure_dbms_metadata_transforms
85+
yield
86+
ensure
87+
reset_dbms_metadata_transforms
88+
end
89+
90+
def list_schema_objects(object_type, non_mview_only: false)
91+
binds = [bind_string("object_type", object_type)]
92+
# MV / MV log surface as TABLE in all_objects.
93+
mview_filter = if non_mview_only
94+
<<~SQL.squish
95+
AND NOT EXISTS (SELECT 1 FROM all_mviews mv
96+
WHERE mv.owner = o.owner AND mv.mview_name = o.object_name)
97+
AND NOT EXISTS (SELECT 1 FROM all_mview_logs mvl
98+
WHERE mvl.log_owner = o.owner AND mvl.log_table = o.object_name)
99+
SQL
100+
else
101+
""
102+
end
103+
select_values(<<~SQL.squish, "SCHEMA", binds)
104+
SELECT object_name FROM all_objects o
105+
WHERE owner = SYS_CONTEXT('userenv', 'current_schema')
106+
AND object_type = :object_type
107+
AND object_name NOT LIKE 'BIN$%'
108+
#{mview_filter}
109+
ORDER BY object_name
110+
SQL
111+
end
112+
113+
# PRIMARY KEY / UNIQUE constraint backing indexes are already inlined by CONSTRAINTS=TRUE.
114+
def constraint_backed_index_names
115+
select_values(<<~SQL.squish, "SCHEMA").map(&:upcase).to_set
116+
SELECT index_name FROM all_constraints
117+
WHERE owner = SYS_CONTEXT('userenv', 'current_schema')
118+
AND constraint_type IN ('P', 'U')
119+
AND index_name IS NOT NULL
120+
SQL
121+
end
122+
123+
# GET_DEPENDENT_DDL("COMMENT", ...) returns an empty CLOB; query directly.
124+
def dbms_metadata_structure_dump_table_comments(table_name)
125+
comment = table_comment(table_name)
126+
return [] if comment.nil?
127+
["COMMENT ON TABLE #{quote_table_name(table_name)} IS '#{quote_string(comment)}'"]
128+
end
129+
130+
def dbms_metadata_structure_dump_column_comments(table_name)
131+
comments = []
132+
columns = select_values(<<~SQL.squish, "SCHEMA", [bind_string("table_name", table_name)])
133+
SELECT column_name FROM all_tab_columns
134+
WHERE owner = SYS_CONTEXT('userenv', 'current_schema')
135+
AND table_name = :table_name ORDER BY column_id
136+
SQL
137+
columns.each do |column|
138+
comment = column_comment(table_name, column)
139+
unless comment.nil?
140+
comments << "COMMENT ON COLUMN #{quote_table_name(table_name)}.#{quote_column_name(column)} IS '#{quote_string(comment)}'"
141+
end
142+
end
143+
comments
144+
end
145+
146+
# Suppress installation-specific output, in spirit of
147+
# `pg_dump --schema-only --no-owner --no-tablespaces`. Oracle
148+
# honors these for TABLE / INDEX / SEQUENCE; MATERIALIZED VIEW
149+
# ignores them and emits its own physical attributes regardless.
150+
def configure_dbms_metadata_transforms
151+
execute(<<~SQL)
152+
BEGIN
153+
DBMS_METADATA.SET_TRANSFORM_PARAM(DBMS_METADATA.SESSION_TRANSFORM, 'STORAGE', FALSE);
154+
DBMS_METADATA.SET_TRANSFORM_PARAM(DBMS_METADATA.SESSION_TRANSFORM, 'TABLESPACE', FALSE);
155+
DBMS_METADATA.SET_TRANSFORM_PARAM(DBMS_METADATA.SESSION_TRANSFORM, 'SEGMENT_ATTRIBUTES', FALSE);
156+
DBMS_METADATA.SET_TRANSFORM_PARAM(DBMS_METADATA.SESSION_TRANSFORM, 'EMIT_SCHEMA', FALSE);
157+
DBMS_METADATA.SET_TRANSFORM_PARAM(DBMS_METADATA.SESSION_TRANSFORM, 'REF_CONSTRAINTS', FALSE);
158+
END;
159+
SQL
160+
end
161+
162+
def reset_dbms_metadata_transforms
163+
execute(<<~SQL)
164+
BEGIN
165+
DBMS_METADATA.SET_TRANSFORM_PARAM(DBMS_METADATA.SESSION_TRANSFORM, 'DEFAULT');
166+
END;
167+
SQL
168+
end
169+
170+
def dbms_metadata_get_ddl(object_type, object_name)
171+
binds = [
172+
bind_string("object_type", object_type),
173+
bind_string("object_name", object_name)
174+
]
175+
result = select_value(
176+
"SELECT DBMS_METADATA.GET_DDL(:object_type, :object_name) FROM DUAL",
177+
"SCHEMA",
178+
binds
179+
)
180+
clean_dbms_metadata_ddl(result)
181+
rescue ActiveRecord::StatementInvalid => e
182+
# ORA-31603: object not found (race vs another session dropping
183+
# the object between the ALL_OBJECTS scan and GET_DDL).
184+
raise unless e.message.include?("ORA-31603")
185+
nil
186+
end
187+
188+
def dbms_metadata_get_dependent_ddl(dependent_type, base_object_name)
189+
binds = [
190+
bind_string("dependent_type", dependent_type),
191+
bind_string("base_object_name", base_object_name)
192+
]
193+
result = select_value(
194+
"SELECT DBMS_METADATA.GET_DEPENDENT_DDL(:dependent_type, :base_object_name) FROM DUAL",
195+
"SCHEMA",
196+
binds
197+
)
198+
clean_dbms_metadata_ddl(result)
199+
rescue ActiveRecord::StatementInvalid => e
200+
# ORA-31608: dependent object not found (e.g. no triggers / no FKs).
201+
raise unless e.message.include?("ORA-31608")
202+
nil
203+
end
204+
205+
def clean_dbms_metadata_ddl(ddl)
206+
return nil if ddl.nil?
207+
result = ddl.to_s.strip
208+
result.empty? ? nil : result
209+
end
210+
211+
# GET_DEPENDENT_DDL can return multiple DDL statements concatenated
212+
# in a single CLOB. Split on blank-line boundaries.
213+
def split_dbms_metadata_ddl(ddl)
214+
return [] if ddl.nil?
215+
ddl.split(/\n\s*\n/).map(&:strip).reject(&:empty?)
216+
end
217+
end
218+
end
219+
end
220+
end
221+
end
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# frozen_string_literal: true
2+
3+
require "active_record/connection_adapters/oracle_enhanced/structure_dump"
4+
require "active_record/connection_adapters/oracle_enhanced/structure_dump/dbms_metadata"
5+
6+
module ActiveRecord # :nodoc:
7+
module ConnectionAdapters # :nodoc:
8+
module OracleEnhanced # :nodoc:
9+
module StructureDump # :nodoc:
10+
# `OracleEnhancedAdapter.structure_dump_method` accepts:
11+
#
12+
# * `:auto` (default) — DBMS_METADATA on Oracle 12.1+, otherwise
13+
# data-dictionary.
14+
# * `:dbms_metadata` — force DBMS_METADATA; raises `ArgumentError`
15+
# on pre-12.1 (mirrors PR #2576's `identifier_max_length: :long`
16+
# policy).
17+
# * `:data_dictionary` — force the implementation that assembles
18+
# DDL from the ALL_* static data dictionary views in Ruby.
19+
module Dispatcher # :nodoc:
20+
def structure_dump
21+
case resolved_structure_dump_method
22+
when :dbms_metadata then dbms_metadata_structure_dump
23+
when :data_dictionary then super
24+
end
25+
end
26+
27+
def structure_dump_db_stored_code
28+
case resolved_structure_dump_method
29+
when :dbms_metadata then dbms_metadata_structure_dump_db_stored_code
30+
when :data_dictionary then super
31+
end
32+
end
33+
34+
def structure_dump_synonyms
35+
case resolved_structure_dump_method
36+
when :dbms_metadata then dbms_metadata_structure_dump_synonyms
37+
when :data_dictionary then super
38+
end
39+
end
40+
41+
private
42+
def resolved_structure_dump_method
43+
case OracleEnhancedAdapter.structure_dump_method
44+
when :auto
45+
use_dbms_metadata_dump? ? :dbms_metadata : :data_dictionary
46+
when :dbms_metadata
47+
unless use_dbms_metadata_dump?
48+
raise ArgumentError,
49+
"structure_dump_method: :dbms_metadata requires Oracle 12.1 or later " \
50+
"(connected server reports #{database_version}). " \
51+
"Use :auto to fall back to :data_dictionary on older releases."
52+
end
53+
:dbms_metadata
54+
when :data_dictionary
55+
:data_dictionary
56+
else
57+
raise ArgumentError,
58+
"Unknown structure_dump_method " \
59+
"#{OracleEnhancedAdapter.structure_dump_method.inspect}; " \
60+
"expected :auto, :dbms_metadata, or :data_dictionary."
61+
end
62+
end
63+
end
64+
65+
include DbmsMetadata
66+
prepend Dispatcher
67+
end
68+
end
69+
end
70+
end

lib/active_record/connection_adapters/oracle_enhanced_adapter.rb

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@
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"
53+
require "active_record/connection_adapters/oracle_enhanced/structure_dump/dispatcher"
5254
require "active_record/connection_adapters/oracle_enhanced/lob"
5355

5456
require "active_record/type/oracle_enhanced/raw"
@@ -357,6 +359,43 @@ def use_shorter_identifier=(value)
357359
cattr_accessor :permissions
358360
self.permissions = ["unlimited tablespace", "create session", "create table", "create view", "create sequence"]
359361

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

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

0 commit comments

Comments
 (0)