feat: add new crud macro for easier entity manipulation in DB
All checks were successful
CI / tests (push) Successful in 7m52s

This commit is contained in:
2025-01-15 03:40:36 +01:00
parent e2a5758e53
commit 642d7bae0d
11 changed files with 580 additions and 166 deletions

View File

@@ -0,0 +1,23 @@
#[derive(deluxe::ExtractAttributes)]
#[deluxe(attributes(crud))]
pub struct CrudStructAttributes {
pub table: String,
}
#[derive(deluxe::ExtractAttributes, Clone)]
#[deluxe(attributes(crud))]
pub struct CrudFieldAttributes {
#[deluxe(default = false)]
pub id: bool,
#[deluxe(default = None)]
pub column: Option<String>,
}
#[derive(Clone)]
pub struct CrudField {
pub ident: syn::Ident,
pub field: syn::Field,
pub column: String,
pub id: bool,
pub ty: syn::Type,
}

View File

@@ -0,0 +1,188 @@
use ir::{CrudField, CrudFieldAttributes, CrudStructAttributes};
use quote::quote;
use syn::DeriveInput;
mod ir;
fn extract_crud_field_attrs(ast: &mut DeriveInput) -> deluxe::Result<(Vec<CrudField>, CrudField)> {
let mut field_attrs: Vec<CrudField> = Vec::new();
// let mut identifier: Option<CrudIdentifier> = None;
let mut identifier: Option<CrudField> = None;
let mut identifier_counter = 0;
if let syn::Data::Struct(s) = &mut ast.data {
for field in &mut s.fields {
let ident = field.clone().ident.unwrap();
let ty = field.clone().ty;
let attrs: CrudFieldAttributes =
deluxe::extract_attributes(field).expect("Could not extract attributes from field");
let field = CrudField {
ident: ident.clone(),
field: field.to_owned(),
column: attrs.column.unwrap_or_else(|| ident.to_string()),
id: attrs.id,
ty,
};
if attrs.id {
identifier_counter += 1;
identifier = Some(field.clone());
}
if identifier_counter > 1 {
return Err(syn::Error::new_spanned(
field.field,
"Struct {name} can only have one identifier",
));
}
field_attrs.push(field);
}
}
if identifier_counter < 1 {
Err(syn::Error::new_spanned(
ast,
"Struct {name} must have one identifier",
))
} else {
Ok((field_attrs, identifier.unwrap()))
}
}
fn generate_find_query(table: &str, id: &CrudField) -> proc_macro2::TokenStream {
let find_string = format!("SELECT * FROM {} WHERE {} = $1", table, id.column);
let ty = &id.ty;
quote! {
async fn find(pool: &::sqlx::PgPool, id: &#ty) -> ::sqlx::Result<Option<Self>> {
::sqlx::query_as!(Self, #find_string, id)
.fetch_optional(pool)
.await
}
}
}
fn generate_create_query(table: &str, fields: &[CrudField]) -> proc_macro2::TokenStream {
let inputs: Vec<String> = (1..=fields.len()).map(|num| format!("${num}")).collect();
let create_string = format!(
"INSERT INTO {} ({}) VALUES ({}) RETURNING *",
table,
fields
.iter()
.map(|v| v.column.clone())
.collect::<Vec<String>>()
.join(", "),
inputs.join(", ")
);
let field_idents: Vec<syn::Ident> = fields.iter().map(|f| f.ident.clone()).collect();
quote! {
async fn create(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Self> {
::sqlx::query_as!(
Self,
#create_string,
#(self.#field_idents),*
)
.fetch_one(pool)
.await
}
}
}
fn generate_update_query(
table: &str,
fields: &[CrudField],
id: &CrudField,
) -> proc_macro2::TokenStream {
let mut fields: Vec<&CrudField> = fields.iter().filter(|f| !f.id).collect();
let update_columns = fields
.iter()
.enumerate()
.map(|(i, &field)| format!("{} = ${}", field.column, i + 1))
.collect::<Vec<String>>()
.join(", ");
let update_string = format!(
"UPDATE {} SET {} WHERE {} = ${} RETURNING *",
table,
update_columns,
id.column,
fields.len() + 1
);
fields.push(id);
let field_idents: Vec<_> = fields.iter().map(|f| f.ident.clone()).collect();
quote! {
async fn update(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Self> {
::sqlx::query_as!(
Self,
#update_string,
#(self.#field_idents),*
)
.fetch_one(pool)
.await
}
}
}
fn generate_delete_query(table: &str, id: &CrudField) -> proc_macro2::TokenStream {
let delete_string = format!("DELETE FROM {} WHERE {} = $1", table, id.column);
let ty = &id.ty;
let ident = &id.ident;
quote! {
async fn delete_by_id(pool: &::sqlx::PgPool, id: &#ty) -> ::sqlx::Result<u64> {
let rows_affected = ::sqlx::query!(#delete_string, id)
.execute(pool)
.await?
.rows_affected();
Ok(rows_affected)
}
async fn delete(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<u64> {
let rows_affected = ::sqlx::query!(#delete_string, self.#ident)
.execute(pool)
.await?
.rows_affected();
Ok(rows_affected)
}
}
}
pub fn crud_derive_macro2(
item: proc_macro2::TokenStream,
) -> deluxe::Result<proc_macro2::TokenStream> {
// parse
let mut ast: DeriveInput = syn::parse2(item).expect("Failed to parse input");
// extract struct attributes
let CrudStructAttributes { table } =
deluxe::extract_attributes(&mut ast).expect("Could not extract attributes from struct");
// extract field attributes
let (fields, id) = extract_crud_field_attrs(&mut ast)?;
let ty = &id.ty;
let id_ident = &id.ident;
// define impl variables
let ident = &ast.ident;
let (impl_generics, type_generics, where_clause) = ast.generics.split_for_impl();
// generate
let find_query = generate_find_query(&table, &id);
let create_query = generate_create_query(&table, &fields);
let update_query = generate_update_query(&table, &fields, &id);
let delete_query = generate_delete_query(&table, &id);
let code = quote! {
impl #impl_generics Crud<#ty> for #ident #type_generics #where_clause {
#find_query
#create_query
#update_query
async fn create_or_update(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Self> {
if Self::find(pool, &self.#id_ident).await?.is_some() {
self.update(pool).await
} else {
self.create(pool).await
}
}
#delete_query
}
};
Ok(code)
}

81
gejdr-macros/src/lib.rs Normal file
View File

@@ -0,0 +1,81 @@
#![deny(clippy::all)]
#![deny(clippy::pedantic)]
#![deny(clippy::nursery)]
#![allow(clippy::module_name_repetitions)]
#![allow(clippy::unused_async)]
#![allow(clippy::useless_let_if_seq)] // Reason: prevents some OpenApi structs from compiling
#![forbid(unsafe_code)]
//! Create ``SQLx`` CRUD code for a struct in Postgres.
//!
//! This crate provides the trait implementation `Crud` which
//! generates the following ``SQLx`` queries:
//! - find an entity by id
//!
//! SQL query: `SELECT * FROM ... WHERE <id> = ...`
//! - insert an entity into the database
//!
//! SQL query: `INSERT INTO ... (...) VALUES (...) RETURNING *`
//! - update an entity in the database
//!
//! SQL query: `UPDATE ... SET ... WHERE <id> = ... RETURNING *`
//! - delete an entity from the database using its id
//!
//! SQL query: `DELETE FROM ... WHERE <id> = ...`
//! - update an entity or create it if it does not already exist in
//! - the database
//!
//! This macro relies on the trait `Crud` found in the `gejdr-core`
//! crate.
//!
//! To use this macro, you need to add it to the derives of the
//! struct. You will also need to define its identifier
//!
//! # Usage
//!
//! Add `#[crud(table = "my_table_name")]` atop of the structure,
//! after the `Crud` derive. You will also need to add `#[crud(id)]`
//! atop of the field of your struct that will be used as the
//! identifier of your entity.
//!
//! ```ignore
//! #[derive(Crud)]
//! #[crud(table = "users")]
//! pub struct User {
//! #[crud(id)]
//! id: String,
//! username: String,
//! created_at: Timestampz,
//! last_updated: Timestampz,
//! }
//! ```
//!
//! With the example of the `User` struct, this links it to the
//! `users` table of the connected database. It will use `Users.id` to
//! uniquely identify a user entity.
//!
//! # Limitations
//! ## ID
//! For now, only one identifier is supported. It does not have to be
//! a primary key, but it is strongly encouraged to use GeJDRs Crud
//! ID on a unique and non-null column of your database schema.
//!
//! ## Database type
//!
//! For now, only the ``PostgreSQL`` syntax is supported. If you use
//! another database that uses the same syntax, youre in luck!
//! Otherwise, pull requests to add additional syntaxes are most
//! welcome.
mod crud;
use crud::crud_derive_macro2;
/// Generates CRUD code for Sqlx for a struct.
///
/// # Panics
///
/// May panic if errors arise while parsing and generating code.
#[proc_macro_derive(Crud, attributes(crud))]
pub fn crud_derive_macro(item: proc_macro::TokenStream) -> proc_macro::TokenStream {
crud_derive_macro2(item.into()).unwrap().into()
}