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