Skip to main content

corium_client/
pull.rs

1//! A typesafe, builder-patterned Pull specification.
2//!
3//! A [`Pull`] is an immutable value that lowers to the boundary [`Edn`] pull
4//! pattern the engine understands: attribute selections, `*`, `:db/id`,
5//! reverse refs, nested sub-patterns, bounded/unbounded recursion, and the
6//! `:as`/`:limit`/`:default` options.
7//!
8//! ```
9//! use corium_client::pull::{Pull, Attr};
10//!
11//! // [:person/name {:person/friends [:person/name]} :person/_manager]
12//! let spec = Pull::new()
13//!     .attr("person/name")
14//!     .nested("person/friends", Pull::new().attr("person/name"))
15//!     .reverse("person/manager");
16//! ```
17
18use corium_query::edn::Edn;
19
20use crate::value::IntoEdn;
21
22/// The cardinality-many result limit for a pulled attribute.
23#[derive(Clone, Copy, Debug, Eq, PartialEq)]
24enum Limit {
25    /// `:limit n`.
26    At(usize),
27    /// `:limit nil` — no bound.
28    Unlimited,
29}
30
31/// One attribute selection with optional `:as`, `:limit`, and `:default`
32/// options. Forward by default; [`Attr::reverse`] flips it to a reverse ref.
33#[derive(Clone, Debug, Eq, PartialEq)]
34pub struct Attr {
35    name: String,
36    reverse: bool,
37    as_key: Option<Edn>,
38    limit: Option<Limit>,
39    default: Option<Edn>,
40}
41
42impl Attr {
43    /// A forward attribute selection for `"ns/name"`.
44    #[must_use]
45    pub fn new(name: impl Into<String>) -> Self {
46        Self {
47            name: name.into(),
48            reverse: false,
49            as_key: None,
50            limit: None,
51            default: None,
52        }
53    }
54
55    /// Marks this as a reverse ref (`:ns/_name`).
56    #[must_use]
57    pub fn reverse(mut self) -> Self {
58        self.reverse = true;
59        self
60    }
61
62    /// Renames the result key (`:as`). Accepts a keyword or any scalar.
63    #[must_use]
64    pub fn as_key(mut self, key: impl IntoEdn) -> Self {
65        self.as_key = Some(key.into_edn());
66        self
67    }
68
69    /// Bounds a cardinality-many result to `n` values (`:limit n`).
70    #[must_use]
71    pub fn limit(mut self, n: usize) -> Self {
72        self.limit = Some(Limit::At(n));
73        self
74    }
75
76    /// Removes the result bound (`:limit nil`).
77    #[must_use]
78    pub fn unlimited(mut self) -> Self {
79        self.limit = Some(Limit::Unlimited);
80        self
81    }
82
83    /// Supplies a value when the attribute is absent (`:default`).
84    #[must_use]
85    pub fn default(mut self, value: impl IntoEdn) -> Self {
86        self.default = Some(value.into_edn());
87        self
88    }
89
90    /// The attribute keyword, applying the reverse `_` prefix if set.
91    fn keyword(&self) -> Edn {
92        Edn::keyword(&reverse_name(&self.name, self.reverse))
93    }
94
95    fn has_options(&self) -> bool {
96        self.as_key.is_some() || self.limit.is_some() || self.default.is_some()
97    }
98
99    fn to_edn(&self) -> Edn {
100        if !self.has_options() {
101            return self.keyword();
102        }
103        let mut items = vec![self.keyword()];
104        if let Some(key) = &self.as_key {
105            items.push(Edn::keyword("as"));
106            items.push(key.clone());
107        }
108        if let Some(limit) = self.limit {
109            items.push(Edn::keyword("limit"));
110            items.push(match limit {
111                Limit::At(n) => Edn::Long(i64::try_from(n).unwrap_or(i64::MAX)),
112                Limit::Unlimited => Edn::Nil,
113            });
114        }
115        if let Some(default) = &self.default {
116            items.push(Edn::keyword("default"));
117            items.push(default.clone());
118        }
119        Edn::Vector(items)
120    }
121}
122
123impl From<&str> for Attr {
124    fn from(value: &str) -> Self {
125        Self::new(value)
126    }
127}
128
129impl From<String> for Attr {
130    fn from(value: String) -> Self {
131        Self::new(value)
132    }
133}
134
135/// Inserts the reverse-ref `_` prefix on the attribute name component.
136fn reverse_name(name: &str, reverse: bool) -> String {
137    if !reverse {
138        return name.to_owned();
139    }
140    match name.rsplit_once('/') {
141        Some((namespace, local)) => format!("{namespace}/_{local}"),
142        None => format!("_{name}"),
143    }
144}
145
146#[derive(Clone, Debug, Eq, PartialEq)]
147enum Item {
148    Wildcard,
149    DbId,
150    Attr(Attr),
151    Nested(Attr, Pull),
152    Recurse(Attr, Option<usize>),
153}
154
155impl Item {
156    fn to_edn(&self) -> Edn {
157        match self {
158            Self::Wildcard => Edn::symbol("*"),
159            Self::DbId => Edn::keyword("db/id"),
160            Self::Attr(attr) => attr.to_edn(),
161            Self::Nested(attr, sub) => Edn::Map(vec![(attr.to_edn(), sub.to_edn())]),
162            Self::Recurse(attr, depth) => {
163                let value = depth.map_or_else(
164                    || Edn::symbol("..."),
165                    |depth| Edn::Long(i64::try_from(depth).unwrap_or(i64::MAX)),
166                );
167                Edn::Map(vec![(attr.to_edn(), value)])
168            }
169        }
170    }
171}
172
173/// An immutable Pull specification. Build it up with the fluent methods and
174/// render with [`Pull::to_edn`].
175#[derive(Clone, Debug, Default, Eq, PartialEq)]
176pub struct Pull {
177    items: Vec<Item>,
178}
179
180impl Pull {
181    /// An empty pull pattern.
182    #[must_use]
183    pub fn new() -> Self {
184        Self::default()
185    }
186
187    /// Selects everything (`*`).
188    #[must_use]
189    pub fn wildcard(mut self) -> Self {
190        self.items.push(Item::Wildcard);
191        self
192    }
193
194    /// Selects the entity id (`:db/id`).
195    #[must_use]
196    pub fn db_id(mut self) -> Self {
197        self.items.push(Item::DbId);
198        self
199    }
200
201    /// Selects a forward attribute by `"ns/name"`.
202    #[must_use]
203    pub fn attr(mut self, attr: impl Into<Attr>) -> Self {
204        self.items.push(Item::Attr(attr.into()));
205        self
206    }
207
208    /// Selects a reverse ref for `"ns/name"` (`:ns/_name`).
209    #[must_use]
210    pub fn reverse(mut self, name: impl Into<String>) -> Self {
211        self.items.push(Item::Attr(Attr::new(name).reverse()));
212        self
213    }
214
215    /// Selects a ref attribute with a nested sub-pattern
216    /// (`{:ns/name sub}`).
217    #[must_use]
218    pub fn nested(mut self, attr: impl Into<Attr>, sub: Pull) -> Self {
219        self.items.push(Item::Nested(attr.into(), sub));
220        self
221    }
222
223    /// Recurses into a ref attribute to a bounded depth (`{:ns/name n}`).
224    #[must_use]
225    pub fn recurse(mut self, attr: impl Into<Attr>, depth: usize) -> Self {
226        self.items.push(Item::Recurse(attr.into(), Some(depth)));
227        self
228    }
229
230    /// Recurses into a ref attribute without bound (`{:ns/name ...}`).
231    #[must_use]
232    pub fn recurse_unbounded(mut self, attr: impl Into<Attr>) -> Self {
233        self.items.push(Item::Recurse(attr.into(), None));
234        self
235    }
236
237    /// Appends an explicit [`Attr`] selection carrying options.
238    #[must_use]
239    pub fn spec(mut self, attr: Attr) -> Self {
240        self.items.push(Item::Attr(attr));
241        self
242    }
243
244    /// Renders the pattern to its boundary [`Edn`] vector form.
245    #[must_use]
246    pub fn to_edn(&self) -> Edn {
247        Edn::Vector(self.items.iter().map(Item::to_edn).collect())
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn renders_expected_forms() {
257        let spec = Pull::new()
258            .attr("person/name")
259            .db_id()
260            .wildcard()
261            .reverse("person/manager")
262            .nested("person/friends", Pull::new().attr("person/name"))
263            .recurse("person/reports", 3)
264            .recurse_unbounded("person/mentor")
265            .spec(
266                Attr::new("person/email")
267                    .as_key(Edn::keyword("email"))
268                    .default("n/a"),
269            );
270        assert_eq!(
271            spec.to_edn().to_string(),
272            "[:person/name :db/id * :person/_manager {:person/friends [:person/name]} \
273             {:person/reports 3} {:person/mentor ...} \
274             [:person/email :as :email :default \"n/a\"]]"
275        );
276    }
277
278    #[test]
279    fn reverse_name_handles_bare_and_namespaced() {
280        assert_eq!(reverse_name("person/manager", true), "person/_manager");
281        assert_eq!(reverse_name("manager", true), "_manager");
282        assert_eq!(reverse_name("person/manager", false), "person/manager");
283    }
284}