use crate::plan::PlanError;
use chrono::{DateTime, SecondsFormat, Utc};
use cufflink_types::manifest::validate::{
folded, folded_ident, is_identifier, is_lowercase_identifier,
};
pub const HISTORY_DDL: &str = "CREATE TABLE IF NOT EXISTS %T(
version INT4 PRIMARY KEY,
name VARCHAR(255),
applied_on VARCHAR(255),
checksum VARCHAR(255));";
pub fn history_ddl(table: &QualifiedName) -> String {
HISTORY_DDL.replace("%T", &table.quoted())
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct QualifiedName {
schema: String,
table: String,
}
impl QualifiedName {
pub fn parse(raw: &str, default_schema: &str) -> Result<Self, String> {
let declared = |schema: Option<&str>, table: &str| {
schema.is_none_or(is_lowercase_identifier) && is_lowercase_identifier(table)
};
let (schema, table) = match raw.split_once('.') {
Some((schema, table)) => (Some(schema), table),
None => (None, raw),
};
if declared(schema, table) && is_identifier(default_schema) {
Ok(Self {
schema: folded(schema.unwrap_or(default_schema)),
table: table.to_string(),
})
} else {
Err(format!(
"'{raw}' is not 'table' or 'schema.table' in lowercase"
))
}
}
pub fn schema(&self) -> &str {
&self.schema
}
pub fn table(&self) -> &str {
&self.table
}
pub fn quoted(&self) -> String {
format!(
"{}.{}",
folded_ident(&self.schema),
folded_ident(&self.table)
)
}
pub fn dotted(&self) -> String {
format!("{}.{}", self.schema, self.table)
}
}
pub fn history_version(version: u32) -> i32 {
i32::try_from(version).unwrap_or(i32::MAX)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Applied {
pub version: i32,
pub name: String,
pub checksum: u64,
pub applied_on: String,
}
pub fn parse_applied(
version: i32,
name: Option<String>,
applied_on: Option<String>,
checksum: Option<String>,
) -> Result<Applied, PlanError> {
let bad = |reason: String| PlanError::BadHistoryRow { version, reason };
let name = name.ok_or_else(|| bad("name is NULL".to_string()))?;
let applied_on = applied_on.ok_or_else(|| bad("applied_on is NULL".to_string()))?;
DateTime::parse_from_rfc3339(&applied_on)
.map_err(|_| bad(format!("applied_on '{applied_on}' is not an RFC 3339 time")))?;
let checksum = checksum
.ok_or_else(|| bad("checksum is NULL".to_string()))
.and_then(|text| {
text.parse::<u64>()
.map_err(|_| bad(format!("checksum '{text}' is not a number")))
})?;
Ok(Applied {
version,
name,
checksum,
applied_on,
})
}
pub fn applied_on_now() -> String {
applied_on(Utc::now())
}
fn applied_on(at: DateTime<Utc>) -> String {
at.to_rfc3339_opts(SecondsFormat::Nanos, true)
}
#[cfg(test)]
mod tests {
use super::*;
fn row(
name: Option<&str>,
applied_on: Option<&str>,
checksum: Option<&str>,
) -> Result<Applied, PlanError> {
parse_applied(
6,
name.map(str::to_string),
applied_on.map(str::to_string),
checksum.map(str::to_string),
)
}
#[test]
fn a_row_refinery_wrote_is_read() {
assert_eq!(
row(
Some("add_note_kind"),
Some("2024-05-01T09:30:00.123456789Z"),
Some("13745322902634218637")
),
Ok(Applied {
version: 6,
name: "add_note_kind".into(),
checksum: 13_745_322_902_634_218_637,
applied_on: "2024-05-01T09:30:00.123456789Z".into(),
})
);
assert!(row(Some("n"), Some("2024-05-01T09:30:00+01:00"), Some("0")).is_ok());
}
#[test]
fn a_row_refinery_could_not_have_written_is_refused_with_its_reason() {
for (result, reason) in [
(
row(None, Some("2024-05-01T09:30:00Z"), Some("1")),
"name is NULL",
),
(row(Some("n"), None, Some("1")), "applied_on is NULL"),
(
row(Some("n"), Some("yesterday"), Some("1")),
"applied_on 'yesterday' is not an RFC 3339 time",
),
(
row(Some("n"), Some("2024-05-01T09:30:00Z"), None),
"checksum is NULL",
),
(
row(Some("n"), Some("2024-05-01T09:30:00Z"), Some("-4")),
"checksum '-4' is not a number",
),
(
row(Some("n"), Some("2024-05-01T09:30:00Z"), Some("abc")),
"checksum 'abc' is not a number",
),
] {
assert_eq!(
result,
Err(PlanError::BadHistoryRow {
version: 6,
reason: reason.to_string()
})
);
}
}
#[test]
fn applied_on_is_rfc_3339_in_utc_with_nanoseconds() {
let at = DateTime::parse_from_rfc3339("2026-09-28T10:00:00.5Z")
.expect("time")
.with_timezone(&Utc);
assert_eq!(applied_on(at), "2026-09-28T10:00:00.500000000Z");
assert!(row(Some("n"), Some(&applied_on_now()), Some("1")).is_ok());
}
#[test]
fn a_table_is_in_the_default_schema_unless_it_names_one() {
let own = QualifiedName::parse("schema_history", "note_service").expect("name");
assert_eq!(
(own.schema(), own.table()),
("note_service", "schema_history")
);
let deferred =
QualifiedName::parse("public.schema_history_deferred", "report_service").expect("name");
assert_eq!(deferred.quoted(), "\"public\".\"schema_history_deferred\"");
assert_eq!(deferred.dotted(), "public.schema_history_deferred");
for raw in [
"a.b.c",
"",
"a.",
".b",
"bad-name",
"a b",
"x\"y",
"Schema_History",
"Public.schema_history",
] {
assert_eq!(
QualifiedName::parse(raw, "s"),
Err(format!(
"'{raw}' is not 'table' or 'schema.table' in lowercase"
)),
"{raw}"
);
}
assert!(QualifiedName::parse("t", "bad-schema").is_err());
}
#[test]
fn a_default_schema_is_the_one_postgres_folded_it_to() {
let legacy = QualifiedName::parse("schema_history", "Order_Desk_2").expect("name");
assert_eq!(legacy.schema(), "order_desk_2");
assert_eq!(legacy.quoted(), "\"order_desk_2\".\"schema_history\"");
assert_eq!(legacy.dotted(), "order_desk_2.schema_history");
let keyword = QualifiedName::parse("schema_history", "order").expect("name");
assert_eq!(keyword.quoted(), "\"order\".\"schema_history\"");
}
#[test]
fn a_version_is_what_the_history_column_holds() {
assert_eq!(history_version(0), 0);
assert_eq!(history_version(2_147_483_647), i32::MAX);
assert_eq!(history_version(u32::MAX), i32::MAX);
}
#[test]
fn the_ddl_is_refinerys_over_the_quoted_table() {
let table = QualifiedName::parse("schema_history", "note_service").expect("name");
assert_eq!(
history_ddl(&table),
"CREATE TABLE IF NOT EXISTS \"note_service\".\"schema_history\"(
version INT4 PRIMARY KEY,
name VARCHAR(255),
applied_on VARCHAR(255),
checksum VARCHAR(255));"
);
}
}