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