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    /// Dialect family for shared behavior (e.g., `PostgreSQL`, `TSql`).
37    pub dialect_family: DialectFamily,
38    /// SQL family label (e.g., "`PostgreSQL`", "T-SQL", "`ClickHouse` SQL").
39    pub family: &'static str,
40    /// Whether this is a cloud-native platform (e.g. Snowflake, Databricks).
41    pub is_cloud_native: bool,
42    /// Whether this is primarily an OLAP engine (e.g. `ClickHouse`).
43    pub is_olap: bool,
44    /// Whether this dialect is PostgreSQL-wire-compatible.
45    pub is_postgres_compatible: bool,
46}
47
48/// Full plugin trait for database platform dialects.
49///
50/// Extends the rendering trait hierarchy (`SqlRenderer`, `ExpressionRenderer`,
51/// `DdlRenderer`, `DmlRenderer`, `ProcedureRenderer`, `FormulaRenderer`,
52/// `TaskRenderer`, `ReplicationRenderer`, `StorageRenderer`, `DialectCapabilities`)
53/// with plugin-specific metadata and registration support.
54///
55/// Dialect authors implement the rendering traits plus `PlatformDialect::info()`.
56pub trait PlatformDialect:
57    SqlRenderer
58    + ExpressionRenderer
59    + DdlRenderer
60    + DmlRenderer
61    + ProcedureRenderer
62    + FormulaRenderer
63    + TaskRenderer
64    + DialectCapabilities
65    + ReplicationRenderer
66    + StorageRenderer
67{
68    /// Returns metadata about this dialect.
69    fn info(&self) -> DialectInfo;
70}
71
72/// String-keyed registry for platform dialects.
73///
74/// All dialects are indexed by their canonical string name
75/// (e.g. `"postgres"`, `"snowflake"`, `"mysql"`).
76pub struct PlatformRegistry {
77    dialects: HashMap<String, Arc<dyn PlatformDialect>>,
78}
79
80impl PlatformRegistry {
81    /// Create an empty registry.
82    #[must_use]
83    pub fn new() -> Self {
84        Self { dialects: HashMap::new() }
85    }
86
87    /// Register a dialect by its canonical string name.
88    pub fn register(&mut self, name: String, dialect: Arc<dyn PlatformDialect>) {
89        self.dialects.insert(name, dialect);
90    }
91
92    /// Resolve by string name (canonical path for all dialects).
93    #[must_use]
94    pub fn get_by_name(&self, name: &str) -> Option<&Arc<dyn PlatformDialect>> {
95        self.dialects.get(name)
96    }
97
98    /// Resolve by string — same as [`get_by_name`](Self::get_by_name).
99    #[must_use]
100    pub fn resolve(&self, target: &str) -> Option<Arc<dyn PlatformDialect>> {
101        self.dialects.get(target).cloned()
102    }
103
104    /// List all registered dialects.
105    #[must_use]
106    pub fn list(&self) -> Vec<DialectInfo> {
107        self.dialects.values().map(|d| d.info()).collect()
108    }
109
110    /// Sorted machine-readable names of every registered dialect.
111    ///
112    /// The stable, ordered form of [`list`](Self::list); the
113    /// [`resolve`](crate::resolve) module uses it for error messages and the
114    /// typed-constants generator reads it for deterministic output.
115    #[must_use]
116    pub fn registered_names(&self) -> Vec<String> {
117        let mut names: Vec<String> = self.dialects.keys().cloned().collect();
118        names.sort();
119        names
120    }
121
122    /// Build a registry pre-populated with all auto-discovered dialect crates.
123    ///
124    /// Uses `inventory` to find every [`crate::builder::DialectRegistration`]
125    /// submitted at link time.
126    #[must_use]
127    pub fn with_auto_discovered() -> Self {
128        use crate::builder::DialectRegistration;
129        let mut registry = Self::new();
130        for reg in inventory::iter::<DialectRegistration> {
131            let dialect = (reg.factory)();
132            let name = dialect.info().name.to_string();
133            registry.register(name, dialect);
134        }
135        registry
136    }
137}
138
139impl Default for PlatformRegistry {
140    fn default() -> Self {
141        Self::new()
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use razeri_plugin_contract::DataType;
149
150    /// Test dialect for unit tests.
151    struct TestDialect {
152        info: DialectInfo,
153    }
154
155    impl TestDialect {
156        fn postgres() -> Self {
157            Self {
158                info: DialectInfo {
159                    name: "postgres",
160                    display_name: "PostgreSQL",
161                    dialect_family: DialectFamily::PostgreSql,
162                    family: "PostgreSQL",
163                    is_cloud_native: false,
164                    is_olap: false,
165                    is_postgres_compatible: true,
166                },
167            }
168        }
169
170        fn redshift() -> Self {
171            Self {
172                info: DialectInfo {
173                    name: "redshift",
174                    display_name: "Amazon Redshift",
175                    dialect_family: DialectFamily::PostgreSql,
176                    family: "PostgreSQL",
177                    is_cloud_native: true,
178                    is_olap: true,
179                    is_postgres_compatible: true,
180                },
181            }
182        }
183
184        fn snowflake() -> Self {
185            Self {
186                info: DialectInfo {
187                    name: "snowflake",
188                    display_name: "Snowflake",
189                    dialect_family: DialectFamily::Snowflake,
190                    family: "Snowflake",
191                    is_cloud_native: true,
192                    is_olap: true,
193                    is_postgres_compatible: false,
194                },
195            }
196        }
197    }
198
199    impl SqlRenderer for TestDialect {
200        fn render_data_type(&self, _dt: &DataType) -> String {
201            "TEXT".to_string()
202        }
203
204        fn quote_identifier(&self, name: &str) -> String {
205            format!("\"{name}\"")
206        }
207
208        fn qualify_name(&self, schema: Option<&str>, name: &str) -> String {
209            match schema {
210                Some(s) => format!("{}.{}", self.quote_identifier(s), self.quote_identifier(name)),
211                None => self.quote_identifier(name),
212            }
213        }
214
215        fn render_current_timestamp(&self) -> String {
216            "CURRENT_TIMESTAMP".to_string()
217        }
218    }
219
220    impl ExpressionRenderer for TestDialect {
221        fn render_concat(&self, parts: &[String]) -> String {
222            parts.join(" || ")
223        }
224    }
225
226    impl DdlRenderer for TestDialect {}
227    impl DmlRenderer for TestDialect {}
228    impl ProcedureRenderer for TestDialect {}
229    impl FormulaRenderer for TestDialect {}
230    impl TaskRenderer for TestDialect {}
231    impl ReplicationRenderer for TestDialect {}
232    impl StorageRenderer for TestDialect {}
233
234    impl DialectCapabilities for TestDialect {
235        fn supports_identity(&self) -> bool {
236            true
237        }
238        fn supports_sequences(&self) -> bool {
239            true
240        }
241        fn supports_stored_procedures(&self) -> bool {
242            true
243        }
244    }
245
246    impl PlatformDialect for TestDialect {
247        fn info(&self) -> DialectInfo {
248            self.info.clone()
249        }
250    }
251
252    #[test]
253    fn register_dialect_resolves_by_name() {
254        let mut registry = PlatformRegistry::new();
255        registry.register("postgres".to_string(), Arc::new(TestDialect::postgres()));
256        assert!(registry.get_by_name("postgres").is_some());
257    }
258
259    #[test]
260    fn register_extension_resolves_by_name() {
261        let mut registry = PlatformRegistry::new();
262        registry.register("redshift".to_string(), Arc::new(TestDialect::redshift()));
263        assert!(registry.get_by_name("redshift").is_some());
264    }
265
266    #[test]
267    fn resolve_tries_name_lookup() {
268        let mut registry = PlatformRegistry::new();
269        registry.register("postgres".to_string(), Arc::new(TestDialect::postgres()));
270        registry.register("redshift".to_string(), Arc::new(TestDialect::redshift()));
271
272        assert!(registry.resolve("postgres").is_some());
273        assert!(registry.resolve("redshift").is_some());
274        assert!(registry.resolve("nonexistent").is_none());
275    }
276
277    #[test]
278    fn list_returns_all_registered_dialects() {
279        let mut registry = PlatformRegistry::new();
280        registry.register("postgres".to_string(), Arc::new(TestDialect::postgres()));
281        registry.register("snowflake".to_string(), Arc::new(TestDialect::snowflake()));
282        registry.register("redshift".to_string(), Arc::new(TestDialect::redshift()));
283
284        let list = registry.list();
285        assert_eq!(list.len(), 3);
286    }
287
288    #[test]
289    fn duplicate_registration_overwrites() {
290        let mut registry = PlatformRegistry::new();
291        registry.register("postgres".to_string(), Arc::new(TestDialect::postgres()));
292        registry.register("postgres".to_string(), Arc::new(TestDialect::postgres()));
293
294        assert!(registry.get_by_name("postgres").is_some());
295        let list = registry.list();
296        assert_eq!(list.len(), 1);
297    }
298
299    #[test]
300    fn dialect_info_contains_expected_metadata() {
301        let dialect = TestDialect::postgres();
302        let info = dialect.info();
303        assert_eq!(info.name, "postgres");
304        assert_eq!(info.display_name, "PostgreSQL");
305        assert_eq!(info.dialect_family, DialectFamily::PostgreSql);
306    }
307
308    #[test]
309    fn classification_fields_reflect_dialect_characteristics() {
310        let pg = TestDialect::postgres();
311        let pg_info = pg.info();
312        assert_eq!(pg_info.family, "PostgreSQL");
313        assert!(pg_info.is_postgres_compatible);
314        assert!(!pg_info.is_cloud_native);
315        assert!(!pg_info.is_olap);
316
317        let sf = TestDialect::snowflake();
318        let sf_info = sf.info();
319        assert_eq!(sf_info.family, "Snowflake");
320        assert!(sf_info.is_cloud_native);
321        assert!(sf_info.is_olap);
322        assert!(!sf_info.is_postgres_compatible);
323
324        let rd = TestDialect::redshift();
325        let rd_info = rd.info();
326        assert!(rd_info.is_cloud_native);
327        assert!(rd_info.is_olap);
328        assert!(rd_info.is_postgres_compatible);
329    }
330
331    #[test]
332    fn empty_registry_returns_none() {
333        let registry = PlatformRegistry::new();
334        assert!(registry.get_by_name("postgres").is_none());
335        assert!(registry.resolve("postgres").is_none());
336        assert!(registry.list().is_empty());
337    }
338
339    // ── Discovery + canonical names ─────────────────────────────────────────
340
341    #[test]
342    fn registered_names_are_sorted() {
343        let mut registry = PlatformRegistry::new();
344        registry.register("snowflake".to_string(), Arc::new(TestDialect::snowflake()));
345        registry.register("postgres".to_string(), Arc::new(TestDialect::postgres()));
346        registry.register("redshift".to_string(), Arc::new(TestDialect::redshift()));
347        assert_eq!(registry.registered_names(), vec!["postgres", "redshift", "snowflake"]);
348    }
349
350    #[test]
351    fn dialect_info_serializes_for_discovery() {
352        // The discovery payload (list_platforms) + the constants generator both
353        // read serialized DialectInfo, so it must round-trip to JSON.
354        let info = TestDialect::snowflake().info();
355        let json = serde_json::to_value(&info).expect("DialectInfo serializes");
356        assert_eq!(json["name"], "snowflake");
357        assert_eq!(json["display_name"], "Snowflake");
358        assert_eq!(json["dialect_family"], "snowflake");
359        assert_eq!(json["is_cloud_native"], true);
360        assert_eq!(json["is_postgres_compatible"], false);
361    }
362}