1use 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#[derive(Debug, Clone)]
57pub struct FileReport {
58 pub resource: String,
59 pub inserted: u64,
61 pub skipped: u64,
63}
64
65#[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 pub fn is_empty(&self) -> bool {
82 self.files.is_empty()
83 }
84}
85
86pub async fn seed(conn: &impl ConnectionTrait, app: &App) -> Result<Report, Error> {
92 seed_dir(conn, app, &app.root.join("seed")).await
93}
94
95pub 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 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 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
154type Row = Vec<(String, Raw)>;
157
158#[derive(Debug, Clone)]
160enum Raw {
161 Text(String),
163 Typed(Json),
165}
166
167async 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 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
273fn 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
282fn to_sql(ty: FieldType, case: Option<TextCase>, raw: &Raw) -> Result<Option<SqlValue>, String> {
285 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 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 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
317fn 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
355fn 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 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
373fn 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 .filter(|(_, cell)| !cell.text.is_empty() || cell.quoted)
406 .map(|(column, cell)| (column, Raw::Text(cell.text)))
407 .collect())
408 })
409 .collect()
410}
411
412pub 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 *byte = u8::from_str_radix(&digest[i * 2..i * 2 + 2], 16).unwrap_or(0);
427 }
428 bytes[6] = (bytes[6] & 0x0f) | 0x80;
432 bytes[8] = (bytes[8] & 0x3f) | 0x80;
433 Uuid::from_bytes(bytes)
434}
435
436#[derive(Debug, Clone, PartialEq, Eq)]
438struct Cell {
439 text: String,
440 quoted: bool,
441}
442
443fn 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 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 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 let derived = uuid_for("acme");
627 assert_eq!(derived.get_version_num(), 8);
628 assert_eq!(derived.as_bytes()[8] & 0xc0, 0x80);
629 }
630}