use crate::errors::AppError;
use rusqlite::{params, Connection};
use std::collections::{BTreeMap, BTreeSet};
pub(crate) fn foreign_key_violation_counts(
conn: &Connection,
) -> Result<BTreeMap<(String, String), usize>, AppError> {
let mut stmt = conn.prepare("PRAGMA foreign_key_check")?;
let mut rows = stmt.query([])?;
let mut counts: BTreeMap<(String, String), usize> = BTreeMap::new();
while let Some(row) = rows.next()? {
let child: String = row.get(0)?;
let parent: String = row.get(2)?;
*counts.entry((child, parent)).or_default() += 1;
}
Ok(counts)
}
pub(crate) fn assert_migration_orphaned_nothing(
before: &BTreeMap<(String, String), usize>,
after: &BTreeMap<(String, String), usize>,
) -> Result<(), AppError> {
for (pair, after_count) in after {
let before_count = before.get(pair).copied().unwrap_or(0);
if *after_count > before_count {
let (child, parent) = pair;
return Err(AppError::Internal(anyhow::anyhow!(
"migration orphaned rows: `{child}` has {after_count} rows with no parent in \
`{parent}`, up from {before_count} before the migration ran. The pre-migration \
copy of the database is next to it, named `.bak.pre-schema-<version>.<stamp>`."
)));
}
}
Ok(())
}
pub(crate) fn warn_about_pre_existing_violations(after: &BTreeMap<(String, String), usize>) {
for ((child, parent), count) in after {
tracing::warn!(
target: "storage",
child_table = %child,
parent_table = %parent,
rows = *count,
"pre-existing foreign key violations left untouched by this migration; \
run `sqlite-graphrag cleanup-orphans --dry-run` to preview the repair"
);
}
}
pub fn find_foreign_key_violations(conn: &Connection) -> Result<Vec<(String, i64)>, AppError> {
let mut stmt = conn.prepare("PRAGMA foreign_key_check")?;
let mut rows = stmt.query([])?;
let mut out = Vec::new();
while let Some(row) = rows.next()? {
let child: String = row.get(0)?;
if let Some(rowid) = row.get::<_, Option<i64>>(1)? {
out.push((child, rowid));
}
}
Ok(out)
}
pub fn delete_foreign_key_violations(
conn: &Connection,
violations: &[(String, i64)],
) -> Result<usize, AppError> {
let known = real_table_names(conn)?;
let mut removed = 0usize;
for (table, rowid) in violations {
if !known.contains(table) {
return Err(AppError::Internal(anyhow::anyhow!(
"foreign_key_check named a table `{table}` that does not exist"
)));
}
let quoted = table.replace('"', "\"\"");
removed += conn.execute(
&format!("DELETE FROM \"{quoted}\" WHERE rowid = ?1"),
params![rowid],
)?;
}
Ok(removed)
}
fn real_table_names(conn: &Connection) -> Result<BTreeSet<String>, AppError> {
let mut stmt = conn.prepare("SELECT name FROM sqlite_master WHERE type = 'table'")?;
let names = stmt
.query_map([], |r| r.get::<_, String>(0))?
.collect::<Result<BTreeSet<_>, _>>()?;
Ok(names)
}
#[cfg(test)]
mod tests {
use super::*;
fn violations(pairs: &[(&str, &str, usize)]) -> BTreeMap<(String, String), usize> {
pairs
.iter()
.map(|(c, p, n)| (((*c).to_string(), (*p).to_string()), *n))
.collect()
}
#[test]
fn pre_existing_violations_do_not_fail_the_migration() {
let before = violations(&[("relationships", "entities", 3)]);
let after = violations(&[("relationships", "entities", 3)]);
assert!(assert_migration_orphaned_nothing(&before, &after).is_ok());
}
#[test]
fn a_migration_that_removes_dangling_rows_passes() {
let before = violations(&[("relationships", "entities", 5)]);
let after = violations(&[("relationships", "entities", 1)]);
assert!(assert_migration_orphaned_nothing(&before, &after).is_ok());
}
#[test]
fn a_migration_that_orphans_new_rows_still_fails() {
let before = violations(&[("relationships", "entities", 1)]);
let after = violations(&[("relationships", "entities", 2)]);
let err = assert_migration_orphaned_nothing(&before, &after)
.expect_err("growth must fail the migration");
let text = err.to_string();
assert!(text.contains("relationships"), "must name the child table");
assert!(
text.contains(".bak.pre-schema-"),
"must point at the automatic pre-migration copy: {text}"
);
}
#[test]
fn violations_in_a_table_untouched_before_are_caught() {
let before = violations(&[]);
let after = violations(&[("memory_entities", "entities", 1)]);
assert!(assert_migration_orphaned_nothing(&before, &after).is_err());
}
#[test]
fn a_swap_that_keeps_the_total_is_still_caught() {
let before = violations(&[("relationships", "entities", 1)]);
let after = violations(&[("memory_entities", "entities", 1)]);
assert!(
assert_migration_orphaned_nothing(&before, &after).is_err(),
"equal totals must not hide a new violation in another table"
);
}
}