use anyhow::{Context, Result};
use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager;
use rusqlite::{params, Connection};
use rusqlite_migration::{Migrations, M};
use ustr::Ustr;
use crate::data::{ExerciseTrial, MasteryScore};
pub trait PracticeStats {
fn get_scores(&self, exercise_id: &Ustr, num_scores: usize) -> Result<Vec<ExerciseTrial>>;
fn record_exercise_score(
&mut self,
exercise_id: &Ustr,
score: MasteryScore,
timestamp: i64,
) -> Result<()>;
fn trim_scores(&mut self, num_scores: usize) -> Result<()>;
}
pub(crate) struct PracticeStatsDB {
pool: Pool<SqliteConnectionManager>,
}
impl PracticeStatsDB {
fn migrations() -> Migrations<'static> {
Migrations::new(vec![
M::up("CREATE TABLE uids(unit_uid INTEGER PRIMARY KEY, unit_id TEXT NOT NULL UNIQUE);")
.down("DROP TABLE uids;"),
M::up(
"CREATE TABLE practice_stats(
id INTEGER PRIMARY KEY,
unit_uid INTEGER NOT NULL REFERENCES uids(unit_uid),
score REAL, timestamp INTEGER);",
)
.down("DROP TABLE practice_stats"),
M::up("CREATE INDEX unit_ids ON uids (unit_id);").down("DROP INDEX unit_ids"),
M::up("CREATE INDEX unit_scores ON practice_stats (unit_uid);")
.down("DROP INDEX unit_scores"),
M::up("DROP INDEX unit_scores")
.down("CREATE INDEX unit_scores ON practice_stats (unit_uid);"),
M::up("CREATE INDEX trials ON practice_stats (unit_uid, timestamp);")
.down("DROP INDEX trials"),
])
}
fn init(&mut self) -> Result<()> {
let mut connection = self.pool.get()?;
let migrations = Self::migrations();
migrations
.to_latest(&mut connection)
.with_context(|| "failed to initialize practice stats DB") }
fn new(connection_manager: SqliteConnectionManager) -> Result<PracticeStatsDB> {
let pool = Pool::new(connection_manager)?;
let mut stats = PracticeStatsDB { pool };
stats.init()?;
Ok(stats)
}
pub fn new_from_disk(db_path: &str) -> Result<PracticeStatsDB> {
let connection_manager = SqliteConnectionManager::file(db_path).with_init(
|connection: &mut Connection| -> Result<(), rusqlite::Error> {
connection.pragma_update(None, "journal_mode", "WAL")?;
connection.pragma_update(None, "synchronous", "NORMAL")
},
);
Self::new(connection_manager)
}
}
impl PracticeStats for PracticeStatsDB {
fn get_scores(&self, exercise_id: &Ustr, num_scores: usize) -> Result<Vec<ExerciseTrial>> {
let connection = self.pool.get()?;
let mut stmt = connection.prepare_cached(
"SELECT score, timestamp from practice_stats WHERE unit_uid = (
SELECT unit_uid FROM uids WHERE unit_id = ?1)
ORDER BY timestamp DESC LIMIT ?2;",
)?;
#[allow(clippy::let_and_return)]
let rows = stmt
.query_map(params![exercise_id.as_str(), num_scores], |row| {
Ok(ExerciseTrial {
score: row.get(0)?,
timestamp: row.get(1)?,
})
})? .map(|r| {
r.with_context(
|| format!("cannot query practice stats for exercise {exercise_id}"), )
})
.collect();
rows
}
fn record_exercise_score(
&mut self,
exercise_id: &Ustr,
score: MasteryScore,
timestamp: i64,
) -> Result<()> {
let connection = self.pool.get()?;
let mut uid_stmt =
connection.prepare_cached("INSERT OR IGNORE INTO uids(unit_id) VALUES (?1);")?;
uid_stmt
.execute(params![exercise_id.as_str()])
.with_context(|| {
format!("cannot add {exercise_id} to uids table in practice stats DB")
})?;
let mut stmt = connection.prepare_cached(
"INSERT INTO practice_stats (unit_uid, score, timestamp) VALUES (
(SELECT unit_uid FROM uids WHERE unit_id = ?1), ?2, ?3);",
)?; stmt.execute(params![
exercise_id.as_str(),
score.float_score(),
timestamp
])
.with_context(|| {
format!("cannot record score {score:?} for exercise {exercise_id} to practice stats DB")
})?; Ok(())
}
fn trim_scores(&mut self, num_scores: usize) -> Result<()> {
let connection = self.pool.get()?;
let mut uid_stmt = connection.prepare_cached("SELECT unit_uid from uids")?;
let uids = uid_stmt
.query_map([], |row| row.get(0))?
.map(|r| r.with_context(|| "cannot query uids table in practice stats DB"))
.collect::<Result<Vec<i64>>>()?;
for uid in uids {
let mut stmt = connection.prepare_cached(
"DELETE FROM practice_stats WHERE unit_uid = ?1 AND timestamp NOT IN (
SELECT timestamp FROM practice_stats WHERE unit_uid = ?1
ORDER BY timestamp DESC LIMIT ?2);",
)?; stmt.execute(params![uid, num_scores])
.with_context(|| "cannot trim scores from practice stats DB")?; }
connection
.execute_batch("VACUUM;")
.with_context(|| "cannot vacuum practice stats DB")?; Ok(())
}
}
#[cfg(test)]
mod test {
use anyhow::{Ok, Result};
use r2d2_sqlite::SqliteConnectionManager;
use ustr::Ustr;
use crate::{
data::{ExerciseTrial, MasteryScore},
practice_stats::{PracticeStats, PracticeStatsDB},
};
fn new_tests_stats() -> Result<Box<dyn PracticeStats>> {
let connection_manager = SqliteConnectionManager::memory();
let practice_stats = PracticeStatsDB::new(connection_manager)?;
Ok(Box::new(practice_stats))
}
fn assert_scores(expected: Vec<f32>, actual: Vec<ExerciseTrial>) {
let only_scores: Vec<f32> = actual.iter().map(|t| t.score).collect();
assert_eq!(expected, only_scores);
let all_sorted = actual
.iter()
.enumerate()
.map(|(i, _)| {
if i == 0 {
return true;
}
actual[i - 1].score >= actual[i].score
})
.all(|b| b);
assert!(all_sorted);
}
#[test]
fn basic() -> Result<()> {
let mut stats = new_tests_stats()?;
let exercise_id = Ustr::from("ex_123");
stats.record_exercise_score(&exercise_id, MasteryScore::Five, 1)?;
let scores = stats.get_scores(&exercise_id, 1)?;
assert_scores(vec![5.0], scores);
Ok(())
}
#[test]
fn multiple_records() -> Result<()> {
let mut stats = new_tests_stats()?;
let exercise_id = Ustr::from("ex_123");
stats.record_exercise_score(&exercise_id, MasteryScore::Three, 1)?;
stats.record_exercise_score(&exercise_id, MasteryScore::Four, 2)?;
stats.record_exercise_score(&exercise_id, MasteryScore::Five, 3)?;
let one_score = stats.get_scores(&exercise_id, 1)?;
assert_scores(vec![5.0], one_score);
let three_scores = stats.get_scores(&exercise_id, 3)?;
assert_scores(vec![5.0, 4.0, 3.0], three_scores);
let more_scores = stats.get_scores(&exercise_id, 10)?;
assert_scores(vec![5.0, 4.0, 3.0], more_scores);
Ok(())
}
#[test]
fn no_records() -> Result<()> {
let stats = new_tests_stats()?;
let scores = stats.get_scores(&Ustr::from("ex_123"), 10)?;
assert_scores(vec![], scores);
Ok(())
}
#[test]
fn trim_scores_some_scores_removed() -> Result<()> {
let mut stats = new_tests_stats()?;
let exercise1_id = Ustr::from("exercise1");
stats.record_exercise_score(&exercise1_id, MasteryScore::Three, 1)?;
stats.record_exercise_score(&exercise1_id, MasteryScore::Four, 2)?;
stats.record_exercise_score(&exercise1_id, MasteryScore::Five, 3)?;
let exercise2_id = Ustr::from("exercise2");
stats.record_exercise_score(&exercise2_id, MasteryScore::One, 1)?;
stats.record_exercise_score(&exercise2_id, MasteryScore::One, 2)?;
stats.record_exercise_score(&exercise2_id, MasteryScore::Three, 3)?;
stats.trim_scores(2)?;
let scores = stats.get_scores(&exercise1_id, 10)?;
assert_scores(vec![5.0, 4.0], scores);
let scores = stats.get_scores(&exercise2_id, 10)?;
assert_scores(vec![3.0, 1.0], scores);
Ok(())
}
#[test]
fn trim_scores_no_scores_removed() -> Result<()> {
let mut stats = new_tests_stats()?;
let exercise1_id = Ustr::from("exercise1");
stats.record_exercise_score(&exercise1_id, MasteryScore::Three, 1)?;
stats.record_exercise_score(&exercise1_id, MasteryScore::Four, 2)?;
stats.record_exercise_score(&exercise1_id, MasteryScore::Five, 3)?;
let exercise2_id = Ustr::from("exercise2");
stats.record_exercise_score(&exercise2_id, MasteryScore::One, 1)?;
stats.record_exercise_score(&exercise2_id, MasteryScore::One, 2)?;
stats.record_exercise_score(&exercise2_id, MasteryScore::Three, 3)?;
stats.trim_scores(10)?;
let scores = stats.get_scores(&exercise1_id, 10)?;
assert_scores(vec![5.0, 4.0, 3.0], scores);
let scores = stats.get_scores(&exercise2_id, 10)?;
assert_scores(vec![3.0, 1.0, 1.0], scores);
Ok(())
}
}