1use anyhow::{Context, Ok, Result};
8use r2d2::Pool;
9use r2d2_sqlite::SqliteConnectionManager;
10use rusqlite::params;
11use rusqlite_migration::{M, Migrations};
12use ustr::Ustr;
13
14use crate::{
15 data::{ExerciseTrial, MasteryScore},
16 error::PracticeStatsError,
17 utils,
18};
19
20pub trait PracticeStats {
22 fn get_scores(
25 &self,
26 exercise_id: Ustr,
27 num_scores: usize,
28 ) -> Result<Vec<ExerciseTrial>, PracticeStatsError>;
29
30 fn record_exercise_score(
35 &mut self,
36 exercise_id: Ustr,
37 score: MasteryScore,
38 timestamp: i64,
39 ) -> Result<(), PracticeStatsError>;
40
41 fn trim_scores(&mut self, num_scores: usize) -> Result<(), PracticeStatsError>;
44
45 fn remove_scores_with_prefix(&mut self, prefix: &str) -> Result<(), PracticeStatsError>;
47}
48
49pub struct LocalPracticeStats {
51 pool: Pool<SqliteConnectionManager>,
53}
54
55impl LocalPracticeStats {
56 fn migrations() -> Migrations<'static> {
58 Migrations::new(vec![
59 M::up("CREATE TABLE uids(unit_uid INTEGER PRIMARY KEY, unit_id TEXT NOT NULL UNIQUE);")
63 .down("DROP TABLE uids;"),
64 M::up(
66 "CREATE TABLE practice_stats(
67 id INTEGER PRIMARY KEY,
68 unit_uid INTEGER NOT NULL REFERENCES uids(unit_uid),
69 score REAL, timestamp INTEGER);",
70 )
71 .down("DROP TABLE practice_stats"),
72 M::up("CREATE INDEX unit_ids ON uids (unit_id);").down("DROP INDEX unit_ids"),
74 M::up("CREATE INDEX unit_scores ON practice_stats (unit_uid);")
80 .down("DROP INDEX unit_scores"),
81 M::up("DROP INDEX unit_scores")
82 .down("CREATE INDEX unit_scores ON practice_stats (unit_uid);"),
83 M::up("CREATE INDEX trials ON practice_stats (unit_uid, timestamp);")
86 .down("DROP INDEX trials"),
87 ])
88 }
89
90 fn init(&mut self) -> Result<()> {
93 let mut connection = self.pool.get()?;
94 let migrations = Self::migrations();
95 migrations
96 .to_latest(&mut connection)
97 .context("failed to initialize practice stats DB")
98 }
99
100 fn new(connection_manager: SqliteConnectionManager) -> Result<LocalPracticeStats> {
102 let pool = utils::new_connection_pool(connection_manager)?;
103 let mut stats = LocalPracticeStats { pool };
104 stats.init()?;
105 Ok(stats)
106 }
107
108 pub fn new_from_disk(db_path: &str) -> Result<LocalPracticeStats> {
110 Self::new(utils::new_connection_manager(db_path))
111 }
112
113 fn get_scores_helper(
115 &self,
116 exercise_id: Ustr,
117 num_scores: usize,
118 ) -> Result<Vec<ExerciseTrial>> {
119 let connection = self.pool.get()?;
121 let mut stmt = connection.prepare_cached(
122 "SELECT score, timestamp from practice_stats WHERE unit_uid = (
123 SELECT unit_uid FROM uids WHERE unit_id = $1)
124 ORDER BY timestamp DESC LIMIT ?2;",
125 )?;
126
127 #[allow(clippy::let_and_return)]
129 let rows = stmt
130 .query_map(params![exercise_id.as_str(), num_scores], |row| {
131 let score = row.get(0)?;
132 let timestamp = row.get(1)?;
133 rusqlite::Result::Ok(ExerciseTrial { score, timestamp })
134 })?
135 .map(|r| r.context("failed to retrieve scores from practice stats DB"))
136 .collect::<Result<Vec<ExerciseTrial>, _>>()?;
137 Ok(rows)
138 }
139
140 fn record_exercise_score_helper(
142 &mut self,
143 exercise_id: Ustr,
144 score: &MasteryScore,
145 timestamp: i64,
146 ) -> Result<()> {
147 let connection = self.pool.get()?;
149 let mut uid_stmt =
150 connection.prepare_cached("INSERT OR IGNORE INTO uids(unit_id) VALUES ($1);")?;
151 uid_stmt.execute(params![exercise_id.as_str()])?;
152
153 let mut stmt = connection.prepare_cached(
155 "INSERT INTO practice_stats (unit_uid, score, timestamp) VALUES (
156 (SELECT unit_uid FROM uids WHERE unit_id = $1), $2, $3);",
157 )?;
158 stmt.execute(params![
159 exercise_id.as_str(),
160 score.float_score(),
161 timestamp
162 ])?;
163 Ok(())
164 }
165
166 fn trim_scores_helper(&mut self, num_scores: usize) -> Result<()> {
168 let connection = self.pool.get()?;
170 let mut uid_stmt = connection.prepare_cached("SELECT unit_uid from uids")?;
171 let uids = uid_stmt
172 .query_map([], |row| row.get(0))?
173 .map(|r| r.context("failed to retrieve UIDs from practice stats DB"))
174 .collect::<Result<Vec<i64>, _>>()?;
175
176 for uid in uids {
178 let mut stmt = connection.prepare_cached(
179 "DELETE FROM practice_stats WHERE unit_uid = $1 AND timestamp NOT IN (
180 SELECT timestamp FROM practice_stats WHERE unit_uid = $1
181 ORDER BY timestamp DESC LIMIT ?2);",
182 )?;
183 let _ = stmt.execute(params![uid, num_scores])?;
184 }
185
186 connection.execute_batch("VACUUM;")?;
188 Ok(())
189 }
190
191 fn remove_scores_with_prefix_helper(&mut self, prefix: &str) -> Result<()> {
193 let connection = self.pool.get()?;
195 let mut uid_stmt =
196 connection.prepare_cached("SELECT unit_uid FROM uids WHERE unit_id LIKE $1;")?;
197 let uids = uid_stmt
198 .query_map(params![format!("{}%", prefix)], |row| row.get(0))?
199 .map(|r| r.context("failed to retrieve UIDs from practice stats DB"))
200 .collect::<Result<Vec<i64>, _>>()?;
201
202 for uid in uids {
204 let mut stmt =
205 connection.prepare_cached("DELETE FROM practice_stats WHERE unit_uid = $1;")?;
206 let _ = stmt.execute(params![uid])?;
207 }
208
209 connection.execute_batch("VACUUM;")?;
211 Ok(())
212 }
213}
214
215impl PracticeStats for LocalPracticeStats {
216 fn get_scores(
217 &self,
218 exercise_id: Ustr,
219 num_scores: usize,
220 ) -> Result<Vec<ExerciseTrial>, PracticeStatsError> {
221 self.get_scores_helper(exercise_id, num_scores)
222 .map_err(|e| PracticeStatsError::GetScores(exercise_id, e))
223 }
224
225 fn record_exercise_score(
226 &mut self,
227 exercise_id: Ustr,
228 score: MasteryScore,
229 timestamp: i64,
230 ) -> Result<(), PracticeStatsError> {
231 self.record_exercise_score_helper(exercise_id, &score, timestamp)
232 .map_err(|e| PracticeStatsError::RecordScore(exercise_id, e))
233 }
234
235 fn trim_scores(&mut self, num_scores: usize) -> Result<(), PracticeStatsError> {
236 self.trim_scores_helper(num_scores)
237 .map_err(PracticeStatsError::TrimScores)
238 }
239
240 fn remove_scores_with_prefix(&mut self, prefix: &str) -> Result<(), PracticeStatsError> {
241 self.remove_scores_with_prefix_helper(prefix)
242 .map_err(|e| PracticeStatsError::RemovePrefix(prefix.to_string(), e))
243 }
244}
245
246#[cfg(test)]
247#[cfg_attr(coverage, coverage(off))]
248mod test {
249 use anyhow::{Ok, Result};
250 use r2d2_sqlite::SqliteConnectionManager;
251 use ustr::Ustr;
252
253 use crate::{
254 data::{ExerciseTrial, MasteryScore},
255 practice_stats::{LocalPracticeStats, PracticeStats},
256 };
257
258 fn new_tests_stats() -> Result<Box<dyn PracticeStats>> {
259 let connection_manager = SqliteConnectionManager::memory();
260 let practice_stats = LocalPracticeStats::new(connection_manager)?;
261 Ok(Box::new(practice_stats))
262 }
263
264 fn assert_scores(expected: &[f32], actual: &[ExerciseTrial]) {
265 let only_scores: Vec<f32> = actual.iter().map(|t| t.score).collect();
266 assert_eq!(expected, only_scores);
267 let timestamp_sorted = actual
268 .iter()
269 .enumerate()
270 .map(|(i, _)| {
271 if i == 0 {
272 return true;
273 }
274 actual[i - 1].timestamp >= actual[i].timestamp
275 })
276 .all(|b| b);
277 assert!(timestamp_sorted);
278 }
279
280 #[test]
282 fn basic() -> Result<()> {
283 let mut stats = new_tests_stats()?;
284 let exercise_id = Ustr::from("ex_123");
285 stats.record_exercise_score(exercise_id, MasteryScore::Five, 1)?;
286 let scores = stats.get_scores(exercise_id, 1)?;
287 assert_scores(&[5.0], &scores);
288 Ok(())
289 }
290
291 #[test]
293 fn multiple_records() -> Result<()> {
294 let mut stats = new_tests_stats()?;
295 let exercise_id = Ustr::from("ex_123");
296 stats.record_exercise_score(exercise_id, MasteryScore::Three, 1)?;
297 stats.record_exercise_score(exercise_id, MasteryScore::Four, 2)?;
298 stats.record_exercise_score(exercise_id, MasteryScore::Five, 3)?;
299
300 let one_score = stats.get_scores(exercise_id, 1)?;
301 assert_scores(&[5.0], &one_score);
302
303 let three_scores = stats.get_scores(exercise_id, 3)?;
304 assert_scores(&[5.0, 4.0, 3.0], &three_scores);
305
306 let more_scores = stats.get_scores(exercise_id, 10)?;
307 assert_scores(&[5.0, 4.0, 3.0], &more_scores);
308 Ok(())
309 }
310
311 #[test]
313 fn no_records() -> Result<()> {
314 let stats = new_tests_stats()?;
315 let scores = stats.get_scores(Ustr::from("ex_123"), 10)?;
316 assert_scores(&[], &scores);
317 Ok(())
318 }
319
320 #[test]
322 fn trim_scores_some_scores_removed() -> Result<()> {
323 let mut stats = new_tests_stats()?;
324 let exercise1_id = Ustr::from("exercise1");
325 stats.record_exercise_score(exercise1_id, MasteryScore::Three, 1)?;
326 stats.record_exercise_score(exercise1_id, MasteryScore::Four, 2)?;
327 stats.record_exercise_score(exercise1_id, MasteryScore::Five, 3)?;
328
329 let exercise2_id = Ustr::from("exercise2");
330 stats.record_exercise_score(exercise2_id, MasteryScore::One, 1)?;
331 stats.record_exercise_score(exercise2_id, MasteryScore::One, 2)?;
332 stats.record_exercise_score(exercise2_id, MasteryScore::Three, 3)?;
333
334 stats.trim_scores(2)?;
335
336 let scores = stats.get_scores(exercise1_id, 10)?;
337 assert_scores(&[5.0, 4.0], &scores);
338 let scores = stats.get_scores(exercise2_id, 10)?;
339 assert_scores(&[3.0, 1.0], &scores);
340 Ok(())
341 }
342
343 #[test]
345 fn trim_scores_no_scores_removed() -> Result<()> {
346 let mut stats = new_tests_stats()?;
347 let exercise1_id = Ustr::from("exercise1");
348 stats.record_exercise_score(exercise1_id, MasteryScore::Three, 1)?;
349 stats.record_exercise_score(exercise1_id, MasteryScore::Four, 2)?;
350 stats.record_exercise_score(exercise1_id, MasteryScore::Five, 3)?;
351
352 let exercise2_id = Ustr::from("exercise2");
353 stats.record_exercise_score(exercise2_id, MasteryScore::One, 1)?;
354 stats.record_exercise_score(exercise2_id, MasteryScore::One, 2)?;
355 stats.record_exercise_score(exercise2_id, MasteryScore::Three, 3)?;
356
357 stats.trim_scores(10)?;
358
359 let scores = stats.get_scores(exercise1_id, 10)?;
360 assert_scores(&[5.0, 4.0, 3.0], &scores);
361 let scores = stats.get_scores(exercise2_id, 10)?;
362 assert_scores(&[3.0, 1.0, 1.0], &scores);
363 Ok(())
364 }
365
366 #[test]
368 fn remove_scores_with_prefix() -> Result<()> {
369 let mut stats = new_tests_stats()?;
370 let exercise1_id = Ustr::from("exercise1");
371 stats.record_exercise_score(exercise1_id, MasteryScore::Three, 1)?;
372 stats.record_exercise_score(exercise1_id, MasteryScore::Four, 2)?;
373 stats.record_exercise_score(exercise1_id, MasteryScore::Five, 3)?;
374
375 let exercise2_id = Ustr::from("exercise2");
376 stats.record_exercise_score(exercise2_id, MasteryScore::One, 1)?;
377 stats.record_exercise_score(exercise2_id, MasteryScore::One, 2)?;
378 stats.record_exercise_score(exercise2_id, MasteryScore::Three, 3)?;
379
380 let exercise3_id = Ustr::from("exercise3");
381 stats.record_exercise_score(exercise3_id, MasteryScore::One, 1)?;
382 stats.record_exercise_score(exercise3_id, MasteryScore::One, 2)?;
383 stats.record_exercise_score(exercise3_id, MasteryScore::Three, 3)?;
384
385 stats.remove_scores_with_prefix("exercise1")?;
387 let scores = stats.get_scores(exercise1_id, 10)?;
388 assert_scores(&[], &scores);
389 let scores = stats.get_scores(exercise2_id, 10)?;
390 assert_scores(&[3.0, 1.0, 1.0], &scores);
391 let scores = stats.get_scores(exercise3_id, 10)?;
392 assert_scores(&[3.0, 1.0, 1.0], &scores);
393
394 stats.remove_scores_with_prefix("exercise")?;
396 let scores = stats.get_scores(exercise1_id, 10)?;
397 assert_scores(&[], &scores);
398 let scores = stats.get_scores(exercise2_id, 10)?;
399 assert_scores(&[], &scores);
400 let scores = stats.get_scores(exercise3_id, 10)?;
401 assert_scores(&[], &scores);
402
403 Ok(())
404 }
405}