Skip to main content

leviathan/
query.rs

1//! Read-only query layer over the index.
2//!
3//! Retrieval contract:
4//! 1. Resolve the group first (exact key, case-insensitive key, normalized
5//!    name, substring, then fuzzy). More than one candidate in the winning
6//!    tier is reported as ambiguous; Leviathan never guesses.
7//! 2. Rank matching records by BM25 (title weighted), times the configured
8//!    boosts, then recency on ties. Group scope and filters are posting-list
9//!    intersections on synthetic tokens, so they cost less as they narrow.
10//! 3. With no query words, list records newest first.
11//! 4. If a group has no match, fall back to clearly labeled matches from
12//!    other groups.
13
14use std::collections::BTreeMap;
15use std::path::{Path, PathBuf};
16use std::time::SystemTime;
17
18use anyhow::{Context, Result, bail};
19use rusqlite::types::Value as SqlValue;
20use rusqlite::{Connection, OptionalExtension, Row, params_from_iter};
21use serde::Serialize;
22
23use crate::card::{Card, CardOptions};
24use crate::config::{About, Config, Fields, Rank, plural};
25use crate::fields::Mapping;
26use crate::index::{check_schema, meta, open_ro, stored_config};
27use crate::text::{Query, facet_token, group_token, normalize_bound, normalize_name};
28
29const FUZZY_CUTOFF: f64 = 0.6;
30pub const MAX_LIMIT: usize = 50;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, clap::ValueEnum)]
33#[serde(rename_all = "snake_case")]
34pub enum Scope {
35    /// Only the named group.
36    Group,
37    /// Every group except the named one.
38    Others,
39    /// Everything.
40    All,
41}
42
43#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, clap::ValueEnum)]
44#[serde(rename_all = "snake_case")]
45pub enum Sort {
46    /// Best match first (newest first when there are no query words).
47    #[default]
48    Relevance,
49    Newest,
50}
51
52#[derive(Debug, Clone, Serialize)]
53pub struct GroupMatch {
54    pub key: String,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub name: Option<String>,
57    pub records: i64,
58    pub match_type: &'static str,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub similarity: Option<f64>,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
64#[serde(rename_all = "snake_case")]
65pub enum Status {
66    Ok,
67    AmbiguousGroup,
68    UnknownGroup,
69    BadRequest,
70}
71
72#[derive(Debug, Clone, Default)]
73pub struct SearchRequest {
74    pub group: Option<String>,
75    pub query: String,
76    /// Defaults to `Group` when a group is given, else `All`.
77    pub scope: Option<Scope>,
78    /// `field = value` pairs; several values for one field mean any of them.
79    pub filters: Vec<(String, String)>,
80    pub since: Option<String>,
81    pub until: Option<String>,
82    pub sort: Sort,
83    pub limit: usize,
84    pub offset: usize,
85    /// When the group has no match, add labeled results from other groups.
86    pub fallback: bool,
87}
88
89#[derive(Debug, Serialize)]
90pub struct Filter {
91    pub field: String,
92    pub value: String,
93}
94
95#[derive(Debug, Serialize)]
96pub struct SearchOutcome {
97    pub status: Status,
98    pub query: String,
99    pub scope: Scope,
100    pub sort: Sort,
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub group: Option<GroupMatch>,
103    #[serde(skip_serializing_if = "Vec::is_empty")]
104    pub candidates: Vec<GroupMatch>,
105    #[serde(skip_serializing_if = "Vec::is_empty")]
106    pub filters: Vec<Filter>,
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub since: Option<String>,
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub until: Option<String>,
111    /// Matches in scope before limit/offset. Zero means none found.
112    pub total_matches: i64,
113    #[serde(skip_serializing_if = "is_zero")]
114    pub offset: usize,
115    pub results: Vec<Card>,
116    #[serde(skip_serializing_if = "Vec::is_empty")]
117    pub other_groups: Vec<Card>,
118    pub corpus_records: i64,
119    #[serde(skip_serializing_if = "Vec::is_empty")]
120    pub notes: Vec<String>,
121    #[serde(skip)]
122    pub about: About,
123}
124
125fn is_zero(n: &usize) -> bool {
126    *n == 0
127}
128
129#[derive(Debug, Serialize)]
130pub struct FacetSummary {
131    pub field: String,
132    pub distinct: i64,
133    pub top: Vec<(String, i64)>,
134}
135
136/// Everything an agent needs to query this index well.
137#[derive(Debug, Serialize)]
138pub struct Description {
139    pub index_path: PathBuf,
140    pub index_bytes: u64,
141    pub about: About,
142    pub fields: Fields,
143    pub rank: Rank,
144    pub record_count: i64,
145    pub group_count: i64,
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub date_min: Option<String>,
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub date_max: Option<String>,
150    pub filters: Vec<FacetSummary>,
151    #[serde(skip_serializing_if = "Vec::is_empty")]
152    pub largest_groups: Vec<GroupMatch>,
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub example_id: Option<String>,
155    pub mapping_inferred: bool,
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub built_at: Option<String>,
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub updated_at: Option<String>,
160    pub skipped_lines: i64,
161    pub schema_version: String,
162}
163
164enum Pick {
165    One(GroupMatch),
166    Many(Vec<GroupMatch>),
167    None,
168}
169
170pub struct Store {
171    conn: Connection,
172    path: PathBuf,
173    stamp: Option<(u64, SystemTime)>,
174    mapping: Mapping,
175    pub card_opts: CardOptions,
176}
177
178fn file_stamp(path: &Path) -> Option<(u64, SystemTime)> {
179    let meta = std::fs::metadata(path).ok()?;
180    Some((meta.len(), meta.modified().ok()?))
181}
182
183impl Store {
184    pub fn open(path: &Path, card_opts: CardOptions) -> Result<Self> {
185        if !path.exists() {
186            bail!(
187                "no index at {}. Build one with `leviathan index <data>` \
188                 or point --index / LEVIATHAN_INDEX at an existing one",
189                path.display()
190            );
191        }
192        let conn = open_ro(path).with_context(|| format!("open index {}", path.display()))?;
193        check_schema(&conn, path)?;
194        let mapping = Mapping::new(&stored_config(&conn)?)?;
195        Ok(Self { conn, path: path.to_path_buf(), stamp: file_stamp(path), mapping, card_opts })
196    }
197
198    /// Reopen when the index file was atomically replaced by a rebuild.
199    pub fn reload_if_replaced(&mut self) -> Result<bool> {
200        let now = file_stamp(&self.path);
201        if now.is_some() && now != self.stamp {
202            *self = Self::open(&self.path, self.card_opts)?;
203            return Ok(true);
204        }
205        Ok(false)
206    }
207
208    pub fn config(&self) -> &Config {
209        &self.mapping.config
210    }
211
212    fn meta(&self, key: &str) -> Result<Option<String>> {
213        meta(&self.conn, key)
214    }
215
216    fn meta_i64(&self, key: &str) -> Result<i64> {
217        Ok(self.meta(key)?.and_then(|v| v.parse().ok()).unwrap_or(0))
218    }
219
220    // ----------------------------------------------------------------- groups
221
222    fn groups_where(
223        &self,
224        clause: &str,
225        arg: &str,
226        match_type: &'static str,
227        limit: usize,
228    ) -> Result<Vec<GroupMatch>> {
229        let sql = format!(
230            "SELECT key, name, record_count FROM groups WHERE {clause} ORDER BY record_count DESC, key LIMIT ?2"
231        );
232        Ok(self
233            .conn
234            .prepare_cached(&sql)?
235            .query_map(rusqlite::params![arg, limit as i64], |r| group_row(r, match_type))?
236            .collect::<rusqlite::Result<Vec<_>>>()?)
237    }
238
239    /// Group key or name to candidates, best tier first.
240    pub fn resolve(&self, query: &str, limit: usize) -> Result<Vec<GroupMatch>> {
241        let query = query.trim();
242        if query.is_empty() || !self.mapping.has_group() {
243            return Ok(Vec::new());
244        }
245        let limit = limit.clamp(1, MAX_LIMIT);
246        let lower = query.to_lowercase();
247        let normalized = normalize_name(query);
248        let tiers: [(&str, &str, &'static str); 3] = [
249            ("key = ?1", query, "exact"),
250            ("key_lower = ?1", &lower, "case_insensitive"),
251            ("name_normalized = ?1", &normalized, "name"),
252        ];
253        for (clause, arg, kind) in tiers {
254            let rows = self.groups_where(clause, arg, kind, limit)?;
255            if !rows.is_empty() {
256                return Ok(rows);
257            }
258        }
259
260        let escaped = normalized.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_");
261        let contains = self.groups_where(
262            "name_normalized LIKE '%' || ?1 || '%' ESCAPE '\\' OR key_lower LIKE '%' || ?1 || '%' ESCAPE '\\'",
263            &escaped,
264            "contains",
265            limit,
266        )?;
267        if !contains.is_empty() {
268            return Ok(contains);
269        }
270
271        let mut stmt = self
272            .conn
273            .prepare_cached("SELECT key, name, record_count, key_lower, name_normalized FROM groups")?;
274        let mut scored: Vec<GroupMatch> = stmt
275            .query_map([], |r| {
276                let key_lower: Option<String> = r.get(3)?;
277                let name_norm: Option<String> = r.get(4)?;
278                let mut m = group_row(r, "fuzzy")?;
279                let score = [key_lower, name_norm]
280                    .iter()
281                    .flatten()
282                    .map(|c| strsim::sorensen_dice(&normalized, c))
283                    .fold(0.0, f64::max);
284                m.similarity = Some((score * 100.0).round() / 100.0);
285                Ok(m)
286            })?
287            .filter_map(|m| m.ok())
288            .filter(|m| m.similarity.unwrap_or(0.0) >= FUZZY_CUTOFF)
289            .collect();
290        scored
291            .sort_by(|a, b| b.similarity.partial_cmp(&a.similarity).unwrap().then(b.records.cmp(&a.records)));
292        scored.truncate(limit);
293        Ok(scored)
294    }
295
296    fn pick(&self, group: &str) -> Result<Pick> {
297        let mut found = self.resolve(group, 10)?;
298        Ok(match found.len() {
299            0 => Pick::None,
300            1 => Pick::One(found.remove(0)),
301            _ => Pick::Many(found),
302        })
303    }
304
305    // ----------------------------------------------------------------- search
306
307    pub fn search(&self, req: &SearchRequest) -> Result<SearchOutcome> {
308        let about = self.config().about.clone();
309        let group_noun = about.group.clone();
310        let limit = req.limit.clamp(1, MAX_LIMIT);
311        let scope = if req.group.is_some() { req.scope.unwrap_or(Scope::Group) } else { Scope::All };
312        let mut out = SearchOutcome {
313            status: Status::Ok,
314            query: req.query.clone(),
315            scope,
316            sort: req.sort,
317            group: None,
318            candidates: Vec::new(),
319            filters: req.filters.iter().map(|(f, v)| Filter { field: f.clone(), value: v.clone() }).collect(),
320            since: None,
321            until: None,
322            total_matches: 0,
323            offset: req.offset,
324            results: Vec::new(),
325            other_groups: Vec::new(),
326            corpus_records: self.meta_i64("record_count")?,
327            notes: Vec::new(),
328            about,
329        };
330        let bad = |mut out: SearchOutcome, note: String| {
331            out.status = Status::BadRequest;
332            out.notes.push(note);
333            Ok(out)
334        };
335
336        let filterable: Vec<&str> = self.mapping.filters.iter().map(|p| p.raw.as_str()).collect();
337        for (field, _) in &req.filters {
338            if !filterable.contains(&field.as_str()) {
339                let available =
340                    if filterable.is_empty() { "none".to_string() } else { filterable.join(", ") };
341                return bad(out, format!("`{field}` is not a filter field; filterable: {available}"));
342            }
343        }
344        for raw in [&req.since, &req.until].into_iter().flatten() {
345            if normalize_bound(raw).is_none() {
346                return bad(out, format!("unrecognized date {raw:?}; use YYYY, YYYY-MM or YYYY-MM-DD"));
347            }
348        }
349        out.since = req.since.as_deref().and_then(normalize_bound);
350        out.until = req.until.as_deref().and_then(normalize_bound);
351        if (out.since.is_some() || out.until.is_some()) && !self.mapping.has_date() {
352            return bad(out, "this index has no date field, so --since/--until cannot apply".into());
353        }
354
355        if let Some(group) = &req.group {
356            if !self.mapping.has_group() {
357                return bad(out, "this index has no group field; search without one".into());
358            }
359            match self.pick(group)? {
360                Pick::One(m) => out.group = Some(m),
361                Pick::Many(c) => {
362                    out.status = Status::AmbiguousGroup;
363                    out.notes.push(format!(
364                        "{} {} match {group:?}; ask which one, then retry with its exact key",
365                        c.len(),
366                        plural(&group_noun)
367                    ));
368                    out.candidates = c;
369                    return Ok(out);
370                }
371                Pick::None => {
372                    out.status = Status::UnknownGroup;
373                    out.notes.push(format!(
374                        "no {group_noun} matches {group:?}; try `resolve` with part of the name, or search without one"
375                    ));
376                    return Ok(out);
377                }
378            }
379        }
380
381        let query = Query::parse(&req.query);
382        if query.is_empty() && !req.query.trim().is_empty() && query.excluded.is_empty() {
383            out.notes.push("the query has no searchable words; listing newest instead".into());
384        }
385        let plan = Plan {
386            group: out.group.as_ref().map(|g| (g.key.clone(), group_token(&g.key))),
387            scope,
388            filters: self.filter_clauses(&req.filters),
389            since: out.since.clone(),
390            until: out.until.clone(),
391        };
392        let highlight = query.highlight();
393
394        if query.is_empty() {
395            (out.total_matches, out.results) = self.browse(&plan, limit, req.offset)?;
396            if !query.excluded.is_empty() {
397                out.notes.push("exclusions need at least one positive word; they were ignored".into());
398            }
399            return Ok(out);
400        }
401
402        let positive = query.fts().expect("non-empty query");
403        let negative = query.not_fts();
404        let expr = plan.expr(&positive, negative.as_deref(), scope);
405        let sort = req.sort;
406        (out.total_matches, out.results) = self.ranked(&expr, &plan, sort, limit, req.offset, &highlight)?;
407
408        if scope == Scope::Group
409            && out.results.is_empty()
410            && req.offset == 0
411            && req.fallback
412            && plan.group.is_some()
413        {
414            let others = plan.expr(&positive, negative.as_deref(), Scope::Others);
415            out.other_groups = self.ranked(&others, &plan, sort, limit, 0, &highlight)?.1;
416            for c in &mut out.other_groups {
417                c.other_group = true;
418            }
419            if !out.other_groups.is_empty() {
420                out.notes.push(format!(
421                    "nothing matched in this {group_noun}; other_groups results come from OTHER {} - say so and keep each card's {group_noun} visible",
422                    plural(&group_noun)
423                ));
424            }
425        }
426        Ok(out)
427    }
428
429    /// One FTS clause per filter field; several values for a field are ORed.
430    fn filter_clauses(&self, filters: &[(String, String)]) -> Vec<String> {
431        let mut by_field: BTreeMap<&str, Vec<String>> = BTreeMap::new();
432        for (field, value) in filters {
433            by_field
434                .entry(field.as_str())
435                .or_default()
436                .push(format!("tags : \"{}\"", facet_token(field, value)));
437        }
438        by_field.into_values().map(|alts| format!("({})", alts.join(" OR "))).collect()
439    }
440
441    /// Total matches and the cards for one page.
442    fn ranked(
443        &self,
444        expr: &str,
445        plan: &Plan,
446        sort: Sort,
447        limit: usize,
448        offset: usize,
449        highlight: &[String],
450    ) -> Result<(i64, Vec<Card>)> {
451        struct Hit {
452            score: f64,
453            date: String,
454            rowid: i64,
455        }
456        let want = offset + limit;
457        let overfetch = if sort == Sort::Newest { want } else { (want * 10).max(100) } as i64;
458        let (dates, mut args) = plan.date_sql(2);
459        args.insert(0, SqlValue::Text(expr.to_string()));
460        args.push(SqlValue::Integer(overfetch));
461        let limit_param = args.len();
462        let sql = if dates.is_empty() && sort == Sort::Relevance {
463            format!(
464                "SELECT r.rowid, r.boost, r.date, f.rank, f.total FROM \
465                   (SELECT rowid, rank, count(*) OVER () AS total FROM record_fts WHERE record_fts MATCH ?1 \
466                    ORDER BY rank LIMIT ?{limit_param}) AS f \
467                 JOIN records AS r ON r.rowid = f.rowid"
468            )
469        } else {
470            let order = if sort == Sort::Newest { "r.date DESC, r.rowid DESC" } else { "record_fts.rank" };
471            format!(
472                "SELECT r.rowid, r.boost, r.date, record_fts.rank, count(*) OVER () FROM record_fts \
473                 JOIN records AS r ON r.rowid = record_fts.rowid \
474                 WHERE record_fts MATCH ?1 {dates} ORDER BY {order} LIMIT ?{limit_param}"
475            )
476        };
477        let mut total = 0i64;
478        let mut hits: Vec<Hit> = self
479            .conn
480            .prepare_cached(&sql)?
481            .query_map(params_from_iter(args), |r| {
482                let boost: f64 = r.get(1)?;
483                let bm25: f64 = r.get(3)?;
484                total = r.get(4)?;
485                Ok(Hit {
486                    score: bm25 * boost,
487                    date: r.get::<_, Option<String>>(2)?.unwrap_or_default(),
488                    rowid: r.get(0)?,
489                })
490            })?
491            .collect::<rusqlite::Result<_>>()?;
492        if sort == Sort::Relevance {
493            hits.sort_by(|a, b| a.score.total_cmp(&b.score).then_with(|| b.date.cmp(&a.date)));
494        }
495        let cards = hits
496            .into_iter()
497            .skip(offset)
498            .take(limit)
499            .enumerate()
500            .map(|(i, hit)| {
501                let mut card = self.card(hit.rowid, highlight)?;
502                card.rank = Some(offset + i + 1);
503                card.relevance = Some((-hit.score * 100.0).round() / 100.0);
504                Ok(card)
505            })
506            .collect::<Result<_>>()?;
507        Ok((total, cards))
508    }
509
510    /// No query words: records in scope, newest first. The page and the
511    /// count are separate queries so the page can walk the date index and
512    /// stop early.
513    fn browse(&self, plan: &Plan, limit: usize, offset: usize) -> Result<(i64, Vec<Card>)> {
514        let (dates, mut args) = plan.date_sql(1);
515        let mut clauses = vec!["1".to_string()];
516        if !dates.is_empty() {
517            clauses.push(dates.trim_start_matches("AND ").to_string());
518        }
519        if let Some((key, _)) = &plan.group
520            && plan.scope != Scope::All
521        {
522            args.push(SqlValue::Text(key.clone()));
523            let n = args.len();
524            clauses.push(if plan.scope == Scope::Group {
525                format!("r.grp = ?{n}")
526            } else {
527                format!("(r.grp IS NULL OR r.grp <> ?{n})")
528            });
529        }
530        if !plan.filters.is_empty() {
531            args.push(SqlValue::Text(plan.filters.join(" AND ")));
532            clauses.push(format!(
533                "r.rowid IN (SELECT rowid FROM record_fts WHERE record_fts MATCH ?{})",
534                args.len()
535            ));
536        }
537        let where_sql = clauses.join(" AND ");
538        let total: i64 = self.conn.query_row(
539            &format!("SELECT count(*) FROM records AS r WHERE {where_sql}"),
540            params_from_iter(args.clone()),
541            |r| r.get(0),
542        )?;
543        let n = args.len();
544        args.push(SqlValue::Integer(limit as i64));
545        args.push(SqlValue::Integer(offset as i64));
546        let rows: Vec<i64> = self
547            .conn
548            .prepare_cached(&format!(
549                "SELECT r.rowid FROM records AS r WHERE {where_sql} \
550                 ORDER BY r.date DESC, r.rowid DESC LIMIT ?{} OFFSET ?{}",
551                n + 1,
552                n + 2
553            ))?
554            .query_map(params_from_iter(args), |r| r.get(0))?
555            .collect::<rusqlite::Result<_>>()?;
556        let cards = rows.iter().map(|rowid| self.card(*rowid, &[])).collect::<Result<_>>()?;
557        Ok((total, cards))
558    }
559
560    fn card(&self, rowid: i64, highlight: &[String]) -> Result<Card> {
561        let (id, doc): (String, String) = self
562            .conn
563            .prepare_cached("SELECT id, doc FROM records WHERE rowid = ?1")?
564            .query_row([rowid], |r| Ok((r.get(0)?, r.get(1)?)))?;
565        Card::build(&id, &doc, &self.mapping, self.card_opts, highlight)
566    }
567
568    /// Full stored record, exactly as ingested.
569    pub fn get(&self, id: &str) -> Result<Option<serde_json::Value>> {
570        let raw: Option<String> = self
571            .conn
572            .prepare_cached("SELECT doc FROM records WHERE id = ?1")?
573            .query_row([id.trim()], |r| r.get(0))
574            .optional()?;
575        raw.map(|r| serde_json::from_str(&r).map_err(Into::into)).transpose()
576    }
577
578    // --------------------------------------------------------------- describe
579
580    pub fn describe(&self, top: usize) -> Result<Description> {
581        let cfg = self.config();
582        let mut filters = Vec::new();
583        for p in &self.mapping.filters {
584            let distinct: i64 =
585                self.conn
586                    .query_row("SELECT COUNT(*) FROM facets WHERE field = ?1", [&p.raw], |r| r.get(0))?;
587            let top_values = self
588                .conn
589                .prepare_cached(
590                    "SELECT value, count FROM facets WHERE field = ?1 ORDER BY count DESC, value LIMIT ?2",
591                )?
592                .query_map(rusqlite::params![p.raw, top as i64], |r| Ok((r.get(0)?, r.get(1)?)))?
593                .collect::<rusqlite::Result<_>>()?;
594            filters.push(FacetSummary { field: p.raw.clone(), distinct, top: top_values });
595        }
596        let largest_groups = self
597            .conn
598            .prepare_cached(
599                "SELECT key, name, record_count FROM groups ORDER BY record_count DESC, key LIMIT 5",
600            )?
601            .query_map([], |r| group_row(r, "listing"))?
602            .collect::<rusqlite::Result<_>>()?;
603        let nonempty = |v: Option<String>| v.filter(|s| !s.is_empty());
604        Ok(Description {
605            index_path: self.path.clone(),
606            index_bytes: std::fs::metadata(&self.path).map(|m| m.len()).unwrap_or(0),
607            about: cfg.about.clone(),
608            fields: cfg.fields.clone(),
609            rank: cfg.rank.clone(),
610            record_count: self.meta_i64("record_count")?,
611            group_count: self.meta_i64("group_count")?,
612            date_min: nonempty(self.meta("date_min")?),
613            date_max: nonempty(self.meta("date_max")?),
614            filters,
615            largest_groups,
616            example_id: self
617                .conn
618                .query_row("SELECT id FROM records ORDER BY date DESC, rowid DESC LIMIT 1", [], |r| r.get(0))
619                .optional()?,
620            mapping_inferred: self.meta("mapping_inferred")?.as_deref() == Some("true"),
621            built_at: self.meta("built_at")?,
622            updated_at: self.meta("updated_at")?,
623            skipped_lines: self.meta_i64("skipped_lines")?,
624            schema_version: self.meta("schema_version")?.unwrap_or_default(),
625        })
626    }
627}
628
629/// Scope, filters and date bounds shared by ranked search and browsing.
630#[derive(Debug, Clone)]
631struct Plan {
632    /// Resolved group key and its token.
633    group: Option<(String, String)>,
634    scope: Scope,
635    filters: Vec<String>,
636    since: Option<String>,
637    until: Option<String>,
638}
639
640impl Plan {
641    fn expr(&self, positive: &str, negative: Option<&str>, scope: Scope) -> String {
642        let mut parts: Vec<String> = Vec::new();
643        if let (Some((_, token)), Scope::Group) = (&self.group, scope) {
644            parts.push(format!("tags : \"{token}\""));
645        }
646        parts.extend(self.filters.iter().cloned());
647        parts.push(if scope == Scope::Group && self.group.is_some() {
648            format!("{{title body}} : ({positive})")
649        } else {
650            format!("({positive})")
651        });
652        let mut expr = parts.join(" AND ");
653        if let (Some((_, token)), Scope::Others) = (&self.group, scope) {
654            expr = format!("({expr}) NOT tags : \"{token}\"");
655        }
656        if let Some(neg) = negative {
657            expr = format!("({expr}) NOT ({neg})");
658        }
659        expr
660    }
661
662    /// `AND ...` conditions on `r.date`, parameters numbered from `first`.
663    /// `until` is inclusive at its own precision (`--until 2024-03` keeps
664    /// all of March).
665    fn date_sql(&self, first: usize) -> (String, Vec<SqlValue>) {
666        let mut sql = String::new();
667        let mut args = Vec::new();
668        if let Some(since) = &self.since {
669            sql.push_str(&format!(" AND r.date >= ?{}", first + args.len()));
670            args.push(SqlValue::Text(since.clone()));
671        }
672        if let Some(until) = &self.until {
673            let n = first + args.len();
674            sql.push_str(&format!(" AND substr(r.date, 1, length(?{n})) <= ?{n}"));
675            args.push(SqlValue::Text(until.clone()));
676        }
677        (sql.trim_start().to_string(), args)
678    }
679}
680
681fn group_row(r: &Row<'_>, match_type: &'static str) -> rusqlite::Result<GroupMatch> {
682    Ok(GroupMatch { key: r.get(0)?, name: r.get(1)?, records: r.get(2)?, match_type, similarity: None })
683}