use std::collections::HashMap;
use super::{digest_bytes, scope_segment, validate_namespace};
use crate::{Error, Orm, RullstValue};
const HASH_KEY_PREFIX: &str = "rullst:orm:hash:v1:";
const LEGACY_HASH_KEY_PREFIX: &str = "orm:";
const MIGRATE_PRELUDE: &str = r"
if redis.call('EXISTS', KEYS[1]) == 0 then
if redis.call('EXISTS', KEYS[2]) == 1 then
redis.call('RENAME', KEYS[2], KEYS[1])
end
else
redis.call('DEL', KEYS[2])
end
";
const WRITE_BODY: &str = r"
redis.call('HSET', KEYS[1], unpack(ARGV))
return 1
";
const INCREMENT_BODY: &str = r"
return redis.call('HINCRBY', KEYS[1], ARGV[1], ARGV[2])
";
#[doc(hidden)]
pub fn model_hash_key(
table: &str,
tenant: Option<&RullstValue>,
id: &str,
) -> Result<String, Error> {
build_model_hash_key(Orm::redis_cache_namespace()?, tenant, table, id)
}
fn build_model_hash_key(
namespace: &str,
tenant: Option<&RullstValue>,
table: &str,
id: &str,
) -> Result<String, Error> {
validate_namespace(namespace)?;
if id.is_empty()
|| id.len() > 64
|| !id
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
{
return Err(Error::Validation(
"Redis model hash ID must contain 1-64 ASCII letters, digits, '-' or '_'".to_string(),
));
}
Ok(format!(
"{HASH_KEY_PREFIX}{namespace}:{}:table-{}:{id}",
scope_segment(tenant),
digest_bytes(table.as_bytes())
))
}
fn legacy_model_hash_key(tenant: Option<&RullstValue>, table: &str, id: &str) -> Option<String> {
tenant
.is_none()
.then(|| format!("{LEGACY_HASH_KEY_PREFIX}{table}:{id}"))
}
#[doc(hidden)]
pub async fn read_model_hash(
table: &str,
tenant: Option<&RullstValue>,
id: &str,
) -> Result<HashMap<String, String>, Error> {
use crate::_redis::AsyncCommands;
let key = model_hash_key(table, tenant, id)?;
let mut connection = Orm::redis_manager()?;
let hash: HashMap<String, String> = connection.hgetall(&key).await?;
match legacy_model_hash_key(tenant, table, id) {
Some(legacy) if hash.is_empty() => Ok(connection.hgetall(&legacy).await?),
_ => Ok(hash),
}
}
#[doc(hidden)]
pub async fn write_model_hash(
table: &str,
tenant: Option<&RullstValue>,
id: &str,
fields: &[(&str, String)],
) -> Result<(), Error> {
use crate::_redis::AsyncCommands;
let key = model_hash_key(table, tenant, id)?;
let mut connection = Orm::redis_manager()?;
let Some(legacy) = legacy_model_hash_key(tenant, table, id) else {
let _: () = connection.hset_multiple(&key, fields).await?;
return Ok(());
};
let mut script = crate::_redis::cmd("EVAL");
script
.arg(format!("{MIGRATE_PRELUDE}{WRITE_BODY}"))
.arg(2)
.arg(&key)
.arg(&legacy);
for (field, value) in fields {
script.arg(*field).arg(value);
}
let _: i64 = script.query_async(&mut connection).await?;
Ok(())
}
#[doc(hidden)]
pub async fn increment_model_hash(
table: &str,
tenant: Option<&RullstValue>,
id: &str,
field: &str,
amount: i64,
) -> Result<i64, Error> {
let key = model_hash_key(table, tenant, id)?;
let mut connection = Orm::redis_manager()?;
let incremented = match legacy_model_hash_key(tenant, table, id) {
None => {
crate::_redis::cmd("HINCRBY")
.arg(&key)
.arg(field)
.arg(amount)
.query_async(&mut connection)
.await?
}
Some(legacy) => {
crate::_redis::cmd("EVAL")
.arg(format!("{MIGRATE_PRELUDE}{INCREMENT_BODY}"))
.arg(2)
.arg(&key)
.arg(&legacy)
.arg(field)
.arg(amount)
.query_async(&mut connection)
.await?
}
};
Ok(incremented)
}
#[cfg(test)]
mod tests {
use super::{build_model_hash_key, legacy_model_hash_key};
use crate::RullstValue;
#[test]
fn model_hash_keys_bind_namespace_tenant_and_table() {
let tenant = RullstValue::String("private-school-name".to_string());
let key = build_model_hash_key("academy", Some(&tenant), "invoices", "42")
.expect("tenant hash key");
assert!(key.starts_with("rullst:orm:hash:v1:academy:tenant-"));
assert!(key.ends_with(":42"));
assert!(!key.contains("private-school-name"));
assert!(!key.contains("invoices"));
for other in [
build_model_hash_key("billing", Some(&tenant), "invoices", "42"),
build_model_hash_key(
"academy",
Some(&RullstValue::String("other".to_string())),
"invoices",
"42",
),
build_model_hash_key("academy", None, "invoices", "42"),
build_model_hash_key("academy", Some(&tenant), "orders", "42"),
] {
assert_ne!(key, other.expect("comparison key"));
}
for invalid in ["", "4:2", "4 2", &"9".repeat(65)] {
assert!(build_model_hash_key("academy", None, "invoices", invalid).is_err());
}
assert!(build_model_hash_key("bad namespace", None, "invoices", "1").is_err());
}
#[test]
fn only_global_models_have_a_legacy_hash_key() {
assert_eq!(
legacy_model_hash_key(None, "invoices", "42").as_deref(),
Some("orm:invoices:42")
);
let tenant = RullstValue::String("acme".to_string());
assert_eq!(legacy_model_hash_key(Some(&tenant), "invoices", "42"), None);
}
}