ransack 6.0.1 → 6.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +43 -68
  3. data/lib/ransack/active_record/context.rb +207 -75
  4. data/lib/ransack/active_record/join_association.rb +8 -17
  5. data/lib/ransack/active_record/join_dependency.rb +163 -9
  6. data/lib/ransack/configuration.rb +25 -2
  7. data/lib/ransack/context.rb +55 -7
  8. data/lib/ransack/errors.rb +13 -0
  9. data/lib/ransack/invalid_search_error.rb +3 -3
  10. data/lib/ransack/locale/ar.yml +35 -0
  11. data/lib/ransack/locale/az.yml +36 -1
  12. data/lib/ransack/locale/bg.yml +35 -0
  13. data/lib/ransack/locale/ca.yml +36 -1
  14. data/lib/ransack/locale/cs.yml +35 -0
  15. data/lib/ransack/locale/da.yml +35 -0
  16. data/lib/ransack/locale/de.yml +36 -1
  17. data/lib/ransack/locale/el.yml +36 -1
  18. data/lib/ransack/locale/en.yml +35 -0
  19. data/lib/ransack/locale/es.yml +36 -1
  20. data/lib/ransack/locale/fa.yml +35 -0
  21. data/lib/ransack/locale/fi.yml +35 -0
  22. data/lib/ransack/locale/fr.yml +35 -0
  23. data/lib/ransack/locale/hu.yml +35 -0
  24. data/lib/ransack/locale/id.yml +35 -0
  25. data/lib/ransack/locale/it.yml +35 -0
  26. data/lib/ransack/locale/ja.yml +35 -0
  27. data/lib/ransack/locale/ko.yml +35 -0
  28. data/lib/ransack/locale/nl.yml +35 -0
  29. data/lib/ransack/locale/pt-BR.yml +35 -0
  30. data/lib/ransack/locale/ro.yml +35 -0
  31. data/lib/ransack/locale/ru.yml +35 -0
  32. data/lib/ransack/locale/sk.yml +35 -0
  33. data/lib/ransack/locale/sv.yml +35 -0
  34. data/lib/ransack/locale/tr.yml +36 -1
  35. data/lib/ransack/locale/uk.yml +35 -0
  36. data/lib/ransack/locale/zh-CN.yml +35 -0
  37. data/lib/ransack/locale/zh-TW.yml +35 -0
  38. data/lib/ransack/nodes/condition.rb +168 -42
  39. data/lib/ransack/nodes/grouping.rb +8 -17
  40. data/lib/ransack/nodes/node.rb +23 -1
  41. data/lib/ransack/nodes/sort.rb +7 -4
  42. data/lib/ransack/nodes/value.rb +6 -0
  43. data/lib/ransack/predicate.rb +19 -6
  44. data/lib/ransack/search.rb +21 -10
  45. data/lib/ransack/translate.rb +43 -35
  46. data/lib/ransack/version.rb +1 -1
  47. data/lib/ransack.rb +2 -1
  48. metadata +7 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9940472d0658f87581dd23a9668c17b557806f72735ed8e66ceef5332c099d8b
4
- data.tar.gz: 576b1c827db6b63a08d980c91fd063eee4b5bbdb359503a9c4af0bacfc134cfe
3
+ metadata.gz: 179b4004dbf19b3b72ebb685877fa5648b8f14b6180f0bf65cc03149be416ca6
4
+ data.tar.gz: 236e4f6a442ebc4dbd6abd0acb2824d6aff7ac56bff553b3f44bd3b473d766c4
5
5
  SHA512:
6
- metadata.gz: 173aab57088df1db09eff37d4e3098ebbb18042fe387ab151958d7d5a892481bbfffc2cd35b1934f87e015c79bdd60f53d3be6a1828e9d541cda76725947a19e
7
- data.tar.gz: 759843af14c0312b0167661a6e650bf933da0cb8ee652ad381452b7509f9e348f59c4a45fa274f6f0364bb78e48a142e171788039fd8f9690e50200108f8c1f3
6
+ metadata.gz: 8d19b1a2f55526e8c2bce3f68cdf4c8844d4ae5d6996d0a843f145a48682506157b9aed2578f40cc9b839f31b0a4faea7a65abdfca5da98a43a26b1dae2cb77f
7
+ data.tar.gz: dacca9e1ecd1ba15e58ca2bd92d87d2d491e3e4326180248d79aa62fb486dc1eb0700459dc86ef6ed2154b4def13ae1e65fcad0d0a0f40f7cfc7284fe1c94607
data/README.md CHANGED
@@ -1,88 +1,63 @@
1
- # ![Ransack](./docs/assets/logo/ransack-h.png "Ransack")
1
+ # Ransack
2
+
3
+ > Search everywhere and in a hurry!
2
4
 
3
5
  [![Build Status](https://github.com/activerecord-hackery/ransack/workflows/test/badge.svg)](https://github.com/activerecord-hackery/ransack/actions)
4
6
  [![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)
6
- [![Code Climate](https://codeclimate.com/github/activerecord-hackery/ransack/badges/gpa.svg)](https://codeclimate.com/github/activerecord-hackery/ransack)
7
-
8
- # Introduction
9
-
10
- Ransack will help you easily add **searching to your Rails application**, without any additional dependencies.
11
-
12
- There are advanced searching solutions around, like ElasticSearch or Algolia. **Ransack** will do the job for many Rails websites, without the need to run additional infrastructure or work in a different language. With Ransack you do it all with standard Ruby and ERB.
13
-
14
- Ready to move beyond the basics? Use **advanced features** like i18n and extensive configuration options.
15
7
 
16
- Ransack is supported for Rails 8.1, 8.0, 7.2 on Ruby 3.2 and later.
8
+ Ransack adds **searching and sorting to your Rails application** with plain Ruby
9
+ and ERB, without a search server or any other infrastructure.
17
10
 
18
- ## Installation
11
+ Ransack is supported for Rails 8.1 on Ruby 3.4 and later, on Postgres, MySQL, Trilogy and SQLite.
19
12
 
20
- To install `ransack` and add it to your Gemfile, run
21
-
22
- ```ruby title='Gemfile'
13
+ ```ruby
23
14
  gem 'ransack'
24
15
  ```
25
16
 
26
- ### Bleeding edge
27
-
28
- If you would like to use the latest updates not yet published to RubyGems, use the `main` branch:
29
-
30
- ```ruby title='Gemfile'
31
- gem 'ransack', :github => 'activerecord-hackery/ransack', :branch => 'main'
17
+ ```ruby
18
+ @q = Post.ransack(params[:q])
19
+ @posts = @q.result
32
20
  ```
33
21
 
34
- ### Documentation
35
-
36
- There is [extensive documentation on Ransack](https://activerecord-hackery.github.io/ransack/), built with [Jekyll](https://jekyllrb.com/) and [Just the Docs](https://just-the-docs.com/) from the Markdown under `docs/` and published as a GitHub Pages site. Alternatively there is [AI Generated documentation](https://deepwiki.com/activerecord-hackery/ransack/1-overview) produced by [devin.ai](https://devin.ai/).
37
-
38
- This [gist](https://gist.github.com/raghubetina/d5fc3df67ddbadcac271) has a quick-start cheatsheet, created by [@raghubetina](https://gist.github.com/raghubetina)
39
-
40
- ## Issue tracker
41
-
42
- * Before filing an issue, please read the [Contributing Guide](CONTRIBUTING.md).
43
- * File an issue if a bug is caused by Ransack, is new (has not already been reported), and _can be reproduced from the information you provide_.
44
- * Please consider creating a pull request with a failing test that demonstrates the problem.
45
- * Contributions are welcome. :smiley:
46
- * Please do not use the issue tracker for personal support requests. Stack Overflow or [GitHub Discussions](https://github.com/activerecord-hackery/ransack/discussions) is a better place for that where a wider community can help you!
47
-
48
-
49
- ## Contributions
50
-
51
- To support the project:
52
-
53
- * Use Ransack in your apps, and let us know if you encounter anything that's
54
- broken or missing. A failing spec to demonstrate the issue is awesome. A pull
55
- request with passing tests is even better!
56
- * Before filing an issue or pull request, be sure to read and follow the
57
- [Contributing Guide](CONTRIBUTING.md).
58
- * Please use Stack Overflow or [GitHub Discussions](https://github.com/activerecord-hackery/ransack/discussions) for questions or discussion not
59
- directly related to bug reports, pull requests, or documentation improvements.
60
- * Spread the word on social media if Ransack's been useful
61
- to you. The more people who are using the project, the quicker we can find and
62
- fix bugs!
22
+ ```erb
23
+ <%= search_form_for @q do |f| %>
24
+ <%= f.search_field :title_cont %>
25
+ <% end %>
26
+ <%= sort_link(@q, :published_at) %>
27
+ ```
63
28
 
64
- ## Ransack for enterprise
29
+ ## Documentation
65
30
 
66
- Available as part of the [Tidelift Subscription](https://tidelift.com/subscription/pkg/rubygems-ransack?utm_source=rubygems-ransack&utm_medium=referral&utm_campaign=enterprise&utm_term=repo).
31
+ **[activerecord-hackery.github.io/ransack](https://activerecord-hackery.github.io/ransack/)**:
32
+ [getting started](https://activerecord-hackery.github.io/ransack/getting-started/),
33
+ [authorization](https://activerecord-hackery.github.io/ransack/getting-started/authorization/),
34
+ [predicates](https://activerecord-hackery.github.io/ransack/getting-started/search-matches/),
35
+ [upgrading](https://activerecord-hackery.github.io/ransack/getting-started/upgrading/)
36
+ and more. The site is built from the Markdown under [`docs/`](docs/).
67
37
 
68
- The maintainers of Ransack and thousands of other packages are working with Tidelift to deliver commercial support and maintenance for the open source dependencies you use to build your applications. Save time, reduce risk, and improve code health, while paying the maintainers of the exact dependencies you use. [Learn more.](https://tidelift.com/subscription/pkg/rubygems-ransack?utm_source=rubygems-ransack&utm_medium=referral&utm_campaign=enterprise&utm_term=repo)
38
+ ## Getting help and contributing
69
39
 
70
- ## Security contact information
40
+ * Questions belong in [GitHub Discussions](https://github.com/activerecord-hackery/ransack/discussions).
41
+ * Reproducible bugs belong in [Issues](https://github.com/activerecord-hackery/ransack/issues).
42
+ Read the [Contributing Guide](CONTRIBUTING.md) first; a failing test is the best bug report.
43
+ * Report security vulnerabilities privately, through GitHub
44
+ [private vulnerability reporting](https://github.com/activerecord-hackery/ransack/security/advisories/new)
45
+ or the [Tidelift security contact](https://tidelift.com/security). See the
46
+ [Security Policy](.github/SECURITY.md) for supported versions.
71
47
 
72
- To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security). Tidelift will coordinate the fix and disclosure. See the [Security Policy](.github/SECURITY.md) for supported versions.
48
+ Ransack is maintained by volunteers. If it is useful to you, please consider
49
+ supporting us via [Open Collective](https://opencollective.com/ransack), or
50
+ through the [Tidelift Subscription](https://tidelift.com/subscription/pkg/rubygems-ransack?utm_source=rubygems-ransack&utm_medium=referral&utm_campaign=enterprise&utm_term=repo)
51
+ for commercial support.
73
52
 
74
53
  ## Contributors
75
54
 
76
- Ransack was created by [Ernie Miller](http://twitter.com/erniemiller) and is developed and maintained by:
77
- * [Sean Carroll](https://github.com/scarroll32)
78
- * [Deivid Rodriguez](https://github.com/deivid-rodriguez)
79
- * [Greg Molnar](https://github.com/gregmolnar)
80
- * [A great group of contributors](https://github.com/activerecord-hackery/ransack/graphs/contributors).
81
- - Ransack's logo is designed by [Anıl Kılıç](https://github.com/anilkilic).
82
-
83
- Alumni Maintainers
84
- - [Jon Atack](http://twitter.com/jonatack)
85
- - [Ryan Bigg](http://twitter.com/ryanbigg)
86
-
87
- This project exists thanks to all the people who contribute. <img src="https://opencollective.com/ransack/contributors.svg?width=890&button=false" />
55
+ Ransack is maintained by [Sean Carroll](https://github.com/scarroll32),
56
+ [Deivid Rodriguez](https://github.com/deivid-rodriguez),
57
+ [Greg Molnar](https://github.com/gregmolnar) and
58
+ [a great group of contributors](https://github.com/activerecord-hackery/ransack/graphs/contributors).
59
+ It was created by [Ernie Miller](http://twitter.com/erniemiller). See
60
+ [Community and support](https://activerecord-hackery.github.io/ransack/project/community/)
61
+ for the full list.
88
62
 
63
+ <img src="https://opencollective.com/ransack/contributors.svg?width=890&button=false" />
@@ -20,23 +20,36 @@ module Ransack
20
20
  # Attributes API rather than the schema column, so that
21
21
  # `attribute :starts_at, :datetime` on a date column is honoured (#1028);
22
22
  # a column with no attribute override reports its column type.
23
+ #
24
+ # There is no separate check that the table exists: the schema cache
25
+ # does not list temporary tables, and matches names case-sensitively
26
+ # where SQLite does not, so it rejected tables Active Record queries
27
+ # fine (#1770, #657, #668). A table that really is missing fails when
28
+ # the model loads its columns, with Active Record's own error.
23
29
  def type_for(attr)
24
30
  return nil unless attr && attr.valid?
25
- relation = attr.arel_attribute.relation
26
- name = attr.arel_attribute.name.to_s
27
- table = relation.respond_to?(:table_name) ? relation.table_name : relation.name
28
- schema_cache = self.klass.connection_pool.schema_cache
29
- unless schema_cache.send(:data_source_exists?, table)
30
- raise "No table named #{table} exists."
31
- end
31
+ name = attr.arel_attribute.name.to_s
32
32
  return nil unless attr.klass.attribute_types.key?(name)
33
33
 
34
34
  attr.klass.type_for_attribute(name).type
35
35
  end
36
36
 
37
+ # Evaluating a search leaves the context as it was, so the search can
38
+ # be evaluated again, or given another condition or sort, and still
39
+ # line up with the query Active Record builds (#1745, #1750).
40
+ #
41
+ # The relation returned keeps only the joins Ransack added that the
42
+ # search, as it stands now, reads: a condition or sort on the joined
43
+ # table, or one on an association reached through it. A negative
44
+ # condition on a collection is a correlated subquery and reads none of
45
+ # its own. Joins the relation already had are always kept.
37
46
  def evaluate(search, opts = {})
38
47
  viz = Visitor.new
39
- relation = @object.where(viz.accept(search.base))
48
+ predicate, used = tracking_used_associations do
49
+ search.sorts.each { |sort| use_association(sort.parent) if sort.valid? }
50
+ viz.accept(search.base)
51
+ end
52
+ relation = with_joins_used_by(@object, used).where(predicate)
40
53
 
41
54
  if search.sorts.any?
42
55
  relation = relation.except(:order)
@@ -74,12 +87,22 @@ module Ransack
74
87
  opts[:distinct] ? relation.distinct : relation
75
88
  end
76
89
 
90
+ # Asked for the same names over and over while a search is built, and
91
+ # answered from the allowlists, so kept per model for the life of the
92
+ # context and dropped with them.
77
93
  def attribute_method?(str, klass = @klass)
78
94
  return false unless key_within_depth_limit?(str)
79
95
 
96
+ cache = ((@attribute_methods ||= {})[klass] ||= {})
97
+ key = str.to_s
98
+ return cache[key] if cache.key?(key)
99
+
100
+ cache[key] = attribute_method_uncached?(key, klass)
101
+ end
102
+
103
+ def attribute_method_uncached?(str, klass)
80
104
  exists = false
81
- if ransackable_attribute?(str, klass) ||
82
- ransortable_attribute?(str, klass)
105
+ if allowlisted_attribute?(str, klass)
83
106
  exists = true
84
107
  elsif (segments = str.split(Constants::UNDERSCORE)).size > 1
85
108
  remainder = []
@@ -90,7 +113,7 @@ module Ransack
90
113
  segments.join(Constants::UNDERSCORE)
91
114
  )
92
115
  if found_assoc = traversable_association(assoc, poly_class, klass)
93
- exists = attribute_method?(
116
+ exists = attribute_method_uncached?(
94
117
  remainder.join(Constants::UNDERSCORE),
95
118
  poly_class || found_assoc.klass
96
119
  )
@@ -99,6 +122,20 @@ module Ransack
99
122
  end
100
123
  exists
101
124
  end
125
+ private :attribute_method_uncached?
126
+
127
+ # A name on the allowlists is only taken for an attribute of `klass`
128
+ # when it is one: a column or `attribute` declaration, an
129
+ # `alias_attribute`, a ransacker or a `ransack_alias`. Anything else,
130
+ # such as `person_name` listed on Article, is left to be followed
131
+ # through the associations rather than bound as a missing column
132
+ # (#1774).
133
+ def allowlisted_attribute?(str, klass)
134
+ (ransackable_attribute?(str, klass) || ransortable_attribute?(str, klass)) &&
135
+ (klass.has_attribute?(str) || klass._ransackers.key?(str) ||
136
+ klass._ransack_aliases.key?(str))
137
+ end
138
+ private :allowlisted_attribute?
102
139
 
103
140
  def table_for(parent)
104
141
  parent.table
@@ -155,24 +192,36 @@ module Ransack
155
192
  @join_dependency.send(:alias_tracker)
156
193
  end
157
194
 
195
+ # Keeps a join, and every join it hangs off, in every evaluation,
196
+ # whether or not the search reads it.
158
197
  def lock_association(association)
159
198
  @lock_associations << association
160
199
  end
161
200
 
162
- # Drops a join Ransack added, when a negative condition on a collection
163
- # replaces it with a correlated subquery. A join the relation already
164
- # had, or one another condition needs, is locked and stays.
165
- def remove_association(association)
166
- return if @lock_associations.include?(association)
201
+ # Records that the search being evaluated reads the association's
202
+ # joined table, so its join, and every join it hangs off, stays.
203
+ def use_association(association)
204
+ @used_associations << association if @used_associations && association
205
+ end
167
206
 
168
- @associations_pot.delete(association)
169
- @base.children.delete_if { |child| child.equal?(association) }
170
- @object.left_outer_joins_values = @object.left_outer_joins_values.reject { |jd|
171
- jd.is_a?(JoinDependency) &&
172
- jd.instance_variable_get(:@join_root).children.map(&:object_id) == [association.object_id]
173
- }
207
+ # Records that the search being evaluated reads the joins the
208
+ # association hangs off but not its own: a negative condition on a
209
+ # collection is a subquery, but one nested under another collection
210
+ # still needs that collection's join.
211
+ def use_ancestors_of(association)
212
+ use_association(parent_of(association)) if association
174
213
  end
175
214
 
215
+ # Whether the association hangs directly off the model searched,
216
+ # rather than off another association.
217
+ def root_association?(association)
218
+ parent_of(association).equal?(@base)
219
+ end
220
+
221
+ # Kept for callers of the 6.0.0 API. Evaluation now keeps only the
222
+ # joins the search reads, so a join nothing reads is already left out.
223
+ def remove_association(_association); end
224
+
176
225
  # Build an Arel subquery that selects keys for the top query,
177
226
  # drawn from the first join association's foreign_key.
178
227
  #
@@ -192,34 +241,48 @@ module Ransack
192
241
  # because it is correlated to the primary key on the outer query.
193
242
  #
194
243
  def build_correlated_subquery(association)
244
+ correlated_subquery(association).last
245
+ end
246
+
247
+ # The subquery a negative condition on a collection is tested against,
248
+ # and the outer query's column to test: the primary key, or the
249
+ # association's `primary_key:` when it has one.
250
+ #
251
+ # The subquery correlates on the join condition that references the
252
+ # outer table, and keeps the join's other conditions: the type column
253
+ # of a polymorphic or STI association, the association's scope and the
254
+ # target's default scope. Leaving them out counted rows the association
255
+ # does not reach (#1747).
256
+ def correlated_subquery(association)
195
257
  join_constraints = extract_joins(association)
196
258
  join_root = join_constraints.shift
197
- correlated_key = extract_correlated_key(join_root)
198
- subquery = Arel::SelectManager.new(association.base_klass)
259
+ correlation = extract_correlation(join_root)
260
+ raise 'Ransack encountered an unexpected arel structure' unless correlation
261
+
262
+ inner_key, outer_key = correlated_keys(correlation)
263
+ subquery = Arel::SelectManager.new(association.base_klass.arel_table)
199
264
  subquery.from(join_root.left)
200
- subquery.project(correlated_key)
265
+ subquery.project(inner_key)
201
266
  join_constraints.each do |j|
202
267
  subquery.join_sources << Arel::Nodes::InnerJoin.new(j.left, j.right)
203
268
  end
204
269
 
205
- # Handle polymorphic associations where correlated_key is an array
206
- if correlated_key.is_a?(Array)
207
- # For polymorphic associations, we need to add conditions for both the foreign key and type
208
- correlated_key.each_with_index do |key, index|
209
- if index == 0
210
- # This is the foreign key
211
- subquery = subquery.where(key.eq(primary_key))
212
- else
213
- # This is the type key, which should be equal to the model name
214
- subquery = subquery.where(key.eq(@klass.name))
215
- end
216
- end
217
- else
218
- # Original behavior for non-polymorphic associations
219
- subquery = subquery.where(correlated_key.eq(primary_key))
270
+ subquery.where(inner_key.eq(outer_key))
271
+ join_conditions(join_root).each do |condition|
272
+ subquery.where(condition) unless condition.equal?(correlation)
220
273
  end
221
274
 
222
- subquery
275
+ [outer_key, subquery]
276
+ end
277
+
278
+ # The SQL for a correlated subquery, with bind values inlined the way
279
+ # Relation#to_sql renders them, since the subquery is embedded in the
280
+ # outer query as a string. An association or default scope condition
281
+ # in the subquery carries its value as a bind.
282
+ def subquery_sql(subquery)
283
+ @klass.with_connection do |connection|
284
+ connection.unprepared_statement { connection.to_sql(subquery) }
285
+ end
223
286
  end
224
287
 
225
288
  def primary_key
@@ -228,50 +291,61 @@ module Ransack
228
291
 
229
292
  private
230
293
 
231
- def extract_correlated_key(join_root)
232
- case join_root
294
+ # The conditions in a join's ON clause, with its ANDs flattened.
295
+ def join_conditions(join)
296
+ on = [join.right, join.left].find { |node| node.is_a?(Arel::Nodes::On) }
297
+ on ? and_children(on.expr) : []
298
+ end
299
+
300
+ def and_children(node)
301
+ node.is_a?(Arel::Nodes::And) ? node.children.flat_map { |child| and_children(child) } : [node]
302
+ end
303
+
304
+ # The equality in a join's ON clause that ties the joined table to
305
+ # the outer query's table.
306
+ def extract_correlation(node)
307
+ case node
233
308
  when Arel::Nodes::OuterJoin
234
- # one of join_root.right/join_root.left is expected to be Arel::Nodes::On
235
- if join_root.right.is_a?(Arel::Nodes::On)
236
- extract_correlated_key(join_root.right.expr)
237
- elsif join_root.left.is_a?(Arel::Nodes::On)
238
- extract_correlated_key(join_root.left.expr)
239
- else
240
- raise 'Ransack encountered an unexpected arel structure'
241
- end
309
+ on = [node.right, node.left].find { |side| side.is_a?(Arel::Nodes::On) }
310
+ raise 'Ransack encountered an unexpected arel structure' unless on
311
+
312
+ extract_correlation(on.expr)
242
313
  when Arel::Nodes::Equality
243
- pk = primary_key
244
- if join_root.left.eql?(pk)
245
- join_root.right
246
- elsif join_root.right.eql?(pk)
247
- join_root.left
248
- else
249
- nil
250
- end
314
+ node if correlated_keys(node)
251
315
  when Arel::Nodes::And
252
- # And may have multiple children, so we need to check all, not via left/right
253
- if join_root.children.any?
254
- join_root.children.each do |child|
255
- key = extract_correlated_key(child)
256
- return key if key
257
- end
258
- else
259
- extract_correlated_key(join_root.left) || extract_correlated_key(join_root.right)
316
+ node.children.each do |child|
317
+ correlation = extract_correlation(child)
318
+ return correlation if correlation
260
319
  end
320
+ nil
261
321
  else
262
- # eg parent was Arel::Nodes::And and the evaluated side was one of
263
- # Arel::Nodes::Grouping or MultiTenant::TenantEnforcementClause
322
+ # eg a Grouping, or a MultiTenant::TenantEnforcementClause
264
323
  nil
265
324
  end
266
325
  end
267
326
 
327
+ # [inner, outer] for an equality with exactly one side on the outer
328
+ # query's table, or nil.
329
+ def correlated_keys(equality)
330
+ left_outer = outer_attribute?(equality.left)
331
+ right_outer = outer_attribute?(equality.right)
332
+ if left_outer && !right_outer
333
+ [equality.right, equality.left]
334
+ elsif right_outer && !left_outer
335
+ [equality.left, equality.right]
336
+ end
337
+ end
338
+
339
+ def outer_attribute?(node)
340
+ node.is_a?(Arel::Attributes::Attribute) && node.relation.eql?(@object.table)
341
+ end
342
+
268
343
  def get_parent_and_attribute_name(str, parent = @base)
269
344
  return [parent, nil] unless key_within_depth_limit?(str)
270
345
 
271
346
  attr_name = nil
272
347
 
273
- if ransackable_attribute?(str, klassify(parent)) ||
274
- ransortable_attribute?(str, klassify(parent))
348
+ if allowlisted_attribute?(str, klassify(parent))
275
349
  attr_name = str
276
350
  elsif (segments = str.split(Constants::UNDERSCORE)).size > 1
277
351
  remainder = []
@@ -332,7 +406,9 @@ module Ransack
332
406
  # agree. Ransack stashes the joins it adds through `left_outer_joins`,
333
407
  # one dependency per association in the order they are added, which is
334
408
  # where they fall in that order; a join the relation already has is
335
- # reused rather than added again.
409
+ # reused rather than added again. Their aliases are pinned, so a join
410
+ # added to the result later, which renders ahead of them, is aliased
411
+ # around them instead (see JoinDependencyExtensions#pin_search_tables).
336
412
  #
337
413
  # The relation's own joins keep their tree shape in the mirror. A node
338
414
  # Ransack adds sits directly under the mirror root whatever its depth,
@@ -358,14 +434,24 @@ module Ransack
358
434
  # `where(assoc: { ... })` records the association name as a reference,
359
435
  # and Active Record then aliases that join to the name (#1437).
360
436
  mirror.instance_variable_set(:@references, referenced_aliases(relation))
437
+ # Before any join is given an alias, so the joins an earlier search
438
+ # added keep theirs (#1776).
439
+ mirror.pin_search_tables(left.grep(JoinDependency))
361
440
  root = mirror.instance_variable_get(:@join_root)
362
441
 
363
442
  root.children.each { |child| adopt(mirror, root, child, Arel::Nodes::InnerJoin) }
364
443
  stashed.each { |jd| graft(mirror, jd, jd.instance_variable_get(:@join_type)) }
365
444
  graft(mirror, JoinDependency.new(relation.klass, relation.table, named_joins(left), Arel::Nodes::OuterJoin), Arel::Nodes::OuterJoin)
366
- left.grep(JoinDependency).each { |jd| graft(mirror, jd, jd.instance_variable_get(:@join_type)) }
445
+ # The joins a search added after counting the eager load are rendered
446
+ # after it (JoinDependencyExtensions#eager_load_first), as long as the
447
+ # relation still eager loads; otherwise they keep their order. Those
448
+ # this search adds are counted after it too.
449
+ @eager_loading = !eager_jd.nil? || eager.any?
450
+ after_eager, before_eager = left.grep(JoinDependency).partition { |jd| @eager_loading && jd.after_eager_load }
451
+ before_eager.each { |jd| graft(mirror, jd, jd.instance_variable_get(:@join_type)) }
367
452
  graft(mirror, eager_jd, Arel::Nodes::OuterJoin) if eager_jd
368
453
  graft(mirror, JoinDependency.new(relation.klass, relation.table, eager, Arel::Nodes::OuterJoin), Arel::Nodes::OuterJoin)
454
+ after_eager.each { |jd| graft(mirror, jd, jd.instance_variable_get(:@join_type)) }
369
455
 
370
456
  mirror
371
457
  end
@@ -435,9 +521,53 @@ module Ransack
435
521
  end
436
522
 
437
523
  # The nodes that hang off a parent: the relation's own joins under it,
438
- # plus the ones Ransack added under it.
524
+ # plus the ones Ransack added under it. The nodes Ransack added all sit
525
+ # under the mirror root whatever their parent, so the root's children
526
+ # are filtered to the relation's own; otherwise a nested `person` would
527
+ # be found as the root's `person` too (#1775).
439
528
  def children_of(parent)
440
- parent.children + @associations_pot.select { |_node, node_parent| node_parent.equal?(parent) }.keys
529
+ parent.children.reject { |node| @associations_pot.key?(node) } +
530
+ @associations_pot.select { |_node, node_parent| node_parent.equal?(parent) }.keys
531
+ end
532
+
533
+ # Collects the associations the block's evaluation reads. Saved and
534
+ # restored, so an evaluation nested inside another one (a ransacker
535
+ # that runs a search on this context) keeps its own list.
536
+ def tracking_used_associations
537
+ outer = @used_associations
538
+ @used_associations = []
539
+ result = yield
540
+ [result, @used_associations]
541
+ ensure
542
+ @used_associations = outer
543
+ end
544
+
545
+ # The relation without the joins Ransack added that the search does not
546
+ # read. A join the relation already had is not Ransack's, and stays.
547
+ def with_joins_used_by(relation, used)
548
+ needed = with_ancestors(used + @lock_associations).map(&:object_id)
549
+ unused = @associations_pot.keys.map(&:object_id) - needed
550
+ return relation if unused.empty?
551
+
552
+ relation = relation.dup
553
+ relation.left_outer_joins_values = relation.left_outer_joins_values.reject { |jd|
554
+ jd.is_a?(JoinDependency) &&
555
+ (children = jd.instance_variable_get(:@join_root).children).size == 1 &&
556
+ unused.include?(children.first.object_id)
557
+ }
558
+ relation
559
+ end
560
+
561
+ def with_ancestors(associations)
562
+ associations.flat_map do |association|
563
+ chain = []
564
+ node = association
565
+ while node && !node.equal?(@base)
566
+ chain << node
567
+ node = parent_of(node)
568
+ end
569
+ chain
570
+ end
441
571
  end
442
572
 
443
573
  def build_or_find_association(name, parent = @base, klass = nil)
@@ -460,6 +590,8 @@ module Ransack
460
590
  Join.new(name, @join_type, klass),
461
591
  @join_type
462
592
  )
593
+ jd.added_by_search = true
594
+ jd.after_eager_load = @eager_loading
463
595
  found_association = jd.instance_variable_get(:@join_root).children.last
464
596
 
465
597
  @associations_pot[found_association] = parent
@@ -8,7 +8,13 @@ module Ransack
8
8
  # collection uses the same aliases as the outer query.
9
9
  module JoinAssociationExtensions
10
10
  def self.prepended(base)
11
- base.class_eval { attr_reader :join_type }
11
+ base.class_eval do
12
+ attr_reader :join_type
13
+ # The tables of the joins this node renders itself, as Ransack
14
+ # first assigned them: its reflection chain up to the first table
15
+ # an earlier join already provides.
16
+ attr_accessor :own_tables
17
+ end
12
18
  end
13
19
 
14
20
  def initialize(reflection, children, polymorphic_class = nil, join_type = Arel::Nodes::InnerJoin)
@@ -75,7 +81,7 @@ module Ransack
75
81
 
76
82
  if others && !others.empty?
77
83
  joins.concat arel.join_sources
78
- append_constraints_to(joins.last, others)
84
+ append_constraints(joins.last, others)
79
85
  end
80
86
 
81
87
  # The current table in this iteration becomes the foreign table in the next
@@ -84,21 +90,6 @@ module Ransack
84
90
 
85
91
  joins
86
92
  end
87
-
88
- private
89
-
90
- # Active Record's private append_constraints takes a leading connection
91
- # argument in 7.2.0–7.2.2.2 and 8.0.0–8.0.2, and none in 7.2.3+, 8.0.3+
92
- # and 8.1; the arity says which is loaded.
93
- def append_constraints_to(join, constraints)
94
- if method(:append_constraints).arity == 3
95
- base_klass.with_connection do |connection|
96
- append_constraints(connection, join, constraints)
97
- end
98
- else
99
- append_constraints(join, constraints)
100
- end
101
- end
102
93
  end
103
94
  end
104
95
  end