add basic traits and a rough sketch of the derive macro.
a lot of this is pillaged from [miniorm](https://github.com/meuter/miniorm-rs) and [sqlx-crud](https://github.com/treydempsey/sqlx-crud) and then streamlined for a specific uuid-only, postgres-only use-case.
This commit is contained in:
parent
43ec1e7128
commit
96f3081f9a
9 changed files with 2140 additions and 2 deletions
1807
Cargo.lock
generated
1807
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -1,6 +1,6 @@
|
|||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/ezpg",
|
||||
"crates/ezpg-macros",
|
||||
]
|
||||
resolver = "2"
|
||||
|
|
|
@ -5,3 +5,10 @@ edition = "2021"
|
|||
|
||||
[lib]
|
||||
proc-macro = true
|
||||
|
||||
[dependencies]
|
||||
darling = "0.20.10"
|
||||
itertools = "0.13.0"
|
||||
proc-macro2 = "1.0.92"
|
||||
quote = "1.0.38"
|
||||
syn = "2.0.92"
|
||||
|
|
43
crates/ezpg-macros/src/column.rs
Normal file
43
crates/ezpg-macros/src/column.rs
Normal file
|
@ -0,0 +1,43 @@
|
|||
use darling::FromField;
|
||||
use proc_macro2::Ident;
|
||||
use quote::quote;
|
||||
|
||||
/// an individual record column, corresponding to a field
|
||||
/// on the parent struct.
|
||||
#[derive(Clone, Debug, FromField)]
|
||||
#[darling(attributes(sqlx))]
|
||||
pub(crate) struct Column {
|
||||
ident: Option<Ident>,
|
||||
rename: Option<String>,
|
||||
#[darling(default)]
|
||||
skip: bool,
|
||||
}
|
||||
|
||||
impl Column {
|
||||
/// returns the column's [proc_macro2::Ident]. this will usually be
|
||||
/// a rust keyword.
|
||||
pub fn ident(&self) -> &Ident {
|
||||
self.ident.as_ref().unwrap()
|
||||
}
|
||||
|
||||
/// returns the column's name, either the `rename` attribute, or the
|
||||
/// column name itself.
|
||||
pub fn name(&self) -> String {
|
||||
self
|
||||
.rename
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.unwrap_or(self.ident().to_string())
|
||||
}
|
||||
|
||||
/// returns the column's value or 'content'.
|
||||
pub fn value(&self) -> proc_macro2::TokenStream {
|
||||
let field_ident = self.ident();
|
||||
quote!(self.#field_ident.clone())
|
||||
}
|
||||
|
||||
/// whether to skip this column.
|
||||
pub fn skip(&self) -> bool {
|
||||
self.skip
|
||||
}
|
||||
}
|
99
crates/ezpg-macros/src/derive.rs
Normal file
99
crates/ezpg-macros/src/derive.rs
Normal file
|
@ -0,0 +1,99 @@
|
|||
use darling::{ast::Data, FromDeriveInput};
|
||||
use itertools::Itertools;
|
||||
use quote::quote;
|
||||
use syn::Ident;
|
||||
use crate::Column;
|
||||
|
||||
#[derive(FromDeriveInput)]
|
||||
#[darling(attributes(sqlx), supports(struct_named))]
|
||||
pub(crate) struct RecordDerive {
|
||||
ident: Ident,
|
||||
rename: Option<String>,
|
||||
data: Data<(), Column>,
|
||||
}
|
||||
|
||||
impl RecordDerive {
|
||||
fn table_name(&self) -> String {
|
||||
self.rename
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.unwrap_or(self.ident.to_string().to_lowercase())
|
||||
}
|
||||
|
||||
// returns all columns that are not marked `skip`.
|
||||
pub fn columns(&self) -> impl Iterator<Item = &Column> {
|
||||
match &self.data {
|
||||
Data::Enum(_) => unreachable!(),
|
||||
Data::Struct(fields) => fields.fields.iter().filter(|c| !c.skip()),
|
||||
}
|
||||
}
|
||||
|
||||
// generates the record c/r/u/d query strings.
|
||||
pub fn gen_withqueries_impl(&self) -> proc_macro2::TokenStream {
|
||||
let ident = &self.ident;
|
||||
let table_name = self.table_name();
|
||||
|
||||
// a string representing the columns in the table, joined with a ",".
|
||||
let column_names = self.columns().map(|c| c.name());
|
||||
let column_names_join = self.columns().map(|c| c.name()).join(",");
|
||||
|
||||
// e.g., "insert into items (name, weight) values ($1, $2) returning id"
|
||||
let create_query = {
|
||||
let bind_points = self
|
||||
.columns()
|
||||
.enumerate()
|
||||
.map(|(i, _)| format!("${}", i + 1)) // enum starts at 0, column ids start at 1.
|
||||
.join(",");
|
||||
|
||||
format!("insert into {table_name} ({column_names_join}) values ({bind_points}) returning id")
|
||||
};
|
||||
|
||||
// e.g., "select id, name, weight from items limit $1 offset $2"
|
||||
let index_query =
|
||||
format!("select id, {column_names_join} from {table_name} limit $1 offset $2");
|
||||
|
||||
// e.g., "select id, name, weight from items where id = $1"
|
||||
let read_query = format!("select id, {column_names_join} from {table_name} where id = $1");
|
||||
|
||||
let update_query = {
|
||||
let value_pairs = self
|
||||
.columns()
|
||||
.enumerate()
|
||||
.map(|(i, col)| format!("{} = ${}", col.name(), i + 2)) // enum starts at 0, id is 1, other columns start at 2.
|
||||
.join(",");
|
||||
|
||||
format!("update {table_name} set {value_pairs} where id = $1")
|
||||
};
|
||||
|
||||
let delete_query = format!("delete from {table_name} where id = $1");
|
||||
|
||||
quote! {
|
||||
impl ::ezpg::WithQueries for #ident {
|
||||
const COLUMNS: &'static [&'static str] = &[#(#column_names,)*];
|
||||
const CREATE_QUERY: &'static str = #create_query;
|
||||
const INDEX_QUERY: &'static str = #index_query;
|
||||
const READ_QUERY: &'static str = #read_query;
|
||||
const UPDATE_QUERY: &'static str = #update_query;
|
||||
const DELETE_QUERY: &'static str = #delete_query;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// generates the column binding functions.
|
||||
pub fn gen_column_impl(&self) -> proc_macro2::TokenStream {
|
||||
let ident = &self.ident;
|
||||
let col_name = self.columns().map(|col| col.name());
|
||||
let col_value = self.columns().map(|col| col.value());
|
||||
|
||||
quote! {
|
||||
impl ::ezpg::BindColumn for #ident {
|
||||
fn bind_column<'q, Q: ::ezpg::QueryBind<'q>>(&self, query: Q, column_name: &'static str) -> Q {
|
||||
match column_name {
|
||||
#(#col_name => query.bind(#col_value),)*
|
||||
_ => query,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
mod column;
|
||||
pub(crate) use column::Column;
|
||||
mod derive;
|
||||
use darling::FromDeriveInput;
|
||||
use proc_macro::TokenStream;
|
||||
use syn::DeriveInput;
|
||||
use quote::quote;
|
||||
|
||||
#[proc_macro_derive(Record, attributes(sqlx))]
|
||||
pub fn derive_record(input: TokenStream) -> TokenStream {
|
||||
let input: DeriveInput = syn::parse_macro_input!(input);
|
||||
let derive_args =
|
||||
derive::RecordDerive::from_derive_input(&input).expect("could not parse derive arguments");
|
||||
|
||||
let queries_impl = derive_args.gen_withqueries_impl();
|
||||
let column_impl = derive_args.gen_column_impl();
|
||||
|
||||
quote! {
|
||||
#queries_impl
|
||||
#column_impl
|
||||
}
|
||||
.into()
|
||||
}
|
|
@ -4,3 +4,6 @@ version = "0.0.0"
|
|||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
ezpg_macros = { version = "0.0.0", path = "../ezpg-macros" }
|
||||
sqlx = { version = "0.8.2", features = ["macros", "postgres", "uuid"] }
|
||||
uuid = { version = "1.11.0", features = ["v7"] }
|
||||
|
|
102
crates/ezpg/src/crud_traits.rs
Normal file
102
crates/ezpg/src/crud_traits.rs
Normal file
|
@ -0,0 +1,102 @@
|
|||
//! traits used for c/r/u/d behaviours.
|
||||
|
||||
use sqlx::{PgPool, Postgres};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::Record;
|
||||
|
||||
/// [c]reate
|
||||
pub trait Create<'a, R> {
|
||||
async fn create(&self, pool: &PgPool) -> sqlx::Result<R>;
|
||||
}
|
||||
|
||||
impl<'a, R> Create<'a, R> for R
|
||||
where
|
||||
R: Record,
|
||||
Uuid: sqlx::Encode<'a, Postgres>,
|
||||
{
|
||||
async fn create(&self, pool: &PgPool) -> sqlx::Result<R> {
|
||||
let (new_uuid,): (Uuid,) = R::COLUMNS
|
||||
.iter()
|
||||
.fold(sqlx::query_as(R::CREATE_QUERY), |q, c| {
|
||||
self.bind_column(q, c)
|
||||
})
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
// technically, this could be called as `self.read`, however
|
||||
// there may be some obscure cases where the read trait isn't
|
||||
// applied.
|
||||
sqlx::query_as::<Postgres, R>(R::READ_QUERY)
|
||||
.bind(new_uuid)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
/// [r]ead
|
||||
pub trait Read<'a, R>: Record {
|
||||
async fn index(offset: i64, limit: i64, pool: &PgPool) -> sqlx::Result<Vec<R>>;
|
||||
async fn read(id: Uuid, pool: &PgPool) -> sqlx::Result<R>;
|
||||
}
|
||||
|
||||
impl<'a, R> Read<'a, R> for R
|
||||
where
|
||||
R: Record,
|
||||
Uuid: sqlx::Encode<'a, Postgres>,
|
||||
{
|
||||
async fn index(offset: i64, limit: i64, pool: &PgPool) -> sqlx::Result<Vec<R>> {
|
||||
sqlx::query_as::<Postgres, R>(R::INDEX_QUERY)
|
||||
.bind(offset)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn read(id: Uuid, pool: &PgPool) -> sqlx::Result<R> {
|
||||
sqlx::query_as::<Postgres, R>(R::READ_QUERY)
|
||||
.bind(id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
/// [c]reate
|
||||
pub trait Update<'a, R> {
|
||||
async fn update(&self, pool: &PgPool) -> sqlx::Result<R>;
|
||||
}
|
||||
|
||||
impl<'a, R> Update<'a, R> for R
|
||||
where
|
||||
R: Record,
|
||||
Uuid: sqlx::Encode<'a, Postgres>,
|
||||
{
|
||||
async fn update(&self, pool: &PgPool) -> sqlx::Result<R> {
|
||||
R::COLUMNS
|
||||
.iter()
|
||||
.fold(sqlx::query_as(R::UPDATE_QUERY), |q, c| {
|
||||
self.bind_column(q, c)
|
||||
})
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
/// [d]elete
|
||||
pub trait Delete<'a, R> {
|
||||
async fn delete(&self, pool: &PgPool) -> sqlx::Result<()>;
|
||||
}
|
||||
|
||||
impl<'a, R> Delete<'a, R> for R
|
||||
where
|
||||
R: Record,
|
||||
Uuid: sqlx::Encode<'a, Postgres>,
|
||||
{
|
||||
async fn delete(&self, pool: &PgPool) -> sqlx::Result<()> {
|
||||
sqlx::query(R::DELETE_QUERY)
|
||||
.bind(self.id())
|
||||
.execute(pool)
|
||||
.await
|
||||
.map(|_| ()) // i don't like this, but it works.
|
||||
}
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
mod crud_traits;
|
||||
|
||||
use sqlx::{postgres::{PgArguments, PgRow}, query::{Query, QueryAs}, Encode, FromRow, Postgres, Type};
|
||||
use uuid::Uuid;
|
||||
|
||||
// re-export the record macro.
|
||||
pub use ezpg_macros::Record;
|
||||
|
||||
// the following were stolen from [miniorm by meuter](https://github.com/meuter/miniorm-rs):
|
||||
pub trait QueryBind<'q> {
|
||||
fn bind<T>(self, value: T) -> Self
|
||||
where
|
||||
T: 'q + Encode<'q, Postgres> + Type<Postgres>;
|
||||
}
|
||||
|
||||
impl<'q> QueryBind<'q> for Query<'q, Postgres, PgArguments> {
|
||||
fn bind<T>(self, value: T) -> Self
|
||||
where
|
||||
T: 'q + Encode<'q, Postgres> + Type<Postgres>,
|
||||
{
|
||||
Query::bind(self, value)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'q, O> QueryBind<'q> for QueryAs<'q, Postgres, O, PgArguments> {
|
||||
fn bind<T>(self, value: T) -> Self
|
||||
where
|
||||
T: 'q + Encode<'q, Postgres> + Type<Postgres>,
|
||||
{
|
||||
QueryAs::bind(self, value)
|
||||
}
|
||||
}
|
||||
|
||||
/// c/r/u/d queries.
|
||||
/// standard crud query strings.
|
||||
pub trait WithQueries {
|
||||
const COLUMNS: &'static [&'static str];
|
||||
const CREATE_QUERY: &'static str;
|
||||
const INDEX_QUERY: &'static str;
|
||||
const READ_QUERY: &'static str;
|
||||
const UPDATE_QUERY: &'static str;
|
||||
const DELETE_QUERY: &'static str;
|
||||
}
|
||||
|
||||
/// binds a value to a field.
|
||||
/// used as part of the [ezpg_macros::Record] derive.
|
||||
pub trait BindColumn {
|
||||
fn bind_column<'q, Q: QueryBind<'q>>(&self, query: Q, column_name: &'static str) -> Q;
|
||||
}
|
||||
|
||||
/// a record stored within the postgres db.
|
||||
pub trait Record: WithQueries + BindColumn + for<'r> FromRow<'r, PgRow> + Send + Unpin {
|
||||
fn id(&self) -> Uuid;
|
||||
}
|
Loading…
Reference in a new issue