activerecord-sort 8.0.1 → 8.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '00973c2942f313280f8239187ec8c9e0a6ae444211606154c92b10c54333b109'
4
- data.tar.gz: d00b63b7250b0b7789c9d02aa6d4654db904ffca88a99e19650715ccd9c25884
3
+ metadata.gz: ac06d1612ad8c40162ba17e9773ff79b9d3be2808a9357cb99baa213d49366dc
4
+ data.tar.gz: fbe32a2fbd355362ddafe5961170dcfda5ceb45bca56753fe9e909a636d0fca8
5
5
  SHA512:
6
- metadata.gz: e50419cf6d24fb591606d97f39d7928b16daffd65ee8d2f70e3f105508e3c5073d2bdd3d1187cc150f0ef3014e422277ba91afad44d6287e19cc3b8fd12fe784
7
- data.tar.gz: dfc4e53f7e3f115c84c2804220e91475dd85c930759ac67af1cdf9b0c0f40d0be9327e0e789f5c15481d74837b7c9c83231311ff1c0723d4e729a0f4f813eb5a
6
+ metadata.gz: 9dee845ccc33507878d09c6c1b3adb31f0e01fa94b70630086fc83fff7a472aa05fec70949d747a18da0c1c97ad54cd11963d4734cc25ea04194075f9470432f
7
+ data.tar.gz: 8d0d7b0feb26f2ba93a20ada61308b5b363328c3d3f31466c75143c6b239cc2ea25b5c25e4eb6d67e51bd5cd03f1964ed4771f2d4e6099dd2a419f0e52c71cae
data/CHANGELOG.md CHANGED
@@ -1,5 +1,47 @@
1
1
  # Changelog
2
2
 
3
+ ## [8.1.0] - 2026-10-21
4
+
5
+ ### Added
6
+
7
+ - `#resort`, which sorts like `#sort` but replaces any order the relation
8
+ already has, as `reorder` does for `order`, including the order of an
9
+ association's or default scope. `Property.order(:name).resort(:id)` orders
10
+ by `id` alone, and `resort(nil)` removes the order as `reorder(nil)` does
11
+ (a Rails 8.2 `default_order` still applies, as it does after
12
+ `reorder(nil)`).
13
+
14
+ ### Fixed
15
+
16
+ - Aggregates on a relation sort now honor `limit` and `offset`. The
17
+ subquery that restricts them to the sorted records dropped the sort's
18
+ `GROUP BY` and `ORDER BY` but kept the `LIMIT`, so the limit applied to
19
+ the join's fanned-out, unordered rows: `Property.sort(addresses:
20
+ :id).limit(2).count` returned 1 where two records load, and `sum` added
21
+ up whichever records those rows happened to belong to. The subquery now
22
+ keeps the group and order, so it selects the same records loading would.
23
+ - Aggregates on a relation sort no longer put back a default scope the
24
+ relation removed. The outer query restricting them to the sorted records
25
+ started from the model, re-applying its `default_scope`, so with
26
+ `default_scope { where(active: true) }`,
27
+ `Property.unscoped.sort(addresses: :id).count` counted only the active
28
+ records while loading returned them all. A relation that keeps its
29
+ default scope still counts within it.
30
+ - Relation sorts now order by the association's own join when its table is
31
+ joined more than once. Rails aliases the later joins of a table — a
32
+ second `belongs_to` to the same model, a scoped `has_many`, a
33
+ self-referential association, or a table the caller already joined —
34
+ but the sort always named the plain table, so it silently ordered by
35
+ another join: `Property.joins(:landlord).sort(manager: :name)` ordered
36
+ by the landlord, `Property.sort(children: :name)` by each record's own
37
+ name, and `Property.sort(manager: :name).sort(landlord: :name)` dropped
38
+ the second sort as a duplicate of the first. The sort now finds the
39
+ alias Rails gave its join when the query is built, so it follows joins
40
+ added after it too. If that join has been removed (e.g.
41
+ `unscope(:left_outer_joins)`), building the query raises
42
+ `ActiveRecord::Sort::InvalidSort` instead of ordering by whichever join
43
+ has the table's name.
44
+
3
45
  ## [8.0.1] - 2026-09-04
4
46
 
5
47
  ### Changed
@@ -128,5 +170,7 @@ The gem's version is now independent of the Rails version it targets.
128
170
  `ActiveRecord::QueryMethods.public_instance_methods(false)` — which keeps
129
171
  Rails' internal delegation invariants intact.
130
172
 
173
+ [8.1.0]: https://github.com/malomalo/activerecord-sort/releases/tag/v8.1.0
174
+ [8.0.1]: https://github.com/malomalo/activerecord-sort/releases/tag/v8.0.1
131
175
  [8.0.0]: https://github.com/malomalo/activerecord-sort/releases/tag/v8.0.0
132
176
  [7.0.0]: https://github.com/malomalo/activerecord-sort/releases/tag/v7.0.0
data/README.md CHANGED
@@ -115,6 +115,37 @@ Property.all.sort # => Array of Property, sorted by <=>
115
115
  Property.sort(nil) # => relation (unchanged), for chaining
116
116
  ```
117
117
 
118
+ Replacing an existing order
119
+ ---------------------------
120
+
121
+ `#sort` adds to any order the relation already has, as `order` does.
122
+ `#resort` replaces it, as `reorder` does, including the order of an
123
+ association's or default scope:
124
+
125
+ ```ruby
126
+ Property.order(:name).sort(:id).to_sql
127
+ # => "...ORDER BY properties.name ASC, properties.id ASC"
128
+
129
+ Property.order(:name).resort(:id).to_sql
130
+ # => "...ORDER BY properties.id ASC"
131
+
132
+ property.addresses.resort(:id) # has_many :addresses, -> { order(:name) }
133
+ # => "...ORDER BY addresses.id ASC"
134
+
135
+ Property.order(:name).resort(nil) # => relation, with no ORDER BY
136
+ ```
137
+
138
+ With no ordering, `#resort` removes the order the way `reorder(nil)` does, so a
139
+ `default_order` (Rails 8.2+), which applies when a relation has no other order,
140
+ still applies:
141
+
142
+ ```ruby
143
+ has_many :addresses, default_order: :likes
144
+
145
+ property.addresses.resort(nil) # ORDER BY likes, as with reorder(nil)
146
+ property.addresses.resort(:name) # ORDER BY name
147
+ ```
148
+
118
149
  Restricting what can be sorted
119
150
  ------------------------------
120
151
 
@@ -60,6 +60,21 @@ module ActiveRecord
60
60
  resource
61
61
  end
62
62
 
63
+ # Like #sort, but replaces any order the relation already has, as
64
+ # +reorder+ does for +order+, including the order of an association's or
65
+ # default scope:
66
+ #
67
+ # Property.order(:name).resort(:id)
68
+ # # => "...ORDER BY properties.id ASC"
69
+ #
70
+ # With no ordering, or a blank one, its order is removed, as
71
+ # +reorder(nil)+ does. A +default_order+ (Rails 8.2+) still applies when
72
+ # the relation has no other order, as it does after +reorder(nil)+.
73
+ def resort(*ordering)
74
+ resource = reorder(nil)
75
+ ordering.empty? ? resource : resource.sort(*ordering)
76
+ end
77
+
63
78
  # Normalizes per-column sort options into [direction, nulls]. A blank
64
79
  # direction — a bare column, or "" as query params often produce —
65
80
  # means 'asc'; a blank or nil value means no NULLS clause.
@@ -140,12 +155,11 @@ module ActiveRecord
140
155
  # A collection (has_many / has_and_belongs_to_many) wraps the
141
156
  # attribute in an Arel::Attributes::Relation so the aggregate
142
157
  # references the joined table correctly; a singular association
143
- # (belongs_to / has_one) uses the plain attribute.
144
- column = if relation.collection?
145
- Arel::Attributes::Relation.new(relation.klass.arel_table[column_name], relation.name)
146
- else
147
- relation.klass.arel_table[column_name]
148
- end
158
+ # (belongs_to / has_one) uses the plain attribute. Either way the
159
+ # attribute is a JoinedAttribute, pointed at the table (or alias)
160
+ # the association's join actually uses once the query is built.
161
+ column = JoinedAttribute.new(relation, column_name)
162
+ column = Arel::Attributes::Relation.new(column, relation.name) if relation.collection?
149
163
 
150
164
  direction, nulls = sort_direction_and_nulls(column_options)
151
165
 
@@ -181,6 +195,100 @@ module ActiveRecord
181
195
  end
182
196
 
183
197
  module Sort
198
+ # A column of a sorted association. Rails aliases a table it joins more
199
+ # than once — a second belongs_to to the same model, a scoped has_many,
200
+ # a self-join, or a table the caller already joined — and which join
201
+ # gets the alias depends on every join in the final query, so the sort
202
+ # can't name the table up front. This stands in for the column until
203
+ # the query is built, when JoinAliases swaps in the association's
204
+ # actual table. The reflection is part of its identity, so sorts on two
205
+ # associations to the same table aren't deduplicated as one.
206
+ class JoinedAttribute < Arel::Attributes::Attribute
207
+ attr_reader :reflection
208
+
209
+ def initialize(reflection, name)
210
+ super(reflection.klass.arel_table, name)
211
+ @reflection = reflection
212
+ end
213
+
214
+ def eql?(other)
215
+ super && reflection == other.reflection
216
+ end
217
+ alias == eql?
218
+
219
+ def hash
220
+ [super, reflection].hash
221
+ end
222
+ end
223
+
224
+ module JoinAliases
225
+ def construct_join_dependency(associations, join_type) # :nodoc:
226
+ join_dependency = super
227
+ @sort_join_dependencies << join_dependency if @sort_join_dependencies
228
+ join_dependency
229
+ end
230
+
231
+ private
232
+
233
+ # Builds the query, recording the join dependencies Rails constructs
234
+ # for it, then points each JoinedAttribute in the ORDER BY at the
235
+ # table its association was joined as. The orders are rebuilt rather
236
+ # than modified: order nodes are shared with every relation chained
237
+ # from this one, and those can join differently.
238
+ def build_arel(...)
239
+ return super if order_values.empty?
240
+
241
+ @sort_join_dependencies = []
242
+ arel = super
243
+ # Plus those built beforehand, as eager loading and merge do.
244
+ join_dependencies = @sort_join_dependencies +
245
+ (joins_values + left_outer_joins_values).grep(ActiveRecord::Associations::JoinDependency)
246
+ arel.ast.orders.map! { |order| resolve_sort_joins(order, join_dependencies) }
247
+ arel
248
+ ensure
249
+ @sort_join_dependencies = nil
250
+ end
251
+
252
+ def resolve_sort_joins(node, join_dependencies)
253
+ case node
254
+ when JoinedAttribute
255
+ table = sort_join_table(node.reflection, join_dependencies)
256
+ raise InvalidSort.new("Can't find the join for #{node.reflection.name} to sort by") unless table
257
+ table[node.name]
258
+ when Arel::Attributes::Relation
259
+ rebuild_sort_node(node, :relation, resolve_sort_joins(node.relation, join_dependencies))
260
+ when Arel::Nodes::Unary
261
+ rebuild_sort_node(node, :expr, resolve_sort_joins(node.expr, join_dependencies))
262
+ when Arel::Nodes::Function
263
+ rebuild_sort_node(node, :expressions, node.expressions.map { |e| resolve_sort_joins(e, join_dependencies) })
264
+ else
265
+ node
266
+ end
267
+ end
268
+
269
+ def rebuild_sort_node(node, attribute, value)
270
+ return node if node.public_send(attribute) == value
271
+
272
+ node = node.dup
273
+ node.public_send(:"#{attribute}=", value)
274
+ node
275
+ end
276
+
277
+ # The table of a top-level join of the association. Every join of it
278
+ # (the sort's own LEFT JOIN, the caller's, an eager load's) has the
279
+ # same conditions, so the first found will do.
280
+ def sort_join_table(reflection, join_dependencies)
281
+ join_dependencies.each do |join_dependency|
282
+ join_dependency.send(:join_root).children.each do |join|
283
+ if join.table && join.reflection.name == reflection.name && join.reflection.active_record == reflection.active_record
284
+ return join.table
285
+ end
286
+ end
287
+ end
288
+ nil
289
+ end
290
+ end
291
+
184
292
  module Calculations
185
293
  # sort_for_relation groups by the primary key and LEFT JOINs the
186
294
  # relation, so aggregates would see grouped, fanned-out rows: count
@@ -190,10 +298,14 @@ module ActiveRecord
190
298
  # answer: compute it over the base table restricted to the sorted
191
299
  # relation's (distinct) primary keys. User joins and conditions still
192
300
  # apply inside the subquery, and a user-added group falls through to
193
- # the standard grouped behavior.
301
+ # the standard grouped behavior. The subquery keeps the group and
302
+ # order so a limit or offset picks the same records loading would,
303
+ # rather than counting off fanned-out, unordered rows. The outer query
304
+ # is unscoped: the subquery already carries whatever default scope the
305
+ # relation kept, and must not have one it removed put back.
194
306
  def calculate(operation, column_name)
195
307
  if @sorted_by_relation && group_values == [klass.arel_table[klass.primary_key]]
196
- klass.where(klass.primary_key => unscope(:group, :order, :select).select(klass.primary_key))
308
+ klass.unscoped.where(klass.primary_key => except(:select).select(klass.primary_key))
197
309
  .calculate(operation, column_name)
198
310
  else
199
311
  super
@@ -204,3 +316,4 @@ module ActiveRecord
204
316
  end
205
317
 
206
318
  ActiveRecord::Relation.prepend(ActiveRecord::Sort::Calculations)
319
+ ActiveRecord::Relation.prepend(ActiveRecord::Sort::JoinAliases)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ActiveRecord
4
4
  module Sort
5
- VERSION = '8.0.1'
5
+ VERSION = '8.1.0'
6
6
  end
7
7
  end
@@ -6,4 +6,4 @@ require 'arel/extensions'
6
6
  require File.expand_path(File.join(__FILE__, '../../../ext/active_record/base'))
7
7
 
8
8
  ActiveRecord::QueryMethods.prepend(ActiveRecord::Sort)
9
- ActiveRecord::Querying.delegate :sort, to: :all
9
+ ActiveRecord::Querying.delegate :sort, :resort, to: :all
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activerecord-sort
3
3
  version: !ruby/object:Gem::Version
4
- version: 8.0.1
4
+ version: 8.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jon Bracy