1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
//! Shared helpers for list-endpoint query-parameter parsing.
//!
//! [`viewset::handle_list`](crate::viewset) and the
//! [`ListView`](crate::template_views::ListView) CBV both consume the
//! same URL-encoded query parameters (`?page=N`, `?ordering=col`,
//! `?search=term`, plus per-model filter keys). Before #809 they each
//! reimplemented the reserved-key skip list and the `-`-prefix
//! ordering split independently, and the reserved lists had drifted:
//! viewset skipped `cursor` and `ordering`; the ListView CBV did not,
//! so a column literally named `ordering` would be silently treated
//! as a filter by one layer and skipped by the other.
//!
//! This module exposes the single source of truth:
//!
//! * [`RESERVED_LIST_KEYS`] / [`is_reserved_list_key`] — the param
//! names that are NEVER candidate filters (pagination + sort +
//! search controls).
//! * [`parse_ordering`] — the `?ordering=col,-col2,col3` split with
//! the `-`-prefix DESC marker, allowlist filter, and schema-field
//! lookup.
//! * [`clamp_page_size`] — the `?page_size=N` resolver with the
//! per-endpoint default + max-cap.
//!
//! Only the safe subset is centralized. The WHERE-clause + search
//! builders in viewset (Django-shape `__lookup` suffixes, typed
//! SqlValue via `parse_form_value`, IR `SearchClause`) and
//! template_views (exact-match Eq + ILIKE OR-folded into WHERE) are
//! intentionally divergent — merging them would regress viewset's
//! richer filter surface.
//!
//! See [issue #809](https://github.com/ujeenet/rustango/issues/809).
use std::collections::HashMap;
use crate::core::{ModelSchema, OrderItem};
/// Reserved query-string keys that NEVER match a model field — they
/// drive pagination, sort, search, and cursor flow. List handlers
/// must skip these when interpreting params as filter clauses.
pub const RESERVED_LIST_KEYS: &[&str] = &[
"page",
"page_size",
"ordering",
"search",
"cursor",
"limit",
"offset",
];
/// `true` when `key` is one of [`RESERVED_LIST_KEYS`]. Use to gate
/// filter parsing:
///
/// ```ignore
/// for (k, v) in ¶ms {
/// if rustango::list_params::is_reserved_list_key(k) {
/// continue;
/// }
/// // ... treat as filter ...
/// }
/// ```
#[must_use]
pub fn is_reserved_list_key(key: &str) -> bool {
RESERVED_LIST_KEYS.iter().any(|r| *r == key)
}
/// Parse the `?ordering=col,-col2,col3` URL parameter into a list of
/// [`OrderItem`]s.
///
/// Each comma-separated token may carry a leading `-` to flip the
/// sort to DESC (Django / DRF convention). Tokens are filtered:
///
/// * If `allowlist` is non-empty, only field names present in it are
/// honored. Off-allowlist tokens are silently dropped (matches
/// DRF's defensive default — a hostile client can't sort on
/// `password_hash` just because it's a column).
/// * The field name (post-`-`-strip) must resolve via
/// [`ModelSchema::field`]. Unknown fields are silently dropped.
///
/// Returns an empty `Vec` when:
/// * `raw` is empty or all-whitespace
/// * every token is filtered out
///
/// Callers can detect "no override applied" by checking the result
/// against the raw input — or by passing `raw=None` to skip the
/// override and use the endpoint's default ordering.
#[must_use]
pub fn parse_ordering(
raw: &str,
allowlist: &[String],
schema: &'static ModelSchema,
) -> Vec<OrderItem> {
raw.split(',')
.filter(|s| !s.is_empty())
.filter_map(|part| {
let (field_name, desc) = if let Some(name) = part.strip_prefix('-') {
(name, true)
} else {
(part, false)
};
if !allowlist.is_empty() && !allowlist.iter().any(|f| f == field_name) {
return None;
}
schema
.field(field_name)
.map(|f| OrderItem::column(f.column, desc))
})
.collect()
}
/// Resolve the effective `page_size` from the URL: pick the
/// `?page_size=N` value when valid + below the cap, else fall back
/// to `default`.
///
/// `default` is the endpoint's preferred page size (typically 20–50).
/// `max` is the hard cap that protects the backend from runaway
/// requests — passing `?page_size=999999` clamps to `max`.
///
/// Edge cases handled:
/// * Missing / empty / non-parseable param → `default`.
/// * Negative or zero → `default` (a 0-row "page" doesn't make sense).
/// * Larger than `max` → clamped to `max`.
#[must_use]
pub fn clamp_page_size(default: i64, max: i64, params: &HashMap<String, String>) -> i64 {
params
.get("page_size")
.and_then(|s| s.parse::<i64>().ok())
.filter(|n| *n > 0)
.map_or(default, |n| n.min(max))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reserved_keys_match_documented_set() {
for k in [
"page",
"page_size",
"ordering",
"search",
"cursor",
"limit",
"offset",
] {
assert!(is_reserved_list_key(k), "{k} should be reserved");
}
assert!(!is_reserved_list_key("name"));
assert!(!is_reserved_list_key("created_at"));
assert!(!is_reserved_list_key(""));
}
#[test]
fn clamp_page_size_picks_param_when_valid() {
let mut p = HashMap::new();
p.insert("page_size".into(), "30".into());
assert_eq!(clamp_page_size(20, 100, &p), 30);
}
#[test]
fn clamp_page_size_caps_at_max() {
let mut p = HashMap::new();
p.insert("page_size".into(), "999".into());
assert_eq!(clamp_page_size(20, 100, &p), 100);
}
#[test]
fn clamp_page_size_falls_back_on_missing() {
let p = HashMap::new();
assert_eq!(clamp_page_size(20, 100, &p), 20);
}
#[test]
fn clamp_page_size_falls_back_on_garbage() {
let mut p = HashMap::new();
p.insert("page_size".into(), "not-a-number".into());
assert_eq!(clamp_page_size(20, 100, &p), 20);
}
#[test]
fn clamp_page_size_falls_back_on_zero_or_negative() {
let mut p = HashMap::new();
p.insert("page_size".into(), "0".into());
assert_eq!(clamp_page_size(20, 100, &p), 20);
p.insert("page_size".into(), "-5".into());
assert_eq!(clamp_page_size(20, 100, &p), 20);
}
}