ransack 4.4.1 → 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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -1
  3. data/lib/ransack/adapters/active_record/context.rb +12 -10
  4. data/lib/ransack/configuration.rb +38 -5
  5. data/lib/ransack/constants.rb +36 -11
  6. data/lib/ransack/context.rb +36 -4
  7. data/lib/ransack/helpers/form_builder.rb +1 -17
  8. data/lib/ransack/helpers/form_helper.rb +7 -7
  9. data/lib/ransack/nodes/attribute.rb +11 -2
  10. data/lib/ransack/nodes/condition.rb +106 -30
  11. data/lib/ransack/nodes/grouping.rb +23 -5
  12. data/lib/ransack/nodes/node.rb +13 -0
  13. data/lib/ransack/nodes/sort.rb +1 -2
  14. data/lib/ransack/nodes/value.rb +3 -1
  15. data/lib/ransack/predicate.rb +24 -3
  16. data/lib/ransack/search.rb +65 -22
  17. data/lib/ransack/version.rb +1 -1
  18. metadata +4 -60
  19. data/spec/console.rb +0 -13
  20. data/spec/factories/articles.rb +0 -7
  21. data/spec/factories/comments.rb +0 -7
  22. data/spec/factories/notes.rb +0 -13
  23. data/spec/factories/people.rb +0 -10
  24. data/spec/factories/tags.rb +0 -5
  25. data/spec/helpers/polyamorous_helper.rb +0 -13
  26. data/spec/helpers/ransack_helper.rb +0 -9
  27. data/spec/polyamorous/activerecord_compatibility_spec.rb +0 -15
  28. data/spec/polyamorous/join_association_spec.rb +0 -29
  29. data/spec/polyamorous/join_dependency_spec.rb +0 -80
  30. data/spec/polyamorous/join_spec.rb +0 -19
  31. data/spec/ransack/adapters/active_record/base_spec.rb +0 -943
  32. data/spec/ransack/adapters/active_record/context_spec.rb +0 -219
  33. data/spec/ransack/configuration_spec.rb +0 -201
  34. data/spec/ransack/helpers/form_builder_spec.rb +0 -169
  35. data/spec/ransack/helpers/form_helper_spec.rb +0 -1075
  36. data/spec/ransack/invalid_search_error_spec.rb +0 -27
  37. data/spec/ransack/nodes/condition_spec.rb +0 -333
  38. data/spec/ransack/nodes/grouping_spec.rb +0 -111
  39. data/spec/ransack/nodes/value_spec.rb +0 -126
  40. data/spec/ransack/predicate_spec.rb +0 -504
  41. data/spec/ransack/ransacker_spec.rb +0 -69
  42. data/spec/ransack/search_spec.rb +0 -821
  43. data/spec/ransack/translate_spec.rb +0 -16
  44. data/spec/spec_helper.rb +0 -52
  45. data/spec/support/en.yml +0 -18
  46. data/spec/support/schema.rb +0 -419
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9174f26f96f894e12f75427d0bc4fee92d3cda026ed899f2356b42973d1fb3bf
4
- data.tar.gz: aa87e6f2d75557369ca2cf1e7821aaf833490c2793380535d9e5514823ebd117
3
+ metadata.gz: 0251527dc22cca8cb55ff672b598d74fef0ec4c2b1c6336cb0b8d52d69dabafc
4
+ data.tar.gz: fd218c05d4697cca5b9a8b4111af7a5fa4ddc559071e9a3194aca2f2b2da6c9d
5
5
  SHA512:
6
- metadata.gz: cdb319a97b23b03a24ac010136212ac53460f03b208e5dff1efae8ff32833256dff1222abbce533e704320740a5bd1cd1d56e14d35d7264a733d0bc1aa75e918
7
- data.tar.gz: 12dd3eae7f2cd9bf78d30ab4ae76e5a3ffc80f33ddb3737856cabf3cfb395adf8b9af83eb5feb4e131c8ffd5294e7bf81870da42f7cdbc734288f086902aab96
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)
@@ -65,7 +65,8 @@ module Ransack
65
65
 
66
66
  def attribute_method?(str, klass = @klass)
67
67
  exists = false
68
- if ransackable_attribute?(str, klass)
68
+ if ransackable_attribute?(str, klass) ||
69
+ ransortable_attribute?(str, klass)
69
70
  exists = true
70
71
  elsif (segments = str.split(Constants::UNDERSCORE)).size > 1
71
72
  remainder = []
@@ -212,9 +213,9 @@ module Ransack
212
213
  end
213
214
  when Arel::Nodes::Equality
214
215
  pk = primary_key
215
- if join_root.left == pk
216
+ if join_root.left.eql?(pk)
216
217
  join_root.right
217
- elsif join_root.right == pk
218
+ elsif join_root.right.eql?(pk)
218
219
  join_root.left
219
220
  else
220
221
  nil
@@ -239,7 +240,8 @@ module Ransack
239
240
  def get_parent_and_attribute_name(str, parent = @base)
240
241
  attr_name = nil
241
242
 
242
- if ransackable_attribute?(str, klassify(parent))
243
+ if ransackable_attribute?(str, klassify(parent)) ||
244
+ ransortable_attribute?(str, klassify(parent))
243
245
  attr_name = str
244
246
  elsif (segments = str.split(Constants::UNDERSCORE)).size > 1
245
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}" }
@@ -155,22 +155,47 @@ module Ransack
155
155
  type: :boolean,
156
156
  validator: proc { |v| BOOLEAN_VALUES.include?(v) },
157
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
+ }
158
183
  ]
159
184
  ].freeze
160
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
+
161
192
  module_function
162
- # 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
163
197
  def escape_wildcards(unescaped)
164
- case ActiveRecord::Base.connection.adapter_name
165
- when "Mysql2".freeze
166
- # Necessary for MySQL
167
- unescaped.to_s.gsub(/([\\%_])/, '\\\\\\1')
168
- when "PostGIS".freeze, "PostgreSQL".freeze
169
- # Necessary for PostgreSQL
170
- unescaped.to_s.gsub(/([\\%_.])/, '\\\\\\1')
171
- else
172
- unescaped
173
- end
198
+ unescaped.to_s.gsub(/([\\%_])/) { "#{LIKE_ESCAPE_CHARACTER}#{$1}" }
174
199
  end
175
200
  end
176
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
@@ -89,6 +94,14 @@ module Ransack
89
94
  @klass.method(scope).arity
90
95
  end
91
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
+
92
105
  def bind(object, str)
93
106
  return nil unless str
94
107
  object.parent, object.attr_name = bind_pair_for(str)
@@ -159,8 +172,11 @@ module Ransack
159
172
  end
160
173
 
161
174
  def ransackable_attribute?(str, klass)
162
- klass.ransackable_attributes(auth_object).any? { |s| s.to_sym == str.to_sym } ||
163
- 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 }
164
180
  end
165
181
 
166
182
  def ransackable_association?(str, klass)
@@ -186,5 +202,21 @@ module Ransack
186
202
  def searchable_associations(str = ''.freeze)
187
203
  traverse(str).ransackable_associations(auth_object)
188
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
189
221
  end
190
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