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        registry
175    }
176}
177
178impl Default for PlatformRegistry {
179    fn default() -> Self {
180        Self::new()
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use razeri_plugin_contract::DataType;
188
189    /// Test dialect for unit tests.
190    struct TestDialect {
191        info: DialectInfo,
192    }
193
194    impl TestDialect {
195        fn postgres() -> Self {
196            Self {
197                info: DialectInfo {
198                    name: "postgres",
199                    display_name: "PostgreSQL",
200                    aliases: &["postgresql", "pg"],
201                    dialect_family: DialectFamily::PostgreSql,
202                    family: "PostgreSQL",
203                    is_cloud_native: false,
204                    is_olap: false,
205                    is_postgres_compatible: true,
206                },
207            }
208        }
209
210        fn redshift() -> Self {
211            Self {
212                info: DialectInfo {
213                    name: "redshift",
214                    display_name: "Amazon Redshift",
215                    aliases: &["amazon_redshift", "rs"],
216                    dialect_family: DialectFamily::PostgreSql,
217                    family: "PostgreSQL",
218                    is_cloud_native: true,
219                    is_olap: true,
220                    is_postgres_compatible: true,
221                },
222            }
223        }
224
225        fn snowflake() -> Self {
226            Self {
227                info: DialectInfo {
228                    name: "snowflake",
229                    display_name: "Snowflake",
230                    aliases: &["sf"],
231                    dialect_family: DialectFamily::Snowflake,
232                    family: "Snowflake",
233                    is_cloud_native: true,
234                    is_olap: true,
235                    is_postgres_compatible: false,
236                },
237            }
238        }
239    }
240
241    impl SqlRenderer for TestDialect {
242        fn render_data_type(&self, _dt: &DataType) -> String {
243            "TEXT".to_string()
244        }
245
246        fn quote_identifier(&self, name: &str) -> String {
247            format!("\"{name}\"")
248        }
249
250        fn qualify_name(&self, schema: Option<&str>, name: &str) -> String {
251            match schema {
252                Some(s) => format!("{}.{}", self.quote_identifier(s), self.quote_identifier(name)),
253                None => self.quote_identifier(name),
254            }
255        }
256
257        fn render_current_timestamp(&self) -> String {
258            "CURRENT_TIMESTAMP".to_string()
259        }
260    }
261
262    impl ExpressionRenderer for TestDialect {
263        fn render_concat(&self, parts: &[String]) -> String {
264            parts.join(" || ")
265        }
266    }
267
268    impl DdlRenderer for TestDialect {}
269    impl DmlRenderer for TestDialect {}
270    impl ProcedureRenderer for TestDialect {}
271    impl FormulaRenderer for TestDialect {}
272    impl TaskRenderer for TestDialect {}
273    impl ReplicationRenderer for TestDialect {}
274    impl StorageRenderer for TestDialect {}
275
276    impl DialectCapabilities for TestDialect {
277        fn supports_identity(&self) -> bool {
278            true
279        }
280        fn supports_sequences(&self) -> bool {
281            true
282        }
283        fn supports_stored_procedures(&self) -> bool {
284            true
285        }
286    }
287
288    impl PlatformDialect for TestDialect {
289        fn info(&self) -> DialectInfo {
290            self.info.clone()
291        }
292    }
293
294    #[test]
295    fn register_dialect_resolves_by_name() {
296        let mut registry = PlatformRegistry::new();
297        registry.register("postgres".to_string(), Arc::new(TestDialect::postgres()));
298        assert!(registry.get_by_name("postgres").is_some());
299    }
300
301    #[test]
302    fn register_extension_resolves_by_name() {
303        let mut registry = PlatformRegistry::new();
304        registry.register("redshift".to_string(), Arc::new(TestDialect::redshift()));
305        assert!(registry.get_by_name("redshift").is_some());
306    }
307
308    #[test]
309    fn resolve_tries_name_lookup() {
310        let mut registry = PlatformRegistry::new();
311        registry.register("postgres".to_string(), Arc::new(TestDialect::postgres()));
312        registry.register("redshift".to_string(), Arc::new(TestDialect::redshift()));
313
314        assert!(registry.resolve("postgres").is_some());
315        assert!(registry.resolve("redshift").is_some());
316        assert!(registry.resolve("nonexistent").is_none());
317    }
318
319    #[test]
320    fn list_returns_all_registered_dialects() {
321        let mut registry = PlatformRegistry::new();
322        registry.register("postgres".to_string(), Arc::new(TestDialect::postgres()));
323        registry.register("snowflake".to_string(), Arc::new(TestDialect::snowflake()));
324        registry.register("redshift".to_string(), Arc::new(TestDialect::redshift()));
325
326        let list = registry.list();
327        assert_eq!(list.len(), 3);
328    }
329
330    #[test]
331    fn duplicate_registration_overwrites() {
332        let mut registry = PlatformRegistry::new();
333        registry.register("postgres".to_string(), Arc::new(TestDialect::postgres()));
334        registry.register("postgres".to_string(), Arc::new(TestDialect::postgres()));
335
336        assert!(registry.get_by_name("postgres").is_some());
337        let list = registry.list();
338        assert_eq!(list.len(), 1);
339    }
340
341    #[test]
342    fn dialect_info_contains_expected_metadata() {
343        let dialect = TestDialect::postgres();
344        let info = dialect.info();
345        assert_eq!(info.name, "postgres");
346        assert_eq!(info.display_name, "PostgreSQL");
347        assert_eq!(info.dialect_family, DialectFamily::PostgreSql);
348    }
349
350    #[test]
351    fn classification_fields_reflect_dialect_characteristics() {
352        let pg = TestDialect::postgres();
353        let pg_info = pg.info();
354        assert_eq!(pg_info.family, "PostgreSQL");
355        assert!(pg_info.is_postgres_compatible);
356        assert!(!pg_info.is_cloud_native);
357        assert!(!pg_info.is_olap);
358
359        let sf = TestDialect::snowflake();
360        let sf_info = sf.info();
361        assert_eq!(sf_info.family, "Snowflake");
362        assert!(sf_info.is_cloud_native);
363        assert!(sf_info.is_olap);
364        assert!(!sf_info.is_postgres_compatible);
365
366        let rd = TestDialect::redshift();
367        let rd_info = rd.info();
368        assert!(rd_info.is_cloud_native);
369        assert!(rd_info.is_olap);
370        assert!(rd_info.is_postgres_compatible);
371    }
372
373    #[test]
374    fn empty_registry_returns_none() {
375        let registry = PlatformRegistry::new();
376        assert!(registry.get_by_name("postgres").is_none());
377        assert!(registry.resolve("postgres").is_none());
378        assert!(registry.list().is_empty());
379    }
380
381    // ── Discovery + canonical names ─────────────────────────────────────────
382
383    #[test]
384    fn registered_names_are_sorted() {
385        let mut registry = PlatformRegistry::new();
386        registry.register("snowflake".to_string(), Arc::new(TestDialect::snowflake()));
387        registry.register("postgres".to_string(), Arc::new(TestDialect::postgres()));
388        registry.register("redshift".to_string(), Arc::new(TestDialect::redshift()));
389        assert_eq!(registry.registered_names(), vec!["postgres", "redshift", "snowflake"]);
390    }
391
392    #[test]
393    fn dialect_info_serializes_for_discovery() {
394        // The discovery payload (list_platforms) + the constants generator both
395        // read serialized DialectInfo, so it must round-trip to JSON.
396        let info = TestDialect::snowflake().info();
397        let json = serde_json::to_value(&info).expect("DialectInfo serializes");
398        assert_eq!(json["name"], "snowflake");
399        assert_eq!(json["display_name"], "Snowflake");
400        assert_eq!(json["dialect_family"], "snowflake");
401        assert_eq!(json["is_cloud_native"], true);
402        assert_eq!(json["is_postgres_compatible"], false);
403        // The spellings ride along, so a host learns what a design may call
404        // this platform from the same payload that lists it.
405        assert_eq!(json["aliases"], serde_json::json!(["sf"]));
406    }
407
408    #[test]
409    fn spellings_start_with_the_dialects_own_name() {
410        let info = TestDialect::postgres().info();
411        assert_eq!(info.spellings(), vec!["postgres", "postgresql", "pg"]);
412    }
413
414    #[test]
415    fn a_dialect_answers_to_its_name_and_its_aliases_in_any_case() {
416        let info = TestDialect::postgres().info();
417        assert!(info.answers_to("postgres"));
418        assert!(info.answers_to("pg"));
419        assert!(info.answers_to("  PostgreSQL "));
420        assert!(!info.answers_to("snowflake"));
421    }
422
423    #[test]
424    fn an_alias_repeating_the_name_is_listed_once() {
425        let info = DialectInfo { aliases: &["postgres", "pg"], ..TestDialect::postgres().info() };
426        assert_eq!(info.spellings(), vec!["postgres", "pg"]);
427    }
428}