ransack 6.0.1 → 6.1.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +43 -68
  3. data/lib/ransack/active_record/context.rb +207 -75
  4. data/lib/ransack/active_record/join_association.rb +8 -17
  5. data/lib/ransack/active_record/join_dependency.rb +163 -9
  6. data/lib/ransack/configuration.rb +25 -2
  7. data/lib/ransack/context.rb +55 -7
  8. data/lib/ransack/errors.rb +13 -0
  9. data/lib/ransack/invalid_search_error.rb +3 -3
  10. data/lib/ransack/locale/ar.yml +35 -0
  11. data/lib/ransack/locale/az.yml +36 -1
  12. data/lib/ransack/locale/bg.yml +35 -0
  13. data/lib/ransack/locale/ca.yml +36 -1
  14. data/lib/ransack/locale/cs.yml +35 -0
  15. data/lib/ransack/locale/da.yml +35 -0
  16. data/lib/ransack/locale/de.yml +36 -1
  17. data/lib/ransack/locale/el.yml +36 -1
  18. data/lib/ransack/locale/en.yml +35 -0
  19. data/lib/ransack/locale/es.yml +36 -1
  20. data/lib/ransack/locale/fa.yml +35 -0
  21. data/lib/ransack/locale/fi.yml +35 -0
  22. data/lib/ransack/locale/fr.yml +35 -0
  23. data/lib/ransack/locale/hu.yml +35 -0
  24. data/lib/ransack/locale/id.yml +35 -0
  25. data/lib/ransack/locale/it.yml +35 -0
  26. data/lib/ransack/locale/ja.yml +35 -0
  27. data/lib/ransack/locale/ko.yml +35 -0
  28. data/lib/ransack/locale/nl.yml +35 -0
  29. data/lib/ransack/locale/pt-BR.yml +35 -0
  30. data/lib/ransack/locale/ro.yml +35 -0
  31. data/lib/ransack/locale/ru.yml +35 -0
  32. data/lib/ransack/locale/sk.yml +35 -0
  33. data/lib/ransack/locale/sv.yml +35 -0
  34. data/lib/ransack/locale/tr.yml +36 -1
  35. data/lib/ransack/locale/uk.yml +35 -0
  36. data/lib/ransack/locale/zh-CN.yml +35 -0
  37. data/lib/ransack/locale/zh-TW.yml +35 -0
  38. data/lib/ransack/nodes/condition.rb +168 -42
  39. data/lib/ransack/nodes/grouping.rb +8 -17
  40. data/lib/ransack/nodes/node.rb +23 -1
  41. data/lib/ransack/nodes/sort.rb +7 -4
  42. data/lib/ransack/nodes/value.rb +6 -0
  43. data/lib/ransack/predicate.rb +19 -6
  44. data/lib/ransack/search.rb +21 -10
  45. data/lib/ransack/translate.rb +43 -35
  46. data/lib/ransack/version.rb +1 -1
  47. data/lib/ransack.rb +2 -1
  48. metadata +7 -6
@@ -99,7 +99,7 @@ module Ransack
99
99
  self.groupings << grouping_object if grouping_object.values.any?
100
100
  end
101
101
  else
102
- raise ArgumentError,
102
+ raise InvalidSearchError,
103
103
  "Invalid argument (#{groupings.class}) supplied to groupings="
104
104
  end
105
105
  end
@@ -107,7 +107,7 @@ module Ransack
107
107
 
108
108
  def method_missing(method_id, *args)
109
109
  method_name = method_id.to_s.dup
110
- writer = method_name.sub!(/\=$/, ''.freeze)
110
+ writer = method_name.sub!(/=\z/, ''.freeze)
111
111
  if attribute_method?(method_name)
112
112
  if writer
113
113
  write_attribute(method_name, *args)
@@ -126,7 +126,7 @@ module Ransack
126
126
  return true if @context.attribute_method?(stripped_name) ||
127
127
  @context.attribute_method?(name)
128
128
  case stripped_name
129
- when /^(g|c|m|groupings|conditions|combinator)=?$/
129
+ when /\A(g|c|m|groupings|conditions|combinator)=?\z/
130
130
  true
131
131
  else
132
132
  stripped_name
@@ -147,9 +147,9 @@ module Ransack
147
147
  end
148
148
 
149
149
  def build(params)
150
- params.with_indifferent_access.each do |key, value|
150
+ indifferent_params(params, 'grouping').each do |key, value|
151
151
  case key
152
- when /^(g|c|m|groupings|conditions|combinator)$/
152
+ when /\A(g|c|m|groupings|conditions|combinator)\z/
153
153
  self.send("#{key}=", value)
154
154
  else
155
155
  if @context.ransackable_scope?(key, @context.object)
@@ -214,25 +214,16 @@ module Ransack
214
214
  # A condition is dropped when it fails validation, as an unknown
215
215
  # attribute or predicate would be. One failure is worth naming: a
216
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.
217
+ # for it, so the caller learns why the filter vanished (#1724).
220
218
  def add_condition(attrs)
221
219
  condition = Condition.new(@context).build(attrs)
222
220
  if condition.valid?
223
221
  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(', ')}"
222
+ elsif @context.strict_conditions? && condition.arity_failure?
223
+ raise condition.arity_error
228
224
  end
229
225
  end
230
226
 
231
- def arity_failure?(condition)
232
- condition.predicate && condition.attributes.any?(&:valid?) &&
233
- !condition.valid_arity?
234
- end
235
-
236
227
  def remove_duplicate_conditions!
237
228
  # If self.conditions.uniq! is called without passing a block, then
238
229
  # conditions differing only by ransacker_args within attributes are
@@ -34,7 +34,7 @@ module Ransack
34
34
  # this is the one place that normalises the spelling and, when the search
35
35
  # is strict, rejects a value it does not recognise.
36
36
  def combinator=(val)
37
- normalised = val.to_s.downcase
37
+ normalised = scalar_param(val, 'combinator').to_s.downcase
38
38
  @combinator = Constants::AND_OR.detect { |v| v == normalised }
39
39
 
40
40
  if @combinator.nil? && !normalised.empty? && context&.strict_conditions?
@@ -42,6 +42,28 @@ module Ransack
42
42
  end
43
43
  end
44
44
 
45
+ private
46
+
47
+ # Search params usually come from the request, so a grouping, condition
48
+ # or sort given as something other than a hash (`q[g]=x`) is a malformed
49
+ # search, not a programming error, whether or not the search is strict.
50
+ def indifferent_params(params, node_name)
51
+ return params.with_indifferent_access if params.respond_to?(:with_indifferent_access)
52
+
53
+ raise InvalidSearchError,
54
+ "Invalid argument (#{params.class}) supplied for a #{node_name}"
55
+ end
56
+
57
+ # A name, predicate, direction or combinator is a single value. Given an
58
+ # array or a hash (`q[s][0][dir][]=desc`), it used to be stringified and
59
+ # then silently ignored or defaulted, so the search ran as something
60
+ # other than what was asked for.
61
+ def scalar_param(value, field)
62
+ return value unless value.is_a?(Enumerable) || value.respond_to?(:to_unsafe_h)
63
+
64
+ raise InvalidSearchError, "Invalid argument (#{value.class}) supplied for #{field}"
65
+ end
66
+
45
67
  end
46
68
  end
47
69
  end
@@ -15,11 +15,14 @@ module Ransack
15
15
  end
16
16
 
17
17
  def build(params)
18
- params.with_indifferent_access.each do |key, value|
19
- if key.match(/^(name|dir|ransacker_args)$/)
18
+ indifferent_params(params, 'sort').each do |key, value|
19
+ if key.match(/\A(name|dir|ransacker_args)\z/)
20
20
  self.send("#{key}=", value)
21
21
  end
22
22
  end
23
+ # A sort given without a direction is ascending, as it is from a
24
+ # string (`s: 'name'`).
25
+ self.dir = nil unless defined?(@dir)
23
26
 
24
27
  self
25
28
  end
@@ -30,12 +33,12 @@ module Ransack
30
33
  end
31
34
 
32
35
  def name=(name)
33
- @name = context.resolve_aliases(name.to_s)
36
+ @name = context.resolve_aliases(scalar_param(name, 'a sort name').to_s)
34
37
  context.bind(self, @name)
35
38
  end
36
39
 
37
40
  def dir=(dir)
38
- dir = dir.downcase if dir
41
+ dir = scalar_param(dir, 'a sort direction').to_s.downcase
39
42
  @dir =
40
43
  if dir == 'asc'.freeze || dir == 'desc'.freeze
41
44
  dir
@@ -4,6 +4,12 @@ module Ransack
4
4
  attr_accessor :value
5
5
  delegate :present?, :blank?, to: :value
6
6
 
7
+ # The types whose cast builds one value from an Array: a multiparameter
8
+ # date or time (`created_at(1i)`, `created_at(2i)`, ...) arrives as one.
9
+ ARRAY_CAST_TYPES = %i[
10
+ date datetime timestamp time timestamptz datetimeoffset
11
+ ].freeze
12
+
7
13
  def initialize(context, value = nil)
8
14
  super(context)
9
15
  @value = value
@@ -1,7 +1,7 @@
1
1
  module Ransack
2
2
  class Predicate
3
- attr_reader :name, :arel_predicate, :type, :formatter, :validator,
4
- :compound, :wants_array, :case_insensitive
3
+ attr_reader :name, :arel_predicate, :arel_node, :type, :formatter,
4
+ :validator, :compound, :wants_array, :case_insensitive
5
5
 
6
6
  # Consulted at search time rather than captured at predicate-definition
7
7
  # time, so that `Ransack.options[:ignore_blank_values]` set in an
@@ -28,14 +28,26 @@ module Ransack
28
28
  detect_from_string str, chomp: true
29
29
  end
30
30
 
31
+ # The longest registered predicate the key ends with, as `_<name>`.
32
+ # Only the tail a predicate name can fill is looked at, and each
33
+ # underscore there is one hash lookup, so the cost depends on neither
34
+ # the length of the key nor the number of predicates. The first
35
+ # underscore found gives the longest candidate, which is the one the
36
+ # longest-first scan this replaces would have picked.
31
37
  def detect_from_string(str, chomp: false)
32
38
  return unless str
33
39
 
34
- Ransack.predicates.sorted_names_with_underscores.each do |predicate, underscored|
35
- if str.end_with? underscored
36
- str.chomp! underscored if chomp
37
- return predicate
40
+ str = str.to_s unless chomp
41
+ predicates = Ransack.predicates
42
+ from = str.length - predicates.max_name_length - 1
43
+ underscore = str.index(Constants::UNDERSCORE, from.negative? ? 0 : from)
44
+ while underscore
45
+ name = str[(underscore + 1)..]
46
+ if predicates.has_key?(name)
47
+ str.slice!(underscore..) if chomp
48
+ return name
38
49
  end
50
+ underscore = str.index(Constants::UNDERSCORE, underscore + 1)
39
51
  end
40
52
 
41
53
  nil
@@ -46,6 +58,7 @@ module Ransack
46
58
  def initialize(opts = {})
47
59
  @name = opts[:name]
48
60
  @arel_predicate = opts[:arel_predicate]
61
+ @arel_node = opts[:arel_node]
49
62
  @type = opts[:type]
50
63
  @formatter = opts[:formatter]
51
64
  @validator = opts[:validator] || DEFAULT_VALIDATOR
@@ -7,7 +7,7 @@ require 'ransack/nodes/sort'
7
7
  require 'ransack/nodes/grouping'
8
8
  require 'ransack/context'
9
9
  require 'ransack/naming'
10
- require 'ransack/invalid_search_error'
10
+ require 'ransack/errors'
11
11
 
12
12
  module Ransack
13
13
  class Search
@@ -57,14 +57,23 @@ module Ransack
57
57
  @context.evaluate(self, opts)
58
58
  end
59
59
 
60
+ GROUPING_KEYS = %w[g c m groupings conditions combinator].to_set.freeze
61
+ private_constant :GROUPING_KEYS
62
+
60
63
  def build(params)
61
64
  collapse_multiparameter_attributes!(params).each do |key, value|
62
65
  if ['s'.freeze, 'sorts'.freeze].freeze.include?(key)
63
66
  send("#{key}=", value)
64
67
  elsif @context.ransackable_scope?(key, @context.object)
65
68
  add_scope(key, value)
66
- elsif base.attribute_method?(key)
69
+ elsif GROUPING_KEYS.include?(key)
67
70
  base.send("#{key}=", value)
71
+ elsif base.attribute_method?(key)
72
+ # Straight to the writer: going through the grouping's
73
+ # method_missing would check the key a second time, and would
74
+ # send a key that happens to name one of its own writers
75
+ # (`i18n_words`) there instead of building a condition.
76
+ base.send(:write_attribute, key, value)
68
77
  elsif !Ransack.options[:ignore_unknown_conditions] || !@ignore_unknown_conditions
69
78
  raise InvalidSearchError, "Invalid search term #{key}"
70
79
  end
@@ -79,11 +88,12 @@ module Ransack
79
88
  case args
80
89
  when Array
81
90
  args.each do |sort|
82
- if sort.kind_of? Hash
83
- sort = Nodes::Sort.new(@context).build(sort)
84
- else
85
- sort = Nodes::Sort.extract(@context, sort)
86
- end
91
+ sort =
92
+ case sort
93
+ when Hash then Nodes::Sort.new(@context).build(sort)
94
+ when String, Symbol, nil then Nodes::Sort.extract(@context, sort.to_s)
95
+ else raise InvalidSearchError, "Invalid argument (#{sort.class}) supplied to sorts="
96
+ end
87
97
  add_sort(sort) if sort
88
98
  end
89
99
  when Hash
@@ -118,9 +128,9 @@ module Ransack
118
128
  # params hash is built (#1472).
119
129
  def method_missing(method_id, *args)
120
130
  method_name = method_id.to_s
121
- getter_name = method_name.sub(/=$/, ''.freeze)
131
+ getter_name = method_name.sub(/=\z/, ''.freeze)
122
132
  if @context.ransackable_scope?(getter_name, @context.object)
123
- if method_name =~ /=$/
133
+ if method_name.end_with?('=')
124
134
  add_scope getter_name, args.size == 1 ? args.first : args
125
135
  else
126
136
  @scope_args[method_name]
@@ -137,7 +147,7 @@ module Ransack
137
147
  # before calling. Mirror method_missing so a search_form_with field is
138
148
  # pre-filled the same way a search_form_for one is.
139
149
  def respond_to_missing?(method_id, include_private = false)
140
- getter_name = method_id.to_s.sub(/=$/, ''.freeze)
150
+ getter_name = method_id.to_s.sub(/=\z/, ''.freeze)
141
151
  base.attribute_method?(getter_name) ||
142
152
  @context.ransackable_scope?(getter_name, @context.object) ||
143
153
  super
@@ -202,6 +212,7 @@ module Ransack
202
212
  real_attribute, position = k.split(/\(|\)/)
203
213
  value = attrs.delete(k)
204
214
  next if position.nil?
215
+ next if value.is_a?(Array) || value.is_a?(Hash)
205
216
 
206
217
  cast = Constants::A_S_I.include?(position.last) ? position.last : nil
207
218
  position = position.to_i - 1
@@ -72,80 +72,88 @@ module Ransack
72
72
 
73
73
  private
74
74
 
75
+ # The module is shared by every thread, so nothing here is kept between
76
+ # calls: each helper is handed what it needs. Storing the name being
77
+ # translated on the module let one request read another's while both
78
+ # rendered sort links.
79
+ AttributeName = Struct.new(:context, :name, :attr_name, :assoc_path,
80
+ :associated_class, :include_associated)
81
+ private_constant :AttributeName
82
+
75
83
  def attribute_name(context, name, include_associations = nil)
76
- @context, @name = context, name
77
- @assoc_path = context.association_path(name)
78
- @attr_name = @name.sub(/^#{@assoc_path}_/, ''.freeze)
79
- associated_class = @context.traverse(@assoc_path) if @assoc_path.present?
80
- @include_associated = include_associations && associated_class
84
+ assoc_path = context.association_path(name)
85
+ associated_class = context.traverse(assoc_path) if assoc_path.present?
86
+ part = AttributeName.new(
87
+ context, name, name.sub(/\A#{Regexp.escape(assoc_path)}_/, ''.freeze),
88
+ assoc_path, associated_class, include_associations && associated_class
89
+ )
81
90
 
82
- defaults = default_attribute_name << fallback_args
91
+ defaults = default_attribute_name(part) << fallback_args(part)
83
92
  options = { count: 1, default: defaults }
84
- interpolations = build_interpolations(associated_class)
93
+ interpolations = build_interpolations(part)
85
94
 
86
95
  I18n.translate(defaults.shift, **options.merge(interpolations))
87
96
  end
88
97
 
89
- def default_attribute_name
90
- ["ransack.attributes.#{i18n_key(@context.klass)}.#{@name}".to_sym]
98
+ def default_attribute_name(part)
99
+ ["ransack.attributes.#{i18n_key(part.context.klass)}.#{part.name}".to_sym]
91
100
  end
92
101
 
93
- def fallback_args
94
- if @include_associated
102
+ def fallback_args(part)
103
+ if part.include_associated
95
104
  '%{association_name} %{attr_fallback_name}'.freeze
96
105
  else
97
106
  '%{attr_fallback_name}'.freeze
98
107
  end
99
108
  end
100
109
 
101
- def build_interpolations(associated_class)
110
+ def build_interpolations(part)
102
111
  {
103
- attr_fallback_name: attr_fallback_name(associated_class),
104
- association_name: association_name
112
+ attr_fallback_name: attr_fallback_name(part),
113
+ association_name: association_name(part)
105
114
  }.reject { |_, value| value.nil? }
106
115
  end
107
116
 
108
- def attr_fallback_name(associated_class)
117
+ def attr_fallback_name(part)
109
118
  I18n.t(
110
- :"ransack.attributes.#{fallback_class(associated_class)}.#{@attr_name}",
111
- default: default_interpolation(associated_class)
119
+ :"ransack.attributes.#{fallback_class(part)}.#{part.attr_name}",
120
+ default: default_interpolation(part)
112
121
  )
113
122
  end
114
123
 
115
- def fallback_class(associated_class)
116
- i18n_key(associated_class || @context.klass)
124
+ def fallback_class(part)
125
+ i18n_key(part.associated_class || part.context.klass)
117
126
  end
118
127
 
119
- def association_name
120
- association(@assoc_path, context: @context) if @include_associated
128
+ def association_name(part)
129
+ association(part.assoc_path, context: part.context) if part.include_associated
121
130
  end
122
131
 
123
- def default_interpolation(associated_class)
132
+ def default_interpolation(part)
124
133
  [
125
- associated_attribute(associated_class),
126
- ".attributes.#{@attr_name}".to_sym,
127
- @attr_name.humanize
134
+ associated_attribute(part),
135
+ ".attributes.#{part.attr_name}".to_sym,
136
+ part.attr_name.humanize
128
137
  ].flatten
129
138
  end
130
139
 
131
- def associated_attribute(associated_class)
132
- if associated_class
133
- translated_attribute(associated_class)
140
+ def associated_attribute(part)
141
+ if part.associated_class
142
+ translated_attribute(part.associated_class, part.attr_name)
134
143
  else
135
- translated_ancestor_attributes
144
+ translated_ancestor_attributes(part)
136
145
  end
137
146
  end
138
147
 
139
- def translated_attribute(associated_class)
140
- key = "#{associated_class.i18n_scope}.attributes.#{
141
- i18n_key(associated_class)}.#{@attr_name}"
148
+ def translated_attribute(klass, attr_name)
149
+ key = "#{klass.i18n_scope}.attributes.#{i18n_key(klass)}.#{attr_name}"
142
150
  ["#{key}.one".to_sym, key.to_sym]
143
151
  end
144
152
 
145
- def translated_ancestor_attributes
146
- @context.klass.ancestors
153
+ def translated_ancestor_attributes(part)
154
+ part.context.klass.ancestors
147
155
  .select { |ancestor| ancestor.respond_to?(:model_name) }
148
- .map { |ancestor| translated_attribute(ancestor) }
156
+ .map { |ancestor| translated_attribute(ancestor, part.attr_name) }
149
157
  end
150
158
 
151
159
  def i18n_key(klass)
@@ -1,3 +1,3 @@
1
1
  module Ransack
2
- VERSION = '6.0.1'
2
+ VERSION = '6.1.0'
3
3
  end
data/lib/ransack.rb CHANGED
@@ -2,12 +2,13 @@ require 'active_support/dependencies/autoload'
2
2
  require 'active_support/deprecation'
3
3
  require 'active_support/deprecator'
4
4
 
5
+ require 'active_support'
5
6
  require 'active_support/core_ext'
7
+ require 'ransack/errors'
6
8
  require 'ransack/configuration'
7
9
 
8
10
  module Ransack
9
11
  extend Configuration
10
- class UntraversableAssociationError < StandardError; end
11
12
 
12
13
  def self.deprecator
13
14
  @deprecator ||= ActiveSupport::Deprecation.new('7.0', 'Ransack')
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ransack
3
3
  version: !ruby/object:Gem::Version
4
- version: 6.0.1
4
+ version: 6.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ernie Miller
@@ -19,28 +19,28 @@ dependencies:
19
19
  requirements:
20
20
  - - ">="
21
21
  - !ruby/object:Gem::Version
22
- version: '7.2'
22
+ version: '8.1'
23
23
  type: :runtime
24
24
  prerelease: false
25
25
  version_requirements: !ruby/object:Gem::Requirement
26
26
  requirements:
27
27
  - - ">="
28
28
  - !ruby/object:Gem::Version
29
- version: '7.2'
29
+ version: '8.1'
30
30
  - !ruby/object:Gem::Dependency
31
31
  name: activesupport
32
32
  requirement: !ruby/object:Gem::Requirement
33
33
  requirements:
34
34
  - - ">="
35
35
  - !ruby/object:Gem::Version
36
- version: '7.2'
36
+ version: '8.1'
37
37
  type: :runtime
38
38
  prerelease: false
39
39
  version_requirements: !ruby/object:Gem::Requirement
40
40
  requirements:
41
41
  - - ">="
42
42
  - !ruby/object:Gem::Version
43
- version: '7.2'
43
+ version: '8.1'
44
44
  - !ruby/object:Gem::Dependency
45
45
  name: i18n
46
46
  requirement: !ruby/object:Gem::Requirement
@@ -82,6 +82,7 @@ files:
82
82
  - lib/ransack/configuration.rb
83
83
  - lib/ransack/constants.rb
84
84
  - lib/ransack/context.rb
85
+ - lib/ransack/errors.rb
85
86
  - lib/ransack/helpers.rb
86
87
  - lib/ransack/helpers/form_builder.rb
87
88
  - lib/ransack/helpers/form_helper.rb
@@ -140,7 +141,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
140
141
  requirements:
141
142
  - - ">="
142
143
  - !ruby/object:Gem::Version
143
- version: '3.2'
144
+ version: '3.3'
144
145
  required_rubygems_version: !ruby/object:Gem::Requirement
145
146
  requirements:
146
147
  - - ">="