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 +4 -4
- data/CHANGELOG.md +44 -0
- data/README.md +31 -0
- data/ext/active_record/base.rb +121 -8
- data/lib/active_record/sort/version.rb +1 -1
- data/lib/active_record/sort.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: ac06d1612ad8c40162ba17e9773ff79b9d3be2808a9357cb99baa213d49366dc
|
|
4
|
+
data.tar.gz: fbe32a2fbd355362ddafe5961170dcfda5ceb45bca56753fe9e909a636d0fca8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
data/ext/active_record/base.rb
CHANGED
|
@@ -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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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 =>
|
|
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)
|
data/lib/active_record/sort.rb
CHANGED
|
@@ -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
|