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]
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
members = [
|
members = [
|
||||||
"crates/ezpg",
|
"crates/ezpg",
|
||||||
"crates/ezpg-macros",
|
"crates/ezpg-macros",
|
||||||
]
|
]
|
||||||
resolver = "2"
|
|
||||||
|
|
|
@ -5,3 +5,10 @@ edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
proc-macro = true
|
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"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[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