node-walk 0.1.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.
@@ -0,0 +1,368 @@
1
+ """
2
+ Query engine — all semantic graph navigation operations.
3
+
4
+ This is the single layer that knows about graph traversal. The CLI,
5
+ future browser API, and LLM skills all go through this module.
6
+
7
+ All traversal operations (walk, trace, blast_radius) run directly on
8
+ SQLite using recursive CTEs — no in-memory graph library needed.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import sqlite3
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+ from typing import Literal
17
+
18
+ from node_walk.ir.enums import Language, RelationshipType, ResolutionStatus, SymbolKind
19
+ from node_walk.ir.models import Relationship, Symbol, SourceLocation
20
+ from node_walk.storage.base import GraphStore
21
+
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Result types
25
+ # ---------------------------------------------------------------------------
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class SymbolMatch:
30
+ """A symbol returned by find_symbol(), possibly with a match score."""
31
+ symbol: Symbol
32
+ score: float = 1.0 # 1.0 = exact, < 1.0 = fuzzy
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class SourceRange:
37
+ """Exact source location returned by get_source()."""
38
+ file_path: str
39
+ start_line: int
40
+ end_line: int
41
+ lines: list[str] = field(default_factory=list)
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class WalkResult:
46
+ """A symbol reached during a bounded graph walk."""
47
+ symbol: Symbol
48
+ depth: int
49
+ via_relationship: RelationshipType | None = None
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Query engine
54
+ # ---------------------------------------------------------------------------
55
+
56
+
57
+ class QueryEngine:
58
+ """
59
+ Semantic graph navigation engine.
60
+
61
+ Instantiate with a GraphStore and call query methods directly.
62
+ All methods return typed result objects, never raw DB rows.
63
+ """
64
+
65
+ def __init__(self, store: GraphStore) -> None:
66
+ self._store = store
67
+
68
+ # ------------------------------------------------------------------
69
+ # Symbol lookup
70
+ # ------------------------------------------------------------------
71
+
72
+ def find_symbol(self, query: str, limit: int = 20) -> list[SymbolMatch]:
73
+ """
74
+ Find symbols whose name or qualified name matches *query*.
75
+
76
+ Matching priority:
77
+ 1. Exact qualified_name match
78
+ 2. Exact name match
79
+ 3. Case-insensitive suffix match (e.g. "createUser" matches "module.Class.createUser")
80
+ 4. Substring match on name
81
+ """
82
+ results: list[SymbolMatch] = []
83
+ seen: set[str] = set()
84
+
85
+ def add(sym: Symbol, score: float) -> None:
86
+ if sym.id not in seen:
87
+ seen.add(sym.id)
88
+ results.append(SymbolMatch(symbol=sym, score=score))
89
+
90
+ # 1. Exact qualified name
91
+ for s in self._store.find_symbols_by_qualified_name(query):
92
+ if s.qualified_name == query:
93
+ add(s, 1.0)
94
+
95
+ # 2. Exact name
96
+ for s in self._store.find_symbols_by_name(query, exact=True):
97
+ add(s, 0.95)
98
+
99
+ # 3. Case-insensitive suffix / substring
100
+ for s in self._store.find_symbols_by_name(query, exact=False):
101
+ score = 0.8 if s.qualified_name.lower().endswith(query.lower()) else 0.6
102
+ add(s, score)
103
+
104
+ results.sort(key=lambda m: -m.score)
105
+ return results[:limit]
106
+
107
+ def get_symbol_by_id(self, symbol_id: str) -> Symbol | None:
108
+ return self._store.get_symbol(symbol_id)
109
+
110
+ # ------------------------------------------------------------------
111
+ # Definition
112
+ # ------------------------------------------------------------------
113
+
114
+ def get_definition(self, symbol_id: str) -> Symbol | None:
115
+ """Return the canonical definition symbol for *symbol_id*."""
116
+ return self._store.get_symbol(symbol_id)
117
+
118
+ # ------------------------------------------------------------------
119
+ # Callers / callees
120
+ # ------------------------------------------------------------------
121
+
122
+ def get_callers(self, symbol_id: str) -> list[tuple[Symbol, Relationship]]:
123
+ """
124
+ Return all symbols that directly call *symbol_id*.
125
+ Returns (caller_symbol, relationship) pairs.
126
+ """
127
+ rels = self._store.get_relationships_to(symbol_id, RelationshipType.CALLS)
128
+ return self._resolve_source_pairs(rels)
129
+
130
+ def get_callees(self, symbol_id: str) -> list[tuple[Symbol, Relationship]]:
131
+ """Return all symbols directly called by *symbol_id*."""
132
+ rels = self._store.get_relationships_from(symbol_id, RelationshipType.CALLS)
133
+ return self._resolve_target_pairs(rels)
134
+
135
+ # ------------------------------------------------------------------
136
+ # References
137
+ # ------------------------------------------------------------------
138
+
139
+ def get_references(self, symbol_id: str) -> list[tuple[Symbol, Relationship]]:
140
+ """Return all symbols that reference (but don't call) *symbol_id*."""
141
+ rels = self._store.get_relationships_to(symbol_id, RelationshipType.REFERENCES)
142
+ return self._resolve_source_pairs(rels)
143
+
144
+ # ------------------------------------------------------------------
145
+ # Implementations / extensions
146
+ # ------------------------------------------------------------------
147
+
148
+ def get_implementations(self, symbol_id: str) -> list[Symbol]:
149
+ """
150
+ Return symbols that IMPLEMENTS or EXTENDS *symbol_id*.
151
+ Useful for finding concrete implementations of ABCs / Protocols.
152
+ """
153
+ impls = self._store.get_relationships_to(symbol_id, RelationshipType.IMPLEMENTS)
154
+ exts = self._store.get_relationships_to(symbol_id, RelationshipType.EXTENDS)
155
+ syms: list[Symbol] = []
156
+ for rel in impls + exts:
157
+ s = self._store.get_symbol(rel.source_id)
158
+ if s:
159
+ syms.append(s)
160
+ return syms
161
+
162
+ # ------------------------------------------------------------------
163
+ # Imports
164
+ # ------------------------------------------------------------------
165
+
166
+ def get_importers(self, symbol_id: str) -> list[Symbol]:
167
+ """Return files/modules that import *symbol_id*."""
168
+ rels = self._store.get_relationships_to(symbol_id, RelationshipType.IMPORTS)
169
+ return [s for rel in rels if (s := self._store.get_symbol(rel.source_id))]
170
+
171
+ def get_imports(self, symbol_id: str) -> list[Relationship]:
172
+ """Return all IMPORTS relationships from *symbol_id*."""
173
+ return self._store.get_relationships_from(symbol_id, RelationshipType.IMPORTS)
174
+
175
+ # ------------------------------------------------------------------
176
+ # Containment
177
+ # ------------------------------------------------------------------
178
+
179
+ def get_children(self, symbol_id: str) -> list[Symbol]:
180
+ """Return symbols directly contained by *symbol_id*."""
181
+ rels = self._store.get_relationships_from(symbol_id, RelationshipType.CONTAINS)
182
+ return [s for rel in rels if (s := self._store.get_symbol(rel.target_id))]
183
+
184
+ def get_parents(self, symbol_id: str) -> list[Symbol]:
185
+ """Return symbols that contain *symbol_id*."""
186
+ rels = self._store.get_relationships_to(symbol_id, RelationshipType.CONTAINS)
187
+ return [s for rel in rels if (s := self._store.get_symbol(rel.source_id))]
188
+
189
+ # ------------------------------------------------------------------
190
+ # Bounded graph walk (BFS via SQLite recursive CTE)
191
+ # ------------------------------------------------------------------
192
+
193
+ def walk(
194
+ self,
195
+ start_id: str,
196
+ rel_types: list[RelationshipType] | None = None,
197
+ direction: Literal["out", "in", "both"] = "out",
198
+ depth: int = 3,
199
+ ) -> list[WalkResult]:
200
+ """
201
+ BFS walk from *start_id* along *rel_types* edges up to *depth* hops.
202
+
203
+ direction:
204
+ "out" — follow source → target
205
+ "in" — follow target → source (reverse)
206
+ "both" — both directions
207
+
208
+ Returns a list of WalkResult ordered by depth.
209
+ """
210
+ rel_type_values = [r.value for r in rel_types] if rel_types else None
211
+ rows = self._walk_sql(start_id, rel_type_values, direction, depth)
212
+
213
+ results: list[WalkResult] = []
214
+ for row in rows:
215
+ sym = self._store.get_symbol(row["symbol_id"])
216
+ if sym and sym.id != start_id:
217
+ results.append(
218
+ WalkResult(
219
+ symbol=sym,
220
+ depth=row["depth"],
221
+ via_relationship=RelationshipType(row["rel_type"]) if row["rel_type"] else None,
222
+ )
223
+ )
224
+ return results
225
+
226
+ def _walk_sql(
227
+ self,
228
+ start_id: str,
229
+ rel_types: list[str] | None,
230
+ direction: str,
231
+ depth: int,
232
+ ) -> list[sqlite3.Row]:
233
+ """
234
+ Execute a recursive CTE to perform bounded BFS on the relationships table.
235
+ Returns raw rows with (symbol_id, depth, rel_type).
236
+ """
237
+ conn = self._store._conn # type: ignore[attr-defined]
238
+
239
+ if direction == "out":
240
+ edge_join = "JOIN relationships r ON r.source_id = walk.symbol_id"
241
+ next_sym = "r.target_id"
242
+ elif direction == "in":
243
+ edge_join = "JOIN relationships r ON r.target_id = walk.symbol_id"
244
+ next_sym = "r.source_id"
245
+ else: # both
246
+ edge_join = "JOIN relationships r ON (r.source_id = walk.symbol_id OR r.target_id = walk.symbol_id)"
247
+ next_sym = "CASE WHEN r.source_id = walk.symbol_id THEN r.target_id ELSE r.source_id END"
248
+
249
+ sql = f"""
250
+ WITH RECURSIVE walk(symbol_id, depth, rel_type) AS (
251
+ SELECT ?, 0, NULL
252
+ UNION
253
+ SELECT {next_sym}, walk.depth + 1, r.type
254
+ FROM walk
255
+ {edge_join}
256
+ WHERE walk.depth < ?
257
+ AND {next_sym} != ''
258
+ )
259
+ SELECT DISTINCT symbol_id, depth, rel_type FROM walk ORDER BY depth
260
+ """
261
+ # Execute with just start_id + depth; filter by rel_type in Python
262
+ # (avoids the complexity of duplicating ? params across the UNION)
263
+ rows = conn.execute(sql, [start_id, depth]).fetchall()
264
+ if rel_types:
265
+ rows = [r for r in rows if r["rel_type"] is None or r["rel_type"] in rel_types]
266
+ return rows
267
+
268
+ # ------------------------------------------------------------------
269
+ # Trace
270
+ # ------------------------------------------------------------------
271
+
272
+ def trace(
273
+ self,
274
+ start_id: str,
275
+ depth: int = 5,
276
+ rel_types: list[RelationshipType] | None = None,
277
+ ) -> list[WalkResult]:
278
+ """
279
+ Follow outgoing edges (default: CALLS + IMPORTS) from start to depth.
280
+ Returns all reachable nodes in BFS order.
281
+ """
282
+ if rel_types is None:
283
+ rel_types = [RelationshipType.CALLS, RelationshipType.IMPORTS]
284
+ return self.walk(start_id, rel_types=rel_types, direction="out", depth=depth)
285
+
286
+ # ------------------------------------------------------------------
287
+ # Blast radius
288
+ # ------------------------------------------------------------------
289
+
290
+ def blast_radius(
291
+ self,
292
+ start_id: str,
293
+ depth: int = 3,
294
+ rel_types: list[RelationshipType] | None = None,
295
+ ) -> list[WalkResult]:
296
+ """
297
+ Walk *inward* (reverse edges) to find everything that depends on *start_id*.
298
+ Default relationship types: CALLS, IMPORTS, REFERENCES.
299
+ """
300
+ if rel_types is None:
301
+ rel_types = [
302
+ RelationshipType.CALLS,
303
+ RelationshipType.IMPORTS,
304
+ RelationshipType.REFERENCES,
305
+ ]
306
+ return self.walk(start_id, rel_types=rel_types, direction="in", depth=depth)
307
+
308
+ # ------------------------------------------------------------------
309
+ # Source retrieval
310
+ # ------------------------------------------------------------------
311
+
312
+ def get_source(self, symbol_id: str) -> SourceRange | None:
313
+ """
314
+ Return the exact source lines for a symbol.
315
+ Reads from disk on every call (no caching in MVP).
316
+ """
317
+ sym = self._store.get_symbol(symbol_id)
318
+ if not sym:
319
+ return None
320
+
321
+ file_info = self._store.get_file(sym.file_id)
322
+ if not file_info:
323
+ return None
324
+
325
+ try:
326
+ all_lines = Path(file_info.path).read_text(encoding="utf-8", errors="replace").splitlines()
327
+ except OSError:
328
+ return None
329
+
330
+ # Convert to 0-indexed slice
331
+ start = max(0, sym.start_line - 1)
332
+ end = min(len(all_lines), sym.end_line)
333
+ snippet = all_lines[start:end]
334
+
335
+ return SourceRange(
336
+ file_path=file_info.path,
337
+ start_line=sym.start_line,
338
+ end_line=sym.end_line,
339
+ lines=snippet,
340
+ )
341
+
342
+ # ------------------------------------------------------------------
343
+ # Stats
344
+ # ------------------------------------------------------------------
345
+
346
+ def stats(self) -> dict[str, int]:
347
+ return self._store.stats()
348
+
349
+ # ------------------------------------------------------------------
350
+ # Private helpers
351
+ # ------------------------------------------------------------------
352
+
353
+ def _resolve_source_pairs(self, rels: list[Relationship]) -> list[tuple[Symbol, Relationship]]:
354
+ pairs: list[tuple[Symbol, Relationship]] = []
355
+ for rel in rels:
356
+ s = self._store.get_symbol(rel.source_id)
357
+ if s:
358
+ pairs.append((s, rel))
359
+ return pairs
360
+
361
+ def _resolve_target_pairs(self, rels: list[Relationship]) -> list[tuple[Symbol, Relationship]]:
362
+ pairs: list[tuple[Symbol, Relationship]] = []
363
+ for rel in rels:
364
+ if rel.target_id:
365
+ s = self._store.get_symbol(rel.target_id)
366
+ if s:
367
+ pairs.append((s, rel))
368
+ return pairs
@@ -0,0 +1,13 @@
1
+ """
2
+ node_walk.storage — graph persistence layer.
3
+
4
+ Canonical imports:
5
+ from node_walk.storage.base import GraphStore
6
+ from node_walk.storage.sqlite_store import SQLiteGraphStore
7
+ from node_walk.storage import schema
8
+ """
9
+
10
+ from node_walk.storage.base import GraphStore
11
+ from node_walk.storage.sqlite_store import SQLiteGraphStore
12
+
13
+ __all__ = ["GraphStore", "SQLiteGraphStore"]
@@ -0,0 +1,79 @@
1
+ """
2
+ Storage base — abstract GraphStore interface.
3
+
4
+ All storage backends must implement GraphStore. This keeps the
5
+ query engine and indexer decoupled from any concrete technology.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from abc import ABC, abstractmethod
11
+
12
+ from node_walk.ir.models import AnalysisResult, FileInfo, Relationship, Symbol
13
+ from node_walk.ir.enums import RelationshipType, ResolutionStatus
14
+
15
+
16
+ class GraphStore(ABC):
17
+ """Abstract graph storage backend."""
18
+
19
+ # --- Write operations ---------------------------------------------------
20
+
21
+ @abstractmethod
22
+ def store_result(self, result: AnalysisResult) -> None:
23
+ """Persist a single file's analysis result (file + symbols + relationships)."""
24
+ ...
25
+
26
+ @abstractmethod
27
+ def store_results(self, results: list[AnalysisResult]) -> None:
28
+ """Persist multiple files' results in a single transaction."""
29
+ ...
30
+
31
+ @abstractmethod
32
+ def clear(self) -> None:
33
+ """Remove all data. Used before a full re-index."""
34
+ ...
35
+
36
+ @abstractmethod
37
+ def update_relationship(
38
+ self, rel_id: str, target_id: str, resolution: ResolutionStatus
39
+ ) -> None:
40
+ """Update a relationship's resolved target and status (cross-file resolution pass)."""
41
+ ...
42
+
43
+ # --- Read operations ----------------------------------------------------
44
+
45
+ @abstractmethod
46
+ def get_file(self, file_id: str) -> FileInfo | None: ...
47
+
48
+ @abstractmethod
49
+ def get_all_files(self) -> list[FileInfo]: ...
50
+
51
+ @abstractmethod
52
+ def get_symbol(self, symbol_id: str) -> Symbol | None: ...
53
+
54
+ @abstractmethod
55
+ def find_symbols_by_name(self, name: str, exact: bool = False) -> list[Symbol]: ...
56
+
57
+ @abstractmethod
58
+ def find_symbols_by_qualified_name(self, qname: str) -> list[Symbol]: ...
59
+
60
+ @abstractmethod
61
+ def get_all_symbols(self) -> list[Symbol]: ...
62
+
63
+ @abstractmethod
64
+ def get_relationships_from(
65
+ self, symbol_id: str, rel_type: RelationshipType | None = None
66
+ ) -> list[Relationship]: ...
67
+
68
+ @abstractmethod
69
+ def get_relationships_to(
70
+ self, symbol_id: str, rel_type: RelationshipType | None = None
71
+ ) -> list[Relationship]: ...
72
+
73
+ @abstractmethod
74
+ def get_all_unresolved_relationships(self) -> list[Relationship]: ...
75
+
76
+ # --- Stats --------------------------------------------------------------
77
+
78
+ @abstractmethod
79
+ def stats(self) -> dict[str, int]: ...
@@ -0,0 +1,14 @@
1
+ """
2
+ node_walk.storage.repository — backward-compatibility shim.
3
+
4
+ The GraphStore interface and SQLiteGraphStore implementation now live in:
5
+ - node_walk.storage.base (GraphStore ABC)
6
+ - node_walk.storage.sqlite_store (SQLiteGraphStore)
7
+
8
+ This module re-exports both so existing imports continue to work.
9
+ """
10
+
11
+ from node_walk.storage.base import GraphStore
12
+ from node_walk.storage.sqlite_store import SQLiteGraphStore
13
+
14
+ __all__ = ["GraphStore", "SQLiteGraphStore"]
@@ -0,0 +1,130 @@
1
+ """
2
+ SQLite schema for CodeGraph.
3
+
4
+ Tables:
5
+ files — one row per discovered source file
6
+ symbols — one row per code symbol (class, function, …)
7
+ relationships — one row per semantic edge in the graph
8
+
9
+ The schema is created idempotently using CREATE TABLE IF NOT EXISTS,
10
+ so it is safe to call initialize() multiple times.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import sqlite3
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # DDL
19
+ # ---------------------------------------------------------------------------
20
+
21
+ _CREATE_FILES = """
22
+ CREATE TABLE IF NOT EXISTS files (
23
+ id TEXT PRIMARY KEY,
24
+ path TEXT NOT NULL UNIQUE,
25
+ language TEXT NOT NULL,
26
+ content_hash TEXT NOT NULL DEFAULT '',
27
+ size_bytes INTEGER NOT NULL DEFAULT 0,
28
+ indexed_at TEXT NOT NULL DEFAULT (datetime('now'))
29
+ );
30
+ """
31
+
32
+ _CREATE_SYMBOLS = """
33
+ CREATE TABLE IF NOT EXISTS symbols (
34
+ id TEXT PRIMARY KEY,
35
+ name TEXT NOT NULL,
36
+ qualified_name TEXT NOT NULL,
37
+ kind TEXT NOT NULL,
38
+ language TEXT NOT NULL,
39
+ file_id TEXT NOT NULL REFERENCES files(id) ON DELETE CASCADE,
40
+ start_line INTEGER NOT NULL,
41
+ end_line INTEGER NOT NULL,
42
+ signature TEXT NOT NULL DEFAULT '',
43
+ parent_id TEXT REFERENCES symbols(id) ON DELETE SET NULL,
44
+ docstring TEXT NOT NULL DEFAULT '',
45
+ is_async INTEGER NOT NULL DEFAULT 0
46
+ );
47
+ """
48
+
49
+ _CREATE_RELATIONSHIPS = """
50
+ CREATE TABLE IF NOT EXISTS relationships (
51
+ id TEXT PRIMARY KEY,
52
+ source_id TEXT NOT NULL,
53
+ target_id TEXT NOT NULL DEFAULT '',
54
+ type TEXT NOT NULL,
55
+ source_file_id TEXT,
56
+ source_line INTEGER,
57
+ source_col INTEGER,
58
+ resolution TEXT NOT NULL DEFAULT 'resolved',
59
+ metadata_json TEXT NOT NULL DEFAULT '{}'
60
+ );
61
+ """
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Indexes
65
+ # ---------------------------------------------------------------------------
66
+
67
+ _CREATE_INDEXES = [
68
+ "CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name);",
69
+ "CREATE INDEX IF NOT EXISTS idx_symbols_qname ON symbols(qualified_name);",
70
+ "CREATE INDEX IF NOT EXISTS idx_symbols_file_id ON symbols(file_id);",
71
+ "CREATE INDEX IF NOT EXISTS idx_symbols_kind ON symbols(kind);",
72
+ "CREATE INDEX IF NOT EXISTS idx_symbols_parent_id ON symbols(parent_id);",
73
+ "CREATE INDEX IF NOT EXISTS idx_rels_source_id ON relationships(source_id);",
74
+ "CREATE INDEX IF NOT EXISTS idx_rels_target_id ON relationships(target_id);",
75
+ "CREATE INDEX IF NOT EXISTS idx_rels_type ON relationships(type);",
76
+ "CREATE INDEX IF NOT EXISTS idx_rels_source_type ON relationships(source_id, type);",
77
+ "CREATE INDEX IF NOT EXISTS idx_rels_target_type ON relationships(target_id, type);",
78
+ ]
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # Schema metadata
82
+ # ---------------------------------------------------------------------------
83
+
84
+ _CREATE_META = """
85
+ CREATE TABLE IF NOT EXISTS node_walk_meta (
86
+ key TEXT PRIMARY KEY,
87
+ value TEXT NOT NULL
88
+ );
89
+ """
90
+
91
+ _SCHEMA_VERSION = "1"
92
+
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # Public function
96
+ # ---------------------------------------------------------------------------
97
+
98
+
99
+ def initialize(conn: sqlite3.Connection) -> None:
100
+ """
101
+ Create all tables and indexes in *conn* if they do not already exist.
102
+ Safe to call repeatedly; never drops data.
103
+ """
104
+ conn.execute("PRAGMA journal_mode=WAL;")
105
+ conn.execute("PRAGMA foreign_keys=ON;")
106
+
107
+ conn.execute(_CREATE_FILES)
108
+ conn.execute(_CREATE_SYMBOLS)
109
+ conn.execute(_CREATE_RELATIONSHIPS)
110
+ conn.execute(_CREATE_META)
111
+
112
+ for idx_sql in _CREATE_INDEXES:
113
+ conn.execute(idx_sql)
114
+
115
+ conn.execute(
116
+ "INSERT OR IGNORE INTO node_walk_meta(key, value) VALUES (?, ?)",
117
+ ("schema_version", _SCHEMA_VERSION),
118
+ )
119
+ conn.commit()
120
+
121
+
122
+ def get_schema_version(conn: sqlite3.Connection) -> str | None:
123
+ """Return the stored schema version, or None if the meta table is missing/empty."""
124
+ try:
125
+ row = conn.execute(
126
+ "SELECT value FROM node_walk_meta WHERE key = 'schema_version'"
127
+ ).fetchone()
128
+ return row[0] if row else None
129
+ except sqlite3.OperationalError:
130
+ return None