use crate::PlatformDialect;
use crate::dialect::PlatformRegistry;
use std::sync::Arc;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ResolveError {
#[error(
"unknown dialect: '{name}'. {suggestions_text}Registered: {registered}",
suggestions_text = if suggestions.is_empty() {
String::new()
} else {
format!("Did you mean: {}? ", suggestions.join(", "))
},
registered = registered.join(", ")
)]
UnknownDialect {
name: String,
suggestions: Vec<String>,
registered: Vec<String>,
},
#[error(
"the platform name '{name}' belongs to {claimed_by_text} at once — use one platform's \
own name, or load only one of them",
claimed_by_text = claimed_by.join(" and ")
)]
AmbiguousName {
name: String,
claimed_by: Vec<String>,
},
}
pub fn resolve_dialect_by_name(name: &str) -> Result<Arc<dyn PlatformDialect>, ResolveError> {
resolve_dialect_by_name_with_registry(name, crate::process::process_registry())
}
pub fn resolve_dialect_by_name_with_registry(
name: &str,
registry: &PlatformRegistry,
) -> Result<Arc<dyn PlatformDialect>, ResolveError> {
let wanted = name.trim();
let mut claimants: Vec<String> = registry
.registered_names()
.into_iter()
.filter(|key| answers_to(registry, key, wanted))
.collect();
claimants.sort();
claimants.dedup();
match claimants.as_slice() {
[] => {
let all_names = registered_names(registry);
let suggestions = near_misses(&wanted.to_lowercase(), &all_names);
Err(ResolveError::UnknownDialect {
name: name.to_string(),
suggestions,
registered: all_names,
})
}
[only] => registry.get_by_name(only).map(Arc::clone).ok_or_else(|| {
let all_names = registered_names(registry);
ResolveError::UnknownDialect {
name: name.to_string(),
suggestions: Vec::new(),
registered: all_names,
}
}),
_ => Err(ResolveError::AmbiguousName { name: wanted.to_string(), claimed_by: claimants }),
}
}
#[must_use]
pub fn accepted_names(registry: &PlatformRegistry) -> Vec<String> {
let mut names: Vec<String> = registry
.list()
.iter()
.flat_map(|info| info.spellings().into_iter().map(str::to_owned))
.collect();
names.extend(registry.registered_names());
names.sort();
names.dedup();
names
}
#[must_use]
pub fn all_accepted_names() -> Vec<String> {
accepted_names(&PlatformRegistry::with_auto_discovered())
}
fn answers_to(registry: &PlatformRegistry, key: &str, wanted: &str) -> bool {
if key.trim().eq_ignore_ascii_case(wanted) {
return true;
}
registry.get_by_name(key).is_some_and(|dialect| dialect.info().answers_to(wanted))
}
fn registered_names(registry: &PlatformRegistry) -> Vec<String> {
let mut names: Vec<String> = registry.list().iter().map(|info| info.name.to_string()).collect();
names.sort();
names
}
fn near_misses(input: &str, candidates: &[String]) -> Vec<String> {
let mut scored: Vec<(&str, usize)> = candidates
.iter()
.filter_map(|c| {
let dist = levenshtein(input, c);
if dist <= 3 { Some((c.as_str(), dist)) } else { None }
})
.collect();
scored.sort_by_key(|(_, d)| *d);
scored.into_iter().map(|(name, _)| name.to_string()).collect()
}
fn levenshtein(a: &str, b: &str) -> usize {
let a_bytes = a.as_bytes();
let b_bytes = b.as_bytes();
let m = a_bytes.len();
let n = b_bytes.len();
let mut prev: Vec<usize> = (0..=n).collect();
let mut curr = vec![0; n + 1];
for i in 1..=m {
curr[0] = i;
for j in 1..=n {
let cost = usize::from(a_bytes[i - 1] != b_bytes[j - 1]);
curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost);
}
std::mem::swap(&mut prev, &mut curr);
}
prev[n]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dialect::{DialectInfo, PlatformDialect, PlatformRegistry};
use razeri_plugin_contract::DataType;
use razeri_plugin_contract::DialectFamily;
use razeri_plugin_contract::{
DdlRenderer, DialectCapabilities, DmlRenderer, ExpressionRenderer, FormulaRenderer,
ProcedureRenderer, ReplicationRenderer, SqlRenderer, StorageRenderer, TaskRenderer,
};
struct StubDialect {
info: DialectInfo,
}
impl SqlRenderer for StubDialect {
fn render_data_type(&self, _dt: &DataType) -> String {
String::new()
}
fn quote_identifier(&self, name: &str) -> String {
format!("\"{name}\"")
}
fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
self.quote_identifier(name)
}
fn render_current_timestamp(&self) -> String {
String::new()
}
}
impl ExpressionRenderer for StubDialect {}
impl DdlRenderer for StubDialect {}
impl DmlRenderer for StubDialect {}
impl ProcedureRenderer for StubDialect {}
impl FormulaRenderer for StubDialect {}
impl TaskRenderer for StubDialect {}
impl DialectCapabilities for StubDialect {}
impl ReplicationRenderer for StubDialect {}
impl StorageRenderer for StubDialect {}
impl PlatformDialect for StubDialect {
fn info(&self) -> DialectInfo {
self.info.clone()
}
}
fn test_registry() -> PlatformRegistry {
let mut reg = PlatformRegistry::new();
let entries: &[(&str, &str, &[&str], DialectFamily, &str)] = &[
(
"postgres",
"PostgreSQL",
&["postgresql", "pg"],
DialectFamily::PostgreSql,
"PostgreSQL",
),
("snowflake", "Snowflake", &["sf"], DialectFamily::Snowflake, "Snowflake"),
(
"sqlserver",
"SQL Server",
&["tsql", "mssql", "sql_server"],
DialectFamily::TSql,
"T-SQL",
),
("databricks", "Databricks", &["dbx", "spark"], DialectFamily::SparkSql, "Spark SQL"),
("clickhouse", "ClickHouse", &["ch"], DialectFamily::ClickHouse, "ClickHouse SQL"),
("duckdb", "DuckDB", &["duck"], DialectFamily::PostgreSql, "PostgreSQL"),
("citus", "Citus", &["citusdb"], DialectFamily::PostgreSql, "PostgreSQL"),
("mariadb", "MariaDB", &["mariadb_columnstore"], DialectFamily::MariaDB, "MariaDB"),
("mysql", "MySQL", &[], DialectFamily::MariaDB, "MariaDB"),
("agnostic", "Agnostic", &[], DialectFamily::Razeri, "ANSI"),
("sqlite", "SQLite", &[], DialectFamily::MariaDB, "SQLite"),
];
for &(name, display, aliases, family, fam_str) in entries {
reg.register(name.to_string(), Arc::new(stub(name, display, aliases, family, fam_str)));
}
reg
}
fn stub(
name: &'static str,
display: &'static str,
aliases: &'static [&'static str],
family: DialectFamily,
fam_str: &'static str,
) -> StubDialect {
StubDialect {
info: DialectInfo {
name,
display_name: display,
aliases,
dialect_family: family,
family: fam_str,
is_cloud_native: false,
is_olap: false,
is_postgres_compatible: false,
},
}
}
fn resolved(name: &str, registry: &PlatformRegistry) -> String {
resolve_dialect_by_name_with_registry(name, registry)
.unwrap_or_else(|e| panic!("'{name}' resolves: {e}"))
.info()
.name
.to_owned()
}
#[test]
fn every_declared_spelling_resolves_to_the_dialect_that_declared_it() {
let reg = test_registry();
for info in reg.list() {
for spelling in info.spellings() {
assert_eq!(resolved(spelling, ®), info.name, "spelling '{spelling}'");
}
}
}
#[test]
fn the_declared_spellings_are_the_accepted_names_and_nothing_else_is() {
let reg = test_registry();
let accepted = accepted_names(®);
for info in reg.list() {
for spelling in info.spellings() {
assert!(accepted.contains(&spelling.to_owned()), "'{spelling}' is accepted");
}
}
for name in accepted {
assert!(
reg.list().iter().any(|info| info.spellings().contains(&name.as_str())),
"'{name}' is accepted, so some loaded dialect declares it"
);
}
}
#[test]
fn resolution_ignores_case_and_surrounding_whitespace() {
let reg = test_registry();
assert_eq!(resolved("SNOWFLAKE", ®), "snowflake");
assert_eq!(resolved(" DuckDB ", ®), "duckdb");
assert_eq!(resolved("PG", ®), "postgres");
}
#[test]
fn a_name_the_host_registered_resolves_even_if_the_dialect_never_declared_it() {
let mut reg = PlatformRegistry::new();
reg.register(
"housename".to_string(),
Arc::new(stub("mydb", "MyDB", &[], DialectFamily::Custom("mydb"), "MyDB")),
);
assert_eq!(resolved("housename", ®), "mydb");
assert_eq!(resolved("mydb", ®), "mydb");
}
#[test]
fn a_dialect_razeri_never_heard_of_resolves_by_its_own_spellings() {
let mut reg = test_registry();
reg.register(
"outsidedb".to_string(),
Arc::new(stub(
"outsidedb",
"Outside DB",
&["odb", "outside_db"],
DialectFamily::Custom("outsidedb"),
"OutsideDB",
)),
);
for spelling in ["outsidedb", "odb", "outside_db", " ODB "] {
assert_eq!(resolved(spelling, ®), "outsidedb", "spelling '{spelling}'");
}
let dialect = resolve_dialect_by_name_with_registry("odb", ®).expect("resolves");
assert_eq!(dialect.quote_identifier("orders"), "\"orders\"");
assert!(accepted_names(®).contains(&"odb".to_string()));
}
#[test]
fn resolve_unknown_returns_error_with_suggestions() {
let reg = test_registry();
let result = resolve_dialect_by_name_with_registry("postres", ®);
let Err(err) = result else { panic!("expected error") };
let msg = err.to_string();
assert!(msg.contains("unknown dialect"), "error should mention unknown dialect: {msg}");
assert!(msg.contains("postgres"), "error should suggest postgres: {msg}");
}
#[test]
fn resolve_completely_unknown_returns_registered_list() {
let reg = test_registry();
let result = resolve_dialect_by_name_with_registry("zzzznotadb", ®);
let Err(err) = result else { panic!("expected error") };
let msg = err.to_string();
assert!(msg.contains("Registered:"), "error should list registered: {msg}");
}
#[test]
fn a_spelling_two_dialects_claim_is_refused_and_both_are_named() {
let mut reg = test_registry();
reg.register(
"sparkler".to_string(),
Arc::new(stub(
"sparkler",
"Sparkler",
&["spark"],
DialectFamily::Custom("sparkler"),
"Sparkler",
)),
);
let Err(err) = resolve_dialect_by_name_with_registry("spark", ®) else {
panic!("a spelling two dialects claim must not resolve")
};
let msg = err.to_string();
assert!(msg.contains("databricks"), "names the first claimant: {msg}");
assert!(msg.contains("sparkler"), "names the second claimant: {msg}");
assert!(msg.contains("use one platform's own name"), "says what to do: {msg}");
assert_eq!(resolved("databricks", ®), "databricks");
assert_eq!(resolved("dbx", ®), "databricks");
assert_eq!(resolved("sparkler", ®), "sparkler");
}
#[test]
fn a_dialect_claiming_another_dialects_own_name_is_refused_too() {
let mut reg = test_registry();
reg.register(
"impostor".to_string(),
Arc::new(stub(
"impostor",
"Impostor",
&["duckdb"],
DialectFamily::Custom("impostor"),
"Impostor",
)),
);
let Err(err) = resolve_dialect_by_name_with_registry("duckdb", ®) else {
panic!("a claimed name must not resolve to whichever came first")
};
assert!(err.to_string().contains("impostor"), "{err}");
}
#[test]
fn levenshtein_distance_basic() {
assert_eq!(levenshtein("postgres", "postgres"), 0);
assert_eq!(levenshtein("postgre", "postgres"), 1);
assert_eq!(levenshtein("postgrse", "postgres"), 2);
assert_eq!(levenshtein("", "abc"), 3);
}
}