diff --git a/.sqlx/query-6bae2f6ec3f8d5295b935cef18cb3dd649758b5afcf70feb2a6eff7d69925bfc.json b/.sqlx/query-6bae2f6ec3f8d5295b935cef18cb3dd649758b5afcf70feb2a6eff7d69925bfc.json deleted file mode 100644 index 60f58c60..00000000 --- a/.sqlx/query-6bae2f6ec3f8d5295b935cef18cb3dd649758b5afcf70feb2a6eff7d69925bfc.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT NOW()", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "now", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - null - ] - }, - "hash": "6bae2f6ec3f8d5295b935cef18cb3dd649758b5afcf70feb2a6eff7d69925bfc" -} diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index 8fba2ba6..f97ee49e 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -35,4 +35,6 @@ - [Aggregates](./aggregates.md) - [Nesting](./nesting.md) +- [Forgettable Data](./forgettable.md) + - [Clock](./clock.md) diff --git a/book/src/es-query.md b/book/src/es-query.md index 92a943e1..b4c36c16 100644 --- a/book/src/es-query.md +++ b/book/src/es-query.md @@ -52,7 +52,7 @@ impl Users { FROM users WHERE name = $1 ) - SELECT e.id as entity_id, e.sequence, e.event, e.context as "context: ContextData", e.recorded_at + SELECT e.id as entity_id, e.sequence, e.event, e.context as "context: ContextData", e.recorded_at, NULL::jsonb as "forgettable_payload?" FROM user_events e JOIN target_entity te ON e.id = te.id ORDER BY e.sequence; diff --git a/book/src/forgettable.md b/book/src/forgettable.md new file mode 100644 index 00000000..9d5ac603 --- /dev/null +++ b/book/src/forgettable.md @@ -0,0 +1,251 @@ +# Forgettable Data + +In event sourcing, events are immutable — they form the permanent audit log. But regulations like GDPR require the ability to permanently delete personal data on request. + +`es-entity` solves this with the `Forgettable` wrapper type. Fields marked as `Forgettable` have their values stored separately from the event data, so they can be deleted independently without rewriting event history. + +## How It Works + +1. When an event is persisted, any `Forgettable` field values are **extracted** and stored in a separate `_forgettable_payloads` table +2. The event itself is stored with `null` in place of those fields +3. When loading, the payload values are **injected** back into the event before deserialization +4. Calling `forget()` deletes the payloads — events remain intact but with `null` for forgotten fields + +## Database Setup + +You need one additional table alongside your events table: + +```sql +CREATE TABLE customers ( + id UUID PRIMARY KEY, + name VARCHAR NOT NULL, + email VARCHAR UNIQUE NOT NULL, + created_at TIMESTAMPTZ NOT NULL +); + +CREATE TABLE customer_events ( + id UUID NOT NULL REFERENCES customers(id), + sequence INT NOT NULL, + event_type VARCHAR NOT NULL, + event JSONB NOT NULL, + context JSONB DEFAULT NULL, + recorded_at TIMESTAMPTZ NOT NULL, + UNIQUE(id, sequence) +); + +-- The forgettable payloads table +CREATE TABLE customer_forgettable_payloads ( + entity_id UUID NOT NULL REFERENCES customers(id), + sequence INT NOT NULL, + payload JSONB NOT NULL, + UNIQUE(entity_id, sequence) +); +``` + +## Defining Forgettable Fields + +Wrap any personal data fields in `Forgettable` in your event enum: + +```rust +# extern crate es_entity; +# extern crate sqlx; +# extern crate serde; +# use serde::{Deserialize, Serialize}; +use es_entity::*; + +es_entity::entity_id! { CustomerId } + +#[derive(EsEvent, Debug, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +#[es_event(id = "CustomerId")] +pub enum CustomerEvent { + Initialized { + id: CustomerId, + // `name` is personal data — wrap it in Forgettable + name: Forgettable, + // `email` is NOT forgettable — it stays in the event + email: String, + }, + NameUpdated { + name: Forgettable, + }, + EmailUpdated { + email: String, + }, +} +# fn main() {} +``` + +The `EsEvent` derive macro detects `Forgettable` fields and generates the extraction/injection code automatically. + +## Accessing Forgettable Values + +`Forgettable` is an opaque type. You cannot pattern-match the inner value directly. Instead, use `.value()` which returns an `Option>`: + +```rust +# extern crate es_entity; +use es_entity::Forgettable; + +// Create with Forgettable::new() or .into() +let name: Forgettable = Forgettable::new("Alice".to_string()); +let also_name: Forgettable = "Alice".to_string().into(); + +// .value() returns Option> +// ForgettableRef derefs to T but does NOT implement Serialize +if let Some(val) = name.value() { + assert_eq!(&*val, "Alice"); +} + +// Forgettable::forgotten() or Default::default() +let forgotten: Forgettable = Forgettable::forgotten(); +let also_forgotten: Forgettable = Default::default(); +assert!(forgotten.value().is_none()); +# fn main() {} +``` + +`ForgettableRef` intentionally does **not** implement `Serialize`, preventing accidental re-serialization of personal data into secondary stores. + +## Hydrating Entities + +In `TryFromEvents`, use `.value()` to read forgettable fields and provide a fallback for forgotten values: + +```rust +# extern crate es_entity; +# extern crate sqlx; +# extern crate serde; +# extern crate derive_builder; +# use serde::{Deserialize, Serialize}; +# use derive_builder::Builder; +# use es_entity::*; +# es_entity::entity_id! { CustomerId } +# #[derive(EsEvent, Debug, Serialize, Deserialize)] +# #[serde(tag = "type", rename_all = "snake_case")] +# #[es_event(id = "CustomerId")] +# pub enum CustomerEvent { +# Initialized { id: CustomerId, name: Forgettable, email: String }, +# NameUpdated { name: Forgettable }, +# EmailUpdated { email: String }, +# } +#[derive(EsEntity, Builder)] +#[builder(pattern = "owned", build_fn(error = "EsEntityError"))] +pub struct Customer { + pub id: CustomerId, + pub name: String, + pub email: String, + events: EntityEvents, +} + +impl TryFromEvents for Customer { + fn try_from_events(events: EntityEvents) -> Result { + let mut builder = CustomerBuilder::default(); + for event in events.iter_all() { + match event { + CustomerEvent::Initialized { id, name, email } => { + builder = builder + .id(*id) + // Provide a fallback for forgotten values + .name( + name.value() + .map(|r| r.clone()) + .unwrap_or_else(|| "[forgotten]".into()), + ) + .email(email.clone()); + } + CustomerEvent::NameUpdated { name } => { + if let Some(n) = name.value() { + builder = builder.name(n.clone()); + } + } + CustomerEvent::EmailUpdated { email } => { + builder = builder.email(email.clone()); + } + } + } + builder.events(events).build() + } +} +# impl IntoEvents for NewCustomer { +# fn into_events(self) -> EntityEvents { +# EntityEvents::init(self.id, [CustomerEvent::Initialized { +# id: self.id, name: Forgettable::new(self.name), email: self.email, +# }]) +# } +# } +# pub struct NewCustomer { id: CustomerId, name: String, email: String } +# fn main() {} +``` + +## Repository Configuration + +Enable forgettable on the repository with the `forgettable` attribute: + +```rust,ignore +#[derive(EsRepo)] +#[es_repo( + entity = "Customer", + columns(name = "String", email = "String"), + forgettable, +)] +pub struct Customers { + pool: sqlx::PgPool, +} +``` + +This generates a `forget` method on the repository that: +1. Deletes all forgettable payloads for the entity from the database +2. Rebuilds the entity in-place with forgotten fields set to `Forgettable::forgotten()` + +```rust,ignore +// Load the entity +let mut customer = customers.find_by_id(id).await?; +assert_eq!(customer.name, "Alice"); + +// Forget personal data — updates `customer` in place +customers.forget(&mut customer).await?; + +// The entity immediately reflects the forgotten state +assert_eq!(customer.name, "[forgotten]"); +``` + +## Custom Queries with `es_query!` + +If you write custom queries using `es_query!`, you must pass the `forgettable_tbl` parameter so the generated SQL includes the LEFT JOIN for forgettable payloads: + +```rust,ignore +let query = es_query!( + entity = Customer, + sql = "SELECT * FROM customers WHERE email = $1", + args = [email as String], + forgettable_tbl = "customer_forgettable_payloads", +); +``` + +If you omit `forgettable_tbl` on an event type that has `Forgettable` fields, you get a **compile-time error**: + +```text +error: es_query! requires `forgettable_tbl` parameter when the event type has Forgettable fields +``` + +This prevents silently loading events without their forgettable data. + +## Delete and Forgettable + +When `forgettable` is enabled and `delete = "soft"` is configured, calling `delete()` will also automatically delete all forgettable payloads for the entity. This prevents orphaned personal data from remaining in the database after a soft delete. + +```rust,ignore +#[derive(EsRepo)] +#[es_repo( + entity = "Customer", + columns(name = "String", email = "String"), + delete = "soft", + forgettable, +)] +pub struct Customers { + pool: sqlx::PgPool, +} + +// Soft-delete also cleans up forgettable payloads +customers.delete(customer).await?; +``` + +**Important:** The payloads are *hard-deleted* even when the entity is only soft-deleted. If the entity is later restored, the forgettable fields will remain permanently forgotten. diff --git a/es-entity-macros/src/event.rs b/es-entity-macros/src/event.rs index de6fc5ce..784fd0e7 100644 --- a/es-entity-macros/src/event.rs +++ b/es-entity-macros/src/event.rs @@ -1,3 +1,4 @@ +use convert_case::{Case, Casing}; use darling::{FromDeriveInput, ToTokens}; use proc_macro2::TokenStream; use quote::{TokenStreamExt, quote}; @@ -11,9 +12,227 @@ pub struct EsEvent { event_ctx: Option, } +/// Information about forgettable fields in an event enum. +struct ForgettableInfo { + /// Whether any variant has forgettable fields. + has_forgettable: bool, + /// Per-variant: (variant_ident, serde_tag_value, list_of_forgettable_field_idents) + variants: Vec<(syn::Ident, String, Vec)>, +} + pub fn derive(ast: syn::DeriveInput) -> darling::Result { let event = EsEvent::from_derive_input(&ast)?; - Ok(quote!(#event)) + let forgettable_info = extract_forgettable_info(&ast); + let ident = &event.ident; + + let mut tokens = quote!(#event); + + // Generate forgettable support methods + let has_forgettable = forgettable_info.has_forgettable; + + let match_arms: Vec<_> = forgettable_info + .variants + .iter() + .map(|(variant_ident, _tag_value, field_idents)| { + if field_idents.is_empty() { + quote! { + #ident::#variant_ident { .. } => None, + } + } else { + let field_name_strs: Vec = + field_idents.iter().map(|i| i.to_string()).collect(); + let inserts: Vec<_> = field_idents + .iter() + .zip(field_name_strs.iter()) + .map(|(field_id, field_name)| { + quote! { + if let Some(v) = #field_id.__extract_payload_value() { + payload.insert( + #field_name.to_string(), + v, + ); + } + } + }) + .collect(); + quote! { + #ident::#variant_ident { #(#field_idents),*, .. } => { + let mut payload = es_entity::prelude::serde_json::Map::new(); + #(#inserts)* + if payload.is_empty() { None } else { Some(payload.into()) } + } + } + } + }) + .collect(); + + let forget_match_arms: Vec<_> = forgettable_info + .variants + .iter() + .map(|(variant_ident, _tag_value, field_idents)| { + if field_idents.is_empty() { + quote! { + #ident::#variant_ident { .. } => {} + } + } else { + let assignments: Vec<_> = field_idents + .iter() + .map(|field_id| { + quote! { + *#field_id = es_entity::Forgettable::forgotten(); + } + }) + .collect(); + quote! { + #ident::#variant_ident { #(#field_idents),*, .. } => { + #(#assignments)* + } + } + } + }) + .collect(); + + tokens.append_all(quote! { + impl #ident { + #[doc(hidden)] + pub const HAS_FORGETTABLE_FIELDS: bool = #has_forgettable; + + #[doc(hidden)] + pub fn extract_forgettable_payloads(&self) -> Option { + match self { + #(#match_arms)* + } + } + + #[doc(hidden)] + pub fn forget_forgettable_payloads(&mut self) { + match self { + #(#forget_match_arms)* + } + } + } + }); + + Ok(tokens) +} + +/// Extract forgettable field information from the enum definition. +fn extract_forgettable_info(ast: &syn::DeriveInput) -> ForgettableInfo { + let rename_rule = parse_serde_rename_all(ast); + + let variants = match &ast.data { + syn::Data::Enum(data) => data + .variants + .iter() + .map(|variant| { + let variant_ident = variant.ident.clone(); + let tag_value = serde_variant_name(variant, &rename_rule); + let forgettable_fields = variant + .fields + .iter() + .filter_map(|field| { + if is_forgettable_type(&field.ty) { + field.ident.clone() + } else { + None + } + }) + .collect::>(); + (variant_ident, tag_value, forgettable_fields) + }) + .collect(), + _ => Vec::new(), + }; + + let has_forgettable = variants.iter().any(|(_, _, fields)| !fields.is_empty()); + + ForgettableInfo { + has_forgettable, + variants, + } +} + +/// Check if a type's last path segment is "Forgettable". +fn is_forgettable_type(ty: &syn::Type) -> bool { + if let syn::Type::Path(type_path) = ty + && let Some(segment) = type_path.path.segments.last() + { + return segment.ident == "Forgettable"; + } + false +} + +/// Parse the `rename_all` value from `#[serde(tag = "type", rename_all = "...")]`. +fn parse_serde_rename_all(ast: &syn::DeriveInput) -> Option { + for attr in &ast.attrs { + if !attr.path().is_ident("serde") { + continue; + } + let mut rename_all_str = None; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("rename_all") { + let value = meta.value()?; + let lit: syn::LitStr = value.parse()?; + rename_all_str = Some(lit.value()); + } else { + // Consume any value so parse_nested_meta can continue to the next item + let _ = meta.value().and_then(|v| v.parse::()); + } + Ok(()) + }); + if rename_all_str.is_some() { + return rename_all_str; + } + } + None +} + +/// Convert a serde rename_all string to a convert_case::Case. +fn serde_rename_to_case(s: &str) -> Option> { + match s { + "lowercase" => Some(Case::Lower), + "UPPERCASE" => Some(Case::Upper), + "PascalCase" => Some(Case::Pascal), + "camelCase" => Some(Case::Camel), + "snake_case" => Some(Case::Snake), + "SCREAMING_SNAKE_CASE" => Some(Case::Constant), + "kebab-case" => Some(Case::Kebab), + "SCREAMING-KEBAB-CASE" => Some(Case::Cobol), + _ => None, + } +} + +/// Get the serde tag name for a variant, considering rename_all and per-variant rename. +fn serde_variant_name(variant: &syn::Variant, rename_rule: &Option) -> String { + // Check for explicit #[serde(rename = "...")] + for attr in &variant.attrs { + if !attr.path().is_ident("serde") { + continue; + } + let mut explicit_rename = None; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("rename") { + let value = meta.value()?; + let lit: syn::LitStr = value.parse()?; + explicit_rename = Some(lit.value()); + } + Ok(()) + }); + if let Some(name) = explicit_rename { + return name; + } + } + + let ident = variant.ident.to_string(); + if let Some(rule) = rename_rule { + if let Some(case) = serde_rename_to_case(rule) { + ident.to_case(case) + } else { + ident + } + } else { + ident + } } impl ToTokens for EsEvent { diff --git a/es-entity-macros/src/query/input.rs b/es-entity-macros/src/query/input.rs index 5292fe95..f0ba3eb8 100644 --- a/es-entity-macros/src/query/input.rs +++ b/es-entity-macros/src/query/input.rs @@ -10,6 +10,7 @@ pub struct QueryInput { pub(super) sql_span: Span, pub(super) arg_exprs: Vec, pub(super) entity: Option, + pub(super) forgettable_tbl: Option, } impl QueryInput { @@ -81,6 +82,7 @@ impl Parse for QueryInput { let mut expect_comma = false; let mut tbl_prefix = None; let mut entity = None; + let mut forgettable_tbl = None; while !input.is_empty() { if expect_comma { @@ -105,6 +107,8 @@ impl Parse for QueryInput { args = Some(exprs.elems.into_iter().collect()) } else if key == "entity" { entity = Some(input.parse::()?); + } else if key == "forgettable_tbl" { + forgettable_tbl = Some(input.parse::()?.value()); } else { let message = format!("unexpected input key: {key}"); return Err(syn::Error::new_spanned(key, message)); @@ -121,6 +125,7 @@ impl Parse for QueryInput { sql_span, arg_exprs: args.unwrap_or_default(), entity, + forgettable_tbl, }) } } @@ -187,6 +192,7 @@ mod tests { sql_span: Span::call_site(), arg_exprs: vec![], entity: None, + forgettable_tbl: None, }; assert_eq!(input.order_by_columns(), expected, "Failed for SQL: {sql}",); } diff --git a/es-entity-macros/src/query/mod.rs b/es-entity-macros/src/query/mod.rs index fbfe3896..36f72f65 100644 --- a/es-entity-macros/src/query/mod.rs +++ b/es-entity-macros/src/query/mod.rs @@ -58,15 +58,56 @@ impl ToTokens for EsQuery { let args = &self.input.arg_exprs; let context_arg = format!("${}", args.len() + 1); + let (payload_column, forgettable_join) = + if let Some(ref forgettable_tbl) = self.input.forgettable_tbl { + ( + "p.payload as \"forgettable_payload?\"".to_string(), + format!( + " LEFT JOIN {} p ON e.id = p.entity_id AND e.sequence = p.sequence", + forgettable_tbl + ), + ) + } else { + ( + "NULL::jsonb as \"forgettable_payload?\"".to_string(), + String::new(), + ) + }; + let query = format!( - "WITH entities AS ({}) SELECT i.id AS \"entity_id: Repo__Id\", e.sequence, e.event, CASE WHEN {} THEN e.context ELSE NULL::jsonb END as \"context: es_entity::ContextData\", e.recorded_at FROM entities i JOIN {} e ON i.id = e.id ORDER BY {} e.sequence", - self.input.sql, context_arg, events_table, order_by + "WITH entities AS ({}) SELECT i.id AS \"entity_id: Repo__Id\", e.sequence, e.event, CASE WHEN {} THEN e.context ELSE NULL::jsonb END as \"context: es_entity::ContextData\", e.recorded_at, {} FROM entities i JOIN {} e ON i.id = e.id{} ORDER BY {} e.sequence", + self.input.sql, context_arg, payload_column, events_table, forgettable_join, order_by ); + let forgettable_check = if self.input.forgettable_tbl.is_none() { + quote! { + const _: () = assert!( + !Repo__Event::HAS_FORGETTABLE_FIELDS, + "es_query! requires `forgettable_tbl` parameter when the event type has Forgettable fields" + ); + } + } else { + quote! {} + }; + + let tbl_prefix_check = if self.input.tbl_prefix.is_none() && self.input.entity.is_none() { + quote! { + const _: () = assert!( + !REPO__HAS_TBL_PREFIX, + "es_query! requires `tbl_prefix` parameter when the repo uses tbl_prefix" + ); + } + } else { + quote! {} + }; + tokens.append_all(quote! { { use #repo_types_mod::*; + #forgettable_check + #tbl_prefix_check + es_entity::EsQuery::::EsQueryFlavor, _, _>::new( sqlx::query_as!( Repo__DbEvent, @@ -101,10 +142,19 @@ mod tests { { use user_repo_types::*; + const _: () = assert!( + !Repo__Event::HAS_FORGETTABLE_FIELDS, + "es_query! requires `forgettable_tbl` parameter when the event type has Forgettable fields" + ); + const _: () = assert!( + !REPO__HAS_TBL_PREFIX, + "es_query! requires `tbl_prefix` parameter when the repo uses tbl_prefix" + ); + es_entity::EsQuery::::EsQueryFlavor, _, _>::new( sqlx::query_as!( Repo__DbEvent, - "WITH entities AS (SELECT * FROM users WHERE id = $1) SELECT i.id AS \"entity_id: Repo__Id\", e.sequence, e.event, CASE WHEN $2 THEN e.context ELSE NULL::jsonb END as \"context: es_entity::ContextData\", e.recorded_at FROM entities i JOIN user_events e ON i.id = e.id ORDER BY i.id, e.sequence", + "WITH entities AS (SELECT * FROM users WHERE id = $1) SELECT i.id AS \"entity_id: Repo__Id\", e.sequence, e.event, CASE WHEN $2 THEN e.context ELSE NULL::jsonb END as \"context: es_entity::ContextData\", e.recorded_at, NULL::jsonb as \"forgettable_payload?\" FROM entities i JOIN user_events e ON i.id = e.id ORDER BY i.id, e.sequence", id as UserId, <<::Entity as EsEntity>::Event>::event_context(), ) @@ -131,10 +181,15 @@ mod tests { { use my_custom_entity_repo_types::*; + const _: () = assert!( + !Repo__Event::HAS_FORGETTABLE_FIELDS, + "es_query! requires `forgettable_tbl` parameter when the event type has Forgettable fields" + ); + es_entity::EsQuery::::EsQueryFlavor, _, _>::new( sqlx::query_as!( Repo__DbEvent, - "WITH entities AS (SELECT * FROM my_custom_table WHERE id = $1) SELECT i.id AS \"entity_id: Repo__Id\", e.sequence, e.event, CASE WHEN $2 THEN e.context ELSE NULL::jsonb END as \"context: es_entity::ContextData\", e.recorded_at FROM entities i JOIN my_custom_table_events e ON i.id = e.id ORDER BY i.id, e.sequence", + "WITH entities AS (SELECT * FROM my_custom_table WHERE id = $1) SELECT i.id AS \"entity_id: Repo__Id\", e.sequence, e.event, CASE WHEN $2 THEN e.context ELSE NULL::jsonb END as \"context: es_entity::ContextData\", e.recorded_at, NULL::jsonb as \"forgettable_payload?\" FROM entities i JOIN my_custom_table_events e ON i.id = e.id ORDER BY i.id, e.sequence", id as MyCustomEntityId, <<::Entity as EsEntity>::Event>::event_context(), ) @@ -164,10 +219,19 @@ mod tests { { use entity_repo_types::*; + const _: () = assert!( + !Repo__Event::HAS_FORGETTABLE_FIELDS, + "es_query! requires `forgettable_tbl` parameter when the event type has Forgettable fields" + ); + const _: () = assert!( + !REPO__HAS_TBL_PREFIX, + "es_query! requires `tbl_prefix` parameter when the repo uses tbl_prefix" + ); + es_entity::EsQuery::::EsQueryFlavor, _, _>::new( sqlx::query_as!( Repo__DbEvent, - "WITH entities AS (SELECT name, id FROM entities WHERE ((name, id) > ($3, $2)) OR $2 IS NULL ORDER BY name, id LIMIT $1) SELECT i.id AS \"entity_id: Repo__Id\", e.sequence, e.event, CASE WHEN $4 THEN e.context ELSE NULL::jsonb END as \"context: es_entity::ContextData\", e.recorded_at FROM entities i JOIN entity_events e ON i.id = e.id ORDER BY i.name, i.id, i.id, e.sequence", + "WITH entities AS (SELECT name, id FROM entities WHERE ((name, id) > ($3, $2)) OR $2 IS NULL ORDER BY name, id LIMIT $1) SELECT i.id AS \"entity_id: Repo__Id\", e.sequence, e.event, CASE WHEN $4 THEN e.context ELSE NULL::jsonb END as \"context: es_entity::ContextData\", e.recorded_at, NULL::jsonb as \"forgettable_payload?\" FROM entities i JOIN entity_events e ON i.id = e.id ORDER BY i.name, i.id, i.id, e.sequence", (first + 1) as i64, id as Option, name as Option, diff --git a/es-entity-macros/src/repo/delete_fn.rs b/es-entity-macros/src/repo/delete_fn.rs index 87eccb95..b48ff52a 100644 --- a/es-entity-macros/src/repo/delete_fn.rs +++ b/es-entity-macros/src/repo/delete_fn.rs @@ -5,11 +5,13 @@ use quote::{TokenStreamExt, quote}; use super::options::*; pub struct DeleteFn<'a> { + id: &'a syn::Ident, error: &'a syn::Type, entity: &'a syn::Ident, table_name: &'a str, columns: &'a Columns, delete_option: &'a DeleteOption, + forgettable_table_name: Option<&'a str>, #[cfg(feature = "instrument")] repo_name_snake: String, } @@ -17,11 +19,13 @@ pub struct DeleteFn<'a> { impl<'a> DeleteFn<'a> { pub fn from(opts: &'a RepositoryOptions) -> Self { Self { + id: opts.id(), entity: opts.entity(), error: opts.err(), columns: &opts.columns, table_name: opts.table_name(), delete_option: &opts.delete, + forgettable_table_name: opts.forgettable_table_name(), #[cfg(feature = "instrument")] repo_name_snake: opts.repo_name_snake_case(), } @@ -73,6 +77,21 @@ impl ToTokens for DeleteFn<'_> { #[cfg(not(feature = "instrument"))] let (instrument_attr, record_id, error_recording) = (quote! {}, quote! {}, quote! {}); + let id_type = self.id; + let forget_payloads = if let Some(forgettable_tbl) = self.forgettable_table_name { + let forget_query = format!("DELETE FROM {} WHERE entity_id = $1", forgettable_tbl); + quote! { + sqlx::query!( + #forget_query, + id as &#id_type + ) + .execute(op.as_executor()) + .await?; + } + } else { + quote! {} + }; + tokens.append_all(quote! { pub async fn delete( &self, @@ -103,6 +122,8 @@ impl ToTokens for DeleteFn<'_> { .execute(op.as_executor()) .await?; + #forget_payloads + let new_events = { let events = Self::extract_events(&mut entity); events.any_new() @@ -142,11 +163,13 @@ mod tests { columns.set_id_column(&id); let delete_fn = DeleteFn { + id: &id, entity: &entity, error: &error, table_name: "entities", columns: &columns, delete_option: &DeleteOption::Soft, + forgettable_table_name: None, #[cfg(feature = "instrument")] repo_name_snake: "test_repo".to_string(), }; @@ -222,11 +245,13 @@ mod tests { ); let delete_fn = DeleteFn { + id: &id, entity: &entity, error: &error, table_name: "entities", columns: &columns, delete_option: &DeleteOption::Soft, + forgettable_table_name: None, #[cfg(feature = "instrument")] repo_name_snake: "test_repo".to_string(), }; @@ -288,4 +313,87 @@ mod tests { assert_eq!(tokens.to_string(), expected.to_string()); } + + #[test] + fn delete_fn_with_forgettable() { + let id = Ident::new("EntityId", Span::call_site()); + let entity = Ident::new("Entity", Span::call_site()); + let error = syn::parse_str("es_entity::EsRepoError").unwrap(); + let mut columns = Columns::default(); + columns.set_id_column(&id); + + let delete_fn = DeleteFn { + id: &id, + entity: &entity, + error: &error, + table_name: "entities", + columns: &columns, + delete_option: &DeleteOption::Soft, + forgettable_table_name: Some("entities_forgettable_payloads"), + #[cfg(feature = "instrument")] + repo_name_snake: "test_repo".to_string(), + }; + + let mut tokens = TokenStream::new(); + delete_fn.to_tokens(&mut tokens); + + let expected = quote! { + pub async fn delete( + &self, + entity: Entity + ) -> Result<(), es_entity::EsRepoError> { + let mut op = self.begin_op().await?; + let res = self.delete_in_op(&mut op, entity).await?; + op.commit().await?; + Ok(res) + } + + pub async fn delete_in_op( + &self, + op: &mut OP, + mut entity: Entity + ) -> Result<(), es_entity::EsRepoError> + where + OP: es_entity::AtomicOperation + { + let __result: Result<(), es_entity::EsRepoError> = async { + let id = &entity.id; + + sqlx::query!( + "UPDATE entities SET deleted = TRUE WHERE id = $1", + id as &EntityId + ) + .execute(op.as_executor()) + .await?; + + sqlx::query!( + "DELETE FROM entities_forgettable_payloads WHERE entity_id = $1", + id as &EntityId + ) + .execute(op.as_executor()) + .await?; + + let new_events = { + let events = Self::extract_events(&mut entity); + events.any_new() + }; + + if new_events { + let n_events = { + let events = Self::extract_events(&mut entity); + self.persist_events(op, events).await? + }; + + self.execute_post_persist_hook(op, &entity, entity.events().last_persisted(n_events)).await?; + } + + Ok(()) + }.await; + + __result + } + }; + + assert_eq!(tokens.to_string(), expected.to_string()); + } } diff --git a/es-entity-macros/src/repo/find_all_fn.rs b/es-entity-macros/src/repo/find_all_fn.rs index 693cb14a..0951bd31 100644 --- a/es-entity-macros/src/repo/find_all_fn.rs +++ b/es-entity-macros/src/repo/find_all_fn.rs @@ -11,6 +11,7 @@ pub struct FindAllFn<'a> { table_name: &'a str, error: &'a syn::Type, any_nested: bool, + forgettable_table_name: Option<&'a str>, #[cfg(feature = "instrument")] repo_name_snake: String, } @@ -24,6 +25,7 @@ impl<'a> From<&'a RepositoryOptions> for FindAllFn<'a> { table_name: opts.table_name(), error: opts.err(), any_nested: opts.any_nested(), + forgettable_table_name: opts.forgettable_table_name(), #[cfg(feature = "instrument")] repo_name_snake: opts.repo_name_snake_case(), } @@ -46,10 +48,17 @@ impl ToTokens for FindAllFn<'_> { let query = format!("SELECT id FROM {} WHERE id = ANY($1)", self.table_name); + let forgettable_tbl_arg = if let Some(tbl) = self.forgettable_table_name { + quote! { forgettable_tbl = #tbl, } + } else { + quote! {} + }; + let es_query_call = if let Some(prefix) = self.prefix { quote! { es_entity::es_query!( tbl_prefix = #prefix, + #forgettable_tbl_arg #query, ids as &[#id], ) @@ -58,6 +67,7 @@ impl ToTokens for FindAllFn<'_> { quote! { es_entity::es_query!( entity = #entity, + #forgettable_tbl_arg #query, ids as &[#id], ) @@ -122,6 +132,7 @@ mod tests { table_name: "entities", error: &error, any_nested: false, + forgettable_table_name: None, #[cfg(feature = "instrument")] repo_name_snake: "test_repo".to_string(), }; diff --git a/es-entity-macros/src/repo/find_by_fn.rs b/es-entity-macros/src/repo/find_by_fn.rs index f0b48b1d..42c96f7e 100644 --- a/es-entity-macros/src/repo/find_by_fn.rs +++ b/es-entity-macros/src/repo/find_by_fn.rs @@ -12,6 +12,7 @@ pub struct FindByFn<'a> { error: &'a syn::Type, delete: DeleteOption, any_nested: bool, + forgettable_table_name: Option<&'a str>, #[cfg(feature = "instrument")] repo_name_snake: String, } @@ -26,6 +27,7 @@ impl<'a> FindByFn<'a> { error: opts.err(), delete: opts.delete, any_nested: opts.any_nested(), + forgettable_table_name: opts.forgettable_table_name(), #[cfg(feature = "instrument")] repo_name_snake: opts.repo_name_snake_case(), } @@ -81,10 +83,17 @@ impl ToTokens for FindByFn<'_> { } ); + let forgettable_tbl_arg = if let Some(tbl) = self.forgettable_table_name { + quote! { forgettable_tbl = #tbl, } + } else { + quote! {} + }; + let es_query_call = if let Some(prefix) = self.prefix { quote! { es_entity::es_query!( tbl_prefix = #prefix, + #forgettable_tbl_arg #query, #column_name as &#column_type, ) @@ -93,6 +102,7 @@ impl ToTokens for FindByFn<'_> { quote! { es_entity::es_query!( entity = #entity, + #forgettable_tbl_arg #query, #column_name as &#column_type, ) @@ -182,6 +192,7 @@ mod tests { error: &error, delete: DeleteOption::No, any_nested: false, + forgettable_table_name: None, #[cfg(feature = "instrument")] repo_name_snake: "test_repo".to_string(), }; @@ -269,6 +280,7 @@ mod tests { error: &error, delete: DeleteOption::No, any_nested: false, + forgettable_table_name: None, #[cfg(feature = "instrument")] repo_name_snake: "test_repo".to_string(), }; @@ -353,6 +365,7 @@ mod tests { error: &error, delete: DeleteOption::SoftWithoutQueries, any_nested: false, + forgettable_table_name: None, #[cfg(feature = "instrument")] repo_name_snake: "test_repo".to_string(), }; @@ -437,6 +450,7 @@ mod tests { error: &error, delete: DeleteOption::Soft, any_nested: false, + forgettable_table_name: None, #[cfg(feature = "instrument")] repo_name_snake: "test_repo".to_string(), }; @@ -463,6 +477,7 @@ mod tests { error: &error, delete: DeleteOption::No, any_nested: true, + forgettable_table_name: None, #[cfg(feature = "instrument")] repo_name_snake: "test_repo".to_string(), }; diff --git a/es-entity-macros/src/repo/forget_fn.rs b/es-entity-macros/src/repo/forget_fn.rs new file mode 100644 index 00000000..676143d0 --- /dev/null +++ b/es-entity-macros/src/repo/forget_fn.rs @@ -0,0 +1,137 @@ +use darling::ToTokens; +use proc_macro2::TokenStream; +use quote::{TokenStreamExt, quote}; + +use super::options::*; + +pub struct ForgetFn<'a> { + id: &'a syn::Ident, + entity: &'a syn::Ident, + event: &'a syn::Ident, + error: &'a syn::Type, + forgettable_table_name: &'a str, +} + +impl<'a> ForgetFn<'a> { + pub fn from(opts: &'a RepositoryOptions) -> Self { + Self { + id: opts.id(), + entity: opts.entity(), + event: opts.event(), + error: opts.err(), + forgettable_table_name: opts + .forgettable_table_name() + .expect("forgettable must be enabled"), + } + } +} + +impl ToTokens for ForgetFn<'_> { + fn to_tokens(&self, tokens: &mut TokenStream) { + let id_type = &self.id; + let entity_type = self.entity; + let event_type = self.event; + let error = self.error; + + let query = format!( + "DELETE FROM {} WHERE entity_id = $1", + self.forgettable_table_name + ); + + tokens.append_all(quote! { + pub async fn forget( + &self, + entity: &mut #entity_type + ) -> Result<(), #error> { + let mut op = self.begin_op().await?; + self.forget_in_op(&mut op, entity).await?; + op.commit().await?; + Ok(()) + } + + pub async fn forget_in_op( + &self, + op: &mut OP, + entity: &mut #entity_type + ) -> Result<(), #error> + where + OP: es_entity::AtomicOperation + { + let id = &entity.id; + sqlx::query!( + #query, + id as &#id_type + ) + .execute(op.as_executor()) + .await?; + let events = entity.events_mut().forget_and_take( + #event_type::forget_forgettable_payloads + ); + *entity = es_entity::TryFromEvents::try_from_events(events)?; + Ok(()) + } + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use proc_macro2::Span; + use syn::Ident; + + #[test] + fn forget_fn() { + let id = Ident::new("EntityId", Span::call_site()); + let entity = Ident::new("Entity", Span::call_site()); + let event = Ident::new("EntityEvent", Span::call_site()); + let error = syn::parse_str("es_entity::EsRepoError").unwrap(); + + let forget_fn = ForgetFn { + id: &id, + entity: &entity, + event: &event, + error: &error, + forgettable_table_name: "entities_forgettable_payloads", + }; + + let mut tokens = TokenStream::new(); + forget_fn.to_tokens(&mut tokens); + + let expected = quote! { + pub async fn forget( + &self, + entity: &mut Entity + ) -> Result<(), es_entity::EsRepoError> { + let mut op = self.begin_op().await?; + self.forget_in_op(&mut op, entity).await?; + op.commit().await?; + Ok(()) + } + + pub async fn forget_in_op( + &self, + op: &mut OP, + entity: &mut Entity + ) -> Result<(), es_entity::EsRepoError> + where + OP: es_entity::AtomicOperation + { + let id = &entity.id; + sqlx::query!( + "DELETE FROM entities_forgettable_payloads WHERE entity_id = $1", + id as &EntityId + ) + .execute(op.as_executor()) + .await?; + let events = entity.events_mut().forget_and_take( + EntityEvent::forget_forgettable_payloads + ); + *entity = es_entity::TryFromEvents::try_from_events(events)?; + Ok(()) + } + }; + + assert_eq!(tokens.to_string(), expected.to_string()); + } +} diff --git a/es-entity-macros/src/repo/list_by_fn.rs b/es-entity-macros/src/repo/list_by_fn.rs index ba03bbee..19e44380 100644 --- a/es-entity-macros/src/repo/list_by_fn.rs +++ b/es-entity-macros/src/repo/list_by_fn.rs @@ -215,6 +215,7 @@ pub struct ListByFn<'a> { delete: DeleteOption, cursor_mod: syn::Ident, any_nested: bool, + forgettable_table_name: Option<&'a str>, #[cfg(feature = "instrument")] repo_name_snake: String, } @@ -231,6 +232,7 @@ impl<'a> ListByFn<'a> { delete: opts.delete, cursor_mod: opts.cursor_mod(), any_nested: opts.any_nested(), + forgettable_table_name: opts.forgettable_table_name(), #[cfg(feature = "instrument")] repo_name_snake: opts.repo_name_snake_case(), } @@ -306,10 +308,17 @@ impl ToTokens for ListByFn<'_> { cursor.order_by(false), ); + let forgettable_tbl_arg = if let Some(tbl) = self.forgettable_table_name { + quote! { forgettable_tbl = #tbl, } + } else { + quote! {} + }; + let es_query_asc_call = if let Some(prefix) = self.ignore_prefix { quote! { es_entity::es_query!( tbl_prefix = #prefix, + #forgettable_tbl_arg #asc_query, #arg_tokens ) @@ -318,6 +327,7 @@ impl ToTokens for ListByFn<'_> { quote! { es_entity::es_query!( entity = #entity, + #forgettable_tbl_arg #asc_query, #arg_tokens ) @@ -328,6 +338,7 @@ impl ToTokens for ListByFn<'_> { quote! { es_entity::es_query!( tbl_prefix = #prefix, + #forgettable_tbl_arg #desc_query, #arg_tokens ) @@ -336,6 +347,7 @@ impl ToTokens for ListByFn<'_> { quote! { es_entity::es_query!( entity = #entity, + #forgettable_tbl_arg #desc_query, #arg_tokens ) @@ -543,6 +555,7 @@ mod tests { delete: DeleteOption::SoftWithoutQueries, cursor_mod, any_nested: false, + forgettable_table_name: None, #[cfg(feature = "instrument")] repo_name_snake: "test_repo".to_string(), }; @@ -632,6 +645,7 @@ mod tests { delete: DeleteOption::Soft, cursor_mod, any_nested: false, + forgettable_table_name: None, #[cfg(feature = "instrument")] repo_name_snake: "test_repo".to_string(), }; @@ -664,6 +678,7 @@ mod tests { delete: DeleteOption::No, cursor_mod, any_nested: false, + forgettable_table_name: None, #[cfg(feature = "instrument")] repo_name_snake: "test_repo".to_string(), }; @@ -759,6 +774,7 @@ mod tests { delete: DeleteOption::No, cursor_mod, any_nested: false, + forgettable_table_name: None, #[cfg(feature = "instrument")] repo_name_snake: "test_repo".to_string(), }; diff --git a/es-entity-macros/src/repo/list_for_filters_fn.rs b/es-entity-macros/src/repo/list_for_filters_fn.rs index 9f2b47b6..e3a6a752 100644 --- a/es-entity-macros/src/repo/list_for_filters_fn.rs +++ b/es-entity-macros/src/repo/list_for_filters_fn.rs @@ -94,6 +94,7 @@ pub struct ListForFiltersFn<'a> { ignore_prefix: Option<&'a syn::LitStr>, id: &'a syn::Ident, any_nested: bool, + forgettable_table_name: Option<&'a str>, #[cfg(feature = "instrument")] repo_name_snake: String, } @@ -118,6 +119,7 @@ impl<'a> ListForFiltersFn<'a> { ignore_prefix: opts.table_prefix(), id: opts.id(), any_nested: opts.any_nested(), + forgettable_table_name: opts.forgettable_table_name(), #[cfg(feature = "instrument")] repo_name_snake: opts.repo_name_snake_case(), } @@ -333,10 +335,17 @@ impl<'a> ListForFiltersFn<'a> { n_filters + 1, ); + let forgettable_tbl_arg = if let Some(tbl) = self.forgettable_table_name { + quote! { forgettable_tbl = #tbl, } + } else { + quote! {} + }; + let es_query_asc_call = if let Some(prefix) = self.ignore_prefix { quote! { es_entity::es_query!( tbl_prefix = #prefix, + #forgettable_tbl_arg #asc_query, #filter_arg_bindings #cursor_arg_tokens @@ -346,6 +355,7 @@ impl<'a> ListForFiltersFn<'a> { quote! { es_entity::es_query!( entity = #entity, + #forgettable_tbl_arg #asc_query, #filter_arg_bindings #cursor_arg_tokens @@ -357,6 +367,7 @@ impl<'a> ListForFiltersFn<'a> { quote! { es_entity::es_query!( tbl_prefix = #prefix, + #forgettable_tbl_arg #desc_query, #filter_arg_bindings #cursor_arg_tokens @@ -366,6 +377,7 @@ impl<'a> ListForFiltersFn<'a> { quote! { es_entity::es_query!( entity = #entity, + #forgettable_tbl_arg #desc_query, #filter_arg_bindings #cursor_arg_tokens @@ -689,6 +701,7 @@ mod tests { ignore_prefix: None, id: &id, any_nested: false, + forgettable_table_name: None, #[cfg(feature = "instrument")] repo_name_snake: "test_repo".to_string(), }; @@ -860,6 +873,7 @@ mod tests { ignore_prefix: None, id: &id, any_nested: false, + forgettable_table_name: None, #[cfg(feature = "instrument")] repo_name_snake: "test_repo".to_string(), }; @@ -926,6 +940,7 @@ mod tests { ignore_prefix: None, id: &id, any_nested: false, + forgettable_table_name: None, #[cfg(feature = "instrument")] repo_name_snake: "test_repo".to_string(), }; diff --git a/es-entity-macros/src/repo/list_for_fn.rs b/es-entity-macros/src/repo/list_for_fn.rs index f076fb62..e190ca8d 100644 --- a/es-entity-macros/src/repo/list_for_fn.rs +++ b/es-entity-macros/src/repo/list_for_fn.rs @@ -15,6 +15,7 @@ pub struct ListForFn<'a> { delete: DeleteOption, cursor_mod: syn::Ident, any_nested: bool, + forgettable_table_name: Option<&'a str>, #[cfg(feature = "instrument")] repo_name_snake: String, } @@ -32,6 +33,7 @@ impl<'a> ListForFn<'a> { delete: opts.delete, cursor_mod: opts.cursor_mod(), any_nested: opts.any_nested(), + forgettable_table_name: opts.forgettable_table_name(), #[cfg(feature = "instrument")] repo_name_snake: opts.repo_name_snake_case(), } @@ -119,10 +121,17 @@ impl ToTokens for ListForFn<'_> { cursor.order_by(false) ); + let forgettable_tbl_arg = if let Some(tbl) = self.forgettable_table_name { + quote! { forgettable_tbl = #tbl, } + } else { + quote! {} + }; + let es_query_asc_call = if let Some(prefix) = self.ignore_prefix { quote! { es_entity::es_query!( tbl_prefix = #prefix, + #forgettable_tbl_arg #asc_query, #filter_arg_name as &#for_column_type, #arg_tokens @@ -132,6 +141,7 @@ impl ToTokens for ListForFn<'_> { quote! { es_entity::es_query!( entity = #entity, + #forgettable_tbl_arg #asc_query, #filter_arg_name as &#for_column_type, #arg_tokens @@ -143,6 +153,7 @@ impl ToTokens for ListForFn<'_> { quote! { es_entity::es_query!( tbl_prefix = #prefix, + #forgettable_tbl_arg #desc_query, #filter_arg_name as &#for_column_type, #arg_tokens @@ -152,6 +163,7 @@ impl ToTokens for ListForFn<'_> { quote! { es_entity::es_query!( entity = #entity, + #forgettable_tbl_arg #desc_query, #filter_arg_name as &#for_column_type, #arg_tokens @@ -300,6 +312,7 @@ mod tests { delete: DeleteOption::No, cursor_mod, any_nested: false, + forgettable_table_name: None, #[cfg(feature = "instrument")] repo_name_snake: "test_repo".to_string(), }; @@ -397,6 +410,7 @@ mod tests { delete: DeleteOption::No, cursor_mod, any_nested: false, + forgettable_table_name: None, #[cfg(feature = "instrument")] repo_name_snake: "test_repo".to_string(), }; diff --git a/es-entity-macros/src/repo/mod.rs b/es-entity-macros/src/repo/mod.rs index c4a92cef..a2d6c857 100644 --- a/es-entity-macros/src/repo/mod.rs +++ b/es-entity-macros/src/repo/mod.rs @@ -5,6 +5,7 @@ mod create_fn; mod delete_fn; mod find_all_fn; mod find_by_fn; +mod forget_fn; mod list_by_fn; mod list_for_filters_fn; mod list_for_fn; @@ -39,6 +40,7 @@ pub struct EsRepo<'a> { create_fn: create_fn::CreateFn<'a>, create_all_fn: create_all_fn::CreateAllFn<'a>, delete_fn: delete_fn::DeleteFn<'a>, + forget_fn: Option>, find_by_fns: Vec>, find_all_fn: find_all_fn::FindAllFn<'a>, post_persist_hook: post_persist_hook::PostPersistHook<'a>, @@ -87,6 +89,12 @@ impl<'a> From<&'a RepositoryOptions> for EsRepo<'a> { .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, @@ -97,6 +105,7 @@ impl<'a> From<&'a RepositoryOptions> for EsRepo<'a> { 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_persist_hook: post_persist_hook::PostPersistHook::from(opts), @@ -121,6 +130,7 @@ impl ToTokens for EsRepo<'_> { 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_persist_hook = &self.post_persist_hook; @@ -166,6 +176,7 @@ impl ToTokens for EsRepo<'_> { 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 @@ -201,6 +212,8 @@ impl ToTokens for EsRepo<'_> { pub(super) type Repo__Error = #error; #[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; } #list_for_filters_struct @@ -221,6 +234,7 @@ impl ToTokens for EsRepo<'_> { #update_fn #update_all_fn #delete_fn + #forget_fn #(#find_by_fns)* #find_all_fn #list_for_filters diff --git a/es-entity-macros/src/repo/options/mod.rs b/es-entity-macros/src/repo/options/mod.rs index dbf8ca97..0c5b8ca7 100644 --- a/es-entity-macros/src/repo/options/mod.rs +++ b/es-entity-macros/src/repo/options/mod.rs @@ -108,6 +108,10 @@ pub struct RepositoryOptions { #[darling(default)] persist_event_context: Option, + #[darling(default)] + forgettable: bool, + #[darling(default, rename = "forgettable_tbl")] + forgettable_table_name: Option, } impl RepositoryOptions { @@ -145,6 +149,13 @@ impl RepositoryOptions { Some(format!("{prefix}{entity_name}Events").to_case(Case::Snake)); } + if self.forgettable && self.forgettable_table_name.is_none() { + self.forgettable_table_name = Some(format!( + "{}_forgettable_payloads", + self.table_name.as_ref().expect("Table name not set") + )); + } + self.columns .set_id_column(self.id_ty.as_ref().expect("Id not set")); @@ -305,4 +316,19 @@ impl RepositoryOptions { pub fn err(&self) -> &syn::Type { self.err_ty.as_ref().expect("Error identifier is not set") } + + pub fn forgettable_enabled(&self) -> bool { + self.forgettable + } + + pub fn forgettable_table_name(&self) -> Option<&str> { + if self.forgettable { + Some(self.forgettable_table_name.as_deref().unwrap_or_else(|| { + // Lazy init not possible with &str, so we use a different approach + panic!("forgettable_table_name should have been set in update_defaults") + })) + } else { + None + } + } } diff --git a/es-entity-macros/src/repo/persist_events_batch_fn.rs b/es-entity-macros/src/repo/persist_events_batch_fn.rs index 9abb0cb9..a197377b 100644 --- a/es-entity-macros/src/repo/persist_events_batch_fn.rs +++ b/es-entity-macros/src/repo/persist_events_batch_fn.rs @@ -10,6 +10,7 @@ pub struct PersistEventsBatchFn<'a> { error: &'a syn::Type, events_table_name: &'a str, event_ctx: bool, + forgettable_table_name: Option<&'a str>, } impl<'a> From<&'a RepositoryOptions> for PersistEventsBatchFn<'a> { @@ -20,6 +21,7 @@ impl<'a> From<&'a RepositoryOptions> for PersistEventsBatchFn<'a> { error: opts.err(), events_table_name: opts.events_table_name(), event_ctx: opts.event_context_enabled(), + forgettable_table_name: opts.forgettable_table_name(), } } } @@ -69,6 +71,49 @@ impl ToTokens for PersistEventsBatchFn<'_> { (quote! {}, quote! {}, quote! {}) }; + let forgettable_vars = if self.forgettable_table_name.is_some() { + quote! { + let mut payload_ids: Vec<&#id_type> = Vec::new(); + let mut payload_sequences: Vec = Vec::new(); + let mut payload_values: Vec = Vec::new(); + } + } else { + quote! {} + }; + + let forgettable_extract = if self.forgettable_table_name.is_some() { + quote! { + for (idx, event_with_ctx) in events.iter_new_events().enumerate() { + if let Some(payload) = #event_type::extract_forgettable_payloads(&event_with_ctx.event) { + payload_ids.push(id); + payload_sequences.push((offset + idx) as i32); + payload_values.push(payload); + } + } + } + } else { + quote! {} + }; + + let forgettable_insert = if let Some(forgettable_tbl) = self.forgettable_table_name { + let payload_insert_query = format!( + "INSERT INTO {} (entity_id, sequence, payload) SELECT unnested.entity_id, unnested.sequence, unnested.payload FROM UNNEST($1, $2::INT[], $3::JSONB[]) AS unnested(entity_id, sequence, payload)", + forgettable_tbl + ); + quote! { + if !payload_sequences.is_empty() { + sqlx::query(#payload_insert_query) + .bind(&payload_ids) + .bind(&payload_sequences) + .bind(&payload_values) + .execute(op.as_executor()) + .await?; + } + } + } else { + quote! {} + }; + tokens.append_all(quote! { async fn persist_events_batch( &self, @@ -83,6 +128,7 @@ impl ToTokens for PersistEventsBatchFn<'_> { let mut all_serialized = Vec::new(); #ctx_var + #forgettable_vars let mut all_types = Vec::new(); let mut all_ids: Vec<&#id_type> = Vec::new(); let mut all_sequences = Vec::new(); @@ -93,6 +139,7 @@ impl ToTokens for PersistEventsBatchFn<'_> { let events: &es_entity::EntityEvents<#event_type> = item.borrow(); let id = events.id(); let offset = events.len_persisted() + 1; + #forgettable_extract let serialized = events.serialize_new_events(); #ctx_extend let types = serialized.iter() @@ -122,6 +169,8 @@ impl ToTokens for PersistEventsBatchFn<'_> { .await )?; + #forgettable_insert + let recorded_at = rows[0].try_get("recorded_at").expect("no recorded at"); for item in all_events.iter_mut() { @@ -150,6 +199,7 @@ mod tests { error: &error, events_table_name: "entity_events", event_ctx: true, + forgettable_table_name: None, }; let mut tokens = TokenStream::new(); @@ -240,6 +290,7 @@ mod tests { error: &error, events_table_name: "entity_events", event_ctx: false, + forgettable_table_name: None, }; let mut tokens = TokenStream::new(); diff --git a/es-entity-macros/src/repo/persist_events_fn.rs b/es-entity-macros/src/repo/persist_events_fn.rs index d7c0ed9d..694c90db 100644 --- a/es-entity-macros/src/repo/persist_events_fn.rs +++ b/es-entity-macros/src/repo/persist_events_fn.rs @@ -10,6 +10,7 @@ pub struct PersistEventsFn<'a> { error: &'a syn::Type, events_table_name: &'a str, event_ctx: bool, + forgettable_table_name: Option<&'a str>, } impl<'a> From<&'a RepositoryOptions> for PersistEventsFn<'a> { @@ -20,6 +21,7 @@ impl<'a> From<&'a RepositoryOptions> for PersistEventsFn<'a> { error: opts.err(), events_table_name: opts.events_table_name(), event_ctx: opts.event_context_enabled(), + forgettable_table_name: opts.forgettable_table_name(), } } } @@ -56,6 +58,35 @@ impl ToTokens for PersistEventsFn<'_> { id as &#id_type }; + let forgettable_code = if let Some(forgettable_tbl) = self.forgettable_table_name { + let payload_insert_query = format!( + "INSERT INTO {} (entity_id, sequence, payload) SELECT $1, unnested.sequence, unnested.payload FROM UNNEST($2::INT[], $3::JSONB[]) AS unnested(sequence, payload)", + forgettable_tbl + ); + quote! { + let mut payload_sequences: Vec = Vec::new(); + let mut payload_values: Vec = Vec::new(); + for (idx, event_with_ctx) in events.iter_new_events().enumerate() { + if let Some(payload) = #event_type::extract_forgettable_payloads(&event_with_ctx.event) { + payload_sequences.push((offset + 1 + idx) as i32); + payload_values.push(payload); + } + } + if !payload_sequences.is_empty() { + sqlx::query!( + #payload_insert_query, + id as &#id_type, + &payload_sequences, + &payload_values, + ) + .execute(op.as_executor()) + .await?; + } + } + } else { + quote! {} + }; + tokens.append_all(quote! { fn extract_concurrent_modification(res: Result) -> Result { match res { @@ -77,6 +108,7 @@ impl ToTokens for PersistEventsFn<'_> { { let id = events.id(); let offset = events.len_persisted(); + #forgettable_code let serialized_events = events.serialize_new_events(); #ctx_var let events_types = serialized_events.iter().map(|e| e.get("type").and_then(es_entity::prelude::serde_json::Value::as_str).expect("Could not read event type").to_owned()).collect::>(); @@ -117,6 +149,7 @@ mod tests { error: &error, events_table_name: "entity_events", event_ctx: true, + forgettable_table_name: None, }; let mut tokens = TokenStream::new(); @@ -180,6 +213,7 @@ mod tests { error: &error, events_table_name: "entity_events", event_ctx: false, + forgettable_table_name: None, }; let mut tokens = TokenStream::new(); diff --git a/es-entity-macros/src/repo/populate_nested.rs b/es-entity-macros/src/repo/populate_nested.rs index 34c202e1..878aca54 100644 --- a/es-entity-macros/src/repo/populate_nested.rs +++ b/es-entity-macros/src/repo/populate_nested.rs @@ -13,6 +13,7 @@ pub struct PopulateNested<'a> { table_name: &'a str, events_table_name: &'a str, repo_types_mod: syn::Ident, + forgettable_table_name: Option<&'a str>, } impl<'a> PopulateNested<'a> { @@ -26,6 +27,7 @@ impl<'a> PopulateNested<'a> { table_name: opts.table_name(), events_table_name: opts.events_table_name(), repo_types_mod: opts.repo_types_mod(), + forgettable_table_name: opts.forgettable_table_name(), } } } @@ -38,12 +40,30 @@ impl ToTokens for PopulateNested<'_> { let repo_types_mod = &self.repo_types_mod; let accessor = self.column.parent_accessor(); + let (payload_column, forgettable_join) = + if let Some(forgettable_tbl) = self.forgettable_table_name { + ( + "p.payload as \"forgettable_payload?\"".to_string(), + format!( + " LEFT JOIN {} p ON e.id = p.entity_id AND e.sequence = p.sequence", + forgettable_tbl + ), + ) + } else { + ( + "NULL::jsonb as \"forgettable_payload?\"".to_string(), + String::new(), + ) + }; + let query = format!( - "WITH entities AS (SELECT * FROM {} WHERE ({} = ANY($1))) SELECT i.id AS \"entity_id: {}\", e.sequence, e.event, CASE WHEN $2 THEN e.context ELSE NULL::jsonb END as \"context: es_entity::ContextData\", e.recorded_at FROM entities i JOIN {} e ON i.id = e.id ORDER BY e.id, e.sequence", + "WITH entities AS (SELECT * FROM {} WHERE ({} = ANY($1))) SELECT i.id AS \"entity_id: {}\", e.sequence, e.event, CASE WHEN $2 THEN e.context ELSE NULL::jsonb END as \"context: es_entity::ContextData\", e.recorded_at, {} FROM entities i JOIN {} e ON i.id = e.id{} ORDER BY e.id, e.sequence", self.table_name, self.column.name(), self.id, + payload_column, self.events_table_name, + forgettable_join, ); let (impl_generics, ty_generics, where_clause) = self.generics.split_for_impl(); diff --git a/migrations/20250718092455_test_setup.sql b/migrations/20250718092455_test_setup.sql index f9f6169c..93cc9b15 100644 --- a/migrations/20250718092455_test_setup.sql +++ b/migrations/20250718092455_test_setup.sql @@ -154,3 +154,27 @@ CREATE TABLE profile_events ( recorded_at TIMESTAMPTZ NOT NULL, UNIQUE(id, sequence) ); + +-- Tables for forgettable payloads test +CREATE TABLE customers ( + id UUID PRIMARY KEY, + email VARCHAR NOT NULL, + created_at TIMESTAMPTZ NOT NULL +); + +CREATE TABLE customer_events ( + id UUID NOT NULL REFERENCES customers(id), + sequence INT NOT NULL, + event_type VARCHAR NOT NULL, + event JSONB NOT NULL, + context JSONB DEFAULT NULL, + recorded_at TIMESTAMPTZ NOT NULL, + UNIQUE(id, sequence) +); + +CREATE TABLE customers_forgettable_payloads ( + entity_id UUID NOT NULL REFERENCES customers(id), + sequence INT NOT NULL, + payload JSONB NOT NULL, + UNIQUE(entity_id, sequence) +); diff --git a/src/events.rs b/src/events.rs index c77a9bfa..343acae9 100644 --- a/src/events.rs +++ b/src/events.rs @@ -18,6 +18,7 @@ pub struct GenericEvent { pub event: serde_json::Value, pub context: Option, pub recorded_at: DateTime, + pub forgettable_payload: Option, } /// Strongly-typed event wrapper with metadata for successfully stored events. @@ -200,11 +201,15 @@ where break; } let cur = current.as_mut().expect("Could not get current"); + let mut event_json = e.event; + if let Some(payload) = e.forgettable_payload { + crate::forgettable::inject_forgettable_payload(&mut event_json, payload); + } cur.persisted_events.push(PersistedEvent { entity_id: e.entity_id, recorded_at: e.recorded_at, sequence: e.sequence as usize, - event: serde_json::from_value(e.event)?, + event: serde_json::from_value(event_json)?, context: e.context, }); } @@ -243,11 +248,15 @@ where }); } let cur = current.as_mut().expect("Could not get current"); + let mut event_json = e.event; + if let Some(payload) = e.forgettable_payload { + crate::forgettable::inject_forgettable_payload(&mut event_json, payload); + } cur.persisted_events.push(PersistedEvent { entity_id: e.entity_id, recorded_at: e.recorded_at, sequence: e.sequence as usize, - event: serde_json::from_value(e.event)?, + event: serde_json::from_value(event_json)?, context: e.context, }); } @@ -257,6 +266,11 @@ where Ok((ret, false)) } + #[doc(hidden)] + pub fn iter_new_events(&self) -> impl Iterator> { + self.new_events.iter() + } + #[doc(hidden)] pub fn mark_new_events_persisted_at( &mut self, @@ -288,6 +302,27 @@ where .collect() } + /// Forgets all forgettable payloads in persisted events and returns the taken events. + /// + /// Applies `forget_fn` to each persisted event, then takes ownership of the event + /// stream, leaving `self` as an empty shell. The returned `EntityEvents` can be passed + /// to `TryFromEvents::try_from_events` to rebuild the entity with forgotten fields. + #[doc(hidden)] + pub fn forget_and_take(&mut self, mut forget_fn: impl FnMut(&mut T)) -> Self { + for persisted in &mut self.persisted_events { + forget_fn(&mut persisted.event); + } + let entity_id = self.entity_id.clone(); + std::mem::replace( + self, + Self { + entity_id, + persisted_events: Vec::new(), + new_events: Vec::new(), + }, + ) + } + #[doc(hidden)] pub fn serialize_new_event_contexts(&self) -> Option> { if ::event_context() { @@ -379,6 +414,7 @@ mod tests { .expect("Could not serialize"), context: None, recorded_at: chrono::Utc::now(), + forgettable_payload: None, }]; let entity: DummyEntity = EntityEvents::load_first(generic_events).expect("Could not load"); assert!(entity.name == "dummy-name"); @@ -394,6 +430,7 @@ mod tests { .expect("Could not serialize"), context: None, recorded_at: chrono::Utc::now(), + forgettable_payload: None, }, GenericEvent { entity_id: Uuid::parse_str("00000000-0000-0000-0000-000000000003").unwrap(), @@ -402,6 +439,7 @@ mod tests { .expect("Could not serialize"), context: None, recorded_at: chrono::Utc::now(), + forgettable_payload: None, }, ]; let (entity, more): (Vec, _) = diff --git a/src/forgettable.rs b/src/forgettable.rs new file mode 100644 index 00000000..140dda8e --- /dev/null +++ b/src/forgettable.rs @@ -0,0 +1,319 @@ +//! Support for forgettable event data (e.g., for GDPR compliance). +//! +//! The [`Forgettable`] wrapper marks event fields containing personal data that +//! can be permanently deleted. Sensitive field values are stored in a separate +//! "forgettable payloads" table. Calling `forget()` on the repository deletes +//! those payloads, leaving the events intact but with `null` for forgotten fields. + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use std::{fmt, hash, ops::Deref}; + +/// Wrapper for event fields containing data that can be forgotten (e.g., for GDPR). +/// +/// This is an opaque struct — internal state is private so callers cannot +/// pattern-match to extract the raw value. Use [`Forgettable::value()`] to get +/// a [`ForgettableRef`] that derefs to `T` but does **not** implement `Serialize`, +/// preventing accidental re-serialization of personal data. +/// +/// # Serde Behavior +/// +/// - **Both** set and forgotten values serialize as `null` to prevent data +/// leakage when events are serialized to secondary stores. +/// - Deserializing `null` produces a forgotten value, non-null produces a set value. +/// - Real values are extracted via [`__extract_payload_value`] **before** serde runs, +/// and stored in the forgettable payloads table. +/// +/// # Example +/// +/// ```rust +/// use es_entity::Forgettable; +/// +/// let name: Forgettable = Forgettable::new("Alice".to_string()); +/// assert_eq!(&*name.value().unwrap(), "Alice"); +/// +/// let forgotten: Forgettable = Forgettable::forgotten(); +/// assert!(forgotten.value().is_none()); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Forgettable(Option); + +impl Default for Forgettable { + /// Returns a forgotten (empty) `Forgettable`. + fn default() -> Self { + Forgettable(None) + } +} + +impl From for Forgettable { + fn from(value: T) -> Self { + Forgettable(Some(value)) + } +} + +impl Forgettable { + /// Creates a new `Forgettable` containing the given value. + pub fn new(value: T) -> Self { + Forgettable(Some(value)) + } + + /// Creates a forgotten (empty) `Forgettable`. + pub fn forgotten() -> Self { + Forgettable(None) + } + + /// Returns a [`ForgettableRef`] wrapping the inner value, or `None` if forgotten. + /// + /// `ForgettableRef` implements `Deref` but **not** `Serialize`, + /// so you can read the value but cannot accidentally serialize it. + pub fn value(&self) -> Option> { + self.0.as_ref().map(ForgettableRef) + } + + /// Returns `true` if the value is present. + pub fn is_set(&self) -> bool { + self.0.is_some() + } + + /// Returns `true` if the value has been forgotten. + pub fn is_forgotten(&self) -> bool { + self.0.is_none() + } +} + +impl Forgettable { + /// Extracts the inner value as a `serde_json::Value` for storage in + /// the forgettable payloads table. Returns `None` if forgotten. + #[doc(hidden)] + pub fn __extract_payload_value(&self) -> Option { + self.0 + .as_ref() + .map(|v| serde_json::to_value(v).expect("Failed to serialize forgettable field")) + } +} + +impl Serialize for Forgettable { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_none() + } +} + +impl<'de, T: Deserialize<'de>> Deserialize<'de> for Forgettable { + fn deserialize>(deserializer: D) -> Result { + let value = Option::::deserialize(deserializer)?; + match value { + Some(v) => Ok(Forgettable(Some(v))), + None => Ok(Forgettable(None)), + } + } +} + +/// A non-serializable reference to the value inside a [`Forgettable`]. +/// +/// Implements `Deref` so you can use it like `&T`, but does **not** +/// implement `Serialize` or `Clone`, preventing accidental re-serialization or +/// extraction of personal data. +pub struct ForgettableRef<'a, T>(&'a T); + +impl fmt::Debug for ForgettableRef<'_, T> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +impl fmt::Display for ForgettableRef<'_, T> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +impl Deref for ForgettableRef<'_, T> { + type Target = T; + + fn deref(&self) -> &T { + self.0 + } +} + +impl PartialEq for ForgettableRef<'_, T> { + fn eq(&self, other: &T) -> bool { + self.0 == other + } +} + +impl PartialEq for ForgettableRef<'_, T> { + fn eq(&self, other: &Self) -> bool { + self.0 == other.0 + } +} + +impl Eq for ForgettableRef<'_, T> {} + +impl hash::Hash for ForgettableRef<'_, T> { + fn hash(&self, state: &mut H) { + self.0.hash(state); + } +} + +/// Injects forgettable payload values back into an event JSON object. +/// +/// Merges all keys from the payload into the event JSON, overwriting `null` values +/// with the original data. +#[doc(hidden)] +pub fn inject_forgettable_payload(event_json: &mut serde_json::Value, payload: serde_json::Value) { + if let (Some(event_obj), serde_json::Value::Object(payload_obj)) = + (event_json.as_object_mut(), payload) + { + for (key, value) in payload_obj { + event_obj.insert(key, value); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serialize_set_emits_null() { + let value: Forgettable = Forgettable::new("Alice".to_string()); + let json = serde_json::to_value(&value).unwrap(); + assert_eq!(json, serde_json::json!(null)); + } + + #[test] + fn serialize_forgotten_emits_null() { + let value: Forgettable = Forgettable::forgotten(); + let json = serde_json::to_value(&value).unwrap(); + assert_eq!(json, serde_json::json!(null)); + } + + #[test] + fn deserialize_value() { + let json = serde_json::json!("Alice"); + let value: Forgettable = serde_json::from_value(json).unwrap(); + assert_eq!(value, Forgettable::new("Alice".to_string())); + } + + #[test] + fn deserialize_null() { + let json = serde_json::json!(null); + let value: Forgettable = serde_json::from_value(json).unwrap(); + assert_eq!(value, Forgettable::forgotten()); + } + + #[test] + fn serialize_struct_with_forgettable_emits_null() { + #[derive(Serialize, Deserialize, Debug, PartialEq)] + struct Event { + #[serde(rename = "type")] + kind: String, + name: Forgettable, + email: String, + } + + let event = Event { + kind: "initialized".to_string(), + name: Forgettable::new("Alice".to_string()), + email: "alice@test.com".to_string(), + }; + let json = serde_json::to_value(&event).unwrap(); + // Set serializes as null to prevent data leakage + assert_eq!(json["name"], serde_json::json!(null)); + assert_eq!(json["email"], serde_json::json!("alice@test.com")); + + // Deserializing null yields Forgotten (real values come from payload table) + let deserialized: Event = serde_json::from_value(json).unwrap(); + assert_eq!(deserialized.name, Forgettable::forgotten()); + + // Forgotten also serializes as null + let event_forgotten = Event { + kind: "initialized".to_string(), + name: Forgettable::forgotten(), + email: "alice@test.com".to_string(), + }; + let json = serde_json::to_value(&event_forgotten).unwrap(); + assert_eq!(json["name"], serde_json::json!(null)); + + let deserialized: Event = serde_json::from_value(json).unwrap(); + assert_eq!(deserialized, event_forgotten); + } + + #[test] + fn inject_payload() { + let mut json = serde_json::json!({ + "type": "initialized", + "id": "uuid", + "name": null, + "email": "alice@test.com" + }); + + let payload = serde_json::json!({"name": "Alice"}); + inject_forgettable_payload(&mut json, payload); + + assert_eq!(json["name"], serde_json::json!("Alice")); + assert_eq!(json["email"], serde_json::json!("alice@test.com")); + } + + #[test] + fn value_helpers() { + let set: Forgettable = Forgettable::new("test".to_string()); + assert!(set.is_set()); + assert!(!set.is_forgotten()); + assert_eq!(&*set.value().unwrap(), "test"); + + let forgotten: Forgettable = Forgettable::forgotten(); + assert!(!forgotten.is_set()); + assert!(forgotten.is_forgotten()); + assert!(forgotten.value().is_none()); + } + + #[test] + fn extract_payload_value() { + let set: Forgettable = Forgettable::new("Alice".to_string()); + assert_eq!( + set.__extract_payload_value(), + Some(serde_json::json!("Alice")) + ); + + let forgotten: Forgettable = Forgettable::forgotten(); + assert_eq!(forgotten.__extract_payload_value(), None); + } + + #[test] + fn forgettable_ref_deref() { + let f = Forgettable::new("hello".to_string()); + let r = f.value().unwrap(); + // Deref to &String + assert_eq!(r.len(), 5); + assert_eq!(&*r, "hello"); + } + + #[test] + fn forgettable_ref_display() { + let f = Forgettable::new("Alice".to_string()); + let r = f.value().unwrap(); + assert_eq!(format!("{r}"), "Alice"); + } + + #[test] + fn forgettable_ref_partial_eq() { + let f = Forgettable::new("Alice".to_string()); + let r = f.value().unwrap(); + assert_eq!(r, "Alice".to_string()); + } + + #[test] + fn default_is_forgotten() { + let f: Forgettable = Default::default(); + assert!(f.is_forgotten()); + assert!(f.value().is_none()); + } + + #[test] + fn from_value() { + let f: Forgettable = "Alice".to_string().into(); + assert!(f.is_set()); + assert_eq!(&*f.value().unwrap(), "Alice"); + } +} diff --git a/src/lib.rs b/src/lib.rs index 07e7b36b..bad00824 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27,6 +27,7 @@ pub mod clock; pub mod context; pub mod error; pub mod events; +pub mod forgettable; pub mod idempotent; mod macros; pub mod nested; @@ -62,6 +63,8 @@ pub use es_entity_macros::retry_on_concurrent_modification; #[doc(inline)] pub use events::*; #[doc(inline)] +pub use forgettable::{Forgettable, ForgettableRef}; +#[doc(inline)] pub use idempotent::*; #[doc(inline)] pub use nested::*; diff --git a/src/macros.rs b/src/macros.rs index 9340d086..af257933 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -128,6 +128,33 @@ macro_rules! idempotency_guard { /// ``` #[macro_export] macro_rules! es_query { + // With entity override + forgettable + ( + entity = $entity:ident, + forgettable_tbl = $forgettable_tbl:literal, + $query:expr, + $($args:tt)* + ) => ({ + $crate::expand_es_query!( + entity = $entity, + forgettable_tbl = $forgettable_tbl, + sql = $query, + args = [$($args)*] + ) + }); + // With entity override + forgettable - no args + ( + entity = $entity:ident, + forgettable_tbl = $forgettable_tbl:literal, + $query:expr + ) => ({ + $crate::expand_es_query!( + entity = $entity, + forgettable_tbl = $forgettable_tbl, + sql = $query + ) + }); + // With entity override ( entity = $entity:ident, @@ -151,6 +178,33 @@ macro_rules! es_query { ) }); + // With tbl_prefix + forgettable + ( + tbl_prefix = $tbl_prefix:literal, + forgettable_tbl = $forgettable_tbl:literal, + $query:expr, + $($args:tt)* + ) => ({ + $crate::expand_es_query!( + tbl_prefix = $tbl_prefix, + forgettable_tbl = $forgettable_tbl, + sql = $query, + args = [$($args)*] + ) + }); + // With tbl_prefix + forgettable - no args + ( + tbl_prefix = $tbl_prefix:literal, + forgettable_tbl = $forgettable_tbl:literal, + $query:expr + ) => ({ + $crate::expand_es_query!( + tbl_prefix = $tbl_prefix, + forgettable_tbl = $forgettable_tbl, + sql = $query + ) + }); + // With tbl_prefix ( tbl_prefix = $tbl_prefix:literal, diff --git a/src/traits.rs b/src/traits.rs index 275d89dd..e3a77b8a 100644 --- a/src/traits.rs +++ b/src/traits.rs @@ -67,6 +67,13 @@ pub trait EsEvent: DeserializeOwned + Serialize + Send + Sync { + Sync; fn event_context() -> bool; + + /// Whether this event type has any `Forgettable` fields. + /// + /// The `#[derive(EsEvent)]` macro sets this automatically via an inherent const + /// that shadows this default. Manual implementors can override it if needed. + #[doc(hidden)] + const HAS_FORGETTABLE_FIELDS: bool = false; } /// Required trait for converting new entities into their initial events before persistence. diff --git a/tests/entities/customer.rs b/tests/entities/customer.rs new file mode 100644 index 00000000..42b86d58 --- /dev/null +++ b/tests/entities/customer.rs @@ -0,0 +1,112 @@ +#![allow(dead_code)] + +use derive_builder::Builder; +use serde::{Deserialize, Serialize}; + +use es_entity::*; + +es_entity::entity_id! { CustomerId } + +#[derive(EsEvent, Debug, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +#[es_event(id = "CustomerId")] +pub enum CustomerEvent { + Initialized { + id: CustomerId, + name: Forgettable, + email: String, + }, + NameUpdated { + name: Forgettable, + }, + EmailUpdated { + email: String, + }, +} + +#[derive(EsEntity, Builder)] +#[builder(pattern = "owned", build_fn(error = "EsEntityError"))] +pub struct Customer { + pub id: CustomerId, + pub name: String, + pub email: String, + + events: EntityEvents, +} + +impl Customer { + pub fn update_name(&mut self, new_name: impl Into) -> Idempotent<()> { + let new_name = new_name.into(); + self.name = new_name.clone(); + self.events.push(CustomerEvent::NameUpdated { + name: Forgettable::new(new_name), + }); + Idempotent::Executed(()) + } + + pub fn update_email(&mut self, new_email: impl Into) -> Idempotent<()> { + let new_email = new_email.into(); + self.email = new_email.clone(); + self.events + .push(CustomerEvent::EmailUpdated { email: new_email }); + Idempotent::Executed(()) + } +} + +impl TryFromEvents for Customer { + fn try_from_events(events: EntityEvents) -> Result { + let mut builder = CustomerBuilder::default(); + for event in events.iter_all() { + match event { + CustomerEvent::Initialized { id, name, email } => { + builder = builder + .id(*id) + .name( + name.value() + .map(|r| r.clone()) + .unwrap_or_else(|| "[forgotten]".into()), + ) + .email(email.clone()); + } + CustomerEvent::NameUpdated { name } => { + if let Some(n) = name.value() { + builder = builder.name(n.clone()); + } + } + CustomerEvent::EmailUpdated { email } => { + builder = builder.email(email.clone()); + } + } + } + builder.events(events).build() + } +} + +#[derive(Debug, Builder)] +pub struct NewCustomer { + #[builder(setter(into))] + pub id: CustomerId, + #[builder(setter(into))] + pub name: String, + #[builder(setter(into))] + pub email: String, +} + +impl NewCustomer { + pub fn builder() -> NewCustomerBuilder { + NewCustomerBuilder::default() + } +} + +impl IntoEvents for NewCustomer { + fn into_events(self) -> EntityEvents { + EntityEvents::init( + self.id, + [CustomerEvent::Initialized { + id: self.id, + name: Forgettable::new(self.name), + email: self.email, + }], + ) + } +} diff --git a/tests/entities/mod.rs b/tests/entities/mod.rs index ee709c51..e6336b4b 100644 --- a/tests/entities/mod.rs +++ b/tests/entities/mod.rs @@ -1,3 +1,4 @@ +pub mod customer; pub mod order; pub mod profile; pub mod user; diff --git a/tests/forgettable.rs b/tests/forgettable.rs new file mode 100644 index 00000000..269b1b05 --- /dev/null +++ b/tests/forgettable.rs @@ -0,0 +1,153 @@ +mod entities; +mod helpers; + +use entities::customer::*; +use es_entity::*; +use sqlx::PgPool; + +#[derive(EsRepo, Debug)] +#[es_repo( + entity = "Customer", + err = "EsRepoError", + forgettable, + columns(email(ty = "String")) +)] +pub struct Customers { + pool: PgPool, +} + +impl Customers { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[tokio::test] +async fn create_and_load_with_forgettable_fields() -> anyhow::Result<()> { + let pool = helpers::init_pool().await?; + let customers = Customers::new(pool); + + let new_customer = NewCustomer::builder() + .id(CustomerId::new()) + .name("Alice Smith") + .email("alice@example.com") + .build() + .unwrap(); + + let customer = customers.create(new_customer).await?; + assert_eq!(customer.name, "Alice Smith"); + assert_eq!(customer.email, "alice@example.com"); + + // Load the customer and verify data is intact + let loaded = customers.find_by_id(customer.id).await?; + assert_eq!(loaded.name, "Alice Smith"); + assert_eq!(loaded.email, "alice@example.com"); + + Ok(()) +} + +#[tokio::test] +async fn forget_removes_forgettable_data() -> anyhow::Result<()> { + let pool = helpers::init_pool().await?; + let customers = Customers::new(pool); + + let id = CustomerId::new(); + let new_customer = NewCustomer::builder() + .id(id) + .name("Bob Jones") + .email("bob@example.com") + .build() + .unwrap(); + + let mut customer = customers.create(new_customer).await?; + assert_eq!(customer.name, "Bob Jones"); + + // Update the name (adds another event with a forgettable field) + let _ = customer.update_name("Robert Jones"); + customers.update(&mut customer).await?; + + // Verify before forget + let loaded = customers.find_by_id(id).await?; + assert_eq!(loaded.name, "Robert Jones"); + assert_eq!(loaded.email, "bob@example.com"); + + // Forget the customer's personal data - entity is updated in-place + let mut loaded = customers.find_by_id(id).await?; + customers.forget(&mut loaded).await?; + + assert_eq!(loaded.name, "[forgotten]"); + // Non-forgettable field should remain intact + assert_eq!(loaded.email, "bob@example.com"); + + Ok(()) +} + +#[tokio::test] +async fn forget_preserves_non_forgettable_events() -> anyhow::Result<()> { + let pool = helpers::init_pool().await?; + let customers = Customers::new(pool); + + let id = CustomerId::new(); + let new_customer = NewCustomer::builder() + .id(id) + .name("Charlie") + .email("charlie@example.com") + .build() + .unwrap(); + + let mut customer = customers.create(new_customer).await?; + + // Update email (non-forgettable field) + let _ = customer.update_email("charlie_new@example.com"); + customers.update(&mut customer).await?; + + // Forget and verify - entity is updated in-place + let mut loaded = customers.find_by_id(id).await?; + customers.forget(&mut loaded).await?; + + assert_eq!(loaded.name, "[forgotten]"); + assert_eq!(loaded.email, "charlie_new@example.com"); + + Ok(()) +} + +#[tokio::test] +async fn find_all_works_with_forgettable() -> anyhow::Result<()> { + let pool = helpers::init_pool().await?; + let customers = Customers::new(pool); + + let id1 = CustomerId::new(); + let id2 = CustomerId::new(); + + let c1 = NewCustomer::builder() + .id(id1) + .name("Dave") + .email("dave@example.com") + .build() + .unwrap(); + let c2 = NewCustomer::builder() + .id(id2) + .name("Eve") + .email("eve@example.com") + .build() + .unwrap(); + + customers.create(c1).await?; + customers.create(c2).await?; + + let all = customers.find_all::(&[id1, id2]).await?; + assert_eq!(all.len(), 2); + assert_eq!(all[&id1].name, "Dave"); + assert_eq!(all[&id2].name, "Eve"); + + // Forget one customer - entity is updated in-place + let mut c1 = customers.find_by_id(id1).await?; + customers.forget(&mut c1).await?; + assert_eq!(c1.name, "[forgotten]"); + + let all = customers.find_all::(&[id1, id2]).await?; + assert_eq!(all[&id1].name, "[forgotten]"); + assert_eq!(all[&id2].name, "Eve"); + + Ok(()) +}