anvilsign in

collin/anvil

1//! Database connection and schema setup via Toasty.
2
3use std::path::Path;
4
5use 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.
31pub 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.
65const 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
81const 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
93const 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
104const 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
111const 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
120const 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
128const 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.
140const 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.
166fn 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)]
189mod 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}