active_record_extended_telescope 2.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +870 -0
  3. data/lib/active_record_extended.rb +10 -0
  4. data/lib/active_record_extended/active_record.rb +25 -0
  5. data/lib/active_record_extended/active_record/relation_patch.rb +50 -0
  6. data/lib/active_record_extended/arel.rb +7 -0
  7. data/lib/active_record_extended/arel/aggregate_function_name.rb +40 -0
  8. data/lib/active_record_extended/arel/nodes.rb +49 -0
  9. data/lib/active_record_extended/arel/predications.rb +50 -0
  10. data/lib/active_record_extended/arel/sql_literal.rb +16 -0
  11. data/lib/active_record_extended/arel/visitors/postgresql_decorator.rb +122 -0
  12. data/lib/active_record_extended/patch/5_1/where_clause.rb +11 -0
  13. data/lib/active_record_extended/patch/5_2/where_clause.rb +11 -0
  14. data/lib/active_record_extended/predicate_builder/array_handler_decorator.rb +20 -0
  15. data/lib/active_record_extended/query_methods/any_of.rb +93 -0
  16. data/lib/active_record_extended/query_methods/either.rb +62 -0
  17. data/lib/active_record_extended/query_methods/inet.rb +88 -0
  18. data/lib/active_record_extended/query_methods/json.rb +329 -0
  19. data/lib/active_record_extended/query_methods/select.rb +118 -0
  20. data/lib/active_record_extended/query_methods/unionize.rb +249 -0
  21. data/lib/active_record_extended/query_methods/where_chain.rb +132 -0
  22. data/lib/active_record_extended/query_methods/window.rb +93 -0
  23. data/lib/active_record_extended/query_methods/with_cte.rb +150 -0
  24. data/lib/active_record_extended/utilities/order_by.rb +77 -0
  25. data/lib/active_record_extended/utilities/support.rb +178 -0
  26. data/lib/active_record_extended/version.rb +5 -0
  27. data/lib/active_record_extended_telescope.rb +4 -0
  28. data/spec/active_record_extended_spec.rb +7 -0
  29. data/spec/query_methods/any_of_spec.rb +131 -0
  30. data/spec/query_methods/array_query_spec.rb +64 -0
  31. data/spec/query_methods/either_spec.rb +59 -0
  32. data/spec/query_methods/hash_query_spec.rb +45 -0
  33. data/spec/query_methods/inet_query_spec.rb +112 -0
  34. data/spec/query_methods/json_spec.rb +157 -0
  35. data/spec/query_methods/select_spec.rb +115 -0
  36. data/spec/query_methods/unionize_spec.rb +165 -0
  37. data/spec/query_methods/window_spec.rb +51 -0
  38. data/spec/query_methods/with_cte_spec.rb +50 -0
  39. data/spec/spec_helper.rb +28 -0
  40. data/spec/sql_inspections/any_of_sql_spec.rb +41 -0
  41. data/spec/sql_inspections/arel/aggregate_function_name_spec.rb +41 -0
  42. data/spec/sql_inspections/arel/array_spec.rb +63 -0
  43. data/spec/sql_inspections/arel/inet_spec.rb +66 -0
  44. data/spec/sql_inspections/contains_sql_queries_spec.rb +47 -0
  45. data/spec/sql_inspections/either_sql_spec.rb +55 -0
  46. data/spec/sql_inspections/json_sql_spec.rb +82 -0
  47. data/spec/sql_inspections/unionize_sql_spec.rb +124 -0
  48. data/spec/sql_inspections/window_sql_spec.rb +98 -0
  49. data/spec/sql_inspections/with_cte_sql_spec.rb +95 -0
  50. data/spec/support/database_cleaner.rb +15 -0
  51. data/spec/support/models.rb +68 -0
  52. metadata +245 -0
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordExtended
4
+ module QueryMethods
5
+ module Select
6
+ class SelectHelper
7
+ include ::ActiveRecordExtended::Utilities::Support
8
+ include ::ActiveRecordExtended::Utilities::OrderBy
9
+
10
+ AGGREGATE_ONE_LINERS = /^(exists|sum|max|min|avg|count|jsonb?_agg|(bit|bool)_(and|or)|xmlagg|array_agg)$/.freeze
11
+
12
+ def initialize(scope)
13
+ @scope = scope
14
+ end
15
+
16
+ def build_foster_select(*args)
17
+ flatten_safely(args).each do |select_arg|
18
+ case select_arg
19
+ when String, Symbol
20
+ select!(select_arg)
21
+ when Hash
22
+ select_arg.each_pair do |alias_name, options_or_column|
23
+ case options_or_column
24
+ when Array
25
+ process_array!(options_or_column, alias_name)
26
+ when Hash
27
+ process_hash!(options_or_column, alias_name)
28
+ else
29
+ select!(options_or_column, alias_name)
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ # Assumes that the first element in the array is the source/target column.
39
+ # Example
40
+ # process_array_options!([:col_name], :my_alias_name)
41
+ # #=> SELECT ([:col_name:]) AS "my_alias_name", ...
42
+ def process_array!(array_of_options, alias_name)
43
+ options = array_of_options.detect { |opts| opts.is_a?(Hash) }
44
+ query = { __select_statement: array_of_options.first }
45
+ query.merge!(options) unless options.nil?
46
+ process_hash!(query, alias_name)
47
+ end
48
+
49
+ # Processes options that come in as Hash elements
50
+ # Examples:
51
+ # process_hash_options!({ memberships: :price, cast_with: :agg_array_distinct }, :past_purchases)
52
+ # #=> SELECT (ARRAY_AGG(DISTINCT members.price)) AS past_purchases, ...
53
+ def process_hash!(hash_of_options, alias_name)
54
+ enforced_options = {
55
+ cast_with: hash_of_options[:cast_with],
56
+ order_by: hash_of_options[:order_by],
57
+ distinct: !(!hash_of_options[:distinct])
58
+ }
59
+ query_statement = hash_to_dot_notation(hash_of_options[:__select_statement] || hash_of_options.first)
60
+ select!(query_statement, alias_name, **enforced_options)
61
+ end
62
+
63
+ # Turn a hash chain into a query statement:
64
+ # Example: hash_to_dot_notation(table_name: :col_name) #=> "table_name.col_name"
65
+ def hash_to_dot_notation(column)
66
+ case column
67
+ when Hash, Array
68
+ column.to_a.flat_map { |col| hash_to_dot_notation(col) }.join(".")
69
+ when String, Symbol
70
+ /^([[:alpha:]]+)$/.match?(column.to_s) ? double_quote(column) : column
71
+ else
72
+ column
73
+ end
74
+ end
75
+
76
+ # Add's select statement values to the current relation, select statement lists
77
+ def select!(query, alias_name = nil, **options)
78
+ pipe_cte_with!(query)
79
+ @scope._select!(to_casted_query(query, alias_name, **options))
80
+ end
81
+
82
+ # Wraps the query with the requested query method
83
+ # Example:
84
+ # to_casted_query("memberships.cost", :total_revenue, :sum)
85
+ # #=> SELECT (SUM(memberships.cost)) AS total_revenue
86
+ def to_casted_query(query, alias_name, **options)
87
+ cast_with = options[:cast_with].to_s.downcase
88
+ order_expr = order_by_expression(options[:order_by])
89
+ distinct = cast_with.chomp!("_distinct") || options[:distinct] # account for [:agg_name:]_distinct
90
+
91
+ case cast_with
92
+ when "array", "true"
93
+ wrap_with_array(query, alias_name)
94
+ when AGGREGATE_ONE_LINERS
95
+ expr = to_sql_array(query) { |value| group_when_needed(value) }
96
+ casted_query = ::Arel::Nodes::AggregateFunctionName.new(cast_with, expr, distinct).order_by(order_expr)
97
+ nested_alias_escape(casted_query, alias_name)
98
+ else
99
+ alias_name.presence ? nested_alias_escape(query, alias_name) : query
100
+ end
101
+ end
102
+ end
103
+
104
+ def foster_select(*args)
105
+ raise ArgumentError.new("Call `.forster_select' with at least one field") if args.empty?
106
+
107
+ spawn._foster_select!(*args)
108
+ end
109
+
110
+ def _foster_select!(*args)
111
+ SelectHelper.new(self).build_foster_select(*args)
112
+ self
113
+ end
114
+ end
115
+ end
116
+ end
117
+
118
+ ActiveRecord::Relation.prepend(ActiveRecordExtended::QueryMethods::Select)
@@ -0,0 +1,249 @@
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::Support
11
+ include ::ActiveRecordExtended::Utilities::OrderBy
12
+
13
+ def initialize(scope)
14
+ @scope = scope
15
+ end
16
+
17
+ def as(from_clause_name)
18
+ @scope.unionized_name = from_clause_name.to_s
19
+ @scope
20
+ end
21
+ alias union_as as
22
+
23
+ def order(*ordering_args)
24
+ process_ordering_arguments!(ordering_args)
25
+ @scope.union_ordering_values += ordering_args
26
+ @scope
27
+ end
28
+ alias order_union order
29
+
30
+ def reorder(*ordering_args)
31
+ @scope.union_ordering_values.clear
32
+ order(*ordering_args)
33
+ end
34
+ alias reorder_union reorder
35
+
36
+ def union(*args)
37
+ append_union_order!(:union, args)
38
+ @scope
39
+ end
40
+
41
+ def all(*args)
42
+ append_union_order!(:union_all, args)
43
+ @scope
44
+ end
45
+ alias union_all all
46
+
47
+ def except(*args)
48
+ append_union_order!(:except, args)
49
+ @scope
50
+ end
51
+ alias union_except except
52
+
53
+ def intersect(*args)
54
+ append_union_order!(:intersect, args)
55
+ @scope
56
+ end
57
+ alias union_intersect intersect
58
+
59
+ protected
60
+
61
+ def append_union_order!(union_type, args)
62
+ args.each { |arg| pipe_cte_with!(arg) }
63
+ flatten_scopes = flatten_to_sql(args)
64
+ @scope.union_values += flatten_scopes
65
+ calculate_union_operation!(union_type, flatten_scopes.size)
66
+ end
67
+
68
+ def calculate_union_operation!(union_type, scope_count)
69
+ scope_count -= 1 unless @scope.union_operations?
70
+ scope_count = 1 if scope_count <= 0 && @scope.union_values.size <= 1
71
+ @scope.union_operations += [union_type] * scope_count
72
+ end
73
+ end
74
+
75
+ def unionize_storage
76
+ @values.fetch(:unionize, {})
77
+ end
78
+
79
+ def unionize_storage!
80
+ @values[:unionize] ||= {
81
+ union_values: [],
82
+ union_operations: [],
83
+ union_ordering_values: [],
84
+ unionized_name: nil
85
+ }
86
+ end
87
+
88
+ {
89
+ union_values: Array,
90
+ union_operations: Array,
91
+ union_ordering_values: Array,
92
+ unionized_name: lambda { |klass| klass.arel_table.name }
93
+ }.each_pair do |method_name, default|
94
+ define_method(method_name) do
95
+ return unionize_storage[method_name] if send("#{method_name}?")
96
+
97
+ (default.is_a?(Proc) ? default.call(@klass) : default.new)
98
+ end
99
+
100
+ define_method("#{method_name}?") do
101
+ unionize_storage.key?(method_name) && !unionize_storage[method_name].presence.nil?
102
+ end
103
+
104
+ define_method("#{method_name}=") do |value|
105
+ unionize_storage![method_name] = value
106
+ end
107
+ end
108
+
109
+ def union(opts = :chain, *args)
110
+ return UnionChain.new(spawn) if opts == :chain
111
+
112
+ opts.nil? ? self : spawn.union!(opts, *args, chain_method: __callee__)
113
+ end
114
+
115
+ (UNIONIZE_METHODS + UNION_RELATION_METHODS).each do |union_method|
116
+ next if union_method == :union
117
+
118
+ alias_method union_method, :union
119
+ end
120
+
121
+ def union!(opts = :chain, *args, chain_method: :union)
122
+ union_chain = UnionChain.new(self)
123
+ chain_method ||= :union
124
+ return union_chain if opts == :chain
125
+
126
+ union_chain.public_send(chain_method, *([opts] + args))
127
+ end
128
+
129
+ # Will construct *Just* the union SQL statement that was been built thus far
130
+ def to_union_sql
131
+ return unless union_values?
132
+
133
+ apply_union_ordering(build_union_nodes!(false)).to_sql
134
+ end
135
+
136
+ def to_nice_union_sql(color = true)
137
+ return to_union_sql unless defined?(::Niceql)
138
+
139
+ ::Niceql::Prettifier.prettify_sql(to_union_sql, color)
140
+ end
141
+
142
+ protected
143
+
144
+ def build_unions(arel = @klass.arel_table)
145
+ return unless union_values?
146
+
147
+ union_nodes = apply_union_ordering(build_union_nodes!)
148
+ table_name = Arel.sql(unionized_name)
149
+ table_alias = arel.create_table_alias(arel.grouping(union_nodes), table_name)
150
+ arel.from(table_alias)
151
+ end
152
+
153
+ # Builds a set of nested nodes that union each other's results
154
+ #
155
+ # Note: Order of chained unions *DOES* matter
156
+ #
157
+ # Example:
158
+ #
159
+ # User.union(User.select(:id).where(id: 8))
160
+ # .union(User.select(:id).where(id: 50))
161
+ # .union.except(User.select(:id).where(id: 8))
162
+ #
163
+ # #=> [<#User id: 50]]
164
+ #
165
+ # ```sql
166
+ # SELECT users.*
167
+ # FROM(
168
+ # (
169
+ # (SELECT users.id FROM users WHERE id = 8)
170
+ # UNION
171
+ # (SELECT users.id FROM users WHERE id = 50)
172
+ # )
173
+ # EXCEPT
174
+ # (SELECT users.id FROM users WHERE id = 8)
175
+ # ) users;
176
+ # ```
177
+
178
+ def build_union_nodes!(raise_error = true)
179
+ unionize_error_or_warn!(raise_error)
180
+ union_values.each_with_index.reduce(nil) do |union_node, (relation_node, index)|
181
+ next resolve_relation_node(relation_node) if union_node.nil?
182
+
183
+ operation = union_operations.fetch(index - 1, :union)
184
+ left = union_node
185
+ right = resolve_relation_node(relation_node)
186
+
187
+ case operation
188
+ when :union_all
189
+ Arel::Nodes::UnionAll.new(left, right)
190
+ when :except
191
+ Arel::Nodes::Except.new(left, right)
192
+ when :intersect
193
+ Arel::Nodes::Intersect.new(left, right)
194
+ else
195
+ Arel::Nodes::Union.new(left, right)
196
+ end
197
+ end
198
+ end
199
+
200
+ # Apply's the allowed ORDER BY to the end of the final union statement
201
+ #
202
+ # Note: This will only apply at the very end of the union statements. Not nested ones.
203
+ # (I guess you could double nest a union and apply it, but that would be dumb)
204
+ #
205
+ # Example:
206
+ # User.union(User.select(:id).where(id: 8))
207
+ # .union(User.select(:id).where(id: 50))
208
+ # .union.order(id: :desc)
209
+ # #=> [<#User id: 50>, <#User id: 8>]
210
+ #
211
+ # ```sql
212
+ # SELECT users.*
213
+ # FROM(
214
+ # (SELECT users.id FROM users WHERE id = 8)
215
+ # UNION
216
+ # (SELECT users.id FROM users WHERE id = 50)
217
+ # ORDER BY id DESC
218
+ # ) users;
219
+ # ```
220
+ #
221
+ def apply_union_ordering(union_nodes)
222
+ return union_nodes unless union_ordering_values?
223
+
224
+ UnionChain.new(self).inline_order_by(union_nodes, union_ordering_values)
225
+ end
226
+
227
+ private
228
+
229
+ def unionize_error_or_warn!(raise_error = true)
230
+ if raise_error && union_values.size <= 1
231
+ raise ArgumentError.new("You are required to provide 2 or more unions to join!")
232
+ elsif !raise_error && union_values.size <= 1
233
+ warn("Warning: You are required to provide 2 or more unions to join.")
234
+ end
235
+ end
236
+
237
+ def resolve_relation_node(relation_node)
238
+ case relation_node
239
+ when String
240
+ Arel::Nodes::Grouping.new(Arel.sql(relation_node))
241
+ else
242
+ relation_node.arel
243
+ end
244
+ end
245
+ end
246
+ end
247
+ end
248
+
249
+ ActiveRecord::Relation.prepend(ActiveRecordExtended::QueryMethods::Unionize)
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordExtended
4
+ module WhereChain
5
+ # Finds Records that have an array column that contain any a set of values
6
+ # User.where.overlap(tags: [1,2])
7
+ # # SELECT * FROM users WHERE tags && {1,2}
8
+ def overlaps(opts, *rest)
9
+ substitute_comparisons(opts, rest, Arel::Nodes::Overlaps, "overlap")
10
+ end
11
+ alias overlap overlaps
12
+
13
+ # Finds Records that contain an element in an array column
14
+ # User.where.any(tags: 3)
15
+ # # SELECT user.* FROM user WHERE 3 = ANY(user.tags)
16
+ def any(opts, *rest)
17
+ equality_to_function("ANY", opts, rest)
18
+ end
19
+
20
+ # Finds Records that contain a single matchable array element
21
+ # User.where.all(tags: 3)
22
+ # # SELECT user.* FROM user WHERE 3 = ALL(user.tags)
23
+ def all(opts, *rest)
24
+ equality_to_function("ALL", opts, rest)
25
+ end
26
+
27
+ # Finds Records that contains a nested set elements
28
+ #
29
+ # Array Column Type:
30
+ # User.where.contains(tags: [1, 3])
31
+ # # SELECT user.* FROM user WHERE user.tags @> {1,3}
32
+ #
33
+ # HStore Column Type:
34
+ # User.where.contains(data: { nickname: 'chainer' })
35
+ # # SELECT user.* FROM user WHERE user.data @> 'nickname' => 'chainer'
36
+ #
37
+ # JSONB Column Type:
38
+ # User.where.contains(data: { nickname: 'chainer' })
39
+ # # SELECT user.* FROM user WHERE user.data @> {'nickname': 'chainer'}
40
+ #
41
+ # This can also be used along side joined tables
42
+ #
43
+ # JSONB Column Type Example:
44
+ # Tag.joins(:user).where.contains(user: { data: { nickname: 'chainer' } })
45
+ # # SELECT tags.* FROM tags INNER JOIN user on user.id = tags.user_id WHERE user.data @> { nickname: 'chainer' }
46
+ #
47
+ def contains(opts, *rest)
48
+ build_where_chain(opts, rest) do |arel|
49
+ case arel
50
+ when Arel::Nodes::In, Arel::Nodes::Equality
51
+ column = left_column(arel) || column_from_association(arel)
52
+
53
+ if [:hstore, :jsonb].include?(column.type)
54
+ Arel::Nodes::ContainsHStore.new(arel.left, arel.right)
55
+ elsif column.try(:array)
56
+ Arel::Nodes::ContainsArray.new(arel.left, arel.right)
57
+ else
58
+ raise ArgumentError.new("Invalid argument for .where.contains(), got #{arel.class}")
59
+ end
60
+ else
61
+ raise ArgumentError.new("Invalid argument for .where.contains(), got #{arel.class}")
62
+ end
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ def matchable_column?(col, arel)
69
+ col.name == arel.left.name.to_s || col.name == arel.left.relation.name.to_s
70
+ end
71
+
72
+ def column_from_association(arel)
73
+ assoc = assoc_from_related_table(arel)
74
+ assoc.klass.columns.detect { |col| matchable_column?(col, arel) } if assoc
75
+ end
76
+
77
+ def assoc_from_related_table(arel)
78
+ @scope.klass.reflect_on_association(arel.left.relation.name.to_sym) ||
79
+ @scope.klass.reflect_on_association(arel.left.relation.name.singularize.to_sym)
80
+ end
81
+
82
+ def left_column(arel)
83
+ @scope.klass.columns_hash[arel.left.name] || @scope.klass.columns_hash[arel.left.relation.name]
84
+ end
85
+
86
+ def equality_to_function(function_name, opts, rest)
87
+ build_where_chain(opts, rest) do |arel|
88
+ case arel
89
+ when Arel::Nodes::Equality
90
+ Arel::Nodes::Equality.new(arel.right, Arel::Nodes::NamedFunction.new(function_name, [arel.left]))
91
+ else
92
+ raise ArgumentError.new("Invalid argument for .where.#{function_name.downcase}(), got #{arel.class}")
93
+ end
94
+ end
95
+ end
96
+
97
+ def substitute_comparisons(opts, rest, arel_node_class, method)
98
+ build_where_chain(opts, rest) do |arel|
99
+ case arel
100
+ when Arel::Nodes::In, Arel::Nodes::Equality
101
+ arel_node_class.new(arel.left, arel.right)
102
+ else
103
+ raise ArgumentError.new("Invalid argument for .where.#{method}(), got #{arel.class}")
104
+ end
105
+ end
106
+ end
107
+
108
+ def build_where_clause_for(scope, opts, rest)
109
+ if ActiveRecord::VERSION::MAJOR == 6 && ActiveRecord::VERSION::MINOR == 1
110
+ scope.send(:build_where_clause, opts, rest)
111
+ else
112
+ scope.send(:where_clause_factory).build(opts, rest)
113
+ end
114
+ end
115
+ end
116
+ end
117
+
118
+ module ActiveRecord
119
+ module QueryMethods
120
+ class WhereChain
121
+ prepend ActiveRecordExtended::WhereChain
122
+
123
+ def build_where_chain(opts, rest, &block)
124
+ where_clause = build_where_clause_for(@scope, opts, rest)
125
+ @scope.tap do |scope|
126
+ scope.references!(PredicateBuilder.references(opts.stringify_keys)) if opts.is_a?(Hash)
127
+ scope.where_clause += where_clause.modified_predicates(&block)
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end