ransack 5.0.1 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +13 -27
- data/lib/ransack/active_record/base.rb +154 -0
- data/lib/ransack/active_record/context.rb +504 -0
- data/lib/ransack/active_record/dialect.rb +83 -0
- data/lib/ransack/active_record/join.rb +75 -0
- data/lib/ransack/active_record/join_association.rb +104 -0
- data/lib/ransack/active_record/join_dependency.rb +123 -0
- data/lib/ransack/active_record/reflection.rb +18 -0
- data/lib/ransack/active_record/tree_node.rb +13 -0
- data/lib/ransack/active_record.rb +44 -9
- data/lib/ransack/configuration.rb +49 -1
- data/lib/ransack/constants.rb +61 -0
- data/lib/ransack/context.rb +170 -35
- data/lib/ransack/helpers/form_builder.rb +30 -25
- data/lib/ransack/nodes/bindable.rb +0 -1
- data/lib/ransack/nodes/condition.rb +215 -73
- data/lib/ransack/nodes/grouping.rb +35 -11
- data/lib/ransack/nodes/sort.rb +1 -1
- data/lib/ransack/nodes/value.rb +8 -1
- data/lib/ransack/predicate.rb +8 -1
- data/lib/ransack/search.rb +52 -9
- data/lib/ransack/version.rb +1 -1
- data/lib/ransack.rb +4 -1
- metadata +11 -13
- data/lib/polyamorous/activerecord/join_association.rb +0 -70
- data/lib/polyamorous/activerecord/join_association_7_2.rb +0 -55
- data/lib/polyamorous/activerecord/join_dependency.rb +0 -102
- data/lib/polyamorous/activerecord/reflection.rb +0 -11
- data/lib/polyamorous/join.rb +0 -70
- data/lib/polyamorous/polyamorous.rb +0 -27
- data/lib/polyamorous/swapping_reflection_class.rb +0 -11
- data/lib/polyamorous/tree_node.rb +0 -7
- data/lib/ransack/adapters/active_record/base.rb +0 -152
- data/lib/ransack/adapters/active_record/context.rb +0 -375
data/lib/ransack/context.rb
CHANGED
|
@@ -4,31 +4,33 @@ module Ransack
|
|
|
4
4
|
class Context
|
|
5
5
|
attr_reader :search, :object, :klass, :base, :engine, :arel_visitor
|
|
6
6
|
attr_accessor :auth_object, :search_key, :ignore_unknown_conditions
|
|
7
|
+
attr_writer :null_sentinel
|
|
8
|
+
|
|
9
|
+
# True when the search was built from `ActionController::Parameters` the
|
|
10
|
+
# controller permitted. A model with no allowlist of its own then lets
|
|
11
|
+
# that permit list be the boundary (see `allowlist` below).
|
|
12
|
+
attr_accessor :permitted
|
|
7
13
|
|
|
8
14
|
class << self
|
|
9
15
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
16
|
+
# An ORM integration registers a block that returns a Context for the
|
|
17
|
+
# objects it understands and nil for anything else. Ransack ships the
|
|
18
|
+
# Active Record integration (see Ransack::ActiveRecord::Context); an
|
|
19
|
+
# integration for another ORM registers its own resolver here.
|
|
20
|
+
def register(&resolver)
|
|
21
|
+
Ransack::Context.resolvers << resolver
|
|
14
22
|
end
|
|
15
23
|
|
|
16
|
-
def
|
|
17
|
-
|
|
18
|
-
when ActiveRecord::Relation
|
|
19
|
-
Adapters::ActiveRecord::Context.new(object.klass, options)
|
|
20
|
-
end
|
|
24
|
+
def resolvers
|
|
25
|
+
@resolvers ||= []
|
|
21
26
|
end
|
|
22
27
|
|
|
23
28
|
def for(object, options = {})
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
end
|
|
30
|
-
context or raise ArgumentError,
|
|
31
|
-
"Don't know what context to use for #{object}"
|
|
29
|
+
Ransack::Context.resolvers.each do |resolver|
|
|
30
|
+
context = resolver.call(object, options)
|
|
31
|
+
return context if context
|
|
32
|
+
end
|
|
33
|
+
raise ArgumentError, "Don't know what context to use for #{object}"
|
|
32
34
|
end
|
|
33
35
|
|
|
34
36
|
end # << self
|
|
@@ -39,19 +41,36 @@ module Ransack
|
|
|
39
41
|
!Ransack.options[:ignore_unknown_conditions] || ignore_unknown_conditions == false
|
|
40
42
|
end
|
|
41
43
|
|
|
44
|
+
# Falls back to the global option unless this search's own option was
|
|
45
|
+
# set, including to `false` to turn the feature off for one search. A
|
|
46
|
+
# Condition captures this when it is built, so a later search reusing
|
|
47
|
+
# this context does not change the conditions of an earlier one.
|
|
48
|
+
def null_sentinel
|
|
49
|
+
@null_sentinel.nil? ? Ransack.options[:null_sentinel] : @null_sentinel
|
|
50
|
+
end
|
|
51
|
+
|
|
42
52
|
def initialize(object, options = {})
|
|
43
53
|
@object = relation_for(object)
|
|
44
54
|
@klass = @object.klass
|
|
45
|
-
@
|
|
46
|
-
@join_type = options[:join_type] || Polyamorous::OuterJoin
|
|
55
|
+
@join_type = options[:join_type] || Arel::Nodes::OuterJoin
|
|
47
56
|
@search_key = options[:search_key] || Ransack.options[:search_key]
|
|
48
57
|
@associations_pot = {}
|
|
49
58
|
@tables_pot = {}
|
|
50
59
|
@lock_associations = []
|
|
60
|
+
@join_dependency = join_dependency(@object)
|
|
51
61
|
|
|
52
62
|
@base = @join_dependency.instance_variable_get(:@join_root)
|
|
53
63
|
end
|
|
54
64
|
|
|
65
|
+
# False when a key has more `_`-separated segments than Ransack will
|
|
66
|
+
# parse. The attribute / association / predicate parsing that follows is
|
|
67
|
+
# superlinear in the segment count and the key is attacker-controlled, so
|
|
68
|
+
# an over-long key is treated as unknown rather than parsed
|
|
69
|
+
# (GHSA-j3f8-w227-4hh8).
|
|
70
|
+
def key_within_depth_limit?(key)
|
|
71
|
+
key.to_s.count(Constants::UNDERSCORE) <= Constants::MAX_KEY_DEPTH
|
|
72
|
+
end
|
|
73
|
+
|
|
55
74
|
def bind_pair_for(key)
|
|
56
75
|
@bind_pairs ||= {}
|
|
57
76
|
|
|
@@ -61,14 +80,24 @@ module Ransack
|
|
|
61
80
|
end
|
|
62
81
|
end
|
|
63
82
|
|
|
83
|
+
# The SQL dialect the search will be rendered in, for the few places
|
|
84
|
+
# where generated SQL differs by database. See
|
|
85
|
+
# Ransack::ActiveRecord::Dialect for the questions it answers.
|
|
86
|
+
def dialect
|
|
87
|
+
raise NotImplementedError, "#{self.class} must implement #dialect"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The model class behind a search object, an association node or a class.
|
|
91
|
+
# Each ORM integration decides what counts as a model, so this is defined
|
|
92
|
+
# on the integration's Context subclass.
|
|
64
93
|
def klassify(obj)
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
94
|
+
raise NotImplementedError, "#{self.class} must implement #klassify"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Whether a class can be the base of a search. An ORM integration refines
|
|
98
|
+
# this; by default any class Ransack has been mixed into qualifies.
|
|
99
|
+
def searchable_class?(klass)
|
|
100
|
+
klass.respond_to?(:ransackable_attributes)
|
|
72
101
|
end
|
|
73
102
|
|
|
74
103
|
# Convert a string representing a chain of associations and an attribute
|
|
@@ -78,8 +107,15 @@ module Ransack
|
|
|
78
107
|
table_for(parent)[attr_name]
|
|
79
108
|
end
|
|
80
109
|
|
|
110
|
+
# A bare `false` normally means "this checkbox was not ticked" and the
|
|
111
|
+
# scope is not applied at all. A scope listed in
|
|
112
|
+
# `ransackable_scopes_skip_sanitize_args` has asked to see its values as
|
|
113
|
+
# given, so `false` reaches it like any other value and it can be driven
|
|
114
|
+
# by a yes / no / any select (#1375).
|
|
81
115
|
def chain_scope(scope, args)
|
|
82
|
-
return unless @klass.method(scope)
|
|
116
|
+
return unless @klass.method(scope)
|
|
117
|
+
return if args == false && !ransackable_scope_skip_sanitize_args?(scope, @klass)
|
|
118
|
+
|
|
83
119
|
@object = if scope_arity(scope) < 1 && args == true
|
|
84
120
|
@object.public_send(scope)
|
|
85
121
|
elsif scope_arity(scope) == 1 && args.is_a?(Array)
|
|
@@ -137,6 +173,8 @@ module Ransack
|
|
|
137
173
|
end
|
|
138
174
|
|
|
139
175
|
def association_path(str, base = @base)
|
|
176
|
+
return ''.freeze unless key_within_depth_limit?(str)
|
|
177
|
+
|
|
140
178
|
base = klassify(base)
|
|
141
179
|
str ||= ''.freeze
|
|
142
180
|
path = []
|
|
@@ -150,6 +188,10 @@ module Ransack
|
|
|
150
188
|
association_parts.join(Constants::UNDERSCORE)
|
|
151
189
|
)
|
|
152
190
|
next unless found_assoc = get_association(assoc, base)
|
|
191
|
+
# A polymorphic association is only complete once its
|
|
192
|
+
# `_of_Model_type` suffix has been consumed; matching on the bare
|
|
193
|
+
# name would ask the reflection for a class it cannot know (#1557).
|
|
194
|
+
next if found_assoc.polymorphic? && klass.nil?
|
|
153
195
|
path += association_parts
|
|
154
196
|
association_parts = []
|
|
155
197
|
base = klassify(klass || found_assoc)
|
|
@@ -160,27 +202,71 @@ module Ransack
|
|
|
160
202
|
end
|
|
161
203
|
|
|
162
204
|
def unpolymorphize_association(str)
|
|
163
|
-
if (match = str.match(/_of_([^_]+?)_type$/))
|
|
164
|
-
|
|
205
|
+
if (match = str.match(/_of_([^_]+?)_type$/)) &&
|
|
206
|
+
(klass = polymorphic_class(match.captures.first))
|
|
207
|
+
[match.pre_match, klass]
|
|
165
208
|
else
|
|
166
209
|
[str, nil]
|
|
167
210
|
end
|
|
168
211
|
end
|
|
169
212
|
|
|
213
|
+
# The class named in an `_of_Model_type` suffix comes from the query
|
|
214
|
+
# string, so it can name anything: a constant that does not exist, a
|
|
215
|
+
# lowercase word, a module, a class with no table, a path through
|
|
216
|
+
# something that is no module at all (`ENV::Person`). None of those is a
|
|
217
|
+
# model to search, so the suffix is then no polymorphic reference at all
|
|
218
|
+
# and the whole key fails as any other unknown attribute does, ignored or
|
|
219
|
+
# raised as `InvalidSearchError` under `ignore_unknown_conditions`.
|
|
220
|
+
# `Kernel.const_get` used to raise `NameError` straight from the URL
|
|
221
|
+
# (#1738).
|
|
222
|
+
def polymorphic_class(name)
|
|
223
|
+
klass = name.safe_constantize
|
|
224
|
+
klass if klass.is_a?(Class) && searchable_class?(klass)
|
|
225
|
+
rescue TypeError
|
|
226
|
+
# `safe_constantize` swallows a missing constant but not a namespace
|
|
227
|
+
# that is not a module, which Ruby reports as a TypeError.
|
|
228
|
+
nil
|
|
229
|
+
end
|
|
230
|
+
|
|
170
231
|
def ransackable_alias(str)
|
|
171
232
|
klass._ransack_aliases.fetch(str, klass._ransack_aliases.fetch(str.to_sym, str))
|
|
172
233
|
end
|
|
173
234
|
|
|
235
|
+
# Expands every `ransack_alias` in a condition name: the whole name, each
|
|
236
|
+
# `_or_` / `_and_` segment of a compound, and an alias defined on an
|
|
237
|
+
# associated model and reached through an association path
|
|
238
|
+
# (`person_term` when Person aliases `term`). An alias that expands to a
|
|
239
|
+
# compound is joined with that compound's own combinator, so
|
|
240
|
+
# `term_or_daddy` with `term => name_or_email` becomes
|
|
241
|
+
# `name_or_email_or_parent_name`. Returns the name unchanged when it
|
|
242
|
+
# holds no alias.
|
|
243
|
+
def resolve_aliases(str)
|
|
244
|
+
return str.to_s unless key_within_depth_limit?(str)
|
|
245
|
+
|
|
246
|
+
whole = resolve_alias_segment(str)
|
|
247
|
+
return whole if whole != str
|
|
248
|
+
# A name that is an attribute in its own right is never split, even if
|
|
249
|
+
# it contains a combinator (a column called `true_or_false`).
|
|
250
|
+
return str if attribute_method?(str)
|
|
251
|
+
|
|
252
|
+
segments = str.split(/_and_|_or_/)
|
|
253
|
+
resolved = segments.map { |segment| resolve_alias_segment(segment) }
|
|
254
|
+
return str if resolved == segments
|
|
255
|
+
|
|
256
|
+
combinator = str[/_(or|and)_/, 1] || Constants::OR
|
|
257
|
+
resolved.join("_#{combinator}_")
|
|
258
|
+
end
|
|
259
|
+
|
|
174
260
|
def ransackable_attribute?(str, klass)
|
|
175
|
-
klass
|
|
261
|
+
attributes_for(klass).any? { |s| s.to_sym == str.to_sym }
|
|
176
262
|
end
|
|
177
263
|
|
|
178
264
|
def ransortable_attribute?(str, klass)
|
|
179
|
-
klass
|
|
265
|
+
sortable_attributes_for(klass).any? { |s| s.to_sym == str.to_sym }
|
|
180
266
|
end
|
|
181
267
|
|
|
182
268
|
def ransackable_association?(str, klass)
|
|
183
|
-
klass
|
|
269
|
+
associations_for(klass).any? { |s| s.to_sym == str.to_sym }
|
|
184
270
|
end
|
|
185
271
|
|
|
186
272
|
def ransackable_scope?(str, klass)
|
|
@@ -192,19 +278,68 @@ module Ransack
|
|
|
192
278
|
end
|
|
193
279
|
|
|
194
280
|
def searchable_attributes(str = ''.freeze)
|
|
195
|
-
traverse(str)
|
|
281
|
+
attributes_for(traverse(str))
|
|
196
282
|
end
|
|
197
283
|
|
|
198
284
|
def sortable_attributes(str = ''.freeze)
|
|
199
|
-
traverse(str)
|
|
285
|
+
sortable_attributes_for(traverse(str))
|
|
200
286
|
end
|
|
201
287
|
|
|
202
288
|
def searchable_associations(str = ''.freeze)
|
|
203
|
-
traverse(str)
|
|
289
|
+
associations_for(traverse(str))
|
|
204
290
|
end
|
|
205
291
|
|
|
206
292
|
private
|
|
207
293
|
|
|
294
|
+
def attributes_for(klass)
|
|
295
|
+
allowlist(klass, :ransackable_attributes) { klass.authorizable_ransackable_attributes }
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# `ransortable_attributes` defaults to `ransackable_attributes`, so a
|
|
299
|
+
# model that defines either one has a sort list of its own.
|
|
300
|
+
def sortable_attributes_for(klass)
|
|
301
|
+
allowlist(klass, :ransortable_attributes, :ransackable_attributes) do
|
|
302
|
+
klass.authorizable_ransackable_attributes
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def associations_for(klass)
|
|
307
|
+
allowlist(klass, :ransackable_associations) { klass.authorizable_ransackable_associations }
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# The allowlist a search consults on `klass`. A model that defines its
|
|
311
|
+
# own `method` is always asked, so its list applies on top of whatever
|
|
312
|
+
# the controller permitted. A model that defines none has left the
|
|
313
|
+
# decision to the controller: when the parameters were permitted, the
|
|
314
|
+
# block's list of everything that exists is used, and otherwise the
|
|
315
|
+
# model's default runs, which raises asking for a list (#1403).
|
|
316
|
+
def allowlist(klass, method, *fallbacks)
|
|
317
|
+
if permitted && [method, *fallbacks].none? { |m| klass.ransackable_list_defined?(m) }
|
|
318
|
+
yield
|
|
319
|
+
else
|
|
320
|
+
klass.public_send(method, auth_object)
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def resolve_alias_segment(segment)
|
|
325
|
+
target = ransackable_alias(segment)
|
|
326
|
+
return target if target != segment
|
|
327
|
+
|
|
328
|
+
path = association_path(segment)
|
|
329
|
+
return segment if path.blank?
|
|
330
|
+
|
|
331
|
+
remainder = segment.delete_prefix("#{path}_")
|
|
332
|
+
associated = traverse(path)
|
|
333
|
+
target = associated._ransack_aliases.fetch(remainder, associated._ransack_aliases.fetch(remainder.to_sym, remainder))
|
|
334
|
+
return segment if target == remainder
|
|
335
|
+
# The target may itself be one attribute whose name contains a
|
|
336
|
+
# combinator (`terms_and_conditions`); it is prefixed whole.
|
|
337
|
+
return "#{path}_#{target}" if ransackable_attribute?(target, associated)
|
|
338
|
+
|
|
339
|
+
# Prefix every attribute of the alias's target with the path.
|
|
340
|
+
target.gsub(/(\A|_and_|_or_)/) { "#{$1}#{path}_" }
|
|
341
|
+
end
|
|
342
|
+
|
|
208
343
|
def cast_scope_args(args)
|
|
209
344
|
if args.is_a?(Array)
|
|
210
345
|
args = args.map(&method(:cast_scope_args))
|
|
@@ -112,7 +112,6 @@ module Ransack
|
|
|
112
112
|
|
|
113
113
|
def predicate_select(options = {}, html_options = {})
|
|
114
114
|
options[:compounds] = true if options[:compounds].nil?
|
|
115
|
-
options[:selected] ||= nil
|
|
116
115
|
default = options.delete(:default) || Constants::CONT
|
|
117
116
|
|
|
118
117
|
keys =
|
|
@@ -134,6 +133,7 @@ module Ransack
|
|
|
134
133
|
collection = keys.map { |k| [k, Translate.predicate(k)] }
|
|
135
134
|
object.predicate ||= Predicate.named(default) if
|
|
136
135
|
can_use_default?(default, :predicate, keys)
|
|
136
|
+
options[:selected] = selected_predicate unless options.key?(:selected)
|
|
137
137
|
template_collection_select(:p, collection, options, html_options)
|
|
138
138
|
end
|
|
139
139
|
|
|
@@ -144,17 +144,25 @@ module Ransack
|
|
|
144
144
|
|
|
145
145
|
private
|
|
146
146
|
|
|
147
|
+
# Rails reads a select's current value back through the field name, which
|
|
148
|
+
# for the predicate select is +p+. Only a Condition has a public +p+, and
|
|
149
|
+
# +Kernel#p+ is private, so a builder for a Search or Grouping would
|
|
150
|
+
# raise instead of finding nothing. Resolve the value here instead.
|
|
151
|
+
def selected_predicate
|
|
152
|
+
object.predicate_name if object.respond_to?(:predicate_name)
|
|
153
|
+
end
|
|
154
|
+
|
|
147
155
|
def template_grouped_collection_select(collection, options, html_options)
|
|
148
156
|
@template.grouped_collection_select(
|
|
149
157
|
@object_name, :name, collection, :last, :first, :first, :last,
|
|
150
|
-
objectify_options(options), @
|
|
158
|
+
objectify_options(options), @default_html_options.merge(html_options)
|
|
151
159
|
)
|
|
152
160
|
end
|
|
153
161
|
|
|
154
162
|
def template_collection_select(name, collection, options, html_options)
|
|
155
163
|
@template.collection_select(
|
|
156
164
|
@object_name, name, collection, :first, :last,
|
|
157
|
-
objectify_options(options), @
|
|
165
|
+
objectify_options(options), @default_html_options.merge(html_options)
|
|
158
166
|
)
|
|
159
167
|
end
|
|
160
168
|
|
|
@@ -188,31 +196,28 @@ module Ransack
|
|
|
188
196
|
end
|
|
189
197
|
end
|
|
190
198
|
|
|
199
|
+
# Expands the +associations:+ option of +attribute_select+ into the flat
|
|
200
|
+
# list of association paths ransack understands. A String or Symbol
|
|
201
|
+
# names one association; an Array lists several; a Hash reaches through
|
|
202
|
+
# an association to its own associations, so +{ articles: :comments }+
|
|
203
|
+
# yields +articles+ and +articles_comments+. Shapes nest freely.
|
|
191
204
|
def association_array(obj, prefix = nil)
|
|
192
|
-
(
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
case obj
|
|
199
|
-
when Array
|
|
200
|
-
obj
|
|
201
|
-
when Hash
|
|
202
|
-
association_hash(obj)
|
|
203
|
-
else
|
|
204
|
-
[obj]
|
|
205
|
-
end
|
|
206
|
-
end
|
|
207
|
-
|
|
208
|
-
def association_hash(obj)
|
|
209
|
-
obj.map do |key, value|
|
|
210
|
-
case value
|
|
211
|
-
when Array, Hash
|
|
212
|
-
association_array(value, key.to_s)
|
|
205
|
+
Array.wrap(obj).flat_map do |item|
|
|
206
|
+
case item
|
|
207
|
+
when Hash
|
|
208
|
+
association_hash(item, prefix)
|
|
209
|
+
when nil
|
|
210
|
+
[]
|
|
213
211
|
else
|
|
214
|
-
[
|
|
212
|
+
[[prefix, item].compact.join(Constants::UNDERSCORE)]
|
|
215
213
|
end
|
|
214
|
+
end.uniq
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def association_hash(obj, prefix = nil)
|
|
218
|
+
obj.flat_map do |key, value|
|
|
219
|
+
path = [prefix, key].compact.join(Constants::UNDERSCORE)
|
|
220
|
+
[path] + association_array(value, path)
|
|
216
221
|
end
|
|
217
222
|
end
|
|
218
223
|
|