use crate::core::{FieldType, ModelEntry, SqlValue};
use crate::sql::{Pool, SQLITE_CANONICAL_GLOB, SQLITE_DATETIME_FORMAT};
use super::MigrateError;
const FRAMEWORK_COLUMNS: &[(&str, &str)] = &[
("rustango_translations", "created_at"),
("rustango_translations", "updated_at"),
("rustango_jobs", "run_at"),
("rustango_jobs", "locked_at"),
("rustango_jobs", "created_at"),
];
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Normalised {
pub rows: u64,
pub columns: Vec<String>,
pub missing: usize,
}
impl Normalised {
#[must_use]
pub fn is_clean(&self) -> bool {
self.rows == 0
}
}
pub async fn normalise_sqlite_datetimes(pool: &Pool) -> Result<Normalised, MigrateError> {
let mut out = Normalised::default();
if pool.dialect().name() != "sqlite" {
return Ok(out);
}
let d = pool.dialect();
for (table, column) in targets() {
let t = d.quote_ident(table.as_str());
let c = d.quote_ident(column.as_str());
let sql = format!(
"UPDATE {t} SET {c} = strftime(?, {c}) \
WHERE strftime(?, {c}) IS NOT NULL \
AND {c} NOT GLOB ?"
);
let binds = vec![
SqlValue::String(SQLITE_DATETIME_FORMAT.to_owned()),
SqlValue::String(SQLITE_DATETIME_FORMAT.to_owned()),
SqlValue::String(SQLITE_CANONICAL_GLOB.to_owned()),
];
match crate::sql::raw_execute_pool(pool, &sql, binds).await {
Ok(n) if n > 0 => {
out.rows += n;
out.columns.push(format!("{table}.{column}"));
}
Ok(_) => {}
Err(e) if is_missing_target(&e) => out.missing += 1,
Err(crate::sql::ExecError::Driver(e)) => return Err(MigrateError::Driver(e)),
Err(e) => return Err(MigrateError::Validation(e.to_string())),
}
}
if out.rows > 0 {
tracing::info!(
rows = out.rows,
columns = ?out.columns,
"normalised SQLite datetime columns onto the RFC3339 shape (#1464); \
values written by the old CURRENT_TIMESTAMP default did not compare \
or sort against timestamps bound from Rust"
);
}
Ok(out)
}
fn targets() -> Vec<(String, String)> {
let mut seen: Vec<(String, String)> = FRAMEWORK_COLUMNS
.iter()
.map(|(t, c)| ((*t).to_owned(), (*c).to_owned()))
.collect();
for t in [
super::runner::LEDGER_TABLE,
super::runner::SYSTEM_LEDGER_TABLE,
] {
seen.push((t.to_owned(), "applied_at".to_owned()));
}
for entry in inventory::iter::<ModelEntry> {
for field in entry.schema.fields {
if field.ty != FieldType::DateTime {
continue;
}
let pair = (entry.schema.table.to_owned(), field.column.to_owned());
if !seen.contains(&pair) {
seen.push(pair);
}
}
}
seen
}
fn is_missing_target(e: &crate::sql::ExecError) -> bool {
let msg = e.to_string();
msg.contains("no such table") || msg.contains("no such column")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_framework_list_holds_no_duplicates() {
let mut seen = Vec::new();
for pair in FRAMEWORK_COLUMNS {
assert!(
!seen.contains(pair),
"{}.{} is listed twice in FRAMEWORK_COLUMNS",
pair.0,
pair.1
);
seen.push(*pair);
}
}
#[test]
fn targets_are_unique() {
let t = targets();
let mut seen: Vec<&(String, String)> = Vec::new();
for pair in &t {
assert!(
!seen.contains(&pair),
"{}.{} appears twice in targets()",
pair.0,
pair.1
);
seen.push(pair);
}
}
#[test]
fn the_canonical_format_has_the_shape_the_sweep_assumes() {
let f = SQLITE_DATETIME_FORMAT;
let rendered_len = f.len() - "%Y".len() + 4 - 4 * ("%m".len() - 2) - "%f".len() + 6;
assert_eq!(
rendered_len, 32,
"the canonical format renders {rendered_len} chars, not 32; the \
sweep's GLOB shape test and every width assumption downstream \
are keyed on 32. Format: {f}"
);
assert!(
f.contains("T%H"),
"the separator must be `T`: a space is the legacy shape that \
sorts below it and started #1464. Format: {f}"
);
assert!(
f.ends_with("+00:00"),
"the offset must be explicit and fixed-width, or two encodings \
of one instant differ. Format: {f}"
);
}
}