From b1db5bf41d2aad4bbf2d15e78af05bb499c48a48 Mon Sep 17 00:00:00 2001 From: Chris Connelly Date: Sat, 11 Dec 2021 00:47:21 +0000 Subject: [PATCH] feat: store humans in an actual database This uses `sqlx` to read/write humans from/to the sample-db. The schema consists of a custom enum type for episodes, and a table for humans. For now, the same structs as were used for the GraphQL schema are being used to (de)serialize database values. This would have been quite smooth, but sadly, `sqlx`'s derivation can't handle `Vec`s of custom enums (https://github.com/launchbadge/sqlx/issues/298), so we have to jump through some hoops by introducing `EpisodeSlice` and `EpisodeVec` newtypes, which can then implement the required traits (plus all the required juniper traits, which is the bigger pain). Since we're using `sqlx`'s macros to check queries at compile time, we need to connect to the database during compilation. The macro will use the `DATABASE_URL` environment variable, which it can read from a `.env` file, so we now write one of these files as part of `make prepare-db` (note that this is for the benefit of editors, language servers, etc., `make run` would already inherit the `DATABASE_URL` when compiling). --- .gitignore | 1 + Cargo.lock | 3 +- Cargo.toml | 6 +- Makefile | 2 + .../20211210224745_create_initial_schema.sql | 10 ++ src/graphql/context.rs | 67 ++++---- src/graphql/mod.rs | 1 - src/graphql/schema.rs | 83 +--------- src/main.rs | 3 +- src/model.rs | 147 ++++++++++++++++++ 10 files changed, 214 insertions(+), 109 deletions(-) create mode 100644 migrations/20211210224745_create_initial_schema.sql create mode 100644 src/model.rs diff --git a/.gitignore b/.gitignore index ea8c4bf..76b565e 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ +.env /target diff --git a/Cargo.lock b/Cargo.lock index 6a99adc..cb524a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1114,9 +1114,9 @@ version = "0.1.0" dependencies = [ "axum", "envy", + "futures", "juniper", "juniper_hyper", - "parking_lot", "serde", "sqlx", "tokio", @@ -1345,6 +1345,7 @@ dependencies = [ "thiserror", "tokio-stream", "url", + "uuid", "webpki", "webpki-roots", "whoami", diff --git a/Cargo.toml b/Cargo.toml index d3d3b56..8c5c575 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,14 +8,16 @@ edition = "2021" [dependencies] axum = "0.4.2" envy = "0.4.2" +futures = "0.3.18" juniper = "0.15.7" juniper_hyper = "0.8.0" -parking_lot = "0.11.2" serde = { version = "1.0.131", features = ["derive"] } -sqlx = { version = "0.5.9", default-features = false, features = ["macros", "postgres", "runtime-tokio-rustls"] } +sqlx = { version = "0.5.9", default-features = false, features = ["macros", "postgres", "runtime-tokio-rustls", "uuid"] } tokio = { version = "1.14.0", features = ["macros", "rt-multi-thread"] } tower = "0.4.11" tower-http = { version = "0.2.0", features = ["trace"] } tracing = "0.1.29" tracing-subscriber = { version = "0.3.3", features = ["env-filter"] } uuid = { version = "0.8.2", features = ["v4"] } + +[features] diff --git a/Makefile b/Makefile index 5b69aa8..479bb29 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,8 @@ export RUST_LOG ?= rust_graphql_sample=debug,tower_http=debug prepare-db: start-db @sqlx database create @sqlx migrate run +# Write a .env file with DATABASE_URL, so that sqlx will always pick it up (e.g. from editor or language server) + @echo "DATABASE_URL=$(DATABASE_URL)" > .env start-db: @scripts/start-db.sh diff --git a/migrations/20211210224745_create_initial_schema.sql b/migrations/20211210224745_create_initial_schema.sql new file mode 100644 index 0000000..aa87619 --- /dev/null +++ b/migrations/20211210224745_create_initial_schema.sql @@ -0,0 +1,10 @@ +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +CREATE TYPE episode AS ENUM ('new_hope', 'empire', 'jedi'); + +CREATE TABLE humans ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name TEXT NOT NULL UNIQUE, + appears_in episode[] NOT NULL, + home_planet TEXT NOT NULL +); diff --git a/src/graphql/context.rs b/src/graphql/context.rs index 0d3fb28..26b9573 100644 --- a/src/graphql/context.rs +++ b/src/graphql/context.rs @@ -1,45 +1,56 @@ -use std::collections::HashMap; - -use parking_lot::RwLock; use uuid::Uuid; -use super::{Human, NewHuman}; +use crate::model::{Human, NewHuman}; pub(crate) struct Context { - _db: sqlx::PgPool, - humans: RwLock>, + db: sqlx::PgPool, } impl Context { pub(crate) fn new(db: sqlx::PgPool) -> Self { - Self { - _db: db, - humans: Default::default(), - } + Self { db } } - pub(crate) fn humans(&self) -> Vec { - self.humans.read().values().cloned().collect() + pub(crate) async fn humans(&self) -> Result, sqlx::Error> { + sqlx::query_as!( + Human, + " +SELECT id, name, appears_in AS \"appears_in: _\", home_planet +FROM humans + ", + ) + .fetch_all(&self.db) + .await } - pub(crate) fn find_human(&self, id: &Uuid) -> Result { - self.humans.read().get(id).cloned().ok_or("not found") + pub(crate) async fn find_human(&self, id: &Uuid) -> Result { + sqlx::query_as!( + Human, + " +SELECT id, name, appears_in AS \"appears_in: _\", home_planet +FROM humans +WHERE id = $1 + ", + id + ) + .fetch_one(&self.db) + .await } - pub(crate) fn insert_human(&self, new_human: NewHuman) -> Result { - let mut humans = self.humans.write(); - - if humans - .values() - .any(|human| human.name() == new_human.name()) - { - return Err("a human with that name already exists"); - } - - let human = Human::new(new_human); - humans.insert(human.id(), human.clone()); - - Ok(human) + pub(crate) async fn insert_human(&self, new_human: NewHuman) -> Result { + sqlx::query_as!( + Human, + " +INSERT INTO humans (name, appears_in, home_planet) +VALUES ($1, $2, $3) +RETURNING id, name, appears_in AS \"appears_in: _\", home_planet + ", + new_human.name(), + new_human.appears_in() as _, + new_human.home_planet(), + ) + .fetch_one(&self.db) + .await } } diff --git a/src/graphql/mod.rs b/src/graphql/mod.rs index 0b0ad5d..b1dacdb 100644 --- a/src/graphql/mod.rs +++ b/src/graphql/mod.rs @@ -1,7 +1,6 @@ mod context; mod schema; -use self::schema::{Human, NewHuman}; pub(crate) use self::{ context::Context, schema::{Mutation, Query}, diff --git a/src/graphql/schema.rs b/src/graphql/schema.rs index 742d4a7..4e803e0 100644 --- a/src/graphql/schema.rs +++ b/src/graphql/schema.rs @@ -1,7 +1,7 @@ use juniper::FieldResult; use uuid::Uuid; -use crate::Context; +use crate::{Context, model::{NewHuman, Human}}; pub(crate) struct Query; @@ -14,13 +14,13 @@ impl Query { } /// All the humanoid creatures in the Star Wars universe that we know about. - fn humans(context: &Context) -> FieldResult> { - Ok(context.humans()) + async fn humans(context: &Context) -> FieldResult> { + Ok(context.humans().await?) } /// A humanoid creature in the Star Wars universe. - fn human(context: &Context, id: Uuid) -> FieldResult { - let human = context.find_human(&id)?; + async fn human(context: &Context, id: Uuid) -> FieldResult { + let human = context.find_human(&id).await?; Ok(human) } } @@ -30,77 +30,8 @@ pub(crate) struct Mutation; /// The root mutation structure. #[juniper::graphql_object(Context = Context)] impl Mutation { - fn create_human(context: &Context, new_human: NewHuman) -> FieldResult { - let human = context.insert_human(new_human)?; + async fn create_human(context: &Context, new_human: NewHuman) -> FieldResult { + let human = context.insert_human(new_human).await?; Ok(human) } } - -/// Episodes in the original (and best) Star Wars trilogy. -#[derive(Clone, Copy, juniper::GraphQLEnum)] -pub(crate) enum Episode { - /// Star Wars: Episode IV – A New Hope - NewHope, - - /// Star Wars: Episode V – The Empire Strikes Back - Empire, - - /// Star Wars: Episode VI – Return of the Jedi - Jedi, -} - -/// A humanoid creature in the Star Wars universe. -#[derive(Clone, juniper::GraphQLObject)] -pub(crate) struct Human { - /// Their unique identifier, assigned by us. - id: Uuid, - - /// Their name. - name: String, - - /// The episodes in which they appeared. - appears_in: Vec, - - /// Their home planet. - home_planet: String, -} - -impl Human { - pub(crate) fn new(new_human: NewHuman) -> Self { - Self { - id: Uuid::new_v4(), - name: new_human.name, - appears_in: new_human.appears_in, - home_planet: new_human.home_planet, - } - } - - pub(crate) fn id(&self) -> Uuid { - self.id - } - - pub(crate) fn name(&self) -> &str { - &self.name - } -} - -/// A new humanoid creature in the Star Wars universe. -/// -/// `id` is assigned by the server upon creation. -#[derive(juniper::GraphQLInputObject)] -pub(crate) struct NewHuman { - /// Their name. - name: String, - - /// The episodes in which they appeared. - appears_in: Vec, - - /// Their home planet. - home_planet: String, -} - -impl NewHuman { - pub(crate) fn name(&self) -> &str { - &self.name - } -} diff --git a/src/main.rs b/src/main.rs index b392c8c..68479d7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ mod graphql; +pub(crate) mod model; use std::{ net::{Ipv4Addr, SocketAddr, TcpListener}, @@ -17,7 +18,7 @@ use tower_http::trace::TraceLayer; use tracing::info; use tracing_subscriber::EnvFilter; -use self::graphql::{Context, Mutation, Query}; +pub(crate) use self::graphql::{Context, Mutation, Query}; #[derive(Debug, serde::Deserialize)] struct Config { diff --git a/src/model.rs b/src/model.rs new file mode 100644 index 0000000..bcdf3ab --- /dev/null +++ b/src/model.rs @@ -0,0 +1,147 @@ +// For now, the application is simple enough that our domain types can derive their serialization +// to/from the DB and GraphQL, and the same types can be used for both. In future, we may need +// different types for the DB and for GraphQL, with conversions between them. + +use uuid::Uuid; + +/// A humanoid creature in the Star Wars universe. +#[derive(Clone, Debug, juniper::GraphQLObject, sqlx::FromRow)] +pub(crate) struct Human { + /// Their unique identifier, assigned by us. + pub(crate) id: Uuid, + + /// Their name. + pub(crate) name: String, + + /// The episodes in which they appeared. + pub(crate) appears_in: EpisodeVec, + + /// Their home planet. + pub(crate) home_planet: String, +} + +/// A new humanoid creature in the Star Wars universe. +/// +/// `id` is assigned by the server upon creation. +#[derive(juniper::GraphQLInputObject)] +pub(crate) struct NewHuman { + /// Their name. + name: String, + + /// The episodes in which they appeared. + appears_in: Vec, + + /// Their home planet. + home_planet: String, +} + +impl NewHuman { + pub(crate) fn name(&self) -> &str { + &self.name + } + + pub(crate) fn appears_in(&self) -> EpisodeSlice { + EpisodeSlice(&self.appears_in) + } + + pub(crate) fn home_planet(&self) -> &str { + &self.home_planet + } +} + +/// Episodes in the original (and best) Star Wars trilogy. +#[derive(Clone, Copy, Debug, juniper::GraphQLEnum, sqlx::Type)] +#[sqlx(type_name = "episode")] +#[sqlx(rename_all = "snake_case")] +pub(crate) enum Episode { + /// Star Wars: Episode IV – A New Hope + NewHope, + + /// Star Wars: Episode V – The Empire Strikes Back + Empire, + + /// Star Wars: Episode VI – Return of the Jedi + Jedi, +} + +// Workarounds for https://github.com/launchbadge/sqlx/issues/298 + +#[derive(Clone, Copy, Debug, sqlx::Encode)] +pub(crate) struct EpisodeSlice<'a>(&'a [Episode]); + +impl sqlx::Type for EpisodeSlice<'_> { + fn type_info() -> sqlx::postgres::PgTypeInfo { + sqlx::postgres::PgTypeInfo::with_name("_episode") + } +} + +#[derive(Clone, Debug, sqlx::Decode)] +pub(crate) struct EpisodeVec(Vec); + +impl sqlx::Type for EpisodeVec { + fn type_info() -> sqlx::postgres::PgTypeInfo { + sqlx::postgres::PgTypeInfo::with_name("_episode") + } +} + +impl From> for EpisodeVec { + fn from(episodes: Vec) -> Self { + Self(episodes) + } +} + +impl juniper::GraphQLType for EpisodeVec +where + S: juniper::ScalarValue, +{ + fn name(_: &Self::TypeInfo) -> Option<&'static str> { + None + } + + fn meta<'r>( + info: &Self::TypeInfo, + registry: &mut juniper::Registry<'r, S>, + ) -> juniper::meta::MetaType<'r, S> + where + S: 'r, + { + Vec::::meta(info, registry) + } +} + +impl juniper::GraphQLValue for EpisodeVec +where + S: juniper::ScalarValue, +{ + type Context = as juniper::GraphQLValue>::Context; + type TypeInfo = as juniper::GraphQLValue>::TypeInfo; + + fn type_name(&self, _: &Self::TypeInfo) -> Option<&'static str> { + None + } + + fn resolve( + &self, + info: &Self::TypeInfo, + selection: Option<&[juniper::Selection]>, + executor: &juniper::Executor, + ) -> juniper::ExecutionResult { + self.0.resolve(info, selection, executor) + } +} + +impl juniper::GraphQLValueAsync for EpisodeVec +where + S: juniper::ScalarValue + Send + Sync, +{ + fn resolve_async<'a>( + &'a self, + info: &'a Self::TypeInfo, + selection: Option<&'a [juniper::Selection]>, + executor: &'a juniper::Executor, + ) -> futures::future::BoxFuture<'a, juniper::ExecutionResult> { + self.0.resolve_async(info, selection, executor) + } +} + +impl juniper::marker::IsOutputType for EpisodeVec where S: juniper::ScalarValue {}