Skip to content

Commit c85ca01

Browse files
authored
perf: route query analysis through native Rust engine (#745)
* perf: route query analysis functions through native Rust engine Query analysis functions (fnDepsData, fnImpactData, fileDepsData, impactAnalysisData, briefData, implementationsData, interfacesData) were hardcoded to use openReadonlyOrFail() (better-sqlite3), completely bypassing the native Rust engine for queries even when --engine native was set. Add withRepo() helper that mirrors withReadonlyDb() but routes through openRepo(), which tries NativeRepository (rusqlite) first and falls back to SqliteRepository (better-sqlite3). Migrate 7 analysis functions to use it. Infrastructure changes: - Add getFileHash, hasImplementsEdges, hasCoChangesTable to Repository - Update normalizeSymbol to accept Repository in addition to DbHandle - Refactor buildTransitiveCallers to use repo.findCallers() instead of raw SQL prepared statements bfsTransitiveCallers accepts both BetterSqlite3Database and Repository for backward compatibility with check.ts and audit.ts callers. * perf(native): add Rust NAPI methods for getFileHash, hasImplementsEdges, hasCoChangesTable Removes conservative placeholder implementations in NativeRepository by adding proper Rust #[napi] query methods that mirror SqliteRepository behavior. Falls back gracefully when running against a prebuilt native addon that predates these methods. * style: fix quote style in native-repository typeof guards * fix: resolve native engine parity gaps for fileHash and method hierarchy (#745) - Port resolveMethodViaHierarchy to accept Repository (not just raw DB), so hierarchy-based caller discovery works on both native and WASM paths - Add lazy better-sqlite3 fallback in NativeRepository for getFileHash, hasImplementsEdges, and hasCoChangesTable until Rust implements them - Pass dbPath to NativeRepository constructor; close fallback on dispose - Remove SqliteRepository instanceof check from fnDepsData * fix: memoize hasImplementsEdges in SqliteRepository (#745) Cache the result of the implements-edge check to avoid re-querying on every call, restoring the per-connection caching behavior that existed before the refactor. * fix: memoize hasCoChangesTable in both repository implementations (#745) * fix: cache toRepo wrapper to preserve per-instance memoization (#745) Legacy raw-db callers of bfsTransitiveCallers create a SqliteRepository via toRepo on each call. Without caching, hasImplementsEdges memoization is lost between calls. A WeakMap keyed on the db handle now ensures the same Repository instance is reused across calls with the same connection.
1 parent 566797c commit c85ca01

13 files changed

Lines changed: 349 additions & 136 deletions

File tree

crates/codegraph-core/src/read_queries.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1128,6 +1128,51 @@ impl NativeDatabase {
11281128
}
11291129
}
11301130

1131+
/// Check whether the graph contains any 'implements' edges.
1132+
#[napi]
1133+
pub fn has_implements_edges(&self) -> napi::Result<bool> {
1134+
let conn = self.conn()?;
1135+
match conn
1136+
.prepare("SELECT 1 FROM edges WHERE kind = 'implements' LIMIT 1")
1137+
.and_then(|mut stmt| stmt.query_row([], |_| Ok(())))
1138+
{
1139+
Ok(()) => Ok(true),
1140+
Err(rusqlite::Error::SqliteFailure(_, _)) => Ok(false),
1141+
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false),
1142+
Err(e) => Err(napi::Error::from_reason(format!("has_implements_edges: {e}"))),
1143+
}
1144+
}
1145+
1146+
/// Check whether the co_changes table exists and has data.
1147+
#[napi]
1148+
pub fn has_co_changes_table(&self) -> napi::Result<bool> {
1149+
let conn = self.conn()?;
1150+
match conn
1151+
.prepare("SELECT 1 FROM co_changes LIMIT 1")
1152+
.and_then(|mut stmt| stmt.query_row([], |_| Ok(())))
1153+
{
1154+
Ok(()) => Ok(true),
1155+
Err(rusqlite::Error::SqliteFailure(_, _)) => Ok(false),
1156+
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false),
1157+
Err(e) => Err(napi::Error::from_reason(format!("has_co_changes_table: {e}"))),
1158+
}
1159+
}
1160+
1161+
/// Look up the stored content hash for a single file.
1162+
#[napi]
1163+
pub fn get_file_hash(&self, file: String) -> napi::Result<Option<String>> {
1164+
let conn = self.conn()?;
1165+
match conn
1166+
.prepare_cached("SELECT hash FROM file_hashes WHERE file = ?1")
1167+
.and_then(|mut stmt| stmt.query_row(rusqlite::params![file], |row| row.get::<_, String>(0)))
1168+
{
1169+
Ok(hash) => Ok(Some(hash)),
1170+
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1171+
Err(rusqlite::Error::SqliteFailure(_, _)) => Ok(None),
1172+
Err(e) => Err(napi::Error::from_reason(format!("get_file_hash: {e}"))),
1173+
}
1174+
}
1175+
11311176
/// Check whether dataflow table exists and has data.
11321177
#[napi]
11331178
pub fn has_dataflow_table(&self) -> napi::Result<bool> {

src/db/connection.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -334,9 +334,11 @@ function openRepoNative(customDbPath?: string): { repo: Repository; close(): voi
334334
const ndb = native.NativeDatabase.openReadonly(dbPath);
335335
try {
336336
warnOnVersionMismatch(() => ndb.getBuildMeta('codegraph_version'));
337+
const repo = new NativeRepository(ndb, dbPath);
337338
return {
338-
repo: new NativeRepository(ndb),
339+
repo,
339340
close() {
341+
repo.closeFallback();
340342
ndb.close();
341343
},
342344
};

src/db/repository/base.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,4 +189,24 @@ export class Repository implements IRepository {
189189
getComplexityForNode(_nodeId: number): ComplexityMetrics | undefined {
190190
throw new Error('not implemented');
191191
}
192+
193+
// ── Convenience queries ──────────────────────────────────────────────
194+
/**
195+
* Look up the stored content hash for a file.
196+
* Returns null when the file is not in file_hashes or the method is
197+
* not yet implemented on the concrete repository.
198+
*/
199+
getFileHash(_file: string): string | null {
200+
return null;
201+
}
202+
203+
/** Check whether the graph contains any 'implements' edges. */
204+
hasImplementsEdges(): boolean {
205+
return false;
206+
}
207+
208+
/** Check whether the co_changes table exists and has data. */
209+
hasCoChangesTable(): boolean {
210+
return false;
211+
}
192212
}

src/db/repository/native-repository.ts

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@
88
* back to the snake_case field names that the Repository interface expects.
99
*/
1010

11+
import Database from 'better-sqlite3';
1112
import { ConfigError } from '../../shared/errors.js';
1213
import type {
1314
AdjacentEdgeRow,
15+
BetterSqlite3Database,
1416
CallableNodeRow,
1517
CallEdgeRow,
1618
ChildNodeRow,
@@ -159,10 +161,33 @@ function toComplexityMetrics(r: NativeComplexityMetrics): ComplexityMetrics {
159161

160162
export class NativeRepository extends Repository {
161163
#ndb: NativeDatabase;
164+
#dbPath?: string;
165+
#fallbackDb?: BetterSqlite3Database;
162166

163-
constructor(ndb: NativeDatabase) {
167+
constructor(ndb: NativeDatabase, dbPath?: string) {
164168
super();
165169
this.#ndb = ndb;
170+
this.#dbPath = dbPath;
171+
}
172+
173+
/** Lazy better-sqlite3 handle for methods not yet ported to Rust. */
174+
#getFallbackDb(): BetterSqlite3Database | undefined {
175+
if (this.#fallbackDb) return this.#fallbackDb;
176+
if (!this.#dbPath) return undefined;
177+
try {
178+
this.#fallbackDb = new Database(this.#dbPath, { readonly: true });
179+
return this.#fallbackDb;
180+
} catch {
181+
return undefined;
182+
}
183+
}
184+
185+
/** Close the lazy fallback connection if it was opened. */
186+
closeFallback(): void {
187+
if (this.#fallbackDb) {
188+
this.#fallbackDb.close();
189+
this.#fallbackDb = undefined;
190+
}
166191
}
167192

168193
// ── Node lookups ──────────────────────────────────────────────────
@@ -358,4 +383,57 @@ export class NativeRepository extends Repository {
358383
const r = this.#ndb.getComplexityForNode(nodeId);
359384
return r ? toComplexityMetrics(r) : undefined;
360385
}
386+
387+
// ── Convenience queries ────────────────────────────────────────────
388+
389+
getFileHash(file: string): string | null {
390+
if (typeof this.#ndb.getFileHash === 'function') return this.#ndb.getFileHash(file);
391+
// Fallback to better-sqlite3 until Rust implements getFileHash
392+
const db = this.#getFallbackDb();
393+
if (db) {
394+
const row = db.prepare('SELECT hash FROM file_hashes WHERE file = ?').get(file) as
395+
| { hash: string }
396+
| undefined;
397+
return row?.hash ?? null;
398+
}
399+
return null;
400+
}
401+
402+
#implementsEdgesCache?: boolean;
403+
hasImplementsEdges(): boolean {
404+
if (this.#implementsEdgesCache !== undefined) return this.#implementsEdgesCache;
405+
if (typeof this.#ndb.hasImplementsEdges === 'function') {
406+
this.#implementsEdgesCache = this.#ndb.hasImplementsEdges();
407+
return this.#implementsEdgesCache;
408+
}
409+
// Fallback to better-sqlite3
410+
const db = this.#getFallbackDb();
411+
if (db) {
412+
this.#implementsEdgesCache = !!db
413+
.prepare("SELECT 1 FROM edges WHERE kind = 'implements' LIMIT 1")
414+
.get();
415+
return this.#implementsEdgesCache;
416+
}
417+
return true; // conservative: assume yes when no fallback available
418+
}
419+
420+
#coChangesTableCache?: boolean;
421+
hasCoChangesTable(): boolean {
422+
if (this.#coChangesTableCache !== undefined) return this.#coChangesTableCache;
423+
if (typeof this.#ndb.hasCoChangesTable === 'function') {
424+
this.#coChangesTableCache = this.#ndb.hasCoChangesTable();
425+
return this.#coChangesTableCache;
426+
}
427+
// Fallback to better-sqlite3
428+
const db = this.#getFallbackDb();
429+
if (db) {
430+
try {
431+
this.#coChangesTableCache = !!db.prepare('SELECT 1 FROM co_changes LIMIT 1').get();
432+
} catch {
433+
this.#coChangesTableCache = false;
434+
}
435+
return this.#coChangesTableCache;
436+
}
437+
return false;
438+
}
361439
}

src/db/repository/sqlite-repository.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,4 +245,33 @@ export class SqliteRepository extends Repository {
245245
getComplexityForNode(nodeId: number): ComplexityMetrics | undefined {
246246
return getComplexityForNode(this.#db, nodeId);
247247
}
248+
249+
// ── Convenience queries ────────────────────────────────────────────
250+
251+
getFileHash(file: string): string | null {
252+
const row = this.#db.prepare('SELECT hash FROM file_hashes WHERE file = ?').get(file) as
253+
| { hash: string }
254+
| undefined;
255+
return row?.hash ?? null;
256+
}
257+
258+
#implementsEdgesCache?: boolean;
259+
hasImplementsEdges(): boolean {
260+
if (this.#implementsEdgesCache !== undefined) return this.#implementsEdgesCache;
261+
this.#implementsEdgesCache = !!this.#db
262+
.prepare("SELECT 1 FROM edges WHERE kind = 'implements' LIMIT 1")
263+
.get();
264+
return this.#implementsEdgesCache;
265+
}
266+
267+
#coChangesTableCache?: boolean;
268+
hasCoChangesTable(): boolean {
269+
if (this.#coChangesTableCache !== undefined) return this.#coChangesTableCache;
270+
try {
271+
this.#coChangesTableCache = !!this.#db.prepare('SELECT 1 FROM co_changes LIMIT 1').get();
272+
} catch {
273+
this.#coChangesTableCache = false;
274+
}
275+
return this.#coChangesTableCache;
276+
}
248277
}

src/domain/analysis/brief.ts

Lines changed: 15 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,8 @@
1-
import {
2-
findDistinctCallers,
3-
findFileNodes,
4-
findImportDependents,
5-
findImportSources,
6-
findImportTargets,
7-
findNodesByFile,
8-
openReadonlyOrFail,
9-
} from '../../db/index.js';
1+
import type { Repository } from '../../db/index.js';
102
import { loadConfig } from '../../infrastructure/config.js';
113
import { isTestFile } from '../../infrastructure/test-filter.js';
12-
import type { BetterSqlite3Database, ImportEdgeRow, NodeRow, RelatedNodeRow } from '../../types.js';
4+
import type { ImportEdgeRow, NodeRow, RelatedNodeRow } from '../../types.js';
5+
import { withRepo } from './query-helpers.js';
136

147
/** Symbol kinds meaningful for a file brief — excludes parameters, properties, constants. */
158
const BRIEF_KINDS = new Set([
@@ -49,7 +42,7 @@ function computeRiskTier(
4942
* Lightweight variant — only counts, does not collect details.
5043
*/
5144
function countTransitiveCallers(
52-
db: BetterSqlite3Database,
45+
repo: InstanceType<typeof Repository>,
5346
startId: number,
5447
noTests: boolean,
5548
maxDepth = 5,
@@ -60,7 +53,7 @@ function countTransitiveCallers(
6053
for (let d = 1; d <= maxDepth; d++) {
6154
const nextFrontier: number[] = [];
6255
for (const fid of frontier) {
63-
const callers = findDistinctCallers(db, fid) as RelatedNodeRow[];
56+
const callers = repo.findDistinctCallers(fid) as RelatedNodeRow[];
6457
for (const c of callers) {
6558
if (!visited.has(c.id) && (!noTests || !isTestFile(c.file))) {
6659
visited.add(c.id);
@@ -80,7 +73,7 @@ function countTransitiveCallers(
8073
* Depth-bounded to match countTransitiveCallers and keep hook latency predictable.
8174
*/
8275
function countTransitiveImporters(
83-
db: BetterSqlite3Database,
76+
repo: InstanceType<typeof Repository>,
8477
fileNodeIds: number[],
8578
noTests: boolean,
8679
maxDepth = 5,
@@ -91,7 +84,7 @@ function countTransitiveImporters(
9184
for (let d = 1; d <= maxDepth; d++) {
9285
const nextFrontier: number[] = [];
9386
for (const current of frontier) {
94-
const dependents = findImportDependents(db, current) as RelatedNodeRow[];
87+
const dependents = repo.findImportDependents(current) as RelatedNodeRow[];
9588
for (const dep of dependents) {
9689
if (!visited.has(dep.id) && (!noTests || !isTestFile(dep.file))) {
9790
visited.add(dep.id);
@@ -115,38 +108,37 @@ export function briefData(
115108
customDbPath: string,
116109
opts: { noTests?: boolean; config?: any } = {},
117110
) {
118-
const db = openReadonlyOrFail(customDbPath);
119-
try {
111+
return withRepo(customDbPath, (repo) => {
120112
const noTests = opts.noTests || false;
121113
const config = opts.config || loadConfig();
122114
const callerDepth = config.analysis?.briefCallerDepth ?? 5;
123115
const importerDepth = config.analysis?.briefImporterDepth ?? 5;
124116
const highRiskCallers = config.analysis?.briefHighRiskCallers ?? 10;
125117
const mediumRiskCallers = config.analysis?.briefMediumRiskCallers ?? 3;
126-
const fileNodes = findFileNodes(db, `%${file}%`) as NodeRow[];
118+
const fileNodes = repo.findFileNodes(`%${file}%`) as NodeRow[];
127119
if (fileNodes.length === 0) {
128120
return { file, results: [] };
129121
}
130122

131123
const results = fileNodes.map((fn) => {
132124
// Direct importers
133-
let importedBy = findImportSources(db, fn.id) as ImportEdgeRow[];
125+
let importedBy = repo.findImportSources(fn.id) as ImportEdgeRow[];
134126
if (noTests) importedBy = importedBy.filter((i) => !isTestFile(i.file));
135127
const directImporters = [...new Set(importedBy.map((i) => i.file))];
136128

137129
// Transitive importer count
138-
const totalImporterCount = countTransitiveImporters(db, [fn.id], noTests, importerDepth);
130+
const totalImporterCount = countTransitiveImporters(repo, [fn.id], noTests, importerDepth);
139131

140132
// Direct imports
141-
let importsTo = findImportTargets(db, fn.id) as ImportEdgeRow[];
133+
let importsTo = repo.findImportTargets(fn.id) as ImportEdgeRow[];
142134
if (noTests) importsTo = importsTo.filter((i) => !isTestFile(i.file));
143135

144136
// Symbol definitions with roles and caller counts
145-
const defs = (findNodesByFile(db, fn.file) as NodeRow[]).filter((d) =>
137+
const defs = (repo.findNodesByFile(fn.file) as NodeRow[]).filter((d) =>
146138
BRIEF_KINDS.has(d.kind),
147139
);
148140
const symbols = defs.map((d) => {
149-
const callerCount = countTransitiveCallers(db, d.id, noTests, callerDepth);
141+
const callerCount = countTransitiveCallers(repo, d.id, noTests, callerDepth);
150142
return {
151143
name: d.name,
152144
kind: d.kind,
@@ -169,7 +161,5 @@ export function briefData(
169161
});
170162

171163
return { file, results };
172-
} finally {
173-
db.close();
174-
}
164+
});
175165
}

0 commit comments

Comments
 (0)