Skip to main content

dagger_sdk/
querybuilder.rs

1use std::{
2    collections::HashMap,
3    ops::{Add, Deref},
4    pin::Pin,
5    sync::Arc,
6};
7
8use crate::core::graphql_client::DynGraphQLClient;
9use futures::{future, Future};
10use serde::{Deserialize, Serialize};
11
12use crate::errors::{DaggerError, DaggerUnpackError};
13
14pub fn query() -> Selection {
15    Selection::default()
16}
17
18#[derive(Clone)]
19struct LazyResolve(Arc<dyn Fn() -> Pin<Box<dyn Future<Output = String> + Send>> + Send + Sync>);
20
21impl LazyResolve {
22    pub fn new(
23        func: Box<dyn Fn() -> Pin<Box<dyn Future<Output = String> + Send>> + Send + Sync>,
24    ) -> Self {
25        Self(Arc::new(func))
26    }
27
28    pub fn from_string(val: impl Into<String>) -> Self {
29        let val: String = val.into();
30        Self(Arc::new(move || Box::pin(future::ready(val.clone()))))
31    }
32}
33
34impl Deref for LazyResolve {
35    type Target = Arc<dyn Fn() -> Pin<Box<dyn Future<Output = String> + Send>> + Send + Sync>;
36
37    fn deref(&self) -> &Self::Target {
38        &self.0
39    }
40}
41
42impl From<String> for LazyResolve {
43    fn from(value: String) -> Self {
44        LazyResolve::from_string(value)
45    }
46}
47
48#[derive(Clone, Default)]
49pub struct Selection {
50    name: Option<String>,
51    alias: Option<String>,
52    args: Option<HashMap<String, LazyResolve>>,
53
54    /// When set, this step emits `... on TypeName` instead of a field name.
55    /// It does not add a nesting level in the response — unpack skips it.
56    inline_fragment: Option<String>,
57
58    prev: Option<Arc<Selection>>,
59}
60
61impl Selection {
62    /// Return a new root Selection, discarding the current query path.
63    /// Used by ConvertID (e.g. sync) to build `node(id) { ... on Foo {} }`
64    /// starting from a fresh root.
65    pub fn root(&self) -> Selection {
66        Selection::default()
67    }
68
69    pub fn select_with_alias(&self, alias: &str, name: &str) -> Selection {
70        Self {
71            name: Some(name.to_string()),
72            alias: Some(alias.to_string()),
73            args: None,
74            inline_fragment: None,
75            prev: Some(Arc::new(self.clone())),
76        }
77    }
78
79    pub fn select(&self, name: &str) -> Selection {
80        Self {
81            name: Some(name.to_string()),
82            alias: None,
83            args: None,
84            inline_fragment: None,
85            prev: Some(Arc::new(self.clone())),
86        }
87    }
88
89    /// Add an inline fragment type condition (`... on TypeName`).
90    /// Subsequent selections will be nested inside the fragment.
91    /// The response data is flat — the fragment doesn't add a nesting
92    /// level during unpack.
93    pub fn inline_fragment(&self, type_name: &str) -> Selection {
94        Self {
95            name: None,
96            alias: None,
97            args: None,
98            inline_fragment: Some(type_name.to_string()),
99            prev: Some(Arc::new(self.clone())),
100        }
101    }
102
103    pub fn arg<S>(&self, name: &str, value: S) -> Selection
104    where
105        S: Serialize,
106    {
107        let mut s = self.clone();
108
109        let val = serde_graphql_input::to_string_pretty(&value).unwrap();
110
111        match s.args.as_mut() {
112            Some(args) => {
113                let _ = args.insert(name.to_string(), val.into());
114            }
115            None => {
116                let mut hm = HashMap::new();
117                let _ = hm.insert(name.to_string(), val.into());
118                s.args = Some(hm);
119            }
120        }
121
122        s
123    }
124
125    pub fn arg_lazy(
126        &self,
127        name: &str,
128        value: Box<dyn Fn() -> Pin<Box<dyn Future<Output = String> + Send>> + Send + Sync>,
129    ) -> Selection {
130        let mut s = self.clone();
131
132        match s.args.as_mut() {
133            Some(args) => {
134                let _ = args.insert(name.to_string(), LazyResolve::new(Box::new(value)));
135            }
136            None => {
137                let mut hm = HashMap::new();
138                let _ = hm.insert(name.to_string(), LazyResolve::new(Box::new(value)));
139                s.args = Some(hm);
140            }
141        }
142
143        s
144    }
145
146    pub async fn build(&self) -> Result<String, DaggerError> {
147        let mut fields = vec!["query".to_string()];
148
149        for sel in self.path() {
150            if let Some(type_name) = sel.inline_fragment {
151                fields.push(format!("... on {}", type_name));
152            } else if let Some(mut query) = sel.name {
153                if let Some(args) = sel.args {
154                    let mut actualargs = Vec::new();
155                    for (name, arg) in args.iter() {
156                        let arg = arg().await;
157                        actualargs.push(format!("{name}:{arg}"));
158                    }
159
160                    query = query.add(&format!("({})", actualargs.join(", ")));
161                }
162
163                if let Some(alias) = sel.alias {
164                    query = format!("{}:{}", alias, query);
165                }
166
167                fields.push(query);
168            }
169        }
170
171        Ok(fields.join("{") + &"}".repeat(fields.len() - 1))
172    }
173
174    pub async fn execute<D>(&self, gql_client: DynGraphQLClient) -> Result<D, DaggerError>
175    where
176        D: for<'de> Deserialize<'de>,
177    {
178        let query = self.build().await?;
179
180        tracing::trace!(query = query.as_str(), "dagger-query");
181
182        let resp: Option<serde_json::Value> = match gql_client.query(&query).await {
183            Ok(r) => r,
184            Err(e) => return Err(DaggerError::Query(e)),
185        };
186
187        let resp: Option<D> = self.unpack_resp(resp)?;
188
189        Ok(resp.unwrap())
190    }
191
192    fn path(&self) -> Vec<Selection> {
193        let mut selections: Vec<Selection> = vec![];
194        let mut cur = self;
195
196        while cur.prev.is_some() {
197            selections.push(cur.clone());
198
199            if let Some(prev) = cur.prev.as_ref() {
200                cur = prev;
201            }
202        }
203
204        selections.reverse();
205        selections
206    }
207
208    pub(crate) fn unpack_resp<D>(
209        &self,
210        resp: Option<serde_json::Value>,
211    ) -> Result<Option<D>, DaggerError>
212    where
213        D: for<'de> Deserialize<'de>,
214    {
215        match resp {
216            Some(r) => self.unpack_resp_value::<D>(r).map(|v| Some(v)),
217            None => Ok(None),
218        }
219    }
220
221    fn unpack_resp_value<D>(&self, r: serde_json::Value) -> Result<D, DaggerError>
222    where
223        D: for<'de> Deserialize<'de>,
224    {
225        let mut data = r;
226
227        for sel in self.path() {
228            // Inline fragments don't add a nesting level in the response.
229            if sel.inline_fragment.is_some() {
230                continue;
231            }
232
233            if let Some(o) = data.as_object() {
234                let key = sel.alias.as_ref().or(sel.name.as_ref());
235                if let Some(key) = key {
236                    data = o.get(key).cloned().unwrap_or(serde_json::Value::Null);
237                }
238            }
239        }
240
241        if let serde_json::Value::Array(arr) = data {
242            let unwrapped: Vec<serde_json::Value> = arr
243                .into_iter()
244                .map(|v| match v {
245                    serde_json::Value::Object(mut o) if o.len() == 1 => {
246                        let key = o.keys().next().unwrap().clone();
247                        o.remove(&key).unwrap()
248                    }
249                    other => other,
250                })
251                .collect();
252            return serde_json::from_value::<D>(serde_json::Value::Array(unwrapped))
253                .map_err(DaggerUnpackError::Deserialize)
254                .map_err(DaggerError::Unpack);
255        }
256
257        serde_json::from_value::<D>(data)
258            .map_err(DaggerUnpackError::Deserialize)
259            .map_err(DaggerError::Unpack)
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use pretty_assertions::assert_eq;
266    use serde::Serialize;
267
268    use super::query;
269
270    #[tokio::test]
271    async fn test_query() {
272        let root = query()
273            .select("core")
274            .select("image")
275            .arg("ref", "alpine")
276            .select("file")
277            .arg("path", "/etc/alpine-release");
278
279        let query = root.build().await.unwrap();
280
281        assert_eq!(
282            query,
283            r#"query{core{image(ref:"alpine"){file(path:"/etc/alpine-release")}}}"#.to_string()
284        )
285    }
286
287    #[tokio::test]
288    async fn test_query_alias() {
289        let root = query()
290            .select("core")
291            .select("image")
292            .arg("ref", "alpine")
293            .select_with_alias("foo", "file")
294            .arg("path", "/etc/alpine-release");
295
296        let query = root.build().await.unwrap();
297
298        assert_eq!(
299            query,
300            r#"query{core{image(ref:"alpine"){foo:file(path:"/etc/alpine-release")}}}"#.to_string()
301        )
302    }
303
304    #[tokio::test]
305    async fn test_arg_collision() {
306        let root = query()
307            .select("a")
308            .arg("arg", "one")
309            .select("b")
310            .arg("arg", "two");
311
312        let query = root.build().await.unwrap();
313
314        assert_eq!(query, r#"query{a(arg:"one"){b(arg:"two")}}"#.to_string())
315    }
316
317    #[tokio::test]
318    async fn test_vec_arg() {
319        let input = vec!["some-string"];
320
321        let root = query().select("a").arg("arg", input);
322        let query = root.build().await.unwrap();
323
324        assert_eq!(query, r#"query{a(arg:["some-string"])}"#.to_string())
325    }
326
327    #[tokio::test]
328    async fn test_ref_slice_arg() {
329        let input = &["some-string"];
330
331        let root = query().select("a").arg("arg", input);
332        let query = root.build().await.unwrap();
333
334        assert_eq!(query, r#"query{a(arg:["some-string"])}"#.to_string())
335    }
336
337    #[tokio::test]
338    async fn test_stringb_arg() {
339        let input = "some-string".to_string();
340
341        let root = query().select("a").arg("arg", input);
342        let query = root.build().await.unwrap();
343
344        assert_eq!(query, r#"query{a(arg:"some-string")}"#.to_string())
345    }
346
347    #[tokio::test]
348    async fn test_field_immutability() {
349        let root = query().select("test");
350
351        let a = root.select("a").build().await.unwrap();
352        assert_eq!(a, r#"query{test{a}}"#.to_string());
353
354        let b = root.select("b").build().await.unwrap();
355        assert_eq!(b, r#"query{test{b}}"#.to_string());
356    }
357
358    #[derive(Serialize)]
359    struct CustomType {
360        pub name: String,
361        pub s: Option<Box<CustomType>>,
362    }
363
364    #[tokio::test]
365    async fn test_arg_custom_type() {
366        let input = CustomType {
367            name: "some-name".to_string(),
368            s: Some(Box::new(CustomType {
369                name: "some-other-name".to_string(),
370                s: None,
371            })),
372        };
373
374        let root = query().select("a").arg("arg", input);
375        let query = root.build().await.unwrap();
376
377        assert_eq!(
378            query,
379            r#"query{a(arg:{name:"some-name",s:{name:"some-other-name",s:null}})}"#.to_string()
380        )
381    }
382
383    #[tokio::test]
384    async fn test_inline_fragment_build() {
385        // node(id: "abc") { ... on Container { imageRef } }
386        let root = query()
387            .select("node")
388            .arg("id", "abc")
389            .inline_fragment("Container")
390            .select("imageRef");
391
392        let query = root.build().await.unwrap();
393
394        assert_eq!(
395            query,
396            r#"query{node(id:"abc"){... on Container{imageRef}}}"#.to_string()
397        )
398    }
399
400    #[tokio::test]
401    async fn test_inline_fragment_nested_fields() {
402        // node(id: "abc") { ... on Container { withExec(args: ["echo"]) { stdout } } }
403        let root = query()
404            .select("node")
405            .arg("id", "abc")
406            .inline_fragment("Container")
407            .select("withExec")
408            .arg("args", vec!["echo"])
409            .select("stdout");
410
411        let query = root.build().await.unwrap();
412
413        assert_eq!(
414            query,
415            r#"query{node(id:"abc"){... on Container{withExec(args:["echo"]){stdout}}}}"#
416                .to_string()
417        )
418    }
419
420    #[tokio::test]
421    async fn test_inline_fragment_unpack() {
422        // Inline fragments don't add a nesting level in the response.
423        // The response for: node(id:"..."){... on Container{imageRef}}
424        // is: {"node": {"imageRef": "alpine"}}
425        // NOT: {"node": {"Container": {"imageRef": "alpine"}}}
426        let root = query()
427            .select("node")
428            .arg("id", "abc")
429            .inline_fragment("Container")
430            .select("imageRef");
431
432        let resp: Option<serde_json::Value> =
433            serde_json::from_str(r#"{"node": {"imageRef": "alpine:3.18"}}"#).unwrap();
434
435        let result: Option<String> = root.unpack_resp(resp).unwrap();
436        assert_eq!(result, Some("alpine:3.18".to_string()));
437    }
438
439    #[tokio::test]
440    async fn test_inline_fragment_unpack_nested() {
441        // Deeper chain: node(id:"..."){... on Container{file(path:"/x"){contents}}}
442        // Response: {"node": {"file": {"contents": "hello"}}}
443        let root = query()
444            .select("node")
445            .arg("id", "abc")
446            .inline_fragment("Container")
447            .select("file")
448            .arg("path", "/x")
449            .select("contents");
450
451        let resp: Option<serde_json::Value> =
452            serde_json::from_str(r#"{"node": {"file": {"contents": "hello"}}}"#).unwrap();
453
454        let result: Option<String> = root.unpack_resp(resp).unwrap();
455        assert_eq!(result, Some("hello".to_string()));
456    }
457
458    #[tokio::test]
459    async fn test_inline_fragment_immutability() {
460        // Branching after inline_fragment should not leak between branches
461        let base = query()
462            .select("node")
463            .arg("id", "abc")
464            .inline_fragment("Container");
465
466        let a = base.select("imageRef").build().await.unwrap();
467        assert_eq!(
468            a,
469            r#"query{node(id:"abc"){... on Container{imageRef}}}"#.to_string()
470        );
471
472        let b = base.select("stdout").build().await.unwrap();
473        assert_eq!(
474            b,
475            r#"query{node(id:"abc"){... on Container{stdout}}}"#.to_string()
476        );
477    }
478}