sqlite_graphrag/storage/
foreign_keys.rs1use crate::errors::AppError;
11use rusqlite::{params, Connection};
12use std::collections::{BTreeMap, BTreeSet};
13
14pub(crate) fn foreign_key_violation_counts(
28 conn: &Connection,
29) -> Result<BTreeMap<(String, String), usize>, AppError> {
30 let mut stmt = conn.prepare("PRAGMA foreign_key_check")?;
31 let mut rows = stmt.query([])?;
32 let mut counts: BTreeMap<(String, String), usize> = BTreeMap::new();
33 while let Some(row) = rows.next()? {
34 let child: String = row.get(0)?;
35 let parent: String = row.get(2)?;
36 *counts.entry((child, parent)).or_default() += 1;
37 }
38 Ok(counts)
39}
40
41pub(crate) fn assert_migration_orphaned_nothing(
46 before: &BTreeMap<(String, String), usize>,
47 after: &BTreeMap<(String, String), usize>,
48) -> Result<(), AppError> {
49 for (pair, after_count) in after {
50 let before_count = before.get(pair).copied().unwrap_or(0);
51 if *after_count > before_count {
52 let (child, parent) = pair;
53 return Err(AppError::Internal(anyhow::anyhow!(
54 "migration orphaned rows: `{child}` has {after_count} rows with no parent in \
55 `{parent}`, up from {before_count} before the migration ran. The pre-migration \
56 copy of the database is next to it, named `.bak.pre-schema-<version>.<stamp>`."
57 )));
58 }
59 }
60 Ok(())
61}
62
63pub(crate) fn warn_about_pre_existing_violations(after: &BTreeMap<(String, String), usize>) {
69 for ((child, parent), count) in after {
70 tracing::warn!(
71 target: "storage",
72 child_table = %child,
73 parent_table = %parent,
74 rows = *count,
75 "pre-existing foreign key violations left untouched by this migration; \
76 run `sqlite-graphrag cleanup-orphans --dry-run` to preview the repair"
77 );
78 }
79}
80
81pub fn find_foreign_key_violations(conn: &Connection) -> Result<Vec<(String, i64)>, AppError> {
97 let mut stmt = conn.prepare("PRAGMA foreign_key_check")?;
98 let mut rows = stmt.query([])?;
99 let mut out = Vec::new();
100 while let Some(row) = rows.next()? {
101 let child: String = row.get(0)?;
102 if let Some(rowid) = row.get::<_, Option<i64>>(1)? {
106 out.push((child, rowid));
107 }
108 }
109 Ok(out)
110}
111
112pub fn delete_foreign_key_violations(
122 conn: &Connection,
123 violations: &[(String, i64)],
124) -> Result<usize, AppError> {
125 let known = real_table_names(conn)?;
126 let mut removed = 0usize;
127 for (table, rowid) in violations {
128 if !known.contains(table) {
129 return Err(AppError::Internal(anyhow::anyhow!(
130 "foreign_key_check named a table `{table}` that does not exist"
131 )));
132 }
133 let quoted = table.replace('"', "\"\"");
134 removed += conn.execute(
135 &format!("DELETE FROM \"{quoted}\" WHERE rowid = ?1"),
136 params![rowid],
137 )?;
138 }
139 Ok(removed)
140}
141
142fn real_table_names(conn: &Connection) -> Result<BTreeSet<String>, AppError> {
144 let mut stmt = conn.prepare("SELECT name FROM sqlite_master WHERE type = 'table'")?;
145 let names = stmt
146 .query_map([], |r| r.get::<_, String>(0))?
147 .collect::<Result<BTreeSet<_>, _>>()?;
148 Ok(names)
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154
155 fn violations(pairs: &[(&str, &str, usize)]) -> BTreeMap<(String, String), usize> {
156 pairs
157 .iter()
158 .map(|(c, p, n)| (((*c).to_string(), (*p).to_string()), *n))
159 .collect()
160 }
161
162 #[test]
169 fn pre_existing_violations_do_not_fail_the_migration() {
170 let before = violations(&[("relationships", "entities", 3)]);
171 let after = violations(&[("relationships", "entities", 3)]);
172 assert!(assert_migration_orphaned_nothing(&before, &after).is_ok());
173 }
174
175 #[test]
177 fn a_migration_that_removes_dangling_rows_passes() {
178 let before = violations(&[("relationships", "entities", 5)]);
179 let after = violations(&[("relationships", "entities", 1)]);
180 assert!(assert_migration_orphaned_nothing(&before, &after).is_ok());
181 }
182
183 #[test]
185 fn a_migration_that_orphans_new_rows_still_fails() {
186 let before = violations(&[("relationships", "entities", 1)]);
187 let after = violations(&[("relationships", "entities", 2)]);
188 let err = assert_migration_orphaned_nothing(&before, &after)
189 .expect_err("growth must fail the migration");
190 let text = err.to_string();
191 assert!(text.contains("relationships"), "must name the child table");
192 assert!(
193 text.contains(".bak.pre-schema-"),
194 "must point at the automatic pre-migration copy: {text}"
195 );
196 }
197
198 #[test]
200 fn violations_in_a_table_untouched_before_are_caught() {
201 let before = violations(&[]);
202 let after = violations(&[("memory_entities", "entities", 1)]);
203 assert!(assert_migration_orphaned_nothing(&before, &after).is_err());
204 }
205
206 #[test]
210 fn a_swap_that_keeps_the_total_is_still_caught() {
211 let before = violations(&[("relationships", "entities", 1)]);
212 let after = violations(&[("memory_entities", "entities", 1)]);
213 assert!(
214 assert_migration_orphaned_nothing(&before, &after).is_err(),
215 "equal totals must not hide a new violation in another table"
216 );
217 }
218}