1use std::fmt::Write as _;
14
15#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17#[non_exhaustive]
18pub enum Op {
19 Eq,
21 NotEq,
23 Lt,
26 Le,
28 Gt,
30 Ge,
32 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#[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 Payload(Vec<String>),
67 WholePayload,
69 PartyList(&'static str),
71 Text(&'static str),
73}
74
75impl Predicate {
76 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 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 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 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 pub fn signatory(party: impl Into<String>) -> Self {
143 Self::party_list("signatories", party)
144 }
145
146 pub fn observer(party: impl Into<String>) -> Self {
148 Self::party_list("observers", party)
149 }
150
151 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 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
174pub trait IntoPath {
178 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#[derive(Clone, Debug, PartialEq)]
214#[non_exhaustive]
215pub enum Param {
216 Json(serde_json::Value),
218 Text(String),
220 Path(Vec<String>),
222 Numeric(String),
225 Offset(i64),
227}
228
229#[derive(Clone, Debug, PartialEq)]
231pub struct Sql {
232 pub text: String,
234 pub params: Vec<Param>,
236}
237
238impl Sql {
239 #[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
267#[non_exhaustive]
268pub enum Source {
269 Active,
271 Creates,
273 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#[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 #[must_use]
310 pub fn active() -> Self {
311 Self::from_source(Source::Active, None, None)
312 }
313
314 #[must_use]
319 pub fn active_at(offset: i64) -> Self {
320 Self::from_source(Source::Active, Some(offset), None)
321 }
322
323 #[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 #[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 #[must_use]
358 pub fn qname() -> String {
359 format!("{}:{}:{}", T::PACKAGE_NAME, T::MODULE_NAME, T::ENTITY_NAME)
360 }
361
362 #[must_use]
364 pub fn filter(mut self, predicate: Predicate) -> Self {
365 self.predicates.push(predicate);
366 self
367 }
368
369 #[must_use]
371 pub fn limit(mut self, rows: i64) -> Self {
372 self.limit = Some(rows);
373 self
374 }
375
376 #[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 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 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
443fn default_from(source: Source) -> &'static str {
445 match source {
446 Source::Archives => "COALESCE(pruned_offset(), oldest_offset())",
449 _ => "oldest_offset()",
450 }
451}
452
453fn 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 match &predicate.value {
477 serde_json::Value::Number(n) => {
478 params.push(Param::Numeric(n.to_string()));
479 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
526fn 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 #[test]
544 fn the_template_names_itself() {
545 assert_eq!(
546 Query::<AppInstallRequest>::qname(),
547 "quickstart-licensing:Licensing.AppInstall:AppInstallRequest"
548 );
549 }
550
551 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 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 let sql = Query::<AppInstallRequest>::active().compile();
777 assert!(!sql.text.contains("ORDER BY"), "{}", sql.text);
778 }
779
780 #[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}