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 ..Default::default()
281 }
282 }
283
284 #[test]
285 fn search_finds_by_title() {
286 let conn = mem_db();
287 upsert_node(
288 &conn,
289 &test_node("n1", "Rust ownership", "borrow checker", vec![]),
290 )
291 .unwrap();
292 upsert_node(&conn, &test_node("n2", "Python GIL", "global lock", vec![])).unwrap();
293 let results = search_nodes(&conn, "Rust", 10).unwrap();
294 assert_eq!(results.len(), 1);
295 assert_eq!(results[0].id, "n1");
296 }
297
298 #[test]
299 fn search_finds_by_body() {
300 let conn = mem_db();
301 upsert_node(
302 &conn,
303 &test_node("n1", "Title", "machine learning models", vec![]),
304 )
305 .unwrap();
306 let results = search_nodes(&conn, "machine learning", 10).unwrap();
307 assert_eq!(results.len(), 1);
308 }
309
310 #[test]
311 fn query_filters_by_tag() {
312 let conn = mem_db();
313 upsert_node(&conn, &test_node("n1", "A", "body", vec!["rust", "async"])).unwrap();
314 upsert_node(&conn, &test_node("n2", "B", "body", vec!["python"])).unwrap();
315 let results = query_nodes(&conn, Some("rust"), None, None, 10).unwrap();
316 assert_eq!(results.len(), 1);
317 assert_eq!(results[0].id, "n1");
318 }
319
320 #[test]
321 fn query_filters_by_type() {
322 let conn = mem_db();
323 let mut n1 = test_node("n1", "A", "body", vec![]);
324 n1.node_type = "decision".to_string();
325 upsert_node(&conn, &n1).unwrap();
326 let results = query_nodes(&conn, None, Some("decision"), None, 10).unwrap();
327 assert_eq!(results.len(), 1);
328 }
329
330 #[test]
331 fn query_tag_wildcard_is_escaped() {
332 let conn = mem_db();
333 upsert_node(&conn, &test_node("n1", "A", "body", vec!["rust"])).unwrap();
334 let results = query_nodes(&conn, Some("ru%t"), None, None, 10).unwrap();
336 assert!(results.is_empty());
337 }
338
339 #[test]
340 fn query_project_wildcard_is_escaped() {
341 let conn = mem_db();
342 let mut n1 = test_node("n1", "A", "body", vec![]);
343 n1.projects = vec!["myproj".to_string()];
344 upsert_node(&conn, &n1).unwrap();
345 let results = query_nodes(&conn, None, None, Some("my%"), 10).unwrap();
346 assert!(results.is_empty());
347 }
348
349 #[test]
350 fn fts_query_with_quotes_does_not_error() {
351 let conn = mem_db();
353 upsert_node(&conn, &test_node("n1", "quoted", "body", vec![])).unwrap();
354 assert!(search_nodes(&conn, "say \"hello\"", 10).is_ok());
355 assert!(search_nodes(&conn, "trailing *", 10).is_ok());
356 assert!(search_nodes(&conn, "NEAR OR AND", 10).is_ok());
357 }
358
359 #[cfg(feature = "graph-cjk")]
360 #[test]
361 fn hybrid_matches_short_korean_that_trigram_misses() {
362 let conn = mem_db();
365 upsert_node(
366 &conn,
367 &test_node(
368 "d1",
369 "SK하이닉스 판정",
370 "매수 의견을 유지한다",
371 vec!["hold"],
372 ),
373 )
374 .unwrap();
375
376 assert!(search_nodes(&conn, "매수", 10).unwrap().is_empty());
378 assert_eq!(search_nodes_hybrid(&conn, "매수", 10).unwrap().len(), 1);
380 assert_eq!(search_nodes_hybrid(&conn, "SK", 10).unwrap().len(), 1);
381 }
382
383 #[cfg(feature = "graph-cjk")]
384 #[test]
385 fn hybrid_negative_control_absent_term_stays_empty() {
386 let conn = mem_db();
389 upsert_node(&conn, &test_node("d1", "SK하이닉스", "매수 의견", vec![])).unwrap();
390 assert!(search_nodes_hybrid(&conn, "반도체", 10).unwrap().is_empty());
391 assert!(
392 search_nodes_hybrid(&conn, "존재하지않는단어", 10)
393 .unwrap()
394 .is_empty()
395 );
396 }
397
398 #[cfg(feature = "graph-cjk")]
399 #[test]
400 fn hybrid_dedups_nodes_found_by_both_paths() {
401 let conn = mem_db();
402 upsert_node(&conn, &test_node("d1", "삼성전자", "반도체 실적", vec![])).unwrap();
403 assert_eq!(search_nodes_hybrid(&conn, "삼성전자", 10).unwrap().len(), 1);
405 }
406
407 fn test_node_dated(id: &str, created: &str, tags: Vec<&str>) -> GraphNode {
413 GraphNode {
414 id: id.to_string(),
415 node_type: "concept".to_string(),
416 title: id.to_string(),
417 body: String::new(),
418 tags: tags.into_iter().map(|s| s.to_string()).collect(),
419 projects: vec![],
420 agents: vec![],
421 created: created.to_string(),
422 updated: created.to_string(),
423 importance: 0.7,
424 access_count: 0,
425 accessed_at: String::new(),
426 ..Default::default()
427 }
428 }
429
430 #[test]
431 fn query_ex_paginates_with_offset() {
432 let conn = mem_db();
433 for i in 1..=5 {
435 upsert_node(
436 &conn,
437 &test_node_dated(&format!("n{i}"), &format!("2026-01-0{i}T00:00:00Z"), vec![]),
438 )
439 .unwrap();
440 }
441 let page1 = query_nodes_ex(
442 &conn,
443 &NodeQuery {
444 limit: 2,
445 offset: 0,
446 ..Default::default()
447 },
448 )
449 .unwrap();
450 let page2 = query_nodes_ex(
451 &conn,
452 &NodeQuery {
453 limit: 2,
454 offset: 2,
455 ..Default::default()
456 },
457 )
458 .unwrap();
459 assert_eq!(page1.len(), 2);
460 assert_eq!(page2.len(), 2);
461 let p1: Vec<&str> = page1.iter().map(|n| n.id.as_str()).collect();
463 let p2: Vec<&str> = page2.iter().map(|n| n.id.as_str()).collect();
464 assert_eq!(p1, vec!["n5", "n4"]);
465 assert_eq!(p2, vec!["n3", "n2"]);
466 assert!(p1.iter().all(|id| !p2.contains(id)));
467 }
468
469 #[test]
470 fn query_ex_filters_by_time_range() {
471 let conn = mem_db();
472 upsert_node(
473 &conn,
474 &test_node_dated("old", "2025-06-01T00:00:00Z", vec![]),
475 )
476 .unwrap();
477 upsert_node(
478 &conn,
479 &test_node_dated("mid", "2026-01-01T00:00:00Z", vec![]),
480 )
481 .unwrap();
482 upsert_node(
483 &conn,
484 &test_node_dated("new", "2026-06-01T00:00:00Z", vec![]),
485 )
486 .unwrap();
487
488 let in_window = query_nodes_ex(
489 &conn,
490 &NodeQuery {
491 since: Some("2026-01-01T00:00:00Z".to_string()),
492 until: Some("2026-06-01T00:00:00Z".to_string()),
493 limit: 50,
494 ..Default::default()
495 },
496 )
497 .unwrap();
498 let ids: Vec<&str> = in_window.iter().map(|n| n.id.as_str()).collect();
499 assert_eq!(ids, vec!["mid"]);
501 }
502
503 #[test]
504 fn query_ex_filters_by_tag() {
505 let conn = mem_db();
506 upsert_node(
507 &conn,
508 &test_node_dated("n1", "2026-01-01T00:00:00Z", vec!["AAPL"]),
509 )
510 .unwrap();
511 upsert_node(
512 &conn,
513 &test_node_dated("n2", "2026-01-02T00:00:00Z", vec!["MSFT"]),
514 )
515 .unwrap();
516 let results = query_nodes_ex(
517 &conn,
518 &NodeQuery {
519 tag: Some("AAPL".to_string()),
520 limit: 50,
521 ..Default::default()
522 },
523 )
524 .unwrap();
525 let ids: Vec<&str> = results.iter().map(|n| n.id.as_str()).collect();
526 assert_eq!(ids, vec!["n1"]);
527 }
528
529 #[test]
530 fn query_ex_respects_limit_cap() {
531 let conn = mem_db();
532 upsert_node(
533 &conn,
534 &test_node_dated("n1", "2026-01-01T00:00:00Z", vec![]),
535 )
536 .unwrap();
537 let results = query_nodes_ex(
539 &conn,
540 &NodeQuery {
541 limit: 10_000,
542 ..Default::default()
543 },
544 )
545 .unwrap();
546 assert!(results.len() <= 200);
547 assert_eq!(results.len(), 1); }
549}