Skip to content

Commit 6514cdb

Browse files
committed
Auto merge of #1807 - sgrif:sg-refactor-pagination, r=jtgeibel
Refactor pagination to prepare for cursor-based pagination. This continues the changes made in #1763. Since that PR was merged, one of the non-code steps has been taken care of -- All users hitting any endpoint with `?page=20` (which is an arbitrary search pattern that seemed high enough to give any crawlers going through pagination) have been contacted about the change, with a PR opened against any that included a repo. (Intersting aside, there are *zero* records of this for any endpoint other than search, which perhaps implies we can get rid of a few of these endpoints, but that's a separate discussion). This PR does not change any functionality, but moves some code around to better encapsulate things for upcoming changes. Specifically: - Change our frontend to show "next/prev page" links on the all crates page - Stop returning the "total" meta item when the next/prev page links will be cursor based (which I'd actually just like to start omitting in general) The main goal of this change was to stop having any code outside of `Paginated` (which has been renamed to `PaginatedQuery`, as there's a new type called `Paginated`) care about how pagination occurs. This means other code can't care about *how* pagination happens (with the exception of `reverse_dependencies`, which uses raw SQL, and sorta has to... That was a bit of a rabbit hole, see diesel-rs/diesel#2150 for details. Given the low traffic to that endpoint, I think we can safely ignore it). The distribution of responsibilities is as follows: - `PaginatedQuery<T>`: Given the query params, decides how to paginate things, generates appropriate SQL, loads a `Paginated<T>`. - `Paginated<T>`: Handles providing an iterator to the records, getting the total count (to be removed in the near future), providing the next/prev page params - `Request`: Takes the pagination related query params, turns that into an actual URL (note: Due to jankiness in our router, this will only ever be a query string, we have no way of getting the actual path) The next step from here is to change our UI to stop showing page numbers, and then remove the `total` field. This PR will introduce a performance regression that was addressed by #1668. That PR was addressing "this will become a problem in a future", not "this is about to take the site down". Given the intent to remove the `total` field entirely, I think it is fine to cause this regression in the short term. If we aren't confident that the changes to remove this field will land in the near future, or want to be conservative about this, I can add some additional complexity/git churn to retain the previous performance characteristics
2 parents 8d29e2e + 8ef5179 commit 6514cdb

File tree

11 files changed

+215
-131
lines changed

11 files changed

+215
-131
lines changed

src/controllers.rs

+8-21
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ mod prelude {
2424
fn json<T: Serialize>(&self, t: &T) -> Response;
2525
fn query(&self) -> IndexMap<String, String>;
2626
fn wants_json(&self) -> bool;
27-
fn pagination(&self, default: usize, max: usize) -> CargoResult<(i64, i64)>;
27+
fn query_with_params(&self, params: IndexMap<String, String>) -> String;
2828
}
2929

3030
impl<'a> RequestUtils for dyn Request + 'a {
@@ -55,26 +55,13 @@ mod prelude {
5555
.unwrap_or(false)
5656
}
5757

58-
fn pagination(&self, default: usize, max: usize) -> CargoResult<(i64, i64)> {
59-
let query = self.query();
60-
let page = query
61-
.get("page")
62-
.and_then(|s| s.parse::<usize>().ok())
63-
.unwrap_or(1);
64-
let limit = query
65-
.get("per_page")
66-
.and_then(|s| s.parse::<usize>().ok())
67-
.unwrap_or(default);
68-
if limit > max {
69-
return Err(human(&format_args!(
70-
"cannot request more than {} items",
71-
max
72-
)));
73-
}
74-
if page == 0 {
75-
return Err(human("page indexing starts from 1, page 0 is invalid"));
76-
}
77-
Ok((((page - 1) * limit) as i64, limit as i64))
58+
fn query_with_params(&self, new_params: IndexMap<String, String>) -> String {
59+
let mut params = self.query().clone();
60+
params.extend(new_params);
61+
let query_string = url::form_urlencoded::Serializer::new(String::new())
62+
.extend_pairs(params)
63+
.finish();
64+
format!("?{}", query_string)
7865
}
7966
}
8067
}

src/controllers/category.rs

+8-2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use super::helpers::pagination::*;
12
use super::prelude::*;
23

34
use crate::models::Category;
@@ -7,11 +8,16 @@ use crate::views::{EncodableCategory, EncodableCategoryWithSubcategories};
78
/// Handles the `GET /categories` route.
89
pub fn index(req: &mut dyn Request) -> CargoResult<Response> {
910
let conn = req.db_conn()?;
10-
let (offset, limit) = req.pagination(10, 100)?;
1111
let query = req.query();
12+
// FIXME: There are 69 categories, 47 top level. This isn't going to
13+
// grow by an OoM. We need a limit for /summary, but we don't need
14+
// to paginate this.
15+
let options = PaginationOptions::new(&query)?;
16+
let offset = options.offset().unwrap_or_default();
1217
let sort = query.get("sort").map_or("alpha", String::as_str);
1318

14-
let categories = Category::toplevel(&conn, sort, limit, offset)?;
19+
let categories =
20+
Category::toplevel(&conn, sort, i64::from(options.per_page), i64::from(offset))?;
1521
let categories = categories.into_iter().map(Category::encodable).collect();
1622

1723
// Query for the total count of categories

src/controllers/helpers.rs

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
use crate::util::{json_response, CargoResult};
22
use conduit::Response;
33

4-
pub mod pagination;
4+
pub(crate) mod pagination;
55

6-
pub use self::pagination::Paginate;
6+
pub(crate) use self::pagination::Paginate;
77

88
pub fn ok_true() -> CargoResult<Response> {
99
#[derive(Serialize)]

src/controllers/helpers/pagination.rs

+157-17
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,184 @@
1+
use crate::models::helpers::with_count::*;
2+
use crate::util::errors::*;
13
use diesel::pg::Pg;
24
use diesel::prelude::*;
35
use diesel::query_builder::*;
6+
use diesel::query_dsl::LoadQuery;
47
use diesel::sql_types::BigInt;
8+
use indexmap::IndexMap;
9+
10+
#[derive(Debug, Clone, Copy)]
11+
pub(crate) enum Page {
12+
Numeric(u32),
13+
Unspecified,
14+
}
15+
16+
impl Page {
17+
fn new(params: &IndexMap<String, String>) -> CargoResult<Self> {
18+
if let Some(s) = params.get("page") {
19+
let numeric_page = s.parse()?;
20+
if numeric_page < 1 {
21+
return Err(human(&format_args!(
22+
"page indexing starts from 1, page {} is invalid",
23+
numeric_page,
24+
)));
25+
}
26+
27+
Ok(Page::Numeric(numeric_page))
28+
} else {
29+
Ok(Page::Unspecified)
30+
}
31+
}
32+
}
33+
34+
#[derive(Debug, Clone, Copy)]
35+
pub(crate) struct PaginationOptions {
36+
page: Page,
37+
pub(crate) per_page: u32,
38+
}
39+
40+
impl PaginationOptions {
41+
pub(crate) fn new(params: &IndexMap<String, String>) -> CargoResult<Self> {
42+
const DEFAULT_PER_PAGE: u32 = 10;
43+
const MAX_PER_PAGE: u32 = 100;
44+
45+
let per_page = params
46+
.get("per_page")
47+
.map(|s| s.parse())
48+
.unwrap_or(Ok(DEFAULT_PER_PAGE))?;
49+
50+
if per_page > MAX_PER_PAGE {
51+
return Err(human(&format_args!(
52+
"cannot request more than {} items",
53+
MAX_PER_PAGE,
54+
)));
55+
}
56+
57+
Ok(Self {
58+
page: Page::new(params)?,
59+
per_page,
60+
})
61+
}
62+
63+
pub(crate) fn offset(&self) -> Option<u32> {
64+
if let Page::Numeric(p) = self.page {
65+
Some((p - 1) * self.per_page)
66+
} else {
67+
None
68+
}
69+
}
70+
}
71+
72+
pub(crate) trait Paginate: Sized {
73+
fn paginate(self, params: &IndexMap<String, String>) -> CargoResult<PaginatedQuery<Self>> {
74+
Ok(PaginatedQuery {
75+
query: self,
76+
options: PaginationOptions::new(params)?,
77+
})
78+
}
79+
}
80+
81+
impl<T> Paginate for T {}
582

6-
#[derive(Debug, QueryId)]
783
pub struct Paginated<T> {
8-
query: T,
9-
limit: i64,
10-
offset: i64,
84+
records_and_total: Vec<WithCount<T>>,
85+
options: PaginationOptions,
1186
}
1287

13-
pub trait Paginate: AsQuery + Sized {
14-
fn paginate(self, limit: i64, offset: i64) -> Paginated<Self::Query> {
15-
Paginated {
16-
query: self.as_query(),
17-
limit,
18-
offset,
88+
impl<T> Paginated<T> {
89+
pub(crate) fn total(&self) -> Option<i64> {
90+
Some(
91+
self.records_and_total
92+
.get(0)
93+
.map(|row| row.total)
94+
.unwrap_or_default(),
95+
)
96+
}
97+
98+
pub(crate) fn next_page_params(&self) -> Option<IndexMap<String, String>> {
99+
if self.records_and_total.len() < self.options.per_page as usize {
100+
return None;
19101
}
102+
103+
let mut opts = IndexMap::new();
104+
match self.options.page {
105+
Page::Numeric(n) => opts.insert("page".into(), (n + 1).to_string()),
106+
Page::Unspecified => opts.insert("page".into(), 2.to_string()),
107+
};
108+
Some(opts)
109+
}
110+
111+
pub(crate) fn prev_page_params(&self) -> Option<IndexMap<String, String>> {
112+
if let Page::Numeric(1) | Page::Unspecified = self.options.page {
113+
return None;
114+
}
115+
116+
let mut opts = IndexMap::new();
117+
match self.options.page {
118+
Page::Numeric(n) => opts.insert("page".into(), (n - 1).to_string()),
119+
Page::Unspecified => unreachable!(),
120+
};
121+
Some(opts)
122+
}
123+
124+
pub(crate) fn iter(&self) -> impl Iterator<Item = &T> {
125+
self.records_and_total.iter().map(|row| &row.record)
126+
}
127+
}
128+
129+
impl<T: 'static> IntoIterator for Paginated<T> {
130+
type IntoIter = Box<dyn Iterator<Item = Self::Item>>;
131+
type Item = T;
132+
133+
fn into_iter(self) -> Self::IntoIter {
134+
Box::new(self.records_and_total.into_iter().map(|row| row.record))
20135
}
21136
}
22137

23-
impl<T: AsQuery> Paginate for T {}
138+
#[derive(Debug)]
139+
pub(crate) struct PaginatedQuery<T> {
140+
query: T,
141+
options: PaginationOptions,
142+
}
24143

25-
impl<T: Query> Query for Paginated<T> {
144+
impl<T> PaginatedQuery<T> {
145+
pub(crate) fn load<U>(self, conn: &PgConnection) -> QueryResult<Paginated<U>>
146+
where
147+
Self: LoadQuery<PgConnection, WithCount<U>>,
148+
{
149+
let options = self.options;
150+
let records_and_total = self.internal_load(conn)?;
151+
Ok(Paginated {
152+
records_and_total,
153+
options,
154+
})
155+
}
156+
}
157+
158+
impl<T> QueryId for PaginatedQuery<T> {
159+
const HAS_STATIC_QUERY_ID: bool = false;
160+
type QueryId = ();
161+
}
162+
163+
impl<T: Query> Query for PaginatedQuery<T> {
26164
type SqlType = (T::SqlType, BigInt);
27165
}
28166

29-
impl<T, DB> RunQueryDsl<DB> for Paginated<T> {}
167+
impl<T, DB> RunQueryDsl<DB> for PaginatedQuery<T> {}
30168

31-
impl<T> QueryFragment<Pg> for Paginated<T>
169+
impl<T> QueryFragment<Pg> for PaginatedQuery<T>
32170
where
33171
T: QueryFragment<Pg>,
34172
{
35173
fn walk_ast(&self, mut out: AstPass<'_, Pg>) -> QueryResult<()> {
36174
out.push_sql("SELECT *, COUNT(*) OVER () FROM (");
37175
self.query.walk_ast(out.reborrow())?;
38176
out.push_sql(") t LIMIT ");
39-
out.push_bind_param::<BigInt, _>(&self.limit)?;
40-
out.push_sql(" OFFSET ");
41-
out.push_bind_param::<BigInt, _>(&self.offset)?;
177+
out.push_bind_param::<BigInt, _>(&i64::from(self.options.per_page))?;
178+
if let Some(offset) = self.options.offset() {
179+
out.push_sql(" OFFSET ");
180+
out.push_bind_param::<BigInt, _>(&i64::from(offset))?;
181+
}
42182
Ok(())
43183
}
44184
}

src/controllers/keyword.rs

+4-10
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ pub fn index(req: &mut dyn Request) -> CargoResult<Response> {
99
use crate::schema::keywords;
1010

1111
let conn = req.db_conn()?;
12-
let (offset, limit) = req.pagination(10, 100)?;
1312
let query = req.query();
1413
let sort = query.get("sort").map(|s| &s[..]).unwrap_or("alpha");
1514

@@ -21,14 +20,9 @@ pub fn index(req: &mut dyn Request) -> CargoResult<Response> {
2120
query = query.order(keywords::keyword.asc());
2221
}
2322

24-
let data = query
25-
.paginate(limit, offset)
26-
.load::<(Keyword, i64)>(&*conn)?;
27-
let total = data.get(0).map(|&(_, t)| t).unwrap_or(0);
28-
let kws = data
29-
.into_iter()
30-
.map(|(k, _)| k.encodable())
31-
.collect::<Vec<_>>();
23+
let data = query.paginate(&req.query())?.load::<Keyword>(&*conn)?;
24+
let total = data.total();
25+
let kws = data.into_iter().map(Keyword::encodable).collect::<Vec<_>>();
3226

3327
#[derive(Serialize)]
3428
struct R {
@@ -37,7 +31,7 @@ pub fn index(req: &mut dyn Request) -> CargoResult<Response> {
3731
}
3832
#[derive(Serialize)]
3933
struct Meta {
40-
total: i64,
34+
total: Option<i64>,
4135
}
4236

4337
Ok(req.json(&R {

src/controllers/krate/metadata.rs

+1-2
Original file line numberDiff line numberDiff line change
@@ -212,8 +212,7 @@ pub fn reverse_dependencies(req: &mut dyn Request) -> CargoResult<Response> {
212212
let name = &req.params()["crate_id"];
213213
let conn = req.db_conn()?;
214214
let krate = Crate::by_name(name).first::<Crate>(&*conn)?;
215-
let (offset, limit) = req.pagination(10, 100)?;
216-
let (rev_deps, total) = krate.reverse_dependencies(&*conn, offset, limit)?;
215+
let (rev_deps, total) = krate.reverse_dependencies(&*conn, &req.query())?;
217216
let rev_deps: Vec<_> = rev_deps
218217
.into_iter()
219218
.map(|dep| dep.encodable(&krate.name))

0 commit comments

Comments
 (0)