1use crate::PlatformDialect;
9use crate::dialect::PlatformRegistry;
10use std::sync::Arc;
11use thiserror::Error;
12
13#[derive(Debug, Error)]
15pub enum ResolveError {
16 #[error(
18 "unknown dialect: '{name}'. {suggestions_text}Registered: {registered}",
19 suggestions_text = if suggestions.is_empty() {
20 String::new()
21 } else {
22 format!("Did you mean: {}? ", suggestions.join(", "))
23 },
24 registered = registered.join(", ")
25 )]
26 UnknownDialect {
27 name: String,
29 suggestions: Vec<String>,
31 registered: Vec<String>,
33 },
34}
35
36const ALIAS_TABLE: &[(&str, &str)] = &[
41 ("postgres", "postgres"),
43 ("postgresql", "postgres"),
44 ("pg", "postgres"),
45 ("snowflake", "snowflake"),
47 ("sf", "snowflake"),
48 ("sqlserver", "sqlserver"),
50 ("tsql", "sqlserver"),
51 ("mssql", "sqlserver"),
52 ("sql_server", "sqlserver"),
53 ("databricks", "databricks"),
55 ("dbx", "databricks"),
56 ("spark", "databricks"),
57 ("duckdb", "duckdb"),
59 ("duck", "duckdb"),
60 ("clickhouse", "clickhouse"),
62 ("ch", "clickhouse"),
63 ("mariadb", "mariadb"),
65 ("mariadb_columnstore", "mariadb"),
66 ("mysql", "mysql"),
67 ("citusdb", "citus"),
69 ("citus", "citus"),
70 ("agnostic", "agnostic"),
72 ("sqlite", "sqlite"),
74 ("bigquery", "bigquery"),
76 ("big_query", "bigquery"),
77 ("bq", "bigquery"),
78 ("redshift", "redshift"),
80 ("amazon_redshift", "redshift"),
81 ("rs", "redshift"),
82];
83
84pub fn resolve_dialect_by_name(name: &str) -> Result<Arc<dyn PlatformDialect>, ResolveError> {
110 let registry = PlatformRegistry::with_auto_discovered();
111 resolve_dialect_by_name_with_registry(name, ®istry)
112}
113
114pub fn resolve_dialect_by_name_with_registry(
120 name: &str,
121 registry: &PlatformRegistry,
122) -> Result<Arc<dyn PlatformDialect>, ResolveError> {
123 let normalized = name.trim().to_lowercase();
124
125 if let Some(canonical) = alias_lookup(&normalized)
127 && let Some(dialect) = registry.get_by_name(canonical)
128 {
129 return Ok(Arc::clone(dialect));
130 }
131
132 if let Some(dialect) = registry.get_by_name(&normalized) {
134 return Ok(Arc::clone(dialect));
135 }
136
137 let all_names = registered_names(registry);
139 let suggestions = near_misses(&normalized, &all_names);
140 Err(ResolveError::UnknownDialect { name: name.to_string(), suggestions, registered: all_names })
141}
142
143#[must_use]
150pub const fn alias_spellings() -> &'static [(&'static str, &'static str)] {
151 ALIAS_TABLE
152}
153
154#[must_use]
156pub fn all_accepted_names() -> Vec<String> {
157 let registry = PlatformRegistry::with_auto_discovered();
158 let mut names: Vec<String> =
159 ALIAS_TABLE.iter().map(|(alias, _)| (*alias).to_string()).collect();
160 for info in registry.list() {
161 let n = info.name.to_string();
162 if !names.contains(&n) {
163 names.push(n);
164 }
165 }
166 names.sort();
167 names.dedup();
168 names
169}
170
171fn alias_lookup(normalized: &str) -> Option<&'static str> {
175 ALIAS_TABLE.iter().find(|(alias, _)| *alias == normalized).map(|(_, canonical)| *canonical)
176}
177
178fn registered_names(registry: &PlatformRegistry) -> Vec<String> {
180 let mut names: Vec<String> = registry.list().iter().map(|info| info.name.to_string()).collect();
181 names.sort();
182 names
183}
184
185fn near_misses(input: &str, candidates: &[String]) -> Vec<String> {
187 let mut scored: Vec<(&str, usize)> = candidates
188 .iter()
189 .filter_map(|c| {
190 let dist = levenshtein(input, c);
191 if dist <= 3 { Some((c.as_str(), dist)) } else { None }
192 })
193 .collect();
194 scored.sort_by_key(|(_, d)| *d);
195 scored.into_iter().map(|(name, _)| name.to_string()).collect()
196}
197
198fn levenshtein(a: &str, b: &str) -> usize {
200 let a_bytes = a.as_bytes();
201 let b_bytes = b.as_bytes();
202 let m = a_bytes.len();
203 let n = b_bytes.len();
204
205 let mut prev: Vec<usize> = (0..=n).collect();
206 let mut curr = vec![0; n + 1];
207
208 for i in 1..=m {
209 curr[0] = i;
210 for j in 1..=n {
211 let cost = usize::from(a_bytes[i - 1] != b_bytes[j - 1]);
212 curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost);
213 }
214 std::mem::swap(&mut prev, &mut curr);
215 }
216 prev[n]
217}
218
219#[must_use]
222pub fn all_aliases() -> &'static [(&'static str, &'static str)] {
223 ALIAS_TABLE
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229 use crate::dialect::{DialectInfo, PlatformDialect, PlatformRegistry};
230 use razeri_plugin_contract::DataType;
231 use razeri_plugin_contract::DialectFamily;
232 use razeri_plugin_contract::{
233 DdlRenderer, DialectCapabilities, DmlRenderer, ExpressionRenderer, FormulaRenderer,
234 ProcedureRenderer, ReplicationRenderer, SqlRenderer, StorageRenderer, TaskRenderer,
235 };
236
237 struct StubDialect {
239 info: DialectInfo,
240 }
241
242 impl SqlRenderer for StubDialect {
243 fn render_data_type(&self, _dt: &DataType) -> String {
244 String::new()
245 }
246 fn quote_identifier(&self, name: &str) -> String {
247 format!("\"{name}\"")
248 }
249 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
250 self.quote_identifier(name)
251 }
252 fn render_current_timestamp(&self) -> String {
253 String::new()
254 }
255 }
256 impl ExpressionRenderer for StubDialect {}
257 impl DdlRenderer for StubDialect {}
258 impl DmlRenderer for StubDialect {}
259 impl ProcedureRenderer for StubDialect {}
260 impl FormulaRenderer for StubDialect {}
261 impl TaskRenderer for StubDialect {}
262 impl DialectCapabilities for StubDialect {}
263 impl ReplicationRenderer for StubDialect {}
264 impl StorageRenderer for StubDialect {}
265 impl PlatformDialect for StubDialect {
266 fn info(&self) -> DialectInfo {
267 self.info.clone()
268 }
269 }
270
271 fn test_registry() -> PlatformRegistry {
272 let mut reg = PlatformRegistry::new();
273 let entries: &[(&str, &str, DialectFamily, &str, bool, bool, bool)] = &[
274 ("postgres", "PostgreSQL", DialectFamily::PostgreSql, "PostgreSQL", false, false, true),
275 ("snowflake", "Snowflake", DialectFamily::Snowflake, "Snowflake", true, true, false),
276 ("sqlserver", "SQL Server", DialectFamily::TSql, "T-SQL", false, false, false),
277 ("databricks", "Databricks", DialectFamily::SparkSql, "Spark SQL", true, true, false),
278 (
279 "clickhouse",
280 "ClickHouse",
281 DialectFamily::ClickHouse,
282 "ClickHouse SQL",
283 false,
284 true,
285 false,
286 ),
287 ("duckdb", "DuckDB", DialectFamily::PostgreSql, "PostgreSQL", false, true, true),
288 ("citus", "Citus", DialectFamily::PostgreSql, "PostgreSQL", false, false, true),
289 ("mariadb", "MariaDB", DialectFamily::MariaDB, "MariaDB", false, true, false),
290 ("mysql", "MySQL", DialectFamily::MariaDB, "MariaDB", false, false, false),
291 ("agnostic", "Agnostic", DialectFamily::Razeri, "ANSI", false, false, false),
292 ("sqlite", "SQLite", DialectFamily::MariaDB, "SQLite", false, false, false),
293 ];
294 for &(name, display, family, fam_str, cloud, olap, pg_compat) in entries {
295 reg.register(
296 name.to_string(),
297 Arc::new(StubDialect {
298 info: DialectInfo {
299 name,
300 display_name: display,
301 dialect_family: family,
302 family: fam_str,
303 is_cloud_native: cloud,
304 is_olap: olap,
305 is_postgres_compatible: pg_compat,
306 },
307 }),
308 );
309 }
310 reg
311 }
312
313 #[test]
314 fn alias_lookup_finds_canonical_names() {
315 assert_eq!(alias_lookup("pg"), Some("postgres"));
316 assert_eq!(alias_lookup("postgresql"), Some("postgres"));
317 assert_eq!(alias_lookup("tsql"), Some("sqlserver"));
318 assert_eq!(alias_lookup("mssql"), Some("sqlserver"));
319 assert_eq!(alias_lookup("sf"), Some("snowflake"));
320 assert_eq!(alias_lookup("ch"), Some("clickhouse"));
321 assert_eq!(alias_lookup("duck"), Some("duckdb"));
322 assert_eq!(alias_lookup("dbx"), Some("databricks"));
323 assert_eq!(alias_lookup("spark"), Some("databricks"));
324 assert_eq!(alias_lookup("citusdb"), Some("citus"));
325 assert_eq!(alias_lookup("sqlite"), Some("sqlite"));
326 assert_eq!(alias_lookup("agnostic"), Some("agnostic"));
327 }
328
329 #[test]
330 fn alias_lookup_with_unknown_returns_none() {
331 assert_eq!(alias_lookup("nosuchdb"), None);
332 }
333
334 #[test]
335 fn levenshtein_distance_basic() {
336 assert_eq!(levenshtein("postgres", "postgres"), 0);
337 assert_eq!(levenshtein("postgre", "postgres"), 1);
338 assert_eq!(levenshtein("postgrse", "postgres"), 2);
339 assert_eq!(levenshtein("", "abc"), 3);
340 }
341
342 #[test]
343 fn resolve_with_canonical_name_succeeds() {
344 let reg = test_registry();
345 assert!(resolve_dialect_by_name_with_registry("postgres", ®).is_ok());
346 }
347
348 #[test]
349 fn resolve_with_alias_succeeds() {
350 let reg = test_registry();
351 assert!(resolve_dialect_by_name_with_registry("pg", ®).is_ok());
352 }
353
354 #[test]
355 fn resolve_is_case_insensitive() {
356 let reg = test_registry();
357 assert!(resolve_dialect_by_name_with_registry("SNOWFLAKE", ®).is_ok());
358 }
359
360 #[test]
361 fn resolve_sqlite_extension_succeeds() {
362 let reg = test_registry();
363 assert!(resolve_dialect_by_name_with_registry("sqlite", ®).is_ok());
364 }
365
366 #[test]
367 fn resolve_databricks_succeeds() {
368 let reg = test_registry();
369 assert!(resolve_dialect_by_name_with_registry("databricks", ®).is_ok());
370 }
371
372 #[test]
373 fn resolve_mssql_alias_succeeds() {
374 let reg = test_registry();
375 assert!(resolve_dialect_by_name_with_registry("mssql", ®).is_ok());
376 }
377
378 #[test]
379 fn resolve_unknown_returns_error_with_suggestions() {
380 let reg = test_registry();
381 let result = resolve_dialect_by_name_with_registry("postres", ®);
382 let Err(err) = result else { panic!("expected error") };
383 let msg = err.to_string();
384 assert!(msg.contains("unknown dialect"), "error should mention unknown dialect: {msg}");
385 assert!(msg.contains("postgres"), "error should suggest postgres: {msg}");
386 }
387
388 #[test]
389 fn resolve_completely_unknown_returns_registered_list() {
390 let reg = test_registry();
391 let result = resolve_dialect_by_name_with_registry("zzzznotadb", ®);
392 let Err(err) = result else { panic!("expected error") };
393 let msg = err.to_string();
394 assert!(msg.contains("Registered:"), "error should list registered: {msg}");
395 }
396
397 #[test]
398 fn all_accepted_names_includes_aliases_and_registered() {
399 let names = all_accepted_names();
400 assert!(names.contains(&"pg".to_string()));
401 assert!(names.contains(&"postgres".to_string()));
402 assert!(names.contains(&"snowflake".to_string()));
403 }
404
405 #[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}