Skip to main content

cloudiful_config/
sql.rs

1use std::io;
2
3use postgres::Client;
4
5use crate::format;
6use crate::ConfigSource;
7
8pub const DEFAULT_CONFIG_TABLE: &str = "app_configs";
9
10pub struct PostgresConfigStore<'client> {
11    client: &'client mut Client,
12    table_name: String,
13    app_name: String,
14}
15
16impl<'client> PostgresConfigStore<'client> {
17    pub fn new(client: &'client mut Client, app_name: &str, table_name: Option<&str>) -> Self {
18        Self {
19            client,
20            table_name: table_name.unwrap_or(DEFAULT_CONFIG_TABLE).to_string(),
21            app_name: app_name.to_string(),
22        }
23    }
24
25    fn ensure_schema(&mut self) -> io::Result<()> {
26        self.validate_identifier(&self.table_name)?;
27        self.client
28            .batch_execute(&format!(
29                "CREATE TABLE IF NOT EXISTS {} (
30                    app_name TEXT PRIMARY KEY,
31                    config_json TEXT NOT NULL,
32                    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
33                )",
34                self.table_name
35            ))
36            .map_err(to_io_error_postgres)?;
37        self.detect_conflict()?;
38        Ok(())
39    }
40
41    fn detect_conflict(&mut self) -> io::Result<()> {
42        let row = self
43            .client
44            .query_one(
45                "SELECT
46                    EXISTS (
47                        SELECT 1
48                        FROM information_schema.columns
49                        WHERE table_name = $1 AND column_name = 'app_name'
50                    ),
51                    EXISTS (
52                        SELECT 1
53                        FROM information_schema.columns
54                        WHERE table_name = $1 AND column_name = 'config_json'
55                    )",
56                &[&self.table_name],
57            )
58            .map_err(to_io_error_postgres)?;
59
60        let has_app_name: bool = row.get(0);
61        let has_config_json: bool = row.get(1);
62
63        if has_app_name && has_config_json {
64            Ok(())
65        } else {
66            Err(io::Error::new(
67                io::ErrorKind::AlreadyExists,
68                format!(
69                    "refusing to use postgres table {} because it does not match the expected config schema",
70                    self.table_name
71                ),
72            ))
73        }
74    }
75
76    fn validate_identifier(&self, ident: &str) -> io::Result<()> {
77        if ident.is_empty()
78            || !ident
79                .chars()
80                .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
81        {
82            return Err(io::Error::new(
83                io::ErrorKind::InvalidInput,
84                format!("invalid postgres config table name {ident}"),
85            ));
86        }
87        Ok(())
88    }
89}
90
91impl ConfigSource for PostgresConfigStore<'_> {
92    fn source_name(&self) -> String {
93        format!("postgres:{}:{}", self.table_name, self.app_name)
94    }
95
96    fn read_value(&mut self) -> io::Result<Option<serde_json::Value>> {
97        self.ensure_schema()?;
98        let query = format!(
99            "SELECT config_json FROM {} WHERE app_name = $1",
100            self.table_name
101        );
102        let row = self
103            .client
104            .query_opt(&query, &[&self.app_name])
105            .map_err(to_io_error_postgres)?;
106
107        match row {
108            Some(row) => {
109                let raw: String = row.get(0);
110                format::parse_config_value(&raw, format::ConfigFormat::Json, &self.source_name())
111                    .map(Some)
112            }
113            None => Ok(None),
114        }
115    }
116
117    fn write_config<T>(&mut self, config: &T) -> io::Result<()>
118    where
119        T: serde::Serialize,
120    {
121        self.ensure_schema()?;
122        let raw = format::serialize_config(config, format::ConfigFormat::Json, &self.source_name())?;
123        let query = format!(
124            "INSERT INTO {} (app_name, config_json) VALUES ($1, $2)
125             ON CONFLICT (app_name) DO UPDATE
126             SET config_json = EXCLUDED.config_json, updated_at = NOW()",
127            self.table_name
128        );
129        self.client
130            .execute(&query, &[&self.app_name, &raw])
131            .map_err(to_io_error_postgres)?;
132        Ok(())
133    }
134}
135
136fn to_io_error_postgres(err: postgres::Error) -> io::Error {
137    io::Error::other(err)
138}
139
140pub fn postgres_store<'client>(
141    client: &'client mut Client,
142    app_name: &str,
143) -> PostgresConfigStore<'client> {
144    PostgresConfigStore::new(client, app_name, None)
145}
146
147pub fn postgres_store_with_table<'client>(
148    client: &'client mut Client,
149    app_name: &str,
150    table_name: &str,
151) -> PostgresConfigStore<'client> {
152    PostgresConfigStore::new(client, app_name, Some(table_name))
153}