active_record_extended 1.4.0 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +8 -7
  3. data/lib/active_record_extended/active_record.rb +1 -10
  4. data/lib/active_record_extended/active_record/relation_patch.rb +16 -1
  5. data/lib/active_record_extended/arel.rb +1 -0
  6. data/lib/active_record_extended/arel/nodes.rb +22 -21
  7. data/lib/active_record_extended/arel/sql_literal.rb +16 -0
  8. data/lib/active_record_extended/query_methods/any_of.rb +5 -4
  9. data/lib/active_record_extended/query_methods/either.rb +1 -1
  10. data/lib/active_record_extended/query_methods/inet.rb +6 -2
  11. data/lib/active_record_extended/query_methods/json.rb +13 -16
  12. data/lib/active_record_extended/query_methods/select.rb +11 -10
  13. data/lib/active_record_extended/query_methods/unionize.rb +10 -4
  14. data/lib/active_record_extended/query_methods/where_chain.rb +14 -6
  15. data/lib/active_record_extended/query_methods/window.rb +4 -3
  16. data/lib/active_record_extended/query_methods/with_cte.rb +102 -35
  17. data/lib/active_record_extended/utilities/order_by.rb +9 -28
  18. data/lib/active_record_extended/utilities/support.rb +8 -15
  19. data/lib/active_record_extended/version.rb +1 -1
  20. data/spec/query_methods/any_of_spec.rb +2 -2
  21. data/spec/query_methods/json_spec.rb +5 -5
  22. data/spec/query_methods/select_spec.rb +13 -13
  23. data/spec/query_methods/unionize_spec.rb +5 -5
  24. data/spec/query_methods/with_cte_spec.rb +12 -2
  25. data/spec/spec_helper.rb +1 -1
  26. data/spec/sql_inspections/any_of_sql_spec.rb +2 -2
  27. data/spec/sql_inspections/contains_sql_queries_spec.rb +8 -8
  28. data/spec/sql_inspections/either_sql_spec.rb +3 -3
  29. data/spec/sql_inspections/json_sql_spec.rb +0 -1
  30. data/spec/sql_inspections/unionize_sql_spec.rb +2 -2
  31. data/spec/sql_inspections/window_sql_spec.rb +12 -0
  32. data/spec/sql_inspections/with_cte_sql_spec.rb +30 -1
  33. metadata +18 -20
  34. data/lib/active_record_extended/patch/5_0/predicate_builder_decorator.rb +0 -87
  35. data/lib/active_record_extended/patch/5_0/regex_match.rb +0 -10
@@ -54,10 +54,10 @@ module ActiveRecordExtended
54
54
  elsif column.try(:array)
55
55
  Arel::Nodes::ContainsArray.new(arel.left, arel.right)
56
56
  else
57
- raise ArgumentError, "Invalid argument for .where.contains(), got #{arel.class}"
57
+ raise ArgumentError.new("Invalid argument for .where.contains(), got #{arel.class}")
58
58
  end
59
59
  else
60
- raise ArgumentError, "Invalid argument for .where.contains(), got #{arel.class}"
60
+ raise ArgumentError.new("Invalid argument for .where.contains(), got #{arel.class}")
61
61
  end
62
62
  end
63
63
  end
@@ -88,7 +88,7 @@ module ActiveRecordExtended
88
88
  when Arel::Nodes::Equality
89
89
  Arel::Nodes::Equality.new(arel.right, Arel::Nodes::NamedFunction.new(function_name, [arel.left]))
90
90
  else
91
- raise ArgumentError, "Invalid argument for .where.#{function_name.downcase}(), got #{arel.class}"
91
+ raise ArgumentError.new("Invalid argument for .where.#{function_name.downcase}(), got #{arel.class}")
92
92
  end
93
93
  end
94
94
  end
@@ -99,10 +99,18 @@ module ActiveRecordExtended
99
99
  when Arel::Nodes::In, Arel::Nodes::Equality
100
100
  arel_node_class.new(arel.left, arel.right)
101
101
  else
102
- raise ArgumentError, "Invalid argument for .where.#{method}(), got #{arel.class}"
102
+ raise ArgumentError.new("Invalid argument for .where.#{method}(), got #{arel.class}")
103
103
  end
104
104
  end
105
105
  end
106
+
107
+ def build_where_clause_for(scope, opts, rest)
108
+ if ActiveRecord::VERSION::MAJOR == 6 && ActiveRecord::VERSION::MINOR == 1
109
+ scope.send(:build_where_clause, opts, rest)
110
+ else
111
+ scope.send(:where_clause_factory).build(opts, rest)
112
+ end
113
+ end
106
114
  end
107
115
  end
108
116
 
@@ -112,9 +120,9 @@ module ActiveRecord
112
120
  prepend ActiveRecordExtended::WhereChain
113
121
 
114
122
  def build_where_chain(opts, rest, &block)
115
- where_clause = @scope.send(:where_clause_factory).build(opts, rest)
123
+ where_clause = build_where_clause_for(@scope, opts, rest)
116
124
  @scope.tap do |scope|
117
- scope.references!(PredicateBuilder.references(opts)) if opts.is_a?(Hash)
125
+ scope.references!(PredicateBuilder.references(opts.stringify_keys)) if opts.is_a?(Hash)
118
126
  scope.where_clause += where_clause.modified_predicates(&block)
119
127
  end
120
128
  end
@@ -16,7 +16,7 @@ module ActiveRecordExtended
16
16
  @scope.window_values! << {
17
17
  window_name: to_arel_sql(@window_name),
18
18
  partition_by: flatten_to_sql(partitions),
19
- order_by: order_by_expression(order_by),
19
+ order_by: order_by_expression(order_by)
20
20
  }
21
21
 
22
22
  @scope
@@ -81,8 +81,9 @@ module ActiveRecordExtended
81
81
 
82
82
  def build_windows(arel)
83
83
  window_values.each do |window_value|
84
- window = arel.window(window_value[:window_name]).partition(window_value[:partition_by])
85
- window.order(window_value[:order_by]) if window_value[:order_by]
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]
86
87
  end
87
88
  end
88
89
  end
@@ -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
116
  return WithChain.new(spawn) if opts == :chain
117
+
43
118
  opts.blank? ? self : spawn.with!(opts, *rest)
44
119
  end
45
120
 
46
- def with!(opts = :chain, *rest)
121
+ # @param [Hash, WithCTE] opts
122
+ def with!(opts = :chain, *_rest)
47
123
  return WithChain.new(self) if opts == :chain
48
- self.with_values += [opts] + rest
49
- self
50
- end
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
@@ -60,34 +60,15 @@ module ActiveRecordExtended
60
60
  end
61
61
  end
62
62
 
63
- # We'll need to preprocess these arguments for allowing `ActiveRecord::Relation#preprocess_order_args`,
64
- # to check for sanitization issues and convert over to `Arel::Nodes::[Ascending/Descending]`.
65
- # Without reflecting / prepending the parent's table name.
66
- #
67
- if ActiveRecord.gem_version < Gem::Version.new("5.1")
68
- # TODO: Rails 5.0.x order logic will *always* append the parents name to the column when its an HASH obj
69
- # We should really do this stuff better. Maybe even just ignore `preprocess_order_args` altogether?
70
- # Maybe I'm just stupidly over paranoid on just the 'ORDER BY' for some odd reason.
71
- def process_ordering_arguments!(ordering_args)
72
- ordering_args.flatten!
73
- ordering_args.compact!
74
- ordering_args.map! do |arg|
75
- next to_arel_sql(arg) unless arg.is_a?(Hash) # ActiveRecord will reflect if an argument is a symbol
76
- arg.each_with_object([]) do |(field, dir), ordering_object|
77
- ordering_object << to_arel_sql(field).send(dir.to_s.downcase)
78
- end
79
- end.flatten!
80
- end
81
- else
82
- def process_ordering_arguments!(ordering_args)
83
- ordering_args.flatten!
84
- ordering_args.compact!
85
- ordering_args.map! do |arg|
86
- next to_arel_sql(arg) unless arg.is_a?(Hash) # ActiveRecord will reflect if an argument is a symbol
87
- arg.each_with_object({}) do |(field, dir), ordering_obj|
88
- # ActiveRecord will not reflect if the Hash keys are a `Arel::Nodes::SqlLiteral` klass
89
- ordering_obj[to_arel_sql(field)] = dir.to_s.downcase
90
- end
63
+ def process_ordering_arguments!(ordering_args)
64
+ ordering_args.flatten!
65
+ ordering_args.compact!
66
+ ordering_args.map! do |arg|
67
+ next to_arel_sql(arg) unless arg.is_a?(Hash) # ActiveRecord will reflect if an argument is a symbol
68
+
69
+ arg.each_with_object({}) do |(field, dir), ordering_obj|
70
+ # ActiveRecord will not reflect if the Hash keys are a `Arel::Nodes::SqlLiteral` klass
71
+ ordering_obj[to_arel_sql(field)] = dir.to_s.downcase
91
72
  end
92
73
  end
93
74
  end
@@ -20,7 +20,7 @@ module ActiveRecordExtended
20
20
 
21
21
  def flatten_safely(values, &block)
22
22
  unless values.is_a?(Array)
23
- values = yield values if block_given?
23
+ values = yield values if block
24
24
  return [values]
25
25
  end
26
26
 
@@ -91,20 +91,12 @@ module ActiveRecordExtended
91
91
  def pipe_cte_with!(subquery)
92
92
  return self unless subquery.try(:with_values?)
93
93
 
94
- cte_ary = flatten_safely(subquery.with_values)
95
- subquery.with_values = nil # Remove nested queries with values
96
-
97
- # Add subquery's CTE's to the parents query stack. (READ THE SPECIAL NOTE ABOVE!)
94
+ # Add subquery CTE's to the parents query stack. (READ THE SPECIAL NOTE ABOVE!)
98
95
  if @scope.with_values?
99
- # combine top-level and lower level queries `.with` values into 1 structure
100
- with_hash = cte_ary.each_with_object(@scope.with_values.first) do |from_cte, hash|
101
- hash.reverse_merge!(from_cte)
102
- end
103
-
104
- @scope.with_values = [with_hash]
96
+ @scope.cte.pipe_cte_with!(subquery.cte)
105
97
  else
106
98
  # Top level has no with values
107
- @scope.with!(*cte_ary)
99
+ @scope.with!(subquery.cte)
108
100
  end
109
101
 
110
102
  self
@@ -143,13 +135,13 @@ module ActiveRecordExtended
143
135
  # Converts a potential subquery into a compatible Arel SQL node.
144
136
  #
145
137
  # Note:
146
- # We convert relations to SQL to maintain compatibility with Rails 5.[0/1].
138
+ # We convert relations to SQL to maintain compatibility with Rails 5.1.
147
139
  # Only Rails 5.2+ maintains bound attributes in Arel, so its better to be safe then sorry.
148
- # When we drop support for Rails 5.[0/1], we then can then drop the '.to_sql' conversation
140
+ # When we drop support for Rails 5.1, we then can then drop the '.to_sql' conversation
149
141
 
150
142
  def to_arel_sql(value)
151
143
  case value
152
- when Arel::Node, Arel::Nodes::SqlLiteral, nil
144
+ when Arel::Nodes::Node, Arel::Nodes::SqlLiteral, nil
153
145
  value
154
146
  when ActiveRecord::Relation
155
147
  Arel.sql(value.spawn.to_sql)
@@ -160,6 +152,7 @@ module ActiveRecordExtended
160
152
 
161
153
  def group_when_needed(arel_or_rel_query)
162
154
  return arel_or_rel_query unless needs_to_be_grouped?(arel_or_rel_query)
155
+
163
156
  generate_grouping(arel_or_rel_query)
164
157
  end
165
158
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveRecordExtended
4
- VERSION = "1.4.0"
4
+ VERSION = "2.0.0"
5
5
  end
@@ -61,7 +61,7 @@ RSpec.describe "Active Record Any / None of Methods" do
61
61
  it "Return matched records of a joined table on the parent level" do
62
62
  query = Tag.joins(:user).where.any_of(
63
63
  { users: { personal_id: 1 } },
64
- { users: { personal_id: 3 } },
64
+ { users: { personal_id: 3 } }
65
65
  )
66
66
 
67
67
  expect(query).to include(tag_one, tag_three)
@@ -120,7 +120,7 @@ RSpec.describe "Active Record Any / None of Methods" do
120
120
  it "Return matched records of a joined table on the parent level" do
121
121
  query = Tag.joins(:user).where.none_of(
122
122
  { users: { personal_id: 1 } },
123
- { users: { personal_id: 3 } },
123
+ { users: { personal_id: 3 } }
124
124
  )
125
125
 
126
126
  expect(query).to include(tag_two)
@@ -47,7 +47,7 @@ RSpec.describe "Active Record JSON methods" do
47
47
  end
48
48
 
49
49
  it "allows for casting results in an aggregate-able Array function" do
50
- query = User.select(:id).select_row_to_json(sub_query, key: :tag_row, as: :results, cast_as_array: true)
50
+ query = User.select(:id).select_row_to_json(sub_query, key: :tag_row, as: :results, cast_with: :array)
51
51
  expect(query.take.results).to be_a(Array).and(be_present)
52
52
  expect(query.take.results.first).to be_a(Hash)
53
53
  end
@@ -61,14 +61,14 @@ RSpec.describe "Active Record JSON methods" do
61
61
 
62
62
  describe ".json_build_object" do
63
63
  let(:sub_query) do
64
- User.select_row_to_json(from: User.select(:id), cast_as_array: true, as: :ids).where(id: user_one.id)
64
+ User.select_row_to_json(from: User.select(:id), cast_with: :array, as: :ids).where(id: user_one.id)
65
65
  end
66
66
 
67
67
  it "defaults the column alias if one is not provided" do
68
68
  query = User.json_build_object(:personal, sub_query)
69
69
  expect(query.size).to eq(1)
70
70
  expect(query.take.results).to match(
71
- "personal" => match("ids" => match_array([{ "id" => user_one.id }, { "id" => user_two.id }])),
71
+ "personal" => match("ids" => match_array([{ "id" => user_one.id }, { "id" => user_two.id }]))
72
72
  )
73
73
  end
74
74
 
@@ -98,7 +98,7 @@ RSpec.describe "Active Record JSON methods" do
98
98
  :personal,
99
99
  sub_query.where.not(id: user_one),
100
100
  value: "COALESCE(array_agg(\"personal\"), '{}')",
101
- as: :cool_dudes,
101
+ as: :cool_dudes
102
102
  )
103
103
 
104
104
  expect(query.take.cool_dudes["personal"]).to be_a(Array).and(be_empty)
@@ -110,7 +110,7 @@ RSpec.describe "Active Record JSON methods" do
110
110
  :personal,
111
111
  sub_query.where.not(id: user_one),
112
112
  value: "COALESCE(array_agg(personal), '{}')",
113
- as: :cool_dudes,
113
+ as: :cool_dudes
114
114
  )
115
115
  end.to output.to_stderr
116
116
  end
@@ -15,7 +15,7 @@ RSpec.describe "Active Record Select Methods" do
15
15
  it "can accept a subquery" do
16
16
  subquery = Tag.select("count(*)").joins("JOIN users u ON tags.user_id = u.id").where("u.ip = users.ip")
17
17
  query =
18
- User.foster_select(tag_count: [subquery, cast_with: :array_agg, distinct: true])
18
+ User.foster_select(tag_count: [subquery, { cast_with: :array_agg, distinct: true }])
19
19
  .joins(:hm_tags)
20
20
  .group(:ip)
21
21
  .take
@@ -25,8 +25,8 @@ RSpec.describe "Active Record Select Methods" do
25
25
 
26
26
  it "can be ordered" do
27
27
  query = User.foster_select(
28
- asc_ordered_numbers: [:number, cast_with: :array_agg, order_by: { number: :asc }],
29
- desc_ordered_numbers: [:number, cast_with: :array_agg, order_by: { number: :desc }],
28
+ asc_ordered_numbers: [:number, { cast_with: :array_agg, order_by: { number: :asc } }],
29
+ desc_ordered_numbers: [:number, { cast_with: :array_agg, order_by: { number: :desc } }]
30
30
  ).take
31
31
 
32
32
  expect(query.asc_ordered_numbers).to eq(number_set.to_a.sort)
@@ -50,10 +50,10 @@ RSpec.describe "Active Record Select Methods" do
50
50
 
51
51
  it "will return a boolean expression" do
52
52
  query = User.foster_select(
53
- truthly_expr: ["users.number > 0", cast_with: :bool_and],
54
- falsey_expr: ["users.number > 200", cast_with: :bool_and],
55
- other_true_expr: ["users.number > 4", cast_with: :bool_or],
56
- other_false_expr: ["users.number > 6", cast_with: :bool_or],
53
+ truthly_expr: ["users.number > 0", { cast_with: :bool_and }],
54
+ falsey_expr: ["users.number > 200", { cast_with: :bool_and }],
55
+ other_true_expr: ["users.number > 4", { cast_with: :bool_or }],
56
+ other_false_expr: ["users.number > 6", { cast_with: :bool_or }]
57
57
  ).take
58
58
 
59
59
  expect(query.truthly_expr).to be_truthy
@@ -67,19 +67,19 @@ RSpec.describe "Active Record Select Methods" do
67
67
  before { 2.times.flat_map { |i| Array.new(2) { |j| User.create!(number: (i + 1) * j + 3) } } }
68
68
 
69
69
  it "max" do
70
- query = User.foster_select(max_num: [:number, cast_with: :max]).take
70
+ query = User.foster_select(max_num: [:number, { cast_with: :max }]).take
71
71
  expect(query.max_num).to eq(5)
72
72
  end
73
73
 
74
74
  it "min" do
75
- query = User.foster_select(max_num: [:number, cast_with: :min]).take
75
+ query = User.foster_select(max_num: [:number, { cast_with: :min }]).take
76
76
  expect(query.max_num).to eq(3)
77
77
  end
78
78
 
79
79
  it "sum" do
80
80
  query = User.foster_select(
81
- num_sum: [:number, cast_with: :sum],
82
- distinct_sum: [:number, cast_with: :sum, distinct: true],
81
+ num_sum: [:number, { cast_with: :sum }],
82
+ distinct_sum: [:number, { cast_with: :sum, distinct: true }]
83
83
  ).take
84
84
 
85
85
  expect(query.num_sum).to eq(15)
@@ -88,8 +88,8 @@ RSpec.describe "Active Record Select Methods" do
88
88
 
89
89
  it "avg" do
90
90
  query = User.foster_select(
91
- num_avg: [:number, cast_with: :avg],
92
- distinct_avg: [:number, cast_with: :avg, distinct: true],
91
+ num_avg: [:number, { cast_with: :avg }],
92
+ distinct_avg: [:number, { cast_with: :avg, distinct: true }]
93
93
  ).take
94
94
 
95
95
  expect(query.num_avg).to eq(3.75)