activerecord-sort 7.0.0 → 8.0.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: ec46a72d471b80abc582726404812b56fb9a89176349d5609b9734a6227c4c61
4
- data.tar.gz: dd93e9fd01692c8113898c9c34aac125af3288a8b4d92d0be047660f396ace24
3
+ metadata.gz: 50d689663cb6717dab8e034f88d8645cf24a31489e322ca79d9cd52634281dc4
4
+ data.tar.gz: 544297bd2fc5b8322f8a719d3a4f52e235681a1c756dc191ac74fd69a4c556a7
5
5
  SHA512:
6
- metadata.gz: 56688dd4bc023b5098de0792f27309452dd85d46bf56f905ae771b9ff3154d5c0ef5c9348a52e363b3a4ed20987805eb7f1e66384faf4eca40429ac126eb15ec
7
- data.tar.gz: 8950bc656939f09fb886d5c917fb12a295a3da80d6d79a59ffde629a909c82a335aea17ce3cb5fbaceb4c1e5925261e11e536853321c89e58896dca9c66e2cff
6
+ metadata.gz: b0caa63a30b528ed30f2fa2ff5c29fe779fd633e9d30ec4404ede2c8b08f9a24a1832b3bd4bd10c39914bebe07cfd8fe23b10ce93496b254d6bb81d2450a3c9e
7
+ data.tar.gz: afc2f2911285ed3e681a39013013ad1b9ffbce9161a4ac5bb2da6fda40ddd03421942f3b42eaf0c0a3865878fe667759d4c77df0b8962b431937267be40a9d70
data/CHANGELOG.md CHANGED
@@ -1,4 +1,74 @@
1
- ## [7.0.0]
1
+ # Changelog
2
+
3
+ ## [8.0.0] - 2026-08-27
4
+
5
+ ### Breaking changes
6
+
7
+ - Relation sorts are unified across association types. Sorting by any
8
+ relation (`has_many`, `has_and_belongs_to_many`, `has_one`,
9
+ `belongs_to`) now `LEFT OUTER JOIN`s the association, groups by the
10
+ sorted table's primary key, and orders by an aggregate of the requested
11
+ column. For `has_many` sorts this changes behavior:
12
+ - each record is returned once, instead of once per associated row
13
+ (the join no longer fans out into duplicates)
14
+ - records with no associated rows are included (previously dropped by
15
+ the `INNER JOIN`)
16
+ - Descending relation sorts key each record by its largest member
17
+ (`MAX`), ascending by its smallest (`MIN`) — the member you'd expect
18
+ to see first in that direction. For records with multiple associated
19
+ rows, descending is therefore not the reverse of ascending: a record
20
+ holding both extremes sorts first in both directions.
21
+ - Sort columns are no longer added to the `SELECT`:
22
+ - loaded records keep their own attributes — a joined sort column can
23
+ no longer overwrite a same-named attribute on the base record
24
+ (previously even `id` could be clobbered)
25
+ - `pluck` and `ids` keep the sort (previously raised on
26
+ `has_and_belongs_to_many` sorts)
27
+ - a caller's `select` is left untouched
28
+ - chaining `.distinct` after a relation sort now raises: PostgreSQL
29
+ requires `ORDER BY` expressions to appear in the select list for
30
+ `SELECT DISTINCT`. It previously appeared to work while silently
31
+ deduplicating over the wrong tuple.
32
+ - A bare `belongs_to`/`has_one` sort with no direction (e.g.
33
+ `Address.sort(property: :name)`) now defaults to ascending, matching
34
+ every other sort form (previously descending).
35
+ - An unknown sort direction on a relation sort (e.g. `:dsc`) now raises
36
+ `ActiveRecord::Sort::InvalidSort` (previously sorted ascending
37
+ silently), matching column sorts.
38
+
39
+ ### Added
40
+
41
+ - Sorting by `has_and_belongs_to_many` relations.
42
+ - Unrecognized sort columns, associations, and directions raise
43
+ `ActiveRecord::Sort::InvalidSort`, a subclass of
44
+ `ActiveRecord::StatementInvalid` — so existing
45
+ `rescue ActiveRecord::StatementInvalid` handlers still catch bad sort
46
+ parameters, while callers can rescue the narrower class. Relation sort
47
+ columns are now validated up front (against the associated model's
48
+ columns) instead of only failing once the query reaches the database.
49
+ - Sorts of different types compose: they can be combined in one call
50
+ (`Property.sort(:name, tags: :name, addresses: :id)`) or chained
51
+ (`.sort(...).sort(...)`), sharing a single `GROUP BY`.
52
+ - Aggregates on a sorted relation (`count`, `sum`, `average`, `minimum`,
53
+ `maximum`) are computed over the records themselves rather than the
54
+ sort's grouped, joined rows — `count` returns the record count instead
55
+ of a per-group `Hash`, and multi-member records aren't weighted once
56
+ per member. A caller-supplied `group` still gets standard grouped
57
+ results.
58
+ - A blank direction (`''`, as query parameters often produce) is
59
+ accepted as ascending on all sort forms.
60
+ - `ActionController::Parameters` are accepted for relation sorts, and
61
+ their nulls option (`nulls_first`/`nulls_last`) is honored.
62
+
63
+ ### Fixed
64
+
65
+ - `has_and_belongs_to_many` sorts crashed with `NoMethodError` on
66
+ ActiveRecord <= 8.0 (`Function#as` mutates and returns the receiver
67
+ there).
68
+ - Combining a `has_and_belongs_to_many` sort with another relation sort
69
+ raised `PG::GroupingError`.
70
+
71
+ ## [7.0.0] - 2026-08-27
2
72
 
3
73
  The gem's version is now independent of the Rails version it targets.
4
74
 
@@ -25,4 +95,5 @@ The gem's version is now independent of the Rails version it targets.
25
95
  `ActiveRecord::QueryMethods.public_instance_methods(false)` — which keeps
26
96
  Rails' internal delegation invariants intact.
27
97
 
98
+ [8.0.0]: https://github.com/malomalo/activerecord-sort/releases/tag/v8.0.0
28
99
  [7.0.0]: https://github.com/malomalo/activerecord-sort/releases/tag/v7.0.0
data/README.md CHANGED
@@ -50,22 +50,41 @@ Property.sort(id: {asc: :nulls_last}).to_sql
50
50
  # => "...ORDER BY properties.id ASC NULLS LAST"
51
51
  ```
52
52
 
53
- It can also sort on associations:
53
+ It can also sort on relations. A relation sort groups by the sorted table's
54
+ primary key — so each record appears once and records with no associated
55
+ rows are still included — and orders by an aggregate of the requested
56
+ column: `MIN` ascending or `MAX` descending, keying each record by the
57
+ member you'd expect to see first in that direction. A record with no
58
+ associated rows has a `NULL` sort key; where `NULL`s land is
59
+ database-dependent, so pass `nulls_first`/`nulls_last` to place those
60
+ records explicitly:
54
61
 
55
62
  ```ruby
56
63
  Property.sort(addresses: :id).to_sql
57
- # => "...INNER JOIN addresses ON addresses.property_id = properties.id
58
- # => " ORDER BY addresses.id ASC"
64
+ # => "SELECT properties.* FROM properties
65
+ # => " LEFT OUTER JOIN addresses ON addresses.property_id = properties.id
66
+ # => " GROUP BY properties.id
67
+ # => " ORDER BY MIN(addresses.id) ASC"
59
68
 
60
69
  Property.sort(addresses: {id: :desc}).to_sql
61
- # => "...INNER JOIN addresses ON addresses.property_id = properties.id
62
- # => " ORDER BY addresses.id DESC"
70
+ # => "...ORDER BY MAX(addresses.id) DESC"
63
71
 
64
72
  Property.sort(addresses: {id: {asc: :nulls_first}}).to_sql
65
- # => "...INNER JOIN addresses ON addresses.property_id = properties.id
66
- # => " ORDER BY addresses.id ASC NULLS FIRST"
73
+ # => "...ORDER BY MIN(addresses.id) ASC NULLS FIRST"
74
+
75
+ Property.sort(tags: :name).to_sql # has_and_belongs_to_many
76
+ # => "SELECT properties.* FROM properties
77
+ # => " LEFT OUTER JOIN properties_tags ON properties_tags.property_id = properties.id
78
+ # => " LEFT OUTER JOIN tags ON tags.id = properties_tags.tag_id
79
+ # => " GROUP BY properties.id
80
+ # => " ORDER BY MIN(tags.name) ASC"
67
81
  ```
68
82
 
83
+ A relation sort is order-only — it never adds or removes records — so
84
+ aggregates on a sorted relation (`count`, `sum`, `average`, `minimum`,
85
+ `maximum`) are computed over the records themselves, not the sort's
86
+ grouped and joined rows.
87
+
69
88
  Order randomly:
70
89
 
71
90
  ```ruby
@@ -77,9 +96,13 @@ Unrecognized columns raise, so unfiltered params can't inject SQL:
77
96
 
78
97
  ```ruby
79
98
  Property.sort(:name_or_something_unexpected)
80
- # => raises ActiveRecord::StatementInvalid
99
+ # => raises ActiveRecord::Sort::InvalidSort
81
100
  ```
82
101
 
102
+ `ActiveRecord::Sort::InvalidSort` subclasses `ActiveRecord::StatementInvalid`,
103
+ so existing `rescue ActiveRecord::StatementInvalid` handlers keep catching bad
104
+ sort parameters, while callers that want to can rescue the narrower class.
105
+
83
106
  Called with no arguments, `#sort` behaves like Ruby's `Enumerable#sort` —
84
107
  it loads the records and sorts them by `<=>` — rather than building a query:
85
108
 
@@ -8,6 +8,15 @@ module ActiveRecord
8
8
  # delegation tests assert against — while relations still respond to them.
9
9
  module Sort
10
10
 
11
+ # Raised when a sort references an unrecognized column or association,
12
+ # or an unknown direction. Subclasses StatementInvalid so existing
13
+ # `rescue ActiveRecord::StatementInvalid` handlers — the safety
14
+ # contract that makes it OK to pass request params straight through —
15
+ # keep working, while callers can rescue this narrower class to tell a
16
+ # bad sort parameter apart from a genuine database error.
17
+ class InvalidSort < ActiveRecord::StatementInvalid
18
+ end
19
+
11
20
  # ordering:
12
21
  # :id
13
22
  # :name, :id
@@ -29,7 +38,6 @@ module ActiveRecord
29
38
  ordering.flatten!
30
39
  return resource if ordering.size == 0
31
40
 
32
- order_columns = []
33
41
  ordering.each do |order|
34
42
  order = Array(order)
35
43
  order.each do |column_or_relation, options|
@@ -38,97 +46,138 @@ module ActiveRecord
38
46
  elsif self.column_names.include?(column_or_relation.to_s)
39
47
  resource = resource.sort_for_column(self.arel_table[column_or_relation.to_s], options)
40
48
  elsif reflect_on_association(column_or_relation.to_sym)
41
- resource = resource.sort_for_relation(column_or_relation.to_sym, options, order_columns)
49
+ resource = resource.sort_for_relation(column_or_relation.to_sym, options)
42
50
  else
43
- raise ActiveRecord::StatementInvalid.new("Unkown column #{column_or_relation}")
51
+ raise InvalidSort.new("Unknown column #{column_or_relation}")
44
52
  end
45
53
  end
46
54
  end
47
-
48
- if order_columns.present?
49
- resource = resource.select(resource.klass.arel_table[Arel::Nodes::SqlLiteral.new('*')], *order_columns)
50
- end
51
55
 
52
56
  resource
53
57
  end
54
58
 
59
+ # Normalizes per-column sort options into [direction, nulls]. A blank
60
+ # direction — a bare column, or "" as query params often produce —
61
+ # means :asc.
55
62
  # TODO: probably don't need to cast to sym
63
+ 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]
68
+ else
69
+ [options.to_s.downcase.to_sym, nil]
70
+ end
71
+ end
72
+
56
73
  def sort_for_column(column, options)
57
- direction = (options.is_a?(Hash) || options.class.name == "ActionController::Parameters" ? options.keys.first.to_sym : options.to_s.downcase.to_sym)
74
+ direction, nulls = sort_direction_and_nulls(options)
58
75
 
59
- nulls = (options.is_a?(Hash) ? options.values.first.to_sym : nil)
60
76
  if direction == :desc
61
77
  self.order(Arel::Nodes::Descending.new(column, nulls))
62
- elsif direction == :asc || direction == :''
78
+ elsif direction == :asc
63
79
  self.order(Arel::Nodes::Ascending.new(column, nulls))
64
80
  else
65
- raise ActiveRecord::StatementInvalid.new("Unkown ordering #{direction}")
81
+ raise InvalidSort.new("Unknown ordering #{direction}")
66
82
  end
67
83
  end
68
84
 
69
- def sort_for_relation(relation, options, order_columns)
85
+ # The sort key lives only in the ORDER BY — nothing is added to the
86
+ # select list. That keeps the ORDER BY self-contained (so pluck/ids,
87
+ # which replace the select list, keep the sort), leaves the caller's
88
+ # select untouched, and means loaded records carry exactly their own
89
+ # attributes (a selected join column would overwrite a same-named
90
+ # attribute on the base table).
91
+ def sort_for_relation(relation, options)
70
92
  resource = self
71
93
  relation = reflect_on_association(relation)
94
+ options = [options] if !options.is_a?(Array)
95
+
96
+ # LEFT JOIN the association, group by this table's primary key — so
97
+ # rows don't fan / duplicate and records with an empty collection
98
+ # still appear — and order by an aggregate of the requested column:
99
+ #
100
+ # SELECT properties.*
101
+ # FROM properties
102
+ # LEFT JOIN properties_tags ON properties_tags.property_id = properties.id
103
+ # LEFT JOIN tags ON tags.id = properties_tags.tag_id
104
+ # GROUP BY properties.id
105
+ # ORDER BY MIN(tags.name) ASC
106
+ #
107
+ # Ascending keys each record by its smallest member (MIN), descending
108
+ # by its largest (MAX) — the member you'd expect to see first in that
109
+ # direction. Toggling asc/desc therefore re-keys multi-value records
110
+ # rather than strictly reversing the list.
111
+ options.each do |order|
112
+ Array(order).each do |column_name, column_options|
113
+ if !relation.klass.column_names.include?(column_name.to_s)
114
+ raise InvalidSort.new("Unknown column #{column_name}")
115
+ end
72
116
 
73
- if relation.macro == :has_many
74
- options = [options] if !options.is_a?(Array)
75
-
76
- options.each do |order|
77
- order = Array(order)
78
- order.each do |column, options|
79
- column = Arel::Attributes::Relation.new(relation.klass.arel_table[column], relation.name)
80
- order_columns.push(column)
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
117
+ # A collection (has_many / has_and_belongs_to_many) wraps the
118
+ # attribute in an Arel::Attributes::Relation so the aggregate
119
+ # 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]
105
125
  end
106
- end
107
- elsif relation.macro == :belongs_to || relation.macro == :has_one
108
- options = [options] if !options.is_a?(Array)
109
-
110
- options.each do |order|
111
- order = Array(order)
112
- order.each do |column, options|
113
- column = relation.klass.arel_table[column]
114
- order_columns.push(column)
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)
126
+
127
+ direction, nulls = sort_direction_and_nulls(column_options)
128
+
129
+ order = if direction == :desc
130
+ Arel::Nodes::Descending.new(column.maximum, nulls)
131
+ elsif direction == :asc
132
+ Arel::Nodes::Ascending.new(column.minimum, nulls)
133
+ else
134
+ raise InvalidSort.new("Unknown ordering #{direction}")
126
135
  end
136
+
137
+ resource = resource.left_outer_joins(relation.name)
138
+ resource = resource.order(order)
127
139
  end
128
140
  end
129
141
 
142
+ # Group by the primary key so the aggregate collapses the joined,
143
+ # fanned-out rows to one per record. Every relation sort adds this,
144
+ # so only add it once: ActiveRecord < 8.1's group! appends rather
145
+ # than unioning, which would leave a duplicate [pk, pk] that trips
146
+ # the count override's group_values check below.
147
+ # TODO: once Rails <= 8.0 is no longer supported, group! unions on
148
+ # its own — drop the include? guard and just group(primary_key).
149
+ primary_key = klass.arel_table[klass.primary_key]
150
+ resource = resource.group(primary_key) unless resource.group_values.include?(primary_key)
151
+ # Tag the relation for the count override below. Chained relations
152
+ # are built with clone, which copies instance variables, so the tag
153
+ # survives further chaining.
154
+ resource.instance_variable_set(:@sorted_by_relation, true)
130
155
  resource
131
156
  end
132
157
 
133
158
  end
159
+
160
+ module Sort
161
+ module Calculations
162
+ # sort_for_relation groups by the primary key and LEFT JOINs the
163
+ # relation, so aggregates would see grouped, fanned-out rows: count
164
+ # returns a per-group Hash, and sum/average weigh a record once per
165
+ # collection member. But a relation sort is order-only — records are
166
+ # never added or removed — so every aggregate has a well-defined
167
+ # answer: compute it over the base table restricted to the sorted
168
+ # relation's (distinct) primary keys. User joins and conditions still
169
+ # apply inside the subquery, and a user-added group falls through to
170
+ # the standard grouped behavior.
171
+ def calculate(operation, column_name)
172
+ 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))
174
+ .calculate(operation, column_name)
175
+ else
176
+ super
177
+ end
178
+ end
179
+ end
180
+ end
134
181
  end
182
+
183
+ ActiveRecord::Relation.prepend(ActiveRecord::Sort::Calculations)
@@ -1,5 +1,5 @@
1
1
  module ActiveRecord
2
2
  module Sort
3
- VERSION = '7.0.0'
3
+ VERSION = '8.0.0'
4
4
  end
5
5
  end
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: 7.0.0
4
+ version: 8.0.0
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: '0'
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: '0'
115
+ version: 1.0.0.rc5
116
116
  - !ruby/object:Gem::Dependency
117
- name: factory_bot
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: faker
131
+ name: sunstone
132
132
  requirement: !ruby/object:Gem::Requirement
133
133
  requirements:
134
134
  - - ">="
135
135
  - !ruby/object:Gem::Version
136
- version: '0'
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: '0'
143
+ version: 7.0.0
144
144
  - !ruby/object:Gem::Dependency
145
- name: sunstone
145
+ name: webmock
146
146
  requirement: !ruby/object:Gem::Requirement
147
147
  requirements:
148
148
  - - ">="
149
149
  - !ruby/object:Gem::Version
150
- version: 7.0.0
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: 7.0.0
157
+ version: '0'
158
158
  - !ruby/object:Gem::Dependency
159
- name: webmock
159
+ name: debug
160
160
  requirement: !ruby/object:Gem::Requirement
161
161
  requirements:
162
162
  - - ">="