use alloc::string::String;
use core::cmp::Ordering;
pub(crate) fn compare(collation: &str, a: &str, b: &str) -> Option<Ordering> {
let name = collation.trim();
let base = name.split(['.', '@']).next().unwrap_or(name);
if base.eq_ignore_ascii_case("C") || base.eq_ignore_ascii_case("POSIX") {
return Some(a.as_bytes().cmp(b.as_bytes()));
}
if base.eq_ignore_ascii_case("binary")
|| base
.rsplit_once('_')
.is_some_and(|(_, tail)| tail.eq_ignore_ascii_case("bin"))
{
return Some(a.as_bytes().cmp(b.as_bytes()));
}
let locale = icu_locale_core::Locale::try_from_str(&normalise(name)).ok()?;
let prefs = icu_collator::CollatorPreferences::from(&locale);
let collator = icu_collator::Collator::try_new(prefs, pg_options()).ok()?;
Some(
collator
.compare(a, b)
.then_with(|| a.as_bytes().cmp(b.as_bytes())),
)
}
pub(crate) fn sort_key(collation: &str, s: &str) -> Option<alloc::vec::Vec<u8>> {
let name = collation.trim();
let base = name.split(['.', '@']).next().unwrap_or(name);
if base.eq_ignore_ascii_case("C")
|| base.eq_ignore_ascii_case("POSIX")
|| base.eq_ignore_ascii_case("binary")
|| base
.rsplit_once('_')
.is_some_and(|(_, tail)| tail.eq_ignore_ascii_case("bin"))
{
return None;
}
let locale = icu_locale_core::Locale::try_from_str(&normalise(name)).ok()?;
let prefs = icu_collator::CollatorPreferences::from(&locale);
let collator = icu_collator::Collator::try_new(prefs, pg_options()).ok()?;
let mut key: alloc::vec::Vec<u8> = alloc::vec::Vec::new();
collator.write_sort_key_to(s, &mut key).ok()?;
key.push(0);
key.extend_from_slice(s.as_bytes());
Some(key)
}
pub(crate) struct Collated {
collator: Option<icu_collator::CollatorBorrowed<'static>>,
}
impl Collated {
pub(crate) fn resolve(name: &str) -> Option<Self> {
if is_byte_wise(name) {
return Some(Self { collator: None });
}
let locale = icu_locale_core::Locale::try_from_str(&normalise(name)).ok()?;
let prefs = icu_collator::CollatorPreferences::from(&locale);
let collator = icu_collator::Collator::try_new(prefs, pg_options()).ok()?;
Some(Self {
collator: Some(collator),
})
}
pub(crate) fn compare(&self, a: &str, b: &str) -> Ordering {
match &self.collator {
Some(c) => c.compare(a, b).then_with(|| a.as_bytes().cmp(b.as_bytes())),
None => a.as_bytes().cmp(b.as_bytes()),
}
}
}
fn pg_options() -> icu_collator::options::CollatorOptions {
let mut o = icu_collator::options::CollatorOptions::default();
o.alternate_handling = Some(icu_collator::options::AlternateHandling::Shifted);
o.max_variable = Some(icu_collator::options::MaxVariable::Punctuation);
o
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TextCompare {
pub fold_case: bool,
pub pads: bool,
pub order: Option<alloc::string::String>,
}
impl TextCompare {
pub fn is_plain_bytes(&self) -> bool {
!self.fold_case && !self.pads && self.order.is_none()
}
pub fn compare(&self, a: &str, b: &str) -> Option<core::cmp::Ordering> {
compare(self.order.as_deref()?, a, b)
}
}
pub(crate) fn pads_space(collation: Option<&str>) -> bool {
let Some(name) = collation else {
return false;
};
let n = name.trim();
if n.eq_ignore_ascii_case("binary") {
return false;
}
let lower = n.to_ascii_lowercase();
!(lower.contains("_0900_") || lower.contains("nopad"))
}
pub(crate) fn is_byte_wise(collation: &str) -> bool {
spg_storage::collation_is_byte_wise(collation)
}
pub(crate) fn is_known(name: &str) -> bool {
let n = name.trim();
fn base(s: &str) -> &str {
let t = s.trim();
t.split(['.', '@']).next().unwrap_or(t)
}
let want = base(n);
if crate::collation_catalog::PG_COLLATIONS
.iter()
.any(|(_, c, ..)| c.eq_ignore_ascii_case(n) || base(c).eq_ignore_ascii_case(want))
{
return true;
}
let tag = normalise(n);
let lang: alloc::string::String = tag.split('-').next().unwrap_or(&tag).into();
if crate::collation_catalog::PG_COLLATIONS
.iter()
.filter(|(_, _, provider, ..)| *provider == "i")
.any(|(_, c, ..)| {
c.strip_suffix("-x-icu").is_some_and(|icu| {
icu.eq_ignore_ascii_case(&tag) || icu.eq_ignore_ascii_case(&lang)
})
})
{
return true;
}
let lower = n.to_ascii_lowercase();
if lower == "case_insensitive" || lower == "nocase" {
return true;
}
lower == "binary" || lower.ends_with("_ci") || lower.ends_with("_cs") || lower.ends_with("_bin")
}
pub(crate) fn unknown_collation_error(name: &str) -> crate::EngineError {
crate::EngineError::Unsupported(alloc::format!(
"collation \"{name}\" for encoding \"UTF8\" does not exist"
))
}
pub(crate) fn is_supported(collation: &str) -> bool {
compare(collation, "a", "b").is_some()
}
fn normalise(name: &str) -> String {
if name.eq_ignore_ascii_case("default")
|| name.eq_ignore_ascii_case("unicode")
|| name.eq_ignore_ascii_case("pg_unicode_fast")
|| name.eq_ignore_ascii_case("ucs_basic")
{
return String::from("und");
}
let head = name.split(['.', '@']).next().unwrap_or(name);
head.replace('_', "-")
}
pub(crate) fn column_key_is_bytewise(col: &spg_storage::ColumnSchema, mysql: bool) -> bool {
if !matches!(
col.ty,
spg_storage::DataType::Text
| spg_storage::DataType::Varchar(_)
| spg_storage::DataType::Char(_)
) {
return true;
}
if matches!(col.collation, spg_storage::Collation::CaseInsensitive) {
return false;
}
!mysql || matches!(col.collation, spg_storage::Collation::Binary)
}
#[cfg(test)]
#[path = "collate_survey.rs"]
mod survey;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sort_key_bytes_order_the_way_compare_does() {
let words = [
"apple",
"Apple",
"APPLE",
"Bob",
"bob",
"client",
"DateStyle",
"Zebra",
"zebra",
"_under",
"cherry",
"de-luca",
"deluca",
"O'Brien",
"Obrien",
"résumé",
"resume",
"Résumé",
"1abc",
"",
" ",
"a",
"A",
"ä",
"Ä",
"z",
"Z",
"élan",
"elan",
];
for coll in ["en_US.utf8", "de_DE.utf8", "fr_FR.utf8"] {
for a in words {
for b in words {
let by_compare = compare(coll, a, b).expect("supported");
let ka = sort_key(coll, a).expect("supported");
let kb = sort_key(coll, b).expect("supported");
assert_eq!(
ka.cmp(&kb),
by_compare,
"{coll}: {a:?} vs {b:?} — sort keys say {:?}, compare says {by_compare:?}",
ka.cmp(&kb)
);
}
}
}
}
use alloc::vec;
use alloc::vec::Vec;
fn sorted<'a>(coll: &str, mut xs: Vec<&'a str>) -> Vec<&'a str> {
xs.sort_by(|a, b| compare(coll, a, b).expect("supported"));
xs
}
#[test]
fn en_us_reproduces_the_rules_measured_from_pg() {
assert_eq!(
sorted("en_US.utf8", vec!["b", "A", "a", "B"]),
["a", "A", "b", "B"]
);
assert_eq!(compare("en_US.utf8", "a", "A"), Some(Ordering::Less));
assert_eq!(
sorted("en_US.utf8", vec!["f", "ê", "E", "é", "e"]),
["e", "E", "é", "ê", "f"]
);
assert_eq!(
compare("en_US.utf8", "résumé", "resumes"),
Some(Ordering::Less)
);
assert_eq!(sorted("en_US.utf8", vec!["a", "1", "A"]), ["1", "a", "A"]);
let g = sorted("en_US.utf8", vec!["aB", "ab", "a-b", "a b"]);
assert_eq!(g[3], "aB", "case outranks punctuation: {g:?}");
assert_eq!(
sorted("en_US.utf8", vec!["a2", "a10", "a1"]),
["a1", "a10", "a2"]
);
assert_eq!(
sorted("en_US.utf8", vec!["中", "あ", "z", "Z"]),
["z", "Z", "あ", "中"]
);
}
#[test]
fn c_and_posix_are_byte_order() {
for c in ["C", "POSIX", "c", "posix"] {
assert_eq!(compare(c, "B", "a"), Some(Ordering::Less), "{c}");
assert_eq!(sorted(c, vec!["a", "B", "_"]), ["B", "_", "a"], "{c}");
}
}
#[test]
fn posix_locale_spellings_are_understood() {
for name in ["en_US", "en_US.utf8", "en_US.UTF-8", "en-US"] {
assert!(is_supported(name), "{name}");
assert_eq!(compare(name, "a", "A"), Some(Ordering::Less), "{name}");
}
}
#[test]
fn an_unknown_name_declines_instead_of_guessing() {
assert_eq!(compare("no_such_locale_at_all", "a", "b"), None);
assert!(!is_supported("no_such_locale_at_all"));
}
#[test]
fn punctuation_is_variable_weighted_like_pg() {
assert_eq!(
sorted("en_US.utf8", vec!["_under", "apple", "Zebra", "cherry"]),
["apple", "cherry", "_under", "Zebra"]
);
assert_eq!(
sorted("en_US.utf8", vec!["_id", "name", "zip", "_ts", "email"]),
["email", "_id", "name", "_ts", "zip"]
);
assert_eq!(
sorted("en_US.utf8", vec!["O'Brien", "Oakes", "Obrien", "O-Brien"]),
["Oakes", "Obrien", "O'Brien", "O-Brien"]
);
let g = sorted(
"en_US.utf8",
vec!["de luca", "deluca", "de-luca", "demarco"],
);
assert_eq!(
g[3], "demarco",
"the de-luca variants group before demarco: {g:?}"
);
}
#[test]
fn all_punctuation_values_now_match_pg() {
assert_eq!(
sorted("en_US.utf8", vec!["_", " ", "-", "."]),
[" ", "-", ".", "_"]
);
}
#[test]
fn a_locale_tailoring_actually_changes_the_answer() {
assert_eq!(compare("sv_SE.utf8", "z", "å"), Some(Ordering::Less));
assert_eq!(compare("en_US.utf8", "z", "å"), Some(Ordering::Greater));
}
#[test]
fn survey_pg18_collation_coverage() {
let all = super::survey::PG18_COLLATIONS;
let unsupported: Vec<&str> = all.iter().copied().filter(|n| !is_supported(n)).collect();
let supported = all.len() - unsupported.len();
assert_eq!(
unsupported,
Vec::<&str>::new(),
"{supported}/{} performable",
all.len()
);
}
}