use anyhow::{Context, Ok, Result};
use parking_lot::Mutex;
use rusqlite::{Connection, params};
use rusqlite_migration::{M, Migrations};
use ustr::Ustr;
use crate::{
data::{ExerciseTrial, MasteryScore},
error::PracticeStatsError,
utils,
};
pub trait PracticeStats {
fn get_scores(
&self,
exercise_id: Ustr,
num_scores: u32,
) -> Result<Vec<ExerciseTrial>, PracticeStatsError>;
fn record_exercise_score(
&mut self,
exercise_id: Ustr,
score: MasteryScore,
timestamp: i64,
) -> Result<(), PracticeStatsError>;
fn trim_scores(&mut self, num_scores: u32) -> Result<(), PracticeStatsError>;
fn remove_scores_with_prefix(&mut self, prefix: &str) -> Result<(), PracticeStatsError>;
}
pub struct LocalPracticeStats {
connection: Mutex<Connection>,
}
impl LocalPracticeStats {
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 migrations = Self::migrations();
let mut connection = self.connection.lock();
migrations
.to_latest(&mut connection)
.context("failed to initialize practice stats DB")
}
fn new(connection: Connection) -> Result<LocalPracticeStats> {
let mut stats = LocalPracticeStats {
connection: Mutex::new(connection),
};
stats.init()?;
Ok(stats)
}
pub fn new_from_disk(db_path: &str) -> Result<LocalPracticeStats> {
Self::new(utils::new_connection(db_path)?)
}
fn get_scores_helper(&self, exercise_id: Ustr, num_scores: u32) -> Result<Vec<ExerciseTrial>> {
let connection = self.connection.lock();
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| {
let score = row.get(0)?;
let timestamp = row.get(1)?;
rusqlite::Result::Ok(ExerciseTrial { score, timestamp })
})?
.map(|r| r.context("failed to retrieve scores from practice stats DB"))
.collect::<Result<Vec<ExerciseTrial>, _>>()?;
Ok(rows)
}
fn record_exercise_score_helper(
&mut self,
exercise_id: Ustr,
score: &MasteryScore,
timestamp: i64,
) -> Result<()> {
let mut connection = self.connection.lock();
let tx = connection.transaction()?;
{
let mut uid_stmt =
tx.prepare_cached("INSERT OR IGNORE INTO uids(unit_id) VALUES ($1);")?;
uid_stmt.execute(params![exercise_id.as_str()])?;
let mut stmt = tx.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
])?;
}
tx.commit()?;
Ok(())
}
fn trim_scores_helper(&mut self, num_scores: u32) -> Result<()> {
let connection = self.connection.lock();
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.context("failed to retrieve UIDs from 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);",
)?;
let _ = stmt.execute(params![uid, num_scores])?;
}
connection.execute_batch("VACUUM;")?;
Ok(())
}
fn remove_scores_with_prefix_helper(&mut self, prefix: &str) -> Result<()> {
let connection = self.connection.lock();
let mut uid_stmt =
connection.prepare_cached("SELECT unit_uid FROM uids WHERE unit_id LIKE $1;")?;
let uids = uid_stmt
.query_map(params![format!("{}%", prefix)], |row| row.get(0))?
.map(|r| r.context("failed to retrieve UIDs from 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;")?;
let _ = stmt.execute(params![uid])?;
}
connection.execute_batch("VACUUM;")?;
Ok(())
}
}
impl PracticeStats for LocalPracticeStats {
fn get_scores(
&self,
exercise_id: Ustr,
num_scores: u32,
) -> Result<Vec<ExerciseTrial>, PracticeStatsError> {
self.get_scores_helper(exercise_id, num_scores)
.map_err(|e| PracticeStatsError::GetScores(exercise_id, e))
}
fn record_exercise_score(
&mut self,
exercise_id: Ustr,
score: MasteryScore,
timestamp: i64,
) -> Result<(), PracticeStatsError> {
self.record_exercise_score_helper(exercise_id, &score, timestamp)
.map_err(|e| PracticeStatsError::RecordScore(exercise_id, e))
}
fn trim_scores(&mut self, num_scores: u32) -> Result<(), PracticeStatsError> {
self.trim_scores_helper(num_scores)
.map_err(PracticeStatsError::TrimScores)
}
fn remove_scores_with_prefix(&mut self, prefix: &str) -> Result<(), PracticeStatsError> {
self.remove_scores_with_prefix_helper(prefix)
.map_err(|e| PracticeStatsError::RemovePrefix(prefix.to_string(), e))
}
}
#[cfg(test)]
#[cfg_attr(coverage, coverage(off))]
mod test {
use anyhow::{Ok, Result};
use rusqlite::Connection;
use ustr::Ustr;
use crate::{
data::{ExerciseTrial, MasteryScore},
practice_stats::{LocalPracticeStats, PracticeStats},
};
fn new_tests_stats() -> Result<Box<dyn PracticeStats>> {
let practice_stats = LocalPracticeStats::new(Connection::open_in_memory()?)?;
Ok(Box::new(practice_stats))
}
fn assert_scores(expected: &[f32], actual: &[ExerciseTrial]) {
let only_scores: Vec<f32> = actual.iter().map(|t| t.score).collect();
assert_eq!(expected, only_scores);
let timestamp_sorted = actual
.iter()
.enumerate()
.map(|(i, _)| {
if i == 0 {
return true;
}
actual[i - 1].timestamp >= actual[i].timestamp
})
.all(|b| b);
assert!(timestamp_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(&[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(&[5.0], &one_score);
let three_scores = stats.get_scores(exercise_id, 3)?;
assert_scores(&[5.0, 4.0, 3.0], &three_scores);
let more_scores = stats.get_scores(exercise_id, 10)?;
assert_scores(&[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(&[], &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(&[5.0, 4.0], &scores);
let scores = stats.get_scores(exercise2_id, 10)?;
assert_scores(&[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(&[5.0, 4.0, 3.0], &scores);
let scores = stats.get_scores(exercise2_id, 10)?;
assert_scores(&[3.0, 1.0, 1.0], &scores);
Ok(())
}
#[test]
fn remove_scores_with_prefix() -> 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)?;
let exercise3_id = Ustr::from("exercise3");
stats.record_exercise_score(exercise3_id, MasteryScore::One, 1)?;
stats.record_exercise_score(exercise3_id, MasteryScore::One, 2)?;
stats.record_exercise_score(exercise3_id, MasteryScore::Three, 3)?;
stats.remove_scores_with_prefix("exercise1")?;
let scores = stats.get_scores(exercise1_id, 10)?;
assert_scores(&[], &scores);
let scores = stats.get_scores(exercise2_id, 10)?;
assert_scores(&[3.0, 1.0, 1.0], &scores);
let scores = stats.get_scores(exercise3_id, 10)?;
assert_scores(&[3.0, 1.0, 1.0], &scores);
stats.remove_scores_with_prefix("exercise")?;
let scores = stats.get_scores(exercise1_id, 10)?;
assert_scores(&[], &scores);
let scores = stats.get_scores(exercise2_id, 10)?;
assert_scores(&[], &scores);
let scores = stats.get_scores(exercise3_id, 10)?;
assert_scores(&[], &scores);
Ok(())
}
}