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:
elliot speck 2024-12-28 21:48:30 +11:00
parent 43ec1e7128
commit 96f3081f9a
No known key found for this signature in database
9 changed files with 2140 additions and 2 deletions

1807
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
[workspace] [workspace]
resolver = "2"
members = [ members = [
"crates/ezpg", "crates/ezpg",
"crates/ezpg-macros", "crates/ezpg-macros",
] ]
resolver = "2"

View file

@ -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"

View 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
}
}

View 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,
}
}
}
}
}
}

View file

@ -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()
}

View file

@ -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"] }

View 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.
}
}

View file

@ -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;
}