use std::sync::Arc;
use tokio::runtime::Runtime;
use tokio_postgres::{Client, NoTls};
use super::{Result, Store, StoreError};
#[derive(Clone, Debug)]
pub struct PgConfig {
pub url: String,
pub table: String,
pub prefix: String,
}
impl PgConfig {
pub fn new(url: impl Into<String>, table: impl Into<String>) -> Self {
Self {
url: url.into(),
table: table.into(),
prefix: String::new(),
}
}
}
#[derive(Clone)]
pub struct PgStore {
rt: Arc<Runtime>,
client: Arc<Client>,
table: String,
prefix: String,
}
impl PgStore {
pub fn connect(cfg: PgConfig) -> Result<Self> {
if cfg.table.is_empty()
|| !cfg
.table
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_')
{
return Err(StoreError::InvalidKey(format!(
"invalid table name: {:?}",
cfg.table
)));
}
let rt = Runtime::new().map_err(|e| StoreError::Connection(e.to_string()))?;
let (client, connection) =
crate::store::block_on(&rt, tokio_postgres::connect(&cfg.url, NoTls))
.map_err(|e| StoreError::Connection(e.to_string()))?;
rt.spawn(async move {
let _ = connection.await;
});
let store = Self {
rt: Arc::new(rt),
client: Arc::new(client),
table: cfg.table,
prefix: cfg.prefix,
};
store.ensure_table()?;
Ok(store)
}
fn ensure_table(&self) -> Result<()> {
let sql = format!(
"CREATE TABLE IF NOT EXISTS {} (key TEXT PRIMARY KEY, data BYTEA NOT NULL)",
self.table
);
crate::store::block_on(&self.rt, self.client.execute(&sql, &[]))
.map(|_| ())
.map_err(|e| StoreError::Connection(e.to_string()))
}
fn full(&self, key: &str) -> String {
format!("{}{}", self.prefix, key)
}
}
impl Store for PgStore {
fn get(&self, key: &str) -> Result<Option<Vec<u8>>> {
let full = self.full(key);
let sql = format!("SELECT data FROM {} WHERE key = $1", self.table);
crate::store::block_on(&self.rt, async {
let row = self
.client
.query_opt(&sql, &[&full])
.await
.map_err(|e| StoreError::Connection(e.to_string()))?;
Ok(row.map(|r| r.get::<_, Vec<u8>>(0)))
})
}
fn put(&self, key: &str, bytes: &[u8]) -> Result<()> {
let full = self.full(key);
let data = bytes.to_vec();
let sql = format!(
"INSERT INTO {} (key, data) VALUES ($1, $2) \
ON CONFLICT (key) DO UPDATE SET data = EXCLUDED.data",
self.table
);
crate::store::block_on(&self.rt, async {
self.client
.execute(&sql, &[&full, &data])
.await
.map(|_| ())
.map_err(|e| StoreError::Connection(e.to_string()))
})
}
fn exists(&self, key: &str) -> Result<bool> {
let full = self.full(key);
let sql = format!("SELECT 1 FROM {} WHERE key = $1", self.table);
crate::store::block_on(&self.rt, async {
let row = self
.client
.query_opt(&sql, &[&full])
.await
.map_err(|e| StoreError::Connection(e.to_string()))?;
Ok(row.is_some())
})
}
fn put_if_absent(&self, key: &str, bytes: &[u8]) -> Result<bool> {
let full = self.full(key);
let data = bytes.to_vec();
let sql = format!(
"INSERT INTO {} (key, data) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING",
self.table
);
crate::store::block_on(&self.rt, async {
let n = self
.client
.execute(&sql, &[&full, &data])
.await
.map_err(|e| StoreError::Connection(e.to_string()))?;
Ok(n == 1) })
}
fn delete(&self, key: &str) -> Result<()> {
let full = self.full(key);
let sql = format!("DELETE FROM {} WHERE key = $1", self.table);
crate::store::block_on(&self.rt, async {
self.client
.execute(&sql, &[&full])
.await
.map(|_| ())
.map_err(|e| StoreError::Connection(e.to_string()))
})
}
fn list(&self, prefix: &str) -> Result<Vec<String>> {
let full_prefix = self.full(prefix);
let strip_len = self.prefix.len();
let sql = format!("SELECT key FROM {} WHERE starts_with(key, $1)", self.table);
crate::store::block_on(&self.rt, async {
let rows = self
.client
.query(&sql, &[&full_prefix])
.await
.map_err(|e| StoreError::Connection(e.to_string()))?;
Ok(rows
.iter()
.map(|r| r.get::<_, String>(0)[strip_len..].to_string())
.collect())
})
}
fn list_paginated(
&self,
prefix: &str,
after: Option<&str>,
limit: usize,
) -> Result<(Vec<String>, Option<String>)> {
let full_prefix = self.full(prefix);
let strip_len = self.prefix.len();
let after_full = after.map(|a| self.full(a));
let lim = limit as i64;
crate::store::block_on(&self.rt, async {
let rows = if let Some(af) = &after_full {
let sql = format!(
"SELECT key FROM {} WHERE starts_with(key, $1) AND key > $2 \
ORDER BY key LIMIT $3",
self.table
);
self.client.query(&sql, &[&full_prefix, af, &lim]).await
} else {
let sql = format!(
"SELECT key FROM {} WHERE starts_with(key, $1) ORDER BY key LIMIT $2",
self.table
);
self.client.query(&sql, &[&full_prefix, &lim]).await
}
.map_err(|e| StoreError::Connection(e.to_string()))?;
let page: Vec<String> = rows
.iter()
.map(|r| r.get::<_, String>(0)[strip_len..].to_string())
.collect();
let next = if limit > 0 && page.len() == limit {
page.last().cloned()
} else {
None
};
Ok((page, next))
})
}
fn health(&self) -> Result<()> {
crate::store::block_on(&self.rt, self.client.execute("SELECT 1", &[]))
.map(|_| ())
.map_err(|e| StoreError::Connection(e.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_store() -> Option<PgStore> {
let url = std::env::var("NUC_TEST_PG_URL").unwrap_or_else(|_| {
"postgres://postgres:postgres@localhost:5432/nucleation".to_string()
});
let cfg = PgConfig {
url,
table: format!("nuc_store_test_{}", std::process::id()),
prefix: String::new(),
};
let store = PgStore::connect(cfg).ok()?;
store.health().ok()?;
Some(store)
}
#[test]
fn pg_store_satisfies_contract() {
let Some(store) = test_store() else {
eprintln!("skipping Postgres contract: no server at NUC_TEST_PG_URL");
return;
};
crate::store::contract::run_contract(&store);
let sql = format!("DROP TABLE IF EXISTS {}", store.table);
let _ = crate::store::block_on(&store.rt, store.client.execute(&sql, &[]));
}
}