use std::collections::HashMap;
use std::sync::Arc;
use crate::form::{FormHandle, Forms};
use crate::persist::SaveError;
use futures::TryStreamExt;
use laterite_core::query::{bind_values, build, text_cast};
use laterite_core::queue::{Job, JobCx, JobError, JobReg, Pushed, Queue};
use laterite_core::strata::async_trait;
use laterite_core::{t, Actor, AnyRowExt, Db, Text};
use laterite_media::{blob_path, ByteStream, StorageDriver};
use sea_query::{Alias, Expr, Query};
use serde::{Deserialize, Serialize};
use crate::import::store;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct RunSpec {
pub resource: String,
pub media_id: Option<i64>,
pub matches: Vec<Match>,
pub duplicates: Option<Duplicates>,
pub first_row_titles: bool,
pub delimiter: u8,
#[serde(default)]
pub dry_run: bool,
#[serde(default = "yes")]
pub keep_blank: bool,
#[serde(default)]
pub titles: Vec<String>,
}
fn yes() -> bool {
true
}
impl RunSpec {
pub fn new(resource: impl Into<String>, matches: Vec<Match>) -> Self {
Self {
resource: resource.into(),
media_id: None,
matches,
duplicates: None,
first_row_titles: true,
delimiter: b',',
dry_run: false,
keep_blank: true,
titles: Vec::new(),
}
}
pub fn dry_run(mut self, dry: bool) -> Self {
self.dry_run = dry;
self
}
pub fn keep_blank(mut self, keep: bool) -> Self {
self.keep_blank = keep;
self
}
pub fn titles(mut self, titles: Vec<String>) -> Self {
self.titles = titles;
self
}
pub fn media(mut self, id: i64) -> Self {
self.media_id = Some(id);
self
}
pub fn duplicates(mut self, duplicates: Duplicates) -> Self {
self.duplicates = Some(duplicates);
self
}
pub fn first_row_titles(mut self, titles: bool) -> Self {
self.first_row_titles = titles;
self
}
pub fn delimiter(mut self, delimiter: u8) -> Self {
self.delimiter = delimiter;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Match {
pub column: usize,
pub field: String,
}
impl Match {
pub fn new(column: usize, field: impl Into<String>) -> Self {
Self {
column,
field: field.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Duplicates {
pub key: String,
pub on_match: OnMatch,
}
impl Duplicates {
pub fn skip(key: impl Into<String>) -> Self {
Self {
key: key.into(),
on_match: OnMatch::Skip,
}
}
pub fn update(key: impl Into<String>) -> Self {
Self {
key: key.into(),
on_match: OnMatch::Update,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OnMatch {
Skip,
Update,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Status {
Queued,
Running,
Done,
Failed,
}
impl Status {
pub(crate) fn as_str(self) -> &'static str {
match self {
Status::Queued => "queued",
Status::Running => "running",
Status::Done => "done",
Status::Failed => "failed",
}
}
pub(crate) fn parse(s: &str) -> Option<Self> {
match s {
"queued" => Some(Status::Queued),
"running" => Some(Status::Running),
"done" => Some(Status::Done),
"failed" => Some(Status::Failed),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Note {
pub row: u64,
pub kind: NoteKind,
pub field: Option<String>,
pub message: Text,
}
impl Note {
fn new(kind: NoteKind, row: u64, field: Option<String>, message: Text) -> Self {
Self {
row,
kind,
field,
message,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum NoteKind {
Error,
Skipped,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Run {
pub id: i64,
pub resource: String,
pub media_id: Option<i64>,
pub spec: RunSpec,
pub actor_label: String,
pub status: Status,
pub processed: i64,
pub created: i64,
pub updated: i64,
pub skipped: i64,
pub errors: i64,
pub notes: Vec<Note>,
pub failure: Option<String>,
pub started_at: String,
pub finished_at: Option<String>,
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum RunError {
#[error("database error: {0}")]
Database(#[from] sqlx::Error),
#[error("the run cannot start: {0}")]
Spec(String),
#[error("the file could not be read: {0}")]
Read(#[from] std::io::Error),
}
#[derive(Debug, Default, Clone, Copy)]
pub(crate) struct Counts {
pub processed: i64,
pub created: i64,
pub updated: i64,
pub skipped: i64,
pub errors: i64,
}
const PROGRESS_EVERY: i64 = 25;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImportJob {
pub run_id: i64,
pub resource: String,
pub spec: RunSpec,
pub actor: Actor,
pub hash: String,
}
#[async_trait]
impl Job for ImportJob {
const NAME: &'static str = "laterite.import::run";
async fn run(&self, _cx: &JobCx) -> Result<(), JobError> {
Err(JobError::fail(
"the import job runs through the admin's handler",
))
}
fn tries(&self) -> u32 {
1
}
fn timeout(&self) -> Option<std::time::Duration> {
None
}
fn unique_key(&self) -> Option<String> {
Some(format!("import:{}", self.run_id))
}
}
pub(crate) fn job_reg(forms: Forms, disk: Arc<dyn StorageDriver>) -> JobReg {
JobReg::with_handler::<ImportJob, _, _>(move |job: ImportJob, cx: JobCx| {
let forms = Arc::clone(&forms);
let disk = Arc::clone(&disk);
async move {
let db = cx.db().clone();
let outcome = async {
let prepared = forms.get(&job.resource).ok_or_else(|| {
RunError::Spec(format!("no resource imports at `{}`", job.resource))
})?;
let form = FormHandle::new(db.clone(), cx.cache().clone(), prepared.clone());
store::started(&db, job.run_id).await?;
let source = disk
.get(&blob_path(&job.hash))
.await
.map_err(|e| RunError::Read(std::io::Error::other(e.to_string())))?;
run(&db, job.run_id, &form, &job.actor, &job.spec, source).await
}
.await;
if let Err(e) = outcome {
tracing::error!(run = job.run_id, error = %e, "import run stopped");
if let Err(e2) = store::fail(&db, job.run_id, &e.to_string()).await {
tracing::error!(run = job.run_id, error = %e2, "import run could not be marked failed");
}
return Err(JobError::fail(e.to_string()));
}
Ok(())
}
})
}
pub async fn start(
db: &Db,
queue: &Queue,
resource: &str,
form: &FormHandle,
actor: Actor,
spec: RunSpec,
hash: &str,
) -> Result<i64, RunError> {
check(&spec, form)?;
let id = store::insert(db, &spec, &actor).await?;
let job = ImportJob {
run_id: id,
resource: resource.to_string(),
spec,
actor,
hash: hash.to_string(),
};
match queue.push(&job).await {
Ok(Pushed::Queued(_)) | Ok(Pushed::Ran) | Ok(Pushed::Duplicate) => Ok(id),
Err(e) => {
if let Ok(Some(run)) = store::find(db, id).await {
if run.status == Status::Queued {
let _ = store::fail(db, id, &e.to_string()).await;
}
}
Ok(id)
}
}
}
pub async fn start_inline(
db: &Db,
form: FormHandle,
actor: Actor,
spec: RunSpec,
source: ByteStream,
) -> Result<i64, RunError> {
check(&spec, &form)?;
let id = store::insert(db, &spec, &actor).await?;
store::started(db, id).await?;
if let Err(e) = run(db, id, &form, &actor, &spec, source).await {
tracing::error!(run = id, error = %e, "import run stopped");
store::fail(db, id, &e.to_string()).await?;
}
Ok(id)
}
fn check(spec: &RunSpec, form: &FormHandle) -> Result<(), RunError> {
if spec.matches.is_empty() {
return Err(RunError::Spec(
"no column is matched to a field".to_string(),
));
}
let known = |name: &str| form.fields().iter().any(|f| f.name == name);
for m in &spec.matches {
if !known(&m.field) {
return Err(RunError::Spec(format!(
"column {} is matched to `{}`, which is not a field of this form",
m.column, m.field
)));
}
}
if let Some(duplicates) = &spec.duplicates {
if !spec.matches.iter().any(|m| m.field == duplicates.key) {
return Err(RunError::Spec(format!(
"duplicates are keyed on `{}`, which no column feeds",
duplicates.key
)));
}
}
Ok(())
}
pub async fn find(db: &Db, id: i64) -> Result<Option<Run>, RunError> {
Ok(store::find(db, id).await?)
}
async fn run(
db: &Db,
id: i64,
form: &FormHandle,
actor: &Actor,
spec: &RunSpec,
source: ByteStream,
) -> Result<(), RunError> {
let bytes: Vec<u8> = source
.try_fold(Vec::new(), |mut all, chunk| async move {
all.extend_from_slice(&chunk);
Ok(all)
})
.await?;
let mut reader = csv::ReaderBuilder::new()
.delimiter(spec.delimiter)
.has_headers(spec.first_row_titles)
.flexible(true)
.from_reader(bytes.as_slice());
let mut counts = Counts::default();
let mut notes = Vec::new();
for record in reader.records() {
let record = match record {
Ok(record) => record,
Err(e) => {
let row = e.position().map(|p| p.line()).unwrap_or(0);
counts.processed += 1;
counts.errors += 1;
notes.push(Note::new(
NoteKind::Error,
row,
None,
t!(
"The row could not be read: {reason}",
reason = e.to_string()
),
));
continue;
}
};
if record.iter().all(|v| v.trim().is_empty()) {
continue;
}
let row = record.position().map(|p| p.line()).unwrap_or(0);
let mut data = HashMap::new();
for m in &spec.matches {
data.insert(
m.field.clone(),
record.get(m.column).unwrap_or_default().to_string(),
);
}
counts.processed += 1;
write_row(db, form, actor, spec, row, data, &mut counts, &mut notes).await;
if counts.processed % PROGRESS_EVERY == 0 {
store::progress(db, id, &counts).await?;
}
}
store::finish(db, id, &counts, ¬es).await?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn write_row(
db: &Db,
form: &FormHandle,
actor: &Actor,
spec: &RunSpec,
row: u64,
data: HashMap<String, String>,
counts: &mut Counts,
notes: &mut Vec<Note>,
) {
if let Some(duplicates) = &spec.duplicates {
let key = data.get(&duplicates.key).cloned().unwrap_or_default();
if !key.is_empty() {
let found = match existing(db, form, &duplicates.key, &key).await {
Ok(found) => found,
Err(e) => {
tracing::error!(row, error = %e, "import duplicate lookup failed");
counts.errors += 1;
notes.push(Note::new(
NoteKind::Error,
row,
Some(duplicates.key.clone()),
t!("The existing record could not be looked up."),
));
return;
}
};
if let Some(id) = found {
match duplicates.on_match {
OnMatch::Skip => {
counts.skipped += 1;
notes.push(Note::new(
NoteKind::Skipped,
row,
Some(duplicates.key.clone()),
t!(
"A record with this {field} is already there.",
field = duplicates.key.clone()
),
));
}
OnMatch::Update => {
let current = match form.load(id).await {
Ok(current) => current.unwrap_or_default(),
Err(e) => {
refused(row, e, counts, notes);
return;
}
};
let mut merged = current;
for (field, value) in data {
if spec.keep_blank && value.trim().is_empty() {
continue;
}
merged.insert(field, value);
}
let outcome = if spec.dry_run {
form.check(Some(id), merged).await
} else {
form.update(id, merged, actor).await
};
match outcome {
Ok(()) => counts.updated += 1,
Err(e) => refused(row, e, counts, notes),
}
}
}
return;
}
}
}
let outcome = if spec.dry_run {
form.check(None, data).await
} else {
form.create(data, actor).await.map(|_| ())
};
match outcome {
Ok(()) => counts.created += 1,
Err(e) => refused(row, e, counts, notes),
}
}
fn refused(row: u64, error: SaveError, counts: &mut Counts, notes: &mut Vec<Note>) {
counts.errors += 1;
match error {
SaveError::Invalid(bag) => {
for (field, messages) in bag.iter() {
for message in messages {
notes.push(Note::new(
NoteKind::Error,
row,
Some(field.to_string()),
message.clone(),
));
}
}
}
SaveError::Failed(reason) => {
tracing::warn!(row, error = %reason, "import row could not be saved");
notes.push(Note::new(
NoteKind::Error,
row,
None,
t!("Could not save: {reason}", reason = reason),
));
}
}
}
async fn existing(
db: &Db,
form: &FormHandle,
key: &str,
value: &str,
) -> Result<Option<i64>, sqlx::Error> {
let (sql, values) = {
let cast = text_cast(db.backend);
build(
db.backend,
Query::select()
.column(Alias::new(form.id_field()))
.from(Alias::new(form.entity()))
.and_where(
Expr::col(Alias::new(key))
.cast_as(Alias::new(cast))
.eq(value),
)
.limit(1)
.to_owned(),
)
};
let row = bind_values(sqlx::query(&sql), values)
.fetch_optional(&db.pool)
.await?;
row.map(|r| r.get_int(form.id_field())).transpose()
}