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::EngineVersion;
15use razeri_plugin_contract::{
16    DdlRenderer, DialectCapabilities, DmlRenderer, ExpressionRenderer, FormulaRenderer,
17    ProcedureRenderer, ReplicationRenderer, SqlRenderer, StorageRenderer, TaskRenderer,
18};
19use std::collections::HashMap;
20use std::sync::Arc;
21
22// Re-export rendering traits so dialect crates can import from one place.
23pub use razeri_plugin_contract;
24
25/// Metadata describing a dialect implementation.
26///
27/// Serializes to the discovery payload returned by `list_platforms()` across
28/// every surface (and consumed by the typed-constants generator), so hosts learn
29/// the registered platforms — including third-party plugins — at runtime instead
30/// of hard-coding them.
31#[derive(Debug, Clone, serde::Serialize)]
32pub struct DialectInfo {
33    /// Machine-readable name (e.g., "postgres", "redshift").
34    pub name: &'static str,
35    /// Human-readable display name (e.g., "`PostgreSQL`", "Amazon Redshift").
36    pub display_name: &'static str,
37    /// The other spellings this dialect answers to (e.g. `["pg", "postgresql"]`).
38    ///
39    /// A dialect owns its own names. [`name`](Self::name) always resolves; this
40    /// is everything else a design or a `--target` may say for the same
41    /// platform. Matching ignores case and surrounding whitespace, so
42    /// `"PostgreSQL"` needs no separate entry. Declare `&[]` to answer to
43    /// `name` alone.
44    ///
45    /// Two loaded dialects must not claim the same spelling: a spelling that
46    /// names two platforms names neither, and resolving it is refused rather
47    /// than guessed.
48    pub aliases: &'static [&'static str],
49    /// Dialect family for shared behavior (e.g., `PostgreSQL`, `TSql`).
50    pub dialect_family: DialectFamily,
51    /// SQL family label (e.g., "`PostgreSQL`", "T-SQL", "`ClickHouse` SQL").
52    pub family: &'static str,
53    /// Whether this is a cloud-native platform (e.g. Snowflake, Databricks).
54    pub is_cloud_native: bool,
55    /// Whether this is primarily an OLAP engine (e.g. `ClickHouse`).
56    pub is_olap: bool,
57    /// Whether this dialect is PostgreSQL-wire-compatible.
58    pub is_postgres_compatible: bool,
59    /// **The versions of the engine this dialect answers for**, the floor
60    /// first, then each version above it at which an answer changes.
61    ///
62    /// The floor is the oldest version the dialect is supported at, and a
63    /// platform named with no version (`postgres`) is the engine at its floor.
64    /// A design opts into a newer server's forms by naming one of the others
65    /// (`postgres@17`). Declare `&[]` for a managed service that chooses its
66    /// own version, where a version named in a design is refused.
67    ///
68    /// Each version is one a dialect has been measured at. A dialect whose
69    /// answers are the same at every version declares one: its floor.
70    pub versions: &'static [EngineVersion],
71    /// **Which client a bundle's deploy script drives for this engine**, or
72    /// `None` where there is no script.
73    ///
74    /// A bundle carries one script, written for one client: `psql`, `mysql`,
75    /// `sqlplus`. Razeri asks the dialect which one its engine speaks rather
76    /// than recognising the engine by name, so an engine that speaks a client
77    /// razeri already drives — a PostgreSQL-wire engine and `psql` — gets that
78    /// script with no change to razeri. An engine whose client razeri does not
79    /// drive describes it with [`DeployScript::Client`], and razeri writes a
80    /// script around that description. `None` gets a script that refuses at
81    /// the door and prints the run order by hand.
82    pub deploy_script: Option<DeployScript>,
83}
84
85/// **The client a generated deploy script drives**: one of the clients razeri
86/// writes a script for, or one the dialect describes.
87#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
88#[serde(rename_all = "snake_case")]
89#[non_exhaustive]
90pub enum DeployScript {
91    /// `deploy.sh` driving the `duckdb` client.
92    Duckdb,
93    /// `deploy.sh` driving `sqlite3`.
94    Sqlite3,
95    /// `deploy.sh` driving `sqlplus`, local or in the engine's container.
96    Sqlplus,
97    /// `deploy.sh` driving `hdbsql`, local or in the engine's container.
98    Hdbsql,
99    /// `deploy.sh` driving `mysql` or `mariadb`, whichever the engine ships.
100    Mysql,
101    /// `deploy.sh` driving `psql`, local or in the engine's container.
102    Psql,
103    /// `deploy.ps1` driving `Invoke-Sqlcmd`.
104    InvokeSqlcmd,
105    /// `deploy.sh` driving a client razeri writes no script of its own for,
106    /// run the way the dialect describes. See [`DeployClient`].
107    Client(DeployClient),
108}
109
110/// **A client described by the dialect**, so razeri can write a deploy script
111/// around it.
112///
113/// Razeri writes one `deploy.sh` for every such client. It runs the bundle's
114/// files in order through the client — the prerequisites, the schema, the
115/// staging fill, the loads — stops at the first file the engine refuses, and
116/// then reads the reconciliation back and grades it. All it asks of the
117/// dialect is how the client is called. Razeri has never run that script
118/// against your engine, and the bundle says so: proving it is a run of your
119/// own.
120///
121/// In each argument, `{file}` stands for the file being run and `{target}`
122/// for the database the operator names, as the script's first argument or in
123/// `RAZERI_TARGET`. The operator can point the script at another copy of the
124/// client with `RAZERI_CLIENT`.
125///
126/// `SQLite`'s own client, described this way:
127///
128/// ```
129/// use razeri_plugin_api::{DeployClient, DeployScript};
130///
131/// const SQLITE3: DeployScript = DeployScript::Client(DeployClient {
132///     command: "sqlite3",
133///     run_file: &["-bail", "{target}", ".read {file}"],
134///     read_rows: Some(&["-csv", "-noheader", "{target}", ".read {file}"]),
135/// });
136/// ```
137#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
138pub struct DeployClient {
139    /// The client's command, as an operator has it on `PATH`.
140    pub command: &'static str,
141    /// The arguments that run one file of SQL and **stop at the first
142    /// statement the engine refuses, exiting non-zero**.
143    ///
144    /// The script reads nothing but the client's exit code, so a client that
145    /// carries on past a refusal, or exits zero after one, deploys part of a
146    /// warehouse and reports it done. Name the option that makes yours stop;
147    /// `sqlite3` calls it `-bail`.
148    pub run_file: &'static [&'static str],
149    /// The arguments that run one query file and print its rows as
150    /// comma-separated values, one row per line, with no header: how the
151    /// script reads the reconciliation back to grade it.
152    ///
153    /// `None` where the client cannot. The script then stops after the loads,
154    /// exits with the code for *nothing was compared*, and says the
155    /// reconciliation is the operator's to run.
156    pub read_rows: Option<&'static [&'static str]>,
157}
158
159/// Full plugin trait for database platform dialects.
160///
161/// Extends the rendering trait hierarchy (`SqlRenderer`, `ExpressionRenderer`,
162/// `DdlRenderer`, `DmlRenderer`, `ProcedureRenderer`, `FormulaRenderer`,
163/// `TaskRenderer`, `ReplicationRenderer`, `StorageRenderer`, `DialectCapabilities`)
164/// with plugin-specific metadata and registration support.
165///
166/// Dialect authors implement the rendering traits plus `PlatformDialect::info()`.
167pub trait PlatformDialect:
168    SqlRenderer
169    + ExpressionRenderer
170    + DdlRenderer
171    + DmlRenderer
172    + ProcedureRenderer
173    + FormulaRenderer
174    + TaskRenderer
175    + DialectCapabilities
176    + ReplicationRenderer
177    + StorageRenderer
178    + DialectHandle
179{
180    /// Returns metadata about this dialect.
181    fn info(&self) -> DialectInfo;
182
183    /// **This dialect, answering for `version`.**
184    ///
185    /// `version` is one of [`DialectInfo::versions`]; the resolver calls this
186    /// after matching the version a design names against that list, so a
187    /// version the dialect does not declare never reaches it. A dialect whose
188    /// answers change between its versions returns an instance that answers
189    /// for `version`, and says so from [`Self::engine_version`].
190    ///
191    /// The default is a dialect with one answer: it returns itself for a
192    /// version it declares, and refuses any other.
193    ///
194    /// # Errors
195    ///
196    /// [`UnsupportedVersion`], naming the versions this dialect answers for.
197    fn at_version(
198        self: Arc<Self>,
199        version: &EngineVersion,
200    ) -> Result<Arc<dyn PlatformDialect>, UnsupportedVersion> {
201        if self.engine_version().as_ref() == Some(version) {
202            return Ok(self.into_dialect());
203        }
204        Err(UnsupportedVersion::of(&self.info(), &version.to_string()))
205    }
206
207    /// **The version this dialect answers for**, or `None` for a managed
208    /// service that chooses its own.
209    ///
210    /// The default is the floor, the first of [`DialectInfo::versions`]: right
211    /// for a dialect with one answer. A dialect that overrides
212    /// [`Self::at_version`] overrides this too.
213    fn engine_version(&self) -> Option<EngineVersion> {
214        self.info().versions.first().copied()
215    }
216}
217
218/// **A dialect as a shared handle**, so [`PlatformDialect::at_version`] can
219/// hand back the dialect it was called on.
220///
221/// Every dialect has it, through the implementation below; there is nothing
222/// to write.
223pub trait DialectHandle {
224    /// This dialect, as the shared handle the registry holds.
225    fn into_dialect(self: Arc<Self>) -> Arc<dyn PlatformDialect>;
226}
227
228impl<T: PlatformDialect + 'static> DialectHandle for T {
229    fn into_dialect(self: Arc<Self>) -> Arc<dyn PlatformDialect> {
230        self
231    }
232}
233
234/// **A version this dialect does not answer for**, with the ones it does.
235#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
236#[error("{}", self.sentence())]
237pub struct UnsupportedVersion {
238    /// The engine's name, as a design writes it (`postgres`).
239    pub engine: String,
240    /// The engine's name as its vendor writes it (`PostgreSQL`).
241    pub display_name: String,
242    /// The version that was asked for, as it was written.
243    pub asked: String,
244    /// The versions the dialect answers for, the floor first.
245    pub answers_for: Vec<EngineVersion>,
246}
247
248impl UnsupportedVersion {
249    /// The refusal of `asked`, for the dialect `info` describes.
250    #[must_use]
251    pub fn of(info: &DialectInfo, asked: &str) -> Self {
252        Self {
253            engine: info.name.to_owned(),
254            display_name: info.display_name.to_owned(),
255            asked: asked.to_owned(),
256            answers_for: info.versions.to_vec(),
257        }
258    }
259
260    /// The refusal, in one sentence that names the versions to write instead.
261    #[must_use]
262    pub fn sentence(&self) -> String {
263        let Self { engine, display_name, asked, answers_for } = self;
264        let Some(floor) = answers_for.first() else {
265            return format!(
266                "razeri renders {display_name} for no version a design names, and not at \
267                 {asked}: write `{engine}`"
268            );
269        };
270        let versions: Vec<String> = answers_for.iter().map(ToString::to_string).collect();
271        let names: Vec<String> = versions.iter().map(|v| format!("`{engine}@{v}`")).collect();
272        if versions.len() == 1 {
273            return format!(
274                "razeri renders {display_name} at {floor} alone, and not at {asked}: write \
275                 `{engine}`"
276            );
277        }
278        format!(
279            "razeri renders {display_name} at {}, and not at {asked}: write {}, or `{engine}` \
280             for {floor}, the oldest",
281            in_words(&versions, "and"),
282            in_words(&names, "or"),
283        )
284    }
285}
286
287/// `15, 16 and 17`: a list in a sentence.
288fn in_words(items: &[String], last: &str) -> String {
289    match items {
290        [] => String::new(),
291        [only] => only.clone(),
292        [rest @ .., tail] => format!("{} {last} {tail}", rest.join(", ")),
293    }
294}
295
296impl DialectInfo {
297    /// Every spelling this dialect answers to: its own name, then its aliases.
298    ///
299    /// This is what the resolver reads, so it is also the honest answer to
300    /// "what may a design call this platform?". An alias repeating the name is
301    /// listed once.
302    #[must_use]
303    pub fn spellings(&self) -> Vec<&'static str> {
304        let mut out = Vec::with_capacity(self.aliases.len() + 1);
305        out.push(self.name);
306        for alias in self.aliases {
307            if !out.contains(alias) {
308                out.push(alias);
309            }
310        }
311        out
312    }
313
314    /// Whether this dialect answers to `spelling`, ignoring case and
315    /// surrounding whitespace.
316    #[must_use]
317    pub fn answers_to(&self, spelling: &str) -> bool {
318        let wanted = spelling.trim();
319        self.spellings().iter().any(|known| known.eq_ignore_ascii_case(wanted))
320    }
321}
322
323/// String-keyed registry for platform dialects.
324///
325/// All dialects are indexed by their canonical string name
326/// (e.g. `"postgres"`, `"snowflake"`, `"mysql"`).
327pub struct PlatformRegistry {
328    dialects: HashMap<String, Arc<dyn PlatformDialect>>,
329}
330
331impl PlatformRegistry {
332    /// Create an empty registry.
333    #[must_use]
334    pub fn new() -> Self {
335        Self { dialects: HashMap::new() }
336    }
337
338    /// Register a dialect by its canonical string name.
339    pub fn register(&mut self, name: String, dialect: Arc<dyn PlatformDialect>) {
340        self.dialects.insert(name, dialect);
341    }
342
343    /// Resolve by string name (canonical path for all dialects).
344    #[must_use]
345    pub fn get_by_name(&self, name: &str) -> Option<&Arc<dyn PlatformDialect>> {
346        self.dialects.get(name)
347    }
348
349    /// Resolve by string — same as [`get_by_name`](Self::get_by_name).
350    #[must_use]
351    pub fn resolve(&self, target: &str) -> Option<Arc<dyn PlatformDialect>> {
352        self.dialects.get(target).cloned()
353    }
354
355    /// List all registered dialects.
356    #[must_use]
357    pub fn list(&self) -> Vec<DialectInfo> {
358        self.dialects.values().map(|d| d.info()).collect()
359    }
360
361    /// Sorted machine-readable names of every registered dialect.
362    ///
363    /// The stable, ordered form of [`list`](Self::list); the
364    /// [`resolve`](crate::resolve) module uses it for error messages and the
365    /// typed-constants generator reads it for deterministic output.
366    #[must_use]
367    pub fn registered_names(&self) -> Vec<String> {
368        let mut names: Vec<String> = self.dialects.keys().cloned().collect();
369        names.sort();
370        names
371    }
372
373    /// Build a registry pre-populated with all auto-discovered dialect crates.
374    ///
375    /// Uses `inventory` to find every [`crate::builder::DialectRegistration`]
376    /// submitted at link time.
377    #[must_use]
378    pub fn with_auto_discovered() -> Self {
379        use crate::builder::DialectRegistration;
380        let mut registry = Self::new();
381        for reg in inventory::iter::<DialectRegistration> {
382            let dialect = (reg.factory)();
383            let name = dialect.info().name.to_string();
384            registry.register(name, dialect);
385        }
386        // Plugin crates linked into this binary with `export_plugin!`
387        // register through a builder; the dialects are what is kept here.
388        for reg in inventory::iter::<crate::builder::PluginRegistration> {
389            let mut linked = crate::builder::PluginRegistryBuilder::new();
390            (reg.register)(&mut linked);
391            for (name, dialect) in linked.build().dialects.dialects {
392                registry.register(name, dialect);
393            }
394        }
395        registry
396    }
397}
398
399impl Default for PlatformRegistry {
400    fn default() -> Self {
401        Self::new()
402    }
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408    use razeri_plugin_contract::DataType;
409
410    /// Test dialect for unit tests.
411    struct TestDialect {
412        info: DialectInfo,
413    }
414
415    impl TestDialect {
416        fn postgres() -> Self {
417            Self {
418                info: DialectInfo {
419                    name: "postgres",
420                    display_name: "PostgreSQL",
421                    aliases: &["postgresql", "pg"],
422                    dialect_family: DialectFamily::PostgreSql,
423                    family: "PostgreSQL",
424                    is_cloud_native: false,
425                    is_olap: false,
426                    is_postgres_compatible: true,
427                    versions: &[],
428                    deploy_script: None,
429                },
430            }
431        }
432
433        fn redshift() -> Self {
434            Self {
435                info: DialectInfo {
436                    name: "redshift",
437                    display_name: "Amazon Redshift",
438                    aliases: &["amazon_redshift", "rs"],
439                    dialect_family: DialectFamily::PostgreSql,
440                    family: "PostgreSQL",
441                    is_cloud_native: true,
442                    is_olap: true,
443                    is_postgres_compatible: true,
444                    versions: &[],
445                    deploy_script: None,
446                },
447            }
448        }
449
450        fn snowflake() -> Self {
451            Self {
452                info: DialectInfo {
453                    name: "snowflake",
454                    display_name: "Snowflake",
455                    aliases: &["sf"],
456                    dialect_family: DialectFamily::Snowflake,
457                    family: "Snowflake",
458                    is_cloud_native: true,
459                    is_olap: true,
460                    is_postgres_compatible: false,
461                    versions: &[],
462                    deploy_script: None,
463                },
464            }
465        }
466    }
467
468    impl SqlRenderer for TestDialect {
469        fn render_data_type(&self, _dt: &DataType) -> String {
470            "TEXT".to_string()
471        }
472
473        fn quote_identifier(&self, name: &str) -> String {
474            format!("\"{name}\"")
475        }
476
477        fn qualify_name(&self, schema: Option<&str>, name: &str) -> String {
478            match schema {
479                Some(s) => format!("{}.{}", self.quote_identifier(s), self.quote_identifier(name)),
480                None => self.quote_identifier(name),
481            }
482        }
483
484        fn render_current_timestamp(&self) -> String {
485            "CURRENT_TIMESTAMP".to_string()
486        }
487    }
488
489    impl ExpressionRenderer for TestDialect {
490        fn render_concat(&self, parts: &[String]) -> String {
491            parts.join(" || ")
492        }
493    }
494
495    impl DdlRenderer for TestDialect {}
496    impl DmlRenderer for TestDialect {}
497    impl ProcedureRenderer for TestDialect {}
498    impl FormulaRenderer for TestDialect {}
499    impl TaskRenderer for TestDialect {}
500    impl ReplicationRenderer for TestDialect {}
501    impl StorageRenderer for TestDialect {}
502
503    impl DialectCapabilities for TestDialect {
504        fn supports_identity(&self) -> bool {
505            true
506        }
507        fn supports_sequences(&self) -> bool {
508            true
509        }
510        fn supports_stored_procedures(&self) -> bool {
511            true
512        }
513    }
514
515    impl PlatformDialect for TestDialect {
516        fn info(&self) -> DialectInfo {
517            self.info.clone()
518        }
519    }
520
521    const FLOOR_ONLY: &[EngineVersion] = &[EngineVersion::declared("15")];
522
523    /// **A dialect with one answer is itself at its one version**, and the
524    /// default refuses any other, naming the one it answers for.
525    #[test]
526    fn a_dialect_with_one_answer_is_itself_at_its_floor_and_refuses_another() {
527        let dialect: Arc<dyn PlatformDialect> = Arc::new(TestDialect {
528            info: DialectInfo { versions: FLOOR_ONLY, ..TestDialect::postgres().info() },
529        });
530        assert_eq!(dialect.engine_version(), Some(EngineVersion::declared("15")));
531        let at_floor =
532            Arc::clone(&dialect).at_version(&EngineVersion::declared("15")).expect("its floor");
533        assert!(Arc::ptr_eq(&at_floor, &dialect), "a dialect with one answer returns itself");
534
535        let Err(refused) = Arc::clone(&dialect).at_version(&EngineVersion::declared("17")) else {
536            panic!("17 is not declared")
537        };
538        assert_eq!(refused.answers_for, vec![EngineVersion::declared("15")]);
539        let said = refused.to_string();
540        assert!(said.contains("PostgreSQL"), "{said}");
541        assert!(said.contains("15"), "{said}");
542        assert!(said.contains("`postgres`"), "names the spelling that works: {said}");
543    }
544
545    /// **A service that chooses its own version answers for none.**
546    #[test]
547    fn a_service_that_chooses_its_own_version_answers_for_none() {
548        let dialect = TestDialect::snowflake();
549        assert!(dialect.info().versions.is_empty());
550        assert_eq!(dialect.engine_version(), None);
551    }
552
553    /// **The refusal lists every version, the floor first, and the names to
554    /// write.**
555    #[test]
556    fn an_unsupported_version_names_every_version_and_how_to_write_each() {
557        let refusal = UnsupportedVersion {
558            engine: "postgres".to_owned(),
559            display_name: "PostgreSQL".to_owned(),
560            asked: "12".to_owned(),
561            answers_for: ["15", "16", "17"].iter().map(|v| EngineVersion::declared(v)).collect(),
562        };
563        let said = refusal.to_string();
564        assert!(said.contains("15, 16 and 17"), "{said}");
565        assert!(said.contains("not at 12"), "{said}");
566        assert!(said.contains("`postgres@17`"), "{said}");
567        assert!(said.contains("`postgres` for 15"), "the name alone is the floor: {said}");
568    }
569
570    #[test]
571    fn the_versions_ride_along_in_the_discovery_payload() {
572        let info = DialectInfo { versions: FLOOR_ONLY, ..TestDialect::postgres().info() };
573        let json = serde_json::to_value(&info).expect("DialectInfo serializes");
574        assert_eq!(json["versions"], serde_json::json!(["15"]));
575    }
576
577    #[test]
578    fn register_dialect_resolves_by_name() {
579        let mut registry = PlatformRegistry::new();
580        registry.register("postgres".to_string(), Arc::new(TestDialect::postgres()));
581        assert!(registry.get_by_name("postgres").is_some());
582    }
583
584    #[test]
585    fn register_extension_resolves_by_name() {
586        let mut registry = PlatformRegistry::new();
587        registry.register("redshift".to_string(), Arc::new(TestDialect::redshift()));
588        assert!(registry.get_by_name("redshift").is_some());
589    }
590
591    #[test]
592    fn resolve_tries_name_lookup() {
593        let mut registry = PlatformRegistry::new();
594        registry.register("postgres".to_string(), Arc::new(TestDialect::postgres()));
595        registry.register("redshift".to_string(), Arc::new(TestDialect::redshift()));
596
597        assert!(registry.resolve("postgres").is_some());
598        assert!(registry.resolve("redshift").is_some());
599        assert!(registry.resolve("nonexistent").is_none());
600    }
601
602    #[test]
603    fn list_returns_all_registered_dialects() {
604        let mut registry = PlatformRegistry::new();
605        registry.register("postgres".to_string(), Arc::new(TestDialect::postgres()));
606        registry.register("snowflake".to_string(), Arc::new(TestDialect::snowflake()));
607        registry.register("redshift".to_string(), Arc::new(TestDialect::redshift()));
608
609        let list = registry.list();
610        assert_eq!(list.len(), 3);
611    }
612
613    #[test]
614    fn duplicate_registration_overwrites() {
615        let mut registry = PlatformRegistry::new();
616        registry.register("postgres".to_string(), Arc::new(TestDialect::postgres()));
617        registry.register("postgres".to_string(), Arc::new(TestDialect::postgres()));
618
619        assert!(registry.get_by_name("postgres").is_some());
620        let list = registry.list();
621        assert_eq!(list.len(), 1);
622    }
623
624    #[test]
625    fn dialect_info_contains_expected_metadata() {
626        let dialect = TestDialect::postgres();
627        let info = dialect.info();
628        assert_eq!(info.name, "postgres");
629        assert_eq!(info.display_name, "PostgreSQL");
630        assert_eq!(info.dialect_family, DialectFamily::PostgreSql);
631    }
632
633    #[test]
634    fn classification_fields_reflect_dialect_characteristics() {
635        let pg = TestDialect::postgres();
636        let pg_info = pg.info();
637        assert_eq!(pg_info.family, "PostgreSQL");
638        assert!(pg_info.is_postgres_compatible);
639        assert!(!pg_info.is_cloud_native);
640        assert!(!pg_info.is_olap);
641
642        let sf = TestDialect::snowflake();
643        let sf_info = sf.info();
644        assert_eq!(sf_info.family, "Snowflake");
645        assert!(sf_info.is_cloud_native);
646        assert!(sf_info.is_olap);
647        assert!(!sf_info.is_postgres_compatible);
648
649        let rd = TestDialect::redshift();
650        let rd_info = rd.info();
651        assert!(rd_info.is_cloud_native);
652        assert!(rd_info.is_olap);
653        assert!(rd_info.is_postgres_compatible);
654    }
655
656    #[test]
657    fn empty_registry_returns_none() {
658        let registry = PlatformRegistry::new();
659        assert!(registry.get_by_name("postgres").is_none());
660        assert!(registry.resolve("postgres").is_none());
661        assert!(registry.list().is_empty());
662    }
663
664    // ── Discovery + canonical names ─────────────────────────────────────────
665
666    #[test]
667    fn registered_names_are_sorted() {
668        let mut registry = PlatformRegistry::new();
669        registry.register("snowflake".to_string(), Arc::new(TestDialect::snowflake()));
670        registry.register("postgres".to_string(), Arc::new(TestDialect::postgres()));
671        registry.register("redshift".to_string(), Arc::new(TestDialect::redshift()));
672        assert_eq!(registry.registered_names(), vec!["postgres", "redshift", "snowflake"]);
673    }
674
675    #[test]
676    fn dialect_info_serializes_for_discovery() {
677        // The discovery payload (list_platforms) + the constants generator both
678        // read serialized DialectInfo, so it must round-trip to JSON.
679        let info = TestDialect::snowflake().info();
680        let json = serde_json::to_value(&info).expect("DialectInfo serializes");
681        assert_eq!(json["name"], "snowflake");
682        assert_eq!(json["display_name"], "Snowflake");
683        assert_eq!(json["dialect_family"], "snowflake");
684        assert_eq!(json["is_cloud_native"], true);
685        assert_eq!(json["is_postgres_compatible"], false);
686        // The spellings ride along, so a host learns what a design may call
687        // this platform from the same payload that lists it.
688        assert_eq!(json["aliases"], serde_json::json!(["sf"]));
689    }
690
691    #[test]
692    fn spellings_start_with_the_dialects_own_name() {
693        let info = TestDialect::postgres().info();
694        assert_eq!(info.spellings(), vec!["postgres", "postgresql", "pg"]);
695    }
696
697    #[test]
698    fn a_dialect_answers_to_its_name_and_its_aliases_in_any_case() {
699        let info = TestDialect::postgres().info();
700        assert!(info.answers_to("postgres"));
701        assert!(info.answers_to("pg"));
702        assert!(info.answers_to("  PostgreSQL "));
703        assert!(!info.answers_to("snowflake"));
704    }
705
706    #[test]
707    fn an_alias_repeating_the_name_is_listed_once() {
708        let info = DialectInfo { aliases: &["postgres", "pg"], ..TestDialect::postgres().info() };
709        assert_eq!(info.spellings(), vec!["postgres", "pg"]);
710    }
711}