musq/expr.rs
1//! Helpers for building safe SQL expressions for use with `Values`.
2//!
3//! Expressions are SQL fragments that can be embedded into `{set:...}`, `{insert:...}`, and
4//! `{where:...}` placeholders. Unlike regular bound values, expressions may expand to arbitrary SQL
5//! (optionally containing their own bind parameters).
6//!
7//! Expression fragments support anonymous `?` and named bind parameters. Numeric positional
8//! placeholders such as `?1` and numeric `$1` are rejected when an expression is embedded because
9//! their absolute SQLite indices cannot be safely rebased inside a larger statement.
10//!
11//! Use curated helpers (like [`crate::expr::now_rfc3339_utc`]) whenever possible. If you must embed
12//! ad-hoc SQL, use [`crate::expr::raw`], which will taint the resulting query.
13
14use either::Either;
15
16use crate::{Arguments, Query, Value, error::EncodeError};
17
18/// A SQL expression fragment with optional bound parameters.
19///
20/// This type is intentionally not `Encode`; it is meant to be embedded into composed SQL, not
21/// bound as a single SQLite value.
22#[derive(Debug, Clone)]
23pub struct Expr {
24 /// SQL for the expression fragment.
25 pub(crate) sql: String,
26 /// Arguments referenced by the expression fragment.
27 pub(crate) arguments: Arguments,
28 /// Whether the expression is tainted with raw SQL.
29 pub(crate) tainted: bool,
30}
31
32impl Expr {
33 /// Returns the SQL text for this expression.
34 pub fn sql(&self) -> &str {
35 &self.sql
36 }
37
38 /// Returns `true` if this expression includes raw SQL and should be treated as tainted.
39 pub fn tainted(&self) -> bool {
40 self.tainted
41 }
42}
43
44impl From<Query> for Expr {
45 fn from(query: Query) -> Self {
46 let sql = match query.statement {
47 Either::Left(sql) => sql,
48 Either::Right(statement) => statement.sql,
49 };
50 let arguments = query.arguments.unwrap_or_default();
51 Self {
52 sql,
53 arguments,
54 tainted: query.tainted,
55 }
56 }
57}
58
59/// SQLite expression for the current UTC time, formatted as RFC3339.
60///
61/// This is intended to match hb's documented storage format for timestamps.
62pub fn now_rfc3339_utc() -> Expr {
63 Expr {
64 sql: "STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'now')".to_string(),
65 arguments: Arguments::default(),
66 tainted: false,
67 }
68}
69
70/// Wrap a JSON text value with SQLite's `jsonb(...)` function.
71///
72/// The JSON text is bound as a parameter; SQLite will store it using its internal JSONB encoding.
73pub fn jsonb(json: &str) -> Expr {
74 let mut arguments = Arguments::default();
75 arguments.values.push(Value::Text {
76 value: json.to_string().into(),
77 type_info: None,
78 });
79 Expr {
80 sql: "jsonb(?)".to_string(),
81 arguments,
82 tainted: false,
83 }
84}
85
86/// Wrap a JSON text value with SQLite's `jsonb(...)` function.
87///
88/// Alias for [`jsonb`].
89pub fn jsonb_text(json: &str) -> Expr {
90 jsonb(json)
91}
92
93/// Serialize a value to JSON and wrap it with SQLite's `jsonb(...)` function.
94pub fn jsonb_serde<T>(value: &T) -> crate::Result<Expr>
95where
96 T: serde::Serialize + ?Sized,
97{
98 let json = serde_json::to_string(value).map_err(|e| {
99 crate::Error::Encode(EncodeError::Conversion(format!(
100 "failed to encode value as JSON: {e}"
101 )))
102 })?;
103
104 let mut arguments = Arguments::default();
105 arguments.values.push(Value::Text {
106 value: json.into(),
107 type_info: None,
108 });
109 Ok(Expr {
110 sql: "jsonb(?)".to_string(),
111 arguments,
112 tainted: false,
113 })
114}
115
116/// Embed raw SQL as an expression and taint the resulting query.
117pub fn raw(sql: &str) -> Expr {
118 Expr {
119 sql: sql.to_string(),
120 arguments: Arguments::default(),
121 tainted: true,
122 }
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128
129 #[test]
130 fn raw_expr_is_tainted() {
131 assert!(raw("1").tainted());
132 }
133
134 #[test]
135 fn now_expr_is_not_tainted() {
136 assert!(!now_rfc3339_utc().tainted());
137 }
138
139 #[test]
140 fn jsonb_serde_roundtrips() {
141 #[derive(serde::Serialize)]
142 struct Payload {
143 a: i32,
144 }
145
146 let expr = jsonb_serde(&Payload { a: 1 }).unwrap();
147 assert_eq!(expr.sql(), "jsonb(?)");
148 assert!(!expr.tainted());
149 }
150}