activerecord-oracle_enhanced-adapter 8.1.2 → 8.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dc0a57a3c3e6704c929a68d675c82aefc07cb37d5d243d3b751efa8b1f25e45e
4
- data.tar.gz: 808f61757c1330f099256c237976febf14b1b524b7632d88bac3d13aeb98897e
3
+ metadata.gz: 3f69a5874a83fcb0afd6b60ae09f61e0a300201cf3a99b6aee5523dcd92a985b
4
+ data.tar.gz: 8eee0fdb3853c42f27d37b37726ea9c4d685cadc047d00971291b0efe36503bd
5
5
  SHA512:
6
- metadata.gz: 47bc577a39b3919ac687bc465db2a11e7ac449afb62ecd9cc3373e87b26b5ef821db8c0e0bbb0bb99e814501a49e7b7b003ac41c5748cd19a994945dfed999f5
7
- data.tar.gz: da7d7996aab9d7912eff0ca4895a580db986246f9ebab0ea98e7a50490537c87cfc6c72f23eb117c703ede774e3bf85d0df499eaf1747248f7bc2118f5b900c7
6
+ metadata.gz: d8f684da4497ca2b4534744100d2c1468a7871420b127d4996816057e90d62213e5f456816c1e294920609f6f01122c778c0ff76b7479e3fa52b8f19c4694728
7
+ data.tar.gz: a2d729ffbcf21518d3b5a291e913c79104308d191e2ee6a54ac60b18418f4c8732e3069da2ecd5302cc6dd9f10e43d35c043fbeba204d99cb0b6e3b527cc0479
data/VERSION CHANGED
@@ -1 +1 @@
1
- 8.1.2
1
+ 8.1.4
@@ -16,102 +16,19 @@ module ActiveRecord
16
16
  end
17
17
  end
18
18
 
19
- attr_reader :raw_connection
19
+ attr_reader :raw_connection, :owner
20
20
 
21
21
  private
22
- # Used always by JDBC connection as well by OCI connection when describing tables over database link
23
- def describe(name)
24
- name = name.to_s
25
- if name.include?("@")
26
- raise ArgumentError "db link is not supported"
27
- else
28
- default_owner = @owner
29
- end
30
- real_name = OracleEnhanced::Quoting.valid_table_name?(name) ? name.upcase : name
31
- if real_name.include?(".")
32
- table_owner, table_name = real_name.split(".")
33
- else
34
- table_owner, table_name = default_owner, real_name
35
- end
36
- sql = <<~SQL.squish
37
- SELECT owner, table_name, 'TABLE' name_type
38
- FROM all_tables
39
- WHERE owner = :table_owner
40
- AND table_name = :table_name
41
- UNION ALL
42
- SELECT owner, view_name table_name, 'VIEW' name_type
43
- FROM all_views
44
- WHERE owner = :table_owner
45
- AND view_name = :table_name
46
- UNION ALL
47
- SELECT table_owner, table_name, 'SYNONYM' name_type
48
- FROM all_synonyms
49
- WHERE owner = :table_owner
50
- AND synonym_name = :table_name
51
- UNION ALL
52
- SELECT table_owner, table_name, 'SYNONYM' name_type
53
- FROM all_synonyms
54
- WHERE owner = 'PUBLIC'
55
- AND synonym_name = :real_name
56
- SQL
57
- if result = _select_one(sql, "CONNECTION", [table_owner, table_name, table_owner, table_name, table_owner, table_name, real_name])
58
- case result["name_type"]
59
- when "SYNONYM"
60
- describe("#{result['owner'] && "#{result['owner']}."}#{result['table_name']}")
61
- else
62
- [result["owner"], result["table_name"]]
63
- end
64
- else
65
- raise OracleEnhanced::ConnectionException, %Q{"DESC #{name}" failed; does it exist?}
66
- end
67
- end
68
-
69
22
  # Oracle column names by default are case-insensitive, but treated as upcase;
70
23
  # for neatness, we'll downcase within Rails. EXCEPT that folks CAN quote
71
24
  # their column names when creating Oracle tables, which makes then case-sensitive.
72
25
  # I don't know anybody who does this, but we'll handle the theoretical case of a
73
26
  # camelCase column name. I imagine other dbs handle this different, since there's a
74
27
  # unit test that's currently failing test_oci.
75
- #
76
- # `_oracle_downcase` is expected to be called only from
77
- # `ActiveRecord::ConnectionAdapters::OracleEnhanced::OCIConnection`
78
- # or `ActiveRecord::ConnectionAdapters::OracleEnhanced::JDBCConnection`.
79
- # Other method should call `ActiveRecord:: ConnectionAdapters::OracleEnhanced::Quoting#oracle_downcase`
80
- # since this is kind of quoting, not connection.
81
- # To avoid it is called from anywhere else, added _ at the beginning of the method name.
82
28
  def _oracle_downcase(column_name)
83
29
  return nil if column_name.nil?
84
30
  /[a-z]/.match?(column_name) ? column_name : column_name.downcase
85
31
  end
86
-
87
- # _select_one and _select_value methods are expected to be called
88
- # only from `ActiveRecord::ConnectionAdapters::OracleEnhanced::Connection#describe`
89
- # Other methods should call `ActiveRecord::ConnectionAdapters::DatabaseStatements#select_one`
90
- # and `ActiveRecord::ConnectionAdapters::DatabaseStatements#select_value`
91
- # To avoid called from its subclass added a underscore in each method.
92
-
93
- # Returns a record hash with the column names as keys and column values
94
- # as values.
95
- # binds is a array of native values in contrast to ActiveRecord::Relation::QueryAttribute
96
- def _select_one(arel, name = nil, binds = [])
97
- cursor = prepare(arel)
98
- cursor.bind_params(binds)
99
- cursor.exec
100
- columns = cursor.get_col_names.map do |col_name|
101
- _oracle_downcase(col_name)
102
- end
103
- row = cursor.fetch
104
- columns.each_with_index.to_h { |x, i| [x, row[i]] } if row
105
- ensure
106
- cursor.close
107
- end
108
-
109
- # Returns a single value from a record
110
- def _select_value(arel, name = nil, binds = [])
111
- if result = _select_one(arel, name, binds)
112
- result.values.first
113
- end
114
- end
115
32
  end
116
33
 
117
34
  # Returns array with major and minor version of database (e.g. [12, 1])
@@ -522,11 +522,6 @@ module ActiveRecord
522
522
  end
523
523
  end
524
524
 
525
- # To allow private method called from `JDBCConnection`
526
- def describe(name)
527
- super
528
- end
529
-
530
525
  # Return java.sql.SQLException error code
531
526
  def error_code(exception)
532
527
  case exception
@@ -237,10 +237,6 @@ module ActiveRecord
237
237
  lob.write value
238
238
  end
239
239
 
240
- def describe(name)
241
- super
242
- end
243
-
244
240
  # Return OCIError error code
245
241
  def error_code(exception)
246
242
  case exception
@@ -53,7 +53,7 @@ module ActiveRecord
53
53
  end
54
54
 
55
55
  def data_source_exists?(table_name)
56
- (_owner, _table_name) = _connection.describe(table_name)
56
+ (_owner, _table_name) = resolve_data_source_name(table_name)
57
57
  true
58
58
  rescue
59
59
  false
@@ -87,7 +87,7 @@ module ActiveRecord
87
87
  end
88
88
 
89
89
  def indexes(table_name) # :nodoc:
90
- (_owner, table_name) = _connection.describe(table_name)
90
+ (_owner, table_name) = resolve_data_source_name(table_name)
91
91
  default_tablespace_name = default_tablespace
92
92
 
93
93
  result = select_all(<<~SQL.squish, "SCHEMA", [bind_string("table_name", table_name)])
@@ -259,7 +259,7 @@ module ActiveRecord
259
259
  schema_cache.clear_data_source_cache!(table_name.to_s)
260
260
  schema_cache.clear_data_source_cache!(new_name.to_s)
261
261
  execute "RENAME #{quote_table_name(table_name)} TO #{quote_table_name(new_name)}"
262
- execute "RENAME #{quote_table_name("#{table_name}_seq")} TO #{default_sequence_name(new_name)}" rescue nil
262
+ execute "RENAME #{default_sequence_name(table_name)} TO #{default_sequence_name(new_name)}" rescue nil
263
263
 
264
264
  rename_table_indexes(table_name, new_name, **options)
265
265
  end
@@ -368,7 +368,7 @@ module ActiveRecord
368
368
  #
369
369
  # Will always query database and not index cache.
370
370
  def index_name_exists?(table_name, index_name)
371
- (_owner, table_name) = _connection.describe(table_name)
371
+ (_owner, table_name) = resolve_data_source_name(table_name)
372
372
  result = select_value(<<~SQL.squish, "SCHEMA", [bind_string("table_name", table_name), bind_string("index_name", index_name.to_s.upcase)])
373
373
  SELECT 1 FROM all_indexes i
374
374
  WHERE i.owner = SYS_CONTEXT('userenv', 'current_schema')
@@ -511,7 +511,7 @@ module ActiveRecord
511
511
 
512
512
  def table_comment(table_name) # :nodoc:
513
513
  # TODO
514
- (_owner, table_name) = _connection.describe(table_name)
514
+ (_owner, table_name) = resolve_data_source_name(table_name)
515
515
  select_value(<<~SQL.squish, "SCHEMA", [bind_string("table_name", table_name)])
516
516
  SELECT comments FROM all_tab_comments
517
517
  WHERE owner = SYS_CONTEXT('userenv', 'current_schema')
@@ -527,7 +527,7 @@ module ActiveRecord
527
527
 
528
528
  def column_comment(table_name, column_name) # :nodoc:
529
529
  # TODO: it does not exist in Abstract adapter
530
- (_owner, table_name) = _connection.describe(table_name)
530
+ (_owner, table_name) = resolve_data_source_name(table_name)
531
531
  select_value(<<~SQL.squish, "SCHEMA", [bind_string("table_name", table_name), bind_string("column_name", column_name.upcase)])
532
532
  SELECT comments FROM all_col_comments
533
533
  WHERE owner = SYS_CONTEXT('userenv', 'current_schema')
@@ -555,7 +555,7 @@ module ActiveRecord
555
555
 
556
556
  # get table foreign keys for schema dump
557
557
  def foreign_keys(table_name) # :nodoc:
558
- (_owner, desc_table_name) = _connection.describe(table_name)
558
+ (_owner, desc_table_name) = resolve_data_source_name(table_name)
559
559
 
560
560
  fk_info = select_all(<<~SQL.squish, "SCHEMA", [bind_string("desc_table_name", desc_table_name)])
561
561
  SELECT r.table_name to_table
@@ -733,6 +733,80 @@ module ActiveRecord
733
733
 
734
734
  execute("ALTER INDEX #{quote_column_name(index_name)} REBUILD TABLESPACE #{tablespace}")
735
735
  end
736
+
737
+ # 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.
746
+ 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("real_name", real_name),
762
+ ]
763
+ # Single-pass lookup against all_objects, ordered so the first row
764
+ # is the one the legacy 4-way UNION ALL (all_tables, all_views,
765
+ # owner synonym, public synonym) would have returned: prefer a
766
+ # match in the caller's schema over PUBLIC, and within a schema
767
+ # prefer TABLE over VIEW over SYNONYM.
768
+ result = select_one(<<~SQL.squish, "SCHEMA", binds)
769
+ SELECT owner, object_name table_name, object_type name_type
770
+ FROM all_objects
771
+ WHERE ((owner = :table_owner AND object_name = :table_name)
772
+ OR (owner = 'PUBLIC' AND object_name = :real_name))
773
+ AND object_type IN ('TABLE', 'VIEW', 'SYNONYM')
774
+ ORDER BY DECODE(owner, 'PUBLIC', 2, 1),
775
+ DECODE(object_type, 'TABLE', 1, 'VIEW', 2, 'SYNONYM', 3)
776
+ SQL
777
+
778
+ raise OracleEnhanced::ConnectionException, %Q{"DESC #{name}" failed; does it exist?} unless result
779
+
780
+ if result["name_type"] == "SYNONYM"
781
+ synonym_binds = [
782
+ bind_string("owner", result["owner"]),
783
+ bind_string("synonym_name", result["table_name"]),
784
+ ]
785
+ syn = select_one(<<~SQL.squish, "SCHEMA", synonym_binds)
786
+ SELECT table_owner, table_name
787
+ FROM all_synonyms
788
+ WHERE owner = :owner AND synonym_name = :synonym_name
789
+ SQL
790
+ raise OracleEnhanced::ConnectionException, %Q{"DESC #{name}" failed; does it exist?} unless syn
791
+ name = "#{syn['table_owner'] && "#{syn['table_owner']}."}#{syn['table_name']}"
792
+ else
793
+ return [result["owner"], result["table_name"]]
794
+ end
795
+ end
796
+ end
797
+
798
+ # Splits "schema.identifier" into its parts, returning [schema, identifier].
799
+ # Mirrors Rails' PostgreSQL/MySQL adapters: a non-qualified name yields
800
+ # schema = nil. Oracle-specific bits: rejects db links and upcases valid
801
+ # identifiers so catalog lookups match the stored upper-case names.
802
+ def extract_schema_qualified_name(string)
803
+ string = string.to_s
804
+ raise ArgumentError, "db link is not supported" if string.include?("@")
805
+
806
+ string = string.upcase if OracleEnhanced::Quoting.valid_table_name?(string)
807
+ schema, identifier = string.split(".") if string.include?(".")
808
+ [schema, identifier || string]
809
+ end
736
810
  end
737
811
  end
738
812
  end
@@ -519,7 +519,7 @@ module ActiveRecord
519
519
  table_name = table_name.to_s
520
520
  do_not_prefetch = @do_not_prefetch_primary_key[table_name]
521
521
  if do_not_prefetch.nil?
522
- owner, desc_table_name = _connection.describe(table_name)
522
+ owner, desc_table_name = resolve_data_source_name(table_name)
523
523
  @do_not_prefetch_primary_key[table_name] = do_not_prefetch = !has_primary_key?(table_name, owner, desc_table_name)
524
524
  end
525
525
  !do_not_prefetch
@@ -588,7 +588,7 @@ module ActiveRecord
588
588
  end
589
589
 
590
590
  def column_definitions(table_name)
591
- (owner, desc_table_name) = _connection.describe(table_name)
591
+ (owner, desc_table_name) = resolve_data_source_name(table_name)
592
592
 
593
593
  select_all(<<~SQL.squish, "SCHEMA", [bind_string("owner", owner), bind_string("table_name", desc_table_name)])
594
594
  SELECT cols.column_name AS name, cols.data_type AS sql_type,
@@ -620,7 +620,7 @@ module ActiveRecord
620
620
  # Find a table's primary key and sequence.
621
621
  # *Note*: Only primary key is implemented - sequence will be nil.
622
622
  def pk_and_sequence_for(table_name, owner = nil, desc_table_name = nil) # :nodoc:
623
- (owner, desc_table_name) = _connection.describe(table_name)
623
+ (owner, desc_table_name) = resolve_data_source_name(table_name)
624
624
 
625
625
  seqs = select_values_forcing_binds(<<~SQL.squish, "SCHEMA", [bind_string("owner", owner), bind_string("sequence_name", default_sequence_name(desc_table_name))])
626
626
  select us.sequence_name
@@ -662,7 +662,7 @@ module ActiveRecord
662
662
  end
663
663
 
664
664
  def primary_keys(table_name) # :nodoc:
665
- (_owner, desc_table_name) = _connection.describe(table_name)
665
+ (_owner, desc_table_name) = resolve_data_source_name(table_name)
666
666
 
667
667
  pks = select_values_forcing_binds(<<~SQL.squish, "SCHEMA", [bind_string("table_name", desc_table_name)])
668
668
  SELECT cc.column_name
@@ -685,8 +685,9 @@ module ActiveRecord
685
685
  # It does not construct DISTINCT clause. Just return column names for distinct.
686
686
  order_columns = orders.reject(&:blank?).map { |s|
687
687
  s = visitor.compile(s) unless s.is_a?(String)
688
- # remove any ASC/DESC modifiers
689
- s.gsub(/\s+(ASC|DESC)\s*?/i, "")
688
+ # remove any ASC/DESC and NULLS FIRST/LAST modifiers
689
+ s.gsub(/\s+(?:ASC|DESC)\b/i, "")
690
+ .gsub(/\s+NULLS\s+(?:FIRST|LAST)\b/i, "")
690
691
  }.reject(&:blank?).map.with_index { |column, i|
691
692
  "FIRST_VALUE(#{column}) OVER (PARTITION BY #{columns.join(', ')} ORDER BY #{column}) AS alias_#{i}__"
692
693
  }
@@ -172,8 +172,12 @@ module Arel # :nodoc: all
172
172
  end.flatten
173
173
  o.orders = []
174
174
  orders.each_with_index do |order, i|
175
- o.orders <<
176
- Nodes::SqlLiteral.new("alias_#{i}__#{' DESC' if /\bdesc$/i.match?(order)}")
175
+ parts = ["alias_#{i}__"]
176
+ parts << "DESC" if /\bdesc\b/i.match?(order)
177
+ if (nulls_match = order.match(/\bNULLS\s+(FIRST|LAST)\b/i))
178
+ parts << "NULLS #{nulls_match[1].upcase}"
179
+ end
180
+ o.orders << Nodes::SqlLiteral.new(parts.join(" "))
177
181
  end
178
182
  o
179
183
  end
@@ -46,6 +46,35 @@ module Arel # :nodoc: all
46
46
  def schema_cache
47
47
  @connection.schema_cache
48
48
  end
49
+
50
+ def visit_Arel_Nodes_In(o, collector)
51
+ attr, values = o.left, o.right
52
+ return super unless values.is_a?(Array)
53
+
54
+ in_clause_length = @connection.in_clause_length
55
+ return super if values.length <= in_clause_length
56
+
57
+ # Split into multiple IN nodes and combine with OR
58
+ in_nodes = values.each_slice(in_clause_length).map do |slice|
59
+ Arel::Nodes::In.new(attr, slice)
60
+ end
61
+ or_node = Arel::Nodes::Or.new(in_nodes)
62
+ visit(Arel::Nodes::Grouping.new(or_node), collector)
63
+ end
64
+
65
+ def visit_Arel_Nodes_NotIn(o, collector)
66
+ attr, values = o.left, o.right
67
+ return super unless values.is_a?(Array)
68
+
69
+ in_clause_length = @connection.in_clause_length
70
+ return super if values.length <= in_clause_length
71
+
72
+ # Split into multiple NOT IN nodes and combine with AND
73
+ not_in_nodes = values.each_slice(in_clause_length).map do |slice|
74
+ Arel::Nodes::NotIn.new(attr, slice)
75
+ end
76
+ visit(Arel::Nodes::And.new(not_in_nodes), collector)
77
+ end
49
78
  end
50
79
  end
51
80
  end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ describe "OracleEnhancedAdapter#columns_for_distinct" do
4
+ before(:all) do
5
+ ActiveRecord::Base.establish_connection(CONNECTION_PARAMS)
6
+ @conn = ActiveRecord::Base.connection
7
+ end
8
+
9
+ it "strips ASC modifier" do
10
+ sql = @conn.columns_for_distinct(["posts.id"], ["posts.created_at ASC"])
11
+ expect(sql).to include("FIRST_VALUE(posts.created_at)")
12
+ expect(sql).not_to match(/\bASC\b/i)
13
+ end
14
+
15
+ it "strips DESC modifier" do
16
+ sql = @conn.columns_for_distinct(["posts.id"], ["posts.created_at DESC"])
17
+ expect(sql).to include("FIRST_VALUE(posts.created_at)")
18
+ expect(sql).not_to match(/\bDESC\b/i)
19
+ end
20
+
21
+ it "strips NULLS FIRST modifier" do
22
+ sql = @conn.columns_for_distinct(["posts.id"], ["posts.created_at NULLS FIRST"])
23
+ expect(sql).to include("FIRST_VALUE(posts.created_at)")
24
+ expect(sql).not_to match(/NULLS\s+FIRST/i)
25
+ end
26
+
27
+ it "strips NULLS LAST modifier" do
28
+ sql = @conn.columns_for_distinct(["posts.id"], ["posts.created_at NULLS LAST"])
29
+ expect(sql).to include("FIRST_VALUE(posts.created_at)")
30
+ expect(sql).not_to match(/NULLS\s+LAST/i)
31
+ end
32
+
33
+ it "strips combined DESC NULLS LAST" do
34
+ sql = @conn.columns_for_distinct(["posts.id"], ["posts.created_at DESC NULLS LAST"])
35
+ expect(sql).to include("FIRST_VALUE(posts.created_at)")
36
+ expect(sql).not_to match(/\bDESC\b/i)
37
+ expect(sql).not_to match(/NULLS/i)
38
+ end
39
+
40
+ it "strips combined ASC NULLS FIRST" do
41
+ sql = @conn.columns_for_distinct(["posts.id"], ["posts.created_at ASC NULLS FIRST"])
42
+ expect(sql).to include("FIRST_VALUE(posts.created_at)")
43
+ expect(sql).not_to match(/\bASC\b/i)
44
+ expect(sql).not_to match(/NULLS/i)
45
+ end
46
+
47
+ it "strips lowercase desc nulls last" do
48
+ sql = @conn.columns_for_distinct(["posts.id"], ["posts.created_at desc nulls last"])
49
+ expect(sql).to include("FIRST_VALUE(posts.created_at)")
50
+ expect(sql).not_to match(/\bdesc\b/i)
51
+ expect(sql).not_to match(/nulls/i)
52
+ end
53
+
54
+ it "joins composite primary key columns in PARTITION BY" do
55
+ sql = @conn.columns_for_distinct(["posts.id", "posts.tenant_id"], ["posts.created_at DESC"])
56
+ expect(sql).to include("PARTITION BY posts.id, posts.tenant_id")
57
+ end
58
+
59
+ it "strips DESC NULLS LAST from an Arel ordering node" do
60
+ order_node = Arel::Table.new(:posts)[:created_at].desc.nulls_last
61
+ sql = @conn.columns_for_distinct(["posts.id"], [order_node])
62
+ expect(sql).to match(/FIRST_VALUE\("POSTS"\."CREATED_AT"\)/i)
63
+ expect(sql).not_to match(/\bDESC\b/i)
64
+ expect(sql).not_to match(/NULLS/i)
65
+ end
66
+
67
+ describe "integration with Arel::Visitors::Oracle#order_hacks" do
68
+ def compile_distinct_order(distinct_columns, order)
69
+ projection = "DISTINCT #{distinct_columns.join(', ')}, " \
70
+ "#{@conn.columns_for_distinct(distinct_columns, [order])}"
71
+ stmt = Arel::Nodes::SelectStatement.new
72
+ stmt.cores.first.projections << Arel::Nodes::SqlLiteral.new(projection)
73
+ stmt.orders << (order.is_a?(String) ? Arel::Nodes::SqlLiteral.new(order) : order)
74
+ @conn.visitor.accept(stmt, Arel::Collectors::SQLString.new).value
75
+ end
76
+
77
+ it "preserves DESC NULLS LAST in the outer ORDER BY for a raw SQL order" do
78
+ sql = compile_distinct_order(["posts.id"], "posts.created_at DESC NULLS LAST")
79
+ expect(sql).to include("ORDER BY alias_0__ DESC NULLS LAST")
80
+ end
81
+
82
+ it "preserves DESC NULLS LAST in the outer ORDER BY for an Arel ordering node" do
83
+ order_node = Arel::Table.new(:posts)[:created_at].desc.nulls_last
84
+ sql = compile_distinct_order(["posts.id"], order_node)
85
+ expect(sql).to include("ORDER BY alias_0__ DESC NULLS LAST")
86
+ end
87
+
88
+ it "preserves NULLS FIRST without emitting DESC for an ASC-with-nulls order" do
89
+ sql = compile_distinct_order(["posts.id"], "posts.created_at ASC NULLS FIRST")
90
+ expect(sql).to match(/ORDER BY alias_0__ NULLS FIRST\b/)
91
+ expect(sql).not_to match(/alias_0__ DESC/)
92
+ end
93
+ end
94
+ end
@@ -531,59 +531,177 @@ describe "OracleEnhancedConnection" do
531
531
  end
532
532
  end
533
533
 
534
- describe "describe table" do
534
+ describe "resolve_data_source_name" do
535
535
  before(:all) do
536
- @conn = ActiveRecord::ConnectionAdapters::OracleEnhanced::Connection.create(CONNECTION_PARAMS)
536
+ ActiveRecord::Base.establish_connection(CONNECTION_PARAMS)
537
+ @conn = ActiveRecord::Base.connection
537
538
  @owner = CONNECTION_PARAMS[:username].upcase
538
539
  end
539
540
 
540
- it "should describe existing table" do
541
- @conn.exec "CREATE TABLE test_employees (first_name VARCHAR2(20))" rescue nil
542
- expect(@conn.describe("test_employees")).to eq([@owner, "TEST_EMPLOYEES"])
543
- @conn.exec "DROP TABLE test_employees" rescue nil
541
+ def resolve(name)
542
+ @conn.send(:resolve_data_source_name, name)
544
543
  end
545
544
 
546
- it "should not describe non-existing table" do
547
- expect { @conn.describe("test_xxx") }.to raise_error(ActiveRecord::ConnectionAdapters::OracleEnhanced::ConnectionException)
545
+ it "should resolve existing table" do
546
+ @conn.execute "CREATE TABLE test_employees (first_name VARCHAR2(20))" rescue nil
547
+ expect(resolve("test_employees")).to eq([@owner, "TEST_EMPLOYEES"])
548
+ @conn.execute "DROP TABLE test_employees" rescue nil
548
549
  end
549
550
 
550
- it "should describe table in other schema" do
551
- expect(@conn.describe("sys.dual")).to eq(["SYS", "DUAL"])
551
+ it "should not resolve non-existing table" do
552
+ expect { resolve("test_xxx") }.to raise_error(ActiveRecord::ConnectionAdapters::OracleEnhanced::ConnectionException)
552
553
  end
553
554
 
554
- it "should describe table in other schema if the schema and table are in different cases" do
555
- expect(@conn.describe("SYS.dual")).to eq(["SYS", "DUAL"])
555
+ it "should resolve table in other schema" do
556
+ expect(resolve("sys.dual")).to eq(["SYS", "DUAL"])
556
557
  end
557
558
 
558
- it "should describe existing view" do
559
- @conn.exec "CREATE TABLE test_employees (first_name VARCHAR2(20))" rescue nil
560
- @conn.exec "CREATE VIEW test_employees_v AS SELECT * FROM test_employees" rescue nil
561
- expect(@conn.describe("test_employees_v")).to eq([@owner, "TEST_EMPLOYEES_V"])
562
- @conn.exec "DROP VIEW test_employees_v" rescue nil
563
- @conn.exec "DROP TABLE test_employees" rescue nil
559
+ it "should resolve table in other schema if the schema and table are in different cases" do
560
+ expect(resolve("SYS.dual")).to eq(["SYS", "DUAL"])
564
561
  end
565
562
 
566
- it "should describe view in other schema" do
567
- expect(@conn.describe("sys.v_$version")).to eq(["SYS", "V_$VERSION"])
563
+ it "should resolve existing view" do
564
+ @conn.execute "CREATE TABLE test_employees (first_name VARCHAR2(20))" rescue nil
565
+ @conn.execute "CREATE VIEW test_employees_v AS SELECT * FROM test_employees" rescue nil
566
+ expect(resolve("test_employees_v")).to eq([@owner, "TEST_EMPLOYEES_V"])
567
+ @conn.execute "DROP VIEW test_employees_v" rescue nil
568
+ @conn.execute "DROP TABLE test_employees" rescue nil
568
569
  end
569
570
 
570
- it "should describe existing private synonym" do
571
- @conn.exec "CREATE SYNONYM test_dual FOR sys.dual" rescue nil
572
- expect(@conn.describe("test_dual")).to eq(["SYS", "DUAL"])
573
- @conn.exec "DROP SYNONYM test_dual" rescue nil
571
+ it "should resolve view in other schema" do
572
+ expect(resolve("sys.v_$version")).to eq(["SYS", "V_$VERSION"])
574
573
  end
575
574
 
576
- it "should describe existing public synonym" do
577
- expect(@conn.describe("all_tables")).to eq(["SYS", "ALL_TABLES"])
575
+ it "should resolve existing materialized view" do
576
+ @conn.execute "CREATE TABLE test_employees (first_name VARCHAR2(20))" rescue nil
577
+ @conn.execute "CREATE MATERIALIZED VIEW test_employees_mv AS SELECT * FROM test_employees" rescue nil
578
+ expect(resolve("test_employees_mv")).to eq([@owner, "TEST_EMPLOYEES_MV"])
579
+ @conn.execute "DROP MATERIALIZED VIEW test_employees_mv" rescue nil
580
+ @conn.execute "DROP TABLE test_employees" rescue nil
578
581
  end
579
582
 
580
- if defined?(OCI8)
581
- context "OCI8 adapter" do
582
- it "should not fallback to SELECT-based logic when querying non-existent table information" do
583
- expect(@conn).not_to receive(:select_one)
584
- @conn.describe("non_existent") rescue ActiveRecord::ConnectionAdapters::OracleEnhanced::ConnectionException
585
- end
583
+ it "should resolve existing private synonym" do
584
+ @conn.execute "CREATE SYNONYM test_dual FOR sys.dual" rescue nil
585
+ expect(resolve("test_dual")).to eq(["SYS", "DUAL"])
586
+ @conn.execute "DROP SYNONYM test_dual" rescue nil
587
+ end
588
+
589
+ it "should resolve existing public synonym" do
590
+ expect(resolve("all_tables")).to eq(["SYS", "ALL_TABLES"])
591
+ end
592
+
593
+ # Exercises all five catalog paths (table, view, materialized view,
594
+ # private synonym, public synonym) for one underlying table in a single
595
+ # run. The individual cases above use disjoint fixtures; this one proves
596
+ # the DECODE-ordered all_objects lookup + synonym follow-through stays
597
+ # consistent when a private and a public synonym to the same table
598
+ # coexist, and that a materialized view created on the same base table
599
+ # resolves to the MV name (not the base table) as a sibling data source.
600
+ it "resolves table, view, materialized view, private synonym and public synonym for the same underlying table" do
601
+ @conn.execute "CREATE TABLE test_describe_all (id NUMBER)" rescue nil
602
+ @conn.execute "CREATE VIEW test_describe_all_v AS SELECT * FROM test_describe_all" rescue nil
603
+ @conn.execute "CREATE MATERIALIZED VIEW test_describe_all_mv AS SELECT * FROM test_describe_all" rescue nil
604
+ @conn.execute "CREATE SYNONYM test_describe_all_syn FOR test_describe_all" rescue nil
605
+ @conn.execute "CREATE PUBLIC SYNONYM test_describe_all_pub FOR #{@owner}.test_describe_all" rescue nil
606
+
607
+ expect(resolve("test_describe_all")).to eq([@owner, "TEST_DESCRIBE_ALL"])
608
+ expect(resolve("test_describe_all_v")).to eq([@owner, "TEST_DESCRIBE_ALL_V"])
609
+ expect(resolve("test_describe_all_mv")).to eq([@owner, "TEST_DESCRIBE_ALL_MV"])
610
+ expect(resolve("test_describe_all_syn")).to eq([@owner, "TEST_DESCRIBE_ALL"])
611
+ expect(resolve("test_describe_all_pub")).to eq([@owner, "TEST_DESCRIBE_ALL"])
612
+ ensure
613
+ @conn.execute "DROP PUBLIC SYNONYM test_describe_all_pub" rescue nil
614
+ @conn.execute "DROP SYNONYM test_describe_all_syn" rescue nil
615
+ @conn.execute "DROP MATERIALIZED VIEW test_describe_all_mv" rescue nil
616
+ @conn.execute "DROP VIEW test_describe_all_v" rescue nil
617
+ @conn.execute "DROP TABLE test_describe_all" rescue nil
618
+ end
619
+
620
+ it "raises when synonym resolution produces a looping chain" do
621
+ @conn.execute "CREATE SYNONYM test_cycle_a FOR test_cycle_b" rescue nil
622
+ @conn.execute "CREATE SYNONYM test_cycle_b FOR test_cycle_a" rescue nil
623
+ expect { resolve("test_cycle_a") }.to raise_error(
624
+ ActiveRecord::ConnectionAdapters::OracleEnhanced::ConnectionException,
625
+ /looping chain of synonyms/
626
+ )
627
+ ensure
628
+ @conn.execute "DROP SYNONYM test_cycle_a" rescue nil
629
+ @conn.execute "DROP SYNONYM test_cycle_b" rescue nil
630
+ end
631
+
632
+ it "raises when a multi-hop synonym chain eventually revisits an earlier link" do
633
+ @conn.execute "CREATE SYNONYM test_cycle_a FOR test_cycle_b" rescue nil
634
+ @conn.execute "CREATE SYNONYM test_cycle_b FOR test_cycle_c" rescue nil
635
+ @conn.execute "CREATE SYNONYM test_cycle_c FOR test_cycle_a" rescue nil
636
+ expect { resolve("test_cycle_a") }.to raise_error(
637
+ ActiveRecord::ConnectionAdapters::OracleEnhanced::ConnectionException,
638
+ /looping chain of synonyms/
639
+ )
640
+ ensure
641
+ @conn.execute "DROP SYNONYM test_cycle_a" rescue nil
642
+ @conn.execute "DROP SYNONYM test_cycle_b" rescue nil
643
+ @conn.execute "DROP SYNONYM test_cycle_c" rescue nil
644
+ end
645
+
646
+ it "raises ArgumentError when the name contains a db link" do
647
+ expect { resolve("test@db_link") }.to raise_error(ArgumentError, /db link is not supported/)
648
+ end
649
+
650
+ # The previous Connection#describe path bypassed the adapter's query machinery
651
+ # by driving a raw cursor, so its catalog lookup produced no sql.active_record
652
+ # event. Routing through select_one(..., "SCHEMA", ...) makes the lookup
653
+ # participate in logging, instrumentation, and the query cache. Lock that in
654
+ # so a future refactor can't silently regress to the raw-cursor path.
655
+ it "emits a SCHEMA sql.active_record event for the catalog lookup" do
656
+ @conn.execute "CREATE TABLE test_employees (first_name VARCHAR2(20))" rescue nil
657
+ events = []
658
+ subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload|
659
+ events << payload
586
660
  end
661
+ resolve("test_employees")
662
+ expect(events.map { |p| p[:name] }).to include("SCHEMA")
663
+ ensure
664
+ ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
665
+ @conn.execute "DROP TABLE test_employees" rescue nil
666
+ end
667
+ end
668
+
669
+ describe "extract_schema_qualified_name" do
670
+ before(:all) do
671
+ ActiveRecord::Base.establish_connection(CONNECTION_PARAMS)
672
+ @conn = ActiveRecord::Base.connection
673
+ end
674
+
675
+ def extract(string)
676
+ @conn.send(:extract_schema_qualified_name, string)
677
+ end
678
+
679
+ it "returns [nil, identifier] for an unqualified name and upcases it" do
680
+ expect(extract("table_name")).to eq([nil, "TABLE_NAME"])
681
+ end
682
+
683
+ it "leaves an already upcased unqualified name as-is" do
684
+ expect(extract("TABLE_NAME")).to eq([nil, "TABLE_NAME"])
685
+ end
686
+
687
+ it "splits a schema-qualified name and upcases it" do
688
+ expect(extract("hr.dept")).to eq(["HR", "DEPT"])
689
+ end
690
+
691
+ it "upcases a qualified name whose parts are in different cases" do
692
+ expect(extract("SYS.dual")).to eq(["SYS", "DUAL"])
693
+ end
694
+
695
+ it "accepts a Symbol and coerces it to a string" do
696
+ expect(extract(:dept)).to eq([nil, "DEPT"])
697
+ end
698
+
699
+ it "raises ArgumentError when the name contains a db link" do
700
+ expect { extract("test@db_link") }.to raise_error(ArgumentError, /db link is not supported/)
701
+ end
702
+
703
+ it "does not upcase a name that is not a valid identifier" do
704
+ expect(extract('"Weird Name"')).to eq([nil, '"Weird Name"'])
587
705
  end
588
706
  end
589
707
  end
@@ -296,6 +296,7 @@ describe "OracleEnhancedAdapter schema definition" do
296
296
  end
297
297
 
298
298
  after(:each) do
299
+ long_name = ("a" * (@conn.sequence_name_length - 3)).to_sym
299
300
  schema_define do
300
301
  drop_table :test_employees_no_primary_key, if_exists: true
301
302
  drop_table :test_employees, if_exists: true
@@ -303,6 +304,7 @@ describe "OracleEnhancedAdapter schema definition" do
303
304
  drop_table :test_employees_no_pkey, if_exists: true
304
305
  drop_table :new_test_employees_no_pkey, if_exists: true
305
306
  drop_table :aaaaaaaaaaaaaaaaaaaaaaaaaaa, if_exists: true
307
+ drop_table long_name, if_exists: true
306
308
  end
307
309
  end
308
310
 
@@ -329,6 +331,26 @@ describe "OracleEnhancedAdapter schema definition" do
329
331
  @conn.rename_table("test_employees_no_pkey", "new_test_employees_no_pkey")
330
332
  end.not_to raise_error
331
333
  end
334
+
335
+ it "renames the auto-generated sequence when the source table name is long enough to truncate it" do
336
+ long_source = "a" * (@conn.sequence_name_length - 3)
337
+ schema_define do
338
+ create_table long_source.to_sym, force: true do |t|
339
+ t.string :first_name
340
+ end
341
+ end
342
+
343
+ expected_old_seq = @conn.default_sequence_name(long_source).upcase
344
+ expected_new_seq = @conn.default_sequence_name("new_test_employees").upcase
345
+
346
+ @conn.rename_table(long_source, "new_test_employees")
347
+
348
+ sequences = @conn.select_values(
349
+ "SELECT sequence_name FROM user_sequences WHERE sequence_name IN ('#{expected_old_seq}', '#{expected_new_seq}')"
350
+ )
351
+ expect(sequences).to include(expected_new_seq)
352
+ expect(sequences).not_to include(expected_old_seq)
353
+ end
332
354
  end
333
355
 
334
356
  describe "add index" do
@@ -244,7 +244,7 @@ describe "OracleEnhancedAdapter" do
244
244
  class ::TestPost < ActiveRecord::Base
245
245
  has_many :test_comments
246
246
  end
247
- @ids = (1..1010).to_a
247
+ @ids = (1..2010).to_a
248
248
  TestPost.transaction do
249
249
  @ids.each do |id|
250
250
  TestPost.create!(id: id, title: "Title #{id}")
@@ -272,6 +272,38 @@ describe "OracleEnhancedAdapter" do
272
272
  posts = TestPost.where(id: [*@ids, nil]).to_a
273
273
  expect(posts.size).to eq(@ids.size)
274
274
  end
275
+
276
+ # some frameworks like baby_squeel construct Arel objects directly
277
+ it "should allow more than 1000 items using Arel::Nodes::In" do
278
+ table = TestPost.arel_table
279
+ in_node = Arel::Nodes::In.new(table[:id], @ids)
280
+ query = table.where(in_node).project(Arel.star)
281
+
282
+ sql = query.to_sql
283
+ posts = TestPost.connection.select_all(sql).to_a
284
+ expect(posts.size).to eq(@ids.size)
285
+
286
+ # SQL contains multiple IN clauses (split due to 1000 limit)
287
+ expect(sql.scan(/IN \(/).size).to be > 1
288
+ end
289
+
290
+ it "should allow more than 1000 items using Arel::Nodes::NotIn" do
291
+ ids = @ids.dup
292
+ non_not_in = ids.pop
293
+
294
+ table = TestPost.arel_table
295
+ not_in_node = Arel::Nodes::NotIn.new(table[:id], ids)
296
+ query = table.where(not_in_node).project(Arel.star)
297
+
298
+ sql = query.to_sql
299
+ posts = TestPost.connection.select_all(sql).to_a
300
+
301
+ expect(posts.size).to eq(1)
302
+ expect(posts.first["id"]).to eq(non_not_in)
303
+
304
+ # SQL contains multiple NOT IN clauses (split due to 1000 limit)
305
+ expect(sql.scan(/NOT IN \(/).size).to be > 1
306
+ end
275
307
  end
276
308
 
277
309
  describe "with statement pool" do
@@ -806,10 +838,54 @@ describe "OracleEnhancedAdapter" do
806
838
  ActiveRecord::Base.clear_cache!
807
839
  end
808
840
 
841
+ before(:each) do
842
+ TestPost.delete_all
843
+ TestComment.delete_all
844
+ end
845
+
809
846
  it "should not raise undefined method length" do
810
847
  post = TestPost.create!
811
848
  post.test_comments << TestComment.create!
812
849
  expect(TestComment.where(test_post_id: TestPost.select(:id)).size).to eq(1)
813
850
  end
851
+
852
+ it "should handle IN with subquery using Arel::Nodes::In" do
853
+ post = TestPost.create!
854
+ post.test_comments << TestComment.create!
855
+
856
+ table = TestComment.arel_table
857
+ subquery = TestPost.select(:id).arel
858
+ in_node = Arel::Nodes::In.new(table[:test_post_id], subquery)
859
+ query = table.where(in_node).project(Arel.star)
860
+
861
+ sql = query.to_sql
862
+ comments = TestComment.connection.select_all(sql).to_a
863
+ expect(comments.size).to eq(1)
864
+
865
+ # SQL should contain IN with subquery, not split into multiple IN clauses
866
+ expect(sql).to match(/IN \(+SELECT/)
867
+ expect(sql.scan(/IN \(/).size).to eq(1)
868
+ end
869
+
870
+ it "should handle NOT IN with subquery using Arel::Nodes::NotIn" do
871
+ post = TestPost.create!
872
+ TestComment.create!(test_post_id: post.id)
873
+ orphan_comment = TestComment.create!(test_post_id: post.id + 1)
874
+
875
+ table = TestComment.arel_table
876
+ subquery = TestPost.select(:id).arel
877
+ not_in_node = Arel::Nodes::NotIn.new(table[:test_post_id], subquery)
878
+ query = table.where(not_in_node).project(Arel.star)
879
+
880
+ sql = query.to_sql
881
+ comments = TestComment.connection.select_all(sql).to_a
882
+
883
+ expect(comments.size).to eq(1)
884
+ expect(comments.first["id"]).to eq(orphan_comment.id)
885
+
886
+ # SQL should contain NOT IN with subquery, not split into multiple NOT IN clauses
887
+ expect(sql).to match(/NOT IN \(+SELECT/)
888
+ expect(sql.scan(/NOT IN \(/).size).to eq(1)
889
+ end
814
890
  end
815
891
  end
@@ -4,28 +4,12 @@ CREATE USER oracle_enhanced IDENTIFIED BY oracle_enhanced;
4
4
 
5
5
  GRANT unlimited tablespace, create session, create table, create sequence,
6
6
  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 type, ctxapp,
8
+ create public synonym, drop public synonym TO oracle_enhanced;
8
9
 
9
10
  CREATE USER oracle_enhanced_schema IDENTIFIED BY oracle_enhanced_schema;
10
11
 
11
12
  GRANT unlimited tablespace, create session, create table, create sequence,
12
13
  create procedure, create trigger, create view, create materialized view,
13
- create database link, create synonym, create type, ctxapp TO oracle_enhanced_schema;
14
-
15
- CREATE USER arunit IDENTIFIED BY arunit;
16
-
17
- GRANT unlimited tablespace, create session, create table, create sequence,
18
- create procedure, create trigger, create view, create materialized view,
19
- create database link, create synonym, create type, ctxapp TO arunit;
20
-
21
- CREATE USER arunit2 IDENTIFIED BY arunit2;
22
-
23
- GRANT unlimited tablespace, create session, create table, create sequence,
24
- create procedure, create trigger, create view, create materialized view,
25
- create database link, create synonym, create type, ctxapp TO arunit2;
26
-
27
- CREATE USER ruby IDENTIFIED BY oci8;
28
- GRANT connect, resource, create view,create synonym TO ruby;
29
- GRANT EXECUTE ON dbms_lock TO ruby;
30
- GRANT CREATE VIEW TO ruby;
31
- GRANT unlimited tablespace to ruby;
14
+ create database link, create synonym, create type, ctxapp,
15
+ create public synonym, drop public synonym TO oracle_enhanced_schema;
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activerecord-oracle_enhanced-adapter
3
3
  version: !ruby/object:Gem::Version
4
- version: 8.1.2
4
+ version: 8.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Raimonds Simanovskis
@@ -103,6 +103,7 @@ files:
103
103
  - lib/arel/visitors/oracle12.rb
104
104
  - lib/arel/visitors/oracle_common.rb
105
105
  - spec/active_record/connection_adapters/emulation/oracle_adapter_spec.rb
106
+ - spec/active_record/connection_adapters/oracle_enhanced/columns_for_distinct_spec.rb
106
107
  - spec/active_record/connection_adapters/oracle_enhanced/compatibility_spec.rb
107
108
  - spec/active_record/connection_adapters/oracle_enhanced/composite_spec.rb
108
109
  - spec/active_record/connection_adapters/oracle_enhanced/connection_spec.rb
@@ -155,11 +156,12 @@ required_rubygems_version: !ruby/object:Gem::Requirement
155
156
  - !ruby/object:Gem::Version
156
157
  version: 1.8.11
157
158
  requirements: []
158
- rubygems_version: 4.0.10
159
+ rubygems_version: 4.0.11
159
160
  specification_version: 4
160
161
  summary: Oracle enhanced adapter for ActiveRecord
161
162
  test_files:
162
163
  - spec/active_record/connection_adapters/emulation/oracle_adapter_spec.rb
164
+ - spec/active_record/connection_adapters/oracle_enhanced/columns_for_distinct_spec.rb
163
165
  - spec/active_record/connection_adapters/oracle_enhanced/compatibility_spec.rb
164
166
  - spec/active_record/connection_adapters/oracle_enhanced/composite_spec.rb
165
167
  - spec/active_record/connection_adapters/oracle_enhanced/connection_spec.rb