Skip to main content

next_plaid/
filtering.rs

1//! SQLite-based metadata filtering for next-plaid indices.
2//!
3//! This module provides functionality for storing, querying, and managing
4//! document metadata using SQLite, enabling efficient filtering during search.
5//!
6//! The API matches fast-plaid's `filtering.py` for compatibility.
7//!
8//! # Example
9//!
10//! ```ignore
11//! use next-plaid::filtering;
12//! use serde_json::json;
13//!
14//! // Create metadata for documents
15//! let metadata = vec![
16//!     json!({"name": "Alice", "category": "A", "score": 95}),
17//!     json!({"name": "Bob", "category": "B", "score": 87}),
18//! ];
19//!
20//! // Create metadata database
21//! filtering::create("my_index", &metadata)?;
22//!
23//! // Query documents matching a condition
24//! let subset = filtering::where_condition(
25//!     "my_index",
26//!     "category = ? AND score > ?",
27//!     &[json!("A"), json!(90)],
28//! )?;
29//!
30//! // Use subset in search
31//! let results = index.search(&query, &params, Some(&subset))?;
32//! ```
33
34use std::collections::{HashMap, HashSet};
35use std::fs;
36use std::path::{Path, PathBuf};
37use std::sync::{Arc, Mutex, OnceLock};
38
39use regex::Regex;
40use rusqlite::{params_from_iter, Connection, OpenFlags, Result as SqliteResult, ToSql};
41use serde_json::Value;
42
43use crate::error::{Error, Result};
44
45/// Database file name within the index directory.
46pub(crate) const METADATA_DB_NAME: &str = "metadata.db";
47const SQLITE_PARAM_LIMIT: usize = 900;
48
49/// Primary key column name (matches fast-plaid).
50pub(crate) const SUBSET_COLUMN: &str = "_subset_";
51
52/// Index over `_subset_` used by the fast-delete (v1) layout.
53const SUBSET_INDEX_NAME: &str = "idx_metadata_subset";
54
55/// `PRAGMA user_version` value marking the fast-delete metadata layout, in which
56/// `_subset_` is a regular indexed column rather than the `INTEGER PRIMARY KEY`
57/// (rowid). Re-sequencing on delete then updates only a small integer + its index
58/// instead of relocating each (potentially multi-KB) row in the table b-tree.
59///
60/// The `SELECT *` projection is unchanged versus the legacy (v0) layout — the
61/// demoted `_subset_` still appears, and the implicit rowid is hidden — so older
62/// binaries (e.g. a deployed next-plaid-api) read a v1 index without modification.
63const METADATA_SCHEMA_V1: i64 = 1;
64
65/// `PRAGMA user_version` value for the thin/fat split layout. METADATA holds only
66/// small filterable columns + `_content_id_` FK; METADATA_CONTENT holds the large
67/// TEXT columns (code, signature, etc). Re-sequencing on delete touches only the
68/// thin table, making it position-independent regardless of row count.
69pub(crate) const METADATA_SCHEMA_V2: i64 = 2;
70
71/// Content table name for the v2 split layout.
72pub(crate) const CONTENT_TABLE: &str = "METADATA_CONTENT";
73
74/// Column linking METADATA to METADATA_CONTENT in the v2 layout.
75pub(crate) const CONTENT_ID_COLUMN: &str = "_content_id_";
76
77/// Columns that belong in the thin METADATA table (v2).
78/// All other columns go into METADATA_CONTENT.
79const THIN_COLUMNS: &[&str] = &[
80    "file",
81    "name",
82    "qualified_name",
83    "line",
84    "end_line",
85    "language",
86    "unit_type",
87    "complexity",
88    "has_loops",
89    "has_branches",
90    "has_error_handling",
91];
92
93/// Validate that a column name is a safe SQL identifier.
94///
95/// Column names must start with a letter or underscore, followed by
96/// letters, digits, or underscores. This prevents SQL injection.
97fn is_valid_column_name(name: &str) -> bool {
98    lazy_static_regex().is_match(name)
99}
100
101fn lazy_static_regex() -> &'static Regex {
102    use std::sync::OnceLock;
103    static REGEX: OnceLock<Regex> = OnceLock::new();
104    REGEX.get_or_init(|| Regex::new(r"^[a-zA-Z_][a-zA-Z0-9_]*$").unwrap())
105}
106
107// =============================================================================
108// SQL Condition Validator
109// =============================================================================
110//
111// This module provides a safe SQL condition validator using a tokenizer and
112// recursive descent parser. It whitelists safe SQL operators and validates
113// column names against the database schema to prevent SQL injection.
114
115/// Token types for SQL condition parsing.
116#[derive(Debug, Clone, PartialEq)]
117enum Token {
118    Identifier(String),
119    Placeholder, // ?
120    // Comparison operators
121    Eq, // =
122    Ne, // != or <>
123    Lt, // <
124    Le, // <=
125    Gt, // >
126    Ge, // >=
127    // Keywords
128    Like,
129    Regexp,
130    Between,
131    In,
132    And,
133    Or,
134    Not,
135    Is,
136    Null,
137    // Delimiters
138    LParen,
139    RParen,
140    Comma,
141    // End of input
142    Eof,
143}
144
145/// Quick safety check to reject obviously dangerous patterns before tokenization.
146fn quick_safety_check(condition: &str) -> Result<()> {
147    let upper = condition.to_uppercase();
148
149    // Check for comment syntax
150    if condition.contains("--") || condition.contains("/*") || condition.contains("*/") {
151        return Err(Error::Filtering(
152            "SQL comments are not allowed in conditions".into(),
153        ));
154    }
155
156    // Check for statement terminators
157    if condition.contains(';') {
158        return Err(Error::Filtering(
159            "Semicolons are not allowed in conditions".into(),
160        ));
161    }
162
163    // Check for dangerous SQL keywords (must be whole words)
164    let dangerous_keywords = [
165        "SELECT", "UNION", "INSERT", "UPDATE", "DELETE", "DROP", "CREATE", "ALTER", "TRUNCATE",
166        "EXEC", "EXECUTE", "GRANT", "REVOKE",
167    ];
168
169    for keyword in dangerous_keywords {
170        // Check if keyword appears as a whole word
171        let pattern = format!(r"\b{}\b", keyword);
172        if Regex::new(&pattern).unwrap().is_match(&upper) {
173            return Err(Error::Filtering(format!(
174                "SQL keyword '{}' is not allowed in conditions",
175                keyword
176            )));
177        }
178    }
179
180    Ok(())
181}
182
183/// Tokenize a SQL condition string into tokens.
184fn tokenize(input: &str) -> Result<Vec<Token>> {
185    let mut tokens = Vec::new();
186    let chars: Vec<char> = input.chars().collect();
187    let mut pos = 0;
188
189    while pos < chars.len() {
190        // Skip whitespace
191        if chars[pos].is_whitespace() {
192            pos += 1;
193            continue;
194        }
195
196        // Single-character tokens
197        match chars[pos] {
198            '?' => {
199                tokens.push(Token::Placeholder);
200                pos += 1;
201                continue;
202            }
203            '(' => {
204                tokens.push(Token::LParen);
205                pos += 1;
206                continue;
207            }
208            ')' => {
209                tokens.push(Token::RParen);
210                pos += 1;
211                continue;
212            }
213            ',' => {
214                tokens.push(Token::Comma);
215                pos += 1;
216                continue;
217            }
218            '=' => {
219                tokens.push(Token::Eq);
220                pos += 1;
221                continue;
222            }
223            _ => {}
224        }
225
226        // Two-character operators
227        if pos + 1 < chars.len() {
228            let two_chars: String = chars[pos..pos + 2].iter().collect();
229            match two_chars.as_str() {
230                "!=" => {
231                    tokens.push(Token::Ne);
232                    pos += 2;
233                    continue;
234                }
235                "<>" => {
236                    tokens.push(Token::Ne);
237                    pos += 2;
238                    continue;
239                }
240                "<=" => {
241                    tokens.push(Token::Le);
242                    pos += 2;
243                    continue;
244                }
245                ">=" => {
246                    tokens.push(Token::Ge);
247                    pos += 2;
248                    continue;
249                }
250                _ => {}
251            }
252        }
253
254        // Single-character comparison operators (checked after two-char)
255        match chars[pos] {
256            '<' => {
257                tokens.push(Token::Lt);
258                pos += 1;
259                continue;
260            }
261            '>' => {
262                tokens.push(Token::Gt);
263                pos += 1;
264                continue;
265            }
266            _ => {}
267        }
268
269        // Identifiers and keywords
270        if chars[pos].is_alphabetic() || chars[pos] == '_' {
271            let start = pos;
272            while pos < chars.len() && (chars[pos].is_alphanumeric() || chars[pos] == '_') {
273                pos += 1;
274            }
275            let word: String = chars[start..pos].iter().collect();
276            let upper = word.to_uppercase();
277
278            let token = match upper.as_str() {
279                "AND" => Token::And,
280                "OR" => Token::Or,
281                "NOT" => Token::Not,
282                "IS" => Token::Is,
283                "NULL" => Token::Null,
284                "LIKE" => Token::Like,
285                "REGEXP" => Token::Regexp,
286                "BETWEEN" => Token::Between,
287                "IN" => Token::In,
288                _ => Token::Identifier(word),
289            };
290            tokens.push(token);
291            continue;
292        }
293
294        // Quoted identifier (double quotes)
295        if chars[pos] == '"' {
296            pos += 1; // skip opening quote
297            let start = pos;
298            while pos < chars.len() && chars[pos] != '"' {
299                pos += 1;
300            }
301            if pos >= chars.len() {
302                return Err(Error::Filtering("Unterminated quoted identifier".into()));
303            }
304            let word: String = chars[start..pos].iter().collect();
305            tokens.push(Token::Identifier(word));
306            pos += 1; // skip closing quote
307            continue;
308        }
309
310        // Reject unexpected characters
311        return Err(Error::Filtering(format!(
312            "Unexpected character '{}' in condition",
313            chars[pos]
314        )));
315    }
316
317    tokens.push(Token::Eof);
318    Ok(tokens)
319}
320
321/// Recursive descent parser/validator for SQL conditions.
322struct ConditionValidator<'a> {
323    tokens: &'a [Token],
324    pos: usize,
325    valid_columns: &'a HashSet<String>,
326    columns_used: Vec<String>,
327}
328
329impl<'a> ConditionValidator<'a> {
330    fn new(tokens: &'a [Token], valid_columns: &'a HashSet<String>) -> Self {
331        Self {
332            tokens,
333            pos: 0,
334            valid_columns,
335            columns_used: Vec::new(),
336        }
337    }
338
339    fn current(&self) -> &Token {
340        self.tokens.get(self.pos).unwrap_or(&Token::Eof)
341    }
342
343    fn advance(&mut self) {
344        if self.pos < self.tokens.len() {
345            self.pos += 1;
346        }
347    }
348
349    fn expect(&mut self, expected: &Token) -> Result<()> {
350        if self.current() == expected {
351            self.advance();
352            Ok(())
353        } else {
354            Err(Error::Filtering(format!(
355                "Expected {:?}, found {:?}",
356                expected,
357                self.current()
358            )))
359        }
360    }
361
362    /// Validate the entire condition.
363    fn validate(&mut self) -> Result<()> {
364        self.parse_expr()?;
365        if *self.current() != Token::Eof {
366            return Err(Error::Filtering(format!(
367                "Unexpected token {:?} after expression",
368                self.current()
369            )));
370        }
371        Ok(())
372    }
373
374    /// expr = and_expr (OR and_expr)*
375    fn parse_expr(&mut self) -> Result<()> {
376        self.parse_and_expr()?;
377        while *self.current() == Token::Or {
378            self.advance();
379            self.parse_and_expr()?;
380        }
381        Ok(())
382    }
383
384    /// and_expr = unary_expr (AND unary_expr)*
385    fn parse_and_expr(&mut self) -> Result<()> {
386        self.parse_unary_expr()?;
387        while *self.current() == Token::And {
388            self.advance();
389            self.parse_unary_expr()?;
390        }
391        Ok(())
392    }
393
394    /// unary_expr = NOT? primary_expr
395    fn parse_unary_expr(&mut self) -> Result<()> {
396        if *self.current() == Token::Not {
397            self.advance();
398        }
399        self.parse_primary_expr()
400    }
401
402    /// primary_expr = comparison | null_check | between_expr | in_expr | "(" expr ")"
403    fn parse_primary_expr(&mut self) -> Result<()> {
404        // Parenthesized expression
405        if *self.current() == Token::LParen {
406            self.advance();
407            self.parse_expr()?;
408            self.expect(&Token::RParen)?;
409            return Ok(());
410        }
411
412        // Must start with an identifier
413        let col_name = match self.current().clone() {
414            Token::Identifier(name) => name,
415            other => {
416                return Err(Error::Filtering(format!(
417                    "Expected column name, found {:?}",
418                    other
419                )))
420            }
421        };
422
423        // Validate column name against schema
424        // Case-insensitive comparison
425        let col_lower = col_name.to_lowercase();
426        let valid = self
427            .valid_columns
428            .iter()
429            .any(|c| c.to_lowercase() == col_lower);
430        if !valid {
431            return Err(Error::Filtering(format!(
432                "Unknown column '{}' in condition",
433                col_name
434            )));
435        }
436        self.columns_used.push(col_name);
437        self.advance();
438
439        // Determine what follows the identifier
440        match self.current() {
441            // IS [NOT] NULL
442            Token::Is => {
443                self.advance();
444                if *self.current() == Token::Not {
445                    self.advance();
446                }
447                self.expect(&Token::Null)?;
448            }
449
450            // [NOT] BETWEEN ? AND ?
451            Token::Not => {
452                self.advance();
453                match self.current() {
454                    Token::Between => {
455                        self.advance();
456                        self.expect(&Token::Placeholder)?;
457                        self.expect(&Token::And)?;
458                        self.expect(&Token::Placeholder)?;
459                    }
460                    Token::In => {
461                        self.advance();
462                        self.parse_in_list()?;
463                    }
464                    Token::Like => {
465                        self.advance();
466                        self.expect(&Token::Placeholder)?;
467                    }
468                    Token::Regexp => {
469                        self.advance();
470                        self.expect(&Token::Placeholder)?;
471                    }
472                    _ => {
473                        return Err(Error::Filtering(format!(
474                            "Expected BETWEEN, IN, LIKE, or REGEXP after NOT, found {:?}",
475                            self.current()
476                        )));
477                    }
478                }
479            }
480
481            Token::Between => {
482                self.advance();
483                self.expect(&Token::Placeholder)?;
484                self.expect(&Token::And)?;
485                self.expect(&Token::Placeholder)?;
486            }
487
488            // [NOT] IN (?, ?, ...)
489            Token::In => {
490                self.advance();
491                self.parse_in_list()?;
492            }
493
494            // [NOT] LIKE ?
495            Token::Like => {
496                self.advance();
497                self.expect(&Token::Placeholder)?;
498            }
499
500            // [NOT] REGEXP ?
501            Token::Regexp => {
502                self.advance();
503                self.expect(&Token::Placeholder)?;
504            }
505
506            // Comparison operators: = != <> < <= > >=
507            Token::Eq | Token::Ne | Token::Lt | Token::Le | Token::Gt | Token::Ge => {
508                self.advance();
509                self.expect(&Token::Placeholder)?;
510            }
511
512            other => {
513                return Err(Error::Filtering(format!(
514                    "Expected operator after column name, found {:?}",
515                    other
516                )));
517            }
518        }
519
520        Ok(())
521    }
522
523    /// Parse IN list: (?, ?, ...)
524    fn parse_in_list(&mut self) -> Result<()> {
525        self.expect(&Token::LParen)?;
526        self.expect(&Token::Placeholder)?;
527        while *self.current() == Token::Comma {
528            self.advance();
529            self.expect(&Token::Placeholder)?;
530        }
531        self.expect(&Token::RParen)?;
532        Ok(())
533    }
534}
535
536/// Get column names from the database schema.
537fn get_schema_columns(conn: &Connection) -> Result<HashSet<String>> {
538    let mut columns = HashSet::new();
539    let split = is_split_schema(conn);
540    let mut stmt = conn.prepare("PRAGMA table_info(METADATA)")?;
541    let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
542    for row in rows {
543        let col = row?;
544        if split && col == CONTENT_ID_COLUMN {
545            continue;
546        }
547        columns.insert(col);
548    }
549    // For v2, also include columns from the content table.
550    if split {
551        let mut stmt = conn.prepare(&format!("PRAGMA table_info({})", CONTENT_TABLE))?;
552        let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
553        for row in rows {
554            let col = row?;
555            if col != CONTENT_ID_COLUMN {
556                columns.insert(col);
557            }
558        }
559    }
560    Ok(columns)
561}
562
563/// Validate a SQL WHERE condition against the allowed grammar and schema.
564///
565/// This function performs security validation on user-provided SQL conditions:
566/// 1. Quick safety check rejects dangerous patterns (comments, semicolons, DDL keywords)
567/// 2. Tokenization converts the condition to a safe token stream
568/// 3. Recursive descent parsing validates the condition against an allowlist grammar
569/// 4. Column validation ensures only known columns are referenced
570///
571/// # Allowed Grammar
572///
573/// ```text
574/// condition    = expr
575/// expr         = and_expr (OR and_expr)*
576/// and_expr     = unary_expr (AND unary_expr)*
577/// unary_expr   = NOT? primary_expr
578/// primary_expr = comparison | null_check | between_expr | in_expr | "(" expr ")"
579/// comparison   = identifier (comp_op | like_op | regexp_op) placeholder
580/// null_check   = identifier IS NOT? NULL
581/// between_expr = identifier NOT? BETWEEN placeholder AND placeholder
582/// in_expr      = identifier NOT? IN "(" placeholder ("," placeholder)* ")"
583/// ```
584/// Check if condition is a simple numeric equality like "1=1", "0=0", etc.
585/// These are common SQL idioms for "always true" or "always false" conditions.
586fn is_numeric_equality(condition: &str) -> bool {
587    lazy_static_numeric_eq_regex().is_match(condition.trim())
588}
589
590fn lazy_static_numeric_eq_regex() -> &'static Regex {
591    use std::sync::OnceLock;
592    static REGEX: OnceLock<Regex> = OnceLock::new();
593    REGEX.get_or_init(|| Regex::new(r"^(\d+)\s*=\s*(\d+)$").unwrap())
594}
595
596fn validate_condition(condition: &str, valid_columns: &HashSet<String>) -> Result<()> {
597    // Special case: numeric equality like "1=1", "0=0" are common SQL idioms
598    // for "always true" / "always false" conditions. Safe to allow.
599    if is_numeric_equality(condition) {
600        return Ok(());
601    }
602
603    // Step 1: Quick safety check
604    quick_safety_check(condition)?;
605
606    // Step 2: Tokenize
607    let tokens = tokenize(condition)?;
608
609    // Step 3: Parse and validate
610    let mut validator = ConditionValidator::new(&tokens, valid_columns);
611    validator.validate()?;
612
613    Ok(())
614}
615
616/// Infer SQL type from a JSON value.
617fn infer_sql_type(value: &Value) -> &'static str {
618    match value {
619        Value::Number(n) => {
620            if n.is_i64() || n.is_u64() {
621                "INTEGER"
622            } else {
623                "REAL"
624            }
625        }
626        Value::Bool(_) => "INTEGER",
627        Value::String(_) => "TEXT",
628        Value::Null => "TEXT",
629        Value::Array(_) | Value::Object(_) => "BLOB",
630    }
631}
632
633/// Convert a JSON value to a type that can be bound to SQLite.
634fn json_to_sql(value: &Value) -> Box<dyn ToSql> {
635    match value {
636        Value::Null => Box::new(None::<String>),
637        Value::Bool(b) => Box::new(if *b { 1i64 } else { 0i64 }),
638        Value::Number(n) => {
639            if let Some(i) = n.as_i64() {
640                Box::new(i)
641            } else if let Some(f) = n.as_f64() {
642                Box::new(f)
643            } else {
644                Box::new(n.to_string())
645            }
646        }
647        Value::String(s) => Box::new(s.clone()),
648        Value::Array(_) | Value::Object(_) => Box::new(serde_json::to_string(value).unwrap()),
649    }
650}
651
652/// Get the path to the metadata database for an index.
653pub(crate) fn get_db_path(index_path: &str) -> std::path::PathBuf {
654    Path::new(index_path).join(METADATA_DB_NAME)
655}
656
657static DB_READ_CONNECTIONS: OnceLock<Mutex<HashMap<PathBuf, Arc<Mutex<Connection>>>>> =
658    OnceLock::new();
659
660fn open_db_read_uncached(db_path: &std::path::Path) -> Result<Connection> {
661    let conn = Connection::open_with_flags(
662        db_path,
663        OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
664    )?;
665    conn.execute_batch(
666        "PRAGMA busy_timeout=5000;
667         PRAGMA temp_store=MEMORY;
668         PRAGMA query_only=ON;",
669    )?;
670    Ok(conn)
671}
672
673fn read_connection(db_path: &Path) -> Result<Arc<Mutex<Connection>>> {
674    let key = db_path.to_path_buf();
675    let connections = DB_READ_CONNECTIONS.get_or_init(|| Mutex::new(HashMap::new()));
676
677    if let Some(conn) = connections
678        .lock()
679        .expect("DB_READ_CONNECTIONS mutex poisoned while reading metadata DB cache")
680        .get(&key)
681        .cloned()
682    {
683        return Ok(conn);
684    }
685
686    let new_conn = Arc::new(Mutex::new(open_db_read_uncached(db_path)?));
687    let mut map = connections
688        .lock()
689        .expect("DB_READ_CONNECTIONS mutex poisoned while updating metadata DB cache");
690    Ok(map.entry(key).or_insert_with(|| new_conn).clone())
691}
692
693fn invalidate_read_connection(db_path: &Path) {
694    if let Some(connections) = DB_READ_CONNECTIONS.get() {
695        connections
696            .lock()
697            .expect("DB_READ_CONNECTIONS mutex poisoned while invalidating metadata DB cache")
698            .remove(db_path);
699    }
700}
701
702/// Open a read-only SQLite connection for metadata queries.
703///
704/// Read connections deliberately do not run `PRAGMA journal_mode=WAL`: changing
705/// journal mode is write-ish SQLite setup work and can cause connection-open
706/// contention when many readers arrive while indexing writes metadata.
707pub(crate) fn with_db_read<T>(
708    db_path: &std::path::Path,
709    f: impl FnOnce(&Connection) -> Result<T>,
710) -> Result<T> {
711    let conn = read_connection(db_path)?;
712    let guard = conn
713        .lock()
714        .expect("cached metadata read connection mutex poisoned");
715    f(&guard)
716}
717
718/// Open a read-write SQLite connection for metadata mutation paths.
719///
720/// WAL keeps readers unblocked during writes, while the open gate prevents
721/// bursts of threads from concurrently running connection setup on the same DB.
722pub(crate) fn open_db_write(db_path: &std::path::Path) -> Result<Connection> {
723    let conn = Connection::open(db_path)?;
724    conn.execute_batch(
725        "PRAGMA busy_timeout=5000;
726         PRAGMA journal_mode=WAL;
727         PRAGMA synchronous=NORMAL;
728         PRAGMA temp_store=MEMORY;",
729    )?;
730    Ok(conn)
731}
732
733fn validate_fixed_columns(columns: &[(&str, &str)]) -> Result<()> {
734    for (name, _) in columns {
735        if !is_valid_column_name(name) {
736            return Err(Error::Filtering(format!(
737                "Invalid column name '{}'. Column names must start with a letter or \
738                 underscore, followed by letters, digits, or underscores.",
739                name
740            )));
741        }
742    }
743    Ok(())
744}
745
746/// Read the metadata schema version from `PRAGMA user_version` (0 = legacy).
747pub(crate) fn metadata_schema_version(conn: &Connection) -> i64 {
748    conn.query_row("PRAGMA user_version", [], |row| row.get(0))
749        .unwrap_or(0)
750}
751
752/// Check if the DB uses the v2 split layout.
753pub(crate) fn is_split_schema(conn: &Connection) -> bool {
754    metadata_schema_version(conn) >= METADATA_SCHEMA_V2
755}
756
757/// Determine if a column belongs to the thin (METADATA) or fat (METADATA_CONTENT) table.
758fn is_thin_column(col: &str) -> bool {
759    col == SUBSET_COLUMN || col == CONTENT_ID_COLUMN || THIN_COLUMNS.contains(&col)
760}
761
762/// Create the (non-unique) index over `_subset_` used by the v1 layout.
763///
764/// Non-unique on purpose: the dense-0..N-1 uniqueness of `_subset_` is guaranteed
765/// by construction (the caller's doc IDs and the delete re-sequencing math), and a
766/// UNIQUE index could spuriously reject a transient state mid-`UPDATE` during the
767/// range-shift re-sequencing. The index exists purely to make `WHERE _subset_ = ?`
768/// / `IN (...)` and `MAX(_subset_)` cheap now that `_subset_` is not the rowid.
769fn create_subset_index(conn: &Connection) -> Result<()> {
770    conn.execute(
771        &format!(
772            "CREATE INDEX IF NOT EXISTS \"{}\" ON METADATA (\"{}\")",
773            SUBSET_INDEX_NAME, SUBSET_COLUMN
774        ),
775        [],
776    )?;
777    Ok(())
778}
779
780/// Ensure the metadata DB uses the v1 fast-delete layout, migrating a legacy v0
781/// index in place if needed.
782///
783/// A v0 index stores `_subset_` as the `INTEGER PRIMARY KEY` (rowid), so the
784/// delete re-sequencing relocates every shifted row — rewriting overflow pages for
785/// large TEXT columns. The migration rebuilds the table with `_subset_` demoted to
786/// a regular indexed column. This is a one-time table copy (pure row shuffling, no
787/// re-encoding) run lazily on the first delete; afterwards the layout is permanent
788/// and `PRAGMA user_version` gates the check to O(1).
789///
790/// Forward compatibility is preserved: the rebuilt table exposes exactly the same
791/// columns under `SELECT *`, so an older binary reads it unchanged.
792fn ensure_fast_subset_schema(conn: &Connection) -> Result<()> {
793    if metadata_schema_version(conn) >= METADATA_SCHEMA_V1 {
794        return Ok(());
795    }
796
797    let has_table: i64 = conn
798        .query_row(
799            "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='METADATA'",
800            [],
801            |row| row.get(0),
802        )
803        .unwrap_or(0);
804    if has_table == 0 {
805        // Nothing to migrate; create() stamps the version for fresh indexes.
806        return Ok(());
807    }
808
809    // Inspect the live schema: is `_subset_` still the rowid PK (legacy)?
810    let mut cols: Vec<(String, String)> = Vec::new(); // (name, declared_type)
811    let mut subset_is_pk = false;
812    {
813        let mut stmt = conn.prepare("PRAGMA table_info(METADATA)")?;
814        let rows = stmt.query_map([], |row| {
815            Ok((
816                row.get::<_, String>(1)?, // name
817                row.get::<_, String>(2)?, // type
818                row.get::<_, i64>(5)?,    // pk
819            ))
820        })?;
821        for r in rows {
822            let (name, ty, pk) = r?;
823            if name == SUBSET_COLUMN && pk > 0 {
824                subset_is_pk = true;
825            }
826            cols.push((name, ty));
827        }
828    }
829
830    if !subset_is_pk {
831        // Already non-PK; just ensure the index + stamp the version.
832        create_subset_index(conn)?;
833        conn.execute_batch(&format!("PRAGMA user_version={}", METADATA_SCHEMA_V1))?;
834        return Ok(());
835    }
836
837    // Rebuild with `_subset_` as a plain column, preserving column order/types.
838    let col_defs: Vec<String> = cols
839        .iter()
840        .map(|(name, ty)| {
841            if name == SUBSET_COLUMN {
842                format!("\"{}\" INTEGER NOT NULL", SUBSET_COLUMN)
843            } else {
844                let ty = if ty.trim().is_empty() {
845                    "TEXT"
846                } else {
847                    ty.as_str()
848                };
849                format!("\"{}\" {}", name, ty)
850            }
851        })
852        .collect();
853    let all_cols = cols
854        .iter()
855        .map(|(name, _)| format!("\"{}\"", name))
856        .collect::<Vec<_>>()
857        .join(", ");
858
859    conn.execute_batch("BEGIN")?;
860    conn.execute("ALTER TABLE METADATA RENAME TO _METADATA_V0", [])?;
861    conn.execute(
862        &format!("CREATE TABLE METADATA ({})", col_defs.join(", ")),
863        [],
864    )?;
865    conn.execute(
866        &format!(
867            "INSERT INTO METADATA ({0}) SELECT {0} FROM _METADATA_V0",
868            all_cols
869        ),
870        [],
871    )?;
872    create_subset_index(conn)?;
873    conn.execute("DROP TABLE _METADATA_V0", [])?;
874    conn.execute_batch(&format!("PRAGMA user_version={}", METADATA_SCHEMA_V1))?;
875    conn.execute_batch("COMMIT")?;
876    Ok(())
877}
878
879fn create_fixed_metadata_table(conn: &Connection, columns: &[(&str, &str)]) -> Result<()> {
880    // v2 split layout: thin METADATA table for fast re-sequencing, fat
881    // METADATA_CONTENT table for large columns that never move.
882    let mut thin_col_defs = vec![
883        format!("\"{}\" INTEGER NOT NULL", SUBSET_COLUMN),
884        format!("\"{}\" INTEGER NOT NULL", CONTENT_ID_COLUMN),
885    ];
886    let mut fat_col_defs = vec![format!("\"{}\" INTEGER PRIMARY KEY", CONTENT_ID_COLUMN)];
887
888    for (name, sql_type) in columns {
889        if is_thin_column(name) {
890            thin_col_defs.push(format!("\"{}\" {}", name, sql_type));
891        } else {
892            fat_col_defs.push(format!("\"{}\" {}", name, sql_type));
893        }
894    }
895
896    conn.execute(
897        &format!("CREATE TABLE METADATA ({})", thin_col_defs.join(", ")),
898        [],
899    )?;
900    conn.execute(
901        &format!(
902            "CREATE TABLE {} ({})",
903            CONTENT_TABLE,
904            fat_col_defs.join(", ")
905        ),
906        [],
907    )?;
908    create_subset_index(conn)?;
909    conn.execute_batch(&format!("PRAGMA user_version={}", METADATA_SCHEMA_V2))?;
910    Ok(())
911}
912
913fn insert_fixed_metadata_rows(
914    conn: &mut Connection,
915    columns: &[(&str, &str)],
916    metadata: &[Value],
917    doc_ids: &[i64],
918) -> Result<usize> {
919    if is_split_schema(conn) {
920        return insert_fixed_metadata_rows_v2(conn, columns, metadata, doc_ids);
921    }
922    let txn = conn.transaction()?;
923    let mut column_names = vec![format!("\"{}\"", SUBSET_COLUMN)];
924    column_names.extend(columns.iter().map(|(name, _)| format!("\"{}\"", name)));
925    let placeholders: Vec<&str> = std::iter::repeat_n("?", columns.len() + 1).collect();
926    let insert_sql = format!(
927        "INSERT INTO METADATA ({}) VALUES ({})",
928        column_names.join(", "),
929        placeholders.join(", ")
930    );
931    {
932        let mut stmt = txn.prepare_cached(&insert_sql)?;
933        for (i, item) in metadata.iter().enumerate() {
934            let obj = item.as_object().ok_or_else(|| {
935                Error::Filtering("Expected metadata rows to be JSON objects".into())
936            })?;
937            let mut values: Vec<Box<dyn ToSql>> = vec![Box::new(doc_ids[i])];
938            for (column_name, _) in columns {
939                let value = obj.get(*column_name).unwrap_or(&Value::Null);
940                values.push(json_to_sql(value));
941            }
942            let params: Vec<&dyn ToSql> = values.iter().map(|value| value.as_ref()).collect();
943            stmt.execute(params_from_iter(params))?;
944        }
945    }
946    txn.commit()?;
947    Ok(metadata.len())
948}
949
950fn insert_fixed_metadata_rows_v2(
951    conn: &mut Connection,
952    columns: &[(&str, &str)],
953    metadata: &[Value],
954    doc_ids: &[i64],
955) -> Result<usize> {
956    let thin_cols: Vec<&(&str, &str)> = columns.iter().filter(|(n, _)| is_thin_column(n)).collect();
957    let fat_cols: Vec<&(&str, &str)> = columns.iter().filter(|(n, _)| !is_thin_column(n)).collect();
958
959    let next_content_id: i64 = conn
960        .query_row(
961            &format!(
962                "SELECT COALESCE(MAX(\"{}\"), -1) + 1 FROM {}",
963                CONTENT_ID_COLUMN, CONTENT_TABLE
964            ),
965            [],
966            |row| row.get(0),
967        )
968        .unwrap_or(0);
969
970    let txn = conn.transaction()?;
971
972    // Prepare fat table INSERT
973    let mut fat_col_names = vec![format!("\"{}\"", CONTENT_ID_COLUMN)];
974    fat_col_names.extend(fat_cols.iter().map(|(name, _)| format!("\"{}\"", name)));
975    let fat_placeholders: Vec<&str> = std::iter::repeat_n("?", fat_cols.len() + 1).collect();
976    let fat_insert_sql = format!(
977        "INSERT INTO {} ({}) VALUES ({})",
978        CONTENT_TABLE,
979        fat_col_names.join(", "),
980        fat_placeholders.join(", ")
981    );
982
983    // Prepare thin table INSERT
984    let mut thin_col_names = vec![
985        format!("\"{}\"", SUBSET_COLUMN),
986        format!("\"{}\"", CONTENT_ID_COLUMN),
987    ];
988    thin_col_names.extend(thin_cols.iter().map(|(name, _)| format!("\"{}\"", name)));
989    let thin_placeholders: Vec<&str> = std::iter::repeat_n("?", thin_cols.len() + 2).collect();
990    let thin_insert_sql = format!(
991        "INSERT INTO METADATA ({}) VALUES ({})",
992        thin_col_names.join(", "),
993        thin_placeholders.join(", ")
994    );
995
996    {
997        let mut fat_stmt = txn.prepare_cached(&fat_insert_sql)?;
998        let mut thin_stmt = txn.prepare_cached(&thin_insert_sql)?;
999
1000        for (i, item) in metadata.iter().enumerate() {
1001            let obj = item.as_object().ok_or_else(|| {
1002                Error::Filtering("Expected metadata rows to be JSON objects".into())
1003            })?;
1004            let content_id = next_content_id + i as i64;
1005
1006            // Insert into fat table
1007            let mut fat_values: Vec<Box<dyn ToSql>> = vec![Box::new(content_id)];
1008            for (column_name, _) in &fat_cols {
1009                let value = obj.get(*column_name).unwrap_or(&Value::Null);
1010                fat_values.push(json_to_sql(value));
1011            }
1012            let fat_params: Vec<&dyn ToSql> = fat_values.iter().map(|v| v.as_ref()).collect();
1013            fat_stmt.execute(params_from_iter(fat_params))?;
1014
1015            // Insert into thin table
1016            let mut thin_values: Vec<Box<dyn ToSql>> =
1017                vec![Box::new(doc_ids[i]), Box::new(content_id)];
1018            for (column_name, _) in &thin_cols {
1019                let value = obj.get(*column_name).unwrap_or(&Value::Null);
1020                thin_values.push(json_to_sql(value));
1021            }
1022            let thin_params: Vec<&dyn ToSql> = thin_values.iter().map(|v| v.as_ref()).collect();
1023            thin_stmt.execute(params_from_iter(thin_params))?;
1024        }
1025    }
1026    txn.commit()?;
1027    Ok(metadata.len())
1028}
1029
1030fn try_fixed_schema_from_first_row(
1031    metadata: &[Value],
1032) -> Result<Option<Vec<(&str, &'static str)>>> {
1033    let Some(Value::Object(first_obj)) = metadata.first() else {
1034        return Ok(None);
1035    };
1036
1037    let mut columns = Vec::with_capacity(first_obj.len());
1038    let mut seen = HashSet::with_capacity(first_obj.len());
1039    for (key, value) in first_obj {
1040        if !is_valid_column_name(key) {
1041            return Err(Error::Filtering(format!(
1042                "Invalid column name '{}'. Column names must start with a letter or \
1043                 underscore, followed by letters, digits, or underscores.",
1044                key
1045            )));
1046        }
1047        seen.insert(key.as_str());
1048        columns.push((key.as_str(), infer_sql_type(value)));
1049    }
1050
1051    // If every later row is a subset of the first row's keys, the batch is
1052    // effectively fixed-schema and we can stay on the cheaper insert path.
1053    for item in &metadata[1..] {
1054        if let Value::Object(obj) = item {
1055            for key in obj.keys() {
1056                if !seen.contains(key.as_str()) {
1057                    return Ok(None);
1058                }
1059            }
1060        }
1061    }
1062
1063    Ok(Some(columns))
1064}
1065
1066/// Check if a metadata database exists for the given index.
1067pub fn exists(index_path: &str) -> bool {
1068    get_db_path(index_path).exists()
1069}
1070
1071fn create_with_fixed_columns(
1072    index_path: &str,
1073    columns: &[(&str, &str)],
1074    metadata: &[Value],
1075    doc_ids: &[i64],
1076) -> Result<usize> {
1077    if metadata.len() != doc_ids.len() {
1078        return Err(Error::Filtering(format!(
1079            "Metadata length ({}) must match doc_ids length ({})",
1080            metadata.len(),
1081            doc_ids.len()
1082        )));
1083    }
1084    validate_fixed_columns(columns)?;
1085
1086    let index_dir = Path::new(index_path);
1087    if !index_dir.exists() {
1088        fs::create_dir_all(index_dir)?;
1089    }
1090
1091    let db_path = get_db_path(index_path);
1092    if db_path.exists() {
1093        invalidate_read_connection(&db_path);
1094        fs::remove_file(&db_path)?;
1095    }
1096
1097    if metadata.is_empty() {
1098        return Ok(0);
1099    }
1100
1101    let mut conn = open_db_write(&db_path)?;
1102    create_fixed_metadata_table(&conn, columns)?;
1103    insert_fixed_metadata_rows(&mut conn, columns, metadata, doc_ids)
1104}
1105
1106/// Create a new SQLite metadata database, replacing any existing one.
1107///
1108/// Each element in `metadata` is a JSON object representing a document's metadata.
1109/// The `_subset_` column is automatically added as the primary key.
1110///
1111/// # Arguments
1112///
1113/// * `index_path` - Path to the index directory
1114/// * `metadata` - Slice of JSON objects, one per document
1115///
1116/// # Returns
1117///
1118/// Number of rows inserted
1119///
1120/// # Errors
1121///
1122/// Returns an error if:
1123/// - The index directory cannot be created
1124/// - Column names are invalid (SQL injection prevention)
1125/// - Database operations fail
1126///
1127/// # Example
1128///
1129/// ```ignore
1130/// use next-plaid::filtering;
1131/// use serde_json::json;
1132///
1133/// let metadata = vec![
1134///     json!({"name": "Alice", "age": 30}),
1135///     json!({"name": "Bob", "age": 25, "city": "NYC"}),
1136/// ];
1137/// let doc_ids: Vec<i64> = (0..2).collect();
1138///
1139/// filtering::create("my_index", &metadata, &doc_ids)?;
1140/// ```
1141pub fn create(index_path: &str, metadata: &[Value], doc_ids: &[i64]) -> Result<usize> {
1142    // Validate doc_ids length matches metadata
1143    if metadata.len() != doc_ids.len() {
1144        return Err(Error::Filtering(format!(
1145            "Metadata length ({}) must match doc_ids length ({})",
1146            metadata.len(),
1147            doc_ids.len()
1148        )));
1149    }
1150
1151    // Ensure index directory exists
1152    let index_dir = Path::new(index_path);
1153    if !index_dir.exists() {
1154        fs::create_dir_all(index_dir)?;
1155    }
1156
1157    // Remove existing database
1158    let db_path = get_db_path(index_path);
1159    if db_path.exists() {
1160        invalidate_read_connection(&db_path);
1161        fs::remove_file(&db_path)?;
1162    }
1163
1164    if metadata.is_empty() {
1165        return Ok(0);
1166    }
1167
1168    // Most colgrep metadata batches are fixed-shape JSON objects. Detect that
1169    // early so creation does one direct CREATE TABLE + INSERT pass instead of
1170    // paying for generic column discovery on every batch.
1171    if let Some(columns) = try_fixed_schema_from_first_row(metadata)? {
1172        return create_with_fixed_columns(index_path, &columns, metadata, doc_ids);
1173    }
1174
1175    // Collect all unique column names and infer types
1176    let mut columns: Vec<String> = Vec::new();
1177    let mut column_types: HashMap<String, &'static str> = HashMap::new();
1178
1179    for item in metadata {
1180        if let Value::Object(obj) = item {
1181            for (key, value) in obj {
1182                if !columns.contains(key) {
1183                    if !is_valid_column_name(key) {
1184                        return Err(Error::Filtering(format!(
1185                            "Invalid column name '{}'. Column names must start with a letter or \
1186                             underscore, followed by letters, digits, or underscores.",
1187                            key
1188                        )));
1189                    }
1190                    columns.push(key.clone());
1191                }
1192                if !value.is_null() && !column_types.contains_key(key) {
1193                    column_types.insert(key.clone(), infer_sql_type(value));
1194                }
1195            }
1196        }
1197    }
1198
1199    // Create connection
1200    let mut conn = open_db_write(&db_path)?;
1201
1202    // v2 split layout: thin METADATA + fat METADATA_CONTENT.
1203    let thin_columns: Vec<&String> = columns.iter().filter(|c| is_thin_column(c)).collect();
1204    let fat_columns: Vec<&String> = columns.iter().filter(|c| !is_thin_column(c)).collect();
1205
1206    let mut thin_col_defs = vec![
1207        format!("\"{}\" INTEGER NOT NULL", SUBSET_COLUMN),
1208        format!("\"{}\" INTEGER NOT NULL", CONTENT_ID_COLUMN),
1209    ];
1210    for col in &thin_columns {
1211        let sql_type = column_types.get(col.as_str()).copied().unwrap_or("TEXT");
1212        thin_col_defs.push(format!("\"{}\" {}", col, sql_type));
1213    }
1214
1215    let mut fat_col_defs = vec![format!("\"{}\" INTEGER PRIMARY KEY", CONTENT_ID_COLUMN)];
1216    for col in &fat_columns {
1217        let sql_type = column_types.get(col.as_str()).copied().unwrap_or("TEXT");
1218        fat_col_defs.push(format!("\"{}\" {}", col, sql_type));
1219    }
1220
1221    let txn = conn.transaction()?;
1222    txn.execute(
1223        &format!("CREATE TABLE METADATA ({})", thin_col_defs.join(", ")),
1224        [],
1225    )?;
1226    txn.execute(
1227        &format!(
1228            "CREATE TABLE {} ({})",
1229            CONTENT_TABLE,
1230            fat_col_defs.join(", ")
1231        ),
1232        [],
1233    )?;
1234    txn.execute(
1235        &format!(
1236            "CREATE INDEX IF NOT EXISTS \"{}\" ON METADATA (\"{}\")",
1237            SUBSET_INDEX_NAME, SUBSET_COLUMN
1238        ),
1239        [],
1240    )?;
1241
1242    // Prepare INSERT statements for both tables
1243    let thin_col_names: Vec<String> = std::iter::once(format!("\"{}\"", SUBSET_COLUMN))
1244        .chain(std::iter::once(format!("\"{}\"", CONTENT_ID_COLUMN)))
1245        .chain(thin_columns.iter().map(|c| format!("\"{}\"", c)))
1246        .collect();
1247    let thin_placeholders: Vec<&str> = std::iter::repeat_n("?", thin_columns.len() + 2).collect();
1248    let thin_insert_sql = format!(
1249        "INSERT INTO METADATA ({}) VALUES ({})",
1250        thin_col_names.join(", "),
1251        thin_placeholders.join(", ")
1252    );
1253
1254    let fat_col_names: Vec<String> = std::iter::once(format!("\"{}\"", CONTENT_ID_COLUMN))
1255        .chain(fat_columns.iter().map(|c| format!("\"{}\"", c)))
1256        .collect();
1257    let fat_placeholders: Vec<&str> = std::iter::repeat_n("?", fat_columns.len() + 1).collect();
1258    let fat_insert_sql = format!(
1259        "INSERT INTO {} ({}) VALUES ({})",
1260        CONTENT_TABLE,
1261        fat_col_names.join(", "),
1262        fat_placeholders.join(", ")
1263    );
1264
1265    {
1266        let mut thin_stmt = txn.prepare(&thin_insert_sql)?;
1267        let mut fat_stmt = txn.prepare(&fat_insert_sql)?;
1268        for (i, item) in metadata.iter().enumerate() {
1269            let content_id = doc_ids[i];
1270
1271            // Insert into fat table
1272            let mut fat_values: Vec<Box<dyn ToSql>> = vec![Box::new(content_id)];
1273            if let Value::Object(obj) = item {
1274                for col in &fat_columns {
1275                    let value = obj.get(col.as_str()).unwrap_or(&Value::Null);
1276                    fat_values.push(json_to_sql(value));
1277                }
1278            } else {
1279                for _ in &fat_columns {
1280                    fat_values.push(Box::new(None::<String>));
1281                }
1282            }
1283            let fat_params: Vec<&dyn ToSql> = fat_values.iter().map(|v| v.as_ref()).collect();
1284            fat_stmt.execute(params_from_iter(fat_params))?;
1285
1286            // Insert into thin table
1287            let mut thin_values: Vec<Box<dyn ToSql>> =
1288                vec![Box::new(doc_ids[i]), Box::new(content_id)];
1289            if let Value::Object(obj) = item {
1290                for col in &thin_columns {
1291                    let value = obj.get(col.as_str()).unwrap_or(&Value::Null);
1292                    thin_values.push(json_to_sql(value));
1293                }
1294            } else {
1295                for _ in &thin_columns {
1296                    thin_values.push(Box::new(None::<String>));
1297                }
1298            }
1299            let thin_params: Vec<&dyn ToSql> = thin_values.iter().map(|v| v.as_ref()).collect();
1300            thin_stmt.execute(params_from_iter(thin_params))?;
1301        }
1302    }
1303    txn.commit()?;
1304
1305    conn.execute_batch(&format!("PRAGMA user_version={}", METADATA_SCHEMA_V2))?;
1306
1307    Ok(metadata.len())
1308}
1309
1310/// Append new metadata rows to an existing database, adding columns if needed.
1311///
1312/// New columns found in the metadata are automatically added to the table.
1313/// The `_subset_` IDs are provided explicitly via `doc_ids` to ensure sync with index.
1314///
1315/// # Arguments
1316///
1317/// * `index_path` - Path to the index directory
1318/// * `metadata` - Slice of JSON objects for new documents
1319/// * `doc_ids` - Document IDs to use as `_subset_` values (must match metadata length)
1320///
1321/// # Returns
1322///
1323/// Number of rows inserted
1324///
1325/// # Errors
1326///
1327/// Returns an error if:
1328/// - The database doesn't exist
1329/// - Column names are invalid
1330/// - Database operations fail
1331/// - metadata length doesn't match doc_ids length
1332pub fn update(index_path: &str, metadata: &[Value], doc_ids: &[i64]) -> Result<usize> {
1333    if metadata.is_empty() {
1334        return Ok(0);
1335    }
1336
1337    // Validate doc_ids length matches metadata
1338    if metadata.len() != doc_ids.len() {
1339        return Err(Error::Filtering(format!(
1340            "Metadata length ({}) must match doc_ids length ({})",
1341            metadata.len(),
1342            doc_ids.len()
1343        )));
1344    }
1345
1346    let db_path = get_db_path(index_path);
1347    if !db_path.exists() {
1348        return Err(Error::Filtering(
1349            "Metadata database does not exist. Use create() first.".into(),
1350        ));
1351    }
1352
1353    let mut conn = open_db_write(&db_path)?;
1354
1355    if is_split_schema(&conn) {
1356        return update_v2(&mut conn, metadata, doc_ids);
1357    }
1358
1359    // Get existing columns
1360    let mut existing_columns: Vec<String> = Vec::new();
1361    {
1362        let mut stmt = conn.prepare("PRAGMA table_info(METADATA)")?;
1363        let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
1364        for row in rows {
1365            let col = row?;
1366            if col != SUBSET_COLUMN {
1367                existing_columns.push(col);
1368            }
1369        }
1370    }
1371
1372    let existing_column_set: HashSet<&str> = existing_columns
1373        .iter()
1374        .map(|column| column.as_str())
1375        .collect();
1376    let has_new_columns = metadata.iter().any(|item| {
1377        item.as_object().is_some_and(|obj| {
1378            obj.keys()
1379                .any(|key| !existing_column_set.contains(key.as_str()))
1380        })
1381    });
1382    if !has_new_columns {
1383        // Common case: callers keep sending the same schema that already exists
1384        // in SQLite. Skip PRAGMA-driven schema mutation work and append rows
1385        // directly using the current column order.
1386        let fixed_columns: Vec<(&str, &str)> = existing_columns
1387            .iter()
1388            .map(|column| (column.as_str(), "TEXT"))
1389            .collect();
1390        return insert_fixed_metadata_rows(&mut conn, &fixed_columns, metadata, doc_ids);
1391    }
1392
1393    // Find new columns and add them
1394    let mut new_columns: Vec<String> = Vec::new();
1395    let mut column_types: HashMap<String, &'static str> = HashMap::new();
1396
1397    for item in metadata {
1398        if let Value::Object(obj) = item {
1399            for (key, value) in obj {
1400                if !existing_columns.contains(key) && !new_columns.contains(key) {
1401                    if !is_valid_column_name(key) {
1402                        return Err(Error::Filtering(format!(
1403                            "Invalid column name '{}'. Column names must start with a letter or \
1404                             underscore, followed by letters, digits, or underscores.",
1405                            key
1406                        )));
1407                    }
1408                    new_columns.push(key.clone());
1409                }
1410                if !value.is_null() && !column_types.contains_key(key) {
1411                    column_types.insert(key.clone(), infer_sql_type(value));
1412                }
1413            }
1414        }
1415    }
1416
1417    let txn = conn.transaction()?;
1418    // Add new columns to table
1419    for col in &new_columns {
1420        let sql_type = column_types.get(col).copied().unwrap_or("TEXT");
1421        let alter_sql = format!("ALTER TABLE METADATA ADD COLUMN \"{}\" {}", col, sql_type);
1422        txn.execute(&alter_sql, [])?;
1423    }
1424
1425    // Get all columns (existing + new)
1426    let all_columns: Vec<String> = existing_columns.into_iter().chain(new_columns).collect();
1427
1428    // Prepare INSERT statement
1429    let placeholders: Vec<&str> = std::iter::repeat_n("?", all_columns.len() + 1).collect();
1430    let insert_sql = if all_columns.is_empty() {
1431        format!("INSERT INTO METADATA (\"{}\") VALUES (?)", SUBSET_COLUMN,)
1432    } else {
1433        let col_names: Vec<String> = all_columns.iter().map(|c| format!("\"{}\"", c)).collect();
1434        format!(
1435            "INSERT INTO METADATA (\"{}\", {}) VALUES ({})",
1436            SUBSET_COLUMN,
1437            col_names.join(", "),
1438            placeholders.join(", ")
1439        )
1440    };
1441
1442    {
1443        let mut stmt = txn.prepare(&insert_sql)?;
1444        for (i, item) in metadata.iter().enumerate() {
1445            let mut values: Vec<Box<dyn ToSql>> = vec![Box::new(doc_ids[i])];
1446            if let Value::Object(obj) = item {
1447                for col in &all_columns {
1448                    let value = obj.get(col).unwrap_or(&Value::Null);
1449                    values.push(json_to_sql(value));
1450                }
1451            } else {
1452                for _ in &all_columns {
1453                    values.push(Box::new(None::<String>));
1454                }
1455            }
1456            let params: Vec<&dyn ToSql> = values.iter().map(|v| v.as_ref()).collect();
1457            stmt.execute(params_from_iter(params))?;
1458        }
1459    }
1460    txn.commit()?;
1461
1462    Ok(metadata.len())
1463}
1464
1465fn update_v2(conn: &mut Connection, metadata: &[Value], doc_ids: &[i64]) -> Result<usize> {
1466    // Gather existing columns from both tables (excluding internal columns).
1467    let mut thin_existing: Vec<String> = Vec::new();
1468    {
1469        let mut stmt = conn.prepare("PRAGMA table_info(METADATA)")?;
1470        let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
1471        for row in rows {
1472            let col = row?;
1473            if col != SUBSET_COLUMN && col != CONTENT_ID_COLUMN {
1474                thin_existing.push(col);
1475            }
1476        }
1477    }
1478    let mut fat_existing: Vec<String> = Vec::new();
1479    {
1480        let mut stmt = conn.prepare(&format!("PRAGMA table_info({})", CONTENT_TABLE))?;
1481        let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
1482        for row in rows {
1483            let col = row?;
1484            if col != CONTENT_ID_COLUMN {
1485                fat_existing.push(col);
1486            }
1487        }
1488    }
1489
1490    let mut all_existing: HashSet<&str> = HashSet::new();
1491    for c in &thin_existing {
1492        all_existing.insert(c.as_str());
1493    }
1494    for c in &fat_existing {
1495        all_existing.insert(c.as_str());
1496    }
1497
1498    // Discover new columns and ALTER TABLE as needed.
1499    let mut column_types: HashMap<String, &'static str> = HashMap::new();
1500    let mut new_thin: Vec<String> = Vec::new();
1501    let mut new_fat: Vec<String> = Vec::new();
1502
1503    for item in metadata {
1504        if let Value::Object(obj) = item {
1505            for (key, value) in obj {
1506                if !all_existing.contains(key.as_str())
1507                    && !new_thin.contains(key)
1508                    && !new_fat.contains(key)
1509                {
1510                    if !is_valid_column_name(key) {
1511                        return Err(Error::Filtering(format!(
1512                            "Invalid column name '{}'. Column names must start with a letter or \
1513                             underscore, followed by letters, digits, or underscores.",
1514                            key
1515                        )));
1516                    }
1517                    if is_thin_column(key) {
1518                        new_thin.push(key.clone());
1519                    } else {
1520                        new_fat.push(key.clone());
1521                    }
1522                }
1523                if !value.is_null() && !column_types.contains_key(key) {
1524                    column_types.insert(key.clone(), infer_sql_type(value));
1525                }
1526            }
1527        }
1528    }
1529
1530    let txn = conn.transaction()?;
1531    for col in &new_thin {
1532        let sql_type = column_types.get(col).copied().unwrap_or("TEXT");
1533        txn.execute(
1534            &format!("ALTER TABLE METADATA ADD COLUMN \"{}\" {}", col, sql_type),
1535            [],
1536        )?;
1537    }
1538    for col in &new_fat {
1539        let sql_type = column_types.get(col).copied().unwrap_or("TEXT");
1540        txn.execute(
1541            &format!(
1542                "ALTER TABLE {} ADD COLUMN \"{}\" {}",
1543                CONTENT_TABLE, col, sql_type
1544            ),
1545            [],
1546        )?;
1547    }
1548
1549    let all_thin: Vec<String> = thin_existing.into_iter().chain(new_thin).collect();
1550    let all_fat: Vec<String> = fat_existing.into_iter().chain(new_fat).collect();
1551
1552    // Next content ID
1553    let next_content_id: i64 = txn
1554        .query_row(
1555            &format!(
1556                "SELECT COALESCE(MAX(\"{}\"), -1) + 1 FROM {}",
1557                CONTENT_ID_COLUMN, CONTENT_TABLE
1558            ),
1559            [],
1560            |row| row.get(0),
1561        )
1562        .unwrap_or(0);
1563
1564    // Prepare fat INSERT
1565    let mut fat_col_names = vec![format!("\"{}\"", CONTENT_ID_COLUMN)];
1566    fat_col_names.extend(all_fat.iter().map(|c| format!("\"{}\"", c)));
1567    let fat_placeholders: Vec<&str> = std::iter::repeat_n("?", all_fat.len() + 1).collect();
1568    let fat_insert_sql = format!(
1569        "INSERT INTO {} ({}) VALUES ({})",
1570        CONTENT_TABLE,
1571        fat_col_names.join(", "),
1572        fat_placeholders.join(", ")
1573    );
1574
1575    // Prepare thin INSERT
1576    let mut thin_col_names = vec![
1577        format!("\"{}\"", SUBSET_COLUMN),
1578        format!("\"{}\"", CONTENT_ID_COLUMN),
1579    ];
1580    thin_col_names.extend(all_thin.iter().map(|c| format!("\"{}\"", c)));
1581    let thin_placeholders: Vec<&str> = std::iter::repeat_n("?", all_thin.len() + 2).collect();
1582    let thin_insert_sql = format!(
1583        "INSERT INTO METADATA ({}) VALUES ({})",
1584        thin_col_names.join(", "),
1585        thin_placeholders.join(", ")
1586    );
1587
1588    {
1589        let mut fat_stmt = txn.prepare(&fat_insert_sql)?;
1590        let mut thin_stmt = txn.prepare(&thin_insert_sql)?;
1591
1592        for (i, item) in metadata.iter().enumerate() {
1593            let content_id = next_content_id + i as i64;
1594
1595            let mut fat_values: Vec<Box<dyn ToSql>> = vec![Box::new(content_id)];
1596            if let Value::Object(obj) = item {
1597                for col in &all_fat {
1598                    let value = obj.get(col).unwrap_or(&Value::Null);
1599                    fat_values.push(json_to_sql(value));
1600                }
1601            } else {
1602                for _ in &all_fat {
1603                    fat_values.push(Box::new(None::<String>));
1604                }
1605            }
1606            let fat_params: Vec<&dyn ToSql> = fat_values.iter().map(|v| v.as_ref()).collect();
1607            fat_stmt.execute(params_from_iter(fat_params))?;
1608
1609            let mut thin_values: Vec<Box<dyn ToSql>> =
1610                vec![Box::new(doc_ids[i]), Box::new(content_id)];
1611            if let Value::Object(obj) = item {
1612                for col in &all_thin {
1613                    let value = obj.get(col).unwrap_or(&Value::Null);
1614                    thin_values.push(json_to_sql(value));
1615                }
1616            } else {
1617                for _ in &all_thin {
1618                    thin_values.push(Box::new(None::<String>));
1619                }
1620            }
1621            let thin_params: Vec<&dyn ToSql> = thin_values.iter().map(|v| v.as_ref()).collect();
1622            thin_stmt.execute(params_from_iter(thin_params))?;
1623        }
1624    }
1625    txn.commit()?;
1626    Ok(metadata.len())
1627}
1628
1629/// Delete rows by subset IDs and re-index the _subset_ column to be sequential.
1630///
1631/// After deletion, remaining documents are re-indexed to maintain sequential
1632/// `_subset_` IDs starting from 0. This matches fast-plaid behavior.
1633///
1634/// # Arguments
1635///
1636/// * `index_path` - Path to the index directory
1637/// * `subset` - Slice of document IDs to delete (must be sorted ascending)
1638///
1639/// # Returns
1640///
1641/// Number of rows actually deleted
1642///
1643/// # Errors
1644///
1645/// Returns an error if the database operations fail.
1646pub fn delete(index_path: &str, subset: &[i64]) -> Result<usize> {
1647    if subset.is_empty() {
1648        return Ok(0);
1649    }
1650
1651    let db_path = get_db_path(index_path);
1652    if !db_path.exists() {
1653        return Ok(0);
1654    }
1655
1656    let conn = open_db_write(&db_path)?;
1657
1658    if is_split_schema(&conn) {
1659        return delete_v2(&conn, subset);
1660    }
1661
1662    // Migrate a legacy (v0) index to the fast-delete layout on the first delete, so
1663    // the re-sequencing below updates a plain indexed column instead of relocating
1664    // each fat row. No-op once the index is v1.
1665    ensure_fast_subset_schema(&conn)?;
1666
1667    // Start transaction
1668    conn.execute("BEGIN", [])?;
1669
1670    // `_subset_` is contiguous 0..N-1 before this call, so MAX+1 is the pre-delete
1671    // row count. Read via the `_subset_` index (O(log n)) rather than a COUNT(*)
1672    // full scan. Used to discard stray out-of-range/non-existent delete ids whose
1673    // shift counts would otherwise corrupt every survivor's id.
1674    let original_count: i64 = conn
1675        .query_row(
1676            &format!(
1677                "SELECT COALESCE(MAX(\"{}\"), -1) FROM METADATA",
1678                SUBSET_COLUMN
1679            ),
1680            [],
1681            |row| row.get::<_, i64>(0),
1682        )
1683        .unwrap_or(-1)
1684        + 1;
1685
1686    // Delete specified rows
1687    let (in_clause, in_params, temp_table) = crate::text_search::build_in_clause(&conn, subset)?;
1688    let delete_sql = format!(
1689        "DELETE FROM METADATA WHERE \"{}\" {}",
1690        SUBSET_COLUMN, in_clause
1691    );
1692    let param_refs: Vec<&dyn ToSql> = in_params.iter().map(|v| v.as_ref()).collect();
1693    let deleted = conn.execute(&delete_sql, params_from_iter(param_refs))?;
1694    if let Some(ref name) = temp_table {
1695        crate::text_search::drop_temp_table(&conn, name);
1696    }
1697
1698    // Re-sequence _subset_ IDs to be contiguous 0-based.
1699    // Instead of copying the entire table (expensive for large tables with TEXT/BLOB
1700    // columns), use range-based UPDATEs. Because `_subset_` is now a regular column
1701    // (not the rowid), each UPDATE rewrites only the small integer value and its
1702    // index entry - the fat row stays put. Process gaps in ascending order so
1703    // decremented values never collide.
1704    let mut sorted_ids: Vec<i64> = subset.to_vec();
1705    sorted_ids.sort_unstable();
1706    sorted_ids.dedup();
1707    sorted_ids.retain(|&id| id >= 0 && id < original_count);
1708
1709    let max_id: i64 = conn
1710        .query_row(
1711            &format!(
1712                "SELECT COALESCE(MAX(\"{}\"), -1) FROM METADATA",
1713                SUBSET_COLUMN
1714            ),
1715            [],
1716            |row| row.get(0),
1717        )
1718        .unwrap_or(-1);
1719
1720    if max_id >= 0 && !sorted_ids.is_empty() {
1721        // For each gap left by deleted rows, shift all subsequent rows down.
1722        // Merge into contiguous ranges: if IDs 5,6,7 were deleted, rows >= 8
1723        // shift down by 3. We process from lowest gap upward.
1724        //
1725        // Build (range_start, range_end, shift) tuples. Consecutive deleted IDs
1726        // form a single gap; the rows between two gaps all need the same shift
1727        // (equal to the number of deleted IDs to their left).
1728        let mut updates: Vec<(i64, i64, i64)> = Vec::new();
1729        let mut i = 0;
1730        while i < sorted_ids.len() {
1731            // Advance past consecutive deleted IDs
1732            let mut j = i + 1;
1733            while j < sorted_ids.len() && sorted_ids[j] == sorted_ids[j - 1] + 1 {
1734                j += 1;
1735            }
1736            // Rows from sorted_ids[j-1]+1 up to (but not including) the next
1737            // deleted ID need to shift down by j (total deletions so far).
1738            let range_start = sorted_ids[j - 1] + 1;
1739            let range_end = if j < sorted_ids.len() {
1740                sorted_ids[j]
1741            } else {
1742                max_id + sorted_ids.len() as i64 + 1
1743            };
1744            if range_start < range_end {
1745                updates.push((range_start, range_end, j as i64));
1746            }
1747            i = j;
1748        }
1749
1750        for (from, to_excl, shift) in &updates {
1751            conn.execute(
1752                &format!(
1753                    "UPDATE METADATA SET \"{}\" = \"{}\" - ?1 WHERE \"{}\" >= ?2 AND \"{}\" < ?3",
1754                    SUBSET_COLUMN, SUBSET_COLUMN, SUBSET_COLUMN, SUBSET_COLUMN
1755                ),
1756                rusqlite::params![shift, from, to_excl],
1757            )?;
1758        }
1759    }
1760
1761    // Commit transaction
1762    conn.execute("COMMIT", [])?;
1763
1764    Ok(deleted)
1765}
1766
1767fn delete_v2(conn: &Connection, subset: &[i64]) -> Result<usize> {
1768    conn.execute("BEGIN", [])?;
1769
1770    let original_count: i64 = conn
1771        .query_row(
1772            &format!(
1773                "SELECT COALESCE(MAX(\"{}\"), -1) FROM METADATA",
1774                SUBSET_COLUMN
1775            ),
1776            [],
1777            |row| row.get::<_, i64>(0),
1778        )
1779        .unwrap_or(-1)
1780        + 1;
1781
1782    // Delete from thin table
1783    let (in_clause, in_params, temp_table) = crate::text_search::build_in_clause(conn, subset)?;
1784    let delete_sql = format!(
1785        "DELETE FROM METADATA WHERE \"{}\" {}",
1786        SUBSET_COLUMN, in_clause
1787    );
1788    let param_refs: Vec<&dyn ToSql> = in_params.iter().map(|v| v.as_ref()).collect();
1789    let deleted = conn.execute(&delete_sql, params_from_iter(param_refs))?;
1790    if let Some(ref name) = temp_table {
1791        crate::text_search::drop_temp_table(conn, name);
1792    }
1793
1794    // A content-id keyed FTS is maintained here, inside the same transaction:
1795    // its rowids are the stable _content_id_ values, so removing the rows for
1796    // the content ids being orphaned is all the FTS upkeep a delete needs —
1797    // the _subset_ re-sequencing below never invalidates FTS rowids, and no
1798    // caller-side rebuild is required. (A legacy subset-keyed FTS is left
1799    // untouched for the caller's delete/rebuild handling.)
1800    crate::text_search::delete_fts_rows_for_orphaned_content(conn)?;
1801
1802    // Delete orphaned content rows
1803    conn.execute(
1804        &format!(
1805            "DELETE FROM {} WHERE \"{}\" NOT IN (SELECT \"{}\" FROM METADATA)",
1806            CONTENT_TABLE, CONTENT_ID_COLUMN, CONTENT_ID_COLUMN
1807        ),
1808        [],
1809    )?;
1810
1811    // Re-sequence _subset_ on the thin table (same algorithm as v1, but now
1812    // the table is small so this is always fast).
1813    let mut sorted_ids: Vec<i64> = subset.to_vec();
1814    sorted_ids.sort_unstable();
1815    sorted_ids.dedup();
1816    sorted_ids.retain(|&id| id >= 0 && id < original_count);
1817
1818    let max_id: i64 = conn
1819        .query_row(
1820            &format!(
1821                "SELECT COALESCE(MAX(\"{}\"), -1) FROM METADATA",
1822                SUBSET_COLUMN
1823            ),
1824            [],
1825            |row| row.get(0),
1826        )
1827        .unwrap_or(-1);
1828
1829    if max_id >= 0 && !sorted_ids.is_empty() {
1830        let mut updates: Vec<(i64, i64, i64)> = Vec::new();
1831        let mut i = 0;
1832        while i < sorted_ids.len() {
1833            let mut j = i + 1;
1834            while j < sorted_ids.len() && sorted_ids[j] == sorted_ids[j - 1] + 1 {
1835                j += 1;
1836            }
1837            let range_start = sorted_ids[j - 1] + 1;
1838            let range_end = if j < sorted_ids.len() {
1839                sorted_ids[j]
1840            } else {
1841                max_id + sorted_ids.len() as i64 + 1
1842            };
1843            if range_start < range_end {
1844                updates.push((range_start, range_end, j as i64));
1845            }
1846            i = j;
1847        }
1848
1849        for (from, to_excl, shift) in &updates {
1850            conn.execute(
1851                &format!(
1852                    "UPDATE METADATA SET \"{}\" = \"{}\" - ?1 WHERE \"{}\" >= ?2 AND \"{}\" < ?3",
1853                    SUBSET_COLUMN, SUBSET_COLUMN, SUBSET_COLUMN, SUBSET_COLUMN
1854                ),
1855                rusqlite::params![shift, from, to_excl],
1856            )?;
1857        }
1858    }
1859
1860    conn.execute("COMMIT", [])?;
1861    Ok(deleted)
1862}
1863
1864/// Query the database and return matching _subset_ IDs.
1865///
1866/// # Arguments
1867///
1868/// * `index_path` - Path to the index directory
1869/// * `condition` - SQL WHERE clause with `?` placeholders (e.g., "category = ? AND score > ?")
1870/// * `parameters` - Values to substitute for placeholders
1871///
1872/// # Returns
1873///
1874/// Vector of `_subset_` IDs matching the condition
1875///
1876/// # Example
1877///
1878/// ```ignore
1879/// use next-plaid::filtering;
1880/// use serde_json::json;
1881///
1882/// let subset = filtering::where_condition(
1883///     "my_index",
1884///     "category = ? AND score > ?",
1885///     &[json!("A"), json!(90)],
1886/// )?;
1887/// ```
1888pub fn where_condition(
1889    index_path: &str,
1890    condition: &str,
1891    parameters: &[Value],
1892) -> Result<Vec<i64>> {
1893    let db_path = get_db_path(index_path);
1894    if !db_path.exists() {
1895        return Err(Error::Filtering(
1896            "No metadata database found. Create it first by adding metadata during index creation."
1897                .into(),
1898        ));
1899    }
1900
1901    with_db_read(&db_path, |conn| {
1902        // Validate condition against SQL injection
1903        let valid_columns = get_schema_columns(conn)?;
1904        validate_condition(condition, &valid_columns)?;
1905
1906        let query = if is_split_schema(conn) && condition_references_fat_column(condition, conn) {
1907            format!(
1908                "SELECT M.\"{}\" FROM METADATA M JOIN {} C ON M.\"{}\" = C.\"{}\" WHERE {}",
1909                SUBSET_COLUMN, CONTENT_TABLE, CONTENT_ID_COLUMN, CONTENT_ID_COLUMN, condition
1910            )
1911        } else {
1912            format!(
1913                "SELECT \"{}\" FROM METADATA WHERE {}",
1914                SUBSET_COLUMN, condition
1915            )
1916        };
1917
1918        let params: Vec<Box<dyn ToSql>> = parameters.iter().map(json_to_sql).collect();
1919        let param_refs: Vec<&dyn ToSql> = params.iter().map(|v| v.as_ref()).collect();
1920
1921        let mut stmt = conn.prepare(&query)?;
1922        let rows = stmt.query_map(params_from_iter(param_refs), |row| row.get::<_, i64>(0))?;
1923
1924        let mut result = Vec::new();
1925        for row in rows {
1926            result.push(row?);
1927        }
1928
1929        Ok(result)
1930    })
1931}
1932
1933/// Check if a SQL condition references any fat (METADATA_CONTENT) column.
1934fn condition_references_fat_column(condition: &str, conn: &Connection) -> bool {
1935    let mut fat_columns: Vec<String> = Vec::new();
1936    if let Ok(mut stmt) = conn.prepare(&format!("PRAGMA table_info({})", CONTENT_TABLE)) {
1937        if let Ok(rows) = stmt.query_map([], |row| row.get::<_, String>(1)) {
1938            for row in rows.flatten() {
1939                if row != CONTENT_ID_COLUMN {
1940                    fat_columns.push(row);
1941                }
1942            }
1943        }
1944    }
1945    let cond_upper = condition.to_uppercase();
1946    fat_columns
1947        .iter()
1948        .any(|col| cond_upper.contains(&col.to_uppercase()))
1949}
1950
1951/// Query document IDs with REGEXP support enabled.
1952///
1953/// This function is similar to `where_condition` but registers a REGEXP
1954/// function that uses Rust's regex crate for pattern matching.
1955///
1956/// # Arguments
1957///
1958/// * `index_path` - Path to the index directory
1959/// * `condition` - SQL WHERE clause (can use `column REGEXP ?`)
1960/// * `parameters` - Values for condition placeholders
1961///
1962/// # Example
1963///
1964/// ```ignore
1965/// // Find documents where code_preview matches a regex
1966/// let ids = filtering::where_condition_regexp(
1967///     "my_index",
1968///     "code_preview REGEXP ?",
1969///     &[json!("async|await")],
1970/// )?;
1971/// ```
1972///
1973/// # Security
1974///
1975/// The regex is compiled with size limits (10MB) to prevent ReDoS attacks.
1976/// Invalid regex patterns return an error with a descriptive message.
1977pub fn where_condition_regexp(
1978    index_path: &str,
1979    condition: &str,
1980    parameters: &[Value],
1981) -> Result<Vec<i64>> {
1982    let db_path = get_db_path(index_path);
1983    if !db_path.exists() {
1984        return Err(Error::Filtering(
1985            "No metadata database found. Create it first by adding metadata during index creation."
1986                .into(),
1987        ));
1988    }
1989
1990    // For REGEXP queries, extract and pre-compile the pattern once (not per-row)
1991    // This provides both performance and security benefits
1992    let regex_pattern = parameters
1993        .first()
1994        .and_then(|v| v.as_str())
1995        .ok_or_else(|| Error::Filtering("REGEXP requires a pattern parameter".into()))?;
1996
1997    // Compile with `fancy-regex` so lookaround (`(?=...)`, `(?<=...)`)
1998    // and backreferences (`\1`) work end-to-end. Standard regex syntax
1999    // still goes through the fast `regex`-crate engine internally;
2000    // fancy-regex only falls back to its NFA when the pattern actually
2001    // needs a feature `regex` does not support. The default
2002    // `backtrack_limit` (1M) caps catastrophic patterns.
2003    //
2004    // Case-insensitivity is expressed as an inline `(?i)` flag so callers
2005    // who want case-sensitive behaviour can simply pass the pattern
2006    // without the flag. The colgrep CLI is the source of truth — it
2007    // prefixes `(?mi)` (multiline + case-insensitive) by default, and
2008    // `(?m)` alone when `--case-sensitive` is requested.
2009    let compiled_regex = std::sync::Arc::new(
2010        fancy_regex::RegexBuilder::new(regex_pattern)
2011            .build()
2012            .map_err(|e| {
2013                Error::Filtering(format!("Invalid regex pattern '{}': {}", regex_pattern, e))
2014            })?,
2015    );
2016
2017    with_db_read(&db_path, |conn| {
2018        // Validate condition against SQL injection
2019        let valid_columns = get_schema_columns(conn)?;
2020        validate_condition(condition, &valid_columns)?;
2021
2022        // Register REGEXP function with pre-compiled regex (compiled once, used for all rows)
2023        let re = compiled_regex.clone();
2024        conn.create_scalar_function(
2025            "regexp",
2026            2,
2027            rusqlite::functions::FunctionFlags::SQLITE_UTF8
2028                | rusqlite::functions::FunctionFlags::SQLITE_DETERMINISTIC,
2029            move |ctx| {
2030                // Pattern argument from SQL is ignored - we use the pre-compiled regex
2031                let _pattern: String = ctx.get(0)?;
2032                let text: String = ctx.get(1)?;
2033
2034                // `is_match` is `Result<bool>` under fancy-regex (the alt engine
2035                // can fail with `backtrack_limit_exceeded` on adversarial input).
2036                // Treat any such failure as "no match" so a single pathological
2037                // chunk can't fail the whole query.
2038                Ok(re.is_match(&text).unwrap_or(false))
2039            },
2040        )?;
2041
2042        let query = if is_split_schema(conn) && condition_references_fat_column(condition, conn) {
2043            format!(
2044                "SELECT M.\"{}\" FROM METADATA M JOIN {} C ON M.\"{}\" = C.\"{}\" WHERE {}",
2045                SUBSET_COLUMN, CONTENT_TABLE, CONTENT_ID_COLUMN, CONTENT_ID_COLUMN, condition
2046            )
2047        } else {
2048            format!(
2049                "SELECT \"{}\" FROM METADATA WHERE {}",
2050                SUBSET_COLUMN, condition
2051            )
2052        };
2053
2054        let params: Vec<Box<dyn ToSql>> = parameters.iter().map(json_to_sql).collect();
2055        let param_refs: Vec<&dyn ToSql> = params.iter().map(|v| v.as_ref()).collect();
2056
2057        let mut stmt = conn.prepare(&query)?;
2058        let rows = stmt.query_map(params_from_iter(param_refs), |row| row.get::<_, i64>(0))?;
2059
2060        let mut result = Vec::new();
2061        for row in rows {
2062            result.push(row?);
2063        }
2064
2065        Ok(result)
2066    })
2067}
2068
2069/// Get distinct non-NULL string values from a single METADATA column.
2070///
2071/// This is a focused, low-cost alternative to [`get`] when callers only need
2072/// to enumerate the unique values of a single string column (for example, the
2073/// distinct file paths represented in the index). It runs a single
2074/// `SELECT DISTINCT` query and avoids loading every row's full metadata.
2075///
2076/// # Arguments
2077///
2078/// * `index_path` - Path to the index directory
2079/// * `column` - Column name (validated against the METADATA schema)
2080///
2081/// # Returns
2082///
2083/// * `Ok(values)` - Distinct non-NULL string values from the column
2084/// * `Ok(vec![])` - The database does not exist or the column is not present
2085/// * `Err(_)` - Invalid column name or a database error
2086pub fn get_distinct_strings(index_path: &str, column: &str) -> Result<Vec<String>> {
2087    let db_path = get_db_path(index_path);
2088    if !db_path.exists() {
2089        return Ok(Vec::new());
2090    }
2091
2092    // Reject column names that aren't safe SQL identifiers up front (defense in
2093    // depth — the schema check below would also catch unknown names).
2094    if !is_valid_column_name(column) {
2095        return Err(Error::Filtering(format!(
2096            "Invalid column name: '{}'",
2097            column
2098        )));
2099    }
2100
2101    with_db_read(&db_path, |conn| {
2102        let columns = get_schema_columns(conn)?;
2103        if !columns.contains(column) {
2104            return Ok(Vec::new());
2105        }
2106
2107        let query = if is_split_schema(conn) && !is_thin_column(column) {
2108            format!(
2109                "SELECT DISTINCT \"{0}\" FROM {1} WHERE \"{0}\" IS NOT NULL",
2110                column, CONTENT_TABLE
2111            )
2112        } else {
2113            format!(
2114                "SELECT DISTINCT \"{0}\" FROM METADATA WHERE \"{0}\" IS NOT NULL",
2115                column
2116            )
2117        };
2118        let mut stmt = conn.prepare(&query)?;
2119        let rows = stmt.query_map([], |row| row.get::<_, Option<String>>(0))?;
2120
2121        let mut values: Vec<String> = Vec::new();
2122        for row in rows {
2123            if let Some(value) = row? {
2124                values.push(value);
2125            }
2126        }
2127
2128        Ok(values)
2129    })
2130}
2131
2132/// Get full metadata rows by condition or subset IDs.
2133///
2134/// Returns metadata as JSON objects with the `_subset_` field included.
2135///
2136/// # Arguments
2137///
2138/// * `index_path` - Path to the index directory
2139/// * `condition` - Optional SQL WHERE clause (mutually exclusive with `subset`)
2140/// * `parameters` - Values for condition placeholders
2141/// * `subset` - Optional list of `_subset_` IDs to retrieve (mutually exclusive with `condition`)
2142///
2143/// # Returns
2144///
2145/// Vector of JSON objects representing metadata rows
2146///
2147/// # Ordering
2148///
2149/// - If `subset` is provided: Returns rows in the order specified by `subset`
2150/// - If `condition` is provided: Returns rows ordered by `_subset_` ascending
2151pub fn get(
2152    index_path: &str,
2153    condition: Option<&str>,
2154    parameters: &[Value],
2155    subset: Option<&[i64]>,
2156) -> Result<Vec<Value>> {
2157    if condition.is_some() && subset.is_some() {
2158        return Err(Error::Filtering(
2159            "Please provide either a 'condition' or a 'subset', not both.".into(),
2160        ));
2161    }
2162
2163    let db_path = get_db_path(index_path);
2164    if !db_path.exists() {
2165        return Ok(Vec::new());
2166    }
2167
2168    with_db_read(&db_path, |conn| {
2169        // Validate condition against SQL injection if provided
2170        if let Some(cond) = condition {
2171            let valid_columns = get_schema_columns(conn)?;
2172            validate_condition(cond, &valid_columns)?;
2173        }
2174
2175        if is_split_schema(conn) {
2176            return get_v2(conn, condition, parameters, subset);
2177        }
2178
2179        // Get column names
2180        let mut columns: Vec<String> = Vec::new();
2181        {
2182            let mut stmt = conn.prepare("PRAGMA table_info(METADATA)")?;
2183            let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
2184            for row in rows {
2185                columns.push(row?);
2186            }
2187        }
2188
2189        if let Some(ids) = subset {
2190            if ids.is_empty() {
2191                return Ok(Vec::new());
2192            }
2193
2194            let mut results: Vec<Value> = Vec::new();
2195            for chunk in ids.chunks(SQLITE_PARAM_LIMIT) {
2196                let placeholders: Vec<&str> = std::iter::repeat_n("?", chunk.len()).collect();
2197                let query = format!(
2198                    "SELECT * FROM METADATA WHERE \"{}\" IN ({})",
2199                    SUBSET_COLUMN,
2200                    placeholders.join(", ")
2201                );
2202                let params: Vec<Box<dyn ToSql>> = chunk
2203                    .iter()
2204                    .map(|&id| Box::new(id) as Box<dyn ToSql>)
2205                    .collect();
2206                let param_refs: Vec<&dyn ToSql> = params.iter().map(|v| v.as_ref()).collect();
2207                let mut stmt = conn.prepare(&query)?;
2208                let mut rows = stmt.query(params_from_iter(param_refs))?;
2209
2210                while let Some(row) = rows.next()? {
2211                    let mut obj = serde_json::Map::new();
2212                    for (i, col) in columns.iter().enumerate() {
2213                        let value = row_to_json_value(row, i)?;
2214                        obj.insert(col.clone(), value);
2215                    }
2216                    results.push(Value::Object(obj));
2217                }
2218            }
2219
2220            let mut results_map: HashMap<i64, Value> = HashMap::new();
2221            for result in results {
2222                if let Some(id) = result.get(SUBSET_COLUMN).and_then(|v| v.as_i64()) {
2223                    results_map.insert(id, result);
2224                }
2225            }
2226            return Ok(ids.iter().filter_map(|id| results_map.remove(id)).collect());
2227        }
2228
2229        // Build query
2230        let (query, params): (String, Vec<Box<dyn ToSql>>) = if let Some(cond) = condition {
2231            let query = format!(
2232                "SELECT * FROM METADATA WHERE {} ORDER BY \"{}\"",
2233                cond, SUBSET_COLUMN
2234            );
2235            let params = parameters.iter().map(json_to_sql).collect();
2236            (query, params)
2237        } else {
2238            let query = format!("SELECT * FROM METADATA ORDER BY \"{}\"", SUBSET_COLUMN);
2239            (query, Vec::new())
2240        };
2241
2242        let param_refs: Vec<&dyn ToSql> = params.iter().map(|v| v.as_ref()).collect();
2243        let mut stmt = conn.prepare(&query)?;
2244        let mut rows = stmt.query(params_from_iter(param_refs))?;
2245
2246        let mut results: Vec<Value> = Vec::new();
2247        while let Some(row) = rows.next()? {
2248            let mut obj = serde_json::Map::new();
2249            for (i, col) in columns.iter().enumerate() {
2250                let value = row_to_json_value(row, i)?;
2251                obj.insert(col.clone(), value);
2252            }
2253            results.push(Value::Object(obj));
2254        }
2255
2256        Ok(results)
2257    })
2258}
2259
2260fn get_v2(
2261    conn: &Connection,
2262    condition: Option<&str>,
2263    parameters: &[Value],
2264    subset: Option<&[i64]>,
2265) -> Result<Vec<Value>> {
2266    // Build the column list for the JOIN query, excluding _content_id_.
2267    let mut thin_cols: Vec<String> = Vec::new();
2268    {
2269        let mut stmt = conn.prepare("PRAGMA table_info(METADATA)")?;
2270        let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
2271        for row in rows {
2272            let col = row?;
2273            if col != CONTENT_ID_COLUMN {
2274                thin_cols.push(col);
2275            }
2276        }
2277    }
2278    let mut fat_cols: Vec<String> = Vec::new();
2279    {
2280        let mut stmt = conn.prepare(&format!("PRAGMA table_info({})", CONTENT_TABLE))?;
2281        let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
2282        for row in rows {
2283            let col = row?;
2284            if col != CONTENT_ID_COLUMN {
2285                fat_cols.push(col);
2286            }
2287        }
2288    }
2289
2290    // Build SELECT columns: M.thin_col, ..., C.fat_col, ...
2291    let mut select_parts: Vec<String> = Vec::new();
2292    for col in &thin_cols {
2293        select_parts.push(format!("M.\"{}\"", col));
2294    }
2295    for col in &fat_cols {
2296        select_parts.push(format!("C.\"{}\"", col));
2297    }
2298    let select_clause = select_parts.join(", ");
2299
2300    let from_clause = format!(
2301        "METADATA M JOIN {} C ON M.\"{}\" = C.\"{}\"",
2302        CONTENT_TABLE, CONTENT_ID_COLUMN, CONTENT_ID_COLUMN
2303    );
2304
2305    // Merged column names for result construction
2306    let all_cols: Vec<&String> = thin_cols.iter().chain(fat_cols.iter()).collect();
2307
2308    if let Some(ids) = subset {
2309        if ids.is_empty() {
2310            return Ok(Vec::new());
2311        }
2312
2313        let mut results: Vec<Value> = Vec::new();
2314        for chunk in ids.chunks(SQLITE_PARAM_LIMIT) {
2315            let placeholders: Vec<&str> = std::iter::repeat_n("?", chunk.len()).collect();
2316            let query = format!(
2317                "SELECT {} FROM {} WHERE M.\"{}\" IN ({})",
2318                select_clause,
2319                from_clause,
2320                SUBSET_COLUMN,
2321                placeholders.join(", ")
2322            );
2323            let params: Vec<Box<dyn ToSql>> = chunk
2324                .iter()
2325                .map(|&id| Box::new(id) as Box<dyn ToSql>)
2326                .collect();
2327            let param_refs: Vec<&dyn ToSql> = params.iter().map(|v| v.as_ref()).collect();
2328            let mut stmt = conn.prepare(&query)?;
2329            let mut rows = stmt.query(params_from_iter(param_refs))?;
2330
2331            while let Some(row) = rows.next()? {
2332                let mut obj = serde_json::Map::new();
2333                for (i, col) in all_cols.iter().enumerate() {
2334                    let value = row_to_json_value(row, i)?;
2335                    obj.insert((*col).clone(), value);
2336                }
2337                results.push(Value::Object(obj));
2338            }
2339        }
2340
2341        let mut results_map: HashMap<i64, Value> = HashMap::new();
2342        for result in results {
2343            if let Some(id) = result.get(SUBSET_COLUMN).and_then(|v| v.as_i64()) {
2344                results_map.insert(id, result);
2345            }
2346        }
2347        return Ok(ids.iter().filter_map(|id| results_map.remove(id)).collect());
2348    }
2349
2350    let (query, params): (String, Vec<Box<dyn ToSql>>) = if let Some(cond) = condition {
2351        let query = format!(
2352            "SELECT {} FROM {} WHERE {} ORDER BY M.\"{}\"",
2353            select_clause, from_clause, cond, SUBSET_COLUMN
2354        );
2355        let params = parameters.iter().map(json_to_sql).collect();
2356        (query, params)
2357    } else {
2358        let query = format!(
2359            "SELECT {} FROM {} ORDER BY M.\"{}\"",
2360            select_clause, from_clause, SUBSET_COLUMN
2361        );
2362        (query, Vec::new())
2363    };
2364
2365    let param_refs: Vec<&dyn ToSql> = params.iter().map(|v| v.as_ref()).collect();
2366    let mut stmt = conn.prepare(&query)?;
2367    let mut rows = stmt.query(params_from_iter(param_refs))?;
2368
2369    let mut results: Vec<Value> = Vec::new();
2370    while let Some(row) = rows.next()? {
2371        let mut obj = serde_json::Map::new();
2372        for (i, col) in all_cols.iter().enumerate() {
2373            let value = row_to_json_value(row, i)?;
2374            obj.insert((*col).clone(), value);
2375        }
2376        results.push(Value::Object(obj));
2377    }
2378
2379    Ok(results)
2380}
2381
2382/// Helper to convert a rusqlite row column to JSON value.
2383fn row_to_json_value(row: &rusqlite::Row, idx: usize) -> SqliteResult<Value> {
2384    // Try to get the value in order of most likely types
2385    if let Ok(i) = row.get::<_, i64>(idx) {
2386        return Ok(Value::Number(i.into()));
2387    }
2388    if let Ok(f) = row.get::<_, f64>(idx) {
2389        return Ok(serde_json::Number::from_f64(f)
2390            .map(Value::Number)
2391            .unwrap_or(Value::Null));
2392    }
2393    if let Ok(s) = row.get::<_, String>(idx) {
2394        return Ok(Value::String(s));
2395    }
2396    if let Ok(b) = row.get::<_, Vec<u8>>(idx) {
2397        // Try to parse as JSON first
2398        if let Ok(v) = serde_json::from_slice(&b) {
2399            return Ok(v);
2400        }
2401        // Otherwise return as base64 string
2402        return Ok(Value::String(base64_encode(&b)));
2403    }
2404    Ok(Value::Null)
2405}
2406
2407fn base64_encode(data: &[u8]) -> String {
2408    let mut result = String::with_capacity(data.len() * 4 / 3 + 4);
2409    const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
2410
2411    for chunk in data.chunks(3) {
2412        let b0 = chunk[0] as usize;
2413        let b1 = chunk.get(1).copied().unwrap_or(0) as usize;
2414        let b2 = chunk.get(2).copied().unwrap_or(0) as usize;
2415
2416        result.push(ALPHABET[b0 >> 2] as char);
2417        result.push(ALPHABET[((b0 & 0x03) << 4) | (b1 >> 4)] as char);
2418
2419        if chunk.len() > 1 {
2420            result.push(ALPHABET[((b1 & 0x0f) << 2) | (b2 >> 6)] as char);
2421        } else {
2422            result.push('=');
2423        }
2424
2425        if chunk.len() > 2 {
2426            result.push(ALPHABET[b2 & 0x3f] as char);
2427        } else {
2428            result.push('=');
2429        }
2430    }
2431
2432    result
2433}
2434
2435/// Update metadata rows matching a SQL condition.
2436///
2437/// This function updates existing metadata rows that match the given condition.
2438/// The updates are provided as a JSON object where keys are column names and values
2439/// are the new values to set.
2440///
2441/// # Arguments
2442///
2443/// * `index_path` - Path to the index directory
2444/// * `condition` - SQL WHERE clause with `?` placeholders (e.g., "category = ? AND score > ?")
2445/// * `parameters` - Values to substitute for condition placeholders
2446/// * `updates` - JSON object with column names and new values
2447///
2448/// # Returns
2449///
2450/// Number of rows updated
2451///
2452/// # Example
2453///
2454/// ```ignore
2455/// use next-plaid::filtering;
2456/// use serde_json::json;
2457///
2458/// let updated = filtering::update_where(
2459///     "my_index",
2460///     "category = ?",
2461///     &[json!("A")],
2462///     &json!({"score": 100, "status": "reviewed"}),
2463/// )?;
2464/// ```
2465pub fn update_where(
2466    index_path: &str,
2467    condition: &str,
2468    parameters: &[Value],
2469    updates: &Value,
2470) -> Result<usize> {
2471    let db_path = get_db_path(index_path);
2472    if !db_path.exists() {
2473        return Err(Error::Filtering(
2474            "No metadata database found. Create it first by adding metadata during index creation."
2475                .into(),
2476        ));
2477    }
2478
2479    // Parse updates as an object
2480    let updates_obj = match updates {
2481        Value::Object(obj) => obj,
2482        _ => {
2483            return Err(Error::Filtering("Updates must be a JSON object".into()));
2484        }
2485    };
2486
2487    if updates_obj.is_empty() {
2488        return Ok(0);
2489    }
2490
2491    let conn = open_db_write(&db_path)?;
2492
2493    // Validate condition against SQL injection
2494    let valid_columns = get_schema_columns(&conn)?;
2495    validate_condition(condition, &valid_columns)?;
2496
2497    // Validate update column names against schema
2498    for col_name in updates_obj.keys() {
2499        if col_name == SUBSET_COLUMN {
2500            return Err(Error::Filtering("Cannot update the _subset_ column".into()));
2501        }
2502        if !is_valid_column_name(col_name) {
2503            return Err(Error::Filtering(format!(
2504                "Invalid column name '{}'. Column names must start with a letter or \
2505                 underscore, followed by letters, digits, or underscores.",
2506                col_name
2507            )));
2508        }
2509        // Check if column exists (case-insensitive)
2510        let col_lower = col_name.to_lowercase();
2511        let exists = valid_columns.iter().any(|c| c.to_lowercase() == col_lower);
2512        if !exists {
2513            return Err(Error::Filtering(format!(
2514                "Unknown column '{}' in updates",
2515                col_name
2516            )));
2517        }
2518    }
2519
2520    // Find affected rows before the update (for FTS sync).
2521    let affected_query =
2522        if is_split_schema(&conn) && condition_references_fat_column(condition, &conn) {
2523            format!(
2524                "SELECT M.\"{}\" FROM METADATA M JOIN {} C ON M.\"{}\" = C.\"{}\" WHERE {}",
2525                SUBSET_COLUMN, CONTENT_TABLE, CONTENT_ID_COLUMN, CONTENT_ID_COLUMN, condition
2526            )
2527        } else {
2528            format!(
2529                "SELECT \"{}\" FROM METADATA WHERE {}",
2530                SUBSET_COLUMN, condition
2531            )
2532        };
2533    let affected_ids: Vec<i64> = {
2534        let cond_params: Vec<Box<dyn ToSql>> = parameters.iter().map(json_to_sql).collect();
2535        let cond_param_refs: Vec<&dyn ToSql> = cond_params.iter().map(|v| v.as_ref()).collect();
2536
2537        let mut affected_stmt = conn.prepare(&affected_query)?;
2538        let rows = affected_stmt.query_map(params_from_iter(cond_param_refs), |row| {
2539            row.get::<_, i64>(0)
2540        })?;
2541        rows.filter_map(|row| row.ok()).collect()
2542    };
2543
2544    if is_split_schema(&conn) {
2545        return update_where_v2(
2546            &conn,
2547            index_path,
2548            updates_obj,
2549            &affected_ids,
2550            condition,
2551            parameters,
2552        );
2553    }
2554
2555    // Build SET clause
2556    let set_parts: Vec<String> = updates_obj
2557        .keys()
2558        .map(|col| format!("\"{}\" = ?", col))
2559        .collect();
2560    let set_clause = set_parts.join(", ");
2561
2562    // Build UPDATE query
2563    let query = format!("UPDATE METADATA SET {} WHERE {}", set_clause, condition);
2564
2565    // Build parameter list: first the update values, then the condition parameters
2566    let mut all_params: Vec<Box<dyn ToSql>> = updates_obj.values().map(json_to_sql).collect();
2567    all_params.extend(parameters.iter().map(json_to_sql));
2568
2569    let param_refs: Vec<&dyn ToSql> = all_params.iter().map(|v| v.as_ref()).collect();
2570
2571    let updated = conn.execute(&query, params_from_iter(param_refs))?;
2572
2573    if updated > 0 && !affected_ids.is_empty() {
2574        crate::text_search::update_rows(index_path, &affected_ids)?;
2575    }
2576
2577    Ok(updated)
2578}
2579
2580fn update_where_v2(
2581    conn: &Connection,
2582    index_path: &str,
2583    updates_obj: &serde_json::Map<String, Value>,
2584    affected_ids: &[i64],
2585    _condition: &str,
2586    _parameters: &[Value],
2587) -> Result<usize> {
2588    if affected_ids.is_empty() {
2589        return Ok(0);
2590    }
2591
2592    // Split updates into thin and fat columns.
2593    let thin_updates: Vec<(&String, &Value)> = updates_obj
2594        .iter()
2595        .filter(|(k, _)| is_thin_column(k))
2596        .collect();
2597    let fat_updates: Vec<(&String, &Value)> = updates_obj
2598        .iter()
2599        .filter(|(k, _)| !is_thin_column(k))
2600        .collect();
2601
2602    conn.execute("BEGIN", [])?;
2603
2604    let mut total_updated = 0usize;
2605
2606    // Update thin table
2607    if !thin_updates.is_empty() {
2608        let set_parts: Vec<String> = thin_updates
2609            .iter()
2610            .map(|(col, _)| format!("\"{}\" = ?", col))
2611            .collect();
2612        let set_clause = set_parts.join(", ");
2613
2614        for chunk in affected_ids.chunks(SQLITE_PARAM_LIMIT) {
2615            let placeholders: Vec<&str> = std::iter::repeat_n("?", chunk.len()).collect();
2616            let query = format!(
2617                "UPDATE METADATA SET {} WHERE \"{}\" IN ({})",
2618                set_clause,
2619                SUBSET_COLUMN,
2620                placeholders.join(", ")
2621            );
2622            let mut params: Vec<Box<dyn ToSql>> =
2623                thin_updates.iter().map(|(_, v)| json_to_sql(v)).collect();
2624            params.extend(chunk.iter().map(|&id| Box::new(id) as Box<dyn ToSql>));
2625            let param_refs: Vec<&dyn ToSql> = params.iter().map(|v| v.as_ref()).collect();
2626            total_updated += conn.execute(&query, params_from_iter(param_refs))?;
2627        }
2628    }
2629
2630    // Update fat table
2631    if !fat_updates.is_empty() {
2632        let set_parts: Vec<String> = fat_updates
2633            .iter()
2634            .map(|(col, _)| format!("\"{}\" = ?", col))
2635            .collect();
2636        let set_clause = set_parts.join(", ");
2637
2638        // Get content IDs for affected rows
2639        for chunk in affected_ids.chunks(SQLITE_PARAM_LIMIT) {
2640            let placeholders: Vec<&str> = std::iter::repeat_n("?", chunk.len()).collect();
2641            let content_ids_query = format!(
2642                "SELECT \"{}\" FROM METADATA WHERE \"{}\" IN ({})",
2643                CONTENT_ID_COLUMN,
2644                SUBSET_COLUMN,
2645                placeholders.join(", ")
2646            );
2647            let id_params: Vec<Box<dyn ToSql>> = chunk
2648                .iter()
2649                .map(|&id| Box::new(id) as Box<dyn ToSql>)
2650                .collect();
2651            let id_param_refs: Vec<&dyn ToSql> = id_params.iter().map(|v| v.as_ref()).collect();
2652            let mut stmt = conn.prepare(&content_ids_query)?;
2653            let content_ids: Vec<i64> = stmt
2654                .query_map(params_from_iter(id_param_refs), |row| row.get::<_, i64>(0))?
2655                .filter_map(|r| r.ok())
2656                .collect();
2657
2658            if !content_ids.is_empty() {
2659                let c_placeholders: Vec<&str> =
2660                    std::iter::repeat_n("?", content_ids.len()).collect();
2661                let query = format!(
2662                    "UPDATE {} SET {} WHERE \"{}\" IN ({})",
2663                    CONTENT_TABLE,
2664                    set_clause,
2665                    CONTENT_ID_COLUMN,
2666                    c_placeholders.join(", ")
2667                );
2668                let mut params: Vec<Box<dyn ToSql>> =
2669                    fat_updates.iter().map(|(_, v)| json_to_sql(v)).collect();
2670                params.extend(content_ids.iter().map(|&id| Box::new(id) as Box<dyn ToSql>));
2671                let param_refs: Vec<&dyn ToSql> = params.iter().map(|v| v.as_ref()).collect();
2672                total_updated += conn.execute(&query, params_from_iter(param_refs))?;
2673            }
2674        }
2675    }
2676
2677    conn.execute("COMMIT", [])?;
2678
2679    if total_updated > 0 && !affected_ids.is_empty() {
2680        crate::text_search::update_rows(index_path, affected_ids)?;
2681    }
2682
2683    Ok(affected_ids.len())
2684}
2685
2686/// Get the number of documents in the metadata database.
2687pub fn count(index_path: &str) -> Result<usize> {
2688    let db_path = get_db_path(index_path);
2689    if !db_path.exists() {
2690        return Ok(0);
2691    }
2692
2693    with_db_read(&db_path, |conn| {
2694        let count: i64 = conn.query_row("SELECT COUNT(*) FROM METADATA", [], |row| row.get(0))?;
2695        Ok(count as usize)
2696    })
2697}
2698
2699#[cfg(test)]
2700mod tests {
2701    use super::*;
2702    use serde_json::json;
2703    use tempfile::TempDir;
2704
2705    fn setup_test_dir() -> TempDir {
2706        TempDir::new().unwrap()
2707    }
2708
2709    #[test]
2710    fn test_create_empty() {
2711        let dir = setup_test_dir();
2712        let path = dir.path().to_str().unwrap();
2713
2714        let result = create(path, &[], &[]).unwrap();
2715        assert_eq!(result, 0);
2716        assert!(!exists(path));
2717    }
2718
2719    #[test]
2720    fn test_create_with_metadata() {
2721        let dir = setup_test_dir();
2722        let path = dir.path().to_str().unwrap();
2723
2724        let metadata = vec![
2725            json!({"name": "Alice", "age": 30, "score": 95.5}),
2726            json!({"name": "Bob", "age": 25, "score": 87.0}),
2727            json!({"name": "Charlie", "age": 35}),
2728        ];
2729        let doc_ids: Vec<i64> = (0..3).collect();
2730
2731        let result = create(path, &metadata, &doc_ids).unwrap();
2732        assert_eq!(result, 3);
2733        assert!(exists(path));
2734
2735        // Verify count
2736        assert_eq!(count(path).unwrap(), 3);
2737    }
2738
2739    #[test]
2740    fn test_create_invalid_column_name() {
2741        let dir = setup_test_dir();
2742        let path = dir.path().to_str().unwrap();
2743
2744        let metadata = vec![json!({"valid_name": "Alice", "invalid name": 30})];
2745        let doc_ids = vec![0];
2746
2747        let result = create(path, &metadata, &doc_ids);
2748        assert!(result.is_err());
2749    }
2750
2751    #[test]
2752    fn test_where_condition() {
2753        let dir = setup_test_dir();
2754        let path = dir.path().to_str().unwrap();
2755
2756        let metadata = vec![
2757            json!({"name": "Alice", "category": "A", "score": 95}),
2758            json!({"name": "Bob", "category": "B", "score": 87}),
2759            json!({"name": "Charlie", "category": "A", "score": 92}),
2760        ];
2761        let doc_ids: Vec<i64> = (0..3).collect();
2762
2763        create(path, &metadata, &doc_ids).unwrap();
2764
2765        // Query by category
2766        let subset = where_condition(path, "category = ?", &[json!("A")]).unwrap();
2767        assert_eq!(subset, vec![0, 2]);
2768
2769        // Query with multiple conditions
2770        let subset =
2771            where_condition(path, "category = ? AND score > ?", &[json!("A"), json!(93)]).unwrap();
2772        assert_eq!(subset, vec![0]);
2773    }
2774
2775    #[test]
2776    fn test_get_distinct_strings_returns_unique_values() {
2777        let dir = setup_test_dir();
2778        let path = dir.path().to_str().unwrap();
2779
2780        let metadata = vec![
2781            json!({"file": "src/a.rs", "code": "x"}),
2782            json!({"file": "src/a.rs", "code": "y"}),
2783            json!({"file": "src/b.rs", "code": "z"}),
2784        ];
2785        let doc_ids: Vec<i64> = (0..3).collect();
2786        create(path, &metadata, &doc_ids).unwrap();
2787
2788        let mut files = get_distinct_strings(path, "file").unwrap();
2789        files.sort();
2790        assert_eq!(files, vec!["src/a.rs".to_string(), "src/b.rs".to_string()]);
2791    }
2792
2793    #[test]
2794    fn test_get_distinct_strings_missing_db_returns_empty() {
2795        let dir = setup_test_dir();
2796        let path = dir.path().to_str().unwrap();
2797        // No create() call — DB does not exist.
2798        let files = get_distinct_strings(path, "file").unwrap();
2799        assert!(files.is_empty());
2800    }
2801
2802    #[test]
2803    fn test_get_distinct_strings_unknown_column_returns_empty() {
2804        let dir = setup_test_dir();
2805        let path = dir.path().to_str().unwrap();
2806
2807        let metadata = vec![json!({"file": "src/a.rs"})];
2808        create(path, &metadata, &[0]).unwrap();
2809
2810        let values = get_distinct_strings(path, "not_a_column").unwrap();
2811        assert!(values.is_empty());
2812    }
2813
2814    #[test]
2815    fn test_get_distinct_strings_rejects_invalid_column_name() {
2816        let dir = setup_test_dir();
2817        let path = dir.path().to_str().unwrap();
2818
2819        let metadata = vec![json!({"file": "src/a.rs"})];
2820        create(path, &metadata, &[0]).unwrap();
2821
2822        let result = get_distinct_strings(path, "file; DROP TABLE METADATA --");
2823        assert!(result.is_err());
2824    }
2825
2826    #[test]
2827    fn test_get_all() {
2828        let dir = setup_test_dir();
2829        let path = dir.path().to_str().unwrap();
2830
2831        let metadata = vec![
2832            json!({"name": "Alice", "age": 30}),
2833            json!({"name": "Bob", "age": 25}),
2834        ];
2835        let doc_ids: Vec<i64> = (0..2).collect();
2836
2837        create(path, &metadata, &doc_ids).unwrap();
2838
2839        let results = get(path, None, &[], None).unwrap();
2840        assert_eq!(results.len(), 2);
2841        assert_eq!(results[0]["name"], "Alice");
2842        assert_eq!(results[1]["name"], "Bob");
2843    }
2844
2845    #[test]
2846    fn test_get_by_subset() {
2847        let dir = setup_test_dir();
2848        let path = dir.path().to_str().unwrap();
2849
2850        let metadata = vec![
2851            json!({"name": "Alice"}),
2852            json!({"name": "Bob"}),
2853            json!({"name": "Charlie"}),
2854        ];
2855        let doc_ids: Vec<i64> = (0..3).collect();
2856
2857        create(path, &metadata, &doc_ids).unwrap();
2858
2859        // Get specific subset in order
2860        let results = get(path, None, &[], Some(&[2, 0])).unwrap();
2861        assert_eq!(results.len(), 2);
2862        assert_eq!(results[0]["name"], "Charlie");
2863        assert_eq!(results[1]["name"], "Alice");
2864    }
2865
2866    #[test]
2867    fn test_update_adds_rows() {
2868        let dir = setup_test_dir();
2869        let path = dir.path().to_str().unwrap();
2870
2871        let metadata1 = vec![json!({"name": "Alice"}), json!({"name": "Bob"})];
2872        let doc_ids1: Vec<i64> = (0..2).collect();
2873
2874        create(path, &metadata1, &doc_ids1).unwrap();
2875        assert_eq!(count(path).unwrap(), 2);
2876
2877        let metadata2 = vec![json!({"name": "Charlie"})];
2878        let doc_ids2 = vec![2]; // Next ID after the first batch
2879
2880        update(path, &metadata2, &doc_ids2).unwrap();
2881        assert_eq!(count(path).unwrap(), 3);
2882
2883        // Verify the new row has correct _subset_ ID
2884        let results = get(path, None, &[], None).unwrap();
2885        assert_eq!(results[2]["_subset_"], 2);
2886        assert_eq!(results[2]["name"], "Charlie");
2887    }
2888
2889    #[test]
2890    fn test_update_adds_columns() {
2891        let dir = setup_test_dir();
2892        let path = dir.path().to_str().unwrap();
2893
2894        let metadata1 = vec![json!({"name": "Alice"})];
2895        let doc_ids1 = vec![0];
2896
2897        create(path, &metadata1, &doc_ids1).unwrap();
2898
2899        let metadata2 = vec![json!({"name": "Bob", "age": 25, "city": "NYC"})];
2900        let doc_ids2 = vec![1];
2901
2902        update(path, &metadata2, &doc_ids2).unwrap();
2903
2904        // Verify new columns exist
2905        let results = get(path, None, &[], None).unwrap();
2906        assert_eq!(results[0]["name"], "Alice");
2907        assert!(results[0]["age"].is_null()); // Old row has null for new column
2908        assert_eq!(results[1]["age"], 25);
2909        assert_eq!(results[1]["city"], "NYC");
2910    }
2911
2912    #[test]
2913    fn test_delete_and_reindex() {
2914        let dir = setup_test_dir();
2915        let path = dir.path().to_str().unwrap();
2916
2917        let metadata = vec![
2918            json!({"name": "Alice"}),
2919            json!({"name": "Bob"}),
2920            json!({"name": "Charlie"}),
2921            json!({"name": "Diana"}),
2922        ];
2923        let doc_ids: Vec<i64> = (0..4).collect();
2924
2925        create(path, &metadata, &doc_ids).unwrap();
2926
2927        // Delete Bob (1) and Charlie (2)
2928        let deleted = delete(path, &[1, 2]).unwrap();
2929        assert_eq!(deleted, 2);
2930        assert_eq!(count(path).unwrap(), 2);
2931
2932        // Verify remaining rows have re-indexed _subset_ IDs
2933        let results = get(path, None, &[], None).unwrap();
2934        assert_eq!(results.len(), 2);
2935        assert_eq!(results[0]["_subset_"], 0);
2936        assert_eq!(results[0]["name"], "Alice");
2937        assert_eq!(results[1]["_subset_"], 1);
2938        assert_eq!(results[1]["name"], "Diana");
2939    }
2940
2941    /// Re-sequencing must be robust to ids that are not actually present: negative
2942    /// or out-of-range ids passed in `subset` must be ignored, not folded into the
2943    /// shift math. Without clamping, a negative id shifts row 0 to -1 (collision) and
2944    /// an in-the-middle phantom over-shifts survivors — corrupting `_subset_` and thus
2945    /// the metadata/FTS/IVF alignment. (Regression guard for the range-UPDATE path.)
2946    #[test]
2947    fn test_delete_resequence_ignores_out_of_range_and_negative_ids() {
2948        let dir = setup_test_dir();
2949        let path = dir.path().to_str().unwrap();
2950
2951        let metadata = vec![
2952            json!({"name": "Alice"}),   // 0
2953            json!({"name": "Bob"}),     // 1
2954            json!({"name": "Charlie"}), // 2
2955            json!({"name": "Diana"}),   // 3
2956            json!({"name": "Eve"}),     // 4
2957            json!({"name": "Frank"}),   // 5
2958        ];
2959        let doc_ids: Vec<i64> = (0..6).collect();
2960        create(path, &metadata, &doc_ids).unwrap();
2961
2962        // Delete Bob(1) and Diana(3); -5 and 999 are not present and must be ignored.
2963        let deleted = delete(path, &[1, 3, -5, 999]).unwrap();
2964        assert_eq!(deleted, 2, "only the two present ids are removed");
2965        assert_eq!(count(path).unwrap(), 4);
2966
2967        // Survivors keep order and renumber contiguously 0..3 with no collisions.
2968        let rows = get(path, None, &[], None).unwrap();
2969        let got: Vec<(i64, String)> = rows
2970            .iter()
2971            .map(|r| {
2972                (
2973                    r["_subset_"].as_i64().unwrap(),
2974                    r["name"].as_str().unwrap().to_string(),
2975                )
2976            })
2977            .collect();
2978        assert_eq!(
2979            got,
2980            vec![
2981                (0, "Alice".into()),
2982                (1, "Charlie".into()),
2983                (2, "Eve".into()),
2984                (3, "Frank".into()),
2985            ]
2986        );
2987    }
2988
2989    #[test]
2990    fn test_where_with_like() {
2991        let dir = setup_test_dir();
2992        let path = dir.path().to_str().unwrap();
2993
2994        let metadata = vec![
2995            json!({"name": "Alice"}),
2996            json!({"name": "Alex"}),
2997            json!({"name": "Bob"}),
2998        ];
2999        let doc_ids: Vec<i64> = (0..3).collect();
3000
3001        create(path, &metadata, &doc_ids).unwrap();
3002
3003        let subset = where_condition(path, "name LIKE ?", &[json!("Al%")]).unwrap();
3004        assert_eq!(subset, vec![0, 1]);
3005    }
3006
3007    #[test]
3008    fn test_is_valid_column_name() {
3009        assert!(is_valid_column_name("name"));
3010        assert!(is_valid_column_name("_private"));
3011        assert!(is_valid_column_name("column123"));
3012        assert!(is_valid_column_name("Col_Name_2"));
3013
3014        assert!(!is_valid_column_name("123column")); // starts with number
3015        assert!(!is_valid_column_name("column name")); // space
3016        assert!(!is_valid_column_name("column-name")); // hyphen
3017        assert!(!is_valid_column_name("")); // empty
3018        assert!(!is_valid_column_name("col;drop")); // SQL injection attempt
3019    }
3020
3021    #[test]
3022    fn test_type_inference() {
3023        let dir = setup_test_dir();
3024        let path = dir.path().to_str().unwrap();
3025
3026        let metadata = vec![json!({
3027            "int_val": 42,
3028            "float_val": 3.125,
3029            "str_val": "hello",
3030            "bool_val": true,
3031            "null_val": null
3032        })];
3033        let doc_ids = vec![0];
3034
3035        create(path, &metadata, &doc_ids).unwrap();
3036
3037        let results = get(path, None, &[], None).unwrap();
3038        assert_eq!(results[0]["int_val"], 42);
3039        assert!((results[0]["float_val"].as_f64().unwrap() - 3.125).abs() < 0.001);
3040        assert_eq!(results[0]["str_val"], "hello");
3041        assert_eq!(results[0]["bool_val"], 1); // Bool stored as INTEGER
3042        assert!(results[0]["null_val"].is_null());
3043    }
3044
3045    // =============================================================================
3046    // SQL Condition Validator Tests
3047    // =============================================================================
3048
3049    fn test_columns() -> HashSet<String> {
3050        ["name", "category", "score", "status", "_subset_"]
3051            .iter()
3052            .map(|s| s.to_string())
3053            .collect()
3054    }
3055
3056    #[test]
3057    fn test_validator_simple_equality() {
3058        let cols = test_columns();
3059        assert!(validate_condition("name = ?", &cols).is_ok());
3060        assert!(validate_condition("score = ?", &cols).is_ok());
3061    }
3062
3063    #[test]
3064    fn test_validator_comparison_operators() {
3065        let cols = test_columns();
3066        assert!(validate_condition("score > ?", &cols).is_ok());
3067        assert!(validate_condition("score >= ?", &cols).is_ok());
3068        assert!(validate_condition("score < ?", &cols).is_ok());
3069        assert!(validate_condition("score <= ?", &cols).is_ok());
3070        assert!(validate_condition("score != ?", &cols).is_ok());
3071        assert!(validate_condition("score <> ?", &cols).is_ok());
3072    }
3073
3074    #[test]
3075    fn test_validator_and_or() {
3076        let cols = test_columns();
3077        assert!(validate_condition("name = ? AND score > ?", &cols).is_ok());
3078        assert!(validate_condition("category = ? OR status = ?", &cols).is_ok());
3079        assert!(validate_condition("name = ? AND score > ? OR category = ?", &cols).is_ok());
3080    }
3081
3082    #[test]
3083    fn test_validator_like() {
3084        let cols = test_columns();
3085        assert!(validate_condition("name LIKE ?", &cols).is_ok());
3086        assert!(validate_condition("name NOT LIKE ?", &cols).is_ok());
3087    }
3088
3089    #[test]
3090    fn test_validator_regexp() {
3091        let cols = test_columns();
3092        assert!(validate_condition("name REGEXP ?", &cols).is_ok());
3093        assert!(validate_condition("name NOT REGEXP ?", &cols).is_ok());
3094    }
3095
3096    #[test]
3097    fn test_validator_between() {
3098        let cols = test_columns();
3099        assert!(validate_condition("score BETWEEN ? AND ?", &cols).is_ok());
3100        assert!(validate_condition("score NOT BETWEEN ? AND ?", &cols).is_ok());
3101    }
3102
3103    #[test]
3104    fn test_validator_in() {
3105        let cols = test_columns();
3106        assert!(validate_condition("category IN (?)", &cols).is_ok());
3107        assert!(validate_condition("category IN (?, ?)", &cols).is_ok());
3108        assert!(validate_condition("category IN (?, ?, ?)", &cols).is_ok());
3109        assert!(validate_condition("category NOT IN (?, ?)", &cols).is_ok());
3110    }
3111
3112    #[test]
3113    fn test_validator_is_null() {
3114        let cols = test_columns();
3115        assert!(validate_condition("name IS NULL", &cols).is_ok());
3116        assert!(validate_condition("name IS NOT NULL", &cols).is_ok());
3117    }
3118
3119    #[test]
3120    fn test_validator_parentheses() {
3121        let cols = test_columns();
3122        assert!(validate_condition("(name = ?)", &cols).is_ok());
3123        assert!(validate_condition("(name = ? AND score > ?)", &cols).is_ok());
3124        assert!(validate_condition("(name = ? OR category = ?) AND score > ?", &cols).is_ok());
3125        assert!(validate_condition("name = ? AND (category = ? OR status = ?)", &cols).is_ok());
3126    }
3127
3128    #[test]
3129    fn test_validator_not() {
3130        let cols = test_columns();
3131        assert!(validate_condition("NOT name = ?", &cols).is_ok());
3132        assert!(validate_condition("NOT (name = ? AND score > ?)", &cols).is_ok());
3133    }
3134
3135    #[test]
3136    fn test_validator_quoted_identifiers() {
3137        let cols = test_columns();
3138        assert!(validate_condition("\"name\" = ?", &cols).is_ok());
3139        assert!(validate_condition("\"score\" > ?", &cols).is_ok());
3140    }
3141
3142    #[test]
3143    fn test_validator_case_insensitive_keywords() {
3144        let cols = test_columns();
3145        assert!(validate_condition("name = ? and score > ?", &cols).is_ok());
3146        assert!(validate_condition("name = ? AND score > ?", &cols).is_ok());
3147        assert!(validate_condition("name LIKE ? or category = ?", &cols).is_ok());
3148        assert!(validate_condition("score between ? and ?", &cols).is_ok());
3149    }
3150
3151    #[test]
3152    fn test_validator_allows_numeric_equality() {
3153        // Special case: numeric equality patterns are common SQL idioms
3154        // "1=1" for "always true", "1=0" for "always false", etc.
3155        let cols = test_columns();
3156        assert!(validate_condition("1=1", &cols).is_ok());
3157        assert!(validate_condition(" 1=1 ", &cols).is_ok()); // with whitespace
3158        assert!(validate_condition("0=0", &cols).is_ok());
3159        assert!(validate_condition("1 = 1", &cols).is_ok()); // with spaces around =
3160        assert!(validate_condition("42=42", &cols).is_ok());
3161        assert!(validate_condition("1=0", &cols).is_ok()); // "always false"
3162    }
3163
3164    // SQL injection tests
3165
3166    #[test]
3167    fn test_validator_rejects_semicolon() {
3168        let cols = test_columns();
3169        let result = validate_condition("name = ?; DROP TABLE METADATA", &cols);
3170        assert!(result.is_err());
3171        assert!(result.unwrap_err().to_string().contains("Semicolon"));
3172    }
3173
3174    #[test]
3175    fn test_validator_rejects_comments() {
3176        let cols = test_columns();
3177        assert!(validate_condition("name = ? -- comment", &cols).is_err());
3178        assert!(validate_condition("name = ? /* comment */", &cols).is_err());
3179    }
3180
3181    #[test]
3182    fn test_validator_rejects_union() {
3183        let cols = test_columns();
3184        // UNION is rejected by quick_safety_check (SELECT may be rejected first if present)
3185        let result = validate_condition("name = ? UNION SELECT * FROM users", &cols);
3186        assert!(result.is_err());
3187        // Both UNION and SELECT are dangerous keywords, either error message is acceptable
3188        let err_msg = result.unwrap_err().to_string();
3189        assert!(
3190            err_msg.contains("UNION") || err_msg.contains("SELECT"),
3191            "Expected error about UNION or SELECT, got: {}",
3192            err_msg
3193        );
3194    }
3195
3196    #[test]
3197    fn test_validator_rejects_subqueries() {
3198        let cols = test_columns();
3199        // SELECT is rejected by quick_safety_check
3200        let result = validate_condition("name = (SELECT name FROM users)", &cols);
3201        assert!(result.is_err());
3202    }
3203
3204    #[test]
3205    fn test_validator_rejects_ddl_keywords() {
3206        let cols = test_columns();
3207        assert!(validate_condition("DROP TABLE METADATA", &cols).is_err());
3208        assert!(validate_condition("DELETE FROM METADATA", &cols).is_err());
3209        assert!(validate_condition("INSERT INTO METADATA VALUES (?)", &cols).is_err());
3210        assert!(validate_condition("UPDATE METADATA SET name = ?", &cols).is_err());
3211        assert!(validate_condition("CREATE TABLE foo (id INT)", &cols).is_err());
3212        assert!(validate_condition("ALTER TABLE METADATA ADD x INT", &cols).is_err());
3213        assert!(validate_condition("TRUNCATE TABLE METADATA", &cols).is_err());
3214    }
3215
3216    #[test]
3217    fn test_validator_rejects_unknown_columns() {
3218        let cols = test_columns();
3219        let result = validate_condition("unknown_column = ?", &cols);
3220        assert!(result.is_err());
3221        assert!(result.unwrap_err().to_string().contains("Unknown column"));
3222    }
3223
3224    #[test]
3225    fn test_validator_rejects_string_literals() {
3226        let cols = test_columns();
3227        // String literals are rejected as unexpected characters
3228        let result = validate_condition("name = 'Alice'", &cols);
3229        assert!(result.is_err());
3230    }
3231
3232    #[test]
3233    fn test_validator_rejects_malformed_syntax() {
3234        let cols = test_columns();
3235        // Missing placeholder
3236        assert!(validate_condition("name =", &cols).is_err());
3237        // Unbalanced parentheses
3238        assert!(validate_condition("(name = ?", &cols).is_err());
3239        assert!(validate_condition("name = ?)", &cols).is_err());
3240        // Double operators
3241        assert!(validate_condition("name = = ?", &cols).is_err());
3242        // Missing column
3243        assert!(validate_condition("= ?", &cols).is_err());
3244    }
3245
3246    #[test]
3247    fn test_validator_rejects_function_calls() {
3248        let cols = test_columns();
3249        // Function calls result in unexpected tokens
3250        let result = validate_condition("LENGTH(name) > ?", &cols);
3251        // LENGTH is parsed as identifier, then ( is unexpected after it
3252        assert!(result.is_err());
3253    }
3254
3255    #[test]
3256    fn test_validator_integration() {
3257        // Test that validation works end-to-end with actual database
3258        let dir = setup_test_dir();
3259        let path = dir.path().to_str().unwrap();
3260
3261        let metadata = vec![
3262            json!({"name": "Alice", "category": "A", "score": 95}),
3263            json!({"name": "Bob", "category": "B", "score": 87}),
3264        ];
3265        let doc_ids: Vec<i64> = (0..2).collect();
3266        create(path, &metadata, &doc_ids).unwrap();
3267
3268        // Valid condition should work
3269        let result = where_condition(path, "category = ? AND score > ?", &[json!("A"), json!(90)]);
3270        assert!(result.is_ok());
3271        assert_eq!(result.unwrap(), vec![0]);
3272
3273        // SQL injection attempt should be rejected
3274        let result = where_condition(path, "category = ?; DROP TABLE METADATA", &[json!("A")]);
3275        assert!(result.is_err());
3276
3277        // Unknown column should be rejected
3278        let result = where_condition(path, "unknown = ?", &[json!("test")]);
3279        assert!(result.is_err());
3280    }
3281
3282    #[test]
3283    fn test_validator_integration_get() {
3284        let dir = setup_test_dir();
3285        let path = dir.path().to_str().unwrap();
3286
3287        let metadata = vec![
3288            json!({"name": "Alice", "score": 95}),
3289            json!({"name": "Bob", "score": 87}),
3290        ];
3291        let doc_ids: Vec<i64> = (0..2).collect();
3292        create(path, &metadata, &doc_ids).unwrap();
3293
3294        // Valid condition should work
3295        let result = get(path, Some("score > ?"), &[json!(90)], None);
3296        assert!(result.is_ok());
3297        assert_eq!(result.unwrap().len(), 1);
3298
3299        // SQL injection should be rejected
3300        let result = get(path, Some("1=1 UNION SELECT * FROM users"), &[], None);
3301        assert!(result.is_err());
3302    }
3303
3304    #[test]
3305    fn test_create_with_empty_metadata_objects() {
3306        let dir = setup_test_dir();
3307        let path = dir.path().to_str().unwrap();
3308
3309        let metadata = vec![json!({}), json!({})];
3310        let doc_ids: Vec<i64> = vec![0, 1];
3311
3312        let result = create(path, &metadata, &doc_ids).unwrap();
3313        assert_eq!(result, 2);
3314        assert!(exists(path));
3315        assert_eq!(count(path).unwrap(), 2);
3316
3317        // Verify rows are retrievable
3318        let all = get(path, None, &[], None).unwrap();
3319        assert_eq!(all.len(), 2);
3320    }
3321
3322    #[test]
3323    fn test_update_with_empty_metadata_objects() {
3324        let dir = setup_test_dir();
3325        let path = dir.path().to_str().unwrap();
3326
3327        // Create initial index with empty metadata
3328        let metadata = vec![json!({})];
3329        let doc_ids: Vec<i64> = vec![0];
3330        create(path, &metadata, &doc_ids).unwrap();
3331
3332        // Update with more empty metadata
3333        let new_metadata = vec![json!({})];
3334        let new_doc_ids: Vec<i64> = vec![1];
3335        let result = update(path, &new_metadata, &new_doc_ids).unwrap();
3336        assert_eq!(result, 1);
3337        assert_eq!(count(path).unwrap(), 2);
3338    }
3339
3340    #[test]
3341    fn test_create_with_mixed_empty_and_non_empty_metadata() {
3342        let dir = setup_test_dir();
3343        let path = dir.path().to_str().unwrap();
3344
3345        // Mix of objects with keys and empty objects
3346        let metadata = vec![
3347            json!({"name": "Alice", "score": 95}),
3348            json!({}),
3349            json!({"name": "Charlie"}),
3350        ];
3351        let doc_ids: Vec<i64> = vec![0, 1, 2];
3352
3353        let result = create(path, &metadata, &doc_ids).unwrap();
3354        assert_eq!(result, 3);
3355        assert_eq!(count(path).unwrap(), 3);
3356
3357        // The empty object should have NULLs; query for non-null name should return 2
3358        let with_name = get(path, Some("name IS NOT NULL"), &[], None).unwrap();
3359        assert_eq!(with_name.len(), 2);
3360    }
3361
3362    #[test]
3363    fn test_update_with_mixed_empty_and_non_empty_metadata() {
3364        let dir = setup_test_dir();
3365        let path = dir.path().to_str().unwrap();
3366
3367        // Create with a keyed object
3368        let metadata = vec![json!({"name": "Alice"})];
3369        let doc_ids: Vec<i64> = vec![0];
3370        create(path, &metadata, &doc_ids).unwrap();
3371
3372        // Update with an empty object — should insert with NULL for existing columns
3373        let new_metadata = vec![json!({})];
3374        let new_doc_ids: Vec<i64> = vec![1];
3375        let result = update(path, &new_metadata, &new_doc_ids).unwrap();
3376        assert_eq!(result, 1);
3377        assert_eq!(count(path).unwrap(), 2);
3378
3379        // Only the first row has a name
3380        let with_name = get(path, Some("name IS NOT NULL"), &[], None).unwrap();
3381        assert_eq!(with_name.len(), 1);
3382    }
3383
3384    #[test]
3385    fn test_read_only_helpers_work_with_query_only_connections() {
3386        let dir = setup_test_dir();
3387        let path = dir.path().to_str().unwrap();
3388
3389        let metadata: Vec<Value> = (0..950)
3390            .map(|i| {
3391                json!({
3392                    "category": if i % 2 == 0 { "A" } else { "B" },
3393                    "source": format!("doc-{i}")
3394                })
3395            })
3396            .collect();
3397        let doc_ids: Vec<i64> = (0..950).collect();
3398        create(path, &metadata, &doc_ids).unwrap();
3399
3400        assert_eq!(count(path).unwrap(), 950);
3401
3402        let mut sources = get_distinct_strings(path, "category").unwrap();
3403        sources.sort();
3404        assert_eq!(sources, vec!["A".to_string(), "B".to_string()]);
3405
3406        let filtered = where_condition(path, "category = ?", &[json!("A")]).unwrap();
3407        assert_eq!(filtered.len(), 475);
3408
3409        let large_subset: Vec<i64> = (0..950).collect();
3410        let rows = get(path, None, &[], Some(&large_subset)).unwrap();
3411        assert_eq!(rows.len(), 950);
3412        assert_eq!(rows[0]["_subset_"], json!(0));
3413        assert_eq!(rows[949]["_subset_"], json!(949));
3414    }
3415
3416    #[test]
3417    fn test_update_fixed_schema_fast_path_reuses_connection() {
3418        let dir = setup_test_dir();
3419        let path = dir.path().to_str().unwrap();
3420
3421        let metadata = vec![json!({"category": "A", "source": "doc-0"})];
3422        create(path, &metadata, &[0]).unwrap();
3423
3424        let new_metadata = vec![
3425            json!({"category": "B", "source": "doc-1"}),
3426            json!({"category": "A", "source": "doc-2"}),
3427        ];
3428        let inserted = update(path, &new_metadata, &[1, 2]).unwrap();
3429        assert_eq!(inserted, 2);
3430
3431        let rows = get(path, None, &[], Some(&[0, 1, 2])).unwrap();
3432        assert_eq!(rows.len(), 3);
3433        assert_eq!(rows[1]["source"], json!("doc-1"));
3434        assert_eq!(count(path).unwrap(), 3);
3435    }
3436
3437    #[test]
3438    fn test_concurrent_metadata_reads_during_updates() {
3439        let dir = setup_test_dir();
3440        let path = dir.path().to_str().unwrap().to_string();
3441
3442        let metadata: Vec<Value> = (0..20)
3443            .map(|i| {
3444                json!({
3445                    "category": if i % 2 == 0 { "A" } else { "B" },
3446                    "source": format!("doc-{i}")
3447                })
3448            })
3449            .collect();
3450        let doc_ids: Vec<i64> = (0..20).collect();
3451        create(&path, &metadata, &doc_ids).unwrap();
3452
3453        let reader_count = 8;
3454        let barrier = Arc::new(std::sync::Barrier::new(reader_count + 1));
3455
3456        std::thread::scope(|scope| {
3457            for _ in 0..reader_count {
3458                let path = path.clone();
3459                let barrier = Arc::clone(&barrier);
3460                scope.spawn(move || {
3461                    barrier.wait();
3462                    for _ in 0..100 {
3463                        let ids = where_condition(&path, "category = ?", &[json!("A")]).unwrap();
3464                        assert!(!ids.is_empty());
3465                        let subset_len = ids.len().min(3);
3466                        let rows = get(&path, None, &[], Some(&ids[..subset_len])).unwrap();
3467                        assert_eq!(rows.len(), subset_len);
3468                        assert!(count(&path).unwrap() >= 20);
3469                    }
3470                });
3471            }
3472
3473            let writer_path = path.clone();
3474            let barrier = Arc::clone(&barrier);
3475            scope.spawn(move || {
3476                barrier.wait();
3477                for i in 20..80 {
3478                    let metadata = vec![json!({
3479                        "category": if i % 2 == 0 { "A" } else { "B" },
3480                        "source": format!("doc-{i}")
3481                    })];
3482                    update(&writer_path, &metadata, &[i]).unwrap();
3483                }
3484            });
3485        });
3486
3487        assert_eq!(count(&path).unwrap(), 80);
3488    }
3489
3490    // ---- fast-delete (v1) layout: schema, re-sequencing, migration ----
3491
3492    fn meta_db(path: &str) -> Connection {
3493        Connection::open(std::path::Path::new(path).join(METADATA_DB_NAME)).unwrap()
3494    }
3495
3496    fn user_version_of(path: &str) -> i64 {
3497        meta_db(path)
3498            .query_row("PRAGMA user_version", [], |r| r.get(0))
3499            .unwrap()
3500    }
3501
3502    fn subset_is_pk(path: &str) -> bool {
3503        let c = meta_db(path);
3504        let mut stmt = c.prepare("PRAGMA table_info(METADATA)").unwrap();
3505        let rows = stmt
3506            .query_map([], |row| {
3507                Ok((row.get::<_, String>(1)?, row.get::<_, i64>(5)?))
3508            })
3509            .unwrap();
3510        for r in rows {
3511            let (name, pk) = r.unwrap();
3512            if name == SUBSET_COLUMN && pk > 0 {
3513                return true;
3514            }
3515        }
3516        false
3517    }
3518
3519    fn select_star_columns(path: &str) -> Vec<String> {
3520        let rows = get(path, None, &[], None).unwrap();
3521        rows[0].as_object().unwrap().keys().cloned().collect()
3522    }
3523
3524    #[test]
3525    fn test_create_uses_v2_layout() {
3526        let dir = setup_test_dir();
3527        let path = dir.path().to_str().unwrap();
3528        let meta: Vec<serde_json::Value> = (0..5)
3529            .map(|i| json!({"file": format!("f{i}.rs"), "code": format!("c{i}")}))
3530            .collect();
3531        create(path, &meta, &(0..5).collect::<Vec<i64>>()).unwrap();
3532
3533        assert_eq!(user_version_of(path), 2);
3534        assert!(!subset_is_pk(path), "v2: _subset_ must not be the PK/rowid");
3535
3536        // Forward-compat: get() exposes _subset_ + user columns, hides _content_id_.
3537        let cols = select_star_columns(path);
3538        assert!(cols.iter().any(|c| c == SUBSET_COLUMN));
3539        assert!(cols.iter().any(|c| c == "file") && cols.iter().any(|c| c == "code"));
3540        assert!(!cols.iter().any(|c| c == CONTENT_ID_COLUMN));
3541
3542        // Verify both tables exist
3543        let c = meta_db(path);
3544        let has_content: i64 = c
3545            .query_row(
3546                &format!(
3547                    "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='{}'",
3548                    CONTENT_TABLE
3549                ),
3550                [],
3551                |row| row.get(0),
3552            )
3553            .unwrap();
3554        assert_eq!(has_content, 1);
3555    }
3556
3557    #[test]
3558    fn test_delete_resequences_dense() {
3559        let dir = setup_test_dir();
3560        let path = dir.path().to_str().unwrap();
3561        let meta: Vec<serde_json::Value> = (0..10)
3562            .map(|i| json!({"file": format!("f{i}.rs")}))
3563            .collect();
3564        create(path, &meta, &(0..10).collect::<Vec<i64>>()).unwrap();
3565
3566        assert_eq!(delete(path, &[2, 5, 7]).unwrap(), 3);
3567
3568        let rows = get(path, None, &[], None).unwrap();
3569        assert_eq!(rows.len(), 7);
3570        let expected = [
3571            "f0.rs", "f1.rs", "f3.rs", "f4.rs", "f6.rs", "f8.rs", "f9.rs",
3572        ];
3573        for (i, row) in rows.iter().enumerate() {
3574            assert_eq!(
3575                row[SUBSET_COLUMN].as_i64().unwrap(),
3576                i as i64,
3577                "dense 0-based"
3578            );
3579            assert_eq!(
3580                row["file"].as_str().unwrap(),
3581                expected[i],
3582                "survivor order preserved"
3583            );
3584        }
3585    }
3586
3587    #[test]
3588    fn test_legacy_v0_index_migrates_on_delete() {
3589        let dir = setup_test_dir();
3590        let path = dir.path().to_str().unwrap();
3591
3592        // Hand-build a legacy v0 index: `_subset_` is the INTEGER PRIMARY KEY and
3593        // user_version is 0 — exactly what a deployed next-plaid-api produced.
3594        {
3595            let c = meta_db(path);
3596            c.execute_batch("PRAGMA user_version=0;").unwrap();
3597            c.execute(
3598                &format!(
3599                    "CREATE TABLE METADATA (\"{}\" INTEGER PRIMARY KEY, file TEXT, code TEXT)",
3600                    SUBSET_COLUMN
3601                ),
3602                [],
3603            )
3604            .unwrap();
3605            for i in 0..10i64 {
3606                c.execute(
3607                    "INSERT INTO METADATA VALUES (?, ?, ?)",
3608                    rusqlite::params![i, format!("f{i}.rs"), format!("c{i}")],
3609                )
3610                .unwrap();
3611            }
3612        }
3613        assert!(subset_is_pk(path), "precondition: legacy v0 layout");
3614        assert_eq!(user_version_of(path), 0);
3615
3616        // First delete migrates in place, then re-sequences.
3617        assert_eq!(delete(path, &[3]).unwrap(), 1);
3618
3619        assert_eq!(user_version_of(path), 1, "migrated to v1");
3620        assert!(!subset_is_pk(path), "_subset_ demoted from PK");
3621
3622        // Forward-compat: identical columns under SELECT *, nothing leaked.
3623        let cols = select_star_columns(path);
3624        assert!(!cols
3625            .iter()
3626            .any(|c| c == "rowid" || c.starts_with("_METADATA")));
3627        assert!(cols.iter().any(|c| c == "file") && cols.iter().any(|c| c == "code"));
3628
3629        // Data intact, dense, survivor order preserved (f3 removed → f4 now at id 3).
3630        let rows = get(path, None, &[], None).unwrap();
3631        assert_eq!(rows.len(), 9);
3632        for (i, row) in rows.iter().enumerate() {
3633            assert_eq!(row[SUBSET_COLUMN].as_i64().unwrap(), i as i64);
3634        }
3635        assert_eq!(rows[3]["file"].as_str().unwrap(), "f4.rs");
3636        assert_eq!(rows[3]["code"].as_str().unwrap(), "c4");
3637    }
3638
3639    // ---- v2 split layout tests ----
3640
3641    #[test]
3642    fn test_v2_delete_resequences_thin_only() {
3643        let dir = setup_test_dir();
3644        let path = dir.path().to_str().unwrap();
3645        let meta: Vec<Value> = (0..10)
3646            .map(|i| {
3647                json!({
3648                    "file": format!("f{i}.rs"),
3649                    "code": format!("fn func_{i}() {{}}"),
3650                })
3651            })
3652            .collect();
3653        create(path, &meta, &(0..10).collect::<Vec<i64>>()).unwrap();
3654        assert_eq!(user_version_of(path), 2);
3655
3656        // Verify content IDs before delete
3657        let c = meta_db(path);
3658        let content_count_before: i64 = c
3659            .query_row(
3660                &format!("SELECT COUNT(*) FROM {}", CONTENT_TABLE),
3661                [],
3662                |row| row.get(0),
3663            )
3664            .unwrap();
3665        assert_eq!(content_count_before, 10);
3666
3667        // Delete rows 2, 5, 7
3668        assert_eq!(delete(path, &[2, 5, 7]).unwrap(), 3);
3669
3670        // Thin table re-sequenced
3671        let rows = get(path, None, &[], None).unwrap();
3672        assert_eq!(rows.len(), 7);
3673        for (i, row) in rows.iter().enumerate() {
3674            assert_eq!(row[SUBSET_COLUMN].as_i64().unwrap(), i as i64);
3675        }
3676        // Survivors in correct order
3677        assert_eq!(rows[0]["file"].as_str().unwrap(), "f0.rs");
3678        assert_eq!(rows[2]["file"].as_str().unwrap(), "f3.rs");
3679
3680        // Content table had orphans removed
3681        let c = meta_db(path);
3682        let content_count_after: i64 = c
3683            .query_row(
3684                &format!("SELECT COUNT(*) FROM {}", CONTENT_TABLE),
3685                [],
3686                |row| row.get(0),
3687            )
3688            .unwrap();
3689        assert_eq!(content_count_after, 7);
3690
3691        // Content IDs are stable (not re-sequenced) - the remaining content_ids
3692        // should be a subset of 0..10, not necessarily 0..7.
3693        let max_content_id: i64 = c
3694            .query_row(
3695                &format!(
3696                    "SELECT MAX(\"{}\") FROM {}",
3697                    CONTENT_ID_COLUMN, CONTENT_TABLE
3698                ),
3699                [],
3700                |row| row.get(0),
3701            )
3702            .unwrap();
3703        assert!(max_content_id >= 7, "content IDs are stable, not compacted");
3704    }
3705
3706    #[test]
3707    fn test_v2_get_returns_all_columns() {
3708        let dir = setup_test_dir();
3709        let path = dir.path().to_str().unwrap();
3710        let meta = vec![
3711            json!({
3712                "file": "src/main.rs",
3713                "name": "main",
3714                "line": 1,
3715                "code": "fn main() { println!(\"hello\"); }",
3716                "signature": "fn main()",
3717            }),
3718            json!({
3719                "file": "src/lib.rs",
3720                "name": "helper",
3721                "line": 10,
3722                "code": "fn helper() -> i32 { 42 }",
3723                "signature": "fn helper() -> i32",
3724            }),
3725        ];
3726        create(path, &meta, &[0, 1]).unwrap();
3727
3728        let rows = get(path, None, &[], None).unwrap();
3729        assert_eq!(rows.len(), 2);
3730
3731        // Thin columns present
3732        assert_eq!(rows[0]["file"], "src/main.rs");
3733        assert_eq!(rows[0]["name"], "main");
3734        assert_eq!(rows[0]["line"], 1);
3735
3736        // Fat columns present
3737        assert_eq!(rows[0]["code"], "fn main() { println!(\"hello\"); }");
3738        assert_eq!(rows[0]["signature"], "fn main()");
3739
3740        // _content_id_ hidden
3741        assert!(rows[0].get(CONTENT_ID_COLUMN).is_none());
3742
3743        // _subset_ present
3744        assert_eq!(rows[0][SUBSET_COLUMN], 0);
3745        assert_eq!(rows[1][SUBSET_COLUMN], 1);
3746    }
3747
3748    #[test]
3749    fn test_v2_where_condition_on_thin_column() {
3750        let dir = setup_test_dir();
3751        let path = dir.path().to_str().unwrap();
3752        let meta = vec![
3753            json!({"file": "src/a.rs", "name": "foo", "code": "fn foo() {}"}),
3754            json!({"file": "src/b.rs", "name": "bar", "code": "fn bar() {}"}),
3755            json!({"file": "src/a.rs", "name": "baz", "code": "fn baz() {}"}),
3756        ];
3757        create(path, &meta, &[0, 1, 2]).unwrap();
3758
3759        let ids = where_condition(path, "file = ?", &[json!("src/a.rs")]).unwrap();
3760        assert_eq!(ids, vec![0, 2]);
3761    }
3762
3763    #[test]
3764    fn test_v2_where_condition_regexp_on_fat_column() {
3765        let dir = setup_test_dir();
3766        let path = dir.path().to_str().unwrap();
3767        let meta = vec![
3768            json!({"file": "a.rs", "code": "fn alpha() { let x = async_fn().await; }"}),
3769            json!({"file": "b.rs", "code": "fn beta() { println!(\"hi\"); }"}),
3770            json!({"file": "c.rs", "code": "fn gamma() { let y = tokio::spawn(async {}); }"}),
3771        ];
3772        create(path, &meta, &[0, 1, 2]).unwrap();
3773
3774        let ids = where_condition_regexp(path, "code REGEXP ?", &[json!("async")]).unwrap();
3775        let mut ids_sorted = ids.clone();
3776        ids_sorted.sort();
3777        assert_eq!(ids_sorted, vec![0, 2]);
3778    }
3779
3780    #[test]
3781    fn test_v1_index_still_works() {
3782        let dir = setup_test_dir();
3783        let path = dir.path().to_str().unwrap();
3784
3785        // Hand-build a v1 index (no METADATA_CONTENT table).
3786        {
3787            let c = meta_db(path);
3788            c.execute_batch(&format!("PRAGMA user_version={};", METADATA_SCHEMA_V1))
3789                .unwrap();
3790            c.execute(
3791                &format!(
3792                    "CREATE TABLE METADATA (\"{}\" INTEGER NOT NULL, file TEXT, code TEXT)",
3793                    SUBSET_COLUMN
3794                ),
3795                [],
3796            )
3797            .unwrap();
3798            c.execute(
3799                &format!(
3800                    "CREATE INDEX \"{}\" ON METADATA (\"{}\")",
3801                    SUBSET_INDEX_NAME, SUBSET_COLUMN
3802                ),
3803                [],
3804            )
3805            .unwrap();
3806            for i in 0..5i64 {
3807                c.execute(
3808                    "INSERT INTO METADATA VALUES (?, ?, ?)",
3809                    rusqlite::params![i, format!("f{i}.rs"), format!("c{i}")],
3810                )
3811                .unwrap();
3812            }
3813        }
3814
3815        assert_eq!(user_version_of(path), 1);
3816        assert_eq!(count(path).unwrap(), 5);
3817
3818        // get works
3819        let rows = get(path, None, &[], None).unwrap();
3820        assert_eq!(rows.len(), 5);
3821        assert_eq!(rows[0]["file"], "f0.rs");
3822        assert_eq!(rows[0]["code"], "c0");
3823
3824        // where_condition works
3825        let ids = where_condition(path, "file = ?", &[json!("f2.rs")]).unwrap();
3826        assert_eq!(ids, vec![2]);
3827
3828        // delete works (uses v1 path)
3829        assert_eq!(delete(path, &[1]).unwrap(), 1);
3830        assert_eq!(count(path).unwrap(), 4);
3831        let rows = get(path, None, &[], None).unwrap();
3832        assert_eq!(rows[1]["file"], "f2.rs");
3833        assert_eq!(rows[1][SUBSET_COLUMN], 1);
3834
3835        // update works (v1 path)
3836        let new_meta = vec![json!({"file": "f5.rs", "code": "c5"})];
3837        update(path, &new_meta, &[4]).unwrap();
3838        assert_eq!(count(path).unwrap(), 5);
3839    }
3840}