active_record_extended 0.7.0 → 1.0.0

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.
@@ -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)