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///     block_terminator: None,
136/// });
137/// ```
138#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
139pub struct DeployClient {
140    /// The client's command, as an operator has it on `PATH`.
141    pub command: &'static str,
142    /// The arguments that run one file of SQL and **stop at the first
143    /// statement the engine refuses, exiting non-zero**.
144    ///
145    /// The script reads nothing but the client's exit code, so a client that
146    /// carries on past a refusal, or exits zero after one, deploys part of a
147    /// warehouse and reports it done. Name the option that makes yours stop;
148    /// `sqlite3` calls it `-bail`.
149    pub run_file: &'static [&'static str],
150    /// The arguments that run one query file and print its rows as
151    /// comma-separated values, one row per line, with no header: how the
152    /// script reads the reconciliation back to grade it.
153    ///
154    /// `None` where the client cannot. The script then stops after the loads,
155    /// exits with the code for *nothing was compared*, and says the
156    /// reconciliation is the operator's to run.
157    pub read_rows: Option<&'static [&'static str]>,
158    /// **How this client is told where a routine ends**, or `None` where a
159    /// routine ends the way every statement does.
160    ///
161    /// A routine's body holds statements of its own, each ending in `;`, and
162    /// some clients end the routine at the first of them: Firebird's `isql`
163    /// needs `SET TERM ^ ;` before a routine, the routine ended with `^`, and
164    /// `SET TERM ; ^` after it. Razeri writes these three around each
165    /// procedure and function in the files the script runs.
166    pub block_terminator: Option<BlockTerminator>,
167}
168
169/// **The three lines a client needs around a routine**, so the `;` inside its
170/// body does not end it. See [`DeployClient::block_terminator`].
171///
172/// Firebird's `isql`, described this way:
173///
174/// ```
175/// use razeri_plugin_api::BlockTerminator;
176///
177/// const SET_TERM: BlockTerminator =
178///     BlockTerminator { open: "SET TERM ^ ;", end: "^", close: "SET TERM ; ^" };
179/// ```
180#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
181pub struct BlockTerminator {
182    /// The line written before the routine.
183    pub open: &'static str,
184    /// What ends the routine, in place of its last `;`.
185    pub end: &'static str,
186    /// The line written after the routine, which puts the ordinary end back.
187    pub close: &'static str,
188}
189
190/// Full plugin trait for database platform dialects.
191///
192/// Extends the rendering trait hierarchy (`SqlRenderer`, `ExpressionRenderer`,
193/// `DdlRenderer`, `DmlRenderer`, `ProcedureRenderer`, `FormulaRenderer`,
194/// `TaskRenderer`, `ReplicationRenderer`, `StorageRenderer`, `DialectCapabilities`)
195/// with plugin-specific metadata and registration support.
196///
197/// Dialect authors implement the rendering traits plus `PlatformDialect::info()`.
198pub trait PlatformDialect:
199    SqlRenderer
200    + ExpressionRenderer
201    + DdlRenderer
202    + DmlRenderer
203    + ProcedureRenderer
204    + FormulaRenderer
205    + TaskRenderer
206    + DialectCapabilities
207    + ReplicationRenderer
208    + StorageRenderer
209    + DialectHandle
210{
211    /// Returns metadata about this dialect.
212    fn info(&self) -> DialectInfo;
213
214    /// **This dialect, answering for `version`.**
215    ///
216    /// `version` is one of [`DialectInfo::versions`]; the resolver calls this
217    /// after matching the version a design names against that list, so a
218    /// version the dialect does not declare never reaches it. A dialect whose
219    /// answers change between its versions returns an instance that answers
220    /// for `version`, and says so from [`Self::engine_version`].
221    ///
222    /// The default is a dialect with one answer: it returns itself for a
223    /// version it declares, and refuses any other.
224    ///
225    /// # Errors
226    ///
227    /// [`UnsupportedVersion`], naming the versions this dialect answers for.
228    fn at_version(
229        self: Arc<Self>,
230        version: &EngineVersion,
231    ) -> Result<Arc<dyn PlatformDialect>, UnsupportedVersion> {
232        if self.engine_version().as_ref() == Some(version) {
233            return Ok(self.into_dialect());
234        }
235        Err(UnsupportedVersion::of(&self.info(), &version.to_string()))
236    }
237
238    /// **The version this dialect answers for**, or `None` for a managed
239    /// service that chooses its own.
240    ///
241    /// The default is the floor, the first of [`DialectInfo::versions`]: right
242    /// for a dialect with one answer. A dialect that overrides
243    /// [`Self::at_version`] overrides this too.
244    fn engine_version(&self) -> Option<EngineVersion> {
245        self.info().versions.first().copied()
246    }
247}
248
249/// **A dialect as a shared handle**, so [`PlatformDialect::at_version`] can
250/// hand back the dialect it was called on.
251///
252/// Every dialect has it, through the implementation below; there is nothing
253/// to write.
254pub trait DialectHandle {
255    /// This dialect, as the shared handle the registry holds.
256    fn into_dialect(self: Arc<Self>) -> Arc<dyn PlatformDialect>;
257}
258
259impl<T: PlatformDialect + 'static> DialectHandle for T {
260    fn into_dialect(self: Arc<Self>) -> Arc<dyn PlatformDialect> {
261        self
262    }
263}
264
265/// **A version this dialect does not answer for**, with the ones it does.
266#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
267#[error("{}", self.sentence())]
268pub struct UnsupportedVersion {
269    /// The engine's name, as a design writes it (`postgres`).
270    pub engine: String,
271    /// The engine's name as its vendor writes it (`PostgreSQL`).
272    pub display_name: String,
273    /// The version that was asked for, as it was written.
274    pub asked: String,
275    /// The versions the dialect answers for, the floor first.
276    pub answers_for: Vec<EngineVersion>,
277}
278
279impl UnsupportedVersion {
280    /// The refusal of `asked`, for the dialect `info` describes.
281    #[must_use]
282    pub fn of(info: &DialectInfo, asked: &str) -> Self {
283        Self {
284            engine: info.name.to_owned(),
285            display_name: info.display_name.to_owned(),
286            asked: asked.to_owned(),
287            answers_for: info.versions.to_vec(),
288        }
289    }
290
291    /// The refusal, in one sentence that names the versions to write instead.
292    #[must_use]
293    pub fn sentence(&self) -> String {
294        let Self { engine, display_name, asked, answers_for } = self;
295        let Some(floor) = answers_for.first() else {
296            return format!(
297                "razeri renders {display_name} for no version a design names, and not at \
298                 {asked}: write `{engine}`"
299            );
300        };
301        let versions: Vec<String> = answers_for.iter().map(ToString::to_string).collect();
302        let names: Vec<String> = versions.iter().map(|v| format!("`{engine}@{v}`")).collect();
303        if versions.len() == 1 {
304            return format!(
305                "razeri renders {display_name} at {floor} alone, and not at {asked}: write \
306                 `{engine}`"
307            );
308        }
309        format!(
310            "razeri renders {display_name} at {}, and not at {asked}: write {}, or `{engine}` \
311             for {floor}, the oldest",
312            in_words(&versions, "and"),
313            in_words(&names, "or"),
314        )
315    }
316}
317
318/// `15, 16 and 17`: a list in a sentence.
319fn in_words(items: &[String], last: &str) -> String {
320    match items {
321        [] => String::new(),
322        [only] => only.clone(),
323        [rest @ .., tail] => format!("{} {last} {tail}", rest.join(", ")),
324    }
325}
326
327impl DialectInfo {
328    /// Every spelling this dialect answers to: its own name, then its aliases.
329    ///
330    /// This is what the resolver reads, so it is also the honest answer to
331    /// "what may a design call this platform?". An alias repeating the name is
332    /// listed once.
333    #[must_use]
334    pub fn spellings(&self) -> Vec<&'static str> {
335        let mut out = Vec::with_capacity(self.aliases.len() + 1);
336        out.push(self.name);
337        for alias in self.aliases {
338            if !out.contains(alias) {
339                out.push(alias);
340            }
341        }
342        out
343    }
344
345    /// Whether this dialect answers to `spelling`, ignoring case and
346    /// surrounding whitespace.
347    #[must_use]
348    pub fn answers_to(&self, spelling: &str) -> bool {
349        let wanted = spelling.trim();
350        self.spellings().iter().any(|known| known.eq_ignore_ascii_case(wanted))
351    }
352}
353
354/// String-keyed registry for platform dialects.
355///
356/// All dialects are indexed by their canonical string name
357/// (e.g. `"postgres"`, `"snowflake"`, `"mysql"`).
358pub struct PlatformRegistry {
359    dialects: HashMap<String, Arc<dyn PlatformDialect>>,
360}
361
362impl PlatformRegistry {
363    /// Create an empty registry.
364    #[must_use]
365    pub fn new() -> Self {
366        Self { dialects: HashMap::new() }
367    }
368
369    /// Register a dialect by its canonical string name.
370    pub fn register(&mut self, name: String, dialect: Arc<dyn PlatformDialect>) {
371        self.dialects.insert(name, dialect);
372    }
373
374    /// Resolve by string name (canonical path for all dialects).
375    #[must_use]
376    pub fn get_by_name(&self, name: &str) -> Option<&Arc<dyn PlatformDialect>> {
377        self.dialects.get(name)
378    }
379
380    /// Resolve by string — same as [`get_by_name`](Self::get_by_name).
381    #[must_use]
382    pub fn resolve(&self, target: &str) -> Option<Arc<dyn PlatformDialect>> {
383        self.dialects.get(target).cloned()
384    }
385
386    /// List all registered dialects.
387    #[must_use]
388    pub fn list(&self) -> Vec<DialectInfo> {
389        self.dialects.values().map(|d| d.info()).collect()
390    }
391
392    /// Sorted machine-readable names of every registered dialect.
393    ///
394    /// The stable, ordered form of [`list`](Self::list); the
395    /// [`resolve`](crate::resolve) module uses it for error messages and the
396    /// typed-constants generator reads it for deterministic output.
397    #[must_use]
398    pub fn registered_names(&self) -> Vec<String> {
399        let mut names: Vec<String> = self.dialects.keys().cloned().collect();
400        names.sort();
401        names
402    }
403
404    /// Build a registry pre-populated with all auto-discovered dialect crates.
405    ///
406    /// Uses `inventory` to find every [`crate::builder::DialectRegistration`]
407    /// submitted at link time.
408    #[must_use]
409    pub fn with_auto_discovered() -> Self {
410        use crate::builder::DialectRegistration;
411        let mut registry = Self::new();
412        for reg in inventory::iter::<DialectRegistration> {
413            let dialect = (reg.factory)();
414            let name = dialect.info().name.to_string();
415            registry.register(name, dialect);
416        }
417        // Plugin crates linked into this binary with `export_plugin!`
418        // register through a builder; the dialects are what is kept here.
419        for reg in inventory::iter::<crate::builder::PluginRegistration> {
420            let mut linked = crate::builder::PluginRegistryBuilder::new();
421            (reg.register)(&mut linked);
422            for (name, dialect) in linked.build().dialects.dialects {
423                registry.register(name, dialect);
424            }
425        }
426        registry
427    }
428}
429
430impl Default for PlatformRegistry {
431    fn default() -> Self {
432        Self::new()
433    }
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439    use razeri_plugin_contract::DataType;
440
441    /// Test dialect for unit tests.
442    struct TestDialect {
443        info: DialectInfo,
444    }
445
446    impl TestDialect {
447        fn postgres() -> Self {
448            Self {
449                info: DialectInfo {
450                    name: "postgres",
451                    display_name: "PostgreSQL",
452                    aliases: &["postgresql", "pg"],
453                    dialect_family: DialectFamily::PostgreSql,
454                    family: "PostgreSQL",
455                    is_cloud_native: false,
456                    is_olap: false,
457                    is_postgres_compatible: true,
458                    versions: &[],
459                    deploy_script: None,
460                },
461            }
462        }
463
464        fn redshift() -> Self {
465            Self {
466                info: DialectInfo {
467                    name: "redshift",
468                    display_name: "Amazon Redshift",
469                    aliases: &["amazon_redshift", "rs"],
470                    dialect_family: DialectFamily::PostgreSql,
471                    family: "PostgreSQL",
472                    is_cloud_native: true,
473                    is_olap: true,
474                    is_postgres_compatible: true,
475                    versions: &[],
476                    deploy_script: None,
477                },
478            }
479        }
480
481        fn snowflake() -> Self {
482            Self {
483                info: DialectInfo {
484                    name: "snowflake",
485                    display_name: "Snowflake",
486                    aliases: &["sf"],
487                    dialect_family: DialectFamily::Snowflake,
488                    family: "Snowflake",
489                    is_cloud_native: true,
490                    is_olap: true,
491                    is_postgres_compatible: false,
492                    versions: &[],
493                    deploy_script: None,
494                },
495            }
496        }
497    }
498
499    impl SqlRenderer for TestDialect {
500        fn render_data_type(&self, _dt: &DataType) -> String {
501            "TEXT".to_string()
502        }
503
504        fn quote_identifier(&self, name: &str) -> String {
505            format!("\"{name}\"")
506        }
507
508        fn qualify_name(&self, schema: Option<&str>, name: &str) -> String {
509            match schema {
510                Some(s) => format!("{}.{}", self.quote_identifier(s), self.quote_identifier(name)),
511                None => self.quote_identifier(name),
512            }
513        }
514
515        fn render_current_timestamp(&self) -> String {
516            "CURRENT_TIMESTAMP".to_string()
517        }
518    }
519
520    impl ExpressionRenderer for TestDialect {
521        fn render_concat(&self, parts: &[String]) -> String {
522            parts.join(" || ")
523        }
524    }
525
526    impl DdlRenderer for TestDialect {}
527    impl DmlRenderer for TestDialect {}
528    impl ProcedureRenderer for TestDialect {}
529    impl FormulaRenderer for TestDialect {}
530    impl TaskRenderer for TestDialect {}
531    impl ReplicationRenderer for TestDialect {}
532    impl StorageRenderer for TestDialect {}
533
534    impl DialectCapabilities for TestDialect {
535        fn supports_identity(&self) -> bool {
536            true
537        }
538        fn supports_sequences(&self) -> bool {
539            true
540        }
541        fn supports_stored_procedures(&self) -> bool {
542            true
543        }
544    }
545
546    impl PlatformDialect for TestDialect {
547        fn info(&self) -> DialectInfo {
548            self.info.clone()
549        }
550    }
551
552    const FLOOR_ONLY: &[EngineVersion] = &[EngineVersion::declared("15")];
553
554    /// **A dialect with one answer is itself at its one version**, and the
555    /// default refuses any other, naming the one it answers for.
556    #[test]
557    fn a_dialect_with_one_answer_is_itself_at_its_floor_and_refuses_another() {
558        let dialect: Arc<dyn PlatformDialect> = Arc::new(TestDialect {
559            info: DialectInfo { versions: FLOOR_ONLY, ..TestDialect::postgres().info() },
560        });
561        assert_eq!(dialect.engine_version(), Some(EngineVersion::declared("15")));
562        let at_floor =
563            Arc::clone(&dialect).at_version(&EngineVersion::declared("15")).expect("its floor");
564        assert!(Arc::ptr_eq(&at_floor, &dialect), "a dialect with one answer returns itself");
565
566        let Err(refused) = Arc::clone(&dialect).at_version(&EngineVersion::declared("17")) else {
567            panic!("17 is not declared")
568        };
569        assert_eq!(refused.answers_for, vec![EngineVersion::declared("15")]);
570        let said = refused.to_string();
571        assert!(said.contains("PostgreSQL"), "{said}");
572        assert!(said.contains("15"), "{said}");
573        assert!(said.contains("`postgres`"), "names the spelling that works: {said}");
574    }
575
576    /// **A service that chooses its own version answers for none.**
577    #[test]
578    fn a_service_that_chooses_its_own_version_answers_for_none() {
579        let dialect = TestDialect::snowflake();
580        assert!(dialect.info().versions.is_empty());
581        assert_eq!(dialect.engine_version(), None);
582    }
583
584    /// **The refusal lists every version, the floor first, and the names to
585    /// write.**
586    #[test]
587    fn an_unsupported_version_names_every_version_and_how_to_write_each() {
588        let refusal = UnsupportedVersion {
589            engine: "postgres".to_owned(),
590            display_name: "PostgreSQL".to_owned(),
591            asked: "12".to_owned(),
592            answers_for: ["15", "16", "17"].iter().map(|v| EngineVersion::declared(v)).collect(),
593        };
594        let said = refusal.to_string();
595        assert!(said.contains("15, 16 and 17"), "{said}");
596        assert!(said.contains("not at 12"), "{said}");
597        assert!(said.contains("`postgres@17`"), "{said}");
598        assert!(said.contains("`postgres` for 15"), "the name alone is the floor: {said}");
599    }
600
601    #[test]
602    fn the_versions_ride_along_in_the_discovery_payload() {
603        let info = DialectInfo { versions: FLOOR_ONLY, ..TestDialect::postgres().info() };
604        let json = serde_json::to_value(&info).expect("DialectInfo serializes");
605        assert_eq!(json["versions"], serde_json::json!(["15"]));
606    }
607
608    #[test]
609    fn register_dialect_resolves_by_name() {
610        let mut registry = PlatformRegistry::new();
611        registry.register("postgres".to_string(), Arc::new(TestDialect::postgres()));
612        assert!(registry.get_by_name("postgres").is_some());
613    }
614
615    #[test]
616    fn register_extension_resolves_by_name() {
617        let mut registry = PlatformRegistry::new();
618        registry.register("redshift".to_string(), Arc::new(TestDialect::redshift()));
619        assert!(registry.get_by_name("redshift").is_some());
620    }
621
622    #[test]
623    fn resolve_tries_name_lookup() {
624        let mut registry = PlatformRegistry::new();
625        registry.register("postgres".to_string(), Arc::new(TestDialect::postgres()));
626        registry.register("redshift".to_string(), Arc::new(TestDialect::redshift()));
627
628        assert!(registry.resolve("postgres").is_some());
629        assert!(registry.resolve("redshift").is_some());
630        assert!(registry.resolve("nonexistent").is_none());
631    }
632
633    #[test]
634    fn list_returns_all_registered_dialects() {
635        let mut registry = PlatformRegistry::new();
636        registry.register("postgres".to_string(), Arc::new(TestDialect::postgres()));
637        registry.register("snowflake".to_string(), Arc::new(TestDialect::snowflake()));
638        registry.register("redshift".to_string(), Arc::new(TestDialect::redshift()));
639
640        let list = registry.list();
641        assert_eq!(list.len(), 3);
642    }
643
644    #[test]
645    fn duplicate_registration_overwrites() {
646        let mut registry = PlatformRegistry::new();
647        registry.register("postgres".to_string(), Arc::new(TestDialect::postgres()));
648        registry.register("postgres".to_string(), Arc::new(TestDialect::postgres()));
649
650        assert!(registry.get_by_name("postgres").is_some());
651        let list = registry.list();
652        assert_eq!(list.len(), 1);
653    }
654
655    #[test]
656    fn dialect_info_contains_expected_metadata() {
657        let dialect = TestDialect::postgres();
658        let info = dialect.info();
659        assert_eq!(info.name, "postgres");
660        assert_eq!(info.display_name, "PostgreSQL");
661        assert_eq!(info.dialect_family, DialectFamily::PostgreSql);
662    }
663
664    #[test]
665    fn classification_fields_reflect_dialect_characteristics() {
666        let pg = TestDialect::postgres();
667        let pg_info = pg.info();
668        assert_eq!(pg_info.family, "PostgreSQL");
669        assert!(pg_info.is_postgres_compatible);
670        assert!(!pg_info.is_cloud_native);
671        assert!(!pg_info.is_olap);
672
673        let sf = TestDialect::snowflake();
674        let sf_info = sf.info();
675        assert_eq!(sf_info.family, "Snowflake");
676        assert!(sf_info.is_cloud_native);
677        assert!(sf_info.is_olap);
678        assert!(!sf_info.is_postgres_compatible);
679
680        let rd = TestDialect::redshift();
681        let rd_info = rd.info();
682        assert!(rd_info.is_cloud_native);
683        assert!(rd_info.is_olap);
684        assert!(rd_info.is_postgres_compatible);
685    }
686
687    #[test]
688    fn empty_registry_returns_none() {
689        let registry = PlatformRegistry::new();
690        assert!(registry.get_by_name("postgres").is_none());
691        assert!(registry.resolve("postgres").is_none());
692        assert!(registry.list().is_empty());
693    }
694
695    // ── Discovery + canonical names ─────────────────────────────────────────
696
697    #[test]
698    fn registered_names_are_sorted() {
699        let mut registry = PlatformRegistry::new();
700        registry.register("snowflake".to_string(), Arc::new(TestDialect::snowflake()));
701        registry.register("postgres".to_string(), Arc::new(TestDialect::postgres()));
702        registry.register("redshift".to_string(), Arc::new(TestDialect::redshift()));
703        assert_eq!(registry.registered_names(), vec!["postgres", "redshift", "snowflake"]);
704    }
705
706    #[test]
707    fn dialect_info_serializes_for_discovery() {
708        // The discovery payload (list_platforms) + the constants generator both
709        // read serialized DialectInfo, so it must round-trip to JSON.
710        let info = TestDialect::snowflake().info();
711        let json = serde_json::to_value(&info).expect("DialectInfo serializes");
712        assert_eq!(json["name"], "snowflake");
713        assert_eq!(json["display_name"], "Snowflake");
714        assert_eq!(json["dialect_family"], "snowflake");
715        assert_eq!(json["is_cloud_native"], true);
716        assert_eq!(json["is_postgres_compatible"], false);
717        // The spellings ride along, so a host learns what a design may call
718        // this platform from the same payload that lists it.
719        assert_eq!(json["aliases"], serde_json::json!(["sf"]));
720    }
721
722    #[test]
723    fn spellings_start_with_the_dialects_own_name() {
724        let info = TestDialect::postgres().info();
725        assert_eq!(info.spellings(), vec!["postgres", "postgresql", "pg"]);
726    }
727
728    #[test]
729    fn a_dialect_answers_to_its_name_and_its_aliases_in_any_case() {
730        let info = TestDialect::postgres().info();
731        assert!(info.answers_to("postgres"));
732        assert!(info.answers_to("pg"));
733        assert!(info.answers_to("  PostgreSQL "));
734        assert!(!info.answers_to("snowflake"));
735    }
736
737    #[test]
738    fn an_alias_repeating_the_name_is_listed_once() {
739        let info = DialectInfo { aliases: &["postgres", "pg"], ..TestDialect::postgres().info() };
740        assert_eq!(info.spellings(), vec!["postgres", "pg"]);
741    }
742}