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