cultcache-rs: #[cultcache(index)] is unique at commit and load; re-registration re-judges held records #95

Merged
libby merged 7 commits from hands/variants-unique-index-rs into main 2026-11-11 07:43:23 +00:00
6 changed files with 960 additions and 91 deletions

View file

@ -10,10 +10,14 @@ only in git history.
- A single-file store written as `cultcache.store.v3` is read and written: a record that holds an element id (an owned list element with an `[CultElementId]` member, carried through every write) marks the store v3, and a v3 store is carried through a rewrite with its ids. A runtime from before this change refuses a v3 store by name.
- A write over a file the reader refuses (a zero-byte file, a truncated store, a stamp this runtime does not read) refuses with `CultCacheStoreUnreadable` and leaves the file as it is. A whole-store write used to replace such a file. This reverses 0.3.1, where `push_all` replaced a file with no readable stamp (healing it); the stamp rule now has one owner, the reader every write asks under the lock, and it accepts v1 and v3 (0.3.1 refused v3).
- `#[cultcache(index)]` means unique, as in C# `RefuseIndexSharing`: within one type and one alias no two records hold one non-blank value. A put, batch, push or load that would make two records hold a value is refused, naming both keys and the alias, and a store that already holds a duplicate does not open. A grouping index is no longer expressible; a consumer that relied on shared values must drop the `index` and look up by another way.
- `bool` index and lookup text renders `True` / `False`, as C# does, where it rendered `true` / `false`.
- `DatabaseEntry::UNIQUE_INDEXES` replaces `cult_indexes` as the one index declaration and changes type (its members are the unique aliases); a hand implementation overriding `cult_indexes` no longer compiles.
- `register_entry_type` over a type that already holds records re-judges them under the new declaration (unique indexes and the global rule) and refuses, changing nothing, when they violate it. A declaration with an index also refuses when a held record cannot be decoded as the new type; a declaration without one does not read the records, so it does not.
### Added
- Lookups, matching the C# `GetByName`, `GetByIndex` and `GetGlobal`. `#[cultcache(name)]` and `#[cultcache(index)]` / `#[cultcache(index = "alias")]` on `DatabaseEntry` fields and `#[cultcache(global)]` on the struct give `CultCache::get_by_name`, `get_by_index` and `get_global`. An empty name and a blank index value are not found; records of one type sharing a name or value find the one with the smallest key. A second record of a global type under another key is refused on every write path and on load with `CultCacheSecondGlobal`. Two `name` members, or two members with one index alias, are refused at compile time. A member's text comes from `CultLookupValue` (`String`, `Option` of one, integers, `bool`, `char`).
- Lookups, matching the C# `GetByName`, `GetByIndex` and `GetGlobal`. `#[cultcache(name)]` and `#[cultcache(index)]` / `#[cultcache(index = "alias")]` on `DatabaseEntry` fields and `#[cultcache(global)]` on the struct give `CultCache::get_by_name`, `get_by_index` and `get_global`. An empty name and a blank index value are not found; records of one type sharing a name find the one with the smallest key (an index value is unique, below). A second record of a global type under another key is refused on every write path and on load with `CultCacheSecondGlobal`. Two `name` members, or two members with one index alias, are refused at compile time. A member's text comes from `CultLookupValue` (`String`, `Option` of one, integers, `bool`, `char`).
- `SchemaConflictError`, the typed refusal of a write the catalog cannot describe: records of different types under one schema id, or an arrived schema and a registered one that share an id and disagree on the schema name. A whole-store write (`push_all`) refuses with it, names the schema id, the names involved and a record key, writes nothing and leaves the file as it is. A caller tells it apart with `error.downcast_ref::<SchemaConflictError>()`.
## [0.3.1]

View file

@ -412,7 +412,9 @@ fn outcome(error: &anyhow::Error) {
3. **Name and index lookups**
- `cache.get_by_name::<T>("Potion")`
- `cache.get_by_index::<T>("faction", "Lucent")`
- `cache.get_by_index::<T>("callsign", "Lucent-7")`: `#[cultcache(index)]` is unique. Within one type no two
records hold one non-blank value of the alias; a put, batch or load that would make two is refused, naming both
keys and the alias, and writes nothing. A grouping (faction, kind, status) is not an index: filter `get_all`.
- generated extractors instead of reflection strings
4. **Schema/version metadata**

View file

@ -69,23 +69,16 @@ fn database_entry_codec(input: DeriveInput) -> syn::Result<proc_macro2::TokenStr
}
}
});
let index_members = slots.iter().filter_map(|slot| slot.index.as_ref().map(|alias| (alias, &slot.ident)));
let index_pushes = index_members.map(|(alias, member)| {
let index_members: Vec<_> = slots.iter().filter_map(|slot| slot.index.as_ref().map(|alias| (alias, &slot.ident))).collect();
let index_rows = index_members.iter().map(|(alias, member)| {
quote! {
if let ::std::option::Option::Some(value) = ::cultcache_rs::CultLookupValue::lookup_value(&self.#member) {
held.push((#alias, value));
}
(#alias, (|record: &Self| ::cultcache_rs::CultLookupValue::lookup_value(&record.#member))
as fn(&Self) -> ::std::option::Option<::std::string::String>)
}
});
let index_pushes: Vec<_> = index_pushes.collect();
let indexes_fn = (!index_pushes.is_empty()).then(|| {
quote! {
fn cult_indexes(&self) -> ::std::vec::Vec<(&'static str, ::std::string::String)> {
let mut held = ::std::vec::Vec::new();
#(#index_pushes)*
held
}
}
let index_rows: Vec<_> = index_rows.collect();
let unique_indexes = (!index_rows.is_empty()).then(|| {
quote! { const UNIQUE_INDEXES: &'static [::cultcache_rs::CultIndexMember<Self>] = &[#(#index_rows),*]; }
});
Ok(quote! {
@ -93,8 +86,8 @@ fn database_entry_codec(input: DeriveInput) -> syn::Result<proc_macro2::TokenStr
const TYPE: &'static str = #entry_type;
const SCHEMA_NAME: &'static str = #schema_name;
const GLOBAL: bool = #global;
#unique_indexes
#name_fn
#indexes_fn
}
#codec
@ -443,7 +436,8 @@ struct CultCacheSlot {
bytes: bool,
/// `#[cultcache(name)]`: the member `get_by_name` finds the record by.
name: bool,
/// `#[cultcache(index)]` or `#[cultcache(index = "alias")]`: the alias `get_by_index` finds the record by.
/// `#[cultcache(index)]` or `#[cultcache(index = "alias")]`: a unique lookup. Within one type no two records hold one
/// non-blank value of the alias; `get_by_index` finds the record by it.
index: Option<String>,
}
@ -941,15 +935,15 @@ mod tests {
);
assert!(tokens.contains("constGLOBAL:bool=true"), "{tokens}");
assert!(tokens.contains("fncult_name"), "{tokens}");
assert!(tokens.contains("(\"code\",value)"), "{tokens}");
assert!(tokens.contains("(\"sku\",value)"), "{tokens}");
assert!(tokens.contains("(\"code\","), "{tokens}");
assert!(tokens.contains("(\"sku\","), "{tokens}");
}
#[test]
fn a_record_without_lookups_generates_none_of_them() {
let tokens = entry_tokens(r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0)] a: String }"#);
assert!(tokens.contains("constGLOBAL:bool=false"), "{tokens}");
assert!(!tokens.contains("fncult_name") && !tokens.contains("fncult_indexes"), "{tokens}");
assert!(!tokens.contains("fncult_name") && !tokens.contains("UNIQUE_INDEXES"), "{tokens}");
}
// ResolveIndexAlias: a blank alias is the member's name.
@ -958,7 +952,7 @@ mod tests {
let tokens = entry_tokens(
r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0, index = " ")] code: String }"#,
);
assert!(tokens.contains("(\"code\",value)"), "{tokens}");
assert!(tokens.contains("(\"code\","), "{tokens}");
}
#[test]

View file

@ -168,12 +168,14 @@ pub trait DatabaseEntry: Serialize + DeserializeOwned + Clone + Send + 'static {
fn cult_name(&self) -> Option<String> {
None
}
/// Each `#[cultcache(index)]` member as (alias, value), which `get_by_index` finds the record by.
fn cult_indexes(&self) -> Vec<(&'static str, String)> {
Vec::new()
}
/// The `#[cultcache(index)]` members, the one declaration: each is an alias with the reader of its value. Each is
/// unique: within one type no two records hold one non-blank value of it, and `get_by_index` finds the record by it.
const UNIQUE_INDEXES: &'static [CultIndexMember<Self>] = &[];
}
/// An index alias and how to read its value from a record; `None` is no value.
pub type CultIndexMember<T> = (&'static str, fn(&T) -> Option<String>);
/// What a `#[cultcache(name)]` or `#[cultcache(index)]` member says it is called: its text, or `None` for a member
/// that holds no value (an `Option` that is `None`). The cache treats an empty name and a blank index value as none.
pub trait CultLookupValue {
@ -192,6 +194,13 @@ impl<T: CultLookupValue> CultLookupValue for Option<T> {
}
}
/// C# renders a boolean `True` or `False`; the index text is shared across runtimes.
impl CultLookupValue for bool {
fn lookup_value(&self) -> Option<String> {
Some(if *self { "True" } else { "False" }.to_string())
}
}
macro_rules! lookup_value_by_display {
($($ty:ty),* $(,)?) => {$(
impl CultLookupValue for $ty {
@ -202,7 +211,7 @@ macro_rules! lookup_value_by_display {
)*};
}
lookup_value_by_display!(bool, char, i8, i16, i32, i64, i128, isize, u8, u16, u32, u64, u128, usize);
lookup_value_by_display!(char, i8, i16, i32, i64, i128, isize, u8, u16, u32, u64, u128, usize);
/// A global type already holds a record under another key; a second would leave `get_global` with two answers.
#[derive(Debug, Clone, PartialEq, Eq)]
@ -2719,9 +2728,46 @@ pub struct CultCache {
globals: BTreeSet<String>,
schema_name_definitions: BTreeMap<String, String>,
entries: BTreeMap<(String, String), CultCacheEnvelope>,
/// Derived from `entries` by the uniqueness judge: (type, alias, value) -> the one key holding that value. It
/// decides nothing; `get_by_index` reads it.
index_holders: IndexHolders,
/// Per registered type with `UNIQUE_INDEXES`: decodes a payload as that type and returns its non-blank index
/// values. Registration data, so the judge decodes only indexed types.
projectors: BTreeMap<String, IndexProjector>,
stores: Vec<CultCacheStoreRegistration>,
}
type IndexProjector = fn(&[u8]) -> Result<Vec<(&'static str, String)>>;
type IndexSlot = (String, String, String);
type IndexHolders = BTreeMap<IndexSlot, String>;
fn project_indexes<T: DatabaseEntry>(payload: &[u8]) -> Result<Vec<(&'static str, String)>> {
let document = decode_payload::<T>(payload)?;
Ok(T::UNIQUE_INDEXES
.iter()
.filter_map(|(alias, read)| read(&document).map(|value| (*alias, value)))
.filter(|(_, value)| !value.trim().is_empty())
.collect())
}
/// What an admission does to the holder map: slots it releases (still held by the key that held them) and slots it takes.
#[derive(Default)]
struct IndexChange {
released: Vec<(IndexSlot, String)>,
taken: Vec<(IndexSlot, String)>,
}
impl IndexChange {
fn apply(self, holders: &mut IndexHolders) {
for (slot, key) in self.released {
if holders.get(&slot) == Some(&key) {
holders.remove(&slot);
}
}
holders.extend(self.taken);
}
}
impl CultCache {
pub fn new() -> Self {
Self {
@ -2729,6 +2775,8 @@ impl CultCache {
globals: BTreeSet::new(),
schema_name_definitions: BTreeMap::new(),
entries: BTreeMap::new(),
index_holders: BTreeMap::new(),
projectors: BTreeMap::new(),
stores: Vec::new(),
}
}
@ -2756,12 +2804,40 @@ impl CultCache {
existing_type
));
}
self.definitions.insert(T::TYPE.to_string(), T::SCHEMA_NAME);
// The declaration decides what the held records hold (index projector, global flag), so a changed declaration
// re-judges them; a held violation (or a record the new type cannot decode) refuses the registration and
// changes nothing.
let previous = self.projectors.get(T::TYPE).copied();
let was_global = self.globals.contains(T::TYPE);
if T::UNIQUE_INDEXES.is_empty() {
self.projectors.remove(T::TYPE);
} else {
self.projectors.insert(T::TYPE.to_string(), project_indexes::<T>);
}
if T::GLOBAL {
self.globals.insert(T::TYPE.to_string());
} else {
self.globals.remove(T::TYPE);
}
let held_type = self.entries.range((T::TYPE.to_string(), String::new())..).next().is_some_and(|((held, _), _)| held == T::TYPE);
if held_type {
match self.refuse_second_globals(&self.entries).and_then(|()| self.judge_image(&self.entries)) {
Ok(holders) => self.index_holders = holders,
Err(refusal) => {
match previous {
Some(project) => self.projectors.insert(T::TYPE.to_string(), project),
None => self.projectors.remove(T::TYPE),
};
if was_global {
self.globals.insert(T::TYPE.to_string());
} else {
self.globals.remove(T::TYPE);
}
return Err(refusal);
}
}
}
self.definitions.insert(T::TYPE.to_string(), T::SCHEMA_NAME);
self.schema_name_definitions
.insert(T::SCHEMA_NAME.to_string(), T::TYPE.to_string());
Ok(())
@ -2869,7 +2945,9 @@ impl CultCache {
}
}
self.refuse_second_globals(&loaded)?;
let holders = self.judge_image(&loaded)?;
self.entries = loaded;
self.index_holders = holders;
Ok(())
}
@ -2973,20 +3051,18 @@ impl CultCache {
self.find_record::<T>(|document| document.cult_name().as_deref() == Some(name))
}
/// The record whose `#[cultcache(index)]` member `alias` holds `value`. A blank value is not indexed, so it finds
/// nothing. Records of one type sharing a value find the one with the smallest key (ordinal), whatever order they
/// were written or loaded in; removing it finds the next.
/// The record whose `#[cultcache(index)]` member `alias` holds `value`: at most one, since an index is unique. A
/// blank value is not indexed, so it finds nothing; neither does an alias `T` does not declare.
pub fn get_by_index<T: DatabaseEntry>(&self, alias: &str, value: &str) -> Result<Option<T>> {
self.require_entry_type::<T>()?;
if value.trim().is_empty() {
if value.trim().is_empty() || !T::UNIQUE_INDEXES.iter().any(|(declared, _)| *declared == alias) {
return Ok(None);
}
self.find_record::<T>(|document| {
document
.cult_indexes()
.iter()
.any(|(held_alias, held_value)| *held_alias == alias && held_value == value)
})
let slot = (T::TYPE.to_string(), alias.to_string(), value.to_string());
match self.index_holders.get(&slot) {
Some(key) => self.get::<T>(key),
None => Ok(None),
}
}
/// The first record of `T`, in key order, that `matches`.
@ -3029,10 +3105,12 @@ impl CultCache {
pub fn put<T: DatabaseEntry>(&mut self, key: impl Into<String>, value: &T) -> Result<T> {
let (entry, parsed) = self.prepare_entry(key, value)?;
self.refuse_second_global(&self.entries, &entry.r#type, &entry.key)?;
let change = self.judge_unique(&[&entry])?;
if let Some(home) = self.home_index(T::TYPE)? {
self.stores[home].store.push(&entry)?;
}
self.entries.insert(entry_id(&entry), entry);
change.apply(&mut self.index_holders);
Ok(parsed)
}
@ -3141,6 +3219,7 @@ impl CultCache {
next.insert(entry_id(entry), entry.clone());
}
self.refuse_second_globals(&next)?;
let change = self.judge_unique(&staged.iter().collect::<Vec<_>>())?;
if let Some(store_index) = store_index {
// The home store receives its own records only: those it already
// holds in this cache plus the staged ones.
@ -3154,16 +3233,19 @@ impl CultCache {
.push_all(&snapshot, PushAllOptions::default())?;
}
self.entries = next;
change.apply(&mut self.index_holders);
Ok(())
}
pub fn put_envelope<T: DatabaseEntry>(&mut self, entry: CultCacheEnvelope) -> Result<T> {
let parsed = self.validate_envelope::<T>(&entry)?;
self.refuse_second_global(&self.entries, &entry.r#type, &entry.key)?;
let change = self.judge_unique(&[&entry])?;
if let Some(home) = self.home_index(T::TYPE)? {
self.stores[home].store.push(&entry)?;
}
self.entries.insert(entry_id(&entry), entry);
change.apply(&mut self.index_holders);
Ok(parsed)
}
@ -3193,10 +3275,12 @@ impl CultCache {
}
require_payload_depth(&entry)?;
self.refuse_second_global(&self.entries, &entry.r#type, &entry.key)?;
let change = self.judge_unique(&[&entry])?;
if let Some(home) = self.home_index(&entry.r#type)? {
self.stores[home].store.push(&entry)?;
}
self.entries.insert(entry_id(&entry), entry);
change.apply(&mut self.index_holders);
Ok(())
}
@ -3247,10 +3331,12 @@ impl CultCache {
let Some(entry) = self.entries.get(&id).cloned() else {
return Ok(false);
};
let released = self.projected_slots(&entry)?;
if let Some(home) = self.home_index(T::TYPE)? {
self.stores[home].store.delete(&entry)?;
}
self.entries.remove(&id);
IndexChange { released, taken: Vec::new() }.apply(&mut self.index_holders);
Ok(true)
}
@ -3279,10 +3365,86 @@ impl CultCache {
loaded.insert(entry_id(&entry), entry);
}
self.refuse_second_globals(&loaded)?;
let holders = self.judge_image(&loaded)?;
self.entries = loaded;
self.index_holders = holders;
Ok(())
}
/// The slots an envelope holds: its type's non-blank index values, each held by its key.
fn projected_slots(&self, entry: &CultCacheEnvelope) -> Result<Vec<(IndexSlot, String)>> {
let Some(project) = self.projectors.get(&entry.r#type) else {
return Ok(Vec::new());
};
let held = project(&entry.payload).with_context(|| {
format!("failed to project the unique indexes of CultCache entry {:?} at key {:?}", entry.r#type, entry.key)
})?;
Ok(held
.into_iter()
.map(|(alias, value)| ((entry.r#type.clone(), alias.to_string(), value), entry.key.clone()))
.collect())
}
/// The one index rule (C# `RefuseIndexSharing`), judged on the result of an admission: within one type and one
/// unique alias, no two records hold one non-blank value. The holders of a value are the held key (unless this
/// admission rewrites it) plus every landing key projecting it; two distinct holders refuse, naming both keys
/// (ordinal) and the alias, never the value. A key landing twice lands as its last envelope.
fn judge_unique(&self, landing: &[&CultCacheEnvelope]) -> Result<IndexChange> {
self.judge_against(&self.index_holders, landing, true)
}
/// Judges a whole image against an empty holder map and returns the map it installs.
fn judge_image(&self, image: &BTreeMap<(String, String), CultCacheEnvelope>) -> Result<IndexHolders> {
let landing: Vec<_> = image.values().collect();
let mut holders = IndexHolders::new();
self.judge_against(&holders, &landing, false)?.apply(&mut holders);
Ok(holders)
}
fn judge_against(&self, held: &IndexHolders, landing: &[&CultCacheEnvelope], replacing: bool) -> Result<IndexChange> {
let mut last: BTreeMap<(&str, &str), &CultCacheEnvelope> = BTreeMap::new();
for entry in landing {
last.insert((entry.r#type.as_str(), entry.key.as_str()), entry);
}
let mut change = IndexChange::default();
let mut groups: Vec<(IndexSlot, BTreeSet<String>)> = Vec::new();
let mut at: BTreeMap<IndexSlot, usize> = BTreeMap::new();
for entry in last.values() {
if !self.projectors.contains_key(&entry.r#type) {
continue;
}
if replacing && let Some(old) = self.entries.get(&entry_id(entry)) {
change.released.extend(self.projected_slots(old)?);
}
for (slot, key) in self.projected_slots(entry)? {
let index = *at.entry(slot.clone()).or_insert_with(|| {
groups.push((slot, BTreeSet::new()));
groups.len() - 1
});
groups[index].1.insert(key);
}
}
groups.sort_by(|left, right| left.0.1.cmp(&right.0.1));
for (slot, mut holders) in groups {
if let Some(held_key) = held.get(&slot)
&& !last.contains_key(&(slot.0.as_str(), held_key.as_str()))
{
holders.insert(held_key.clone());
}
let mut ordered = holders.iter();
if let (Some(first), Some(second)) = (ordered.next(), ordered.next()) {
return Err(anyhow!(
"Records {first} and {second} ({}) both hold unique index '{}'; an index value names one record.",
self.definitions.get(&slot.0).copied().unwrap_or(&slot.0),
slot.1
));
}
let key = holders.into_iter().next().expect("a group holds at least one key");
change.taken.push((slot, key));
}
Ok(change)
}
fn refuse_second_global(
&self,
entries: &BTreeMap<(String, String), CultCacheEnvelope>,

View file

@ -11,7 +11,7 @@ use cultcache_rs::{
struct Item {
#[cultcache(key = 0, name)]
name: String,
#[cultcache(key = 1, index)]
#[cultcache(key = 1)]
category: String,
#[cultcache(key = 2, index = "sku")]
code: Option<String>,
@ -30,7 +30,7 @@ struct Gadget {
#[cultcache(key = 0, name)]
name: String,
#[cultcache(key = 1, index)]
category: String,
serial: String,
}
/// Sorts after `Item` and has its exact shape: a lookup that ran past the end of its own type would answer from it.
@ -39,7 +39,7 @@ struct Gadget {
struct Zed {
#[cultcache(key = 0, name)]
name: String,
#[cultcache(key = 1, index)]
#[cultcache(key = 1)]
category: String,
#[cultcache(key = 2, index = "sku")]
code: Option<String>,
@ -48,7 +48,7 @@ struct Zed {
}
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "settings", global)]
#[cultcache(type = "settings", schema = "tests.settings", global)]
struct Settings {
#[cultcache(key = 0)]
value: String,
@ -71,7 +71,7 @@ struct Numbered {
#[cultcache(key = 0, name)]
id: u32,
#[cultcache(key = 1, index)]
owner: u64,
badge: u64,
}
fn cache() -> CultCache {
@ -110,7 +110,6 @@ fn a_record_is_found_by_name_and_by_index_after_a_reopen() {
let reopened = reopened(&path);
let by_name = reopened.get_by_name::<Item>("Potion").unwrap().unwrap();
assert_eq!(by_name.value, 50);
assert_eq!(reopened.get_by_index::<Item>("category", "Consumable").unwrap(), Some(by_name.clone()));
assert_eq!(reopened.get_by_index::<Item>("sku", "P-1").unwrap(), Some(by_name));
}
@ -121,31 +120,32 @@ fn a_missing_name_or_value_is_none_and_an_unregistered_type_is_refused() {
assert_eq!(cache.get_by_name::<Item>("B").unwrap(), None);
assert_eq!(cache.get_by_name::<Item>("a").unwrap(), None, "names compare ordinally");
assert_eq!(cache.get_by_index::<Item>("category", "other").unwrap(), None);
assert_eq!(cache.get_by_index::<Item>("nosuchalias", "cat").unwrap(), None);
assert_eq!(cache.get_by_index::<Item>("sku", "other").unwrap(), None);
assert_eq!(cache.get_by_index::<Item>("nosuchalias", "a-1").unwrap(), None);
assert_eq!(cache.get_by_index::<Item>("category", "cat").unwrap(), None, "category is a grouping, not an index");
assert_eq!(cache.get_by_index::<Item>("code", "a-1").unwrap(), None, "the alias is sku, not the member name");
let bare = CultCache::new();
assert!(bare.get_by_name::<Item>("A").is_err());
assert!(bare.get_by_index::<Item>("category", "cat").is_err());
assert!(bare.get_by_index::<Item>("sku", "a-1").is_err());
assert!(bare.get_global::<Settings>().is_err());
}
#[test]
fn an_index_without_an_alias_is_named_by_its_member() {
let mut cache = cache();
cache.put("item:a", &item("A", "cat", None)).unwrap();
assert!(cache.get_by_index::<Item>("category", "cat").unwrap().is_some());
cache.put("numbered:1", &Numbered { id: 1, badge: 7 }).unwrap();
assert!(cache.get_by_index::<Numbered>("badge", "7").unwrap().is_some());
}
// A blank name is no name; a blank index value is not indexed; a nil member holds no value.
#[test]
fn blank_and_nil_values_are_not_found() {
let mut cache = cache();
cache.put("item:blank", &item("", " ", None)).unwrap();
cache.put("item:blank", &item("", " ", Some(" "))).unwrap();
assert_eq!(cache.get_by_name::<Item>("").unwrap(), None);
assert_eq!(cache.get_by_index::<Item>("category", " ").unwrap(), None);
assert_eq!(cache.get_by_index::<Item>("category", "").unwrap(), None);
assert_eq!(cache.get_by_index::<Item>("sku", " ").unwrap(), None);
assert_eq!(cache.get_by_index::<Item>("sku", "").unwrap(), None);
assert_eq!(cache.get_by_index::<Item>("sku", "").unwrap(), None);
// Only emptiness voids a name: whitespace is a name, as in C# (Length > 0).
@ -156,68 +156,41 @@ fn blank_and_nil_values_are_not_found() {
#[test]
fn lookups_never_cross_types() {
let mut cache = cache();
cache.put("item:a", &item("same", "same", None)).unwrap();
cache.put("gadget:a", &Gadget { name: "same".into(), category: "same".into() }).unwrap();
cache.put("item:a", &item("same", "same", Some("same"))).unwrap();
cache.put("gadget:a", &Gadget { name: "same".into(), serial: "same".into() }).unwrap();
cache.put("zed:a", &Zed { name: "same".into(), category: "same".into(), code: None, value: 0 }).unwrap();
assert_eq!(cache.get_by_name::<Gadget>("same").unwrap().unwrap().name, "same");
cache.delete::<Item>("item:a").unwrap();
assert_eq!(cache.get_by_name::<Item>("same").unwrap(), None);
assert_eq!(cache.get_by_index::<Item>("category", "same").unwrap(), None);
assert_eq!(cache.get_by_index::<Item>("sku", "same").unwrap(), None);
assert!(cache.get_by_name::<Gadget>("same").unwrap().is_some());
assert!(cache.get_by_index::<Gadget>("category", "same").unwrap().is_some());
assert!(cache.get_by_index::<Gadget>("serial", "same").unwrap().is_some());
}
#[test]
fn a_name_and_index_follow_a_rewritten_record_and_leave_a_removed_one() {
let mut cache = cache();
cache.put("item:a", &item("old", "c1", None)).unwrap();
cache.put("item:a", &item("new", "c2", None)).unwrap();
cache.put("item:a", &item("old", "cat", Some("c1"))).unwrap();
cache.put("item:a", &item("new", "cat", Some("c2"))).unwrap();
assert_eq!(cache.get_by_name::<Item>("old").unwrap(), None, "a stale name is not found");
assert_eq!(cache.get_by_index::<Item>("category", "c1").unwrap(), None, "a stale value is not found");
assert_eq!(cache.get_by_index::<Item>("sku", "c1").unwrap(), None, "a stale value is not found");
assert!(cache.get_by_name::<Item>("new").unwrap().is_some());
assert!(cache.get_by_index::<Item>("category", "c2").unwrap().is_some());
assert!(cache.get_by_index::<Item>("sku", "c2").unwrap().is_some());
cache.delete::<Item>("item:a").unwrap();
assert_eq!(cache.get_by_name::<Item>("new").unwrap(), None);
assert_eq!(cache.get_by_index::<Item>("category", "c2").unwrap(), None);
assert_eq!(cache.get_by_index::<Item>("sku", "c2").unwrap(), None);
}
#[test]
fn non_string_members_are_found_by_their_text() {
let mut cache = cache();
let owner = 161_u64;
cache.put("numbered:1", &Numbered { id: 42, owner }).unwrap();
let badge = 161_u64;
cache.put("numbered:1", &Numbered { id: 42, badge }).unwrap();
assert!(cache.get_by_name::<Numbered>("42").unwrap().is_some());
assert!(cache.get_by_index::<Numbered>("owner", "161").unwrap().is_some());
}
// VariantTests.RemovingTheHolderADuplicateValueFindsLeavesTheOtherFindable: records sharing a value find the one with
// the smallest key, whatever the write order, live and after a reopen; removing the holder finds the other.
#[test]
fn records_sharing_a_value_find_the_smallest_key_and_removing_it_finds_the_next() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("shared.cc");
{
let mut cache = stored_cache(&path);
cache.put("item:p2", &item("p2", "x", None)).unwrap();
cache.put("item:p1", &item("p1", "x", None)).unwrap();
let found = |cache: &CultCache| cache.get_by_index::<Item>("category", "x").unwrap().map(|found| found.name);
assert_eq!(found(&cache).as_deref(), Some("p1"), "the smallest key, though written second");
assert_eq!(reopened(&path).get_by_index::<Item>("category", "x").unwrap().unwrap().name, "p1");
cache.delete::<Item>("item:p1").unwrap();
assert_eq!(found(&cache).as_deref(), Some("p2"), "removing the holder finds the other one");
cache.put("item:p1", &item("p1", "x", None)).unwrap();
assert_eq!(found(&cache).as_deref(), Some("p1"));
cache.delete::<Item>("item:p2").unwrap();
assert_eq!(found(&cache).as_deref(), Some("p1"), "removing the other changes nothing");
cache.delete::<Item>("item:p1").unwrap();
assert_eq!(found(&cache), None);
}
assert_eq!(reopened(&path).get_by_index::<Item>("category", "x").unwrap(), None);
assert!(cache.get_by_index::<Numbered>("badge", "161").unwrap().is_some());
}
#[test]
@ -244,6 +217,71 @@ fn a_global_is_none_until_written_and_survives_a_reopen() {
assert_eq!(reopened(&path).get_global::<Settings>().unwrap(), Some(settings("durable authority")));
}
/// `Settings` as an ordinary type: the same type id and schema, no global rule.
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "settings", schema = "tests.settings")]
struct LooseSettings {
#[cultcache(key = 0)]
value: String,
}
// Declaring a held type global judges the records it holds: two are already a second, so the registration is
// refused and the type stays ordinary.
#[test]
fn registering_a_type_global_over_two_held_records_is_refused_and_changes_nothing() {
let mut cache = CultCache::new();
cache.register_entry_type::<LooseSettings>().unwrap();
cache.put("a", &LooseSettings { value: "one".into() }).unwrap();
cache.put("b", &LooseSettings { value: "two".into() }).unwrap();
let refusal = cache.register_entry_type::<Settings>().unwrap_err();
assert!(refusal.downcast_ref::<CultCacheSecondGlobal>().is_some(), "{refusal:#}");
cache.put("c", &LooseSettings { value: "three".into() }).unwrap();
assert_eq!(cache.get_all_with_keys::<LooseSettings>().unwrap().len(), 3, "still ordinary");
}
// One held record may become the global; a second is then refused as a second global.
#[test]
fn registering_a_type_global_over_one_held_record_is_admitted() {
let mut cache = CultCache::new();
cache.register_entry_type::<LooseSettings>().unwrap();
cache.put("a", &LooseSettings { value: "one".into() }).unwrap();
cache.register_entry_type::<Settings>().unwrap();
let refusal = cache.put("b", &Settings { value: "two".into() }).unwrap_err();
assert!(refusal.downcast_ref::<CultCacheSecondGlobal>().is_some(), "{refusal:#}");
}
/// A global with an indexed member, and the same type and schema declaring that member as an integer.
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "indexed_global", schema = "tests.indexed_global", global)]
struct IndexedGlobal {
#[cultcache(key = 0, index = "V")]
v: String,
}
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "indexed_global", schema = "tests.indexed_global", global)]
struct RetypedGlobal {
#[cultcache(key = 0, index = "V")]
v: i64,
}
// A refused re-registration over an already-global type leaves it global: the rule keeps refusing a second record.
#[test]
fn a_refused_reregistration_leaves_a_global_type_global() {
let mut cache = CultCache::new();
cache.register_entry_type::<IndexedGlobal>().unwrap();
cache.put("a", &IndexedGlobal { v: "x".into() }).unwrap();
assert!(cache.register_entry_type::<RetypedGlobal>().is_err(), "the held text cannot be decoded as an integer");
let refusal = cache.put("b", &IndexedGlobal { v: "y".into() }).unwrap_err();
assert!(refusal.downcast_ref::<CultCacheSecondGlobal>().is_some(), "{refusal:#}");
assert_eq!(cache.get_global::<IndexedGlobal>().unwrap(), Some(IndexedGlobal { v: "x".into() }));
assert_eq!(cache.get_by_index::<IndexedGlobal>("V", "x").unwrap(), cache.get::<IndexedGlobal>("a").unwrap());
}
#[test]
fn a_type_that_is_not_a_global_has_no_global() {
let mut cache = cache();

View file

@ -0,0 +1,669 @@
//! `#[cultcache(index)]` is unique (`variants:ruling:unique-indexes`; C# `RefuseIndexSharing`): within one type and one
//! alias no two records hold one non-blank value, judged on the result of each admission, on every write path and every
//! load. The refusal text is informational; the contract is the verdict, so most pins assert the verdict and the
//! shared vectors in `tests/vectors/unique-index` pin the text and the canonical index text.
use std::path::{Path, PathBuf};
use cultcache_rs::{
CultCache, CultCacheEnvelope, CultCacheSoaTable, DatabaseEntry, SingleFileMessagePackBackingStore,
};
use serde::Deserialize;
/// The vectors' `tests.unique_item`: Sku is unique, Category a grouping.
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "unique_item", schema = "tests.unique_item")]
struct Unique {
#[cultcache(key = 0, index = "Sku")]
sku: String,
#[cultcache(key = 1)]
category: String,
}
/// The same type with no index: a role, which writes the duplicates `Unique` refuses.
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "unique_item", schema = "tests.unique_item")]
struct Twin {
#[cultcache(key = 0)]
sku: String,
#[cultcache(key = 1)]
category: String,
}
/// Two unique indexes declared out of alphabetical order, so no ordering is an accident of declaration.
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "multi", schema = "Multi")]
struct Multi {
#[cultcache(key = 0, index = "B")]
b: Option<String>,
#[cultcache(key = 1, index = "A")]
a: Option<String>,
}
/// Another type sharing the alias `Sku`: types never conflict.
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "other", schema = "Other")]
struct Other {
#[cultcache(key = 0, index = "Sku")]
sku: String,
}
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "plain", schema = "Plain")]
struct Plain {
#[cultcache(key = 0)]
value: String,
}
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "vs", schema = "VS")]
struct VString {
#[cultcache(key = 0, index = "V")]
v: String,
}
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "vi", schema = "VI")]
struct VInt {
#[cultcache(key = 0, index = "V")]
v: i64,
}
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "vu", schema = "VU")]
struct VUint {
#[cultcache(key = 0, index = "V")]
v: u64,
}
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "vb", schema = "VB")]
struct VBool {
#[cultcache(key = 0, index = "V")]
v: bool,
}
fn unique(sku: &str) -> Unique {
Unique { sku: sku.into(), category: "c".into() }
}
fn multi(a: Option<&str>, b: Option<&str>) -> Multi {
Multi { a: a.map(Into::into), b: b.map(Into::into) }
}
fn refusal(k1: &str, k2: &str, schema: &str, alias: &str) -> String {
format!("Records {k1} and {k2} ({schema}) both hold unique index '{alias}'; an index value names one record.")
}
fn cache_at(path: &Path) -> CultCache {
let mut cache = CultCache::new();
cache.register_entry_type::<Unique>().unwrap();
cache.register_entry_type::<Multi>().unwrap();
cache.register_entry_type::<Other>().unwrap();
cache.register_entry_type::<Plain>().unwrap();
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(path)).unwrap();
cache.pull_all_backing_stores().unwrap();
cache
}
fn memory_cache() -> CultCache {
let mut cache = CultCache::new();
cache.register_entry_type::<Unique>().unwrap();
cache.register_entry_type::<Multi>().unwrap();
cache.register_entry_type::<Other>().unwrap();
cache
}
fn twin_cache_at(path: &Path) -> CultCache {
let mut cache = CultCache::new();
cache.register_entry_type::<Twin>().unwrap();
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(path)).unwrap();
cache.pull_all_backing_stores().unwrap();
cache
}
fn error_text(result: anyhow::Result<impl std::fmt::Debug>) -> String {
format!("{:#}", result.unwrap_err())
}
fn skus(cache: &CultCache) -> Vec<(String, String)> {
cache.get_all_with_keys::<Unique>().unwrap().into_iter().map(|(key, value)| (key, value.sku)).collect()
}
#[test]
fn the_derive_emits_the_unique_aliases() {
fn aliases<T: DatabaseEntry>() -> Vec<&'static str> {
T::UNIQUE_INDEXES.iter().map(|(alias, _)| *alias).collect()
}
assert_eq!(aliases::<Unique>(), ["Sku"]);
assert_eq!(aliases::<Multi>(), ["B", "A"]);
assert!(aliases::<Plain>().is_empty());
}
#[test]
fn a_put_of_a_held_value_at_another_key_is_refused_and_changes_nothing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("u.cc");
let mut cache = cache_at(&path);
cache.put("item:b", &unique("S-1")).unwrap();
let bytes = std::fs::read(&path).unwrap();
let text = error_text(cache.put("item:a", &unique("S-1")));
assert_eq!(text, refusal("item:a", "item:b", "tests.unique_item", "Sku"));
assert!(!text.contains("S-1"), "the refusal never names the value");
assert_eq!(std::fs::read(&path).unwrap(), bytes, "the store is untouched");
assert_eq!(cache.get::<Unique>("item:a").unwrap(), None);
assert_eq!(cache.get_by_index::<Unique>("Sku", "S-1").unwrap().unwrap().sku, "S-1");
assert_eq!(cache.get_by_index::<Unique>("Sku", "S-1").unwrap(), cache.get::<Unique>("item:b").unwrap());
}
// The old bug: write b then a sharing a value used to answer whichever key sorted first.
#[test]
fn the_first_holder_keeps_the_value_whatever_the_key_order() {
let mut cache = memory_cache();
cache.put("item:z", &unique("S")).unwrap();
assert!(cache.put("item:a", &unique("S")).is_err());
assert_eq!(cache.get_by_index::<Unique>("Sku", "S").unwrap().unwrap(), unique("S"));
assert_eq!(skus(&cache), vec![("item:z".to_string(), "S".to_string())]);
}
#[test]
fn a_record_rewriting_its_own_key_keeps_its_value_and_a_change_releases_the_old_one() {
let mut cache = memory_cache();
cache.put("k", &unique("S")).unwrap();
cache.put("k", &Unique { sku: "S".into(), category: "other".into() }).unwrap();
assert!(cache.get_by_index::<Unique>("Sku", "S").unwrap().is_some());
cache.put("k", &unique("T")).unwrap();
assert_eq!(cache.get_by_index::<Unique>("Sku", "S").unwrap(), None, "a stale value is not found");
cache.put("j", &unique("S")).unwrap();
assert_eq!(cache.get_by_index::<Unique>("Sku", "S").unwrap().unwrap().sku, "S");
assert_eq!(cache.get_by_index::<Unique>("Sku", "T").unwrap().unwrap().sku, "T");
// Updating through `update` is the same rule.
assert!(cache.update::<Unique, _>("k", |_| unique("S")).is_err());
assert_eq!(cache.get::<Unique>("k").unwrap().unwrap().sku, "T");
}
#[test]
fn delete_releases_a_value() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("d.cc");
let mut cache = cache_at(&path);
cache.put("item:a", &unique("S")).unwrap();
assert!(cache.put("item:b", &unique("S")).is_err());
assert!(cache.delete::<Unique>("item:a").unwrap());
assert_eq!(cache.get_by_index::<Unique>("Sku", "S").unwrap(), None);
cache.put("item:b", &unique("S")).unwrap();
assert!(cache.get_by_index::<Unique>("Sku", "S").unwrap().is_some());
assert!(!cache.delete::<Unique>("item:a").unwrap(), "deleting an absent key releases nothing");
assert!(cache.get_by_index::<Unique>("Sku", "S").unwrap().is_some());
assert!(cache_at(&path).get_by_index::<Unique>("Sku", "S").unwrap().is_some());
}
#[test]
fn types_sharing_an_alias_and_a_value_do_not_conflict() {
let mut cache = memory_cache();
cache.put("a", &unique("S")).unwrap();
cache.put("a", &Other { sku: "S".into() }).unwrap();
cache.put("b", &Other { sku: "T".into() }).unwrap();
assert!(cache.put("b", &Other { sku: "S".into() }).is_err());
assert_eq!(cache.get_by_index::<Unique>("Sku", "S").unwrap().unwrap(), unique("S"));
assert_eq!(cache.get_by_index::<Other>("Sku", "S").unwrap().unwrap(), Other { sku: "S".into() });
}
#[test]
fn an_alias_the_type_does_not_declare_finds_nothing() {
let mut cache = memory_cache();
cache.put("a", &unique("S")).unwrap();
assert_eq!(cache.get_by_index::<Unique>("sku", "S").unwrap(), None);
assert_eq!(cache.get_by_index::<Unique>("Category", "c").unwrap(), None);
}
#[test]
fn blank_and_none_values_never_conflict() {
let mut cache = memory_cache();
for (key, sku) in [("a", " "), ("b", ""), ("c", "\u{85}"), ("d", "\t\n\u{a0}\u{3000}")] {
cache.put(key, &unique(sku)).unwrap();
}
cache.put("m1", &multi(None, None)).unwrap();
cache.put("m2", &multi(None, Some(" "))).unwrap();
assert_eq!(cache.get_by_index::<Unique>("Sku", " ").unwrap(), None);
// U+FEFF, U+001F, U+200B, U+180E are values, never trimmed.
cache.put("e", &unique("\u{feff}")).unwrap();
assert!(cache.put("f", &unique("\u{feff}")).is_err());
cache.put("g", &unique(" S ")).unwrap();
cache.put("h", &unique("S")).unwrap();
assert_eq!(cache.get_by_index::<Unique>("Sku", " S ").unwrap().unwrap().sku, " S ");
assert_eq!(cache.get_by_index::<Unique>("Sku", "S").unwrap().unwrap().sku, "S");
}
// C# `string.IsNullOrWhiteSpace` over every code unit: exactly these 25 are blank (probed at 7760219b, dotnet 10).
#[test]
fn the_blank_set_is_exactly_the_csharp_white_space() {
let blank: Vec<u32> = (0x9..=0xD)
.chain([0x20, 0x85, 0xA0, 0x1680])
.chain(0x2000..=0x200A)
.chain([0x2028, 0x2029, 0x202F, 0x205F, 0x3000])
.collect();
assert_eq!(blank.len(), 25);
let mut cache = memory_cache();
let mut found = Vec::new();
for unit in (0..=0xFFFF_u32).filter(|unit| char::from_u32(*unit).is_some()) {
let text = char::from_u32(unit).unwrap().to_string();
cache.put("a", &unique(&text)).unwrap();
let second = cache.put("b", &unique(&text));
if second.is_ok() {
found.push(unit);
cache.delete::<Unique>("b").unwrap();
assert_eq!(cache.get_by_index::<Unique>("Sku", &text).unwrap(), None, "U+{unit:04X} blank is never found");
} else {
assert_eq!(cache.get_by_index::<Unique>("Sku", &text).unwrap().unwrap().sku, text);
}
}
assert_eq!(found, blank);
}
fn staged<T: DatabaseEntry>(cache: &CultCache, rows: &[(&str, T)]) -> Vec<CultCacheEnvelope> {
rows.iter().map(|(key, value)| cache.prepare_entry(*key, value).unwrap().0).collect()
}
// Two unique indexes, collision on the second-declared, through every write path.
#[test]
fn every_write_path_refuses_and_changes_nothing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("paths.cc");
let mut cache = cache_at(&path);
cache.put("m:held", &multi(Some("a1"), Some("b1"))).unwrap();
let bytes = std::fs::read(&path).unwrap();
let before = cache.snapshot();
let clash_b = multi(Some("a2"), Some("b1"));
let clash_a = multi(Some("a1"), Some("b2"));
for (clash, alias) in [(&clash_b, "B"), (&clash_a, "A")] {
let pick = |text: &str| {
let (first, second) = if "m:new" < "m:held" { ("m:new", "m:held") } else { ("m:held", "m:new") };
assert_eq!(text, refusal(first, second, "Multi", alias));
};
pick(&error_text(cache.put("m:new", clash)));
pick(&error_text(cache.update::<Multi, _>("m:new", |_| clash.clone())));
let envelope = staged(&cache, &[("m:new", clash.clone())]).remove(0);
pick(&error_text(cache.put_envelope::<Multi>(envelope.clone())));
pick(&error_text(cache.put_raw_envelope(envelope.clone())));
pick(&error_text(cache.put_prepared_batch(vec![envelope])));
assert_eq!(std::fs::read(&path).unwrap(), bytes);
assert_eq!(cache.snapshot(), before);
assert_eq!(cache.get_by_index::<Multi>("B", "b2").unwrap(), None);
}
// A clean row through every path lands and is found by both aliases.
let fine = multi(Some("a9"), Some("b9"));
cache.put("m:1", &fine).unwrap();
let envelope = staged(&cache, &[("m:2", multi(Some("a8"), Some("b8")))]).remove(0);
cache.put_envelope::<Multi>(envelope).unwrap();
let envelope = staged(&cache, &[("m:3", multi(Some("a7"), Some("b7")))]).remove(0);
cache.put_raw_envelope(envelope).unwrap();
cache.put_prepared_batch(staged(&cache, &[("m:4", multi(Some("a6"), Some("b6")))])).unwrap();
for (alias, value, key) in [("A", "a9", 1), ("B", "b8", 2), ("A", "a7", 3), ("B", "b6", 4)] {
let found = cache.get_by_index::<Multi>(alias, value).unwrap().unwrap();
assert_eq!(Some(found), cache.get::<Multi>(&format!("m:{key}")).unwrap());
}
// Held values survive a reopen of the store: the load installs the same holders.
let mut reopened = cache_at(&path);
assert!(reopened.put("m:x", &multi(None, Some("b9"))).is_err());
assert!(reopened.put("m:x", &multi(Some("a6"), None)).is_err());
assert!(reopened.put("m:x", &multi(Some("zz"), Some("yy"))).is_ok());
}
#[test]
fn a_batch_is_judged_whole() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("batch.cc");
let mut cache = cache_at(&path);
cache.put("a", &unique("X")).unwrap();
cache.put("b", &unique("Y")).unwrap();
// A swap inside one batch lands.
cache.put_prepared_batch(staged(&cache, &[("a", unique("Y")), ("b", unique("X"))])).unwrap();
assert_eq!(cache.get_by_index::<Unique>("Sku", "Y").unwrap(), cache.get::<Unique>("a").unwrap());
assert_eq!(cache.get_by_index::<Unique>("Sku", "X").unwrap(), cache.get::<Unique>("b").unwrap());
assert_eq!(cache_at(&path).get_by_index::<Unique>("Sku", "X").unwrap().unwrap().sku, "X");
// A batch taking a value a record being replaced just released lands; one holding a value twice is refused whole.
cache.put_prepared_batch(staged(&cache, &[("a", unique("Z")), ("c", unique("Y"))])).unwrap();
let bytes = std::fs::read(&path).unwrap();
let before = cache.snapshot();
let text = error_text(cache.put_prepared_batch(staged(&cache, &[("p", unique("N")), ("q", unique("N")), ("r", unique("W"))])));
assert_eq!(text, refusal("p", "q", "tests.unique_item", "Sku"));
assert_eq!(std::fs::read(&path).unwrap(), bytes);
assert_eq!(cache.snapshot(), before);
// A key staged twice lands as its last envelope.
cache.put_prepared_batch(staged(&cache, &[("d", unique("D1")), ("d", unique("D2")), ("e", unique("D1"))])).unwrap();
assert_eq!(cache.get_by_index::<Unique>("Sku", "D2").unwrap(), cache.get::<Unique>("d").unwrap());
assert_eq!(cache.get_by_index::<Unique>("Sku", "D1").unwrap(), cache.get::<Unique>("e").unwrap());
}
// The verdict and the named pair do not depend on key order, staging order, or alias declaration order.
#[test]
fn the_refusal_names_the_ordinal_pair_whatever_the_order() {
let orders: [[&str; 3]; 6] =
[["a", "b", "c"], ["a", "c", "b"], ["b", "a", "c"], ["b", "c", "a"], ["c", "a", "b"], ["c", "b", "a"]];
for order in orders {
let mut cache = memory_cache();
let rows: Vec<_> = order.iter().map(|key| (*key, unique("S"))).collect();
let text = error_text(cache.put_prepared_batch(staged(&cache, &rows)));
assert_eq!(text, refusal("a", "b", "tests.unique_item", "Sku"), "{order:?}");
assert!(skus(&cache).is_empty());
}
// Both aliases collide in one batch: the alias sorting first is named, though `B` is declared first.
for order in [["x", "y"], ["y", "x"]] {
let mut cache = memory_cache();
let rows: Vec<_> = order.iter().map(|key| (*key, multi(Some("s"), Some("t")))).collect();
let text = error_text(cache.put_prepared_batch(staged(&cache, &rows)));
assert_eq!(text, refusal("x", "y", "Multi", "A"));
}
// A held key and a landing key: the pair is ordinal, not held-first.
let mut cache = memory_cache();
cache.put("m", &unique("S")).unwrap();
assert_eq!(error_text(cache.put("z", &unique("S"))), refusal("m", "z", "tests.unique_item", "Sku"));
assert_eq!(error_text(cache.put("a", &unique("S"))), refusal("a", "m", "tests.unique_item", "Sku"));
// A value that differs only by case or by a surrounding space is another value.
cache.put("n", &unique("s")).unwrap();
cache.put("o", &unique("S ")).unwrap();
}
#[test]
fn a_load_of_a_duplicate_image_is_refused_and_keeps_the_prior_view() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("load.cc");
let mut cache = cache_at(&path);
cache.put("item:keep", &unique("K")).unwrap();
let mut twin = twin_cache_at(&path);
twin.put("item:b", &Twin { sku: "S".into(), category: "c".into() }).unwrap();
twin.put("item:a", &Twin { sku: "S".into(), category: "c".into() }).unwrap();
let bytes = std::fs::read(&path).unwrap();
let before = cache.snapshot();
assert_eq!(error_text(cache.pull_all_backing_stores()), refusal("item:a", "item:b", "tests.unique_item", "Sku"));
assert_eq!(cache.snapshot(), before, "a refused pull admits nothing");
assert_eq!(cache.get_by_index::<Unique>("Sku", "K").unwrap().unwrap().sku, "K");
assert_eq!(std::fs::read(&path).unwrap(), bytes);
// load_soa of the same image through a cache with no store.
let table: CultCacheSoaTable = twin.snapshot_soa();
let mut soa = memory_cache();
soa.put("item:keep", &unique("K")).unwrap();
let before = soa.snapshot();
assert_eq!(error_text(soa.load_soa(table)), refusal("item:a", "item:b", "tests.unique_item", "Sku"));
assert_eq!(soa.snapshot(), before);
assert!(soa.get_by_index::<Unique>("Sku", "K").unwrap().is_some());
// A clean image loads and replaces the holders with the image's.
let mut clean = twin_cache_at(&dir.path().join("clean.cc"));
clean.put("item:x", &Twin { sku: "X".into(), category: "c".into() }).unwrap();
soa.load_soa(clean.snapshot_soa()).unwrap();
assert_eq!(soa.get_by_index::<Unique>("Sku", "K").unwrap(), None);
assert!(soa.get_by_index::<Unique>("Sku", "X").unwrap().is_some());
soa.put("item:y", &unique("K")).unwrap();
}
#[test]
fn a_multi_index_store_with_a_duplicate_on_the_second_alias_refuses_to_open() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("multi.cc");
{
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "multi", schema = "Multi")]
struct Role {
#[cultcache(key = 0)]
b: Option<String>,
#[cultcache(key = 1)]
a: Option<String>,
}
let mut cache = CultCache::new();
cache.register_entry_type::<Role>().unwrap();
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap();
cache.pull_all_backing_stores().unwrap();
cache.put("m:1", &Role { b: Some("b".into()), a: Some("a1".into()) }).unwrap();
cache.put("m:2", &Role { b: Some("b".into()), a: Some("a2".into()) }).unwrap();
}
let mut cache = CultCache::new();
cache.register_entry_type::<Multi>().unwrap();
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap();
assert_eq!(error_text(cache.pull_all_backing_stores()), refusal("m:1", "m:2", "Multi", "B"));
}
#[test]
fn a_bool_index_is_found_by_the_csharp_text() {
let mut cache = CultCache::new();
cache.register_entry_type::<VBool>().unwrap();
cache.put("t", &VBool { v: true }).unwrap();
assert!(cache.put("u", &VBool { v: true }).is_err());
cache.put("f", &VBool { v: false }).unwrap();
assert_eq!(cache.get_by_index::<VBool>("V", "True").unwrap().unwrap().v, true);
assert_eq!(cache.get_by_index::<VBool>("V", "False").unwrap().unwrap().v, false);
assert_eq!(cache.get_by_index::<VBool>("V", "true").unwrap(), None);
assert_eq!(cache.get_by_index::<VBool>("V", "false").unwrap(), None);
}
// ---- shared vectors, written by the C# reference ----
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum Cell {
Nil,
Bool(bool),
Int(i64),
Uint(u64),
Double(f64),
Text(String),
}
fn vectors() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../tests/vectors/unique-index")
}
type ValueRow = (String, String, Cell, Option<String>);
#[test]
fn vectors_values_give_the_csharp_index_text() {
let bytes = std::fs::read(vectors().join("values.msgpack")).unwrap();
let rows: Vec<ValueRow> = rmp_serde::from_slice(&bytes).unwrap();
assert!(!rows.is_empty());
let mut checked = 0;
for (label, kind, value, text) in rows {
let mut cache = CultCache::new();
cache.register_entry_type::<VString>().unwrap();
cache.register_entry_type::<VInt>().unwrap();
cache.register_entry_type::<VUint>().unwrap();
cache.register_entry_type::<VBool>().unwrap();
// `double` rows are skipped: CultLookupValue has no float impl, so Rust cannot index a double member.
match (kind.as_str(), value) {
("string", Cell::Text(held)) => {
cache.put("one", &VString { v: held.clone() }).unwrap();
match text {
Some(text) => {
assert_eq!(cache.get_by_index::<VString>("V", &text).unwrap().unwrap().v, held, "{label}");
assert!(cache.put("two", &VString { v: held }).is_err(), "{label}");
}
None => {
cache.put("two", &VString { v: held.clone() }).unwrap();
assert_eq!(cache.get_by_index::<VString>("V", &held).unwrap(), None, "{label}");
}
}
}
("int64", Cell::Int(held)) => {
cache.put("one", &VInt { v: held }).unwrap();
assert_eq!(cache.get_by_index::<VInt>("V", &text.unwrap()).unwrap().unwrap().v, held, "{label}");
assert!(cache.put("two", &VInt { v: held }).is_err(), "{label}");
}
("uint64", Cell::Uint(held)) => {
cache.put("one", &VUint { v: held }).unwrap();
assert_eq!(cache.get_by_index::<VUint>("V", &text.unwrap()).unwrap().unwrap().v, held, "{label}");
assert!(cache.put("two", &VUint { v: held }).is_err(), "{label}");
}
("uint64", Cell::Int(held)) => {
cache.put("one", &VUint { v: held as u64 }).unwrap();
assert_eq!(cache.get_by_index::<VUint>("V", &text.unwrap()).unwrap().unwrap().v, held as u64, "{label}");
}
("int64", Cell::Uint(held)) => {
cache.put("one", &VInt { v: held as i64 }).unwrap();
assert_eq!(cache.get_by_index::<VInt>("V", &text.unwrap()).unwrap().unwrap().v, held as i64, "{label}");
}
("bool", Cell::Bool(held)) => {
cache.put("one", &VBool { v: held }).unwrap();
assert_eq!(cache.get_by_index::<VBool>("V", &text.unwrap()).unwrap().unwrap().v, held, "{label}");
}
("double", Cell::Double(_) | Cell::Int(_) | Cell::Uint(_) | Cell::Nil) => continue,
(kind, value) => panic!("{label}: unexpected vector row {kind} {value:?}"),
}
checked += 1;
}
assert!(checked > 10, "the vectors were read, not skipped: {checked}");
}
type OutcomeRow = (String, Option<String>, String, Option<String>, Option<String>);
#[test]
fn vectors_stores_hold_their_verdicts() {
let bytes = std::fs::read(vectors().join("outcomes.msgpack")).unwrap();
let rows: Vec<OutcomeRow> = rmp_serde::from_slice(&bytes).unwrap();
assert!(rows.len() >= 3);
let dir = tempfile::tempdir().unwrap();
for (file, alias, verdict, k1, k2) in rows {
let path = dir.path().join(&file);
std::fs::copy(vectors().join(&file), &path).unwrap();
let before = std::fs::read(&path).unwrap();
let attach = |cache: &mut CultCache| {
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap();
cache.pull_all_backing_stores()
};
let mut cache = CultCache::new();
let opened = match alias.as_deref() {
Some("Sku") => {
cache.register_entry_type::<Unique>().unwrap();
attach(&mut cache)
}
None => {
cache.register_entry_type::<Twin>().unwrap();
attach(&mut cache)
}
Some(other) => panic!("unexpected vector alias {other}"),
};
match verdict.as_str() {
"open" => opened.unwrap_or_else(|error| panic!("{file} / {alias:?} should open: {error:#}")),
"refused" => assert_eq!(
error_text(opened),
refusal(&k1.unwrap(), &k2.unwrap(), "tests.unique_item", "Sku"),
"{file}"
),
other => panic!("unexpected verdict {other}"),
}
assert_eq!(std::fs::read(&path).unwrap(), before, "{file} is byte-identical");
}
}
// A pull replaces the holder table with the stored image's: a value another writer changed is released, not kept.
#[test]
fn a_second_pull_replaces_the_holders_with_the_stored_image() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("pull.cc");
let mut cache = cache_at(&path);
cache.put("a", &unique("S")).unwrap();
cache_at(&path).put("a", &unique("T")).unwrap();
cache.pull_all_backing_stores().unwrap();
assert_eq!(cache.get_by_index::<Unique>("Sku", "S").unwrap(), None, "the replaced value is no longer held");
assert_eq!(cache.get_by_index::<Unique>("Sku", "T").unwrap(), cache.get::<Unique>("a").unwrap());
cache.put("b", &unique("S")).unwrap();
assert!(cache.put("c", &unique("T")).is_err());
}
// The projector decides what held records hold: declaring an index for a type that already has records derives
// their holders, or refuses the registration when they collide.
#[test]
fn registering_an_index_over_held_records_derives_their_holders() {
let mut cache = CultCache::new();
cache.register_entry_type::<Twin>().unwrap();
cache.put("a", &Twin { sku: "S".into(), category: "x".into() }).unwrap();
cache.put("b", &Twin { sku: "T".into(), category: "x".into() }).unwrap();
cache.register_entry_type::<Unique>().unwrap();
assert_eq!(cache.get_by_index::<Unique>("Sku", "S").unwrap(), cache.get::<Unique>("a").unwrap());
assert!(cache.get_by_index::<Unique>("Sku", "S").unwrap().is_some());
assert!(cache.put("c", &unique("S")).is_err(), "a held value is refused");
cache.put("c", &unique("U")).unwrap();
}
#[test]
fn registering_an_index_over_colliding_held_records_is_refused_and_changes_nothing() {
let mut cache = CultCache::new();
cache.register_entry_type::<Twin>().unwrap();
cache.put("a", &Twin { sku: "S".into(), category: "x".into() }).unwrap();
cache.put("b", &Twin { sku: "S".into(), category: "y".into() }).unwrap();
let text = error_text(cache.register_entry_type::<Unique>());
assert_eq!(text, refusal("a", "b", "tests.unique_item", "Sku"));
// Still the unindexed declaration: duplicates keep landing, nothing is looked up.
cache.put("c", &Twin { sku: "S".into(), category: "z".into() }).unwrap();
assert_eq!(cache.get_all_with_keys::<Twin>().unwrap().len(), 3);
assert_eq!(cache.get_by_index::<Unique>("Sku", "S").unwrap(), None);
}
/// The same type and schema declaring Sku as an integer: the held text cannot be decoded as one.
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "unique_item", schema = "tests.unique_item")]
struct Retyped {
#[cultcache(key = 0, index = "Sku")]
sku: i64,
}
/// A refused re-registration leaves the previous declaration in force: it keeps judging, so a later duplicate is refused.
#[test]
fn a_refused_reregistration_keeps_the_previous_index_judging() {
let mut cache = CultCache::new();
cache.register_entry_type::<Unique>().unwrap();
cache.put("a", &unique("S")).unwrap();
cache.put("b", &unique("T")).unwrap();
// A different declaration over the same type (Category is the unique alias) collides on held records.
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "unique_item", schema = "tests.unique_item")]
struct ByCat {
#[cultcache(key = 0)]
sku: String,
#[cultcache(key = 1, index = "Cat")]
category: String,
}
assert!(cache.register_entry_type::<ByCat>().is_err(), "a and b both hold category c");
assert!(cache.put("z", &unique("S")).is_err(), "the previous index still judges");
assert_eq!(cache.get_by_index::<Unique>("Sku", "S").unwrap(), cache.get::<Unique>("a").unwrap());
cache.put("z", &unique("U")).unwrap();
}
// A held record the new declaration cannot decode refuses the registration; the unindexed declaration stays.
#[test]
fn registering_an_index_over_an_undecodable_held_record_is_refused_and_changes_nothing() {
let mut cache = CultCache::new();
cache.register_entry_type::<Twin>().unwrap();
cache.put("a", &Twin { sku: "S".into(), category: "x".into() }).unwrap();
assert!(cache.register_entry_type::<Retyped>().is_err());
cache.put("b", &Twin { sku: "S".into(), category: "y".into() }).unwrap();
assert_eq!(cache.get_all_with_keys::<Twin>().unwrap().len(), 2, "no index judges the duplicate");
}
// Dropping the declaration releases the holders.
#[test]
fn registering_without_the_index_releases_the_holders() {
let mut cache = CultCache::new();
cache.register_entry_type::<Unique>().unwrap();
cache.put("a", &unique("S")).unwrap();
cache.register_entry_type::<Twin>().unwrap();
assert_eq!(cache.get_by_index::<Unique>("Sku", "S").unwrap(), None, "no declaration, no holders");
cache.put("b", &Twin { sku: "S".into(), category: "x".into() }).unwrap();
assert!(cache.register_entry_type::<Unique>().is_err(), "the duplicate now written is a held violation");
}