1use crate::history::QualifiedName;
7use crate::lock::{legacy_table_lock_key, platform_lock_key};
8use cufflink_types::{MigrationLock, SqlMigration};
9use std::time::Duration;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub struct Timeouts {
14 pub lock_wait: Duration,
15 pub statement: Duration,
16 pub lock: Duration,
17}
18
19impl Default for Timeouts {
20 fn default() -> Self {
21 Self {
22 lock_wait: Duration::from_secs(600),
23 statement: Duration::from_secs(600),
24 lock: Duration::from_secs(10),
25 }
26 }
27}
28
29pub const TRY_LOCK: &str = "SELECT pg_try_advisory_lock($1)";
31pub const UNLOCK: &str = "SELECT pg_advisory_unlock($1)";
32
33pub fn lock_key(lock: MigrationLock, history: &QualifiedName) -> i64 {
35 let table = history.dotted();
36 match lock {
37 MigrationLock::Platform => platform_lock_key(&table),
38 MigrationLock::LegacyTableHash => legacy_table_lock_key(&table),
39 }
40}
41
42pub fn applied_rows(history: &QualifiedName) -> String {
45 format!(
46 "SELECT version::int4, name::text, applied_on::text, checksum::text \
47 FROM {} ORDER BY version",
48 history.quoted()
49 )
50}
51
52pub fn record_statement(history: &QualifiedName) -> String {
55 format!(
56 "INSERT INTO {} (version, name, applied_on, checksum) VALUES ($1, $2, $3, $4)",
57 history.quoted()
58 )
59}
60
61pub const STANDING: &str = "SELECT txid_current()::text, current_user::text";
64
65#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct Standing {
68 pub transaction: String,
69 pub role: String,
70}
71
72impl Standing {
73 pub fn escape(&self, after: &Self, role_imposed: bool) -> Option<Escape> {
76 if self.transaction != after.transaction {
77 Some(Escape::Transaction)
78 } else if role_imposed && self.role != after.role {
79 Some(Escape::Role)
80 } else {
81 None
82 }
83 }
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum Escape {
89 Transaction,
90 Role,
91}
92
93impl std::fmt::Display for Escape {
94 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95 f.write_str(match self {
96 Escape::Transaction => "ended the transaction it ran in",
97 Escape::Role => "changed the role it ran as",
98 })
99 }
100}
101
102pub fn escaped(migration: &SqlMigration, escape: Escape) -> String {
104 format!(
105 "migration V{}__{} {escape}; it is not recorded, and what it committed itself stays. \
106 Transaction and role control belong to the runner: a migration that must run \
107 outside a transaction begins with '{}'",
108 migration.version,
109 migration.name,
110 SqlMigration::NO_TRANSACTION_MARKER
111 )
112}
113
114pub const INVALID_INDEXES: &str = "SELECT c.relname::text FROM pg_index i \
116 JOIN pg_class c ON c.oid = i.indexrelid \
117 JOIN pg_namespace n ON n.oid = c.relnamespace \
118 WHERE n.nspname = $1 AND NOT i.indisvalid ORDER BY 1";
119
120pub fn interrupted_builds<'a>(invalid: &'a [String], sql: &str) -> Vec<&'a str> {
123 let named = |index: &str| {
124 sql.match_indices(index).any(|(at, _)| {
125 let is_word = |c: char| c.is_ascii_alphanumeric() || c == '_';
126 let before = sql[..at].chars().next_back();
127 let after = sql[at + index.len()..].chars().next();
128 !before.is_some_and(is_word) && !after.is_some_and(is_word)
129 })
130 };
131 invalid
132 .iter()
133 .map(String::as_str)
134 .filter(|index| !index.is_empty() && named(index))
135 .collect()
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141
142 fn history() -> QualifiedName {
143 QualifiedName::parse("schema_history", "loan_service").expect("a history")
144 }
145
146 #[test]
147 fn the_history_is_read_and_written_by_its_quoted_name() {
148 assert_eq!(
149 applied_rows(&history()),
150 "SELECT version::int4, name::text, applied_on::text, checksum::text \
151 FROM \"loan_service\".\"schema_history\" ORDER BY version"
152 );
153 assert_eq!(
154 record_statement(&history()),
155 "INSERT INTO \"loan_service\".\"schema_history\" (version, name, applied_on, \
156 checksum) VALUES ($1, $2, $3, $4)"
157 );
158 }
159
160 #[test]
161 fn the_lock_is_the_one_the_history_declares() {
162 let history = history();
163 assert_eq!(
164 lock_key(MigrationLock::LegacyTableHash, &history),
165 legacy_table_lock_key("loan_service.schema_history")
166 );
167 assert_eq!(
168 lock_key(MigrationLock::Platform, &history),
169 platform_lock_key("loan_service.schema_history")
170 );
171 }
172
173 #[test]
174 fn timeouts_default_to_the_documented_values() {
175 assert_eq!(
176 Timeouts::default(),
177 Timeouts {
178 lock_wait: Duration::from_secs(600),
179 statement: Duration::from_secs(600),
180 lock: Duration::from_secs(10),
181 }
182 );
183 }
184
185 #[test]
186 fn a_migration_may_not_take_the_transaction_or_an_imposed_role() {
187 let standing = |transaction: &str, role: &str| Standing {
188 transaction: transaction.into(),
189 role: role.into(),
190 };
191 let before = standing("811", "loan_service_user");
192 assert_eq!(before.escape(&before.clone(), true), None);
193 assert_eq!(
194 before.escape(&standing("812", "loan_service_user"), false),
195 Some(Escape::Transaction)
196 );
197 assert_eq!(
198 before.escape(&standing("811", "platform"), true),
199 Some(Escape::Role)
200 );
201 assert_eq!(
202 before.escape(&standing("811", "platform"), false),
203 None,
204 "with no role imposed there is none to escape"
205 );
206 let migration = SqlMigration {
207 version: 8,
208 name: "commits".into(),
209 sql: String::new(),
210 no_transaction: false,
211 };
212 assert!(escaped(&migration, Escape::Transaction).starts_with(
213 "migration V8__commits ended the transaction it ran in; it is not recorded"
214 ));
215 assert!(escaped(&migration, Escape::Role)
216 .starts_with("migration V8__commits changed the role it ran as;"));
217 }
218
219 #[test]
220 fn an_invalid_index_is_recovered_only_when_the_migration_names_it() {
221 let invalid = vec![
222 "notes_kind".to_string(),
223 "notes_kind_created".to_string(),
224 "other".to_string(),
225 ];
226 assert_eq!(
227 interrupted_builds(
228 &invalid,
229 "CREATE INDEX CONCURRENTLY notes_kind ON notes (kind);"
230 ),
231 ["notes_kind"]
232 );
233 assert_eq!(
234 interrupted_builds(
235 &invalid,
236 "CREATE INDEX CONCURRENTLY \"notes_kind_created\" ON notes (kind, created_at);"
237 ),
238 ["notes_kind_created"]
239 );
240 assert!(interrupted_builds(&invalid, "CREATE INDEX another ON notes (id);").is_empty());
241 assert!(interrupted_builds(&[String::new()], "SELECT 1;").is_empty());
242 }
243}