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 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.
29pub 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.
61const 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
73const 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
85const 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
96const 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
103const 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.
118const 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.
127fn 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)]
150mod 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}