Skip to main content

sqlite_graphrag/storage/
foreign_keys.rs

1//! `PRAGMA foreign_key_check`: measuring it, judging it, and repairing it.
2//!
3//! Split out of `connection.rs` when that file crossed the 800-line ceiling.
4//! The split follows the seam the gate asks for rather than convenience:
5//! opening a database and applying pragmas is one responsibility, deciding
6//! whether the file satisfies its own foreign keys is another, and the second
7//! is the one that grew — a guard, a repair, and the tests that keep the guard
8//! from being wider than the action it verifies.
9
10use crate::errors::AppError;
11use rusqlite::{params, Connection};
12use std::collections::{BTreeMap, BTreeSet};
13
14/// Counts `PRAGMA foreign_key_check` rows grouped by child and parent table.
15///
16/// Written as a query rather than `execute_batch` on purpose: the pragma
17/// reports violations as a *result set*, never as an error, so batching it
18/// inside a `.sql` migration — as `V010` does — discards the answer and
19/// verifies nothing.
20///
21/// Grouping by table pair rather than totalling is what lets the comparison
22/// survive a migration that deletes one dangling row and creates another: the
23/// total would match while a real regression hid inside it.
24///
25/// # Errors
26/// Returns `Err` when the pragma cannot be prepared or read.
27pub(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
41/// Fails when a migration left MORE rows orphaned than it found.
42///
43/// # Errors
44/// Returns `Err` naming the first table pair whose violation count grew.
45pub(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
63/// Warns, without failing, about violations the migration inherited.
64///
65/// Emitted on stderr through tracing so the JSON contract on stdout is
66/// untouched. Naming `cleanup-orphans` matters: the state is repairable, and a
67/// warning that does not say how to repair it trains the reader to ignore it.
68pub(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
81/// Every `PRAGMA foreign_key_check` row, as `(child table, rowid)`.
82///
83/// Table-agnostic on purpose. The guard above knows about every child table in
84/// the schema — eleven of them reference `memories`, `entities`, `relationships`
85/// or `memory_chunks` — so a repair that knows about only one leaves the warning
86/// pointing at a command that cannot deliver, and goes stale the day a migration
87/// adds the twelfth. A rebuild-and-rename of `entities` orphans four tables at
88/// once, not just `relationships`, which is exactly how this state is produced.
89///
90/// A row reported here is unreachable by construction: with enforcement on,
91/// SQLite would have cascaded it away when its parent was deleted. Removing it
92/// destroys no reachable data.
93///
94/// # Errors
95/// Returns `Err` when the pragma cannot be prepared or read.
96pub 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        // NULL only for a WITHOUT ROWID child. This schema declares none, and
103        // skipping rather than failing keeps a future one from bricking the
104        // repair for every other table.
105        if let Some(rowid) = row.get::<_, Option<i64>>(1)? {
106            out.push((child, rowid));
107        }
108    }
109    Ok(out)
110}
111
112/// Deletes the rows reported by [`find_foreign_key_violations`].
113///
114/// The table name arrives from the pragma, so it already comes from
115/// `sqlite_master`; it is still checked against the live table list before
116/// being interpolated, because a name reaching SQL by string concatenation
117/// deserves a witness rather than an assumption.
118///
119/// # Errors
120/// Returns `Err` when a delete fails, or when a reported table does not exist.
121pub 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
142/// Names of the ordinary tables in this database.
143fn 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    /// A row that was already dangling is not evidence against this migration.
163    ///
164    /// This is the case that bricked whole databases: `PRAGMA foreign_key_check`
165    /// scans the entire file, `ensure_db_ready` migrates on open, and nearly
166    /// every subcommand calls it — so one inherited row refused every command,
167    /// including `cleanup-orphans`, which is the repair.
168    #[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    /// Fewer than before is a repair, never a regression.
176    #[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    /// The assertion still has to catch the cascade it was written for.
184    #[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    /// A table pair that appears only after the migration starts from zero.
199    #[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    /// Grouping by table pair is what makes the comparison honest: a migration
207    /// that deletes one dangling row and creates another keeps the TOTAL equal
208    /// while a real regression hides inside it.
209    #[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}