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(cased_rows(
240 r,
241 row.try_get::<serde_json::Value>("", "result")?,
242 ))
243 }
244
245 pub async fn get(
247 &self,
248 r: &Resource,
249 id: Uuid,
250 filters: &[Filter],
251 ) -> Result<Option<serde_json::Value>, Error> {
252 let table = quote_ident(&r.table_name())?;
253 let mut all = vec![Filter::eq("id", id)];
254 all.extend_from_slice(filters);
255 let (where_sql, params, _) = self.build_where(&all)?;
256 let hidden = self.hidden_subtraction(r)?;
257 let sql = format!(
258 "SELECT to_jsonb(t){hidden} AS result FROM (SELECT * FROM {table} {where_sql} LIMIT 1) t"
259 );
260 let row = self
261 .conn
262 .query_one(Statement::from_sql_and_values(
263 DatabaseBackend::Postgres,
264 sql,
265 params,
266 ))
267 .await?;
268 match row {
269 Some(row) => Ok(Some(cased_rows(
270 r,
271 row.try_get::<serde_json::Value>("", "result")?,
272 ))),
273 None => Ok(None),
274 }
275 }
276
277 pub async fn create(
279 &self,
280 r: &Resource,
281 data: &serde_json::Map<String, serde_json::Value>,
282 ) -> Result<serde_json::Value, Error> {
283 let table = quote_ident(&r.table_name())?;
284 let mut cols = Vec::new();
285 let mut placeholders = Vec::new();
286 let mut params: Vec<SqlValue> = Vec::new();
287 let mut n = 1;
288 for (name, field) in &r.fields {
289 if let Some(v) = data.get(name) {
290 cols.push(quote_ident(name)?);
291 placeholders.push(format!("${n}"));
292 params.push(
293 value::json_to_sql(field.ty, field.text_case(), v).map_err(Error::BadInput)?,
294 );
295 n += 1;
296 }
297 }
298
299 let hidden = self.hidden_subtraction(r)?;
300 let returning = format!("RETURNING (to_jsonb({table}.*){hidden}) AS result");
301 let sql = if cols.is_empty() {
302 format!("INSERT INTO {table} DEFAULT VALUES {returning}")
303 } else {
304 format!(
305 "INSERT INTO {table} ({}) VALUES ({}) {returning}",
306 cols.join(", "),
307 placeholders.join(", ")
308 )
309 };
310 let row = self
311 .conn
312 .query_one(Statement::from_sql_and_values(
313 DatabaseBackend::Postgres,
314 sql,
315 params,
316 ))
317 .await?
318 .ok_or_else(|| Error::Db(sea_orm::DbErr::Custom("insert returned no row".into())))?;
319 Ok(cased_rows(
320 r,
321 row.try_get::<serde_json::Value>("", "result")?,
322 ))
323 }
324
325 pub async fn update(
327 &self,
328 r: &Resource,
329 id: Uuid,
330 data: &serde_json::Map<String, serde_json::Value>,
331 filters: &[Filter],
332 ) -> Result<Option<serde_json::Value>, Error> {
333 let table = quote_ident(&r.table_name())?;
334 let mut assignments = Vec::new();
335 let mut params: Vec<SqlValue> = Vec::new();
336 let mut n = 1;
337 for (name, field) in &r.fields {
338 if let Some(v) = data.get(name) {
339 assignments.push(format!("{} = ${n}", quote_ident(name)?));
340 params.push(
341 value::json_to_sql(field.ty, field.text_case(), v).map_err(Error::BadInput)?,
342 );
343 n += 1;
344 }
345 }
346 if r.meta.timestamps {
347 assignments.push("updated_at = now()".to_string());
348 }
349 if assignments.is_empty() {
350 return self.get(r, id, filters).await;
351 }
352
353 let mut where_parts = vec![format!("{} = ${n}", quote_ident("id")?)];
354 params.push(SqlValue::from(id));
355 n += 1;
356 for f in filters {
357 where_parts.push(Self::render_filter(f, &mut params, &mut n)?);
358 }
359
360 let hidden = self.hidden_subtraction(r)?;
361 let sql = format!(
362 "UPDATE {table} SET {} WHERE {} RETURNING (to_jsonb({table}.*){hidden}) AS result",
363 assignments.join(", "),
364 where_parts.join(" AND "),
365 );
366 let row = self
367 .conn
368 .query_one(Statement::from_sql_and_values(
369 DatabaseBackend::Postgres,
370 sql,
371 params,
372 ))
373 .await?;
374 match row {
375 Some(row) => Ok(Some(cased_rows(
376 r,
377 row.try_get::<serde_json::Value>("", "result")?,
378 ))),
379 None => Ok(None),
380 }
381 }
382
383 pub async fn delete(&self, r: &Resource, id: Uuid, filters: &[Filter]) -> Result<bool, Error> {
385 let table = quote_ident(&r.table_name())?;
386 let mut all = vec![Filter::eq("id", id)];
387 all.extend_from_slice(filters);
388 let (where_sql, params, _) = self.build_where(&all)?;
389 let res = self
390 .conn
391 .execute(Statement::from_sql_and_values(
392 DatabaseBackend::Postgres,
393 format!("DELETE FROM {table} {where_sql}"),
394 params,
395 ))
396 .await?;
397 Ok(res.rows_affected() > 0)
398 }
399
400 pub async fn fetch_by_ids(
405 &self,
406 r: &Resource,
407 ids: &[Uuid],
408 filters: &[Filter],
409 ) -> Result<serde_json::Value, Error> {
410 if ids.is_empty() {
411 return Ok(serde_json::Value::Array(Vec::new()));
412 }
413 let table = quote_ident(&r.table_name())?;
414 let mut all = vec![Filter::in_uuids("id", ids.to_vec())];
415 all.extend_from_slice(filters);
416 let (where_sql, params, _) = self.build_where(&all)?;
417 let hidden = self.hidden_subtraction(r)?;
418 let sql = format!(
419 "SELECT coalesce(jsonb_agg(to_jsonb(t){hidden}), '[]'::jsonb) AS result \
420 FROM (SELECT * FROM {table} {where_sql}) t"
421 );
422 let row = self
423 .conn
424 .query_one(Statement::from_sql_and_values(
425 DatabaseBackend::Postgres,
426 sql,
427 params,
428 ))
429 .await?
430 .ok_or_else(|| Error::Db(sea_orm::DbErr::Custom("no aggregate row".into())))?;
431 Ok(row.try_get::<serde_json::Value>("", "result")?)
432 }
433
434 pub async fn raw_json(
437 &self,
438 sql: &str,
439 params: &[serde_json::Value],
440 ) -> Result<serde_json::Value, Error> {
441 let vals: Vec<SqlValue> = params.iter().map(value::json_param).collect();
442 let head = sql.trim_start();
443 let is_query = (head.len() >= 6 && head[..6].eq_ignore_ascii_case("select"))
444 || (head.len() >= 4 && head[..4].eq_ignore_ascii_case("with"));
445
446 if is_query {
447 let wrapped =
448 format!("SELECT coalesce(jsonb_agg(t), '[]'::jsonb) AS result FROM ({sql}) t");
449 let row = self
450 .conn
451 .query_one(Statement::from_sql_and_values(
452 DatabaseBackend::Postgres,
453 wrapped,
454 vals,
455 ))
456 .await?
457 .ok_or_else(|| Error::Db(sea_orm::DbErr::Custom("no aggregate row".into())))?;
458 Ok(row.try_get::<serde_json::Value>("", "result")?)
459 } else {
460 let res = self
461 .conn
462 .execute(Statement::from_sql_and_values(
463 DatabaseBackend::Postgres,
464 sql.to_string(),
465 vals,
466 ))
467 .await?;
468 Ok(serde_json::json!({ "rows_affected": res.rows_affected() }))
469 }
470 }
471
472 fn build_where(&self, filters: &[Filter]) -> Result<(String, Vec<SqlValue>, usize), Error> {
477 if filters.is_empty() {
478 return Ok((String::new(), Vec::new(), 1));
479 }
480 let mut parts = Vec::new();
481 let mut params = Vec::new();
482 let mut n = 1;
483 for f in filters {
484 parts.push(Self::render_filter(f, &mut params, &mut n)?);
485 }
486 Ok((format!("WHERE {}", parts.join(" AND ")), params, n))
487 }
488
489 fn render_filter(
492 f: &Filter,
493 params: &mut Vec<SqlValue>,
494 n: &mut usize,
495 ) -> Result<String, Error> {
496 let col = match f.column() {
497 Some(column) => quote_ident(column)?,
498 None => String::new(),
499 };
500 Ok(match f {
501 Filter::Eq { value, .. } => {
502 let part = format!("{col} = ${n}");
503 params.push(value.clone());
504 *n += 1;
505 part
506 }
507 Filter::In { values, .. } => {
508 if values.is_empty() {
509 return Ok("false".to_string());
510 }
511 let placeholders: Vec<String> = values
512 .iter()
513 .map(|v| {
514 let p = format!("${n}");
515 params.push(v.clone());
516 *n += 1;
517 p
518 })
519 .collect();
520 format!("{col} IN ({})", placeholders.join(", "))
521 }
522 Filter::Contains { value, .. } => {
523 let escaped = value
526 .replace('\\', "\\\\")
527 .replace('%', "\\%")
528 .replace('_', "\\_");
529 let part = format!("{col}::text ILIKE ${n}");
530 params.push(SqlValue::from(format!("%{escaped}%")));
531 *n += 1;
532 part
533 }
534 Filter::AnyContains { columns, value } => {
535 if columns.is_empty() {
538 return Ok("false".to_string());
539 }
540 let escaped = value
541 .replace('\\', "\\\\")
542 .replace('%', "\\%")
543 .replace('_', "\\_");
544 let placeholder = format!("${n}");
547 params.push(SqlValue::from(format!("%{escaped}%")));
548 *n += 1;
549 let mut parts = Vec::with_capacity(columns.len());
550 for column in columns {
551 parts.push(format!(
552 "{}::text ILIKE {placeholder}",
553 quote_ident(column)?
554 ));
555 }
556 format!("({})", parts.join(" OR "))
557 }
558 })
559 }
560
561 fn hidden_subtraction(&self, r: &Resource) -> Result<String, Error> {
563 let mut s = String::new();
564 for (name, field) in &r.fields {
565 if field.hidden {
566 quote_ident(name)?; s.push_str(&format!(" - '{name}'"));
568 }
569 }
570 Ok(s)
571 }
572}
573
574fn cased_rows(r: &Resource, mut result: serde_json::Value) -> serde_json::Value {
590 if r.cased_fields().next().is_none() {
591 return result;
592 }
593 match &mut result {
594 serde_json::Value::Array(rows) => {
595 for row in rows {
596 r.apply_text_case(row);
597 }
598 }
599 row => r.apply_text_case(row),
600 }
601 result
602}
603
604fn is_missing_database(err: &Error) -> bool {
605 let Error::Db(err) = err else { return false };
606 let msg = err.to_string();
607 msg.contains("3D000") || msg.contains("does not exist")
608}
609
610fn maintenance_url(url: &str) -> Option<(String, String)> {
614 let (before_query, query) = match url.find(['?', '#']) {
615 Some(i) => (&url[..i], &url[i..]),
616 None => (url, ""),
617 };
618 let authority_start = before_query.find("://")? + 3;
620 let slash = authority_start + before_query[authority_start..].find('/')?;
621 let name = &before_query[slash + 1..];
622 if name.is_empty() || name.contains('/') {
623 return None;
624 }
625 Some((
626 format!("{}/postgres{query}", &before_query[..slash]),
627 name.to_string(),
628 ))
629}
630
631#[cfg(test)]
632mod connect_tests {
633 use super::maintenance_url;
634
635 #[test]
636 fn swaps_the_database_name() {
637 assert_eq!(
638 maintenance_url("postgres://user:pw@127.0.0.1:5432/apiplant"),
639 Some((
640 "postgres://user:pw@127.0.0.1:5432/postgres".into(),
641 "apiplant".into()
642 ))
643 );
644 }
645
646 #[test]
647 fn keeps_query_parameters() {
648 assert_eq!(
649 maintenance_url("postgres://localhost/app?sslmode=require"),
650 Some((
651 "postgres://localhost/postgres?sslmode=require".into(),
652 "app".into()
653 ))
654 );
655 }
656
657 #[test]
658 fn no_database_in_url() {
659 assert_eq!(maintenance_url("postgres://localhost"), None);
660 assert_eq!(maintenance_url("postgres://localhost/"), None);
661 }
662}