active_record_extended 1.1.0 → 1.2.0

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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +12 -18
  3. data/lib/active_record_extended.rb +2 -1
  4. data/lib/active_record_extended/active_record.rb +2 -0
  5. data/lib/active_record_extended/active_record/relation_patch.rb +1 -1
  6. data/lib/active_record_extended/arel.rb +1 -0
  7. data/lib/active_record_extended/arel/aggregate_function_name.rb +40 -0
  8. data/lib/active_record_extended/arel/nodes.rb +31 -41
  9. data/lib/active_record_extended/arel/predications.rb +4 -1
  10. data/lib/active_record_extended/arel/visitors/postgresql_decorator.rb +40 -1
  11. data/lib/active_record_extended/patch/5_0/predicate_builder_decorator.rb +4 -4
  12. data/lib/active_record_extended/patch/5_0/regex_match.rb +10 -0
  13. data/lib/active_record_extended/query_methods/any_of.rb +5 -4
  14. data/lib/active_record_extended/query_methods/inet.rb +1 -1
  15. data/lib/active_record_extended/query_methods/json.rb +152 -43
  16. data/lib/active_record_extended/query_methods/select.rb +117 -0
  17. data/lib/active_record_extended/query_methods/unionize.rb +4 -39
  18. data/lib/active_record_extended/query_methods/with_cte.rb +3 -3
  19. data/lib/active_record_extended/utilities/order_by.rb +96 -0
  20. data/lib/active_record_extended/utilities/support.rb +175 -0
  21. data/lib/active_record_extended/version.rb +1 -1
  22. data/spec/query_methods/any_of_spec.rb +40 -40
  23. data/spec/query_methods/array_query_spec.rb +14 -14
  24. data/spec/query_methods/either_spec.rb +14 -14
  25. data/spec/query_methods/hash_query_spec.rb +11 -11
  26. data/spec/query_methods/inet_query_spec.rb +33 -31
  27. data/spec/query_methods/json_spec.rb +40 -25
  28. data/spec/query_methods/select_spec.rb +115 -0
  29. data/spec/query_methods/unionize_spec.rb +54 -54
  30. data/spec/query_methods/with_cte_spec.rb +12 -12
  31. data/spec/sql_inspections/any_of_sql_spec.rb +11 -11
  32. data/spec/sql_inspections/arel/aggregate_function_name_spec.rb +41 -0
  33. data/spec/sql_inspections/arel/array_spec.rb +7 -7
  34. data/spec/sql_inspections/arel/inet_spec.rb +7 -7
  35. data/spec/sql_inspections/contains_sql_queries_spec.rb +14 -14
  36. data/spec/sql_inspections/either_sql_spec.rb +11 -11
  37. data/spec/sql_inspections/json_sql_spec.rb +38 -8
  38. data/spec/sql_inspections/unionize_sql_spec.rb +27 -27
  39. data/spec/sql_inspections/with_cte_sql_spec.rb +22 -22
  40. data/spec/support/models.rb +18 -4
  41. metadata +11 -3
  42. data/lib/active_record_extended/utilities.rb +0 -141
@@ -0,0 +1,117 @@
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.delete(:cast_with),
56
+ order_by: hash_of_options.delete(:order_by),
57
+ distinct: !(!hash_of_options.delete(:distinct)),
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)
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(&method(:hash_to_dot_notation)).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.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
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, &method(:group_when_needed))
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, "Call `.forster_select' with at least one field" if args.empty?
106
+ spawn._foster_select!(*args)
107
+ end
108
+
109
+ def _foster_select!(*args)
110
+ SelectHelper.new(self).build_foster_select(*args)
111
+ self
112
+ end
113
+ end
114
+ end
115
+ end
116
+
117
+ ActiveRecord::Relation.prepend(ActiveRecordExtended::QueryMethods::Select)
@@ -7,7 +7,8 @@ module ActiveRecordExtended
7
7
  UNIONIZE_METHODS = [:union, :union_all, :union_except, :union_intersect].freeze
8
8
 
9
9
  class UnionChain
10
- include ::ActiveRecordExtended::Utilities
10
+ include ::ActiveRecordExtended::Utilities::Support
11
+ include ::ActiveRecordExtended::Utilities::OrderBy
11
12
 
12
13
  def initialize(scope)
13
14
  @scope = scope
@@ -69,38 +70,6 @@ module ActiveRecordExtended
69
70
  scope_count = 1 if scope_count <= 0 && @scope.union_values.size <= 1
70
71
  @scope.union_operations += [union_type] * scope_count
71
72
  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
73
  end
105
74
 
106
75
  def unionize_storage
@@ -171,7 +140,7 @@ module ActiveRecordExtended
171
140
  return unless union_values?
172
141
 
173
142
  union_nodes = apply_union_ordering(build_union_nodes!)
174
- table_name = Arel::Nodes::SqlLiteral.new(unionized_name)
143
+ table_name = Arel.sql(unionized_name)
175
144
  table_alias = arel.create_table_alias(arel.grouping(union_nodes), table_name)
176
145
  arel.from(table_alias)
177
146
  end
@@ -246,11 +215,7 @@ module ActiveRecordExtended
246
215
  #
247
216
  def apply_union_ordering(union_nodes)
248
217
  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)
218
+ UnionChain.new(self).inline_order_by(union_nodes, union_ordering_values)
254
219
  end
255
220
 
256
221
  private
@@ -54,12 +54,12 @@ module ActiveRecordExtended
54
54
  select =
55
55
  case expression
56
56
  when String
57
- Arel::Nodes::SqlLiteral.new("(#{expression})")
57
+ Arel.sql("(#{expression})")
58
58
  when ActiveRecord::Relation, Arel::SelectManager
59
- Arel::Nodes::SqlLiteral.new("(#{expression.to_sql})")
59
+ Arel.sql("(#{expression.to_sql})")
60
60
  end
61
61
  next if select.nil?
62
- Arel::Nodes::As.new(Arel::Nodes::SqlLiteral.new(PG::Connection.quote_ident(name.to_s)), select)
62
+ Arel::Nodes::As.new(Arel.sql(PG::Connection.quote_ident(name.to_s)), select)
63
63
  end
64
64
  end
65
65
 
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+
5
+ module ActiveRecordExtended
6
+ module Utilities
7
+ module OrderBy
8
+ def inline_order_by(arel_node, ordering_args)
9
+ return arel_node unless scope_preprocess_order_args(ordering_args)
10
+
11
+ Arel::Nodes::InfixOperation.new("ORDER BY", arel_node, ordering_args)
12
+ end
13
+
14
+ def scope_preprocess_order_args(ordering_args)
15
+ return false if ordering_args.blank? || !@scope.respond_to?(:preprocess_order_args, true)
16
+
17
+ # Sanitation check / resolver (ActiveRecord::Relation#preprocess_order_args)
18
+ @scope.send(:preprocess_order_args, ordering_args)
19
+ ordering_args
20
+ end
21
+
22
+ # Processes "ORDER BY" expressions for supported aggregate functions
23
+ def order_by_expression(order_by)
24
+ return false unless order_by && order_by.presence.present?
25
+
26
+ to_ordered_table_path(order_by)
27
+ .tap(&method(:process_ordering_arguments!))
28
+ .tap(&method(:scope_preprocess_order_args))
29
+ end
30
+
31
+ #
32
+ # Turns a hash into a dot notation path.
33
+ #
34
+ # Example:
35
+ # - Using pre-set directions:
36
+ # [{ products: { position: :asc, id: :desc } }]
37
+ # #=> [{ "products.position" => :asc, "products.id" => :desc }]
38
+ #
39
+ # - Using fallback directions:
40
+ # [{products: :position}]
41
+ # #=> [{"products.position" => :asc}]
42
+ #
43
+ def to_ordered_table_path(args)
44
+ flatten_safely(Array.wrap(args)) do |arg|
45
+ next arg unless arg.is_a?(Hash)
46
+
47
+ arg.each_with_object({}) do |(tbl_or_col, obj), new_hash|
48
+ if obj.is_a?(Hash)
49
+ obj.each_pair do |o_key, o_value|
50
+ new_hash["#{tbl_or_col}.#{o_key}"] = o_value
51
+ end
52
+ elsif ::ActiveRecord::QueryMethods::VALID_DIRECTIONS.include?(obj)
53
+ new_hash[tbl_or_col] = obj
54
+ elsif obj.nil?
55
+ new_hash[tbl_or_col.to_s] = :asc
56
+ else
57
+ new_hash["#{tbl_or_col}.#{obj}"] = :asc
58
+ end
59
+ end
60
+ end
61
+ end
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
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordExtended
4
+ module Utilities
5
+ module Support
6
+ A_TO_Z_KEYS = ("a".."z").to_a.freeze
7
+
8
+ # We need to ensure we can flatten nested ActiveRecord::Relations
9
+ # that might have been nested due to the (splat)*args parameters
10
+ #
11
+ # Note: calling `Array.flatten[!]/1` will actually remove all AR relations from the array.
12
+ #
13
+ def flatten_to_sql(*values)
14
+ flatten_safely(values) do |value|
15
+ value = yield value if block_given?
16
+ to_arel_sql(value)
17
+ end
18
+ end
19
+ alias to_sql_array flatten_to_sql
20
+
21
+ def flatten_safely(values, &block)
22
+ unless values.is_a?(Array)
23
+ values = yield values if block_given?
24
+ return [values]
25
+ end
26
+
27
+ values.map { |value| flatten_safely(value, &block) }.reduce(:+)
28
+ end
29
+
30
+ # Applies aliases to the given query
31
+ # Ex: `SELECT * FROM users` => `(SELECT * FROM users) AS "members"`
32
+ def nested_alias_escape(query, alias_name)
33
+ sql_query = Arel::Nodes::Grouping.new(to_arel_sql(query))
34
+ Arel::Nodes::As.new(sql_query, to_arel_sql(double_quote(alias_name)))
35
+ end
36
+
37
+ # Wraps subquery into an Aliased ARRAY
38
+ # Ex: `SELECT * FROM users` => (ARRAY(SELECT * FROM users)) AS "members"
39
+ def wrap_with_array(arel_or_rel_query, alias_name, order_by: false)
40
+ if order_by && arel_or_rel_query.is_a?(ActiveRecord::Relation)
41
+ arel_or_rel_query = arel_or_rel_query.order(order_by)
42
+ end
43
+
44
+ query = Arel::Nodes::Array.new(to_sql_array(arel_or_rel_query))
45
+ nested_alias_escape(query, alias_name)
46
+ end
47
+
48
+ # Wraps query into an aggregated array
49
+ # EX: `(ARRAY_AGG((SELECT * FROM users)) AS "members"`
50
+ # `(ARRAY_AGG(DISTINCT (SELECT * FROM users)) AS "members"`
51
+ # `SELECT ARRAY_AGG((id)) AS "ids" FROM users`
52
+ # `SELECT ARRAY_AGG(DISTINCT (id)) AS "ids" FROM users`
53
+ def wrap_with_agg_array(arel_or_rel_query, alias_name, order_by: false, distinct: false)
54
+ distinct = !(!distinct)
55
+ order_exp = distinct ? nil : order_by # Can't order a distinct agg
56
+ query = group_when_needed(arel_or_rel_query)
57
+ query =
58
+ Arel::Nodes::AggregateFunctionName
59
+ .new("ARRAY_AGG", to_sql_array(query), distinct)
60
+ .order_by(order_exp)
61
+
62
+ nested_alias_escape(query, alias_name)
63
+ end
64
+
65
+ # Will attempt to digest and resolve the from clause
66
+ #
67
+ # If the from clause is a String, it will check to see if a table reference key has been assigned.
68
+ # - If one cannot be detected, one will be appended.
69
+ # - Rails does not allow assigning table references using the `.from/2` method, when its a string / sym type.
70
+ #
71
+ # If the from clause is an AR relation; it will duplicate the object.
72
+ # - Ensures any memorizers are reset (ex: `.to_sql` sets a memorizer on the instance)
73
+ # - Key's can be assigned using the `.from/2` method.
74
+ #
75
+ def from_clause_constructor(from, reference_key)
76
+ case from
77
+ when /\s.?#{reference_key}.?$/ # The from clause is a string and has the tbl reference key
78
+ @scope.unscoped.from(from)
79
+ when String, Symbol
80
+ @scope.unscoped.from("#{from} #{reference_key}")
81
+ else
82
+ replicate_klass = from.respond_to?(:unscoped) ? from.unscoped : @scope.unscoped
83
+ replicate_klass.from(from.dup, reference_key)
84
+ end
85
+ end
86
+
87
+ # Will carry defined CTE tables from the nested sub-query and gradually pushes it up to the parents query stack
88
+ # I.E: It pushes `WITH [:cte_name:] AS(...), ..` to the top of the query structure tree
89
+ #
90
+ # SPECIAL GOTCHA NOTE: (if duplicate keys are found) This will favor the parents query `with's` over nested ones!
91
+ def pipe_cte_with!(subquery)
92
+ return self unless subquery.try(:with_values?)
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!)
98
+ 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]
105
+ else
106
+ # Top level has no with values
107
+ @scope.with!(*cte_ary)
108
+ end
109
+
110
+ self
111
+ end
112
+
113
+ # Ensures the given value is properly double quoted.
114
+ # This also ensures we don't have conflicts with reversed keywords.
115
+ #
116
+ # IE: `user` is a reserved keyword in PG. But `"user"` is allowed and works the same
117
+ # when used as an column/tbl alias.
118
+ def double_quote(value)
119
+ return if value.nil?
120
+
121
+ case value.to_s
122
+ # Ignore keys that contain double quotes or a Arel.star (*)[all columns]
123
+ # or if a table has already been explicitly declared (ex: users.id)
124
+ when "*", /((^".+"$)|(^[[:alpha:]]+\.[[:alnum:]]+))/
125
+ value
126
+ else
127
+ PG::Connection.quote_ident(value.to_s)
128
+ end
129
+ end
130
+
131
+ # Ensures the key is properly single quoted and treated as a actual PG key reference.
132
+ def literal_key(key)
133
+ case key
134
+ when TrueClass then "'t'"
135
+ when FalseClass then "'f'"
136
+ when Numeric then key
137
+ else
138
+ key = key.to_s
139
+ key.start_with?("'") && key.end_with?("'") ? key : "'#{key}'"
140
+ end
141
+ end
142
+
143
+ # Converts a potential subquery into a compatible Arel SQL node.
144
+ #
145
+ # Note:
146
+ # We convert relations to SQL to maintain compatibility with Rails 5.[0/1].
147
+ # 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
149
+
150
+ def to_arel_sql(value)
151
+ case value
152
+ when Arel::Node, Arel::Nodes::SqlLiteral, nil
153
+ value
154
+ when ActiveRecord::Relation
155
+ Arel.sql(value.spawn.to_sql)
156
+ else
157
+ Arel.sql(value.respond_to?(:to_sql) ? value.to_sql : value.to_s)
158
+ end
159
+ end
160
+
161
+ def group_when_needed(arel_or_rel_query)
162
+ return arel_or_rel_query unless needs_to_be_grouped?(arel_or_rel_query)
163
+ Arel::Nodes::Grouping.new(to_arel_sql(arel_or_rel_query))
164
+ end
165
+
166
+ def needs_to_be_grouped?(query)
167
+ query.respond_to?(:to_sql) || (query.is_a?(String) && /^SELECT.+/i.match?(query))
168
+ end
169
+
170
+ def key_generator
171
+ A_TO_Z_KEYS.sample
172
+ end
173
+ end
174
+ end
175
+ end