diff --git a/CHANGELOG.md b/CHANGELOG.md index 19de51e..e85a3f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ All notable changes to this project are documented here. The format is based on ## [Unreleased] +### Fixed + +- Removing files from a large index no longer takes hours: `chunks.symbol_id` and + `symbols.parent_id` are now indexed, so a deleted symbol no longer costs a full scan + of both tables. Existing indexes gain the two indexes the next time they are opened, + with no rebuild. + ## [2.1.2] - 2026-09-25 ### Fixed diff --git a/docs/SCHEMA.md b/docs/SCHEMA.md index 4726a54..3d74b6f 100644 --- a/docs/SCHEMA.md +++ b/docs/SCHEMA.md @@ -44,6 +44,7 @@ CREATE TABLE chunks ( symbol_names TEXT NOT NULL DEFAULT '' -- denormalized symbol name, FTS-indexed (mirrored by triggers) ); CREATE INDEX idx_chunks_file ON chunks(file_id); +CREATE INDEX idx_chunks_symbol ON chunks(symbol_id); -- Symbols extracted via tree-sitter (definitions). CREATE TABLE symbols ( @@ -63,6 +64,7 @@ CREATE TABLE symbols ( CREATE INDEX idx_symbols_name ON symbols(name); CREATE INDEX idx_symbols_file ON symbols(file_id); CREATE INDEX idx_symbols_kind ON symbols(kind); +CREATE INDEX idx_symbols_parent ON symbols(parent_id); -- Graph edges: imports / calls / references / inheritance / dependencies. -- src is always a known symbol or file; dst may be unresolved (dst_id NULL, dst_name kept). diff --git a/src/codebase_index/storage/schema.sql b/src/codebase_index/storage/schema.sql index 6b48e1a..08e4b83 100644 --- a/src/codebase_index/storage/schema.sql +++ b/src/codebase_index/storage/schema.sql @@ -35,6 +35,9 @@ CREATE TABLE IF NOT EXISTS symbols ( CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name); CREATE INDEX IF NOT EXISTS idx_symbols_file ON symbols(file_id); CREATE INDEX IF NOT EXISTS idx_symbols_kind ON symbols(kind); +-- Every deleted symbol looks up its children for ON DELETE SET NULL here and on +-- chunks.symbol_id; without an index each lookup is a full table scan. +CREATE INDEX IF NOT EXISTS idx_symbols_parent ON symbols(parent_id); CREATE TABLE IF NOT EXISTS chunks ( id INTEGER PRIMARY KEY, @@ -52,6 +55,7 @@ CREATE TABLE IF NOT EXISTS chunks ( symbol_names TEXT NOT NULL DEFAULT '' ); CREATE INDEX IF NOT EXISTS idx_chunks_file ON chunks(file_id); +CREATE INDEX IF NOT EXISTS idx_chunks_symbol ON chunks(symbol_id); CREATE TABLE IF NOT EXISTS edges ( id INTEGER PRIMARY KEY, diff --git a/tests/test_storage.py b/tests/test_storage.py index c43effa..25467ee 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -42,6 +42,35 @@ def test_future_schema_version_raises(tmp_path): pass +def test_every_foreign_key_column_leads_an_index(tmp_path): + """Without one, SQLite scans the child table for every deleted parent row.""" + with Database(tmp_path / "index.sqlite") as db: + conn = db.conn + tables = [ + r[0] + for r in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'fts_%'" + ) + ] + for table in tables: + leading = { + conn.execute(f"PRAGMA index_info({index['name']})").fetchone()["name"] + for index in conn.execute(f"PRAGMA index_list({table})").fetchall() + } + for fk in conn.execute(f"PRAGMA foreign_key_list({table})").fetchall(): + assert fk["from"] in leading, f"{table}.{fk['from']} has no index" + + +def test_existing_index_gains_new_indexes_on_open(tmp_path): + db_path = tmp_path / "index.sqlite" + with Database(db_path) as db: + db.conn.execute("DROP INDEX idx_chunks_symbol") + db.conn.execute("DROP INDEX idx_symbols_parent") + with Database(db_path) as db: + names = {r[0] for r in db.conn.execute("SELECT name FROM sqlite_master WHERE type='index'")} + assert {"idx_chunks_symbol", "idx_symbols_parent"} <= names + + def _open(tmp_path): return Database(tmp_path / "index.sqlite").open()