From 17caa0c87409482f90486585643220fb6b74eb26 Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 12:16:57 +0000 Subject: [PATCH] cultcache-rs: judge_against names the least violation by UTF-16 order; walk refusals.msgpack (#102) --- packages/cultcache-rs/src/lib.rs | 46 ++++-- packages/cultcache-rs/tests/unique_index.rs | 153 ++++++++++++++++---- 2 files changed, 159 insertions(+), 40 deletions(-) diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index 11d21330..e5e88b9c 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -2738,6 +2738,16 @@ pub struct CultCache { } type IndexProjector = fn(&[u8]) -> Result>; +/// Orders by UTF-16 code unit (C# `StringComparer.Ordinal`), not by UTF-8 bytes or code point. +fn utf16_cmp(left: &str, right: &str) -> std::cmp::Ordering { + left.encode_utf16().cmp(right.encode_utf16()) +} + +/// Orders violations (alias, least key, second key, schema) field by field, each by UTF-16. +fn violation_cmp(left: &[&str; 4], right: &[&str; 4]) -> std::cmp::Ordering { + left.iter().zip(right).map(|(a, b)| utf16_cmp(a, b)).find(|order| order.is_ne()).unwrap_or(std::cmp::Ordering::Equal) +} + type IndexSlot = (String, String, String); type IndexHolders = BTreeMap; @@ -3424,22 +3434,36 @@ impl CultCache { 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) + let mut least: Option<[&str; 4]> = None; + for (slot, holders) in &mut 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 mut ordered: Vec<(&IndexSlot, Vec<&String>)> = Vec::new(); + for (slot, holders) in &groups { + let mut keys: Vec<&String> = holders.iter().collect(); + keys.sort_by(|left, right| utf16_cmp(left, right)); + ordered.push((slot, keys)); + } + for (slot, keys) in &ordered { + if let [first, second, ..] = keys.as_slice() { + let schema = self.definitions.get(&slot.0).copied().unwrap_or(&slot.0); + let violation = [slot.1.as_str(), first.as_str(), second.as_str(), schema]; + if least.is_none_or(|best| violation_cmp(&violation, &best).is_lt()) { + least = Some(violation); + } } - let key = holders.into_iter().next().expect("a group holds at least one key"); + } + if let Some([alias, first, second, schema]) = least { + return Err(anyhow!( + "Records {first} and {second} ({schema}) both hold unique index '{alias}'; an index value names one record." + )); + } + for (slot, mut keys) in ordered.into_iter().map(|(slot, keys)| (slot.clone(), keys)) { + let key = keys.remove(0).clone(); change.taken.push((slot, key)); } Ok(change) diff --git a/packages/cultcache-rs/tests/unique_index.rs b/packages/cultcache-rs/tests/unique_index.rs index d0da4175..1fc3c4eb 100644 --- a/packages/cultcache-rs/tests/unique_index.rs +++ b/packages/cultcache-rs/tests/unique_index.rs @@ -80,6 +80,41 @@ struct VBool { v: bool, } +/// The refusals vector's other schemas: `Sku` is unique in each; `Alt` is a second unique index in `uq_pair` (alias +/// `Alt`) and `uq_alias` (alias `alt`, sorting after `Sku` only under an ordinal compare); alpha and beta are plain. +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "uq_pair", schema = "tests.uq_pair")] +struct UqPair { + #[cultcache(key = 0, index = "Sku")] + sku: String, + #[cultcache(key = 1, index = "Alt")] + alt: String, +} +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "uq_alias", schema = "tests.uq_alias")] +struct UqAlias { + #[cultcache(key = 0, index = "Sku")] + sku: String, + #[cultcache(key = 1, index = "alt")] + alt: String, +} +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "uq_alpha", schema = "tests.uq_alpha")] +struct UqAlpha { + #[cultcache(key = 0, index = "Sku")] + sku: String, + #[cultcache(key = 1)] + second: String, +} +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "uq_beta", schema = "tests.uq_beta")] +struct UqBeta { + #[cultcache(key = 0, index = "Sku")] + sku: String, + #[cultcache(key = 1)] + second: String, +} + fn unique(sku: &str) -> Unique { Unique { sku: sku.into(), category: "c".into() } } @@ -339,35 +374,6 @@ fn a_batch_is_judged_whole() { 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(); @@ -561,6 +567,95 @@ fn vectors_stores_hold_their_verdicts() { } } +/// One op of a refusals row: `["open", file-or-nil]`, `["pull", file]` or `["commit", [[key, schema, sku, second], ...]]`. +#[derive(Deserialize)] +#[serde(untagged)] +enum Arg { + Records(Vec<(String, String, String, String)>), + File(Option), +} + +type RefusalRow = (String, Vec<(String, Arg)>, Option<(String, String, String, String)>); + +/// Rows `[label, ops, refusal]`: the pair a refusal names is a function of the state the admission would leave +/// (`tests/vectors/unique-index/README.md`, RULE), whatever the staging, file or pull order, key case or alias case. +#[test] +fn vectors_refusals_hold_the_rule() { + let bytes = std::fs::read(vectors().join("refusals.msgpack")).unwrap(); + let rows: Vec = rmp_serde::from_slice(&bytes).unwrap(); + let dir = tempfile::tempdir().unwrap(); + let mut walked = 0; + for (index, (label, ops, refused)) in rows.into_iter().enumerate() { + let path = dir.path().join(format!("row-{index}.cc")); + let mut cache: Option = None; + let mut failure = None; + for (at, (kind, arg)) in ops.iter().enumerate() { + let last = at + 1 == ops.len(); + let mut before = path.exists().then(|| std::fs::read(&path).unwrap()); + let result = match (kind.as_str(), arg) { + ("open", Arg::File(file)) => { + if let Some(file) = file { + std::fs::copy(vectors().join(file), &path).unwrap(); + } + let mut opened = CultCache::new(); + opened.register_entry_type::().unwrap(); + opened.register_entry_type::().unwrap(); + opened.register_entry_type::().unwrap(); + opened.register_entry_type::().unwrap(); + opened.register_entry_type::().unwrap(); + opened.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap(); + let pulled = opened.pull_all_backing_stores(); + cache = Some(opened); + pulled + } + ("pull", Arg::File(Some(file))) => { + std::fs::copy(vectors().join(file), &path).unwrap(); + before = Some(std::fs::read(&path).unwrap()); + cache.as_mut().unwrap().pull_all_backing_stores() + } + ("commit", Arg::Records(records)) => { + let cache = cache.as_mut().unwrap(); + let staged: Vec<_> = records + .iter() + .map(|(key, schema, sku, second)| { + let (sku, second) = (sku.clone(), second.clone()); + match schema.as_str() { + "tests.unique_item" => cache.prepare_entry(key, &Unique { sku, category: second }).map(|(envelope, _)| envelope), + "tests.uq_pair" => cache.prepare_entry(key, &UqPair { sku, alt: second }).map(|(envelope, _)| envelope), + "tests.uq_alias" => cache.prepare_entry(key, &UqAlias { sku, alt: second }).map(|(envelope, _)| envelope), + "tests.uq_alpha" => cache.prepare_entry(key, &UqAlpha { sku, second }).map(|(envelope, _)| envelope), + "tests.uq_beta" => cache.prepare_entry(key, &UqBeta { sku, second }).map(|(envelope, _)| envelope), + other => panic!("{label}: unexpected schema {other}"), + } + .unwrap() + }) + .collect(); + cache.put_prepared_batch(staged) + } + other => panic!("{label}: unexpected op {:?}", other.0), + }; + if last { + failure = Some((result, before)); + } else { + result.unwrap_or_else(|error| panic!("{label}: op {at} should succeed: {error:#}")); + } + } + let (result, before) = failure.unwrap(); + match refused { + None => result.unwrap_or_else(|error| panic!("{label}: should succeed: {error:#}")), + Some((k1, k2, schema, alias)) => { + let error = result.err().unwrap_or_else(|| panic!("{label}: should refuse")); + assert_eq!(format!("{error:#}"), refusal(&k1, &k2, &schema, &alias), "{label}"); + if let Some(before) = before { + assert_eq!(std::fs::read(&path).unwrap(), before, "{label}: the file is byte-identical"); + } + } + } + walked += 1; + } + assert_eq!(walked, 33); +} + // 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() {