activerecord-sort 8.0.0 → 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: 50d689663cb6717dab8e034f88d8645cf24a31489e322ca79d9cd52634281dc4
4
- data.tar.gz: 544297bd2fc5b8322f8a719d3a4f52e235681a1c756dc191ac74fd69a4c556a7
3
+ metadata.gz: ac06d1612ad8c40162ba17e9773ff79b9d3be2808a9357cb99baa213d49366dc
4
+ data.tar.gz: fbe32a2fbd355362ddafe5961170dcfda5ceb45bca56753fe9e909a636d0fca8
5
5
  SHA512:
6
- metadata.gz: b0caa63a30b528ed30f2fa2ff5c29fe779fd633e9d30ec4404ede2c8b08f9a24a1832b3bd4bd10c39914bebe07cfd8fe23b10ce93496b254d6bb81d2450a3c9e
7
- data.tar.gz: afc2f2911285ed3e681a39013013ad1b9ffbce9161a4ac5bb2da6fda40ddd03421942f3b42eaf0c0a3865878fe667759d4c77df0b8962b431937267be40a9d70
6
+ metadata.gz: 9dee845ccc33507878d09c6c1b3adb31f0e01fa94b70630086fc83fff7a472aa05fec70949d747a18da0c1c97ad54cd11963d4734cc25ea04194075f9470432f
7
+ data.tar.gz: 8d0d7b0feb26f2ba93a20ada61308b5b363328c3d3f31466c75143c6b239cc2ea25b5c25e4eb6d67e51bd5cd03f1964ed4771f2d4e6099dd2a419f0e52c71cae
data/CHANGELOG.md CHANGED
@@ -1,5 +1,80 @@
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
+
45
+ ## [8.0.1] - 2026-09-04
46
+
47
+ ### Changed
48
+
49
+ - Enabled `# frozen_string_literal: true`, so the gem no longer allocates a
50
+ new String for each literal it evaluates.
51
+ - An unrecognized value in the nulls position now raises
52
+ `ActiveRecord::Sort::InvalidSort` instead of being silently dropped.
53
+ `sort(:name => {:asc => :nulls_frist})` previously ordered by a plain
54
+ `ASC`, quietly returning results in the wrong null order; it now raises,
55
+ like any other unknown sort value. A blank nulls value still means "no
56
+ `NULLS` clause" rather than an invalid one — `""` (what query params
57
+ send) and `nil` (what JSON sends) are both absent, not wrong.
58
+
59
+ ### Fixed
60
+
61
+ - Directions and nulls values are now matched case-insensitively wherever
62
+ they appear. `sort(:name => 'DESC')` already downcased, but the hash forms
63
+ did not, so `sort(:name => {'DESC' => 'NULLS_LAST'})` raised on the same
64
+ input the bare form accepted.
65
+ - `sort(:name => {})` now sorts ascending instead of raising. An empty hash
66
+ states no direction, which is what `""` (from a query string) and `nil`
67
+ (from JSON) already meant; the blank check simply sat after the hash
68
+ check and so never saw it.
69
+ - Sort parameters holding `nil` or a non-string key no longer raise
70
+ `NoMethodError`. `{"name" => {"asc" => nil}}`, `{"name" => {nil =>
71
+ "asc"}}` and a non-string column such as `{123 =>
72
+ "asc"}` — all reachable from a JSON request body, where a query string
73
+ would send `""` — now raise `ActiveRecord::Sort::InvalidSort`, or sort
74
+ normally where the value is merely absent, honoring the documented
75
+ contract that unfiltered params are rescuable as
76
+ `ActiveRecord::StatementInvalid`.
77
+
3
78
  ## [8.0.0] - 2026-08-27
4
79
 
5
80
  ### Breaking changes
@@ -95,5 +170,7 @@ The gem's version is now independent of the Rails version it targets.
95
170
  `ActiveRecord::QueryMethods.public_instance_methods(false)` — which keeps
96
171
  Rails' internal delegation invariants intact.
97
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
98
175
  [8.0.0]: https://github.com/malomalo/activerecord-sort/releases/tag/v8.0.0
99
176
  [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 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,66 @@ 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
+ 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
+
149
+ Restricting what can be sorted
150
+ ------------------------------
151
+
152
+ `#sort` checks that a name is a real column or association — not that the
153
+ requester is allowed to know about it. Every column is sortable, including the
154
+ ones you don't select:
155
+
156
+ ```ruby
157
+ User.sort(:password_digest) # valid, and it sorts
158
+ Post.sort(author: :reset_password_token) # so is this
159
+ ```
160
+
161
+ A sort reveals something about a column even though its values are never
162
+ returned, because the resulting order is a comparison. Someone with a row they
163
+ control can set their own value, see which side of it a target row lands on,
164
+ and narrow the value down over a series of ordinary-looking requests. Equal
165
+ values also sort together, which is enough to tell that two accounts share a
166
+ password hash.
167
+
168
+ So passing parameters straight through is safe as far as SQL injection goes,
169
+ but *which* columns may be sorted is an authorization question, and only your
170
+ application can answer it. Filter the parameters before they reach `#sort`:
171
+
172
+ ```ruby
173
+ SORTABLE = %w[name created_at].freeze
174
+
175
+ Property.sort(sort_params.slice(*SORTABLE))
176
+ ```
177
+
178
+ [StandardAPI](https://github.com/malomalo/standardapi) does this with an ACL,
179
+ 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
@@ -56,26 +60,60 @@ module ActiveRecord
56
60
  resource
57
61
  end
58
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
+
59
78
  # Normalizes per-column sort options into [direction, nulls]. A blank
60
79
  # direction — a bare column, or "" as query params often produce —
61
- # means :asc.
62
- # TODO: probably don't need to cast to sym
80
+ # means 'asc'; a blank or nil value means no NULLS clause.
63
81
  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]
82
+ if options.blank?
83
+ ['asc', nil]
84
+ elsif options.is_a?(Hash) || options.class.name == "ActionController::Parameters"
85
+ direction = options.keys.first.to_s.downcase
86
+
87
+ if direction == 'asc' || direction == 'desc'
88
+ [direction, sort_nulls(options.values.first)]
89
+ else
90
+ [direction, nil]
91
+ end
68
92
  else
69
- [options.to_s.downcase.to_sym, nil]
93
+ [options.to_s.downcase, nil]
94
+ end
95
+ end
96
+
97
+ # Cast on the way out: Arel's visitor matches the nulls value with
98
+ # `case o.nulls when :nulls_first`, so this is the one value that has
99
+ # to reach the node as a symbol.
100
+ def sort_nulls(value)
101
+ return nil if value.blank?
102
+
103
+ nulls = value.to_s.downcase
104
+ if nulls != 'nulls_first' && nulls != 'nulls_last'
105
+ raise InvalidSort.new("Unknown nulls ordering #{value}")
70
106
  end
107
+
108
+ nulls.to_sym
71
109
  end
72
110
 
73
111
  def sort_for_column(column, options)
74
112
  direction, nulls = sort_direction_and_nulls(options)
75
113
 
76
- if direction == :desc
114
+ if direction == 'desc'
77
115
  self.order(Arel::Nodes::Descending.new(column, nulls))
78
- elsif direction == :asc
116
+ elsif direction == 'asc'
79
117
  self.order(Arel::Nodes::Ascending.new(column, nulls))
80
118
  else
81
119
  raise InvalidSort.new("Unknown ordering #{direction}")
@@ -117,18 +155,17 @@ module ActiveRecord
117
155
  # A collection (has_many / has_and_belongs_to_many) wraps the
118
156
  # attribute in an Arel::Attributes::Relation so the aggregate
119
157
  # references the joined table correctly; a singular association
120
- # (belongs_to / has_one) uses the plain attribute.
121
- column = if relation.collection?
122
- Arel::Attributes::Relation.new(relation.klass.arel_table[column_name], relation.name)
123
- else
124
- relation.klass.arel_table[column_name]
125
- 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?
126
163
 
127
164
  direction, nulls = sort_direction_and_nulls(column_options)
128
165
 
129
- order = if direction == :desc
166
+ order = if direction == 'desc'
130
167
  Arel::Nodes::Descending.new(column.maximum, nulls)
131
- elsif direction == :asc
168
+ elsif direction == 'asc'
132
169
  Arel::Nodes::Ascending.new(column.minimum, nulls)
133
170
  else
134
171
  raise InvalidSort.new("Unknown ordering #{direction}")
@@ -158,6 +195,100 @@ module ActiveRecord
158
195
  end
159
196
 
160
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
+
161
292
  module Calculations
162
293
  # sort_for_relation groups by the primary key and LEFT JOINs the
163
294
  # relation, so aggregates would see grouped, fanned-out rows: count
@@ -167,10 +298,14 @@ module ActiveRecord
167
298
  # answer: compute it over the base table restricted to the sorted
168
299
  # relation's (distinct) primary keys. User joins and conditions still
169
300
  # apply inside the subquery, and a user-added group falls through to
170
- # 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.
171
306
  def calculate(operation, column_name)
172
307
  if @sorted_by_relation && group_values == [klass.arel_table[klass.primary_key]]
173
- 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))
174
309
  .calculate(operation, column_name)
175
310
  else
176
311
  super
@@ -181,3 +316,4 @@ module ActiveRecord
181
316
  end
182
317
 
183
318
  ActiveRecord::Relation.prepend(ActiveRecord::Sort::Calculations)
319
+ ActiveRecord::Relation.prepend(ActiveRecord::Sort::JoinAliases)
@@ -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.1.0'
4
6
  end
5
7
  end
@@ -1,7 +1,9 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'active_record'
2
4
  require 'arel/extensions'
3
5
 
4
6
  require File.expand_path(File.join(__FILE__, '../../../ext/active_record/base'))
5
7
 
6
8
  ActiveRecord::QueryMethods.prepend(ActiveRecord::Sort)
7
- 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.0
4
+ version: 8.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jon Bracy