razeri-plugin-contract 0.4.0

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.

//! Storage rendering trait for partition, compression, and index strategies.
//!
//! Each dialect plugin implements [`StorageRenderer`] to translate the agnostic
//! [`StoragePlan`] into native SQL fragments. All methods have default no-op
//! implementations — dialects that lack a feature simply do not override.
//!
//! Two rules bind every implementation: callers must never branch on dialect
//! name, and implementations must never panic on unsupported features — emit a
//! storage warning event and produce no SQL instead.

use crate::data_type::DataType;
use crate::storage_strategy::StoragePlan;

/// Lightweight reference to a target entity for storage rendering.
///
/// Carries only what storage rendering needs, so the contract does not depend
/// on Razeri's full design model.
#[derive(Debug, Clone, Copy)]
pub struct EntityRef<'a> {
    /// Optional schema/database namespace.
    pub schema: Option<&'a str>,
    /// Entity name (without schema qualifier).
    pub name: &'a str,
    /// Whether this rendering targets the entity's identity table (`*_identity`)
    /// rather than the primary table. Identity tables generally do not partition.
    pub is_identity_table: bool,
    /// Column names of the target table, in declaration order. Required by
    /// dialects whose index DDL must enumerate columns explicitly — for
    /// example T-SQL `CREATE NONCLUSTERED COLUMNSTORE INDEX ... (col, ...)`,
    /// which fails with `Msg 35336` if the column list is missing. Empty when
    /// the caller does not have column information; renderers that need
    /// columns must skip emission and warn rather than emit invalid DDL.
    pub columns: &'a [&'a str],
    /// The declared type of the plan's partition column, when the caller knows
    /// it.
    ///
    /// **Some platforms spell a partition differently per column type, and
    /// spell it wrong without this.** `GoogleSQL` accepts
    /// `DATE(<timestamp_column>)` and `DATE(<datetime_column>)`, and it accepts
    /// a `DATE` column only *bare* — `PARTITION BY DATE(<date_column>)` is
    /// refused with the grammar quoted back (measured on the service,
    /// 2026-09-07). A renderer that cannot tell the three apart has to guess,
    /// and one of its guesses is invalid DDL.
    ///
    /// `None` when the caller has no type information; a renderer must then
    /// emit only what is valid for every type it could be, or emit nothing.
    pub partition_column_type: Option<&'a DataType>,
}

/// **The one slice a load was told to replace**, as a statement writes it.
///
/// The value is not known when the SQL is composed — it is a different day on
/// every run — so what travels here is the text that names it: the load
/// procedure's own parameter, already marked so each procedure grammar can
/// decorate it its own way. A renderer puts `value` where its statement takes
/// the slice and never invents a literal of its own.
#[derive(Debug, Clone, Copy)]
pub struct SliceRef<'a> {
    /// The time-slice column the value is compared against.
    pub column: &'a str,
    /// The text that names the value — the bound parameter, in the spelling
    /// composed statements use. Copy it through; do not re-spell it.
    pub value: &'a str,
}

/// **How one engine replaces a single slice of a table.**
///
/// A load asked to refresh one day has to clear that day before it writes it,
/// and engines divide into two answers. Most empty it with a statement of
/// their own that runs first — a partition truncated, a partition dropped, or
/// a row-level delete narrowed to the slice. A few fold the clearing into the
/// write, so there is nothing to run first and the load's own `INSERT` carries
/// a clause instead.
///
/// Answering at all is a claim that the engine documents a verb for this.
/// Where it does not, answer `None` and Razeri writes the portable form: a
/// delete filtered to the slice, then the insert.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SliceReplacement {
    /// Statements that empty the named slice, run in order before the load
    /// writes it. Each must be complete and terminated the way this engine's
    /// procedure bodies expect.
    Emptied(Vec<String>),
    /// A clause the load's own `INSERT` carries, which replaces the named
    /// slice with the rows that insert writes — one statement, no separate
    /// clearing. Delta Lake's `REPLACE WHERE` is the worked case.
    WrittenByInsert(String),
}

/// Renders agnostic [`StoragePlan`] data into platform-native SQL fragments.
///
/// All methods have default implementations that produce no SQL. Dialects
/// override only the methods their platform supports.
pub trait StorageRenderer {
    /// Render any standalone partition function DDL (e.g. T-SQL
    /// `CREATE PARTITION FUNCTION`). Returns a list of statements emitted
    /// before the `CREATE TABLE`.
    #[must_use]
    fn render_partition_function(
        &self,
        _entity: EntityRef<'_>,
        _plan: &StoragePlan,
    ) -> Vec<String> {
        Vec::new()
    }

    /// Render any standalone partition scheme DDL (e.g. T-SQL
    /// `CREATE PARTITION SCHEME`). Returns statements emitted after the
    /// partition function and before the `CREATE TABLE`.
    #[must_use]
    fn render_partition_scheme(&self, _entity: EntityRef<'_>, _plan: &StoragePlan) -> Vec<String> {
        Vec::new()
    }

    /// Render the inline storage clause appended directly to the
    /// `CREATE TABLE` statement (e.g. `PostgreSQL` `PARTITION BY RANGE (...)`,
    /// `ClickHouse` `PARTITION BY toYYYYMM(...) SETTINGS ...`).
    ///
    /// **On a platform whose
    /// [`requires_table_engine`](crate::SqlRenderer::requires_table_engine) is
    /// `true` this clause must contain NEITHER an engine clause nor a sorting
    /// key.** The `CREATE TABLE` composer emits both, and it
    /// is the only thing that can: a sorting key has to begin with the inline
    /// `PRIMARY KEY`, which this function is never handed. A clause that spells
    /// its own produces two engine clauses in one statement — `Code: 62` on
    /// `ClickHouse` — or a key the engine rejects at `Code: 36`. Clustering
    /// intent reaches the sorting key on the table instead, as its clustering
    /// columns.
    #[must_use]
    fn render_table_storage_clause(
        &self,
        _entity: EntityRef<'_>,
        _plan: &StoragePlan,
    ) -> Option<String> {
        None
    }

    /// Render statements emitted after the `CREATE TABLE` (e.g. `PostgreSQL`
    /// child partition `CREATE TABLE ... PARTITION OF ...`, secondary indexes,
    /// `CLUSTER BY`).
    #[must_use]
    fn render_post_create_storage(
        &self,
        _entity: EntityRef<'_>,
        _plan: &StoragePlan,
    ) -> Vec<String> {
        Vec::new()
    }

    /// Render the maintenance procedure that extends the partitioning window
    /// (creates the partitions covering a day and the days after it).
    ///
    /// **The procedure takes the day it should cover.** A warehouse loads the
    /// day a scheduler hands it, and that day is as often behind as ahead: a
    /// backfill runs last Tuesday today. A procedure that reads the clock
    /// instead can only ever cover a window around *now*, so the one day the
    /// caller actually asked about is the one day it may not make. Take the
    /// day as the first parameter, spelled the way the load takes it, and
    /// default it to today where the engine has defaults — the procedure then
    /// still answers the deploy-time call that names no day.
    ///
    /// The forward window is a second parameter where the engine allows one,
    /// so the same call covers the named day and the days a scheduler has not
    /// reached yet.
    #[must_use]
    fn render_partition_extend_procedure(
        &self,
        _entity: EntityRef<'_>,
        _plan: &StoragePlan,
    ) -> Option<String> {
        None
    }

    /// Render the maintenance procedure that retains the partitioning window
    /// (drops or archives the oldest partition per the retention policy).
    ///
    /// The day the window is measured back from is a parameter for the same
    /// reason the extend procedure's is, and defaults to today the same way.
    #[must_use]
    fn render_partition_retain_procedure(
        &self,
        _entity: EntityRef<'_>,
        _plan: &StoragePlan,
    ) -> Option<String> {
        None
    }

    /// **The extend procedure a load must call before it writes a day**, or
    /// `None` where this engine needs no such call.
    ///
    /// Engines divide on what happens to a row whose day falls outside every
    /// partition that has been declared. Most keep somewhere for it to go — a
    /// range ending in a catch-all, a partition function with no boundaries
    /// yet, a partition the engine derives from the value itself — so the row
    /// lands whatever the maintenance window has reached, and extending is a
    /// tidiness the load need not wait for. `PostgreSQL` declarative
    /// partitioning keeps nowhere: a parent with no matching child answers
    /// *"no partition of relation … found for row"* and the load stops.
    ///
    /// Answer with the procedure's name — unqualified, the way
    /// [`Self::render_partition_extend_procedure`] names the object it creates
    /// — and only where that procedure is rendered at all. Razeri then writes
    /// the call ahead of the load, with the same day the load is given.
    #[must_use]
    fn partition_extend_call_before_write(
        &self,
        _entity: EntityRef<'_>,
        _plan: &StoragePlan,
    ) -> Option<String> {
        None
    }

    /// Render this engine's own way of replacing **one slice** of a table:
    /// the day, month or other value of the time-slice column that a load was
    /// told to refresh outright.
    ///
    /// The default is `None`, which is not a gap — it is the answer for every
    /// engine that documents no verb for this, and Razeri then writes the
    /// portable form: a `DELETE` filtered to the slice, followed by the
    /// insert. Snowflake, Redshift and `DuckDB` take that form because none of
    /// the three documents a single-slice replace; saying so is better than
    /// borrowing a neighbour's grammar and calling it theirs.
    ///
    /// **Answer with a partition-level verb only when one partition holds
    /// exactly one slice.** A table partitioned by month and loaded by day has
    /// thirty slices inside each partition, so truncating or dropping the
    /// partition that holds the named day would throw away twenty-nine days
    /// nobody asked about. Check the plan's granularity against the slice
    /// column's own grain ([`EntityRef::partition_column_type`]) and answer
    /// `None` when they do not agree — the portable delete is correct at every
    /// grain. A predicate-based verb, which names the slice rather than the
    /// partition, has no such limit.
    ///
    /// The slice arrives as text naming a bound parameter, not as a value:
    /// put it where your statement takes the slice, and where your engine
    /// needs a name rather than an expression, build the name from it the same
    /// way [`Self::render_partition_extend_procedure`] built it when the
    /// partition was created. Two spellings of one name is how a load comes to
    /// clear a partition that does not exist.
    #[must_use]
    fn render_slice_replacement(
        &self,
        _entity: EntityRef<'_>,
        _plan: &StoragePlan,
        _slice: SliceRef<'_>,
    ) -> Option<SliceReplacement> {
        None
    }
}

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

    struct DefaultRenderer;
    impl StorageRenderer for DefaultRenderer {}

    fn entity() -> EntityRef<'static> {
        EntityRef {
            schema: Some("core"),
            name: "events",
            is_identity_table: false,
            columns: &[],
            partition_column_type: None,
        }
    }

    #[test]
    fn defaults_produce_no_sql() {
        let r = DefaultRenderer;
        let plan = StoragePlan::none();
        assert!(r.render_partition_function(entity(), &plan).is_empty());
        assert!(r.render_partition_scheme(entity(), &plan).is_empty());
        assert!(r.render_table_storage_clause(entity(), &plan).is_none());
        assert!(r.render_post_create_storage(entity(), &plan).is_empty());
        assert!(r.render_partition_extend_procedure(entity(), &plan).is_none());
        assert!(r.render_partition_retain_procedure(entity(), &plan).is_none());
        assert!(r.partition_extend_call_before_write(entity(), &plan).is_none());
        assert!(
            r.render_slice_replacement(
                entity(),
                &plan,
                SliceRef { column: "day", value: "$p_slice" }
            )
            .is_none()
        );
    }
}