Skip to main content

razeri_plugin_api/
dialect.rs

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