1use rusqlite::{Connection, params};
4
5use crate::error::{KernelError, Result};
6
7use super::types::{GraphNode, NODE_COLUMNS_PREFIXED, escape_like, row_to_node};
8
9fn fts_phrase(query: &str) -> String {
16 format!("\"{}\"", query.replace('"', "\"\""))
17}
18
19pub fn search_nodes(conn: &Connection, query: &str, limit: usize) -> Result<Vec<GraphNode>> {
26 let sql = format!(
27 "SELECT {NODE_COLUMNS_PREFIXED}
28 FROM nodes n
29 JOIN nodes_fts ON n.rowid = nodes_fts.rowid
30 WHERE nodes_fts MATCH ?1
31 ORDER BY bm25(nodes_fts), n.importance DESC
32 LIMIT ?2"
33 );
34 let mut stmt = conn
35 .prepare(&sql)
36 .map_err(|e| KernelError::Store(e.to_string()))?;
37 let rows = match stmt.query_map(params![fts_phrase(query), limit as i64], row_to_node) {
38 Ok(rows) => rows,
39 Err(_) => return Ok(Vec::new()),
41 };
42 Ok(rows.filter_map(|r| r.ok()).collect())
43}
44
45pub fn search_nodes_hybrid(conn: &Connection, query: &str, limit: usize) -> Result<Vec<GraphNode>> {
58 #[cfg_attr(not(feature = "graph-cjk"), allow(unused_mut))]
61 let mut out = search_nodes(conn, query, limit)?;
62
63 #[cfg(feature = "graph-cjk")]
64 {
65 use std::collections::HashSet;
70 let seen: HashSet<String> = out.iter().map(|n| n.id.clone()).collect();
71 let fresh: Vec<GraphNode> = super::cjk::search_nodes_cjk(conn, query, limit)?
72 .into_iter()
73 .filter(|n| !seen.contains(&n.id))
74 .collect();
75 out.extend(fresh);
76 out.truncate(limit);
77 }
78
79 Ok(out)
80}
81
82pub fn query_nodes(
84 conn: &Connection,
85 tag: Option<&str>,
86 node_type: Option<&str>,
87 project: Option<&str>,
88 limit: usize,
89) -> Result<Vec<GraphNode>> {
90 let limit = limit.min(200);
91
92 let mut condition_strs: Vec<&str> = vec![];
93 let mut param_vals: Vec<Box<dyn rusqlite::ToSql>> = vec![];
94
95 if let Some(t) = tag {
96 condition_strs.push("(',' || tags || ',' LIKE '%,' || ? || ',%' ESCAPE '\\')");
97 param_vals.push(Box::new(escape_like(t)));
98 }
99 if let Some(nt) = node_type {
100 condition_strs.push("type = ?");
101 param_vals.push(Box::new(nt.to_string()));
102 }
103 if let Some(p) = project {
104 condition_strs.push("(',' || projects || ',' LIKE '%,' || ? || ',%' ESCAPE '\\')");
105 param_vals.push(Box::new(escape_like(p)));
106 }
107
108 let where_clause = if condition_strs.is_empty() {
109 String::new()
110 } else {
111 format!("WHERE {}", condition_strs.join(" AND "))
112 };
113
114 let node_columns = super::types::NODE_COLUMNS;
115 let sql = format!(
116 "SELECT {node_columns} FROM nodes {where_clause} ORDER BY updated DESC LIMIT {}",
117 limit as i64,
118 );
119
120 let mut stmt = conn
121 .prepare(&sql)
122 .map_err(|e| KernelError::Store(e.to_string()))?;
123 let refs: Vec<&dyn rusqlite::ToSql> = param_vals.iter().map(|b| b.as_ref()).collect();
124 let nodes: Vec<GraphNode> = stmt
125 .query_map(refs.as_slice(), row_to_node)
126 .map_err(|e| KernelError::Store(e.to_string()))?
127 .filter_map(|r| r.ok())
128 .collect();
129 Ok(nodes)
130}
131
132#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
134pub enum NodeOrder {
135 #[default]
137 CreatedDesc,
138 CreatedAsc,
140 UpdatedDesc,
142 ImportanceDesc,
144}
145
146#[derive(Debug, Clone)]
151#[non_exhaustive]
152pub struct NodeQuery {
153 pub tag: Option<String>,
155 pub node_type: Option<String>,
157 pub project: Option<String>,
159 pub since: Option<String>,
161 pub until: Option<String>,
163 pub order_by: NodeOrder,
165 pub limit: usize,
167 pub offset: usize,
169}
170
171impl Default for NodeQuery {
172 fn default() -> Self {
173 Self {
174 tag: None,
175 node_type: None,
176 project: None,
177 since: None,
178 until: None,
179 order_by: NodeOrder::default(),
180 limit: 50,
181 offset: 0,
182 }
183 }
184}
185
186impl NodeQuery {
187 fn order_clause(&self) -> &'static str {
188 match self.order_by {
189 NodeOrder::CreatedDesc => "created DESC",
190 NodeOrder::CreatedAsc => "created ASC",
191 NodeOrder::UpdatedDesc => "updated DESC",
192 NodeOrder::ImportanceDesc => "importance DESC",
193 }
194 }
195}
196
197pub fn query_nodes_ex(conn: &Connection, q: &NodeQuery) -> Result<Vec<GraphNode>> {
202 let limit = q.limit.min(200) as i64;
203 let offset = q.offset as i64;
204
205 let mut condition_strs: Vec<&str> = vec![];
206 let mut param_vals: Vec<Box<dyn rusqlite::ToSql>> = vec![];
207
208 if let Some(t) = &q.tag {
209 condition_strs.push("(',' || tags || ',' LIKE '%,' || ? || ',%' ESCAPE '\\')");
210 param_vals.push(Box::new(escape_like(t)));
211 }
212 if let Some(nt) = &q.node_type {
213 condition_strs.push("type = ?");
214 param_vals.push(Box::new(nt.clone()));
215 }
216 if let Some(p) = &q.project {
217 condition_strs.push("(',' || projects || ',' LIKE '%,' || ? || ',%' ESCAPE '\\')");
218 param_vals.push(Box::new(escape_like(p)));
219 }
220 if let Some(s) = &q.since {
221 condition_strs.push("created >= ?");
222 param_vals.push(Box::new(s.clone()));
223 }
224 if let Some(u) = &q.until {
225 condition_strs.push("created < ?");
226 param_vals.push(Box::new(u.clone()));
227 }
228
229 let where_clause = if condition_strs.is_empty() {
230 String::new()
231 } else {
232 format!("WHERE {}", condition_strs.join(" AND "))
233 };
234 let order = q.order_clause();
235 let node_columns = super::types::NODE_COLUMNS;
236 let sql = format!(
237 "SELECT {node_columns} FROM nodes {where_clause} ORDER BY {order} LIMIT ? OFFSET ?"
238 );
239
240 param_vals.push(Box::new(limit));
241 param_vals.push(Box::new(offset));
242
243 let mut stmt = conn
244 .prepare(&sql)
245 .map_err(|e| KernelError::Store(e.to_string()))?;
246 let refs: Vec<&dyn rusqlite::ToSql> = param_vals.iter().map(|b| b.as_ref()).collect();
247 let nodes: Vec<GraphNode> = stmt
248 .query_map(refs.as_slice(), row_to_node)
249 .map_err(|e| KernelError::Store(e.to_string()))?
250 .filter_map(|r| r.ok())
251 .collect();
252 Ok(nodes)
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258 use crate::graph::schema::init_graph_schema;
259 use crate::graph::store::upsert_node;
260 use crate::graph::types::GraphNode;
261 use rusqlite::Connection;
262
263 fn mem_db() -> Connection {
264 let conn = Connection::open_in_memory().unwrap();
265 init_graph_schema(&conn).unwrap();
266 conn
267 }
268
269 fn test_node(id: &str, title: &str, body: &str, tags: Vec<&str>) -> GraphNode {
270 GraphNode {
271 id: id.to_string(),
272 node_type: "concept".to_string(),
273 title: title.to_string(),
274 body: body.to_string(),
275 tags: tags.into_iter().map(|s| s.to_string()).collect(),
276 projects: vec![],
277 agents: vec![],
278 created: "2026-01-01T00:00:00Z".to_string(),
279 updated: "2026-01-01T00:00:00Z".to_string(),
280 importance: 0.7,
281 access_count: 0,
282 accessed_at: String::new(),
283 ..Default::default()
284 }
285 }
286
287 #[test]
288 fn search_finds_by_title() {
289 let conn = mem_db();
290 upsert_node(
291 &conn,
292 &test_node("n1", "Rust ownership", "borrow checker", vec![]),
293 )
294 .unwrap();
295 upsert_node(&conn, &test_node("n2", "Python GIL", "global lock", vec![])).unwrap();
296 let results = search_nodes(&conn, "Rust", 10).unwrap();
297 assert_eq!(results.len(), 1);
298 assert_eq!(results[0].id, "n1");
299 }
300
301 #[test]
302 fn search_finds_by_body() {
303 let conn = mem_db();
304 upsert_node(
305 &conn,
306 &test_node("n1", "Title", "machine learning models", vec![]),
307 )
308 .unwrap();
309 let results = search_nodes(&conn, "machine learning", 10).unwrap();
310 assert_eq!(results.len(), 1);
311 }
312
313 #[test]
314 fn query_filters_by_tag() {
315 let conn = mem_db();
316 upsert_node(&conn, &test_node("n1", "A", "body", vec!["rust", "async"])).unwrap();
317 upsert_node(&conn, &test_node("n2", "B", "body", vec!["python"])).unwrap();
318 let results = query_nodes(&conn, Some("rust"), None, None, 10).unwrap();
319 assert_eq!(results.len(), 1);
320 assert_eq!(results[0].id, "n1");
321 }
322
323 #[test]
324 fn query_filters_by_type() {
325 let conn = mem_db();
326 let mut n1 = test_node("n1", "A", "body", vec![]);
327 n1.node_type = "decision".to_string();
328 upsert_node(&conn, &n1).unwrap();
329 let results = query_nodes(&conn, None, Some("decision"), None, 10).unwrap();
330 assert_eq!(results.len(), 1);
331 }
332
333 #[test]
334 fn query_tag_wildcard_is_escaped() {
335 let conn = mem_db();
336 upsert_node(&conn, &test_node("n1", "A", "body", vec!["rust"])).unwrap();
337 let results = query_nodes(&conn, Some("ru%t"), None, None, 10).unwrap();
339 assert!(results.is_empty());
340 }
341
342 #[test]
343 fn query_project_wildcard_is_escaped() {
344 let conn = mem_db();
345 let mut n1 = test_node("n1", "A", "body", vec![]);
346 n1.projects = vec!["myproj".to_string()];
347 upsert_node(&conn, &n1).unwrap();
348 let results = query_nodes(&conn, None, None, Some("my%"), 10).unwrap();
349 assert!(results.is_empty());
350 }
351
352 #[test]
353 fn fts_query_with_quotes_does_not_error() {
354 let conn = mem_db();
356 upsert_node(&conn, &test_node("n1", "quoted", "body", vec![])).unwrap();
357 assert!(search_nodes(&conn, "say \"hello\"", 10).is_ok());
358 assert!(search_nodes(&conn, "trailing *", 10).is_ok());
359 assert!(search_nodes(&conn, "NEAR OR AND", 10).is_ok());
360 }
361
362 #[cfg(feature = "graph-cjk")]
363 #[test]
364 fn hybrid_matches_short_korean_that_trigram_misses() {
365 let conn = mem_db();
368 upsert_node(
369 &conn,
370 &test_node(
371 "d1",
372 "SK하이닉스 판정",
373 "매수 의견을 유지한다",
374 vec!["hold"],
375 ),
376 )
377 .unwrap();
378
379 assert!(search_nodes(&conn, "매수", 10).unwrap().is_empty());
381 assert_eq!(search_nodes_hybrid(&conn, "매수", 10).unwrap().len(), 1);
383 assert_eq!(search_nodes_hybrid(&conn, "SK", 10).unwrap().len(), 1);
384 }
385
386 #[cfg(feature = "graph-cjk")]
387 #[test]
388 fn hybrid_negative_control_absent_term_stays_empty() {
389 let conn = mem_db();
392 upsert_node(&conn, &test_node("d1", "SK하이닉스", "매수 의견", vec![])).unwrap();
393 assert!(search_nodes_hybrid(&conn, "반도체", 10).unwrap().is_empty());
394 assert!(
395 search_nodes_hybrid(&conn, "존재하지않는단어", 10)
396 .unwrap()
397 .is_empty()
398 );
399 }
400
401 #[cfg(feature = "graph-cjk")]
402 #[test]
403 fn hybrid_dedups_nodes_found_by_both_paths() {
404 let conn = mem_db();
405 upsert_node(&conn, &test_node("d1", "삼성전자", "반도체 실적", vec![])).unwrap();
406 assert_eq!(search_nodes_hybrid(&conn, "삼성전자", 10).unwrap().len(), 1);
408 }
409
410 fn test_node_dated(id: &str, created: &str, tags: Vec<&str>) -> GraphNode {
416 GraphNode {
417 id: id.to_string(),
418 node_type: "concept".to_string(),
419 title: id.to_string(),
420 body: String::new(),
421 tags: tags.into_iter().map(|s| s.to_string()).collect(),
422 projects: vec![],
423 agents: vec![],
424 created: created.to_string(),
425 updated: created.to_string(),
426 importance: 0.7,
427 access_count: 0,
428 accessed_at: String::new(),
429 ..Default::default()
430 }
431 }
432
433 #[test]
434 fn query_ex_paginates_with_offset() {
435 let conn = mem_db();
436 for i in 1..=5 {
438 upsert_node(
439 &conn,
440 &test_node_dated(&format!("n{i}"), &format!("2026-01-0{i}T00:00:00Z"), vec![]),
441 )
442 .unwrap();
443 }
444 let page1 = query_nodes_ex(
445 &conn,
446 &NodeQuery {
447 limit: 2,
448 offset: 0,
449 ..Default::default()
450 },
451 )
452 .unwrap();
453 let page2 = query_nodes_ex(
454 &conn,
455 &NodeQuery {
456 limit: 2,
457 offset: 2,
458 ..Default::default()
459 },
460 )
461 .unwrap();
462 assert_eq!(page1.len(), 2);
463 assert_eq!(page2.len(), 2);
464 let p1: Vec<&str> = page1.iter().map(|n| n.id.as_str()).collect();
466 let p2: Vec<&str> = page2.iter().map(|n| n.id.as_str()).collect();
467 assert_eq!(p1, vec!["n5", "n4"]);
468 assert_eq!(p2, vec!["n3", "n2"]);
469 assert!(p1.iter().all(|id| !p2.contains(id)));
470 }
471
472 #[test]
473 fn query_ex_filters_by_time_range() {
474 let conn = mem_db();
475 upsert_node(
476 &conn,
477 &test_node_dated("old", "2025-06-01T00:00:00Z", vec![]),
478 )
479 .unwrap();
480 upsert_node(
481 &conn,
482 &test_node_dated("mid", "2026-01-01T00:00:00Z", vec![]),
483 )
484 .unwrap();
485 upsert_node(
486 &conn,
487 &test_node_dated("new", "2026-06-01T00:00:00Z", vec![]),
488 )
489 .unwrap();
490
491 let in_window = query_nodes_ex(
492 &conn,
493 &NodeQuery {
494 since: Some("2026-01-01T00:00:00Z".to_string()),
495 until: Some("2026-06-01T00:00:00Z".to_string()),
496 limit: 50,
497 ..Default::default()
498 },
499 )
500 .unwrap();
501 let ids: Vec<&str> = in_window.iter().map(|n| n.id.as_str()).collect();
502 assert_eq!(ids, vec!["mid"]);
504 }
505
506 #[test]
507 fn query_ex_filters_by_tag() {
508 let conn = mem_db();
509 upsert_node(
510 &conn,
511 &test_node_dated("n1", "2026-01-01T00:00:00Z", vec!["AAPL"]),
512 )
513 .unwrap();
514 upsert_node(
515 &conn,
516 &test_node_dated("n2", "2026-01-02T00:00:00Z", vec!["MSFT"]),
517 )
518 .unwrap();
519 let results = query_nodes_ex(
520 &conn,
521 &NodeQuery {
522 tag: Some("AAPL".to_string()),
523 limit: 50,
524 ..Default::default()
525 },
526 )
527 .unwrap();
528 let ids: Vec<&str> = results.iter().map(|n| n.id.as_str()).collect();
529 assert_eq!(ids, vec!["n1"]);
530 }
531
532 #[test]
533 fn query_ex_respects_limit_cap() {
534 let conn = mem_db();
535 upsert_node(
536 &conn,
537 &test_node_dated("n1", "2026-01-01T00:00:00Z", vec![]),
538 )
539 .unwrap();
540 let results = query_nodes_ex(
542 &conn,
543 &NodeQuery {
544 limit: 10_000,
545 ..Default::default()
546 },
547 )
548 .unwrap();
549 assert!(results.len() <= 200);
550 assert_eq!(results.len(), 1); }
552}