cloudiful-config 0.6.1

Small serde-based config helpers for default app TOML/SQL stores, env overrides, and atomic saves.
Documentation
use std::io;

use postgres::Client;
use rusqlite::{params, Connection, OptionalExtension};

use crate::format;
use crate::ConfigSource;

pub const DEFAULT_CONFIG_TABLE: &str = "app_configs";

pub struct SqliteConfigStore<'conn> {
    conn: &'conn Connection,
    table_name: String,
    app_name: String,
}

impl<'conn> SqliteConfigStore<'conn> {
    pub fn new(conn: &'conn Connection, app_name: &str, table_name: Option<&str>) -> Self {
        Self {
            conn,
            table_name: table_name.unwrap_or(DEFAULT_CONFIG_TABLE).to_string(),
            app_name: app_name.to_string(),
        }
    }

    fn ensure_schema(&self) -> io::Result<()> {
        self.validate_identifier(&self.table_name)?;
        self.conn
            .execute(
                &format!(
                    "CREATE TABLE IF NOT EXISTS {} (
                        app_name TEXT PRIMARY KEY,
                        config_json TEXT NOT NULL,
                        updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
                    )",
                    self.table_name
                ),
                [],
            )
            .map_err(to_io_error_sqlite)?;
        self.detect_conflict()?;
        Ok(())
    }

    fn detect_conflict(&self) -> io::Result<()> {
        let pragma = format!("PRAGMA table_info({})", self.table_name);
        let mut stmt = self.conn.prepare(&pragma).map_err(to_io_error_sqlite)?;
        let mut rows = stmt.query([]).map_err(to_io_error_sqlite)?;

        let mut has_app_name = false;
        let mut has_config_json = false;

        while let Some(row) = rows.next().map_err(to_io_error_sqlite)? {
            let name: String = row.get(1).map_err(to_io_error_sqlite)?;
            let pk: i64 = row.get(5).map_err(to_io_error_sqlite)?;
            if name == "app_name" && pk == 1 {
                has_app_name = true;
            }
            if name == "config_json" {
                has_config_json = true;
            }
        }

        if has_app_name && has_config_json {
            Ok(())
        } else {
            Err(io::Error::new(
                io::ErrorKind::AlreadyExists,
                format!(
                    "refusing to use sqlite table {} because it does not match the expected config schema",
                    self.table_name
                ),
            ))
        }
    }

    fn validate_identifier(&self, ident: &str) -> io::Result<()> {
        if ident.is_empty()
            || !ident
                .chars()
                .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
        {
            return Err(io::Error::new(
                io::ErrorKind::InvalidInput,
                format!("invalid sqlite config table name {ident}"),
            ));
        }
        Ok(())
    }
}

impl ConfigSource for SqliteConfigStore<'_> {
    fn source_name(&self) -> String {
        format!("sqlite:{}:{}", self.table_name, self.app_name)
    }

    fn read_value(&mut self) -> io::Result<Option<serde_json::Value>> {
        self.ensure_schema()?;
        let query = format!(
            "SELECT config_json FROM {} WHERE app_name = ?1",
            self.table_name
        );
        let mut stmt = self.conn.prepare(&query).map_err(to_io_error_sqlite)?;
        let value: Option<String> = stmt
            .query_row([&self.app_name], |row| row.get(0))
            .optional()
            .map_err(to_io_error_sqlite)?;

        match value {
            Some(raw) => {
                format::parse_config_value(&raw, format::ConfigFormat::Json, &self.source_name())
                    .map(Some)
            }
            None => Ok(None),
        }
    }

    fn write_config<T>(&mut self, config: &T) -> io::Result<()>
    where
        T: serde::Serialize,
    {
        self.ensure_schema()?;
        let raw = format::serialize_config(config, format::ConfigFormat::Json, &self.source_name())?;
        let query = format!(
            "INSERT INTO {} (app_name, config_json) VALUES (?1, ?2)
             ON CONFLICT(app_name) DO UPDATE
             SET config_json = excluded.config_json, updated_at = CURRENT_TIMESTAMP",
            self.table_name
        );
        self.conn
            .execute(&query, params![self.app_name, raw])
            .map_err(to_io_error_sqlite)?;
        Ok(())
    }
}

pub struct PostgresConfigStore<'client> {
    client: &'client mut Client,
    table_name: String,
    app_name: String,
}

impl<'client> PostgresConfigStore<'client> {
    pub fn new(client: &'client mut Client, app_name: &str, table_name: Option<&str>) -> Self {
        Self {
            client,
            table_name: table_name.unwrap_or(DEFAULT_CONFIG_TABLE).to_string(),
            app_name: app_name.to_string(),
        }
    }

    fn ensure_schema(&mut self) -> io::Result<()> {
        self.validate_identifier(&self.table_name)?;
        self.client
            .batch_execute(&format!(
                "CREATE TABLE IF NOT EXISTS {} (
                    app_name TEXT PRIMARY KEY,
                    config_json TEXT NOT NULL,
                    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
                )",
                self.table_name
            ))
            .map_err(to_io_error_postgres)?;
        self.detect_conflict()?;
        Ok(())
    }

    fn detect_conflict(&mut self) -> io::Result<()> {
        let row = self
            .client
            .query_one(
                "SELECT
                    EXISTS (
                        SELECT 1
                        FROM information_schema.columns
                        WHERE table_name = $1 AND column_name = 'app_name'
                    ),
                    EXISTS (
                        SELECT 1
                        FROM information_schema.columns
                        WHERE table_name = $1 AND column_name = 'config_json'
                    )",
                &[&self.table_name],
            )
            .map_err(to_io_error_postgres)?;

        let has_app_name: bool = row.get(0);
        let has_config_json: bool = row.get(1);

        if has_app_name && has_config_json {
            Ok(())
        } else {
            Err(io::Error::new(
                io::ErrorKind::AlreadyExists,
                format!(
                    "refusing to use postgres table {} because it does not match the expected config schema",
                    self.table_name
                ),
            ))
        }
    }

    fn validate_identifier(&self, ident: &str) -> io::Result<()> {
        if ident.is_empty()
            || !ident
                .chars()
                .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
        {
            return Err(io::Error::new(
                io::ErrorKind::InvalidInput,
                format!("invalid postgres config table name {ident}"),
            ));
        }
        Ok(())
    }
}

impl ConfigSource for PostgresConfigStore<'_> {
    fn source_name(&self) -> String {
        format!("postgres:{}:{}", self.table_name, self.app_name)
    }

    fn read_value(&mut self) -> io::Result<Option<serde_json::Value>> {
        self.ensure_schema()?;
        let query = format!(
            "SELECT config_json FROM {} WHERE app_name = $1",
            self.table_name
        );
        let row = self
            .client
            .query_opt(&query, &[&self.app_name])
            .map_err(to_io_error_postgres)?;

        match row {
            Some(row) => {
                let raw: String = row.get(0);
                format::parse_config_value(&raw, format::ConfigFormat::Json, &self.source_name())
                    .map(Some)
            }
            None => Ok(None),
        }
    }

    fn write_config<T>(&mut self, config: &T) -> io::Result<()>
    where
        T: serde::Serialize,
    {
        self.ensure_schema()?;
        let raw = format::serialize_config(config, format::ConfigFormat::Json, &self.source_name())?;
        let query = format!(
            "INSERT INTO {} (app_name, config_json) VALUES ($1, $2)
             ON CONFLICT (app_name) DO UPDATE
             SET config_json = EXCLUDED.config_json, updated_at = NOW()",
            self.table_name
        );
        self.client
            .execute(&query, &[&self.app_name, &raw])
            .map_err(to_io_error_postgres)?;
        Ok(())
    }
}

fn to_io_error_sqlite(err: rusqlite::Error) -> io::Error {
    io::Error::other(err)
}

fn to_io_error_postgres(err: postgres::Error) -> io::Error {
    io::Error::other(err)
}

pub fn sqlite_store<'conn>(conn: &'conn Connection, app_name: &str) -> SqliteConfigStore<'conn> {
    SqliteConfigStore::new(conn, app_name, None)
}

pub fn sqlite_store_with_table<'conn>(
    conn: &'conn Connection,
    app_name: &str,
    table_name: &str,
) -> SqliteConfigStore<'conn> {
    SqliteConfigStore::new(conn, app_name, Some(table_name))
}

pub fn postgres_store<'client>(
    client: &'client mut Client,
    app_name: &str,
) -> PostgresConfigStore<'client> {
    PostgresConfigStore::new(client, app_name, None)
}

pub fn postgres_store_with_table<'client>(
    client: &'client mut Client,
    app_name: &str,
    table_name: &str,
) -> PostgresConfigStore<'client> {
    PostgresConfigStore::new(client, app_name, Some(table_name))
}