active_record_extended 0.7.0 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "active_record_extended/version"
4
-
4
+ require "active_record_extended/utilities"
5
5
  require "active_record_extended/active_record"
6
6
  require "active_record_extended/arel"
7
7
 
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # TODO: Remove this when ruby 2.3 support is dropped
4
+ unless Hash.instance_methods(false).include?(:compact!)
5
+ require "active_support/all"
6
+ end
7
+
3
8
  require "active_record"
4
9
  require "active_record/relation"
5
10
  require "active_record/relation/merger"
@@ -7,11 +12,15 @@ require "active_record/relation/query_methods"
7
12
 
8
13
  require "active_record_extended/predicate_builder/array_handler_decorator"
9
14
 
15
+ require "active_record_extended/active_record/relation_patch"
16
+
10
17
  require "active_record_extended/query_methods/where_chain"
11
18
  require "active_record_extended/query_methods/with_cte"
19
+ require "active_record_extended/query_methods/unionize"
12
20
  require "active_record_extended/query_methods/any_of"
13
21
  require "active_record_extended/query_methods/either"
14
22
  require "active_record_extended/query_methods/inet"
23
+ require "active_record_extended/query_methods/json"
15
24
 
16
25
  if ActiveRecord::VERSION::MAJOR == 5 && ActiveRecord::VERSION::MINOR <= 1
17
26
  if ActiveRecord::VERSION::MINOR.zero?
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record_extended/query_methods/unionize"
4
+ require "active_record_extended/query_methods/json"
5
+
6
+ module ActiveRecordExtended
7
+ module RelationPatch
8
+ module QueryDelegation
9
+ delegate :with, to: :all
10
+ delegate(*::ActiveRecordExtended::QueryMethods::Unionize::UNIONIZE_METHODS, to: :all)
11
+ delegate(*::ActiveRecordExtended::QueryMethods::Json::JSON_QUERY_METHODS, to: :all)
12
+ end
13
+
14
+ module Merger
15
+ def normal_values
16
+ super + [:with, :union]
17
+ end
18
+ end
19
+
20
+ module ArelBuildPatch
21
+ def build_arel(*aliases)
22
+ super.tap do |arel|
23
+ build_unions(arel) if union_values?
24
+ build_with(arel) if with_values?
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+
31
+ ActiveRecord::Relation.prepend(ActiveRecordExtended::RelationPatch::ArelBuildPatch)
32
+ ActiveRecord::Relation::Merger.prepend(ActiveRecordExtended::RelationPatch::Merger)
33
+ ActiveRecord::Querying.prepend(ActiveRecordExtended::RelationPatch::QueryDelegation)
@@ -19,6 +19,26 @@ module Arel
19
19
  class ContainedInArray < Arel::Nodes::Binary
20
20
  end
21
21
 
22
+ class RowToJson < Arel::Nodes::Function
23
+ def initialize(*args)
24
+ super
25
+ unless @expressions.is_a?(Array)
26
+ @expressions = Arel.sql(@expressions) unless @expressions.is_a?(Arel::Nodes::SqlLiteral)
27
+ @expressions = [@expressions]
28
+ end
29
+ end
30
+ end
31
+
32
+ class JsonBuildObject < Arel::Nodes::Function
33
+ def initialize(*args)
34
+ super
35
+ @expressions = Array(@expressions)
36
+ end
37
+ end
38
+
39
+ class JsonbBuildObject < JsonBuildObject
40
+ end
41
+
22
42
  module Inet
23
43
  class ContainsEquals < Arel::Nodes::Binary
24
44
  end
@@ -18,7 +18,7 @@ module ActiveRecordExtended
18
18
  matchable_column?(col, object)
19
19
  end
20
20
 
21
- if %i[hstore jsonb].include?(left_column&.type)
21
+ if [:hstore, :jsonb].include?(left_column&.type)
22
22
  visit_Arel_Nodes_ContainsHStore(object, collector)
23
23
  elsif left_column.try(:array)
24
24
  visit_Arel_Nodes_ContainsArray(object, collector)
@@ -47,6 +47,18 @@ module ActiveRecordExtended
47
47
  infix_value object, collector, " << "
48
48
  end
49
49
 
50
+ def visit_Arel_Nodes_RowToJson(object, collector)
51
+ aggregate "ROW_TO_JSON", object, collector
52
+ end
53
+
54
+ def visit_Arel_Nodes_JsonBuildObject(object, collector)
55
+ aggregate "JSON_BUILD_OBJECT", object, collector
56
+ end
57
+
58
+ def visit_Arel_Nodes_JsonbBuildObject(object, collector)
59
+ aggregate "JSONB_BUILD_OBJECT", object, collector
60
+ end
61
+
50
62
  def visit_Arel_Nodes_Inet_ContainedWithinEquals(object, collector)
51
63
  infix_value object, collector, " <<= "
52
64
  end
@@ -6,7 +6,7 @@ module ActiveRecordExtended
6
6
  module QueryMethods
7
7
  module Either
8
8
  XOR_FIELD_SQL = "(CASE WHEN %<t1>s.%<c1>s IS NULL THEN %<t2>s.%<c2>s ELSE %<t1>s.%<c1>s END) "
9
- XOR_FIELD_KEYS = %i[t1 c1 t2 c2].freeze
9
+ XOR_FIELD_KEYS = [:t1, :c1, :t2, :c2].freeze
10
10
 
11
11
  def either_join(initial_association, fallback_association)
12
12
  associations = [initial_association, fallback_association]
@@ -3,12 +3,6 @@
3
3
  module ActiveRecordExtended
4
4
  module QueryMethods
5
5
  module Inet
6
- def contained_within(opts, *rest)
7
- ActiveSupport::Deprecation.warn("#contained_within will soon be deprecated for version 1.0 release. "\
8
- "Please use #inet_contained_within instead.", caller(1))
9
- inet_contained_within(opts, *rest)
10
- end
11
-
12
6
  # Finds matching inet column records that fall within a given submasked IP range
13
7
  #
14
8
  # Column(inet) << "127.0.0.1/24"
@@ -23,12 +17,6 @@ module ActiveRecordExtended
23
17
  substitute_comparisons(opts, rest, Arel::Nodes::Inet::ContainedWithin, "inet_contained_within")
24
18
  end
25
19
 
26
- def contained_within_or_equals(opts, *rest)
27
- ActiveSupport::Deprecation.warn("#contained_within_or_equals will soon be deprecated for version 1.0 release. "\
28
- "Please use #inet_contained_within_or_equals instead.", caller(1))
29
- inet_contained_within_or_equals(opts, *rest)
30
- end
31
-
32
20
  # Finds matching inet column records that fall within a given submasked IP range and also finds records that also
33
21
  # contain a submasking field that fall within range too.
34
22
  #
@@ -44,12 +32,6 @@ module ActiveRecordExtended
44
32
  substitute_comparisons(opts, rest, Arel::Nodes::Inet::ContainedWithinEquals, "inet_contained_within_or_equals")
45
33
  end
46
34
 
47
- def contains_or_equals(opts, *rest)
48
- ActiveSupport::Deprecation.warn("#contains_or_equals will soon be deprecated for version 1.0 release. "\
49
- "Please use #inet_contains_or_equals instead.", caller(1))
50
- inet_contains_or_equals(opts, *rest)
51
- end
52
-
53
35
  # Finds records that contain a submask and the supplied IP address falls within its range.
54
36
  #
55
37
  # Column(inet) >>= "127.0.0.1/24"
@@ -0,0 +1,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordExtended
4
+ module QueryMethods
5
+ module Json
6
+ JSON_QUERY_METHODS = [
7
+ :select_row_to_json,
8
+ :json_build_object,
9
+ :jsonb_build_object,
10
+ :json_build_literal,
11
+ :jsonb_build_literal,
12
+ ].freeze
13
+
14
+ class JsonChain
15
+ include ::ActiveRecordExtended::Utilities
16
+ DEFAULT_ALIAS = '"results"'
17
+
18
+ def initialize(scope)
19
+ @scope = scope
20
+ end
21
+
22
+ def row_to_json!(**args, &block)
23
+ options = json_object_options(args).except(:values, :value)
24
+ build_row_to_json(**options, &block)
25
+ end
26
+
27
+ def json_build_object!(*args)
28
+ options = json_object_options(args).except!(:values)
29
+ build_json_object(Arel::Nodes::JsonBuildObject, **options)
30
+ end
31
+
32
+ def jsonb_build_object!(*args)
33
+ options = json_object_options(args).except!(:values)
34
+ build_json_object(Arel::Nodes::JsonbBuildObject, **options)
35
+ end
36
+
37
+ def json_build_literal!(*args)
38
+ options = json_object_options(args).slice(:values, :col_alias)
39
+ build_json_literal(Arel::Nodes::JsonBuildObject, **options)
40
+ end
41
+
42
+ def jsonb_build_literal!(*args)
43
+ options = json_object_options(args).slice(:values, :col_alias)
44
+ build_json_literal(Arel::Nodes::JsonbBuildObject, **options)
45
+ end
46
+
47
+ private
48
+
49
+ def build_json_literal(arel_klass, values:, col_alias: DEFAULT_ALIAS)
50
+ json_values = flatten_to_sql(values.to_a, &method(:literal_key))
51
+ col_alias = double_quote(col_alias)
52
+ json_build_obj = arel_klass.new(json_values)
53
+ @scope.select(nested_alias_escape(json_build_obj, col_alias))
54
+ end
55
+
56
+ def build_json_object(arel_klass, from:, key: key_generator, value: nil, col_alias: DEFAULT_ALIAS)
57
+ tbl_alias = double_quote(key)
58
+ col_alias = double_quote(col_alias)
59
+ col_key = literal_key(key)
60
+ col_value = to_arel_sql(value.presence || tbl_alias)
61
+ json_build_object = arel_klass.new(to_sql_array(col_key, col_value))
62
+
63
+ # TODO: Change this to #match?(..) when we drop Rails 5.0 or Ruby 2.4 support
64
+ unless col_value.index(/".+"/)
65
+ warn("`#{col_value}`: the `value` argument should contain a double quoted key reference for safety")
66
+ end
67
+
68
+ @scope.select(nested_alias_escape(json_build_object, col_alias)).from(nested_alias_escape(from, tbl_alias))
69
+ end
70
+
71
+ def build_row_to_json(from:, key: key_generator, col_alias: nil, cast_to_array: false)
72
+ row_to_json = Arel::Nodes::RowToJson.new(double_quote(key))
73
+ dummy_table = from_clause_constructor(from, key).select(row_to_json)
74
+ dummy_table = yield dummy_table if block_given?
75
+
76
+ if col_alias.blank?
77
+ dummy_table
78
+ elsif cast_to_array
79
+ @scope.select(wrap_with_array(dummy_table, col_alias))
80
+ else
81
+ @scope.select(nested_alias_escape(dummy_table, col_alias))
82
+ end
83
+ end
84
+
85
+ def json_object_options(*args) # rubocop:disable Metrics/AbcSize
86
+ flatten_safely(args).each_with_object(values: []) do |arg, options|
87
+ next if arg.nil?
88
+
89
+ if arg.is_a?(Hash)
90
+ options[:key] ||= arg.delete(:key)
91
+ options[:value] ||= arg.delete(:value).presence
92
+ options[:col_alias] ||= arg.delete(:as)
93
+ options[:cast_to_array] ||= arg.delete(:cast_as_array)
94
+ options[:from] ||= arg.delete(:from).tap(&method(:pipe_cte_with!))
95
+ end
96
+
97
+ options[:values] << (arg.respond_to?(:to_a) ? arg.to_a : arg)
98
+ end.compact
99
+ end
100
+ end
101
+
102
+ # Appends a select statement that contains a subquery that is converted to a json response
103
+ #
104
+ # Arguments:
105
+ # - from: [String, Arel, or ActiveRecord::Relation] A subquery that can be nested into a ROW_TO_JSON clause
106
+ #
107
+ # Options:
108
+ # - as: [Symbol or String] (default="results"): What the column will be aliased to
109
+ #
110
+ # - key: [Symbol or String] (default=[random letter]) What the row clause will be set as.
111
+ # - This is useful if you would like to add additional mid-level clauses (see mid-level scope example)
112
+ #
113
+ # - cast_as_array [boolean] (default=false): Determines if the query should be nested inside an Array() function
114
+ #
115
+ # Example:
116
+ # subquery = Group.select(:name, :category_id).where("user_id = users.id")
117
+ # User.select(:name, email).select_row_to_json(subquery, as: :users_groups, cast_as_array: true)
118
+ # #=> [<#User name:.., email:.., users_groups: [{ name: .., category_id: .. }, ..]]
119
+ #
120
+ # - Adding mid-level scopes:
121
+ #
122
+ # subquery = Group.select(:name, :category_id)
123
+ # User.select_row_to_json(subquery, key: :group, cast_as_array: true) do |scope|
124
+ # scope.where(group: { name: "Nerd Core" })
125
+ # end
126
+ #
127
+
128
+ def select_row_to_json(from = nil, **options, &block)
129
+ from.is_a?(Hash) ? options.merge!(from) : options.reverse_merge!(from: from)
130
+ options.compact!
131
+ raise ArgumentError, "Required to provide a non-nilled from clause" unless options.key?(:from)
132
+ JsonChain.new(spawn).row_to_json!(**options, &block)
133
+ end
134
+
135
+ # Creates a json response object that will convert all subquery results into a json compatible response
136
+ #
137
+ # Arguments:
138
+ # key: [Symbol or String]: What should this response return as
139
+ # from: [String, Arel, or ActiveRecord::Relation] : A subquery that can be nested into the top-level from clause
140
+ #
141
+ # Options:
142
+ # - as: [Symbol or String] (default="results"): What the column will be aliased to
143
+ #
144
+ #
145
+ # - value: [Symbol or String] (defaults=[key]): How the response should handel the json value return
146
+ #
147
+ # Example:
148
+ #
149
+ # - Generic example:
150
+ #
151
+ # subquery = Group.select(:name, :category_id).where("user_id = users.id")
152
+ # User.select(:name, email).select_row_to_json(subquery, as: :users_groups, cast_as_array: true)
153
+ # #=> [<#User name:.., email:.., users_groups: [{ name: .., category_id: .. }, ..]]
154
+ #
155
+ # - Setting a custom value:
156
+ #
157
+ # Before:
158
+ # subquery = User.select(:name).where(id: 100..110).group(:name)
159
+ # User.build_json_object(:gang_members, subquery).take.results["gang_members"] #=> nil
160
+ #
161
+ # After:
162
+ # User.build_json_object(:gang_members, subquery, value: "COALESCE(array_agg(\"gang_members\"), 'BANG!')")
163
+ # .take
164
+ # .results["gang_members"] #=> "BANG!"
165
+ #
166
+ #
167
+ # - Adding mid-level scopes
168
+ #
169
+ # subquery = Group.select(:name, :category_id)
170
+ # User.select_row_to_json(subquery, key: :group, cast_as_array: true) do |scope|
171
+ # scope.where(group: { name: "Nerd Core" })
172
+ # end #=> ```sql
173
+ # SELECT ARRAY(
174
+ # SELECT ROW_TO_JSON("group")
175
+ # FROM(SELECT name, category_id FROM groups) AS group
176
+ # WHERE group.name = 'Nerd Core'
177
+ # )
178
+ # ```
179
+ #
180
+
181
+ def json_build_object(key, from, **options)
182
+ options[:key] = key
183
+ options[:from] = from
184
+ JsonChain.new(spawn).json_build_object!(options)
185
+ end
186
+
187
+ def jsonb_build_object(key, from, **options)
188
+ options[:key] = key
189
+ options[:from] = from
190
+ JsonChain.new(spawn).jsonb_build_object!(options)
191
+ end
192
+
193
+ # Appends a hash literal to the calling relations response
194
+ #
195
+ # Arguments: Requires an Array or Hash set of values
196
+ #
197
+ # Options:
198
+ #
199
+ # - as: [Symbol or String] (default="results"): What the column will be aliased to
200
+ #
201
+ # Example:
202
+ # - Supplying inputs as a Hash
203
+ # query = User.json_build_literal(number: 1, last_name: "json", pi: 3.14)
204
+ # query.take.results #=> { "number" => 1, "last_name" => "json", "pi" => 3.14 }
205
+ #
206
+ # - Supplying inputs as an Array
207
+ #
208
+ # query = User.json_build_literal(:number, 1, :last_name, "json", :pi, 3.14)
209
+ # query.take.results #=> { "number" => 1, "last_name" => "json", "pi" => 3.14 }
210
+ #
211
+
212
+ def json_build_literal(*args)
213
+ JsonChain.new(spawn).json_build_literal!(args)
214
+ end
215
+
216
+ def jsonb_build_literal(*args)
217
+ JsonChain.new(spawn).jsonb_build_literal!(args)
218
+ end
219
+ end
220
+ end
221
+ end
222
+
223
+ ActiveRecord::Relation.prepend(ActiveRecordExtended::QueryMethods::Json)
@@ -0,0 +1,278 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordExtended
4
+ module QueryMethods
5
+ module Unionize
6
+ UNION_RELATION_METHODS = [:order_union, :reorder_union, :union_as].freeze
7
+ UNIONIZE_METHODS = [:union, :union_all, :union_except, :union_intersect].freeze
8
+
9
+ class UnionChain
10
+ include ::ActiveRecordExtended::Utilities
11
+
12
+ def initialize(scope)
13
+ @scope = scope
14
+ end
15
+
16
+ def as(from_clause_name)
17
+ @scope.unionized_name = from_clause_name.to_s
18
+ @scope
19
+ end
20
+ alias union_as as
21
+
22
+ def order(*ordering_args)
23
+ process_ordering_arguments!(ordering_args)
24
+ @scope.union_ordering_values += ordering_args
25
+ @scope
26
+ end
27
+ alias order_union order
28
+
29
+ def reorder(*ordering_args)
30
+ @scope.union_ordering_values.clear
31
+ order(*ordering_args)
32
+ end
33
+ alias reorder_union reorder
34
+
35
+ def union(*args)
36
+ append_union_order!(:union, args)
37
+ @scope
38
+ end
39
+
40
+ def all(*args)
41
+ append_union_order!(:union_all, args)
42
+ @scope
43
+ end
44
+ alias union_all all
45
+
46
+ def except(*args)
47
+ append_union_order!(:except, args)
48
+ @scope
49
+ end
50
+ alias union_except except
51
+
52
+ def intersect(*args)
53
+ append_union_order!(:intersect, args)
54
+ @scope
55
+ end
56
+ alias union_intersect intersect
57
+
58
+ protected
59
+
60
+ def append_union_order!(union_type, args)
61
+ args.each(&method(:pipe_cte_with!))
62
+ flatten_scopes = flatten_to_sql(args)
63
+ @scope.union_values += flatten_scopes
64
+ calculate_union_operation!(union_type, flatten_scopes.size)
65
+ end
66
+
67
+ def calculate_union_operation!(union_type, scope_count)
68
+ scope_count -= 1 unless @scope.union_operations?
69
+ scope_count = 1 if scope_count <= 0 && @scope.union_values.size <= 1
70
+ @scope.union_operations += [union_type] * scope_count
71
+ end
72
+
73
+ # We'll need to preprocess these arguments for allowing `ActiveRecord::Relation#preprocess_order_args`,
74
+ # to check for sanitization issues and convert over to `Arel::Nodes::[Ascending/Descending]`.
75
+ # Without reflecting / prepending the parent's table name.
76
+
77
+ if ActiveRecord.gem_version < Gem::Version.new("5.1")
78
+ # TODO: Rails 5.0.x order logic will *always* append the parents name to the column when its an HASH obj
79
+ # We should really do this stuff better. Maybe even just ignore `preprocess_order_args` altogether?
80
+ # Maybe I'm just stupidly over paranoid on just the 'ORDER BY' for some odd reason.
81
+ def process_ordering_arguments!(ordering_args)
82
+ ordering_args.flatten!
83
+ ordering_args.compact!
84
+ ordering_args.map! do |arg|
85
+ next to_arel_sql(arg) unless arg.is_a?(Hash) # ActiveRecord will reflect if an argument is a symbol
86
+ arg.each_with_object([]) do |(field, dir), ordering_object|
87
+ ordering_object << to_arel_sql(field).send(dir.to_s.downcase)
88
+ end
89
+ end.flatten!
90
+ end
91
+ else
92
+ def process_ordering_arguments!(ordering_args)
93
+ ordering_args.flatten!
94
+ ordering_args.compact!
95
+ ordering_args.map! do |arg|
96
+ next to_arel_sql(arg) unless arg.is_a?(Hash) # ActiveRecord will reflect if an argument is a symbol
97
+ arg.each_with_object({}) do |(field, dir), ordering_obj|
98
+ # ActiveRecord will not reflect if the Hash keys are a `Arel::Nodes::SqlLiteral` klass
99
+ ordering_obj[to_arel_sql(field)] = dir.to_s.downcase
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
105
+
106
+ def unionize_storage
107
+ @values.fetch(:unionize, {})
108
+ end
109
+
110
+ def unionize_storage!
111
+ @values[:unionize] ||= {
112
+ union_values: [],
113
+ union_operations: [],
114
+ union_ordering_values: [],
115
+ unionized_name: nil,
116
+ }
117
+ end
118
+
119
+ {
120
+ union_values: Array,
121
+ union_operations: Array,
122
+ union_ordering_values: Array,
123
+ unionized_name: lambda { |klass| klass.arel_table.name },
124
+ }.each_pair do |method_name, default|
125
+ define_method(method_name) do
126
+ return unionize_storage[method_name] if send("#{method_name}?")
127
+ (default.is_a?(Proc) ? default.call(@klass) : default.new)
128
+ end
129
+
130
+ define_method("#{method_name}?") do
131
+ unionize_storage.key?(method_name) && !unionize_storage[method_name].presence.nil?
132
+ end
133
+
134
+ define_method("#{method_name}=") do |value|
135
+ unionize_storage![method_name] = value
136
+ end
137
+ end
138
+
139
+ def union(opts = :chain, *args)
140
+ return UnionChain.new(spawn) if opts == :chain
141
+ opts.nil? ? self : spawn.union!(opts, *args, chain_method: __callee__)
142
+ end
143
+
144
+ (UNIONIZE_METHODS + UNION_RELATION_METHODS).each do |union_method|
145
+ next if union_method == :union
146
+ alias_method union_method, :union
147
+ end
148
+
149
+ def union!(opts = :chain, *args, chain_method: :union)
150
+ union_chain = UnionChain.new(self)
151
+ chain_method ||= :union
152
+ return union_chain if opts == :chain
153
+
154
+ union_chain.public_send(chain_method, *([opts] + args))
155
+ end
156
+
157
+ # Will construct *Just* the union SQL statement that was been built thus far
158
+ def to_union_sql
159
+ return unless union_values?
160
+ apply_union_ordering(build_union_nodes!(false)).to_sql
161
+ end
162
+
163
+ def to_nice_union_sql(color = true)
164
+ return to_union_sql unless defined?(::Niceql)
165
+ ::Niceql::Prettifier.prettify_sql(to_union_sql, color)
166
+ end
167
+
168
+ protected
169
+
170
+ def build_unions(arel = @klass.arel_table)
171
+ return unless union_values?
172
+
173
+ union_nodes = apply_union_ordering(build_union_nodes!)
174
+ table_name = Arel::Nodes::SqlLiteral.new(unionized_name)
175
+ table_alias = arel.create_table_alias(arel.grouping(union_nodes), table_name)
176
+ arel.from(table_alias)
177
+ end
178
+
179
+ # Builds a set of nested nodes that union each other's results
180
+ #
181
+ # Note: Order of chained unions *DOES* matter
182
+ #
183
+ # Example:
184
+ #
185
+ # User.union(User.select(:id).where(id: 8))
186
+ # .union(User.select(:id).where(id: 50))
187
+ # .union.except(User.select(:id).where(id: 8))
188
+ #
189
+ # #=> [<#User id: 50]]
190
+ #
191
+ # ```sql
192
+ # SELECT users.*
193
+ # FROM(
194
+ # (
195
+ # (SELECT users.id FROM users WHERE id = 8)
196
+ # UNION
197
+ # (SELECT users.id FROM users WHERE id = 50)
198
+ # )
199
+ # EXCEPT
200
+ # (SELECT users.id FROM users WHERE id = 8)
201
+ # ) users;
202
+ # ```
203
+
204
+ def build_union_nodes!(raise_error = true)
205
+ unionize_error_or_warn!(raise_error)
206
+ union_values.each_with_index.inject(nil) do |union_node, (relation_node, index)|
207
+ next resolve_relation_node(relation_node) if union_node.nil?
208
+
209
+ operation = union_operations.fetch(index - 1, :union)
210
+ left = union_node
211
+ right = resolve_relation_node(relation_node)
212
+
213
+ case operation
214
+ when :union_all
215
+ Arel::Nodes::UnionAll.new(left, right)
216
+ when :except
217
+ Arel::Nodes::Except.new(left, right)
218
+ when :intersect
219
+ Arel::Nodes::Intersect.new(left, right)
220
+ else
221
+ Arel::Nodes::Union.new(left, right)
222
+ end
223
+ end
224
+ end
225
+
226
+ # Apply's the allowed ORDER BY to the end of the final union statement
227
+ #
228
+ # Note: This will only apply at the very end of the union statements. Not nested ones.
229
+ # (I guess you could double nest a union and apply it, but that would be dumb)
230
+ #
231
+ # Example:
232
+ # User.union(User.select(:id).where(id: 8))
233
+ # .union(User.select(:id).where(id: 50))
234
+ # .union.order(id: :desc)
235
+ # #=> [<#User id: 50>, <#User id: 8>]
236
+ #
237
+ # ```sql
238
+ # SELECT users.*
239
+ # FROM(
240
+ # (SELECT users.id FROM users WHERE id = 8)
241
+ # UNION
242
+ # (SELECT users.id FROM users WHERE id = 50)
243
+ # ORDER BY id DESC
244
+ # ) users;
245
+ # ```
246
+ #
247
+ def apply_union_ordering(union_nodes)
248
+ return union_nodes unless union_ordering_values?
249
+
250
+ # Sanitation check / resolver (ActiveRecord::Relation#preprocess_order_args)
251
+ preprocess_order_args(union_ordering_values)
252
+ union_ordering_values.uniq!
253
+ Arel::Nodes::InfixOperation.new("ORDER BY", union_nodes, union_ordering_values)
254
+ end
255
+
256
+ private
257
+
258
+ def unionize_error_or_warn!(raise_error = true)
259
+ if raise_error && union_values.size <= 1
260
+ raise ArgumentError, "You are required to provide 2 or more unions to join!"
261
+ elsif !raise_error && union_values.size <= 1
262
+ warn("Warning: You are required to provide 2 or more unions to join.")
263
+ end
264
+ end
265
+
266
+ def resolve_relation_node(relation_node)
267
+ case relation_node
268
+ when String
269
+ Arel::Nodes::Grouping.new(Arel.sql(relation_node))
270
+ else
271
+ relation_node.arel
272
+ end
273
+ end
274
+ end
275
+ end
276
+ end
277
+
278
+ ActiveRecord::Relation.prepend(ActiveRecordExtended::QueryMethods::Unionize)