ransack 4.4.3 → 5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4de514e3bd66dcd465c8253260a52e8fa9e40476a6e1d12b09dae4ed1ec8aaaf
4
- data.tar.gz: 2f156ce6c2bde6adca76eec24e59cf625e2e53b6922d5a0de370d7a1be75354c
3
+ metadata.gz: 0251527dc22cca8cb55ff672b598d74fef0ec4c2b1c6336cb0b8d52d69dabafc
4
+ data.tar.gz: fd218c05d4697cca5b9a8b4111af7a5fa4ddc559071e9a3194aca2f2b2da6c9d
5
5
  SHA512:
6
- metadata.gz: f4c4983ce0a10d9afc2f538666f98eeee0eb8f9da168fcd30800ded495cff71807d456e1c462530866d7e5c7954a737fd7127f3badcdb511d48bc9eab22b5d89
7
- data.tar.gz: 83403fc8e6cfce559597fa844e0ff79b45623dfd285382a2d0d84547263bd8c95f59998c226b7bd5b4c4745e6d70ddf45c6d5e1fd75c9b7278ae4b09592ef150
6
+ metadata.gz: bc524a7f9a4fac90d6f2e3b9818ccf76bab0f44c872c33db043f049f759f2641c03b338c2cfa28c70a20f6d1d2c4569390e43ec9e5da9daf2537002c519ad3fc
7
+ data.tar.gz: 3a599c3c9472f0033976ed0301cc0545b62711cccb36817db373c0f19ba0b828640a89bc3784b533346633112fb017353c63c490fa792b3eb3e7aea82f41e527
data/README.md CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  [![Build Status](https://github.com/activerecord-hackery/ransack/workflows/test/badge.svg)](https://github.com/activerecord-hackery/ransack/actions)
4
4
  [![Gem Version](https://badge.fury.io/rb/ransack.svg)](http://badge.fury.io/rb/ransack)
5
+ [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/activerecord-hackery/ransack)
5
6
  [![Code Climate](https://codeclimate.com/github/activerecord-hackery/ransack/badges/gpa.svg)](https://codeclimate.com/github/activerecord-hackery/ransack)
6
7
  [![Backers on Open Collective](https://opencollective.com/ransack/backers/badge.svg)](#backers) [![Sponsors on Open Collective](https://opencollective.com/ransack/sponsors/badge.svg)](#sponsors)
7
8
 
@@ -13,7 +14,7 @@ There are advanced searching solutions around, like ElasticSearch or Algolia. **
13
14
 
14
15
  Ready to move beyond the basics? Use **advanced features** like i18n and extensive configuration options.
15
16
 
16
- Ransack is supported for Rails 8.0, 7.2 on Ruby 3.1 and later.
17
+ Ransack is supported for Rails 8.1, 8.0, 7.2 on Ruby 3.1 and later.
17
18
 
18
19
  ## Installation
19
20
 
@@ -15,7 +15,7 @@ module Ransack
15
15
  relation = attr.arel_attribute.relation
16
16
  name = attr.arel_attribute.name.to_s
17
17
  table = relation.respond_to?(:table_name) ? relation.table_name : relation.name
18
- schema_cache = self.klass.connection.schema_cache
18
+ schema_cache = self.klass.connection_pool.schema_cache
19
19
  unless schema_cache.send(:data_source_exists?, table)
20
20
  raise "No table named #{table} exists."
21
21
  end
@@ -44,15 +44,15 @@ module Ransack
44
44
  if scope_or_sort.is_a?(Symbol)
45
45
  relation = relation.send(scope_or_sort)
46
46
  else
47
- case Ransack.options[:postgres_fields_sort_option]
47
+ case Ransack.options[:fields_sort_option]
48
48
  when :nulls_first
49
- scope_or_sort = scope_or_sort.direction == :asc ? Arel.sql("#{scope_or_sort.to_sql} NULLS FIRST") : Arel.sql("#{scope_or_sort.to_sql} NULLS LAST")
49
+ scope_or_sort = scope_or_sort.direction == :asc ? scope_or_sort.nulls_first : scope_or_sort.nulls_last
50
50
  when :nulls_last
51
- scope_or_sort = scope_or_sort.direction == :asc ? Arel.sql("#{scope_or_sort.to_sql} NULLS LAST") : Arel.sql("#{scope_or_sort.to_sql} NULLS FIRST")
51
+ scope_or_sort = scope_or_sort.direction == :asc ? scope_or_sort.nulls_last : scope_or_sort.nulls_first
52
52
  when :nulls_always_first
53
- scope_or_sort = Arel.sql("#{scope_or_sort.to_sql} NULLS FIRST")
53
+ scope_or_sort = scope_or_sort.nulls_first
54
54
  when :nulls_always_last
55
- scope_or_sort = Arel.sql("#{scope_or_sort.to_sql} NULLS LAST")
55
+ scope_or_sort = scope_or_sort.nulls_last
56
56
  end
57
57
 
58
58
  relation = relation.order(scope_or_sort)
@@ -64,10 +64,9 @@ module Ransack
64
64
  end
65
65
 
66
66
  def attribute_method?(str, klass = @klass)
67
- return false unless key_within_depth_limit?(str)
68
-
69
67
  exists = false
70
- if ransackable_attribute?(str, klass)
68
+ if ransackable_attribute?(str, klass) ||
69
+ ransortable_attribute?(str, klass)
71
70
  exists = true
72
71
  elsif (segments = str.split(Constants::UNDERSCORE)).size > 1
73
72
  remainder = []
@@ -214,9 +213,9 @@ module Ransack
214
213
  end
215
214
  when Arel::Nodes::Equality
216
215
  pk = primary_key
217
- if join_root.left == pk
216
+ if join_root.left.eql?(pk)
218
217
  join_root.right
219
- elsif join_root.right == pk
218
+ elsif join_root.right.eql?(pk)
220
219
  join_root.left
221
220
  else
222
221
  nil
@@ -239,11 +238,10 @@ module Ransack
239
238
  end
240
239
 
241
240
  def get_parent_and_attribute_name(str, parent = @base)
242
- return [parent, nil] unless key_within_depth_limit?(str)
243
-
244
241
  attr_name = nil
245
242
 
246
- if ransackable_attribute?(str, klassify(parent))
243
+ if ransackable_attribute?(str, klassify(parent)) ||
244
+ ransortable_attribute?(str, klassify(parent))
247
245
  attr_name = str
248
246
  elsif (segments = str.split(Constants::UNDERSCORE)).size > 1
249
247
  remainder = []
@@ -34,8 +34,9 @@ module Ransack
34
34
  down_arrow: '▲'.freeze,
35
35
  default_arrow: nil,
36
36
  sanitize_scope_args: true,
37
- postgres_fields_sort_option: nil,
38
- strip_whitespace: true
37
+ fields_sort_option: nil,
38
+ strip_whitespace: true,
39
+ ignore_blank_values: true
39
40
  }
40
41
 
41
42
  def configure
@@ -162,13 +163,24 @@ module Ransack
162
163
  # User may want to configure it like this:
163
164
  #
164
165
  # Ransack.configure do |c|
165
- # c.postgres_fields_sort_option = :nulls_first # or e.g. :nulls_always_last
166
+ # c.fields_sort_option = :nulls_first # or e.g. :nulls_always_last
166
167
  # end
167
168
  #
168
- # See this feature: https://www.postgresql.org/docs/13/queries-order.html
169
+ # Emitted through Arel's `nulls_first` / `nulls_last`, so it works on any
170
+ # backend Arel supports it for. MySQL has no NULLS FIRST / LAST syntax and
171
+ # Arel does not emulate it, so this option does not apply there.
169
172
  #
173
+ # See https://www.postgresql.org/docs/current/queries-order.html
174
+ #
175
+ def fields_sort_option=(setting)
176
+ self.options[:fields_sort_option] = setting
177
+ end
178
+
179
+ # Renamed to `fields_sort_option` now that NULLS FIRST / NULLS LAST is
180
+ # emitted through Arel and is no longer PostgreSQL-specific. The old name
181
+ # still works so existing initializers keep running.
170
182
  def postgres_fields_sort_option=(setting)
171
- self.options[:postgres_fields_sort_option] = setting
183
+ self.fields_sort_option = setting
172
184
  end
173
185
 
174
186
  # By default, Ransack displays sort order indicator arrows in sort links.
@@ -197,6 +209,27 @@ module Ransack
197
209
  self.options[:strip_whitespace] = boolean
198
210
  end
199
211
 
212
+ # By default, Ransack ignores search conditions whose value is blank — an
213
+ # empty string, or an array containing only blank values. This is what makes
214
+ # a search form submitted with empty fields return every record rather than
215
+ # none, and it is almost always what an HTML form wants.
216
+ #
217
+ # Set this to false to treat a blank value as a value to search *for*:
218
+ # `name_eq: ''` then generates `WHERE name = ''`, and `id_in: []` generates a
219
+ # condition matching nothing rather than being dropped. This is usually what
220
+ # a JSON API wants, where an empty value is an explicit filter rather than an
221
+ # untouched form field.
222
+ #
223
+ # A `nil` value is ignored either way.
224
+ #
225
+ # Ransack.configure do |config|
226
+ # config.ignore_blank_values = false
227
+ # end
228
+ #
229
+ def ignore_blank_values=(boolean)
230
+ self.options[:ignore_blank_values] = boolean
231
+ end
232
+
200
233
  def arel_predicate_with_suffix(arel_predicate, suffix)
201
234
  if arel_predicate === Proc
202
235
  proc { |v| "#{arel_predicate.call(v)}#{suffix}" }
@@ -50,15 +50,6 @@ module Ransack
50
50
 
51
51
  DISTINCT = 'DISTINCT '.freeze
52
52
 
53
- # The most `_`-separated segments a single condition or sort key may have
54
- # before Ransack rejects it as invalid instead of parsing it. Working out
55
- # whether a key is an attribute, an `_and_`/`_or_` compound or a path
56
- # through associations is superlinear in this count, and the key comes
57
- # straight from the query string, so an unbounded one is a CPU-exhaustion
58
- # denial of service (GHSA-j3f8-w227-4hh8). No real search key is this
59
- # long: OR-ing every column of a very wide table stays well under it.
60
- MAX_KEY_DEPTH = 200
61
-
62
53
  DERIVED_PREDICATES = [
63
54
  [CONT, {
64
55
  arel_predicate: 'matches'.freeze,
@@ -164,22 +155,47 @@ module Ransack
164
155
  type: :boolean,
165
156
  validator: proc { |v| BOOLEAN_VALUES.include?(v) },
166
157
  formatter: proc { |v| nil } }
158
+ ],
159
+ ['length_eq'.freeze, {
160
+ arel_predicate: 'eq'.freeze,
161
+ type: :integer
162
+ }
163
+ ],
164
+ ['length_lt'.freeze, {
165
+ arel_predicate: 'lt'.freeze,
166
+ type: :integer
167
+ }
168
+ ],
169
+ ['length_lteq'.freeze, {
170
+ arel_predicate: 'lteq'.freeze,
171
+ type: :integer
172
+ }
173
+ ],
174
+ ['length_gt'.freeze, {
175
+ arel_predicate: 'gt'.freeze,
176
+ type: :integer
177
+ }
178
+ ],
179
+ ['length_gteq'.freeze, {
180
+ arel_predicate: 'gteq'.freeze,
181
+ type: :integer
182
+ }
167
183
  ]
168
184
  ].freeze
169
185
 
186
+ # The character used to escape LIKE wildcards. Emitted as an explicit
187
+ # `ESCAPE` clause alongside every LIKE / NOT LIKE predicate, so the
188
+ # behaviour does not depend on a backend's default escape character —
189
+ # MySQL and PostgreSQL default to backslash, SQLite has no default at all.
190
+ LIKE_ESCAPE_CHARACTER = '\\'.freeze
191
+
170
192
  module_function
171
- # replace % \ to \% \\
193
+ # Escapes the LIKE wildcards `%` and `_`, and the escape character itself,
194
+ # so they are matched literally. Paired with the `ESCAPE` clause added in
195
+ # `Condition#format_predicate`; escaping without it silently does nothing
196
+ # on SQLite. See https://github.com/activerecord-hackery/ransack/issues/1581
172
197
  def escape_wildcards(unescaped)
173
- case ActiveRecord::Base.connection.adapter_name
174
- when "Mysql2".freeze
175
- # Necessary for MySQL
176
- unescaped.to_s.gsub(/([\\%_])/, '\\\\\\1')
177
- when "PostGIS".freeze, "PostgreSQL".freeze
178
- # Necessary for PostgreSQL
179
- unescaped.to_s.gsub(/([\\%_.])/, '\\\\\\1')
180
- else
181
- unescaped
182
- end
198
+ unescaped.to_s.gsub(/([\\%_])/) { "#{LIKE_ESCAPE_CHARACTER}#{$1}" }
183
199
  end
184
200
  end
185
201
  end
@@ -3,8 +3,7 @@ require 'ransack/visitor'
3
3
  module Ransack
4
4
  class Context
5
5
  attr_reader :search, :object, :klass, :base, :engine, :arel_visitor
6
- attr_accessor :auth_object, :search_key
7
- attr_reader :arel_visitor
6
+ attr_accessor :auth_object, :search_key, :ignore_unknown_conditions
8
7
 
9
8
  class << self
10
9
 
@@ -34,6 +33,12 @@ module Ransack
34
33
 
35
34
  end # << self
36
35
 
36
+ # Unknown attributes, predicates and combinators raise rather than being
37
+ # ignored when either the global option or this search's own option says so.
38
+ def strict_conditions?
39
+ !Ransack.options[:ignore_unknown_conditions] || ignore_unknown_conditions == false
40
+ end
41
+
37
42
  def initialize(object, options = {})
38
43
  @object = relation_for(object)
39
44
  @klass = @object.klass
@@ -47,15 +52,6 @@ module Ransack
47
52
  @base = @join_dependency.instance_variable_get(:@join_root)
48
53
  end
49
54
 
50
- # False when a key has more `_`-separated segments than Ransack will
51
- # parse. The attribute / association / predicate parsing that follows is
52
- # superlinear in the segment count and the key is attacker-controlled, so
53
- # an over-long key is treated as unknown rather than parsed
54
- # (GHSA-j3f8-w227-4hh8).
55
- def key_within_depth_limit?(key)
56
- key.to_s.count(Constants::UNDERSCORE) <= Constants::MAX_KEY_DEPTH
57
- end
58
-
59
55
  def bind_pair_for(key)
60
56
  @bind_pairs ||= {}
61
57
 
@@ -98,6 +94,14 @@ module Ransack
98
94
  @klass.method(scope).arity
99
95
  end
100
96
 
97
+ def sanitize_scope_args(key, args)
98
+ if Ransack.options[:sanitize_scope_args] && !ransackable_scope_skip_sanitize_args?(key, object)
99
+ cast_scope_args(args)
100
+ else
101
+ args
102
+ end
103
+ end
104
+
101
105
  def bind(object, str)
102
106
  return nil unless str
103
107
  object.parent, object.attr_name = bind_pair_for(str)
@@ -133,8 +137,6 @@ module Ransack
133
137
  end
134
138
 
135
139
  def association_path(str, base = @base)
136
- return ''.freeze unless key_within_depth_limit?(str)
137
-
138
140
  base = klassify(base)
139
141
  str ||= ''.freeze
140
142
  path = []
@@ -170,8 +172,11 @@ module Ransack
170
172
  end
171
173
 
172
174
  def ransackable_attribute?(str, klass)
173
- klass.ransackable_attributes(auth_object).any? { |s| s.to_sym == str.to_sym } ||
174
- klass.ransortable_attributes(auth_object).any? { |s| s.to_sym == str.to_sym }
175
+ klass.ransackable_attributes(auth_object).any? { |s| s.to_sym == str.to_sym }
176
+ end
177
+
178
+ def ransortable_attribute?(str, klass)
179
+ klass.ransortable_attributes(auth_object).any? { |s| s.to_sym == str.to_sym }
175
180
  end
176
181
 
177
182
  def ransackable_association?(str, klass)
@@ -197,5 +202,21 @@ module Ransack
197
202
  def searchable_associations(str = ''.freeze)
198
203
  traverse(str).ransackable_associations(auth_object)
199
204
  end
205
+
206
+ private
207
+
208
+ def cast_scope_args(args)
209
+ if args.is_a?(Array)
210
+ args = args.map(&method(:cast_scope_args))
211
+ end
212
+
213
+ if Constants::TRUE_VALUES.include? args
214
+ true
215
+ elsif Constants::FALSE_VALUES.include? args
216
+ false
217
+ else
218
+ args
219
+ end
220
+ end
200
221
  end
201
222
  end
@@ -1,22 +1,5 @@
1
1
  require 'action_view'
2
2
 
3
- module ActionView::Helpers::Tags
4
- # TODO: Find a better way to solve this issue!
5
- # This patch is needed since this Rails commit:
6
- # https://github.com/rails/rails/commit/c1a118a
7
- class Base
8
- private
9
-
10
- def value
11
- if @allow_method_names_outside_object
12
- object.send @method_name if object && object.respond_to?(@method_name, true)
13
- else
14
- object.send @method_name if object
15
- end
16
- end
17
- end
18
- end
19
-
20
3
  RANSACK_FORM_BUILDER = 'RANSACK_FORM_BUILDER'.freeze
21
4
 
22
5
  require 'simple_form' if
@@ -129,6 +112,7 @@ module Ransack
129
112
 
130
113
  def predicate_select(options = {}, html_options = {})
131
114
  options[:compounds] = true if options[:compounds].nil?
115
+ options[:selected] ||= nil
132
116
  default = options.delete(:default) || Constants::CONT
133
117
 
134
118
  keys =
@@ -10,7 +10,7 @@ module Ransack
10
10
  search = extract_search_and_set_url(record, options, 'search_form_for')
11
11
  options[:html] ||= {}
12
12
  html_options = build_html_options(search, options, :get)
13
- finalize_form_options(options, html_options)
13
+ finalize_form_options(search, options, html_options)
14
14
  form_for(record, options, &proc)
15
15
  end
16
16
 
@@ -30,7 +30,7 @@ module Ransack
30
30
  search = extract_search_and_set_url(record, options, 'search_form_with')
31
31
  options[:html] ||= {}
32
32
  html_options = build_html_options(search, options, :get)
33
- finalize_form_with_options(options, html_options)
33
+ finalize_form_with_options(search, options, html_options)
34
34
  form_with(model: search, **options, &proc)
35
35
  end
36
36
 
@@ -48,7 +48,7 @@ module Ransack
48
48
  turbo_options = build_turbo_options(options)
49
49
  method = options.delete(:method) || :post
50
50
  html_options = build_html_options(search, options, method).merge(turbo_options)
51
- finalize_form_options(options, html_options)
51
+ finalize_form_options(search, options, html_options)
52
52
  form_for(record, options, &proc)
53
53
  end
54
54
 
@@ -122,14 +122,14 @@ module Ransack
122
122
  }
123
123
  end
124
124
 
125
- def finalize_form_options(options, html_options)
126
- options[:as] ||= Ransack.options[:search_key]
125
+ def finalize_form_options(search, options, html_options)
126
+ options[:as] ||= search.context.search_key
127
127
  options[:html].reverse_merge!(html_options)
128
128
  options[:builder] ||= FormBuilder
129
129
  end
130
130
 
131
- def finalize_form_with_options(options, html_options)
132
- options[:scope] ||= Ransack.options[:search_key]
131
+ def finalize_form_with_options(search, options, html_options)
132
+ options[:scope] ||= search.context.search_key
133
133
  options[:html].reverse_merge!(html_options)
134
134
  options[:builder] ||= FormBuilder
135
135
  end
@@ -20,8 +20,7 @@ module Ransack
20
20
 
21
21
  def valid?
22
22
  bound? && attr &&
23
- context.klassify(parent).ransackable_attributes(context.auth_object)
24
- .include?(attr_name.split('.').last)
23
+ context.ransackable_attribute?(attr_name.split('.').last, context.klassify(parent))
25
24
  end
26
25
 
27
26
  def associated_collection?
@@ -31,6 +30,8 @@ module Ransack
31
30
  def type
32
31
  if ransacker
33
32
  ransacker.type
33
+ elsif enum?
34
+ :enum
34
35
  else
35
36
  context.type_for(self)
36
37
  end
@@ -54,6 +55,14 @@ module Ransack
54
55
  "Attribute <#{name}>"
55
56
  end
56
57
 
58
+ private
59
+
60
+ def enum?
61
+ bound? &&
62
+ klass.respond_to?(:defined_enums) &&
63
+ klass.defined_enums.key?(attr_name.to_s)
64
+ end
65
+
57
66
  end
58
67
  end
59
68
  end
@@ -81,8 +81,13 @@ module Ransack
81
81
  def attributes=(args)
82
82
  case args
83
83
  when Array
84
- args.each do |name|
85
- build_attribute(name)
84
+ args.each do |attr|
85
+ if attr.is_a?(Hash) && (attr.key?(:name) || attr.key?("name"))
86
+ attr = attr.with_indifferent_access
87
+ build_attribute(attr[:name], attr[:ransacker_args])
88
+ else
89
+ build_attribute(attr)
90
+ end
86
91
  end
87
92
  when Hash
88
93
  args.each do |index, attrs|
@@ -104,8 +109,10 @@ module Ransack
104
109
  case args
105
110
  when Array
106
111
  args.each do |val|
107
- val = Value.new(@context, val)
108
- self.values << val
112
+ if val.is_a?(Hash) && (val.key?(:value) || val.key?("value"))
113
+ val = val.with_indifferent_access[:value]
114
+ end
115
+ self.values << Value.new(@context, val)
109
116
  end
110
117
  when Hash
111
118
  args.each do |index, attrs|
@@ -124,8 +131,9 @@ module Ransack
124
131
  end
125
132
 
126
133
  def combinator=(val)
127
- @combinator = Constants::AND_OR.detect { |v| v == val.to_s } || nil
134
+ super
128
135
  end
136
+
129
137
  alias :m= :combinator=
130
138
  alias :m :combinator
131
139
 
@@ -171,10 +179,17 @@ module Ransack
171
179
  end
172
180
  end
173
181
 
182
+ # The long spellings route through the short setters: `p=` resolves a
183
+ # Predicate object, whereas the `predicate=` attribute writer would store
184
+ # the bare name.
185
+ LONG_KEYS = {
186
+ 'attributes' => 'a', 'values' => 'v', 'predicate' => 'p', 'combinator' => 'm'
187
+ }.freeze
188
+
174
189
  def build(params)
175
190
  params.with_indifferent_access.each do |key, value|
176
- if key.match(/^(a|v|p|m)$/)
177
- self.send("#{key}=", value)
191
+ if key.match(/^(a|v|p|m|attributes|values|predicate|combinator)$/)
192
+ self.send("#{LONG_KEYS.fetch(key, key)}=", value)
178
193
  end
179
194
  end
180
195
 
@@ -217,10 +232,6 @@ module Ransack
217
232
  end
218
233
  alias :p :predicate_name
219
234
 
220
- def arel_predicate
221
- raise "not implemented"
222
- end
223
-
224
235
  def validated_values
225
236
  values.select { |v| predicate.validator.call(v.value) }
226
237
  end
@@ -260,7 +271,7 @@ module Ransack
260
271
  end
261
272
 
262
273
  def attr_value_for_attribute(attr)
263
- return attr.attr if ActiveRecord::Base.connection.adapter_name == "PostgreSQL"
274
+ return attr.attr if ActiveRecord::Base.adapter_class::ADAPTER_NAME == "PostgreSQL"
264
275
 
265
276
  predicate.case_insensitive ? attr.attr.lower : attr.attr
266
277
  rescue
@@ -289,11 +300,11 @@ module Ransack
289
300
  end
290
301
 
291
302
  def arel_predicate
292
- predicate = attributes.map { |attribute|
303
+ attributes.map { |attribute|
293
304
  association = attribute.parent
294
305
  parent_table = association.table
295
306
 
296
- if negative? && attribute.associated_collection? && not_nested_condition(attribute, parent_table)
307
+ predicate = if negative? && attribute.associated_collection? && not_nested_condition(attribute, parent_table)
297
308
  query = context.build_correlated_subquery(association)
298
309
  context.remove_association(association)
299
310
 
@@ -316,18 +327,22 @@ module Ransack
316
327
  else
317
328
  format_predicate(attribute)
318
329
  end
319
- }.reduce(combinator_method)
320
330
 
321
- if replace_right_node?(predicate)
322
- # Replace right node object to plain integer value in order to avoid
323
- # ActiveModel::RangeError from Arel::Node::Casted.
324
- # The error can be ignored here because RDBMSs accept large numbers
325
- # in condition clauses.
326
- plain_value = predicate.right.value
327
- predicate.right = plain_value
328
- end
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
329
343
 
330
- predicate
344
+ predicate
345
+ }.reduce(combinator_method)
331
346
  end
332
347
 
333
348
  def not_nested_condition(attribute, parent_table)
@@ -343,14 +358,39 @@ module Ransack
343
358
  def format_predicate(attribute)
344
359
  arel_pred = arel_predicate_for_attribute(attribute)
345
360
  arel_values = formatted_values_for_attribute(attribute)
346
-
361
+
362
+ # The `present` / `blank` predicate formatters produce `[nil, '']` regardless
363
+ # of column type. The empty-string half has no meaning on non-string columns:
364
+ # ActiveRecord casts `''` to NULL for those types, producing the
365
+ # always-UNKNOWN `column != NULL` (or `column = NULL`) clause. Strip the
366
+ # empty string when the attribute is not a string-like column.
367
+ if arel_values.is_a?(Array) && arel_values.include?(''.freeze) && !string_like_attribute?(attribute)
368
+ arel_values = arel_values.reject { |v| v == ''.freeze }
369
+ end
370
+
347
371
  # For LIKE predicates, wrap the value in Arel::Nodes.build_quoted to prevent
348
- # ActiveRecord normalization from affecting wildcard patterns
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
+ # A length predicate compares LENGTH(column) rather than the column.
378
+ attr_value = length_predicate? ? length_function_for_attribute(attribute) : attr_value_for_attribute(attribute)
379
+
349
380
  if like_predicate?(arel_pred)
350
- arel_values = Arel::Nodes.build_quoted(arel_values)
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)
391
+ else
392
+ predicate = attr_value.public_send(arel_pred, arel_values)
351
393
  end
352
-
353
- predicate = attr_value_for_attribute(attribute).public_send(arel_pred, arel_values)
354
394
 
355
395
  if in_predicate?(predicate)
356
396
  predicate.right = predicate.right.map do |pr|
@@ -366,8 +406,22 @@ module Ransack
366
406
  predicate.class == Arel::Nodes::In || predicate.class == Arel::Nodes::NotIn
367
407
  end
368
408
 
409
+ LIKE_PREDICATES = %w[
410
+ matches matches_any matches_all
411
+ does_not_match does_not_match_any does_not_match_all
412
+ ].freeze
413
+
369
414
  def like_predicate?(arel_predicate)
370
- arel_predicate == 'matches' || arel_predicate == 'does_not_match'
415
+ LIKE_PREDICATES.include?(arel_predicate)
416
+ end
417
+
418
+ STRING_LIKE_TYPES = %i[string text citext].freeze
419
+
420
+ def string_like_attribute?(attribute)
421
+ type = attribute.type
422
+ # Treat unknown types as string-like (conservative: keep the existing
423
+ # empty-string comparison rather than silently dropping it).
424
+ type.nil? || STRING_LIKE_TYPES.include?(type.to_sym)
371
425
  end
372
426
 
373
427
  def casted_array?(predicate)
@@ -391,6 +445,28 @@ module Ransack
391
445
  attribute_type == :integer && arel_node.value.is_a?(Integer)
392
446
  end
393
447
 
448
+ def length_predicate?
449
+ predicate_name.to_s.start_with?('length_')
450
+ end
451
+
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
+ 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
+ Arel::Nodes::NamedFunction.new(
465
+ function_name,
466
+ [attr_value_for_attribute(attribute)]
467
+ )
468
+ end
469
+
394
470
  def valid_combinator?
395
471
  attributes.size < 2 || Constants::AND_OR.include?(combinator)
396
472
  end
@@ -1,8 +1,12 @@
1
1
  module Ransack
2
2
  module Nodes
3
3
  class Grouping < Node
4
- attr_reader :conditions
5
- attr_accessor :combinator
4
+ attr_reader :combinator
5
+
6
+ # The writer is inherited from Node, which normalises the value. Defining
7
+ # it here as well — or aliasing before the definition is in place — would
8
+ # bind `m=` to the plain attribute writer and skip that normalisation,
9
+ # which is how `g: [{ m: 'OR' }]` silently fell back to AND.
6
10
  alias :m :combinator
7
11
  alias :m= :combinator=
8
12
 
@@ -13,7 +17,7 @@ module Ransack
13
17
 
14
18
  def initialize(context, combinator = nil)
15
19
  super(context)
16
- self.combinator = combinator.to_s if combinator
20
+ self.combinator = combinator if combinator
17
21
  end
18
22
 
19
23
  def persisted?
@@ -122,8 +126,6 @@ module Ransack
122
126
  end
123
127
 
124
128
  def attribute_method?(name)
125
- return false unless @context.key_within_depth_limit?(name)
126
-
127
129
  stripped_name = strip_predicate_and_index(name)
128
130
  return true if @context.attribute_method?(stripped_name) ||
129
131
  @context.attribute_method?(name)
@@ -151,10 +153,14 @@ module Ransack
151
153
  def build(params)
152
154
  params.with_indifferent_access.each do |key, value|
153
155
  case key
154
- when /^(g|c|m)$/
156
+ when /^(g|c|m|groupings|conditions|combinator)$/
155
157
  self.send("#{key}=", value)
156
158
  else
157
- write_attribute(key.to_s, value)
159
+ if @context.ransackable_scope?(key, @context.object)
160
+ @context.chain_scope(key, @context.sanitize_scope_args(key, value))
161
+ else
162
+ write_attribute(key.to_s, value)
163
+ end
158
164
  end
159
165
  end
160
166
  self
@@ -180,6 +186,16 @@ module Ransack
180
186
  end
181
187
 
182
188
  def read_attribute(name)
189
+ # A field built from an aliased attribute writes its value under the real
190
+ # name but reads it back under the alias. Resolve the alias so the
191
+ # value survives a form round-trip (#689).
192
+ if self[name].nil?
193
+ stripped_name = name.dup
194
+ predicate = Predicate.detect_and_strip_from_string!(stripped_name)
195
+ aliased_attribute = context.ransackable_alias(stripped_name)
196
+ name = "#{aliased_attribute}_#{predicate}" unless aliased_attribute == stripped_name
197
+ end
198
+
183
199
  if self[name].respond_to?(:value)
184
200
  self[name].value
185
201
  else
@@ -29,6 +29,19 @@ module Ransack
29
29
  end
30
30
  end
31
31
 
32
+ # Every way of supplying a combinator — a top-level `combinator`/`m`,
33
+ # or an `m` inside any nested grouping or condition — ends up here, so
34
+ # this is the one place that normalises the spelling and, when the search
35
+ # is strict, rejects a value it does not recognise.
36
+ def combinator=(val)
37
+ normalised = val.to_s.downcase
38
+ @combinator = Constants::AND_OR.detect { |v| v == normalised }
39
+
40
+ if @combinator.nil? && !normalised.empty? && context&.strict_conditions?
41
+ raise InvalidSearchError, "Invalid combinator #{val}"
42
+ end
43
+ end
44
+
32
45
  end
33
46
  end
34
47
  end
@@ -26,8 +26,7 @@ module Ransack
26
26
 
27
27
  def valid?
28
28
  bound? && attr &&
29
- context.klassify(parent).ransortable_attributes(context.auth_object)
30
- .include?(attr_name)
29
+ context.ransortable_attribute?(attr_name.split('.').last, context.klassify(parent))
31
30
  end
32
31
 
33
32
  def name=(name)
@@ -24,9 +24,11 @@ module Ransack
24
24
 
25
25
  def cast(type)
26
26
  case type
27
+ when :enum
28
+ value
27
29
  when :date
28
30
  cast_to_date(value)
29
- when :datetime, :timestamp, :time, :timestamptz
31
+ when :datetime, :timestamp, :time, :timestamptz, :datetimeoffset
30
32
  cast_to_time(value)
31
33
  when :boolean
32
34
  cast_to_boolean(value)
@@ -3,6 +3,17 @@ module Ransack
3
3
  attr_reader :name, :arel_predicate, :type, :formatter, :validator,
4
4
  :compound, :wants_array, :case_insensitive
5
5
 
6
+ # Consulted at search time rather than captured at predicate-definition
7
+ # time, so that `Ransack.options[:ignore_blank_values]` set in an
8
+ # initializer applies to the predicates registered before it ran.
9
+ DEFAULT_VALIDATOR = lambda do |v|
10
+ if Ransack.options[:ignore_blank_values]
11
+ v.respond_to?(:empty?) ? !v.empty? : !v.nil?
12
+ else
13
+ !v.nil?
14
+ end
15
+ end
16
+
6
17
  class << self
7
18
 
8
19
  def names
@@ -37,8 +48,7 @@ module Ransack
37
48
  @arel_predicate = opts[:arel_predicate]
38
49
  @type = opts[:type]
39
50
  @formatter = opts[:formatter]
40
- @validator = opts[:validator] ||
41
- lambda { |v| v.respond_to?(:empty?) ? !v.empty? : !v.nil? }
51
+ @validator = opts[:validator] || DEFAULT_VALIDATOR
42
52
  @compound = opts[:compound]
43
53
  @wants_array = opts.fetch(:wants_array,
44
54
  @compound || Constants::IN_NOT_IN.include?(@arel_predicate))
@@ -64,7 +74,18 @@ module Ransack
64
74
  end
65
75
 
66
76
  def validate(vals, type = @type)
67
- vals.any? { |v| validator.call(type ? v.cast(type) : v.value) }
77
+ # An explicitly empty array is a meaningful filter (matching nothing)
78
+ # rather than an absent one, but only when blank values are not ignored.
79
+ return true if vals.empty? && wants_array &&
80
+ !Ransack.options[:ignore_blank_values]
81
+
82
+ # When blank values are meaningful, validate the value as given. Casting
83
+ # first would turn '' into nil for an integer or boolean column and the
84
+ # explicit blank would be dropped — leaving no condition at all.
85
+ vals.any? do |v|
86
+ value = type && Ransack.options[:ignore_blank_values] ? v.cast(type) : v.value
87
+ validator.call(value)
88
+ end
68
89
  end
69
90
 
70
91
  def negative?
@@ -24,14 +24,19 @@ module Ransack
24
24
  strip_whitespace = options.fetch(:strip_whitespace, Ransack.options[:strip_whitespace])
25
25
  params = params.to_unsafe_h if params.respond_to?(:to_unsafe_h)
26
26
  if params.is_a? Hash
27
- params = params.dup
28
- params = params.transform_values { |v| v.is_a?(String) && strip_whitespace ? v.strip : v }
29
- params.delete_if { |k, v| [*v].all?{ |i| i.blank? && i != false } }
27
+ # deep_transform_values rebuilds every nested hash and array, which
28
+ # also gives the pruning below a private copy to edit — the caller's
29
+ # params come back untouched. Values nested inside `g:` groupings and
30
+ # `c:` conditions are stripped too, not just the top level.
31
+ params = params.deep_transform_values { |v| v.is_a?(String) && strip_whitespace ? v.strip : v }
32
+ params.delete_if { |_k, v| blank_condition_value?(v) }
33
+ prune_blank_advanced_conditions!(params)
30
34
  else
31
35
  params = {}
32
36
  end
33
37
  @context = options[:context] || Context.for(object, options)
34
38
  @context.auth_object = options[:auth_object]
39
+ @context.ignore_unknown_conditions = options[:ignore_unknown_conditions]
35
40
  @base = Nodes::Grouping.new(
36
41
  @context, options[:grouping] || Constants::AND
37
42
  )
@@ -132,11 +137,7 @@ module Ransack
132
137
  private
133
138
 
134
139
  def add_scope(key, args)
135
- sanitized_args = if Ransack.options[:sanitize_scope_args] && !@context.ransackable_scope_skip_sanitize_args?(key, @context.object)
136
- sanitized_scope_args(args)
137
- else
138
- args
139
- end
140
+ sanitized_args = @context.sanitize_scope_args(key, args)
140
141
 
141
142
  if @context.scope_arity(key) == 1
142
143
  @scope_args[key] = args.is_a?(Array) ? args[0] : args
@@ -146,45 +147,19 @@ module Ransack
146
147
  @context.chain_scope(key, sanitized_args)
147
148
  end
148
149
 
149
- def sanitized_scope_args(args)
150
- if args.is_a?(Array)
151
- args = args.map(&method(:sanitized_scope_args))
152
- end
153
-
154
- if Constants::TRUE_VALUES.include? args
155
- true
156
- elsif Constants::FALSE_VALUES.include? args
157
- false
158
- else
159
- args
160
- end
161
- end
162
-
163
- # The largest position a multiparameter key (`created_at(1i)`) may carry.
164
- # Rails' date and time selects emit at most six, year to second. The
165
- # position indexes an array, so an unbounded one from a crafted query
166
- # string would allocate an array that size (GHSA-vxc9-rm8f-p56j).
167
- MULTIPARAMETER_POSITION_LIMIT = 16
168
-
169
- # Folds `created_at(1i)`, `created_at(2i)`, ... into `created_at` as an
170
- # array of cast values, the way Active Record does for form input. The
171
- # keys are untrusted, so a malformed one is dropped rather than raised
172
- # on: no position (`created_at(`), a position outside 1 to the limit,
173
- # or a fragment for an attribute that was also given as a plain value.
174
150
  def collapse_multiparameter_attributes!(attrs)
175
151
  attrs.keys.each do |k|
176
152
  if k.include?(Constants::LEFT_PARENTHESIS)
177
153
  real_attribute, position = k.split(/\(|\)/)
178
- value = attrs.delete(k)
179
- next if position.nil?
180
-
181
- cast = Constants::A_S_I.include?(position.last) ? position.last : nil
154
+ cast =
155
+ if Constants::A_S_I.include?(position.last)
156
+ position.last
157
+ else
158
+ nil
159
+ end
182
160
  position = position.to_i - 1
183
- next if position < 0 || position >= MULTIPARAMETER_POSITION_LIMIT
184
-
161
+ value = attrs.delete(k)
185
162
  attrs[real_attribute] ||= []
186
- next unless attrs[real_attribute].is_a?(Array)
187
-
188
163
  attrs[real_attribute][position] =
189
164
  if cast
190
165
  if value.blank? && cast == Constants::I
@@ -203,5 +178,61 @@ module Ransack
203
178
  attrs
204
179
  end
205
180
 
181
+ # True when a condition's value should be dropped before building. With
182
+ # `ignore_blank_values` on (the default) a blank value means "this form
183
+ # field was left empty"; with it off, only nil is treated that way and a
184
+ # blank value is a value to search for. `false` is always a real value, and
185
+ # an explicit nil inside an array is kept so `name_in: [nil]` still reaches
186
+ # the query. The `c:` pruning below shares this, so it follows the option.
187
+ def blank_condition_value?(value)
188
+ if Ransack.options[:ignore_blank_values]
189
+ [*value].all? { |i| i.blank? && i != false && !i.nil? }
190
+ else
191
+ value.nil? || (value.is_a?(Array) && !value.empty? && value.all?(&:nil?))
192
+ end
193
+ end
194
+
195
+ # The low-level `c:` API nests its values a level deeper than the shorthand
196
+ # form, so the filter above never sees them. Left in place, a condition with
197
+ # an empty value still builds its attribute and contributes a join, giving a
198
+ # LEFT OUTER JOIN with no WHERE clause to go with it.
199
+ def prune_blank_advanced_conditions!(node)
200
+ return unless node.is_a?(Hash)
201
+
202
+ conditions = fetch_either(node, :c, :conditions)
203
+ case conditions
204
+ when Array then conditions.delete_if { |c| blank_advanced_condition?(c) }
205
+ when Hash then conditions.delete_if { |_k, c| blank_advanced_condition?(c) }
206
+ end
207
+
208
+ # Groupings nest to any depth and carry their own conditions, so descend
209
+ # into each of them too.
210
+ groupings = fetch_either(node, :g, :groupings)
211
+ groupings = groupings.values if groupings.is_a?(Hash)
212
+ Array(groupings).each { |grouping| prune_blank_advanced_conditions!(grouping) }
213
+ end
214
+
215
+ # Params are not yet indifferent-access here, so look under both spellings
216
+ # of a key and both its short and long forms.
217
+ def fetch_either(hash, short, long)
218
+ hash[short] || hash[short.to_s] || hash[long] || hash[long.to_s]
219
+ end
220
+
221
+ def blank_advanced_condition?(condition)
222
+ return false unless condition.is_a?(Hash)
223
+
224
+ values = condition[:v] || condition['v']
225
+
226
+ case values
227
+ when Array then values.all? { |v| blank_condition_value?(unwrap_value(v)) }
228
+ when Hash then values.all? { |_k, v| blank_condition_value?(unwrap_value(v)) }
229
+ end
230
+ end
231
+
232
+ # Values in the `c:` API may be given bare or wrapped in a `{ value: ... }`
233
+ # envelope, the same two forms `Condition#values=` accepts.
234
+ def unwrap_value(value)
235
+ value.is_a?(Hash) ? (value[:value] || value['value']) : value
236
+ end
206
237
  end
207
238
  end
@@ -1,3 +1,3 @@
1
1
  module Ransack
2
- VERSION = '4.4.3'
2
+ VERSION = '5.0.0'
3
3
  end
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: 4.4.3
4
+ version: 5.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ernie Miller