1use crate::plan::PlanError;
4use chrono::{DateTime, SecondsFormat, Utc};
5use cufflink_types::manifest::validate::{
6 folded, folded_ident, is_identifier, is_lowercase_identifier,
7};
8
9pub 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
16pub fn history_ddl(table: &QualifiedName) -> String {
18 HISTORY_DDL.replace("%T", &table.quoted())
19}
20
21#[derive(Debug, Clone, PartialEq, Eq, Hash)]
24pub struct QualifiedName {
25 schema: String,
26 table: String,
27}
28
29impl QualifiedName {
30 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 pub fn quoted(&self) -> String {
63 format!(
64 "{}.{}",
65 folded_ident(&self.schema),
66 folded_ident(&self.table)
67 )
68 }
69
70 pub fn dotted(&self) -> String {
72 format!("{}.{}", self.schema, self.table)
73 }
74}
75
76pub fn history_version(version: u32) -> i32 {
79 i32::try_from(version).unwrap_or(i32::MAX)
80}
81
82#[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
91pub 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
119pub 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_note_kind"),
151 Some("2024-05-01T09:30:00.123456789Z"),
152 Some("13745322902634218637")
153 ),
154 Ok(Applied {
155 version: 6,
156 name: "add_note_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", "note_service").expect("name");
211 assert_eq!(
212 (own.schema(), own.table()),
213 ("note_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", "note_service").expect("name");
261 assert_eq!(
262 history_ddl(&table),
263 "CREATE TABLE IF NOT EXISTS \"note_service\".\"schema_history\"(
264 version INT4 PRIMARY KEY,
265 name VARCHAR(255),
266 applied_on VARCHAR(255),
267 checksum VARCHAR(255));"
268 );
269 }
270}