Skip to main content

pylon_client/
query_arg.rs

1//
2// This source file is part of the Pylon open source project.
3//
4// Copyright (c) 2026 Jaldis B.V.
5//
6// Licensed under the MIT OR Apache-2.0 license (the "License");
7// you may not use this file except in compliance with the License.
8// You may obtain a copy of the License at
9//
10//     https://opensource.org/licenses/MIT
11//     https://www.apache.org/licenses/LICENSE-2.0
12//
13// Unless required by applicable law or agreed to in writing, software
14// distributed under the License is distributed on an "AS IS" BASIS,
15// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16// See the License for the specific language governing permissions and
17// limitations under the License.
18//
19
20//! Query arguments: [`QueryArg`] for a single value, [`QueryArgs`] for the
21//! collection a query method takes, and [`ValueOpt`] + [`named_args!`] for
22//! building a named collection inline.
23//!
24//! Mirrors `gel-protocol::query_arg` closely enough that a call site moving
25//! off Gel's Rust client keeps its argument expressions: `&()` for no
26//! arguments, `&(a, b)` for positional `$0`/`$1`, and
27//! `&named_args! { "name" => value }` for `$name`.
28//!
29//! Positional arguments work because PyQL compiles `$0` to the parameter
30//! *name* `"0"` — so a tuple is just a named collection whose names are its
31//! indices.
32
33use std::collections::HashMap;
34
35use pylon_value::DecodedValue;
36
37/// A single query argument. Implemented for the scalars Pylon can bind, for
38/// `Option`/the common `Vec` element types, and for [`DecodedValue`] itself
39/// as the escape hatch for anything not covered here.
40pub trait QueryArg {
41    fn to_decoded(&self) -> DecodedValue;
42}
43
44impl<T: QueryArg + ?Sized> QueryArg for &T {
45    fn to_decoded(&self) -> DecodedValue {
46        (**self).to_decoded()
47    }
48}
49
50impl QueryArg for DecodedValue {
51    fn to_decoded(&self) -> DecodedValue {
52        self.clone()
53    }
54}
55
56/// An absent optional argument (`<optional str>$x`) binds as NULL.
57impl<T: QueryArg> QueryArg for Option<T> {
58    fn to_decoded(&self) -> DecodedValue {
59        match self {
60            Some(value) => value.to_decoded(),
61            None => DecodedValue::Null,
62        }
63    }
64}
65
66macro_rules! query_arg_via_into {
67    ($($target:ty),* $(,)?) => {
68        $(
69            impl QueryArg for $target {
70                fn to_decoded(&self) -> DecodedValue {
71                    DecodedValue::from(self.clone())
72                }
73            }
74        )*
75    };
76}
77
78query_arg_via_into!(bool, i16, i32, i64, f32, f64, String, uuid::Uuid);
79
80impl QueryArg for str {
81    fn to_decoded(&self) -> DecodedValue {
82        DecodedValue::Str(self.to_string())
83    }
84}
85
86/// `Vec<u8>` is bytes, not an array of integers — matching
87/// `DecodedValue::from(Vec<u8>)` and Gel's own precedence. That is also why
88/// the array impls below are enumerated per element type instead of a
89/// blanket `impl<T: QueryArg> QueryArg for Vec<T>`: the blanket form would
90/// overlap this one, and Rust won't accept the pair on the grounds that
91/// `u8` merely happens not to implement `QueryArg` today.
92impl QueryArg for Vec<u8> {
93    fn to_decoded(&self) -> DecodedValue {
94        DecodedValue::Bytes(self.clone())
95    }
96}
97
98macro_rules! query_arg_array {
99    ($($element:ty),* $(,)?) => {
100        $(
101            impl QueryArg for Vec<$element> {
102                fn to_decoded(&self) -> DecodedValue {
103                    DecodedValue::Array(self.iter().map(QueryArg::to_decoded).collect())
104                }
105            }
106
107            impl QueryArg for [$element] {
108                fn to_decoded(&self) -> DecodedValue {
109                    DecodedValue::Array(self.iter().map(QueryArg::to_decoded).collect())
110                }
111            }
112        )*
113    };
114}
115
116query_arg_array!(bool, i16, i32, i64, f32, f64, String, uuid::Uuid);
117
118/// `std::datetime`. Pylon stores a timestamp as microseconds from the
119/// PostgreSQL epoch (2000-01-01), not the Unix epoch.
120impl QueryArg for chrono::DateTime<chrono::Utc> {
121    fn to_decoded(&self) -> DecodedValue {
122        DecodedValue::Timestamptz(pg_micros(self.naive_utc()))
123    }
124}
125
126/// `cal::local_datetime`.
127impl QueryArg for chrono::NaiveDateTime {
128    fn to_decoded(&self) -> DecodedValue {
129        DecodedValue::Timestamp(pg_micros(*self))
130    }
131}
132
133/// `cal::local_date`.
134impl QueryArg for chrono::NaiveDate {
135    fn to_decoded(&self) -> DecodedValue {
136        DecodedValue::Date(self.signed_duration_since(pg_epoch_date()).num_days() as i32)
137    }
138}
139
140/// `cal::local_time`.
141impl QueryArg for chrono::NaiveTime {
142    fn to_decoded(&self) -> DecodedValue {
143        let midnight = chrono::NaiveTime::from_hms_opt(0, 0, 0).expect("00:00:00 is a valid time");
144        DecodedValue::Time(
145            self.signed_duration_since(midnight)
146                .num_microseconds()
147                .unwrap_or_default(),
148        )
149    }
150}
151
152/// `std::duration`. Pylon stores an interval as its three wire components;
153/// months is always 0 here, since a `std::time::Duration` is a fixed span and
154/// a calendar month is not (`cal::relative_duration` is the type that carries
155/// one). Days stay 0 too — Postgres treats an interval's days as
156/// calendar-relative across a DST boundary, so the whole span goes in
157/// microseconds.
158impl QueryArg for std::time::Duration {
159    fn to_decoded(&self) -> DecodedValue {
160        DecodedValue::Interval {
161            months: 0,
162            days: 0,
163            microseconds: i64::try_from(self.as_micros()).unwrap_or(i64::MAX),
164        }
165    }
166}
167
168/// A `json` argument. Bound as JSON text rather than as a
169/// `DecodedValue::Object`, because that is the form `pylon-pgcon` accepts
170/// for a `jsonb` parameter regardless of whether the document's root is an
171/// object, and it matches what a Gel call site was already doing by hand
172/// (`Json::new_unchecked(serde_json::to_string(&value)?)`).
173impl QueryArg for serde_json::Value {
174    fn to_decoded(&self) -> DecodedValue {
175        DecodedValue::Str(self.to_string())
176    }
177}
178
179fn pg_epoch_date() -> chrono::NaiveDate {
180    chrono::NaiveDate::from_ymd_opt(2000, 1, 1).expect("2000-01-01 is a valid date")
181}
182
183/// Microseconds from the PostgreSQL epoch. Saturates rather than wrapping
184/// for a datetime far enough out to overflow — a value that extreme is a
185/// caller bug, and a silently wrapped timestamp would be worse than a
186/// clamped one.
187fn pg_micros(value: chrono::NaiveDateTime) -> i64 {
188    let epoch = pg_epoch_date().and_hms_opt(0, 0, 0).expect("00:00:00 is a valid time");
189    value
190        .signed_duration_since(epoch)
191        .num_microseconds()
192        .unwrap_or(i64::MAX)
193}
194
195/// An argument value inside [`named_args!`], constructible from anything
196/// that is a [`QueryArg`]. The Pylon counterpart of
197/// `gel_protocol::value_opt::ValueOpt`, and the reason `named_args!` can mix
198/// argument types in one collection.
199#[derive(Debug, Clone, PartialEq)]
200pub struct ValueOpt(DecodedValue);
201
202impl<T: QueryArg> From<T> for ValueOpt {
203    fn from(value: T) -> Self {
204        ValueOpt(value.to_decoded())
205    }
206}
207
208impl ValueOpt {
209    pub fn into_decoded(self) -> DecodedValue {
210        self.0
211    }
212}
213
214impl From<ValueOpt> for DecodedValue {
215    fn from(value: ValueOpt) -> Self {
216        value.0
217    }
218}
219
220/// The collection of arguments a query method takes. `&()` when there are
221/// none, a tuple for positional `$0`/`$1`/…, a `HashMap` or a slice of
222/// `(name, value)` pairs for named ones.
223pub trait QueryArgs {
224    /// Borrows the names out of `self` (which the query method holds by
225    /// reference for the whole call) so only the values are cloned.
226    fn to_params(&self) -> Vec<(&str, DecodedValue)>;
227}
228
229impl QueryArgs for () {
230    fn to_params(&self) -> Vec<(&str, DecodedValue)> {
231        Vec::new()
232    }
233}
234
235impl QueryArgs for [(&str, DecodedValue)] {
236    fn to_params(&self) -> Vec<(&str, DecodedValue)> {
237        self.iter().map(|(name, value)| (*name, value.clone())).collect()
238    }
239}
240
241impl<const N: usize> QueryArgs for [(&str, DecodedValue); N] {
242    fn to_params(&self) -> Vec<(&str, DecodedValue)> {
243        self.as_slice().to_params()
244    }
245}
246
247impl QueryArgs for Vec<(&str, DecodedValue)> {
248    fn to_params(&self) -> Vec<(&str, DecodedValue)> {
249        self.as_slice().to_params()
250    }
251}
252
253/// Keyed by [`ValueOpt`] rather than by any `V: QueryArg`, because
254/// `ValueOpt` deliberately does *not* implement `QueryArg`: it is built from
255/// one via a blanket `From`, and making it a `QueryArg` too would collide
256/// with the standard library's reflexive `impl<T> From<T> for T`. Gel's
257/// client draws the same line for the same reason.
258impl QueryArgs for HashMap<&str, ValueOpt> {
259    fn to_params(&self) -> Vec<(&str, DecodedValue)> {
260        self.iter().map(|(name, value)| (*name, value.0.clone())).collect()
261    }
262}
263
264impl QueryArgs for HashMap<String, ValueOpt> {
265    fn to_params(&self) -> Vec<(&str, DecodedValue)> {
266        self.iter()
267            .map(|(name, value)| (name.as_str(), value.0.clone()))
268            .collect()
269    }
270}
271
272/// `$0`, `$1`, … compile to these parameter names, so a positional tuple
273/// needs no allocation to name its own elements.
274const POSITIONAL_NAMES: [&str; 12] = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"];
275
276macro_rules! impl_query_args_for_tuple {
277    ($($index:tt : $param:ident),+) => {
278        impl<$($param: QueryArg),+> QueryArgs for ($($param,)+) {
279            fn to_params(&self) -> Vec<(&str, DecodedValue)> {
280                vec![$((POSITIONAL_NAMES[$index], self.$index.to_decoded())),+]
281            }
282        }
283    };
284}
285
286impl_query_args_for_tuple!(0: A);
287impl_query_args_for_tuple!(0: A, 1: B);
288impl_query_args_for_tuple!(0: A, 1: B, 2: C);
289impl_query_args_for_tuple!(0: A, 1: B, 2: C, 3: D);
290impl_query_args_for_tuple!(0: A, 1: B, 2: C, 3: D, 4: E);
291impl_query_args_for_tuple!(0: A, 1: B, 2: C, 3: D, 4: E, 5: F);
292impl_query_args_for_tuple!(0: A, 1: B, 2: C, 3: D, 4: E, 5: F, 6: G);
293impl_query_args_for_tuple!(0: A, 1: B, 2: C, 3: D, 4: E, 5: F, 6: G, 7: H);
294impl_query_args_for_tuple!(0: A, 1: B, 2: C, 3: D, 4: E, 5: F, 6: G, 7: H, 8: I);
295impl_query_args_for_tuple!(0: A, 1: B, 2: C, 3: D, 4: E, 5: F, 6: G, 7: H, 8: I, 9: J);
296impl_query_args_for_tuple!(0: A, 1: B, 2: C, 3: D, 4: E, 5: F, 6: G, 7: H, 8: I, 9: J, 10: K);
297impl_query_args_for_tuple!(0: A, 1: B, 2: C, 3: D, 4: E, 5: F, 6: G, 7: H, 8: I, 9: J, 10: K, 11: L);
298
299/// Builds a named argument collection for a query with `$name` parameters:
300///
301/// ```no_run
302/// # use pylon_client::named_args;
303/// # fn go(client: &pylon_client::Client, id: uuid::Uuid) {
304/// let args = named_args! { "id" => id, "label" => "urgent".to_string() };
305/// # let _ = (client, args);
306/// # }
307/// ```
308///
309/// Mirrors `gel_protocol::named_args!` — same syntax, same
310/// `HashMap<&str, ValueOpt>` result, with a trailing comma allowed.
311#[macro_export]
312macro_rules! named_args {
313    ($($key:expr => $value:expr,)+) => { $crate::named_args!($($key => $value),+) };
314    ($($key:expr => $value:expr),*) => {{
315        let mut args = ::std::collections::HashMap::<&str, $crate::ValueOpt>::new();
316        $(
317            args.insert($key, $crate::ValueOpt::from($value));
318        )*
319        args
320    }};
321}