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(row.try_get::<serde_json::Value>("", "result")?)
240    }
241
242    /// `GET /<resource>/<id>` — one row or `None`.
243    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    /// `POST /<resource>` — insert and return the created row.
272    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    /// `PATCH /<resource>/<id>` — update present fields, return the new row.
315    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    /// `DELETE /<resource>/<id>` — returns whether a row was removed.
368    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    /// Fetch multiple rows of a resource by id (used for relation expansion).
385    /// `filters` carry the caller's authorization scope, so an expansion can
386    /// never reach a row a direct read would have refused. Returns a JSON array
387    /// with hidden fields stripped; order is unspecified.
388    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    /// Raw query bridge used by function `.so`s. `SELECT`/`WITH` statements come
419    /// back as a JSON array of rows; anything else returns `{"rows_affected":n}`.
420    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    // --- helpers ----------------------------------------------------------
457
458    /// Build a `WHERE …` clause from filters; returns the SQL, the bound values,
459    /// and the next free parameter index.
460    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    /// Render one filter to SQL, appending its bound values to `params` and
474    /// advancing the `$n` counter.
475    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                // The term is bound, so it cannot be SQL — but it is still a
508                // LIKE *pattern*, and an unescaped `%` would match everything.
509                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                // A search with nothing to search is not "match everything":
520                // the caller asked for rows containing a term, and none do.
521                if columns.is_empty() {
522                    return Ok("false".to_string());
523                }
524                let escaped = value
525                    .replace('\\', "\\\\")
526                    .replace('%', "\\%")
527                    .replace('_', "\\_");
528                // One bound term shared by every column, so a search over five
529                // fields still costs one parameter.
530                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    /// `- 'col'` fragments that strip hidden fields from a `to_jsonb` result.
546    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)?; // validate the identifier before embedding
551                s.push_str(&format!(" - '{name}'"));
552            }
553        }
554        Ok(s)
555    }
556}
557
558/// Does this error mean "the database in the URL isn't there"?
559///
560/// sea-orm flattens the sqlx error into its message, so the SQLSTATE for
561/// `invalid_catalog_name` (`3D000`) is matched on text — that code only ever
562/// means a missing database.
563fn 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
569/// Split a Postgres URL into (same server, `postgres` database) and the database
570/// name it asked for. Returns `None` when the URL names no database, in which
571/// case there is nothing to create.
572fn 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    // Skip the `scheme://` so its slashes aren't mistaken for the path.
578    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}