1mod ident;
18pub mod migrate;
19pub mod seed;
20pub mod value;
21
22use apiplant_core::Resource;
23use sea_orm::sea_query::Value as SqlValue;
24use sea_orm::{
25 ConnectOptions, ConnectionTrait, Database, DatabaseBackend, DatabaseConnection, Statement,
26};
27use uuid::Uuid;
28
29use ident::quote_ident;
30pub use migrate::migrate;
31pub use seed::{seed, Report as SeedReport};
32
33#[derive(thiserror::Error, Debug)]
35pub enum Error {
36 #[error("database: {0}")]
37 Db(#[from] sea_orm::DbErr),
38 #[error("schema: {0}")]
39 Schema(String),
40 #[error("bad input: {0}")]
41 BadInput(String),
42}
43
44#[derive(Clone, Debug)]
48pub enum Filter {
49 Eq { column: String, value: SqlValue },
51 In {
53 column: String,
54 values: Vec<SqlValue>,
55 },
56 Contains { column: String, value: String },
60 AnyContains { columns: Vec<String>, value: String },
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct Sort {
72 pub column: String,
73 pub descending: bool,
74}
75
76impl Sort {
77 pub fn new(column: impl Into<String>, descending: bool) -> Self {
78 Sort {
79 column: column.into(),
80 descending,
81 }
82 }
83}
84
85impl Filter {
86 pub fn eq(column: impl Into<String>, value: impl Into<SqlValue>) -> Self {
87 Filter::Eq {
88 column: column.into(),
89 value: value.into(),
90 }
91 }
92
93 pub fn in_(column: impl Into<String>, values: Vec<SqlValue>) -> Self {
94 Filter::In {
95 column: column.into(),
96 values,
97 }
98 }
99
100 pub fn in_uuids(column: impl Into<String>, ids: Vec<Uuid>) -> Self {
102 Filter::In {
103 column: column.into(),
104 values: ids.into_iter().map(SqlValue::from).collect(),
105 }
106 }
107
108 pub fn contains(column: impl Into<String>, value: impl Into<String>) -> Self {
109 Filter::Contains {
110 column: column.into(),
111 value: value.into(),
112 }
113 }
114
115 pub fn any_contains(columns: Vec<String>, value: impl Into<String>) -> Self {
117 Filter::AnyContains {
118 columns,
119 value: value.into(),
120 }
121 }
122
123 fn column(&self) -> Option<&str> {
125 match self {
126 Filter::Eq { column, .. }
127 | Filter::In { column, .. }
128 | Filter::Contains { column, .. } => Some(column),
129 Filter::AnyContains { .. } => None,
130 }
131 }
132}
133
134#[derive(Clone)]
136pub struct Db {
137 conn: DatabaseConnection,
138}
139
140impl Db {
141 pub async fn connect(url: &str, max_connections: u32) -> Result<Self, Error> {
150 match Self::open(url, max_connections).await {
151 Ok(db) => Ok(db),
152 Err(err) if is_missing_database(&err) => {
153 let Some((admin_url, name)) = maintenance_url(url) else {
154 return Err(err);
155 };
156 tracing::info!("database `{name}` does not exist; creating it");
157 let admin = Self::open(&admin_url, 1).await?;
158 let created = admin
163 .raw_json(&format!("CREATE DATABASE {}", quote_ident(&name)?), &[])
164 .await;
165 match (Self::open(url, max_connections).await, created) {
166 (Ok(db), _) => Ok(db),
167 (Err(_), Err(create_err)) => Err(create_err),
168 (Err(open_err), Ok(_)) => Err(open_err),
169 }
170 }
171 Err(err) => Err(err),
172 }
173 }
174
175 async fn open(url: &str, max_connections: u32) -> Result<Self, Error> {
176 let mut opt = ConnectOptions::new(url.to_owned());
177 opt.max_connections(max_connections).sqlx_logging(false);
178 let conn = Database::connect(opt).await?;
179 Ok(Db { conn })
180 }
181
182 pub fn connection(&self) -> &DatabaseConnection {
184 &self.conn
185 }
186
187 pub async fn list(
192 &self,
193 r: &Resource,
194 filters: &[Filter],
195 sort: &[Sort],
196 limit: i64,
197 offset: i64,
198 ) -> Result<serde_json::Value, Error> {
199 let table = quote_ident(&r.table_name())?;
200 let (where_sql, mut params, n) = self.build_where(filters)?;
201 let order = if sort.is_empty() {
202 if r.meta.timestamps {
203 "ORDER BY created_at DESC".to_string()
204 } else {
205 String::new()
206 }
207 } else {
208 let mut keys = Vec::with_capacity(sort.len());
209 for key in sort {
210 keys.push(format!(
213 "{} {} NULLS LAST",
214 quote_ident(&key.column)?,
215 if key.descending { "DESC" } else { "ASC" }
216 ));
217 }
218 format!("ORDER BY {}", keys.join(", "))
219 };
220 let limit_ph = format!("${}", n);
221 let offset_ph = format!("${}", n + 1);
222 params.push(SqlValue::from(limit));
223 params.push(SqlValue::from(offset));
224
225 let hidden = self.hidden_subtraction(r)?;
226 let sql = format!(
227 "SELECT coalesce(jsonb_agg(to_jsonb(t){hidden}), '[]'::jsonb) AS result \
228 FROM (SELECT * FROM {table} {where_sql} {order} LIMIT {limit_ph} OFFSET {offset_ph}) t"
229 );
230 let row = self
231 .conn
232 .query_one(Statement::from_sql_and_values(
233 DatabaseBackend::Postgres,
234 sql,
235 params,
236 ))
237 .await?
238 .ok_or_else(|| Error::Db(sea_orm::DbErr::Custom("no aggregate row".into())))?;
239 Ok(row.try_get::<serde_json::Value>("", "result")?)
240 }
241
242 pub async fn get(
244 &self,
245 r: &Resource,
246 id: Uuid,
247 filters: &[Filter],
248 ) -> Result<Option<serde_json::Value>, Error> {
249 let table = quote_ident(&r.table_name())?;
250 let mut all = vec![Filter::eq("id", id)];
251 all.extend_from_slice(filters);
252 let (where_sql, params, _) = self.build_where(&all)?;
253 let hidden = self.hidden_subtraction(r)?;
254 let sql = format!(
255 "SELECT to_jsonb(t){hidden} AS result FROM (SELECT * FROM {table} {where_sql} LIMIT 1) t"
256 );
257 let row = self
258 .conn
259 .query_one(Statement::from_sql_and_values(
260 DatabaseBackend::Postgres,
261 sql,
262 params,
263 ))
264 .await?;
265 match row {
266 Some(row) => Ok(Some(row.try_get::<serde_json::Value>("", "result")?)),
267 None => Ok(None),
268 }
269 }
270
271 pub async fn create(
273 &self,
274 r: &Resource,
275 data: &serde_json::Map<String, serde_json::Value>,
276 ) -> Result<serde_json::Value, Error> {
277 let table = quote_ident(&r.table_name())?;
278 let mut cols = Vec::new();
279 let mut placeholders = Vec::new();
280 let mut params: Vec<SqlValue> = Vec::new();
281 let mut n = 1;
282 for (name, field) in &r.fields {
283 if let Some(v) = data.get(name) {
284 cols.push(quote_ident(name)?);
285 placeholders.push(format!("${n}"));
286 params.push(value::json_to_sql(field.ty, v).map_err(Error::BadInput)?);
287 n += 1;
288 }
289 }
290
291 let hidden = self.hidden_subtraction(r)?;
292 let returning = format!("RETURNING (to_jsonb({table}.*){hidden}) AS result");
293 let sql = if cols.is_empty() {
294 format!("INSERT INTO {table} DEFAULT VALUES {returning}")
295 } else {
296 format!(
297 "INSERT INTO {table} ({}) VALUES ({}) {returning}",
298 cols.join(", "),
299 placeholders.join(", ")
300 )
301 };
302 let row = self
303 .conn
304 .query_one(Statement::from_sql_and_values(
305 DatabaseBackend::Postgres,
306 sql,
307 params,
308 ))
309 .await?
310 .ok_or_else(|| Error::Db(sea_orm::DbErr::Custom("insert returned no row".into())))?;
311 Ok(row.try_get::<serde_json::Value>("", "result")?)
312 }
313
314 pub async fn update(
316 &self,
317 r: &Resource,
318 id: Uuid,
319 data: &serde_json::Map<String, serde_json::Value>,
320 filters: &[Filter],
321 ) -> Result<Option<serde_json::Value>, Error> {
322 let table = quote_ident(&r.table_name())?;
323 let mut assignments = Vec::new();
324 let mut params: Vec<SqlValue> = Vec::new();
325 let mut n = 1;
326 for (name, field) in &r.fields {
327 if let Some(v) = data.get(name) {
328 assignments.push(format!("{} = ${n}", quote_ident(name)?));
329 params.push(value::json_to_sql(field.ty, v).map_err(Error::BadInput)?);
330 n += 1;
331 }
332 }
333 if r.meta.timestamps {
334 assignments.push("updated_at = now()".to_string());
335 }
336 if assignments.is_empty() {
337 return self.get(r, id, filters).await;
338 }
339
340 let mut where_parts = vec![format!("{} = ${n}", quote_ident("id")?)];
341 params.push(SqlValue::from(id));
342 n += 1;
343 for f in filters {
344 where_parts.push(Self::render_filter(f, &mut params, &mut n)?);
345 }
346
347 let hidden = self.hidden_subtraction(r)?;
348 let sql = format!(
349 "UPDATE {table} SET {} WHERE {} RETURNING (to_jsonb({table}.*){hidden}) AS result",
350 assignments.join(", "),
351 where_parts.join(" AND "),
352 );
353 let row = self
354 .conn
355 .query_one(Statement::from_sql_and_values(
356 DatabaseBackend::Postgres,
357 sql,
358 params,
359 ))
360 .await?;
361 match row {
362 Some(row) => Ok(Some(row.try_get::<serde_json::Value>("", "result")?)),
363 None => Ok(None),
364 }
365 }
366
367 pub async fn delete(&self, r: &Resource, id: Uuid, filters: &[Filter]) -> Result<bool, Error> {
369 let table = quote_ident(&r.table_name())?;
370 let mut all = vec![Filter::eq("id", id)];
371 all.extend_from_slice(filters);
372 let (where_sql, params, _) = self.build_where(&all)?;
373 let res = self
374 .conn
375 .execute(Statement::from_sql_and_values(
376 DatabaseBackend::Postgres,
377 format!("DELETE FROM {table} {where_sql}"),
378 params,
379 ))
380 .await?;
381 Ok(res.rows_affected() > 0)
382 }
383
384 pub async fn fetch_by_ids(
389 &self,
390 r: &Resource,
391 ids: &[Uuid],
392 filters: &[Filter],
393 ) -> Result<serde_json::Value, Error> {
394 if ids.is_empty() {
395 return Ok(serde_json::Value::Array(Vec::new()));
396 }
397 let table = quote_ident(&r.table_name())?;
398 let mut all = vec![Filter::in_uuids("id", ids.to_vec())];
399 all.extend_from_slice(filters);
400 let (where_sql, params, _) = self.build_where(&all)?;
401 let hidden = self.hidden_subtraction(r)?;
402 let sql = format!(
403 "SELECT coalesce(jsonb_agg(to_jsonb(t){hidden}), '[]'::jsonb) AS result \
404 FROM (SELECT * FROM {table} {where_sql}) t"
405 );
406 let row = self
407 .conn
408 .query_one(Statement::from_sql_and_values(
409 DatabaseBackend::Postgres,
410 sql,
411 params,
412 ))
413 .await?
414 .ok_or_else(|| Error::Db(sea_orm::DbErr::Custom("no aggregate row".into())))?;
415 Ok(row.try_get::<serde_json::Value>("", "result")?)
416 }
417
418 pub async fn raw_json(
421 &self,
422 sql: &str,
423 params: &[serde_json::Value],
424 ) -> Result<serde_json::Value, Error> {
425 let vals: Vec<SqlValue> = params.iter().map(value::json_param).collect();
426 let head = sql.trim_start();
427 let is_query = (head.len() >= 6 && head[..6].eq_ignore_ascii_case("select"))
428 || (head.len() >= 4 && head[..4].eq_ignore_ascii_case("with"));
429
430 if is_query {
431 let wrapped =
432 format!("SELECT coalesce(jsonb_agg(t), '[]'::jsonb) AS result FROM ({sql}) t");
433 let row = self
434 .conn
435 .query_one(Statement::from_sql_and_values(
436 DatabaseBackend::Postgres,
437 wrapped,
438 vals,
439 ))
440 .await?
441 .ok_or_else(|| Error::Db(sea_orm::DbErr::Custom("no aggregate row".into())))?;
442 Ok(row.try_get::<serde_json::Value>("", "result")?)
443 } else {
444 let res = self
445 .conn
446 .execute(Statement::from_sql_and_values(
447 DatabaseBackend::Postgres,
448 sql.to_string(),
449 vals,
450 ))
451 .await?;
452 Ok(serde_json::json!({ "rows_affected": res.rows_affected() }))
453 }
454 }
455
456 fn build_where(&self, filters: &[Filter]) -> Result<(String, Vec<SqlValue>, usize), Error> {
461 if filters.is_empty() {
462 return Ok((String::new(), Vec::new(), 1));
463 }
464 let mut parts = Vec::new();
465 let mut params = Vec::new();
466 let mut n = 1;
467 for f in filters {
468 parts.push(Self::render_filter(f, &mut params, &mut n)?);
469 }
470 Ok((format!("WHERE {}", parts.join(" AND ")), params, n))
471 }
472
473 fn render_filter(
476 f: &Filter,
477 params: &mut Vec<SqlValue>,
478 n: &mut usize,
479 ) -> Result<String, Error> {
480 let col = match f.column() {
481 Some(column) => quote_ident(column)?,
482 None => String::new(),
483 };
484 Ok(match f {
485 Filter::Eq { value, .. } => {
486 let part = format!("{col} = ${n}");
487 params.push(value.clone());
488 *n += 1;
489 part
490 }
491 Filter::In { values, .. } => {
492 if values.is_empty() {
493 return Ok("false".to_string());
494 }
495 let placeholders: Vec<String> = values
496 .iter()
497 .map(|v| {
498 let p = format!("${n}");
499 params.push(v.clone());
500 *n += 1;
501 p
502 })
503 .collect();
504 format!("{col} IN ({})", placeholders.join(", "))
505 }
506 Filter::Contains { value, .. } => {
507 let escaped = value
510 .replace('\\', "\\\\")
511 .replace('%', "\\%")
512 .replace('_', "\\_");
513 let part = format!("{col}::text ILIKE ${n}");
514 params.push(SqlValue::from(format!("%{escaped}%")));
515 *n += 1;
516 part
517 }
518 Filter::AnyContains { columns, value } => {
519 if columns.is_empty() {
522 return Ok("false".to_string());
523 }
524 let escaped = value
525 .replace('\\', "\\\\")
526 .replace('%', "\\%")
527 .replace('_', "\\_");
528 let placeholder = format!("${n}");
531 params.push(SqlValue::from(format!("%{escaped}%")));
532 *n += 1;
533 let mut parts = Vec::with_capacity(columns.len());
534 for column in columns {
535 parts.push(format!(
536 "{}::text ILIKE {placeholder}",
537 quote_ident(column)?
538 ));
539 }
540 format!("({})", parts.join(" OR "))
541 }
542 })
543 }
544
545 fn hidden_subtraction(&self, r: &Resource) -> Result<String, Error> {
547 let mut s = String::new();
548 for (name, field) in &r.fields {
549 if field.hidden {
550 quote_ident(name)?; s.push_str(&format!(" - '{name}'"));
552 }
553 }
554 Ok(s)
555 }
556}
557
558fn is_missing_database(err: &Error) -> bool {
564 let Error::Db(err) = err else { return false };
565 let msg = err.to_string();
566 msg.contains("3D000") || msg.contains("does not exist")
567}
568
569fn maintenance_url(url: &str) -> Option<(String, String)> {
573 let (before_query, query) = match url.find(['?', '#']) {
574 Some(i) => (&url[..i], &url[i..]),
575 None => (url, ""),
576 };
577 let authority_start = before_query.find("://")? + 3;
579 let slash = authority_start + before_query[authority_start..].find('/')?;
580 let name = &before_query[slash + 1..];
581 if name.is_empty() || name.contains('/') {
582 return None;
583 }
584 Some((
585 format!("{}/postgres{query}", &before_query[..slash]),
586 name.to_string(),
587 ))
588}
589
590#[cfg(test)]
591mod connect_tests {
592 use super::maintenance_url;
593
594 #[test]
595 fn swaps_the_database_name() {
596 assert_eq!(
597 maintenance_url("postgres://user:pw@127.0.0.1:5432/apiplant"),
598 Some((
599 "postgres://user:pw@127.0.0.1:5432/postgres".into(),
600 "apiplant".into()
601 ))
602 );
603 }
604
605 #[test]
606 fn keeps_query_parameters() {
607 assert_eq!(
608 maintenance_url("postgres://localhost/app?sslmode=require"),
609 Some((
610 "postgres://localhost/postgres?sslmode=require".into(),
611 "app".into()
612 ))
613 );
614 }
615
616 #[test]
617 fn no_database_in_url() {
618 assert_eq!(maintenance_url("postgres://localhost"), None);
619 assert_eq!(maintenance_url("postgres://localhost/"), None);
620 }
621}