ransack 5.0.1 → 6.0.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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +13 -27
  3. data/lib/ransack/active_record/base.rb +154 -0
  4. data/lib/ransack/active_record/context.rb +504 -0
  5. data/lib/ransack/active_record/dialect.rb +83 -0
  6. data/lib/ransack/active_record/join.rb +75 -0
  7. data/lib/ransack/active_record/join_association.rb +104 -0
  8. data/lib/ransack/active_record/join_dependency.rb +123 -0
  9. data/lib/ransack/active_record/reflection.rb +18 -0
  10. data/lib/ransack/active_record/tree_node.rb +13 -0
  11. data/lib/ransack/active_record.rb +44 -9
  12. data/lib/ransack/configuration.rb +49 -1
  13. data/lib/ransack/constants.rb +61 -0
  14. data/lib/ransack/context.rb +170 -35
  15. data/lib/ransack/helpers/form_builder.rb +30 -25
  16. data/lib/ransack/nodes/bindable.rb +0 -1
  17. data/lib/ransack/nodes/condition.rb +215 -73
  18. data/lib/ransack/nodes/grouping.rb +35 -11
  19. data/lib/ransack/nodes/sort.rb +1 -1
  20. data/lib/ransack/nodes/value.rb +8 -1
  21. data/lib/ransack/predicate.rb +8 -1
  22. data/lib/ransack/search.rb +52 -9
  23. data/lib/ransack/version.rb +1 -1
  24. data/lib/ransack.rb +4 -1
  25. metadata +11 -13
  26. data/lib/polyamorous/activerecord/join_association.rb +0 -70
  27. data/lib/polyamorous/activerecord/join_association_7_2.rb +0 -55
  28. data/lib/polyamorous/activerecord/join_dependency.rb +0 -102
  29. data/lib/polyamorous/activerecord/reflection.rb +0 -11
  30. data/lib/polyamorous/join.rb +0 -70
  31. data/lib/polyamorous/polyamorous.rb +0 -27
  32. data/lib/polyamorous/swapping_reflection_class.rb +0 -11
  33. data/lib/polyamorous/tree_node.rb +0 -7
  34. data/lib/ransack/adapters/active_record/base.rb +0 -152
  35. data/lib/ransack/adapters/active_record/context.rb +0 -375
@@ -0,0 +1,104 @@
1
+ module Ransack
2
+ module ActiveRecord
3
+ # Prepended to ActiveRecord::Associations::JoinDependency::JoinAssociation.
4
+ #
5
+ # Adds the join type and the polymorphic class to a join association, and
6
+ # a variant of #join_constraints that builds against tables Ransack has
7
+ # already aliased, so a correlated subquery for a negative condition on a
8
+ # collection uses the same aliases as the outer query.
9
+ module JoinAssociationExtensions
10
+ def self.prepended(base)
11
+ base.class_eval { attr_reader :join_type }
12
+ end
13
+
14
+ def initialize(reflection, children, polymorphic_class = nil, join_type = Arel::Nodes::InnerJoin)
15
+ @join_type = join_type
16
+ if polymorphic_class && ::ActiveRecord::Base > polymorphic_class
17
+ swapping_reflection_klass(reflection, polymorphic_class) do |reflection|
18
+ super(reflection, children)
19
+ self.reflection.options[:polymorphic] = true
20
+ end
21
+ else
22
+ super(reflection, children)
23
+ end
24
+ end
25
+
26
+ # A polymorphic reflection has no class of its own. To join it to one
27
+ # concrete class, work on a copy of the reflection that names that class
28
+ # and drops the polymorphic option; the shared reflection on the model
29
+ # is left untouched, so this is safe across threads.
30
+ def swapping_reflection_klass(reflection, klass)
31
+ new_reflection = reflection.clone
32
+ new_reflection.instance_variable_set(:@options, reflection.options.clone)
33
+ new_reflection.options.delete(:polymorphic)
34
+ new_reflection.instance_variable_set(:@klass, klass)
35
+ yield new_reflection
36
+ end
37
+
38
+ # Same as #join_constraints, but instead of constructing tables from the
39
+ # given block, uses the ones passed
40
+ def join_constraints_with_tables(foreign_table, foreign_klass, join_type, alias_tracker, tables)
41
+ joins = []
42
+ chain = []
43
+
44
+ reflection.chain.each.with_index do |reflection, i|
45
+ table = tables[i]
46
+
47
+ @table ||= table
48
+ chain << [reflection, table]
49
+ end
50
+
51
+ # The chain starts with the target table, but we want to end with it here (makes
52
+ # more sense in this context), so we reverse
53
+ chain.reverse_each do |reflection, table|
54
+ klass = reflection.klass
55
+
56
+ join_scope = reflection.join_scope(table, foreign_table, foreign_klass)
57
+
58
+ unless join_scope.references_values.empty?
59
+ join_dependency = join_scope.construct_join_dependency(
60
+ join_scope.eager_load_values | join_scope.includes_values, Arel::Nodes::OuterJoin
61
+ )
62
+ join_scope.joins!(join_dependency)
63
+ end
64
+
65
+ arel = join_scope.arel(alias_tracker.aliases)
66
+ nodes = arel.constraints.first
67
+
68
+ if nodes.is_a?(Arel::Nodes::And)
69
+ others = nodes.children.extract! do |node|
70
+ !Arel.fetch_attribute(node) { |attr| attr.relation.name == table.name }
71
+ end
72
+ end
73
+
74
+ joins << table.create_join(table, table.create_on(nodes), join_type)
75
+
76
+ if others && !others.empty?
77
+ joins.concat arel.join_sources
78
+ append_constraints_to(joins.last, others)
79
+ end
80
+
81
+ # The current table in this iteration becomes the foreign table in the next
82
+ foreign_table, foreign_klass = table, klass
83
+ end
84
+
85
+ joins
86
+ end
87
+
88
+ private
89
+
90
+ # Active Record's private append_constraints takes a leading connection
91
+ # argument in 7.2.0–7.2.2.2 and 8.0.0–8.0.2, and none in 7.2.3+, 8.0.3+
92
+ # and 8.1; the arity says which is loaded.
93
+ def append_constraints_to(join, constraints)
94
+ if method(:append_constraints).arity == 3
95
+ base_klass.with_connection do |connection|
96
+ append_constraints(connection, join, constraints)
97
+ end
98
+ else
99
+ append_constraints(join, constraints)
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,123 @@
1
+ module Ransack
2
+ module ActiveRecord
3
+ # Prepended to ActiveRecord::Associations::JoinDependency.
4
+ #
5
+ # Teaches the join tree about Join nodes (join type, polymorphic class),
6
+ # and exposes the table aliasing Ransack needs to add one association at a
7
+ # time to a relation that may already carry joins of its own.
8
+ module JoinDependencyExtensions
9
+ # Replaces ActiveRecord::Associations::JoinDependency#build
10
+ def build(associations, base_klass)
11
+ associations.map do |name, right|
12
+ if name.is_a? Join
13
+ reflection = find_reflection base_klass, name.name
14
+ reflection.check_validity!
15
+ reflection.check_eager_loadable!
16
+
17
+ klass = if reflection.polymorphic?
18
+ name.klass || base_klass
19
+ else
20
+ reflection.klass
21
+ end
22
+ JoinAssociation.new(reflection, build(right, klass), name.klass, name.type)
23
+ else
24
+ reflection = find_reflection base_klass, name
25
+ reflection.check_validity!
26
+ reflection.check_eager_loadable!
27
+
28
+ if reflection.polymorphic?
29
+ raise ::ActiveRecord::EagerLoadPolymorphicError.new(reflection)
30
+ end
31
+ JoinAssociation.new(reflection, build(right, reflection.klass))
32
+ end
33
+ end
34
+ end
35
+
36
+ def join_constraints(joins_to_add, alias_tracker, references)
37
+ @alias_tracker = alias_tracker
38
+ @joined_tables = {}
39
+ @references = {}
40
+
41
+ references.each do |table_name|
42
+ @references[table_name.to_sym] = table_name if table_name.is_a?(String)
43
+ end
44
+
45
+ joins = make_join_constraints(join_root, join_type)
46
+
47
+ joins.concat joins_to_add.flat_map { |oj|
48
+ if join_root.match?(oj.join_root) && join_root.table.name == oj.join_root.table.name
49
+ walk join_root, oj.join_root, oj.join_type
50
+ else
51
+ make_join_constraints(oj.join_root, oj.join_type)
52
+ end
53
+ }
54
+ end
55
+
56
+ def construct_tables_for_association!(join_root, association, join_type = self.join_type)
57
+ tables = table_aliases_for(join_root, association, join_type)
58
+ association.table = tables.first
59
+ tables
60
+ end
61
+
62
+ private
63
+
64
+ # The tables for a node's reflection chain, assigned the way Active
65
+ # Record's JoinDependency#make_constraints assigns them, so that the
66
+ # aliases Ransack binds conditions to are the ones the query will have.
67
+ # Once a chain tail has been joined before, Active Record stops
68
+ # aliasing and reuses that join; the remaining positions here are
69
+ # filled from the same record for the correlated-subquery builder.
70
+ def table_aliases_for(parent, node, join_type)
71
+ @joined_tables ||= {}
72
+ chain = node.reflection.chain
73
+ terminated_at = nil
74
+
75
+ chain.each_with_index.map { |reflection, index|
76
+ remaining = chain[index..]
77
+ table, terminated = @joined_tables[remaining]
78
+ root = reflection == node.reflection
79
+
80
+ if terminated_at
81
+ table || reflection.klass.arel_table
82
+ elsif table && (!root || !terminated)
83
+ @joined_tables[remaining] = [table, root] if root
84
+ terminated_at = index
85
+ table
86
+ else
87
+ table_name = @references && @references[reflection.name.to_sym]&.to_s
88
+ table = alias_tracker.aliased_table_for(reflection.klass.arel_table, table_name) do
89
+ name = reflection.alias_candidate(parent.table_name)
90
+ root ? name : "#{name}_join"
91
+ end
92
+ @joined_tables[remaining] ||= [table, root] if join_type == Arel::Nodes::OuterJoin
93
+ table
94
+ end
95
+ }
96
+ end
97
+
98
+ module ClassMethods
99
+ # Prepended before ActiveRecord::Associations::JoinDependency#walk_tree
100
+ #
101
+ def walk_tree(associations, hash)
102
+ case associations
103
+ when TreeNode
104
+ associations.add_to_tree(hash)
105
+ when Hash
106
+ associations.each do |k, v|
107
+ cache =
108
+ if TreeNode === k
109
+ k.add_to_tree(hash)
110
+ else
111
+ hash[k] ||= {}
112
+ end
113
+ walk_tree(v, cache)
114
+ end
115
+ else
116
+ super(associations, hash)
117
+ end
118
+ end
119
+ end
120
+
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,18 @@
1
+ module Ransack
2
+ module ActiveRecord
3
+ # Prepended to ActiveRecord::Reflection::AbstractReflection.
4
+ #
5
+ # When a polymorphic belongs_to is joined to one concrete class, the join
6
+ # must also constrain the type column, or rows pointing at a different
7
+ # class with a colliding id would match.
8
+ module ReflectionExtensions
9
+ def join_scope(table, foreign_table, foreign_klass)
10
+ if respond_to?(:polymorphic?) && polymorphic?
11
+ super.where!(foreign_table[foreign_type].eq(klass.name))
12
+ else
13
+ super
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,13 @@
1
+ module Ransack
2
+ module ActiveRecord
3
+ # Anything that can be placed in the association tree handed to
4
+ # JoinDependency. Active Record's own tree is built from symbols and
5
+ # hashes; a TreeNode adds itself to that tree however it likes, which is
6
+ # how a Join carries its join type and polymorphic class through.
7
+ module TreeNode
8
+ def add_to_tree(hash)
9
+ raise NotImplementedError
10
+ end
11
+ end
12
+ end
13
+ end
@@ -1,14 +1,49 @@
1
- require 'ransack/adapters/active_record/base'
1
+ require 'ransack/active_record/base'
2
+ require 'ransack/active_record/dialect'
3
+ require 'ransack/active_record/context'
2
4
 
3
5
  ActiveSupport.on_load(:active_record) do
4
- extend Ransack::Adapters::ActiveRecord::Base
5
-
6
- Ransack::SUPPORTS_ATTRIBUTE_ALIAS =
7
- begin
8
- ActiveRecord::Base.respond_to?(:attribute_aliases)
9
- rescue NameError
10
- false
6
+ module Ransack
7
+ # Everything Ransack needs from Active Record lives under this module: the
8
+ # class methods mixed into every model (Base), the Context that turns a
9
+ # search into a relation, and the extensions to Active Record's join
10
+ # machinery that let Ransack build outer joins and polymorphic joins the
11
+ # public query interface cannot express.
12
+ module ActiveRecord
13
+ JoinDependency = ::ActiveRecord::Associations::JoinDependency
14
+ JoinAssociation = ::ActiveRecord::Associations::JoinDependency::JoinAssociation
15
+ end
11
16
  end
17
+
18
+ require 'ransack/active_record/tree_node'
19
+ require 'ransack/active_record/join'
20
+ require 'ransack/active_record/join_association'
21
+ require 'ransack/active_record/join_dependency'
22
+ require 'ransack/active_record/reflection'
23
+
24
+ ::ActiveRecord::Reflection::AbstractReflection.prepend Ransack::ActiveRecord::ReflectionExtensions
25
+ Ransack::ActiveRecord::JoinDependency.prepend Ransack::ActiveRecord::JoinDependencyExtensions
26
+ Ransack::ActiveRecord::JoinDependency.singleton_class.prepend Ransack::ActiveRecord::JoinDependencyExtensions::ClassMethods
27
+ Ransack::ActiveRecord::JoinAssociation.prepend Ransack::ActiveRecord::JoinAssociationExtensions
28
+
29
+ extend Ransack::ActiveRecord::Base
12
30
  end
13
31
 
14
- require 'ransack/adapters/active_record/context'
32
+ module Ransack
33
+ # Ransack 5 and earlier kept the Active Record integration under
34
+ # Ransack::Adapters::ActiveRecord, a leftover from when Mongoid was also
35
+ # supported. Initializers that reopen it — usually to change the default
36
+ # `ransackable_attributes` — keep working for one more major release, with a
37
+ # deprecation warning pointing at the new name.
38
+ module Adapters
39
+ def self.const_missing(name)
40
+ return super unless name == :ActiveRecord
41
+
42
+ Ransack.deprecator.warn(
43
+ 'Ransack::Adapters::ActiveRecord is deprecated and will be removed ' \
44
+ 'in Ransack 7.0. Use Ransack::ActiveRecord instead.'
45
+ )
46
+ const_set(:ActiveRecord, Ransack::ActiveRecord)
47
+ end
48
+ end
49
+ end
@@ -36,7 +36,9 @@ module Ransack
36
36
  sanitize_scope_args: true,
37
37
  fields_sort_option: nil,
38
38
  strip_whitespace: true,
39
- ignore_blank_values: true
39
+ ignore_blank_values: true,
40
+ dialect: nil,
41
+ null_sentinel: nil
40
42
  }
41
43
 
42
44
  def configure
@@ -230,6 +232,52 @@ module Ransack
230
232
  self.options[:ignore_blank_values] = boolean
231
233
  end
232
234
 
235
+ # Ransack works out which SQL dialect a model's database speaks from the
236
+ # connection adapter's class: PostgreSQL, MySQL (mysql2 and trilogy) and
237
+ # SQLite are recognised, as is any adapter that subclasses one of them,
238
+ # and anything else uses standard SQL only. For an adapter that speaks a
239
+ # known dialect without inheriting from Rails' adapter for it, name the
240
+ # dialect explicitly:
241
+ #
242
+ # Ransack.configure do |config|
243
+ # config.dialect = :postgresql # or :mysql, :sqlite, :generic
244
+ # end
245
+ #
246
+ def dialect=(name)
247
+ self.options[:dialect] = name&.to_sym
248
+ end
249
+
250
+ # Unset by default, so no application changes behavior on upgrade. Set
251
+ # this to a unique String value that cannot occur in your data, and an
252
+ # `eq` or `in` predicate (including their `_any` compounds) treats it as
253
+ # a request to also match `NULL`, instead of as a literal value to
254
+ # search for. A blank String, `false`, or a non-String value is not
255
+ # supported and produces undefined behavior:
256
+ #
257
+ # Ransack.configure do |config|
258
+ # config.null_sentinel = '__ransack_null__'
259
+ # end
260
+ #
261
+ # name_in: ['Ernie'] # name IN ('Ernie')
262
+ # name_in: ['Ernie', '__ransack_null__']
263
+ # # (name IN ('Ernie') OR name IS NULL)
264
+ # name_in: ['__ransack_null__'] # name IS NULL
265
+ #
266
+ # A single multi-value form field can express all three states this
267
+ # way, with no change to the field's name and no controller-side
268
+ # rewriting. `eq` takes one value, so through it the sentinel means
269
+ # `IS NULL` alone; "a value or NULL" from one field needs `in` or an
270
+ # `_any` compound.
271
+ #
272
+ # This does not bypass `ransackable_attributes`: `NULL` on an exposed
273
+ # attribute is already reachable through the built-in `null` predicate.
274
+ #
275
+ # See https://github.com/activerecord-hackery/ransack/issues/940
276
+ #
277
+ def null_sentinel=(value)
278
+ self.options[:null_sentinel] = value
279
+ end
280
+
233
281
  def arel_predicate_with_suffix(arel_predicate, suffix)
234
282
  if arel_predicate === Proc
235
283
  proc { |v| "#{arel_predicate.call(v)}#{suffix}" }
@@ -39,6 +39,21 @@ module Ransack
39
39
  ].freeze
40
40
  A_S_I = ['a'.freeze, 's'.freeze, 'i'.freeze].freeze
41
41
 
42
+ # The predicates `Ransack.options[:null_sentinel]` applies to. Limited to
43
+ # the positive equality family (`eq`, `in`, and their `_any` compounds)
44
+ # because these are the only predicates where "or is null" is
45
+ # unambiguous. The `_all` compounds are excluded because they are
46
+ # conjunctive: `eq_all`/`in_all` require a single column to equal every
47
+ # supplied value at once, so ORing in `IS NULL` would widen a query that
48
+ # already matches nothing (or almost nothing) rather than narrow it. A
49
+ # negative predicate treats the sentinel as a literal value for the same
50
+ # reason of unambiguity: `not_eq` matching the sentinel would otherwise
51
+ # mean "is not unassigned", which reads as the opposite of what a caller
52
+ # submitting it alone almost certainly wants.
53
+ NULL_SENTINEL_PREDICATES = [
54
+ 'eq'.freeze, 'in'.freeze, 'eq_any'.freeze, 'in_any'.freeze
55
+ ].freeze
56
+
42
57
  EQ = 'eq'.freeze
43
58
  NOT_EQ = 'not_eq'.freeze
44
59
  EQ_ANY = 'eq_any'.freeze
@@ -50,6 +65,15 @@ module Ransack
50
65
 
51
66
  DISTINCT = 'DISTINCT '.freeze
52
67
 
68
+ # The most `_`-separated segments a single condition or sort key may have
69
+ # before Ransack rejects it as invalid instead of parsing it. Working out
70
+ # whether a key is an attribute, an `_and_`/`_or_` compound or a path
71
+ # through associations is superlinear in this count, and the key comes
72
+ # straight from the query string, so an unbounded one is a CPU-exhaustion
73
+ # denial of service (GHSA-j3f8-w227-4hh8). No real search key is this
74
+ # long: OR-ing every column of a very wide table stays well under it.
75
+ MAX_KEY_DEPTH = 200
76
+
53
77
  DERIVED_PREDICATES = [
54
78
  [CONT, {
55
79
  arel_predicate: 'matches'.freeze,
@@ -83,6 +107,18 @@ module Ransack
83
107
  formatter: proc { |v| "#{escape_wildcards(v)}%" }
84
108
  }
85
109
  ],
110
+ ['i_start'.freeze, {
111
+ arel_predicate: 'matches'.freeze,
112
+ formatter: proc { |v| "#{escape_wildcards(v.downcase)}%" },
113
+ case_insensitive: true
114
+ }
115
+ ],
116
+ ['not_i_start'.freeze, {
117
+ arel_predicate: 'does_not_match'.freeze,
118
+ formatter: proc { |v| "#{escape_wildcards(v.downcase)}%" },
119
+ case_insensitive: true
120
+ }
121
+ ],
86
122
  ['end'.freeze, {
87
123
  arel_predicate: 'matches'.freeze,
88
124
  formatter: proc { |v| "%#{escape_wildcards(v)}" }
@@ -93,6 +129,18 @@ module Ransack
93
129
  formatter: proc { |v| "%#{escape_wildcards(v)}" }
94
130
  }
95
131
  ],
132
+ ['i_end'.freeze, {
133
+ arel_predicate: 'matches'.freeze,
134
+ formatter: proc { |v| "%#{escape_wildcards(v.downcase)}" },
135
+ case_insensitive: true
136
+ }
137
+ ],
138
+ ['not_i_end'.freeze, {
139
+ arel_predicate: 'does_not_match'.freeze,
140
+ formatter: proc { |v| "%#{escape_wildcards(v.downcase)}" },
141
+ case_insensitive: true
142
+ }
143
+ ],
96
144
  ['true'.freeze, {
97
145
  arel_predicate: proc { |v| v ? EQ : NOT_EQ },
98
146
  compounds: false,
@@ -197,5 +245,18 @@ module Ransack
197
245
  def escape_wildcards(unescaped)
198
246
  unescaped.to_s.gsub(/([\\%_])/) { "#{LIKE_ESCAPE_CHARACTER}#{$1}" }
199
247
  end
248
+
249
+ def null_sentinel_predicate?(predicate_name, sentinel)
250
+ sentinel && NULL_SENTINEL_PREDICATES.include?(predicate_name)
251
+ end
252
+
253
+ def null_sentinel_value?(value, sentinel)
254
+ sentinel && value == sentinel
255
+ end
256
+
257
+ def null_sentinel_requested?(predicate_name, values, sentinel)
258
+ null_sentinel_predicate?(predicate_name, sentinel) &&
259
+ values.any? { |v| null_sentinel_value?(v.value, sentinel) }
260
+ end
200
261
  end
201
262
  end