use std::collections::{HashMap, HashSet};
use std::sync::{LazyLock, Mutex};
use crate::form::FormHandle;
use crate::persist::SaveError;
use futures::TryStreamExt;
use laterite_core::query::{bind_values, build, text_cast};
use laterite_core::{t, Actor, AnyRowExt, Db, Text};
use laterite_media::ByteStream;
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 {
Running,
Done,
Failed,
}
impl Status {
pub(crate) fn as_str(self) -> &'static str {
match self {
Status::Running => "running",
Status::Done => "done",
Status::Failed => "failed",
}
}
pub(crate) fn parse(s: &str) -> Option<Self> {
match s {
"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;
static LIVE: LazyLock<Mutex<HashSet<i64>>> = LazyLock::new(|| Mutex::new(HashSet::new()));
const STALE_AFTER_SECONDS: i64 = 5;
fn live(id: i64, is: bool) {
let mut set = LIVE.lock().unwrap_or_else(|e| e.into_inner());
if is {
set.insert(id);
} else {
set.remove(&id);
}
}
fn is_live(id: i64) -> bool {
LIVE.lock().unwrap_or_else(|e| e.into_inner()).contains(&id)
}
pub async fn start(
db: &Db,
form: FormHandle,
actor: Actor,
spec: RunSpec,
source: ByteStream,
) -> Result<i64, RunError> {
check(&spec, &form)?;
let id = store::insert(db, &spec, &actor).await?;
live(id, true);
let db = db.clone();
tokio::spawn(async move {
if let Err(e) = run(&db, id, &form, &actor, &spec, source).await {
tracing::error!(run = id, error = %e, "import run stopped");
if let Err(e) = store::fail(&db, id, &e.to_string()).await {
tracing::error!(run = id, error = %e, "import run could not be marked failed");
}
}
live(id, false);
});
Ok(id)
}
pub async fn find(db: &Db, id: i64) -> Result<Option<Run>, RunError> {
let Some(run) = store::find(db, id).await? else {
return Ok(None);
};
if run.status == Status::Running && !is_live(run.id) && is_stale(&run.started_at) {
store::fail(db, run.id, "The server stopped while the run was going.").await?;
return Ok(store::find(db, run.id).await?);
}
Ok(Some(run))
}
fn is_stale(started_at: &str) -> bool {
chrono::DateTime::parse_from_rfc3339(started_at)
.map(|at| {
(chrono::Utc::now() - at.with_timezone(&chrono::Utc)).num_seconds()
> STALE_AFTER_SECONDS
})
.unwrap_or(true)
}
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(())
}
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()
}