pgevolve-core 0.3.3

Postgres declarative schema management — core library (parser, IR, diff, planner) powering the pgevolve CLI.
Documentation
//! `Role` and `RoleAttributes` — Postgres `pg_authid` row, normalized.

use serde::{Deserialize, Serialize};

use crate::identifier::Identifier;
use crate::ir::eq::DiffMacro;

/// A managed Postgres role.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DiffMacro)]
pub struct Role {
    /// Role name.
    pub name: Identifier,
    /// Boolean + numeric attributes from `pg_authid`.
    #[diff(nested)]
    pub attributes: RoleAttributes,
    /// Roles this role is a member of (the `IN ROLE x` direction).
    /// Canonicalized to lexicographic order in [`crate::ir::canon`].
    #[diff(via_debug)]
    pub member_of: Vec<Identifier>,
    /// Optional comment from `pg_shdescription`.
    #[diff(via_debug)]
    pub comment: Option<String>,
}

/// `pg_authid` attribute matrix. Passwords intentionally absent (set out-of-band).
// Each bool maps 1:1 to a `pg_authid` column (SUPERUSER, CREATEDB, …). Replacing
// them with two-variant enums would obscure the direct PG mapping without adding
// type-safety benefit — the columns are genuinely independent boolean flags.
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DiffMacro)]
pub struct RoleAttributes {
    /// `SUPERUSER` / `NOSUPERUSER`. Default false.
    pub superuser: bool,
    /// `CREATEDB` / `NOCREATEDB`. Default false.
    pub createdb: bool,
    /// `CREATEROLE` / `NOCREATEROLE`. Default false.
    pub createrole: bool,
    /// `INHERIT` / `NOINHERIT`. Default true (matches PG default).
    pub inherit: bool,
    /// `LOGIN` / `NOLOGIN`. Default false. `CREATE USER` sugar sets this true.
    pub login: bool,
    /// `REPLICATION` / `NOREPLICATION`. Default false.
    pub replication: bool,
    /// `BYPASSRLS` / `NOBYPASSRLS`. Default false.
    pub bypass_rls: bool,
    /// `CONNECTION LIMIT n`. `None` means unlimited (PG `-1`).
    #[diff(via_debug)]
    pub connection_limit: Option<i64>,
    /// `VALID UNTIL 'ts'`. RFC 3339 string; opaque to differ.
    #[diff(via_debug)]
    pub valid_until: Option<String>,
}

impl Default for RoleAttributes {
    fn default() -> Self {
        Self {
            superuser: false,
            createdb: false,
            createrole: false,
            inherit: true, // PG default
            login: false,
            replication: false,
            bypass_rls: false,
            connection_limit: None,
            valid_until: None,
        }
    }
}

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

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

    fn base() -> Role {
        Role {
            name: id("app_user"),
            attributes: RoleAttributes::default(),
            member_of: vec![],
            comment: None,
        }
    }

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

    #[test]
    fn login_change_diffs() {
        let mut b = base();
        b.attributes.login = true;
        // Per-field path: "attributes.login", not the coarse "attributes".
        assert!(base().diff(&b).iter().any(|x| x.path == "attributes.login"));
    }

    #[test]
    fn connection_limit_change_diffs() {
        let mut b = base();
        b.attributes.connection_limit = Some(10);
        assert!(
            base()
                .diff(&b)
                .iter()
                .any(|x| x.path == "attributes.connection_limit")
        );
    }

    #[test]
    fn valid_until_change_diffs() {
        let mut b = base();
        b.attributes.valid_until = Some("2030-01-01T00:00:00Z".into());
        assert!(
            base()
                .diff(&b)
                .iter()
                .any(|x| x.path == "attributes.valid_until")
        );
    }

    #[test]
    fn attributes_diff_does_not_emit_coarse_path() {
        let mut b = base();
        b.attributes.superuser = true;
        // The diff must NOT produce the coarse "attributes" path; it should be
        // "attributes.superuser" so callers can introspect individual flags.
        assert!(
            !base().diff(&b).iter().any(|x| x.path == "attributes"),
            "coarse 'attributes' path must not appear; use per-field paths"
        );
    }

    #[test]
    fn membership_change_diffs() {
        let mut b = base();
        b.member_of.push(id("readers"));
        assert!(base().diff(&b).iter().any(|x| x.path == "member_of"));
    }

    #[test]
    fn comment_change_diffs() {
        let mut b = base();
        b.comment = Some("the app".into());
        assert!(base().diff(&b).iter().any(|x| x.path == "comment"));
    }

    #[test]
    fn default_attributes_match_postgres_defaults() {
        let a = RoleAttributes::default();
        assert!(a.inherit, "PG default for INHERIT is true");
        assert!(!a.superuser);
        assert!(!a.login);
        assert_eq!(a.connection_limit, None);
    }
}