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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 50d689663cb6717dab8e034f88d8645cf24a31489e322ca79d9cd52634281dc4
4
- data.tar.gz: 544297bd2fc5b8322f8a719d3a4f52e235681a1c756dc191ac74fd69a4c556a7
3
+ metadata.gz: '00973c2942f313280f8239187ec8c9e0a6ae444211606154c92b10c54333b109'
4
+ data.tar.gz: d00b63b7250b0b7789c9d02aa6d4654db904ffca88a99e19650715ccd9c25884
5
5
  SHA512:
6
- metadata.gz: b0caa63a30b528ed30f2fa2ff5c29fe779fd633e9d30ec4404ede2c8b08f9a24a1832b3bd4bd10c39914bebe07cfd8fe23b10ce93496b254d6bb81d2450a3c9e
7
- data.tar.gz: afc2f2911285ed3e681a39013013ad1b9ffbce9161a4ac5bb2da6fda40ddd03421942f3b42eaf0c0a3865878fe667759d4c77df0b8962b431937267be40a9d70
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 it's safe to pass request
6
- parameters straight through.
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.
@@ -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
- if column_or_relation.to_sym == :random
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?(column_or_relation.to_s)
47
- resource = resource.sort_for_column(self.arel_table[column_or_relation.to_s], options)
48
- elsif reflect_on_association(column_or_relation.to_sym)
49
- resource = resource.sort_for_relation(column_or_relation.to_sym, options)
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 #{column_or_relation}")
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 :asc.
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.is_a?(Hash) || options.class.name == "ActionController::Parameters"
65
- [options.keys.first.to_sym, options.values.first.to_sym]
66
- elsif options.blank?
67
- [:asc, nil]
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.to_sym, nil]
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 == :desc
99
+ if direction == 'desc'
77
100
  self.order(Arel::Nodes::Descending.new(column, nulls))
78
- elsif direction == :asc
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 == :desc
152
+ order = if direction == 'desc'
130
153
  Arel::Nodes::Descending.new(column.maximum, nulls)
131
- elsif direction == :asc
154
+ elsif direction == 'asc'
132
155
  Arel::Nodes::Ascending.new(column.minimum, nulls)
133
156
  else
134
157
  raise InvalidSort.new("Unknown ordering #{direction}")
@@ -1,5 +1,7 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module ActiveRecord
2
4
  module Sort
3
- VERSION = '8.0.0'
5
+ VERSION = '8.0.1'
4
6
  end
5
7
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'active_record'
2
4
  require 'arel/extensions'
3
5
 
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.0
4
+ version: 8.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jon Bracy