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>,
},
}
const ALIAS_TABLE: &[(&str, &str)] = &[
("postgres", "postgres"),
("postgresql", "postgres"),
("pg", "postgres"),
("snowflake", "snowflake"),
("sf", "snowflake"),
("sqlserver", "sqlserver"),
("tsql", "sqlserver"),
("mssql", "sqlserver"),
("sql_server", "sqlserver"),
("databricks", "databricks"),
("dbx", "databricks"),
("spark", "databricks"),
("duckdb", "duckdb"),
("duck", "duckdb"),
("clickhouse", "clickhouse"),
("ch", "clickhouse"),
("mariadb", "mariadb"),
("mariadb_columnstore", "mariadb"),
("mysql", "mysql"),
("citusdb", "citus"),
("citus", "citus"),
("agnostic", "agnostic"),
("sqlite", "sqlite"),
("bigquery", "bigquery"),
("big_query", "bigquery"),
("bq", "bigquery"),
("redshift", "redshift"),
("amazon_redshift", "redshift"),
("rs", "redshift"),
];
pub fn resolve_dialect_by_name(name: &str) -> Result<Arc<dyn PlatformDialect>, ResolveError> {
let registry = PlatformRegistry::with_auto_discovered();
resolve_dialect_by_name_with_registry(name, ®istry)
}
pub fn resolve_dialect_by_name_with_registry(
name: &str,
registry: &PlatformRegistry,
) -> Result<Arc<dyn PlatformDialect>, ResolveError> {
let normalized = name.trim().to_lowercase();
if let Some(canonical) = alias_lookup(&normalized)
&& let Some(dialect) = registry.get_by_name(canonical)
{
return Ok(Arc::clone(dialect));
}
if let Some(dialect) = registry.get_by_name(&normalized) {
return Ok(Arc::clone(dialect));
}
let all_names = registered_names(registry);
let suggestions = near_misses(&normalized, &all_names);
Err(ResolveError::UnknownDialect { name: name.to_string(), suggestions, registered: all_names })
}
#[must_use]
pub const fn alias_spellings() -> &'static [(&'static str, &'static str)] {
ALIAS_TABLE
}
#[must_use]
pub fn all_accepted_names() -> Vec<String> {
let registry = PlatformRegistry::with_auto_discovered();
let mut names: Vec<String> =
ALIAS_TABLE.iter().map(|(alias, _)| (*alias).to_string()).collect();
for info in registry.list() {
let n = info.name.to_string();
if !names.contains(&n) {
names.push(n);
}
}
names.sort();
names.dedup();
names
}
fn alias_lookup(normalized: &str) -> Option<&'static str> {
ALIAS_TABLE.iter().find(|(alias, _)| *alias == normalized).map(|(_, canonical)| *canonical)
}
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]
}
#[must_use]
pub fn all_aliases() -> &'static [(&'static str, &'static str)] {
ALIAS_TABLE
}
#[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, DialectFamily, &str, bool, bool, bool)] = &[
("postgres", "PostgreSQL", DialectFamily::PostgreSql, "PostgreSQL", false, false, true),
("snowflake", "Snowflake", DialectFamily::Snowflake, "Snowflake", true, true, false),
("sqlserver", "SQL Server", DialectFamily::TSql, "T-SQL", false, false, false),
("databricks", "Databricks", DialectFamily::SparkSql, "Spark SQL", true, true, false),
(
"clickhouse",
"ClickHouse",
DialectFamily::ClickHouse,
"ClickHouse SQL",
false,
true,
false,
),
("duckdb", "DuckDB", DialectFamily::PostgreSql, "PostgreSQL", false, true, true),
("citus", "Citus", DialectFamily::PostgreSql, "PostgreSQL", false, false, true),
("mariadb", "MariaDB", DialectFamily::MariaDB, "MariaDB", false, true, false),
("mysql", "MySQL", DialectFamily::MariaDB, "MariaDB", false, false, false),
("agnostic", "Agnostic", DialectFamily::Razeri, "ANSI", false, false, false),
("sqlite", "SQLite", DialectFamily::MariaDB, "SQLite", false, false, false),
];
for &(name, display, family, fam_str, cloud, olap, pg_compat) in entries {
reg.register(
name.to_string(),
Arc::new(StubDialect {
info: DialectInfo {
name,
display_name: display,
dialect_family: family,
family: fam_str,
is_cloud_native: cloud,
is_olap: olap,
is_postgres_compatible: pg_compat,
},
}),
);
}
reg
}
#[test]
fn alias_lookup_finds_canonical_names() {
assert_eq!(alias_lookup("pg"), Some("postgres"));
assert_eq!(alias_lookup("postgresql"), Some("postgres"));
assert_eq!(alias_lookup("tsql"), Some("sqlserver"));
assert_eq!(alias_lookup("mssql"), Some("sqlserver"));
assert_eq!(alias_lookup("sf"), Some("snowflake"));
assert_eq!(alias_lookup("ch"), Some("clickhouse"));
assert_eq!(alias_lookup("duck"), Some("duckdb"));
assert_eq!(alias_lookup("dbx"), Some("databricks"));
assert_eq!(alias_lookup("spark"), Some("databricks"));
assert_eq!(alias_lookup("citusdb"), Some("citus"));
assert_eq!(alias_lookup("sqlite"), Some("sqlite"));
assert_eq!(alias_lookup("agnostic"), Some("agnostic"));
}
#[test]
fn alias_lookup_with_unknown_returns_none() {
assert_eq!(alias_lookup("nosuchdb"), None);
}
#[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);
}
#[test]
fn resolve_with_canonical_name_succeeds() {
let reg = test_registry();
assert!(resolve_dialect_by_name_with_registry("postgres", ®).is_ok());
}
#[test]
fn resolve_with_alias_succeeds() {
let reg = test_registry();
assert!(resolve_dialect_by_name_with_registry("pg", ®).is_ok());
}
#[test]
fn resolve_is_case_insensitive() {
let reg = test_registry();
assert!(resolve_dialect_by_name_with_registry("SNOWFLAKE", ®).is_ok());
}
#[test]
fn resolve_sqlite_extension_succeeds() {
let reg = test_registry();
assert!(resolve_dialect_by_name_with_registry("sqlite", ®).is_ok());
}
#[test]
fn resolve_databricks_succeeds() {
let reg = test_registry();
assert!(resolve_dialect_by_name_with_registry("databricks", ®).is_ok());
}
#[test]
fn resolve_mssql_alias_succeeds() {
let reg = test_registry();
assert!(resolve_dialect_by_name_with_registry("mssql", ®).is_ok());
}
#[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 all_accepted_names_includes_aliases_and_registered() {
let names = all_accepted_names();
assert!(names.contains(&"pg".to_string()));
assert!(names.contains(&"postgres".to_string()));
assert!(names.contains(&"snowflake".to_string()));
}
#[test]
fn design_schema_platform_enum_matches_the_alias_table() {
let schema_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../schemas/razeri-design.schema.json");
let text = std::fs::read_to_string(&schema_path)
.unwrap_or_else(|e| panic!("read {}: {e}", schema_path.display()));
let schema: serde_json::Value =
serde_json::from_str(&text).unwrap_or_else(|e| panic!("parse the design schema: {e}"));
let enum_values = schema["$defs"]["DatabasePlatform"]["enum"]
.as_array()
.expect("$defs.DatabasePlatform.enum is an array in the published design schema");
let mut published: Vec<String> = enum_values
.iter()
.map(|v| v.as_str().expect("every DatabasePlatform enum value is a string").to_string())
.collect();
published.sort();
published.dedup();
let mut accepted: Vec<String> =
ALIAS_TABLE.iter().map(|(alias, _)| (*alias).to_string()).collect();
accepted.sort();
accepted.dedup();
let advertised_but_refused: Vec<&String> =
published.iter().filter(|n| !accepted.contains(n)).collect();
let accepted_but_unpublished: Vec<&String> =
accepted.iter().filter(|n| !published.contains(n)).collect();
assert!(
advertised_but_refused.is_empty() && accepted_but_unpublished.is_empty(),
"schemas/razeri-design.schema.json $defs.DatabasePlatform has drifted from the \
canonical resolver's ALIAS_TABLE.\n advertised by the schema but REFUSED by the \
resolver: {advertised_but_refused:?}\n accepted by the resolver but MISSING from \
the schema: {accepted_but_unpublished:?}\nThe alias table is the authority: add the \
spelling to the schema's enum, or take it out of the alias table — do not let the \
two vocabularies differ."
);
}
}