use crate::error::{Error, Result};
pub(crate) const KV_TABLE: &str = "_hyperdb_kv_store";
pub(crate) const KV_MAX_NAME_BYTES: usize = 512;
pub(crate) const KV_CHARSET: &str = "A-Z a-z 0-9 _ . -";
pub(crate) fn validate_kv_name(name: &str, kind: &str) -> Result<()> {
if name.is_empty() {
return Err(Error::invalid_name(format!("KV {kind} must not be empty")));
}
if name.len() > KV_MAX_NAME_BYTES {
return Err(Error::invalid_name(format!(
"KV {kind} exceeds {KV_MAX_NAME_BYTES}-byte limit ({} bytes)",
name.len()
)));
}
if let Some(bad) = name
.bytes()
.find(|&b| !(b.is_ascii_alphanumeric() || b == b'_' || b == b'.' || b == b'-'))
{
return Err(Error::invalid_name(format!(
"KV {kind} contains an invalid byte {bad:#04x}; allowed: {KV_CHARSET}"
)));
}
Ok(())
}
pub(crate) fn kv_create_table_sql(table_ref: &str) -> String {
format!(
"CREATE TABLE IF NOT EXISTS {table_ref} \
(store_name TEXT NOT NULL, key TEXT NOT NULL, value TEXT)"
)
}
pub(crate) fn kv_target_prefix(database: &str) -> Result<String> {
Ok(format!(
"{}.{}",
crate::escape_name(database)?,
crate::escape_name("public")?
))
}
use crate::connection::Connection;
#[derive(Debug)]
pub struct KvStore<'conn> {
connection: &'conn Connection,
store_name: String,
table_ref: String,
}
impl<'conn> KvStore<'conn> {
fn open(connection: &'conn Connection, name: &str, table_ref: String) -> Result<Self> {
validate_kv_name(name, "store name")?;
connection.execute_command(&kv_create_table_sql(&table_ref))?;
Ok(KvStore {
connection,
store_name: name.to_string(),
table_ref,
})
}
pub(crate) fn new(connection: &'conn Connection, name: &str) -> Result<Self> {
Self::open(connection, name, KV_TABLE.to_string())
}
pub(crate) fn with_target(
connection: &'conn Connection,
name: &str,
target: &str,
) -> Result<Self> {
Self::open(connection, name, format!("{target}.{KV_TABLE}"))
}
#[must_use]
pub fn name(&self) -> &str {
&self.store_name
}
pub fn get(&self, key: &str) -> Result<Option<String>> {
validate_kv_name(key, "key")?;
let sql = format!(
"SELECT value FROM {} WHERE store_name = $1 AND key = $2",
self.table_ref
);
let row = self
.connection
.query_params(&sql, &[&self.store_name.as_str(), &key])?
.first_row()?;
Ok(row.and_then(|r| r.get::<String>(0)))
}
pub fn set(&self, key: &str, value: &str) -> Result<()> {
validate_kv_name(key, "key")?;
self.upsert(key, value)
}
fn upsert(&self, key: &str, value: &str) -> Result<()> {
let store = self.store_name.as_str();
let updated = self.connection.command_params(
&format!(
"UPDATE {} SET value = $3 WHERE store_name = $1 AND key = $2",
self.table_ref
),
&[&store, &key, &value],
)?;
if updated == 0 {
self.connection.command_params(
&format!(
"INSERT INTO {t} (store_name, key, value) \
SELECT $1, $2, $3 \
WHERE NOT EXISTS (SELECT 1 FROM {t} WHERE store_name = $4 AND key = $5)",
t = self.table_ref
),
&[&store, &key, &value, &store, &key],
)?;
}
Ok(())
}
pub fn get_as<T: serde::de::DeserializeOwned>(&self, key: &str) -> Result<Option<T>> {
match self.get(key)? {
Some(json) => serde_json::from_str(&json)
.map(Some)
.map_err(|e| Error::serialization(e.to_string())),
None => Ok(None),
}
}
pub fn set_as<T: serde::Serialize>(&self, key: &str, value: &T) -> Result<()> {
validate_kv_name(key, "key")?;
let json = serde_json::to_string(value).map_err(|e| Error::serialization(e.to_string()))?;
self.upsert(key, &json)
}
pub fn delete(&self, key: &str) -> Result<bool> {
validate_kv_name(key, "key")?;
let affected = self.connection.command_params(
&format!(
"DELETE FROM {} WHERE store_name = $1 AND key = $2",
self.table_ref
),
&[&self.store_name.as_str(), &key],
)?;
Ok(affected > 0)
}
pub fn exists(&self, key: &str) -> Result<bool> {
validate_kv_name(key, "key")?;
let sql = format!(
"SELECT 1 FROM {} WHERE store_name = $1 AND key = $2 LIMIT 1",
self.table_ref
);
Ok(self
.connection
.query_params(&sql, &[&self.store_name.as_str(), &key])?
.first_row()?
.is_some())
}
pub fn size(&self) -> Result<i64> {
let sql = format!(
"SELECT COUNT(*) FROM {} WHERE store_name = $1",
self.table_ref
);
Ok(self
.connection
.query_params(&sql, &[&self.store_name.as_str()])?
.scalar::<i64>()?
.unwrap_or(0))
}
pub fn keys(&self) -> Result<Vec<String>> {
let sql = format!(
"SELECT key FROM {} WHERE store_name = $1 ORDER BY key ASC",
self.table_ref
);
let mut result = self
.connection
.query_params(&sql, &[&self.store_name.as_str()])?;
let mut keys = Vec::new();
while let Some(chunk) = result.next_chunk()? {
for row in &chunk {
if let Some(k) = row.get::<String>(0) {
keys.push(k);
}
}
}
Ok(keys)
}
pub fn clear(&self) -> Result<u64> {
self.connection.command_params(
&format!("DELETE FROM {} WHERE store_name = $1", self.table_ref),
&[&self.store_name.as_str()],
)
}
pub fn pop(&self) -> Result<Option<(String, String)>> {
self.connection.begin_transaction_raw()?;
let result = self.pop_inner();
match &result {
Ok(_) => self.connection.commit_raw()?,
Err(_) => {
let _ = self.connection.rollback_raw();
}
}
result
}
fn pop_inner(&self) -> Result<Option<(String, String)>> {
let store = self.store_name.as_str();
let select = format!(
"SELECT key, value FROM {} WHERE store_name = $1 ORDER BY key ASC LIMIT 1",
self.table_ref
);
let Some(row) = self
.connection
.query_params(&select, &[&store])?
.first_row()?
else {
return Ok(None);
};
let key: String = row
.get::<String>(0)
.ok_or_else(|| Error::internal("kv pop: key column was unexpectedly NULL"))?;
let value: String = row.get::<String>(1).unwrap_or_default();
self.connection.command_params(
&format!(
"DELETE FROM {} WHERE store_name = $1 AND key = $2",
self.table_ref
),
&[&store, &key.as_str()],
)?;
Ok(Some((key, value)))
}
pub fn set_batch(&self, entries: &[(&str, &str)]) -> Result<()> {
for (key, _) in entries {
validate_kv_name(key, "key")?;
}
self.connection.begin_transaction_raw()?;
let result = (|| {
for (key, value) in entries {
self.upsert(key, value)?;
}
Ok(())
})();
match &result {
Ok(()) => self.connection.commit_raw()?,
Err(_) => {
let _ = self.connection.rollback_raw();
}
}
result
}
}
impl Connection {
pub fn kv_store(&self, name: &str) -> Result<KvStore<'_>> {
KvStore::new(self, name)
}
pub fn kv_store_in(&self, database: &str, name: &str) -> Result<KvStore<'_>> {
KvStore::with_target(self, name, &kv_target_prefix(database)?)
}
pub fn kv_list_stores(&self) -> Result<Vec<String>> {
self.kv_list_stores_impl(KV_TABLE)
}
pub fn kv_list_stores_in(&self, database: &str) -> Result<Vec<String>> {
let table_ref = format!("{}.{KV_TABLE}", kv_target_prefix(database)?);
self.kv_list_stores_impl(&table_ref)
}
fn kv_list_stores_impl(&self, table_ref: &str) -> Result<Vec<String>> {
self.execute_command(&kv_create_table_sql(table_ref))?;
let mut result = self.execute_query(&format!(
"SELECT DISTINCT store_name FROM {table_ref} ORDER BY store_name ASC"
))?;
let mut names = Vec::new();
while let Some(chunk) = result.next_chunk()? {
for row in &chunk {
if let Some(name) = row.get::<String>(0) {
names.push(name);
}
}
}
Ok(names)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_valid_names() {
for ok in [
"a",
"store_1",
"my.key-2",
"A",
&"z".repeat(KV_MAX_NAME_BYTES),
] {
assert!(validate_kv_name(ok, "key").is_ok(), "should accept {ok:?}");
}
}
#[test]
fn rejects_empty() {
let err = validate_kv_name("", "store name").unwrap_err();
assert!(matches!(err, Error::InvalidName(_)));
assert!(err.to_string().contains("must not be empty"));
}
#[test]
fn rejects_too_long() {
let long = "a".repeat(KV_MAX_NAME_BYTES + 1);
let err = validate_kv_name(&long, "key").unwrap_err();
assert!(matches!(err, Error::InvalidName(_)));
assert!(err.to_string().contains("byte limit"));
}
#[test]
fn rejects_bad_charset() {
for bad in ["a b", "a/b", "a'b", "a\"b", "a;b", "naïve", "a\0b"] {
let err = validate_kv_name(bad, "key").unwrap_err();
assert!(
matches!(err, Error::InvalidName(_)),
"should reject {bad:?}"
);
}
}
}