razeri-plugin-api 0.1.1

Extension traits and registries for Razeri plugins
Documentation
// Copyright (C) 2026 Gabriel Shea
// Licensed under either of the Apache License, Version 2.0 or the MIT
// license, at your option.

//! Platform dialect trait and registry for database SQL rendering.
//!
//! [`PlatformDialect`] extends the rendering trait hierarchy from
//! `razeri-plugin-contract` with plugin metadata and registration support.
//! [`PlatformRegistry`] resolves dialects by canonical string name,
//! so built-in and third-party plugins are looked up the same way — there is no
//! closed platform enum, and an unknown name is an error, never a fallback.

use razeri_plugin_contract::DialectFamily;
use razeri_plugin_contract::{
    DdlRenderer, DialectCapabilities, DmlRenderer, ExpressionRenderer, FormulaRenderer,
    ProcedureRenderer, ReplicationRenderer, SqlRenderer, StorageRenderer, TaskRenderer,
};
use std::collections::HashMap;
use std::sync::Arc;

// Re-export rendering traits so dialect crates can import from one place.
pub use razeri_plugin_contract;

/// Metadata describing a dialect implementation.
///
/// Serializes to the discovery payload returned by `list_platforms()` across
/// every surface (and consumed by the typed-constants generator), so hosts learn
/// the registered platforms — including third-party plugins — at runtime instead
/// of hard-coding them.
#[derive(Debug, Clone, serde::Serialize)]
pub struct DialectInfo {
    /// Machine-readable name (e.g., "postgres", "redshift").
    pub name: &'static str,
    /// Human-readable display name (e.g., "`PostgreSQL`", "Amazon Redshift").
    pub display_name: &'static str,
    /// Dialect family for shared behavior (e.g., `PostgreSQL`, `TSql`).
    pub dialect_family: DialectFamily,
    /// SQL family label (e.g., "`PostgreSQL`", "T-SQL", "`ClickHouse` SQL").
    pub family: &'static str,
    /// Whether this is a cloud-native platform (e.g. Snowflake, Databricks).
    pub is_cloud_native: bool,
    /// Whether this is primarily an OLAP engine (e.g. `ClickHouse`).
    pub is_olap: bool,
    /// Whether this dialect is PostgreSQL-wire-compatible.
    pub is_postgres_compatible: bool,
}

/// Full plugin trait for database platform dialects.
///
/// Extends the rendering trait hierarchy (`SqlRenderer`, `ExpressionRenderer`,
/// `DdlRenderer`, `DmlRenderer`, `ProcedureRenderer`, `FormulaRenderer`,
/// `TaskRenderer`, `ReplicationRenderer`, `StorageRenderer`, `DialectCapabilities`)
/// with plugin-specific metadata and registration support.
///
/// Dialect authors implement the rendering traits plus `PlatformDialect::info()`.
pub trait PlatformDialect:
    SqlRenderer
    + ExpressionRenderer
    + DdlRenderer
    + DmlRenderer
    + ProcedureRenderer
    + FormulaRenderer
    + TaskRenderer
    + DialectCapabilities
    + ReplicationRenderer
    + StorageRenderer
{
    /// Returns metadata about this dialect.
    fn info(&self) -> DialectInfo;
}

/// String-keyed registry for platform dialects.
///
/// All dialects are indexed by their canonical string name
/// (e.g. `"postgres"`, `"snowflake"`, `"mysql"`).
pub struct PlatformRegistry {
    dialects: HashMap<String, Arc<dyn PlatformDialect>>,
}

impl PlatformRegistry {
    /// Create an empty registry.
    #[must_use]
    pub fn new() -> Self {
        Self { dialects: HashMap::new() }
    }

    /// Register a dialect by its canonical string name.
    pub fn register(&mut self, name: String, dialect: Arc<dyn PlatformDialect>) {
        self.dialects.insert(name, dialect);
    }

    /// Resolve by string name (canonical path for all dialects).
    #[must_use]
    pub fn get_by_name(&self, name: &str) -> Option<&Arc<dyn PlatformDialect>> {
        self.dialects.get(name)
    }

    /// Resolve by string — same as [`get_by_name`](Self::get_by_name).
    #[must_use]
    pub fn resolve(&self, target: &str) -> Option<Arc<dyn PlatformDialect>> {
        self.dialects.get(target).cloned()
    }

    /// List all registered dialects.
    #[must_use]
    pub fn list(&self) -> Vec<DialectInfo> {
        self.dialects.values().map(|d| d.info()).collect()
    }

    /// Sorted machine-readable names of every registered dialect.
    ///
    /// The stable, ordered form of [`list`](Self::list); the
    /// [`resolve`](crate::resolve) module uses it for error messages and the
    /// typed-constants generator reads it for deterministic output.
    #[must_use]
    pub fn registered_names(&self) -> Vec<String> {
        let mut names: Vec<String> = self.dialects.keys().cloned().collect();
        names.sort();
        names
    }

    /// Build a registry pre-populated with all auto-discovered dialect crates.
    ///
    /// Uses `inventory` to find every [`crate::builder::DialectRegistration`]
    /// submitted at link time.
    #[must_use]
    pub fn with_auto_discovered() -> Self {
        use crate::builder::DialectRegistration;
        let mut registry = Self::new();
        for reg in inventory::iter::<DialectRegistration> {
            let dialect = (reg.factory)();
            let name = dialect.info().name.to_string();
            registry.register(name, dialect);
        }
        registry
    }
}

impl Default for PlatformRegistry {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use razeri_plugin_contract::DataType;

    /// Test dialect for unit tests.
    struct TestDialect {
        info: DialectInfo,
    }

    impl TestDialect {
        fn postgres() -> Self {
            Self {
                info: DialectInfo {
                    name: "postgres",
                    display_name: "PostgreSQL",
                    dialect_family: DialectFamily::PostgreSql,
                    family: "PostgreSQL",
                    is_cloud_native: false,
                    is_olap: false,
                    is_postgres_compatible: true,
                },
            }
        }

        fn redshift() -> Self {
            Self {
                info: DialectInfo {
                    name: "redshift",
                    display_name: "Amazon Redshift",
                    dialect_family: DialectFamily::PostgreSql,
                    family: "PostgreSQL",
                    is_cloud_native: true,
                    is_olap: true,
                    is_postgres_compatible: true,
                },
            }
        }

        fn snowflake() -> Self {
            Self {
                info: DialectInfo {
                    name: "snowflake",
                    display_name: "Snowflake",
                    dialect_family: DialectFamily::Snowflake,
                    family: "Snowflake",
                    is_cloud_native: true,
                    is_olap: true,
                    is_postgres_compatible: false,
                },
            }
        }
    }

    impl SqlRenderer for TestDialect {
        fn render_data_type(&self, _dt: &DataType) -> String {
            "TEXT".to_string()
        }

        fn quote_identifier(&self, name: &str) -> String {
            format!("\"{name}\"")
        }

        fn qualify_name(&self, schema: Option<&str>, name: &str) -> String {
            match schema {
                Some(s) => format!("{}.{}", self.quote_identifier(s), self.quote_identifier(name)),
                None => self.quote_identifier(name),
            }
        }

        fn render_current_timestamp(&self) -> String {
            "CURRENT_TIMESTAMP".to_string()
        }
    }

    impl ExpressionRenderer for TestDialect {
        fn render_concat(&self, parts: &[String]) -> String {
            parts.join(" || ")
        }
    }

    impl DdlRenderer for TestDialect {}
    impl DmlRenderer for TestDialect {}
    impl ProcedureRenderer for TestDialect {}
    impl FormulaRenderer for TestDialect {}
    impl TaskRenderer for TestDialect {}
    impl ReplicationRenderer for TestDialect {}
    impl StorageRenderer for TestDialect {}

    impl DialectCapabilities for TestDialect {
        fn supports_identity(&self) -> bool {
            true
        }
        fn supports_sequences(&self) -> bool {
            true
        }
        fn supports_stored_procedures(&self) -> bool {
            true
        }
    }

    impl PlatformDialect for TestDialect {
        fn info(&self) -> DialectInfo {
            self.info.clone()
        }
    }

    #[test]
    fn register_dialect_resolves_by_name() {
        let mut registry = PlatformRegistry::new();
        registry.register("postgres".to_string(), Arc::new(TestDialect::postgres()));
        assert!(registry.get_by_name("postgres").is_some());
    }

    #[test]
    fn register_extension_resolves_by_name() {
        let mut registry = PlatformRegistry::new();
        registry.register("redshift".to_string(), Arc::new(TestDialect::redshift()));
        assert!(registry.get_by_name("redshift").is_some());
    }

    #[test]
    fn resolve_tries_name_lookup() {
        let mut registry = PlatformRegistry::new();
        registry.register("postgres".to_string(), Arc::new(TestDialect::postgres()));
        registry.register("redshift".to_string(), Arc::new(TestDialect::redshift()));

        assert!(registry.resolve("postgres").is_some());
        assert!(registry.resolve("redshift").is_some());
        assert!(registry.resolve("nonexistent").is_none());
    }

    #[test]
    fn list_returns_all_registered_dialects() {
        let mut registry = PlatformRegistry::new();
        registry.register("postgres".to_string(), Arc::new(TestDialect::postgres()));
        registry.register("snowflake".to_string(), Arc::new(TestDialect::snowflake()));
        registry.register("redshift".to_string(), Arc::new(TestDialect::redshift()));

        let list = registry.list();
        assert_eq!(list.len(), 3);
    }

    #[test]
    fn duplicate_registration_overwrites() {
        let mut registry = PlatformRegistry::new();
        registry.register("postgres".to_string(), Arc::new(TestDialect::postgres()));
        registry.register("postgres".to_string(), Arc::new(TestDialect::postgres()));

        assert!(registry.get_by_name("postgres").is_some());
        let list = registry.list();
        assert_eq!(list.len(), 1);
    }

    #[test]
    fn dialect_info_contains_expected_metadata() {
        let dialect = TestDialect::postgres();
        let info = dialect.info();
        assert_eq!(info.name, "postgres");
        assert_eq!(info.display_name, "PostgreSQL");
        assert_eq!(info.dialect_family, DialectFamily::PostgreSql);
    }

    #[test]
    fn classification_fields_reflect_dialect_characteristics() {
        let pg = TestDialect::postgres();
        let pg_info = pg.info();
        assert_eq!(pg_info.family, "PostgreSQL");
        assert!(pg_info.is_postgres_compatible);
        assert!(!pg_info.is_cloud_native);
        assert!(!pg_info.is_olap);

        let sf = TestDialect::snowflake();
        let sf_info = sf.info();
        assert_eq!(sf_info.family, "Snowflake");
        assert!(sf_info.is_cloud_native);
        assert!(sf_info.is_olap);
        assert!(!sf_info.is_postgres_compatible);

        let rd = TestDialect::redshift();
        let rd_info = rd.info();
        assert!(rd_info.is_cloud_native);
        assert!(rd_info.is_olap);
        assert!(rd_info.is_postgres_compatible);
    }

    #[test]
    fn empty_registry_returns_none() {
        let registry = PlatformRegistry::new();
        assert!(registry.get_by_name("postgres").is_none());
        assert!(registry.resolve("postgres").is_none());
        assert!(registry.list().is_empty());
    }

    // ── Discovery + canonical names ─────────────────────────────────────────

    #[test]
    fn registered_names_are_sorted() {
        let mut registry = PlatformRegistry::new();
        registry.register("snowflake".to_string(), Arc::new(TestDialect::snowflake()));
        registry.register("postgres".to_string(), Arc::new(TestDialect::postgres()));
        registry.register("redshift".to_string(), Arc::new(TestDialect::redshift()));
        assert_eq!(registry.registered_names(), vec!["postgres", "redshift", "snowflake"]);
    }

    #[test]
    fn dialect_info_serializes_for_discovery() {
        // The discovery payload (list_platforms) + the constants generator both
        // read serialized DialectInfo, so it must round-trip to JSON.
        let info = TestDialect::snowflake().info();
        let json = serde_json::to_value(&info).expect("DialectInfo serializes");
        assert_eq!(json["name"], "snowflake");
        assert_eq!(json["display_name"], "Snowflake");
        assert_eq!(json["dialect_family"], "snowflake");
        assert_eq!(json["is_cloud_native"], true);
        assert_eq!(json["is_postgres_compatible"], false);
    }
}