ransack 5.0.2 → 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 +52 -0
  14. data/lib/ransack/context.rb +159 -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 +33 -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 -379
@@ -4,31 +4,33 @@ module Ransack
4
4
  class Context
5
5
  attr_reader :search, :object, :klass, :base, :engine, :arel_visitor
6
6
  attr_accessor :auth_object, :search_key, :ignore_unknown_conditions
7
+ attr_writer :null_sentinel
8
+
9
+ # True when the search was built from `ActionController::Parameters` the
10
+ # controller permitted. A model with no allowlist of its own then lets
11
+ # that permit list be the boundary (see `allowlist` below).
12
+ attr_accessor :permitted
7
13
 
8
14
  class << self
9
15
 
10
- def for_class(klass, options = {})
11
- if klass < ActiveRecord::Base
12
- Adapters::ActiveRecord::Context.new(klass, options)
13
- end
16
+ # An ORM integration registers a block that returns a Context for the
17
+ # objects it understands and nil for anything else. Ransack ships the
18
+ # Active Record integration (see Ransack::ActiveRecord::Context); an
19
+ # integration for another ORM registers its own resolver here.
20
+ def register(&resolver)
21
+ Ransack::Context.resolvers << resolver
14
22
  end
15
23
 
16
- def for_object(object, options = {})
17
- case object
18
- when ActiveRecord::Relation
19
- Adapters::ActiveRecord::Context.new(object.klass, options)
20
- end
24
+ def resolvers
25
+ @resolvers ||= []
21
26
  end
22
27
 
23
28
  def for(object, options = {})
24
- context =
25
- if Class === object
26
- for_class(object, options)
27
- else
28
- for_object(object, options)
29
- end
30
- context or raise ArgumentError,
31
- "Don't know what context to use for #{object}"
29
+ Ransack::Context.resolvers.each do |resolver|
30
+ context = resolver.call(object, options)
31
+ return context if context
32
+ end
33
+ raise ArgumentError, "Don't know what context to use for #{object}"
32
34
  end
33
35
 
34
36
  end # << self
@@ -39,15 +41,23 @@ module Ransack
39
41
  !Ransack.options[:ignore_unknown_conditions] || ignore_unknown_conditions == false
40
42
  end
41
43
 
44
+ # Falls back to the global option unless this search's own option was
45
+ # set, including to `false` to turn the feature off for one search. A
46
+ # Condition captures this when it is built, so a later search reusing
47
+ # this context does not change the conditions of an earlier one.
48
+ def null_sentinel
49
+ @null_sentinel.nil? ? Ransack.options[:null_sentinel] : @null_sentinel
50
+ end
51
+
42
52
  def initialize(object, options = {})
43
53
  @object = relation_for(object)
44
54
  @klass = @object.klass
45
- @join_dependency = join_dependency(@object)
46
- @join_type = options[:join_type] || Polyamorous::OuterJoin
55
+ @join_type = options[:join_type] || Arel::Nodes::OuterJoin
47
56
  @search_key = options[:search_key] || Ransack.options[:search_key]
48
57
  @associations_pot = {}
49
58
  @tables_pot = {}
50
59
  @lock_associations = []
60
+ @join_dependency = join_dependency(@object)
51
61
 
52
62
  @base = @join_dependency.instance_variable_get(:@join_root)
53
63
  end
@@ -70,14 +80,24 @@ module Ransack
70
80
  end
71
81
  end
72
82
 
83
+ # The SQL dialect the search will be rendered in, for the few places
84
+ # where generated SQL differs by database. See
85
+ # Ransack::ActiveRecord::Dialect for the questions it answers.
86
+ def dialect
87
+ raise NotImplementedError, "#{self.class} must implement #dialect"
88
+ end
89
+
90
+ # The model class behind a search object, an association node or a class.
91
+ # Each ORM integration decides what counts as a model, so this is defined
92
+ # on the integration's Context subclass.
73
93
  def klassify(obj)
74
- if Class === obj && ::ActiveRecord::Base > obj
75
- obj
76
- elsif obj.respond_to? :klass
77
- obj.klass
78
- else
79
- raise ArgumentError, "Don't know how to klassify #{obj.inspect}"
80
- end
94
+ raise NotImplementedError, "#{self.class} must implement #klassify"
95
+ end
96
+
97
+ # Whether a class can be the base of a search. An ORM integration refines
98
+ # this; by default any class Ransack has been mixed into qualifies.
99
+ def searchable_class?(klass)
100
+ klass.respond_to?(:ransackable_attributes)
81
101
  end
82
102
 
83
103
  # Convert a string representing a chain of associations and an attribute
@@ -87,8 +107,15 @@ module Ransack
87
107
  table_for(parent)[attr_name]
88
108
  end
89
109
 
110
+ # A bare `false` normally means "this checkbox was not ticked" and the
111
+ # scope is not applied at all. A scope listed in
112
+ # `ransackable_scopes_skip_sanitize_args` has asked to see its values as
113
+ # given, so `false` reaches it like any other value and it can be driven
114
+ # by a yes / no / any select (#1375).
90
115
  def chain_scope(scope, args)
91
- return unless @klass.method(scope) && args != false
116
+ return unless @klass.method(scope)
117
+ return if args == false && !ransackable_scope_skip_sanitize_args?(scope, @klass)
118
+
92
119
  @object = if scope_arity(scope) < 1 && args == true
93
120
  @object.public_send(scope)
94
121
  elsif scope_arity(scope) == 1 && args.is_a?(Array)
@@ -161,6 +188,10 @@ module Ransack
161
188
  association_parts.join(Constants::UNDERSCORE)
162
189
  )
163
190
  next unless found_assoc = get_association(assoc, base)
191
+ # A polymorphic association is only complete once its
192
+ # `_of_Model_type` suffix has been consumed; matching on the bare
193
+ # name would ask the reflection for a class it cannot know (#1557).
194
+ next if found_assoc.polymorphic? && klass.nil?
164
195
  path += association_parts
165
196
  association_parts = []
166
197
  base = klassify(klass || found_assoc)
@@ -171,27 +202,71 @@ module Ransack
171
202
  end
172
203
 
173
204
  def unpolymorphize_association(str)
174
- if (match = str.match(/_of_([^_]+?)_type$/))
175
- [match.pre_match, Kernel.const_get(match.captures.first)]
205
+ if (match = str.match(/_of_([^_]+?)_type$/)) &&
206
+ (klass = polymorphic_class(match.captures.first))
207
+ [match.pre_match, klass]
176
208
  else
177
209
  [str, nil]
178
210
  end
179
211
  end
180
212
 
213
+ # The class named in an `_of_Model_type` suffix comes from the query
214
+ # string, so it can name anything: a constant that does not exist, a
215
+ # lowercase word, a module, a class with no table, a path through
216
+ # something that is no module at all (`ENV::Person`). None of those is a
217
+ # model to search, so the suffix is then no polymorphic reference at all
218
+ # and the whole key fails as any other unknown attribute does, ignored or
219
+ # raised as `InvalidSearchError` under `ignore_unknown_conditions`.
220
+ # `Kernel.const_get` used to raise `NameError` straight from the URL
221
+ # (#1738).
222
+ def polymorphic_class(name)
223
+ klass = name.safe_constantize
224
+ klass if klass.is_a?(Class) && searchable_class?(klass)
225
+ rescue TypeError
226
+ # `safe_constantize` swallows a missing constant but not a namespace
227
+ # that is not a module, which Ruby reports as a TypeError.
228
+ nil
229
+ end
230
+
181
231
  def ransackable_alias(str)
182
232
  klass._ransack_aliases.fetch(str, klass._ransack_aliases.fetch(str.to_sym, str))
183
233
  end
184
234
 
235
+ # Expands every `ransack_alias` in a condition name: the whole name, each
236
+ # `_or_` / `_and_` segment of a compound, and an alias defined on an
237
+ # associated model and reached through an association path
238
+ # (`person_term` when Person aliases `term`). An alias that expands to a
239
+ # compound is joined with that compound's own combinator, so
240
+ # `term_or_daddy` with `term => name_or_email` becomes
241
+ # `name_or_email_or_parent_name`. Returns the name unchanged when it
242
+ # holds no alias.
243
+ def resolve_aliases(str)
244
+ return str.to_s unless key_within_depth_limit?(str)
245
+
246
+ whole = resolve_alias_segment(str)
247
+ return whole if whole != str
248
+ # A name that is an attribute in its own right is never split, even if
249
+ # it contains a combinator (a column called `true_or_false`).
250
+ return str if attribute_method?(str)
251
+
252
+ segments = str.split(/_and_|_or_/)
253
+ resolved = segments.map { |segment| resolve_alias_segment(segment) }
254
+ return str if resolved == segments
255
+
256
+ combinator = str[/_(or|and)_/, 1] || Constants::OR
257
+ resolved.join("_#{combinator}_")
258
+ end
259
+
185
260
  def ransackable_attribute?(str, klass)
186
- klass.ransackable_attributes(auth_object).any? { |s| s.to_sym == str.to_sym }
261
+ attributes_for(klass).any? { |s| s.to_sym == str.to_sym }
187
262
  end
188
263
 
189
264
  def ransortable_attribute?(str, klass)
190
- klass.ransortable_attributes(auth_object).any? { |s| s.to_sym == str.to_sym }
265
+ sortable_attributes_for(klass).any? { |s| s.to_sym == str.to_sym }
191
266
  end
192
267
 
193
268
  def ransackable_association?(str, klass)
194
- klass.ransackable_associations(auth_object).any? { |s| s.to_sym == str.to_sym }
269
+ associations_for(klass).any? { |s| s.to_sym == str.to_sym }
195
270
  end
196
271
 
197
272
  def ransackable_scope?(str, klass)
@@ -203,19 +278,68 @@ module Ransack
203
278
  end
204
279
 
205
280
  def searchable_attributes(str = ''.freeze)
206
- traverse(str).ransackable_attributes(auth_object)
281
+ attributes_for(traverse(str))
207
282
  end
208
283
 
209
284
  def sortable_attributes(str = ''.freeze)
210
- traverse(str).ransortable_attributes(auth_object)
285
+ sortable_attributes_for(traverse(str))
211
286
  end
212
287
 
213
288
  def searchable_associations(str = ''.freeze)
214
- traverse(str).ransackable_associations(auth_object)
289
+ associations_for(traverse(str))
215
290
  end
216
291
 
217
292
  private
218
293
 
294
+ def attributes_for(klass)
295
+ allowlist(klass, :ransackable_attributes) { klass.authorizable_ransackable_attributes }
296
+ end
297
+
298
+ # `ransortable_attributes` defaults to `ransackable_attributes`, so a
299
+ # model that defines either one has a sort list of its own.
300
+ def sortable_attributes_for(klass)
301
+ allowlist(klass, :ransortable_attributes, :ransackable_attributes) do
302
+ klass.authorizable_ransackable_attributes
303
+ end
304
+ end
305
+
306
+ def associations_for(klass)
307
+ allowlist(klass, :ransackable_associations) { klass.authorizable_ransackable_associations }
308
+ end
309
+
310
+ # The allowlist a search consults on `klass`. A model that defines its
311
+ # own `method` is always asked, so its list applies on top of whatever
312
+ # the controller permitted. A model that defines none has left the
313
+ # decision to the controller: when the parameters were permitted, the
314
+ # block's list of everything that exists is used, and otherwise the
315
+ # model's default runs, which raises asking for a list (#1403).
316
+ def allowlist(klass, method, *fallbacks)
317
+ if permitted && [method, *fallbacks].none? { |m| klass.ransackable_list_defined?(m) }
318
+ yield
319
+ else
320
+ klass.public_send(method, auth_object)
321
+ end
322
+ end
323
+
324
+ def resolve_alias_segment(segment)
325
+ target = ransackable_alias(segment)
326
+ return target if target != segment
327
+
328
+ path = association_path(segment)
329
+ return segment if path.blank?
330
+
331
+ remainder = segment.delete_prefix("#{path}_")
332
+ associated = traverse(path)
333
+ target = associated._ransack_aliases.fetch(remainder, associated._ransack_aliases.fetch(remainder.to_sym, remainder))
334
+ return segment if target == remainder
335
+ # The target may itself be one attribute whose name contains a
336
+ # combinator (`terms_and_conditions`); it is prefixed whole.
337
+ return "#{path}_#{target}" if ransackable_attribute?(target, associated)
338
+
339
+ # Prefix every attribute of the alias's target with the path.
340
+ target.gsub(/(\A|_and_|_or_)/) { "#{$1}#{path}_" }
341
+ end
342
+
219
343
  def cast_scope_args(args)
220
344
  if args.is_a?(Array)
221
345
  args = args.map(&method(:cast_scope_args))
@@ -112,7 +112,6 @@ module Ransack
112
112
 
113
113
  def predicate_select(options = {}, html_options = {})
114
114
  options[:compounds] = true if options[:compounds].nil?
115
- options[:selected] ||= nil
116
115
  default = options.delete(:default) || Constants::CONT
117
116
 
118
117
  keys =
@@ -134,6 +133,7 @@ module Ransack
134
133
  collection = keys.map { |k| [k, Translate.predicate(k)] }
135
134
  object.predicate ||= Predicate.named(default) if
136
135
  can_use_default?(default, :predicate, keys)
136
+ options[:selected] = selected_predicate unless options.key?(:selected)
137
137
  template_collection_select(:p, collection, options, html_options)
138
138
  end
139
139
 
@@ -144,17 +144,25 @@ module Ransack
144
144
 
145
145
  private
146
146
 
147
+ # Rails reads a select's current value back through the field name, which
148
+ # for the predicate select is +p+. Only a Condition has a public +p+, and
149
+ # +Kernel#p+ is private, so a builder for a Search or Grouping would
150
+ # raise instead of finding nothing. Resolve the value here instead.
151
+ def selected_predicate
152
+ object.predicate_name if object.respond_to?(:predicate_name)
153
+ end
154
+
147
155
  def template_grouped_collection_select(collection, options, html_options)
148
156
  @template.grouped_collection_select(
149
157
  @object_name, :name, collection, :last, :first, :first, :last,
150
- objectify_options(options), @default_options.merge(html_options)
158
+ objectify_options(options), @default_html_options.merge(html_options)
151
159
  )
152
160
  end
153
161
 
154
162
  def template_collection_select(name, collection, options, html_options)
155
163
  @template.collection_select(
156
164
  @object_name, name, collection, :first, :last,
157
- objectify_options(options), @default_options.merge(html_options)
165
+ objectify_options(options), @default_html_options.merge(html_options)
158
166
  )
159
167
  end
160
168
 
@@ -188,31 +196,28 @@ module Ransack
188
196
  end
189
197
  end
190
198
 
199
+ # Expands the +associations:+ option of +attribute_select+ into the flat
200
+ # list of association paths ransack understands. A String or Symbol
201
+ # names one association; an Array lists several; a Hash reaches through
202
+ # an association to its own associations, so +{ articles: :comments }+
203
+ # yields +articles+ and +articles_comments+. Shapes nest freely.
191
204
  def association_array(obj, prefix = nil)
192
- ([prefix] + association_object(obj))
193
- .compact
194
- .flat_map { |v| [prefix, v].compact.join(Constants::UNDERSCORE) }
195
- end
196
-
197
- def association_object(obj)
198
- case obj
199
- when Array
200
- obj
201
- when Hash
202
- association_hash(obj)
203
- else
204
- [obj]
205
- end
206
- end
207
-
208
- def association_hash(obj)
209
- obj.map do |key, value|
210
- case value
211
- when Array, Hash
212
- association_array(value, key.to_s)
205
+ Array.wrap(obj).flat_map do |item|
206
+ case item
207
+ when Hash
208
+ association_hash(item, prefix)
209
+ when nil
210
+ []
213
211
  else
214
- [key.to_s, [key, value].join(Constants::UNDERSCORE)]
212
+ [[prefix, item].compact.join(Constants::UNDERSCORE)]
215
213
  end
214
+ end.uniq
215
+ end
216
+
217
+ def association_hash(obj, prefix = nil)
218
+ obj.flat_map do |key, value|
219
+ path = [prefix, key].compact.join(Constants::UNDERSCORE)
220
+ [path] + association_array(value, path)
216
221
  end
217
222
  end
218
223
 
@@ -44,7 +44,6 @@ module Ransack
44
44
  end
45
45
 
46
46
  def is_alias_attribute?
47
- Ransack::SUPPORTS_ATTRIBUTE_ALIAS &&
48
47
  parent.base_klass.attribute_aliases.key?(attr_name)
49
48
  end
50
49
  end