| 1 | //! Database connection and schema setup via Toasty. |
| 2 | |
| 3 | use std::path::Path; |
| 4 | |
| 5 | use crate::{ |
| 6 | error::Result, |
| 7 | models::{ |
| 8 | Attachment, |
| 9 | CiArtifact, |
| 10 | CiRun, |
| 11 | Issue, |
| 12 | IssueComment, |
| 13 | Repository, |
| 14 | Session, |
| 15 | SshKey, |
| 16 | User, |
| 17 | }, |
| 18 | }; |
| 19 | |
| 20 | /// Open (creating if necessary) the SQLite database at `path`, creating the |
| 21 | /// schema on first run. |
| 22 | /// |
| 23 | /// Note: Toasty 0.7's `push_schema` emits plain `CREATE TABLE` (no |
| 24 | /// `IF NOT EXISTS`) for model tables, so it is *not* idempotent — calling it on |
| 25 | /// an existing database errors. We therefore only push the schema when the |
| 26 | /// database file does not yet exist. Skipping it on an existing database issues |
| 27 | /// no DDL, so data is never touched. Schema evolution will move to Toasty's |
| 28 | /// migration system when models start changing. |
| 29 | pub async fn connect(path: impl AsRef<Path>) -> Result<toasty::Db> { |
| 30 | let path = path.as_ref(); |
| 31 | let fresh = !path.exists(); |
| 32 | let url = format!("sqlite:{}", path.display()); |
| 33 | |
| 34 | let db = toasty::Db::builder() |
| 35 | .models(toasty::models!( |
| 36 | User, |
| 37 | Repository, |
| 38 | SshKey, |
| 39 | Session, |
| 40 | CiRun, |
| 41 | CiArtifact, |
| 42 | Issue, |
| 43 | IssueComment, |
| 44 | Attachment |
| 45 | )) |
| 46 | .connect(&url) |
| 47 | .await?; |
| 48 | |
| 49 | if fresh { |
| 50 | db.push_schema().await?; |
| 51 | } else { |
| 52 | migrate_existing(path)?; |
| 53 | } |
| 54 | Ok(db) |
| 55 | } |
| 56 | |
| 57 | /// Tables added after a deployment's database was first created, as idempotent |
| 58 | /// DDL applied to existing databases (Toasty's `push_schema` only runs on a |
| 59 | /// fresh file; see above). Each statement must match what `push_schema` would |
| 60 | /// generate for the model — `schema_shim_matches_push_schema` asserts that. |
| 61 | const SCHEMA_SHIMS: &[&str] = &[ |
| 62 | CI_ARTIFACTS_DDL, |
| 63 | r#"CREATE INDEX IF NOT EXISTS "index_ci_artifacts_by_repo_id" ON "ci_artifacts" ("repo_id")"#, |
| 64 | r#"CREATE INDEX IF NOT EXISTS "index_ci_artifacts_by_run_id" ON "ci_artifacts" ("run_id")"#, |
| 65 | ISSUES_DDL, |
| 66 | r#"CREATE INDEX IF NOT EXISTS "index_issues_by_repo_id" ON "issues" ("repo_id")"#, |
| 67 | ISSUE_COMMENTS_DDL, |
| 68 | r#"CREATE INDEX IF NOT EXISTS "index_issue_comments_by_issue_id" ON "issue_comments" ("issue_id")"#, |
| 69 | ATTACHMENTS_DDL, |
| 70 | r#"CREATE INDEX IF NOT EXISTS "index_attachments_by_repo_id" ON "attachments" ("repo_id")"#, |
| 71 | ]; |
| 72 | |
| 73 | const CI_ARTIFACTS_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "ci_artifacts" ( |
| 74 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, |
| 75 | "run_id" BIGINT NOT NULL, |
| 76 | "repo_id" BIGINT NOT NULL, |
| 77 | "commit" TEXT NOT NULL, |
| 78 | "name" TEXT NOT NULL, |
| 79 | "size" BIGINT NOT NULL, |
| 80 | "is_dir" BOOLEAN NOT NULL, |
| 81 | "browse" BOOLEAN NOT NULL, |
| 82 | "meta" TEXT NOT NULL, |
| 83 | "created_at" BIGINT NOT NULL )"#; |
| 84 | |
| 85 | const ISSUES_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "issues" ( |
| 86 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, |
| 87 | "repo_id" BIGINT NOT NULL, |
| 88 | "number" BIGINT NOT NULL, |
| 89 | "title" TEXT NOT NULL, |
| 90 | "body" TEXT NOT NULL, |
| 91 | "author_id" BIGINT NOT NULL, |
| 92 | "state" TEXT NOT NULL, |
| 93 | "created_at" BIGINT NOT NULL, |
| 94 | "updated_at" BIGINT NOT NULL )"#; |
| 95 | |
| 96 | const ISSUE_COMMENTS_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "issue_comments" ( |
| 97 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, |
| 98 | "issue_id" BIGINT NOT NULL, |
| 99 | "author_id" BIGINT NOT NULL, |
| 100 | "body" TEXT NOT NULL, |
| 101 | "created_at" BIGINT NOT NULL )"#; |
| 102 | |
| 103 | const ATTACHMENTS_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "attachments" ( |
| 104 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, |
| 105 | "repo_id" BIGINT NOT NULL, |
| 106 | "hash" TEXT NOT NULL, |
| 107 | "content_type" TEXT NOT NULL, |
| 108 | "size" BIGINT NOT NULL, |
| 109 | "uploader_id" BIGINT NOT NULL, |
| 110 | "created_at" BIGINT NOT NULL )"#; |
| 111 | |
| 112 | /// Columns added to existing tables after deployment, applied as |
| 113 | /// `ALTER TABLE … ADD COLUMN` when missing (SQLite has no `IF NOT EXISTS` |
| 114 | /// for columns, so presence is checked via `pragma_table_info`). The model |
| 115 | /// must declare the field *last* so fresh and migrated column orders agree. |
| 116 | /// The `DEFAULT` backfills existing rows; Toasty's fresh DDL omits it, which |
| 117 | /// is fine — inserts always provide every field. |
| 118 | const COLUMN_SHIMS: &[(&str, &str, &str)] = &[( |
| 119 | "repositories", |
| 120 | "mirror_url", |
| 121 | r#"ALTER TABLE "repositories" ADD COLUMN "mirror_url" TEXT NOT NULL DEFAULT ''"#, |
| 122 | )]; |
| 123 | |
| 124 | /// Apply [`SCHEMA_SHIMS`] and [`COLUMN_SHIMS`] to an existing database. Uses |
| 125 | /// rusqlite directly (already in-tree via Toasty's SQLite driver); every |
| 126 | /// statement is a no-op once applied. |
| 127 | fn migrate_existing(path: &Path) -> Result<()> { |
| 128 | let migrate_err = |e: &dyn std::fmt::Display| crate::Error::Config(format!("schema shim: {e}")); |
| 129 | let conn = rusqlite::Connection::open(path) |
| 130 | .map_err(|e| crate::Error::Config(format!("opening {} to migrate: {e}", path.display())))?; |
| 131 | for ddl in SCHEMA_SHIMS { |
| 132 | conn.execute_batch(ddl).map_err(|e| migrate_err(&e))?; |
| 133 | } |
| 134 | for (table, column, ddl) in COLUMN_SHIMS { |
| 135 | let present: i64 = conn |
| 136 | .query_row( |
| 137 | "SELECT count(*) FROM pragma_table_info(?1) WHERE name = ?2", |
| 138 | (table, column), |
| 139 | |r| r.get(0), |
| 140 | ) |
| 141 | .map_err(|e| migrate_err(&e))?; |
| 142 | if present == 0 { |
| 143 | conn.execute_batch(ddl).map_err(|e| migrate_err(&e))?; |
| 144 | } |
| 145 | } |
| 146 | Ok(()) |
| 147 | } |
| 148 | |
| 149 | #[cfg(test)] |
| 150 | mod tests { |
| 151 | use super::*; |
| 152 | |
| 153 | /// Tables created by shims (i.e. added after the first deployment). |
| 154 | const SHIMMED_TABLES: &[&str] = &["ci_artifacts", "issues", "issue_comments", "attachments"]; |
| 155 | |
| 156 | /// Every schema object (table + indexes) for `table`, normalized. |
| 157 | fn schema_objects(path: &Path, table: &str) -> Vec<String> { |
| 158 | let conn = rusqlite::Connection::open(path).unwrap(); |
| 159 | let mut stmt = conn |
| 160 | .prepare( |
| 161 | "SELECT sql FROM sqlite_master WHERE tbl_name=?1 \ |
| 162 | AND sql IS NOT NULL ORDER BY name", |
| 163 | ) |
| 164 | .unwrap(); |
| 165 | stmt.query_map([table], |r| r.get::<_, String>(0)) |
| 166 | .unwrap() |
| 167 | .map(|s| { |
| 168 | s.unwrap() |
| 169 | .replace(" IF NOT EXISTS", "") |
| 170 | .split_whitespace() |
| 171 | .collect::<Vec<_>>() |
| 172 | .join(" ") |
| 173 | }) |
| 174 | .collect() |
| 175 | } |
| 176 | |
| 177 | /// The hand-written shim DDL must produce the same tables and indexes as |
| 178 | /// a fresh `push_schema`, or fresh and migrated databases would diverge. |
| 179 | #[tokio::test] |
| 180 | async fn schema_shim_matches_push_schema() { |
| 181 | let dir = tempfile::tempdir().unwrap(); |
| 182 | |
| 183 | let fresh = dir.path().join("fresh.db"); |
| 184 | connect(&fresh).await.unwrap(); |
| 185 | |
| 186 | // A database from before the shimmed tables existed. |
| 187 | let migrated = dir.path().join("migrated.db"); |
| 188 | connect(&migrated).await.unwrap(); |
| 189 | let conn = rusqlite::Connection::open(&migrated).unwrap(); |
| 190 | for table in SHIMMED_TABLES { |
| 191 | conn.execute_batch(&format!("DROP TABLE {table}")).unwrap(); |
| 192 | } |
| 193 | drop(conn); |
| 194 | connect(&migrated).await.unwrap(); |
| 195 | |
| 196 | for table in SHIMMED_TABLES { |
| 197 | assert_eq!( |
| 198 | schema_objects(&fresh, table), |
| 199 | schema_objects(&migrated, table), |
| 200 | "schema diverges for {table}" |
| 201 | ); |
| 202 | assert!( |
| 203 | !schema_objects(&fresh, table).is_empty(), |
| 204 | "no schema objects for {table}" |
| 205 | ); |
| 206 | } |
| 207 | } |
| 208 | |
| 209 | /// Column name/type/notnull triples for a table. |
| 210 | fn columns(path: &Path, table: &str) -> Vec<(String, String, bool)> { |
| 211 | let conn = rusqlite::Connection::open(path).unwrap(); |
| 212 | let mut stmt = conn |
| 213 | .prepare("SELECT name, type, \"notnull\" FROM pragma_table_info(?1)") |
| 214 | .unwrap(); |
| 215 | stmt.query_map([table], |r| { |
| 216 | Ok((r.get(0)?, r.get(1)?, r.get::<_, i64>(2)? != 0)) |
| 217 | }) |
| 218 | .unwrap() |
| 219 | .map(|r| r.unwrap()) |
| 220 | .collect() |
| 221 | } |
| 222 | |
| 223 | /// A column shim must converge an old table to the fresh schema's columns |
| 224 | /// (same names, order, types, nullability — the DDL text itself differs |
| 225 | /// because of the backfill `DEFAULT`). |
| 226 | #[tokio::test] |
| 227 | async fn column_shim_matches_push_schema() { |
| 228 | let dir = tempfile::tempdir().unwrap(); |
| 229 | |
| 230 | let fresh = dir.path().join("fresh.db"); |
| 231 | connect(&fresh).await.unwrap(); |
| 232 | |
| 233 | let migrated = dir.path().join("migrated.db"); |
| 234 | connect(&migrated).await.unwrap(); |
| 235 | let conn = rusqlite::Connection::open(&migrated).unwrap(); |
| 236 | conn.execute_batch(r#"ALTER TABLE "repositories" DROP COLUMN "mirror_url""#) |
| 237 | .unwrap(); |
| 238 | drop(conn); |
| 239 | connect(&migrated).await.unwrap(); |
| 240 | connect(&migrated).await.unwrap(); // idempotent |
| 241 | |
| 242 | assert_eq!( |
| 243 | columns(&fresh, "repositories"), |
| 244 | columns(&migrated, "repositories") |
| 245 | ); |
| 246 | } |
| 247 | |
| 248 | /// Reconnecting to an existing database that predates a table must create |
| 249 | /// it (and reconnecting again must be a no-op). |
| 250 | #[tokio::test] |
| 251 | async fn migrate_existing_adds_missing_tables() { |
| 252 | let dir = tempfile::tempdir().unwrap(); |
| 253 | let path = dir.path().join("old.db"); |
| 254 | connect(&path).await.unwrap(); |
| 255 | |
| 256 | // Simulate a database from before the table existed. |
| 257 | let conn = rusqlite::Connection::open(&path).unwrap(); |
| 258 | conn.execute_batch("DROP TABLE ci_artifacts").unwrap(); |
| 259 | drop(conn); |
| 260 | |
| 261 | connect(&path).await.unwrap(); |
| 262 | connect(&path).await.unwrap(); // idempotent |
| 263 | |
| 264 | let conn = rusqlite::Connection::open(&path).unwrap(); |
| 265 | let n: i64 = conn |
| 266 | .query_row( |
| 267 | "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='ci_artifacts'", |
| 268 | [], |
| 269 | |r| r.get(0), |
| 270 | ) |
| 271 | .unwrap(); |
| 272 | assert_eq!(n, 1); |
| 273 | } |
| 274 | } |