use rusqlite::{types::Value, Connection};
use crate::error::Result;
use crate::model::Item;
use crate::store::row_to_item_with_tags;
#[derive(Debug, Clone, Default)]
pub struct Filter {
pub query: Option<String>,
pub category: Option<i64>,
pub tags: Vec<String>,
pub template_id: Option<String>,
pub favorite_only: bool,
}
fn sanitize_fts(raw: &str) -> String {
raw.split_whitespace()
.map(|tok| {
let escaped = tok.replace('"', "\"\"");
format!("\"{escaped}\"")
})
.collect::<Vec<_>>()
.join(" ")
}
pub fn search(conn: &Connection, f: &Filter) -> Result<Vec<Item>> {
let mut sql = String::new();
let mut where_clauses: Vec<String> = Vec::new();
let mut bind_params: Vec<Value> = Vec::new();
let query_trimmed = f
.query
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty());
if let Some(q) = query_trimmed {
sql.push_str(
"SELECT i.id, i.type, i.title, i.category_id, i.data, i.favorite, i.search_text, i.created_at, i.updated_at,
(SELECT GROUP_CONCAT(tn, char(31))
FROM (SELECT t.name AS tn FROM item_tags it JOIN tags t ON t.id = it.tag_id
WHERE it.item_id = i.id ORDER BY t.name ASC)) AS tags
FROM items_fts f JOIN items i ON i.id = f.rowid",
);
where_clauses.push("f.items_fts MATCH ?1".to_string());
bind_params.push(Value::from(sanitize_fts(q)));
} else {
sql.push_str(
"SELECT i.id, i.type, i.title, i.category_id, i.data, i.favorite, i.search_text, i.created_at, i.updated_at,
(SELECT GROUP_CONCAT(tn, char(31))
FROM (SELECT t.name AS tn FROM item_tags it JOIN tags t ON t.id = it.tag_id
WHERE it.item_id = i.id ORDER BY t.name ASC)) AS tags
FROM items i",
);
}
if let Some(cat) = f.category {
where_clauses.push("i.category_id = ?".to_string());
bind_params.push(Value::from(cat));
}
if let Some(tpl) = &f.template_id {
where_clauses.push("i.type = ?".to_string());
bind_params.push(Value::from(tpl.clone()));
}
if f.favorite_only {
where_clauses.push("i.favorite = 1".to_string());
}
if !f.tags.is_empty() {
let placeholders: Vec<String> = (0..f.tags.len())
.map(|_| "?".to_string())
.collect();
let sub = format!(
"EXISTS(SELECT 1 FROM item_tags it JOIN tags t ON t.id = it.tag_id
WHERE it.item_id = i.id AND t.name IN ({}))",
placeholders.join(", ")
);
where_clauses.push(sub);
for t in &f.tags {
bind_params.push(Value::from(t.clone()));
}
}
if !where_clauses.is_empty() {
sql.push_str(" WHERE ");
sql.push_str(&where_clauses.join(" AND "));
}
sql.push_str(" ORDER BY i.updated_at DESC");
let mut stmt = conn.prepare(&sql)?;
let binds: Vec<&dyn rusqlite::ToSql> = bind_params
.iter()
.map(|v| v as &dyn rusqlite::ToSql)
.collect();
let items: Vec<Item> = stmt
.query_map(binds.as_slice(), row_to_item_with_tags)?
.filter_map(|r| r.ok())
.collect();
Ok(items)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Database;
use crate::model::{FieldKind, Item};
use crate::store;
use crate::test_support::mk_item;
fn make_password(title: &str, body: &str, tags: Vec<&str>, favorite: bool) -> Item {
let mut it = mk_item(
"password",
title,
&[
("username", "u", FieldKind::Text),
("password", "p", FieldKind::Secret),
("url", "https://x", FieldKind::Text),
("totp", body, FieldKind::Totp),
("notes", body, FieldKind::Multiline),
],
);
it.favorite = favorite;
it.tags = tags.into_iter().map(String::from).collect();
it
}
fn seed() -> Database {
let db = Database::open_in_memory().unwrap();
let conn = db.conn();
let mut cat = crate::model::Category {
id: None,
name: "Work".into(),
parent_id: None,
sort_order: 0,
};
let cid = store::insert_category(conn, &mut cat).unwrap();
let mut a = make_password("GitHub Login", "github myuser", vec!["work", "vip"], true);
a.category_id = Some(cid);
store::insert_item(conn, &mut a).unwrap();
let mut b = make_password("GitLab Notes", "gitlab secret", vec!["work"], false);
b.category_id = Some(cid);
store::insert_item(conn, &mut b).unwrap();
let mut c = mk_item(
"note",
"Personal Diary",
&[
("format", "markdown", FieldKind::Text),
("content", "today was a good day", FieldKind::Multiline),
],
);
c.tags = vec!["personal".into()];
store::insert_item(conn, &mut c).unwrap();
db
}
#[test]
fn query_hit_and_miss() {
let db = seed();
let conn = db.conn();
let hits = search(
conn,
&Filter {
query: Some("github".into()),
..Default::default()
},
)
.unwrap();
assert_eq!(hits.len(), 1, "should match only GitHub item");
assert_eq!(hits[0].title, "GitHub Login");
let miss = search(
conn,
&Filter {
query: Some("zzznomatch".into()),
..Default::default()
},
)
.unwrap();
assert!(miss.is_empty());
}
#[test]
fn query_empty_returns_all() {
let db = seed();
let conn = db.conn();
let all = search(conn, &Filter::default()).unwrap();
assert_eq!(all.len(), 3);
}
#[test]
fn filter_by_category() {
let db = seed();
let conn = db.conn();
let cats = store::list_categories(conn).unwrap();
let cid = cats[0].id.unwrap();
let res = search(
conn,
&Filter {
category: Some(cid),
..Default::default()
},
)
.unwrap();
assert_eq!(res.len(), 2);
for it in &res {
assert_eq!(it.category_id, Some(cid));
}
}
#[test]
fn filter_by_tags() {
let db = seed();
let conn = db.conn();
let vip = search(
conn,
&Filter {
tags: vec!["vip".into()],
..Default::default()
},
)
.unwrap();
assert_eq!(vip.len(), 1);
assert_eq!(vip[0].title, "GitHub Login");
let work = search(
conn,
&Filter {
tags: vec!["work".into()],
..Default::default()
},
)
.unwrap();
assert_eq!(work.len(), 2);
}
#[test]
fn filter_by_template_id() {
let db = seed();
let conn = db.conn();
let notes = search(
conn,
&Filter {
template_id: Some("note".into()),
..Default::default()
},
)
.unwrap();
assert_eq!(notes.len(), 1);
assert_eq!(notes[0].template_id, "note");
}
#[test]
fn fts_does_not_match_secret_values() {
let db = seed();
let conn = db.conn();
let hits = search(
conn,
&Filter {
query: Some("p".into()),
..Default::default()
},
)
.unwrap();
assert!(hits.is_empty(), "Secret 值不应进入 FTS: {:?}", hits);
}
#[test]
fn filter_favorite_only() {
let db = seed();
let conn = db.conn();
let fav = search(
conn,
&Filter {
favorite_only: true,
..Default::default()
},
)
.unwrap();
assert_eq!(fav.len(), 1);
assert!(fav[0].favorite);
}
#[test]
fn combined_filter() {
let db = seed();
let conn = db.conn();
let cats = store::list_categories(conn).unwrap();
let cid = cats[0].id.unwrap();
let res = search(
conn,
&Filter {
category: Some(cid),
tags: vec!["vip".into()],
..Default::default()
},
)
.unwrap();
assert_eq!(res.len(), 1);
assert_eq!(res[0].title, "GitHub Login");
}
#[test]
fn sanitize_fts_quotes_tokens() {
assert_eq!(sanitize_fts("hello world"), "\"hello\" \"world\"");
assert_eq!(sanitize_fts("a\"b"), "\"a\"\"b\"");
}
}