use crate::PlatformDialect;
use crate::dialect::{PlatformRegistry, UnsupportedVersion};
use razeri_plugin_contract::{EngineVersion, EngineVersionError, split_platform};
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>,
},
#[error("'{name}' is not a platform name: {reason}")]
NotAPlatformName {
name: String,
reason: EngineVersionError,
},
#[error(
"{display_name} {why}, so a platform name gives it no version: write `{engine}`, not \
'{name}'",
why = if *chosen_by_its_service {
"chooses its own version"
} else {
"is rendered the same for every version"
}
)]
VersionChosenByTheService {
name: String,
engine: String,
display_name: String,
chosen_by_its_service: bool,
},
#[error(transparent)]
UnsupportedVersion(#[from] UnsupportedVersion),
}
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 (engine, version) = split_platform(name).map_err(|reason| {
ResolveError::NotAPlatformName { name: name.trim().to_owned(), reason }
})?;
let dialect = resolve_engine(engine, name, registry)?;
at_named_version(dialect, version, name)
}
fn at_named_version(
dialect: Arc<dyn PlatformDialect>,
version: Option<EngineVersion>,
name: &str,
) -> Result<Arc<dyn PlatformDialect>, ResolveError> {
let info = dialect.info();
let Some(asked) = version else {
return match info.versions.first() {
Some(floor) => Ok(dialect.at_version(floor)?),
None => Ok(dialect),
};
};
if info.versions.is_empty() {
return Err(ResolveError::VersionChosenByTheService {
name: name.trim().to_owned(),
engine: info.name.to_owned(),
display_name: info.display_name.to_owned(),
chosen_by_its_service: info.is_cloud_native,
});
}
match info.versions.iter().find(|declared| **declared == asked) {
Some(declared) => Ok(dialect.at_version(declared)?),
None => Err(UnsupportedVersion::of(&info, asked.as_str()).into()),
}
}
fn resolve_engine(
wanted: &str,
name: &str,
registry: &PlatformRegistry,
) -> Result<Arc<dyn PlatformDialect>, ResolveError> {
let wanted = wanted.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,
at: Option<EngineVersion>,
}
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 at_version(
self: Arc<Self>,
version: &EngineVersion,
) -> Result<Arc<dyn PlatformDialect>, UnsupportedVersion> {
Ok(Arc::new(StubDialect { info: self.info.clone(), at: Some(*version) }))
}
fn engine_version(&self) -> Option<EngineVersion> {
self.at
}
}
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,
versions: &[],
deploy_script: None,
},
at: None,
}
}
const POSTGRES_VERSIONS: &[EngineVersion] = &[
EngineVersion::declared("15"),
EngineVersion::declared("16"),
EngineVersion::declared("17"),
];
const ORACLE_VERSIONS: &[EngineVersion] = &[EngineVersion::declared("23ai")];
fn versioned_registry() -> PlatformRegistry {
let mut reg = PlatformRegistry::new();
for (name, display, aliases, versions, is_service) in [
("postgres", "PostgreSQL", &["postgresql", "pg"][..], POSTGRES_VERSIONS, false),
("oracle", "Oracle", &[][..], ORACLE_VERSIONS, false),
("snowflake", "Snowflake", &["sf"][..], &[][..], true),
] {
let mut dialect = stub(name, display, aliases, DialectFamily::Custom(name), display);
dialect.info.versions = versions;
dialect.info.is_cloud_native = is_service;
reg.register(name.to_string(), Arc::new(dialect));
}
reg
}
fn version_of(name: &str, registry: &PlatformRegistry) -> String {
resolve_dialect_by_name_with_registry(name, registry)
.unwrap_or_else(|e| panic!("'{name}' resolves: {e}"))
.engine_version()
.map_or_else(|| "none".to_owned(), |v| v.to_string())
}
fn refusal(name: &str, registry: &PlatformRegistry) -> ResolveError {
match resolve_dialect_by_name_with_registry(name, registry) {
Ok(dialect) => panic!("'{name}' resolved to {}", dialect.info().name),
Err(error) => error,
}
}
#[test]
fn a_name_with_no_version_is_the_engine_at_its_floor() {
let reg = versioned_registry();
assert_eq!(version_of("postgres", ®), "15");
assert_eq!(version_of("PG", ®), "15");
assert_eq!(version_of("snowflake", ®), "none");
}
#[test]
fn a_named_version_is_the_dialect_at_that_version() {
let reg = versioned_registry();
assert_eq!(version_of("postgres@17", ®), "17");
assert_eq!(version_of(" PostgreSQL@16 ", ®), "16");
assert_eq!(resolved("pg@17", ®), "postgres");
}
#[test]
fn a_version_is_kept_in_the_spelling_the_dialect_declares() {
let reg = versioned_registry();
assert_eq!(version_of("oracle@23", ®), "23ai");
assert_eq!(version_of("oracle@23ai", ®), "23ai");
}
#[test]
fn a_version_the_engine_does_not_answer_for_is_refused_with_the_list() {
let reg = versioned_registry();
let error = refusal("postgres@12", ®);
assert!(matches!(error, ResolveError::UnsupportedVersion(_)), "{error:?}");
let said = error.to_string();
assert!(said.contains("15, 16 and 17"), "{said}");
assert!(said.contains("`postgres@17`"), "{said}");
}
#[test]
fn a_version_on_a_service_that_chooses_its_own_is_refused_by_name() {
let reg = versioned_registry();
let error = refusal("snowflake@8", ®);
assert!(matches!(error, ResolveError::VersionChosenByTheService { .. }), "{error:?}");
let said = error.to_string();
assert!(said.contains("Snowflake chooses its own version"), "{said}");
assert!(said.contains("write `snowflake`"), "{said}");
}
#[test]
fn a_dot_before_letters_is_refused_showing_the_accepted_spelling() {
let reg = versioned_registry();
let error = refusal("oracle@23.ai", ®);
assert!(matches!(error, ResolveError::NotAPlatformName { .. }), "{error:?}");
assert!(error.to_string().contains("`oracle@23ai`"), "{error}");
}
#[test]
fn a_colon_is_refused() {
let reg = versioned_registry();
let error = refusal("postgres:17", ®);
assert!(matches!(error, ResolveError::NotAPlatformName { .. }), "{error:?}");
assert!(error.to_string().contains("`postgres@17`"), "{error}");
}
#[test]
fn an_unknown_engine_with_a_version_is_refused_as_an_unknown_engine() {
let reg = versioned_registry();
let error = refusal("postgress@17", ®);
let ResolveError::UnknownDialect { suggestions, .. } = &error else { panic!("{error:?}") };
assert_eq!(suggestions, &vec!["postgres".to_owned()]);
}
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);
}
}