active_record_extended_telescope 2.0.1

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