use std::path::Path;
use std::sync::Arc;
use anyhow::Result;
use duckdb::types::Value as DuckValue;
use crate::storage::engine::StorageEngine;
use super::{SignalType, TheologianFinding};
const INIT_SQL: &str = "
CREATE TABLE IF NOT EXISTS theologian_findings (
id TEXT NOT NULL PRIMARY KEY,
book_slug TEXT NOT NULL,
signal_type TEXT NOT NULL,
chapter_ord INTEGER NOT NULL,
para_id TEXT NOT NULL,
description TEXT NOT NULL,
suppressed INTEGER NOT NULL DEFAULT 0,
text_hash TEXT NOT NULL,
emitted_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_tf_book ON theologian_findings(book_slug);
";
fn as_text(v: Option<&DuckValue>) -> Option<String> {
match v {
Some(DuckValue::Text(s)) => Some(s.clone()),
_ => None,
}
}
fn as_i64(v: Option<&DuckValue>) -> Option<i64> {
match v {
Some(DuckValue::Int(i)) => Some(*i as i64),
Some(DuckValue::BigInt(i)) => Some(*i),
Some(DuckValue::HugeInt(i)) => Some(*i as i64),
_ => None,
}
}
pub(crate) struct TheologianStore {
engine: Arc<StorageEngine>,
}
impl TheologianStore {
pub(crate) fn open(project_root: &Path) -> Result<Self> {
let path = project_root.join("inner_theologian.db");
Ok(Self { engine: Arc::new(StorageEngine::new(&path, INIT_SQL, 2)?) })
}
pub(crate) fn clear_chapter(&self, book_slug: &str, chapter_ord: u32) -> Result<()> {
let bs = book_slug.to_string();
let ord = chapter_ord as i64;
let params: Vec<&dyn duckdb::ToSql> = vec![&bs, &ord];
self.engine.execute_with(
"DELETE FROM theologian_findings WHERE book_slug = ? AND chapter_ord = ?",
¶ms,
)
}
pub(crate) fn upsert_finding(
&self,
book_slug: &str,
f: &TheologianFinding,
text_hash: u64,
now: &str,
) -> Result<()> {
let id = format!("{book_slug}:{}:{}", f.para_id, f.signal_type.as_code());
let bs = book_slug.to_string();
let st = f.signal_type.as_code().to_string();
let ord = f.chapter_ord as i64;
let pid = f.para_id.clone();
let desc = f.description.clone();
let supp = i64::from(f.suppressed);
let th = text_hash.to_string();
let at = now.to_string();
let params: Vec<&dyn duckdb::ToSql> =
vec![&id, &bs, &st, &ord, &pid, &desc, &supp, &th, &at];
self.engine.execute_with(
"INSERT OR REPLACE INTO theologian_findings \
(id, book_slug, signal_type, chapter_ord, para_id, description, suppressed, text_hash, emitted_at) \
VALUES (?,?,?,?,?,?,?,?,?)",
¶ms,
)
}
pub(crate) fn findings(&self, book_slug: &str, include_suppressed: bool) -> Result<Vec<TheologianFinding>> {
let bs = book_slug.to_string();
let sql = if include_suppressed {
"SELECT signal_type, chapter_ord, para_id, description, suppressed \
FROM theologian_findings WHERE book_slug = ? ORDER BY chapter_ord, para_id"
} else {
"SELECT signal_type, chapter_ord, para_id, description, suppressed \
FROM theologian_findings WHERE book_slug = ? AND suppressed = 0 ORDER BY chapter_ord, para_id"
};
let rows = self.engine.select_all_with(sql, &[&bs])?;
Ok(rows.iter().filter_map(row_to_finding).collect())
}
pub(crate) fn findings_for_chapter(&self, book_slug: &str, chapter_ord: u32) -> Result<Vec<TheologianFinding>> {
let bs = book_slug.to_string();
let ord = chapter_ord as i64;
let rows = self.engine.select_all_with(
"SELECT signal_type, chapter_ord, para_id, description, suppressed \
FROM theologian_findings WHERE book_slug = ? AND chapter_ord = ? AND suppressed = 0 \
ORDER BY para_id",
&[&bs, &ord],
)?;
Ok(rows.iter().filter_map(row_to_finding).collect())
}
pub(crate) fn suppress_paragraph(&self, book_slug: &str, para_id: &str) -> Result<usize> {
let bs = book_slug.to_string();
let pid = para_id.to_string();
let before = self.findings(&bs, false)?.iter().filter(|f| f.para_id == para_id).count();
let params: Vec<&dyn duckdb::ToSql> = vec![&bs, &pid];
self.engine.execute_with(
"UPDATE theologian_findings SET suppressed = 1 WHERE book_slug = ? AND para_id = ?",
¶ms,
)?;
Ok(before)
}
}
fn row_to_finding(r: &Vec<DuckValue>) -> Option<TheologianFinding> {
Some(TheologianFinding {
signal_type: SignalType::from_code(&as_text(r.first())?)?,
chapter_ord: as_i64(r.get(1))? as u32,
para_id: as_text(r.get(2))?,
description: as_text(r.get(3))?,
suppressed: as_i64(r.get(4)).unwrap_or(0) != 0,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn mk(sig: SignalType, ch: u32, para: &str) -> TheologianFinding {
TheologianFinding {
signal_type: sig,
chapter_ord: ch,
para_id: para.to_string(),
description: format!("{} at {para}", sig.as_code()),
suppressed: false,
}
}
#[test]
fn insert_query_clear_suppress_roundtrip() -> Result<()> {
let dir = std::env::temp_dir().join(format!("it-store-{}", std::process::id()));
std::fs::create_dir_all(&dir).ok();
let s = TheologianStore::open(&dir)?;
s.upsert_finding("bk", &mk(SignalType::MoralInvisibility, 12, "p1"), 1, "now")?;
s.upsert_finding("bk", &mk(SignalType::ConsequenceGap, 12, "p2"), 2, "now")?;
s.upsert_finding("bk", &mk(SignalType::SacredLevity, 7, "p3"), 3, "now")?;
assert_eq!(s.findings("bk", false)?.len(), 3);
assert_eq!(s.findings_for_chapter("bk", 12)?.len(), 2);
assert_eq!(s.suppress_paragraph("bk", "p1")?, 1);
assert_eq!(s.findings("bk", false)?.len(), 2);
assert_eq!(s.findings("bk", true)?.len(), 3);
s.clear_chapter("bk", 12)?;
assert_eq!(s.findings("bk", true)?.iter().filter(|f| f.chapter_ord == 12).count(), 0);
assert_eq!(s.findings("bk", true)?.len(), 1);
s.upsert_finding("bk", &mk(SignalType::SacredLevity, 7, "p3"), 9, "later")?;
assert_eq!(s.findings("bk", true)?.iter().filter(|f| f.para_id == "p3").count(), 1);
let _ = std::fs::remove_dir_all(&dir);
Ok(())
}
#[test]
fn suppress_unknown_paragraph_returns_zero() -> Result<()> {
let dir = std::env::temp_dir().join(format!("it-store2-{}", std::process::id()));
std::fs::create_dir_all(&dir).ok();
let s = TheologianStore::open(&dir)?;
assert_eq!(s.suppress_paragraph("bk", "nope")?, 0);
let _ = std::fs::remove_dir_all(&dir);
Ok(())
}
}