Skip to main content

razeri_plugin_api/
dialect.rs

1// Copyright (C) 2026 Gabriel Shea
2// Licensed under either of the Apache License, Version 2.0 or the MIT
3// license, at your option.
4
5//! Platform dialect trait and registry for database SQL rendering.
6//!
7//! [`PlatformDialect`] extends the rendering trait hierarchy from
8//! `razeri-plugin-contract` with plugin metadata and registration support.
9//! [`PlatformRegistry`] resolves dialects by canonical string name,
10//! so built-in and third-party plugins are looked up the same way — there is no
11//! closed platform enum, and an unknown name is an error, never a fallback.
12
13use razeri_plugin_contract::DialectFamily;
14use razeri_plugin_contract::{
15    DdlRenderer, DialectCapabilities, DmlRenderer, ExpressionRenderer, FormulaRenderer,
16    ProcedureRenderer, ReplicationRenderer, SqlRenderer, StorageRenderer, TaskRenderer,
17};
18use std::collections::HashMap;
19use std::sync::Arc;
20
21// Re-export rendering traits so dialect crates can import from one place.
22pub use razeri_plugin_contract;
23
24/// Metadata describing a dialect implementation.
25///
26/// Serializes to the discovery payload returned by `list_platforms()` across
27/// every surface (and consumed by the typed-constants generator), so hosts learn
28/// the registered platforms — including third-party plugins — at runtime instead
29/// of hard-coding them.
30#[derive(Debug, Clone, serde::Serialize)]
31pub struct DialectInfo {
32    /// Machine-readable name (e.g., "postgres", "redshift").
33    pub name: &'static str,
34    /// Human-readable display name (e.g., "`PostgreSQL`", "Amazon Redshift").
35    pub display_name: &'static str,
36    /// The other spellings this dialect answers to (e.g. `["pg", "postgresql"]`).
37    ///
38    /// A dialect owns its own names. [`name`](Self::name) always resolves; this
39    /// is everything else a design or a `--target` may say for the same
40    /// platform. Matching ignores case and surrounding whitespace, so
41    /// `"PostgreSQL"` needs no separate entry. Declare `&[]` to answer to
42    /// `name` alone.
43    ///
44    /// Two loaded dialects must not claim the same spelling: a spelling that
45    /// names two platforms names neither, and resolving it is refused rather
46    /// than guessed.
47    pub aliases: &'static [&'static str],
48    /// Dialect family for shared behavior (e.g., `PostgreSQL`, `TSql`).
49    pub dialect_family: DialectFamily,
50    /// SQL family label (e.g., "`PostgreSQL`", "T-SQL", "`ClickHouse` SQL").
51    pub family: &'static str,
52    /// Whether this is a cloud-native platform (e.g. Snowflake, Databricks).
53    pub is_cloud_native: bool,
54    /// Whether this is primarily an OLAP engine (e.g. `ClickHouse`).
55    pub is_olap: bool,
56    /// Whether this dialect is PostgreSQL-wire-compatible.
57    pub is_postgres_compatible: bool,
58}
59
60/// Full plugin trait for database platform dialects.
61///
62/// Extends the rendering trait hierarchy (`SqlRenderer`, `ExpressionRenderer`,
63/// `DdlRenderer`, `DmlRenderer`, `ProcedureRenderer`, `FormulaRenderer`,
64/// `TaskRenderer`, `ReplicationRenderer`, `StorageRenderer`, `DialectCapabilities`)
65/// with plugin-specific metadata and registration support.
66///
67/// Dialect authors implement the rendering traits plus `PlatformDialect::info()`.
68pub trait PlatformDialect:
69    SqlRenderer
70    + ExpressionRenderer
71    + DdlRenderer
72    + DmlRenderer
73    + ProcedureRenderer
74    + FormulaRenderer
75    + TaskRenderer
76    + DialectCapabilities
77    + ReplicationRenderer
78    + StorageRenderer
79{
80    /// Returns metadata about this dialect.
81    fn info(&self) -> DialectInfo;
82}
83
84impl DialectInfo {
85    /// Every spelling this dialect answers to: its own name, then its aliases.
86    ///
87    /// This is what the resolver reads, so it is also the honest answer to
88    /// "what may a design call this platform?". An alias repeating the name is
89    /// listed once.
90    #[must_use]
91    pub fn spellings(&self) -> Vec<&'static str> {
92        let mut out = Vec::with_capacity(self.aliases.len() + 1);
93        out.push(self.name);
94        for alias in self.aliases {
95            if !out.contains(alias) {
96                out.push(alias);
97            }
98        }
99        out
100    }
101
102    /// Whether this dialect answers to `spelling`, ignoring case and
103    /// surrounding whitespace.
104    #[must_use]
105    pub fn answers_to(&self, spelling: &str) -> bool {
106        let wanted = spelling.trim();
107        self.spellings().iter().any(|known| known.eq_ignore_ascii_case(wanted))
108    }
109}
110
111/// String-keyed registry for platform dialects.
112///
113/// All dialects are indexed by their canonical string name
114/// (e.g. `"postgres"`, `"snowflake"`, `"mysql"`).
115pub struct PlatformRegistry {
116    dialects: HashMap<String, Arc<dyn PlatformDialect>>,
117}
118
119impl PlatformRegistry {
120    /// Create an empty registry.
121    #[must_use]
122    pub fn new() -> Self {
123        Self { dialects: HashMap::new() }
124    }
125
126    /// Register a dialect by its canonical string name.
127    pub fn register(&mut self, name: String, dialect: Arc<dyn PlatformDialect>) {
128        self.dialects.insert(name, dialect);
129    }
130
131    /// Resolve by string name (canonical path for all dialects).
132    #[must_use]
133    pub fn get_by_name(&self, name: &str) -> Option<&Arc<dyn PlatformDialect>> {
134        self.dialects.get(name)
135    }
136
137    /// Resolve by string — same as [`get_by_name`](Self::get_by_name).
138    #[must_use]
139    pub fn resolve(&self, target: &str) -> Option<Arc<dyn PlatformDialect>> {
140        self.dialects.get(target).cloned()
141    }
142
143    /// List all registered dialects.
144    #[must_use]
145    pub fn list(&self) -> Vec<DialectInfo> {
146        self.dialects.values().map(|d| d.info()).collect()
147    }
148
149    /// Sorted machine-readable names of every registered dialect.
150    ///
151    /// The stable, ordered form of [`list`](Self::list); the
152    /// [`resolve`](crate::resolve) module uses it for error messages and the
153    /// typed-constants generator reads it for deterministic output.
154    #[must_use]
155    pub fn registered_names(&self) -> Vec<String> {
156        let mut names: Vec<String> = self.dialects.keys().cloned().collect();
157        names.sort();
158        names
159    }
160
161    /// Build a registry pre-populated with all auto-discovered dialect crates.
162    ///
163    /// Uses `inventory` to find every [`crate::builder::DialectRegistration`]
164    /// submitted at link time.
165    #[must_use]
166    pub fn with_auto_discovered() -> Self {
167        use crate::builder::DialectRegistration;
168        let mut registry = Self::new();
169        for reg in inventory::iter::<DialectRegistration> {
170            let dialect = (reg.factory)();
171            let name = dialect.info().name.to_string();
172            registry.register(name, dialect);
173        }
174        // Plugin crates linked into this binary with `export_plugin!`
175        // register through a builder; the dialects are what is kept here.
176        for reg in inventory::iter::<crate::builder::PluginRegistration> {
177            let mut linked = crate::builder::PluginRegistryBuilder::new();
178            (reg.register)(&mut linked);
179            for (name, dialect) in linked.build().dialects.dialects {
180                registry.register(name, dialect);
181            }
182        }
183        registry
184    }
185}
186
187impl Default for PlatformRegistry {
188    fn default() -> Self {
189        Self::new()
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use razeri_plugin_contract::DataType;
197
198    /// Test dialect for unit tests.
199    struct TestDialect {
200        info: DialectInfo,
201    }
202
203    impl TestDialect {
204        fn postgres() -> Self {
205            Self {
206                info: DialectInfo {
207                    name: "postgres",
208                    display_name: "PostgreSQL",
209                    aliases: &["postgresql", "pg"],
210                    dialect_family: DialectFamily::PostgreSql,
211                    family: "PostgreSQL",
212                    is_cloud_native: false,
213                    is_olap: false,
214                    is_postgres_compatible: true,
215                },
216            }
217        }
218
219        fn redshift() -> Self {
220            Self {
221                info: DialectInfo {
222                    name: "redshift",
223                    display_name: "Amazon Redshift",
224                    aliases: &["amazon_redshift", "rs"],
225                    dialect_family: DialectFamily::PostgreSql,
226                    family: "PostgreSQL",
227                    is_cloud_native: true,
228                    is_olap: true,
229                    is_postgres_compatible: true,
230                },
231            }
232        }
233
234        fn snowflake() -> Self {
235            Self {
236                info: DialectInfo {
237                    name: "snowflake",
238                    display_name: "Snowflake",
239                    aliases: &["sf"],
240                    dialect_family: DialectFamily::Snowflake,
241                    family: "Snowflake",
242                    is_cloud_native: true,
243                    is_olap: true,
244                    is_postgres_compatible: false,
245                },
246            }
247        }
248    }
249
250    impl SqlRenderer for TestDialect {
251        fn render_data_type(&self, _dt: &DataType) -> String {
252            "TEXT".to_string()
253        }
254
255        fn quote_identifier(&self, name: &str) -> String {
256            format!("\"{name}\"")
257        }
258
259        fn qualify_name(&self, schema: Option<&str>, name: &str) -> String {
260            match schema {
261                Some(s) => format!("{}.{}", self.quote_identifier(s), self.quote_identifier(name)),
262                None => self.quote_identifier(name),
263            }
264        }
265
266        fn render_current_timestamp(&self) -> String {
267            "CURRENT_TIMESTAMP".to_string()
268        }
269    }
270
271    impl ExpressionRenderer for TestDialect {
272        fn render_concat(&self, parts: &[String]) -> String {
273            parts.join(" || ")
274        }
275    }
276
277    impl DdlRenderer for TestDialect {}
278    impl DmlRenderer for TestDialect {}
279    impl ProcedureRenderer for TestDialect {}
280    impl FormulaRenderer for TestDialect {}
281    impl TaskRenderer for TestDialect {}
282    impl ReplicationRenderer for TestDialect {}
283    impl StorageRenderer for TestDialect {}
284
285    impl DialectCapabilities for TestDialect {
286        fn supports_identity(&self) -> bool {
287            true
288        }
289        fn supports_sequences(&self) -> bool {
290            true
291        }
292        fn supports_stored_procedures(&self) -> bool {
293            true
294        }
295    }
296
297    impl PlatformDialect for TestDialect {
298        fn info(&self) -> DialectInfo {
299            self.info.clone()
300        }
301    }
302
303    #[test]
304    fn register_dialect_resolves_by_name() {
305        let mut registry = PlatformRegistry::new();
306        registry.register("postgres".to_string(), Arc::new(TestDialect::postgres()));
307        assert!(registry.get_by_name("postgres").is_some());
308    }
309
310    #[test]
311    fn register_extension_resolves_by_name() {
312        let mut registry = PlatformRegistry::new();
313        registry.register("redshift".to_string(), Arc::new(TestDialect::redshift()));
314        assert!(registry.get_by_name("redshift").is_some());
315    }
316
317    #[test]
318    fn resolve_tries_name_lookup() {
319        let mut registry = PlatformRegistry::new();
320        registry.register("postgres".to_string(), Arc::new(TestDialect::postgres()));
321        registry.register("redshift".to_string(), Arc::new(TestDialect::redshift()));
322
323        assert!(registry.resolve("postgres").is_some());
324        assert!(registry.resolve("redshift").is_some());
325        assert!(registry.resolve("nonexistent").is_none());
326    }
327
328    #[test]
329    fn list_returns_all_registered_dialects() {
330        let mut registry = PlatformRegistry::new();
331        registry.register("postgres".to_string(), Arc::new(TestDialect::postgres()));
332        registry.register("snowflake".to_string(), Arc::new(TestDialect::snowflake()));
333        registry.register("redshift".to_string(), Arc::new(TestDialect::redshift()));
334
335        let list = registry.list();
336        assert_eq!(list.len(), 3);
337    }
338
339    #[test]
340    fn duplicate_registration_overwrites() {
341        let mut registry = PlatformRegistry::new();
342        registry.register("postgres".to_string(), Arc::new(TestDialect::postgres()));
343        registry.register("postgres".to_string(), Arc::new(TestDialect::postgres()));
344
345        assert!(registry.get_by_name("postgres").is_some());
346        let list = registry.list();
347        assert_eq!(list.len(), 1);
348    }
349
350    #[test]
351    fn dialect_info_contains_expected_metadata() {
352        let dialect = TestDialect::postgres();
353        let info = dialect.info();
354        assert_eq!(info.name, "postgres");
355        assert_eq!(info.display_name, "PostgreSQL");
356        assert_eq!(info.dialect_family, DialectFamily::PostgreSql);
357    }
358
359    #[test]
360    fn classification_fields_reflect_dialect_characteristics() {
361        let pg = TestDialect::postgres();
362        let pg_info = pg.info();
363        assert_eq!(pg_info.family, "PostgreSQL");
364        assert!(pg_info.is_postgres_compatible);
365        assert!(!pg_info.is_cloud_native);
366        assert!(!pg_info.is_olap);
367
368        let sf = TestDialect::snowflake();
369        let sf_info = sf.info();
370        assert_eq!(sf_info.family, "Snowflake");
371        assert!(sf_info.is_cloud_native);
372        assert!(sf_info.is_olap);
373        assert!(!sf_info.is_postgres_compatible);
374
375        let rd = TestDialect::redshift();
376        let rd_info = rd.info();
377        assert!(rd_info.is_cloud_native);
378        assert!(rd_info.is_olap);
379        assert!(rd_info.is_postgres_compatible);
380    }
381
382    #[test]
383    fn empty_registry_returns_none() {
384        let registry = PlatformRegistry::new();
385        assert!(registry.get_by_name("postgres").is_none());
386        assert!(registry.resolve("postgres").is_none());
387        assert!(registry.list().is_empty());
388    }
389
390    // ── Discovery + canonical names ─────────────────────────────────────────
391
392    #[test]
393    fn registered_names_are_sorted() {
394        let mut registry = PlatformRegistry::new();
395        registry.register("snowflake".to_string(), Arc::new(TestDialect::snowflake()));
396        registry.register("postgres".to_string(), Arc::new(TestDialect::postgres()));
397        registry.register("redshift".to_string(), Arc::new(TestDialect::redshift()));
398        assert_eq!(registry.registered_names(), vec!["postgres", "redshift", "snowflake"]);
399    }
400
401    #[test]
402    fn dialect_info_serializes_for_discovery() {
403        // The discovery payload (list_platforms) + the constants generator both
404        // read serialized DialectInfo, so it must round-trip to JSON.
405        let info = TestDialect::snowflake().info();
406        let json = serde_json::to_value(&info).expect("DialectInfo serializes");
407        assert_eq!(json["name"], "snowflake");
408        assert_eq!(json["display_name"], "Snowflake");
409        assert_eq!(json["dialect_family"], "snowflake");
410        assert_eq!(json["is_cloud_native"], true);
411        assert_eq!(json["is_postgres_compatible"], false);
412        // The spellings ride along, so a host learns what a design may call
413        // this platform from the same payload that lists it.
414        assert_eq!(json["aliases"], serde_json::json!(["sf"]));
415    }
416
417    #[test]
418    fn spellings_start_with_the_dialects_own_name() {
419        let info = TestDialect::postgres().info();
420        assert_eq!(info.spellings(), vec!["postgres", "postgresql", "pg"]);
421    }
422
423    #[test]
424    fn a_dialect_answers_to_its_name_and_its_aliases_in_any_case() {
425        let info = TestDialect::postgres().info();
426        assert!(info.answers_to("postgres"));
427        assert!(info.answers_to("pg"));
428        assert!(info.answers_to("  PostgreSQL "));
429        assert!(!info.answers_to("snowflake"));
430    }
431
432    #[test]
433    fn an_alias_repeating_the_name_is_listed_once() {
434        let info = DialectInfo { aliases: &["postgres", "pg"], ..TestDialect::postgres().info() };
435        assert_eq!(info.spellings(), vec!["postgres", "pg"]);
436    }
437}