activerecord-sort 7.0.0 → 8.0.1
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 +105 -1
- data/README.md +69 -10
- data/ext/active_record/base.rb +142 -70
- data/lib/active_record/sort/version.rb +3 -1
- data/lib/active_record/sort.rb +2 -0
- metadata +13 -13
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '00973c2942f313280f8239187ec8c9e0a6ae444211606154c92b10c54333b109'
|
|
4
|
+
data.tar.gz: d00b63b7250b0b7789c9d02aa6d4654db904ffca88a99e19650715ccd9c25884
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e50419cf6d24fb591606d97f39d7928b16daffd65ee8d2f70e3f105508e3c5073d2bdd3d1187cc150f0ef3014e422277ba91afad44d6287e19cc3b8fd12fe784
|
|
7
|
+
data.tar.gz: dfc4e53f7e3f115c84c2804220e91475dd85c930759ac67af1cdf9b0c0f40d0be9327e0e789f5c15481d74837b7c9c83231311ff1c0723d4e729a0f4f813eb5a
|
data/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,107 @@
|
|
|
1
|
-
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [8.0.1] - 2026-09-04
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- Enabled `# frozen_string_literal: true`, so the gem no longer allocates a
|
|
8
|
+
new String for each literal it evaluates.
|
|
9
|
+
- An unrecognized value in the nulls position now raises
|
|
10
|
+
`ActiveRecord::Sort::InvalidSort` instead of being silently dropped.
|
|
11
|
+
`sort(:name => {:asc => :nulls_frist})` previously ordered by a plain
|
|
12
|
+
`ASC`, quietly returning results in the wrong null order; it now raises,
|
|
13
|
+
like any other unknown sort value. A blank nulls value still means "no
|
|
14
|
+
`NULLS` clause" rather than an invalid one — `""` (what query params
|
|
15
|
+
send) and `nil` (what JSON sends) are both absent, not wrong.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- Directions and nulls values are now matched case-insensitively wherever
|
|
20
|
+
they appear. `sort(:name => 'DESC')` already downcased, but the hash forms
|
|
21
|
+
did not, so `sort(:name => {'DESC' => 'NULLS_LAST'})` raised on the same
|
|
22
|
+
input the bare form accepted.
|
|
23
|
+
- `sort(:name => {})` now sorts ascending instead of raising. An empty hash
|
|
24
|
+
states no direction, which is what `""` (from a query string) and `nil`
|
|
25
|
+
(from JSON) already meant; the blank check simply sat after the hash
|
|
26
|
+
check and so never saw it.
|
|
27
|
+
- Sort parameters holding `nil` or a non-string key no longer raise
|
|
28
|
+
`NoMethodError`. `{"name" => {"asc" => nil}}`, `{"name" => {nil =>
|
|
29
|
+
"asc"}}` and a non-string column such as `{123 =>
|
|
30
|
+
"asc"}` — all reachable from a JSON request body, where a query string
|
|
31
|
+
would send `""` — now raise `ActiveRecord::Sort::InvalidSort`, or sort
|
|
32
|
+
normally where the value is merely absent, honoring the documented
|
|
33
|
+
contract that unfiltered params are rescuable as
|
|
34
|
+
`ActiveRecord::StatementInvalid`.
|
|
35
|
+
|
|
36
|
+
## [8.0.0] - 2026-08-27
|
|
37
|
+
|
|
38
|
+
### Breaking changes
|
|
39
|
+
|
|
40
|
+
- Relation sorts are unified across association types. Sorting by any
|
|
41
|
+
relation (`has_many`, `has_and_belongs_to_many`, `has_one`,
|
|
42
|
+
`belongs_to`) now `LEFT OUTER JOIN`s the association, groups by the
|
|
43
|
+
sorted table's primary key, and orders by an aggregate of the requested
|
|
44
|
+
column. For `has_many` sorts this changes behavior:
|
|
45
|
+
- each record is returned once, instead of once per associated row
|
|
46
|
+
(the join no longer fans out into duplicates)
|
|
47
|
+
- records with no associated rows are included (previously dropped by
|
|
48
|
+
the `INNER JOIN`)
|
|
49
|
+
- Descending relation sorts key each record by its largest member
|
|
50
|
+
(`MAX`), ascending by its smallest (`MIN`) — the member you'd expect
|
|
51
|
+
to see first in that direction. For records with multiple associated
|
|
52
|
+
rows, descending is therefore not the reverse of ascending: a record
|
|
53
|
+
holding both extremes sorts first in both directions.
|
|
54
|
+
- Sort columns are no longer added to the `SELECT`:
|
|
55
|
+
- loaded records keep their own attributes — a joined sort column can
|
|
56
|
+
no longer overwrite a same-named attribute on the base record
|
|
57
|
+
(previously even `id` could be clobbered)
|
|
58
|
+
- `pluck` and `ids` keep the sort (previously raised on
|
|
59
|
+
`has_and_belongs_to_many` sorts)
|
|
60
|
+
- a caller's `select` is left untouched
|
|
61
|
+
- chaining `.distinct` after a relation sort now raises: PostgreSQL
|
|
62
|
+
requires `ORDER BY` expressions to appear in the select list for
|
|
63
|
+
`SELECT DISTINCT`. It previously appeared to work while silently
|
|
64
|
+
deduplicating over the wrong tuple.
|
|
65
|
+
- A bare `belongs_to`/`has_one` sort with no direction (e.g.
|
|
66
|
+
`Address.sort(property: :name)`) now defaults to ascending, matching
|
|
67
|
+
every other sort form (previously descending).
|
|
68
|
+
- An unknown sort direction on a relation sort (e.g. `:dsc`) now raises
|
|
69
|
+
`ActiveRecord::Sort::InvalidSort` (previously sorted ascending
|
|
70
|
+
silently), matching column sorts.
|
|
71
|
+
|
|
72
|
+
### Added
|
|
73
|
+
|
|
74
|
+
- Sorting by `has_and_belongs_to_many` relations.
|
|
75
|
+
- Unrecognized sort columns, associations, and directions raise
|
|
76
|
+
`ActiveRecord::Sort::InvalidSort`, a subclass of
|
|
77
|
+
`ActiveRecord::StatementInvalid` — so existing
|
|
78
|
+
`rescue ActiveRecord::StatementInvalid` handlers still catch bad sort
|
|
79
|
+
parameters, while callers can rescue the narrower class. Relation sort
|
|
80
|
+
columns are now validated up front (against the associated model's
|
|
81
|
+
columns) instead of only failing once the query reaches the database.
|
|
82
|
+
- Sorts of different types compose: they can be combined in one call
|
|
83
|
+
(`Property.sort(:name, tags: :name, addresses: :id)`) or chained
|
|
84
|
+
(`.sort(...).sort(...)`), sharing a single `GROUP BY`.
|
|
85
|
+
- Aggregates on a sorted relation (`count`, `sum`, `average`, `minimum`,
|
|
86
|
+
`maximum`) are computed over the records themselves rather than the
|
|
87
|
+
sort's grouped, joined rows — `count` returns the record count instead
|
|
88
|
+
of a per-group `Hash`, and multi-member records aren't weighted once
|
|
89
|
+
per member. A caller-supplied `group` still gets standard grouped
|
|
90
|
+
results.
|
|
91
|
+
- A blank direction (`''`, as query parameters often produce) is
|
|
92
|
+
accepted as ascending on all sort forms.
|
|
93
|
+
- `ActionController::Parameters` are accepted for relation sorts, and
|
|
94
|
+
their nulls option (`nulls_first`/`nulls_last`) is honored.
|
|
95
|
+
|
|
96
|
+
### Fixed
|
|
97
|
+
|
|
98
|
+
- `has_and_belongs_to_many` sorts crashed with `NoMethodError` on
|
|
99
|
+
ActiveRecord <= 8.0 (`Function#as` mutates and returns the receiver
|
|
100
|
+
there).
|
|
101
|
+
- Combining a `has_and_belongs_to_many` sort with another relation sort
|
|
102
|
+
raised `PG::GroupingError`.
|
|
103
|
+
|
|
104
|
+
## [7.0.0] - 2026-08-27
|
|
2
105
|
|
|
3
106
|
The gem's version is now independent of the Rails version it targets.
|
|
4
107
|
|
|
@@ -25,4 +128,5 @@ The gem's version is now independent of the Rails version it targets.
|
|
|
25
128
|
`ActiveRecord::QueryMethods.public_instance_methods(false)` — which keeps
|
|
26
129
|
Rails' internal delegation invariants intact.
|
|
27
130
|
|
|
131
|
+
[8.0.0]: https://github.com/malomalo/activerecord-sort/releases/tag/v8.0.0
|
|
28
132
|
[7.0.0]: https://github.com/malomalo/activerecord-sort/releases/tag/v7.0.0
|
data/README.md
CHANGED
|
@@ -2,8 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
`ActiveRecord::Sort` provides an easy, safe way to accept user input and order a
|
|
4
4
|
query by it. Only recognized columns and associations produce SQL — anything
|
|
5
|
-
else raises `ActiveRecord::StatementInvalid`, so
|
|
6
|
-
|
|
5
|
+
else raises `ActiveRecord::StatementInvalid`, so unfiltered request parameters
|
|
6
|
+
can't inject SQL.
|
|
7
|
+
|
|
8
|
+
Recognized is not the same as permitted, though. Every column on the model and
|
|
9
|
+
on its associations is sortable, including ones you never expose — see
|
|
10
|
+
[Restricting what can be sorted](#restricting-what-can-be-sorted).
|
|
7
11
|
|
|
8
12
|
Requirements
|
|
9
13
|
------------
|
|
@@ -50,22 +54,41 @@ Property.sort(id: {asc: :nulls_last}).to_sql
|
|
|
50
54
|
# => "...ORDER BY properties.id ASC NULLS LAST"
|
|
51
55
|
```
|
|
52
56
|
|
|
53
|
-
It can also sort on
|
|
57
|
+
It can also sort on relations. A relation sort groups by the sorted table's
|
|
58
|
+
primary key — so each record appears once and records with no associated
|
|
59
|
+
rows are still included — and orders by an aggregate of the requested
|
|
60
|
+
column: `MIN` ascending or `MAX` descending, keying each record by the
|
|
61
|
+
member you'd expect to see first in that direction. A record with no
|
|
62
|
+
associated rows has a `NULL` sort key; where `NULL`s land is
|
|
63
|
+
database-dependent, so pass `nulls_first`/`nulls_last` to place those
|
|
64
|
+
records explicitly:
|
|
54
65
|
|
|
55
66
|
```ruby
|
|
56
67
|
Property.sort(addresses: :id).to_sql
|
|
57
|
-
# => "
|
|
58
|
-
# => "
|
|
68
|
+
# => "SELECT properties.* FROM properties
|
|
69
|
+
# => " LEFT OUTER JOIN addresses ON addresses.property_id = properties.id
|
|
70
|
+
# => " GROUP BY properties.id
|
|
71
|
+
# => " ORDER BY MIN(addresses.id) ASC"
|
|
59
72
|
|
|
60
73
|
Property.sort(addresses: {id: :desc}).to_sql
|
|
61
|
-
# => "...
|
|
62
|
-
# => " ORDER BY addresses.id DESC"
|
|
74
|
+
# => "...ORDER BY MAX(addresses.id) DESC"
|
|
63
75
|
|
|
64
76
|
Property.sort(addresses: {id: {asc: :nulls_first}}).to_sql
|
|
65
|
-
# => "...
|
|
66
|
-
|
|
77
|
+
# => "...ORDER BY MIN(addresses.id) ASC NULLS FIRST"
|
|
78
|
+
|
|
79
|
+
Property.sort(tags: :name).to_sql # has_and_belongs_to_many
|
|
80
|
+
# => "SELECT properties.* FROM properties
|
|
81
|
+
# => " LEFT OUTER JOIN properties_tags ON properties_tags.property_id = properties.id
|
|
82
|
+
# => " LEFT OUTER JOIN tags ON tags.id = properties_tags.tag_id
|
|
83
|
+
# => " GROUP BY properties.id
|
|
84
|
+
# => " ORDER BY MIN(tags.name) ASC"
|
|
67
85
|
```
|
|
68
86
|
|
|
87
|
+
A relation sort is order-only — it never adds or removes records — so
|
|
88
|
+
aggregates on a sorted relation (`count`, `sum`, `average`, `minimum`,
|
|
89
|
+
`maximum`) are computed over the records themselves, not the sort's
|
|
90
|
+
grouped and joined rows.
|
|
91
|
+
|
|
69
92
|
Order randomly:
|
|
70
93
|
|
|
71
94
|
```ruby
|
|
@@ -77,9 +100,13 @@ Unrecognized columns raise, so unfiltered params can't inject SQL:
|
|
|
77
100
|
|
|
78
101
|
```ruby
|
|
79
102
|
Property.sort(:name_or_something_unexpected)
|
|
80
|
-
# => raises ActiveRecord::
|
|
103
|
+
# => raises ActiveRecord::Sort::InvalidSort
|
|
81
104
|
```
|
|
82
105
|
|
|
106
|
+
`ActiveRecord::Sort::InvalidSort` subclasses `ActiveRecord::StatementInvalid`,
|
|
107
|
+
so existing `rescue ActiveRecord::StatementInvalid` handlers keep catching bad
|
|
108
|
+
sort parameters, while callers that want to can rescue the narrower class.
|
|
109
|
+
|
|
83
110
|
Called with no arguments, `#sort` behaves like Ruby's `Enumerable#sort` —
|
|
84
111
|
it loads the records and sorts them by `<=>` — rather than building a query:
|
|
85
112
|
|
|
@@ -87,3 +114,35 @@ it loads the records and sorts them by `<=>` — rather than building a query:
|
|
|
87
114
|
Property.all.sort # => Array of Property, sorted by <=>
|
|
88
115
|
Property.sort(nil) # => relation (unchanged), for chaining
|
|
89
116
|
```
|
|
117
|
+
|
|
118
|
+
Restricting what can be sorted
|
|
119
|
+
------------------------------
|
|
120
|
+
|
|
121
|
+
`#sort` checks that a name is a real column or association — not that the
|
|
122
|
+
requester is allowed to know about it. Every column is sortable, including the
|
|
123
|
+
ones you don't select:
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
User.sort(:password_digest) # valid, and it sorts
|
|
127
|
+
Post.sort(author: :reset_password_token) # so is this
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
A sort reveals something about a column even though its values are never
|
|
131
|
+
returned, because the resulting order is a comparison. Someone with a row they
|
|
132
|
+
control can set their own value, see which side of it a target row lands on,
|
|
133
|
+
and narrow the value down over a series of ordinary-looking requests. Equal
|
|
134
|
+
values also sort together, which is enough to tell that two accounts share a
|
|
135
|
+
password hash.
|
|
136
|
+
|
|
137
|
+
So passing parameters straight through is safe as far as SQL injection goes,
|
|
138
|
+
but *which* columns may be sorted is an authorization question, and only your
|
|
139
|
+
application can answer it. Filter the parameters before they reach `#sort`:
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
SORTABLE = %w[name created_at].freeze
|
|
143
|
+
|
|
144
|
+
Property.sort(sort_params.slice(*SORTABLE))
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
[StandardAPI](https://github.com/malomalo/standardapi) does this with an ACL,
|
|
148
|
+
resolving per request which attributes a user may read and sort by.
|
data/ext/active_record/base.rb
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require 'active_record'
|
|
2
4
|
require 'active_record/relation'
|
|
3
5
|
|
|
@@ -8,6 +10,15 @@ module ActiveRecord
|
|
|
8
10
|
# delegation tests assert against — while relations still respond to them.
|
|
9
11
|
module Sort
|
|
10
12
|
|
|
13
|
+
# Raised when a sort references an unrecognized column or association,
|
|
14
|
+
# or an unknown direction. Subclasses StatementInvalid so existing
|
|
15
|
+
# `rescue ActiveRecord::StatementInvalid` handlers — the safety
|
|
16
|
+
# contract that makes it OK to pass request params straight through —
|
|
17
|
+
# keep working, while callers can rescue this narrower class to tell a
|
|
18
|
+
# bad sort parameter apart from a genuine database error.
|
|
19
|
+
class InvalidSort < ActiveRecord::StatementInvalid
|
|
20
|
+
end
|
|
21
|
+
|
|
11
22
|
# ordering:
|
|
12
23
|
# :id
|
|
13
24
|
# :name, :id
|
|
@@ -29,106 +40,167 @@ module ActiveRecord
|
|
|
29
40
|
ordering.flatten!
|
|
30
41
|
return resource if ordering.size == 0
|
|
31
42
|
|
|
32
|
-
order_columns = []
|
|
33
43
|
ordering.each do |order|
|
|
34
44
|
order = Array(order)
|
|
35
45
|
order.each do |column_or_relation, options|
|
|
36
|
-
|
|
46
|
+
key = column_or_relation.to_s
|
|
47
|
+
|
|
48
|
+
if key == 'random'
|
|
37
49
|
resource = resource.order(Arel::Nodes::RandomOrdering.new)
|
|
38
|
-
elsif self.column_names.include?(
|
|
39
|
-
resource = resource.sort_for_column(self.arel_table[
|
|
40
|
-
elsif reflect_on_association(
|
|
41
|
-
resource = resource.sort_for_relation(
|
|
50
|
+
elsif self.column_names.include?(key)
|
|
51
|
+
resource = resource.sort_for_column(self.arel_table[key], options)
|
|
52
|
+
elsif reflect_on_association(key)
|
|
53
|
+
resource = resource.sort_for_relation(key, options)
|
|
42
54
|
else
|
|
43
|
-
raise
|
|
55
|
+
raise InvalidSort.new("Unknown column #{key}")
|
|
44
56
|
end
|
|
45
57
|
end
|
|
46
58
|
end
|
|
47
|
-
|
|
48
|
-
if order_columns.present?
|
|
49
|
-
resource = resource.select(resource.klass.arel_table[Arel::Nodes::SqlLiteral.new('*')], *order_columns)
|
|
50
|
-
end
|
|
51
59
|
|
|
52
60
|
resource
|
|
53
61
|
end
|
|
54
62
|
|
|
55
|
-
#
|
|
63
|
+
# Normalizes per-column sort options into [direction, nulls]. A blank
|
|
64
|
+
# direction — a bare column, or "" as query params often produce —
|
|
65
|
+
# means 'asc'; a blank or nil value means no NULLS clause.
|
|
66
|
+
def sort_direction_and_nulls(options)
|
|
67
|
+
if options.blank?
|
|
68
|
+
['asc', nil]
|
|
69
|
+
elsif options.is_a?(Hash) || options.class.name == "ActionController::Parameters"
|
|
70
|
+
direction = options.keys.first.to_s.downcase
|
|
71
|
+
|
|
72
|
+
if direction == 'asc' || direction == 'desc'
|
|
73
|
+
[direction, sort_nulls(options.values.first)]
|
|
74
|
+
else
|
|
75
|
+
[direction, nil]
|
|
76
|
+
end
|
|
77
|
+
else
|
|
78
|
+
[options.to_s.downcase, nil]
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Cast on the way out: Arel's visitor matches the nulls value with
|
|
83
|
+
# `case o.nulls when :nulls_first`, so this is the one value that has
|
|
84
|
+
# to reach the node as a symbol.
|
|
85
|
+
def sort_nulls(value)
|
|
86
|
+
return nil if value.blank?
|
|
87
|
+
|
|
88
|
+
nulls = value.to_s.downcase
|
|
89
|
+
if nulls != 'nulls_first' && nulls != 'nulls_last'
|
|
90
|
+
raise InvalidSort.new("Unknown nulls ordering #{value}")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
nulls.to_sym
|
|
94
|
+
end
|
|
95
|
+
|
|
56
96
|
def sort_for_column(column, options)
|
|
57
|
-
direction = (options
|
|
97
|
+
direction, nulls = sort_direction_and_nulls(options)
|
|
58
98
|
|
|
59
|
-
|
|
60
|
-
if direction == :desc
|
|
99
|
+
if direction == 'desc'
|
|
61
100
|
self.order(Arel::Nodes::Descending.new(column, nulls))
|
|
62
|
-
elsif direction ==
|
|
101
|
+
elsif direction == 'asc'
|
|
63
102
|
self.order(Arel::Nodes::Ascending.new(column, nulls))
|
|
64
103
|
else
|
|
65
|
-
raise
|
|
104
|
+
raise InvalidSort.new("Unknown ordering #{direction}")
|
|
66
105
|
end
|
|
67
106
|
end
|
|
68
107
|
|
|
69
|
-
|
|
108
|
+
# The sort key lives only in the ORDER BY — nothing is added to the
|
|
109
|
+
# select list. That keeps the ORDER BY self-contained (so pluck/ids,
|
|
110
|
+
# which replace the select list, keep the sort), leaves the caller's
|
|
111
|
+
# select untouched, and means loaded records carry exactly their own
|
|
112
|
+
# attributes (a selected join column would overwrite a same-named
|
|
113
|
+
# attribute on the base table).
|
|
114
|
+
def sort_for_relation(relation, options)
|
|
70
115
|
resource = self
|
|
71
116
|
relation = reflect_on_association(relation)
|
|
117
|
+
options = [options] if !options.is_a?(Array)
|
|
118
|
+
|
|
119
|
+
# LEFT JOIN the association, group by this table's primary key — so
|
|
120
|
+
# rows don't fan / duplicate and records with an empty collection
|
|
121
|
+
# still appear — and order by an aggregate of the requested column:
|
|
122
|
+
#
|
|
123
|
+
# SELECT properties.*
|
|
124
|
+
# FROM properties
|
|
125
|
+
# LEFT JOIN properties_tags ON properties_tags.property_id = properties.id
|
|
126
|
+
# LEFT JOIN tags ON tags.id = properties_tags.tag_id
|
|
127
|
+
# GROUP BY properties.id
|
|
128
|
+
# ORDER BY MIN(tags.name) ASC
|
|
129
|
+
#
|
|
130
|
+
# Ascending keys each record by its smallest member (MIN), descending
|
|
131
|
+
# by its largest (MAX) — the member you'd expect to see first in that
|
|
132
|
+
# direction. Toggling asc/desc therefore re-keys multi-value records
|
|
133
|
+
# rather than strictly reversing the list.
|
|
134
|
+
options.each do |order|
|
|
135
|
+
Array(order).each do |column_name, column_options|
|
|
136
|
+
if !relation.klass.column_names.include?(column_name.to_s)
|
|
137
|
+
raise InvalidSort.new("Unknown column #{column_name}")
|
|
138
|
+
end
|
|
72
139
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
direction = (options.is_a?(Hash) ? options.keys.first.to_sym : options.to_s.downcase.to_sym)
|
|
82
|
-
|
|
83
|
-
nulls = (options.is_a?(Hash) ? options.values.first.to_sym : nil)
|
|
84
|
-
if direction == :desc
|
|
85
|
-
# aggregation = Arel::Nodes::Max.new([column], "max_#{relation.name}_#{column.name}")
|
|
86
|
-
# order = Arel::Nodes::Descending.new(Arel::Nodes::SqlLiteral.new("max_#{relation.name}_#{column.name}"), nulls)
|
|
87
|
-
|
|
88
|
-
if relation.options[:through]
|
|
89
|
-
resource = resource.joins(relation.options[:through] => relation.source_reflection_name)
|
|
90
|
-
else
|
|
91
|
-
resource = resource.joins(relation.name)
|
|
92
|
-
end
|
|
93
|
-
# resource = resource.select(aggregation)
|
|
94
|
-
# resource = resource.order(order)
|
|
95
|
-
resource = resource.order(Arel::Nodes::Descending.new(column, nulls))
|
|
96
|
-
else
|
|
97
|
-
# aggregation = Arel::Nodes::Min.new([column], "min_#{relation.name}_#{column.name}")
|
|
98
|
-
order = Arel::Nodes::Ascending.new(Arel::Nodes::SqlLiteral.new("min_#{relation.name}_#{column.name}"), nulls)
|
|
99
|
-
|
|
100
|
-
resource = resource.joins(relation.name)
|
|
101
|
-
# resource = resource.select(aggregation)
|
|
102
|
-
# resource = resource.order(order)
|
|
103
|
-
resource = resource.order(Arel::Nodes::Ascending.new(column, nulls))
|
|
104
|
-
end
|
|
140
|
+
# A collection (has_many / has_and_belongs_to_many) wraps the
|
|
141
|
+
# attribute in an Arel::Attributes::Relation so the aggregate
|
|
142
|
+
# 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]
|
|
105
148
|
end
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
direction = (options.is_a?(Hash) ? options.keys.first.to_sym : options.to_s.downcase.to_sym)
|
|
116
|
-
|
|
117
|
-
nulls = (options.is_a?(Hash) ? options.values.first.to_sym : nil)
|
|
118
|
-
if direction == :asc
|
|
119
|
-
order = Arel::Nodes::Ascending.new(column, nulls)
|
|
120
|
-
else
|
|
121
|
-
order = Arel::Nodes::Descending.new(column, nulls)
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
resource = resource.left_outer_joins(relation.name)
|
|
125
|
-
resource = resource.order(order)
|
|
149
|
+
|
|
150
|
+
direction, nulls = sort_direction_and_nulls(column_options)
|
|
151
|
+
|
|
152
|
+
order = if direction == 'desc'
|
|
153
|
+
Arel::Nodes::Descending.new(column.maximum, nulls)
|
|
154
|
+
elsif direction == 'asc'
|
|
155
|
+
Arel::Nodes::Ascending.new(column.minimum, nulls)
|
|
156
|
+
else
|
|
157
|
+
raise InvalidSort.new("Unknown ordering #{direction}")
|
|
126
158
|
end
|
|
159
|
+
|
|
160
|
+
resource = resource.left_outer_joins(relation.name)
|
|
161
|
+
resource = resource.order(order)
|
|
127
162
|
end
|
|
128
163
|
end
|
|
129
164
|
|
|
165
|
+
# Group by the primary key so the aggregate collapses the joined,
|
|
166
|
+
# fanned-out rows to one per record. Every relation sort adds this,
|
|
167
|
+
# so only add it once: ActiveRecord < 8.1's group! appends rather
|
|
168
|
+
# than unioning, which would leave a duplicate [pk, pk] that trips
|
|
169
|
+
# the count override's group_values check below.
|
|
170
|
+
# TODO: once Rails <= 8.0 is no longer supported, group! unions on
|
|
171
|
+
# its own — drop the include? guard and just group(primary_key).
|
|
172
|
+
primary_key = klass.arel_table[klass.primary_key]
|
|
173
|
+
resource = resource.group(primary_key) unless resource.group_values.include?(primary_key)
|
|
174
|
+
# Tag the relation for the count override below. Chained relations
|
|
175
|
+
# are built with clone, which copies instance variables, so the tag
|
|
176
|
+
# survives further chaining.
|
|
177
|
+
resource.instance_variable_set(:@sorted_by_relation, true)
|
|
130
178
|
resource
|
|
131
179
|
end
|
|
132
180
|
|
|
133
181
|
end
|
|
182
|
+
|
|
183
|
+
module Sort
|
|
184
|
+
module Calculations
|
|
185
|
+
# sort_for_relation groups by the primary key and LEFT JOINs the
|
|
186
|
+
# relation, so aggregates would see grouped, fanned-out rows: count
|
|
187
|
+
# returns a per-group Hash, and sum/average weigh a record once per
|
|
188
|
+
# collection member. But a relation sort is order-only — records are
|
|
189
|
+
# never added or removed — so every aggregate has a well-defined
|
|
190
|
+
# answer: compute it over the base table restricted to the sorted
|
|
191
|
+
# relation's (distinct) primary keys. User joins and conditions still
|
|
192
|
+
# apply inside the subquery, and a user-added group falls through to
|
|
193
|
+
# the standard grouped behavior.
|
|
194
|
+
def calculate(operation, column_name)
|
|
195
|
+
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))
|
|
197
|
+
.calculate(operation, column_name)
|
|
198
|
+
else
|
|
199
|
+
super
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
134
204
|
end
|
|
205
|
+
|
|
206
|
+
ActiveRecord::Relation.prepend(ActiveRecord::Sort::Calculations)
|
data/lib/active_record/sort.rb
CHANGED
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:
|
|
4
|
+
version: 8.0.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jon Bracy
|
|
@@ -103,18 +103,18 @@ dependencies:
|
|
|
103
103
|
name: simplecov
|
|
104
104
|
requirement: !ruby/object:Gem::Requirement
|
|
105
105
|
requirements:
|
|
106
|
-
- -
|
|
106
|
+
- - '='
|
|
107
107
|
- !ruby/object:Gem::Version
|
|
108
|
-
version:
|
|
108
|
+
version: 1.0.0.rc5
|
|
109
109
|
type: :development
|
|
110
110
|
prerelease: false
|
|
111
111
|
version_requirements: !ruby/object:Gem::Requirement
|
|
112
112
|
requirements:
|
|
113
|
-
- -
|
|
113
|
+
- - '='
|
|
114
114
|
- !ruby/object:Gem::Version
|
|
115
|
-
version:
|
|
115
|
+
version: 1.0.0.rc5
|
|
116
116
|
- !ruby/object:Gem::Dependency
|
|
117
|
-
name:
|
|
117
|
+
name: faker
|
|
118
118
|
requirement: !ruby/object:Gem::Requirement
|
|
119
119
|
requirements:
|
|
120
120
|
- - ">="
|
|
@@ -128,35 +128,35 @@ dependencies:
|
|
|
128
128
|
- !ruby/object:Gem::Version
|
|
129
129
|
version: '0'
|
|
130
130
|
- !ruby/object:Gem::Dependency
|
|
131
|
-
name:
|
|
131
|
+
name: sunstone
|
|
132
132
|
requirement: !ruby/object:Gem::Requirement
|
|
133
133
|
requirements:
|
|
134
134
|
- - ">="
|
|
135
135
|
- !ruby/object:Gem::Version
|
|
136
|
-
version:
|
|
136
|
+
version: 7.0.0
|
|
137
137
|
type: :development
|
|
138
138
|
prerelease: false
|
|
139
139
|
version_requirements: !ruby/object:Gem::Requirement
|
|
140
140
|
requirements:
|
|
141
141
|
- - ">="
|
|
142
142
|
- !ruby/object:Gem::Version
|
|
143
|
-
version:
|
|
143
|
+
version: 7.0.0
|
|
144
144
|
- !ruby/object:Gem::Dependency
|
|
145
|
-
name:
|
|
145
|
+
name: webmock
|
|
146
146
|
requirement: !ruby/object:Gem::Requirement
|
|
147
147
|
requirements:
|
|
148
148
|
- - ">="
|
|
149
149
|
- !ruby/object:Gem::Version
|
|
150
|
-
version:
|
|
150
|
+
version: '0'
|
|
151
151
|
type: :development
|
|
152
152
|
prerelease: false
|
|
153
153
|
version_requirements: !ruby/object:Gem::Requirement
|
|
154
154
|
requirements:
|
|
155
155
|
- - ">="
|
|
156
156
|
- !ruby/object:Gem::Version
|
|
157
|
-
version:
|
|
157
|
+
version: '0'
|
|
158
158
|
- !ruby/object:Gem::Dependency
|
|
159
|
-
name:
|
|
159
|
+
name: debug
|
|
160
160
|
requirement: !ruby/object:Gem::Requirement
|
|
161
161
|
requirements:
|
|
162
162
|
- - ">="
|