Keyword search across node names. Tries FTS5 first (fast, tokenized matching), then falls back to LIKE-based substring search when FTS5 returns no results.
(self, query: str, limit: int = 20)
| 563 | return [r["file_path"] for r in rows] |
| 564 | |
| 565 | def search_nodes(self, query: str, limit: int = 20) -> list[GraphNode]: |
| 566 | """Keyword search across node names. |
| 567 | |
| 568 | Tries FTS5 first (fast, tokenized matching), then falls back to |
| 569 | LIKE-based substring search when FTS5 returns no results. |
| 570 | """ |
| 571 | words = query.split() |
| 572 | if not words: |
| 573 | return [] |
| 574 | |
| 575 | # Phase 1: FTS5 search (uses the indexed nodes_fts table) |
| 576 | try: |
| 577 | if len(words) == 1: |
| 578 | fts_query = '"' + query.replace('"', '""') + '"' |
| 579 | else: |
| 580 | fts_query = " AND ".join( |
| 581 | '"' + w.replace('"', '""') + '"' for w in words |
| 582 | ) |
| 583 | rows = self._conn.execute( |
| 584 | "SELECT n.* FROM nodes_fts f " |
| 585 | "JOIN nodes n ON f.rowid = n.id " |
| 586 | "WHERE nodes_fts MATCH ? LIMIT ?", |
| 587 | (fts_query, limit), |
| 588 | ).fetchall() |
| 589 | if rows: |
| 590 | return [self._row_to_node(r) for r in rows] |
| 591 | except Exception: # nosec B110 - FTS5 table may not exist on older schemas |
| 592 | pass |
| 593 | |
| 594 | # Phase 2: LIKE fallback (substring matching) |
| 595 | conditions: list[str] = [] |
| 596 | params: list[str | int] = [] |
| 597 | for word in words: |
| 598 | w = word.lower() |
| 599 | conditions.append( |
| 600 | "(LOWER(name) LIKE ? OR LOWER(qualified_name) LIKE ?)" |
| 601 | ) |
| 602 | params.extend([f"%{w}%", f"%{w}%"]) |
| 603 | |
| 604 | where = " AND ".join(conditions) |
| 605 | sql = f"SELECT * FROM nodes WHERE {where} LIMIT ?" # nosec B608 |
| 606 | params.append(limit) |
| 607 | rows = self._conn.execute(sql, params).fetchall() |
| 608 | return [self._row_to_node(r) for r in rows] |
| 609 | |
| 610 | # --- Impact / Graph traversal --- |
| 611 |