forked from mirror/grapevine
add basic typed key value store abstraction
Should eliminate a few classes of footgun when it's done.
This commit is contained in:
parent
41c01ad1b7
commit
029e32971e
6 changed files with 455 additions and 0 deletions
53
Cargo.lock
generated
53
Cargo.lock
generated
|
@ -713,6 +713,58 @@ dependencies = [
|
|||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "frunk"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11a351b59e12f97b4176ee78497dff72e4276fb1ceb13e19056aca7fa0206287"
|
||||
dependencies = [
|
||||
"frunk_core",
|
||||
"frunk_derives",
|
||||
"frunk_proc_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "frunk_core"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af2469fab0bd07e64ccf0ad57a1438f63160c69b2e57f04a439653d68eb558d6"
|
||||
|
||||
[[package]]
|
||||
name = "frunk_derives"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b0fa992f1656e1707946bbba340ad244f0814009ef8c0118eb7b658395f19a2e"
|
||||
dependencies = [
|
||||
"frunk_proc_macro_helpers",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "frunk_proc_macro_helpers"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35b54add839292b743aeda6ebedbd8b11e93404f902c56223e51b9ec18a13d2c"
|
||||
dependencies = [
|
||||
"frunk_core",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "frunk_proc_macros"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71b85a1d4a9a6b300b41c05e8e13ef2feca03e0334127f29eca9506a7fe13a93"
|
||||
dependencies = [
|
||||
"frunk_core",
|
||||
"frunk_proc_macro_helpers",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.30"
|
||||
|
@ -840,6 +892,7 @@ dependencies = [
|
|||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"clap",
|
||||
"frunk",
|
||||
"futures-util",
|
||||
"hmac",
|
||||
"html-escape",
|
||||
|
|
|
@ -95,6 +95,7 @@ axum-server = { version = "0.6.0", features = ["tls-rustls"] }
|
|||
base64 = "0.22.1"
|
||||
bytes = "1.6.0"
|
||||
clap = { version = "4.5.4", default-features = false, features = ["std", "derive", "help", "usage", "error-context", "string", "wrap_help"] }
|
||||
frunk = "0.4.2"
|
||||
futures-util = { version = "0.3.30", default-features = false }
|
||||
hmac = "0.12.1"
|
||||
html-escape = "0.2.13"
|
||||
|
|
|
@ -28,6 +28,7 @@ use crate::{
|
|||
|
||||
pub(crate) mod abstraction;
|
||||
pub(crate) mod key_value;
|
||||
mod map;
|
||||
|
||||
use abstraction::{KeyValueDatabaseEngine, KvTree};
|
||||
|
||||
|
|
178
src/database/map.rs
Normal file
178
src/database/map.rs
Normal file
|
@ -0,0 +1,178 @@
|
|||
//! A high-level strongly-typed abstraction over key-value stores
|
||||
|
||||
#![warn(missing_docs, clippy::missing_docs_in_private_items)]
|
||||
|
||||
use std::{
|
||||
any::TypeId,
|
||||
borrow::{Borrow, Cow},
|
||||
error::Error,
|
||||
};
|
||||
|
||||
use frunk::{HCons, HNil};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
/// Errors that can occur during key-value store operations
|
||||
// Missing docs are allowed here since that kind of information should be
|
||||
// encoded in the error messages themselves anyway.
|
||||
#[allow(clippy::missing_docs_in_private_items, dead_code)]
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub(crate) enum MapError {
|
||||
#[cfg(any(feature = "sqlite", feature = "rocksdb"))]
|
||||
#[error(transparent)]
|
||||
Database(Box<dyn Error>),
|
||||
|
||||
#[error("failed to convert stored value into structured data")]
|
||||
FromBytes(#[source] Box<dyn Error>),
|
||||
}
|
||||
|
||||
/// A high-level representation of a key-value relation in a key-value store
|
||||
#[allow(dead_code)]
|
||||
pub(crate) trait Map {
|
||||
/// The key type of this relation
|
||||
type Key: ToBytes + FromBytes;
|
||||
|
||||
/// The value type of this relation
|
||||
type Value: ToBytes + FromBytes;
|
||||
|
||||
/// Load a value based on its corresponding key
|
||||
async fn get<K>(&self, key: &K) -> Result<Option<Self::Value>, MapError>
|
||||
where
|
||||
Self::Key: Borrow<K>,
|
||||
K: ToBytes + ?Sized;
|
||||
|
||||
/// Insert or update a key-value pair
|
||||
async fn set<K, V>(&self, key: &K, value: &V) -> Result<(), MapError>
|
||||
where
|
||||
Self::Key: Borrow<K>,
|
||||
Self::Value: Borrow<V>,
|
||||
K: ToBytes + ?Sized,
|
||||
V: ToBytes + ?Sized;
|
||||
|
||||
/// Remove a key-value pair by its key
|
||||
///
|
||||
/// It is not an error to remove a key-value pair that is not present in the
|
||||
/// store.
|
||||
async fn del<K>(&self, key: &K) -> Result<(), MapError>
|
||||
where
|
||||
Self::Key: Borrow<K>,
|
||||
K: ToBytes + ?Sized;
|
||||
}
|
||||
|
||||
/// Convert `Self` into bytes for storage in a key-value store
|
||||
///
|
||||
/// Implementations on types other than `HList`s must not contain `0xFF` bytes
|
||||
/// in their serialized form.
|
||||
///
|
||||
/// [`FromBytes`] must be the exact inverse of this operation.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) trait ToBytes {
|
||||
/// Perform the conversion
|
||||
fn to_bytes(&self) -> Cow<'_, [u8]>;
|
||||
}
|
||||
|
||||
impl ToBytes for () {
|
||||
fn to_bytes(&self) -> Cow<'_, [u8]> {
|
||||
Cow::Borrowed(&[])
|
||||
}
|
||||
}
|
||||
|
||||
impl ToBytes for HNil {
|
||||
fn to_bytes(&self) -> Cow<'_, [u8]> {
|
||||
Cow::Borrowed(&[])
|
||||
}
|
||||
}
|
||||
|
||||
impl<H, T> ToBytes for HCons<H, T>
|
||||
where
|
||||
H: ToBytes,
|
||||
T: ToBytes + 'static,
|
||||
{
|
||||
fn to_bytes(&self) -> Cow<'_, [u8]> {
|
||||
let buf = self.head.to_bytes();
|
||||
|
||||
if TypeId::of::<T>() == TypeId::of::<HNil>() {
|
||||
buf
|
||||
} else {
|
||||
let mut buf = buf.into_owned();
|
||||
buf.push(0xFF);
|
||||
buf.extend_from_slice(self.tail.to_bytes().as_ref());
|
||||
Cow::Owned(buf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ToBytes for String {
|
||||
fn to_bytes(&self) -> Cow<'_, [u8]> {
|
||||
Cow::Borrowed(self.as_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert from bytes stored in a key-value store into structured data
|
||||
///
|
||||
/// This should generally only be implemented by owned types.
|
||||
///
|
||||
/// [`ToBytes`] must be the exact inverse of this operation.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) trait FromBytes
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
/// Perform the conversion
|
||||
fn from_bytes(bytes: Vec<u8>) -> Result<Self, Box<dyn Error>>;
|
||||
}
|
||||
|
||||
impl FromBytes for () {
|
||||
fn from_bytes(bytes: Vec<u8>) -> Result<Self, Box<dyn Error>> {
|
||||
bytes
|
||||
.is_empty()
|
||||
.then_some(())
|
||||
.ok_or_else(|| "got bytes when none were expected".into())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromBytes for HNil {
|
||||
fn from_bytes(bytes: Vec<u8>) -> Result<Self, Box<dyn Error>> {
|
||||
bytes
|
||||
.is_empty()
|
||||
.then_some(HNil)
|
||||
.ok_or_else(|| "got bytes when none were expected".into())
|
||||
}
|
||||
}
|
||||
|
||||
impl<H, T> FromBytes for HCons<H, T>
|
||||
where
|
||||
H: FromBytes,
|
||||
T: FromBytes + 'static,
|
||||
{
|
||||
fn from_bytes(bytes: Vec<u8>) -> Result<Self, Box<dyn Error>> {
|
||||
let (head, tail) = if TypeId::of::<T>() == TypeId::of::<HNil>() {
|
||||
// There is no spoon. I mean, tail.
|
||||
(bytes, Vec::new())
|
||||
} else {
|
||||
let boundary = bytes
|
||||
.iter()
|
||||
.copied()
|
||||
.position(|x| x == 0xFF)
|
||||
.ok_or("map entry is missing a boundary")?;
|
||||
|
||||
// Don't include the boundary in the head or tail
|
||||
let head = &bytes[..boundary];
|
||||
let tail = &bytes[boundary + 1..];
|
||||
|
||||
(head.to_owned(), tail.to_owned())
|
||||
};
|
||||
|
||||
Ok(HCons {
|
||||
head: H::from_bytes(head)?,
|
||||
tail: T::from_bytes(tail)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl FromBytes for String {
|
||||
fn from_bytes(bytes: Vec<u8>) -> Result<Self, Box<dyn Error>> {
|
||||
String::from_utf8(bytes).map_err(Into::into)
|
||||
}
|
||||
}
|
117
src/database/map/tests.rs
Normal file
117
src/database/map/tests.rs
Normal file
|
@ -0,0 +1,117 @@
|
|||
use std::{
|
||||
borrow::Borrow, collections::BTreeMap, marker::PhantomData, sync::RwLock,
|
||||
};
|
||||
|
||||
use frunk::{hlist, HList};
|
||||
|
||||
use super::{FromBytes, Map, MapError, ToBytes};
|
||||
|
||||
mod conversions;
|
||||
|
||||
struct TestMap<K, V> {
|
||||
storage: RwLock<BTreeMap<Vec<u8>, Vec<u8>>>,
|
||||
types: PhantomData<(K, V)>,
|
||||
}
|
||||
|
||||
impl<K, V> TestMap<K, V> {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
storage: RwLock::new(BTreeMap::new()),
|
||||
types: PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<K, V> Map for TestMap<K, V>
|
||||
where
|
||||
K: ToBytes + FromBytes,
|
||||
V: ToBytes + FromBytes,
|
||||
{
|
||||
type Key = K;
|
||||
type Value = V;
|
||||
|
||||
async fn get<KB>(&self, key: &KB) -> Result<Option<Self::Value>, MapError>
|
||||
where
|
||||
Self::Key: Borrow<KB>,
|
||||
KB: ToBytes + ?Sized,
|
||||
{
|
||||
self.storage
|
||||
.read()
|
||||
.expect("lock should not be poisoned")
|
||||
.get(key.borrow().to_bytes().as_ref())
|
||||
.map(|v| {
|
||||
Self::Value::from_bytes(v.to_owned())
|
||||
.map_err(MapError::FromBytes)
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn set<KB, VB>(&self, key: &KB, value: &VB) -> Result<(), MapError>
|
||||
where
|
||||
Self::Key: Borrow<KB>,
|
||||
Self::Value: Borrow<VB>,
|
||||
KB: ToBytes + ?Sized,
|
||||
VB: ToBytes + ?Sized,
|
||||
{
|
||||
self.storage.write().expect("lock should not be poisoned").insert(
|
||||
key.borrow().to_bytes().into_owned(),
|
||||
value.borrow().to_bytes().into_owned(),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn del<KB>(&self, key: &KB) -> Result<(), MapError>
|
||||
where
|
||||
Self::Key: Borrow<KB>,
|
||||
KB: ToBytes + ?Sized,
|
||||
{
|
||||
self.storage
|
||||
.write()
|
||||
.expect("lock should not be poisoned")
|
||||
.remove(key.borrow().to_bytes().as_ref());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn string_to_string() {
|
||||
let test_map = TestMap::<String, String>::new();
|
||||
|
||||
let key = "hello".to_owned();
|
||||
let value = "world".to_owned();
|
||||
|
||||
test_map.set(&key, &value).await.expect("insertion should succed");
|
||||
|
||||
let actual_value = test_map.get(&key).await.expect("lookup should succeed");
|
||||
|
||||
assert_eq!(Some(value), actual_value);
|
||||
|
||||
test_map.del(&key).await.expect("deletion should succeed");
|
||||
|
||||
let actual_value = test_map.get(&key).await.expect("lookup should succeed");
|
||||
|
||||
assert_eq!(None, actual_value);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hlist_to_hlist() {
|
||||
let test_map =
|
||||
TestMap::<HList![String, String], HList![String, String]>::new();
|
||||
|
||||
let key = hlist!["hello".to_owned(), "world".to_owned()];
|
||||
let value = hlist!["test".to_owned(), "suite".to_owned()];
|
||||
|
||||
test_map.set(&key, &value).await.expect("insertion should succed");
|
||||
|
||||
let actual_value = test_map.get(&key).await.expect("lookup should succeed");
|
||||
|
||||
assert_eq!(Some(value), actual_value);
|
||||
|
||||
test_map.del(&key).await.expect("deletion should succeed");
|
||||
|
||||
let actual_value = test_map.get(&key).await.expect("lookup should succeed");
|
||||
|
||||
assert_eq!(None, actual_value);
|
||||
}
|
105
src/database/map/tests/conversions.rs
Normal file
105
src/database/map/tests/conversions.rs
Normal file
|
@ -0,0 +1,105 @@
|
|||
use frunk::{hlist, HList};
|
||||
|
||||
use super::super::{FromBytes, ToBytes};
|
||||
|
||||
#[test]
|
||||
pub(crate) fn serialize_hlist_0() {
|
||||
let expected: &[u8] = &[];
|
||||
|
||||
let actual = hlist![];
|
||||
let actual_bytes = actual.to_bytes();
|
||||
|
||||
assert_eq!(expected, actual_bytes.as_ref());
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub(crate) fn serialize_hlist_1() {
|
||||
let expected =
|
||||
[b"hello"].into_iter().flatten().copied().collect::<Vec<_>>();
|
||||
|
||||
let actual = hlist!["hello".to_owned()];
|
||||
let actual_bytes = actual.to_bytes();
|
||||
|
||||
assert_eq!(expected.as_slice(), actual_bytes.as_ref());
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub(crate) fn serialize_hlist_2() {
|
||||
let expected = [b"hello", [0xFF].as_slice(), b"world"]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.copied()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let actual = hlist!["hello".to_owned(), "world".to_owned()];
|
||||
let actual_bytes = actual.to_bytes();
|
||||
|
||||
assert_eq!(expected.as_slice(), actual_bytes.as_ref());
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub(crate) fn serialize_hlist_3() {
|
||||
let expected =
|
||||
[b"what's", [0xFF].as_slice(), b"up", [0xFF].as_slice(), b"world"]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.copied()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let actual =
|
||||
hlist!["what's".to_owned(), "up".to_owned(), "world".to_owned()];
|
||||
let actual_bytes = actual.to_bytes();
|
||||
|
||||
assert_eq!(expected.as_slice(), actual_bytes.as_ref());
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub(crate) fn deserialize_hlist_0() {
|
||||
let actual = <HList![]>::from_bytes(Vec::new())
|
||||
.expect("should be able to deserialize");
|
||||
|
||||
assert_eq!(hlist![], actual);
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub(crate) fn deserialize_hlist_1() {
|
||||
let serialized =
|
||||
[b"hello"].into_iter().flatten().copied().collect::<Vec<_>>();
|
||||
|
||||
let actual = <HList![String]>::from_bytes(serialized)
|
||||
.expect("should be able to deserialize");
|
||||
|
||||
assert_eq!(hlist!["hello".to_owned()], actual);
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub(crate) fn deserialize_hlist_2() {
|
||||
let serialized = [b"hello", [0xFF].as_slice(), b"world"]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.copied()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let actual = <HList![String, String]>::from_bytes(serialized)
|
||||
.expect("should be able to deserialize");
|
||||
|
||||
assert_eq!(hlist!["hello".to_owned(), "world".to_owned()], actual);
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub(crate) fn deserialize_hlist_3() {
|
||||
let serialized =
|
||||
[b"what's", [0xFF].as_slice(), b"up", [0xFF].as_slice(), b"world"]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.copied()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let actual = <HList![String, String, String]>::from_bytes(serialized)
|
||||
.expect("should be able to deserialize");
|
||||
|
||||
assert_eq!(
|
||||
hlist!["what's".to_owned(), "up".to_owned(), "world".to_owned()],
|
||||
actual
|
||||
);
|
||||
}
|
Loading…
Reference in a new issue