eventuary_sqlite/
database.rs1use std::path::Path;
2use std::sync::{Arc, Mutex};
3
4use rusqlite::Connection;
5
6use eventuary_core::{Error, Result};
7
8use crate::relation::SqliteRelationName;
9
10pub type SqliteConn = Arc<Mutex<Connection>>;
11
12#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13pub struct Migration {
14 pub filename: &'static str,
15 pub template: &'static str,
16}
17
18impl Migration {
19 pub fn version(&self) -> i64 {
20 migration_version(self.filename)
21 }
22}
23
24const MIGRATION_TEMPLATES: &[Migration] = &[
25 Migration {
26 filename: "0001_init.sql",
27 template: include_str!("../migrations/0001_init.sql"),
28 },
29 Migration {
30 filename: "0002_stores.sql",
31 template: include_str!("../migrations/0002_stores.sql"),
32 },
33];
34
35pub fn migrations() -> &'static [Migration] {
36 MIGRATION_TEMPLATES
37}
38
39#[derive(Debug, Clone)]
40pub struct SqliteDatabaseConfig {
41 pub events_relation: SqliteRelationName,
42 pub offsets_relation: SqliteRelationName,
43 pub multiplexer_completions_relation: SqliteRelationName,
44 pub dedupe_keys_relation: SqliteRelationName,
45 pub buffer_entries_relation: SqliteRelationName,
46 pub watermarks_relation: SqliteRelationName,
47}
48
49impl Default for SqliteDatabaseConfig {
50 fn default() -> Self {
51 Self {
52 events_relation: SqliteRelationName::new("events").expect("default events relation"),
53 offsets_relation: SqliteRelationName::new("consumer_offsets")
54 .expect("default offsets relation"),
55 multiplexer_completions_relation: SqliteRelationName::new("multiplexer_completions")
56 .expect("default multiplexer relation"),
57 dedupe_keys_relation: SqliteRelationName::new("dedupe_keys")
58 .expect("default dedupe relation"),
59 buffer_entries_relation: SqliteRelationName::new("buffer_entries")
60 .expect("default buffer relation"),
61 watermarks_relation: SqliteRelationName::new("watermarks")
62 .expect("default watermarks relation"),
63 }
64 }
65}
66
67pub fn render_migration_sql(migration: &Migration, config: &SqliteDatabaseConfig) -> String {
68 migration
69 .template
70 .replace("{events}", &config.events_relation.render())
71 .replace("{offsets}", &config.offsets_relation.render())
72 .replace(
73 "{multiplexer_completions}",
74 &config.multiplexer_completions_relation.render(),
75 )
76 .replace("{dedupe_keys}", &config.dedupe_keys_relation.render())
77 .replace("{buffer_entries}", &config.buffer_entries_relation.render())
78 .replace("{watermarks}", &config.watermarks_relation.render())
79}
80
81pub fn render_schema_sql(config: &SqliteDatabaseConfig) -> String {
82 let mut sql = String::new();
83 for migration in migrations() {
84 sql.push_str(&render_migration_sql(migration, config));
85 if !sql.ends_with('\n') {
86 sql.push('\n');
87 }
88 }
89 sql
90}
91
92pub fn schema_sql() -> String {
93 render_schema_sql(&SqliteDatabaseConfig::default())
94}
95
96pub struct SqliteDatabase {
97 conn: SqliteConn,
98 config: SqliteDatabaseConfig,
99}
100
101impl SqliteDatabase {
102 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
103 let conn = Connection::open(path).map_err(|e| Error::Store(e.to_string()))?;
104 Self::init(conn, SqliteDatabaseConfig::default())
105 }
106
107 pub fn open_in_memory() -> Result<Self> {
108 let conn = Connection::open_in_memory().map_err(|e| Error::Store(e.to_string()))?;
109 Self::init(conn, SqliteDatabaseConfig::default())
110 }
111
112 pub fn open_with_config(path: impl AsRef<Path>, config: SqliteDatabaseConfig) -> Result<Self> {
113 let conn = Connection::open(path).map_err(|e| Error::Store(e.to_string()))?;
114 Self::init(conn, config)
115 }
116
117 pub fn open_in_memory_with_config(config: SqliteDatabaseConfig) -> Result<Self> {
118 let conn = Connection::open_in_memory().map_err(|e| Error::Store(e.to_string()))?;
119 Self::init(conn, config)
120 }
121
122 fn init(conn: Connection, config: SqliteDatabaseConfig) -> Result<Self> {
123 let _: String = conn
124 .pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))
125 .map_err(|e| Error::Store(e.to_string()))?;
126 apply_migrations(&conn, &config)?;
127
128 Ok(Self {
129 conn: Arc::new(Mutex::new(conn)),
130 config,
131 })
132 }
133
134 pub fn conn(&self) -> SqliteConn {
135 Arc::clone(&self.conn)
136 }
137
138 pub fn config(&self) -> &SqliteDatabaseConfig {
139 &self.config
140 }
141}
142
143fn apply_migrations(conn: &Connection, config: &SqliteDatabaseConfig) -> Result<()> {
144 for migration in migrations() {
150 let sql = render_migration_sql(migration, config);
151 conn.execute_batch(&sql)
152 .map_err(|e| Error::Store(e.to_string()))?;
153 }
154 Ok(())
155}
156
157fn migration_version(filename: &str) -> i64 {
158 let Some((version, _)) = filename.split_once('_') else {
159 panic!("migration filename must start with a numeric version prefix")
160 };
161 version
162 .parse()
163 .expect("migration filename version prefix must be numeric")
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169
170 #[test]
171 fn open_in_memory_creates_schema() {
172 let db = SqliteDatabase::open_in_memory().unwrap();
173 let conn = db.conn();
174 let guard = conn.lock().unwrap();
175 let count: i64 = guard
176 .query_row(
177 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name IN ('events', 'consumer_offsets')",
178 [],
179 |row| row.get(0),
180 )
181 .unwrap();
182 assert_eq!(count, 2);
183 }
184
185 #[test]
186 fn schema_idempotent() {
187 let db = SqliteDatabase::open_in_memory().unwrap();
188 let conn = db.conn();
189 let guard = conn.lock().unwrap();
190 let sql = render_migration_sql(&migrations()[0], &SqliteDatabaseConfig::default());
191 guard.execute_batch(&sql).unwrap();
192 }
193
194 #[test]
195 fn schema_sql_is_available_for_manual_migrations() {
196 let sql = schema_sql();
197 assert!(sql.contains("CREATE TABLE IF NOT EXISTS \"events\""));
198 assert!(sql.contains("CREATE TABLE IF NOT EXISTS \"consumer_offsets\""));
199 }
200
201 #[test]
202 fn opens_idempotently_with_alternate_relations_after_default_relations() {
203 let db = SqliteDatabase::open_in_memory().unwrap();
205 let conn = db.conn();
206
207 let alt = SqliteDatabaseConfig {
211 events_relation: SqliteRelationName::new("alt_events").unwrap(),
212 offsets_relation: SqliteRelationName::new("alt_offsets").unwrap(),
213 ..SqliteDatabaseConfig::default()
214 };
215 let sql = render_migration_sql(&migrations()[0], &alt);
216 {
217 let guard = conn.lock().unwrap();
218 guard.execute_batch(&sql).unwrap();
219 }
220 let guard = conn.lock().unwrap();
221 let alt_count: i64 = guard
222 .query_row(
223 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name IN ('alt_events', 'alt_offsets')",
224 [],
225 |row| row.get(0),
226 )
227 .unwrap();
228 assert_eq!(alt_count, 2, "alternate relations must be created");
229 }
230
231 #[test]
232 fn schema_sql_uses_configured_relations() {
233 let config = SqliteDatabaseConfig {
234 events_relation: SqliteRelationName::new("custom_events").unwrap(),
235 offsets_relation: SqliteRelationName::new("custom_offsets").unwrap(),
236 ..SqliteDatabaseConfig::default()
237 };
238 let sql = render_schema_sql(&config);
239 assert!(sql.contains("CREATE TABLE IF NOT EXISTS \"custom_events\""));
240 assert!(sql.contains("CREATE TABLE IF NOT EXISTS \"custom_offsets\""));
241 }
242}