Skip to content

Commit 9818557

Browse files
committed
Smarter cursor detection: explicit cursors, multiple ID fields
Cursor-based pagination now checks for explicit cursor fields in the response body (next_cursor, cursor, end_cursor, next_page_token, etc.) before falling back to the last item's id. Also checks _id and uuid as ID field alternatives. Cursor parameter name is detected from the response shape (cursor, page_token, starting_after).
1 parent 4d0fff2 commit 9818557

1 file changed

Lines changed: 55 additions & 13 deletions

File tree

crates/sqlize-core/src/exec/pagination.rs

Lines changed: 55 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ fn body_url_field(ctx: &PageContext<'_>) -> Option<String> {
6565
fn cursor_based(ctx: &PageContext<'_>) -> Option<String> {
6666
let obj = ctx.body.as_object()?;
6767

68-
// Check for has_more: true
68+
// Check for has_more / hasMore / has_next_page: true
6969
let has_more = obj.get("has_more")
7070
.or_else(|| obj.get("hasMore"))
7171
.or_else(|| obj.get("has_next_page"))?
@@ -75,25 +75,67 @@ fn cursor_based(ctx: &PageContext<'_>) -> Option<String> {
7575
return None;
7676
}
7777

78-
// Get the last item's ID from the data array
79-
let items = ctx.data.as_array()?;
80-
let last_item = items.last()?;
81-
let cursor = last_item.get("id")
82-
.and_then(|v| v.as_str().map(|s| s.to_owned()))
83-
.or_else(|| last_item.get("id").and_then(|v| v.as_i64().map(|n| n.to_string())))?;
78+
// Determine cursor value. Priority:
79+
// 1. Explicit cursor field in the response body (next_cursor, cursor, end_cursor)
80+
// 2. Last item's id field (Stripe and most REST APIs)
81+
let cursor = extract_explicit_cursor(obj)
82+
.or_else(|| extract_last_item_id(ctx.data))?;
8483

85-
// Determine the cursor parameter name
86-
let cursor_param = if obj.contains_key("has_more") || obj.contains_key("hasMore") {
87-
"starting_after" // Stripe convention
88-
} else {
89-
"after" // Generic cursor convention
90-
};
84+
// Determine the cursor parameter name from the response shape
85+
let cursor_param = detect_cursor_param(obj);
9186

9287
// Append cursor to URL
9388
let separator = if ctx.current_url.contains('?') { "&" } else { "?" };
9489
Some(format!("{}{separator}{cursor_param}={cursor}", ctx.current_url))
9590
}
9691

92+
/// Look for an explicit cursor/token in the response body.
93+
fn extract_explicit_cursor(obj: &serde_json::Map<String, serde_json::Value>) -> Option<String> {
94+
for key in ["next_cursor", "cursor", "end_cursor", "ending_before",
95+
"next_page_token", "pageToken", "continuation_token"] {
96+
if let Some(val) = obj.get(key) {
97+
match val {
98+
serde_json::Value::String(s) if !s.is_empty() => return Some(s.clone()),
99+
serde_json::Value::Number(n) => return Some(n.to_string()),
100+
_ => {}
101+
}
102+
}
103+
}
104+
None
105+
}
106+
107+
/// Fall back to the last item's ID in the data array.
108+
fn extract_last_item_id(data: &serde_json::Value) -> Option<String> {
109+
let items = data.as_array()?;
110+
let last = items.last()?;
111+
for key in ["id", "_id", "uuid"] {
112+
if let Some(val) = last.get(key) {
113+
match val {
114+
serde_json::Value::String(s) => return Some(s.clone()),
115+
serde_json::Value::Number(n) => return Some(n.to_string()),
116+
_ => {}
117+
}
118+
}
119+
}
120+
None
121+
}
122+
123+
/// Detect the right query parameter name for the cursor.
124+
fn detect_cursor_param(obj: &serde_json::Map<String, serde_json::Value>) -> &'static str {
125+
// If the response has an explicit cursor field, match the param name
126+
if obj.contains_key("next_cursor") || obj.contains_key("cursor") {
127+
return "cursor";
128+
}
129+
if obj.contains_key("next_page_token") || obj.contains_key("pageToken") {
130+
return "page_token";
131+
}
132+
if obj.contains_key("continuation_token") {
133+
return "continuation_token";
134+
}
135+
// Default: Stripe convention
136+
"starting_after"
137+
}
138+
97139
#[cfg(test)]
98140
mod tests {
99141
use super::*;

0 commit comments

Comments
 (0)