coil-data 0.1.0

Data access and persistence primitives for the Coil framework.
Documentation
use std::fmt;

use crate::{
    CompiledStatement, DataModelError, DataRuntime, DataValue, MigrationId, quote_identifier,
    require_non_empty,
};

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MigrationOwner {
    Core,
    Module(String),
    CustomerApp(String),
    AuthPackage(String),
}

impl fmt::Display for MigrationOwner {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Core => f.write_str("core"),
            Self::Module(module) => write!(f, "module:{module}"),
            Self::CustomerApp(app) => write!(f, "customer_app:{app}"),
            Self::AuthPackage(package) => write!(f, "auth_package:{package}"),
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MigrationStep {
    pub id: MigrationId,
    pub owner: MigrationOwner,
    pub order: u32,
    pub description: String,
    pub online_safe: bool,
    pub statements: Vec<String>,
}

impl MigrationStep {
    pub fn new(
        id: MigrationId,
        owner: MigrationOwner,
        order: u32,
        description: impl Into<String>,
    ) -> Result<Self, DataModelError> {
        Ok(Self {
            id,
            owner,
            order,
            description: require_non_empty("migration_description", description.into())?,
            online_safe: true,
            statements: Vec::new(),
        })
    }

    pub fn blocking(mut self) -> Self {
        self.online_safe = false;
        self
    }

    pub fn with_statement(mut self, sql: impl Into<String>) -> Result<Self, DataModelError> {
        self.statements
            .push(require_non_empty("migration_statement", sql.into())?);
        Ok(self)
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct MigrationPlan {
    steps: Vec<MigrationStep>,
}

impl MigrationPlan {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn insert(&mut self, step: MigrationStep) -> Result<(), DataModelError> {
        if self
            .steps
            .iter()
            .any(|existing| existing.owner == step.owner && existing.id == step.id)
        {
            return Err(DataModelError::DuplicateMigration {
                owner: step.owner.to_string(),
                migration_id: step.id.to_string(),
            });
        }

        self.steps.push(step);
        self.steps.sort_by(|left, right| {
            owner_rank(&left.owner)
                .cmp(&owner_rank(&right.owner))
                .then(left.order.cmp(&right.order))
                .then(left.id.as_str().cmp(right.id.as_str()))
        });
        Ok(())
    }

    pub fn ordered_steps(&self) -> &[MigrationStep] {
        &self.steps
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct MigrationRegistry {
    plan: MigrationPlan,
}

impl MigrationRegistry {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn register(&mut self, plan: &MigrationPlan) -> Result<(), DataModelError> {
        for step in plan.ordered_steps() {
            self.plan.insert(step.clone())?;
        }
        Ok(())
    }

    pub fn composed_plan(&self) -> &MigrationPlan {
        &self.plan
    }

    pub fn compile_apply_batch(
        &self,
        runtime: &DataRuntime,
    ) -> Result<CompiledMigrationBatch, DataModelError> {
        let mut statements = Vec::new();
        let migrations_table =
            quote_identifier(&format!("{}.{}", runtime.schema, runtime.migrations_table));
        statements.push(CompiledStatement {
            sql: format!(
                "CREATE TABLE IF NOT EXISTS {migrations_table} (owner TEXT NOT NULL, migration_id TEXT NOT NULL, description TEXT NOT NULL, applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (owner, migration_id))"
            ),
            bind_values: Vec::new(),
        });

        for step in self.plan.ordered_steps() {
            if step.statements.is_empty() {
                return Err(DataModelError::MissingMigrationStatements {
                    migration_id: step.id.to_string(),
                });
            }

            for sql in &step.statements {
                statements.push(CompiledStatement {
                    sql: sql.clone(),
                    bind_values: Vec::new(),
                });
            }

            statements.push(CompiledStatement {
                sql: format!(
                    "INSERT INTO {migrations_table} (owner, migration_id, description) VALUES ($1, $2, $3) ON CONFLICT (owner, migration_id) DO NOTHING"
                ),
                bind_values: vec![
                    DataValue::String(step.owner.to_string()),
                    DataValue::String(step.id.to_string()),
                    DataValue::String(step.description.clone()),
                ],
            });
        }

        Ok(CompiledMigrationBatch { statements })
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CompiledMigrationBatch {
    pub statements: Vec<CompiledStatement>,
}

fn owner_rank(owner: &MigrationOwner) -> u8 {
    match owner {
        MigrationOwner::Core => 0,
        MigrationOwner::Module(_) => 1,
        MigrationOwner::AuthPackage(_) => 2,
        MigrationOwner::CustomerApp(_) => 3,
    }
}