Skip to main content

canton_pqs/
query.rs

1//! Typed predicates, compiled to parameterized SQL.
2//!
3//! Nothing a caller supplies is ever spliced into the statement — not a value,
4//! and not a JSON field path either. A path is sent as a `text[]` parameter and
5//! applied with `#>`, so a field name taken from user input is data rather than
6//! syntax. The only text this module concatenates is its own.
7//!
8//! What varies with the caller's *type*, not their input, is the qname: it is
9//! `PACKAGE_NAME:MODULE_NAME:ENTITY_NAME`, read off the generated
10//! [`Contract`](canton_daml::Contract), so a query names its template without
11//! anybody typing a string.
12
13use std::fmt::Write as _;
14
15/// How a value is compared.
16#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17#[non_exhaustive]
18pub enum Op {
19    /// JSON equality — exact, and correct for every JSON type.
20    Eq,
21    /// JSON inequality.
22    NotEq,
23    /// Ordered comparison. Numbers compare numerically and strings compare as
24    /// text; see [`Predicate::compare`].
25    Lt,
26    /// Ordered comparison.
27    Le,
28    /// Ordered comparison.
29    Gt,
30    /// Ordered comparison.
31    Ge,
32    /// JSONB containment (`@>`). The one that a GIN index on `payload` can
33    /// serve, so it is worth reaching for on a large store.
34    Contains,
35}
36
37impl Op {
38    fn sql(self) -> &'static str {
39        match self {
40            Self::Eq => "=",
41            Self::NotEq => "IS DISTINCT FROM",
42            Self::Lt => "<",
43            Self::Le => "<=",
44            Self::Gt => ">",
45            Self::Ge => ">=",
46            Self::Contains => "@>",
47        }
48    }
49
50    fn is_ordered(self) -> bool {
51        matches!(self, Self::Lt | Self::Le | Self::Gt | Self::Ge)
52    }
53}
54
55/// One condition on a contract's payload or on its row.
56#[derive(Clone, Debug)]
57pub struct Predicate {
58    target: Target,
59    op: Op,
60    value: serde_json::Value,
61}
62
63#[derive(Clone, Debug)]
64enum Target {
65    /// A path into the JSONB payload.
66    Payload(Vec<String>),
67    /// The whole payload, for containment.
68    WholePayload,
69    /// A `text[]` column tested for membership.
70    PartyList(&'static str),
71    /// A plain text column.
72    Text(&'static str),
73}
74
75impl Predicate {
76    /// A field of the payload equals `value`.
77    ///
78    /// The comparison is JSON equality, so it is exact for every type: a
79    /// `Numeric` that LF-JSON carries as `"10.0"` matches `"10.0"` and not
80    /// `10.0`, which is the distinction the encoding makes on purpose.
81    pub fn eq(path: impl IntoPath, value: impl Into<serde_json::Value>) -> Self {
82        Self {
83            target: Target::Payload(path.into_path()),
84            op: Op::Eq,
85            value: as_stored(value.into()),
86        }
87    }
88
89    /// A field of the payload does not equal `value` — **including when the
90    /// field is absent**.
91    ///
92    /// Rendered as `IS DISTINCT FROM` rather than `<>`, and the difference is
93    /// not pedantic here. `<>` against a missing path yields SQL `NULL`, so the
94    /// row is excluded — and missing paths are the *normal* case for this
95    /// crate, because [`qname`](Query::qname) deliberately matches on the
96    /// package **name** so a query survives a Smart Contract Upgrade. A result
97    /// set therefore mixes payloads of different versions with different field
98    /// sets. Under `<>`, adding a field in v2 would silently drop every v1
99    /// contract from a `not_eq` on it, with no error and no warning.
100    pub fn not_eq(path: impl IntoPath, value: impl Into<serde_json::Value>) -> Self {
101        Self {
102            target: Target::Payload(path.into_path()),
103            op: Op::NotEq,
104            value: as_stored(value.into()),
105        }
106    }
107
108    /// An ordered comparison on a field.
109    ///
110    /// A contract whose payload lacks the field is **not** returned: there is
111    /// no answer to "is the missing value greater than 10". That is the right
112    /// verdict, unlike for [`not_eq`](Self::not_eq), but it is worth knowing
113    /// when a query spans two versions of a template — use
114    /// [`contains`](Self::contains) or a `not_eq` if the absent case should
115    /// count.
116    ///
117    /// A JSON number compares numerically and a JSON string compares as text.
118    /// That distinction is not cosmetic: LF-JSON carries `Int64` and `Numeric`
119    /// as *strings*, so comparing an amount lexically would order `"9"` after
120    /// `"10"`. Pass a number to compare numerically — the cast is applied for
121    /// you — and a string only where the field really is text.
122    pub fn compare(path: impl IntoPath, op: Op, value: impl Into<serde_json::Value>) -> Self {
123        Self {
124            target: Target::Payload(path.into_path()),
125            op,
126            value: value.into(),
127        }
128    }
129
130    /// The payload contains this JSON object (`@>`).
131    ///
132    /// The predicate a GIN index on `payload` can serve.
133    pub fn contains(value: impl Into<serde_json::Value>) -> Self {
134        Self {
135            target: Target::WholePayload,
136            op: Op::Contains,
137            value: value.into(),
138        }
139    }
140
141    /// The party is among the contract's signatories.
142    pub fn signatory(party: impl Into<String>) -> Self {
143        Self::party_list("signatories", party)
144    }
145
146    /// The party is among the contract's observers.
147    pub fn observer(party: impl Into<String>) -> Self {
148        Self::party_list("observers", party)
149    }
150
151    /// The party is among the parties that witnessed it.
152    pub fn witness(party: impl Into<String>) -> Self {
153        Self::party_list("witnesses", party)
154    }
155
156    fn party_list(column: &'static str, party: impl Into<String>) -> Self {
157        Self {
158            target: Target::PartyList(column),
159            op: Op::Eq,
160            value: serde_json::Value::String(party.into()),
161        }
162    }
163
164    /// The contract id is exactly this.
165    pub fn contract_id(id: impl Into<String>) -> Self {
166        Self {
167            target: Target::Text("contract_id"),
168            op: Op::Eq,
169            value: serde_json::Value::String(id.into()),
170        }
171    }
172}
173
174/// Anything that names a path into the payload.
175///
176/// A single field is the common case; a slice reaches a nested one.
177pub trait IntoPath {
178    /// The path, one segment per level.
179    fn into_path(self) -> Vec<String>;
180}
181
182impl IntoPath for &str {
183    fn into_path(self) -> Vec<String> {
184        vec![self.to_string()]
185    }
186}
187
188impl IntoPath for String {
189    fn into_path(self) -> Vec<String> {
190        vec![self]
191    }
192}
193
194impl<const N: usize> IntoPath for [&str; N] {
195    fn into_path(self) -> Vec<String> {
196        self.iter().map(ToString::to_string).collect()
197    }
198}
199
200impl IntoPath for &[&str] {
201    fn into_path(self) -> Vec<String> {
202        self.iter().map(ToString::to_string).collect()
203    }
204}
205
206impl IntoPath for Vec<String> {
207    fn into_path(self) -> Vec<String> {
208        self
209    }
210}
211
212/// A parameter, in the form `tokio-postgres` will bind it.
213#[derive(Clone, Debug, PartialEq)]
214#[non_exhaustive]
215pub enum Param {
216    /// A JSON value, bound as `jsonb`.
217    Json(serde_json::Value),
218    /// Text, bound as `text`.
219    Text(String),
220    /// A path, bound as `text[]`.
221    Path(Vec<String>),
222    /// A number, bound as `numeric` via its text form — Postgres parses it
223    /// exactly, where a float would not.
224    Numeric(String),
225    /// A ledger offset.
226    Offset(i64),
227}
228
229/// A compiled statement: SQL with `$n` placeholders, and the values for them.
230#[derive(Clone, Debug, PartialEq)]
231pub struct Sql {
232    /// The statement.
233    pub text: String,
234    /// The parameters, in `$1..$n` order.
235    pub params: Vec<Param>,
236}
237
238impl Sql {
239    /// A statement this crate did not compile.
240    ///
241    /// **This is the one place the crate's safety property is a convention
242    /// rather than a type.** Everything [`Query::compile`] produces binds every
243    /// caller value as a parameter — including the JSON field path, which goes
244    /// as `text[]` and is applied with `#>` — so no input can change which
245    /// statement runs. `text` here is executed as given.
246    ///
247    /// It exists because PQS has functions this crate does not model
248    /// (`lookup_contract`, `exercises`, `latest_offset` are built with it
249    /// internally) and because a caller reaching for one should not have to
250    /// fork the crate. Named `raw` so that a reader of the call site knows
251    /// which half they are in: `Query::compile()` is checked, `Sql::raw` is
252    /// theirs to get right.
253    ///
254    /// Parameters are still bound — `params` goes to Postgres as parameters,
255    /// never interpolated — so the hazard is the *statement*, not the values.
256    #[must_use]
257    pub fn raw(text: impl Into<String>, params: Vec<Param>) -> Self {
258        Self {
259            text: text.into(),
260            params,
261        }
262    }
263}
264
265/// Which PQS function a query reads from.
266#[derive(Clone, Copy, Debug, Eq, PartialEq)]
267#[non_exhaustive]
268pub enum Source {
269    /// Contracts active at an offset — the ACS.
270    Active,
271    /// Contracts created in a range.
272    Creates,
273    /// Contracts archived in a range.
274    Archives,
275}
276
277impl Source {
278    fn function(self) -> &'static str {
279        match self {
280            Self::Active => "active",
281            Self::Creates => "creates",
282            Self::Archives => "archives",
283        }
284    }
285}
286
287/// A query over one template, compiled to parameterized SQL.
288///
289/// The template comes from the type, so the qname is never typed out:
290///
291/// ```rust,ignore
292/// let sql = Query::<AppInstallRequest>::active()
293///     .filter(Predicate::eq("user", party))
294///     .limit(100)
295///     .compile();
296/// ```
297#[derive(Clone, Debug)]
298pub struct Query<T> {
299    source: Source,
300    predicates: Vec<Predicate>,
301    limit: Option<i64>,
302    offset_bounds: Option<(Option<i64>, Option<i64>)>,
303    at_offset: Option<i64>,
304    marker: std::marker::PhantomData<fn() -> T>,
305}
306
307impl<T: canton_daml::Contract> Query<T> {
308    /// Contracts of this template that are active now.
309    #[must_use]
310    pub fn active() -> Self {
311        Self::from_source(Source::Active, None, None)
312    }
313
314    /// Contracts of this template that were active **as of** an offset.
315    ///
316    /// What makes a paged or repeated read consistent: the ACS at a point,
317    /// rather than a target that moves between calls.
318    #[must_use]
319    pub fn active_at(offset: i64) -> Self {
320        Self::from_source(Source::Active, Some(offset), None)
321    }
322
323    /// Contracts of this template created between two offsets.
324    ///
325    /// `None` on either side keeps the store's own default — its oldest
326    /// retained offset, and its latest.
327    #[must_use]
328    pub fn creates(from: Option<i64>, to: Option<i64>) -> Self {
329        Self::from_source(Source::Creates, None, Some((from, to)))
330    }
331
332    /// Contracts of this template archived between two offsets.
333    #[must_use]
334    pub fn archives(from: Option<i64>, to: Option<i64>) -> Self {
335        Self::from_source(Source::Archives, None, Some((from, to)))
336    }
337
338    fn from_source(
339        source: Source,
340        at_offset: Option<i64>,
341        offset_bounds: Option<(Option<i64>, Option<i64>)>,
342    ) -> Self {
343        Self {
344            source,
345            predicates: Vec::new(),
346            limit: None,
347            offset_bounds,
348            at_offset,
349            marker: std::marker::PhantomData,
350        }
351    }
352
353    /// The qname PQS knows this template by: `<package>:<Module>:<Entity>`.
354    ///
355    /// The package **name**, not its id — which is what makes a query survive
356    /// a Smart Contract Upgrade instead of pinning one build.
357    #[must_use]
358    pub fn qname() -> String {
359        format!("{}:{}:{}", T::PACKAGE_NAME, T::MODULE_NAME, T::ENTITY_NAME)
360    }
361
362    /// Add a condition. Conditions are combined with `AND`.
363    #[must_use]
364    pub fn filter(mut self, predicate: Predicate) -> Self {
365        self.predicates.push(predicate);
366        self
367    }
368
369    /// Cap the number of rows.
370    #[must_use]
371    pub fn limit(mut self, rows: i64) -> Self {
372        self.limit = Some(rows);
373        self
374    }
375
376    /// Compile to SQL and its parameters.
377    ///
378    /// Every caller-supplied value is a parameter — including JSON paths, which
379    /// are bound as `text[]`. The statement text depends only on the *shape* of
380    /// the query, never on its data.
381    #[must_use]
382    pub fn compile(&self) -> Sql {
383        let mut params = vec![Param::Text(Self::qname())];
384        let mut text = format!("SELECT * FROM {}($1", self.source.function());
385
386        match (self.source, self.at_offset, self.offset_bounds) {
387            (Source::Active, Some(offset), _) => {
388                params.push(Param::Offset(offset));
389                let _ = write!(text, ", ${}", params.len());
390            }
391            (Source::Creates | Source::Archives, _, Some((from, to))) => {
392                // The two bounds are positional, so a `to` without a `from`
393                // still has to pass something for the first: the function's own
394                // default is spelled out rather than skipped.
395                let from_sql = from.map_or_else(
396                    || default_from(self.source).to_string(),
397                    |value| {
398                        params.push(Param::Offset(value));
399                        format!("${}", params.len())
400                    },
401                );
402                let to_sql = to.map_or_else(
403                    || "latest_offset()".to_string(),
404                    |value| {
405                        params.push(Param::Offset(value));
406                        format!("${}", params.len())
407                    },
408                );
409                let _ = write!(text, ", {from_sql}, {to_sql}");
410            }
411            _ => {}
412        }
413        text.push(')');
414
415        if !self.predicates.is_empty() {
416            text.push_str(" WHERE ");
417            let mut first = true;
418            for predicate in &self.predicates {
419                if !first {
420                    text.push_str(" AND ");
421                }
422                first = false;
423                render(predicate, &mut text, &mut params);
424            }
425        }
426
427        if let Some(rows) = self.limit {
428            // A limit without an order is a random sample: two reads at the
429            // same offset would return overlapping subsets. Ledger order, then
430            // contract id, is total, so pages taken this way are disjoint.
431            params.push(Param::Offset(rows));
432            let _ = write!(
433                text,
434                " ORDER BY created_at_offset, contract_id LIMIT ${}",
435                params.len()
436            );
437        }
438
439        Sql { text, params }
440    }
441}
442
443/// The offset a range source starts from when the caller does not say.
444fn default_from(source: Source) -> &'static str {
445    match source {
446        // `archives` starts at the pruning boundary where one exists, because
447        // rows before it are gone; `creates` has no such boundary.
448        Source::Archives => "COALESCE(pruned_offset(), oldest_offset())",
449        _ => "oldest_offset()",
450    }
451}
452
453/// LF-JSON carries every number as a **string** — `Int64` and `Numeric` alike
454/// — and that is what PQS stores. A caller who writes `Predicate::eq("count",
455/// 5)` means the row whose payload holds `"5"`, so a JSON number is bound in
456/// its string form; comparing a `jsonb` number against a `jsonb` string would
457/// match nothing, silently. `Numeric` equality is still better asked with
458/// [`compare`](Predicate::compare) and [`Op::Eq`], which casts both sides and
459/// so treats `1.0` and `1.0000000000` as the same amount.
460fn as_stored(value: serde_json::Value) -> serde_json::Value {
461    match value {
462        serde_json::Value::Number(n) => serde_json::Value::String(n.to_string()),
463        other => other,
464    }
465}
466
467fn render(predicate: &Predicate, text: &mut String, params: &mut Vec<Param>) {
468    match &predicate.target {
469        Target::Payload(path) => {
470            params.push(Param::Path(path.clone()));
471            let path_param = params.len();
472            if predicate.op.is_ordered() {
473                // `#>>` extracts text; the cast decides how it orders. LF-JSON
474                // carries numbers as strings, so comparing an amount without
475                // this would sort "9" after "10".
476                match &predicate.value {
477                    serde_json::Value::Number(n) => {
478                        params.push(Param::Numeric(n.to_string()));
479                        // `$n::text::numeric`, not `$n::numeric`: a bare cast
480                        // makes Postgres infer the parameter as `numeric`, and
481                        // the driver has no Rust type that binds to it. Bound
482                        // as text and parsed by Postgres, a decimal keeps every
483                        // digit — which a float would not.
484                        let _ = write!(
485                            text,
486                            "(payload #>> ${path_param})::numeric {} ${}::text::numeric",
487                            predicate.op.sql(),
488                            params.len()
489                        );
490                    }
491                    other => {
492                        params.push(Param::Text(json_text(other)));
493                        let _ = write!(
494                            text,
495                            "payload #>> ${path_param} {} ${}",
496                            predicate.op.sql(),
497                            params.len()
498                        );
499                    }
500                }
501            } else {
502                params.push(Param::Json(predicate.value.clone()));
503                let _ = write!(
504                    text,
505                    "payload #> ${path_param} {} ${}",
506                    predicate.op.sql(),
507                    params.len()
508                );
509            }
510        }
511        Target::WholePayload => {
512            params.push(Param::Json(predicate.value.clone()));
513            let _ = write!(text, "payload {} ${}", predicate.op.sql(), params.len());
514        }
515        Target::PartyList(column) => {
516            params.push(Param::Text(json_text(&predicate.value)));
517            let _ = write!(text, "${} = ANY({column})", params.len());
518        }
519        Target::Text(column) => {
520            params.push(Param::Text(json_text(&predicate.value)));
521            let _ = write!(text, "{column} {} ${}", predicate.op.sql(), params.len());
522        }
523    }
524}
525
526/// A JSON value as the text Postgres should compare against — a string without
527/// its quotes, anything else as it is written.
528fn json_text(value: &serde_json::Value) -> String {
529    match value {
530        serde_json::Value::String(s) => s.clone(),
531        other => other.to_string(),
532    }
533}
534
535#[cfg(test)]
536#[allow(clippy::unwrap_used, clippy::expect_used)]
537mod tests {
538    use super::*;
539    use canton_quickstart_licensing::quickstart_licensing::Licensing_AppInstall::AppInstallRequest;
540
541    /// The qname is derived from the type, so nobody types it — and it uses the
542    /// package *name*, which is what survives an upgrade.
543    #[test]
544    fn the_template_names_itself() {
545        assert_eq!(
546            Query::<AppInstallRequest>::qname(),
547            "quickstart-licensing:Licensing.AppInstall:AppInstallRequest"
548        );
549    }
550
551    /// The bare query reads the ACS for one template, with the qname as a
552    /// parameter rather than spliced into the text.
553    #[test]
554    fn a_bare_query_is_one_function_call_with_one_parameter() {
555        let sql = Query::<AppInstallRequest>::active().compile();
556        assert_eq!(sql.text, "SELECT * FROM active($1)");
557        assert_eq!(
558            sql.params,
559            vec![Param::Text(
560                "quickstart-licensing:Licensing.AppInstall:AppInstallRequest".to_string()
561            )]
562        );
563    }
564
565    /// The property this module exists for: a field name from outside is a
566    /// parameter, not syntax. Postgres never parses it, so there is nothing to
567    /// inject into.
568    #[test]
569    fn a_hostile_field_name_is_data_and_not_syntax() {
570        let sql = Query::<AppInstallRequest>::active()
571            .filter(Predicate::eq("user'; DROP TABLE __contracts; --", "x"))
572            .compile();
573
574        assert_eq!(
575            sql.text,
576            "SELECT * FROM active($1) WHERE payload #> $2 = $3"
577        );
578        assert!(
579            !sql.text.contains("DROP"),
580            "the field name must not reach the statement: {}",
581            sql.text
582        );
583        assert_eq!(
584            sql.params[1],
585            Param::Path(vec!["user'; DROP TABLE __contracts; --".to_string()])
586        );
587    }
588
589    #[test]
590    fn a_hostile_value_is_a_parameter_too() {
591        let sql = Query::<AppInstallRequest>::active()
592            .filter(Predicate::eq("user", "'; DROP TABLE __contracts; --"))
593            .compile();
594        assert!(!sql.text.contains("DROP"), "{}", sql.text);
595        assert_eq!(
596            sql.params[2],
597            Param::Json(serde_json::json!("'; DROP TABLE __contracts; --"))
598        );
599    }
600
601    /// `<>` against a missing JSON path yields SQL NULL, so the row is
602    /// excluded. That matters here more than it would elsewhere: a query
603    /// matches on the package **name** so it survives an upgrade, which means a
604    /// result set mixes payloads of different versions with different field
605    /// sets. Under `<>`, adding a field in v2 would silently drop every v1
606    /// contract from a `not_eq` on it — no error, no warning, just a short
607    /// answer.
608    #[test]
609    fn not_equal_still_matches_a_contract_whose_field_is_absent() {
610        let sql = Query::<AppInstallRequest>::active()
611            .filter(Predicate::not_eq("status", "closed"))
612            .compile();
613        assert!(
614            sql.text.contains("IS DISTINCT FROM"),
615            "not_eq must include the absent case: {}",
616            sql.text
617        );
618        assert!(
619            !sql.text.contains(" <> "),
620            "plain `<>` drops rows lacking the field: {}",
621            sql.text
622        );
623    }
624
625    /// A nested path reaches into the payload without any string building.
626    #[test]
627    fn a_nested_path_is_one_array_parameter() {
628        let sql = Query::<AppInstallRequest>::active()
629            .filter(Predicate::eq(["meta", "values", "reason"], "renewal"))
630            .compile();
631        assert_eq!(
632            sql.params[1],
633            Param::Path(vec![
634                "meta".to_string(),
635                "values".to_string(),
636                "reason".to_string()
637            ])
638        );
639    }
640
641    /// LF-JSON carries numbers as strings, so an ordered comparison on an
642    /// amount must be numeric. Compared as text, "9" sorts after "10" and every
643    /// threshold query is quietly wrong.
644    #[test]
645    fn an_ordered_comparison_on_a_number_is_numeric_and_not_lexical() {
646        let sql = Query::<AppInstallRequest>::active()
647            .filter(Predicate::compare("amount", Op::Gt, 100))
648            .compile();
649        assert_eq!(
650            sql.text,
651            "SELECT * FROM active($1) WHERE (payload #>> $2)::numeric > $3::text::numeric"
652        );
653        assert_eq!(sql.params[2], Param::Numeric("100".to_string()));
654    }
655
656    /// A comparison on text stays textual — casting it to numeric would fail
657    /// at the database rather than compare wrongly, but it would still be the
658    /// wrong statement.
659    #[test]
660    fn an_ordered_comparison_on_text_stays_textual() {
661        let sql = Query::<AppInstallRequest>::active()
662            .filter(Predicate::compare("name", Op::Ge, "m"))
663            .compile();
664        assert_eq!(
665            sql.text,
666            "SELECT * FROM active($1) WHERE payload #>> $2 >= $3"
667        );
668        assert_eq!(sql.params[2], Param::Text("m".to_string()));
669    }
670
671    /// Containment is the one an index can serve, so it is offered by name.
672    #[test]
673    fn containment_uses_the_operator_an_index_can_serve() {
674        let sql = Query::<AppInstallRequest>::active()
675            .filter(Predicate::contains(
676                serde_json::json!({ "user": "alice::1220" }),
677            ))
678            .compile();
679        assert_eq!(sql.text, "SELECT * FROM active($1) WHERE payload @> $2");
680    }
681
682    #[test]
683    fn party_columns_are_tested_for_membership() {
684        let sql = Query::<AppInstallRequest>::active()
685            .filter(Predicate::signatory("alice::1220"))
686            .filter(Predicate::observer("bob::1220"))
687            .compile();
688        assert_eq!(
689            sql.text,
690            "SELECT * FROM active($1) WHERE $2 = ANY(signatories) AND $3 = ANY(observers)"
691        );
692        assert_eq!(sql.params[1], Param::Text("alice::1220".to_string()));
693    }
694
695    #[test]
696    fn an_offset_pins_the_acs_to_a_point_in_time() {
697        let sql = Query::<AppInstallRequest>::active_at(42).compile();
698        assert_eq!(sql.text, "SELECT * FROM active($1, $2)");
699        assert_eq!(sql.params[1], Param::Offset(42));
700    }
701
702    /// The bounds are positional. A `to` without a `from` must still pass
703    /// something for the first argument, and the function's own default is
704    /// what it passes — not a guess.
705    #[test]
706    fn an_upper_bound_alone_still_names_the_lower_one() {
707        let sql = Query::<AppInstallRequest>::creates(None, Some(99)).compile();
708        assert_eq!(sql.text, "SELECT * FROM creates($1, oldest_offset(), $2)");
709        assert_eq!(sql.params[1], Param::Offset(99));
710
711        // And archives starts at the pruning boundary, because rows before it
712        // are not there to be read.
713        let sql = Query::<AppInstallRequest>::archives(None, Some(99)).compile();
714        assert_eq!(
715            sql.text,
716            "SELECT * FROM archives($1, COALESCE(pruned_offset(), oldest_offset()), $2)"
717        );
718    }
719
720    #[test]
721    fn both_bounds_are_parameters_when_both_are_given() {
722        let sql = Query::<AppInstallRequest>::creates(Some(10), Some(20)).compile();
723        assert_eq!(sql.text, "SELECT * FROM creates($1, $2, $3)");
724        assert_eq!(sql.params[1], Param::Offset(10));
725        assert_eq!(sql.params[2], Param::Offset(20));
726    }
727
728    /// Placeholders have to be numbered in the order the parameters are bound,
729    /// or every query after the first predicate reads the wrong value.
730    #[test]
731    fn placeholders_are_numbered_in_binding_order() {
732        let sql = Query::<AppInstallRequest>::active()
733            .filter(Predicate::eq("user", "alice"))
734            .filter(Predicate::signatory("bob"))
735            .filter(Predicate::compare("amount", Op::Lt, 5))
736            .limit(10)
737            .compile();
738
739        assert_eq!(
740            sql.text,
741            "SELECT * FROM active($1) WHERE payload #> $2 = $3 AND $4 = ANY(signatories) \
742             AND (payload #>> $5)::numeric < $6::text::numeric ORDER BY created_at_offset, contract_id LIMIT $7"
743        );
744        assert_eq!(sql.params.len(), 7);
745        assert_eq!(sql.params[6], Param::Offset(10));
746    }
747
748    #[test]
749    fn a_number_in_eq_is_bound_as_the_string_lf_json_stores() {
750        // `Int64` and `Numeric` payload fields are JSON strings on the wire and
751        // in the store; a jsonb number would match nothing, silently.
752        let sql = Query::<AppInstallRequest>::active()
753            .filter(Predicate::eq("count", 5))
754            .compile();
755        assert!(
756            matches!(&sql.params[2], Param::Json(serde_json::Value::String(s)) if s == "5"),
757            "{:?}",
758            sql.params[2]
759        );
760        let sql = Query::<AppInstallRequest>::active()
761            .filter(Predicate::not_eq("count", 5))
762            .compile();
763        assert!(matches!(&sql.params[2], Param::Json(serde_json::Value::String(s)) if s == "5"));
764    }
765
766    #[test]
767    fn a_limit_orders_the_rows_so_pages_do_not_overlap() {
768        let sql = Query::<AppInstallRequest>::active().limit(10).compile();
769        assert!(
770            sql.text
771                .ends_with("ORDER BY created_at_offset, contract_id LIMIT $2"),
772            "{}",
773            sql.text
774        );
775        // And without a limit there is no order clause to pay for.
776        let sql = Query::<AppInstallRequest>::active().compile();
777        assert!(!sql.text.contains("ORDER BY"), "{}", sql.text);
778    }
779
780    /// Every way of spelling a path lands as the same segments, and none of
781    /// them splits or joins anything: a single string is one segment.
782    #[test]
783    fn every_path_spelling_gives_the_same_segments() {
784        assert_eq!("amount".into_path(), vec!["amount".to_string()]);
785        assert_eq!(
786            "a.b".into_path(),
787            vec!["a.b".to_string()],
788            "a string is one segment"
789        );
790        assert_eq!(String::from("owner").into_path(), vec!["owner".to_string()]);
791        assert_eq!(
792            ["lock", "holders"].into_path(),
793            vec!["lock".to_string(), "holders".to_string()]
794        );
795        let slice: &[&str] = &["lock", "expiresAt"];
796        assert_eq!(
797            slice.into_path(),
798            vec!["lock".to_string(), "expiresAt".to_string()]
799        );
800        assert_eq!(
801            vec!["meta".to_string(), "values".to_string()].into_path(),
802            vec!["meta".to_string(), "values".to_string()]
803        );
804    }
805}