Skip to content

Commit 9861df8

Browse files
authored
Fix oracle for Rails 7.1 (#4296)
Remove redundant monkey patches. Add necessary patches. Clarify comments so we know when we can remove them.
1 parent b90fc81 commit 9861df8

5 files changed

Lines changed: 113 additions & 201 deletions

File tree

Gemfile

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,6 @@ gem 'svg-graph', require: false
111111
gem 'swagger-ui_rails', git: 'https://github.com/3scale/swagger-ui_rails.git', branch: 'dev'
112112
gem 'swagger-ui_rails2', git: 'https://github.com/3scale/swagger-ui_rails.git', branch: 'dev-2.1.3'
113113
gem 'thinking-sphinx', '~> 5.6.0'
114-
gem 'ts-datetime-delta', require: 'thinking_sphinx/deltas/datetime_delta'
115114
gem 'will_paginate', '~> 3.3'
116115
gem 'zip-zip', require: false
117116

Gemfile.lock

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -938,8 +938,6 @@ GEM
938938
tilt (2.0.11)
939939
timeout (0.4.3)
940940
tomlrb (2.0.3)
941-
ts-datetime-delta (2.0.2)
942-
thinking-sphinx (>= 1.3.8)
943941
ttfunk (1.7.0)
944942
tty-color (0.6.0)
945943
tty-cursor (0.7.1)
@@ -1161,7 +1159,6 @@ DEPENDENCIES
11611159
swagger-ui_rails!
11621160
swagger-ui_rails2!
11631161
thinking-sphinx (~> 5.6.0)
1164-
ts-datetime-delta
11651162
uglifier
11661163
unicorn
11671164
unicorn-rails

config/initializers/oracle.rb

Lines changed: 19 additions & 147 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
ActiveSupport.on_load(:active_record) do
44
if System::Database.oracle?
5+
ActiveRecord::ConnectionAdapters::OracleEnhancedAdapter.class_eval do
6+
# https://github.com/rsim/oracle-enhanced/pull/2573
7+
# Perhaps will end-up into adapter version 8.1.4+ but double check
8+
def supports_fetch_first_n_rows_and_offset?
9+
true
10+
end
11+
end
12+
513
require 'arel/visitors/oracle12_hack' || next # once done, we can skip setup
614

715
# in 6.0.6 automatic detection of max identifier length was introduced
@@ -41,7 +49,15 @@ def close_and_clear_statements
4149
end
4250
end
4351

52+
# https://github.com/rsim/oracle-enhanced/pull/2485 enables callbacks
53+
# which are basically redundant for use case we don't have.
54+
# Version 7.1 has only the `update` callbacks so the `create` ones
55+
# need to be uncommented by probably 7.2 upgrade.
56+
# If you uncomment them and things don't fail, then we are good.
4457
ActiveRecord::Base.skip_callback(:update, :after, :enhanced_write_lobs)
58+
# ActiveRecord::Base.skip_callback(:create, :after, :enhanced_write_lobs)
59+
ActiveRecord::Base.skip_callback(:update, :before, :record_changed_lobs)
60+
# ActiveRecord::Base.skip_callback(:create, :before, :record_lobs_for_create)
4561

4662
# For more information see https://github.com/rsim/oracle-enhanced/pull/2483
4763
module OracleEnhancedSmartQuoting
@@ -159,6 +175,9 @@ def add_column(table_name, column_name, type, **options)
159175
# ar_object.with_lock doesn't work OOB on oracle, see https://github.com/rsim/oracle-enhanced/issues/2237
160176
# A workaround is to avoid using FETCH FIRST when reloading an object by primary key.
161177
# https://github.com/rails/rails/blob/v7.1.5.1/activerecord/lib/active_record/relation/finder_methods.rb#L506
178+
# Might be fixed in 8.1.4+ with
179+
# https://github.com/rsim/oracle-enhanced/pull/2573 and
180+
# https://github.com/rsim/oracle-enhanced/pull/2693
162181
def find_one(id)
163182
if ActiveRecord::Base === id
164183
raise ArgumentError, <<-MSG.squish
@@ -184,153 +203,6 @@ def find_one(id)
184203
end
185204
end)
186205

187-
BabySqueel::Nodes::Attribute.prepend(Module.new do
188-
# those relations are used in subqueries and oracle does not support ORDER in subqueries
189-
private def sanitize_relation(rel)
190-
super rel.unscope(:order)
191-
end
192-
end)
193-
194-
ThinkingSphinx::ActiveRecord::SQLSource.prepend(Module.new do
195-
# If the Adapter is Oracle then we forcibly use ODBC client
196-
def type
197-
@type ||= case adapter
198-
when ThinkingSphinx::ActiveRecord::DatabaseAdapters::OracleAdapter
199-
'odbc'
200-
else
201-
super
202-
end
203-
end
204-
end)
205-
206-
ThinkingSphinx::ActiveRecord::DatabaseAdapters.module_eval do
207-
class << self
208-
prepend(Module.new do
209-
# https://github.com/pat/thinking-sphinx/blob/v3.2.0/lib/thinking_sphinx/active_record/database_adapters.rb#L35-L45
210-
# Adding a new DatabaseAdapters for ThinkingSphinx
211-
# This adds support for Oracle. OracleAdapter is responsible of query generation for Sphinx
212-
def adapter_for(model)
213-
return default.new(model) if default
214-
215-
adapter = adapter_type_for(model)
216-
klass = case adapter
217-
when :oracle
218-
ThinkingSphinx::ActiveRecord::DatabaseAdapters::OracleAdapter
219-
else
220-
super
221-
end
222-
klass.new model
223-
end
224-
225-
# https://github.com/pat/thinking-sphinx/blob/v3.2.0/lib/thinking_sphinx/active_record/database_adapters.rb#L21-L33
226-
# This method is only needed for `adapter_for`
227-
# Part of freedom patch for ThinkingSphinx handling with Oracle
228-
def adapter_type_for(model)
229-
class_name = model.connection.class.name
230-
case class_name.split('::').last
231-
when /oracle/i
232-
:oracle
233-
else
234-
super
235-
end
236-
end
237-
end
238-
)
239-
end
240-
end
241-
242-
ThinkingSphinx::Deltas::DatetimeDelta.prepend(Module.new do
243-
# https://github.com/pat/ts-datetime-delta/blob/v2.0.2/lib/thinking_sphinx/deltas/datetime_delta.rb#L167
244-
# A SQL condition (as part of the WHERE clause) that limits the result set to
245-
# just the delta data, or all data, depending on whether the toggled argument
246-
# is true or not. For datetime deltas, the former value is a check on the
247-
# delta column being within the threshold. In the latter's case, no condition
248-
# is needed, so nil is returned.
249-
def clause(*args)
250-
model = (args.length >= 2 ? args[0] : nil)
251-
is_delta = (args.length >= 2 ? args[1] : args[0]) || false
252-
253-
table_name = (model.nil? ? adapter.quoted_table_name : model.quoted_table_name)
254-
column_name = (model.nil? ? adapter.quote(@column.to_s) : model.connection.quote_column_name(@column.to_s))
255-
256-
if is_delta
257-
if adapter.class.name.downcase[/oracle/]
258-
"EXTRACT( day from ((#{table_name}.#{column_name} - SYSDATE) * 60 * 60 * 24)) + #{@threshold} > 0"
259-
else
260-
super
261-
end
262-
else
263-
nil
264-
end
265-
end
266-
end)
267-
268-
ThinkingSphinx::ActiveRecord::SQLSource.prepend(Module.new do
269-
# This autogenerate the odbc_dsn string based on database.yml
270-
# For now it is only particular to our project, later we could extract it and make a PR to the upstream project
271-
def set_database_settings(settings)
272-
super
273-
conf = System::Database.database_config.configuration_hash
274-
if type == 'odbc'
275-
@odbc_dsn ||= "DSN=oracle;Driver={Oracle-Driver};Dbq=#{conf[:host]}:#{conf[:port] || 1521}/#{conf[:database]};Uid=#{conf[:username]};Pwd=#{conf[:password]}"
276-
end
277-
end
278-
end)
279-
280-
ActiveRecord::ConnectionAdapters::OracleEnhanced::SchemaStatements.module_eval do
281-
def add_index(table_name, column_name, **options) #:nodoc:
282-
# All this code is exactly the same as the original except the line of the ALTER TABLE, which adds an additional USING INDEX #{quote_column_name(index_name)}
283-
# The reason of this is otherwise it picks the first index that finds that contains that column name, even if it is shared with other columns and it is not unique.
284-
# upstreamed: https://github.com/rsim/oracle-enhanced/pull/2293
285-
index_name, index_type, quoted_column_names, tablespace, index_options = add_index_options(table_name, column_name, **options)
286-
quoted_table_name = quote_table_name(table_name)
287-
quoted_column_name = quote_column_name(index_name)
288-
execute "CREATE #{index_type} INDEX #{quoted_column_name} ON #{quoted_table_name} (#{quoted_column_names})#{tablespace} #{index_options}"
289-
if index_type == 'UNIQUE' && quoted_column_names !~ /\(.*\)/
290-
execute "ALTER TABLE #{quoted_table_name} ADD CONSTRAINT #{quoted_column_name} #{index_type} (#{quoted_column_names}) USING INDEX #{quoted_column_name}"
291-
end
292-
end
293-
294-
def distinct_relation_for_primary_key(relation) # :nodoc:
295-
primary_key_columns = Array(relation.primary_key).map do |column|
296-
visitor.compile(relation.table[column])
297-
end
298-
299-
values = columns_for_distinct(
300-
primary_key_columns,
301-
relation.order_values
302-
)
303-
304-
limited = relation.reselect(values).distinct!
305-
306-
# The original code in https://github.com/rails/rails/blob/v7.1.5.1/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb#L1404-L1406
307-
# ----
308-
# limited_ids = select_rows(limited.arel, "SQL").map do |results|
309-
# results.last(Array(relation.primary_key).length) # ignores order values for MySQL and PostgreSQL
310-
# end
311-
# ----
312-
# The change is needed because in Oracle, because otherwise the resulting `limited_ids` array would be wrong. For example,
313-
# for a ServiceContract model that has the primary key "id" and a query with ordering and filtering you may get:
314-
# 1. `limited` variable can be something like: #<ActiveRecord::Relation [#<ServiceContract alias_0__: "live", id: 4, raw_rnum_: 1>]>
315-
# 2. `select_rows` would then return [["live",4,1]]
316-
# 3. `limited_ids` calculation will result in [[1]], which is an invalid IDs list (the expected is [[4]])
317-
# The updated code doesn't make assumptions about how many columns are selected or their order, but fetches the values according
318-
# to the primary key column names
319-
limited_ids = select_all(limited.arel, "SQL").to_ary.map do |result|
320-
result.values_at(*Array(relation.primary_key))
321-
end
322-
323-
if limited_ids.empty?
324-
relation.none!
325-
else
326-
relation.where!(**Array(relation.primary_key).zip(limited_ids.transpose).to_h)
327-
end
328-
329-
relation.limit_value = relation.offset_value = nil
330-
relation
331-
end
332-
end
333-
334206
# see https://github.com/rsim/oracle-enhanced/issues/2276
335207
module OracleEnhancedAdapterSchemaIssue2276
336208
def column_definitions(table_name)

lib/arel/visitors/oracle12_hack.rb

Lines changed: 42 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -3,69 +3,64 @@
33
module Arel
44
module Visitors
55
Oracle12.class_eval do
6-
# we need to strip ORDER from subqueries because Oracle does not support it
7-
def strip_order_from_select(o)
8-
case (node = o.right)
9-
when Arel::Nodes::SelectStatement
10-
node.orders = []
11-
end
12-
end
13-
14-
def visit_Arel_Nodes_In(o, collector)
15-
strip_order_from_select(o)
16-
17-
super
18-
end
19-
20-
def visit_Arel_Nodes_NotIn(o, collector)
21-
strip_order_from_select(o)
22-
23-
super
24-
end
25-
266
# Another wonderful piece.
277
# Oracle can't compare CLOB columns with standard SQL operators for comparison.
288
# We need to replace standard equality for text/binary columns to use DBMS_LOB.COMPARE function.
299
# Fixes ORA-00932: inconsistent datatypes: expected - got CLOB
3010
# remove when https://github.com/rsim/oracle-enhanced/pull/2415 is merged
3111
def visit_Arel_Nodes_Equality(o, collector)
12+
left = o.left
3213
right = o.right
3314

34-
return super if right.nil?
15+
return super unless right && %i(text binary).include?(cached_column_for(left)&.type)
3516

36-
case (left = o.left)
37-
when Arel::Attributes::Attribute
38-
table = left.relation.table_name
39-
schema_cache = @connection.schema_cache
17+
comparator = Arel::Nodes::NamedFunction.new("DBMS_LOB.COMPARE", [left, right])
18+
collector = visit comparator, collector
19+
collector << " = 0"
20+
collector
21+
end
4022

41-
return super unless schema_cache.data_source_exists?(table)
23+
# remove once fixed in https://github.com/rsim/oracle-enhanced/pull/2573 (8.1.4+)
24+
# Note that I'm not fully happy with this as the other visitor is missing potential
25+
# patches that we have in this visitor. But limit+lock should be rare and simpler.
26+
# An example failing operation without this is ProxyRule#move_to_top
27+
def visit_Arel_Nodes_SelectStatement(o, collector)
28+
if o.limit && o.lock
29+
@oracle11_visitor ||= Arel::Visitors::Oracle.new(@connection)
30+
return @oracle11_visitor.accept(o.dup, collector)
31+
end
32+
super
33+
end
4234

43-
column = schema_cache.columns_hash(table)[left.name.to_s]
35+
# can remove both after upgrade to a version with (8.1.4+ perhaps)
36+
# https://github.com/rsim/oracle-enhanced/pull/2654
37+
def visit_Arel_Nodes_In(o, collector)
38+
attr, values = o.left, o.right
39+
return super unless values.is_a?(Array)
4440

45-
case column.type
46-
when :text, :binary
47-
# https://docs.oracle.com/cd/B19306_01/appdev.102/b14258/d_lob.htm#i1016668
48-
# returns 0 when the comparison succeeds
49-
comparator = Arel::Nodes::NamedFunction.new('DBMS_LOB.COMPARE', [left, right])
50-
collector = visit comparator, collector
51-
collector << ' = 0'
52-
collector
53-
else
54-
super
55-
end
56-
else
57-
super
41+
in_clause_length = @connection.in_clause_length
42+
return super if values.length <= in_clause_length
43+
44+
# Split into multiple IN nodes and combine with OR
45+
in_nodes = values.each_slice(in_clause_length).map do |slice|
46+
Arel::Nodes::In.new(attr, slice)
5847
end
48+
or_node = in_nodes.reduce { |left, right| Arel::Nodes::Or.new(left, right) }
49+
visit(Arel::Nodes::Grouping.new(or_node), collector)
5950
end
6051

61-
# remove when addressed: https://github.com/rsim/oracle-enhanced/pull/2247 - included in v7.1.0
62-
def visit_Arel_Nodes_Matches o, collector
63-
if !o.case_sensitive && o.left && o.right
64-
o.left = Arel::Nodes::NamedFunction.new('UPPER', [o.left])
65-
o.right = Arel::Nodes::NamedFunction.new('UPPER', [o.right])
66-
end
52+
def visit_Arel_Nodes_NotIn(o, collector)
53+
attr, values = o.left, o.right
54+
return super unless values.is_a?(Array)
6755

68-
super o, collector
56+
in_clause_length = @connection.in_clause_length
57+
return super if values.length <= in_clause_length
58+
59+
# Split into multiple NOT IN nodes and combine with AND
60+
not_in_nodes = values.each_slice(in_clause_length).map do |slice|
61+
Arel::Nodes::NotIn.new(attr, slice)
62+
end
63+
visit(Arel::Nodes::And.new(not_in_nodes), collector)
6964
end
7065
end
7166
end

test/unit/oracle_hacks_test.rb

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,57 @@
1+
# frozen_string_literal: true
2+
13
require 'test_helper'
24

35
class OracleHacksTest < ActiveSupport::TestCase
4-
test "using #with_lock block" do
5-
object = FactoryBot.create(:audit)
6-
assert( object.with_lock { :ok } )
6+
setup do
7+
skip "Oracle-specific tests" unless System::Database.oracle?
8+
end
9+
10+
class ArelTest < OracleHacksTest
11+
test "using #with_lock block" do
12+
object = FactoryBot.create(:audit)
13+
assert( object.with_lock { :ok } )
14+
end
15+
end
16+
17+
class CollectionsTest < OracleHacksTest
18+
setup do
19+
messages = FactoryBot.create_list(:message, 1010, sender: master_account)
20+
@ids = messages.map(&:id)
21+
end
22+
23+
test "should allow more than 1000 items using Arel::Nodes::In" do
24+
table = Message.arel_table
25+
in_node = Arel::Nodes::In.new(table[:id], @ids)
26+
query = table.where(in_node).project(Arel.star)
27+
28+
sql = query.to_sql
29+
messages = Message.connection.select_all(sql).to_a
30+
31+
assert_equal @ids.size, messages.size, "Expected to retrieve all #{@ids.size} messages"
32+
33+
# SQL contains multiple IN clauses (split due to 1000 limit)
34+
in_clause_count = sql.scan("IN (").size
35+
assert in_clause_count > 1, "Expected multiple IN clauses due to Oracle's 1000 item limit, but found #{in_clause_count}"
36+
end
37+
38+
test "should allow more than 1000 items using raw Arel::Nodes::NotIn" do
39+
ids = @ids.dup
40+
non_not_in = ids.pop
41+
42+
table = Message.arel_table
43+
not_in_node = Arel::Nodes::NotIn.new(table[:id], ids)
44+
query = table.where(not_in_node).project(Arel.star)
45+
46+
sql = query.to_sql
47+
messages = Message.connection.select_all(sql).to_a
48+
49+
assert_equal 1, messages.size, "Expected to retrieve exactly 1 message"
50+
assert_equal non_not_in, messages.first["id"], "Expected to retrieve the message with id #{non_not_in}"
51+
52+
# SQL contains multiple NOT IN clauses (split due to 1000 limit)
53+
not_in_clause_count = sql.scan("NOT IN (").size
54+
assert not_in_clause_count > 1, "Expected multiple NOT IN clauses due to Oracle's 1000 item limit, but found #{not_in_clause_count}"
55+
end
756
end
857
end

0 commit comments

Comments
 (0)