Skip to main content

dactyl_db/
lib.rs

1//! Dactyl — the backend-neutral datastore boundary for Decapod.
2//!
3//! The public API contains no `rusqlite` or HTTP adapter types. Callers can
4//! use one-shot functions for small operations or retain a [`Connection`] for
5//! migrations, validation probes, and several operations against one route.
6//! Both paths use the same parameter, row, transaction, and SQL-analysis
7//! contracts.
8
9mod adapter;
10mod rows;
11
12pub mod error;
13pub mod query;
14
15#[doc(hidden)]
16pub mod __private;
17
18pub use dactyl_db_macros::query;
19
20pub use crate::error::DactylError;
21pub use crate::query::{Construct, Dialect, QueryAnalyzer};
22pub use crate::rows::{Parameter, Row, Rows};
23
24use crate::adapter::Adapter;
25use std::time::Duration;
26
27/// A backend selected by a [`DatastoreRoute`].
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum Datastore {
30    Sqlite,
31    Neon,
32}
33
34/// SQLite journal policy. `Wal` is the default for concurrent local readers;
35/// the adapter falls back to `Delete` if the filesystem cannot support WAL.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum SqliteJournalMode {
38    Wal,
39    Delete,
40    Memory,
41    Off,
42}
43
44impl SqliteJournalMode {
45    #[cfg(feature = "sqlite")]
46    pub(crate) fn as_sql(self) -> &'static str {
47        match self {
48            Self::Wal => "WAL",
49            Self::Delete => "DELETE",
50            Self::Memory => "MEMORY",
51            Self::Off => "OFF",
52        }
53    }
54}
55
56impl Datastore {
57    fn dialect(self) -> Dialect {
58        match self {
59            Datastore::Sqlite => Dialect::Sqlite,
60            Datastore::Neon => Dialect::Postgres,
61        }
62    }
63}
64
65/// Explicit route information for a backend connection.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct DatastoreRoute {
68    datastore: Datastore,
69    route: String,
70    token: Option<String>,
71}
72
73impl DatastoreRoute {
74    /// Route a connection to a local SQLite file.
75    pub fn sqlite(path: impl Into<String>) -> Self {
76        Self {
77            datastore: Datastore::Sqlite,
78            route: path.into(),
79            token: None,
80        }
81    }
82
83    /// Route a connection to a Neon/Propodus SQL-over-HTTP endpoint.
84    pub fn neon(endpoint: impl Into<String>, token: Option<String>) -> Self {
85        Self {
86            datastore: Datastore::Neon,
87            route: endpoint.into(),
88            token,
89        }
90    }
91
92    pub fn datastore(&self) -> Datastore {
93        self.datastore
94    }
95
96    pub fn route(&self) -> &str {
97        &self.route
98    }
99
100    pub fn token(&self) -> Option<&str> {
101        self.token.as_deref()
102    }
103
104    /// Resolve the active route from `DATASTORE`, `DATASTORE_ROUTE`, and the
105    /// optional `DATASTORE_TOKEN` environment variables.
106    pub fn from_env() -> Result<Self, DactylError> {
107        let datastore = std::env::var("DATASTORE").map_err(|_| {
108            DactylError::Adapter("DATASTORE is not set: set DATASTORE and DATASTORE_ROUTE".into())
109        })?;
110        let kind = match datastore.as_str() {
111            "sqlite" => Datastore::Sqlite,
112            "neon" => Datastore::Neon,
113            other => Err(DactylError::Adapter(format!(
114                "invalid DATASTORE value {other:?}: must be 'sqlite' or 'neon'"
115            )))?,
116        };
117        let route = std::env::var("DATASTORE_ROUTE").map_err(|_| {
118            DactylError::Adapter(
119                "DATASTORE_ROUTE is not set: set DATASTORE and DATASTORE_ROUTE".into(),
120            )
121        })?;
122        Ok(match kind {
123            Datastore::Sqlite => Self::sqlite(route),
124            Datastore::Neon => Self::neon(route, std::env::var("DATASTORE_TOKEN").ok()),
125        })
126    }
127}
128
129/// Per-connection behavior that is safe to expose across all adapters.
130#[derive(Debug, Clone)]
131pub struct ConnectionOptions {
132    /// Permit only dactyl's explicitly safe dialect rewrites.
133    pub allow_rewrites: bool,
134    /// Open SQLite in read-only mode. Ignored by Neon.
135    pub read_only: bool,
136    /// Busy timeout for SQLite lock contention. Ignored by Neon.
137    pub busy_timeout: Duration,
138    /// Enable SQLite foreign-key enforcement. Ignored by Neon.
139    pub foreign_keys: bool,
140    /// SQLite journal mode. Read-only connections leave the existing mode
141    /// unchanged. Ignored by Neon.
142    pub journal_mode: Option<SqliteJournalMode>,
143}
144
145impl Default for ConnectionOptions {
146    fn default() -> Self {
147        Self {
148            allow_rewrites: false,
149            read_only: false,
150            busy_timeout: Duration::from_secs(5),
151            foreign_keys: true,
152            journal_mode: Some(SqliteJournalMode::Wal),
153        }
154    }
155}
156
157impl ConnectionOptions {
158    pub fn with_rewrites(mut self, allow: bool) -> Self {
159        self.allow_rewrites = allow;
160        self
161    }
162
163    pub fn read_only(mut self, read_only: bool) -> Self {
164        self.read_only = read_only;
165        self
166    }
167
168    fn from_env() -> Self {
169        let allow_rewrites = std::env::var("DATASTORE_REWRITE")
170            .ok()
171            .map(|value| {
172                matches!(
173                    value.to_ascii_lowercase().as_str(),
174                    "1" | "true" | "yes" | "on"
175                )
176            })
177            .unwrap_or(false);
178        Self {
179            allow_rewrites,
180            ..Self::default()
181        }
182    }
183}
184
185/// A connection-scoped backend-neutral datastore handle.
186pub struct Connection {
187    adapter: Box<dyn Adapter>,
188    route: DatastoreRoute,
189    options: ConnectionOptions,
190}
191
192impl Connection {
193    /// Open a route with the default policy.
194    pub fn open(route: DatastoreRoute) -> Result<Self, DactylError> {
195        Self::open_with_options(route, ConnectionOptions::default())
196    }
197
198    /// Open a route with explicit connection policy.
199    #[cfg(any(feature = "sqlite", feature = "neon"))]
200    pub fn open_with_options(
201        route: DatastoreRoute,
202        options: ConnectionOptions,
203    ) -> Result<Self, DactylError> {
204        let adapter: Box<dyn Adapter> = match route.datastore {
205            Datastore::Sqlite => {
206                #[cfg(feature = "sqlite")]
207                {
208                    Box::new(
209                        crate::adapter::sqlite::SqliteAdapter::open_with_options(
210                            &route.route,
211                            options.read_only,
212                            options.busy_timeout,
213                            options.foreign_keys,
214                            options.journal_mode,
215                        )
216                        .map_err(|e| DactylError::Adapter(format!("sqlite open: {e}")))?,
217                    )
218                }
219                #[cfg(not(feature = "sqlite"))]
220                {
221                    return Err(DactylError::Adapter(
222                        "sqlite adapter requested but `sqlite` feature is disabled".into(),
223                    ));
224                }
225            }
226            Datastore::Neon => {
227                #[cfg(feature = "neon")]
228                {
229                    Box::new(crate::adapter::neon::NeonAdapter::new(
230                        &route.route,
231                        route.token.clone(),
232                    ))
233                }
234                #[cfg(not(feature = "neon"))]
235                {
236                    return Err(DactylError::Adapter(
237                        "neon adapter requested but `neon` feature is disabled".into(),
238                    ));
239                }
240            }
241        };
242        Ok(Self {
243            adapter,
244            route,
245            options,
246        })
247    }
248
249    /// Keep the crate's no-feature build useful for analysis-only consumers.
250    #[cfg(not(any(feature = "sqlite", feature = "neon")))]
251    pub fn open_with_options(
252        _route: DatastoreRoute,
253        _options: ConnectionOptions,
254    ) -> Result<Self, DactylError> {
255        Err(DactylError::Adapter(
256            "no datastore adapter feature is enabled; enable `sqlite` or `neon`".into(),
257        ))
258    }
259
260    /// Open the active environment-selected route.
261    pub fn from_env() -> Result<Self, DactylError> {
262        Self::open_with_options(DatastoreRoute::from_env()?, ConnectionOptions::from_env())
263    }
264
265    pub fn datastore(&self) -> Datastore {
266        self.route.datastore
267    }
268
269    pub fn dialect(&self) -> Dialect {
270        self.route.datastore.dialect()
271    }
272
273    pub fn route(&self) -> &DatastoreRoute {
274        &self.route
275    }
276
277    pub fn options(&self) -> &ConnectionOptions {
278        &self.options
279    }
280
281    /// Execute a query or write and return its projected rows.
282    pub fn query(&self, sql: &str, params: &[Parameter]) -> Result<Rows, DactylError> {
283        let prepared = self.prepare(sql)?;
284        self.adapter.execute(&prepared, params)
285    }
286
287    /// Execute one DDL/migration/affected-row statement.
288    pub fn execute(&self, sql: &str, params: &[Parameter]) -> Result<u64, DactylError> {
289        let prepared = self.prepare(sql)?;
290        self.adapter.execute_raw(&prepared, params)
291    }
292
293    /// Execute a caller-owned SQL script containing multiple statements.
294    pub fn execute_batch(&self, sql: &str) -> Result<(), DactylError> {
295        let prepared = self.prepare(sql)?;
296        self.adapter.execute_script(&prepared)
297    }
298
299    /// Execute an all-or-nothing batch of parameterized statements.
300    pub fn transaction(&self, statements: &[Statement]) -> Result<Vec<Rows>, DactylError> {
301        if statements.is_empty() {
302            return Ok(Vec::new());
303        }
304        let prepared = statements
305            .iter()
306            .map(|statement| {
307                Ok(Statement {
308                    sql: self.prepare(&statement.sql)?,
309                    params: statement.params.clone(),
310                })
311            })
312            .collect::<Result<Vec<_>, DactylError>>()?;
313        self.adapter.execute_batch(&prepared)
314    }
315
316    /// Return the adapter's last generated insert id when available.
317    pub fn last_insert_id(&self) -> Result<i64, DactylError> {
318        self.adapter.last_insert_id()
319    }
320
321    /// Execute the operation-based thin-waist contract used by callers that
322    /// must support both local and remote storage without closure types tied to
323    /// SQLite.
324    pub fn execute_op(&self, op: StorageOp) -> Result<StorageResult, DactylError> {
325        match op {
326            StorageOp::Query { sql, params } => self.query(&sql, &params).map(StorageResult::Rows),
327            StorageOp::Execute { sql, params } => {
328                self.execute(&sql, &params).map(StorageResult::Affected)
329            }
330            StorageOp::Script { sql } => self.execute_batch(&sql).map(|()| StorageResult::Unit),
331            StorageOp::Transaction { statements } => {
332                self.transaction(&statements).map(StorageResult::Batch)
333            }
334            StorageOp::LastInsertId => self.last_insert_id().map(StorageResult::LastInsertId),
335        }
336    }
337
338    fn prepare(&self, sql: &str) -> Result<String, DactylError> {
339        QueryAnalyzer::new().prepare(sql, self.dialect(), self.options.allow_rewrites)
340    }
341}
342
343/// Operation-based storage contract for integration boundaries.
344#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
345pub enum StorageOp {
346    Query { sql: String, params: Vec<Parameter> },
347    Execute { sql: String, params: Vec<Parameter> },
348    Script { sql: String },
349    Transaction { statements: Vec<Statement> },
350    LastInsertId,
351}
352
353/// Results for [`StorageOp`].
354#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
355pub enum StorageResult {
356    Rows(Rows),
357    Affected(u64),
358    Unit,
359    Batch(Vec<Rows>),
360    LastInsertId(i64),
361}
362
363/// A parameterized SQL statement for batch execution.
364#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
365pub struct Statement {
366    pub sql: String,
367    pub params: Vec<Parameter>,
368}
369
370impl Statement {
371    pub fn new(sql: &str, params: Vec<Parameter>) -> Self {
372        Self {
373            sql: sql.to_string(),
374            params,
375        }
376    }
377}
378
379/// Test-only compatibility helper. Connections are explicit and there is no
380/// process-wide cache to clear.
381#[doc(hidden)]
382pub fn reset() {}
383
384fn connection_for_query(sql: &str) -> Result<Connection, DactylError> {
385    let route = route_for_query(sql)?;
386    Connection::open_with_options(route, ConnectionOptions::from_env())
387}
388
389fn route_for_query(sql: &str) -> Result<DatastoreRoute, DactylError> {
390    let active = DatastoreRoute::from_env()?;
391    let analyzed = QueryAnalyzer::new().analyze(sql);
392    let Some(inline) = analyzed.inline_override else {
393        return Ok(active);
394    };
395    let Some(inline_datastore) = (match inline {
396        "sqlite" => Some(Datastore::Sqlite),
397        "neon" => Some(Datastore::Neon),
398        _ => None,
399    }) else {
400        return Err(DactylError::Routing(format!(
401            "unknown inline datastore {inline:?}"
402        )));
403    };
404    if inline_datastore == active.datastore {
405        return Ok(active);
406    }
407
408    let (route_var, token_var) = match inline_datastore {
409        Datastore::Sqlite => ("DATASTORE_SQLITE_ROUTE", None),
410        Datastore::Neon => ("DATASTORE_NEON_ROUTE", Some("DATASTORE_NEON_TOKEN")),
411    };
412    let route = std::env::var(route_var).map_err(|_| {
413        DactylError::Routing(format!(
414            "inline datastore {inline:?} requires {route_var} when it differs from DATASTORE"
415        ))
416    })?;
417    let token = token_var
418        .and_then(|name| std::env::var(name).ok())
419        .or_else(|| {
420            (inline_datastore == Datastore::Neon)
421                .then(|| std::env::var("DATASTORE_TOKEN").ok())
422                .flatten()
423        });
424    Ok(match inline_datastore {
425        Datastore::Sqlite => DatastoreRoute::sqlite(route),
426        Datastore::Neon => DatastoreRoute::neon(route, token),
427    })
428}
429
430/// Execute any query against the active environment-selected datastore.
431pub fn query(sql: &str, params: &[Parameter]) -> Result<Rows, DactylError> {
432    connection_for_query(sql)?.query(sql, params)
433}
434
435/// Execute one DDL/migration/affected-row statement.
436pub fn execute(sql: &str, params: &[Parameter]) -> Result<u64, DactylError> {
437    connection_for_query(sql)?.execute(sql, params)
438}
439
440/// Execute a caller-owned SQL script.
441pub fn execute_batch(sql: &str) -> Result<(), DactylError> {
442    connection_for_query(sql)?.execute_batch(sql)
443}
444
445/// Execute an atomic batch of parameterized statements.
446pub fn transaction(statements: &[Statement]) -> Result<Vec<Rows>, DactylError> {
447    if statements.is_empty() {
448        return Ok(Vec::new());
449    }
450    connection_for_query(&statements[0].sql)?.transaction(statements)
451}