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