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