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}