diff --git a/packages/cultcache-rs/CHANGELOG.md b/packages/cultcache-rs/CHANGELOG.md index c417d535..f136bccf 100644 --- a/packages/cultcache-rs/CHANGELOG.md +++ b/packages/cultcache-rs/CHANGELOG.md @@ -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::()`. ## [0.3.1] diff --git a/packages/cultcache-rs/README.md b/packages/cultcache-rs/README.md index 2c5c8027..1dab4fc7 100644 --- a/packages/cultcache-rs/README.md +++ b/packages/cultcache-rs/README.md @@ -412,7 +412,9 @@ fn outcome(error: &anyhow::Error) { 3. **Name and index lookups** - `cache.get_by_name::("Potion")` - - `cache.get_by_index::("faction", "Lucent")` + - `cache.get_by_index::("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** diff --git a/packages/cultcache-rs/cultcache-rs-derive/src/lib.rs b/packages/cultcache-rs/cultcache-rs-derive/src/lib.rs index fe4e57c6..c7f9a3a2 100644 --- a/packages/cultcache-rs/cultcache-rs-derive/src/lib.rs +++ b/packages/cultcache-rs/cultcache-rs-derive/src/lib.rs @@ -69,23 +69,16 @@ fn database_entry_codec(input: DeriveInput) -> syn::Result = 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] = &[#(#index_rows),*]; } }); Ok(quote! { @@ -93,8 +86,8 @@ fn database_entry_codec(input: DeriveInput) -> syn::Result, } @@ -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] diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index ce7756e3..11d21330 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -168,12 +168,14 @@ pub trait DatabaseEntry: Serialize + DeserializeOwned + Clone + Send + 'static { fn cult_name(&self) -> Option { 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] = &[]; } +/// An index alias and how to read its value from a record; `None` is no value. +pub type CultIndexMember = (&'static str, fn(&T) -> Option); + /// 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 CultLookupValue for Option { } } +/// C# renders a boolean `True` or `False`; the index text is shared across runtimes. +impl CultLookupValue for bool { + fn lookup_value(&self) -> Option { + 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, schema_name_definitions: BTreeMap, 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, stores: Vec, } +type IndexProjector = fn(&[u8]) -> Result>; +type IndexSlot = (String, String, String); +type IndexHolders = BTreeMap; + +fn project_indexes(payload: &[u8]) -> Result> { + let document = decode_payload::(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::); + } 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::(|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(&self, alias: &str, value: &str) -> Result> { self.require_entry_type::()?; - if value.trim().is_empty() { + if value.trim().is_empty() || !T::UNIQUE_INDEXES.iter().any(|(declared, _)| *declared == alias) { return Ok(None); } - self.find_record::(|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::(key), + None => Ok(None), + } } /// The first record of `T`, in key order, that `matches`. @@ -3029,10 +3105,12 @@ impl CultCache { pub fn put(&mut self, key: impl Into, value: &T) -> Result { 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::>())?; 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(&mut self, entry: CultCacheEnvelope) -> Result { let parsed = self.validate_envelope::(&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> { + 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 { + 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 { + 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 { + 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)> = Vec::new(); + let mut at: BTreeMap = 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>, diff --git a/packages/cultcache-rs/tests/lookups.rs b/packages/cultcache-rs/tests/lookups.rs index ca5b459d..29aa3489 100644 --- a/packages/cultcache-rs/tests/lookups.rs +++ b/packages/cultcache-rs/tests/lookups.rs @@ -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, @@ -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, @@ -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::("Potion").unwrap().unwrap(); assert_eq!(by_name.value, 50); - assert_eq!(reopened.get_by_index::("category", "Consumable").unwrap(), Some(by_name.clone())); assert_eq!(reopened.get_by_index::("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::("B").unwrap(), None); assert_eq!(cache.get_by_name::("a").unwrap(), None, "names compare ordinally"); - assert_eq!(cache.get_by_index::("category", "other").unwrap(), None); - assert_eq!(cache.get_by_index::("nosuchalias", "cat").unwrap(), None); + assert_eq!(cache.get_by_index::("sku", "other").unwrap(), None); + assert_eq!(cache.get_by_index::("nosuchalias", "a-1").unwrap(), None); + assert_eq!(cache.get_by_index::("category", "cat").unwrap(), None, "category is a grouping, not an index"); assert_eq!(cache.get_by_index::("code", "a-1").unwrap(), None, "the alias is sku, not the member name"); let bare = CultCache::new(); assert!(bare.get_by_name::("A").is_err()); - assert!(bare.get_by_index::("category", "cat").is_err()); + assert!(bare.get_by_index::("sku", "a-1").is_err()); assert!(bare.get_global::().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::("category", "cat").unwrap().is_some()); + cache.put("numbered:1", &Numbered { id: 1, badge: 7 }).unwrap(); + assert!(cache.get_by_index::("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::("").unwrap(), None); - assert_eq!(cache.get_by_index::("category", " ").unwrap(), None); - assert_eq!(cache.get_by_index::("category", "").unwrap(), None); + assert_eq!(cache.get_by_index::("sku", " ").unwrap(), None); + assert_eq!(cache.get_by_index::("sku", "").unwrap(), None); assert_eq!(cache.get_by_index::("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::("same").unwrap().unwrap().name, "same"); cache.delete::("item:a").unwrap(); assert_eq!(cache.get_by_name::("same").unwrap(), None); - assert_eq!(cache.get_by_index::("category", "same").unwrap(), None); + assert_eq!(cache.get_by_index::("sku", "same").unwrap(), None); assert!(cache.get_by_name::("same").unwrap().is_some()); - assert!(cache.get_by_index::("category", "same").unwrap().is_some()); + assert!(cache.get_by_index::("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::("old").unwrap(), None, "a stale name is not found"); - assert_eq!(cache.get_by_index::("category", "c1").unwrap(), None, "a stale value is not found"); + assert_eq!(cache.get_by_index::("sku", "c1").unwrap(), None, "a stale value is not found"); assert!(cache.get_by_name::("new").unwrap().is_some()); - assert!(cache.get_by_index::("category", "c2").unwrap().is_some()); + assert!(cache.get_by_index::("sku", "c2").unwrap().is_some()); cache.delete::("item:a").unwrap(); assert_eq!(cache.get_by_name::("new").unwrap(), None); - assert_eq!(cache.get_by_index::("category", "c2").unwrap(), None); + assert_eq!(cache.get_by_index::("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::("42").unwrap().is_some()); - assert!(cache.get_by_index::("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::("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::("category", "x").unwrap().unwrap().name, "p1"); - - cache.delete::("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:p2").unwrap(); - assert_eq!(found(&cache).as_deref(), Some("p1"), "removing the other changes nothing"); - cache.delete::("item:p1").unwrap(); - assert_eq!(found(&cache), None); - } - assert_eq!(reopened(&path).get_by_index::("category", "x").unwrap(), None); + assert!(cache.get_by_index::("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::().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::().unwrap(); + cache.put("a", &LooseSettings { value: "one".into() }).unwrap(); + cache.put("b", &LooseSettings { value: "two".into() }).unwrap(); + + let refusal = cache.register_entry_type::().unwrap_err(); + assert!(refusal.downcast_ref::().is_some(), "{refusal:#}"); + + cache.put("c", &LooseSettings { value: "three".into() }).unwrap(); + assert_eq!(cache.get_all_with_keys::().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::().unwrap(); + cache.put("a", &LooseSettings { value: "one".into() }).unwrap(); + cache.register_entry_type::().unwrap(); + let refusal = cache.put("b", &Settings { value: "two".into() }).unwrap_err(); + assert!(refusal.downcast_ref::().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::().unwrap(); + cache.put("a", &IndexedGlobal { v: "x".into() }).unwrap(); + + assert!(cache.register_entry_type::().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::().is_some(), "{refusal:#}"); + assert_eq!(cache.get_global::().unwrap(), Some(IndexedGlobal { v: "x".into() })); + assert_eq!(cache.get_by_index::("V", "x").unwrap(), cache.get::("a").unwrap()); +} + #[test] fn a_type_that_is_not_a_global_has_no_global() { let mut cache = cache(); diff --git a/packages/cultcache-rs/tests/unique_index.rs b/packages/cultcache-rs/tests/unique_index.rs new file mode 100644 index 00000000..d0da4175 --- /dev/null +++ b/packages/cultcache-rs/tests/unique_index.rs @@ -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, + #[cultcache(key = 1, index = "A")] + a: Option, +} + +/// 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::().unwrap(); + cache.register_entry_type::().unwrap(); + cache.register_entry_type::().unwrap(); + cache.register_entry_type::().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::().unwrap(); + cache.register_entry_type::().unwrap(); + cache.register_entry_type::().unwrap(); + cache +} + +fn twin_cache_at(path: &Path) -> CultCache { + let mut cache = CultCache::new(); + cache.register_entry_type::().unwrap(); + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(path)).unwrap(); + cache.pull_all_backing_stores().unwrap(); + cache +} + +fn error_text(result: anyhow::Result) -> String { + format!("{:#}", result.unwrap_err()) +} + +fn skus(cache: &CultCache) -> Vec<(String, String)> { + cache.get_all_with_keys::().unwrap().into_iter().map(|(key, value)| (key, value.sku)).collect() +} + +#[test] +fn the_derive_emits_the_unique_aliases() { + fn aliases() -> Vec<&'static str> { + T::UNIQUE_INDEXES.iter().map(|(alias, _)| *alias).collect() + } + assert_eq!(aliases::(), ["Sku"]); + assert_eq!(aliases::(), ["B", "A"]); + assert!(aliases::().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::("item:a").unwrap(), None); + assert_eq!(cache.get_by_index::("Sku", "S-1").unwrap().unwrap().sku, "S-1"); + assert_eq!(cache.get_by_index::("Sku", "S-1").unwrap(), cache.get::("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::("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::("Sku", "S").unwrap().is_some()); + + cache.put("k", &unique("T")).unwrap(); + assert_eq!(cache.get_by_index::("Sku", "S").unwrap(), None, "a stale value is not found"); + cache.put("j", &unique("S")).unwrap(); + assert_eq!(cache.get_by_index::("Sku", "S").unwrap().unwrap().sku, "S"); + assert_eq!(cache.get_by_index::("Sku", "T").unwrap().unwrap().sku, "T"); + // Updating through `update` is the same rule. + assert!(cache.update::("k", |_| unique("S")).is_err()); + assert_eq!(cache.get::("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::("item:a").unwrap()); + assert_eq!(cache.get_by_index::("Sku", "S").unwrap(), None); + cache.put("item:b", &unique("S")).unwrap(); + assert!(cache.get_by_index::("Sku", "S").unwrap().is_some()); + assert!(!cache.delete::("item:a").unwrap(), "deleting an absent key releases nothing"); + assert!(cache.get_by_index::("Sku", "S").unwrap().is_some()); + assert!(cache_at(&path).get_by_index::("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::("Sku", "S").unwrap().unwrap(), unique("S")); + assert_eq!(cache.get_by_index::("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::("sku", "S").unwrap(), None); + assert_eq!(cache.get_by_index::("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::("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::("Sku", " S ").unwrap().unwrap().sku, " S "); + assert_eq!(cache.get_by_index::("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 = (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::("b").unwrap(); + assert_eq!(cache.get_by_index::("Sku", &text).unwrap(), None, "U+{unit:04X} blank is never found"); + } else { + assert_eq!(cache.get_by_index::("Sku", &text).unwrap().unwrap().sku, text); + } + } + assert_eq!(found, blank); +} + +fn staged(cache: &CultCache, rows: &[(&str, T)]) -> Vec { + 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::("m:new", |_| clash.clone()))); + let envelope = staged(&cache, &[("m:new", clash.clone())]).remove(0); + pick(&error_text(cache.put_envelope::(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::("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::(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::(alias, value).unwrap().unwrap(); + assert_eq!(Some(found), cache.get::(&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::("Sku", "Y").unwrap(), cache.get::("a").unwrap()); + assert_eq!(cache.get_by_index::("Sku", "X").unwrap(), cache.get::("b").unwrap()); + assert_eq!(cache_at(&path).get_by_index::("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::("Sku", "D2").unwrap(), cache.get::("d").unwrap()); + assert_eq!(cache.get_by_index::("Sku", "D1").unwrap(), cache.get::("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::("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::("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::("Sku", "K").unwrap(), None); + assert!(soa.get_by_index::("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, + #[cultcache(key = 1)] + a: Option, + } + let mut cache = CultCache::new(); + cache.register_entry_type::().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::().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::().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::("V", "True").unwrap().unwrap().v, true); + assert_eq!(cache.get_by_index::("V", "False").unwrap().unwrap().v, false); + assert_eq!(cache.get_by_index::("V", "true").unwrap(), None); + assert_eq!(cache.get_by_index::("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); + +#[test] +fn vectors_values_give_the_csharp_index_text() { + let bytes = std::fs::read(vectors().join("values.msgpack")).unwrap(); + let rows: Vec = 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::().unwrap(); + cache.register_entry_type::().unwrap(); + cache.register_entry_type::().unwrap(); + cache.register_entry_type::().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::("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::("V", &held).unwrap(), None, "{label}"); + } + } + } + ("int64", Cell::Int(held)) => { + cache.put("one", &VInt { v: held }).unwrap(); + assert_eq!(cache.get_by_index::("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::("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::("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::("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::("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, Option, Option); + +#[test] +fn vectors_stores_hold_their_verdicts() { + let bytes = std::fs::read(vectors().join("outcomes.msgpack")).unwrap(); + let rows: Vec = 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::().unwrap(); + attach(&mut cache) + } + None => { + cache.register_entry_type::().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::("Sku", "S").unwrap(), None, "the replaced value is no longer held"); + assert_eq!(cache.get_by_index::("Sku", "T").unwrap(), cache.get::("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::().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::().unwrap(); + + assert_eq!(cache.get_by_index::("Sku", "S").unwrap(), cache.get::("a").unwrap()); + assert!(cache.get_by_index::("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::().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::()); + 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::().unwrap().len(), 3); + assert_eq!(cache.get_by_index::("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::().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::().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::("Sku", "S").unwrap(), cache.get::("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::().unwrap(); + cache.put("a", &Twin { sku: "S".into(), category: "x".into() }).unwrap(); + + assert!(cache.register_entry_type::().is_err()); + + cache.put("b", &Twin { sku: "S".into(), category: "y".into() }).unwrap(); + assert_eq!(cache.get_all_with_keys::().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::().unwrap(); + cache.put("a", &unique("S")).unwrap(); + cache.register_entry_type::().unwrap(); + assert_eq!(cache.get_by_index::("Sku", "S").unwrap(), None, "no declaration, no holders"); + cache.put("b", &Twin { sku: "S".into(), category: "x".into() }).unwrap(); + assert!(cache.register_entry_type::().is_err(), "the duplicate now written is a held violation"); +}