use std::fmt::Write as _;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum Op {
Eq,
NotEq,
Lt,
Le,
Gt,
Ge,
Contains,
}
impl Op {
fn sql(self) -> &'static str {
match self {
Self::Eq => "=",
Self::NotEq => "IS DISTINCT FROM",
Self::Lt => "<",
Self::Le => "<=",
Self::Gt => ">",
Self::Ge => ">=",
Self::Contains => "@>",
}
}
fn is_ordered(self) -> bool {
matches!(self, Self::Lt | Self::Le | Self::Gt | Self::Ge)
}
}
#[derive(Clone, Debug)]
pub struct Predicate {
target: Target,
op: Op,
value: serde_json::Value,
}
#[derive(Clone, Debug)]
enum Target {
Payload(Vec<String>),
WholePayload,
PartyList(&'static str),
Text(&'static str),
}
impl Predicate {
pub fn eq(path: impl IntoPath, value: impl Into<serde_json::Value>) -> Self {
Self {
target: Target::Payload(path.into_path()),
op: Op::Eq,
value: as_stored(value.into()),
}
}
pub fn not_eq(path: impl IntoPath, value: impl Into<serde_json::Value>) -> Self {
Self {
target: Target::Payload(path.into_path()),
op: Op::NotEq,
value: as_stored(value.into()),
}
}
pub fn compare(path: impl IntoPath, op: Op, value: impl Into<serde_json::Value>) -> Self {
Self {
target: Target::Payload(path.into_path()),
op,
value: value.into(),
}
}
pub fn contains(value: impl Into<serde_json::Value>) -> Self {
Self {
target: Target::WholePayload,
op: Op::Contains,
value: value.into(),
}
}
pub fn signatory(party: impl Into<String>) -> Self {
Self::party_list("signatories", party)
}
pub fn observer(party: impl Into<String>) -> Self {
Self::party_list("observers", party)
}
pub fn witness(party: impl Into<String>) -> Self {
Self::party_list("witnesses", party)
}
fn party_list(column: &'static str, party: impl Into<String>) -> Self {
Self {
target: Target::PartyList(column),
op: Op::Eq,
value: serde_json::Value::String(party.into()),
}
}
pub fn contract_id(id: impl Into<String>) -> Self {
Self {
target: Target::Text("contract_id"),
op: Op::Eq,
value: serde_json::Value::String(id.into()),
}
}
}
pub trait IntoPath {
fn into_path(self) -> Vec<String>;
}
impl IntoPath for &str {
fn into_path(self) -> Vec<String> {
vec![self.to_string()]
}
}
impl IntoPath for String {
fn into_path(self) -> Vec<String> {
vec![self]
}
}
impl<const N: usize> IntoPath for [&str; N] {
fn into_path(self) -> Vec<String> {
self.iter().map(ToString::to_string).collect()
}
}
impl IntoPath for &[&str] {
fn into_path(self) -> Vec<String> {
self.iter().map(ToString::to_string).collect()
}
}
impl IntoPath for Vec<String> {
fn into_path(self) -> Vec<String> {
self
}
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub enum Param {
Json(serde_json::Value),
Text(String),
Path(Vec<String>),
Numeric(String),
Offset(i64),
}
#[derive(Clone, Debug, PartialEq)]
pub struct Sql {
pub text: String,
pub params: Vec<Param>,
}
impl Sql {
#[must_use]
pub fn raw(text: impl Into<String>, params: Vec<Param>) -> Self {
Self {
text: text.into(),
params,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum Source {
Active,
Creates,
Archives,
}
impl Source {
fn function(self) -> &'static str {
match self {
Self::Active => "active",
Self::Creates => "creates",
Self::Archives => "archives",
}
}
}
#[derive(Clone, Debug)]
pub struct Query<T> {
source: Source,
predicates: Vec<Predicate>,
limit: Option<i64>,
offset_bounds: Option<(Option<i64>, Option<i64>)>,
at_offset: Option<i64>,
marker: std::marker::PhantomData<fn() -> T>,
}
impl<T: canton_daml::Contract> Query<T> {
#[must_use]
pub fn active() -> Self {
Self::from_source(Source::Active, None, None)
}
#[must_use]
pub fn active_at(offset: i64) -> Self {
Self::from_source(Source::Active, Some(offset), None)
}
#[must_use]
pub fn creates(from: Option<i64>, to: Option<i64>) -> Self {
Self::from_source(Source::Creates, None, Some((from, to)))
}
#[must_use]
pub fn archives(from: Option<i64>, to: Option<i64>) -> Self {
Self::from_source(Source::Archives, None, Some((from, to)))
}
fn from_source(
source: Source,
at_offset: Option<i64>,
offset_bounds: Option<(Option<i64>, Option<i64>)>,
) -> Self {
Self {
source,
predicates: Vec::new(),
limit: None,
offset_bounds,
at_offset,
marker: std::marker::PhantomData,
}
}
#[must_use]
pub fn qname() -> String {
format!("{}:{}:{}", T::PACKAGE_NAME, T::MODULE_NAME, T::ENTITY_NAME)
}
#[must_use]
pub fn filter(mut self, predicate: Predicate) -> Self {
self.predicates.push(predicate);
self
}
#[must_use]
pub fn limit(mut self, rows: i64) -> Self {
self.limit = Some(rows);
self
}
#[must_use]
pub fn compile(&self) -> Sql {
let mut params = vec![Param::Text(Self::qname())];
let mut text = format!("SELECT * FROM {}($1", self.source.function());
match (self.source, self.at_offset, self.offset_bounds) {
(Source::Active, Some(offset), _) => {
params.push(Param::Offset(offset));
let _ = write!(text, ", ${}", params.len());
}
(Source::Creates | Source::Archives, _, Some((from, to))) => {
let from_sql = from.map_or_else(
|| default_from(self.source).to_string(),
|value| {
params.push(Param::Offset(value));
format!("${}", params.len())
},
);
let to_sql = to.map_or_else(
|| "latest_offset()".to_string(),
|value| {
params.push(Param::Offset(value));
format!("${}", params.len())
},
);
let _ = write!(text, ", {from_sql}, {to_sql}");
}
_ => {}
}
text.push(')');
if !self.predicates.is_empty() {
text.push_str(" WHERE ");
let mut first = true;
for predicate in &self.predicates {
if !first {
text.push_str(" AND ");
}
first = false;
render(predicate, &mut text, &mut params);
}
}
if let Some(rows) = self.limit {
params.push(Param::Offset(rows));
let _ = write!(
text,
" ORDER BY created_at_offset, contract_id LIMIT ${}",
params.len()
);
}
Sql { text, params }
}
}
fn default_from(source: Source) -> &'static str {
match source {
Source::Archives => "COALESCE(pruned_offset(), oldest_offset())",
_ => "oldest_offset()",
}
}
fn as_stored(value: serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::Number(n) => serde_json::Value::String(n.to_string()),
other => other,
}
}
fn render(predicate: &Predicate, text: &mut String, params: &mut Vec<Param>) {
match &predicate.target {
Target::Payload(path) => {
params.push(Param::Path(path.clone()));
let path_param = params.len();
if predicate.op.is_ordered() {
match &predicate.value {
serde_json::Value::Number(n) => {
params.push(Param::Numeric(n.to_string()));
let _ = write!(
text,
"(payload #>> ${path_param})::numeric {} ${}::text::numeric",
predicate.op.sql(),
params.len()
);
}
other => {
params.push(Param::Text(json_text(other)));
let _ = write!(
text,
"payload #>> ${path_param} {} ${}",
predicate.op.sql(),
params.len()
);
}
}
} else {
params.push(Param::Json(predicate.value.clone()));
let _ = write!(
text,
"payload #> ${path_param} {} ${}",
predicate.op.sql(),
params.len()
);
}
}
Target::WholePayload => {
params.push(Param::Json(predicate.value.clone()));
let _ = write!(text, "payload {} ${}", predicate.op.sql(), params.len());
}
Target::PartyList(column) => {
params.push(Param::Text(json_text(&predicate.value)));
let _ = write!(text, "${} = ANY({column})", params.len());
}
Target::Text(column) => {
params.push(Param::Text(json_text(&predicate.value)));
let _ = write!(text, "{column} {} ${}", predicate.op.sql(), params.len());
}
}
}
fn json_text(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use canton_quickstart_licensing::quickstart_licensing::Licensing_AppInstall::AppInstallRequest;
#[test]
fn the_template_names_itself() {
assert_eq!(
Query::<AppInstallRequest>::qname(),
"quickstart-licensing:Licensing.AppInstall:AppInstallRequest"
);
}
#[test]
fn a_bare_query_is_one_function_call_with_one_parameter() {
let sql = Query::<AppInstallRequest>::active().compile();
assert_eq!(sql.text, "SELECT * FROM active($1)");
assert_eq!(
sql.params,
vec![Param::Text(
"quickstart-licensing:Licensing.AppInstall:AppInstallRequest".to_string()
)]
);
}
#[test]
fn a_hostile_field_name_is_data_and_not_syntax() {
let sql = Query::<AppInstallRequest>::active()
.filter(Predicate::eq("user'; DROP TABLE __contracts; --", "x"))
.compile();
assert_eq!(
sql.text,
"SELECT * FROM active($1) WHERE payload #> $2 = $3"
);
assert!(
!sql.text.contains("DROP"),
"the field name must not reach the statement: {}",
sql.text
);
assert_eq!(
sql.params[1],
Param::Path(vec!["user'; DROP TABLE __contracts; --".to_string()])
);
}
#[test]
fn a_hostile_value_is_a_parameter_too() {
let sql = Query::<AppInstallRequest>::active()
.filter(Predicate::eq("user", "'; DROP TABLE __contracts; --"))
.compile();
assert!(!sql.text.contains("DROP"), "{}", sql.text);
assert_eq!(
sql.params[2],
Param::Json(serde_json::json!("'; DROP TABLE __contracts; --"))
);
}
#[test]
fn not_equal_still_matches_a_contract_whose_field_is_absent() {
let sql = Query::<AppInstallRequest>::active()
.filter(Predicate::not_eq("status", "closed"))
.compile();
assert!(
sql.text.contains("IS DISTINCT FROM"),
"not_eq must include the absent case: {}",
sql.text
);
assert!(
!sql.text.contains(" <> "),
"plain `<>` drops rows lacking the field: {}",
sql.text
);
}
#[test]
fn a_nested_path_is_one_array_parameter() {
let sql = Query::<AppInstallRequest>::active()
.filter(Predicate::eq(["meta", "values", "reason"], "renewal"))
.compile();
assert_eq!(
sql.params[1],
Param::Path(vec![
"meta".to_string(),
"values".to_string(),
"reason".to_string()
])
);
}
#[test]
fn an_ordered_comparison_on_a_number_is_numeric_and_not_lexical() {
let sql = Query::<AppInstallRequest>::active()
.filter(Predicate::compare("amount", Op::Gt, 100))
.compile();
assert_eq!(
sql.text,
"SELECT * FROM active($1) WHERE (payload #>> $2)::numeric > $3::text::numeric"
);
assert_eq!(sql.params[2], Param::Numeric("100".to_string()));
}
#[test]
fn an_ordered_comparison_on_text_stays_textual() {
let sql = Query::<AppInstallRequest>::active()
.filter(Predicate::compare("name", Op::Ge, "m"))
.compile();
assert_eq!(
sql.text,
"SELECT * FROM active($1) WHERE payload #>> $2 >= $3"
);
assert_eq!(sql.params[2], Param::Text("m".to_string()));
}
#[test]
fn containment_uses_the_operator_an_index_can_serve() {
let sql = Query::<AppInstallRequest>::active()
.filter(Predicate::contains(
serde_json::json!({ "user": "alice::1220" }),
))
.compile();
assert_eq!(sql.text, "SELECT * FROM active($1) WHERE payload @> $2");
}
#[test]
fn party_columns_are_tested_for_membership() {
let sql = Query::<AppInstallRequest>::active()
.filter(Predicate::signatory("alice::1220"))
.filter(Predicate::observer("bob::1220"))
.compile();
assert_eq!(
sql.text,
"SELECT * FROM active($1) WHERE $2 = ANY(signatories) AND $3 = ANY(observers)"
);
assert_eq!(sql.params[1], Param::Text("alice::1220".to_string()));
}
#[test]
fn an_offset_pins_the_acs_to_a_point_in_time() {
let sql = Query::<AppInstallRequest>::active_at(42).compile();
assert_eq!(sql.text, "SELECT * FROM active($1, $2)");
assert_eq!(sql.params[1], Param::Offset(42));
}
#[test]
fn an_upper_bound_alone_still_names_the_lower_one() {
let sql = Query::<AppInstallRequest>::creates(None, Some(99)).compile();
assert_eq!(sql.text, "SELECT * FROM creates($1, oldest_offset(), $2)");
assert_eq!(sql.params[1], Param::Offset(99));
let sql = Query::<AppInstallRequest>::archives(None, Some(99)).compile();
assert_eq!(
sql.text,
"SELECT * FROM archives($1, COALESCE(pruned_offset(), oldest_offset()), $2)"
);
}
#[test]
fn both_bounds_are_parameters_when_both_are_given() {
let sql = Query::<AppInstallRequest>::creates(Some(10), Some(20)).compile();
assert_eq!(sql.text, "SELECT * FROM creates($1, $2, $3)");
assert_eq!(sql.params[1], Param::Offset(10));
assert_eq!(sql.params[2], Param::Offset(20));
}
#[test]
fn placeholders_are_numbered_in_binding_order() {
let sql = Query::<AppInstallRequest>::active()
.filter(Predicate::eq("user", "alice"))
.filter(Predicate::signatory("bob"))
.filter(Predicate::compare("amount", Op::Lt, 5))
.limit(10)
.compile();
assert_eq!(
sql.text,
"SELECT * FROM active($1) WHERE payload #> $2 = $3 AND $4 = ANY(signatories) \
AND (payload #>> $5)::numeric < $6::text::numeric ORDER BY created_at_offset, contract_id LIMIT $7"
);
assert_eq!(sql.params.len(), 7);
assert_eq!(sql.params[6], Param::Offset(10));
}
#[test]
fn a_number_in_eq_is_bound_as_the_string_lf_json_stores() {
let sql = Query::<AppInstallRequest>::active()
.filter(Predicate::eq("count", 5))
.compile();
assert!(
matches!(&sql.params[2], Param::Json(serde_json::Value::String(s)) if s == "5"),
"{:?}",
sql.params[2]
);
let sql = Query::<AppInstallRequest>::active()
.filter(Predicate::not_eq("count", 5))
.compile();
assert!(matches!(&sql.params[2], Param::Json(serde_json::Value::String(s)) if s == "5"));
}
#[test]
fn a_limit_orders_the_rows_so_pages_do_not_overlap() {
let sql = Query::<AppInstallRequest>::active().limit(10).compile();
assert!(
sql.text
.ends_with("ORDER BY created_at_offset, contract_id LIMIT $2"),
"{}",
sql.text
);
let sql = Query::<AppInstallRequest>::active().compile();
assert!(!sql.text.contains("ORDER BY"), "{}", sql.text);
}
#[test]
fn every_path_spelling_gives_the_same_segments() {
assert_eq!("amount".into_path(), vec!["amount".to_string()]);
assert_eq!(
"a.b".into_path(),
vec!["a.b".to_string()],
"a string is one segment"
);
assert_eq!(String::from("owner").into_path(), vec!["owner".to_string()]);
assert_eq!(
["lock", "holders"].into_path(),
vec!["lock".to_string(), "holders".to_string()]
);
let slice: &[&str] = &["lock", "expiresAt"];
assert_eq!(
slice.into_path(),
vec!["lock".to_string(), "expiresAt".to_string()]
);
assert_eq!(
vec!["meta".to_string(), "values".to_string()].into_path(),
vec!["meta".to_string(), "values".to_string()]
);
}
}