active_record_extended 1.2.0 → 2.0.3

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +85 -7
  3. data/lib/active_record_extended/active_record.rb +2 -11
  4. data/lib/active_record_extended/active_record/relation_patch.rb +21 -4
  5. data/lib/active_record_extended/arel.rb +1 -0
  6. data/lib/active_record_extended/arel/nodes.rb +24 -21
  7. data/lib/active_record_extended/arel/predications.rb +3 -2
  8. data/lib/active_record_extended/arel/sql_literal.rb +16 -0
  9. data/lib/active_record_extended/arel/visitors/postgresql_decorator.rb +1 -1
  10. data/lib/active_record_extended/query_methods/any_of.rb +5 -4
  11. data/lib/active_record_extended/query_methods/either.rb +2 -1
  12. data/lib/active_record_extended/query_methods/inet.rb +6 -2
  13. data/lib/active_record_extended/query_methods/json.rb +14 -17
  14. data/lib/active_record_extended/query_methods/select.rb +13 -12
  15. data/lib/active_record_extended/query_methods/unionize.rb +13 -7
  16. data/lib/active_record_extended/query_methods/where_chain.rb +17 -8
  17. data/lib/active_record_extended/query_methods/window.rb +93 -0
  18. data/lib/active_record_extended/query_methods/with_cte.rb +104 -37
  19. data/lib/active_record_extended/utilities/order_by.rb +11 -30
  20. data/lib/active_record_extended/utilities/support.rb +21 -18
  21. data/lib/active_record_extended/version.rb +1 -1
  22. data/spec/query_methods/any_of_spec.rb +2 -2
  23. data/spec/query_methods/either_spec.rb +11 -0
  24. data/spec/query_methods/json_spec.rb +5 -5
  25. data/spec/query_methods/select_spec.rb +13 -13
  26. data/spec/query_methods/unionize_spec.rb +5 -5
  27. data/spec/query_methods/window_spec.rb +51 -0
  28. data/spec/query_methods/with_cte_spec.rb +12 -2
  29. data/spec/spec_helper.rb +1 -1
  30. data/spec/sql_inspections/any_of_sql_spec.rb +2 -2
  31. data/spec/sql_inspections/contains_sql_queries_spec.rb +8 -8
  32. data/spec/sql_inspections/either_sql_spec.rb +19 -3
  33. data/spec/sql_inspections/json_sql_spec.rb +7 -1
  34. data/spec/sql_inspections/unionize_sql_spec.rb +2 -2
  35. data/spec/sql_inspections/window_sql_spec.rb +98 -0
  36. data/spec/sql_inspections/with_cte_sql_spec.rb +30 -1
  37. data/spec/support/models.rb +18 -0
  38. metadata +23 -20
  39. data/lib/active_record_extended/patch/5_0/predicate_builder_decorator.rb +0 -87
  40. data/lib/active_record_extended/patch/5_0/regex_match.rb +0 -10
@@ -52,12 +52,12 @@ module ActiveRecordExtended
52
52
  # #=> SELECT (ARRAY_AGG(DISTINCT members.price)) AS past_purchases, ...
53
53
  def process_hash!(hash_of_options, alias_name)
54
54
  enforced_options = {
55
- cast_with: hash_of_options.delete(:cast_with),
56
- order_by: hash_of_options.delete(:order_by),
57
- distinct: !(!hash_of_options.delete(:distinct)),
55
+ cast_with: hash_of_options[:cast_with],
56
+ order_by: hash_of_options[:order_by],
57
+ distinct: !(!hash_of_options[:distinct])
58
58
  }
59
- query_statement = hash_to_dot_notation(hash_of_options.delete(:__select_statement) || hash_of_options.first)
60
- select!(query_statement, alias_name, enforced_options)
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
61
  end
62
62
 
63
63
  # Turn a hash chain into a query statement:
@@ -65,7 +65,7 @@ module ActiveRecordExtended
65
65
  def hash_to_dot_notation(column)
66
66
  case column
67
67
  when Hash, Array
68
- column.to_a.flat_map(&method(:hash_to_dot_notation)).join(".")
68
+ column.to_a.flat_map { |col| hash_to_dot_notation(col) }.join(".")
69
69
  when String, Symbol
70
70
  /^([[:alpha:]]+)$/.match?(column.to_s) ? double_quote(column) : column
71
71
  else
@@ -76,7 +76,7 @@ module ActiveRecordExtended
76
76
  # Add's select statement values to the current relation, select statement lists
77
77
  def select!(query, alias_name = nil, **options)
78
78
  pipe_cte_with!(query)
79
- @scope._select!(to_casted_query(query, alias_name, options))
79
+ @scope._select!(to_casted_query(query, alias_name, **options))
80
80
  end
81
81
 
82
82
  # Wraps the query with the requested query method
@@ -84,15 +84,15 @@ module ActiveRecordExtended
84
84
  # to_casted_query("memberships.cost", :total_revenue, :sum)
85
85
  # #=> SELECT (SUM(memberships.cost)) AS total_revenue
86
86
  def to_casted_query(query, alias_name, **options)
87
- cast_with = options.delete(:cast_with).to_s.downcase
88
- order_expr = order_by_expression(options.delete(:order_by))
89
- distinct = cast_with.chomp!("_distinct") || options.delete(:distinct) # account for [:agg_name:]_distinct
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
90
 
91
91
  case cast_with
92
92
  when "array", "true"
93
93
  wrap_with_array(query, alias_name)
94
94
  when AGGREGATE_ONE_LINERS
95
- expr = to_sql_array(query, &method(:group_when_needed))
95
+ expr = to_sql_array(query) { |value| group_when_needed(value) }
96
96
  casted_query = ::Arel::Nodes::AggregateFunctionName.new(cast_with, expr, distinct).order_by(order_expr)
97
97
  nested_alias_escape(casted_query, alias_name)
98
98
  else
@@ -102,7 +102,8 @@ module ActiveRecordExtended
102
102
  end
103
103
 
104
104
  def foster_select(*args)
105
- raise ArgumentError, "Call `.forster_select' with at least one field" if args.empty?
105
+ raise ArgumentError.new("Call `.forster_select' with at least one field") if args.empty?
106
+
106
107
  spawn._foster_select!(*args)
107
108
  end
108
109
 
@@ -59,7 +59,7 @@ module ActiveRecordExtended
59
59
  protected
60
60
 
61
61
  def append_union_order!(union_type, args)
62
- args.each(&method(:pipe_cte_with!))
62
+ args.each { |arg| pipe_cte_with!(arg) }
63
63
  flatten_scopes = flatten_to_sql(args)
64
64
  @scope.union_values += flatten_scopes
65
65
  calculate_union_operation!(union_type, flatten_scopes.size)
@@ -81,7 +81,7 @@ module ActiveRecordExtended
81
81
  union_values: [],
82
82
  union_operations: [],
83
83
  union_ordering_values: [],
84
- unionized_name: nil,
84
+ unionized_name: nil
85
85
  }
86
86
  end
87
87
 
@@ -89,10 +89,11 @@ module ActiveRecordExtended
89
89
  union_values: Array,
90
90
  union_operations: Array,
91
91
  union_ordering_values: Array,
92
- unionized_name: lambda { |klass| klass.arel_table.name },
92
+ unionized_name: lambda { |klass| klass.arel_table.name }
93
93
  }.each_pair do |method_name, default|
94
94
  define_method(method_name) do
95
95
  return unionize_storage[method_name] if send("#{method_name}?")
96
+
96
97
  (default.is_a?(Proc) ? default.call(@klass) : default.new)
97
98
  end
98
99
 
@@ -106,19 +107,21 @@ module ActiveRecordExtended
106
107
  end
107
108
 
108
109
  def union(opts = :chain, *args)
109
- return UnionChain.new(spawn) if opts == :chain
110
+ return UnionChain.new(spawn) if :chain == opts
111
+
110
112
  opts.nil? ? self : spawn.union!(opts, *args, chain_method: __callee__)
111
113
  end
112
114
 
113
115
  (UNIONIZE_METHODS + UNION_RELATION_METHODS).each do |union_method|
114
116
  next if union_method == :union
117
+
115
118
  alias_method union_method, :union
116
119
  end
117
120
 
118
121
  def union!(opts = :chain, *args, chain_method: :union)
119
122
  union_chain = UnionChain.new(self)
120
123
  chain_method ||= :union
121
- return union_chain if opts == :chain
124
+ return union_chain if :chain == opts
122
125
 
123
126
  union_chain.public_send(chain_method, *([opts] + args))
124
127
  end
@@ -126,11 +129,13 @@ module ActiveRecordExtended
126
129
  # Will construct *Just* the union SQL statement that was been built thus far
127
130
  def to_union_sql
128
131
  return unless union_values?
132
+
129
133
  apply_union_ordering(build_union_nodes!(false)).to_sql
130
134
  end
131
135
 
132
136
  def to_nice_union_sql(color = true)
133
137
  return to_union_sql unless defined?(::Niceql)
138
+
134
139
  ::Niceql::Prettifier.prettify_sql(to_union_sql, color)
135
140
  end
136
141
 
@@ -172,7 +177,7 @@ module ActiveRecordExtended
172
177
 
173
178
  def build_union_nodes!(raise_error = true)
174
179
  unionize_error_or_warn!(raise_error)
175
- union_values.each_with_index.inject(nil) do |union_node, (relation_node, index)|
180
+ union_values.each_with_index.reduce(nil) do |union_node, (relation_node, index)|
176
181
  next resolve_relation_node(relation_node) if union_node.nil?
177
182
 
178
183
  operation = union_operations.fetch(index - 1, :union)
@@ -215,6 +220,7 @@ module ActiveRecordExtended
215
220
  #
216
221
  def apply_union_ordering(union_nodes)
217
222
  return union_nodes unless union_ordering_values?
223
+
218
224
  UnionChain.new(self).inline_order_by(union_nodes, union_ordering_values)
219
225
  end
220
226
 
@@ -222,7 +228,7 @@ module ActiveRecordExtended
222
228
 
223
229
  def unionize_error_or_warn!(raise_error = true)
224
230
  if raise_error && union_values.size <= 1
225
- raise ArgumentError, "You are required to provide 2 or more unions to join!"
231
+ raise ArgumentError.new("You are required to provide 2 or more unions to join!")
226
232
  elsif !raise_error && union_values.size <= 1
227
233
  warn("Warning: You are required to provide 2 or more unions to join.")
228
234
  end
@@ -5,9 +5,10 @@ module ActiveRecordExtended
5
5
  # Finds Records that have an array column that contain any a set of values
6
6
  # User.where.overlap(tags: [1,2])
7
7
  # # SELECT * FROM users WHERE tags && {1,2}
8
- def overlap(opts, *rest)
9
- substitute_comparisons(opts, rest, Arel::Nodes::Overlap, "overlap")
8
+ def overlaps(opts, *rest)
9
+ substitute_comparisons(opts, rest, Arel::Nodes::Overlaps, "overlap")
10
10
  end
11
+ alias overlap overlaps
11
12
 
12
13
  # Finds Records that contain an element in an array column
13
14
  # User.where.any(tags: 3)
@@ -54,10 +55,10 @@ module ActiveRecordExtended
54
55
  elsif column.try(:array)
55
56
  Arel::Nodes::ContainsArray.new(arel.left, arel.right)
56
57
  else
57
- raise ArgumentError, "Invalid argument for .where.contains(), got #{arel.class}"
58
+ raise ArgumentError.new("Invalid argument for .where.contains(), got #{arel.class}")
58
59
  end
59
60
  else
60
- raise ArgumentError, "Invalid argument for .where.contains(), got #{arel.class}"
61
+ raise ArgumentError.new("Invalid argument for .where.contains(), got #{arel.class}")
61
62
  end
62
63
  end
63
64
  end
@@ -88,7 +89,7 @@ module ActiveRecordExtended
88
89
  when Arel::Nodes::Equality
89
90
  Arel::Nodes::Equality.new(arel.right, Arel::Nodes::NamedFunction.new(function_name, [arel.left]))
90
91
  else
91
- raise ArgumentError, "Invalid argument for .where.#{function_name.downcase}(), got #{arel.class}"
92
+ raise ArgumentError.new("Invalid argument for .where.#{function_name.downcase}(), got #{arel.class}")
92
93
  end
93
94
  end
94
95
  end
@@ -99,10 +100,18 @@ module ActiveRecordExtended
99
100
  when Arel::Nodes::In, Arel::Nodes::Equality
100
101
  arel_node_class.new(arel.left, arel.right)
101
102
  else
102
- raise ArgumentError, "Invalid argument for .where.#{method}(), got #{arel.class}"
103
+ raise ArgumentError.new("Invalid argument for .where.#{method}(), got #{arel.class}")
103
104
  end
104
105
  end
105
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
106
115
  end
107
116
  end
108
117
 
@@ -112,9 +121,9 @@ module ActiveRecord
112
121
  prepend ActiveRecordExtended::WhereChain
113
122
 
114
123
  def build_where_chain(opts, rest, &block)
115
- where_clause = @scope.send(:where_clause_factory).build(opts, rest)
124
+ where_clause = build_where_clause_for(@scope, opts, rest)
116
125
  @scope.tap do |scope|
117
- scope.references!(PredicateBuilder.references(opts)) if opts.is_a?(Hash)
126
+ scope.references!(PredicateBuilder.references(opts.stringify_keys)) if opts.is_a?(Hash)
118
127
  scope.where_clause += where_clause.modified_predicates(&block)
119
128
  end
120
129
  end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordExtended
4
+ module QueryMethods
5
+ module Window
6
+ class DefineWindowChain
7
+ include ::ActiveRecordExtended::Utilities::Support
8
+ include ::ActiveRecordExtended::Utilities::OrderBy
9
+
10
+ def initialize(scope, window_name)
11
+ @scope = scope
12
+ @window_name = window_name
13
+ end
14
+
15
+ def partition_by(*partitions, order_by: nil)
16
+ @scope.window_values! << {
17
+ window_name: to_arel_sql(@window_name),
18
+ partition_by: flatten_to_sql(partitions),
19
+ order_by: order_by_expression(order_by)
20
+ }
21
+
22
+ @scope
23
+ end
24
+ end
25
+
26
+ class WindowSelectBuilder
27
+ include ::ActiveRecordExtended::Utilities::Support
28
+
29
+ def initialize(window_function, args, window_name)
30
+ @window_function = window_function
31
+ @win_args = to_sql_array(args)
32
+ @over = to_arel_sql(window_name)
33
+ end
34
+
35
+ def build_select(alias_name = nil)
36
+ window_arel = generate_named_function(@window_function, *@win_args).over(@over)
37
+
38
+ if alias_name.nil?
39
+ window_arel
40
+ else
41
+ nested_alias_escape(window_arel, alias_name)
42
+ end
43
+ end
44
+ end
45
+
46
+ def window_values
47
+ @values.fetch(:window, [])
48
+ end
49
+
50
+ def window_values!
51
+ @values[:window] ||= []
52
+ end
53
+
54
+ def window_values?
55
+ !window_values.empty?
56
+ end
57
+
58
+ def window_values=(*values)
59
+ @values[:window] = values.flatten(1)
60
+ end
61
+
62
+ def define_window(name)
63
+ spawn.define_window!(name)
64
+ end
65
+
66
+ def define_window!(name)
67
+ DefineWindowChain.new(self, name)
68
+ end
69
+
70
+ def select_window(window_function, *args, over:, as: nil)
71
+ spawn.select_window!(window_function, args, over: over, as: as)
72
+ end
73
+
74
+ def select_window!(window_function, *args, over:, as: nil)
75
+ args.flatten!
76
+ args.compact!
77
+
78
+ select_statement = WindowSelectBuilder.new(window_function, args, over).build_select(as)
79
+ _select!(select_statement)
80
+ end
81
+
82
+ def build_windows(arel)
83
+ window_values.each do |window_value|
84
+ window = arel.window(window_value[:window_name])
85
+ window = window.partition(window_value[:partition_by]) if window_value[:partition_by].present?
86
+ window.order(window_value[:order_by]) if window_value[:order_by]
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+
93
+ ActiveRecord::Relation.prepend(ActiveRecordExtended::QueryMethods::Window)
@@ -3,78 +3,145 @@
3
3
  module ActiveRecordExtended
4
4
  module QueryMethods
5
5
  module WithCTE
6
- class WithChain
6
+ class WithCTE
7
+ include ::ActiveRecordExtended::Utilities::Support
8
+ include Enumerable
9
+ extend Forwardable
10
+
11
+ def_delegators :@with_values, :empty?, :blank?, :present?
12
+ attr_reader :with_values, :with_keys
13
+
14
+ # @param [ActiveRecord::Relation] scope
7
15
  def initialize(scope)
8
16
  @scope = scope
17
+ reset!
18
+ end
19
+
20
+ # @return [Enumerable] Returns the order for which CTE's were imported as.
21
+ def each
22
+ return to_enum(:each) unless block_given?
23
+
24
+ with_keys.each do |key|
25
+ yield(key, with_values[key])
26
+ end
27
+ end
28
+ alias each_pair each
29
+
30
+ # @param [Hash, WithCTE] value
31
+ def with_values=(value)
32
+ reset!
33
+ pipe_cte_with!(value)
9
34
  end
10
35
 
11
- def recursive(*args)
36
+ # @param [Hash, WithCTE] value
37
+ def pipe_cte_with!(value)
38
+ return if value.nil? || value.empty?
39
+
40
+ value.each_pair do |name, expression|
41
+ sym_name = name.to_sym
42
+ next if with_values.key?(sym_name)
43
+
44
+ # Ensure we follow FIFO pattern.
45
+ # If the parent has similar CTE alias keys, we want to favor the parent's expressions over its children's.
46
+ if expression.is_a?(ActiveRecord::Relation) && expression.with_values?
47
+ pipe_cte_with!(expression.cte)
48
+ expression.cte.reset!
49
+ end
50
+
51
+ @with_keys |= [sym_name]
52
+ @with_values[sym_name] = expression
53
+ end
54
+
55
+ value.reset! if value.is_a?(WithCTE)
56
+ end
57
+
58
+ def reset!
59
+ @with_keys = []
60
+ @with_values = {}
61
+ end
62
+ end
63
+
64
+ class WithChain
65
+ # @param [ActiveRecord::Relation] scope
66
+ def initialize(scope)
67
+ @scope = scope
68
+ @scope.cte ||= WithCTE.new(scope)
69
+ end
70
+
71
+ # @param [Hash, WithCTE] args
72
+ def recursive(args)
12
73
  @scope.tap do |scope|
13
- scope.with_values += args
14
74
  scope.recursive_value = true
75
+ scope.cte.pipe_cte_with!(args)
15
76
  end
16
77
  end
17
78
  end
18
79
 
19
- def with_values
20
- @values[:with] || []
80
+ # @return [WithCTE]
81
+ def cte
82
+ @values[:cte]
83
+ end
84
+
85
+ # @param [WithCTE] cte
86
+ def cte=(cte)
87
+ raise TypeError.new("Must be a WithCTE class type") unless cte.is_a?(WithCTE)
88
+
89
+ @values[:cte] = cte
21
90
  end
22
91
 
92
+ # @return [Boolean]
23
93
  def with_values?
24
- !(@values[:with].nil? || @values[:with].empty?)
94
+ !(cte.nil? || cte.empty?)
25
95
  end
26
96
 
97
+ # @param [Hash, WithCTE] values
27
98
  def with_values=(values)
28
- @values[:with] = values
99
+ cte.with_values = values
29
100
  end
30
101
 
102
+ # @param [Boolean] value
31
103
  def recursive_value=(value)
32
104
  raise ImmutableRelation if @loaded
105
+
33
106
  @values[:recursive] = value
34
107
  end
35
108
 
36
- def recursive_value
37
- @values[:recursive]
109
+ # @return [Boolean]
110
+ def recursive_value?
111
+ !(!@values[:recursive])
38
112
  end
39
- alias recursive_value? recursive_value
40
113
 
114
+ # @param [Hash, WithCTE] opts
41
115
  def with(opts = :chain, *rest)
42
- return WithChain.new(spawn) if opts == :chain
116
+ return WithChain.new(spawn) if :chain == opts
117
+
43
118
  opts.blank? ? self : spawn.with!(opts, *rest)
44
119
  end
45
120
 
46
- def with!(opts = :chain, *rest)
47
- return WithChain.new(self) if opts == :chain
48
- self.with_values += [opts] + rest
49
- self
50
- end
121
+ # @param [Hash, WithCTE] opts
122
+ def with!(opts = :chain, *_rest)
123
+ return WithChain.new(self) if :chain == opts
51
124
 
52
- def build_with_hashed_value(with_value)
53
- with_value.map do |name, expression|
54
- select =
55
- case expression
56
- when String
57
- Arel.sql("(#{expression})")
58
- when ActiveRecord::Relation, Arel::SelectManager
59
- Arel.sql("(#{expression.to_sql})")
60
- end
61
- next if select.nil?
62
- Arel::Nodes::As.new(Arel.sql(PG::Connection.quote_ident(name.to_s)), select)
125
+ tap do |scope|
126
+ scope.cte ||= WithCTE.new(self)
127
+ scope.cte.pipe_cte_with!(opts)
63
128
  end
64
129
  end
65
130
 
66
131
  def build_with(arel)
67
- with_statements = with_values.flat_map do |with_value|
68
- case with_value
69
- when String, Arel::Nodes::As
70
- with_value
71
- when Hash
72
- build_with_hashed_value(with_value)
73
- end
74
- end.compact
132
+ return unless with_values?
133
+
134
+ cte_statements = cte.map do |name, expression|
135
+ grouped_expression = cte.generate_grouping(expression)
136
+ cte_name = cte.to_arel_sql(cte.double_quote(name.to_s))
137
+ Arel::Nodes::As.new(cte_name, grouped_expression)
138
+ end
75
139
 
76
- return if with_statements.empty?
77
- recursive_value? ? arel.with(:recursive, with_statements) : arel.with(with_statements)
140
+ if recursive_value?
141
+ arel.with(:recursive, cte_statements)
142
+ else
143
+ arel.with(cte_statements)
144
+ end
78
145
  end
79
146
  end
80
147
  end