Skip to main content

zeph_db/
lib.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Database abstraction layer for Zeph.
5//!
6//! Provides [`DbPool`], [`DbRow`], [`DbTransaction`], [`DbQueryResult`] type
7//! aliases that resolve to either `SQLite` or `PostgreSQL` types at compile time,
8//! based on the active feature flag (`sqlite` or `postgres`).
9//!
10//! The [`sql!`] macro converts `?` placeholders to `$N` style for `PostgreSQL`,
11//! and is a no-op identity for `SQLite` (returning `&'static str` directly).
12//!
13//! # Feature Flags
14//!
15//! Exactly one of `sqlite` or `postgres` must be enabled. The root workspace
16//! default includes `zeph-db/sqlite`. When both are enabled simultaneously,
17//! `postgres` takes priority. Use `--features full` for the standard `SQLite` build.
18
19#[cfg(not(any(feature = "sqlite", feature = "postgres")))]
20compile_error!("exactly one of `sqlite` or `postgres` must be enabled for `zeph-db`");
21
22pub mod bounds;
23pub mod dialect;
24pub mod driver;
25pub mod error;
26pub mod fts;
27pub mod migrate;
28pub mod pool;
29pub mod transaction;
30
31pub use bounds::FullDriver;
32pub use dialect::{Dialect, Postgres, Sqlite};
33pub use driver::DatabaseDriver;
34pub use error::DbError;
35pub use migrate::run_migrations;
36pub use pool::{DbConfig, redact_url};
37pub use transaction::{begin, begin_write};
38
39// Re-export sqlx query builders bound to the active backend.
40pub use sqlx::query_builder::QueryBuilder;
41pub use sqlx::{Error as SqlxError, Executor, FromRow, Row, query, query_as, query_scalar};
42// Re-export the full sqlx crate so consumers can use `zeph_db::sqlx::Type` etc.
43pub use sqlx;
44
45// --- Active driver type alias ---
46
47/// The active database driver, selected at compile time.
48#[cfg(all(feature = "sqlite", not(feature = "postgres")))]
49pub type ActiveDriver = driver::SqliteDriver;
50#[cfg(feature = "postgres")]
51pub type ActiveDriver = driver::PostgresDriver;
52
53// --- Convenience type aliases ---
54
55/// A connection pool for the active database backend.
56///
57/// Resolves to [`sqlx::SqlitePool`] or [`sqlx::PgPool`] at compile time.
58pub type DbPool = sqlx::Pool<<ActiveDriver as DatabaseDriver>::Database>;
59
60/// A row from the active database backend.
61pub type DbRow = <<ActiveDriver as DatabaseDriver>::Database as sqlx::Database>::Row;
62
63/// A query result from the active database backend.
64pub type DbQueryResult =
65    <<ActiveDriver as DatabaseDriver>::Database as sqlx::Database>::QueryResult;
66
67/// A transaction on the active database backend.
68pub type DbTransaction<'a> = sqlx::Transaction<'a, <ActiveDriver as DatabaseDriver>::Database>;
69
70/// The active SQL dialect type.
71pub type ActiveDialect = <ActiveDriver as DatabaseDriver>::Dialect;
72
73// --- sql! macro ---
74
75/// Convert SQL with `?` placeholders to the active backend's placeholder style.
76///
77/// `SQLite`: returns the input `&str` directly — zero allocation, zero runtime cost.
78///
79/// `PostgreSQL`: rewrites `?` to `$1`, `$2`, ... using [`rewrite_placeholders`].
80/// The rewritten string is leaked via `Box::leak` to obtain `&'static str` —
81/// no caching: each call site leaks one allocation per unique SQL string.
82/// The set of unique SQL strings is bounded (call sites are fixed at compile
83/// time), so total leaked memory is bounded and acceptable for a long-running
84/// process. Do NOT wrap `PostgreSQL` JSONB queries using `?`/`?|`/`?&`
85/// operators through this macro; use `$N` placeholders directly for those.
86///
87/// # Example
88///
89/// ```rust,ignore
90/// let rows = sqlx::query(sql!("SELECT id FROM messages WHERE conversation_id = ?"))
91///     .bind(cid)
92///     .fetch_all(&pool)
93///     .await?;
94/// ```
95#[cfg(all(feature = "sqlite", not(feature = "postgres")))]
96#[macro_export]
97macro_rules! sql {
98    ($query:expr) => {
99        $query
100    };
101}
102
103#[cfg(feature = "postgres")]
104#[macro_export]
105macro_rules! sql {
106    ($query:expr) => {{
107        // Leak the rewritten query string to obtain `&'static str`.
108        // The set of unique SQL strings in the application is finite, so total
109        // leaked memory is bounded and acceptable for a long-running process.
110        let s: String = $crate::rewrite_placeholders($query);
111        Box::leak(s.into_boxed_str()) as &'static str
112    }};
113}
114
115/// Returns `true` if the given database URL looks like a `PostgreSQL` connection string.
116///
117/// Check whether `url` looks like a `PostgreSQL` connection URL.
118///
119/// Used to detect misconfigured `database_url` values (e.g. a `SQLite` path passed
120/// to a postgres build, or vice versa).
121#[must_use]
122pub fn is_postgres_url(url: &str) -> bool {
123    url.starts_with("postgres://") || url.starts_with("postgresql://")
124}
125
126/// Rewrite `?` bind markers to `$1, $2, ...` for `PostgreSQL`.
127///
128/// Skips `?` inside single-quoted string literals. Does NOT handle dollar-quoted
129/// strings (`$$...$$`) or `?` inside comments — document this limitation at call
130/// sites where those patterns appear.
131#[must_use]
132pub fn rewrite_placeholders(query: &str) -> String {
133    let mut out = String::with_capacity(query.len() + 16);
134    let mut n = 0u32;
135    let mut in_string = false;
136    for ch in query.chars() {
137        match ch {
138            '\'' => {
139                in_string = !in_string;
140                out.push(ch);
141            }
142            '?' if !in_string => {
143                n += 1;
144                out.push('$');
145                out.push_str(&n.to_string());
146            }
147            _ => out.push(ch),
148        }
149    }
150    out
151}
152
153/// Generate a single numbered placeholder for bind position `n` (1-based).
154///
155/// `SQLite`: `?N`, `PostgreSQL`: `$N`
156#[must_use]
157#[cfg(all(feature = "sqlite", not(feature = "postgres")))]
158pub fn numbered_placeholder(n: usize) -> String {
159    format!("?{n}")
160}
161
162/// Generate a single numbered placeholder for bind position `n` (1-based).
163///
164/// `SQLite`: `?N`, `PostgreSQL`: `$N`
165#[must_use]
166#[cfg(feature = "postgres")]
167pub fn numbered_placeholder(n: usize) -> String {
168    format!("${n}")
169}
170
171/// Generate a comma-separated list of placeholders for `count` binds starting at position
172/// `start` (1-based).
173///
174/// Example (SQLite): `placeholder_list(3, 2)` → `"?3, ?4"`
175/// Example (PostgreSQL): `placeholder_list(3, 2)` → `"$3, $4"`
176#[must_use]
177pub fn placeholder_list(start: usize, count: usize) -> String {
178    (start..start + count)
179        .map(numbered_placeholder)
180        .collect::<Vec<_>>()
181        .join(", ")
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    #[test]
189    fn rewrite_placeholders_basic() {
190        let out = rewrite_placeholders("SELECT * FROM t WHERE a = ? AND b = ?");
191        assert_eq!(out, "SELECT * FROM t WHERE a = $1 AND b = $2");
192    }
193
194    #[test]
195    fn rewrite_placeholders_skips_string_literals() {
196        let out = rewrite_placeholders("SELECT '?' FROM t WHERE a = ?");
197        assert_eq!(out, "SELECT '?' FROM t WHERE a = $1");
198    }
199
200    #[test]
201    fn rewrite_placeholders_no_params() {
202        let out = rewrite_placeholders("SELECT 1");
203        assert_eq!(out, "SELECT 1");
204    }
205
206    #[test]
207    fn numbered_placeholder_one_based() {
208        let p1 = numbered_placeholder(1);
209        let p3 = numbered_placeholder(3);
210        #[cfg(all(feature = "sqlite", not(feature = "postgres")))]
211        {
212            assert_eq!(p1, "?1");
213            assert_eq!(p3, "?3");
214        }
215        #[cfg(feature = "postgres")]
216        {
217            assert_eq!(p1, "$1");
218            assert_eq!(p3, "$3");
219        }
220    }
221
222    #[test]
223    fn placeholder_list_range() {
224        let list = placeholder_list(2, 3);
225        #[cfg(all(feature = "sqlite", not(feature = "postgres")))]
226        assert_eq!(list, "?2, ?3, ?4");
227        #[cfg(feature = "postgres")]
228        assert_eq!(list, "$2, $3, $4");
229    }
230
231    #[test]
232    fn placeholder_list_empty() {
233        assert_eq!(placeholder_list(1, 0), "");
234    }
235}