Skip to main content

cratefield_core/ports/
database.rs

1//! The `Database` port: engine-agnostic statements and rows over
2//! sea-query-rendered SQL (ADR 0004).
3
4use async_trait::async_trait;
5use sea_query::Value as SeaValue;
6
7/// A rendered SQL statement: `(sql, values)` with `?` placeholders, produced
8/// by rendering a sea-query query for a dialect. Modules build queries with
9/// sea-query and render through the helpers on this type (or let adapters do
10/// it); adapters bind `values` positionally.
11#[derive(Debug, Clone)]
12pub struct Statement {
13    pub sql: String,
14    pub values: sea_query::Values,
15}
16
17impl Statement {
18    /// A parameterless statement (`SELECT 1`, DDL).
19    pub fn new(sql: impl Into<String>) -> Self {
20        Self {
21            sql: sql.into(),
22            values: sea_query::Values(Vec::new()),
23        }
24    }
25
26    /// A statement with positional `?` values.
27    pub fn with_values(sql: impl Into<String>, values: Vec<SeaValue>) -> Self {
28        Self {
29            sql: sql.into(),
30            values: sea_query::Values(values),
31        }
32    }
33
34    /// Renders a sea-query statement (select/insert/update/delete) with the
35    /// SQLite dialect. The portable subset renders identically for D1,
36    /// rusqlite and (later) Postgres (ADR 0004).
37    pub fn render(query: &impl sea_query::QueryStatementBuilder) -> Self {
38        let (sql, values) = query.build_any(&sea_query::SqliteQueryBuilder);
39        Self { sql, values }
40    }
41}
42
43/// A small owned row model. No engine types leak past this point.
44#[derive(Debug, Clone)]
45pub struct Rows {
46    pub rows: Vec<Row>,
47}
48
49impl Rows {
50    pub fn new(rows: Vec<Row>) -> Self {
51        Self { rows }
52    }
53
54    pub fn is_empty(&self) -> bool {
55        self.rows.is_empty()
56    }
57
58    pub fn len(&self) -> usize {
59        self.rows.len()
60    }
61
62    pub fn first(&self) -> Option<&Row> {
63        self.rows.first()
64    }
65}
66
67/// One result row: ordered `(column name, value)` pairs.
68#[derive(Debug, Clone)]
69pub struct Row {
70    columns: Vec<(String, SeaValue)>,
71}
72
73impl Row {
74    pub fn new(columns: Vec<(String, SeaValue)>) -> Self {
75        Self { columns }
76    }
77
78    /// Column names in result order.
79    pub fn column_names(&self) -> impl Iterator<Item = &str> {
80        self.columns.iter().map(|(name, _)| name.as_str())
81    }
82
83    /// Every column, in result order, untyped.
84    ///
85    /// [`get`](Row::get) answers "is this column an `i64`" and returns `None`
86    /// for a NULL, a missing column and an unrepresentable one alike — three
87    /// different facts wearing one answer. A caller that must serialise a row
88    /// it did not write, like a subject-access export, needs to tell them
89    /// apart: reporting a value it could not read as `null` is a quiet claim
90    /// that the database held nothing there.
91    pub fn columns(&self) -> impl Iterator<Item = (&str, &SeaValue)> {
92        self.columns
93            .iter()
94            .map(|(name, value)| (name.as_str(), value))
95    }
96
97    /// Typed column access. `None` when the column is missing, `NULL`, or
98    /// not representable as `T`.
99    pub fn get<T: TryFromValue>(&self, column: &str) -> Option<T> {
100        let value = self
101            .columns
102            .iter()
103            .find(|(name, _)| name == column)
104            .map(|(_, value)| value)?;
105        T::try_from_value(value)
106    }
107}
108
109/// Conversion from a sea-query [`SeaValue`] for typed row access.
110pub trait TryFromValue: Sized {
111    fn try_from_value(value: &SeaValue) -> Option<Self>;
112}
113
114fn text(value: &SeaValue) -> Option<String> {
115    match value {
116        SeaValue::String(Some(s)) => Some((**s).clone()),
117        SeaValue::Char(Some(c)) => Some(c.to_string()),
118        _ => None,
119    }
120}
121
122macro_rules! impl_int {
123    ($($t:ty),* $(,)?) => {
124        $(
125            impl TryFromValue for $t {
126                fn try_from_value(value: &SeaValue) -> Option<Self> {
127                    let i: i64 = match value {
128                        SeaValue::TinyInt(Some(v)) => i64::from(*v),
129                        SeaValue::SmallInt(Some(v)) => i64::from(*v),
130                        SeaValue::Int(Some(v)) => i64::from(*v),
131                        SeaValue::BigInt(Some(v)) => *v,
132                        _ => return None,
133                    };
134                    <$t>::try_from(i).ok()
135                }
136            }
137        )*
138    };
139}
140
141impl_int!(i8, i16, i32, i64, u8, u16, u32, u64, usize);
142
143impl TryFromValue for String {
144    fn try_from_value(value: &SeaValue) -> Option<Self> {
145        text(value)
146    }
147}
148
149/// Blob columns. A `Database` that cannot return bytes cannot hold a
150/// ciphertext, a wrapped key or a nonce (issue #39).
151impl TryFromValue for Vec<u8> {
152    fn try_from_value(value: &SeaValue) -> Option<Self> {
153        match value {
154            SeaValue::Bytes(Some(bytes)) => Some(bytes.as_ref().clone()),
155            _ => None,
156        }
157    }
158}
159
160impl TryFromValue for bool {
161    fn try_from_value(value: &SeaValue) -> Option<Self> {
162        match value {
163            SeaValue::Bool(Some(v)) => Some(*v),
164            SeaValue::Int(Some(v)) => Some(*v != 0),
165            _ => None,
166        }
167    }
168}
169
170impl TryFromValue for f64 {
171    fn try_from_value(value: &SeaValue) -> Option<Self> {
172        match value {
173            SeaValue::Float(Some(v)) => Some(f64::from(*v)),
174            SeaValue::Double(Some(v)) => Some(*v),
175            _ => None,
176        }
177    }
178}
179
180impl TryFromValue for Option<String> {
181    fn try_from_value(value: &SeaValue) -> Option<Self> {
182        // In sea-query 0.32 a SQL NULL is the variant with `None` inside
183        // (e.g. `Value::String(None)`); a type mismatch is our `None`.
184        match value {
185            SeaValue::String(inner) => Some(inner.as_deref().map(String::from)),
186            _ => None,
187        }
188    }
189}
190
191impl TryFromValue for SeaValue {
192    fn try_from_value(value: &SeaValue) -> Option<Self> {
193        Some(value.clone())
194    }
195}
196
197/// Database failures, sanitized for logs and problem details.
198///
199/// The adapters wrap the raw driver message (`sqlx`, D1, rusqlite), which
200/// is **not** safe on its own: a Postgres unique violation quotes the
201/// offending row in its `DETAIL:` line — an email address — and a connect
202/// failure can echo the connection URL with its credentials. `Display`
203/// therefore runs the message through [`crate::logging::scrub_text`]
204/// (issue #135), so every `tracing` field, forwarded diagnostic or
205/// `format!` that renders a `DbError` gets the sanitized text. `Debug`
206/// still shows the raw string for tests; the logging formatters scrub
207/// `{:?}` output too.
208#[derive(Debug, Clone, PartialEq, Eq)]
209pub enum DbError {
210    Execute(String),
211    Query(String),
212    Batch(String),
213}
214
215impl std::fmt::Display for DbError {
216    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
217        let (label, message) = match self {
218            Self::Execute(message) => ("execute failed", message),
219            Self::Query(message) => ("query failed", message),
220            Self::Batch(message) => ("batch failed", message),
221        };
222        write!(f, "{label}: {}", crate::logging::scrub_text(message))
223    }
224}
225
226impl std::error::Error for DbError {}
227
228/// Execute statements against the venture database. Implementations: D1
229/// (Workers), rusqlite (tests, self-hosted), Postgres (phase 3).
230///
231/// `batch_atomic` is **all-or-nothing, on every adapter, without
232/// exception** (issue #126). Either every statement commits or none does:
233/// a failure part-way through leaves the database exactly as it was, as
234/// if the batch never ran. This is a port contract, not an adapter
235/// nicety — confirmation, position assignment and referral credit each
236/// span several statements and are correct only if they commit together —
237/// so an adapter that cannot honour it must not implement this trait,
238/// and the testing kit's `assert_batch_is_atomic` enforces the
239/// contract per engine in CI. Per-statement row counts are not reported;
240/// a caller that needs to know whether a statement changed anything reads
241/// the row back (see `module-waitlist`'s `confirm_entry`).
242#[async_trait]
243pub trait Database: Send + Sync {
244    async fn execute(&self, stmt: &Statement) -> Result<u64, DbError>;
245    async fn query(&self, stmt: &Statement) -> Result<Rows, DbError>;
246    async fn batch_atomic(&self, stmts: &[Statement]) -> Result<(), DbError>;
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    #[test]
254    fn display_sanitizes_the_driver_message() {
255        // The finding: `DbError::Execute(err.to_string())` wraps the raw
256        // driver error, and a Postgres unique violation quotes the row —
257        // an email — in its DETAIL line.
258        let error = DbError::Execute(
259            "duplicate key value violates unique constraint \"subscribers_email_normalized_key\" \
260             DETAIL:  Key (email_normalized)=(nick@example.com) already exists."
261                .to_owned(),
262        );
263        let text = error.to_string();
264        assert!(text.starts_with("execute failed: "), "{text}");
265        assert!(!text.contains('@'), "{text}");
266        assert!(!text.contains("nick"), "{text}");
267        assert!(text.contains("[subject_hash:"), "{text}");
268
269        // A connect failure echoing the URL must not disclose credentials.
270        let error = DbError::Execute(
271            "error connecting to postgres://venture:sup3r-s3cret@db.internal:5432/app".to_owned(),
272        );
273        let text = error.to_string();
274        assert!(!text.contains("sup3r-s3cret"), "{text}");
275        assert!(text.contains("postgres://[redacted]@db.internal"), "{text}");
276
277        assert_eq!(
278            DbError::Query("no such table: subscribers".to_owned()).to_string(),
279            "query failed: no such table: subscribers"
280        );
281        assert_eq!(
282            DbError::Batch("batch aborted".to_owned()).to_string(),
283            "batch failed: batch aborted"
284        );
285    }
286}