chronon_backend_sql_common/
backend.rs1use std::fmt;
2
3use chronon_core::Result;
4use sqlx::{Executor, Pool, Postgres, Sqlite};
5
6use crate::error_map::{map_connect_err, map_err};
7use crate::schema;
8
9#[must_use]
11pub fn postgres_max_connections() -> u32 {
12 std::env::var("CHRONON_PG_POOL_SIZE")
13 .ok()
14 .and_then(|v| v.parse::<u32>().ok())
15 .unwrap_or(5)
16 .clamp(1, 200)
17}
18
19pub fn bind_sql(dialect: SqlDialect, sql: &str) -> String {
21 match dialect {
22 SqlDialect::Sqlite => sql.to_string(),
23 SqlDialect::Postgres => {
24 let mut out = String::with_capacity(sql.len());
25 let mut n = 1u32;
26 for ch in sql.chars() {
27 if ch == '?' {
28 out.push('$');
29 out.push_str(&n.to_string());
30 n += 1;
31 } else {
32 out.push(ch);
33 }
34 }
35 out
36 }
37 }
38}
39
40const MAX_POSTGRES_SCHEMA_NAME_LEN: usize = 63;
42
43pub fn validate_postgres_schema_name(schema: &str) -> Result<()> {
53 if schema.is_empty() || schema.len() > MAX_POSTGRES_SCHEMA_NAME_LEN {
54 return Err(chronon_core::ChrononError::ParamError(format!(
55 "postgres schema name must be 1..{MAX_POSTGRES_SCHEMA_NAME_LEN} characters"
56 )));
57 }
58 let mut chars = schema.chars();
59 let first = chars.next().ok_or_else(|| {
60 chronon_core::ChrononError::ParamError("postgres schema name must not be empty".into())
61 })?;
62 if !(first.is_ascii_alphabetic() || first == '_') {
63 return Err(chronon_core::ChrononError::ParamError(
64 "postgres schema name must start with ASCII letter or underscore".into(),
65 ));
66 }
67 if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_') {
68 return Err(chronon_core::ChrononError::ParamError(
69 "postgres schema name may contain only ASCII letters, digits, and underscores".into(),
70 ));
71 }
72 Ok(())
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum SqlDialect {
78 Postgres,
80 Sqlite,
82}
83
84#[derive(Clone)]
86pub enum SqlPool {
87 Sqlite(Pool<Sqlite>),
89 Postgres(Pool<Postgres>),
91}
92
93impl fmt::Debug for SqlPool {
94 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95 match self {
96 Self::Sqlite(_) => f.debug_tuple("SqlPool::Sqlite").finish(),
97 Self::Postgres(_) => f.debug_tuple("SqlPool::Postgres").finish(),
98 }
99 }
100}
101
102pub struct SqlSchedulerStore {
104 pub(crate) pool: SqlPool,
105 pub(crate) dialect: SqlDialect,
106}
107
108impl SqlSchedulerStore {
109 pub async fn connect_sqlite(url: &str) -> Result<Self> {
115 let pool = sqlx::sqlite::SqlitePoolOptions::new()
116 .max_connections(5)
117 .connect(url)
118 .await
119 .map_err(|e| map_connect_err("sqlite", url, e))?;
120 Self::from_sqlite_pool(pool).await
121 }
122
123 pub async fn connect_postgres(url: &str) -> Result<Self> {
129 let pool = sqlx::postgres::PgPoolOptions::new()
130 .max_connections(postgres_max_connections())
131 .connect(url)
132 .await
133 .map_err(|e| map_connect_err("postgres", url, e))?;
134 Self::from_postgres_pool(pool).await
135 }
136
137 pub async fn connect_postgres_isolated(url: &str, schema: &str) -> Result<Self> {
145 validate_postgres_schema_name(schema)?;
146 let admin = sqlx::postgres::PgPoolOptions::new()
147 .max_connections(1)
148 .connect(url)
149 .await
150 .map_err(|e| map_connect_err("postgres", url, e))?;
151 let ddl = format!("CREATE SCHEMA IF NOT EXISTS \"{schema}\"");
152 admin.execute(ddl.as_str()).await.map_err(map_err)?;
153 drop(admin);
154
155 let schema = schema.to_string();
156 let pool = sqlx::postgres::PgPoolOptions::new()
157 .max_connections(postgres_max_connections())
158 .after_connect(move |conn, _meta| {
159 let schema = schema.clone();
160 Box::pin(async move {
161 let sql = format!("SET search_path TO \"{schema}\"");
162 sqlx::query(&sql).execute(conn).await?;
163 Ok(())
164 })
165 })
166 .connect(url)
167 .await
168 .map_err(|e| map_connect_err("postgres", url, e))?;
169 Self::from_postgres_pool(pool).await
170 }
171
172 pub async fn attach_postgres_isolated(url: &str, schema: &str) -> Result<Self> {
181 validate_postgres_schema_name(schema)?;
182 let schema = schema.to_string();
183 let pool = sqlx::postgres::PgPoolOptions::new()
184 .max_connections(postgres_max_connections())
185 .after_connect(move |conn, _meta| {
186 let schema = schema.clone();
187 Box::pin(async move {
188 let sql = format!("SET search_path TO \"{schema}\"");
189 sqlx::query(&sql).execute(conn).await?;
190 Ok(())
191 })
192 })
193 .connect(url)
194 .await
195 .map_err(|e| map_connect_err("postgres", url, e))?;
196 Ok(Self {
197 pool: SqlPool::Postgres(pool),
198 dialect: SqlDialect::Postgres,
199 })
200 }
201
202 pub async fn from_sqlite_pool(pool: Pool<Sqlite>) -> Result<Self> {
208 let store = Self {
209 pool: SqlPool::Sqlite(pool),
210 dialect: SqlDialect::Sqlite,
211 };
212 schema::ensure_schema(&store).await?;
213 Ok(store)
214 }
215
216 pub async fn from_postgres_pool(pool: Pool<Postgres>) -> Result<Self> {
222 let store = Self {
223 pool: SqlPool::Postgres(pool),
224 dialect: SqlDialect::Postgres,
225 };
226 schema::ensure_schema(&store).await?;
227 Ok(store)
228 }
229
230 #[must_use]
232 pub const fn pool(&self) -> &SqlPool {
233 &self.pool
234 }
235
236 #[must_use]
238 pub const fn dialect(&self) -> SqlDialect {
239 self.dialect
240 }
241
242 pub(crate) async fn run_ddl(&self, ddl: &str) -> Result<()> {
243 match &self.pool {
244 SqlPool::Sqlite(pool) => {
245 pool.execute(ddl).await.map_err(map_err)?;
246 }
247 SqlPool::Postgres(pool) => {
248 pool.execute(ddl).await.map_err(map_err)?;
249 }
250 }
251 Ok(())
252 }
253
254 pub async fn drop_postgres_schema(url: &str, schema: &str) -> Result<()> {
262 validate_postgres_schema_name(schema)?;
263 let admin = sqlx::postgres::PgPoolOptions::new()
264 .max_connections(1)
265 .connect(url)
266 .await
267 .map_err(|e| map_connect_err("postgres", url, e))?;
268 let ddl = format!("DROP SCHEMA IF EXISTS \"{schema}\" CASCADE");
269 admin.execute(ddl.as_str()).await.map_err(map_err)?;
270 Ok(())
271 }
272}
273
274impl fmt::Debug for SqlSchedulerStore {
275 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
276 f.debug_struct("SqlSchedulerStore")
277 .field("dialect", &self.dialect)
278 .finish_non_exhaustive()
279 }
280}
281
282#[cfg(test)]
283mod tests {
284 use super::{bind_sql, validate_postgres_schema_name, SqlDialect};
285
286 #[test]
287 fn bind_sql_sqlite_passthrough() {
288 let sql = "SELECT * FROM t WHERE id = ? AND name = ?";
289 assert_eq!(bind_sql(SqlDialect::Sqlite, sql), sql);
290 }
291
292 #[test]
293 fn bind_sql_postgres_renumbers_placeholders() {
294 let sql = "UPDATE t SET a = ?, b = ? WHERE id = ?";
295 assert_eq!(
296 bind_sql(SqlDialect::Postgres, sql),
297 "UPDATE t SET a = $1, b = $2 WHERE id = $3"
298 );
299 }
300
301 #[test]
302 fn schema_name_accepts_safe_identifiers() {
303 assert!(validate_postgres_schema_name("bench_cell_1").is_ok());
304 assert!(validate_postgres_schema_name("_tmp").is_ok());
305 assert!(validate_postgres_schema_name("A").is_ok());
306 }
307
308 #[test]
309 fn schema_name_rejects_injection_and_empty() {
310 use chronon_core::ChrononError;
311
312 let too_long = "x".repeat(64);
313 for name in [
314 "",
315 "a\";drop",
316 "evil-name",
317 "1leading",
318 "has space",
319 too_long.as_str(),
320 ] {
321 match validate_postgres_schema_name(name) {
322 Err(ChrononError::ParamError(_)) => {}
323 other => panic!("expected ParamError for {name:?}, got {other:?}"),
324 }
325 }
326 }
327}