activerecord-sort 8.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 +33 -0
- data/README.md +38 -2
- data/ext/active_record/base.rb +40 -17
- data/lib/active_record/sort/version.rb +3 -1
- data/lib/active_record/sort.rb +2 -0
- 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: '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,5 +1,38 @@
|
|
|
1
1
|
# Changelog
|
|
2
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
|
+
|
|
3
36
|
## [8.0.0] - 2026-08-27
|
|
4
37
|
|
|
5
38
|
### Breaking changes
|
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
|
------------
|
|
@@ -110,3 +114,35 @@ it loads the records and sorts them by `<=>` — rather than building a query:
|
|
|
110
114
|
Property.all.sort # => Array of Property, sorted by <=>
|
|
111
115
|
Property.sort(nil) # => relation (unchanged), for chaining
|
|
112
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
|
|
|
@@ -41,14 +43,16 @@ module ActiveRecord
|
|
|
41
43
|
ordering.each do |order|
|
|
42
44
|
order = Array(order)
|
|
43
45
|
order.each do |column_or_relation, options|
|
|
44
|
-
|
|
46
|
+
key = column_or_relation.to_s
|
|
47
|
+
|
|
48
|
+
if key == 'random'
|
|
45
49
|
resource = resource.order(Arel::Nodes::RandomOrdering.new)
|
|
46
|
-
elsif self.column_names.include?(
|
|
47
|
-
resource = resource.sort_for_column(self.arel_table[
|
|
48
|
-
elsif reflect_on_association(
|
|
49
|
-
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)
|
|
50
54
|
else
|
|
51
|
-
raise InvalidSort.new("Unknown column #{
|
|
55
|
+
raise InvalidSort.new("Unknown column #{key}")
|
|
52
56
|
end
|
|
53
57
|
end
|
|
54
58
|
end
|
|
@@ -58,24 +62,43 @@ module ActiveRecord
|
|
|
58
62
|
|
|
59
63
|
# Normalizes per-column sort options into [direction, nulls]. A blank
|
|
60
64
|
# direction — a bare column, or "" as query params often produce —
|
|
61
|
-
# means
|
|
62
|
-
# TODO: probably don't need to cast to sym
|
|
65
|
+
# means 'asc'; a blank or nil value means no NULLS clause.
|
|
63
66
|
def sort_direction_and_nulls(options)
|
|
64
|
-
if options.
|
|
65
|
-
[
|
|
66
|
-
elsif options.
|
|
67
|
-
|
|
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
|
|
68
77
|
else
|
|
69
|
-
[options.to_s.downcase
|
|
78
|
+
[options.to_s.downcase, nil]
|
|
70
79
|
end
|
|
71
80
|
end
|
|
72
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
|
+
|
|
73
96
|
def sort_for_column(column, options)
|
|
74
97
|
direction, nulls = sort_direction_and_nulls(options)
|
|
75
98
|
|
|
76
|
-
if direction ==
|
|
99
|
+
if direction == 'desc'
|
|
77
100
|
self.order(Arel::Nodes::Descending.new(column, nulls))
|
|
78
|
-
elsif direction ==
|
|
101
|
+
elsif direction == 'asc'
|
|
79
102
|
self.order(Arel::Nodes::Ascending.new(column, nulls))
|
|
80
103
|
else
|
|
81
104
|
raise InvalidSort.new("Unknown ordering #{direction}")
|
|
@@ -126,9 +149,9 @@ module ActiveRecord
|
|
|
126
149
|
|
|
127
150
|
direction, nulls = sort_direction_and_nulls(column_options)
|
|
128
151
|
|
|
129
|
-
order = if direction ==
|
|
152
|
+
order = if direction == 'desc'
|
|
130
153
|
Arel::Nodes::Descending.new(column.maximum, nulls)
|
|
131
|
-
elsif direction ==
|
|
154
|
+
elsif direction == 'asc'
|
|
132
155
|
Arel::Nodes::Ascending.new(column.minimum, nulls)
|
|
133
156
|
else
|
|
134
157
|
raise InvalidSort.new("Unknown ordering #{direction}")
|
data/lib/active_record/sort.rb
CHANGED