use rusqlite::{params, Connection, OptionalExtension};
use crate::error::{Error, Result};
use crate::migrations::CLAIMS_DDL;
use crate::schema::Issue;
use crate::time;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Claim {
pub claimed_by: String,
pub claimed_at: String,
}
pub fn ensure(conn: &Connection) -> Result<()> {
for stmt in CLAIMS_DDL {
conn.execute_batch(stmt)?;
}
Ok(())
}
pub fn get(conn: &Connection, issue_id: i64) -> Result<Option<Claim>> {
ensure(conn)?;
Ok(conn
.query_row(
"SELECT claimed_by, claimed_at FROM issue_claims WHERE issue_id = ?1",
params![issue_id],
|r| {
Ok(Claim {
claimed_by: r.get(0)?,
claimed_at: r.get(1)?,
})
},
)
.optional()?)
}
pub fn claim(conn: &Connection, issue: &Issue, by: &str, force: bool) -> Result<Claim> {
let by = by.trim();
if by.is_empty() {
return Err(Error::validation("claimed_by", "can't be blank"));
}
if let Some(existing) = get(conn, issue.id)? {
if existing.claimed_by == by {
return Ok(existing);
}
if !force {
return Err(Error::validation(
"claim",
&format!(
"{} is already claimed by {} (pass --force to take it over)",
issue.key, existing.claimed_by
),
));
}
}
let now = time::format_usec(time::now_usec());
conn.execute(
"INSERT INTO issue_claims (issue_id, claimed_by, claimed_at) VALUES (?1, ?2, ?3) \
ON CONFLICT (issue_id) DO UPDATE SET claimed_by = ?2, claimed_at = ?3",
params![issue.id, by, now],
)?;
Ok(Claim {
claimed_by: by.to_string(),
claimed_at: now,
})
}
pub fn release(conn: &Connection, issue: &Issue) -> Result<Option<String>> {
let existing = get(conn, issue.id)?;
conn.execute(
"DELETE FROM issue_claims WHERE issue_id = ?1",
params![issue.id],
)?;
Ok(existing.map(|c| c.claimed_by))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::contexts::{issues, projects};
fn setup() -> (Connection, Issue) {
let conn = Connection::open_in_memory().unwrap();
conn.pragma_update(None, "foreign_keys", "ON").unwrap();
crate::migrations::run(&conn).unwrap();
projects::create(
&conn,
projects::CreateProject {
key: "ACME".into(),
name: "Acme".into(),
description: None,
auto_archive_done_after_days: None,
},
)
.unwrap();
let issue = issues::create(
&conn,
"ACME",
issues::CreateIssue {
title: "t".into(),
..Default::default()
},
)
.unwrap();
(conn, issue)
}
#[test]
fn claim_release_round_trip() {
let (conn, issue) = setup();
assert!(get(&conn, issue.id).unwrap().is_none());
claim(&conn, &issue, "session:abc", false).unwrap();
assert_eq!(
get(&conn, issue.id).unwrap().unwrap().claimed_by,
"session:abc"
);
assert_eq!(
release(&conn, &issue).unwrap().as_deref(),
Some("session:abc")
);
assert!(get(&conn, issue.id).unwrap().is_none());
}
#[test]
fn reclaim_by_same_actor_is_idempotent() {
let (conn, issue) = setup();
claim(&conn, &issue, "a", false).unwrap();
claim(&conn, &issue, "a", false).unwrap();
}
#[test]
fn claim_over_another_actor_requires_force() {
let (conn, issue) = setup();
claim(&conn, &issue, "a", false).unwrap();
assert!(claim(&conn, &issue, "b", false).is_err());
claim(&conn, &issue, "b", true).unwrap();
assert_eq!(get(&conn, issue.id).unwrap().unwrap().claimed_by, "b");
}
#[test]
fn releasing_unclaimed_is_a_quiet_no_op() {
let (conn, issue) = setup();
assert_eq!(release(&conn, &issue).unwrap(), None);
}
#[test]
fn ensure_creates_the_table_on_a_ledgerless_db() {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch("CREATE TABLE issues (id INTEGER PRIMARY KEY)")
.unwrap();
ensure(&conn).unwrap();
conn.execute("INSERT INTO issues (id) VALUES (1)", [])
.unwrap();
conn.execute(
"INSERT INTO issue_claims (issue_id, claimed_by, claimed_at) VALUES (1, 'x', 'now')",
[],
)
.unwrap();
}
}