Skip to main content

cufflink_migrate/
history.rs

1//! The history table: its name, its DDL and its rows.
2
3use crate::plan::PlanError;
4use chrono::{DateTime, SecondsFormat, Utc};
5use cufflink_types::manifest::validate::{
6    folded, folded_ident, is_identifier, is_lowercase_identifier,
7};
8
9/// refinery's DDL; `%T` stands for the table.
10pub const HISTORY_DDL: &str = "CREATE TABLE IF NOT EXISTS %T(
11             version INT4 PRIMARY KEY,
12             name VARCHAR(255),
13             applied_on VARCHAR(255),
14             checksum VARCHAR(255));";
15
16/// The statement that creates `table` unless it exists.
17pub fn history_ddl(table: &QualifiedName) -> String {
18    HISTORY_DDL.replace("%T", &table.quoted())
19}
20
21/// A table and the schema it is in, both as Postgres stores them: what a
22/// statement quotes and what a session resolves unquoted are one table.
23#[derive(Debug, Clone, PartialEq, Eq, Hash)]
24pub struct QualifiedName {
25    schema: String,
26    table: String,
27}
28
29impl QualifiedName {
30    /// `table`, which is in `default_schema`, or `schema.table`. What is
31    /// declared is lowercase; the default schema, a service's own, is
32    /// folded as Postgres folded it when the schema was created.
33    pub fn parse(raw: &str, default_schema: &str) -> Result<Self, String> {
34        let declared = |schema: Option<&str>, table: &str| {
35            schema.is_none_or(is_lowercase_identifier) && is_lowercase_identifier(table)
36        };
37        let (schema, table) = match raw.split_once('.') {
38            Some((schema, table)) => (Some(schema), table),
39            None => (None, raw),
40        };
41        if declared(schema, table) && is_identifier(default_schema) {
42            Ok(Self {
43                schema: folded(schema.unwrap_or(default_schema)),
44                table: table.to_string(),
45            })
46        } else {
47            Err(format!(
48                "'{raw}' is not 'table' or 'schema.table' in lowercase"
49            ))
50        }
51    }
52
53    pub fn schema(&self) -> &str {
54        &self.schema
55    }
56
57    pub fn table(&self) -> &str {
58        &self.table
59    }
60
61    /// `"schema"."table"`.
62    pub fn quoted(&self) -> String {
63        format!(
64            "{}.{}",
65            folded_ident(&self.schema),
66            folded_ident(&self.table)
67        )
68    }
69
70    /// `schema.table`, the name lock keys are derived from.
71    pub fn dotted(&self) -> String {
72        format!("{}.{}", self.schema, self.table)
73    }
74}
75
76/// A migration's version as the history's `INT4` holds it. A version the
77/// column cannot hold is refused before a run is planned.
78pub fn history_version(version: u32) -> i32 {
79    i32::try_from(version).unwrap_or(i32::MAX)
80}
81
82/// A row of the history table.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct Applied {
85    pub version: i32,
86    pub name: String,
87    pub checksum: u64,
88    pub applied_on: String,
89}
90
91/// A history row read with every column but the version as nullable text.
92/// A row refinery could not have written is refused, where refinery's own
93/// driver panics.
94pub fn parse_applied(
95    version: i32,
96    name: Option<String>,
97    applied_on: Option<String>,
98    checksum: Option<String>,
99) -> Result<Applied, PlanError> {
100    let bad = |reason: String| PlanError::BadHistoryRow { version, reason };
101    let name = name.ok_or_else(|| bad("name is NULL".to_string()))?;
102    let applied_on = applied_on.ok_or_else(|| bad("applied_on is NULL".to_string()))?;
103    DateTime::parse_from_rfc3339(&applied_on)
104        .map_err(|_| bad(format!("applied_on '{applied_on}' is not an RFC 3339 time")))?;
105    let checksum = checksum
106        .ok_or_else(|| bad("checksum is NULL".to_string()))
107        .and_then(|text| {
108            text.parse::<u64>()
109                .map_err(|_| bad(format!("checksum '{text}' is not a number")))
110        })?;
111    Ok(Applied {
112        version,
113        name,
114        checksum,
115        applied_on,
116    })
117}
118
119/// The `applied_on` of a migration applied now: RFC 3339 in UTC, as
120/// refinery writes it.
121pub fn applied_on_now() -> String {
122    applied_on(Utc::now())
123}
124
125fn applied_on(at: DateTime<Utc>) -> String {
126    at.to_rfc3339_opts(SecondsFormat::Nanos, true)
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    fn row(
134        name: Option<&str>,
135        applied_on: Option<&str>,
136        checksum: Option<&str>,
137    ) -> Result<Applied, PlanError> {
138        parse_applied(
139            6,
140            name.map(str::to_string),
141            applied_on.map(str::to_string),
142            checksum.map(str::to_string),
143        )
144    }
145
146    #[test]
147    fn a_row_refinery_wrote_is_read() {
148        assert_eq!(
149            row(
150                Some("add_loan_kind"),
151                Some("2024-05-01T09:30:00.123456789Z"),
152                Some("13745322902634218637")
153            ),
154            Ok(Applied {
155                version: 6,
156                name: "add_loan_kind".into(),
157                checksum: 13_745_322_902_634_218_637,
158                applied_on: "2024-05-01T09:30:00.123456789Z".into(),
159            })
160        );
161        assert!(row(Some("n"), Some("2024-05-01T09:30:00+01:00"), Some("0")).is_ok());
162    }
163
164    #[test]
165    fn a_row_refinery_could_not_have_written_is_refused_with_its_reason() {
166        for (result, reason) in [
167            (
168                row(None, Some("2024-05-01T09:30:00Z"), Some("1")),
169                "name is NULL",
170            ),
171            (row(Some("n"), None, Some("1")), "applied_on is NULL"),
172            (
173                row(Some("n"), Some("yesterday"), Some("1")),
174                "applied_on 'yesterday' is not an RFC 3339 time",
175            ),
176            (
177                row(Some("n"), Some("2024-05-01T09:30:00Z"), None),
178                "checksum is NULL",
179            ),
180            (
181                row(Some("n"), Some("2024-05-01T09:30:00Z"), Some("-4")),
182                "checksum '-4' is not a number",
183            ),
184            (
185                row(Some("n"), Some("2024-05-01T09:30:00Z"), Some("abc")),
186                "checksum 'abc' is not a number",
187            ),
188        ] {
189            assert_eq!(
190                result,
191                Err(PlanError::BadHistoryRow {
192                    version: 6,
193                    reason: reason.to_string()
194                })
195            );
196        }
197    }
198
199    #[test]
200    fn applied_on_is_rfc_3339_in_utc_with_nanoseconds() {
201        let at = DateTime::parse_from_rfc3339("2026-09-28T10:00:00.5Z")
202            .expect("time")
203            .with_timezone(&Utc);
204        assert_eq!(applied_on(at), "2026-09-28T10:00:00.500000000Z");
205        assert!(row(Some("n"), Some(&applied_on_now()), Some("1")).is_ok());
206    }
207
208    #[test]
209    fn a_table_is_in_the_default_schema_unless_it_names_one() {
210        let own = QualifiedName::parse("schema_history", "loan_service").expect("name");
211        assert_eq!(
212            (own.schema(), own.table()),
213            ("loan_service", "schema_history")
214        );
215        let deferred =
216            QualifiedName::parse("public.schema_history_deferred", "report_service").expect("name");
217        assert_eq!(deferred.quoted(), "\"public\".\"schema_history_deferred\"");
218        assert_eq!(deferred.dotted(), "public.schema_history_deferred");
219        for raw in [
220            "a.b.c",
221            "",
222            "a.",
223            ".b",
224            "bad-name",
225            "a b",
226            "x\"y",
227            "Schema_History",
228            "Public.schema_history",
229        ] {
230            assert_eq!(
231                QualifiedName::parse(raw, "s"),
232                Err(format!(
233                    "'{raw}' is not 'table' or 'schema.table' in lowercase"
234                )),
235                "{raw}"
236            );
237        }
238        assert!(QualifiedName::parse("t", "bad-schema").is_err());
239    }
240
241    #[test]
242    fn a_default_schema_is_the_one_postgres_folded_it_to() {
243        let legacy = QualifiedName::parse("schema_history", "Order_Desk_2").expect("name");
244        assert_eq!(legacy.schema(), "order_desk_2");
245        assert_eq!(legacy.quoted(), "\"order_desk_2\".\"schema_history\"");
246        assert_eq!(legacy.dotted(), "order_desk_2.schema_history");
247        let keyword = QualifiedName::parse("schema_history", "order").expect("name");
248        assert_eq!(keyword.quoted(), "\"order\".\"schema_history\"");
249    }
250
251    #[test]
252    fn a_version_is_what_the_history_column_holds() {
253        assert_eq!(history_version(0), 0);
254        assert_eq!(history_version(2_147_483_647), i32::MAX);
255        assert_eq!(history_version(u32::MAX), i32::MAX);
256    }
257
258    #[test]
259    fn the_ddl_is_refinerys_over_the_quoted_table() {
260        let table = QualifiedName::parse("schema_history", "loan_service").expect("name");
261        assert_eq!(
262            history_ddl(&table),
263            "CREATE TABLE IF NOT EXISTS \"loan_service\".\"schema_history\"(
264             version INT4 PRIMARY KEY,
265             name VARCHAR(255),
266             applied_on VARCHAR(255),
267             checksum VARCHAR(255));"
268        );
269    }
270}