Skip to main content

akar_postgres/
lib.rs

1//! PostgreSQL extension for Akar.
2//!
3//! Provides integration with PostgreSQL databases using the native
4//! `tokio-postgres` crate with a synchronous wrapper via `block_on`.
5//!
6//! ## Native Rust approach
7//! Uses `tokio-postgres` (v0.7) with `futures::executor::block_on()` to bridge
8//! the async PostgreSQL client with Akar's synchronous runtime.
9//! Most complex extension due to catalog binding + table enumeration + type mapping.
10
11use akar_extension::{Extension, ExtensionContext};
12use std::sync::Arc;
13
14/// Render a single cell of a PostgreSQL row as text.
15///
16/// `try_get` needs a concrete type at compile time, so we cascade through the
17/// common representations. A NULL cell yields `Ok(None)` for every `Option<T>`
18/// probe and is reported as "NULL"; cells whose type matches none of the probes
19/// (e.g. bytea, timestamps) fall back to their column type name.
20#[cfg(feature = "native")]
21fn postgres_value_to_string(row: &tokio_postgres::Row, i: usize) -> String {
22    let type_name = row
23        .columns()
24        .get(i)
25        .map(|c| c.type_().name())
26        .unwrap_or("unknown")
27        .to_string();
28    if let Ok(Some(v)) = row.try_get::<_, Option<String>>(i) {
29        return v;
30    }
31    if let Ok(Some(v)) = row.try_get::<_, Option<i64>>(i) {
32        return v.to_string();
33    }
34    if let Ok(Some(v)) = row.try_get::<_, Option<i32>>(i) {
35        return v.to_string();
36    }
37    if let Ok(Some(v)) = row.try_get::<_, Option<f64>>(i) {
38        return v.to_string();
39    }
40    if let Ok(Some(v)) = row.try_get::<_, Option<bool>>(i) {
41        return v.to_string();
42    }
43    // A NULL cell succeeds with None for any Option<T> probe.
44    if row.try_get::<_, Option<i64>>(i).is_ok() {
45        "NULL".to_string()
46    } else {
47        format!("<{type_name}>")
48    }
49}
50
51/// Shared PostgreSQL runtime, connection cache and query helper.
52///
53/// Reuses a single process-wide tokio runtime and caches one connection per
54/// connection string, instead of building a fresh multi-thread runtime and a
55/// fresh TCP connection on every `sql_query` call (P52.37). A `connect_timeout`
56/// is enforced so an unreachable host cannot block the caller forever.
57#[cfg(feature = "native")]
58mod runtime {
59    use std::collections::HashMap;
60    use std::sync::{Arc, Mutex, OnceLock};
61    use std::time::Duration;
62
63    static RUNTIME: OnceLock<Result<tokio::runtime::Runtime, String>> = OnceLock::new();
64    static CONNECTIONS: OnceLock<Mutex<HashMap<String, Arc<tokio_postgres::Client>>>> = OnceLock::new();
65
66    fn runtime() -> Result<&'static tokio::runtime::Runtime, String> {
67        RUNTIME
68            .get_or_init(|| {
69                tokio::runtime::Builder::new_multi_thread()
70                    .enable_all()
71                    .build()
72                    .map_err(|e| format!("Failed to create tokio runtime: {e}"))
73            })
74            .as_ref()
75            .map_err(|e| e.clone())
76    }
77
78    fn connections() -> &'static Mutex<HashMap<String, Arc<tokio_postgres::Client>>> {
79        CONNECTIONS.get_or_init(Default::default)
80    }
81
82    pub fn query(conn_str: &str, sql: &str) -> Result<Vec<tokio_postgres::Row>, String> {
83        let rt = runtime()?;
84
85        let mut config = conn_str
86            .parse::<tokio_postgres::Config>()
87            .map_err(|e| format!("Invalid PostgreSQL connection string: {e}"))?;
88
89        // Fail secure rather than silently sending credentials over plaintext
90        // when the caller explicitly requires TLS (which we cannot provide yet).
91        if config.get_ssl_mode() == tokio_postgres::config::SslMode::Require {
92            return Err(
93                "sslmode=require requested but TLS support is not compiled into akar-postgres \
94                 (use sslmode=disable or sslmode=prefer)"
95                    .into(),
96            );
97        }
98        config.connect_timeout(Duration::from_secs(10));
99
100        let mut cache = connections()
101            .lock()
102            .map_err(|_| "Connection cache lock poisoned".to_string())?;
103
104        let client = match cache.get(conn_str) {
105            Some(c) => Arc::clone(c),
106            None => {
107                let (client, connection) = rt
108                    .block_on(async { config.connect(tokio_postgres::NoTls).await })
109                    .map_err(|e| format!("PostgreSQL connect error: {e}"))?;
110                rt.spawn(async move {
111                    if let Err(e) = connection.await {
112                        tracing::warn!("PostgreSQL connection error: {e}");
113                    }
114                });
115                let client = Arc::new(client);
116                cache.insert(conn_str.to_string(), Arc::clone(&client));
117                client
118            }
119        };
120
121        match rt.block_on(async { client.query(sql, &[]).await }) {
122            Ok(rows) => Ok(rows),
123            Err(e) => {
124                // Drop a dead connection so the next call reconnects.
125                if e.is_closed() {
126                    cache.remove(conn_str);
127                }
128                Err(format!("PostgreSQL query error: {e}"))
129            }
130        }
131    }
132}
133
134/// The PostgreSQL extension enables querying PostgreSQL databases from Akar.
135pub struct PostgresExtension;
136
137impl Default for PostgresExtension {
138    fn default() -> Self {
139        Self::new()
140    }
141}
142
143impl PostgresExtension {
144    pub fn new() -> Self {
145        Self
146    }
147}
148
149impl Extension for PostgresExtension {
150    fn name(&self) -> &'static str {
151        "POSTGRES"
152    }
153
154    fn load(&self, context: &ExtensionContext) -> Result<(), String> {
155        use akar_function::registry::ScalarFunction;
156
157        #[cfg(feature = "native")]
158        {
159            use akar_function::Value;
160
161            // sql_query(conn_str: String, sql: String) → executes SQL against PostgreSQL
162            let query_fn: Arc<dyn Fn(&[Value]) -> Result<Value, String> + Send + Sync> = Arc::new(|args| {
163                if args.len() < 2 {
164                    return Err("sql_query requires (connection_string, sql) arguments".into());
165                }
166                let conn_str = match &args[0] {
167                    Value::String(s) => s.clone(),
168                    _ => return Err("sql_query: first argument must be a connection string".into()),
169                };
170                let sql = match &args[1] {
171                    Value::String(s) => s.clone(),
172                    _ => return Err("sql_query: second argument must be a SQL string".into()),
173                };
174
175                let rows = runtime::query(&conn_str, &sql)?;
176
177                // Collect every row (all columns) as strings, not just the first.
178                let mut parts = Vec::new();
179                for row in &rows {
180                    for i in 0..row.len() {
181                        parts.push(postgres_value_to_string(row, i));
182                    }
183                }
184                if parts.is_empty() {
185                    Ok(Value::String("(empty)".into()))
186                } else {
187                    Ok(Value::String(parts.join(",")))
188                }
189            });
190
191            context.register_scalar_function(
192                "sql_query",
193                ScalarFunction::CustomScalar {
194                    name: "sql_query".into(),
195                    execute: query_fn,
196                },
197            );
198
199            tracing::info!("PostgreSQL extension loaded: 1 function registered (tokio-postgres native)");
200        }
201
202        #[cfg(not(feature = "native"))]
203        {
204            context.register_scalar_function(
205                "sql_query",
206                ScalarFunction::CustomScalar {
207                    name: "sql_query".into(),
208                    execute: Arc::new(|_| Err("PostgreSQL not available (feature 'native' disabled)".into())),
209                },
210            );
211            tracing::info!("PostgreSQL extension loaded: 1 function registered (placeholder)");
212        }
213
214        Ok(())
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    #[test]
223    fn test_postgres_extension_name() {
224        let ext = PostgresExtension::new();
225        assert_eq!(ext.name(), "POSTGRES");
226    }
227}