activerecord-sort 6.1.0.1 → 10.0.0.rc1

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: 24b933e7b9456dfe06da3a8da7a20ad8bf7b03043c81667021dbdafc8f6b9cf4
4
- data.tar.gz: 77347b9a5f294de607d485598b2c41305e66b2932debec47da84ded5cca9772d
3
+ metadata.gz: 55d032ac200c1f9c9bc9a06f988d54744427dcb2afbb2d0f82e89b7f7e6f32de
4
+ data.tar.gz: 5e10e73498fb36d17d579f9610a79d54ff254b3e36eb5940f9971e5538f15300
5
5
  SHA512:
6
- metadata.gz: 41524ce760cfbf7f8bdb93bd5688c149e553a277b615d45c240939d8c51541c6d34660ccf8ad681ce78aeececcf86995b62ab20fd10797f5440115b88767acd6
7
- data.tar.gz: f2098644c9201456b82899f1b621f8348a140bee47d6db7e4780ca4273b2ee47061771c535905d727a70195d4b20fc3da2c02181396bfcf84c1e645a739ef73c
6
+ metadata.gz: 91149455ebd6e9bf3f44b78a4e249c11335ca900923898dc4b90a10113b00276ab44dbe6412f58829f41aa40a04b3d664238e261633c8d3efcfee9087480d78f
7
+ data.tar.gz: 6e8028be2e9a156bd5a36ff0fc566121b2313aaa4aa48a51b48e41d344f7f59f10837f7c4cdee2fc8a98c763e8962b6fe9a406d560c77f482b26dc4c03651248
data/CHANGELOG.md ADDED
@@ -0,0 +1,68 @@
1
+ # Changelog
2
+
3
+ ## 10.0.0.rc1 (July 15, 2026)
4
+
5
+ Starting with this release the gem is versioned independently of Rails,
6
+ following [semantic versioning](https://semver.org). Earlier releases
7
+ (6.x) tracked the minimum supported Rails version; the jump to 10 makes
8
+ the break explicit so the version can't be misread as a Rails version.
9
+
10
+ ### Breaking changes
11
+
12
+ - Relation sorts are unified across association types. Sorting by any
13
+ relation (`has_many`, `has_and_belongs_to_many`, `has_one`,
14
+ `belongs_to`) now `LEFT OUTER JOIN`s the association, groups by the
15
+ sorted table's primary key, and orders by an aggregate of the requested
16
+ column. For `has_many` sorts this changes behavior:
17
+ - each record is returned once, instead of once per associated row
18
+ (the join no longer fans out into duplicates)
19
+ - records with no associated rows are included (previously dropped by
20
+ the `INNER JOIN`)
21
+ - Descending relation sorts key each record by its largest member
22
+ (`MAX`), ascending by its smallest (`MIN`) — the member you'd expect
23
+ to see first in that direction. For records with multiple associated
24
+ rows, descending is therefore not the reverse of ascending: a record
25
+ holding both extremes sorts first in both directions.
26
+ - Sort columns are no longer added to the `SELECT`:
27
+ - loaded records keep their own attributes — a joined sort column can
28
+ no longer overwrite a same-named attribute on the base record
29
+ (previously even `id` could be clobbered)
30
+ - `pluck` and `ids` keep the sort (previously raised on
31
+ `has_and_belongs_to_many` sorts)
32
+ - a caller's `select` is left untouched
33
+ - chaining `.distinct` after a relation sort now raises: PostgreSQL
34
+ requires `ORDER BY` expressions to appear in the select list for
35
+ `SELECT DISTINCT`. It previously appeared to work while silently
36
+ deduplicating over the wrong tuple.
37
+ - A bare `belongs_to`/`has_one` sort with no direction (e.g.
38
+ `Address.sort(property: :name)`) now defaults to ascending, matching
39
+ every other sort form (previously descending).
40
+ - An unknown sort direction on a relation sort (e.g. `:dsc`) now raises
41
+ `ActiveRecord::StatementInvalid` (previously sorted ascending
42
+ silently), matching column sorts.
43
+ - ActiveRecord 7.1 or newer is required (previously 6.1).
44
+
45
+ ### Added
46
+
47
+ - Sorting by `has_and_belongs_to_many` relations.
48
+ - Sorts of different types compose: they can be combined in one call
49
+ (`Property.sort(:name, tags: :name, addresses: :id)`) or chained
50
+ (`.sort(...).sort(...)`), sharing a single `GROUP BY`.
51
+ - Aggregates on a sorted relation (`count`, `sum`, `average`, `minimum`,
52
+ `maximum`) are computed over the records themselves rather than the
53
+ sort's grouped, joined rows — `count` returns the record count instead
54
+ of a per-group `Hash`, and multi-member records aren't weighted once
55
+ per member. A caller-supplied `group` still gets standard grouped
56
+ results.
57
+ - A blank direction (`''`, as query parameters often produce) is
58
+ accepted as ascending on all sort forms.
59
+ - `ActionController::Parameters` are accepted for relation sorts, and
60
+ their nulls option (`nulls_first`/`nulls_last`) is honored.
61
+
62
+ ### Fixed
63
+
64
+ - `has_and_belongs_to_many` sorts crashed with `NoMethodError` on
65
+ ActiveRecord <= 8.0 (`Function#as` mutates and returns the receiver
66
+ there).
67
+ - Combining a `has_and_belongs_to_many` sort with another relation sort
68
+ raised `PG::GroupingError`.
data/README.md CHANGED
@@ -40,18 +40,34 @@ Property.sort(id: {asc: :nulls_last})
40
40
  # => "...ORDER BY properties.id ASC NULLS LAST"
41
41
  ```
42
42
 
43
- It can also sort on relations:
43
+ It can also sort on relations. A relation sort groups by the sorted table's
44
+ primary key — so each record appears once and records with no associated
45
+ rows are still included — and orders by an aggregate of the requested
46
+ column: `MIN` ascending or `MAX` descending, keying each record by the
47
+ member you'd expect to see first in that direction:
44
48
 
45
49
  ```ruby
46
50
  Property.sort(addresses: :id).to_sql
47
- # => "...INNER JOIN addresses ON addresses.property_id = properties.id
48
- # => " ORDER BY addresses.id ASC"
51
+ # => "SELECT properties.* FROM properties
52
+ # => " LEFT OUTER JOIN addresses ON addresses.property_id = properties.id
53
+ # => " GROUP BY properties.id
54
+ # => " ORDER BY MIN(addresses.id) ASC"
49
55
 
50
56
  Property.sort(addresses: {id: :desc}).to_sql
51
- # => "...INNER JOIN addresses ON addresses.property_id = properties.id
52
- # => " ORDER BY addresses.id DESC"
57
+ # => "...ORDER BY MAX(addresses.id) DESC"
53
58
 
54
- Property.sort(addresses: {id: {asc: :nulls_frist}}).to_sql
55
- # => "...INNER JOIN addresses ON addresses.property_id = properties.id
56
- # => " ORDER BY addresses.id ASC NULLS FIRST"
59
+ Property.sort(addresses: {id: {asc: :nulls_first}}).to_sql
60
+ # => "...ORDER BY MIN(addresses.id) ASC NULLS FIRST"
61
+
62
+ Property.sort(tags: :name).to_sql # has_and_belongs_to_many
63
+ # => "SELECT properties.* FROM properties
64
+ # => " LEFT OUTER JOIN properties_tags ON properties_tags.property_id = properties.id
65
+ # => " LEFT OUTER JOIN tags ON tags.id = properties_tags.tag_id
66
+ # => " GROUP BY properties.id
67
+ # => " ORDER BY MIN(tags.name) ASC"
57
68
  ```
69
+
70
+ A relation sort is order-only — it never adds or removes records — so
71
+ aggregates on a sorted relation (`count`, `sum`, `average`, `minimum`,
72
+ `maximum`) are computed over the records themselves, not the sort's
73
+ grouped and joined rows.
@@ -0,0 +1,173 @@
1
+ require 'active_record'
2
+ require 'active_record/relation'
3
+
4
+ module ActiveRecord
5
+ module QueryMethods
6
+ # class << self
7
+
8
+ # ordering:
9
+ # :id
10
+ # :name, :id
11
+ # :id => :desc
12
+ # :id => {:desc => :nulls_last}
13
+ # :listings => :id
14
+ # :listings => {:id => {:asc => :nulls_first}}
15
+ # :random
16
+ def sort(*ordering)
17
+ resource = all
18
+ ordering.compact!
19
+ ordering.flatten!
20
+ return resource if ordering.size == 0
21
+
22
+ ordering.each do |order|
23
+ order = Array(order)
24
+ order.each do |column_or_relation, options|
25
+ if column_or_relation.to_sym == :random
26
+ resource = resource.random_sort
27
+ elsif self.column_names.include?(column_or_relation.to_s)
28
+ resource = resource.sort_for_column(self.arel_table[column_or_relation.to_s], options)
29
+ elsif reflect_on_association(column_or_relation.to_sym)
30
+ resource = resource.sort_for_relation(column_or_relation.to_sym, options)
31
+ else
32
+ raise ActiveRecord::StatementInvalid.new("Unkown column #{column_or_relation}")
33
+ end
34
+ end
35
+ end
36
+
37
+ resource
38
+ end
39
+
40
+ def random_sort
41
+ self.order(Arel::Nodes::RandomOrdering.new)
42
+ end
43
+
44
+ # Normalizes per-column sort options into [direction, nulls]. A blank
45
+ # direction — a bare column, or "" as query params often produce —
46
+ # means :asc.
47
+ # TODO: probably don't need to cast to sym
48
+ def sort_direction_and_nulls(options)
49
+ if options.is_a?(Hash) || options.class.name == "ActionController::Parameters"
50
+ [options.keys.first.to_sym, options.values.first.to_sym]
51
+ elsif options.blank?
52
+ [:asc, nil]
53
+ else
54
+ [options.to_s.downcase.to_sym, nil]
55
+ end
56
+ end
57
+
58
+ def sort_for_column(column, options)
59
+ direction, nulls = sort_direction_and_nulls(options)
60
+
61
+ if direction == :desc
62
+ self.order(Arel::Nodes::Descending.new(column, nulls))
63
+ elsif direction == :asc
64
+ self.order(Arel::Nodes::Ascending.new(column, nulls))
65
+ else
66
+ raise ActiveRecord::StatementInvalid.new("Unkown ordering #{direction}")
67
+ end
68
+ end
69
+
70
+ # The sort key lives only in the ORDER BY — nothing is added to the
71
+ # select list. That keeps the ORDER BY self-contained (so pluck/ids,
72
+ # which replace the select list, keep the sort), leaves the caller's
73
+ # select untouched, and means loaded records carry exactly their own
74
+ # attributes (a selected join column would overwrite a same-named
75
+ # attribute on the base table).
76
+ def sort_for_relation(relation, options)
77
+ resource = self
78
+ relation = reflect_on_association(relation)
79
+
80
+ if relation.macro == :has_many || relation.macro == :has_and_belongs_to_many
81
+ options = [options] if !options.is_a?(Array)
82
+
83
+ options.each do |order|
84
+ Array(order).each do |column_name, options|
85
+ # LEFT JOIN the collection, group by this table's primary key —
86
+ # so rows don't fan / duplicate and records with an empty
87
+ # collection still appear — and order by an aggregate of the
88
+ # requested column:
89
+ #
90
+ # SELECT properties.*
91
+ # FROM properties
92
+ # LEFT JOIN properties_tags ON properties_tags.property_id = properties.id
93
+ # LEFT JOIN tags ON tags.id = properties_tags.tag_id
94
+ # GROUP BY properties.id
95
+ # ORDER BY MIN(tags.name) ASC
96
+ #
97
+ # Ascending keys each record by its smallest member (MIN),
98
+ # descending by its largest (MAX) — the member you'd expect to
99
+ # see first in that direction. Toggling asc/desc therefore
100
+ # re-keys multi-value records rather than strictly reversing
101
+ # the list.
102
+ column = Arel::Attributes::Relation.new(relation.klass.arel_table[column_name], relation.name)
103
+ direction, nulls = sort_direction_and_nulls(options)
104
+
105
+ order = if direction == :desc
106
+ Arel::Nodes::Descending.new(column.maximum, nulls)
107
+ elsif direction == :asc
108
+ Arel::Nodes::Ascending.new(column.minimum, nulls)
109
+ else
110
+ raise ActiveRecord::StatementInvalid.new("Unkown ordering #{direction}")
111
+ end
112
+
113
+ resource = resource.left_outer_joins(relation.name)
114
+ resource = resource.order(order)
115
+ end
116
+ end
117
+ elsif relation.macro == :belongs_to || relation.macro == :has_one
118
+ options = [options] if !options.is_a?(Array)
119
+
120
+ options.each do |order|
121
+ order = Array(order)
122
+ order.each do |column, options|
123
+ column = relation.klass.arel_table[column]
124
+ direction, nulls = sort_direction_and_nulls(options)
125
+
126
+ if direction == :desc
127
+ order = Arel::Nodes::Descending.new(column.maximum, nulls)
128
+ elsif direction == :asc
129
+ order = Arel::Nodes::Ascending.new(column.minimum, nulls)
130
+ else
131
+ raise ActiveRecord::StatementInvalid.new("Unkown ordering #{direction}")
132
+ end
133
+
134
+ resource = resource.left_outer_joins(relation.name)
135
+ resource = resource.order(order)
136
+ end
137
+ end
138
+ end
139
+
140
+ resource = resource.group(klass.arel_table[klass.primary_key])
141
+ # Tag the relation for the count override below. Chained relations
142
+ # are built with clone, which copies instance variables, so the tag
143
+ # survives further chaining.
144
+ resource.instance_variable_set(:@sorted_by_relation, true)
145
+ resource
146
+ end
147
+
148
+ end
149
+
150
+ module Sort
151
+ module Calculations
152
+ # sort_for_relation groups by the primary key and LEFT JOINs the
153
+ # relation, so aggregates would see grouped, fanned-out rows: count
154
+ # returns a per-group Hash, and sum/average weigh a record once per
155
+ # collection member. But a relation sort is order-only — records are
156
+ # never added or removed — so every aggregate has a well-defined
157
+ # answer: compute it over the base table restricted to the sorted
158
+ # relation's (distinct) primary keys. User joins and conditions still
159
+ # apply inside the subquery, and a user-added group falls through to
160
+ # the standard grouped behavior.
161
+ def calculate(operation, column_name)
162
+ if @sorted_by_relation && group_values == [klass.arel_table[klass.primary_key]]
163
+ klass.where(klass.primary_key => unscope(:group, :order, :select).select(klass.primary_key))
164
+ .calculate(operation, column_name)
165
+ else
166
+ super
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
172
+
173
+ ActiveRecord::Relation.prepend(ActiveRecord::Sort::Calculations)
@@ -1,5 +1,5 @@
1
1
  module ActiveRecord
2
2
  module Sort
3
- VERSION = '6.1.0.1'
3
+ VERSION = '10.0.0.rc1'
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activerecord-sort
3
3
  version: !ruby/object:Gem::Version
4
- version: 6.1.0.1
4
+ version: 10.0.0.rc1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jon Bracy
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2021-03-23 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: activerecord
@@ -16,14 +15,14 @@ dependencies:
16
15
  requirements:
17
16
  - - ">="
18
17
  - !ruby/object:Gem::Version
19
- version: 6.1.0
18
+ version: '7.1'
20
19
  type: :runtime
21
20
  prerelease: false
22
21
  version_requirements: !ruby/object:Gem::Requirement
23
22
  requirements:
24
23
  - - ">="
25
24
  - !ruby/object:Gem::Version
26
- version: 6.1.0
25
+ version: '7.1'
27
26
  - !ruby/object:Gem::Dependency
28
27
  name: arel-extensions
29
28
  requirement: !ruby/object:Gem::Requirement
@@ -112,18 +111,18 @@ dependencies:
112
111
  name: simplecov
113
112
  requirement: !ruby/object:Gem::Requirement
114
113
  requirements:
115
- - - ">="
114
+ - - '='
116
115
  - !ruby/object:Gem::Version
117
- version: '0'
116
+ version: 1.0.0.rc5
118
117
  type: :development
119
118
  prerelease: false
120
119
  version_requirements: !ruby/object:Gem::Requirement
121
120
  requirements:
122
- - - ">="
121
+ - - '='
123
122
  - !ruby/object:Gem::Version
124
- version: '0'
123
+ version: 1.0.0.rc5
125
124
  - !ruby/object:Gem::Dependency
126
- name: factory_bot
125
+ name: faker
127
126
  requirement: !ruby/object:Gem::Requirement
128
127
  requirements:
129
128
  - - ">="
@@ -137,35 +136,35 @@ dependencies:
137
136
  - !ruby/object:Gem::Version
138
137
  version: '0'
139
138
  - !ruby/object:Gem::Dependency
140
- name: faker
139
+ name: sunstone
141
140
  requirement: !ruby/object:Gem::Requirement
142
141
  requirements:
143
142
  - - ">="
144
143
  - !ruby/object:Gem::Version
145
- version: '0'
144
+ version: 6.1.0.2
146
145
  type: :development
147
146
  prerelease: false
148
147
  version_requirements: !ruby/object:Gem::Requirement
149
148
  requirements:
150
149
  - - ">="
151
150
  - !ruby/object:Gem::Version
152
- version: '0'
151
+ version: 6.1.0.2
153
152
  - !ruby/object:Gem::Dependency
154
- name: sunstone
153
+ name: webmock
155
154
  requirement: !ruby/object:Gem::Requirement
156
155
  requirements:
157
156
  - - ">="
158
157
  - !ruby/object:Gem::Version
159
- version: 6.1.0.2
158
+ version: '0'
160
159
  type: :development
161
160
  prerelease: false
162
161
  version_requirements: !ruby/object:Gem::Requirement
163
162
  requirements:
164
163
  - - ">="
165
164
  - !ruby/object:Gem::Version
166
- version: 6.1.0.2
165
+ version: '0'
167
166
  - !ruby/object:Gem::Dependency
168
- name: webmock
167
+ name: debug
169
168
  requirement: !ruby/object:Gem::Requirement
170
169
  requirements:
171
170
  - - ">="
@@ -187,15 +186,16 @@ extensions: []
187
186
  extra_rdoc_files:
188
187
  - README.md
189
188
  files:
189
+ - CHANGELOG.md
190
190
  - LICENSE
191
191
  - README.md
192
+ - ext/active_record/base.rb
192
193
  - lib/active_record/sort.rb
193
194
  - lib/active_record/sort/version.rb
194
195
  homepage: https://github.com/malomalo/activerecord-sort
195
196
  licenses:
196
197
  - MIT
197
198
  metadata: {}
198
- post_install_message:
199
199
  rdoc_options:
200
200
  - "--main"
201
201
  - README.md
@@ -205,15 +205,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
205
205
  requirements:
206
206
  - - ">="
207
207
  - !ruby/object:Gem::Version
208
- version: '0'
208
+ version: '3.3'
209
209
  required_rubygems_version: !ruby/object:Gem::Requirement
210
210
  requirements:
211
211
  - - ">="
212
212
  - !ruby/object:Gem::Version
213
213
  version: '0'
214
214
  requirements: []
215
- rubygems_version: 3.2.3
216
- signing_key:
215
+ rubygems_version: 4.0.2
217
216
  specification_version: 4
218
217
  summary: A safe way to accept user parameters and order against your ActiveRecord
219
218
  Models