Skip to content

Commit c2543b5

Browse files
committed
Explicitly specify the cursor type.
1 parent fbbd062 commit c2543b5

File tree

6 files changed

+88
-62
lines changed

6 files changed

+88
-62
lines changed

trailbase-core/src/admin/list_logs.rs

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};
99
use std::borrow::Cow;
1010
use std::collections::HashMap;
1111
use trailbase_extension::geoip::{City, DatabaseType};
12-
use trailbase_qs::{Cursor, Order, OrderPrecedent, Query};
12+
use trailbase_qs::{Order, OrderPrecedent, Query};
1313
use ts_rs::TS;
1414
use uuid::Uuid;
1515

@@ -174,16 +174,15 @@ pub async fn list_logs_handler(
174174
};
175175
}
176176

177-
let cursor = cursor.and_then(|c| match c {
178-
Cursor::Integer(i) => Some(i),
179-
Cursor::Blob(b) => {
180-
log::warn!(
181-
"expected integer cursor, got: {} from {raw_url_query:?}",
182-
String::from_utf8_lossy(&b)
183-
);
184-
None
185-
}
186-
});
177+
let cursor = if let Some(cursor) = cursor {
178+
Some(
179+
cursor
180+
.parse::<i64>()
181+
.map_err(|err| Error::BadRequest(err.into()))?,
182+
)
183+
} else {
184+
None
185+
};
187186

188187
let geoip_db_type = trailbase_extension::geoip::database_type();
189188
let mut logs = fetch_logs(

trailbase-core/src/admin/rows/list_rows.rs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ use log::*;
33
use serde::Serialize;
44
use std::borrow::Cow;
55
use std::sync::Arc;
6-
use trailbase_qs::{Cursor, Order, OrderPrecedent, Query};
6+
use trailbase_qs::{Cursor, CursorType, Order, OrderPrecedent, Query};
77
use trailbase_schema::QualifiedName;
8-
use trailbase_schema::sqlite::Column;
8+
use trailbase_schema::sqlite::{Column, ColumnDataType};
99
use trailbase_sqlite::rows::rows_to_json_arrays;
1010
use ts_rs::TS;
1111

@@ -93,6 +93,10 @@ pub async fn list_rows_handler(
9393
};
9494

9595
let cursor_column = table_or_view_metadata.record_pk_column();
96+
let cursor = match (cursor, cursor_column) {
97+
(Some(cursor), Some((_idx, c))) => Some(parse_cursor(&cursor, &c)?),
98+
_ => None,
99+
};
96100
let (rows, columns) = fetch_rows(
97101
state.conn(),
98102
qualified_name,
@@ -235,6 +239,18 @@ async fn fetch_rows(
235239
));
236240
}
237241

242+
fn parse_cursor(cursor: &str, pk_col: &Column) -> Result<Cursor, Error> {
243+
return match pk_col.data_type {
244+
ColumnDataType::Blob => {
245+
Cursor::parse(cursor, CursorType::Blob).map_err(|err| Error::BadRequest(err.into()))
246+
}
247+
ColumnDataType::Integer => {
248+
Cursor::parse(cursor, CursorType::Integer).map_err(|err| Error::BadRequest(err.into()))
249+
}
250+
_ => Err(Error::BadRequest("Invalid cursor column type".into())),
251+
};
252+
}
253+
238254
#[cfg(test)]
239255
mod tests {
240256
use base64::prelude::*;

trailbase-core/src/admin/user/list_users.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,14 @@ pub async fn list_users_handler(
104104
let users = fetch_users(
105105
conn,
106106
filter_where_clause.clone(),
107-
cursor,
107+
if let Some(cursor) = cursor {
108+
Some(
109+
Cursor::parse(&cursor, trailbase_qs::CursorType::Blob)
110+
.map_err(|err| Error::BadRequest(err.into()))?,
111+
)
112+
} else {
113+
None
114+
},
108115
order.as_ref().unwrap_or_else(|| &DEFAULT_ORDERING),
109116
limit_or_default(limit).map_err(|err| Error::BadRequest(err.into()))?,
110117
)

trailbase-core/src/records/list_records.rs

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ use axum::{
66
use itertools::Itertools;
77
use serde::Serialize;
88
use std::borrow::Cow;
9-
use trailbase_qs::{Cursor, OrderPrecedent, Query};
10-
use trailbase_schema::QualifiedNameEscaped;
9+
use trailbase_qs::{Cursor, CursorType, OrderPrecedent, Query};
10+
use trailbase_schema::sqlite::Column;
11+
use trailbase_schema::{QualifiedNameEscaped, sqlite::ColumnDataType};
1112
use trailbase_sqlite::Value;
1213

1314
use crate::app_state::AppState;
@@ -124,6 +125,8 @@ pub async fn list_records_handler(
124125
}
125126

126127
let cursor_clause = if let Some(cursor) = cursor {
128+
let cursor = parse_cursor(&cursor, pk_column)?;
129+
127130
let mut pk_order = OrderPrecedent::Descending;
128131
if let Some(ref order) = order {
129132
if let Some((col, ord)) = order.columns.first() {
@@ -137,7 +140,10 @@ pub async fn list_records_handler(
137140
}
138141
}
139142

140-
params.push((Cow::Borrowed(":cursor"), cursor_to_value(cursor)));
143+
params.push((
144+
Cow::Borrowed(":cursor"),
145+
crate::listing::cursor_to_value(cursor),
146+
));
141147
match pk_order {
142148
OrderPrecedent::Descending => Some(format!(r#"_ROW_."{}" < :cursor"#, pk_column.name)),
143149
OrderPrecedent::Ascending => Some(format!(r#"_ROW_."{}" > :cursor"#, pk_column.name)),
@@ -305,10 +311,13 @@ fn column_filter(col_name: &str) -> bool {
305311
return !col_name.starts_with("_");
306312
}
307313

308-
fn cursor_to_value(cursor: Cursor) -> Value {
309-
return match cursor {
310-
Cursor::Integer(i) => Value::Integer(i),
311-
Cursor::Blob(b) => Value::Blob(b),
314+
fn parse_cursor(cursor: &str, pk_col: &Column) -> Result<Cursor, RecordError> {
315+
return match pk_col.data_type {
316+
ColumnDataType::Blob => Cursor::parse(cursor, CursorType::Blob)
317+
.map_err(|_| RecordError::BadRequest("Invalid blob cursor")),
318+
ColumnDataType::Integer => Cursor::parse(cursor, CursorType::Integer)
319+
.map_err(|_| RecordError::BadRequest("Invalid integer cursor")),
320+
_ => Err(RecordError::BadRequest("Invalid cursor column type")),
312321
};
313322
}
314323

trailbase-qs/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,5 @@ mod util;
99
mod value;
1010

1111
pub use filter::{Combiner, ValueOrComposite};
12-
pub use query::{Cursor, Expand, Order, OrderPrecedent, Query};
12+
pub use query::{Cursor, CursorType, Expand, Order, OrderPrecedent, Query};
1313
pub use value::Value;

trailbase-qs/src/query.rs

Lines changed: 35 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ use crate::util::deserialize_bool;
66

77
pub type Error = serde_qs::Error;
88

9+
#[derive(Clone, Debug, PartialEq)]
10+
pub enum CursorType {
11+
Blob,
12+
Integer,
13+
}
14+
915
/// TrailBase supports cursors in a few formats:
1016
/// * Integers
1117
/// * Text-encoded UUIDs ([u8; 16])
@@ -19,42 +25,25 @@ pub enum Cursor {
1925
Integer(i64),
2026
}
2127

22-
impl<'de> serde::de::Deserialize<'de> for Cursor {
23-
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
24-
where
25-
D: serde::de::Deserializer<'de>,
26-
{
27-
use serde::de::Error;
28-
use serde_value::Value;
28+
impl Cursor {
29+
pub fn parse(s: &str, cursor_type: CursorType) -> Result<Self, Error> {
30+
return match cursor_type {
31+
CursorType::Integer => {
32+
let i = s.parse::<i64>().map_err(|err| Error::ParseInt(err))?;
33+
Ok(Self::Integer(i))
34+
}
35+
CursorType::Blob => {
36+
if let Ok(uuid) = uuid::Uuid::parse_str(&s) {
37+
return Ok(Cursor::Blob(uuid.into()));
38+
}
2939

30-
static EXPECTED: &str = "integer or url-safe base64 encoded byte cursor";
40+
if let Ok(base64) = BASE64_URL_SAFE.decode(&s) {
41+
return Ok(Cursor::Blob(base64));
42+
}
3143

32-
let value = Value::deserialize(deserializer)?;
33-
let Value::String(str) = value else {
34-
return Err(Error::invalid_type(
35-
crate::util::unexpected(&value),
36-
&EXPECTED,
37-
));
44+
Err(Error::Custom(format!("Failed to parse: {s}")))
45+
}
3846
};
39-
40-
// FIXME: This is brittle. The consumer should explicitly define what kind of cursor we expect
41-
// based on table schema.
42-
if let Ok(integer) = str.parse::<i64>() {
43-
return Ok(Cursor::Integer(integer));
44-
}
45-
46-
if let Ok(uuid) = uuid::Uuid::parse_str(&str) {
47-
return Ok(Cursor::Blob(uuid.into()));
48-
}
49-
50-
if let Ok(base64) = BASE64_URL_SAFE.decode(&str) {
51-
return Ok(Cursor::Blob(base64));
52-
}
53-
54-
return Err(Error::invalid_type(
55-
crate::util::unexpected(&Value::String(str)),
56-
&EXPECTED,
57-
));
5847
}
5948
}
6049

@@ -168,7 +157,7 @@ pub struct Query {
168157
/// Max number of elements returned per page.
169158
pub limit: Option<usize>,
170159
/// Cursor to page.
171-
pub cursor: Option<Cursor>,
160+
pub cursor: Option<String>,
172161
/// Offset to page. Cursor is more efficient when available
173162
pub offset: Option<usize>,
174163

@@ -191,7 +180,7 @@ impl Query {
191180
pub fn parse(query: &str) -> Result<Query, Error> {
192181
// NOTE: We rely on non-strict mode to parse `filter[col0]=a&b%filter[col1]=c`.
193182
let qs = serde_qs::Config::new(9, false);
194-
return qs.deserialize_str::<Query>(query);
183+
return qs.deserialize_bytes::<Query>(query.as_bytes());
195184
}
196185
}
197186

@@ -439,27 +428,33 @@ mod tests {
439428
assert_eq!(
440429
qs.deserialize_str::<Query>("cursor=-5").unwrap(),
441430
Query {
442-
cursor: Some(Cursor::Integer(-5)),
431+
cursor: Some("-5".to_string()),
443432
..Default::default()
444433
}
445434
);
446435

447436
let uuid = uuid::Uuid::now_v7();
437+
let r = qs
438+
.deserialize_str::<Query>(&format!("cursor={}", uuid.to_string()))
439+
.unwrap();
448440
assert_eq!(
449-
qs.deserialize_str::<Query>(&format!("cursor={}", uuid.to_string()))
450-
.unwrap(),
441+
r,
451442
Query {
452-
cursor: Some(Cursor::Blob(uuid.as_bytes().into())),
443+
cursor: Some(uuid.to_string()),
453444
..Default::default()
454445
}
455446
);
447+
assert_eq!(
448+
Cursor::parse(&r.cursor.unwrap(), CursorType::Blob).unwrap(),
449+
Cursor::Blob(uuid.into())
450+
);
456451

457452
let blob = BASE64_URL_SAFE.encode(uuid.as_bytes());
458453
assert_eq!(
459454
qs.deserialize_str::<Query>(&format!("cursor={blob}"))
460455
.unwrap(),
461456
Query {
462-
cursor: Some(Cursor::Blob(uuid.as_bytes().into())),
457+
cursor: Some(blob),
463458
..Default::default()
464459
}
465460
);

0 commit comments

Comments
 (0)