razeri-plugin-contract 0.3.2

The dialect contract Razeri plugins are written against — rendering traits, data types, platform identity
Documentation
// Copyright (C) 2026 Gabriel Shea
// Licensed under either of the Apache License, Version 2.0 or the MIT
// license, at your option.

//! Formula-level rendering extensions with ANSI defaults.

use crate::DataTypeRef;
use crate::SqlRenderer;

/// Formula-level function rendering with ANSI-safe defaults.
///
/// These methods replace platform `match` blocks in formula rendering code.
pub trait FormulaRenderer: SqlRenderer {
    /// Render `STRING_AGG` / LISTAGG style aggregation.
    fn render_string_agg(
        &self,
        expr: &str,
        sep: &str,
        distinct: bool,
        order: Option<&str>,
    ) -> String {
        let d = if distinct { "DISTINCT " } else { "" };
        match order {
            Some(o) => format!("STRING_AGG({d}{expr}, '{sep}' ORDER BY {o})"),
            None => format!("STRING_AGG({d}{expr}, '{sep}')"),
        }
    }

    /// Render `CONCAT_WS`.
    fn render_concat_ws(&self, separator: &str, parts: &[&str]) -> String {
        format!("CONCAT_WS('{separator}', {})", parts.join(", "))
    }

    /// Render `FIRST_VALUE` with optional IGNORE NULLS.
    fn render_first_value(&self, expr: &str, ignore_nulls: bool) -> String {
        if ignore_nulls {
            format!("FIRST_VALUE({expr}) IGNORE NULLS")
        } else {
            format!("FIRST_VALUE({expr})")
        }
    }

    /// Render `STDDEV_POP` / `STDDEV_SAMP`.
    fn render_stddev(&self, expr: &str, population: bool) -> String {
        let func = if population { "STDDEV_POP" } else { "STDDEV_SAMP" };
        format!("{func}({expr})")
    }

    /// Render `VAR_POP` / `VAR_SAMP`.
    fn render_variance(&self, expr: &str, population: bool) -> String {
        let func = if population { "VAR_POP" } else { "VAR_SAMP" };
        format!("{func}({expr})")
    }

    /// Render `PERCENTILE_CONT` / `PERCENTILE_DISC`.
    fn render_percentile(&self, expr: &str, percentile: f64, continuous: bool) -> String {
        let func = if continuous { "PERCENTILE_CONT" } else { "PERCENTILE_DISC" };
        format!("{func}({percentile}) WITHIN GROUP (ORDER BY {expr})")
    }

    /// Render MEDIAN.
    fn render_median(&self, expr: &str) -> String {
        format!("PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY {expr})")
    }

    /// Render COUNT IF semantics.
    fn render_count_if(&self, condition: &str) -> String {
        format!("COUNT(CASE WHEN {condition} THEN 1 END)")
    }

    /// Render SUM IF semantics.
    fn render_sum_if(&self, expr: &str, condition: &str) -> String {
        format!("SUM(CASE WHEN {condition} THEN {expr} END)")
    }

    /// Render AVG IF semantics.
    fn render_avg_if(&self, expr: &str, condition: &str) -> String {
        format!("AVG(CASE WHEN {condition} THEN {expr} END)")
    }

    /// Render SPLIT / `STRING_TO_ARRAY` style expression.
    fn render_split(&self, expr: &str, delimiter: &str) -> String {
        format!("STRING_TO_ARRAY({expr}, '{delimiter}')")
    }

    /// Render `REGEXP_LIKE` / RLIKE.
    fn render_regexp_like(&self, expr: &str, pattern: &str) -> String {
        format!("REGEXP_LIKE({expr}, '{pattern}')")
    }

    /// Render `REGEXP_REPLACE`.
    fn render_regexp_replace(&self, expr: &str, pattern: &str, replacement: &str) -> String {
        format!("REGEXP_REPLACE({expr}, '{pattern}', '{replacement}')")
    }

    /// Render `REGEXP_EXTRACT` / `REGEXP_SUBSTR`.
    fn render_regexp_extract(&self, expr: &str, pattern: &str, group: Option<usize>) -> String {
        match group {
            Some(g) => format!("REGEXP_SUBSTR({expr}, '{pattern}', 1, 1, 'e', {g})"),
            None => format!("REGEXP_SUBSTR({expr}, '{pattern}')"),
        }
    }

    // There is deliberately no `render_generate_sid` here, and there never will
    // be one. A `GENERATE_SID` formula in a view and a dimension load over the
    // same column must key a member the same way, so Razeri composes both from
    // one derivation and asks the dialect only for its
    // [`SidSpelling`](crate::SidSpelling). Eight dialect crates used to spell
    // their own; a second definition that does not exist cannot drift back.

    /// Render NVL2 semantics.
    fn render_nvl2(&self, expr: &str, not_null: &str, is_null: &str) -> String {
        format!("CASE WHEN {expr} IS NOT NULL THEN {not_null} ELSE {is_null} END")
    }

    /// Render multi-branch IF semantics.
    fn render_multi_if(&self, conditions: &[(&str, &str)], else_: &str) -> String {
        use std::fmt::Write;
        let mut sql = "CASE".to_string();
        for (cond, then_expr) in conditions {
            let _ = write!(sql, " WHEN {cond} THEN {then_expr}");
        }
        let _ = write!(sql, " ELSE {else_} END");
        sql
    }

    // ================================================================
    // Operator rendering
    // ================================================================

    /// Render the POWER / exponentiation operator.
    fn render_power_operator(&self) -> String {
        "^".to_string()
    }

    /// Render the ILIKE operator (case-insensitive LIKE).
    fn render_ilike_operator(&self) -> String {
        "ILIKE".to_string()
    }

    /// Render the NOT ILIKE operator.
    fn render_not_ilike_operator(&self) -> String {
        "NOT ILIKE".to_string()
    }

    /// Render the string concatenation operator.
    fn render_concat_operator(&self) -> String {
        "||".to_string()
    }

    /// Render the bitwise XOR operator.
    fn render_bitwise_xor_operator(&self) -> String {
        "^".to_string()
    }

    // ================================================================
    // Conditional / cast rendering
    // ================================================================

    /// Render `TRY_CAST` expression (takes pre-rendered type string).
    fn render_try_cast_str(&self, expr: &str, type_str: &str) -> String {
        format!("CAST({expr} AS {type_str})")
    }

    /// Render CONVERT expression with optional style.
    fn render_convert(&self, expr: &str, type_str: &str, style: Option<i32>) -> String {
        let _ = style;
        format!("CAST({expr} AS {type_str})")
    }

    /// Render `SAFE_CAST` expression.
    fn render_safe_cast(&self, expr: &str, type_str: &str) -> String {
        format!("CAST({expr} AS {type_str})")
    }

    // ================================================================
    // Null handling
    // ================================================================

    /// Render IFNULL / ISNULL / COALESCE expression.
    fn render_if_null(&self, expr: &str, replacement: &str) -> String {
        format!("COALESCE({expr}, {replacement})")
    }

    // ================================================================
    // Parameter references
    // ================================================================

    /// Render a named parameter reference.
    fn render_named_param(&self, name: &str) -> String {
        format!(":{name}")
    }

    /// Render a positional parameter reference.
    fn render_positional_param(&self, pos: usize) -> String {
        let _ = pos;
        "?".to_string()
    }

    // ================================================================
    // Data type reference rendering (CAST expressions)
    // ================================================================

    /// Render a [`DataTypeRef`] as a SQL type name string.
    ///
    /// The default implementation produces ANSI-standard type names.
    /// Dialects override this for platform-specific names
    /// (e.g., `ClickHouse` `Int32` instead of `INT`).
    fn render_data_type_ref(&self, dt: &DataTypeRef) -> String {
        match dt {
            DataTypeRef::Int => "INT".to_string(),
            DataTypeRef::BigInt => "BIGINT".to_string(),
            DataTypeRef::SmallInt => "SMALLINT".to_string(),
            DataTypeRef::TinyInt => "TINYINT".to_string(),
            DataTypeRef::Float => "FLOAT".to_string(),
            DataTypeRef::Double => "DOUBLE PRECISION".to_string(),
            DataTypeRef::Real => "REAL".to_string(),
            DataTypeRef::Decimal { precision, scale } => format!("DECIMAL({precision}, {scale})"),
            DataTypeRef::Numeric { precision, scale } => format!("NUMERIC({precision}, {scale})"),
            DataTypeRef::Varchar { length } => match length {
                Some(len) => format!("VARCHAR({len})"),
                None => "VARCHAR".to_string(),
            },
            DataTypeRef::Char { length } => format!("CHAR({length})"),
            DataTypeRef::Text | DataTypeRef::Xml => "TEXT".to_string(),
            DataTypeRef::NVarchar { length } => match length {
                Some(len) => format!("NVARCHAR({len})"),
                None => "NVARCHAR(MAX)".to_string(),
            },
            DataTypeRef::NChar { length } => format!("NCHAR({length})"),
            DataTypeRef::Binary { length } => match length {
                Some(len) => format!("BINARY({len})"),
                None => "BINARY".to_string(),
            },
            DataTypeRef::VarBinary { length } => match length {
                Some(len) => format!("VARBINARY({len})"),
                None => "VARBINARY".to_string(),
            },
            DataTypeRef::Blob => "BLOB".to_string(),
            DataTypeRef::Date => "DATE".to_string(),
            DataTypeRef::Time { precision } => match precision {
                Some(p) => format!("TIME({p})"),
                None => "TIME".to_string(),
            },
            DataTypeRef::DateTime => "DATETIME".to_string(),
            DataTypeRef::DateTime2 { precision } => match precision {
                Some(p) => format!("DATETIME2({p})"),
                None => "DATETIME2".to_string(),
            },
            DataTypeRef::Timestamp { precision } => match precision {
                Some(p) => format!("TIMESTAMP({p})"),
                None => "TIMESTAMP".to_string(),
            },
            DataTypeRef::TimestampTz { precision } => match precision {
                Some(p) => format!("TIMESTAMP({p}) WITH TIME ZONE"),
                None => "TIMESTAMP WITH TIME ZONE".to_string(),
            },
            DataTypeRef::Interval => "INTERVAL".to_string(),
            DataTypeRef::Boolean => "BOOLEAN".to_string(),
            DataTypeRef::Bit => "BIT".to_string(),
            DataTypeRef::Json => "VARIANT".to_string(),
            DataTypeRef::Jsonb | DataTypeRef::Variant => "JSON".to_string(),
            DataTypeRef::Uuid => "UUID".to_string(),
            DataTypeRef::Custom(name) => name.clone(),
        }
    }
}

/// ANSI-standard rendering of [`DataTypeRef`].
///
/// Used as a fallback for dialects that don't override type rendering.
#[must_use]
pub fn render_data_type_ref_ansi(dt: &DataTypeRef) -> String {
    match dt {
        DataTypeRef::Int => "INT".to_string(),
        DataTypeRef::BigInt => "BIGINT".to_string(),
        DataTypeRef::SmallInt => "SMALLINT".to_string(),
        DataTypeRef::TinyInt => "TINYINT".to_string(),
        DataTypeRef::Float => "FLOAT".to_string(),
        DataTypeRef::Double => "DOUBLE PRECISION".to_string(),
        DataTypeRef::Real => "REAL".to_string(),
        DataTypeRef::Decimal { precision, scale } => format!("DECIMAL({precision}, {scale})"),
        DataTypeRef::Numeric { precision, scale } => format!("NUMERIC({precision}, {scale})"),
        DataTypeRef::Varchar { length } => match length {
            Some(len) => format!("VARCHAR({len})"),
            None => "VARCHAR".to_string(),
        },
        DataTypeRef::Char { length } => format!("CHAR({length})"),
        DataTypeRef::Text | DataTypeRef::Xml => "TEXT".to_string(),
        DataTypeRef::NVarchar { length } => match length {
            Some(len) => format!("NVARCHAR({len})"),
            None => "NVARCHAR(MAX)".to_string(),
        },
        DataTypeRef::NChar { length } => format!("NCHAR({length})"),
        DataTypeRef::Binary { length } => match length {
            Some(len) => format!("BINARY({len})"),
            None => "BINARY".to_string(),
        },
        DataTypeRef::VarBinary { length } => match length {
            Some(len) => format!("VARBINARY({len})"),
            None => "VARBINARY".to_string(),
        },
        DataTypeRef::Blob => "BLOB".to_string(),
        DataTypeRef::Date => "DATE".to_string(),
        DataTypeRef::Time { precision } => match precision {
            Some(p) => format!("TIME({p})"),
            None => "TIME".to_string(),
        },
        DataTypeRef::DateTime => "DATETIME".to_string(),
        DataTypeRef::DateTime2 { precision } => match precision {
            Some(p) => format!("DATETIME2({p})"),
            None => "DATETIME2".to_string(),
        },
        DataTypeRef::Timestamp { precision } => match precision {
            Some(p) => format!("TIMESTAMP({p})"),
            None => "TIMESTAMP".to_string(),
        },
        DataTypeRef::TimestampTz { precision } => match precision {
            Some(p) => format!("TIMESTAMP({p}) WITH TIME ZONE"),
            None => "TIMESTAMP WITH TIME ZONE".to_string(),
        },
        DataTypeRef::Interval => "INTERVAL".to_string(),
        DataTypeRef::Boolean => "BOOLEAN".to_string(),
        DataTypeRef::Bit => "BIT".to_string(),
        DataTypeRef::Json => "VARIANT".to_string(),
        DataTypeRef::Jsonb | DataTypeRef::Variant => "JSON".to_string(),
        DataTypeRef::Uuid => "UUID".to_string(),
        DataTypeRef::Custom(name) => name.clone(),
    }
}

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

    struct TestDialect;

    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!("\"{s}\".\"{name}\""),
                None => format!("\"{name}\""),
            }
        }

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

    impl FormulaRenderer for TestDialect {}

    #[test]
    fn formula_renderer_defaults_render_string_agg_with_order() {
        let d = TestDialect;
        assert_eq!(
            d.render_string_agg("name", ",", false, Some("name ASC")),
            "STRING_AGG(name, ',' ORDER BY name ASC)"
        );
    }

    #[test]
    fn formula_renderer_defaults_render_string_agg_with_distinct() {
        let d = TestDialect;
        assert_eq!(d.render_string_agg("name", ",", true, None), "STRING_AGG(DISTINCT name, ',')");
    }

    #[test]
    fn formula_renderer_defaults_render_count_if() {
        let d = TestDialect;
        assert_eq!(d.render_count_if("x > 0"), "COUNT(CASE WHEN x > 0 THEN 1 END)");
    }

    /// A dialect that has stated no spelling renders the guess, and the guess
    /// says so in its name. The value matters — it is what a plugin author sees
    /// before they have decided anything — and the name is what lets Razeri's
    /// conformance checks tell an undecided dialect from the agnostic renderer,
    /// whose identical vocabulary is ANSI on purpose.
    #[test]
    fn a_dialect_that_states_no_spelling_renders_the_guess_and_is_named_as_one() {
        use crate::sid_spelling::UNKNOWN_PLATFORM;
        let d = TestDialect;
        let spelling = crate::SqlRenderer::sid_spelling(&d);
        assert_eq!(spelling.platform, UNKNOWN_PLATFORM);
        assert_eq!(spelling.hash, ("xxhash64(", ")"));
    }
}