1use crate::PlatformDialect;
11use crate::dialect::PlatformRegistry;
12use std::sync::Arc;
13use thiserror::Error;
14
15#[derive(Debug, Error)]
17pub enum ResolveError {
18 #[error(
20 "unknown dialect: '{name}'. {suggestions_text}Registered: {registered}",
21 suggestions_text = if suggestions.is_empty() {
22 String::new()
23 } else {
24 format!("Did you mean: {}? ", suggestions.join(", "))
25 },
26 registered = registered.join(", ")
27 )]
28 UnknownDialect {
29 name: String,
31 suggestions: Vec<String>,
33 registered: Vec<String>,
35 },
36}
37
38const ALIAS_TABLE: &[(&str, &str)] = &[
43 ("postgres", "postgres"),
45 ("postgresql", "postgres"),
46 ("pg", "postgres"),
47 ("snowflake", "snowflake"),
49 ("sf", "snowflake"),
50 ("sqlserver", "sqlserver"),
52 ("tsql", "sqlserver"),
53 ("mssql", "sqlserver"),
54 ("sql_server", "sqlserver"),
55 ("databricks", "databricks"),
57 ("dbx", "databricks"),
58 ("spark", "databricks"),
59 ("duckdb", "duckdb"),
61 ("duck", "duckdb"),
62 ("clickhouse", "clickhouse"),
64 ("ch", "clickhouse"),
65 ("mariadb", "mariadb"),
67 ("mariadb_columnstore", "mariadb"),
68 ("mysql", "mysql"),
69 ("citusdb", "citus"),
71 ("citus", "citus"),
72 ("agnostic", "agnostic"),
74 ("sqlite", "sqlite"),
76 ("bigquery", "bigquery"),
78 ("big_query", "bigquery"),
79 ("bq", "bigquery"),
80 ("redshift", "redshift"),
82 ("amazon_redshift", "redshift"),
83 ("rs", "redshift"),
84];
85
86pub fn resolve_dialect_by_name(name: &str) -> Result<Arc<dyn PlatformDialect>, ResolveError> {
112 let registry = PlatformRegistry::with_auto_discovered();
113 resolve_dialect_by_name_with_registry(name, ®istry)
114}
115
116pub fn resolve_dialect_by_name_with_registry(
122 name: &str,
123 registry: &PlatformRegistry,
124) -> Result<Arc<dyn PlatformDialect>, ResolveError> {
125 let normalized = name.trim().to_lowercase();
126
127 if let Some(canonical) = alias_lookup(&normalized)
129 && let Some(dialect) = registry.get_by_name(canonical)
130 {
131 return Ok(Arc::clone(dialect));
132 }
133
134 if let Some(dialect) = registry.get_by_name(&normalized) {
136 return Ok(Arc::clone(dialect));
137 }
138
139 let all_names = registered_names(registry);
141 let suggestions = near_misses(&normalized, &all_names);
142 Err(ResolveError::UnknownDialect { name: name.to_string(), suggestions, registered: all_names })
143}
144
145#[must_use]
152pub const fn alias_spellings() -> &'static [(&'static str, &'static str)] {
153 ALIAS_TABLE
154}
155
156#[must_use]
158pub fn all_accepted_names() -> Vec<String> {
159 let registry = PlatformRegistry::with_auto_discovered();
160 let mut names: Vec<String> =
161 ALIAS_TABLE.iter().map(|(alias, _)| (*alias).to_string()).collect();
162 for info in registry.list() {
163 let n = info.name.to_string();
164 if !names.contains(&n) {
165 names.push(n);
166 }
167 }
168 names.sort();
169 names.dedup();
170 names
171}
172
173fn alias_lookup(normalized: &str) -> Option<&'static str> {
177 ALIAS_TABLE.iter().find(|(alias, _)| *alias == normalized).map(|(_, canonical)| *canonical)
178}
179
180fn registered_names(registry: &PlatformRegistry) -> Vec<String> {
182 let mut names: Vec<String> = registry.list().iter().map(|info| info.name.to_string()).collect();
183 names.sort();
184 names
185}
186
187fn near_misses(input: &str, candidates: &[String]) -> Vec<String> {
189 let mut scored: Vec<(&str, usize)> = candidates
190 .iter()
191 .filter_map(|c| {
192 let dist = levenshtein(input, c);
193 if dist <= 3 { Some((c.as_str(), dist)) } else { None }
194 })
195 .collect();
196 scored.sort_by_key(|(_, d)| *d);
197 scored.into_iter().map(|(name, _)| name.to_string()).collect()
198}
199
200fn levenshtein(a: &str, b: &str) -> usize {
202 let a_bytes = a.as_bytes();
203 let b_bytes = b.as_bytes();
204 let m = a_bytes.len();
205 let n = b_bytes.len();
206
207 let mut prev: Vec<usize> = (0..=n).collect();
208 let mut curr = vec![0; n + 1];
209
210 for i in 1..=m {
211 curr[0] = i;
212 for j in 1..=n {
213 let cost = usize::from(a_bytes[i - 1] != b_bytes[j - 1]);
214 curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost);
215 }
216 std::mem::swap(&mut prev, &mut curr);
217 }
218 prev[n]
219}
220
221#[must_use]
224pub fn all_aliases() -> &'static [(&'static str, &'static str)] {
225 ALIAS_TABLE
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231 use crate::dialect::{DialectInfo, PlatformDialect, PlatformRegistry};
232 use razeri_plugin_contract::DataType;
233 use razeri_plugin_contract::DialectFamily;
234 use razeri_plugin_contract::{
235 DdlRenderer, DialectCapabilities, DmlRenderer, ExpressionRenderer, FormulaRenderer,
236 ProcedureRenderer, ReplicationRenderer, SqlRenderer, StorageRenderer, TaskRenderer,
237 };
238
239 struct StubDialect {
241 info: DialectInfo,
242 }
243
244 impl SqlRenderer for StubDialect {
245 fn render_data_type(&self, _dt: &DataType) -> String {
246 String::new()
247 }
248 fn quote_identifier(&self, name: &str) -> String {
249 format!("\"{name}\"")
250 }
251 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
252 self.quote_identifier(name)
253 }
254 fn render_current_timestamp(&self) -> String {
255 String::new()
256 }
257 }
258 impl ExpressionRenderer for StubDialect {}
259 impl DdlRenderer for StubDialect {}
260 impl DmlRenderer for StubDialect {}
261 impl ProcedureRenderer for StubDialect {}
262 impl FormulaRenderer for StubDialect {}
263 impl TaskRenderer for StubDialect {}
264 impl DialectCapabilities for StubDialect {}
265 impl ReplicationRenderer for StubDialect {}
266 impl StorageRenderer for StubDialect {}
267 impl PlatformDialect for StubDialect {
268 fn info(&self) -> DialectInfo {
269 self.info.clone()
270 }
271 }
272
273 fn test_registry() -> PlatformRegistry {
274 let mut reg = PlatformRegistry::new();
275 let entries: &[(&str, &str, DialectFamily, &str, bool, bool, bool)] = &[
276 ("postgres", "PostgreSQL", DialectFamily::PostgreSql, "PostgreSQL", false, false, true),
277 ("snowflake", "Snowflake", DialectFamily::Snowflake, "Snowflake", true, true, false),
278 ("sqlserver", "SQL Server", DialectFamily::TSql, "T-SQL", false, false, false),
279 ("databricks", "Databricks", DialectFamily::SparkSql, "Spark SQL", true, true, false),
280 (
281 "clickhouse",
282 "ClickHouse",
283 DialectFamily::ClickHouse,
284 "ClickHouse SQL",
285 false,
286 true,
287 false,
288 ),
289 ("duckdb", "DuckDB", DialectFamily::PostgreSql, "PostgreSQL", false, true, true),
290 ("citus", "Citus", DialectFamily::PostgreSql, "PostgreSQL", false, false, true),
291 ("mariadb", "MariaDB", DialectFamily::MariaDB, "MariaDB", false, true, false),
292 ("mysql", "MySQL", DialectFamily::MariaDB, "MariaDB", false, false, false),
293 ("agnostic", "Agnostic", DialectFamily::Razeri, "ANSI", false, false, false),
294 ("sqlite", "SQLite", DialectFamily::MariaDB, "SQLite", false, false, false),
295 ];
296 for &(name, display, family, fam_str, cloud, olap, pg_compat) in entries {
297 reg.register(
298 name.to_string(),
299 Arc::new(StubDialect {
300 info: DialectInfo {
301 name,
302 display_name: display,
303 dialect_family: family,
304 family: fam_str,
305 is_cloud_native: cloud,
306 is_olap: olap,
307 is_postgres_compatible: pg_compat,
308 },
309 }),
310 );
311 }
312 reg
313 }
314
315 #[test]
316 fn alias_lookup_finds_canonical_names() {
317 assert_eq!(alias_lookup("pg"), Some("postgres"));
318 assert_eq!(alias_lookup("postgresql"), Some("postgres"));
319 assert_eq!(alias_lookup("tsql"), Some("sqlserver"));
320 assert_eq!(alias_lookup("mssql"), Some("sqlserver"));
321 assert_eq!(alias_lookup("sf"), Some("snowflake"));
322 assert_eq!(alias_lookup("ch"), Some("clickhouse"));
323 assert_eq!(alias_lookup("duck"), Some("duckdb"));
324 assert_eq!(alias_lookup("dbx"), Some("databricks"));
325 assert_eq!(alias_lookup("spark"), Some("databricks"));
326 assert_eq!(alias_lookup("citusdb"), Some("citus"));
327 assert_eq!(alias_lookup("sqlite"), Some("sqlite"));
328 assert_eq!(alias_lookup("agnostic"), Some("agnostic"));
329 }
330
331 #[test]
332 fn alias_lookup_with_unknown_returns_none() {
333 assert_eq!(alias_lookup("nosuchdb"), None);
334 }
335
336 #[test]
337 fn levenshtein_distance_basic() {
338 assert_eq!(levenshtein("postgres", "postgres"), 0);
339 assert_eq!(levenshtein("postgre", "postgres"), 1);
340 assert_eq!(levenshtein("postgrse", "postgres"), 2);
341 assert_eq!(levenshtein("", "abc"), 3);
342 }
343
344 #[test]
345 fn resolve_with_canonical_name_succeeds() {
346 let reg = test_registry();
347 assert!(resolve_dialect_by_name_with_registry("postgres", ®).is_ok());
348 }
349
350 #[test]
351 fn resolve_with_alias_succeeds() {
352 let reg = test_registry();
353 assert!(resolve_dialect_by_name_with_registry("pg", ®).is_ok());
354 }
355
356 #[test]
357 fn resolve_is_case_insensitive() {
358 let reg = test_registry();
359 assert!(resolve_dialect_by_name_with_registry("SNOWFLAKE", ®).is_ok());
360 }
361
362 #[test]
363 fn resolve_sqlite_extension_succeeds() {
364 let reg = test_registry();
365 assert!(resolve_dialect_by_name_with_registry("sqlite", ®).is_ok());
366 }
367
368 #[test]
369 fn resolve_databricks_succeeds() {
370 let reg = test_registry();
371 assert!(resolve_dialect_by_name_with_registry("databricks", ®).is_ok());
372 }
373
374 #[test]
375 fn resolve_mssql_alias_succeeds() {
376 let reg = test_registry();
377 assert!(resolve_dialect_by_name_with_registry("mssql", ®).is_ok());
378 }
379
380 #[test]
381 fn resolve_unknown_returns_error_with_suggestions() {
382 let reg = test_registry();
383 let result = resolve_dialect_by_name_with_registry("postres", ®);
384 let Err(err) = result else { panic!("expected error") };
385 let msg = err.to_string();
386 assert!(msg.contains("unknown dialect"), "error should mention unknown dialect: {msg}");
387 assert!(msg.contains("postgres"), "error should suggest postgres: {msg}");
388 }
389
390 #[test]
391 fn resolve_completely_unknown_returns_registered_list() {
392 let reg = test_registry();
393 let result = resolve_dialect_by_name_with_registry("zzzznotadb", ®);
394 let Err(err) = result else { panic!("expected error") };
395 let msg = err.to_string();
396 assert!(msg.contains("Registered:"), "error should list registered: {msg}");
397 }
398
399 #[test]
400 fn all_accepted_names_includes_aliases_and_registered() {
401 let names = all_accepted_names();
402 assert!(names.contains(&"pg".to_string()));
403 assert!(names.contains(&"postgres".to_string()));
404 assert!(names.contains(&"snowflake".to_string()));
405 }
406
407 #[test]
426 fn design_schema_platform_enum_matches_the_alias_table() {
427 let schema_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
428 .join("../../schemas/razeri-design.schema.json");
429 let text = std::fs::read_to_string(&schema_path)
430 .unwrap_or_else(|e| panic!("read {}: {e}", schema_path.display()));
431 let schema: serde_json::Value =
432 serde_json::from_str(&text).unwrap_or_else(|e| panic!("parse the design schema: {e}"));
433
434 let enum_values = schema["$defs"]["DatabasePlatform"]["enum"]
435 .as_array()
436 .expect("$defs.DatabasePlatform.enum is an array in the published design schema");
437 let mut published: Vec<String> = enum_values
438 .iter()
439 .map(|v| v.as_str().expect("every DatabasePlatform enum value is a string").to_string())
440 .collect();
441 published.sort();
442 published.dedup();
443
444 let mut accepted: Vec<String> =
445 ALIAS_TABLE.iter().map(|(alias, _)| (*alias).to_string()).collect();
446 accepted.sort();
447 accepted.dedup();
448
449 let advertised_but_refused: Vec<&String> =
450 published.iter().filter(|n| !accepted.contains(n)).collect();
451 let accepted_but_unpublished: Vec<&String> =
452 accepted.iter().filter(|n| !published.contains(n)).collect();
453
454 assert!(
455 advertised_but_refused.is_empty() && accepted_but_unpublished.is_empty(),
456 "schemas/razeri-design.schema.json $defs.DatabasePlatform has drifted from the \
457 canonical resolver's ALIAS_TABLE.\n advertised by the schema but REFUSED by the \
458 resolver: {advertised_but_refused:?}\n accepted by the resolver but MISSING from \
459 the schema: {accepted_but_unpublished:?}\nThe alias table is the authority: add the \
460 spelling to the schema's enum, or take it out of the alias table — do not let the \
461 two vocabularies differ."
462 );
463 }
464}