Skip to main content

siderite_cli/seed/
mod.rs

1//! `seed --dataset NAME` and `seed --list`: datasets the application
2//! registers with [`AppCli::seed`](crate::AppCli::seed), loaded only when
3//! asked for, into the database the global flags select.
4//!
5//! A seed is not a migration: nothing is recorded in the migration
6//! history and there is no rollback. Nothing is deleted or reset first,
7//! and no SQL file is read; the callback decides what to write. The
8//! dataset runs in one transaction whenever the backend has transactions
9//! (SQLite, PostgreSQL and MySQL do), so a failing seed leaves nothing
10//! behind. Datasets should be idempotent (look up before inserting, or
11//! rely on unique keys) so running one twice is harmless.
12
13use crate::error::CliError;
14use crate::report::{Diagnostic, Envelope, Severity, UrlSource, redact_text};
15use serde::Serialize;
16use siderite_orm::{Db, TransactionSupport};
17use std::future::Future;
18use std::pin::Pin;
19use std::sync::Arc;
20
21#[cfg(test)]
22mod tests;
23
24/// What a seed callback returns on failure; the message is redacted
25/// before it is shown.
26pub type SeedError = Box<dyn std::error::Error + Send + Sync>;
27
28/// The future a registered dataset returns.
29pub(crate) type SeedFuture = Pin<Box<dyn Future<Output = Seeded> + Send>>;
30
31type Seeded = Result<(), SeedError>;
32
33type Callback = Arc<dyn Fn(Seeding) -> SeedFuture + Send + Sync>;
34
35/// What a dataset receives.
36#[derive(Debug, Clone)]
37pub struct Seeding {
38    /// The selected database; inside the dataset's transaction when the
39    /// backend has transactions.
40    pub db: Db,
41    /// The selected alias (`default` unless `--database` names another).
42    pub alias: String,
43    /// The dataset's name.
44    pub dataset: String,
45}
46
47/// A registered dataset.
48#[derive(Clone)]
49pub(crate) struct Dataset {
50    name: String,
51    summary: String,
52    run: Callback,
53}
54
55impl std::fmt::Debug for Dataset {
56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        f.debug_struct("Dataset")
58            .field("name", &self.name)
59            .finish_non_exhaustive()
60    }
61}
62
63impl Dataset {
64    /// The dataset `name` described by `summary`, loaded by `run`.
65    pub(crate) fn new<F, Fut>(name: &str, summary: &str, run: F) -> Self
66    where
67        F: Fn(Seeding) -> Fut + Send + Sync + 'static,
68        Fut: Future<Output = Result<(), SeedError>> + Send + 'static,
69    {
70        let run: Callback = Arc::new(move |s| Box::pin(run(s)));
71        Self {
72            name: name.to_owned(),
73            summary: summary.to_owned(),
74            run,
75        }
76    }
77}
78
79/// Parsed arguments.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub(crate) enum Request {
82    /// `--list`.
83    List,
84    /// `--dataset NAME`.
85    Load(String),
86}
87
88const USAGE: &str = "usage: seed --dataset NAME | seed --list";
89
90/// Parse the arguments after the global flags (`seed ...`).
91///
92/// Errors:
93///   A usage error unless exactly one of `--dataset NAME` and `--list`
94///   is given.
95pub(crate) fn parse(rest: &[String]) -> Result<Request, CliError> {
96    let mut args = rest.iter().skip(1).map(String::as_str);
97    let request = match (args.next(), args.next(), args.next()) {
98        (Some("--list"), None, _) => Request::List,
99        (Some("--dataset"), Some(name), None) if !name.starts_with('-') => {
100            Request::Load(name.to_owned())
101        }
102        (Some(flag), None, _) if flag.starts_with("--dataset=") => {
103            Request::Load(flag["--dataset=".len()..].to_owned())
104        }
105        _ => return Err(CliError::usage(USAGE)),
106    };
107    if request == Request::Load(String::new()) {
108        return Err(CliError::usage(USAGE));
109    }
110    Ok(request)
111}
112
113/// The registered names, in registration order.
114fn names(datasets: &[Dataset]) -> String {
115    let names: Vec<&str> = datasets.iter().map(|d| d.name.as_str()).collect();
116    if names.is_empty() {
117        "none is registered".to_owned()
118    } else {
119        format!("registered: {}", names.join(", "))
120    }
121}
122
123type Found<'a> = Result<&'a Dataset, CliError>;
124
125/// The dataset called `name`.
126///
127/// Errors:
128///   A usage error naming the registered datasets when there is none,
129///   or more than one, called `name`.
130pub(crate) fn find<'a>(datasets: &'a [Dataset], name: &str) -> Found<'a> {
131    let mut found = datasets.iter().filter(|d| d.name == name);
132    match (found.next(), found.next()) {
133        (Some(dataset), None) => Ok(dataset),
134        (Some(_), Some(_)) => Err(CliError::usage(format!(
135            "dataset `{name}` is registered more than once"
136        ))),
137        _ => Err(CliError::usage(format!(
138            "no dataset `{name}` ({})",
139            names(datasets)
140        ))),
141    }
142}
143
144/// One dataset in `seed --list`.
145#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
146pub(crate) struct DatasetInfo {
147    /// Its name.
148    pub name: String,
149    /// What it loads.
150    pub summary: String,
151}
152
153/// A loaded dataset.
154#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
155pub(crate) struct Loaded {
156    /// The dataset.
157    pub dataset: String,
158    /// The database alias.
159    pub database: String,
160    /// Where its URL came from.
161    pub url_source: UrlSource,
162    /// The URL without credentials.
163    pub url: String,
164    /// Whether it ran in one transaction.
165    pub transaction: bool,
166}
167
168/// The `data` of a `seed` envelope.
169#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
170pub(crate) struct SeedData {
171    /// The registered datasets.
172    pub datasets: Vec<DatasetInfo>,
173    /// The dataset that was loaded, for `--dataset`.
174    pub loaded: Option<Loaded>,
175}
176
177/// The `seed --list` report.
178pub(crate) fn list(datasets: &[Dataset]) -> Envelope<SeedData> {
179    let info = datasets.iter().map(|d| DatasetInfo {
180        name: d.name.clone(),
181        summary: d.summary.clone(),
182    });
183    let data = SeedData {
184        datasets: info.collect(),
185        loaded: None,
186    };
187    Envelope::new("seed", data, Vec::new())
188}
189
190/// Where a dataset is loaded.
191#[derive(Debug, Clone)]
192pub(crate) struct Target {
193    /// The connected database.
194    pub db: Db,
195    /// Its alias.
196    pub alias: String,
197    /// Where its URL came from.
198    pub source: UrlSource,
199    /// Its URL (redacted before it is reported).
200    pub url: String,
201}
202
203/// Load `dataset` into `target`, in one transaction when the backend has
204/// transactions.
205///
206/// Returns:
207///   The report; a failing dataset is a `seed.E001` error diagnostic.
208pub(crate) async fn load(
209    datasets: &[Dataset],
210    dataset: &Dataset,
211    target: Target,
212) -> Envelope<SeedData> {
213    let support = target.db.capabilities().transactions;
214    let transaction = support != TransactionSupport::None;
215    let seeding = |db| Seeding {
216        db,
217        alias: target.alias.clone(),
218        dataset: dataset.name.clone(),
219    };
220    let run = &dataset.run;
221    let outcome = if transaction {
222        let body = |tx| run(seeding(tx));
223        target.db.transaction(body).await
224    } else {
225        run(seeding(target.db.clone())).await
226    };
227    let diags = outcome
228        .err()
229        .map(|err| failed(&dataset.name, &*err, transaction));
230    let mut report = list(datasets);
231    report.data.loaded = Some(Loaded {
232        dataset: dataset.name.clone(),
233        database: target.alias.clone(),
234        url_source: target.source,
235        url: redact_text(&target.url),
236        transaction,
237    });
238    let diags: Vec<Diagnostic> = diags.into_iter().collect();
239    Envelope::new("seed", report.data, diags)
240}
241
242fn failed(
243    name: &str,
244    err: &(dyn std::error::Error + Send + Sync),
245    transaction: bool,
246) -> Diagnostic {
247    let after = if transaction {
248        "nothing was written (the transaction was rolled back)"
249    } else {
250        "writes made before the failure remain (no transaction)"
251    };
252    let message = redact_text(&format!("dataset `{name}` failed: {err}"));
253    let diag = Diagnostic::new("seed.E001", Severity::Error, &message);
254    diag.with_hint(after)
255}
256
257/// Text form: what was loaded where, or the registered datasets.
258pub(crate) fn render(report: &Envelope<SeedData>) -> String {
259    use std::fmt::Write;
260    let mut out = String::new();
261    let data = &report.data;
262    if let Some(loaded) = &data.loaded {
263        let how = if loaded.transaction {
264            "in one transaction"
265        } else {
266            "without a transaction"
267        };
268        let verb = if report.ok { "loaded" } else { "failed" };
269        let _ = writeln!(
270            out,
271            "dataset `{}` {verb} into database `{}` ({}) {how}",
272            loaded.dataset, loaded.database, loaded.url
273        );
274    } else if data.datasets.is_empty() {
275        let _ = writeln!(out, "no datasets; register one with AppCli::seed");
276    }
277    if data.loaded.is_none() {
278        for info in &data.datasets {
279            let _ = writeln!(out, "{:<20} {}", info.name, info.summary);
280        }
281    }
282    for diag in &report.diagnostics {
283        let line = crate::report::text::diagnostic_line(diag);
284        let _ = writeln!(out, "{line}");
285        if let Some(hint) = diag.hint() {
286            let _ = writeln!(out, "  {hint}");
287        }
288    }
289    out
290}