codegraph-py 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codegraph/__init__.py +12 -0
- codegraph/__main__.py +6 -0
- codegraph/cli.py +911 -0
- codegraph/codegraph.py +618 -0
- codegraph/context/__init__.py +216 -0
- codegraph/db/__init__.py +11 -0
- codegraph/db/connection.py +163 -0
- codegraph/db/queries.py +752 -0
- codegraph/db/schema.sql +151 -0
- codegraph/directory.py +160 -0
- codegraph/errors.py +101 -0
- codegraph/extraction/__init__.py +956 -0
- codegraph/extraction/languages/__init__.py +64 -0
- codegraph/extraction/languages/base.py +132 -0
- codegraph/extraction/languages/c_cfg.py +53 -0
- codegraph/extraction/languages/cpp_cfg.py +60 -0
- codegraph/extraction/languages/dart_cfg.py +53 -0
- codegraph/extraction/languages/go_cfg.py +70 -0
- codegraph/extraction/languages/java_cfg.py +62 -0
- codegraph/extraction/languages/javascript_cfg.py +84 -0
- codegraph/extraction/languages/kotlin_cfg.py +52 -0
- codegraph/extraction/languages/lua_cfg.py +65 -0
- codegraph/extraction/languages/python_cfg.py +75 -0
- codegraph/extraction/languages/ruby_cfg.py +48 -0
- codegraph/extraction/languages/rust_cfg.py +68 -0
- codegraph/extraction/languages/scala_cfg.py +59 -0
- codegraph/extraction/languages/swift_cfg.py +64 -0
- codegraph/extraction/languages/typescript_cfg.py +89 -0
- codegraph/extraction/tree_sitter_extractor.py +689 -0
- codegraph/graph/__init__.py +685 -0
- codegraph/installer/__init__.py +6 -0
- codegraph/mcp/__init__.py +668 -0
- codegraph/project_config.py +191 -0
- codegraph/resolution/__init__.py +337 -0
- codegraph/search/__init__.py +653 -0
- codegraph/sync/__init__.py +204 -0
- codegraph/types.py +334 -0
- codegraph/ui/__init__.py +11 -0
- codegraph/utils.py +218 -0
- codegraph_py-1.0.0.dist-info/METADATA +238 -0
- codegraph_py-1.0.0.dist-info/RECORD +44 -0
- codegraph_py-1.0.0.dist-info/WHEEL +5 -0
- codegraph_py-1.0.0.dist-info/entry_points.txt +2 -0
- codegraph_py-1.0.0.dist-info/top_level.txt +1 -0
codegraph/db/queries.py
ADDED
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CodeGraph Query Builder
|
|
3
|
+
|
|
4
|
+
All database CRUD operations and query methods.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import dataclasses
|
|
10
|
+
import json
|
|
11
|
+
import sqlite3
|
|
12
|
+
import time
|
|
13
|
+
from typing import Any, Dict, List, Optional, Set, Tuple
|
|
14
|
+
from collections import OrderedDict
|
|
15
|
+
|
|
16
|
+
from codegraph.types import (
|
|
17
|
+
Node, Edge, FileRecord, UnresolvedReference,
|
|
18
|
+
SearchResult, SearchOptions, GraphStats,
|
|
19
|
+
)
|
|
20
|
+
from codegraph.errors import DatabaseError
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class QueryBuilder:
|
|
24
|
+
"""Build and execute database queries."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, db: sqlite3.Connection, db_path: str = ''):
|
|
27
|
+
self._db = db
|
|
28
|
+
self._db_path_value = db_path
|
|
29
|
+
self._node_cache: Dict[str, Node] = OrderedDict()
|
|
30
|
+
self._cache_max = 1000
|
|
31
|
+
self._project_name_tokens: List[str] = []
|
|
32
|
+
|
|
33
|
+
def set_project_name_tokens(self, tokens: List[str]) -> None:
|
|
34
|
+
"""Set project name tokens for search ranking."""
|
|
35
|
+
self._project_name_tokens = tokens
|
|
36
|
+
|
|
37
|
+
# =========================================================================
|
|
38
|
+
# Node Operations
|
|
39
|
+
# =========================================================================
|
|
40
|
+
|
|
41
|
+
def insert_node(self, node: Node) -> None:
|
|
42
|
+
"""Insert or replace a node."""
|
|
43
|
+
self._db.execute(
|
|
44
|
+
'''INSERT OR REPLACE INTO nodes
|
|
45
|
+
(id, kind, name, qualified_name, file_path, language,
|
|
46
|
+
start_line, end_line, start_column, end_column,
|
|
47
|
+
docstring, signature, visibility,
|
|
48
|
+
is_exported, is_async, is_static, is_abstract,
|
|
49
|
+
decorators, type_parameters, return_type, updated_at)
|
|
50
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)''',
|
|
51
|
+
(
|
|
52
|
+
node.id, node.kind, node.name, node.qualified_name,
|
|
53
|
+
node.file_path, node.language,
|
|
54
|
+
node.start_line, node.end_line,
|
|
55
|
+
node.start_column, node.end_column,
|
|
56
|
+
node.docstring, node.signature, node.visibility,
|
|
57
|
+
1 if node.is_exported else 0,
|
|
58
|
+
1 if node.is_async else 0,
|
|
59
|
+
1 if node.is_static else 0,
|
|
60
|
+
1 if node.is_abstract else 0,
|
|
61
|
+
json.dumps(node.decorators) if node.decorators else None,
|
|
62
|
+
json.dumps(node.type_parameters) if node.type_parameters else None,
|
|
63
|
+
node.return_type,
|
|
64
|
+
node.updated_at or int(time.time() * 1000),
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def insert_nodes(self, nodes: List[Node]) -> None:
|
|
69
|
+
"""Insert multiple nodes in a transaction."""
|
|
70
|
+
with self._db:
|
|
71
|
+
for node in nodes:
|
|
72
|
+
self.insert_node(node)
|
|
73
|
+
|
|
74
|
+
def update_node(self, node: Node) -> None:
|
|
75
|
+
"""Update an existing node."""
|
|
76
|
+
self._db.execute(
|
|
77
|
+
'''UPDATE nodes SET
|
|
78
|
+
kind=?, name=?, qualified_name=?, file_path=?, language=?,
|
|
79
|
+
start_line=?, end_line=?, start_column=?, end_column=?,
|
|
80
|
+
docstring=?, signature=?, visibility=?,
|
|
81
|
+
is_exported=?, is_async=?, is_static=?, is_abstract=?,
|
|
82
|
+
decorators=?, type_parameters=?, return_type=?, updated_at=?
|
|
83
|
+
WHERE id=?''',
|
|
84
|
+
(
|
|
85
|
+
node.kind, node.name, node.qualified_name,
|
|
86
|
+
node.file_path, node.language,
|
|
87
|
+
node.start_line, node.end_line,
|
|
88
|
+
node.start_column, node.end_column,
|
|
89
|
+
node.docstring, node.signature, node.visibility,
|
|
90
|
+
1 if node.is_exported else 0,
|
|
91
|
+
1 if node.is_async else 0,
|
|
92
|
+
1 if node.is_static else 0,
|
|
93
|
+
1 if node.is_abstract else 0,
|
|
94
|
+
json.dumps(node.decorators) if node.decorators else None,
|
|
95
|
+
json.dumps(node.type_parameters) if node.type_parameters else None,
|
|
96
|
+
node.return_type,
|
|
97
|
+
node.updated_at or int(time.time() * 1000),
|
|
98
|
+
node.id,
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def delete_node(self, node_id: str) -> None:
|
|
103
|
+
"""Delete a node by ID."""
|
|
104
|
+
self._db.execute('DELETE FROM nodes WHERE id = ?', (node_id,))
|
|
105
|
+
self._node_cache.pop(node_id, None)
|
|
106
|
+
|
|
107
|
+
def delete_nodes_by_file(self, file_path: str) -> None:
|
|
108
|
+
"""Delete all nodes in a file."""
|
|
109
|
+
self._db.execute('DELETE FROM nodes WHERE file_path = ?', (file_path,))
|
|
110
|
+
|
|
111
|
+
def get_node_by_id(self, node_id: str) -> Optional[Node]:
|
|
112
|
+
"""Get a node by its ID (with LRU cache)."""
|
|
113
|
+
# Check cache
|
|
114
|
+
if node_id in self._node_cache:
|
|
115
|
+
node = self._node_cache.pop(node_id)
|
|
116
|
+
self._node_cache[node_id] = node
|
|
117
|
+
return node
|
|
118
|
+
|
|
119
|
+
cur = self._db.execute('SELECT * FROM nodes WHERE id = ?', (node_id,))
|
|
120
|
+
row = cur.fetchone()
|
|
121
|
+
if not row:
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
node = self._row_to_node(row)
|
|
125
|
+
|
|
126
|
+
# Update cache
|
|
127
|
+
self._node_cache[node_id] = node
|
|
128
|
+
if len(self._node_cache) > self._cache_max:
|
|
129
|
+
self._node_cache.pop(next(iter(self._node_cache)), None)
|
|
130
|
+
|
|
131
|
+
return node
|
|
132
|
+
|
|
133
|
+
def get_nodes_by_ids(self, node_ids: List[str]) -> List[Node]:
|
|
134
|
+
"""Get multiple nodes by their IDs."""
|
|
135
|
+
if not node_ids:
|
|
136
|
+
return []
|
|
137
|
+
|
|
138
|
+
# Check cache first
|
|
139
|
+
result: List[Node] = []
|
|
140
|
+
uncached: List[str] = []
|
|
141
|
+
for nid in node_ids:
|
|
142
|
+
if nid in self._node_cache:
|
|
143
|
+
result.append(self._node_cache[nid])
|
|
144
|
+
else:
|
|
145
|
+
uncached.append(nid)
|
|
146
|
+
|
|
147
|
+
if uncached:
|
|
148
|
+
placeholders = ','.join('?' * len(uncached))
|
|
149
|
+
cur = self._db.execute(
|
|
150
|
+
f'SELECT * FROM nodes WHERE id IN ({placeholders})', uncached
|
|
151
|
+
)
|
|
152
|
+
for row in cur.fetchall():
|
|
153
|
+
node = self._row_to_node(row)
|
|
154
|
+
result.append(node)
|
|
155
|
+
self._node_cache[node.id] = node
|
|
156
|
+
|
|
157
|
+
return result
|
|
158
|
+
|
|
159
|
+
def get_nodes_by_file(self, file_path: str) -> List[Node]:
|
|
160
|
+
"""Get all nodes in a file, ordered by start line."""
|
|
161
|
+
cur = self._db.execute(
|
|
162
|
+
'SELECT * FROM nodes WHERE file_path = ? ORDER BY start_line',
|
|
163
|
+
(file_path,)
|
|
164
|
+
)
|
|
165
|
+
return [self._row_to_node(row) for row in cur.fetchall()]
|
|
166
|
+
|
|
167
|
+
def get_nodes_by_kind(self, kind: str) -> List[Node]:
|
|
168
|
+
"""Get all nodes of a specific kind."""
|
|
169
|
+
cur = self._db.execute(
|
|
170
|
+
'SELECT * FROM nodes WHERE kind = ? ORDER BY name', (kind,)
|
|
171
|
+
)
|
|
172
|
+
return [self._row_to_node(row) for row in cur.fetchall()]
|
|
173
|
+
|
|
174
|
+
def get_nodes_by_name(self, name: str) -> List[Node]:
|
|
175
|
+
"""Get nodes by exact name match."""
|
|
176
|
+
cur = self._db.execute(
|
|
177
|
+
'SELECT * FROM nodes WHERE name = ? ORDER BY file_path, start_line',
|
|
178
|
+
(name,)
|
|
179
|
+
)
|
|
180
|
+
return [self._row_to_node(row) for row in cur.fetchall()]
|
|
181
|
+
|
|
182
|
+
def get_nodes_by_name_prefix(self, prefix: str) -> List[Node]:
|
|
183
|
+
"""Get nodes by name prefix (range scan)."""
|
|
184
|
+
end = prefix + '\uffff'
|
|
185
|
+
cur = self._db.execute(
|
|
186
|
+
'SELECT * FROM nodes WHERE name >= ? AND name < ? ORDER BY name',
|
|
187
|
+
(prefix, end)
|
|
188
|
+
)
|
|
189
|
+
return [self._row_to_node(row) for row in cur.fetchall()]
|
|
190
|
+
|
|
191
|
+
def get_nodes_by_qualified_name(self, qname: str) -> List[Node]:
|
|
192
|
+
"""Get nodes by exact qualified name."""
|
|
193
|
+
cur = self._db.execute(
|
|
194
|
+
'SELECT * FROM nodes WHERE qualified_name = ?', (qname,)
|
|
195
|
+
)
|
|
196
|
+
return [self._row_to_node(row) for row in cur.fetchall()]
|
|
197
|
+
|
|
198
|
+
# =========================================================================
|
|
199
|
+
# Edge Operations
|
|
200
|
+
# =========================================================================
|
|
201
|
+
|
|
202
|
+
def insert_edge(self, edge: Edge) -> None:
|
|
203
|
+
"""Insert an edge (with conflict ignore and FK error handling)."""
|
|
204
|
+
try:
|
|
205
|
+
self._db.execute(
|
|
206
|
+
'''INSERT OR IGNORE INTO edges
|
|
207
|
+
(source, target, kind, metadata, line, col, provenance)
|
|
208
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)''',
|
|
209
|
+
(
|
|
210
|
+
edge.source, edge.target, edge.kind,
|
|
211
|
+
json.dumps(edge.metadata) if edge.metadata else None,
|
|
212
|
+
edge.line, edge.column, edge.provenance,
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
except Exception:
|
|
216
|
+
# Gracefully handle FK violations (e.g., import edges to external modules)
|
|
217
|
+
pass
|
|
218
|
+
|
|
219
|
+
def insert_edges(self, edges: List[Edge]) -> None:
|
|
220
|
+
"""Insert multiple edges in a transaction."""
|
|
221
|
+
with self._db:
|
|
222
|
+
for edge in edges:
|
|
223
|
+
self.insert_edge(edge)
|
|
224
|
+
|
|
225
|
+
def delete_edges_for_file(self, file_path: str) -> None:
|
|
226
|
+
"""Delete all edges referencing nodes in a file."""
|
|
227
|
+
self._db.execute(
|
|
228
|
+
'''DELETE FROM edges WHERE source IN
|
|
229
|
+
(SELECT id FROM nodes WHERE file_path = ?)
|
|
230
|
+
OR target IN (SELECT id FROM nodes WHERE file_path = ?)''',
|
|
231
|
+
(file_path, file_path)
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
def get_edges_by_source(self, source_id: str) -> List[Edge]:
|
|
235
|
+
"""Get all edges from a source node."""
|
|
236
|
+
cur = self._db.execute(
|
|
237
|
+
'SELECT * FROM edges WHERE source = ?', (source_id,)
|
|
238
|
+
)
|
|
239
|
+
return [self._row_to_edge(row) for row in cur.fetchall()]
|
|
240
|
+
|
|
241
|
+
def get_edges_by_target(self, target_id: str) -> List[Edge]:
|
|
242
|
+
"""Get all edges targeting a node."""
|
|
243
|
+
cur = self._db.execute(
|
|
244
|
+
'SELECT * FROM edges WHERE target = ?', (target_id,)
|
|
245
|
+
)
|
|
246
|
+
return [self._row_to_edge(row) for row in cur.fetchall()]
|
|
247
|
+
|
|
248
|
+
def get_edges(self, source: str, target: str, kind: str) -> List[Edge]:
|
|
249
|
+
"""Get edges matching source, target, and kind."""
|
|
250
|
+
cur = self._db.execute(
|
|
251
|
+
'SELECT * FROM edges WHERE source = ? AND target = ? AND kind = ?',
|
|
252
|
+
(source, target, kind)
|
|
253
|
+
)
|
|
254
|
+
return [self._row_to_edge(row) for row in cur.fetchall()]
|
|
255
|
+
|
|
256
|
+
def get_outgoing_edges(self, source_id: str,
|
|
257
|
+
kinds: Optional[List[str]] = None) -> List[Edge]:
|
|
258
|
+
"""Get outgoing edges, optionally filtered by kind."""
|
|
259
|
+
if kinds:
|
|
260
|
+
placeholders = ','.join('?' * len(kinds))
|
|
261
|
+
cur = self._db.execute(
|
|
262
|
+
f'SELECT * FROM edges WHERE source = ? AND kind IN ({placeholders})',
|
|
263
|
+
[source_id] + kinds
|
|
264
|
+
)
|
|
265
|
+
else:
|
|
266
|
+
cur = self._db.execute(
|
|
267
|
+
'SELECT * FROM edges WHERE source = ?', (source_id,)
|
|
268
|
+
)
|
|
269
|
+
return [self._row_to_edge(row) for row in cur.fetchall()]
|
|
270
|
+
|
|
271
|
+
def get_incoming_edges(self, target_id: str,
|
|
272
|
+
kinds: Optional[List[str]] = None) -> List[Edge]:
|
|
273
|
+
"""Get incoming edges, optionally filtered by kind."""
|
|
274
|
+
if kinds:
|
|
275
|
+
placeholders = ','.join('?' * len(kinds))
|
|
276
|
+
cur = self._db.execute(
|
|
277
|
+
f'SELECT * FROM edges WHERE target = ? AND kind IN ({placeholders})',
|
|
278
|
+
[target_id] + kinds
|
|
279
|
+
)
|
|
280
|
+
else:
|
|
281
|
+
cur = self._db.execute(
|
|
282
|
+
'SELECT * FROM edges WHERE target = ?', (target_id,)
|
|
283
|
+
)
|
|
284
|
+
return [self._row_to_edge(row) for row in cur.fetchall()]
|
|
285
|
+
|
|
286
|
+
def get_outgoing_edges_batch(self, source_ids: List[str],
|
|
287
|
+
kinds: Optional[List[str]] = None) -> Dict[str, List[Edge]]:
|
|
288
|
+
"""Get outgoing edges for multiple source nodes (batched)."""
|
|
289
|
+
if not source_ids:
|
|
290
|
+
return {}
|
|
291
|
+
|
|
292
|
+
result: Dict[str, List[Edge]] = {sid: [] for sid in source_ids}
|
|
293
|
+
placeholders = ','.join('?' * len(source_ids))
|
|
294
|
+
|
|
295
|
+
if kinds:
|
|
296
|
+
kind_placeholders = ','.join('?' * len(kinds))
|
|
297
|
+
cur = self._db.execute(
|
|
298
|
+
f'SELECT * FROM edges WHERE source IN ({placeholders}) AND kind IN ({kind_placeholders})',
|
|
299
|
+
source_ids + kinds
|
|
300
|
+
)
|
|
301
|
+
else:
|
|
302
|
+
cur = self._db.execute(
|
|
303
|
+
f'SELECT * FROM edges WHERE source IN ({placeholders})', source_ids
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
for row in cur.fetchall():
|
|
307
|
+
edge = self._row_to_edge(row)
|
|
308
|
+
if edge.source in result:
|
|
309
|
+
result[edge.source].append(edge)
|
|
310
|
+
return result
|
|
311
|
+
|
|
312
|
+
def get_incoming_edges_batch(self, target_ids: List[str],
|
|
313
|
+
kinds: Optional[List[str]] = None) -> Dict[str, List[Edge]]:
|
|
314
|
+
"""Get incoming edges for multiple target nodes (batched)."""
|
|
315
|
+
if not target_ids:
|
|
316
|
+
return {}
|
|
317
|
+
|
|
318
|
+
result: Dict[str, List[Edge]] = {tid: [] for tid in target_ids}
|
|
319
|
+
placeholders = ','.join('?' * len(target_ids))
|
|
320
|
+
|
|
321
|
+
if kinds:
|
|
322
|
+
kind_placeholders = ','.join('?' * len(kinds))
|
|
323
|
+
cur = self._db.execute(
|
|
324
|
+
f'SELECT * FROM edges WHERE target IN ({placeholders}) AND kind IN ({kind_placeholders})',
|
|
325
|
+
target_ids + kinds
|
|
326
|
+
)
|
|
327
|
+
else:
|
|
328
|
+
cur = self._db.execute(
|
|
329
|
+
f'SELECT * FROM edges WHERE target IN ({placeholders})', target_ids
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
for row in cur.fetchall():
|
|
333
|
+
edge = self._row_to_edge(row)
|
|
334
|
+
if edge.target in result:
|
|
335
|
+
result[edge.target].append(edge)
|
|
336
|
+
return result
|
|
337
|
+
|
|
338
|
+
# =========================================================================
|
|
339
|
+
# File Operations
|
|
340
|
+
# =========================================================================
|
|
341
|
+
|
|
342
|
+
def upsert_file(self, record: FileRecord) -> None:
|
|
343
|
+
"""Insert or update a file record."""
|
|
344
|
+
errors_json = None
|
|
345
|
+
if record.errors:
|
|
346
|
+
errors_json = json.dumps(
|
|
347
|
+
[dataclasses.asdict(e) for e in record.errors]
|
|
348
|
+
)
|
|
349
|
+
self._db.execute(
|
|
350
|
+
'''INSERT OR REPLACE INTO files
|
|
351
|
+
(path, content_hash, language, size, modified_at, indexed_at, node_count, errors)
|
|
352
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)''',
|
|
353
|
+
(
|
|
354
|
+
record.path, record.content_hash, record.language,
|
|
355
|
+
record.size, record.modified_at, record.indexed_at,
|
|
356
|
+
record.node_count,
|
|
357
|
+
errors_json,
|
|
358
|
+
)
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
def delete_file(self, file_path: str) -> None:
|
|
362
|
+
"""Delete a file record."""
|
|
363
|
+
self._db.execute('DELETE FROM files WHERE path = ?', (file_path,))
|
|
364
|
+
|
|
365
|
+
def get_file(self, file_path: str) -> Optional[FileRecord]:
|
|
366
|
+
"""Get a file record by path."""
|
|
367
|
+
cur = self._db.execute('SELECT * FROM files WHERE path = ?', (file_path,))
|
|
368
|
+
row = cur.fetchone()
|
|
369
|
+
if not row:
|
|
370
|
+
return None
|
|
371
|
+
return FileRecord(
|
|
372
|
+
path=row['path'],
|
|
373
|
+
content_hash=row['content_hash'],
|
|
374
|
+
language=row['language'],
|
|
375
|
+
size=row['size'],
|
|
376
|
+
modified_at=row['modified_at'],
|
|
377
|
+
indexed_at=row['indexed_at'],
|
|
378
|
+
node_count=row['node_count'],
|
|
379
|
+
errors=json.loads(row['errors']) if row['errors'] else None,
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
def get_all_files(self) -> List[FileRecord]:
|
|
383
|
+
"""Get all file records."""
|
|
384
|
+
cur = self._db.execute('SELECT * FROM files')
|
|
385
|
+
return [
|
|
386
|
+
FileRecord(
|
|
387
|
+
path=row['path'],
|
|
388
|
+
content_hash=row['content_hash'],
|
|
389
|
+
language=row['language'],
|
|
390
|
+
size=row['size'],
|
|
391
|
+
modified_at=row['modified_at'],
|
|
392
|
+
indexed_at=row['indexed_at'],
|
|
393
|
+
node_count=row['node_count'],
|
|
394
|
+
errors=json.loads(row['errors']) if row['errors'] else None,
|
|
395
|
+
)
|
|
396
|
+
for row in cur.fetchall()
|
|
397
|
+
]
|
|
398
|
+
|
|
399
|
+
def get_all_file_paths(self) -> List[str]:
|
|
400
|
+
"""Get all tracked file paths."""
|
|
401
|
+
cur = self._db.execute('SELECT path FROM files')
|
|
402
|
+
return [row['path'] for row in cur.fetchall()]
|
|
403
|
+
|
|
404
|
+
# =========================================================================
|
|
405
|
+
# Unresolved Reference Operations
|
|
406
|
+
# =========================================================================
|
|
407
|
+
|
|
408
|
+
def insert_unresolved_ref(self, ref: UnresolvedReference) -> None:
|
|
409
|
+
"""Insert an unresolved reference."""
|
|
410
|
+
self._db.execute(
|
|
411
|
+
'''INSERT INTO unresolved_refs
|
|
412
|
+
(from_node_id, reference_name, reference_kind, line, col,
|
|
413
|
+
candidates, file_path, language)
|
|
414
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)''',
|
|
415
|
+
(
|
|
416
|
+
ref.from_node_id, ref.reference_name, ref.reference_kind,
|
|
417
|
+
ref.line, ref.column,
|
|
418
|
+
json.dumps(ref.candidates) if ref.candidates else None,
|
|
419
|
+
ref.file_path or '', ref.language or 'unknown',
|
|
420
|
+
)
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
def delete_unresolved_refs_for_file(self, file_path: str) -> None:
|
|
424
|
+
"""Delete unresolved refs for a file."""
|
|
425
|
+
self._db.execute(
|
|
426
|
+
'DELETE FROM unresolved_refs WHERE file_path = ?', (file_path,)
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
def get_unresolved_refs(self) -> List[UnresolvedReference]:
|
|
430
|
+
"""Get all unresolved references."""
|
|
431
|
+
cur = self._db.execute('SELECT * FROM unresolved_refs')
|
|
432
|
+
return [self._row_to_unresolved(row) for row in cur.fetchall()]
|
|
433
|
+
|
|
434
|
+
def get_unresolved_refs_count(self) -> int:
|
|
435
|
+
"""Get count of unresolved references."""
|
|
436
|
+
cur = self._db.execute('SELECT COUNT(*) FROM unresolved_refs')
|
|
437
|
+
return cur.fetchone()[0]
|
|
438
|
+
|
|
439
|
+
def get_unresolved_refs_by_name(self, name: str) -> List[UnresolvedReference]:
|
|
440
|
+
"""Get unresolved references by reference name."""
|
|
441
|
+
cur = self._db.execute(
|
|
442
|
+
'SELECT * FROM unresolved_refs WHERE reference_name = ?', (name,)
|
|
443
|
+
)
|
|
444
|
+
return [self._row_to_unresolved(row) for row in cur.fetchall()]
|
|
445
|
+
|
|
446
|
+
def clear_unresolved_refs(self) -> None:
|
|
447
|
+
"""Clear all unresolved references."""
|
|
448
|
+
self._db.execute('DELETE FROM unresolved_refs')
|
|
449
|
+
|
|
450
|
+
# =========================================================================
|
|
451
|
+
# Search (FTS5)
|
|
452
|
+
# =========================================================================
|
|
453
|
+
|
|
454
|
+
def search_nodes(self, query: str,
|
|
455
|
+
options: Optional[SearchOptions] = None) -> List[SearchResult]:
|
|
456
|
+
"""Search nodes using FTS5 full-text search."""
|
|
457
|
+
opts = options or SearchOptions()
|
|
458
|
+
results: List[SearchResult] = []
|
|
459
|
+
|
|
460
|
+
try:
|
|
461
|
+
# FTS5 query with prefix matching
|
|
462
|
+
fts_query = ' OR '.join(
|
|
463
|
+
f'"{word}"*' if len(word) > 1 else word
|
|
464
|
+
for word in query.split()
|
|
465
|
+
if word.strip()
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
if fts_query:
|
|
469
|
+
cur = self._db.execute(
|
|
470
|
+
'''SELECT n.*, rank FROM nodes_fts
|
|
471
|
+
JOIN nodes n ON nodes_fts.id = n.id
|
|
472
|
+
WHERE nodes_fts MATCH ?
|
|
473
|
+
ORDER BY rank
|
|
474
|
+
LIMIT ? OFFSET ?''',
|
|
475
|
+
(fts_query, opts.limit, opts.offset)
|
|
476
|
+
)
|
|
477
|
+
for row in cur.fetchall():
|
|
478
|
+
node = self._row_to_node(row)
|
|
479
|
+
if self._matches_filters(node, opts):
|
|
480
|
+
results.append(SearchResult(
|
|
481
|
+
node=node,
|
|
482
|
+
score=1.0 - float(row['rank']) / 100.0 if row['rank'] else 0.0,
|
|
483
|
+
))
|
|
484
|
+
except Exception:
|
|
485
|
+
pass
|
|
486
|
+
|
|
487
|
+
# Fallback: LIKE search if FTS returns nothing
|
|
488
|
+
if not results:
|
|
489
|
+
for word in query.split():
|
|
490
|
+
if not word.strip():
|
|
491
|
+
continue
|
|
492
|
+
like_pattern = f'%{word}%'
|
|
493
|
+
cur = self._db.execute(
|
|
494
|
+
'''SELECT * FROM nodes WHERE
|
|
495
|
+
name LIKE ? OR qualified_name LIKE ?
|
|
496
|
+
ORDER BY
|
|
497
|
+
CASE WHEN name = ? THEN 0
|
|
498
|
+
WHEN name LIKE ? THEN 1
|
|
499
|
+
ELSE 2 END
|
|
500
|
+
LIMIT ?''',
|
|
501
|
+
(like_pattern, like_pattern, word, f'{word}%', opts.limit)
|
|
502
|
+
)
|
|
503
|
+
for row in cur.fetchall():
|
|
504
|
+
node = self._row_to_node(row)
|
|
505
|
+
if self._matches_filters(node, opts):
|
|
506
|
+
results.append(SearchResult(node=node, score=0.5))
|
|
507
|
+
|
|
508
|
+
return results
|
|
509
|
+
|
|
510
|
+
def _matches_filters(self, node: Node, opts: SearchOptions) -> bool:
|
|
511
|
+
"""Check if a node matches search filters."""
|
|
512
|
+
if opts.kinds and node.kind not in opts.kinds:
|
|
513
|
+
return False
|
|
514
|
+
if opts.languages and node.language not in opts.languages:
|
|
515
|
+
return False
|
|
516
|
+
if opts.include_patterns:
|
|
517
|
+
import fnmatch
|
|
518
|
+
if not any(fnmatch.fnmatch(node.file_path, p) for p in opts.include_patterns):
|
|
519
|
+
return False
|
|
520
|
+
if opts.exclude_patterns:
|
|
521
|
+
import fnmatch
|
|
522
|
+
if any(fnmatch.fnmatch(node.file_path, p) for p in opts.exclude_patterns):
|
|
523
|
+
return False
|
|
524
|
+
return True
|
|
525
|
+
|
|
526
|
+
# =========================================================================
|
|
527
|
+
# Name Segment Vocabulary
|
|
528
|
+
# =========================================================================
|
|
529
|
+
|
|
530
|
+
def insert_name_segment(self, segment: str, name: str) -> None:
|
|
531
|
+
"""Insert a name segment mapping."""
|
|
532
|
+
try:
|
|
533
|
+
self._db.execute(
|
|
534
|
+
'INSERT OR IGNORE INTO name_segment_vocab (segment, name) VALUES (?, ?)',
|
|
535
|
+
(segment, name)
|
|
536
|
+
)
|
|
537
|
+
except Exception:
|
|
538
|
+
pass
|
|
539
|
+
|
|
540
|
+
def clear_name_segment_vocab(self) -> None:
|
|
541
|
+
"""Clear all name segment entries."""
|
|
542
|
+
try:
|
|
543
|
+
self._db.execute('DELETE FROM name_segment_vocab')
|
|
544
|
+
except Exception:
|
|
545
|
+
pass
|
|
546
|
+
|
|
547
|
+
def get_names_by_segment(self, segment: str) -> List[str]:
|
|
548
|
+
"""Get symbol names matching a prose segment."""
|
|
549
|
+
cur = self._db.execute(
|
|
550
|
+
'SELECT name FROM name_segment_vocab WHERE segment = ?', (segment,)
|
|
551
|
+
)
|
|
552
|
+
return [row['name'] for row in cur.fetchall()]
|
|
553
|
+
|
|
554
|
+
# =========================================================================
|
|
555
|
+
# Statistics
|
|
556
|
+
# =========================================================================
|
|
557
|
+
|
|
558
|
+
def get_stats(self) -> GraphStats:
|
|
559
|
+
"""Get graph statistics."""
|
|
560
|
+
stats = GraphStats()
|
|
561
|
+
|
|
562
|
+
try:
|
|
563
|
+
cur = self._db.execute('SELECT COUNT(*) FROM nodes')
|
|
564
|
+
stats.node_count = cur.fetchone()[0]
|
|
565
|
+
except Exception:
|
|
566
|
+
pass
|
|
567
|
+
|
|
568
|
+
try:
|
|
569
|
+
cur = self._db.execute('SELECT COUNT(*) FROM edges')
|
|
570
|
+
stats.edge_count = cur.fetchone()[0]
|
|
571
|
+
except Exception:
|
|
572
|
+
pass
|
|
573
|
+
|
|
574
|
+
try:
|
|
575
|
+
cur = self._db.execute('SELECT COUNT(*) FROM files')
|
|
576
|
+
stats.file_count = cur.fetchone()[0]
|
|
577
|
+
except Exception:
|
|
578
|
+
pass
|
|
579
|
+
|
|
580
|
+
try:
|
|
581
|
+
cur = self._db.execute(
|
|
582
|
+
'SELECT kind, COUNT(*) as cnt FROM nodes GROUP BY kind'
|
|
583
|
+
)
|
|
584
|
+
for row in cur.fetchall():
|
|
585
|
+
stats.nodes_by_kind[row['kind']] = row['cnt']
|
|
586
|
+
except Exception:
|
|
587
|
+
pass
|
|
588
|
+
|
|
589
|
+
try:
|
|
590
|
+
cur = self._db.execute(
|
|
591
|
+
'SELECT kind, COUNT(*) as cnt FROM edges GROUP BY kind'
|
|
592
|
+
)
|
|
593
|
+
for row in cur.fetchall():
|
|
594
|
+
stats.edges_by_kind[row['kind']] = row['cnt']
|
|
595
|
+
except Exception:
|
|
596
|
+
pass
|
|
597
|
+
|
|
598
|
+
try:
|
|
599
|
+
cur = self._db.execute(
|
|
600
|
+
'SELECT language, COUNT(*) as cnt FROM files GROUP BY language'
|
|
601
|
+
)
|
|
602
|
+
for row in cur.fetchall():
|
|
603
|
+
stats.files_by_language[row['language']] = row['cnt']
|
|
604
|
+
except Exception:
|
|
605
|
+
pass
|
|
606
|
+
|
|
607
|
+
try:
|
|
608
|
+
stats.db_size_bytes = os.path.getsize(self._db_path)
|
|
609
|
+
except Exception:
|
|
610
|
+
pass
|
|
611
|
+
|
|
612
|
+
try:
|
|
613
|
+
stats.last_updated = int(time.time() * 1000)
|
|
614
|
+
except Exception:
|
|
615
|
+
pass
|
|
616
|
+
|
|
617
|
+
return stats
|
|
618
|
+
|
|
619
|
+
def get_node_and_edge_count(self) -> Tuple[int, int]:
|
|
620
|
+
"""Get total node and edge counts."""
|
|
621
|
+
try:
|
|
622
|
+
cur = self._db.execute('SELECT COUNT(*) FROM nodes')
|
|
623
|
+
nodes = cur.fetchone()[0]
|
|
624
|
+
except Exception:
|
|
625
|
+
nodes = 0
|
|
626
|
+
try:
|
|
627
|
+
cur = self._db.execute('SELECT COUNT(*) FROM edges')
|
|
628
|
+
edges = cur.fetchone()[0]
|
|
629
|
+
except Exception:
|
|
630
|
+
edges = 0
|
|
631
|
+
return (nodes, edges)
|
|
632
|
+
|
|
633
|
+
def get_changed_files(self, file_records: Dict[str, FileRecord]) -> Tuple[
|
|
634
|
+
List[str], List[str], List[str]]:
|
|
635
|
+
"""Compare files table with current records to find changes.
|
|
636
|
+
|
|
637
|
+
Returns (added, modified, removed) file paths.
|
|
638
|
+
"""
|
|
639
|
+
indexed_paths = set(self.get_all_file_paths())
|
|
640
|
+
current_paths = set(file_records.keys())
|
|
641
|
+
|
|
642
|
+
added = list(current_paths - indexed_paths)
|
|
643
|
+
removed = list(indexed_paths - current_paths)
|
|
644
|
+
|
|
645
|
+
modified = []
|
|
646
|
+
for path in current_paths & indexed_paths:
|
|
647
|
+
cur = self._db.execute(
|
|
648
|
+
'SELECT content_hash FROM files WHERE path = ?', (path,)
|
|
649
|
+
)
|
|
650
|
+
row = cur.fetchone()
|
|
651
|
+
if row and row['content_hash'] != file_records[path].content_hash:
|
|
652
|
+
modified.append(path)
|
|
653
|
+
|
|
654
|
+
return (added, modified, removed)
|
|
655
|
+
|
|
656
|
+
# =========================================================================
|
|
657
|
+
# Metadata
|
|
658
|
+
# =========================================================================
|
|
659
|
+
|
|
660
|
+
def set_metadata(self, key: str, value: str) -> None:
|
|
661
|
+
"""Set a metadata key-value pair."""
|
|
662
|
+
try:
|
|
663
|
+
self._db.execute(
|
|
664
|
+
'''INSERT OR REPLACE INTO project_metadata (key, value, updated_at)
|
|
665
|
+
VALUES (?, ?, ?)''',
|
|
666
|
+
(key, value, int(time.time() * 1000))
|
|
667
|
+
)
|
|
668
|
+
except Exception:
|
|
669
|
+
pass
|
|
670
|
+
|
|
671
|
+
def get_metadata(self, key: str) -> Optional[str]:
|
|
672
|
+
"""Get a metadata value."""
|
|
673
|
+
try:
|
|
674
|
+
cur = self._db.execute(
|
|
675
|
+
'SELECT value FROM project_metadata WHERE key = ?', (key,)
|
|
676
|
+
)
|
|
677
|
+
row = cur.fetchone()
|
|
678
|
+
return row['value'] if row else None
|
|
679
|
+
except Exception:
|
|
680
|
+
return None
|
|
681
|
+
|
|
682
|
+
# =========================================================================
|
|
683
|
+
# Helper methods
|
|
684
|
+
# =========================================================================
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
@property
|
|
688
|
+
def _db_path(self) -> str:
|
|
689
|
+
"""Get the database file path."""
|
|
690
|
+
if self._db_path_value:
|
|
691
|
+
return self._db_path_value
|
|
692
|
+
try:
|
|
693
|
+
cur = self._db.execute("PRAGMA database_list")
|
|
694
|
+
row = cur.fetchone()
|
|
695
|
+
if row and row[2]:
|
|
696
|
+
return row[2]
|
|
697
|
+
except Exception:
|
|
698
|
+
pass
|
|
699
|
+
return ''
|
|
700
|
+
|
|
701
|
+
def _row_to_node(self, row: sqlite3.Row) -> Node:
|
|
702
|
+
"""Convert a database row to a Node object."""
|
|
703
|
+
return Node(
|
|
704
|
+
id=row['id'],
|
|
705
|
+
kind=row['kind'],
|
|
706
|
+
name=row['name'],
|
|
707
|
+
qualified_name=row['qualified_name'],
|
|
708
|
+
file_path=row['file_path'],
|
|
709
|
+
language=row['language'],
|
|
710
|
+
start_line=row['start_line'],
|
|
711
|
+
end_line=row['end_line'],
|
|
712
|
+
start_column=row['start_column'],
|
|
713
|
+
end_column=row['end_column'],
|
|
714
|
+
docstring=row['docstring'],
|
|
715
|
+
signature=row['signature'],
|
|
716
|
+
visibility=row['visibility'],
|
|
717
|
+
is_exported=bool(row['is_exported']),
|
|
718
|
+
is_async=bool(row['is_async']),
|
|
719
|
+
is_static=bool(row['is_static']),
|
|
720
|
+
is_abstract=bool(row['is_abstract']),
|
|
721
|
+
decorators=json.loads(row['decorators']) if row['decorators'] else None,
|
|
722
|
+
type_parameters=json.loads(row['type_parameters']) if row['type_parameters'] else None,
|
|
723
|
+
return_type=row['return_type'],
|
|
724
|
+
updated_at=row['updated_at'],
|
|
725
|
+
)
|
|
726
|
+
|
|
727
|
+
def _row_to_edge(self, row: sqlite3.Row) -> Edge:
|
|
728
|
+
"""Convert a database row to an Edge object."""
|
|
729
|
+
return Edge(
|
|
730
|
+
id=row['id'],
|
|
731
|
+
source=row['source'],
|
|
732
|
+
target=row['target'],
|
|
733
|
+
kind=row['kind'],
|
|
734
|
+
metadata=json.loads(row['metadata']) if row['metadata'] else None,
|
|
735
|
+
line=row['line'],
|
|
736
|
+
column=row['col'],
|
|
737
|
+
provenance=row['provenance'],
|
|
738
|
+
)
|
|
739
|
+
|
|
740
|
+
def _row_to_unresolved(self, row: sqlite3.Row) -> UnresolvedReference:
|
|
741
|
+
"""Convert a database row to an UnresolvedReference object."""
|
|
742
|
+
return UnresolvedReference(
|
|
743
|
+
id=row['id'],
|
|
744
|
+
from_node_id=row['from_node_id'],
|
|
745
|
+
reference_name=row['reference_name'],
|
|
746
|
+
reference_kind=row['reference_kind'],
|
|
747
|
+
line=row['line'],
|
|
748
|
+
column=row['col'],
|
|
749
|
+
candidates=json.loads(row['candidates']) if row['candidates'] else None,
|
|
750
|
+
file_path=row['file_path'],
|
|
751
|
+
language=row['language'],
|
|
752
|
+
)
|