use crate::parser::{EncryptedFieldKind, ParsedModel};
use proc_macro2::TokenStream;
use quote::quote;
#[cfg_attr(test, mutants::skip)]
pub fn generate_redis_hash_methods(parsed: &ParsedModel) -> TokenStream {
let redis_cfg = crate::feature_gates::redis();
let name = &parsed.name;
let table_name = &parsed.table_name;
let normal_fields = &parsed.normal_fields;
let skipped_fields = &parsed.skipped_fields;
let column_enum_name = quote::format_ident!("{}Column", name);
let RedisTenant {
context: tenant_context,
key_arg: tenant_arg,
instance_check: instance_tenant_check,
decoded_check: decoded_tenant_check,
field_guard: tenant_field_guard,
} = redis_tenant(parsed);
let mut relation_field_idents = vec![];
for rel in &parsed.relations {
relation_field_idents.push(rel.field_name.clone());
}
let skip_tail = if skipped_fields.is_empty() {
quote! {}
} else {
quote! { ..Default::default() }
};
let redis_get_default_bound = if skipped_fields.is_empty() {
quote! {}
} else {
quote! { where Self: Default }
};
let mut to_hash_fields = vec![];
let mut from_hash_fields = vec![];
for field in normal_fields {
let field_str = field.to_string();
let encrypted_kind = parsed
.encrypted_fields
.iter()
.find(|encrypted| encrypted.name == *field)
.map(|encrypted| encrypted.kind);
let stored_value = match encrypted_kind {
Some(EncryptedFieldKind::String) => quote! {
rullst_orm::privacy::encrypt_model_field(&self.#field, #table_name, #field_str)?
},
Some(EncryptedFieldKind::OptionalString) => quote! {
match self.#field.as_deref() {
Some(value) => Some(rullst_orm::privacy::encrypt_model_field(
value,
#table_name,
#field_str,
)?),
None => None,
}
},
None => quote! { &self.#field },
};
to_hash_fields.push(quote! {
(#field_str, rullst_orm::_serde_json::to_string(&#stored_value)?)
});
let decoded_value = match encrypted_kind {
Some(EncryptedFieldKind::String) => quote! {
let stored: String = rullst_orm::_serde_json::from_str(serialized)?;
rullst_orm::privacy::decrypt_model_field(&stored, #table_name, #field_str)?
},
Some(EncryptedFieldKind::OptionalString) => quote! {
let stored: Option<String> = rullst_orm::_serde_json::from_str(serialized)?;
match stored {
Some(value) => Some(rullst_orm::privacy::decrypt_model_field(
&value,
#table_name,
#field_str,
)?),
None => None,
}
},
None => quote! { rullst_orm::_serde_json::from_str(serialized)? },
};
from_hash_fields.push(quote! {
#field: {
let serialized = hash.get(#field_str).ok_or_else(|| {
rullst_orm::Error::CacheError(format!(
"Redis hash for {} is missing field {}",
#table_name,
#field_str,
))
})?;
#decoded_value
}
});
}
quote! {
#redis_cfg
fn __rullst_redis_hash_fields(&self) -> Result<Vec<(&'static str, String)>, rullst_orm::Error> {
Ok(vec![
#(#to_hash_fields),*
])
}
#redis_cfg
fn __rullst_from_redis_hash(
hash: &std::collections::HashMap<String, String>,
) -> Result<Self, rullst_orm::Error>
#redis_get_default_bound
{
Ok(Self {
#(#from_hash_fields,)*
#(#relation_field_idents: None,)*
#skip_tail
})
}
#redis_cfg
pub async fn save_to_redis(&self) -> Result<(), rullst_orm::Error> {
#tenant_context
#instance_tenant_check
let fields = self.__rullst_redis_hash_fields()?;
rullst_orm::query_cache::write_model_hash(
#table_name,
#tenant_arg,
&self.id.to_string(),
&fields,
)
.await
}
#redis_cfg
pub async fn get_from_redis(id: impl std::fmt::Display) -> Result<Option<Self>, rullst_orm::Error>
#redis_get_default_bound
{
#tenant_context
let hash = rullst_orm::query_cache::read_model_hash(
#table_name,
#tenant_arg,
&id.to_string(),
)
.await?;
if hash.is_empty() {
return Ok(None);
}
let model = Self::__rullst_from_redis_hash(&hash)?;
#decoded_tenant_check
Ok(Some(model))
}
#redis_cfg
pub async fn increment_redis_field(id: impl std::fmt::Display, field: #column_enum_name, amount: i64) -> Result<i64, rullst_orm::Error> {
#tenant_context
#tenant_field_guard
rullst_orm::query_cache::increment_model_hash(
#table_name,
#tenant_arg,
&id.to_string(),
field.as_str(),
amount,
)
.await
}
}
}
struct RedisTenant {
context: TokenStream,
key_arg: TokenStream,
instance_check: TokenStream,
decoded_check: TokenStream,
field_guard: TokenStream,
}
fn redis_tenant(parsed: &ParsedModel) -> RedisTenant {
let table_name = &parsed.table_name;
let Some(tenant_type) = parsed
.normal_fields
.iter()
.zip(parsed.normal_fields_types.iter())
.find(|(field, _)| *field == parsed.tenant_column.as_str())
.map(|(_, ty)| ty)
else {
return RedisTenant {
context: quote! {},
key_arg: quote! { None },
instance_check: quote! {},
decoded_check: quote! {},
field_guard: quote! {},
};
};
let column = syn::Ident::new(&parsed.tenant_column, parsed.name.span());
let column_name = &parsed.tenant_column;
let outside = quote! {
return Err(rullst_orm::Error::Validation(
"record is outside the active tenant scope".to_string()
));
};
RedisTenant {
context: quote! {
let tenant = rullst_orm::tenant::get_tenant_id().ok_or_else(|| {
rullst_orm::Error::Validation(format!(
"tenant context is required to use the Redis hash of `{}`",
#table_name
))
})?;
let expected_tenant: #tenant_type = tenant.clone().try_into().map_err(|_| {
rullst_orm::Error::Validation(format!(
"tenant context type does not match `{}.{}`",
#table_name,
#column_name
))
})?;
},
key_arg: quote! { Some(&tenant) },
instance_check: quote! {
if self.#column != expected_tenant { #outside }
},
decoded_check: quote! {
if model.#column != expected_tenant { #outside }
},
field_guard: quote! {
let _ = expected_tenant;
if field.as_str() == #column_name {
return Err(rullst_orm::Error::Validation(
"the tenant column of a Redis model hash cannot be incremented".to_string()
));
}
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use syn::{DeriveInput, parse_quote};
#[test]
fn redis_deserialization_propagates_corrupt_cache_errors() {
let input: DeriveInput = parse_quote! {
struct CachedModel {
id: i64,
payload: Json<Payload>,
}
};
let parsed = crate::parser::parse(&input).expect("test model should parse");
let generated = generate_redis_hash_methods(&parsed).to_string();
assert!(!generated.contains("unwrap_or_default"));
assert!(!generated.contains("String :: from (\"null\")"));
assert!(generated.contains("is missing field"));
assert!(generated.contains("from_str (serialized) ?"));
}
#[test]
fn hash_keys_bind_the_namespace_and_the_active_tenant() {
let global: DeriveInput = parse_quote! {
#[orm(table = "docs")]
struct Doc { id: i32, title: String }
};
let parsed = crate::parser::parse(&global).expect("test model should parse");
let generated = generate_redis_hash_methods(&parsed).to_string();
for helper in [
"read_model_hash",
"write_model_hash",
"increment_model_hash",
] {
assert_eq!(
generated
.matches(&format!("query_cache :: {helper} (\"docs\" , None ,"))
.count(),
1,
"{helper}"
);
}
assert!(!generated.contains("\"orm:{}:{}\""));
assert!(!generated.contains("AsyncCommands"));
assert!(!generated.contains("get_tenant_id"));
let tenant: DeriveInput = parse_quote! {
#[orm(table = "invoices", tenant_column = "organization_id")]
struct Invoice { id: i32, organization_id: String, total: i64 }
};
let parsed = crate::parser::parse(&tenant).expect("test model should parse");
let generated = generate_redis_hash_methods(&parsed).to_string();
assert_eq!(generated.matches("Some (& tenant)").count(), 3);
assert_eq!(
generated
.matches("tenant context is required to use the Redis hash")
.count(),
3
);
assert!(generated.contains("if self . organization_id != expected_tenant"));
assert!(generated.contains("if model . organization_id != expected_tenant"));
assert!(generated.contains("cannot be incremented"));
}
#[test]
fn encrypted_fields_are_enveloped_in_redis_hashes() {
let input: DeriveInput = parse_quote! {
#[orm(table = "patients")]
struct Patient {
id: i32,
#[orm(encrypted)]
diagnosis: String,
#[orm(encrypted)]
note: Option<String>,
}
};
let parsed = crate::parser::parse(&input).expect("test model should parse");
let generated = generate_redis_hash_methods(&parsed).to_string();
assert!(
generated.contains(
"encrypt_model_field (& self . diagnosis , \"patients\" , \"diagnosis\")"
)
);
assert!(
generated.contains("decrypt_model_field (& stored , \"patients\" , \"diagnosis\")")
);
assert!(generated.contains("decrypt_model_field (& value , \"patients\" , \"note\" ,)"));
assert!(!generated.contains("to_string (& & self . diagnosis)"));
}
}