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//! `sqlite` and `postgres` are mutually exclusive: exactly one must be enabled.
16//! The root workspace default includes `zeph-db/sqlite`. Enabling both, or
17//! neither, is a compile error. Use `--no-default-features --features postgres`
18//! for a `PostgreSQL` build.
19
20#[cfg(all(feature = "sqlite", feature = "postgres"))]
21compile_error!("features `sqlite` and `postgres` are mutually exclusive for `zeph-db`");
22
23#[cfg(not(any(feature = "sqlite", feature = "postgres")))]
24compile_error!("exactly one of `sqlite` or `postgres` must be enabled for `zeph-db`");
25
26pub mod bounds;
27pub mod dialect;
28pub mod driver;
29pub mod error;
30pub mod fts;
31pub mod graph_store;
32pub mod instance_id;
33pub mod migrate;
34pub mod pool;
35pub mod transaction;
36
37pub use bounds::FullDriver;
38pub use dialect::{Dialect, Postgres, Sqlite};
39pub use driver::DatabaseDriver;
40pub use error::DbError;
41pub use graph_store::{GraphSummary, RawGraphStore};
42pub use instance_id::get_or_create_instance_id;
43pub use migrate::run_migrations;
44pub use pool::{DbConfig, redact_url};
45pub use transaction::{begin, begin_write};
46
47// Re-export sqlx query builders bound to the active backend.
48pub use sqlx::query_builder::QueryBuilder;
49pub use sqlx::{Error as SqlxError, Executor, FromRow, Row, query, query_as, query_scalar};
50// Re-export the full sqlx crate so consumers can use `zeph_db::sqlx::Type` etc.
51pub use sqlx;
52
53// --- Active driver type alias ---
54
55/// The active database driver, selected at compile time.
56#[cfg(all(feature = "sqlite", not(feature = "postgres")))]
57pub type ActiveDriver = driver::SqliteDriver;
58#[cfg(feature = "postgres")]
59pub type ActiveDriver = driver::PostgresDriver;
60
61// --- Convenience type aliases ---
62
63/// A connection pool for the active database backend.
64///
65/// Resolves to `sqlx::SqlitePool` or `sqlx::PgPool` at compile time depending on the active driver.
66pub type DbPool = sqlx::Pool<<ActiveDriver as DatabaseDriver>::Database>;
67
68/// A row from the active database backend.
69pub type DbRow = <<ActiveDriver as DatabaseDriver>::Database as sqlx::Database>::Row;
70
71/// A query result from the active database backend.
72pub type DbQueryResult =
73    <<ActiveDriver as DatabaseDriver>::Database as sqlx::Database>::QueryResult;
74
75/// A transaction on the active database backend.
76pub type DbTransaction<'a> = sqlx::Transaction<'a, <ActiveDriver as DatabaseDriver>::Database>;
77
78/// The active SQL dialect type.
79pub type ActiveDialect = <ActiveDriver as DatabaseDriver>::Dialect;
80
81// --- sql! macro ---
82
83/// Convert SQL with `?` placeholders to the active backend's placeholder style.
84///
85/// `SQLite`: returns the input `&str` directly — zero allocation, zero runtime cost.
86///
87/// `PostgreSQL`: rewrites `?` to `$1`, `$2`, ... and `?N` (`SQLite`'s numbered
88/// placeholder, e.g. `?1`) to `$N` using [`rewrite_placeholders`]. Repeated
89/// references to the same `?N` collapse to the same `$N`. The rewrite runs at
90/// most once per call site: the result is memoized in a call-site-local
91/// `LazyLock<String>`, so a query executed in a loop or on a hot path incurs
92/// the rewrite cost once per process lifetime rather than once per execution.
93/// This requires `$query` to be a compile-time-constant expression (in
94/// practice, always a string literal at every call site in this workspace) —
95/// the closure passed to `LazyLock::new` captures `$query` and runs exactly
96/// once, so a runtime-varying `$query` would silently freeze at its
97/// first-seen value. Do NOT wrap `PostgreSQL` JSONB queries using `?`/`?|`/`?&`
98/// operators through this macro; use `$N` placeholders directly for those.
99///
100/// # Example
101///
102/// ```rust,ignore
103/// let rows = sqlx::query(sql!("SELECT id FROM messages WHERE conversation_id = ?"))
104///     .bind(cid)
105///     .fetch_all(&pool)
106///     .await?;
107/// ```
108#[cfg(all(feature = "sqlite", not(feature = "postgres")))]
109#[macro_export]
110macro_rules! sql {
111    ($query:expr) => {
112        $query
113    };
114}
115
116#[cfg(feature = "postgres")]
117#[macro_export]
118macro_rules! sql {
119    ($query:expr) => {{
120        // A `static` item inside a macro expansion is instantiated once per
121        // call site (macros expand textually), giving each call site its own
122        // `LazyLock`. The rewrite runs on first execution of this call site
123        // and is cached for the process lifetime — no per-execution leak.
124        static CACHE: ::std::sync::LazyLock<::std::string::String> =
125            ::std::sync::LazyLock::new(|| $crate::rewrite_placeholders($query));
126        CACHE.as_str()
127    }};
128}
129
130/// Returns `true` if the given database URL looks like a `PostgreSQL` connection string.
131///
132/// Check whether `url` looks like a `PostgreSQL` connection URL.
133///
134/// Used to detect misconfigured `database_url` values (e.g. a `SQLite` path passed
135/// to a postgres build, or vice versa).
136#[must_use]
137pub fn is_postgres_url(url: &str) -> bool {
138    url.starts_with("postgres://") || url.starts_with("postgresql://")
139}
140
141/// Rewrite `?` bind markers to `$1, $2, ...` for `PostgreSQL`.
142///
143/// Handles both bare `?` (`SQLite`'s "next unused index" placeholder) and
144/// numbered `?N` (`SQLite`'s explicit-index placeholder, e.g. `?1`, `?2`).
145/// A numbered `?N` is rewritten directly to `$N` — preserving the index
146/// rather than renumbering by position — so that repeated references to the
147/// same `?N` collapse to the same `$N` and require only one `.bind()` call,
148/// matching both `SQLite`'s and `sqlx`'s `PostgreSQL` binding semantics (bind
149/// values are supplied once per distinct slot, in ascending slot order,
150/// regardless of how many times the slot's placeholder appears in the SQL
151/// text). A bare `?` is assigned the next index after the highest index used
152/// so far (explicit or bare), matching `SQLite`'s own numbering rule.
153///
154/// Skips `?` inside single-quoted string literals. Does NOT handle dollar-quoted
155/// strings (`$$...$$`) or `?` inside comments — document this limitation at call
156/// sites where those patterns appear.
157#[must_use]
158pub fn rewrite_placeholders(query: &str) -> String {
159    let mut out = String::with_capacity(query.len() + 16);
160    let mut chars = query.chars().peekable();
161    let mut max_n = 0u32;
162    let mut in_string = false;
163    while let Some(ch) = chars.next() {
164        match ch {
165            '\'' => {
166                in_string = !in_string;
167                out.push(ch);
168            }
169            '?' if !in_string => {
170                let digits: String =
171                    std::iter::from_fn(|| chars.next_if(char::is_ascii_digit)).collect();
172                let n = if digits.is_empty() {
173                    max_n += 1;
174                    max_n
175                } else {
176                    let explicit = digits.parse().unwrap_or(u32::MAX);
177                    max_n = max_n.max(explicit);
178                    explicit
179                };
180                out.push('$');
181                out.push_str(&n.to_string());
182            }
183            _ => out.push(ch),
184        }
185    }
186    out
187}
188
189/// Generate a single numbered placeholder for bind position `n` (1-based).
190///
191/// `SQLite`: `?N`, `PostgreSQL`: `$N`
192#[must_use]
193#[cfg(all(feature = "sqlite", not(feature = "postgres")))]
194pub fn numbered_placeholder(n: usize) -> String {
195    format!("?{n}")
196}
197
198/// Generate a single numbered placeholder for bind position `n` (1-based).
199///
200/// `SQLite`: `?N`, `PostgreSQL`: `$N`
201#[must_use]
202#[cfg(feature = "postgres")]
203pub fn numbered_placeholder(n: usize) -> String {
204    format!("${n}")
205}
206
207/// Generate a comma-separated list of placeholders for `count` binds starting at position
208/// `start` (1-based).
209///
210/// Example (SQLite): `placeholder_list(3, 2)` → `"?3, ?4"`
211/// Example (PostgreSQL): `placeholder_list(3, 2)` → `"$3, $4"`
212#[must_use]
213pub fn placeholder_list(start: usize, count: usize) -> String {
214    (start..start + count)
215        .map(numbered_placeholder)
216        .collect::<Vec<_>>()
217        .join(", ")
218}
219
220/// Builds the `LIMIT` clause fragment (with leading space) and its bind value for the
221/// "`0` means unlimited" convention used across list-style queries (`SessionStore::list`,
222/// `list_acp_sessions`, `list_owned_sessions`, `list_agent_sessions`, ...).
223///
224/// `limit == 0` returns `("", None)`: the caller omits the `LIMIT` clause entirely.
225/// This is the only cross-backend-safe way to express "unlimited" — `LIMIT -1`, the
226/// `SQLite`-only convenience sentinel used before this helper existed, is rejected by
227/// `PostgreSQL` at execution time (`ERROR: LIMIT must not be negative`), and binding a
228/// `NULL` in its place is rejected by `SQLite` (`SQLITE_MISMATCH`), so neither backend
229/// accepts a single placeholder value that means "no limit".
230///
231/// Any other value returns `(" LIMIT ?", Some(limit))`; the caller appends the fragment
232/// to the query text and, only when the returned bind value is `Some`, adds a matching
233/// `.bind()` call.
234///
235/// # Examples
236///
237/// ```
238/// use zeph_db::limit_clause;
239///
240/// assert_eq!(limit_clause(0), ("", None));
241/// assert_eq!(limit_clause(10), (" LIMIT ?", Some(10)));
242/// ```
243#[must_use]
244pub fn limit_clause(limit: u64) -> (&'static str, Option<i64>) {
245    if limit == 0 {
246        ("", None)
247    } else {
248        #[allow(clippy::cast_possible_wrap)]
249        (" LIMIT ?", Some(limit as i64))
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn limit_clause_zero_is_unlimited() {
259        assert_eq!(limit_clause(0), ("", None));
260    }
261
262    #[test]
263    fn limit_clause_nonzero_binds_value() {
264        assert_eq!(limit_clause(1), (" LIMIT ?", Some(1)));
265        assert_eq!(limit_clause(42), (" LIMIT ?", Some(42)));
266    }
267
268    #[test]
269    fn rewrite_placeholders_basic() {
270        let out = rewrite_placeholders("SELECT * FROM t WHERE a = ? AND b = ?");
271        assert_eq!(out, "SELECT * FROM t WHERE a = $1 AND b = $2");
272    }
273
274    #[test]
275    fn rewrite_placeholders_skips_string_literals() {
276        let out = rewrite_placeholders("SELECT '?' FROM t WHERE a = ?");
277        assert_eq!(out, "SELECT '?' FROM t WHERE a = $1");
278    }
279
280    #[test]
281    fn rewrite_placeholders_no_params() {
282        let out = rewrite_placeholders("SELECT 1");
283        assert_eq!(out, "SELECT 1");
284    }
285
286    #[test]
287    fn rewrite_placeholders_numbered() {
288        let out = rewrite_placeholders("INSERT INTO t (a, b) VALUES (?1, ?2) RETURNING id");
289        assert_eq!(out, "INSERT INTO t (a, b) VALUES ($1, $2) RETURNING id");
290    }
291
292    #[test]
293    fn rewrite_placeholders_collapses_repeated_numbered() {
294        // `?1` appears twice — SQLite binds it once, so PostgreSQL must too.
295        let out = rewrite_placeholders("INSERT INTO edges (source_id, target_id) VALUES (?1, ?1)");
296        assert_eq!(
297            out,
298            "INSERT INTO edges (source_id, target_id) VALUES ($1, $1)"
299        );
300    }
301
302    #[test]
303    fn rewrite_placeholders_mixed_bare_and_numbered() {
304        // A bare `?` after `?2` continues numbering from the highest index seen (3),
305        // matching SQLite's "next unused index" rule for anonymous placeholders.
306        let out = rewrite_placeholders("SELECT * FROM t WHERE a = ?2 AND b = ? AND c = ?1");
307        assert_eq!(out, "SELECT * FROM t WHERE a = $2 AND b = $3 AND c = $1");
308    }
309
310    #[test]
311    fn numbered_placeholder_one_based() {
312        let p1 = numbered_placeholder(1);
313        let p3 = numbered_placeholder(3);
314        #[cfg(all(feature = "sqlite", not(feature = "postgres")))]
315        {
316            assert_eq!(p1, "?1");
317            assert_eq!(p3, "?3");
318        }
319        #[cfg(feature = "postgres")]
320        {
321            assert_eq!(p1, "$1");
322            assert_eq!(p3, "$3");
323        }
324    }
325
326    #[test]
327    fn placeholder_list_range() {
328        let list = placeholder_list(2, 3);
329        #[cfg(all(feature = "sqlite", not(feature = "postgres")))]
330        assert_eq!(list, "?2, ?3, ?4");
331        #[cfg(feature = "postgres")]
332        assert_eq!(list, "$2, $3, $4");
333    }
334
335    #[test]
336    fn placeholder_list_empty() {
337        assert_eq!(placeholder_list(1, 0), "");
338    }
339}