mod begin;
mod combo_cursor;
mod create_all_fn;
mod create_fn;
mod delete_fn;
mod error_types;
mod find_all_fn;
mod find_by_fn;
mod forget_fn;
mod list_by_fn;
mod list_for_filters_fn;
mod list_for_fn;
mod nested;
mod options;
mod persist_events_batch_fn;
mod persist_events_fn;
mod populate_nested;
mod post_hydrate_hook;
mod post_persist_hook;
mod scope;
mod update_all_fn;
mod update_fn;
use darling::{FromDeriveInput, ToTokens};
use proc_macro2::TokenStream;
use quote::{TokenStreamExt, quote};
use options::RepositoryOptions;
pub fn derive(ast: syn::DeriveInput) -> darling::Result<proc_macro2::TokenStream> {
let opts = RepositoryOptions::from_derive_input(&ast)?;
opts.columns.validate_list_for_by_columns()?;
opts.columns.validate_scope()?;
opts.validate_forgettable()?;
let repo = EsRepo::from(&opts);
Ok(quote!(#repo))
}
pub struct EsRepo<'a> {
repo: &'a syn::Ident,
generics: &'a syn::Generics,
persist_events_fn: persist_events_fn::PersistEventsFn<'a>,
persist_events_batch_fn: persist_events_batch_fn::PersistEventsBatchFn<'a>,
update_fn: update_fn::UpdateFn<'a>,
update_all_fn: update_all_fn::UpdateAllFn<'a>,
create_fn: create_fn::CreateFn<'a>,
create_all_fn: create_all_fn::CreateAllFn<'a>,
delete_fn: delete_fn::DeleteFn<'a>,
forget_fn: Option<forget_fn::ForgetFn<'a>>,
find_by_fns: Vec<find_by_fn::FindByFn<'a>>,
find_all_fn: find_all_fn::FindAllFn<'a>,
post_hydrate_hook: post_hydrate_hook::PostHydrateHook<'a>,
post_persist_hook: post_persist_hook::PostPersistHook<'a>,
begin: begin::Begin<'a>,
list_by_fns: Vec<list_by_fn::ListByFn<'a>>,
list_for_fns: Vec<list_for_fn::ListForFn<'a>>,
nested_fns: Vec<syn::Ident>,
nested_include_deleted_fns: Vec<syn::Ident>,
nested: Vec<nested::Nested<'a>>,
populate_nested: Option<populate_nested::PopulateNested<'a>>,
error_types: error_types::ErrorTypes<'a>,
opts: &'a RepositoryOptions,
}
impl<'a> From<&'a RepositoryOptions> for EsRepo<'a> {
fn from(opts: &'a RepositoryOptions) -> Self {
let find_by_fns = opts
.columns
.all_find_by()
.map(|c| find_by_fn::FindByFn::new(c, opts))
.collect();
let list_by_fns = opts
.columns
.all_list_by()
.map(|c| list_by_fn::ListByFn::new(c, opts))
.collect();
let list_for_fns = opts
.columns
.all_list_for()
.flat_map(|for_col| {
for_col
.list_for_by_columns()
.iter()
.filter_map(|by_name| {
opts.columns
.find_list_by(by_name)
.map(|by_col| list_for_fn::ListForFn::new(for_col, by_col, opts))
})
.collect::<Vec<_>>()
})
.collect();
let populate_nested = opts
.columns
.parent()
.map(|c| populate_nested::PopulateNested::new(c, opts));
let nested_include_deleted_fns: Vec<_> = opts
.all_nested()
.map(|n| n.find_nested_include_deleted_fn_name())
.collect();
let (nested_fns, nested): (Vec<_>, Vec<_>) = opts
.all_nested()
.map(|n| (n.find_nested_fn_name(), nested::Nested::new(n, opts)))
.unzip();
let forget_fn = if opts.forgettable_enabled() {
Some(forget_fn::ForgetFn::from(opts))
} else {
None
};
Self {
repo: &opts.ident,
generics: &opts.generics,
persist_events_fn: persist_events_fn::PersistEventsFn::from(opts),
persist_events_batch_fn: persist_events_batch_fn::PersistEventsBatchFn::from(opts),
update_fn: update_fn::UpdateFn::from(opts),
update_all_fn: update_all_fn::UpdateAllFn::from(opts),
create_fn: create_fn::CreateFn::from(opts),
create_all_fn: create_all_fn::CreateAllFn::from(opts),
delete_fn: delete_fn::DeleteFn::from(opts),
forget_fn,
find_by_fns,
find_all_fn: find_all_fn::FindAllFn::from(opts),
post_hydrate_hook: post_hydrate_hook::PostHydrateHook::from(opts),
post_persist_hook: post_persist_hook::PostPersistHook::from(opts),
begin: begin::Begin::from(opts),
list_by_fns,
list_for_fns,
nested_fns,
nested_include_deleted_fns,
nested,
populate_nested,
error_types: error_types::ErrorTypes::new(opts),
opts,
}
}
}
impl ToTokens for EsRepo<'_> {
fn to_tokens(&self, tokens: &mut TokenStream) {
let repo = &self.repo;
let persist_events_fn = &self.persist_events_fn;
let persist_events_batch_fn = &self.persist_events_batch_fn;
let update_fn = &self.update_fn;
let update_all_fn = &self.update_all_fn;
let create_fn = &self.create_fn;
let create_all_fn = &self.create_all_fn;
let delete_fn = &self.delete_fn;
let forget_fn = &self.forget_fn;
let find_by_fns = &self.find_by_fns;
let find_all_fn = &self.find_all_fn;
let post_hydrate_hook = &self.post_hydrate_hook;
let post_persist_hook = &self.post_persist_hook;
let begin = &self.begin;
let cursors = self.list_by_fns.iter().map(|l| l.cursor());
let combo_cursor = combo_cursor::ComboCursor::new(
self.opts,
self.list_by_fns.iter().map(|l| l.cursor()).collect(),
);
let sort_by = combo_cursor.sort_by();
let list_for_filters = list_for_filters_fn::ListForFiltersFn::new(
self.opts,
self.opts.columns.all_list_for().collect(),
self.opts.columns.all_list_by().collect(),
&combo_cursor,
);
let list_for_filters_struct = &list_for_filters.filters_struct;
#[cfg(feature = "graphql")]
let gql_combo_cursor = combo_cursor.gql_cursor();
#[cfg(not(feature = "graphql"))]
let gql_combo_cursor = TokenStream::new();
#[cfg(feature = "graphql")]
let gql_cursors: Vec<_> = self
.list_by_fns
.iter()
.map(|l| l.cursor().gql_cursor())
.collect();
#[cfg(not(feature = "graphql"))]
let gql_cursors: Vec<TokenStream> = Vec::new();
let list_by_fns = &self.list_by_fns;
let list_for_fns = &self.list_for_fns;
let entity = self.opts.entity();
let event = self.opts.event();
let id = self.opts.id();
let cursor_mod = self.opts.cursor_mod();
let types_mod = self.opts.repo_types_mod();
let nested_fns = &self.nested_fns;
let nested_include_deleted_fns = &self.nested_include_deleted_fns;
let nested = &self.nested;
let populate_nested = &self.populate_nested;
let pool_field = self.opts.pool_field();
let has_tbl_prefix = self.opts.table_prefix().is_some();
let es_query_flavor = if nested_fns.is_empty() {
quote! {
es_entity::EsQueryFlavorFlat
}
} else {
quote! { es_entity::EsQueryFlavorNested }
};
let create_error = self.opts.create_error();
let modify_error = self.opts.modify_error();
let find_error = self.opts.find_error();
let query_error = self.opts.query_error();
let error_types = self.error_types.generate();
let map_constraint_fn = self.error_types.generate_map_constraint_fn();
let scope_type = scope::ScopeType::new(self.opts);
let scope_type = quote! { #scope_type };
let (impl_generics, ty_generics, where_clause) = self.generics.split_for_impl();
let (scoped_fn, scoped_view) = if let Some(info) = scope::ScopeInfo::from_opts(self.opts) {
let scoped_ident = self.opts.scoped_view_ident();
let scope_ty = &info.scope_ty;
let repo_ident = self.repo;
let mut scoped_generics = self.generics.clone();
scoped_generics
.params
.insert(0, syn::parse_quote!('scoped_repo));
let scoped_struct_where = scoped_generics.where_clause.clone();
let (scoped_impl_generics, scoped_ty_generics, scoped_where) =
scoped_generics.split_for_impl();
let find_by_delegates = self.find_by_fns.iter().map(|f| f.scoped_delegates());
let find_all_delegates = self.find_all_fn.scoped_delegates();
let list_by_delegates = self.list_by_fns.iter().map(|f| f.scoped_delegates());
let list_for_delegates = self.list_for_fns.iter().map(|f| f.scoped_delegates());
let list_for_filters_delegates = list_for_filters.scoped_delegates();
let scoped_doc = format!(
"Bound view of [`{repo_ident}`] with a [`{scope_ty}`] captured once: every \
read method delegates to the corresponding scope-argument fn with the bound \
scope. Obtained via [`{repo_ident}::scoped`]. Borrows the repo, so it is \
naturally request-scoped."
);
(
quote! {
pub fn scoped<'scoped_repo>(
&'scoped_repo self,
scope: impl Into<#scope_ty>,
) -> #scoped_ident #scoped_ty_generics {
#scoped_ident {
repo: self,
scope: scope.into(),
}
}
},
quote! {
#[doc = #scoped_doc]
pub struct #scoped_ident #scoped_generics #scoped_struct_where {
repo: &'scoped_repo #repo_ident #ty_generics,
scope: #scope_ty,
}
impl #scoped_impl_generics #scoped_ident #scoped_ty_generics #scoped_where {
#[inline(always)]
pub fn scope(&self) -> #scope_ty {
self.scope
}
#(#find_by_delegates)*
#find_all_delegates
#(#list_by_delegates)*
#(#list_for_delegates)*
#list_for_filters_delegates
}
},
)
} else {
(quote! {}, quote! {})
};
let forgettable_event_guard = if self.opts.forgettable_enabled() {
quote! {}
} else {
quote! {
const _: () = assert!(
!Repo__Event::HAS_FORGETTABLE_FIELDS,
"event type has Forgettable fields but this repo does not enable `forgettable`; add `forgettable` to #[es_repo(...)]"
);
}
};
tokens.append_all(quote! {
pub mod #cursor_mod {
use super::*;
#(#cursors)*
#(#gql_cursors)*
#combo_cursor
#gql_combo_cursor
}
mod #types_mod {
use super::*;
#[allow(non_camel_case_types)]
pub(super) type Repo__Id = #id;
#[allow(non_camel_case_types)]
pub(super) type Repo__Event = #event;
#[allow(non_camel_case_types)]
pub(super) type Repo__Entity = #entity;
#[allow(non_camel_case_types)]
pub(super) type Repo__DbEvent = es_entity::GenericEvent<#id>;
#[allow(dead_code)]
pub(super) const REPO__HAS_TBL_PREFIX: bool = #has_tbl_prefix;
#forgettable_event_guard
}
#error_types
#scope_type
#scoped_view
#list_for_filters_struct
#sort_by
impl #impl_generics #repo #ty_generics #where_clause {
#[inline(always)]
pub fn pool(&self) -> &es_entity::db::Pool {
&self.#pool_field
}
#scoped_fn
#map_constraint_fn
#begin
#post_hydrate_hook
#post_persist_hook
#persist_events_fn
#persist_events_batch_fn
#create_fn
#create_all_fn
#update_fn
#update_all_fn
#delete_fn
#forget_fn
#(#find_by_fns)*
#find_all_fn
#list_for_filters
#(#list_by_fns)*
#(#list_for_fns)*
#(#nested)*
}
#populate_nested
impl #impl_generics es_entity::EsRepo for #repo #ty_generics #where_clause {
type Entity = #entity;
type CreateError = #create_error;
type ModifyError = #modify_error;
type FindError = #find_error;
type QueryError = #query_error;
type EsQueryFlavor = #es_query_flavor;
#[inline(always)]
async fn load_all_nested_in_op<OP, __EsErr>(
op: &mut OP, entities: &mut [#entity]
) -> Result<(), __EsErr>
where
OP: es_entity::AtomicOperation,
__EsErr: From<sqlx::Error> + From<es_entity::EntityHydrationError> + Send,
{
#(Self::#nested_fns::<_, _, __EsErr>(op, entities).await?;)*
Ok(())
}
#[inline(always)]
async fn load_all_nested_in_op_include_deleted<OP, __EsErr>(
op: &mut OP, entities: &mut [#entity]
) -> Result<(), __EsErr>
where
OP: es_entity::AtomicOperation,
__EsErr: From<sqlx::Error> + From<es_entity::EntityHydrationError> + Send,
{
#(Self::#nested_include_deleted_fns::<_, _, __EsErr>(op, entities).await?;)*
Ok(())
}
}
});
}
}
#[cfg(test)]
mod tests {
use syn::parse_quote;
use super::*;
#[test]
fn forgettable_index_column_without_flag_is_error() {
let input: syn::DeriveInput = parse_quote! {
#[es_repo(entity = "Subscriber", columns(email(ty = "Forgettable<String>")))]
struct Subscribers {
pool: sqlx::PgPool,
}
};
let err = derive(input).unwrap_err();
assert!(
err.to_string().contains("does not enable `forgettable`"),
"unexpected error: {err}"
);
}
#[test]
fn forgettable_index_column_with_flag_is_ok() {
let input: syn::DeriveInput = parse_quote! {
#[es_repo(
entity = "Subscriber",
forgettable,
columns(email(ty = "Forgettable<String>"))
)]
struct Subscribers {
pool: sqlx::PgPool,
}
};
assert!(derive(input).is_ok());
}
#[test]
fn plain_repo_is_ok() {
let input: syn::DeriveInput = parse_quote! {
#[es_repo(entity = "User", columns(name(ty = "String")))]
struct Users {
pool: sqlx::PgPool,
}
};
assert!(derive(input).is_ok());
}
#[test]
fn scoped_repo_is_ok() {
let input: syn::DeriveInput = parse_quote! {
#[es_repo(
entity = "User",
columns(partner_id(ty = "PartnerId", scope), name(ty = "String"))
)]
struct Users {
pool: sqlx::PgPool,
}
};
let tokens = derive(input)
.expect("scoped repo should derive")
.to_string();
assert!(tokens.contains("pub enum UserScope"));
assert!(tokens.contains("fn find_by_id (& self , scope : impl Into < UserScope >"));
assert!(tokens.contains("(& self , scope : impl Into < UserScope > , ids"));
assert!(tokens.contains("fn list_by_created_at (& self , scope : impl Into < UserScope >"));
assert!(tokens.contains("WHERE id = $1 AND partner_id = $2"));
assert!(tokens.contains("WHERE id = $1\""));
assert!(!tokens.contains("find_by_partner_id"));
assert!(tokens.contains("fn create_in_op < OP > (& self , op : & mut OP , new_entity"));
assert!(tokens.contains("fn update_in_op < OP > (& self , op : & mut OP , entity"));
}
#[test]
fn scoped_repo_generates_bound_view() {
let input: syn::DeriveInput = parse_quote! {
#[es_repo(
entity = "User",
columns(partner_id(ty = "PartnerId", scope), name(ty = "String"))
)]
struct Users {
pool: sqlx::PgPool,
}
};
let tokens = derive(input)
.expect("scoped repo should derive")
.to_string();
assert!(tokens.contains("pub struct ScopedUsers"));
assert!(tokens.contains("pub fn scoped <"));
assert!(tokens.contains("self . repo . find_by_id (self . scope"));
assert!(tokens.contains("self . repo . maybe_find_by_id_in_op (op , self . scope"));
assert!(tokens.contains("self . repo . find_all (self . scope"));
assert!(tokens.contains("self . repo . list_by_created_at (self . scope"));
assert!(tokens.contains("self . repo . list_for_filters (self . scope"));
}
#[test]
fn unscoped_repo_has_no_bound_view() {
let input: syn::DeriveInput = parse_quote! {
#[es_repo(entity = "User", columns(name(ty = "String")))]
struct Users {
pool: sqlx::PgPool,
}
};
let tokens = derive(input).unwrap().to_string();
assert!(!tokens.contains("ScopedUsers"));
assert!(!tokens.contains("pub fn scoped <"));
}
#[test]
fn scoped_repo_with_generics_generates_bound_view() {
let input: syn::DeriveInput = parse_quote! {
#[es_repo(
entity = "User",
columns(partner_id(ty = "PartnerId", scope))
)]
struct Users<E> {
pool: sqlx::PgPool,
_phantom: std::marker::PhantomData<E>,
}
};
let tokens = derive(input)
.expect("generic scoped repo should derive")
.to_string();
assert!(tokens.contains("pub struct ScopedUsers < 'scoped_repo , E >"));
}
#[test]
fn two_scope_columns_is_error() {
let input: syn::DeriveInput = parse_quote! {
#[es_repo(
entity = "User",
columns(
partner_id(ty = "PartnerId", scope),
customer_id(ty = "CustomerId", scope)
)
)]
struct Users {
pool: sqlx::PgPool,
}
};
let err = derive(input).unwrap_err();
assert!(
err.to_string().contains("only one scope column"),
"unexpected error: {err}"
);
}
#[test]
fn optional_scope_column_is_error() {
let input: syn::DeriveInput = parse_quote! {
#[es_repo(
entity = "User",
columns(partner_id(ty = "Option<PartnerId>", scope))
)]
struct Users {
pool: sqlx::PgPool,
}
};
let err = derive(input).unwrap_err();
assert!(
err.to_string().contains("non-nullable"),
"unexpected error: {err}"
);
}
#[test]
fn nullable_annotated_scope_column_is_error() {
let input: syn::DeriveInput = parse_quote! {
#[es_repo(
entity = "User",
columns(partner_id(ty = "PartnerId", scope, nullable = true))
)]
struct Users {
pool: sqlx::PgPool,
}
};
let err = derive(input).unwrap_err();
assert!(
err.to_string().contains("non-nullable"),
"unexpected error: {err}"
);
}
#[test]
fn scope_column_with_query_flags_is_error() {
for extra in ["find_by = true", "list_by = true", "list_for"] {
let src = format!(
r#"
#[es_repo(
entity = "User",
columns(partner_id(ty = "PartnerId", scope, {extra}))
)]
struct Users {{
pool: sqlx::PgPool,
}}
"#
);
let input: syn::DeriveInput = syn::parse_str(&src).unwrap();
let err = derive(input).unwrap_err();
assert!(
err.to_string()
.contains("cannot also be find_by, list_by or list_for"),
"unexpected error for `{extra}`: {err}"
);
}
}
#[test]
fn scope_on_nested_child_repo_is_error() {
let input: syn::DeriveInput = parse_quote! {
#[es_repo(
entity = "LineItem",
columns(
order_id(ty = "OrderId", parent),
partner_id(ty = "PartnerId", scope)
)
)]
struct LineItems {
pool: sqlx::PgPool,
}
};
let err = derive(input).unwrap_err();
assert!(
err.to_string().contains("not supported on nested repos"),
"unexpected error: {err}"
);
}
#[test]
fn forgettable_scope_column_is_error() {
let input: syn::DeriveInput = parse_quote! {
#[es_repo(
entity = "User",
forgettable,
columns(partner_id(ty = "Forgettable<PartnerId>", scope))
)]
struct Users {
pool: sqlx::PgPool,
}
};
let err = derive(input).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("Forgettable") || msg.contains("non-nullable"),
"unexpected error: {msg}"
);
}
}