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.
- checksums.yaml +4 -4
- data/README.md +508 -12
- data/lib/active_record_extended.rb +1 -1
- data/lib/active_record_extended/active_record.rb +9 -0
- data/lib/active_record_extended/active_record/relation_patch.rb +33 -0
- data/lib/active_record_extended/arel/nodes.rb +20 -0
- data/lib/active_record_extended/arel/visitors/postgresql_decorator.rb +13 -1
- data/lib/active_record_extended/query_methods/either.rb +1 -1
- data/lib/active_record_extended/query_methods/inet.rb +0 -18
- data/lib/active_record_extended/query_methods/json.rb +223 -0
- data/lib/active_record_extended/query_methods/unionize.rb +278 -0
- data/lib/active_record_extended/query_methods/where_chain.rb +1 -1
- data/lib/active_record_extended/query_methods/with_cte.rb +0 -18
- data/lib/active_record_extended/utilities.rb +141 -0
- data/lib/active_record_extended/version.rb +1 -1
- data/spec/query_methods/inet_query_spec.rb +0 -11
- data/spec/query_methods/json_spec.rb +142 -0
- data/spec/query_methods/unionize_spec.rb +165 -0
- data/spec/sql_inspections/json_sql_spec.rb +46 -0
- data/spec/sql_inspections/unionize_sql_spec.rb +124 -0
- data/spec/support/models.rb +28 -5
- metadata +22 -4
@@ -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
|
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 =
|
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)
|