Skip to main content

apiplant_db/
seed.rs

1//! Seed data: an app's `seed/` directory, loaded into the database.
2//!
3//! A seed directory holds one file per resource, named after it —
4//! `seed/organization.toml`, `seed/user.toml`, `seed/product.csv` — whose rows
5//! become that resource's rows. It is the fixture an app starts life with: an
6//! administrator who can sign in, the organisation they administer, and enough
7//! underneath for the dashboard to have something to show.
8//!
9//! TOML is the primary format, because it is the format the app is already
10//! written in — a seed file looks like the resource beside it:
11//!
12//! ```toml
13//! [[row]]
14//! id = "acme"
15//! name = "Acme, Inc."
16//! slug = "acme"
17//! ```
18//!
19//! CSV is accepted for the same job at a hundred rows, where a header line and
20//! a column per field says it better than a hundred `[[row]]` headers — and
21//! because it is what a spreadsheet or a `COPY … TO` exports.
22//!
23//! Three things make either enough, without a migration format or a script per
24//! app:
25//!
26//! * **Aliases instead of UUIDs.** Anywhere an id is expected — the `id`
27//!   column, or any `reference` field — a value that is not a UUID is taken as
28//!   a name and hashed into one ([`uuid_for`]). `acme` means the same row in
29//!   every file, so `seed/membership.toml` can say `organization_id = "acme"`
30//!   without anyone minting a UUID by hand.
31//! * **Idempotence.** Because those ids are derived rather than random,
32//!   inserting is `ON CONFLICT DO NOTHING`: seeding twice inserts once, and a
33//!   seed file that grew a row since the last run adds only that row. Rows
34//!   already present are never overwritten — someone who edited the fixture in
35//!   the dashboard keeps their edit.
36//! * **Passwords.** A `password` column on a resource that declares one
37//!   ([`AuthSpec`](apiplant_core::schema::AuthSpec)) is hashed into the
38//!   password field, so the seeded administrator can actually sign in and the
39//!   file stays readable.
40//!
41//! Seeding runs in dependency order, so a file may reference rows from a file
42//! it is listed before.
43
44use apiplant_core::schema::{FieldType, TextCase};
45use apiplant_core::{App, Resource};
46use sea_orm::sea_query::Value as SqlValue;
47use sea_orm::{ConnectionTrait, DatabaseBackend, Statement};
48use serde_json::Value as Json;
49use std::path::{Path, PathBuf};
50use uuid::Uuid;
51
52use crate::ident::quote_ident;
53use crate::{value, Error};
54
55/// What one seed file did.
56#[derive(Debug, Clone)]
57pub struct FileReport {
58    pub resource: String,
59    /// Rows the database did not already have.
60    pub inserted: u64,
61    /// Rows whose id was already there, and were therefore left alone.
62    pub skipped: u64,
63}
64
65/// What a whole `seed/` directory did.
66#[derive(Debug, Clone, Default)]
67pub struct Report {
68    pub files: Vec<FileReport>,
69}
70
71impl Report {
72    pub fn inserted(&self) -> u64 {
73        self.files.iter().map(|f| f.inserted).sum()
74    }
75
76    pub fn skipped(&self) -> u64 {
77        self.files.iter().map(|f| f.skipped).sum()
78    }
79
80    /// True when there was no `seed/` directory, or nothing in it.
81    pub fn is_empty(&self) -> bool {
82        self.files.is_empty()
83    }
84}
85
86/// Load `<app>/seed/` into the database.
87///
88/// A missing directory is not an error — most apps have no fixture — but a file
89/// naming a resource the app does not define is, because a typo that silently
90/// seeds nothing is the whole failure mode this is meant to avoid.
91pub async fn seed(conn: &impl ConnectionTrait, app: &App) -> Result<Report, Error> {
92    seed_dir(conn, app, &app.root.join("seed")).await
93}
94
95/// Load a specific directory of seed files, for an app whose fixtures live
96/// somewhere other than `seed/`.
97pub async fn seed_dir(conn: &impl ConnectionTrait, app: &App, dir: &Path) -> Result<Report, Error> {
98    if !dir.is_dir() {
99        return Ok(Report::default());
100    }
101
102    // Collect `<resource>.{toml,csv}`, and refuse the ones that name nothing.
103    let mut files: Vec<(String, PathBuf)> = Vec::new();
104    let entries = std::fs::read_dir(dir)
105        .map_err(|e| Error::Schema(format!("cannot read {}: {e}", dir.display())))?;
106    for entry in entries {
107        let path = entry
108            .map_err(|e| Error::Schema(format!("cannot read {}: {e}", dir.display())))?
109            .path();
110        match path.extension().and_then(|e| e.to_str()) {
111            Some("toml") | Some("csv") => {}
112            _ => continue,
113        }
114        let name = path
115            .file_stem()
116            .and_then(|s| s.to_str())
117            .unwrap_or_default()
118            .to_string();
119        if !app.resources.contains_key(&name) {
120            return Err(Error::Schema(format!(
121                "{}: no resource named `{name}` — a seed file is named after the \
122                 resource it fills",
123                path.display()
124            )));
125        }
126        if let Some((_, other)) = files.iter().find(|(existing, _)| existing == &name) {
127            return Err(Error::Schema(format!(
128                "{name} is seeded twice, by {} and {} — one file per resource",
129                other.display(),
130                path.display()
131            )));
132        }
133        files.push((name, path));
134    }
135
136    // Parents before children, so a reference always finds its row.
137    let mut report = Report::default();
138    for resource in app.resources_in_dependency_order() {
139        let Some((_, path)) = files.iter().find(|(name, _)| name == &resource.meta.name) else {
140            continue;
141        };
142        let file = seed_file(conn, resource, path).await?;
143        tracing::info!(
144            resource = %file.resource,
145            inserted = file.inserted,
146            skipped = file.skipped,
147            "seeded"
148        );
149        report.files.push(file);
150    }
151    Ok(report)
152}
153
154/// One row on its way into the database: columns in file order, each value
155/// still in the shape its format produced.
156type Row = Vec<(String, Raw)>;
157
158/// A value as it was written, before it knows what column it is for.
159#[derive(Debug, Clone)]
160enum Raw {
161    /// From CSV: a string that the column's type will parse.
162    Text(String),
163    /// From TOML: already typed.
164    Typed(Json),
165}
166
167/// Seed one resource from its file.
168async fn seed_file(
169    conn: &impl ConnectionTrait,
170    r: &Resource,
171    path: &Path,
172) -> Result<FileReport, Error> {
173    let origin = path.display().to_string();
174    let text = std::fs::read_to_string(path)
175        .map_err(|e| Error::Schema(format!("cannot read {origin}: {e}")))?;
176    let rows = if path.extension().and_then(|e| e.to_str()) == Some("csv") {
177        csv_rows(&text)
178    } else {
179        toml_rows(&text)
180    }
181    .map_err(|e| Error::Schema(format!("{origin}: {e}")))?;
182
183    let password_field = r.auth.as_ref().map(|a| a.password_field.clone());
184    let table = quote_ident(&r.table_name())?;
185    let mut inserted = 0u64;
186    let mut skipped = 0u64;
187
188    for (index, row) in rows.into_iter().enumerate() {
189        let position = index + 1;
190        let mut columns: Vec<String> = Vec::new();
191        let mut params: Vec<SqlValue> = Vec::new();
192        let mut id = None;
193
194        for (column, raw) in row {
195            let known = column == "id"
196                || r.fields.contains_key(&column)
197                || (column == "password" && password_field.is_some());
198            if !known {
199                return Err(Error::Schema(format!(
200                    "{origin}: row {position}: `{column}` is not a field of `{}`",
201                    r.meta.name
202                )));
203            }
204            if column == "id" {
205                id = Some(uuid_for(&as_key(&raw).map_err(|e| {
206                    Error::Schema(format!("{origin}: row {position}: `id`: {e}"))
207                })?));
208                continue;
209            }
210            if Some(column.as_str()) == password_field.as_deref() {
211                return Err(Error::Schema(format!(
212                    "{origin}: row {position}: set `password` rather than `{column}` — \
213                     seeding hashes it"
214                )));
215            }
216            if column == "password" {
217                let field = password_field.as_deref().expect("checked just above");
218                let plaintext = as_key(&raw)
219                    .map_err(|e| Error::Schema(format!("{origin}: row {position}: {e}")))?;
220                let hash = apiplant_auth::Authenticator::hash_password_with_argon2(&plaintext)
221                    .map_err(|e| Error::Schema(format!("{origin}: row {position}: {e}")))?;
222                columns.push(field.to_string());
223                params.push(SqlValue::from(hash));
224                continue;
225            }
226
227            let field = &r.fields[&column];
228            let sql = to_sql(field.ty, field.text_case(), &raw)
229                .map_err(|e| Error::Schema(format!("{origin}: row {position}: `{column}`: {e}")))?;
230            let Some(sql) = sql else { continue };
231            columns.push(column);
232            params.push(sql);
233        }
234
235        // Without an explicit id, the row is still given a derived one — from
236        // the resource and its position in the file — so that re-running the
237        // seed inserts nothing twice.
238        let id = id.unwrap_or_else(|| uuid_for(&format!("{}#{position}", r.meta.name)));
239        columns.insert(0, "id".to_string());
240        params.insert(0, SqlValue::from(id));
241
242        let quoted: Vec<String> = columns
243            .iter()
244            .map(|c| quote_ident(c))
245            .collect::<Result<_, _>>()?;
246        let placeholders: Vec<String> = (1..=quoted.len()).map(|n| format!("${n}")).collect();
247        let sql = format!(
248            "INSERT INTO {table} ({}) VALUES ({}) ON CONFLICT (\"id\") DO NOTHING",
249            quoted.join(", "),
250            placeholders.join(", ")
251        );
252        let result = conn
253            .execute(Statement::from_sql_and_values(
254                DatabaseBackend::Postgres,
255                sql,
256                params,
257            ))
258            .await?;
259        if result.rows_affected() > 0 {
260            inserted += 1;
261        } else {
262            skipped += 1;
263        }
264    }
265
266    Ok(FileReport {
267        resource: r.meta.name.clone(),
268        inserted,
269        skipped,
270    })
271}
272
273/// The string behind a value that has to be one — an id, an alias, a password.
274fn as_key(raw: &Raw) -> Result<String, String> {
275    match raw {
276        Raw::Text(s) => Ok(s.clone()),
277        Raw::Typed(Json::String(s)) => Ok(s.clone()),
278        Raw::Typed(_) => Err("expected a string".to_string()),
279    }
280}
281
282/// Convert one written value for one column, or `None` when the row leaves the
283/// column out and the database's own default should apply.
284fn to_sql(ty: FieldType, case: Option<TextCase>, raw: &Raw) -> Result<Option<SqlValue>, String> {
285    // A reference or an id is written as the alias its target row uses, in
286    // either format.
287    if matches!(ty, FieldType::Reference | FieldType::Uuid) {
288        return Ok(Some(SqlValue::from(uuid_for(&as_key(raw)?))));
289    }
290    Ok(Some(match raw {
291        Raw::Text(s) if s.is_empty() => return Ok(None),
292        // CSV has one type; the column says what it means.
293        Raw::Text(s) if ty == FieldType::Json => {
294            SqlValue::from(serde_json::from_str::<Json>(s).map_err(|e| format!("not JSON: {e}"))?)
295        }
296        Raw::Text(s) => value::string_to_sql(ty, case, s)?,
297        Raw::Typed(Json::Null) => return Ok(None),
298        // TOML is typed, but a number written for a text column (a postcode, a
299        // version) is a spelling, not a mistake — so a scalar is accepted
300        // wherever a string is wanted.
301        Raw::Typed(v)
302            if matches!(ty, FieldType::String | FieldType::Text | FieldType::File)
303                && !v.is_string() =>
304        {
305            match v {
306                Json::Object(_) | Json::Array(_) => return Err("expected a string".to_string()),
307                other => SqlValue::from(match case {
308                    Some(case) => case.apply(&other.to_string()),
309                    None => other.to_string(),
310                }),
311            }
312        }
313        Raw::Typed(v) => value::json_to_sql(ty, case, v)?,
314    }))
315}
316
317/// Parse a TOML seed file: an array of `[[row]]` tables.
318///
319/// A datetime is handed on as its RFC 3339 text, which is what a `timestamp`
320/// column parses — so a seed file may write a bare TOML datetime and does not
321/// have to quote it.
322fn toml_rows(text: &str) -> Result<Vec<Row>, String> {
323    let doc: toml::Value = toml::from_str(text).map_err(|e| e.to_string())?;
324    let table = doc
325        .as_table()
326        .ok_or("expected a table of `[[row]]` entries")?;
327    for key in table.keys() {
328        if key != "row" {
329            return Err(format!(
330                "`{key}` is not `row` — a seed file is a list of `[[row]]` tables"
331            ));
332        }
333    }
334    let Some(rows) = table.get("row") else {
335        return Ok(Vec::new());
336    };
337    let rows = rows
338        .as_array()
339        .ok_or("`row` must be written as `[[row]]` tables")?;
340
341    rows.iter()
342        .enumerate()
343        .map(|(index, row)| {
344            let row = row
345                .as_table()
346                .ok_or_else(|| format!("row {} is not a table", index + 1))?;
347            Ok(row
348                .iter()
349                .map(|(k, v)| (k.clone(), Raw::Typed(toml_to_json(v))))
350                .collect())
351        })
352        .collect()
353}
354
355/// TOML's values as JSON's, which is the shape the column converters take.
356fn toml_to_json(v: &toml::Value) -> Json {
357    match v {
358        toml::Value::String(s) => Json::String(s.clone()),
359        toml::Value::Integer(i) => Json::from(*i),
360        toml::Value::Float(f) => Json::from(*f),
361        toml::Value::Boolean(b) => Json::Bool(*b),
362        // RFC 3339 text, which is what a timestamp column reads.
363        toml::Value::Datetime(d) => Json::String(d.to_string()),
364        toml::Value::Array(items) => Json::Array(items.iter().map(toml_to_json).collect()),
365        toml::Value::Table(t) => Json::Object(
366            t.iter()
367                .map(|(k, v)| (k.clone(), toml_to_json(v)))
368                .collect(),
369        ),
370    }
371}
372
373/// Parse a CSV seed file into the same rows a TOML one produces: the header
374/// names the columns, and each record pairs with it.
375fn csv_rows(text: &str) -> Result<Vec<Row>, String> {
376    let records = parse_csv(text)?;
377    let mut records = records.into_iter();
378    let Some(header) = records.next() else {
379        return Ok(Vec::new());
380    };
381    let header: Vec<String> = header
382        .into_iter()
383        .map(|c| c.text.trim().to_string())
384        .collect();
385
386    records
387        .enumerate()
388        .map(|(index, record)| {
389            if record.len() > header.len() {
390                return Err(format!(
391                    "row {}: {} values for {} columns",
392                    index + 1,
393                    record.len(),
394                    header.len()
395                ));
396            }
397            Ok(header
398                .iter()
399                .cloned()
400                .zip(record)
401                // An empty, unquoted cell is "no value" — the column is left
402                // out and the database's own default (or NULL) applies. `""`
403                // is an empty string, which is a different thing and sometimes
404                // what is wanted.
405                .filter(|(_, cell)| !cell.text.is_empty() || cell.quoted)
406                .map(|(column, cell)| (column, Raw::Text(cell.text)))
407                .collect())
408        })
409        .collect()
410}
411
412/// The id a seed file's key stands for.
413///
414/// A real UUID is itself, so a fixture may pin an id exactly. Anything else is
415/// a name — `acme`, `admin`, `widget-blue` — hashed into a UUID, the same one
416/// every time and on every machine. That determinism is what makes seeding
417/// idempotent and lets one file point at another's rows by a readable word.
418pub fn uuid_for(key: &str) -> Uuid {
419    if let Ok(uuid) = Uuid::parse_str(key) {
420        return uuid;
421    }
422    let digest = apiplant_auth::Authenticator::hash_api_key(&format!("apiplant-seed:{key}"));
423    let mut bytes = [0u8; 16];
424    for (i, byte) in bytes.iter_mut().enumerate() {
425        // The digest is hex, so two characters per byte.
426        *byte = u8::from_str_radix(&digest[i * 2..i * 2 + 2], 16).unwrap_or(0);
427    }
428    // Version 8 (custom) with the RFC 4122 variant: honest about being derived
429    // rather than random, and still a well-formed UUID to everything that
430    // looks at one.
431    bytes[6] = (bytes[6] & 0x0f) | 0x80;
432    bytes[8] = (bytes[8] & 0x3f) | 0x80;
433    Uuid::from_bytes(bytes)
434}
435
436/// One CSV cell, and whether it arrived quoted.
437#[derive(Debug, Clone, PartialEq, Eq)]
438struct Cell {
439    text: String,
440    quoted: bool,
441}
442
443/// Parse RFC 4180 CSV: commas separate, `"` quotes, `""` is a literal quote
444/// inside a quoted field, and a quoted field may span lines.
445///
446/// Two departures, both for files people edit by hand: a line whose first
447/// character is `#` is a comment, and a blank line is nothing at all.
448fn parse_csv(text: &str) -> Result<Vec<Vec<Cell>>, String> {
449    let mut rows: Vec<Vec<Cell>> = Vec::new();
450    let mut row: Vec<Cell> = Vec::new();
451    let mut cell = String::new();
452    let mut quoted = false;
453    let mut in_quotes = false;
454    // Only meaningful at the very start of a line, which is where the comment
455    // and blank-line rules apply.
456    let mut at_line_start = true;
457    let mut chars = text.chars().peekable();
458
459    while let Some(c) = chars.next() {
460        if at_line_start {
461            if c == '#' {
462                for c in chars.by_ref() {
463                    if c == '\n' {
464                        break;
465                    }
466                }
467                continue;
468            }
469            if c == '\n' {
470                continue;
471            }
472            if c == '\r' && chars.peek() == Some(&'\n') {
473                chars.next();
474                continue;
475            }
476            at_line_start = false;
477        }
478
479        if in_quotes {
480            if c == '"' {
481                if chars.peek() == Some(&'"') {
482                    chars.next();
483                    cell.push('"');
484                } else {
485                    in_quotes = false;
486                }
487            } else {
488                cell.push(c);
489            }
490            continue;
491        }
492
493        match c {
494            '"' if cell.is_empty() => {
495                in_quotes = true;
496                quoted = true;
497            }
498            '"' => return Err("a quote may only open a field".to_string()),
499            ',' => row.push(Cell {
500                text: std::mem::take(&mut cell),
501                quoted: std::mem::take(&mut quoted),
502            }),
503            '\r' if chars.peek() == Some(&'\n') => {}
504            '\n' => {
505                row.push(Cell {
506                    text: std::mem::take(&mut cell),
507                    quoted: std::mem::take(&mut quoted),
508                });
509                rows.push(std::mem::take(&mut row));
510                at_line_start = true;
511            }
512            _ => cell.push(c),
513        }
514    }
515
516    if in_quotes {
517        return Err("a quoted field was never closed".to_string());
518    }
519    // A file not ending in a newline still ends in a row.
520    if !cell.is_empty() || quoted || !row.is_empty() {
521        row.push(Cell { text: cell, quoted });
522        rows.push(row);
523    }
524    Ok(rows)
525}
526
527#[cfg(test)]
528mod tests {
529    use super::*;
530
531    fn cells(row: &[Cell]) -> Vec<&str> {
532        row.iter().map(|c| c.text.as_str()).collect()
533    }
534
535    fn column<'a>(row: &'a Row, name: &str) -> &'a Raw {
536        &row.iter().find(|(c, _)| c == name).expect("column").1
537    }
538
539    #[test]
540    fn parses_quotes_commas_and_newlines() {
541        let rows = parse_csv("a,b\n1,\"two, and\"\n\"line\nbreak\",\"say \"\"hi\"\"\"\n").unwrap();
542        assert_eq!(cells(&rows[0]), ["a", "b"]);
543        assert_eq!(cells(&rows[1]), ["1", "two, and"]);
544        assert_eq!(cells(&rows[2]), ["line\nbreak", "say \"hi\""]);
545    }
546
547    #[test]
548    fn comments_and_blank_lines_are_skipped() {
549        let rows = parse_csv("# a note\nname\n\nacme\n").unwrap();
550        assert_eq!(rows.len(), 2);
551        assert_eq!(cells(&rows[1]), ["acme"]);
552    }
553
554    #[test]
555    fn an_empty_csv_cell_is_left_out_but_an_empty_string_is_not() {
556        let rows = csv_rows("a,b\n,\"\"\n").unwrap();
557        assert!(rows[0].iter().all(|(c, _)| c != "a"));
558        assert!(matches!(column(&rows[0], "b"), Raw::Text(s) if s.is_empty()));
559    }
560
561    #[test]
562    fn a_final_row_without_a_newline_still_counts() {
563        assert_eq!(parse_csv("a\n1").unwrap().len(), 2);
564    }
565
566    #[test]
567    fn an_unterminated_quote_is_an_error() {
568        assert!(parse_csv("a\n\"oops\n").is_err());
569    }
570
571    #[test]
572    fn toml_rows_are_read_in_order_with_their_types() {
573        let rows = toml_rows(
574            r#"
575            [[row]]
576            id = "acme"
577            name = "Acme, Inc."
578            seats = 12
579            active = true
580
581            [[row]]
582            id = "globex"
583            name = "Globex"
584            "#,
585        )
586        .unwrap();
587        assert_eq!(rows.len(), 2);
588        assert!(matches!(
589            column(&rows[0], "seats"),
590            Raw::Typed(Json::Number(_))
591        ));
592        assert!(matches!(
593            column(&rows[0], "active"),
594            Raw::Typed(Json::Bool(true))
595        ));
596        assert!(matches!(column(&rows[1], "id"), Raw::Typed(Json::String(s)) if s == "globex"));
597    }
598
599    #[test]
600    fn a_toml_file_that_is_not_rows_says_so() {
601        let err = toml_rows("[[organization]]\nname = \"Acme\"\n").unwrap_err();
602        assert!(err.contains("`[[row]]`"), "{err}");
603    }
604
605    #[test]
606    fn a_toml_datetime_becomes_a_timestamp() {
607        let rows = toml_rows("[[row]]\nat = 2024-01-31T09:00:00Z\n").unwrap();
608        let sql = to_sql(FieldType::Timestamp, None, column(&rows[0], "at")).unwrap();
609        assert!(sql.is_some());
610    }
611
612    #[test]
613    fn a_number_is_accepted_where_a_string_column_wants_one() {
614        let rows = toml_rows("[[row]]\npostcode = 90210\n").unwrap();
615        let sql = to_sql(FieldType::String, None, column(&rows[0], "postcode")).unwrap();
616        assert_eq!(sql, Some(SqlValue::from("90210".to_string())));
617    }
618
619    #[test]
620    fn aliases_are_stable_and_uuids_pass_through() {
621        assert_eq!(uuid_for("acme"), uuid_for("acme"));
622        assert_ne!(uuid_for("acme"), uuid_for("globex"));
623        let explicit = "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0";
624        assert_eq!(uuid_for(explicit).to_string(), explicit);
625        // Well-formed: version 8, RFC 4122 variant.
626        let derived = uuid_for("acme");
627        assert_eq!(derived.get_version_num(), 8);
628        assert_eq!(derived.as_bytes()[8] & 0xc0, 0x80);
629    }
630}