Skip to content

Commit 4ffbef3

Browse files
authored
Add primary key tiebreaker for stable sorting (#2886)
This pull request improves the stability of sorting in Administrate by introducing a tiebreaker using the primary key. When ordering by a column, the primary key is now used as a secondary sort key. If the relation does not have a primary key, the tiebreaker is skipped. Without a tiebreaker, sorting by a non-unique column can result in unpredictable order for records with the same value. This can cause issues such as the order changing on every page load, or the same record appearing on multiple pages when paginating. By using the primary key as a tiebreaker, we guarantee a stable sort order and prevent these problems. Changes: - Add the primary key as a tiebreaker when ordering by a column - Skip the tiebreaker if the relation has no primary key - Update and add specs to cover these scenarios
1 parent be92bca commit 4ffbef3

File tree

5 files changed

+90
-10
lines changed

5 files changed

+90
-10
lines changed

docs/using_administrate.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
title: Using Administrate
3+
---
4+
5+
- [Stable sorting](./using_administrate/stable_sorting.md)
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
title: Stable Sorting
3+
---
4+
5+
The display order of `index` pages and `HasMany` fields is controlled by `Administrate::Order`.
6+
By default, both are set to `nil`, so the model's default sort order is applied.
7+
8+
You can toggle sorting by clicking on a table header attribute. When sorting by a specific attribute, a tiebreaker is used to ensure stable sorting.
9+
10+
The tiebreaker uses the table's primary key.
11+
The generated SQL looks like this:
12+
13+
```sql
14+
-- When toggling the name attribute
15+
SELECT * FROM users ORDER BY name DESC, id DESC;
16+
17+
-- When toggling the name attribute again
18+
SELECT * FROM users ORDER BY name ASC, id ASC;
19+
```
20+
21+
If there is no primary key (such as in a join table), the tiebreaker is not used.
22+
23+
```sql
24+
-- When toggling the name attribute
25+
SELECT * FROM users ORDER BY name DESC;
26+
```

lib/administrate/order.rb

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,18 @@ def apply(relation)
1212

1313
order = relation.arel_table[sorting_column].public_send(direction)
1414

15-
return relation.reorder(order) if
16-
column_exist?(relation, sorting_column)
17-
18-
relation
15+
tiebreak_key = relation.primary_key
16+
tiebreak_order = relation.arel_table[tiebreak_key].public_send(direction)
17+
18+
if column_exist?(relation, sorting_column)
19+
if column_exist?(relation, tiebreak_key) && sorting_column.to_s != tiebreak_key.to_s
20+
relation.reorder(order, tiebreak_order)
21+
else
22+
relation.reorder(order)
23+
end
24+
else
25+
relation
26+
end
1927
end
2028

2129
def ordered_by?(attr)

spec/example_app/app/views/layouts/docs.html.erb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
<li><a href="/rails_api">Rails API</a></li>
3838
<li><a href="/extending_administrate">Extending Administrate</a></li>
3939
<li><a href="/guides">Guides</a></li>
40+
<li><a href="/using_administrate">Using Administrate</a></li>
4041
</ul>
4142
</div>
4243

spec/lib/administrate/order_spec.rb

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,16 @@
3030
end
3131

3232
context "when `order` argument is valid" do
33-
it "orders by the column" do
33+
it "orders by the column and tiebreaks by the primary key" do
3434
order = Administrate::Order.new(:name, :asc)
3535
relation = relation_with_column(:name)
3636
allow(relation).to receive(:reorder).and_return(relation)
3737

3838
ordered = order.apply(relation)
3939

4040
expect(relation).to have_received(:reorder).with(
41-
to_sql('"table_name"."name" ASC')
41+
to_sql('"table_name"."name" ASC'),
42+
to_sql('"table_name"."id" ASC')
4243
)
4344
expect(ordered).to eq(relation)
4445
end
@@ -51,7 +52,8 @@
5152
ordered = order.apply(relation)
5253

5354
expect(relation).to have_received(:reorder).with(
54-
to_sql('"table_name"."name" DESC')
55+
to_sql('"table_name"."name" DESC'),
56+
to_sql('"table_name"."id" DESC')
5557
)
5658
expect(ordered).to eq(relation)
5759
end
@@ -64,10 +66,47 @@
6466
ordered = order.apply(relation)
6567

6668
expect(relation).to have_received(:reorder).with(
67-
to_sql('"table_name"."name" ASC')
69+
to_sql('"table_name"."name" ASC'),
70+
to_sql('"table_name"."id" ASC')
6871
)
6972
expect(ordered).to eq(relation)
7073
end
74+
75+
context "and same with own primary key" do
76+
it "orders by the primary key" do
77+
order = Administrate::Order.new(:id, :asc)
78+
relation = relation_with_column(:name)
79+
allow(relation).to receive(:reorder).and_return(relation)
80+
81+
ordered = order.apply(relation)
82+
83+
expect(relation).to have_received(:reorder).with(
84+
to_sql('"table_name"."id" ASC')
85+
)
86+
expect(ordered).to eq(relation)
87+
end
88+
end
89+
90+
context "when the relation has no primary key" do
91+
it "orders by the column without tiebreaks" do
92+
order = Administrate::Order.new(:name, :asc)
93+
relation = double(
94+
klass: double(reflect_on_association: nil),
95+
columns_hash: {"name" => :column_info},
96+
table_name: "table_name",
97+
arel_table: Arel::Table.new("table_name"),
98+
primary_key: nil
99+
)
100+
allow(relation).to receive(:reorder).and_return(relation)
101+
102+
ordered = order.apply(relation)
103+
104+
expect(relation).to have_received(:reorder).with(
105+
to_sql('"table_name"."name" ASC')
106+
)
107+
expect(ordered).to eq(relation)
108+
end
109+
end
71110
end
72111

73112
context "when relation has_many association" do
@@ -329,9 +368,10 @@
329368
def relation_with_column(column)
330369
double(
331370
klass: double(reflect_on_association: nil),
332-
columns_hash: {column.to_s => :column_info},
371+
columns_hash: {column.to_s => :column_info, "id" => :column_info},
333372
table_name: "table_name",
334-
arel_table: Arel::Table.new("table_name")
373+
arel_table: Arel::Table.new("table_name"),
374+
primary_key: "id"
335375
)
336376
end
337377

0 commit comments

Comments
 (0)