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 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.
30pub 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.
63const 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
78const 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
90const 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
101const 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
108const 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
117const 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.
131const 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.
140fn 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)]
163mod 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}