Skip to main content

apiplant_db/
lib.rs

1//! # apiplant-db
2//!
3//! The database layer. It has three jobs:
4//!
5//! * **Migrations** ([`migrate`]) — make Postgres match the resource schemas.
6//! * **CRUD** ([`Db`]) — build parameterised statements for a [`Resource`] at
7//!   runtime and hand rows back as plain JSON.
8//! * **Seeding** ([`seed`]) — load an app's `seed/` directory, the rows it
9//!   starts life with.
10//!
11//! Rows come back as JSON by letting Postgres do the conversion (`to_jsonb` /
12//! `jsonb_agg`), so the executor only ever extracts a single JSON column and
13//! never needs a compile-time entity for a table it only learned about from a
14//! TOML file. Values always travel as `$n` bind parameters; only validated,
15//! double-quoted identifiers are ever interpolated.
16
17mod 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/// Database errors.
34#[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/// An extra predicate applied to a query: equality (owner/org scoping,
45/// `?field=` filters) or membership (`id IN (…)`, e.g. "organisations you belong
46/// to"). Column names are always validated and quoted; values are always bound.
47#[derive(Clone, Debug)]
48pub enum Filter {
49    /// `column = value`.
50    Eq { column: String, value: SqlValue },
51    /// `column IN (values…)`. An empty set matches no rows.
52    In {
53        column: String,
54        values: Vec<SqlValue>,
55    },
56    /// `column ILIKE '%value%'` — a case-insensitive substring match, which is
57    /// what a search box means by "search". The pattern's own wildcards are
58    /// escaped, so a term containing `%` looks for a per-cent sign.
59    Contains { column: String, value: String },
60    /// `(c1 ILIKE '%value%' OR c2 ILIKE '%value%' …)` — one term against
61    /// several columns, which is what a search box over a configured set of
62    /// fields means. An empty column list matches no rows.
63    AnyContains { columns: Vec<String>, value: String },
64}
65
66/// One `ORDER BY` key: a column and a direction.
67///
68/// The column is a validated field name — the caller (the CRUD layer) decides
69/// what a client may sort by; this type only renders it.
70#[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    /// Convenience: `column IN (…uuids)`.
101    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    /// Search `value` against every one of `columns`, matching if any does.
116    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    /// The single column this filter constrains, if it constrains just one.
124    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/// A connection pool plus the dynamic CRUD executor.
135#[derive(Clone)]
136pub struct Db {
137    conn: DatabaseConnection,
138}
139
140impl Db {
141    /// Open a pool against the given Postgres URL, creating the database first
142    /// if it does not exist yet.
143    ///
144    /// A fresh checkout pointed at a running Postgres would otherwise fail with
145    /// `database "…" does not exist` before migrations ever get a chance to
146    /// run, so on that specific error we connect to the `postgres` maintenance
147    /// database on the same server, `CREATE DATABASE`, and retry once. Any
148    /// other failure (bad credentials, no server) is returned untouched.
149    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                // Another worker starting at the same time may win the race and
159                // create it first, which is fine: what matters is whether the
160                // database is there on the retry, so a failed CREATE is only
161                // reported if the retry also fails.
162                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    /// Access the underlying connection (used by [`migrate`]).
183    pub fn connection(&self) -> &DatabaseConnection {
184        &self.conn
185    }
186
187    // --- CRUD -------------------------------------------------------------
188
189    /// `GET /<resource>` — a JSON array of rows, newest first unless `sort`
190    /// says otherwise.
191    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                // NULLS LAST in both directions: an empty cell is the least
211                // informative row on the page, wherever the arrow points.
212                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    /// `GET /<resource>/<id>` — one row or `None`.
246    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    /// `POST /<resource>` — insert and return the created row.
278    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    /// `PATCH /<resource>/<id>` — update present fields, return the new row.
326    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    /// `DELETE /<resource>/<id>` — returns whether a row was removed.
384    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    /// Fetch multiple rows of a resource by id (used for relation expansion).
401    /// `filters` carry the caller's authorization scope, so an expansion can
402    /// never reach a row a direct read would have refused. Returns a JSON array
403    /// with hidden fields stripped; order is unspecified.
404    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    /// Raw query bridge used by function `.so`s. `SELECT`/`WITH` statements come
435    /// back as a JSON array of rows; anything else returns `{"rows_affected":n}`.
436    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    // --- helpers ----------------------------------------------------------
473
474    /// Build a `WHERE …` clause from filters; returns the SQL, the bound values,
475    /// and the next free parameter index.
476    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    /// Render one filter to SQL, appending its bound values to `params` and
490    /// advancing the `$n` counter.
491    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                // The term is bound, so it cannot be SQL — but it is still a
524                // LIKE *pattern*, and an unescaped `%` would match everything.
525                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                // A search with nothing to search is not "match everything":
536                // the caller asked for rows containing a term, and none do.
537                if columns.is_empty() {
538                    return Ok("false".to_string());
539                }
540                let escaped = value
541                    .replace('\\', "\\\\")
542                    .replace('%', "\\%")
543                    .replace('_', "\\_");
544                // One bound term shared by every column, so a search over five
545                // fields still costs one parameter.
546                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    /// `- 'col'` fragments that strip hidden fields from a `to_jsonb` result.
562    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)?; // validate the identifier before embedding
567                s.push_str(&format!(" - '{name}'"));
568            }
569        }
570        Ok(s)
571    }
572}
573
574/// Does this error mean "the database in the URL isn't there"?
575///
576/// sea-orm flattens the sqlx error into its message, so the SQLSTATE for
577/// `invalid_catalog_name` (`3D000`) is matched on text — that code only ever
578/// means a missing database.
579/// Force every cased text field into its case, on a row or a list of rows.
580///
581/// Applied to what comes *back* rather than only to what goes in, because a
582/// column is written by more than this API: seeds, the payments webhook, a
583/// migration somebody ran by hand. `case = "upper"` promising uppercase only
584/// for rows that arrived through `POST` would be a guarantee with a hole in it
585/// exactly where the interesting data is.
586///
587/// Costs nothing for the resources that force no case, which is nearly all of
588/// them — the check is an empty iterator.
589fn 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
610/// Split a Postgres URL into (same server, `postgres` database) and the database
611/// name it asked for. Returns `None` when the URL names no database, in which
612/// case there is nothing to create.
613fn 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    // Skip the `scheme://` so its slashes aren't mistaken for the path.
619    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}