use rusqlite::{Connection, params};
use crate::error::{KernelError, Result};
use super::types::{GraphNode, NODE_COLUMNS_PREFIXED, escape_like, row_to_node};
fn fts_phrase(query: &str) -> String {
format!("\"{}\"", query.replace('"', "\"\""))
}
pub fn search_nodes(conn: &Connection, query: &str, limit: usize) -> Result<Vec<GraphNode>> {
let sql = format!(
"SELECT {NODE_COLUMNS_PREFIXED}
FROM nodes n
JOIN nodes_fts ON n.rowid = nodes_fts.rowid
WHERE nodes_fts MATCH ?1
ORDER BY bm25(nodes_fts), n.importance DESC
LIMIT ?2"
);
let mut stmt = conn
.prepare(&sql)
.map_err(|e| KernelError::Store(e.to_string()))?;
let rows = match stmt.query_map(params![fts_phrase(query), limit as i64], row_to_node) {
Ok(rows) => rows,
Err(_) => return Ok(Vec::new()),
};
Ok(rows.filter_map(|r| r.ok()).collect())
}
pub fn search_nodes_hybrid(conn: &Connection, query: &str, limit: usize) -> Result<Vec<GraphNode>> {
let mut out = search_nodes(conn, query, limit)?;
#[cfg(feature = "graph-cjk")]
{
use std::collections::HashSet;
let seen: HashSet<String> = out.iter().map(|n| n.id.clone()).collect();
let fresh: Vec<GraphNode> = super::cjk::search_nodes_cjk(conn, query, limit)?
.into_iter()
.filter(|n| !seen.contains(&n.id))
.collect();
out.extend(fresh);
out.truncate(limit);
}
Ok(out)
}
pub fn query_nodes(
conn: &Connection,
tag: Option<&str>,
node_type: Option<&str>,
project: Option<&str>,
limit: usize,
) -> Result<Vec<GraphNode>> {
let limit = limit.min(200);
let mut condition_strs: Vec<&str> = vec![];
let mut param_vals: Vec<Box<dyn rusqlite::ToSql>> = vec![];
if let Some(t) = tag {
condition_strs.push("(',' || tags || ',' LIKE '%,' || ? || ',%' ESCAPE '\\')");
param_vals.push(Box::new(escape_like(t)));
}
if let Some(nt) = node_type {
condition_strs.push("type = ?");
param_vals.push(Box::new(nt.to_string()));
}
if let Some(p) = project {
condition_strs.push("(',' || projects || ',' LIKE '%,' || ? || ',%' ESCAPE '\\')");
param_vals.push(Box::new(escape_like(p)));
}
let where_clause = if condition_strs.is_empty() {
String::new()
} else {
format!("WHERE {}", condition_strs.join(" AND "))
};
let node_columns = super::types::NODE_COLUMNS;
let sql = format!(
"SELECT {node_columns} FROM nodes {where_clause} ORDER BY updated DESC LIMIT {}",
limit as i64,
);
let mut stmt = conn
.prepare(&sql)
.map_err(|e| KernelError::Store(e.to_string()))?;
let refs: Vec<&dyn rusqlite::ToSql> = param_vals.iter().map(|b| b.as_ref()).collect();
let nodes: Vec<GraphNode> = stmt
.query_map(refs.as_slice(), row_to_node)
.map_err(|e| KernelError::Store(e.to_string()))?
.filter_map(|r| r.ok())
.collect();
Ok(nodes)
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum NodeOrder {
#[default]
CreatedDesc,
CreatedAsc,
UpdatedDesc,
ImportanceDesc,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct NodeQuery {
pub tag: Option<String>,
pub node_type: Option<String>,
pub project: Option<String>,
pub since: Option<String>,
pub until: Option<String>,
pub order_by: NodeOrder,
pub limit: usize,
pub offset: usize,
}
impl Default for NodeQuery {
fn default() -> Self {
Self {
tag: None,
node_type: None,
project: None,
since: None,
until: None,
order_by: NodeOrder::default(),
limit: 50,
offset: 0,
}
}
}
impl NodeQuery {
fn order_clause(&self) -> &'static str {
match self.order_by {
NodeOrder::CreatedDesc => "created DESC",
NodeOrder::CreatedAsc => "created ASC",
NodeOrder::UpdatedDesc => "updated DESC",
NodeOrder::ImportanceDesc => "importance DESC",
}
}
}
pub fn query_nodes_ex(conn: &Connection, q: &NodeQuery) -> Result<Vec<GraphNode>> {
let limit = q.limit.min(200) as i64;
let offset = q.offset as i64;
let mut condition_strs: Vec<&str> = vec![];
let mut param_vals: Vec<Box<dyn rusqlite::ToSql>> = vec![];
if let Some(t) = &q.tag {
condition_strs.push("(',' || tags || ',' LIKE '%,' || ? || ',%' ESCAPE '\\')");
param_vals.push(Box::new(escape_like(t)));
}
if let Some(nt) = &q.node_type {
condition_strs.push("type = ?");
param_vals.push(Box::new(nt.clone()));
}
if let Some(p) = &q.project {
condition_strs.push("(',' || projects || ',' LIKE '%,' || ? || ',%' ESCAPE '\\')");
param_vals.push(Box::new(escape_like(p)));
}
if let Some(s) = &q.since {
condition_strs.push("created >= ?");
param_vals.push(Box::new(s.clone()));
}
if let Some(u) = &q.until {
condition_strs.push("created < ?");
param_vals.push(Box::new(u.clone()));
}
let where_clause = if condition_strs.is_empty() {
String::new()
} else {
format!("WHERE {}", condition_strs.join(" AND "))
};
let order = q.order_clause();
let node_columns = super::types::NODE_COLUMNS;
let sql = format!(
"SELECT {node_columns} FROM nodes {where_clause} ORDER BY {order} LIMIT ? OFFSET ?"
);
param_vals.push(Box::new(limit));
param_vals.push(Box::new(offset));
let mut stmt = conn
.prepare(&sql)
.map_err(|e| KernelError::Store(e.to_string()))?;
let refs: Vec<&dyn rusqlite::ToSql> = param_vals.iter().map(|b| b.as_ref()).collect();
let nodes: Vec<GraphNode> = stmt
.query_map(refs.as_slice(), row_to_node)
.map_err(|e| KernelError::Store(e.to_string()))?
.filter_map(|r| r.ok())
.collect();
Ok(nodes)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::schema::init_graph_schema;
use crate::graph::store::upsert_node;
use crate::graph::types::GraphNode;
use rusqlite::Connection;
fn mem_db() -> Connection {
let conn = Connection::open_in_memory().unwrap();
init_graph_schema(&conn).unwrap();
conn
}
fn test_node(id: &str, title: &str, body: &str, tags: Vec<&str>) -> GraphNode {
GraphNode {
id: id.to_string(),
node_type: "concept".to_string(),
title: title.to_string(),
body: body.to_string(),
tags: tags.into_iter().map(|s| s.to_string()).collect(),
projects: vec![],
agents: vec![],
created: "2026-01-01T00:00:00Z".to_string(),
updated: "2026-01-01T00:00:00Z".to_string(),
importance: 0.7,
access_count: 0,
accessed_at: String::new(),
}
}
#[test]
fn search_finds_by_title() {
let conn = mem_db();
upsert_node(
&conn,
&test_node("n1", "Rust ownership", "borrow checker", vec![]),
)
.unwrap();
upsert_node(&conn, &test_node("n2", "Python GIL", "global lock", vec![])).unwrap();
let results = search_nodes(&conn, "Rust", 10).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].id, "n1");
}
#[test]
fn search_finds_by_body() {
let conn = mem_db();
upsert_node(
&conn,
&test_node("n1", "Title", "machine learning models", vec![]),
)
.unwrap();
let results = search_nodes(&conn, "machine learning", 10).unwrap();
assert_eq!(results.len(), 1);
}
#[test]
fn query_filters_by_tag() {
let conn = mem_db();
upsert_node(&conn, &test_node("n1", "A", "body", vec!["rust", "async"])).unwrap();
upsert_node(&conn, &test_node("n2", "B", "body", vec!["python"])).unwrap();
let results = query_nodes(&conn, Some("rust"), None, None, 10).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].id, "n1");
}
#[test]
fn query_filters_by_type() {
let conn = mem_db();
let mut n1 = test_node("n1", "A", "body", vec![]);
n1.node_type = "decision".to_string();
upsert_node(&conn, &n1).unwrap();
let results = query_nodes(&conn, None, Some("decision"), None, 10).unwrap();
assert_eq!(results.len(), 1);
}
#[test]
fn query_tag_wildcard_is_escaped() {
let conn = mem_db();
upsert_node(&conn, &test_node("n1", "A", "body", vec!["rust"])).unwrap();
let results = query_nodes(&conn, Some("ru%t"), None, None, 10).unwrap();
assert!(results.is_empty());
}
#[test]
fn query_project_wildcard_is_escaped() {
let conn = mem_db();
let mut n1 = test_node("n1", "A", "body", vec![]);
n1.projects = vec!["myproj".to_string()];
upsert_node(&conn, &n1).unwrap();
let results = query_nodes(&conn, None, None, Some("my%"), 10).unwrap();
assert!(results.is_empty());
}
#[test]
fn fts_query_with_quotes_does_not_error() {
let conn = mem_db();
upsert_node(&conn, &test_node("n1", "quoted", "body", vec![])).unwrap();
assert!(search_nodes(&conn, "say \"hello\"", 10).is_ok());
assert!(search_nodes(&conn, "trailing *", 10).is_ok());
assert!(search_nodes(&conn, "NEAR OR AND", 10).is_ok());
}
#[cfg(feature = "graph-cjk")]
#[test]
fn hybrid_matches_short_korean_that_trigram_misses() {
let conn = mem_db();
upsert_node(
&conn,
&test_node(
"d1",
"SK하이닉스 판정",
"매수 의견을 유지한다",
vec!["hold"],
),
)
.unwrap();
assert!(search_nodes(&conn, "매수", 10).unwrap().is_empty());
assert_eq!(search_nodes_hybrid(&conn, "매수", 10).unwrap().len(), 1);
assert_eq!(search_nodes_hybrid(&conn, "SK", 10).unwrap().len(), 1);
}
#[cfg(feature = "graph-cjk")]
#[test]
fn hybrid_negative_control_absent_term_stays_empty() {
let conn = mem_db();
upsert_node(&conn, &test_node("d1", "SK하이닉스", "매수 의견", vec![])).unwrap();
assert!(search_nodes_hybrid(&conn, "반도체", 10).unwrap().is_empty());
assert!(
search_nodes_hybrid(&conn, "존재하지않는단어", 10)
.unwrap()
.is_empty()
);
}
#[cfg(feature = "graph-cjk")]
#[test]
fn hybrid_dedups_nodes_found_by_both_paths() {
let conn = mem_db();
upsert_node(&conn, &test_node("d1", "삼성전자", "반도체 실적", vec![])).unwrap();
assert_eq!(search_nodes_hybrid(&conn, "삼성전자", 10).unwrap().len(), 1);
}
fn test_node_dated(id: &str, created: &str, tags: Vec<&str>) -> GraphNode {
GraphNode {
id: id.to_string(),
node_type: "concept".to_string(),
title: id.to_string(),
body: String::new(),
tags: tags.into_iter().map(|s| s.to_string()).collect(),
projects: vec![],
agents: vec![],
created: created.to_string(),
updated: created.to_string(),
importance: 0.7,
access_count: 0,
accessed_at: String::new(),
}
}
#[test]
fn query_ex_paginates_with_offset() {
let conn = mem_db();
for i in 1..=5 {
upsert_node(
&conn,
&test_node_dated(&format!("n{i}"), &format!("2026-01-0{i}T00:00:00Z"), vec![]),
)
.unwrap();
}
let page1 = query_nodes_ex(
&conn,
&NodeQuery {
limit: 2,
offset: 0,
..Default::default()
},
)
.unwrap();
let page2 = query_nodes_ex(
&conn,
&NodeQuery {
limit: 2,
offset: 2,
..Default::default()
},
)
.unwrap();
assert_eq!(page1.len(), 2);
assert_eq!(page2.len(), 2);
let p1: Vec<&str> = page1.iter().map(|n| n.id.as_str()).collect();
let p2: Vec<&str> = page2.iter().map(|n| n.id.as_str()).collect();
assert_eq!(p1, vec!["n5", "n4"]);
assert_eq!(p2, vec!["n3", "n2"]);
assert!(p1.iter().all(|id| !p2.contains(id)));
}
#[test]
fn query_ex_filters_by_time_range() {
let conn = mem_db();
upsert_node(
&conn,
&test_node_dated("old", "2025-06-01T00:00:00Z", vec![]),
)
.unwrap();
upsert_node(
&conn,
&test_node_dated("mid", "2026-01-01T00:00:00Z", vec![]),
)
.unwrap();
upsert_node(
&conn,
&test_node_dated("new", "2026-06-01T00:00:00Z", vec![]),
)
.unwrap();
let in_window = query_nodes_ex(
&conn,
&NodeQuery {
since: Some("2026-01-01T00:00:00Z".to_string()),
until: Some("2026-06-01T00:00:00Z".to_string()),
limit: 50,
..Default::default()
},
)
.unwrap();
let ids: Vec<&str> = in_window.iter().map(|n| n.id.as_str()).collect();
assert_eq!(ids, vec!["mid"]);
}
#[test]
fn query_ex_filters_by_tag() {
let conn = mem_db();
upsert_node(
&conn,
&test_node_dated("n1", "2026-01-01T00:00:00Z", vec!["AAPL"]),
)
.unwrap();
upsert_node(
&conn,
&test_node_dated("n2", "2026-01-02T00:00:00Z", vec!["MSFT"]),
)
.unwrap();
let results = query_nodes_ex(
&conn,
&NodeQuery {
tag: Some("AAPL".to_string()),
limit: 50,
..Default::default()
},
)
.unwrap();
let ids: Vec<&str> = results.iter().map(|n| n.id.as_str()).collect();
assert_eq!(ids, vec!["n1"]);
}
#[test]
fn query_ex_respects_limit_cap() {
let conn = mem_db();
upsert_node(
&conn,
&test_node_dated("n1", "2026-01-01T00:00:00Z", vec![]),
)
.unwrap();
let results = query_nodes_ex(
&conn,
&NodeQuery {
limit: 10_000,
..Default::default()
},
)
.unwrap();
assert!(results.len() <= 200);
assert_eq!(results.len(), 1); }
}