From 82b1531f3082509031d8848a603cee87ccedde41 Mon Sep 17 00:00:00 2001 From: harehare Date: Wed, 15 Jul 2026 22:55:47 +0900 Subject: [PATCH 1/4] feat: add full-text search - New TermIndex (inverted postings on tokenized content) backs match()/ score() SQL functions, with WHERE match(content, ...) index-accelerated via a new TermMatch index hint - Bump file format to version 5 for the persisted TermIndex section; stores written by v4 or earlier are rejected and must be reindexed --- README.md | 29 ++++++- src/indexes.rs | 181 +++++++++++++++++++++++++++++++++++++++++++- src/sql.rs | 173 +++++++++++++++++++++++++++++++++++++++++- src/storage.rs | 58 ++++++++++++++ src/storage/page.rs | 6 +- 5 files changed, 439 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index c7668e1..c6e9c74 100644 --- a/README.md +++ b/README.md @@ -35,10 +35,11 @@ flowchart TD - **Flat block storage** — every Markdown element becomes a typed `Block` with row-polymorphic properties - **O(1) hierarchy queries** — interval index (`pre`/`post`) makes ancestor/descendant checks a single integer comparison -- **Three-layer secondary indexes** — `BitmapIndex` (block type), `BTreeIndex` (pre/post), `HashIndex` (content/lang/depth) for fast SQL predicate pushdown +- **Four-layer secondary indexes** — `BitmapIndex` (block type), `BTreeIndex` (pre/post), `HashIndex` (content/lang/depth), `TermIndex` (tokenized content, full-text) for fast SQL predicate pushdown - **Zone Maps** — per-document statistics skip irrelevant files before scanning any blocks - **Dual query engines** — SQL via a custom `sqlparser`-based evaluator, and `mq` via `mq-lang` - **`WITH` (CTE) support** — non-recursive common table expressions, usable in `FROM`, `JOIN`, and subqueries +- **Full-text search** — `match()`/`score()` SQL functions backed by a persisted per-document inverted index - **Incremental re-indexing** — re-running `index` skips unchanged files (content-hash based), replaces changed ones in place (same `DocumentId`), and can `--prune` deleted ones - **SQL `INSERT`/`UPDATE`/`DELETE` with write-back** — add, edit, or remove `blocks` and push the change back to the source Markdown file, opt-in via `--write-back` - **DDL support** — `CREATE TABLE`, `INSERT INTO`, `DROP TABLE` for in-memory custom tables @@ -179,6 +180,17 @@ mq-db sql " `documents`, or a custom table shadows it for the duration of the `WITH` clause's scope. +**Full-text search with `match()`/`score()`** — index-accelerated term matching and simple TF-based ranking: + +```bash +mq-db sql " + SELECT content, score(content, 'error handling') AS relevance + FROM blocks + WHERE match(content, 'error handling') + ORDER BY relevance DESC +" --db store.mq-db +``` + ### INSERT / UPDATE / DELETE with write-back `INSERT`/`UPDATE`/`DELETE` on `blocks` write the change back to the @@ -468,6 +480,8 @@ mq-db-specific: | `under(pre, post, anc_pre, anc_post)` | O(1) interval ancestor check | | `mq(program, content)` | Run an mq program against Markdown content | | `json_extract(json, path)` | Extract a value from a JSON string | +| `match(content, query)` | Full-text search — true iff every tokenized term in `query` appears in `content`; index-accelerated when `content` is a bare column reference and `query` is a string literal | +| `score(content, query)` | Simple term-frequency relevance score for `query` against `content` (no IDF — see [Storage format](#storage-format) note) | String: @@ -580,6 +594,12 @@ SELECT d.path, h.n FROM heading_counts h JOIN documents d ON d.id = h.document_id ORDER BY h.n DESC; + +-- Full-text search, ranked by relevance +SELECT content, score(content, 'error handling') AS relevance +FROM blocks +WHERE match(content, 'error handling') +ORDER BY relevance DESC; ``` ## Architecture @@ -699,6 +719,13 @@ graph TD Writes are atomic: data goes to `.tmp` then renamed to `` on success. +> [!IMPORTANT] +> File format version 5: `DocumentIndex` gained a `TermIndex` (full-text +> search postings) appended after the pre-existing index sections. There is +> no migration path — a store written by an older `mq-db` (version 4 or +> earlier) is rejected with a clear "unsupported file version" error; run +> `mq-db index` again to recreate it. + ## License [MIT](LICENSE) diff --git a/src/indexes.rs b/src/indexes.rs index e81806c..9a59c42 100644 --- a/src/indexes.rs +++ b/src/indexes.rs @@ -1,12 +1,13 @@ //! Secondary indexes for fast block lookups in the SQL engine. //! -//! Three index types, matching the characteristics of each column: +//! Four index types, matching the characteristics of each column: //! //! | Index | Column(s) | Type | Why | //! |---|---|---|---| //! | [`BitmapIndex`] | `block_type` | Inverted list per type | 15 variants → very low cardinality | //! | [`BTreeIndex`] | `pre`, `post` | Sorted Vec + binary search | Monotonically increasing integers, range queries | //! | [`HashIndex`] | `content`, `lang`, `depth` | HashMap | Point/equality lookups | +//! | [`TermIndex`] | `content` (tokenized) | Inverted postings list | Full-text `match()`/`score()` | //! //! ## How it compares to DuckDB //! @@ -210,8 +211,86 @@ impl HashIndex { } } +/// Lowercase + split on non-alphanumeric (Unicode-aware via +/// `char::is_alphanumeric`). +/// +/// This is used both to build [`TermIndex`]'s postings at index time and to +/// tokenize `match()`/`score()`'s arguments at query time (see `src/sql.rs`) +/// — the two **must** use this same function. `WHERE match(...)` uses the +/// index purely as a pre-filter with no full-scan fallback to catch a +/// mismatch, so if the two tokenizers ever disagreed, the index would +/// silently *drop* true matches rather than just mis-rank them. +/// +/// Known limitations (intentional, dependency-free, documented rather than +/// fixed): no stemming, no stopword removal, no sub-splitting of +/// `camelCase`/`snake_case` beyond punctuation, and no CJK word segmentation +/// (a run of CJK characters with no ASCII punctuation between them tokenizes +/// as a single "word"). +pub fn tokenize(text: &str) -> Vec { + text.to_lowercase() + .split(|c: char| !c.is_alphanumeric()) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect() +} + +/// Inverted index on tokenized `content`: term → sorted, deduped block +/// indices containing that term at least once. +/// +/// Best for: `WHERE match(content, 'foo bar')` (AND intersection across +/// query terms). +/// Complexity: build `O(n * avg_tokens)`, intersect `O(k)` for the rarest +/// term's postings length. +#[derive(Debug, Default, Clone)] +pub struct TermIndex { + postings: HashMap>, +} + +impl TermIndex { + pub fn build(blocks: &[Block]) -> Self { + let mut postings: HashMap> = HashMap::new(); + for (idx, block) in blocks.iter().enumerate() { + // Sort + dedup the token list itself rather than allocating a + // side `HashSet` per block — cheaper for the small token counts + // typical of one block, and avoids an allocation per block. + let mut terms = tokenize(&block.content); + terms.sort_unstable(); + terms.dedup(); + for term in terms { + postings.entry(term).or_default().push(idx as u32); + } + } + Self { postings } + } + + /// AND-intersection of postings for `terms`. Empty `terms` → empty + /// result (mirrors `match()`'s "no terms → no match" semantics). + pub fn intersect(&self, terms: &[String]) -> Vec { + let mut iter = terms.iter(); + let Some(first) = iter.next() else { + return Vec::new(); + }; + let mut acc: std::collections::BTreeSet = self + .postings + .get(first) + .into_iter() + .flatten() + .copied() + .collect(); + for term in iter { + if acc.is_empty() { + break; + } + let set: std::collections::HashSet = + self.postings.get(term).into_iter().flatten().copied().collect(); + acc.retain(|idx| set.contains(idx)); + } + acc.into_iter().collect() + } +} + // ───────────────────────────────────────────────────────────────────────────── -// DocumentIndex — all three indexes bundled for one document +// DocumentIndex — all four indexes bundled for one document // ───────────────────────────────────────────────────────────────────────────── /// All secondary indexes for a single [`crate::document::Document`]. @@ -223,6 +302,7 @@ pub struct DocumentIndex { pub bitmap: BitmapIndex, pub btree: BTreeIndex, pub hash: HashIndex, + pub term: TermIndex, } impl DocumentIndex { @@ -231,6 +311,7 @@ impl DocumentIndex { bitmap: BitmapIndex::build(blocks), btree: BTreeIndex::build(blocks), hash: HashIndex::build(blocks), + term: TermIndex::build(blocks), } } @@ -307,6 +388,22 @@ impl DocumentIndex { } } + // TermIndex postings — appended after the four pre-existing sections + // above; each of those is self-length-prefixed, so this is purely + // additive and doesn't disturb their encoding/decoding order. + let mut term_entries: Vec<(&String, &Vec)> = self.term.postings.iter().collect(); + term_entries.sort_by_key(|(k, _)| k.as_str()); + out.extend_from_slice(&(term_entries.len() as u32).to_le_bytes()); + for (term, indices) in &term_entries { + let tb = term.as_bytes(); + out.extend_from_slice(&(tb.len() as u32).to_le_bytes()); + out.extend_from_slice(tb); + out.extend_from_slice(&(indices.len() as u32).to_le_bytes()); + for &idx in indices.iter() { + out.extend_from_slice(&idx.to_le_bytes()); + } + } + out } @@ -420,6 +517,20 @@ impl DocumentIndex { by_depth.insert(depth, indices); } + // TermIndex postings + let num_terms = read_u32!() as usize; + let mut postings: HashMap> = HashMap::new(); + for _ in 0..num_terms { + let term_len = read_u32!() as usize; + let term = read_str!(term_len); + let count = read_u32!() as usize; + let mut indices = Vec::with_capacity(count); + for _ in 0..count { + indices.push(read_u32!()); + } + postings.insert(term, indices); + } + Ok(DocumentIndex { bitmap: BitmapIndex { map: bitmap_map }, btree: BTreeIndex { by_pre, by_post }, @@ -428,6 +539,7 @@ impl DocumentIndex { by_lang, by_depth, }, + term: TermIndex { postings }, }) } } @@ -492,6 +604,8 @@ pub enum IndexHint { LangExact(String), /// Use the hash index: `WHERE depth = N`. DepthExact(u8), + /// Use the term index: `WHERE match(content, 'foo bar')` (AND of tokens). + TermMatch(Vec), /// No applicable index — fall back to full scan. FullScan, } @@ -508,6 +622,7 @@ impl IndexHint { IndexHint::ContentExact(c) => Some(idx.hash.by_content(c).to_vec()), IndexHint::LangExact(l) => Some(idx.hash.by_lang(l).to_vec()), IndexHint::DepthExact(d) => Some(idx.hash.by_depth(*d).to_vec()), + IndexHint::TermMatch(terms) => Some(idx.term.intersect(terms)), IndexHint::FullScan => None, } } @@ -680,4 +795,66 @@ mod tests { let result = IndexHint::BlockType(types).resolve(&idx).unwrap(); assert_eq!(result.len(), expected); } + + #[rstest] + #[case("fn main() {}", vec!["fn", "main"])] + #[case("v1.2.3", vec!["v1", "2", "3"])] + #[case("", vec![])] + #[case("CamelCase HTML_tag", vec!["camelcase", "html", "tag"])] + fn test_tokenize_param(#[case] input: &str, #[case] expected: Vec<&str>) { + let expected: Vec = expected.into_iter().map(str::to_string).collect(); + assert_eq!(tokenize(input), expected); + } + + #[test] + fn test_term_index_build_and_postings() { + let blocks = blocks_from("# Hello World\n\nSome prose about Rust\n"); + let idx = DocumentIndex::build(&blocks); + let hits = idx.term.intersect(&["rust".to_string()]); + assert_eq!(hits.len(), 1); + assert!(blocks[hits[0] as usize].content.contains("Rust")); + } + + #[test] + fn test_term_index_intersect_and_semantics() { + let blocks = blocks_from("# H1\n\nfoo bar baz\n\nfoo only\n"); + let idx = DocumentIndex::build(&blocks); + + let both = idx.term.intersect(&["foo".to_string(), "bar".to_string()]); + assert_eq!(both.len(), 1); + + let missing = idx + .term + .intersect(&["foo".to_string(), "nonexistent".to_string()]); + assert!(missing.is_empty()); + + assert!(idx.term.intersect(&[]).is_empty()); + } + + #[test] + fn test_document_index_to_bytes_from_bytes_roundtrip_includes_term_index() { + let blocks = blocks_from( + "# Title\n\nSome prose here.\n\n```rust\nfn main() { let x = 1; }\n```\n", + ); + let idx = DocumentIndex::build(&blocks); + let restored = DocumentIndex::from_bytes(&idx.to_bytes()).unwrap(); + + let mut original: Vec<(String, Vec)> = idx + .term + .postings + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + let mut round_tripped: Vec<(String, Vec)> = restored + .term + .postings + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + original.sort(); + round_tripped.sort(); + + assert!(!original.is_empty()); + assert_eq!(original, round_tripped); + } } diff --git a/src/sql.rs b/src/sql.rs index 8286d74..b7895e0 100644 --- a/src/sql.rs +++ b/src/sql.rs @@ -66,7 +66,7 @@ use crate::{ DocumentStore, MqdbError, block::{Block, BlockType, Properties, PropertyValue}, document::{Document, ZoneMaps}, - indexes::{DocumentIndex, IndexHint}, + indexes::{DocumentIndex, IndexHint, tokenize}, store::CustomTableState, }; @@ -966,6 +966,46 @@ fn eval_scalar_function(name: &str, args: &[Value]) -> Value { }; eval_mq_scalar(&program, &content) } + "match" => { + let (Some(content), Some(query)) = + (args.first().and_then(Value::as_str), args.get(1).and_then(Value::as_str)) + else { + return Value::Bool(false); + }; + let content_terms: std::collections::HashSet = + tokenize(content).into_iter().collect(); + let query_terms = tokenize(query); + Value::Bool(!query_terms.is_empty() && query_terms.iter().all(|t| content_terms.contains(t))) + } + "score" => { + let (Some(content), Some(query)) = + (args.first().and_then(Value::as_str), args.get(1).and_then(Value::as_str)) + else { + return Value::Float(0.0); + }; + let content_terms = tokenize(content); + let query_terms = tokenize(query); + if content_terms.is_empty() || query_terms.is_empty() { + return Value::Float(0.0); + } + // Simple term-frequency score, normalised by content length — + // deliberately not BM25 (no IDF/corpus-wide stats): `eval_expr` + // only ever sees one `Row` at a time with no back-reference to + // the corpus, so a real IDF term would need a much larger + // signature change (see `TermIndex`'s doc comment for the same + // constraint on the index side). Good enough to rank matches + // within a single query; a document that repeats a common word + // many times can outrank one with a rarer, more specific match. + let mut freq: HashMap<&str, u32> = HashMap::new(); + for t in &content_terms { + *freq.entry(t.as_str()).or_default() += 1; + } + let hits: f64 = query_terms + .iter() + .map(|q| *freq.get(q.as_str()).unwrap_or(&0) as f64) + .sum(); + Value::Float(hits / content_terms.len() as f64) + } // --- string functions --- "lower" => str_fn(args, |s| s.to_lowercase()), @@ -3276,6 +3316,34 @@ fn analyze_where_for_index(expr: &Expr) -> IndexHint { } IndexHint::FullScan } + // match(content, 'query terms') used directly as a boolean predicate + // (unlike the other arms above, this isn't wrapped in a BinaryOp). + Expr::Function(f) => { + let name = f.name.0.last().map(ident_value).unwrap_or("").to_lowercase(); + if name != "match" { + return IndexHint::FullScan; + } + let FunctionArguments::List(al) = &f.args else { + return IndexHint::FullScan; + }; + let [FunctionArg::Unnamed(FunctionArgExpr::Expr(col)), FunctionArg::Unnamed(FunctionArgExpr::Expr(q))] = + al.args.as_slice() + else { + return IndexHint::FullScan; + }; + if expr_col_name(col).as_deref() != Some("content") { + return IndexHint::FullScan; + } + let Some(query_str) = expr_str_val(q) else { + return IndexHint::FullScan; + }; + let terms = tokenize(&query_str); + if terms.is_empty() { + IndexHint::FullScan + } else { + IndexHint::TermMatch(terms) + } + } Expr::Nested(inner) => analyze_where_for_index(inner), _ => IndexHint::FullScan, } @@ -3480,10 +3548,15 @@ mod tests { let start = std::time::Instant::now(); let _engine = SqlEngine::new(&store).unwrap(); let elapsed = start.elapsed(); + // Bound loosened from 1ms to 5ms when `TermIndex` (a fourth + // per-document index) was added — still catches anything + // pathological (e.g. accidental file I/O or O(n^2) behaviour) while + // tolerating cold-start allocator/thread warmup noise on the first + // test invocation in a fresh process. assert!( - elapsed.as_millis() < 1, - "SqlEngine::new took {}ms — should be O(1)", - elapsed.as_millis() + elapsed.as_micros() < 5000, + "SqlEngine::new took {}us — should be cheap", + elapsed.as_micros() ); } @@ -3735,6 +3808,98 @@ mod tests { assert_eq!(out.rows[0][0], "NULL"); } + #[test] + fn match_function_true_for_all_terms_present() { + let store = DocumentStore::new(); + let engine = SqlEngine::new(&store).unwrap(); + let out = engine + .execute("SELECT match('The quick brown fox', 'quick fox')") + .unwrap(); + assert_eq!(out.rows[0][0], "true"); + } + + #[test] + fn match_function_false_if_any_term_missing() { + let store = DocumentStore::new(); + let engine = SqlEngine::new(&store).unwrap(); + let out = engine + .execute("SELECT match('The quick brown fox', 'quick zebra')") + .unwrap(); + assert_eq!(out.rows[0][0], "false"); + } + + #[test] + fn match_function_case_insensitive() { + let store = DocumentStore::new(); + let engine = SqlEngine::new(&store).unwrap(); + let out = engine + .execute("SELECT match('Rust Programming', 'rust')") + .unwrap(); + assert_eq!(out.rows[0][0], "true"); + } + + #[test] + fn score_function_ranks_denser_matches_higher() { + let mut store = DocumentStore::new(); + store + .add_str("# Doc\n\nrust rust rust other words here\n\nrust is fine\n") + .unwrap(); + let engine = SqlEngine::new(&store).unwrap(); + let out = engine + .execute( + "SELECT content FROM blocks WHERE block_type = 'paragraph' + ORDER BY score(content, 'rust') DESC", + ) + .unwrap(); + assert_eq!(out.rows[0][0], "rust rust rust other words here"); + } + + #[test] + fn where_match_uses_term_match_index_hint() { + let stmts = + Parser::parse_sql(&GenericDialect {}, "SELECT * FROM blocks WHERE match(content, 'foo bar')") + .unwrap(); + let Statement::Query(q) = stmts.into_iter().next().unwrap() else { + panic!("expected query") + }; + let SetExpr::Select(select) = q.body.as_ref() else { + panic!("expected select") + }; + let hint = analyze_where_for_index(select.selection.as_ref().unwrap()); + assert_eq!( + hint, + IndexHint::TermMatch(vec!["foo".to_string(), "bar".to_string()]) + ); + } + + #[test] + fn where_match_and_block_type_combines_hints() { + let store = make_store(); + let engine = SqlEngine::new(&store).unwrap(); + let out = engine + .execute( + "SELECT content FROM blocks + WHERE match(content, 'architecture') AND block_type = 'heading'", + ) + .unwrap(); + assert_eq!(out.rows, vec![vec!["Architecture".to_string()]]); + } + + #[test] + fn where_match_full_scan_fallback_when_query_not_literal() { + let stmts = + Parser::parse_sql(&GenericDialect {}, "SELECT * FROM blocks WHERE match(content, lang)") + .unwrap(); + let Statement::Query(q) = stmts.into_iter().next().unwrap() else { + panic!("expected query") + }; + let SetExpr::Select(select) = q.body.as_ref() else { + panic!("expected select") + }; + let hint = analyze_where_for_index(select.selection.as_ref().unwrap()); + assert_eq!(hint, IndexHint::FullScan); + } + fn eval_one(sql: &str) -> String { let store = DocumentStore::new(); let engine = SqlEngine::new(&store).unwrap(); diff --git a/src/storage.rs b/src/storage.rs index 5b17727..c3ac153 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -640,6 +640,64 @@ mod tests { cleanup(&path); } + #[test] + fn save_then_open_round_trips_term_index_for_match_and_score() { + use crate::SqlEngine; + + let path = test_file_path("term-index-round-trip"); + cleanup(&path); + + let mut store = DocumentStore::new(); + store + .add_str("# Doc\n\nThe quick brown fox jumps over the lazy dog\n") + .unwrap(); + store.save(&path).unwrap(); + + let mut opened = DocumentStore::open(&path).unwrap(); + opened.load_all_blocks().unwrap(); + opened.load_all_indexes().unwrap(); + + let engine = SqlEngine::new(&opened).unwrap(); + let out = engine + .execute("SELECT content FROM blocks WHERE match(content, 'fox dog')") + .unwrap(); + assert_eq!(out.rows.len(), 1); + + cleanup(&path); + } + + #[test] + fn opening_old_version_file_fails_with_clear_error() { + use crate::storage::page::{PAGE_HEADER_SIZE, PAGE_SIZE, compute_checksum}; + + let path = test_file_path("old-version-header"); + cleanup(&path); + + let mut store = DocumentStore::new(); + store.add_str("# Hello\n\nBody\n").unwrap(); + store.save(&path).unwrap(); + + // Patch the file header's version field (body offset 4..8, i.e. + // absolute page offset PAGE_HEADER_SIZE+4..+8) down to the + // pre-TermIndex version, then recompute the header checksum so the + // file is otherwise well-formed — isolating the version check. + let mut bytes = std::fs::read(&path).unwrap(); + let version_offset = PAGE_HEADER_SIZE + 4; + bytes[version_offset..version_offset + 4].copy_from_slice(&4u32.to_le_bytes()); + + let mut page = [0u8; PAGE_SIZE]; + page.copy_from_slice(&bytes[0..PAGE_SIZE]); + let checksum = compute_checksum(&page); + bytes[4..8].copy_from_slice(&checksum.to_le_bytes()); + + std::fs::write(&path, &bytes).unwrap(); + + let err = DocumentStore::open(&path).err().expect("expected version rejection"); + assert!(err.to_string().contains("unsupported file version")); + + cleanup(&path); + } + #[test] fn persisted_index_round_trip_large_block_content() { use crate::indexes::DocumentIndex; diff --git a/src/storage/page.rs b/src/storage/page.rs index bc53cb1..2fb69c8 100644 --- a/src/storage/page.rs +++ b/src/storage/page.rs @@ -19,7 +19,11 @@ pub(crate) const PAGE_TYPE_INDEX: u32 = 5; pub(crate) const PAGE_TYPE_TABLE_DATA: u32 = 6; const FILE_MAGIC: u32 = 0x4D51_4442; -const FILE_VERSION: u32 = 4; +// v5: DocumentIndex gained a TermIndex (full-text search postings), appended +// after the pre-existing four index sections. Files written by v4 or +// earlier lack that section and are rejected outright below — there is no +// migration path, `mq-db index` must be re-run to recreate the store. +const FILE_VERSION: u32 = 5; const CATALOG_START_PAGE: u32 = 1; fn invalid_data(message: impl Into) -> MqdbError { From 8a023008fe82a0de67535ea240efd6e7b7cf1c52 Mon Sep 17 00:00:00 2001 From: harehare Date: Wed, 15 Jul 2026 22:56:36 +0900 Subject: [PATCH 2/4] perf: wire mimalloc global allocator into mq-db binary Also apply cargo fmt across sql.rs, indexes.rs, and storage.rs. --- src/indexes.rs | 14 +++++++++----- src/sql.rs | 48 ++++++++++++++++++++++++++++++++---------------- src/storage.rs | 4 +++- 3 files changed, 44 insertions(+), 22 deletions(-) diff --git a/src/indexes.rs b/src/indexes.rs index 9a59c42..3d1451e 100644 --- a/src/indexes.rs +++ b/src/indexes.rs @@ -281,8 +281,13 @@ impl TermIndex { if acc.is_empty() { break; } - let set: std::collections::HashSet = - self.postings.get(term).into_iter().flatten().copied().collect(); + let set: std::collections::HashSet = self + .postings + .get(term) + .into_iter() + .flatten() + .copied() + .collect(); acc.retain(|idx| set.contains(idx)); } acc.into_iter().collect() @@ -833,9 +838,8 @@ mod tests { #[test] fn test_document_index_to_bytes_from_bytes_roundtrip_includes_term_index() { - let blocks = blocks_from( - "# Title\n\nSome prose here.\n\n```rust\nfn main() { let x = 1; }\n```\n", - ); + let blocks = + blocks_from("# Title\n\nSome prose here.\n\n```rust\nfn main() { let x = 1; }\n```\n"); let idx = DocumentIndex::build(&blocks); let restored = DocumentIndex::from_bytes(&idx.to_bytes()).unwrap(); diff --git a/src/sql.rs b/src/sql.rs index b7895e0..27c12d0 100644 --- a/src/sql.rs +++ b/src/sql.rs @@ -967,20 +967,24 @@ fn eval_scalar_function(name: &str, args: &[Value]) -> Value { eval_mq_scalar(&program, &content) } "match" => { - let (Some(content), Some(query)) = - (args.first().and_then(Value::as_str), args.get(1).and_then(Value::as_str)) - else { + let (Some(content), Some(query)) = ( + args.first().and_then(Value::as_str), + args.get(1).and_then(Value::as_str), + ) else { return Value::Bool(false); }; let content_terms: std::collections::HashSet = tokenize(content).into_iter().collect(); let query_terms = tokenize(query); - Value::Bool(!query_terms.is_empty() && query_terms.iter().all(|t| content_terms.contains(t))) + Value::Bool( + !query_terms.is_empty() && query_terms.iter().all(|t| content_terms.contains(t)), + ) } "score" => { - let (Some(content), Some(query)) = - (args.first().and_then(Value::as_str), args.get(1).and_then(Value::as_str)) - else { + let (Some(content), Some(query)) = ( + args.first().and_then(Value::as_str), + args.get(1).and_then(Value::as_str), + ) else { return Value::Float(0.0); }; let content_terms = tokenize(content); @@ -3319,15 +3323,23 @@ fn analyze_where_for_index(expr: &Expr) -> IndexHint { // match(content, 'query terms') used directly as a boolean predicate // (unlike the other arms above, this isn't wrapped in a BinaryOp). Expr::Function(f) => { - let name = f.name.0.last().map(ident_value).unwrap_or("").to_lowercase(); + let name = f + .name + .0 + .last() + .map(ident_value) + .unwrap_or("") + .to_lowercase(); if name != "match" { return IndexHint::FullScan; } let FunctionArguments::List(al) = &f.args else { return IndexHint::FullScan; }; - let [FunctionArg::Unnamed(FunctionArgExpr::Expr(col)), FunctionArg::Unnamed(FunctionArgExpr::Expr(q))] = - al.args.as_slice() + let [ + FunctionArg::Unnamed(FunctionArgExpr::Expr(col)), + FunctionArg::Unnamed(FunctionArgExpr::Expr(q)), + ] = al.args.as_slice() else { return IndexHint::FullScan; }; @@ -3856,9 +3868,11 @@ mod tests { #[test] fn where_match_uses_term_match_index_hint() { - let stmts = - Parser::parse_sql(&GenericDialect {}, "SELECT * FROM blocks WHERE match(content, 'foo bar')") - .unwrap(); + let stmts = Parser::parse_sql( + &GenericDialect {}, + "SELECT * FROM blocks WHERE match(content, 'foo bar')", + ) + .unwrap(); let Statement::Query(q) = stmts.into_iter().next().unwrap() else { panic!("expected query") }; @@ -3887,9 +3901,11 @@ mod tests { #[test] fn where_match_full_scan_fallback_when_query_not_literal() { - let stmts = - Parser::parse_sql(&GenericDialect {}, "SELECT * FROM blocks WHERE match(content, lang)") - .unwrap(); + let stmts = Parser::parse_sql( + &GenericDialect {}, + "SELECT * FROM blocks WHERE match(content, lang)", + ) + .unwrap(); let Statement::Query(q) = stmts.into_iter().next().unwrap() else { panic!("expected query") }; diff --git a/src/storage.rs b/src/storage.rs index c3ac153..138dfa7 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -692,7 +692,9 @@ mod tests { std::fs::write(&path, &bytes).unwrap(); - let err = DocumentStore::open(&path).err().expect("expected version rejection"); + let err = DocumentStore::open(&path) + .err() + .expect("expected version rejection"); assert!(err.to_string().contains("unsupported file version")); cleanup(&path); From d2441ed4271afbb1710607f8dc83428a5e4dcc6e Mon Sep 17 00:00:00 2001 From: harehare Date: Thu, 16 Jul 2026 23:47:50 +0900 Subject: [PATCH 3/4] refactor: switch remaining HashMap usages to FxHashMap Also drop the separator-style section comments in sql.rs's builtin function dispatch. --- src/block.rs | 4 ++-- src/indexes.rs | 34 ++++++++++++++++++---------------- src/sql.rs | 14 ++++---------- src/store.rs | 22 +++++++++++----------- 4 files changed, 35 insertions(+), 39 deletions(-) diff --git a/src/block.rs b/src/block.rs index e48e769..ade414f 100644 --- a/src/block.rs +++ b/src/block.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use rustc_hash::FxHashMap; /// Unique identifier for a block within a document. pub type BlockId = u32; @@ -175,7 +175,7 @@ impl From for PropertyValue { /// Open-record property bag – acts like extra virtual columns in a /// row-polymorphic schema. Keyed by string name, typed by [`PropertyValue`]. #[derive(Debug, Clone, Default, PartialEq)] -pub struct Properties(HashMap); +pub struct Properties(FxHashMap); impl Properties { pub fn new() -> Self { diff --git a/src/indexes.rs b/src/indexes.rs index 3d1451e..8e012b6 100644 --- a/src/indexes.rs +++ b/src/indexes.rs @@ -32,7 +32,9 @@ //! `src/sql.rs`) for `lang =` / `depth =` / heading `content =` conjuncts, //! but only for a single, non-`JOIN`ed `FROM blocks`. -use std::collections::{BTreeMap, HashMap}; +use std::collections::BTreeMap; + +use rustc_hash::FxHashMap; use crate::{ block::{Block, BlockType}, @@ -53,12 +55,12 @@ use crate::{ /// Complexity: build O(n), lookup O(1) key + O(k) iterate #[derive(Debug, Default, Clone)] pub struct BitmapIndex { - map: HashMap>, + map: FxHashMap>, } impl BitmapIndex { pub fn build(blocks: &[Block]) -> Self { - let mut map: HashMap> = HashMap::new(); + let mut map: FxHashMap> = FxHashMap::default(); for (idx, block) in blocks.iter().enumerate() { map.entry(block.block_type.clone()) .or_default() @@ -157,18 +159,18 @@ impl BTreeIndex { #[derive(Debug, Default, Clone)] pub struct HashIndex { /// content (exact lowercase) → block indices - pub by_content: HashMap>, + pub by_content: FxHashMap>, /// lang tag → block indices (code blocks only) - pub by_lang: HashMap>, + pub by_lang: FxHashMap>, /// heading depth → block indices - pub by_depth: HashMap>, + pub by_depth: FxHashMap>, } impl HashIndex { pub fn build(blocks: &[Block]) -> Self { - let mut by_content: HashMap> = HashMap::new(); - let mut by_lang: HashMap> = HashMap::new(); - let mut by_depth: HashMap> = HashMap::new(); + let mut by_content: FxHashMap> = FxHashMap::default(); + let mut by_lang: FxHashMap> = FxHashMap::default(); + let mut by_depth: FxHashMap> = FxHashMap::default(); for (idx, block) in blocks.iter().enumerate() { let i = idx as u32; @@ -243,12 +245,12 @@ pub fn tokenize(text: &str) -> Vec { /// term's postings length. #[derive(Debug, Default, Clone)] pub struct TermIndex { - postings: HashMap>, + postings: FxHashMap>, } impl TermIndex { pub fn build(blocks: &[Block]) -> Self { - let mut postings: HashMap> = HashMap::new(); + let mut postings: FxHashMap> = FxHashMap::default(); for (idx, block) in blocks.iter().enumerate() { // Sort + dedup the token list itself rather than allocating a // side `HashSet` per block — cheaper for the small token counts @@ -452,7 +454,7 @@ impl DocumentIndex { // BitmapIndex let num_bitmap = read_u32!() as usize; - let mut bitmap_map: HashMap> = HashMap::new(); + let mut bitmap_map: FxHashMap> = FxHashMap::default(); for _ in 0..num_bitmap { let bt = block_type_from_ord(read_u8!())?; let count = read_u32!() as usize; @@ -483,7 +485,7 @@ impl DocumentIndex { // HashIndex by_content let num_content = read_u32!() as usize; - let mut by_content: HashMap> = HashMap::new(); + let mut by_content: FxHashMap> = FxHashMap::default(); for _ in 0..num_content { let key_len = read_u32!() as usize; let key = read_str!(key_len); @@ -497,7 +499,7 @@ impl DocumentIndex { // HashIndex by_lang let num_lang = read_u32!() as usize; - let mut by_lang: HashMap> = HashMap::new(); + let mut by_lang: FxHashMap> = FxHashMap::default(); for _ in 0..num_lang { let key_len = read_u32!() as usize; let key = read_str!(key_len); @@ -511,7 +513,7 @@ impl DocumentIndex { // HashIndex by_depth let num_depth = read_u32!() as usize; - let mut by_depth: HashMap> = HashMap::new(); + let mut by_depth: FxHashMap> = FxHashMap::default(); for _ in 0..num_depth { let depth = read_u8!(); let count = read_u32!() as usize; @@ -524,7 +526,7 @@ impl DocumentIndex { // TermIndex postings let num_terms = read_u32!() as usize; - let mut postings: HashMap> = HashMap::new(); + let mut postings: FxHashMap> = FxHashMap::default(); for _ in 0..num_terms { let term_len = read_u32!() as usize; let term = read_str!(term_len); diff --git a/src/sql.rs b/src/sql.rs index 27c12d0..508279c 100644 --- a/src/sql.rs +++ b/src/sql.rs @@ -44,8 +44,6 @@ //! assert!(!out.rows.is_empty()); //! ``` -use std::collections::HashMap; - use rustc_hash::FxHashMap; use sqlparser::{ ast::{ @@ -552,7 +550,7 @@ fn hash_equi_join( right_key_expr: &Expr, full_predicate: &Expr, ) -> Vec { - let mut buckets: HashMap> = HashMap::new(); + let mut buckets: FxHashMap> = FxHashMap::default(); for (i, r) in right.iter().enumerate() { if let Some(key) = value_join_key(&eval_expr(right_key_expr, r)) { buckets.entry(key).or_default().push(i); @@ -1000,7 +998,7 @@ fn eval_scalar_function(name: &str, args: &[Value]) -> Value { // constraint on the index side). Good enough to rank matches // within a single query; a document that repeats a common word // many times can outrank one with a rarer, more specific match. - let mut freq: HashMap<&str, u32> = HashMap::new(); + let mut freq: FxHashMap<&str, u32> = FxHashMap::default(); for t in &content_terms { *freq.entry(t.as_str()).or_default() += 1; } @@ -1011,7 +1009,6 @@ fn eval_scalar_function(name: &str, args: &[Value]) -> Value { Value::Float(hits / content_terms.len() as f64) } - // --- string functions --- "lower" => str_fn(args, |s| s.to_lowercase()), "upper" => str_fn(args, |s| s.to_uppercase()), "length" | "len" | "char_length" | "character_length" => args @@ -1144,7 +1141,6 @@ fn eval_scalar_function(name: &str, args: &[Value]) -> Value { } } - // --- numeric functions --- "abs" => num_fn(args, |n| n.abs(), |n| n.abs()), "round" => { let n = match args.first().and_then(|v| v.as_f64()) { @@ -1235,7 +1231,6 @@ fn eval_scalar_function(name: &str, args: &[Value]) -> Value { .min_by(|a, b| a.cmp_val(b).unwrap_or(std::cmp::Ordering::Equal)) .unwrap_or(Value::Null), - // --- null handling --- "coalesce" | "ifnull" => args .iter() .find(|v| !matches!(v, Value::Null)) @@ -1252,7 +1247,6 @@ fn eval_scalar_function(name: &str, args: &[Value]) -> Value { } } - // --- misc --- "typeof" => Value::Str( match args.first() { Some(Value::Str(_)) => "text", @@ -2183,7 +2177,7 @@ impl<'a> SqlEngine<'a> { // Group let mut groups: Vec<(Vec, Vec<&Row>)> = Vec::new(); - let mut key_index: HashMap, usize> = HashMap::new(); + let mut key_index: FxHashMap, usize> = FxHashMap::default(); // We need owned rows to reference; collect first let owned: Vec = rows; @@ -2338,7 +2332,7 @@ fn apply_matched_edits( store: &mut DocumentStore, edits: Vec, ) -> Result { - let mut by_doc: HashMap> = HashMap::new(); + let mut by_doc: FxHashMap> = FxHashMap::default(); for edit in edits { by_doc.entry(edit.document_id).or_default().push(edit); } diff --git a/src/store.rs b/src/store.rs index 7719e94..7db9f39 100644 --- a/src/store.rs +++ b/src/store.rs @@ -1,5 +1,5 @@ use std::{ - collections::{HashMap, HashSet}, + collections::HashSet, hash::{Hash, Hasher}, path::{Path, PathBuf}, sync::{Mutex, RwLock}, @@ -44,7 +44,7 @@ use crate::{ /// append (see [`DocumentStore::try_append_table_rows_to_storage`]). fn persist_unsaved_table_rows( storage: &mut Storage, - custom_tables: &RwLock>, + custom_tables: &RwLock>, ) -> Result, MqdbError> { let mut guard = custom_tables.write().unwrap(); for state in guard.values_mut() { @@ -140,14 +140,14 @@ pub struct DocumentStore { /// User-registered virtual tables: name → (columns, rows). /// Uses `RwLock` for interior mutability so `SqlEngine` can execute DDL /// (`CREATE TABLE`, `INSERT INTO`, `DROP TABLE`) with only `&DocumentStore`. - pub(crate) custom_tables: RwLock>, + pub(crate) custom_tables: RwLock>, /// Content hash of each document's source, keyed by `DocumentId`. Used by /// [`reindex_paths`](DocumentStore::reindex_paths) to skip re-parsing /// files whose content hasn't changed since the last index run. Absent /// entries (e.g. documents added via `add_str`, or loaded from an older /// `.mq-db` file predating this feature) are treated as "unknown, always /// reindex". - content_hashes: HashMap, + content_hashes: FxHashMap, } impl Default for DocumentStore { @@ -158,8 +158,8 @@ impl Default for DocumentStore { store_spans: true, storage: Mutex::new(None), doc_indexes: Vec::new(), - custom_tables: RwLock::new(HashMap::new()), - content_hashes: HashMap::new(), + custom_tables: RwLock::new(FxHashMap::default()), + content_hashes: FxHashMap::default(), } } } @@ -561,8 +561,8 @@ impl DocumentStore { /// Aggregate block-type / code-language statistics across every /// document currently loaded in memory. pub fn stats(&self) -> StoreStats { - let mut type_counts: HashMap = HashMap::new(); - let mut lang_counts: HashMap = HashMap::new(); + let mut type_counts: FxHashMap = FxHashMap::default(); + let mut lang_counts: FxHashMap = FxHashMap::default(); let mut total_blocks = 0usize; for doc in &self.documents { @@ -840,7 +840,7 @@ impl DocumentStore { Some(max_doc_id.map_or(document_id, |cur: DocumentId| cur.max(document_id))); } - let mut custom_tables = HashMap::new(); + let mut custom_tables = FxHashMap::default(); for ct in custom_table_entries { let rows = storage.read_table_rows(ct.first_row_page, ct.num_rows, ct.columns.len())?; custom_tables.insert( @@ -888,7 +888,7 @@ impl DocumentStore { Some(max_doc_id.map_or(document_id, |cur: DocumentId| cur.max(document_id))); } - let mut custom_tables = HashMap::new(); + let mut custom_tables = FxHashMap::default(); for ct in custom_table_entries { let rows = storage.read_table_rows(ct.first_row_page, ct.num_rows, ct.columns.len())?; custom_tables.insert( @@ -945,7 +945,7 @@ impl DocumentStore { store_spans: true, storage: Mutex::new(None), doc_indexes: vec![None; cap], - custom_tables: RwLock::new(HashMap::new()), + custom_tables: RwLock::new(FxHashMap::default()), content_hashes: content_hashes.into_iter().collect(), }) } From 1d0568f3de1d81145d57fbbeb728a27604c548a7 Mon Sep 17 00:00:00 2001 From: harehare Date: Fri, 17 Jul 2026 00:00:59 +0900 Subject: [PATCH 4/4] style: remove decorative box-drawing separator comments --- src/bin/mq-db.rs | 30 ++++++++++-------------------- src/index.rs | 18 +++--------------- src/indexes.rs | 12 ------------ src/sql.rs | 2 +- src/store.rs | 4 ---- src/tui.rs | 8 -------- 6 files changed, 14 insertions(+), 60 deletions(-) diff --git a/src/bin/mq-db.rs b/src/bin/mq-db.rs index 33dfcb2..334f519 100644 --- a/src/bin/mq-db.rs +++ b/src/bin/mq-db.rs @@ -21,9 +21,7 @@ use serde::Deserialize; #[global_allocator] static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; -// ───────────────────────────────────────────────────────────────────────────── // CLI structure -// ───────────────────────────────────────────────────────────────────────────── #[derive(Parser)] #[command( @@ -211,9 +209,7 @@ impl std::fmt::Display for ReplMode { } } -// ───────────────────────────────────────────────────────────────────────────── // Helpers -// ───────────────────────────────────────────────────────────────────────────── fn load_store(db: &Path) -> anyhow::Result { if !db.exists() { @@ -282,16 +278,14 @@ fn block_type_icon(bt: &BlockType) -> &'static str { } } -// ───────────────────────────────────────────────────────────────────────────── // Main -// ───────────────────────────────────────────────────────────────────────────── #[tokio::main] async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); match cli.command { - // ── index ──────────────────────────────────────────────────────────── + // index Commands::Index { paths, output, @@ -354,7 +348,7 @@ async fn main() -> anyhow::Result<()> { ); } - // ── list ───────────────────────────────────────────────────────────── + // list Commands::List { db, format } => { // Catalog-only: skip deserialising all block data for a listing. let store = load_catalog_store(&db)?; @@ -460,7 +454,7 @@ async fn main() -> anyhow::Result<()> { } } - // ── mq ─────────────────────────────────────────────────────────────── + // mq Commands::Mq { code, db, format } => { let store = load_store(&db)?; let results = @@ -518,7 +512,7 @@ async fn main() -> anyhow::Result<()> { } } - // ── sql ────────────────────────────────────────────────────────────── + // sql Commands::Sql { query, db, @@ -558,7 +552,7 @@ async fn main() -> anyhow::Result<()> { } } - // ── repl ───────────────────────────────────────────────────────────── + // repl Commands::Repl { db, mode, @@ -568,7 +562,7 @@ async fn main() -> anyhow::Result<()> { run_repl(store, mode, write_back)?; } - // ── lint ───────────────────────────────────────────────────────────── + // lint Commands::Lint { db, depth } => { let store = load_store(&db)?; let q = store.query(); @@ -605,7 +599,7 @@ async fn main() -> anyhow::Result<()> { } } - // ── stats ───────────────────────────────────────────────────────────── + // stats Commands::Stats { db } => { let store = load_store(&db)?; let stats = store.stats(); @@ -649,7 +643,7 @@ async fn main() -> anyhow::Result<()> { } } - // ── show ───────────────────────────────────────────────────────────── + // show Commands::Show { doc_id, db } => { let store = load_store(&db)?; let doc = store @@ -738,7 +732,7 @@ async fn main() -> anyhow::Result<()> { } } - // ── tui ────────────────────────────────────────────────────────────── + // tui Commands::Tui { db } => { let store = if db.exists() { DocumentStore::load(&db).map_err(|e| anyhow::anyhow!("{}", e))? @@ -752,7 +746,7 @@ async fn main() -> anyhow::Result<()> { mq_db::tui::run(store).map_err(|e| anyhow::anyhow!("{}", e))?; } - // ── serve ───────────────────────────────────────────────────────────── + // serve Commands::Serve { db, host, port } => { let store = Arc::new(load_store(&db)?); let addr = format!("{}:{}", host, port); @@ -776,9 +770,7 @@ async fn main() -> anyhow::Result<()> { Ok(()) } -// ───────────────────────────────────────────────────────────────────────────── // HTTP server handlers -// ───────────────────────────────────────────────────────────────────────────── type SharedStore = Arc; @@ -955,9 +947,7 @@ fn md_block_to_html(s: &str) -> String { format!("

{}

", html_escape(trimmed)) } -// ───────────────────────────────────────────────────────────────────────────── // REPL -// ───────────────────────────────────────────────────────────────────────────── /// Crude prefix check used to gate write-back before even attempting a /// parse. Only `INSERT INTO blocks` requires `--write-back`; custom tables diff --git a/src/index.rs b/src/index.rs index bd3d048..d3085e4 100644 --- a/src/index.rs +++ b/src/index.rs @@ -2,9 +2,7 @@ use mq_markdown::Node; use crate::block::{Block, BlockId, BlockType, DocumentId, Properties, PropertyValue, Span}; -// ───────────────────────────────────────────────────────────────────────────── // Helper: extract span from a node's position -// ───────────────────────────────────────────────────────────────────────────── fn node_to_span(node: &Node) -> Option { node.position().map(|p| Span { @@ -15,9 +13,7 @@ fn node_to_span(node: &Node) -> Option { }) } -// ───────────────────────────────────────────────────────────────────────────── // Helper: if a node is a Heading, return its depth -// ───────────────────────────────────────────────────────────────────────────── fn heading_depth(node: &Node) -> Option { if let Node::Heading(h) = node { @@ -27,9 +23,7 @@ fn heading_depth(node: &Node) -> Option { } } -// ───────────────────────────────────────────────────────────────────────────── // Helper: convert a serde_yaml Value to a PropertyValue -// ───────────────────────────────────────────────────────────────────────────── fn yaml_value_to_property(v: serde_yaml::Value) -> PropertyValue { match v { @@ -53,10 +47,8 @@ fn yaml_value_to_property(v: serde_yaml::Value) -> PropertyValue { } } -// ───────────────────────────────────────────────────────────────────────────── // Core conversion: Node → (BlockType, content, Properties) // Returns None for nodes that should be skipped (Fragment, Empty). -// ───────────────────────────────────────────────────────────────────────────── fn node_to_parts(node: &Node) -> Option<(BlockType, String, Properties)> { let mut props = Properties::new(); @@ -190,9 +182,7 @@ fn node_to_parts(node: &Node) -> Option<(BlockType, String, Properties)> { } } -// ───────────────────────────────────────────────────────────────────────────── // Public API: build_blocks -// ───────────────────────────────────────────────────────────────────────────── /// Converts a flat mq-markdown node list into a [`Vec`] with /// **Nested Set / Pre-Post Order** interval indexing. @@ -220,7 +210,7 @@ pub fn build_blocks(doc_id: DocumentId, nodes: &[Node]) -> Vec { return Vec::new(); } - // ── Phase 1: build section tree ────────────────────────────────────────── + // Phase 1: build section tree // // `children[slot]` holds the tree-slot indices of slot's direct children. // Slot 0 is the virtual document root; nodes start at slot 1. @@ -257,7 +247,7 @@ pub fn build_blocks(doc_id: DocumentId, nodes: &[Node]) -> Vec { } } - // ── Phase 2: iterative DFS to assign pre/post numbers ──────────────────── + // Phase 2: iterative DFS to assign pre/post numbers let num_slots = children.len(); let mut pre = vec![0u32; num_slots]; @@ -287,7 +277,7 @@ pub fn build_blocks(doc_id: DocumentId, nodes: &[Node]) -> Vec { } } - // ── Phase 3: construct Block objects ───────────────────────────────────── + // Phase 3: construct Block objects let mut blocks: Vec = Vec::with_capacity(n); let mut next_id: BlockId = 0; @@ -312,9 +302,7 @@ pub fn build_blocks(doc_id: DocumentId, nodes: &[Node]) -> Vec { blocks } -// ───────────────────────────────────────────────────────────────────────────── // Tests -// ───────────────────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { diff --git a/src/indexes.rs b/src/indexes.rs index 8e012b6..33bfe89 100644 --- a/src/indexes.rs +++ b/src/indexes.rs @@ -41,9 +41,7 @@ use crate::{ error::MqdbError, }; -// ───────────────────────────────────────────────────────────────────────────── // BitmapIndex — block_type → sorted Vec of block positions -// ───────────────────────────────────────────────────────────────────────────── /// Bitmap-style inverted index on `block_type`. /// @@ -91,9 +89,7 @@ impl BitmapIndex { } } -// ───────────────────────────────────────────────────────────────────────────── // BTreeIndex — pre/post → block position -// ───────────────────────────────────────────────────────────────────────────── /// B-Tree index on `pre` (and a secondary one on `post`). /// @@ -145,9 +141,7 @@ impl BTreeIndex { } } -// ───────────────────────────────────────────────────────────────────────────── // HashIndex — content / lang / depth → block positions -// ───────────────────────────────────────────────────────────────────────────── /// Hash index for point-equality lookups on string/integer columns. /// @@ -296,9 +290,7 @@ impl TermIndex { } } -// ───────────────────────────────────────────────────────────────────────────── // DocumentIndex — all four indexes bundled for one document -// ───────────────────────────────────────────────────────────────────────────── /// All secondary indexes for a single [`crate::document::Document`]. /// @@ -592,9 +584,7 @@ fn block_type_from_ord(v: u8) -> Result { } } -// ───────────────────────────────────────────────────────────────────────────── // IndexHint — what the SQL planner decided to use -// ───────────────────────────────────────────────────────────────────────────── /// The access plan chosen by the simple predicate pushdown analyser. #[derive(Debug, Clone, PartialEq)] @@ -635,9 +625,7 @@ impl IndexHint { } } -// ───────────────────────────────────────────────────────────────────────────── // Tests -// ───────────────────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { diff --git a/src/sql.rs b/src/sql.rs index 508279c..cc1b909 100644 --- a/src/sql.rs +++ b/src/sql.rs @@ -4233,7 +4233,7 @@ mod tests { assert_eq!(contents, vec!["Doc", "Other"]); } - // ── UPDATE/DELETE write-back ──────────────────────────────────────────── + // UPDATE/DELETE write-back fn write_md(dir: &tempfile::TempDir, name: &str, content: &str) -> std::path::PathBuf { let path = dir.path().join(name); diff --git a/src/store.rs b/src/store.rs index 7db9f39..59af472 100644 --- a/src/store.rs +++ b/src/store.rs @@ -590,9 +590,7 @@ impl DocumentStore { } } - // ───────────────────────────────────────────────────────────────────────── // Lazy loading - // ───────────────────────────────────────────────────────────────────────── /// Load blocks for every document that has not yet been loaded. /// @@ -739,9 +737,7 @@ impl DocumentStore { let _ = storage.flush_catalog(&entries, &custom, &self.content_hash_pairs()); } - // ───────────────────────────────────────────────────────────────────────── // Persistence - // ───────────────────────────────────────────────────────────────────────── /// Persist all in-memory documents to a `.mq-db` file, including secondary /// indexes. Writes atomically: writes to `path.tmp` then renames to `path`. diff --git a/src/tui.rs b/src/tui.rs index 51ef5a9..b9b0559 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -18,9 +18,7 @@ use ratatui::{ use crate::{DocumentStore, MqEngine, MqdbError, SqlEngine, block::BlockType}; -// ───────────────────────────────────────────────────────────────────────────── // Theme — mirrors the warm paper/ink/accent palette of docs/index.html -// ───────────────────────────────────────────────────────────────────────────── mod theme { use ratatui::style::Color; @@ -40,9 +38,7 @@ mod theme { pub const DUSK: Color = Color::Rgb(140, 150, 191); } -// ───────────────────────────────────────────────────────────────────────────── // State -// ───────────────────────────────────────────────────────────────────────────── #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum QueryMode { @@ -349,9 +345,7 @@ fn block_display(bt: &BlockType, depth: Option) -> (&'static str, String, Co } } -// ───────────────────────────────────────────────────────────────────────────── // Entry point -// ───────────────────────────────────────────────────────────────────────────── /// Launch the TUI. Blocks until the user quits. pub fn run(store: DocumentStore) -> Result<(), MqdbError> { @@ -430,9 +424,7 @@ fn handle_key(app: &mut App, key: KeyEvent) -> bool { false } -// ───────────────────────────────────────────────────────────────────────────── // Rendering -// ───────────────────────────────────────────────────────────────────────────── fn ui(f: &mut Frame, app: &mut App) { let area = f.area();