Skip to main content

supabase_client_graphql/
mutation.rs

1use serde::de::DeserializeOwned;
2use serde_json::Value;
3
4use crate::client::GraphqlClient;
5use crate::error::GraphqlError;
6use crate::filter::GqlFilter;
7use crate::render;
8use crate::types::MutationResult;
9
10/// The kind of mutation operation.
11#[derive(Debug, Clone, Copy)]
12pub enum MutationKind {
13    Insert,
14    Update,
15    Delete,
16}
17
18impl From<MutationKind> for render::MutationKind {
19    fn from(kind: MutationKind) -> Self {
20        match kind {
21            MutationKind::Insert => render::MutationKind::Insert,
22            MutationKind::Update => render::MutationKind::Update,
23            MutationKind::Delete => render::MutationKind::Delete,
24        }
25    }
26}
27
28/// Builder for GraphQL mutations (insert, update, delete).
29///
30/// # Examples
31///
32/// ```ignore
33/// // Insert
34/// let result = client.insert_into("blogCollection")
35///     .objects(vec![json!({"title": "New", "body": "Content"})])
36///     .returning(&["id", "title"])
37///     .execute::<BlogRow>().await?;
38///
39/// // Update
40/// let result = client.update("blogCollection")
41///     .set(json!({"title": "Updated"}))
42///     .filter(GqlFilter::eq("id", 1))
43///     .at_most(1)
44///     .returning(&["id", "title"])
45///     .execute::<BlogRow>().await?;
46///
47/// // Delete
48/// let result = client.delete_from("blogCollection")
49///     .filter(GqlFilter::eq("id", 1))
50///     .at_most(1)
51///     .returning(&["id"])
52///     .execute::<BlogRow>().await?;
53/// ```
54#[derive(Debug)]
55pub struct MutationBuilder {
56    client: GraphqlClient,
57    collection: String,
58    kind: MutationKind,
59    returning_fields: Vec<String>,
60    filter: Option<GqlFilter>,
61    set: Option<Value>,
62    objects: Option<Value>,
63    at_most: Option<i64>,
64}
65
66impl MutationBuilder {
67    pub(crate) fn new(client: GraphqlClient, collection: String, kind: MutationKind) -> Self {
68        Self {
69            client,
70            collection,
71            kind,
72            returning_fields: Vec::new(),
73            filter: None,
74            set: None,
75            objects: None,
76            at_most: None,
77        }
78    }
79
80    /// Set the fields to return in the mutation result.
81    pub fn returning(mut self, fields: &[&str]) -> Self {
82        self.returning_fields = fields.iter().map(|s| s.to_string()).collect();
83        self
84    }
85
86    /// Set a filter condition (for update/delete).
87    pub fn filter(mut self, filter: GqlFilter) -> Self {
88        self.filter = Some(filter);
89        self
90    }
91
92    /// Set the values to update (for update mutations).
93    ///
94    /// The value should be a JSON object with field names and new values.
95    pub fn set(mut self, values: Value) -> Self {
96        self.set = Some(values);
97        self
98    }
99
100    /// Set the objects to insert (for insert mutations).
101    ///
102    /// The value should be a JSON array of objects.
103    pub fn objects(mut self, objects: Vec<Value>) -> Self {
104        self.objects = Some(Value::Array(objects));
105        self
106    }
107
108    /// Limit the number of affected rows (for update/delete).
109    pub fn at_most(mut self, n: i64) -> Self {
110        self.at_most = Some(n);
111        self
112    }
113
114    /// Build the mutation 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_mutation(
120            &self.collection,
121            self.kind.into(),
122            &self.returning_fields,
123            filter_value.as_ref(),
124            self.set.as_ref(),
125            self.objects.as_ref(),
126            self.at_most,
127        )
128    }
129
130    /// Execute the mutation and return a typed `MutationResult<T>`.
131    ///
132    /// The response is expected to have the shape:
133    /// `{ "mutationField": { "affectedCount": N, "records": [...] } }`
134    pub async fn execute<T: DeserializeOwned>(self) -> Result<MutationResult<T>, GraphqlError> {
135        let (query, variables) = self.build();
136
137        // Derive the expected mutation field name to extract from response
138        let mutation_field = match self.kind {
139            MutationKind::Insert => format!(
140                "insertInto{}{}",
141                self.collection[..1].to_uppercase(),
142                &self.collection[1..]
143            ),
144            MutationKind::Update => format!(
145                "update{}{}",
146                self.collection[..1].to_uppercase(),
147                &self.collection[1..]
148            ),
149            MutationKind::Delete => format!(
150                "deleteFrom{}{}",
151                self.collection[..1].to_uppercase(),
152                &self.collection[1..]
153            ),
154        };
155
156        let response = self
157            .client
158            .execute::<Value>(&query, Some(variables), None)
159            .await?;
160
161        let data = response.data.ok_or_else(|| {
162            GraphqlError::InvalidConfig("No data in GraphQL response".to_string())
163        })?;
164
165        let mutation_data = data.get(&mutation_field).ok_or_else(|| {
166            GraphqlError::InvalidConfig(format!(
167                "Mutation field '{}' not found in response data",
168                mutation_field
169            ))
170        })?;
171
172        let result: MutationResult<T> = serde_json::from_value(mutation_data.clone())?;
173        Ok(result)
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use serde_json::json;
181
182    fn test_client() -> GraphqlClient {
183        GraphqlClient::new("https://example.supabase.co", "test-key").unwrap()
184    }
185
186    #[test]
187    fn build_insert_mutation() {
188        let builder = MutationBuilder::new(
189            test_client(),
190            "blogCollection".into(),
191            MutationKind::Insert,
192        )
193        .objects(vec![json!({"title": "New", "body": "Content"})])
194        .returning(&["id", "title"]);
195
196        let (query, _) = builder.build();
197        assert!(query.contains("insertIntoBlogCollection"));
198        assert!(query.contains("objects:"));
199        assert!(query.contains("records { id title }"));
200        assert!(query.contains("affectedCount"));
201    }
202
203    #[test]
204    fn build_update_mutation() {
205        let builder = MutationBuilder::new(
206            test_client(),
207            "blogCollection".into(),
208            MutationKind::Update,
209        )
210        .set(json!({"title": "Updated"}))
211        .filter(GqlFilter::eq("id", json!(1)))
212        .at_most(1)
213        .returning(&["id", "title"]);
214
215        let (query, _) = builder.build();
216        assert!(query.contains("updateBlogCollection"));
217        assert!(query.contains("set: {title: \"Updated\"}"));
218        assert!(query.contains("filter: {id: {eq: 1}}"));
219        assert!(query.contains("atMost: 1"));
220    }
221
222    #[test]
223    fn build_delete_mutation() {
224        let builder = MutationBuilder::new(
225            test_client(),
226            "blogCollection".into(),
227            MutationKind::Delete,
228        )
229        .filter(GqlFilter::eq("id", json!(1)))
230        .at_most(1)
231        .returning(&["id"]);
232
233        let (query, _) = builder.build();
234        assert!(query.contains("deleteFromBlogCollection"));
235        assert!(query.contains("filter: {id: {eq: 1}}"));
236        assert!(query.contains("atMost: 1"));
237        assert!(query.contains("records { id }"));
238    }
239
240    #[test]
241    fn build_mutation_no_returning_uses_typename() {
242        let builder = MutationBuilder::new(
243            test_client(),
244            "blogCollection".into(),
245            MutationKind::Delete,
246        )
247        .filter(GqlFilter::eq("id", json!(1)));
248
249        let (query, _) = builder.build();
250        assert!(query.contains("records { __typename }"));
251    }
252
253    #[test]
254    fn build_insert_multiple_objects() {
255        let builder = MutationBuilder::new(
256            test_client(),
257            "blogCollection".into(),
258            MutationKind::Insert,
259        )
260        .objects(vec![
261            json!({"title": "Post 1"}),
262            json!({"title": "Post 2"}),
263        ])
264        .returning(&["id"]);
265
266        let (query, _) = builder.build();
267        assert!(query.contains("insertIntoBlogCollection"));
268        assert!(query.contains("objects: ["));
269    }
270}