Skip to main content

dactyl_db/
lib.rs

1//! Dactyl — the governed datastore boundary for Decapod.
2//!
3//! The public API is intentionally tiny. There is no `init`. There is no
4//! configuration step. The first call to [`read`] or [`write`] establishes
5//! the connection.
6//!
7//! ```ignore
8//! use dactyl::Rows;
9//!
10//! let rows: Rows = dactyl::read("select id, title from todos", true)?;
11//! ```
12//!
13//! ## How dactyl picks the adapter
14//!
15//! On the first call, dactyl consults the environment for the target adapter:
16//!
17//! - `DATASTORE` — must be set to either `"sqlite"` or `"neon"`.
18//! - `DATASTORE_ROUTE` — when `DATASTORE` is `"sqlite"`, this is the path to the SQLite file.
19//!   When `DATASTORE` is `"neon"`, this is the Propodus endpoint URL.
20//!
21//! If the new `DATASTORE` variable is not set, dactyl falls back to the legacy environment variables
22//! (`DACTYL_NEON_ENDPOINT`, `DACTYL_NEON_BEARER`, `DACTYL_SQLITE_PATH`, `DACTYL_SQLITE_ROOT`) for backwards compatibility.
23//!
24//! The connection is held in a `OnceLock` for the lifetime of the process.
25
26pub mod adapter;
27pub mod error;
28pub mod query;
29mod rows;
30
31#[doc(hidden)]
32pub mod __private;
33
34pub use dactyl_db_macros::query;
35
36pub use crate::error::DactylError;
37pub use crate::rows::{Row, Rows};
38
39use std::sync::{Arc, Mutex, OnceLock};
40
41use crate::adapter::Adapter;
42
43/// Lazy connections, keyed by the connection string. Populated by the first
44/// `read` / `write` call for a given key. Tests may reset it.
45static CONNECTIONS: OnceLock<Mutex<std::collections::HashMap<String, Arc<dyn Adapter>>>> =
46    OnceLock::new();
47
48fn connections() -> &'static Mutex<std::collections::HashMap<String, Arc<dyn Adapter>>> {
49    CONNECTIONS.get_or_init(|| Mutex::new(std::collections::HashMap::new()))
50}
51
52/// Reset the cached connections. Test-only helper exposed to integration
53/// tests; production code should never call this.
54#[doc(hidden)]
55pub fn __reset_for_tests() {
56    let mut guard = connections()
57        .lock()
58        .expect("dactyl connection lock poisoned");
59    guard.clear();
60}
61
62/// Execute a read against the dactyl connection.
63///
64/// The first call lazily establishes the connection (see module docs for the
65/// selection rules). Subsequent calls reuse it.
66pub fn read(query: &str, optimize: bool) -> Result<Rows, DactylError> {
67    dispatch(query, optimize, false)
68}
69
70/// Execute a write against the dactyl connection.
71pub fn write(query: &str, optimize: bool) -> Result<Rows, DactylError> {
72    dispatch(query, optimize, true)
73}
74
75fn validate_env() -> Result<(), DactylError> {
76    if let Ok(ds) = std::env::var("DATASTORE") {
77        if ds != "sqlite" && ds != "neon" {
78            return Err(DactylError::Adapter(
79                "invalid DATASTORE value: must be 'sqlite' or 'neon'".to_string(),
80            ));
81        }
82        if std::env::var("DATASTORE_ROUTE").is_err() {
83            return Err(DactylError::Adapter(
84                "DATASTORE_ROUTE is required when DATASTORE is set".into(),
85            ));
86        }
87    } else {
88        let has_legacy = std::env::var("DACTYL_NEON_ENDPOINT").is_ok()
89            || std::env::var("DACTYL_SQLITE_PATH").is_ok();
90        if !has_legacy {
91            return Err(DactylError::Adapter(
92                "no adapter configured: set DATASTORE and DATASTORE_ROUTE".into(),
93            ));
94        }
95    }
96    Ok(())
97}
98
99fn dispatch(query: &str, optimize: bool, write: bool) -> Result<Rows, DactylError> {
100    validate_env()?;
101
102    let analyzer = query::QueryAnalyzer::new();
103    let analyzed = analyzer.analyze(query);
104
105    // Dialect for the mismatch check. The inline `-- dactyl: <store>`
106    // directive overrides the env-derived dialect so a single query can
107    // target a different adapter for routing-only purposes.
108    let inferred_dialect = infer_dialect(&analyzed);
109
110    // Pick the adapter up-front so we can enforce the dialect check.
111    let key = connection_key().ok_or_else(|| {
112        DactylError::Adapter("no adapter configured: set DATASTORE and DATASTORE_ROUTE".into())
113    })?;
114    let adapter = connection(&key, query, &analyzed)?;
115    if !optimize {
116        if let Some(c) = query::first_unsupported(&analyzed.constructs, inferred_dialect) {
117            return Err(DactylError::Unsupported { construct: c });
118        }
119    }
120
121    let sql = analyzed.rewrite.apply(query);
122    let params = serde_json::Value::Null;
123    adapter.execute(&sql, Some(&params), optimize, write)
124}
125
126fn infer_dialect(analyzed: &query::Analyzed) -> query::Dialect {
127    // The dialect we treat as "native" for the dialect-mismatch check.
128    // The inline `-- dactyl: <store>` directive wins when present; otherwise
129    // we fall back to whichever adapter is configured in the environment.
130    if let Some(override_ds) = analyzed.inline_override {
131        return match override_ds {
132            "neon" => query::Dialect::Postgres,
133            _ => query::Dialect::Sqlite,
134        };
135    }
136    if let Ok(ds) = std::env::var("DATASTORE") {
137        return match ds.as_str() {
138            "neon" => query::Dialect::Postgres,
139            _ => query::Dialect::Sqlite,
140        };
141    }
142    if std::env::var("DACTYL_NEON_ENDPOINT").is_ok() {
143        query::Dialect::Postgres
144    } else {
145        query::Dialect::Sqlite
146    }
147}
148
149/// Establish (or return) the adapter for the given key.
150fn connection(
151    key: &str,
152    query: &str,
153    analyzed: &query::Analyzed,
154) -> Result<Arc<dyn Adapter>, DactylError> {
155    {
156        let guard = connections()
157            .lock()
158            .expect("dactyl connection lock poisoned");
159        if let Some(existing) = guard.get(key) {
160            return Ok(existing.clone());
161        }
162    }
163    let adapter = build_adapter(query, analyzed)?;
164    let mut guard = connections()
165        .lock()
166        .expect("dactyl connection lock poisoned");
167    if let Some(existing) = guard.get(key) {
168        return Ok(existing.clone());
169    }
170    guard.insert(key.to_string(), adapter.clone());
171    Ok(adapter)
172}
173
174/// Compute the cache key for the current env config. SQLite paths use the
175/// resolved file path; neon uses the endpoint URL.
176fn connection_key() -> Option<String> {
177    if let Ok(ds) = std::env::var("DATASTORE") {
178        if let Ok(route) = std::env::var("DATASTORE_ROUTE") {
179            return Some(format!("{ds}:{route}"));
180        }
181    }
182    if let Ok(endpoint) = std::env::var("DACTYL_NEON_ENDPOINT") {
183        return Some(format!("neon:{endpoint}"));
184    }
185    if let Ok(path) = std::env::var("DACTYL_SQLITE_PATH") {
186        return Some(format!("sqlite:{path}"));
187    }
188    None
189}
190
191#[cfg(feature = "sqlite")]
192fn sqlite_adapter(path: &str) -> Result<Arc<dyn Adapter>, DactylError> {
193    use crate::adapter::sqlite::SqliteAdapter;
194    let adapter =
195        SqliteAdapter::open(path).map_err(|e| DactylError::Adapter(format!("sqlite open: {e}")))?;
196    Ok(Arc::new(adapter))
197}
198
199#[cfg(feature = "neon")]
200fn neon_adapter() -> Result<Arc<dyn Adapter>, DactylError> {
201    use crate::adapter::neon::NeonAdapter;
202    let (endpoint, bearer) = resolve_neon_config()?;
203    let adapter = NeonAdapter::new(&endpoint, bearer, None);
204    Ok(Arc::new(adapter))
205}
206
207#[cfg(not(feature = "sqlite"))]
208fn sqlite_adapter(_path: &str) -> Result<Arc<dyn Adapter>, DactylError> {
209    Err(DactylError::Adapter(
210        "sqlite adapter requested but `sqlite` feature is disabled".into(),
211    ))
212}
213
214#[cfg(not(feature = "neon"))]
215fn neon_adapter() -> Result<Arc<dyn Adapter>, DactylError> {
216    Err(DactylError::Adapter(
217        "neon adapter requested but `neon` feature is disabled".into(),
218    ))
219}
220
221#[cfg(feature = "neon")]
222fn resolve_neon_config() -> Result<(String, Option<String>), DactylError> {
223    if let Ok(ds) = std::env::var("DATASTORE") {
224        if ds == "neon" {
225            let route = std::env::var("DATASTORE_ROUTE")
226                .map_err(|_| DactylError::Adapter("DATASTORE_ROUTE not set".into()))?;
227            let token = std::env::var("DATASTORE_TOKEN")
228                .ok()
229                .or_else(|| std::env::var("DACTYL_NEON_BEARER").ok());
230            return Ok((route, token));
231        }
232    }
233    let endpoint = std::env::var("DACTYL_NEON_ENDPOINT")
234        .map_err(|_| DactylError::Adapter("DACTYL_NEON_ENDPOINT not set".into()))?;
235    let bearer = std::env::var("DACTYL_NEON_BEARER").ok();
236    Ok((endpoint, bearer))
237}
238
239fn resolve_sqlite_path(query: &str) -> Result<String, DactylError> {
240    if let Ok(ds) = std::env::var("DATASTORE") {
241        if ds == "sqlite" {
242            let route = std::env::var("DATASTORE_ROUTE")
243                .map_err(|_| DactylError::Adapter("DATASTORE_ROUTE not set".into()))?;
244            return Ok(route);
245        }
246    }
247    if let Ok(p) = std::env::var("DACTYL_SQLITE_PATH") {
248        return Ok(p);
249    }
250    let default_root =
251        std::env::var("DACTYL_SQLITE_ROOT").unwrap_or_else(|_| ".decapod/data".to_string());
252    let store = infer_store(query).unwrap_or_else(|| "dactyl".to_string());
253    Ok(format!("{default_root}/{store}.db"))
254}
255
256fn build_adapter(query: &str, analyzed: &query::Analyzed) -> Result<Arc<dyn Adapter>, DactylError> {
257    let neon_env = if let Ok(ds) = std::env::var("DATASTORE") {
258        ds == "neon"
259    } else {
260        std::env::var("DACTYL_NEON_ENDPOINT").is_ok()
261    };
262    let sqlite_only = !analyzed.constructs.is_empty()
263        && analyzed
264            .constructs
265            .iter()
266            .all(|c| c.dialect() == query::Dialect::Sqlite);
267    let postgres_only = !analyzed.constructs.is_empty()
268        && analyzed
269            .constructs
270            .iter()
271            .all(|c| c.dialect() == query::Dialect::Postgres);
272
273    if neon_env && !sqlite_only {
274        neon_adapter()
275    } else if !neon_env && postgres_only {
276        // Caller is asking for postgres but hasn't configured the endpoint.
277        // Fall back to sqlite (which will fail at the adapter); the caller
278        // gets a clear error rather than a silent success.
279        sqlite_adapter(&resolve_sqlite_path(query)?)
280    } else {
281        sqlite_adapter(&resolve_sqlite_path(query)?)
282    }
283}
284
285/// Extract the first `from <name>` (or first table-shaped identifier) from
286/// the query. Used to pick a default SQLite path when the caller hasn't
287/// supplied one.
288fn infer_store(query: &str) -> Option<String> {
289    let lower = query.to_ascii_lowercase();
290    let mut iter = lower.split_whitespace();
291    while let Some(tok) = iter.next() {
292        if tok == "from" || tok == "into" || tok == "update" || tok == "table" {
293            if let Some(name) = iter.next() {
294                return Some(name.trim_end_matches(';').to_string());
295            }
296        }
297    }
298    None
299}