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 +4 -4
- data/README.md +2 -1
- data/lib/ransack/adapters/active_record/context.rb +12 -14
- data/lib/ransack/configuration.rb +38 -5
- data/lib/ransack/constants.rb +36 -20
- data/lib/ransack/context.rb +36 -15
- data/lib/ransack/helpers/form_builder.rb +1 -17
- data/lib/ransack/helpers/form_helper.rb +7 -7
- data/lib/ransack/nodes/attribute.rb +11 -2
- data/lib/ransack/nodes/condition.rb +106 -30
- data/lib/ransack/nodes/grouping.rb +23 -7
- data/lib/ransack/nodes/node.rb +13 -0
- data/lib/ransack/nodes/sort.rb +1 -2
- data/lib/ransack/nodes/value.rb +3 -1
- data/lib/ransack/predicate.rb +24 -3
- data/lib/ransack/search.rb +72 -41
- data/lib/ransack/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0251527dc22cca8cb55ff672b598d74fef0ec4c2b1c6336cb0b8d52d69dabafc
|
|
4
|
+
data.tar.gz: fd218c05d4697cca5b9a8b4111af7a5fa4ddc559071e9a3194aca2f2b2da6c9d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bc524a7f9a4fac90d6f2e3b9818ccf76bab0f44c872c33db043f049f759f2641c03b338c2cfa28c70a20f6d1d2c4569390e43ec9e5da9daf2537002c519ad3fc
|
|
7
|
+
data.tar.gz: 3a599c3c9472f0033976ed0301cc0545b62711cccb36817db373c0f19ba0b828640a89bc3784b533346633112fb017353c63c490fa792b3eb3e7aea82f41e527
|
data/README.md
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/activerecord-hackery/ransack/actions)
|
|
4
4
|
[](http://badge.fury.io/rb/ransack)
|
|
5
|
+
[](https://deepwiki.com/activerecord-hackery/ransack)
|
|
5
6
|
[](https://codeclimate.com/github/activerecord-hackery/ransack)
|
|
6
7
|
[](#backers) [](#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.
|
|
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[:
|
|
47
|
+
case Ransack.options[:fields_sort_option]
|
|
48
48
|
when :nulls_first
|
|
49
|
-
scope_or_sort = scope_or_sort.direction == :asc ?
|
|
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 ?
|
|
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 =
|
|
53
|
+
scope_or_sort = scope_or_sort.nulls_first
|
|
54
54
|
when :nulls_always_last
|
|
55
|
-
scope_or_sort =
|
|
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
|
|
216
|
+
if join_root.left.eql?(pk)
|
|
218
217
|
join_root.right
|
|
219
|
-
elsif join_root.right
|
|
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
|
-
|
|
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.
|
|
166
|
+
# c.fields_sort_option = :nulls_first # or e.g. :nulls_always_last
|
|
166
167
|
# end
|
|
167
168
|
#
|
|
168
|
-
#
|
|
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.
|
|
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}" }
|
data/lib/ransack/constants.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
-
|
|
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
|
data/lib/ransack/context.rb
CHANGED
|
@@ -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
|
-
|
|
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] ||=
|
|
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] ||=
|
|
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.
|
|
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 |
|
|
85
|
-
|
|
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
|
|
108
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
322
|
-
#
|
|
323
|
-
#
|
|
324
|
-
#
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 :
|
|
5
|
-
|
|
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
|
|
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
|
-
|
|
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
|
data/lib/ransack/nodes/node.rb
CHANGED
|
@@ -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
|
data/lib/ransack/nodes/sort.rb
CHANGED
|
@@ -26,8 +26,7 @@ module Ransack
|
|
|
26
26
|
|
|
27
27
|
def valid?
|
|
28
28
|
bound? && attr &&
|
|
29
|
-
|
|
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)
|
data/lib/ransack/nodes/value.rb
CHANGED
|
@@ -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)
|
data/lib/ransack/predicate.rb
CHANGED
|
@@ -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
|
-
|
|
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?
|
data/lib/ransack/search.rb
CHANGED
|
@@ -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
|
-
|
|
28
|
-
|
|
29
|
-
params
|
|
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 =
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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
|
data/lib/ransack/version.rb
CHANGED