use rusqlite::{OptionalExtension, params, params_from_iter, types::Value};
use crate::storage::StorageError;
use super::code_search::{delete_search_documents_for_paths, delete_search_documents_for_scope};
const MAX_PATH_DELETE_PATHS_PER_STATEMENT: usize = 500;
pub(super) fn delete_scope_index(
transaction: &rusqlite::Transaction<'_>,
source_scope: &str,
) -> Result<(), StorageError> {
for table in [
"code_repository_path_tombstones",
"code_repository_file_diagnostics",
"code_repository_chunks",
"code_repository_calls",
"code_repository_feature_flags",
"code_repository_dependencies",
"code_repository_imports",
"code_repository_references",
"code_repository_symbols",
"code_repository_files",
"software_components",
"software_dependency_usages",
"software_sdk_usages",
"software_files",
"software_topics",
"software_relationships",
"software_global_status",
] {
transaction.execute(
&format!("DELETE FROM {table} WHERE source_scope = ?1"),
params![source_scope],
)?;
}
delete_search_documents_for_scope(transaction, source_scope)?;
Ok(())
}
pub(super) fn delete_path_index(
transaction: &rusqlite::Transaction<'_>,
source_scope: &str,
path: &str,
) -> Result<(), StorageError> {
delete_path_indexes(transaction, source_scope, [path])
}
pub(super) fn path_indexes_exist<'path>(
transaction: &rusqlite::Transaction<'_>,
source_scope: &str,
paths: impl IntoIterator<Item = &'path str>,
) -> Result<bool, StorageError> {
let mut paths = paths.into_iter().collect::<Vec<_>>();
paths.sort_unstable();
paths.dedup();
if paths.is_empty() {
return Ok(false);
}
for path_chunk in paths.chunks(MAX_PATH_DELETE_PATHS_PER_STATEMENT) {
let placeholders = std::iter::repeat_n("?", path_chunk.len())
.collect::<Vec<_>>()
.join(", ");
let mut values = Vec::with_capacity(path_chunk.len() + 1);
values.push(Value::Text(source_scope.to_owned()));
values.extend(
path_chunk
.iter()
.map(|path| Value::Text((*path).to_owned())),
);
let existing = transaction
.query_row(
&format!(
"SELECT 1 FROM code_repository_files WHERE source_scope = ? AND path IN ({placeholders}) LIMIT 1"
),
params_from_iter(values),
|_| Ok(()),
)
.optional()?;
if existing.is_some() {
return Ok(true);
}
}
Ok(false)
}
pub(super) fn delete_path_indexes<'path>(
transaction: &rusqlite::Transaction<'_>,
source_scope: &str,
paths: impl IntoIterator<Item = &'path str>,
) -> Result<(), StorageError> {
let mut paths = paths.into_iter().collect::<Vec<_>>();
paths.sort_unstable();
paths.dedup();
if paths.is_empty() {
return Ok(());
}
for table in [
"code_repository_file_diagnostics",
"code_repository_chunks",
"code_repository_calls",
"code_repository_feature_flags",
"code_repository_dependencies",
"code_repository_imports",
"code_repository_references",
"code_repository_symbols",
"code_repository_files",
] {
for path_chunk in paths.chunks(MAX_PATH_DELETE_PATHS_PER_STATEMENT) {
let placeholders = std::iter::repeat_n("?", path_chunk.len())
.collect::<Vec<_>>()
.join(", ");
let mut values = Vec::with_capacity(path_chunk.len() + 1);
values.push(Value::Text(source_scope.to_owned()));
values.extend(
path_chunk
.iter()
.map(|path| Value::Text((*path).to_owned())),
);
transaction.execute(
&format!("DELETE FROM {table} WHERE source_scope = ? AND path IN ({placeholders})"),
params_from_iter(values),
)?;
}
}
delete_search_documents_for_paths(transaction, source_scope, paths)?;
Ok(())
}
pub(super) fn count_code_rows(
transaction: &rusqlite::Transaction<'_>,
table: &'static str,
source_scope: &str,
) -> Result<usize, StorageError> {
transaction
.query_row(
&format!("SELECT COUNT(*) FROM {table} WHERE source_scope = ?1"),
params![source_scope],
|row| row.get(0),
)
.map_err(StorageError::from)
}
#[cfg(test)]
mod tests {
use rusqlite::Connection;
use super::*;
const PATH_TABLES: &[&str] = &[
"code_repository_file_diagnostics",
"code_repository_chunks",
"code_repository_calls",
"code_repository_feature_flags",
"code_repository_dependencies",
"code_repository_imports",
"code_repository_references",
"code_repository_symbols",
"code_repository_files",
];
const SCOPE_TABLES: &[&str] = &[
"code_repository_path_tombstones",
"code_repository_file_diagnostics",
"code_repository_chunks",
"code_repository_calls",
"code_repository_feature_flags",
"code_repository_dependencies",
"code_repository_imports",
"code_repository_references",
"code_repository_symbols",
"code_repository_files",
"software_components",
"software_dependency_usages",
"software_sdk_usages",
"software_files",
"software_topics",
"software_relationships",
"software_global_status",
];
#[test]
fn delete_scope_index_removes_software_projection_tables() {
let mut connection = Connection::open_in_memory().expect("connection should open");
for table in SCOPE_TABLES {
connection
.execute(
&format!("CREATE TABLE {table} (source_scope TEXT NOT NULL)"),
[],
)
.expect("table should create");
connection
.execute(
&format!("INSERT INTO {table} (source_scope) VALUES ('scope'), ('other')"),
[],
)
.expect("rows should insert");
}
connection
.execute(
"
CREATE VIRTUAL TABLE code_repository_search USING fts5(
source_scope UNINDEXED,
document_kind UNINDEXED,
record_id UNINDEXED,
path UNINDEXED,
language_id UNINDEXED,
content
)
",
[],
)
.expect("search table should create");
create_search_metadata_table(&connection);
connection
.execute(
"
INSERT INTO code_repository_search (
source_scope, document_kind, record_id, path, language_id, content
)
VALUES ('scope', 'symbol', 'a', 'src/a.rs', 'rust', 'target'),
('other', 'symbol', 'b', 'src/b.rs', 'rust', 'target')
",
[],
)
.expect("search rows should insert");
backfill_search_metadata(&connection);
let transaction = connection.transaction().expect("transaction should open");
delete_scope_index(&transaction, "scope").expect("scope should delete");
transaction.commit().expect("transaction should commit");
for table in SCOPE_TABLES
.iter()
.copied()
.chain(["code_repository_search"])
{
let deleted_remaining = connection
.query_row(
&format!("SELECT COUNT(*) FROM {table} WHERE source_scope = 'scope'"),
[],
|row| row.get::<_, usize>(0),
)
.expect("deleted row count should load");
let retained_remaining = connection
.query_row(
&format!("SELECT COUNT(*) FROM {table} WHERE source_scope = 'other'"),
[],
|row| row.get::<_, usize>(0),
)
.expect("retained row count should load");
assert_eq!(deleted_remaining, 0, "{table} should delete pruned scope");
assert_eq!(retained_remaining, 1, "{table} should keep other scope");
}
}
#[test]
fn delete_path_indexes_removes_multiple_paths_from_all_path_tables() {
let mut connection = Connection::open_in_memory().expect("connection should open");
for table in PATH_TABLES {
connection
.execute(
&format!(
"CREATE TABLE {table} (source_scope TEXT NOT NULL, path TEXT NOT NULL)"
),
[],
)
.expect("table should create");
}
connection
.execute(
"
CREATE VIRTUAL TABLE code_repository_search USING fts5(
source_scope UNINDEXED,
document_kind UNINDEXED,
record_id UNINDEXED,
path UNINDEXED,
language_id UNINDEXED,
content
)
",
[],
)
.expect("search table should create");
create_search_metadata_table(&connection);
for path in ["src/a.rs", "src/b.rs", "src/c.rs"] {
for table in PATH_TABLES {
connection
.execute(
&format!("INSERT INTO {table} (source_scope, path) VALUES (?1, ?2)"),
rusqlite::params!["scope", path],
)
.expect("path row should insert");
}
connection
.execute(
"
INSERT INTO code_repository_search (
source_scope, document_kind, record_id, path, language_id, content
)
VALUES (?1, 'symbol', ?2, ?2, 'rust', 'target')
",
rusqlite::params!["scope", path],
)
.expect("search row should insert");
}
backfill_search_metadata(&connection);
let transaction = connection.transaction().expect("transaction should open");
assert!(
path_indexes_exist(&transaction, "scope", ["src/a.rs", "src/b.rs"])
.expect("path existence should load")
);
assert!(
!path_indexes_exist(&transaction, "scope", ["src/missing.rs"])
.expect("missing path existence should load")
);
delete_path_indexes(&transaction, "scope", ["src/a.rs", "src/b.rs", "src/a.rs"])
.expect("paths should delete");
transaction.commit().expect("transaction should commit");
for table in PATH_TABLES
.iter()
.copied()
.chain(["code_repository_search"])
{
let remaining = connection
.query_row(
&format!("SELECT COUNT(*) FROM {table} WHERE source_scope = 'scope'"),
[],
|row| row.get::<_, usize>(0),
)
.expect("remaining row count should load");
assert_eq!(remaining, 1, "{table} should keep only the unmatched path");
}
}
fn create_search_metadata_table(connection: &Connection) {
connection
.execute(
"
CREATE TABLE code_repository_search_metadata (
source_scope TEXT NOT NULL,
document_kind TEXT NOT NULL,
record_id TEXT NOT NULL,
path TEXT NOT NULL,
search_rowid INTEGER NOT NULL UNIQUE,
PRIMARY KEY (source_scope, document_kind, record_id)
)
",
[],
)
.expect("search metadata table should create");
}
fn backfill_search_metadata(connection: &Connection) {
connection
.execute(
"
INSERT INTO code_repository_search_metadata (
source_scope, document_kind, record_id, path, search_rowid
)
SELECT source_scope, document_kind, record_id, path, rowid
FROM code_repository_search
",
[],
)
.expect("search metadata rows should insert");
}
}