use super::{ExtCtx, ExtensionDef, RuntimeStrategy, any_null, arg_text, no_such};
use crate::relational::SqlValue;
use crate::sql::error::{Result, SqlError};
use unicode_normalization::UnicodeNormalization;
use unicode_normalization::char::is_combining_mark;
pub static DEF: ExtensionDef = ExtensionDef {
name: "unaccent",
default_version: "1.1",
comment: "text search dictionary that removes accents",
requires: &[],
functions: &["unaccent"],
types: &[],
gucs: &[],
trusted: true,
call: Some(call),
strategy: RuntimeStrategy::Native,
};
fn call(_ctx: &ExtCtx, func: &str, args: &[SqlValue]) -> Result<SqlValue> {
match func {
"unaccent" => unaccent(args),
_ => Err(no_such(func)),
}
}
fn unaccent(args: &[SqlValue]) -> Result<SqlValue> {
if any_null(args) {
return Ok(SqlValue::Null);
}
let text = match args.len() {
1 => arg_text(args, 0, "unaccent")?,
2 => {
check_dictionary(&arg_text(args, 0, "unaccent")?)?;
arg_text(args, 1, "unaccent")?
}
_ => {
return Err(SqlError::UndefinedFunction(format!(
"unaccent({})",
args.iter()
.map(|a| a.type_of().name())
.collect::<Vec<_>>()
.join(", ")
)));
}
};
Ok(SqlValue::Text(strip_accents(&text)))
}
fn check_dictionary(dict: &str) -> Result<()> {
let name = dict.trim();
let unqualified = name
.strip_prefix("public.")
.or_else(|| name.strip_prefix("PUBLIC."))
.unwrap_or(name);
if unqualified.eq_ignore_ascii_case("unaccent") {
return Ok(());
}
Err(SqlError::UndefinedObject(format!(
"text search dictionary \"{dict}\""
)))
}
fn strip_accents(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for ch in input.nfd() {
if is_combining_mark(ch) {
continue;
}
match special(ch) {
Some(s) => out.push_str(s),
None => out.push(ch),
}
}
out.as_str().nfc().collect()
}
fn special(ch: char) -> Option<&'static str> {
Some(match ch {
'æ' => "ae",
'Æ' => "AE",
'œ' => "oe",
'Œ' => "OE",
'ø' => "o",
'Ø' => "O",
'đ' | 'ð' => "d",
'Đ' | 'Ð' => "D",
'þ' => "th",
'Þ' => "TH",
'ß' => "ss",
'ẞ' => "SS",
'ł' => "l",
'Ł' => "L",
'ħ' => "h",
'Ħ' => "H",
'ı' => "i",
'ĸ' => "k",
'ŋ' => "n",
'Ŋ' => "N",
'ⱥ' => "a",
'ȼ' => "c",
'\u{2013}' | '\u{2014}' => "-",
'\u{2018}' | '\u{2019}' | '\u{201A}' => "'",
'\u{201C}' | '\u{201D}' | '\u{201E}' => "\"",
'\u{2026}' => "...",
'©' => "(C)",
'®' => "(R)",
'\u{2122}' => "(TM)",
'½' => "1/2",
'¼' => "1/4",
'¾' => "3/4",
'\u{2116}' => "No",
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use std::cell::RefCell;
use std::collections::HashMap;
fn run(args: &[SqlValue]) -> Result<SqlValue> {
let vars = RefCell::new(HashMap::new());
let ctx = ExtCtx {
now: Utc::now(),
vars: &vars,
};
call(&ctx, "unaccent", args)
}
fn txt(s: &str) -> SqlValue {
SqlValue::Text(s.to_string())
}
fn out(args: &[SqlValue]) -> String {
match run(args).unwrap() {
SqlValue::Text(s) => s,
other => panic!("expected text result, got {other:?}"),
}
}
#[test]
fn strips_diacritics_like_postgres() {
assert_eq!(out(&[txt("Hôtel")]), "Hotel");
assert_eq!(out(&[txt("ÀÉÎÕÜ")]), "AEIOU");
assert_eq!(out(&[txt("naïve café")]), "naive cafe");
}
#[test]
fn expands_rules_file_specials() {
assert_eq!(out(&[txt("Æther")]), "AEther");
assert_eq!(out(&[txt("straße")]), "strasse");
assert_eq!(out(&[txt("Łódź")]), "Lodz");
assert_eq!(out(&[txt("smørrebrød")]), "smorrebrod");
assert_eq!(out(&[txt("Þórður")]), "THordur");
assert_eq!(out(&[txt("œuvre")]), "oeuvre");
}
#[test]
fn maps_typographic_punctuation() {
assert_eq!(out(&[txt("“smart” — quotes…")]), "\"smart\" - quotes...");
assert_eq!(out(&[txt("‘a’ ‚b‛?")]), "'a' 'b‛?");
assert_eq!(out(&[txt("© ® ™ № ½ ¼ ¾")]), "(C) (R) (TM) No 1/2 1/4 3/4");
}
#[test]
fn ascii_and_empty_pass_through() {
assert_eq!(
out(&[txt("plain ASCII text 123!")]),
"plain ASCII text 123!"
);
assert_eq!(out(&[txt("")]), "");
}
#[test]
fn null_arguments_are_strict() {
assert!(matches!(run(&[SqlValue::Null]).unwrap(), SqlValue::Null));
assert!(matches!(
run(&[SqlValue::Null, txt("Hôtel")]).unwrap(),
SqlValue::Null
));
assert!(matches!(
run(&[txt("unaccent"), SqlValue::Null]).unwrap(),
SqlValue::Null
));
assert!(matches!(
run(&[txt("other_dict"), SqlValue::Null]).unwrap(),
SqlValue::Null
));
}
#[test]
fn two_argument_form_accepts_the_unaccent_dictionary() {
assert_eq!(out(&[txt("unaccent"), txt("Hôtel")]), "Hotel");
assert_eq!(out(&[txt("public.unaccent"), txt("Łódź")]), "Lodz");
}
#[test]
fn two_argument_form_rejects_unknown_dictionaries() {
match run(&[txt("other_dict"), txt("Hôtel")]) {
Err(SqlError::UndefinedObject(d)) => {
assert_eq!(d, "text search dictionary \"other_dict\"");
}
other => panic!("expected UndefinedObject error, got {other:?}"),
}
}
#[test]
fn unknown_function_is_an_internal_error() {
let vars = RefCell::new(HashMap::new());
let ctx = ExtCtx {
now: Utc::now(),
vars: &vars,
};
assert!(matches!(
call(&ctx, "not_unaccent", &[txt("x")]),
Err(SqlError::Internal(_))
));
}
}