pgevolve-core 0.4.0

Postgres declarative schema management — core library (parser, IR, diff, planner) powering the pgevolve CLI.
Documentation
//! `Column` and column-level features (identity, generated, default, collation).

use serde::{Deserialize, Serialize};

use crate::identifier::{Identifier, QualifiedName};
use crate::ir::column_type::ColumnType;
use crate::ir::default_expr::DefaultExpr;
use crate::ir::eq::DiffMacro;

/// A table column.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, DiffMacro)]
pub struct Column {
    /// Column name.
    pub name: Identifier,
    /// Canonical column type.
    #[diff(via_debug)]
    pub ty: ColumnType,
    /// `NOT NULL` lives here, not in `Constraint`.
    pub nullable: bool,
    /// Optional column default.
    #[diff(via_debug)]
    pub default: Option<DefaultExpr>,
    /// Identity specification (`GENERATED ALWAYS|BY DEFAULT AS IDENTITY`).
    #[diff(via_debug)]
    pub identity: Option<Identity>,
    /// Generated-column specification (`GENERATED ALWAYS AS (expr) STORED`).
    #[diff(via_debug)]
    pub generated: Option<Generated>,
    /// Optional collation.
    #[diff(via_debug)]
    pub collation: Option<QualifiedName>,
    /// Per-column TOAST storage strategy. `None` means "PG type default".
    #[diff(via_debug)]
    pub storage: Option<StorageKind>,
    /// Per-column TOAST compression codec. `None` means "cluster default".
    #[diff(via_debug)]
    pub compression: Option<Compression>,
    /// Optional comment.
    #[diff(via_debug)]
    pub comment: Option<String>,
}

/// `GENERATED ... AS IDENTITY` specification.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Identity {
    /// `ALWAYS` or `BY DEFAULT`.
    pub kind: IdentityKind,
    /// Backing sequence options.
    pub sequence: SequenceOptions,
}

/// Identity kind.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IdentityKind {
    /// `GENERATED ALWAYS AS IDENTITY`.
    Always,
    /// `GENERATED BY DEFAULT AS IDENTITY`.
    ByDefault,
}

/// `GENERATED ALWAYS AS (expr) STORED` specification.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Generated {
    /// Stored vs virtual (PG only supports stored as of v17).
    pub kind: GeneratedKind,
    /// Generation expression.
    pub expression: crate::ir::default_expr::NormalizedExpr,
}

/// Generated-column kind.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GeneratedKind {
    /// `STORED`.
    Stored,
}

/// Per-column TOAST storage strategy.
///
/// Mirrors Postgres `pg_attribute.attstorage` (`p|e|x|m`). The semantic
/// effective value depends on the column's data type; see
/// [`crate::ir::canon::filter_pg_defaults`] for how an explicit
/// `Some(default)` is stripped to `None` so source and catalog round-trip equally.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StorageKind {
    /// `PLAIN` — inline only, never `TOASTed` or compressed.
    Plain,
    /// `EXTERNAL` — out-of-line allowed, compression forbidden.
    External,
    /// `EXTENDED` — out-of-line and compression allowed (default for most variable-width types).
    Extended,
    /// `MAIN` — compression allowed inline, out-of-line only as a last resort.
    Main,
}

/// Per-column TOAST compression codec.
///
/// Mirrors the codec character stored in Postgres `pg_attribute.attcompression`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Compression {
    /// PGLZ — Postgres's historical lz-family codec; the default for clusters
    /// shipped with `default_toast_compression = 'pglz'`.
    Pglz,
    /// LZ4 — added in PG 14; faster compression/decompression at lower ratio.
    Lz4,
}

/// Sequence options that back an identity column.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SequenceOptions {
    /// Start value.
    pub start: i64,
    /// Increment.
    pub increment: i64,
    /// Min value (`None` = type minimum).
    pub min_value: Option<i64>,
    /// Max value (`None` = type maximum).
    pub max_value: Option<i64>,
    /// Cache size.
    pub cache: i64,
    /// Whether the sequence cycles.
    pub cycle: bool,
}

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

    fn id(s: &str) -> Identifier {
        Identifier::from_unquoted(s).unwrap()
    }

    fn base() -> Column {
        Column {
            name: id("email"),
            ty: ColumnType::Text,
            nullable: false,
            default: None,
            identity: None,
            generated: None,
            collation: None,
            storage: None,
            compression: None,
            comment: None,
        }
    }

    #[test]
    fn equal_columns_have_no_diff() {
        assert!(base().canonical_eq(&base()));
    }

    #[test]
    fn type_change_diffs() {
        let mut b = base();
        b.ty = ColumnType::Varchar { len: Some(255) };
        assert!(base().diff(&b).iter().any(|x| x.path == "ty"));
    }

    #[test]
    fn nullability_change_diffs() {
        let mut b = base();
        b.nullable = true;
        assert!(base().diff(&b).iter().any(|x| x.path == "nullable"));
    }

    #[test]
    fn identity_change_diffs() {
        let mut b = base();
        b.identity = Some(Identity {
            kind: IdentityKind::ByDefault,
            sequence: SequenceOptions {
                start: 1,
                increment: 1,
                min_value: None,
                max_value: None,
                cache: 1,
                cycle: false,
            },
        });
        assert!(base().diff(&b).iter().any(|x| x.path == "identity"));
    }

    #[test]
    fn storage_change_diffs() {
        let mut b = base();
        b.storage = Some(StorageKind::External);
        assert!(base().diff(&b).iter().any(|x| x.path == "storage"));
    }

    #[test]
    fn compression_change_diffs() {
        let mut b = base();
        b.compression = Some(Compression::Lz4);
        assert!(base().diff(&b).iter().any(|x| x.path == "compression"));
    }
}