From 568b248f3e82048865cd145088e118e3008278d5 Mon Sep 17 00:00:00 2001 From: Orion Kindel Date: Fri, 7 Jul 2023 21:38:27 -0500 Subject: [PATCH] feat: newtype macro, user repo --- Cargo.lock | 1 + Cargo.toml | 1 + src/hashed_text.rs | 16 ++- src/main.rs | 64 +++++++++++- src/postgres.rs | 26 ++++- src/repo.rs | 8 +- src/user.rs | 246 +++++++++++++++++++++++++++++++++++++++++++-- 7 files changed, 335 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d0b660b..6a9cacf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -31,6 +31,7 @@ name = "api" version = "0.1.0" dependencies = [ "log", + "naan", "nb", "postgres", "rand", diff --git a/Cargo.toml b/Cargo.toml index 9122142..770776a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,3 +14,4 @@ serde_json = "1" log = "0.4" postgres = {path = "./postgres/postgres"} rand = "0.8" +naan = "0.1.32" diff --git a/src/hashed_text.rs b/src/hashed_text.rs index d07576a..84bb127 100644 --- a/src/hashed_text.rs +++ b/src/hashed_text.rs @@ -1,10 +1,13 @@ use postgres::{GenericClient, GenericRow}; +use crate::newtype; use crate::postgres::{DbError, Postgres}; use crate::repo::Ext; -#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)] -pub struct HashedText(String); +newtype!( + #[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)] + pub struct HashedText(String); +); pub trait HashedTextExt: Ext { fn matches>(&self, this: &HashedText, other: S) -> Result; @@ -20,8 +23,7 @@ impl Ext for HashedTextExtImpl where Db: Postgres impl HashedTextExt for HashedTextExtImpl where Db: Postgres { fn matches>(&self, this: &HashedText, other: S) -> Result> { - static QUERY: &str = - "select public.hashed_text_matches($1, public.hashed_text_of_string($2))"; + static QUERY: &str = "select public.hashed_text_matches($1, public.hashed_text_of_string($2))"; self.0 .with_client(|client| client.query_one(QUERY, &[&other.as_ref(), &this.0.as_str()])) @@ -31,11 +33,7 @@ impl HashedTextExt for HashedTextExtImpl where Db: Postgres #[cfg(test)] mod test { - - - - - use postgres::types::{Type}; + use postgres::types::Type; use super::{HashedText, HashedTextExt, HashedTextExtImpl}; use crate::postgres::test::{from_sql_owned, Client, Row}; diff --git a/src/main.rs b/src/main.rs index d4180ae..9f56429 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,21 +1,77 @@ - use toad::config::Config; use toad::net::Addrd; use toad::platform::Platform as _; use toad::req::Req; use toad::resp::Resp; - mod app; mod hashed_text; mod postgres; mod repo; mod user; +#[macro_export] +macro_rules! newtype { + ( + $(#[derive($($der:ident),+)])? + $(#[doc = $doc:literal])* + pub struct $name:ident(String); + ) => { + $(#[derive($($der),+)])? + $(#[doc = $doc])* + pub struct $name(String); + impl AsRef for $name { + fn as_ref(&self) -> &str { + self.0.as_str() + } + } -#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)] -pub struct Email(String); + impl ToString for $name { + fn to_string(&self) -> String { + self.0.clone() + } + } + + impl From for $name { + fn from(s: String) -> $name { + $name(s) + } + } + + impl From<&str> for $name { + fn from(s: &str) -> $name { + $name(s.to_string()) + } + } + + impl From<$name> for String { + fn from(s: $name) -> String { + s.0 + } + } + }; + (pub struct $name:ident($inner:ty);) => { + pub struct $name($inner); + + impl From<$inner> for $name { + fn from(s: $inner) -> $name { + $name(s) + } + } + + impl From<$name> for $inner { + fn from(s: $name) -> $inner { + s.0 + } + } + }; +} + +newtype!( + #[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)] + pub struct Email(String); +); type Dtls = toad::std::dtls::N; type ToadT = toad::std::PlatformTypes; diff --git a/src/postgres.rs b/src/postgres.rs index bbc5cf2..5498aa5 100644 --- a/src/postgres.rs +++ b/src/postgres.rs @@ -1,11 +1,17 @@ -use std::ops::{DerefMut}; +use std::ops::DerefMut; use std::pin::Pin; use std::sync::{Mutex, MutexGuard}; -use rand::{Rng}; +use postgres::GenericRow; +use rand::Rng; pub type DbError = <::Client as postgres::GenericClient>::Error; +pub trait UnmarshalRow: Sized + Send + Sync { + fn unmarshal(row: &R) -> Result + where R: GenericRow; +} + #[derive(Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct ConnectionParams { pub host: String, @@ -80,7 +86,9 @@ impl Postgres for PostgresImpl #[cfg(test)] pub mod test { + use std::any::Any; use std::marker::PhantomData; + use std::sync::Mutex; pub fn from_sql_owned(to: &dyn ToSql) -> T where T: 'static + for<'a> FromSql<'a> + postgres::types::Typed @@ -219,6 +227,7 @@ pub mod test { #[non_exhaustive] pub struct Client { pub __phantom: PhantomData, + pub state: Box, pub execute: Box>, pub query: Box>, pub query_one: Box>, @@ -233,10 +242,19 @@ pub mod test { unsafe impl Send for Client {} unsafe impl Sync for Client {} + impl Client { + pub fn state_mut(&mut self) -> &mut T + where T: 'static + { + self.state.downcast_mut::().unwrap() + } + } + impl Default for Client where E: core::fmt::Debug { fn default() -> Self { Client { __phantom: PhantomData, + state: Box::new(()), execute: Box::new(|_, _, _| panic!("execute not implemented")), query: Box::new(|_, _, _| panic!("query not implemented")), query_one: Box::new(|_, _, _| panic!("query_one not implemented")), @@ -374,14 +392,14 @@ pub mod test { fn commit(mut self) -> Result<(), E> { let mut f = Client::::default().commit; std::mem::swap(&mut self.commit, &mut f); - + f(self) } fn rollback(mut self) -> Result<(), E> { let mut f = Client::::default().rollback; std::mem::swap(&mut self.commit, &mut f); - + f(self) } } diff --git a/src/repo.rs b/src/repo.rs index 80e1d11..e4ca91b 100644 --- a/src/repo.rs +++ b/src/repo.rs @@ -1,13 +1,15 @@ pub trait Repo: Send + Sync { type T; - type TPut; + type Patch; + type Insert; type Error: core::fmt::Debug; type Id: AsRef; fn get(&self, id: Self::Id) -> Result, Self::Error>; fn get_all(&self) -> Result, Self::Error>; - fn put(&self, id: Self::Id, state: Self::TPut) -> Result<(), Self::Error>; - fn del(&self, id: Self::Id) -> Result<(), Self::Error>; + fn patch(&self, id: Self::Id, state: Self::Patch) -> Result; + fn insert(&self, state: Self::Insert) -> Result; + fn del(&self, id: Self::Id) -> Result; } /// An entity that has some operations which rely on diff --git a/src/user.rs b/src/user.rs index 0a1ed79..05478bb 100644 --- a/src/user.rs +++ b/src/user.rs @@ -1,12 +1,20 @@ +use naan::prelude::*; +use postgres::{GenericClient, GenericRow}; + use crate::hashed_text::HashedText; +use crate::postgres::{DbError, Postgres, UnmarshalRow}; use crate::repo::Repo; -use crate::Email; +use crate::{newtype, Email}; -#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)] -pub struct UserId(String); +newtype!( + #[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)] + pub struct UserId(String); +); -#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)] -pub struct UserTag(String); +newtype!( + #[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)] + pub struct UserTag(String); +); #[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)] pub struct User { @@ -16,11 +24,235 @@ pub struct User { email: Email, } +impl UnmarshalRow for User { + fn unmarshal(row: &R) -> Result + where R: GenericRow + { + row.try_get::<_, String>("uid") + .zip(|_: &_| row.try_get::<_, String>("tag")) + .zip(|_: &_| row.try_get::<_, String>("password")) + .zip(|_: &_| row.try_get::<_, String>("email")) + .map(|(((uid, tag), password), email)| User { uid: UserId(uid), + tag: UserTag(tag), + password: HashedText::from(password), + email: Email(email) }) + } +} + #[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)] -pub struct UserPut { +pub struct UserPatch { + tag: Option, + password: Option, + email: Option, +} + +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)] +pub struct UserInsert { tag: UserTag, password: HashedText, email: Email, } -pub trait UserRepo: Repo {} +pub trait UserRepo: Repo {} + +pub struct UserRepoImpl(&'static Db); +impl Repo for UserRepoImpl where Db: Postgres + 'static +{ + type T = User; + type Patch = UserPatch; + type Insert = UserInsert; + type Error = DbError; + type Id = UserId; + + fn get(&self, id: Self::Id) -> Result, Self::Error> { + static QUERY: &'static str = + "select uid, tag, password, email from public.usr where id = $1 :: uuid"; + + self.0 + .with_client(|c| c.query_opt(QUERY, &[&id.as_ref()])) + .and_then(|opt| { + opt.as_ref() + .map(User::unmarshal) + .sequence::>() + }) + } + + fn get_all(&self) -> Result, Self::Error> { + static QUERY: &'static str = "select uid, tag, password, email from public.usr"; + + self.0 + .with_client(|c| c.query(QUERY, &[])) + .and_then(|vec| vec.iter().map(User::unmarshal).collect()) + } + + fn patch(&self, id: UserId, patch: UserPatch) -> Result { + static QUERY_LINES: [&'static str; 5] = ["update public.usr", + "set tag = coalesce($2, tag)", + " , password = coalesce($3, password)", + " , email = coalesce($4, email)", + "from public.usr where uid = $1"]; + + self.0 + .with_client(|c| { + c.execute(&QUERY_LINES.iter() + .fold(String::new(), |b, a| format!("{b}{a}\n")), + &[&id.as_ref(), + &patch.tag.as_ref().map(|t| t.as_ref()), + &patch.password.as_ref().map(|h| h.as_ref()), + &patch.email.as_ref().map(|e| e.as_ref())]) + }) + .map(|n| n == 1) + } + + fn insert(&self, insert: UserInsert) -> Result { + static QUERY_LINES: [&'static str; 5] = ["insert into public.usr", + " (tag, password, email)", + "values", + " ($2, $3, $4)", + "returning uid;"]; + + self.0 + .with_client(|c| { + c.query_one(&QUERY_LINES.iter() + .fold(String::new(), |b, a| format!("{b}{a}\n")), + &[&insert.tag.as_ref(), + &insert.password.as_ref(), + &insert.email.as_ref()]) + }) + .and_then(|r| r.try_get::<_, String>(0)) + .map(UserId::from) + } + + fn del(&self, id: UserId) -> Result { + static QUERY: &'static str = "delete from public.usr where uid = $1 :: uuid;"; + + self.0 + .with_client(|c| c.execute(QUERY, &[&id.as_ref()])) + .map(|n| n == 1) + } +} + +impl UserRepo for UserRepoImpl where Db: Postgres + 'static {} + +#[cfg(test)] +mod tests { + use postgres::types::Type; + + use super::{User, UserRepoImpl}; + use crate::hashed_text::HashedText; + use crate::postgres::test::{from_sql_owned, Client, Row}; + use crate::postgres::{Postgres, PostgresImpl}; + use crate::repo::Repo; + use crate::user::{UserId, UserTag}; + use crate::Email; + + fn usr_row(usr: User) -> Row<()> { + Row::new(vec![("uid", Type::UUID), + ("email", Type::TEXT), + ("password", Type::TEXT), + ("tag", Type::TEXT)]).value(Type::UUID, usr.uid.to_string()) + .value(Type::TEXT, usr.email.to_string()) + .value(Type::TEXT, usr.password.to_string()) + .value(Type::TEXT, usr.tag.to_string()) + } + + #[test] + fn user_repo_get_one() { + let client = || Client::<()> { query_opt: Box::new(|_, q, ps| { + assert_eq!(q.unwrap_str(), "select uid, tag, password, email from public.usr where id = $1 :: uuid"); + Ok(Some(usr_row(User {uid: UserId::from("1"), tag: UserTag::from("foo"), email: Email::from("foo@bar.baz"), password: HashedText::from("XXX")})).filter(|_| from_sql_owned::(ps[0]) == String::from("1"))) + }), + ..Client::default() }; + + let db = PostgresImpl::try_new(|| Ok(client()), 1).unwrap(); + + let repo = + UserRepoImpl(unsafe { std::mem::transmute::<_, &'static PostgresImpl>>(&db) }); + + assert!(repo.get(UserId::from("1")).unwrap().is_some()); + assert!(repo.get(UserId::from("0")).unwrap().is_none()); + } + + #[test] + fn user_repo_get_all() { + let client = || Client::<()> { query: Box::new(|_, q, _| { + assert_eq!(q.unwrap_str(), + "select uid, tag, password, email from public.usr"); + Ok(vec![usr_row(User { uid: UserId::from("1"), + tag: UserTag::from("foo"), + email: Email::from("foo@bar.baz"), + password: HashedText::from("XXX") })]) + }), + ..Client::default() }; + + let db = PostgresImpl::try_new(|| Ok(client()), 1).unwrap(); + + let repo = + UserRepoImpl(unsafe { std::mem::transmute::<_, &'static PostgresImpl>>(&db) }); + + assert_eq!(repo.get_all().unwrap().len(), 1); + } + + #[test] + fn user_repo_del() { + let client = + || Client::<()> { state: Box::new(false), // already deleted? + execute: Box::new(|c, q, ps| { + assert_eq!(q.unwrap_str(), + "delete from public.usr where uid = $1 :: uuid;"); + + if from_sql_owned::(ps[0]) == "1" && !*c.state_mut::() { + *c.state_mut::() = true; + Ok(1) + } else { + Ok(0) + } + }), + ..Client::default() }; + + let db = PostgresImpl::try_new(|| Ok(client()), 1).unwrap(); + + let repo = + UserRepoImpl(unsafe { std::mem::transmute::<_, &'static PostgresImpl>>(&db) }); + + assert_eq!(repo.del(UserId::from("1")).unwrap(), true); + assert_eq!(repo.del(UserId::from("1")).unwrap(), false); + assert_eq!(repo.del(UserId::from("2")).unwrap(), false); + } + + #[test] + fn user_repo_insert() { + let client = + || Client::<()> { state: Box::new(vec![UserTag::from("foo")]), + query_one: Box::new(|c, q, ps| { + assert_eq!(q.unwrap_str(), + format!( + "insert into public.usr + (tag, password, email) +values + ($2, $3, $4) +returning uid;")); + + let tags = c.state_mut::>(); + + let tag = UserTag::from(from_sql_owned::(ps[0])); + + if tags.contains(&tag) { + Err(()) + } else { + tags.push(tag); + Ok(Row::new(vec![("", Type::TEXT)]).value(Type::TEXT, tags.len().to_string())) + } + }), + ..Client::default() }; + + let db = PostgresImpl::try_new(|| Ok(client()), 1).unwrap(); + + let repo = + UserRepoImpl(unsafe { std::mem::transmute::<_, &'static PostgresImpl>>(&db) }); + + assert!(repo.insert(UserId::from("1")).is_ok()); + assert!(repo.insert(UserId::from("1")).is_err()); + assert!(repo.insert(UserId::from("2")).is_ok()); + } +}