razeri-plugin-contract 0.12.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.

//! **Changing a table that already holds rows** — what this engine keeps
//! through such a change, and the statement it takes to make one.
//!
//! A first deploy writes `CREATE`. Every deploy after it has rows to keep, and
//! the two questions a dialect is the only one able to answer are:
//!
//! - **does the engine keep every value through this change?** A column
//!   declared to hold ten characters and now declared to hold forty holds
//!   everything it held on one engine and is rebuilt by a cast on another.
//!   [`EvolutionAnswers`] is where a dialect says which it is.
//! - **what statement does the engine take?** Most write
//!   `ALTER TABLE … ADD COLUMN …`, some write `ALTER TABLE … ADD …`, and a
//!   change some engines make with a statement is not a statement other engines
//!   have at all. [`ColumnEvolution`] is the change, handed over to be spelled.
//!
//! # Both default to the careful answer
//!
//! An engine nobody has measured keeps nothing it has not been said to keep,
//! and is given no statement Razeri cannot show it accepts. A dialect that
//! answers nothing here still gets `ADD COLUMN`, the spelling most engines
//! take, and gets no other statement at all. An engine that writes `ADD`
//! without `COLUMN` overrides
//! [`DdlRenderer::render_column_evolution`](crate::DdlRenderer::render_column_evolution).

use crate::data_type::DataType;

/// **What this engine keeps through a change to a table that already holds
/// rows.**
///
/// Every answer defaults to `false`, which means *nothing has been measured*
/// rather than *this engine loses data*. Razeri's classifier treats a change it
/// cannot prove keeps every value as one that does not, so a silence here costs
/// an operator a warning they can read and never costs them a row.
///
/// An answer only ever makes a verdict gentler. Razeri proves what it can from
/// the types themselves first; these answers are asked afterwards, about the
/// changes the engine's own spelling left unprovable.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub struct EvolutionAnswers {
    /// **This engine widens a string's declared length in place, keeping every
    /// value.**
    ///
    /// A column declared `varchar(10)` and now declared `varchar(40)` holds a
    /// strict superset of what it held, as a type. Whether the engine gets it
    /// there without rewriting the column is the engine's own business, and
    /// this is where it says so.
    ///
    /// The question is asked at all because a length is spelled differently on
    /// every engine — `VARCHAR(40)` on one, `STRING(40)` on another,
    /// `NVARCHAR(40)` on a third — and the length in a spelling Razeri does not
    /// write is a length Razeri cannot read back. A `true` here is the dialect
    /// saying *"that pair is a widening on my engine, and nothing is lost"*.
    pub parameterized_string_widening_is_lossless: bool,

    /// **This engine adds a field to a record a table already holds, keeping
    /// every value.**
    ///
    /// The change is a record column that gained a field and changed in no
    /// other way. Some engines take it as a change to the column's declared
    /// shape and keep every row; others have no way to make it at all, and the
    /// column has to be rebuilt.
    ///
    /// `false` is the careful answer, and it is the right one for every engine
    /// that has no records to begin with.
    pub nested_field_addition_is_lossless: bool,
}

impl EvolutionAnswers {
    /// **Nothing measured** — the answer for an engine nobody has asked.
    #[must_use]
    pub const fn none() -> Self {
        Self {
            parameterized_string_widening_is_lossless: false,
            nested_field_addition_is_lossless: false,
        }
    }

    /// **This engine widens a string's declared length in place.** See
    /// [`EvolutionAnswers::parameterized_string_widening_is_lossless`].
    #[must_use]
    pub const fn widening_a_string_in_place() -> Self {
        Self {
            parameterized_string_widening_is_lossless: true,
            nested_field_addition_is_lossless: false,
        }
    }

    /// The same answers with the nested-field one added, so an engine that
    /// keeps both states both without a constructor per pair.
    #[must_use]
    pub const fn and_a_nested_field(self) -> Self {
        Self { nested_field_addition_is_lossless: true, ..self }
    }
}

/// **One change to a table that already holds rows**, handed to the dialect to
/// spell.
///
/// The schema and table arrive unqualified and unquoted, because the dialect
/// knows how it qualifies and quotes them. The type arrives as a
/// [`DataType`] — Razeri's own, so the dialect renders it the way it renders
/// every other column of that type, and the statement cannot disagree with the
/// `CREATE` that would have built the same column.
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub enum ColumnEvolution<'a> {
    /// A column the table does not have yet, which accepts no value today.
    AddColumn {
        /// The table's schema, absent on engines that have none.
        schema: Option<&'a str>,
        /// The table.
        table: &'a str,
        /// The new column.
        column: &'a str,
        /// The type it is declared with.
        data_type: &'a DataType,
    },

    /// A column whose declared type now holds more than it held.
    WidenColumn {
        /// The table's schema, absent on engines that have none.
        schema: Option<&'a str>,
        /// The table.
        table: &'a str,
        /// The column.
        column: &'a str,
        /// The type it is declared with now.
        data_type: &'a DataType,
    },

    /// A record column that gained a field, and gained nothing else.
    AddNestedField {
        /// The table's schema, absent on engines that have none.
        schema: Option<&'a str>,
        /// The table.
        table: &'a str,
        /// The column whose record grew.
        column: &'a str,
        /// The path from the column to the new field, dotted through the
        /// records that contain it — for a dialect whose engine names the field
        /// rather than restating the column.
        field: &'a str,
        /// **The column's whole type, with the new field in it.** An engine
        /// that makes this change by restating the column's shape needs all of
        /// it; an engine that names the field alone reads `field` instead.
        data_type: &'a DataType,
    },
}

impl ColumnEvolution<'_> {
    /// The table's schema, whichever change this is.
    #[must_use]
    pub const fn schema(&self) -> Option<&str> {
        match self {
            Self::AddColumn { schema, .. }
            | Self::WidenColumn { schema, .. }
            | Self::AddNestedField { schema, .. } => *schema,
        }
    }

    /// The table, whichever change this is.
    #[must_use]
    pub const fn table(&self) -> &str {
        match self {
            Self::AddColumn { table, .. }
            | Self::WidenColumn { table, .. }
            | Self::AddNestedField { table, .. } => table,
        }
    }

    /// The column, whichever change this is.
    #[must_use]
    pub const fn column(&self) -> &str {
        match self {
            Self::AddColumn { column, .. }
            | Self::WidenColumn { column, .. }
            | Self::AddNestedField { column, .. } => column,
        }
    }

    /// The type the column is declared with after the change.
    #[must_use]
    pub const fn data_type(&self) -> &DataType {
        match self {
            Self::AddColumn { data_type, .. }
            | Self::WidenColumn { data_type, .. }
            | Self::AddNestedField { data_type, .. } => data_type,
        }
    }
}

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

    #[test]
    fn nothing_is_claimed_until_a_dialect_claims_it() {
        let silent = EvolutionAnswers::default();
        assert!(!silent.parameterized_string_widening_is_lossless);
        assert!(!silent.nested_field_addition_is_lossless);
        assert_eq!(silent, EvolutionAnswers::none());
    }

    #[test]
    fn an_engine_that_keeps_both_states_both() {
        let both = EvolutionAnswers::widening_a_string_in_place().and_a_nested_field();
        assert!(both.parameterized_string_widening_is_lossless);
        assert!(both.nested_field_addition_is_lossless);
    }

    #[test]
    fn a_change_names_its_table_and_column_whichever_change_it_is() {
        let data_type = DataType::boolean();
        let change = ColumnEvolution::AddColumn {
            schema: Some("telemetry_core"),
            table: "core_clients_daily",
            column: "is_default_browser",
            data_type: &data_type,
        };
        assert_eq!(change.schema(), Some("telemetry_core"));
        assert_eq!(change.table(), "core_clients_daily");
        assert_eq!(change.column(), "is_default_browser");
    }
}