Skip to main content

supabase_client_graphql/
query.rs

1use serde::de::DeserializeOwned;
2use serde_json::Value;
3
4use crate::client::GraphqlClient;
5use crate::error::GraphqlError;
6use crate::filter::GqlFilter;
7use crate::order::{OrderByDirection, OrderByEntry};
8use crate::render;
9use crate::types::Connection;
10
11/// Builder for GraphQL collection queries.
12///
13/// Produces Relay-style connection queries against pg_graphql.
14///
15/// # Example
16/// ```ignore
17/// let connection = client.collection("blogCollection")
18///     .select(&["id", "title", "createdAt"])
19///     .filter(GqlFilter::eq("status", "published"))
20///     .order_by("createdAt", OrderByDirection::DescNullsLast)
21///     .first(10)
22///     .total_count()
23///     .execute::<BlogRow>().await?;
24/// ```
25#[derive(Debug)]
26pub struct QueryBuilder {
27    client: GraphqlClient,
28    collection: String,
29    select_fields: Vec<String>,
30    filter: Option<GqlFilter>,
31    order_by: Vec<OrderByEntry>,
32    first: Option<i64>,
33    last: Option<i64>,
34    after: Option<String>,
35    before: Option<String>,
36    offset: Option<i64>,
37    include_total_count: bool,
38}
39
40impl QueryBuilder {
41    pub(crate) fn new(client: GraphqlClient, collection: String) -> Self {
42        Self {
43            client,
44            collection,
45            select_fields: Vec::new(),
46            filter: None,
47            order_by: Vec::new(),
48            first: None,
49            last: None,
50            after: None,
51            before: None,
52            offset: None,
53            include_total_count: false,
54        }
55    }
56
57    /// Set the fields to select in each node.
58    pub fn select(mut self, fields: &[&str]) -> Self {
59        self.select_fields = fields.iter().map(|s| s.to_string()).collect();
60        self
61    }
62
63    /// Set a filter condition.
64    pub fn filter(mut self, filter: GqlFilter) -> Self {
65        self.filter = Some(filter);
66        self
67    }
68
69    /// Add an order-by clause.
70    pub fn order_by(mut self, column: &str, direction: OrderByDirection) -> Self {
71        self.order_by.push(OrderByEntry {
72            column: column.to_string(),
73            direction,
74        });
75        self
76    }
77
78    /// Limit results to the first N items (forward pagination).
79    pub fn first(mut self, n: i64) -> Self {
80        self.first = Some(n);
81        self
82    }
83
84    /// Limit results to the last N items (backward pagination).
85    pub fn last(mut self, n: i64) -> Self {
86        self.last = Some(n);
87        self
88    }
89
90    /// Set the cursor for forward pagination.
91    pub fn after(mut self, cursor: &str) -> Self {
92        self.after = Some(cursor.to_string());
93        self
94    }
95
96    /// Set the cursor for backward pagination.
97    pub fn before(mut self, cursor: &str) -> Self {
98        self.before = Some(cursor.to_string());
99        self
100    }
101
102    /// Set the offset for pagination.
103    pub fn offset(mut self, n: i64) -> Self {
104        self.offset = Some(n);
105        self
106    }
107
108    /// Include the `totalCount` field in the response.
109    pub fn total_count(mut self) -> Self {
110        self.include_total_count = true;
111        self
112    }
113
114    /// Build the query string and variables without executing.
115    ///
116    /// Returns `(query_string, variables)` for inspection or debugging.
117    pub fn build(&self) -> (String, Value) {
118        let filter_value = self.filter.as_ref().map(|f| f.to_value());
119        render::render_collection_query(
120            &self.collection,
121            &self.select_fields,
122            filter_value.as_ref(),
123            &self.order_by,
124            self.first,
125            self.last,
126            self.after.as_deref(),
127            self.before.as_deref(),
128            self.offset,
129            self.include_total_count,
130        )
131    }
132
133    /// Execute the query and return a typed `Connection<T>`.
134    ///
135    /// The response `data` field is expected to have the shape:
136    /// `{ "collectionName": { "edges": [...], "pageInfo": {...}, "totalCount": ... } }`
137    pub async fn execute<T: DeserializeOwned>(self) -> Result<Connection<T>, GraphqlError> {
138        let (query, variables) = self.build();
139        let collection_name = self.collection.clone();
140
141        let response = self
142            .client
143            .execute::<Value>(&query, Some(variables), None)
144            .await?;
145
146        let data = response.data.ok_or_else(|| {
147            GraphqlError::InvalidConfig("No data in GraphQL response".to_string())
148        })?;
149
150        let collection_data = data.get(&collection_name).ok_or_else(|| {
151            GraphqlError::InvalidConfig(format!(
152                "Collection '{}' not found in response data",
153                collection_name
154            ))
155        })?;
156
157        let connection: Connection<T> = serde_json::from_value(collection_data.clone())?;
158        Ok(connection)
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use serde_json::json;
166
167    fn test_client() -> GraphqlClient {
168        GraphqlClient::new("https://example.supabase.co", "test-key").unwrap()
169    }
170
171    #[test]
172    fn build_simple_query() {
173        let builder = QueryBuilder::new(test_client(), "blogCollection".into())
174            .select(&["id", "title"])
175            .first(10);
176
177        let (query, vars) = builder.build();
178        assert!(query.contains("blogCollection"));
179        assert!(query.contains("node { id title }"));
180        assert!(query.contains("$first: Int"));
181        assert_eq!(vars["first"], 10);
182    }
183
184    #[test]
185    fn build_query_with_filter() {
186        let builder = QueryBuilder::new(test_client(), "blogCollection".into())
187            .select(&["id"])
188            .filter(GqlFilter::eq("status", json!("published")));
189
190        let (query, _) = builder.build();
191        assert!(query.contains("filter: {status: {eq: \"published\"}}"));
192    }
193
194    #[test]
195    fn build_query_with_order() {
196        let builder = QueryBuilder::new(test_client(), "blogCollection".into())
197            .select(&["id"])
198            .order_by("createdAt", OrderByDirection::DescNullsLast);
199
200        let (query, _) = builder.build();
201        assert!(query.contains("orderBy: [{createdAt: DescNullsLast}]"));
202    }
203
204    #[test]
205    fn build_query_with_total_count() {
206        let builder = QueryBuilder::new(test_client(), "blogCollection".into())
207            .select(&["id"])
208            .total_count();
209
210        let (query, _) = builder.build();
211        assert!(query.contains("totalCount"));
212    }
213
214    #[test]
215    fn build_query_with_cursors() {
216        let builder = QueryBuilder::new(test_client(), "blogCollection".into())
217            .select(&["id"])
218            .first(5)
219            .after("abc123");
220
221        let (query, vars) = builder.build();
222        assert!(query.contains("$after: Cursor"));
223        assert!(query.contains("after: $after"));
224        assert_eq!(vars["after"], "abc123");
225    }
226
227    #[test]
228    fn build_query_backward_pagination() {
229        let builder = QueryBuilder::new(test_client(), "blogCollection".into())
230            .select(&["id"])
231            .last(5)
232            .before("xyz789");
233
234        let (query, vars) = builder.build();
235        assert!(query.contains("$last: Int"));
236        assert!(query.contains("$before: Cursor"));
237        assert_eq!(vars["last"], 5);
238        assert_eq!(vars["before"], "xyz789");
239    }
240
241    #[test]
242    fn build_full_query() {
243        let builder = QueryBuilder::new(test_client(), "blogCollection".into())
244            .select(&["id", "title", "createdAt"])
245            .filter(GqlFilter::and(vec![
246                GqlFilter::eq("status", json!("published")),
247                GqlFilter::gte("views", json!(100)),
248            ]))
249            .order_by("createdAt", OrderByDirection::DescNullsLast)
250            .first(10)
251            .after("cursor123")
252            .total_count();
253
254        let (query, vars) = builder.build();
255        assert!(query.contains("blogCollection"));
256        assert!(query.contains("node { id title createdAt }"));
257        assert!(query.contains("filter:"));
258        assert!(query.contains("orderBy:"));
259        assert!(query.contains("totalCount"));
260        assert_eq!(vars["first"], 10);
261        assert_eq!(vars["after"], "cursor123");
262    }
263}