Skip to main content

reinhardt_pages/api/
queryset.rs

1//! QuerySet-like API for client-side data fetching.
2//!
3//! This module provides a Django QuerySet-inspired interface for making
4//! API calls from WASM applications.
5
6use crate::server_fn::ServerFnError;
7use serde::{Deserialize, Serialize, de::DeserializeOwned};
8use std::marker::PhantomData;
9
10/// Filter operation types.
11#[derive(Debug, Clone, Serialize, Deserialize, Default)]
12pub enum FilterOp {
13	/// Exact match (field = value).
14	#[default]
15	Exact,
16	/// Case-insensitive exact match.
17	IExact,
18	/// Contains substring.
19	Contains,
20	/// Case-insensitive contains.
21	IContains,
22	/// Greater than.
23	Gt,
24	/// Greater than or equal.
25	Gte,
26	/// Less than.
27	Lt,
28	/// Less than or equal.
29	Lte,
30	/// Starts with.
31	StartsWith,
32	/// Case-insensitive starts with.
33	IStartsWith,
34	/// Ends with.
35	EndsWith,
36	/// Case-insensitive ends with.
37	IEndsWith,
38	/// In list of values.
39	In,
40	/// Is null check.
41	IsNull,
42	/// Range (between two values).
43	Range,
44}
45
46/// A single filter condition.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct Filter {
49	/// The field name to filter on.
50	pub field: String,
51	/// The filter operation.
52	pub op: FilterOp,
53	/// The value to filter with.
54	pub value: serde_json::Value,
55	/// Whether this is an exclude filter (NOT).
56	pub exclude: bool,
57}
58
59impl Filter {
60	/// Creates a new exact match filter.
61	pub fn exact(field: impl Into<String>, value: impl Serialize) -> Self {
62		Self {
63			field: field.into(),
64			op: FilterOp::Exact,
65			value: serde_json::to_value(value).unwrap_or(serde_json::Value::Null),
66			exclude: false,
67		}
68	}
69
70	/// Creates a new filter with a specific operation.
71	pub fn with_op(field: impl Into<String>, op: FilterOp, value: impl Serialize) -> Self {
72		Self {
73			field: field.into(),
74			op,
75			value: serde_json::to_value(value).unwrap_or(serde_json::Value::Null),
76			exclude: false,
77		}
78	}
79
80	/// Converts this filter to an exclude filter.
81	pub fn negate(mut self) -> Self {
82		self.exclude = !self.exclude;
83		self
84	}
85
86	/// Converts the filter to a query parameter string.
87	pub fn to_query_param(&self) -> (String, String) {
88		let key = match self.op {
89			FilterOp::Exact => self.field.clone(),
90			FilterOp::IExact => format!("{}__iexact", self.field),
91			FilterOp::Contains => format!("{}__contains", self.field),
92			FilterOp::IContains => format!("{}__icontains", self.field),
93			FilterOp::Gt => format!("{}__gt", self.field),
94			FilterOp::Gte => format!("{}__gte", self.field),
95			FilterOp::Lt => format!("{}__lt", self.field),
96			FilterOp::Lte => format!("{}__lte", self.field),
97			FilterOp::StartsWith => format!("{}__startswith", self.field),
98			FilterOp::IStartsWith => format!("{}__istartswith", self.field),
99			FilterOp::EndsWith => format!("{}__endswith", self.field),
100			FilterOp::IEndsWith => format!("{}__iendswith", self.field),
101			FilterOp::In => format!("{}__in", self.field),
102			FilterOp::IsNull => format!("{}__isnull", self.field),
103			FilterOp::Range => format!("{}__range", self.field),
104		};
105
106		let value = match &self.value {
107			serde_json::Value::String(s) => s.clone(),
108			serde_json::Value::Number(n) => n.to_string(),
109			serde_json::Value::Bool(b) => b.to_string(),
110			serde_json::Value::Array(arr) => arr
111				.iter()
112				.map(|v| match v {
113					serde_json::Value::String(s) => s.clone(),
114					other => other.to_string(),
115				})
116				.collect::<Vec<_>>()
117				.join(","),
118			serde_json::Value::Null => "null".to_string(),
119			other => other.to_string(),
120		};
121
122		(key, value)
123	}
124}
125
126/// A QuerySet-like builder for API requests.
127///
128/// This provides a fluent interface similar to Django's QuerySet
129/// for building and executing API queries.
130#[derive(Debug, Clone)]
131pub struct ApiQuerySet<T> {
132	/// The API endpoint URL.
133	endpoint: String,
134	/// Filter conditions.
135	filters: Vec<Filter>,
136	/// Ordering fields (prefix with '-' for descending).
137	ordering: Vec<String>,
138	/// Maximum number of results.
139	limit: Option<usize>,
140	/// Number of results to skip.
141	offset: Option<usize>,
142	/// Fields to select (for partial responses).
143	fields: Vec<String>,
144	/// PhantomData for the model type.
145	_marker: PhantomData<T>,
146}
147
148impl<T> ApiQuerySet<T>
149where
150	T: Serialize + DeserializeOwned,
151{
152	/// Creates a new QuerySet for the given endpoint.
153	pub fn new(endpoint: impl Into<String>) -> Self {
154		Self {
155			endpoint: endpoint.into(),
156			filters: Vec::new(),
157			ordering: Vec::new(),
158			limit: None,
159			offset: None,
160			fields: Vec::new(),
161			_marker: PhantomData,
162		}
163	}
164
165	/// Adds a filter condition (exact match).
166	///
167	/// # Example
168	/// ```ignore
169	/// User::objects().filter("is_active", true)
170	/// ```
171	pub fn filter(mut self, field: impl Into<String>, value: impl Serialize) -> Self {
172		self.filters.push(Filter::exact(field, value));
173		self
174	}
175
176	/// Adds a filter with a specific operation.
177	///
178	/// # Example
179	/// ```ignore
180	/// User::objects().filter_op("age", FilterOp::Gte, 18)
181	/// ```
182	pub fn filter_op(
183		mut self,
184		field: impl Into<String>,
185		op: FilterOp,
186		value: impl Serialize,
187	) -> Self {
188		self.filters.push(Filter::with_op(field, op, value));
189		self
190	}
191
192	/// Adds an exclude filter (NOT condition).
193	///
194	/// # Example
195	/// ```ignore
196	/// User::objects().exclude("status", "banned")
197	/// ```
198	pub fn exclude(mut self, field: impl Into<String>, value: impl Serialize) -> Self {
199		self.filters.push(Filter::exact(field, value).negate());
200		self
201	}
202
203	/// Sets the ordering for results.
204	///
205	/// Prefix field names with '-' for descending order.
206	///
207	/// # Example
208	/// ```ignore
209	/// User::objects().order_by(&["-created_at", "username"])
210	/// ```
211	pub fn order_by(mut self, fields: &[&str]) -> Self {
212		self.ordering = fields.iter().map(|s| (*s).to_string()).collect();
213		self
214	}
215
216	/// Limits the number of results.
217	///
218	/// # Example
219	/// ```ignore
220	/// User::objects().limit(10)
221	/// ```
222	pub fn limit(mut self, n: usize) -> Self {
223		self.limit = Some(n);
224		self
225	}
226
227	/// Skips the first N results.
228	///
229	/// # Example
230	/// ```ignore
231	/// User::objects().offset(20).limit(10)  // Page 3
232	/// ```
233	pub fn offset(mut self, n: usize) -> Self {
234		self.offset = Some(n);
235		self
236	}
237
238	/// Selects specific fields for partial responses.
239	///
240	/// # Example
241	/// ```ignore
242	/// User::objects().only(&["id", "username"])
243	/// ```
244	pub fn only(mut self, fields: &[&str]) -> Self {
245		self.fields = fields.iter().map(|s| (*s).to_string()).collect();
246		self
247	}
248
249	/// Returns a clone of this QuerySet with no filters or ordering.
250	pub fn all_clone(&self) -> Self {
251		Self::new(&self.endpoint)
252	}
253
254	/// Builds the query URL with all parameters.
255	pub fn build_url(&self) -> String {
256		let mut params: Vec<(String, String)> = Vec::new();
257
258		// Add filters
259		for filter in &self.filters {
260			let (key, value) = filter.to_query_param();
261			if filter.exclude {
262				params.push((format!("exclude__{}", key), value));
263			} else {
264				params.push((key, value));
265			}
266		}
267
268		// Add ordering
269		if !self.ordering.is_empty() {
270			params.push(("ordering".to_string(), self.ordering.join(",")));
271		}
272
273		// Add pagination
274		if let Some(limit) = self.limit {
275			params.push(("limit".to_string(), limit.to_string()));
276		}
277		if let Some(offset) = self.offset {
278			params.push(("offset".to_string(), offset.to_string()));
279		}
280
281		// Add field selection
282		if !self.fields.is_empty() {
283			params.push(("fields".to_string(), self.fields.join(",")));
284		}
285
286		// Build URL
287		if params.is_empty() {
288			self.endpoint.clone()
289		} else {
290			let query_string = params
291				.iter()
292				.map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
293				.collect::<Vec<_>>()
294				.join("&");
295			format!("{}?{}", self.endpoint, query_string)
296		}
297	}
298
299	#[cfg(wasm)]
300	async fn fetch_response(
301		&self,
302		method: &str,
303		url: &str,
304		body: Option<&str>,
305	) -> Result<crate::fetch::FetchResponse, ServerFnError> {
306		use crate::csrf::csrf_headers;
307		use crate::fetch;
308
309		let mut headers = Vec::new();
310		if body.is_some() {
311			headers.push(("Content-Type".to_string(), "application/json".to_string()));
312		}
313		if let Some((header_name, header_value)) = csrf_headers() {
314			headers.push((header_name.to_string(), header_value));
315		}
316
317		let response = fetch::request(method, url, body, headers).await?;
318		if !response.is_success() {
319			return Err(ServerFnError::server(
320				response.status(),
321				response.into_text(),
322			));
323		}
324		Ok(response)
325	}
326
327	#[cfg(wasm)]
328	async fn fetch_json<R>(
329		&self,
330		method: &str,
331		url: &str,
332		body: Option<&str>,
333	) -> Result<R, ServerFnError>
334	where
335		R: DeserializeOwned,
336	{
337		self.fetch_response(method, url, body).await?.json()
338	}
339
340	/// Fetches all matching results.
341	#[cfg(wasm)]
342	pub async fn all(&self) -> Result<Vec<T>, ServerFnError> {
343		let url = self.build_url();
344		self.fetch_json("GET", &url, None).await
345	}
346
347	/// Fetches all matching results (non-WASM stub).
348	#[cfg(native)]
349	pub async fn all(&self) -> Result<Vec<T>, ServerFnError> {
350		Err(ServerFnError::Network(
351			"API calls not supported outside WASM".to_string(),
352		))
353	}
354
355	/// Fetches the first matching result.
356	#[cfg(wasm)]
357	pub async fn first(&self) -> Result<Option<T>, ServerFnError>
358	where
359		T: Clone,
360	{
361		let mut queryset = self.clone();
362		queryset.limit = Some(1);
363		let results = queryset.all().await?;
364		Ok(results.into_iter().next())
365	}
366
367	/// Fetches the first matching result (non-WASM stub).
368	#[cfg(native)]
369	pub async fn first(&self) -> Result<Option<T>, ServerFnError> {
370		Err(ServerFnError::Network(
371			"API calls not supported outside WASM".to_string(),
372		))
373	}
374
375	/// Fetches a single result by primary key.
376	#[cfg(wasm)]
377	pub async fn get(&self, pk: impl std::fmt::Display) -> Result<T, ServerFnError> {
378		let url = format!("{}{}/", self.endpoint.trim_end_matches('/'), pk);
379		self.fetch_json("GET", &url, None).await
380	}
381
382	/// Fetches a single result by primary key (non-WASM stub).
383	#[cfg(native)]
384	pub async fn get(&self, _pk: impl std::fmt::Display) -> Result<T, ServerFnError> {
385		Err(ServerFnError::Network(
386			"API calls not supported outside WASM".to_string(),
387		))
388	}
389
390	/// Returns the count of matching results.
391	#[cfg(wasm)]
392	pub async fn count(&self) -> Result<usize, ServerFnError> {
393		let base_url = self.build_url();
394		let separator = if base_url.contains('?') { '&' } else { '?' };
395		let url = format!("{base_url}{separator}count=true");
396
397		#[derive(Deserialize)]
398		struct CountResponse {
399			count: usize,
400		}
401
402		let result: CountResponse = self.fetch_json("GET", &url, None).await?;
403		Ok(result.count)
404	}
405
406	/// Returns the count of matching results (non-WASM stub).
407	#[cfg(native)]
408	pub async fn count(&self) -> Result<usize, ServerFnError> {
409		Err(ServerFnError::Network(
410			"API calls not supported outside WASM".to_string(),
411		))
412	}
413
414	/// Checks if any matching results exist.
415	pub async fn exists(&self) -> Result<bool, ServerFnError>
416	where
417		Self: Clone,
418	{
419		let count = self.clone().limit(1).count().await?;
420		Ok(count > 0)
421	}
422
423	/// Creates a new record.
424	#[cfg(wasm)]
425	pub async fn create(&self, data: &T) -> Result<T, ServerFnError> {
426		let body =
427			serde_json::to_string(data).map_err(|e| ServerFnError::Serialization(e.to_string()))?;
428		self.fetch_json("POST", &self.endpoint, Some(&body)).await
429	}
430
431	/// Creates a new record (non-WASM stub).
432	#[cfg(native)]
433	pub async fn create(&self, _data: &T) -> Result<T, ServerFnError> {
434		Err(ServerFnError::Network(
435			"API calls not supported outside WASM".to_string(),
436		))
437	}
438
439	/// Updates an existing record.
440	#[cfg(wasm)]
441	pub async fn update(&self, pk: impl std::fmt::Display, data: &T) -> Result<T, ServerFnError> {
442		let url = format!("{}{}/", self.endpoint.trim_end_matches('/'), pk);
443		let body =
444			serde_json::to_string(data).map_err(|e| ServerFnError::Serialization(e.to_string()))?;
445		self.fetch_json("PUT", &url, Some(&body)).await
446	}
447
448	/// Updates an existing record (non-WASM stub).
449	#[cfg(native)]
450	pub async fn update(&self, _pk: impl std::fmt::Display, _data: &T) -> Result<T, ServerFnError> {
451		Err(ServerFnError::Network(
452			"API calls not supported outside WASM".to_string(),
453		))
454	}
455
456	/// Partially updates an existing record.
457	#[cfg(wasm)]
458	pub async fn partial_update(
459		&self,
460		pk: impl std::fmt::Display,
461		data: &serde_json::Value,
462	) -> Result<T, ServerFnError> {
463		let url = format!("{}{}/", self.endpoint.trim_end_matches('/'), pk);
464		let body =
465			serde_json::to_string(data).map_err(|e| ServerFnError::Serialization(e.to_string()))?;
466		self.fetch_json("PATCH", &url, Some(&body)).await
467	}
468
469	/// Partially updates an existing record (non-WASM stub).
470	#[cfg(native)]
471	pub async fn partial_update(
472		&self,
473		_pk: impl std::fmt::Display,
474		_data: &serde_json::Value,
475	) -> Result<T, ServerFnError> {
476		Err(ServerFnError::Network(
477			"API calls not supported outside WASM".to_string(),
478		))
479	}
480
481	/// Deletes a record by primary key.
482	#[cfg(wasm)]
483	pub async fn delete(&self, pk: impl std::fmt::Display) -> Result<(), ServerFnError> {
484		let url = format!("{}{}/", self.endpoint.trim_end_matches('/'), pk);
485		self.fetch_response("DELETE", &url, None).await?;
486		Ok(())
487	}
488
489	/// Deletes a record by primary key (non-WASM stub).
490	#[cfg(native)]
491	pub async fn delete(&self, _pk: impl std::fmt::Display) -> Result<(), ServerFnError> {
492		Err(ServerFnError::Network(
493			"API calls not supported outside WASM".to_string(),
494		))
495	}
496}
497
498#[cfg(test)]
499mod tests {
500	use super::*;
501
502	#[test]
503	fn test_filter_exact() {
504		let filter = Filter::exact("name", "test");
505		assert_eq!(filter.field, "name");
506		assert!(!filter.exclude);
507		let (key, value) = filter.to_query_param();
508		assert_eq!(key, "name");
509		assert_eq!(value, "test");
510	}
511
512	#[test]
513	fn test_filter_with_op() {
514		let filter = Filter::with_op("age", FilterOp::Gte, 18);
515		let (key, value) = filter.to_query_param();
516		assert_eq!(key, "age__gte");
517		assert_eq!(value, "18");
518	}
519
520	#[test]
521	fn test_filter_negate() {
522		let filter = Filter::exact("status", "banned").negate();
523		assert!(filter.exclude);
524	}
525
526	#[test]
527	fn test_queryset_build_url_simple() {
528		let qs: ApiQuerySet<serde_json::Value> = ApiQuerySet::new("/api/users/");
529		assert_eq!(qs.build_url(), "/api/users/");
530	}
531
532	#[test]
533	fn test_queryset_build_url_with_filters() {
534		let qs: ApiQuerySet<serde_json::Value> = ApiQuerySet::new("/api/users/")
535			.filter("is_active", true)
536			.filter_op("age", FilterOp::Gte, 18);
537
538		let url = qs.build_url();
539		assert!(url.contains("is_active=true"));
540		assert!(url.contains("age__gte=18"));
541	}
542
543	#[test]
544	fn test_queryset_build_url_with_ordering() {
545		let qs: ApiQuerySet<serde_json::Value> =
546			ApiQuerySet::new("/api/users/").order_by(&["-created_at", "username"]);
547
548		let url = qs.build_url();
549		assert!(url.contains("ordering=-created_at%2Cusername"));
550	}
551
552	#[test]
553	fn test_queryset_build_url_with_pagination() {
554		let qs: ApiQuerySet<serde_json::Value> =
555			ApiQuerySet::new("/api/users/").limit(10).offset(20);
556
557		let url = qs.build_url();
558		assert!(url.contains("limit=10"));
559		assert!(url.contains("offset=20"));
560	}
561
562	#[test]
563	fn test_queryset_build_url_with_fields() {
564		let qs: ApiQuerySet<serde_json::Value> =
565			ApiQuerySet::new("/api/users/").only(&["id", "username"]);
566
567		let url = qs.build_url();
568		assert!(url.contains("fields=id%2Cusername"));
569	}
570
571	#[test]
572	fn test_queryset_chain() {
573		let qs: ApiQuerySet<serde_json::Value> = ApiQuerySet::new("/api/users/")
574			.filter("is_active", true)
575			.exclude("role", "admin")
576			.order_by(&["-created_at"])
577			.limit(10)
578			.offset(0);
579
580		let url = qs.build_url();
581		assert!(url.starts_with("/api/users/?"));
582		assert!(url.contains("is_active=true"));
583		assert!(url.contains("exclude__role=admin"));
584		assert!(url.contains("ordering=-created_at"));
585		assert!(url.contains("limit=10"));
586	}
587
588	#[test]
589	fn test_filter_in_list() {
590		let filter = Filter::with_op("id", FilterOp::In, vec![1, 2, 3]);
591		let (key, value) = filter.to_query_param();
592		assert_eq!(key, "id__in");
593		assert_eq!(value, "1,2,3");
594	}
595}