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
@@ -9,6 +9,18 @@ module Ransack
9
9
 
10
10
  attr_accessor :predicate
11
11
 
12
+ # The effective null sentinel, captured when the condition is built. A
13
+ # Context can be shared by several searches, each with its own
14
+ # per-search override; reading the context lazily at SQL generation or
15
+ # form rendering time would let a later search's setting rewrite an
16
+ # earlier search's conditions.
17
+ attr_reader :null_sentinel
18
+
19
+ def initialize(context)
20
+ super
21
+ @null_sentinel = context&.null_sentinel
22
+ end
23
+
12
24
  class << self
13
25
  def extract(context, key, values)
14
26
  attributes, predicate, combinator =
@@ -24,7 +36,7 @@ module Ransack
24
36
  )
25
37
  # TODO: Figure out what to do with multiple types of attributes,
26
38
  # if anything. Tempted to go with "garbage in, garbage out" here.
27
- if predicate.validate(condition.values, condition.default_type)
39
+ if predicate.validate(condition.values, condition.default_type, condition.null_sentinel)
28
40
  condition
29
41
  else
30
42
  nil
@@ -44,29 +56,41 @@ module Ransack
44
56
  end
45
57
 
46
58
  if context.present?
47
- str = context.ransackable_alias(str)
48
- end
49
-
50
- combinator =
51
- if str.match(/_(or|and)_/)
52
- $1
53
- else
54
- nil
59
+ str = context.resolve_aliases(str)
55
60
  end
56
61
 
62
+ # A name that is an attribute in its own right is never split,
63
+ # whatever it contains.
57
64
  if context.present? && context.attribute_method?(str)
58
65
  attributes = [str]
66
+ combinator = nil
59
67
  else
68
+ combinator = combinator_in(str, context)
60
69
  attributes = str.split(/_and_|_or_/)
61
70
  end
62
71
 
63
72
  [attributes, predicate, combinator]
64
73
  end
74
+
75
+ # A condition name carries one combinator: `a_or_b_or_c` or
76
+ # `a_and_b`. There is no grammar for precedence between the two,
77
+ # so a name using both is rejected under a strict search rather
78
+ # than silently applying whichever came first to every attribute
79
+ # (#1019). Mixed logic is what groupings are for.
80
+ def combinator_in(str, context)
81
+ combinators = str.scan(/_(or|and)_/).flatten.uniq
82
+ if combinators.size > 1 && context&.strict_conditions?
83
+ raise InvalidSearchError,
84
+ "Mixed combinators in #{str}: a condition name may use " \
85
+ "_and_ or _or_ but not both; use groupings for mixed logic"
86
+ end
87
+ combinators.first
88
+ end
65
89
  end
66
90
 
67
91
  def valid?
68
92
  attributes.detect(&:valid?) && predicate && valid_arity? &&
69
- predicate.validate(values, default_type) && valid_combinator?
93
+ predicate.validate(values, default_type, null_sentinel) && valid_combinator?
70
94
  end
71
95
 
72
96
  def valid_arity?
@@ -84,14 +108,14 @@ module Ransack
84
108
  args.each do |attr|
85
109
  if attr.is_a?(Hash) && (attr.key?(:name) || attr.key?("name"))
86
110
  attr = attr.with_indifferent_access
87
- build_attribute(attr[:name], attr[:ransacker_args])
111
+ build_attributes(attr[:name], attr[:ransacker_args])
88
112
  else
89
- build_attribute(attr)
113
+ build_attributes(attr)
90
114
  end
91
115
  end
92
116
  when Hash
93
117
  args.each do |index, attrs|
94
- build_attribute(attrs[:name], attrs[:ransacker_args])
118
+ build_attributes(attrs[:name], attrs[:ransacker_args])
95
119
  end
96
120
  else
97
121
  raise ArgumentError,
@@ -126,12 +150,21 @@ module Ransack
126
150
  end
127
151
  alias :v= :values=
128
152
 
153
+ # A supplied combinator wins; the one an expanded alias carried is the
154
+ # fallback, so `a: { '0' => { name: 'term' } }` with `term` aliasing
155
+ # `name_or_email` ORs its two attributes without an `m` in the params.
156
+ # The fallback covers a missing or blank `m` only: an unknown one that
157
+ # `Node#combinator=` normalised to nil still fails `valid_combinator?`,
158
+ # as it does for any other multi-attribute condition.
129
159
  def combinator
130
- @attributes.size > 1 ? @combinator : nil
160
+ return nil unless @attributes.size > 1
161
+
162
+ @combinator || (@alias_combinator unless @combinator_supplied)
131
163
  end
132
164
 
133
165
  def combinator=(val)
134
166
  super
167
+ @combinator_supplied = !val.to_s.strip.empty?
135
168
  end
136
169
 
137
170
  alias :m= :combinator=
@@ -155,6 +188,25 @@ module Ransack
155
188
  # TODO: Add test coverage for this behavior and ensure that `name.nil?`
156
189
  # isn't fixing issue #701 by introducing untested regressions.
157
190
  #
191
+ # An attribute named in advanced-search params (`a: { '0' => { name:
192
+ # 'term' } }`) arrives raw, unlike a simple key, which `Condition.extract`
193
+ # resolves before it gets here. So a `ransack_alias` is expanded here the
194
+ # same way: an alias to one attribute is built under that name, and an
195
+ # alias to a compound becomes one attribute per segment, joined by the
196
+ # compound's combinator (#1728). Left alone, the alias passed `valid?`
197
+ # by being allowlisted and bound a column that does not exist.
198
+ def build_attributes(name, ransacker_args = [])
199
+ return build_attribute(name, ransacker_args) if name.blank? || @context.nil?
200
+
201
+ resolved = @context.resolve_aliases(name.to_s)
202
+ if resolved == name.to_s || @context.attribute_method?(resolved)
203
+ build_attribute(resolved, ransacker_args)
204
+ else
205
+ @alias_combinator = resolved[/_(or|and)_/, 1]
206
+ resolved.split(/_and_|_or_/).each { |segment| build_attribute(segment, ransacker_args) }
207
+ end
208
+ end
209
+
158
210
  def build_attribute(name = nil, ransacker_args = [])
159
211
  Attribute.new(@context, name, ransacker_args).tap do |attribute|
160
212
  @context.bind(attribute, attribute.name)
@@ -173,22 +225,37 @@ module Ransack
173
225
 
174
226
  def value
175
227
  if predicate.wants_array
176
- values.map { |v| v.cast(default_type) }
228
+ values.map { |v| cast_unless_null_sentinel(v) }
177
229
  else
178
- values.first.cast(default_type)
230
+ cast_unless_null_sentinel(values.first)
231
+ end
232
+ end
233
+
234
+ # Casting the sentinel here would turn it into a real value on a
235
+ # typed column (e.g. an integer column casts it to 0), so a form
236
+ # re-rendering this condition's current value would resubmit that
237
+ # literal instead of the sentinel.
238
+ def cast_unless_null_sentinel(v)
239
+ if Constants.null_sentinel_predicate?(predicate.name, null_sentinel) &&
240
+ Constants.null_sentinel_value?(v.value, null_sentinel)
241
+ v.value
242
+ else
243
+ v.cast(default_type)
179
244
  end
180
245
  end
181
246
 
182
247
  # The long spellings route through the short setters: `p=` resolves a
183
248
  # Predicate object, whereas the `predicate=` attribute writer would store
184
- # the bare name.
249
+ # the bare name. `predicate_name` is the spelling the ransacker docs
250
+ # used for years, so it is accepted too (#1009).
185
251
  LONG_KEYS = {
186
- 'attributes' => 'a', 'values' => 'v', 'predicate' => 'p', 'combinator' => 'm'
252
+ 'attributes' => 'a', 'values' => 'v', 'predicate' => 'p',
253
+ 'predicate_name' => 'p', 'combinator' => 'm'
187
254
  }.freeze
188
255
 
189
256
  def build(params)
190
257
  params.with_indifferent_access.each do |key, value|
191
- if key.match(/^(a|v|p|m|attributes|values|predicate|combinator)$/)
258
+ if key.match(/^(a|v|p|m|attributes|values|predicate|predicate_name|combinator)$/)
192
259
  self.send("#{LONG_KEYS.fetch(key, key)}=", value)
193
260
  end
194
261
  end
@@ -220,7 +287,7 @@ module Ransack
220
287
 
221
288
  def predicate_name=(name)
222
289
  self.predicate = Predicate.named(name)
223
- unless negative?
290
+ if predicate && !negative?
224
291
  attributes.each { |a| context.lock_association(a.parent) }
225
292
  end
226
293
  @predicate
@@ -233,7 +300,17 @@ module Ransack
233
300
  alias :p :predicate_name
234
301
 
235
302
  def validated_values
236
- values.select { |v| predicate.validator.call(v.value) }
303
+ selected = values.select { |v| predicate.validator.call(v.value) }
304
+ return selected unless null_sentinel_requested?
305
+
306
+ # The sentinel is a marker to detect, never a value to search for
307
+ # or to cast to the column's type, so it is excluded from the
308
+ # values #format_predicate builds the ordinary IN/EQ node from.
309
+ selected.reject { |v| Constants.null_sentinel_value?(v.value, null_sentinel) }
310
+ end
311
+
312
+ def null_sentinel_requested?
313
+ Constants.null_sentinel_requested?(predicate.name, values, null_sentinel)
237
314
  end
238
315
 
239
316
  def casted_values_for_attribute(attr)
@@ -270,12 +347,18 @@ module Ransack
270
347
  end
271
348
  end
272
349
 
350
+ # The column (or ransacker expression) a predicate compares against.
351
+ # A case-insensitive predicate lowers it, unless the dialect has a
352
+ # case-insensitive LIKE of its own (ILIKE), in which case the lowering
353
+ # is left to the database. LOWER() is applied as a function rather than
354
+ # through Arel's Attribute#lower so that a ransacker returning any Arel
355
+ # node — a SQL literal, an infix operation — is lowered too (#1357).
273
356
  def attr_value_for_attribute(attr)
274
- return attr.attr if ActiveRecord::Base.adapter_class::ADAPTER_NAME == "PostgreSQL"
275
-
276
- predicate.case_insensitive ? attr.attr.lower : attr.attr
277
- rescue
278
- attr.attr
357
+ if predicate.case_insensitive && !context.dialect.case_insensitive_like?
358
+ Arel::Nodes::NamedFunction.new('LOWER'.freeze, [attr.attr])
359
+ else
360
+ attr.attr
361
+ end
279
362
  end
280
363
 
281
364
  def default_type
@@ -318,7 +401,7 @@ module Ransack
318
401
  Arel::Nodes::NotIn.new(context.primary_key, Arel.sql(query.to_sql))
319
402
  end
320
403
  when 'not_cont'
321
- query.where(attribute.attr.matches(formatted_values_for_attribute(attribute)))
404
+ query.where(like_predicate(attribute, 'matches'.freeze))
322
405
  Arel::Nodes::NotIn.new(context.primary_key, Arel.sql(query.to_sql))
323
406
  else
324
407
  query.where(format_predicate(attribute).not)
@@ -328,19 +411,6 @@ module Ransack
328
411
  format_predicate(attribute)
329
412
  end
330
413
 
331
- # Applied per attribute rather than to the reduced node: once several
332
- # attributes are combined, the result is an And/Or whose `right` is
333
- # another predicate node rather than a Casted value, so
334
- # replace_right_node? returns false and nothing is unwrapped at all.
335
- if replace_right_node?(predicate)
336
- # Replace right node object to plain integer value in order to avoid
337
- # ActiveModel::RangeError from Arel::Node::Casted.
338
- # The error can be ignored here because RDBMSs accept large numbers
339
- # in condition clauses.
340
- plain_value = predicate.right.value
341
- predicate.right = plain_value
342
- end
343
-
344
414
  predicate
345
415
  }.reduce(combinator_method)
346
416
  end
@@ -368,26 +438,11 @@ module Ransack
368
438
  arel_values = arel_values.reject { |v| v == ''.freeze }
369
439
  end
370
440
 
371
- # For LIKE predicates, wrap the value in Arel::Nodes.build_quoted to prevent
372
- # ActiveRecord normalization from affecting wildcard patterns, and pass
373
- # the escape character so the escaping done by `escape_wildcards` is
374
- # actually honoured. Without an explicit ESCAPE clause, SQLite treats a
375
- # backslash as a literal character rather than an escape.
376
- # See https://github.com/activerecord-hackery/ransack/issues/1581
377
441
  # A length predicate compares LENGTH(column) rather than the column.
378
442
  attr_value = length_predicate? ? length_function_for_attribute(attribute) : attr_value_for_attribute(attribute)
379
443
 
380
444
  if like_predicate?(arel_pred)
381
- # The compound forms (matches_any / matches_all and their negations)
382
- # iterate over an Array of patterns, so each element is quoted on its
383
- # own; wrapping the whole Array in one node would hand them a single
384
- # Quoted to iterate over.
385
- arel_values = if arel_values.is_a?(Array)
386
- arel_values.map { |v| Arel::Nodes.build_quoted(v) }
387
- else
388
- Arel::Nodes.build_quoted(arel_values)
389
- end
390
- predicate = attr_value.public_send(arel_pred, arel_values, Constants::LIKE_ESCAPE_CHARACTER)
445
+ predicate = like_predicate(attribute, arel_pred, arel_values)
391
446
  else
392
447
  predicate = attr_value.public_send(arel_pred, arel_values)
393
448
  end
@@ -398,9 +453,39 @@ module Ransack
398
453
  end
399
454
  end
400
455
 
456
+ predicate = null_sentinel_requested? ? apply_null_sentinel(predicate, attr_value) : predicate
457
+
458
+ # Replace an oversized Casted integer value with the plain integer,
459
+ # in order to avoid ActiveModel::RangeError from Arel::Node::Casted.
460
+ # The error can be ignored here because RDBMSs accept large numbers
461
+ # in condition clauses. Applied recursively rather than only to the
462
+ # top-level node, because the Casted value can sit inside an In /
463
+ # NotIn node's `right` Array, or inside the Grouping / And / Or
464
+ # that Arel's own `_any` / `_all` predicates build. Done here rather
465
+ # than in #arel_predicate because the negated collection branch there
466
+ # renders this node to SQL for its correlated subquery straight away,
467
+ # which is where the Casted value would raise. Runs last so it also
468
+ # covers the OR grouping the null sentinel may have added.
469
+ unwrap_oversized_integers(predicate)
470
+
401
471
  predicate
402
472
  end
403
473
 
474
+ # `validated_values` has already excluded the sentinel above, so
475
+ # `predicate` here is built from the real values alone (or, when
476
+ # the sentinel was the only value submitted, from an empty
477
+ # array). An empty IN or eq_any/in_any is not `IS NULL` on its
478
+ # own terms (an empty `IN` is a literal false, and an empty
479
+ # `eq_any` builds a broken Grouping), so that case is replaced
480
+ # outright rather than OR'd.
481
+ def apply_null_sentinel(predicate, attr_value)
482
+ if validated_values.empty?
483
+ attr_value.eq(nil)
484
+ else
485
+ predicate.or(attr_value.eq(nil))
486
+ end
487
+ end
488
+
404
489
  def in_predicate?(predicate)
405
490
  return unless defined?(Arel::Nodes::Casted)
406
491
  predicate.class == Arel::Nodes::In || predicate.class == Arel::Nodes::NotIn
@@ -415,6 +500,44 @@ module Ransack
415
500
  LIKE_PREDICATES.include?(arel_predicate)
416
501
  end
417
502
 
503
+ # Builds a LIKE / NOT LIKE node (or the _any / _all forms).
504
+ #
505
+ # The value is wrapped in Arel::Nodes.build_quoted so that Active
506
+ # Record's `normalizes` cannot rewrite the wildcards, and the escape
507
+ # character is passed so the escaping done by `escape_wildcards` is
508
+ # honoured: without an explicit ESCAPE clause SQLite treats a backslash
509
+ # as a literal character (#1581). The compound forms iterate over an
510
+ # Array of patterns, so each element is quoted on its own.
511
+ #
512
+ # Only the `i_` predicates ask for a case-insensitive match. Arel's
513
+ # PostgreSQL visitor renders a case-sensitive `matches` as LIKE and the
514
+ # other kind as ILIKE; before 6.0 the flag was never passed and every
515
+ # LIKE on PostgreSQL became ILIKE (#1421).
516
+ def like_predicate(attribute, arel_predicate, values = formatted_values_for_attribute(attribute))
517
+ attr_value = attr_value_for_attribute(attribute)
518
+ escape = Constants::LIKE_ESCAPE_CHARACTER
519
+ case_sensitive = !predicate.case_insensitive
520
+
521
+ case arel_predicate
522
+ # Arel's does_not_match_any / _all take no case_sensitive argument,
523
+ # unlike the other four, so they are assembled here the same way Arel
524
+ # assembles them.
525
+ when 'does_not_match_any'.freeze
526
+ nodes = values.map { |v| attr_value.does_not_match(Arel::Nodes.build_quoted(v), escape, case_sensitive) }
527
+ Arel::Nodes::Grouping.new(nodes.inject(&:or))
528
+ when 'does_not_match_all'.freeze
529
+ nodes = values.map { |v| attr_value.does_not_match(Arel::Nodes.build_quoted(v), escape, case_sensitive) }
530
+ Arel::Nodes::Grouping.new(Arel::Nodes::And.new(nodes))
531
+ else
532
+ quoted = if values.is_a?(Array)
533
+ values.map { |v| Arel::Nodes.build_quoted(v) }
534
+ else
535
+ Arel::Nodes.build_quoted(values)
536
+ end
537
+ attr_value.public_send(arel_predicate, quoted, escape, case_sensitive)
538
+ end
539
+ end
540
+
418
541
  STRING_LIKE_TYPES = %i[string text citext].freeze
419
542
 
420
543
  def string_like_attribute?(attribute)
@@ -434,35 +557,54 @@ module Ransack
434
557
  end
435
558
  end
436
559
 
437
- def replace_right_node?(predicate)
438
- return false unless predicate.is_a?(Arel::Nodes::Binary)
560
+ # Walks a predicate node for the shapes `#arel_predicate` can produce
561
+ # (a single Binary, an In/NotIn holding an Array, or several of these
562
+ # combined with Grouping/And/Or) and replaces each integer Casted
563
+ # value it finds with the plain integer, in place.
564
+ def unwrap_oversized_integers(node)
565
+ case node
566
+ when Arel::Nodes::Grouping
567
+ unwrap_oversized_integers(node.expr)
568
+ when Arel::Nodes::And, Arel::Nodes::Or
569
+ # Both are Nary: `grouping_any` (behind every `_any` predicate)
570
+ # builds a single Or with one child per value, not a pairwise
571
+ # chain, so `left`/`right` alone would miss the third value on.
572
+ node.children.each { |child| unwrap_oversized_integers(child) }
573
+ when Arel::Nodes::In, Arel::Nodes::NotIn
574
+ if node.right.is_a?(Array)
575
+ node.right = node.right.map { |v| plain_integer(v) || v }
576
+ else
577
+ # In / NotIn are Binary subclasses and match before that arm, so a
578
+ # bare Casted right (not an Array) needs the same handling as one.
579
+ unwrap_binary_right(node)
580
+ end
581
+ when Arel::Nodes::Binary
582
+ unwrap_binary_right(node)
583
+ end
584
+ end
439
585
 
440
- arel_node = predicate.right
441
- return false unless arel_node.is_a?(Arel::Nodes::Casted)
586
+ def unwrap_binary_right(node)
587
+ plain_value = plain_integer(node.right)
588
+ node.right = plain_value if plain_value
589
+ end
590
+
591
+ def plain_integer(arel_node)
592
+ return nil unless arel_node.is_a?(Arel::Nodes::Casted)
442
593
 
443
594
  relation, name = arel_node.attribute.values
444
595
  attribute_type = relation.type_for_attribute(name).type
445
- attribute_type == :integer && arel_node.value.is_a?(Integer)
596
+ return nil unless attribute_type == :integer && arel_node.value.is_a?(Integer)
597
+
598
+ arel_node.value
446
599
  end
447
600
 
448
601
  def length_predicate?
449
602
  predicate_name.to_s.start_with?('length_')
450
603
  end
451
604
 
452
- # CHAR_LENGTH counts characters and is the SQL standard spelling; SQLite
453
- # has no CHAR_LENGTH and its LENGTH already counts characters for text.
454
- CHAR_LENGTH_ADAPTERS = %w[PostgreSQL PostGIS Mysql2 Trilogy].freeze
455
-
456
605
  def length_function_for_attribute(attribute)
457
- function_name =
458
- if CHAR_LENGTH_ADAPTERS.include?(ActiveRecord::Base.adapter_class::ADAPTER_NAME)
459
- 'CHAR_LENGTH'.freeze
460
- else
461
- 'LENGTH'.freeze
462
- end
463
-
464
606
  Arel::Nodes::NamedFunction.new(
465
- function_name,
607
+ context.dialect.length_function,
466
608
  [attr_value_for_attribute(attribute)]
467
609
  )
468
610
  end
@@ -38,15 +38,9 @@ module Ransack
38
38
  def conditions=(conditions)
39
39
  case conditions
40
40
  when Array
41
- conditions.each do |attrs|
42
- condition = Condition.new(@context).build(attrs)
43
- self.conditions << condition if condition.valid?
44
- end
41
+ conditions.each { |attrs| add_condition(attrs) }
45
42
  when Hash
46
- conditions.each do |index, attrs|
47
- condition = Condition.new(@context).build(attrs)
48
- self.conditions << condition if condition.valid?
49
- end
43
+ conditions.each_value { |attrs| add_condition(attrs) }
50
44
  end
51
45
  remove_duplicate_conditions!
52
46
  end
@@ -126,7 +120,9 @@ module Ransack
126
120
  end
127
121
 
128
122
  def attribute_method?(name)
129
- stripped_name = strip_predicate_and_index(name)
123
+ return false unless @context.key_within_depth_limit?(name)
124
+
125
+ stripped_name = @context.resolve_aliases(strip_predicate_and_index(name))
130
126
  return true if @context.attribute_method?(stripped_name) ||
131
127
  @context.attribute_method?(name)
132
128
  case stripped_name
@@ -192,7 +188,7 @@ module Ransack
192
188
  if self[name].nil?
193
189
  stripped_name = name.dup
194
190
  predicate = Predicate.detect_and_strip_from_string!(stripped_name)
195
- aliased_attribute = context.ransackable_alias(stripped_name)
191
+ aliased_attribute = context.resolve_aliases(stripped_name)
196
192
  name = "#{aliased_attribute}_#{predicate}" unless aliased_attribute == stripped_name
197
193
  end
198
194
 
@@ -203,12 +199,40 @@ module Ransack
203
199
  end
204
200
  end
205
201
 
202
+ # Drops a multiparameter index (`created_at(1i)`) and then the predicate
203
+ # from a key. The index is found by a plain scan. The `/(.+?)\(/` this
204
+ # replaces let its lazy group re-run from every position of a long key,
205
+ # which CodeQL reports as polynomial (rb/polynomial-redos); Ruby's engine
206
+ # short-circuits that in practice, but one pass needs no such help.
206
207
  def strip_predicate_and_index(str)
207
- string = str[/(.+?)\(/, 1] || str.dup
208
+ paren = str.index('(')
209
+ string = paren&.positive? ? str[0...paren] : str.dup
208
210
  Predicate.detect_and_strip_from_string!(string)
209
211
  string
210
212
  end
211
213
 
214
+ # A condition is dropped when it fails validation, as an unknown
215
+ # attribute or predicate would be. One failure is worth naming: a
216
+ # single-value predicate given several values. A strict search raises
217
+ # for it, so the caller learns why the filter vanished (#1724). The
218
+ # check only applies once the predicate and an attribute have resolved;
219
+ # a condition that is invalid for another reason keeps its behaviour.
220
+ def add_condition(attrs)
221
+ condition = Condition.new(@context).build(attrs)
222
+ if condition.valid?
223
+ self.conditions << condition
224
+ elsif @context.strict_conditions? && arity_failure?(condition)
225
+ raise InvalidSearchError,
226
+ "Predicate #{condition.predicate_name} takes a single value, " \
227
+ "#{condition.values.size} given for #{condition.attributes.map(&:name).join(', ')}"
228
+ end
229
+ end
230
+
231
+ def arity_failure?(condition)
232
+ condition.predicate && condition.attributes.any?(&:valid?) &&
233
+ !condition.valid_arity?
234
+ end
235
+
212
236
  def remove_duplicate_conditions!
213
237
  # If self.conditions.uniq! is called without passing a block, then
214
238
  # conditions differing only by ransacker_args within attributes are
@@ -30,7 +30,7 @@ module Ransack
30
30
  end
31
31
 
32
32
  def name=(name)
33
- @name = context.ransackable_alias(name) || name
33
+ @name = context.resolve_aliases(name.to_s)
34
34
  context.bind(self, @name)
35
35
  end
36
36
 
@@ -61,7 +61,14 @@ module Ransack
61
61
  Time.zone.local(*val) rescue nil
62
62
  else
63
63
  unless val.acts_like?(:time)
64
- val = val.is_a?(String) ? Time.zone.parse(val) : val.to_time rescue val
64
+ val = case val
65
+ when String then Time.zone.parse(val)
66
+ # A Date means midnight in Time.zone. Date#to_time would
67
+ # give midnight in the process's system zone, which then
68
+ # shifts by the offset between the two (#1436).
69
+ when Date then val.in_time_zone
70
+ else val.to_time
71
+ end rescue val
65
72
  end
66
73
  val.in_time_zone rescue nil
67
74
  end
@@ -73,12 +73,19 @@ module Ransack
73
73
  end
74
74
  end
75
75
 
76
- def validate(vals, type = @type)
76
+ def validate(vals, type = @type, sentinel = Ransack.options[:null_sentinel])
77
77
  # An explicitly empty array is a meaningful filter (matching nothing)
78
78
  # rather than an absent one, but only when blank values are not ignored.
79
79
  return true if vals.empty? && wants_array &&
80
80
  !Ransack.options[:ignore_blank_values]
81
81
 
82
+ # The null sentinel is a marker compared by identity, never cast to
83
+ # the column's type. Casting it here would be the only place it is
84
+ # cast at all: a cast to :date or :integer turns most strings into
85
+ # nil, which the validator then rejects, dropping the whole condition
86
+ # when the sentinel is the only value submitted (#940).
87
+ return true if Constants.null_sentinel_requested?(name, vals, sentinel)
88
+
82
89
  # When blank values are meaningful, validate the value as given. Casting
83
90
  # first would turn '' into nil for an integer or boolean column and the
84
91
  # explicit blank would be dropped — leaving no condition at all.