@huanlin/dsh-plugin-codegraph-sqlite 0.1.8
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.
- package/LICENSE +21 -0
- package/README.md +17 -0
- package/lib/database.d.ts +75 -0
- package/lib/database.js +180 -0
- package/lib/index.d.ts +66 -0
- package/lib/index.js +119 -0
- package/lib/invariant.d.ts +16 -0
- package/lib/invariant.js +23 -0
- package/lib/queries.d.ts +66 -0
- package/lib/queries.js +302 -0
- package/lib/rows.d.ts +31 -0
- package/lib/rows.js +174 -0
- package/lib/sql.d.ts +37 -0
- package/lib/sql.js +116 -0
- package/lib/traverse.d.ts +73 -0
- package/lib/traverse.js +0 -0
- package/package.json +57 -0
package/lib/queries.js
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The eight seam operations against one open graph database. Every function here is synchronous:
|
|
3
|
+
* `node:sqlite` is a synchronous binding, so a query either completes within one turn or throws,
|
|
4
|
+
* and the store's async surface exists for the seam's contract rather than for I/O interleaving.
|
|
5
|
+
* @module @huanlin/dsh-plugin-codegraph-sqlite/queries
|
|
6
|
+
*/
|
|
7
|
+
import { statSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { CodegraphError } from '@huanlin/dsh-plugin-codegraph-service';
|
|
10
|
+
import { toEdge, toFile, toNode } from "./rows.js";
|
|
11
|
+
import { NODE_COLUMNS, SEARCH_ORDER, SYMBOL_ORDER, ftsPhrase, likeAnywhere, } from "./sql.js";
|
|
12
|
+
/** Run a statement and return its rows already typed as raw records. */
|
|
13
|
+
function rows(db, sql, ...params) {
|
|
14
|
+
return db.prepare(sql).all(...params);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Read a single-column aggregate as a number. Every caller passes an aggregate, which always yields
|
|
18
|
+
* exactly one row and one column; `MAX` over an empty table yields SQL NULL, which reads as 0.
|
|
19
|
+
*/
|
|
20
|
+
function scalar(db, sql, ...params) {
|
|
21
|
+
const [value] = Object.values(db.prepare(sql).get(...params));
|
|
22
|
+
return typeof value === 'number' ? value : 0;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Candidate declarations for a symbol name, most relevant first.
|
|
26
|
+
* @param db - the open graph connection.
|
|
27
|
+
* @param symbol - the simple or qualified name to resolve.
|
|
28
|
+
* @param limit - largest number of candidates to read.
|
|
29
|
+
* @returns the matching nodes in relevance order; empty when the name matches nothing.
|
|
30
|
+
*/
|
|
31
|
+
function resolveSymbol(db, symbol, limit) {
|
|
32
|
+
return rows(db, `SELECT ${NODE_COLUMNS} FROM nodes n
|
|
33
|
+
WHERE n.qualified_name = ? OR n.name = ? OR lower(n.name) = lower(?)
|
|
34
|
+
ORDER BY ${SYMBOL_ORDER}
|
|
35
|
+
LIMIT ?`, symbol, symbol, symbol, symbol, symbol, symbol, limit).map(toNode);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Find declarations matching a free-text query.
|
|
39
|
+
* @param db - the open graph connection.
|
|
40
|
+
* @param request - the search query with its resolved bounds.
|
|
41
|
+
* @returns the matching declarations, most relevant first, with the pre-limit total.
|
|
42
|
+
*/
|
|
43
|
+
export function search(db, request) {
|
|
44
|
+
const like = likeAnywhere(request.query);
|
|
45
|
+
const kind = request.kind ?? null;
|
|
46
|
+
const language = request.language ?? null;
|
|
47
|
+
const candidates = `
|
|
48
|
+
FROM nodes n
|
|
49
|
+
JOIN (
|
|
50
|
+
SELECT rowid AS rid FROM nodes WHERE lower(name) LIKE ? ESCAPE '\\' OR lower(qualified_name) LIKE ? ESCAPE '\\'
|
|
51
|
+
UNION
|
|
52
|
+
SELECT rowid FROM nodes_fts WHERE nodes_fts MATCH ?
|
|
53
|
+
) m ON m.rid = n.rowid
|
|
54
|
+
WHERE (? IS NULL OR n.kind = ?) AND (? IS NULL OR n.language = ?)`;
|
|
55
|
+
const filters = [like, like, ftsPhrase(request.query), kind, kind, language, language];
|
|
56
|
+
const total = scalar(db, `SELECT count(*) AS total ${candidates}`, ...filters);
|
|
57
|
+
const matches = rows(db, `SELECT ${NODE_COLUMNS} ${candidates} ORDER BY ${SEARCH_ORDER} LIMIT ?`, ...filters, request.query, request.query, request.query, request.query, request.limit).map(toNode);
|
|
58
|
+
return { kind: 'search', nodes: matches, total, truncated: total > matches.length };
|
|
59
|
+
}
|
|
60
|
+
/** Read every edge on one side of a node, with the node at the far end. */
|
|
61
|
+
function relationsOf(db, id, direction, edgeKind, limit) {
|
|
62
|
+
const anchor = direction === 'incoming' ? 'target' : 'source';
|
|
63
|
+
const far = direction === 'incoming' ? 'source' : 'target';
|
|
64
|
+
const where = `WHERE e.${anchor} = ? AND (? IS NULL OR e.kind = ?)`;
|
|
65
|
+
// Grouping by the far node AND the edge kind is what makes a relation distinct: five calls from
|
|
66
|
+
// one function collapse to one relation, while a node that both calls and contains another stays
|
|
67
|
+
// two, because those are different facts about the pair.
|
|
68
|
+
const grouping = `GROUP BY e.${far}, e.kind`;
|
|
69
|
+
const total = scalar(db, `SELECT count(*) AS total FROM (SELECT 1 FROM edges e ${where} ${grouping})`, id, edgeKind, edgeKind);
|
|
70
|
+
const relations = rows(db,
|
|
71
|
+
// MIN(e.line) both picks the earliest site and, per SQLite's bare-column rule for a query with a
|
|
72
|
+
// single MIN aggregate, makes every other `e.` column come from that same earliest row.
|
|
73
|
+
`SELECT ${NODE_COLUMNS}, e.source AS edge_source, e.target AS edge_target, e.kind AS edge_kind,
|
|
74
|
+
MIN(e.line) AS edge_line, e.col AS edge_col, e.provenance AS edge_provenance,
|
|
75
|
+
count(*) AS site_count
|
|
76
|
+
FROM edges e
|
|
77
|
+
JOIN nodes n ON n.id = e.${far}
|
|
78
|
+
${where}
|
|
79
|
+
${grouping}
|
|
80
|
+
ORDER BY n.file_path, MIN(e.line), n.start_line
|
|
81
|
+
LIMIT ?`, id, edgeKind, edgeKind, limit).map(row => ({
|
|
82
|
+
node: toNode(row),
|
|
83
|
+
edge: toEdge(row),
|
|
84
|
+
siteCount: Number(row['site_count']),
|
|
85
|
+
}));
|
|
86
|
+
return { relations, total };
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Resolve one symbol and read its immediate neighbourhood.
|
|
90
|
+
* @param db - the open graph connection.
|
|
91
|
+
* @param request - the node query with its resolved bounds.
|
|
92
|
+
* @returns the resolved declaration with its one-hop relations, or a null subject when the name
|
|
93
|
+
* matches nothing.
|
|
94
|
+
*/
|
|
95
|
+
export function node(db, request) {
|
|
96
|
+
// One extra candidate beyond the reported alternatives distinguishes "exactly this many" from
|
|
97
|
+
// "at least this many" without a second count query.
|
|
98
|
+
const candidates = resolveSymbol(db, request.symbol, request.limit + 1);
|
|
99
|
+
const best = candidates[0];
|
|
100
|
+
if (best === undefined) {
|
|
101
|
+
return { kind: 'node', node: null, incoming: [], outgoing: [], alternatives: [] };
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
kind: 'node',
|
|
105
|
+
node: best,
|
|
106
|
+
incoming: relationsOf(db, best.id, 'incoming', null, request.limit).relations,
|
|
107
|
+
outgoing: relationsOf(db, best.id, 'outgoing', null, request.limit).relations,
|
|
108
|
+
alternatives: candidates.slice(1),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Find the declarations that call, or are called by, one symbol.
|
|
113
|
+
* @param db - the open graph connection.
|
|
114
|
+
* @param request - the callers or callees query; its operation picks the direction walked.
|
|
115
|
+
* @returns the distinct related declarations with their earliest call sites and repeat counts.
|
|
116
|
+
*/
|
|
117
|
+
export function relations(db, request) {
|
|
118
|
+
const kind = request.operation === 'callers' ? 'callers' : 'callees';
|
|
119
|
+
const subject = resolveSymbol(db, request.symbol, 1)[0];
|
|
120
|
+
if (subject === undefined) {
|
|
121
|
+
return { kind, subject: null, relations: [], total: 0, truncated: false };
|
|
122
|
+
}
|
|
123
|
+
const direction = request.operation === 'callers' ? 'incoming' : 'outgoing';
|
|
124
|
+
const found = relationsOf(db, subject.id, direction, 'calls', request.limit);
|
|
125
|
+
return {
|
|
126
|
+
kind,
|
|
127
|
+
subject,
|
|
128
|
+
relations: found.relations,
|
|
129
|
+
total: found.total,
|
|
130
|
+
truncated: found.total > found.relations.length,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
/** Read many nodes by id, preserving the caller's order. */
|
|
134
|
+
function nodesByIds(db, ids) {
|
|
135
|
+
if (ids.length === 0)
|
|
136
|
+
return new Map();
|
|
137
|
+
const found = rows(db, `SELECT ${NODE_COLUMNS} FROM nodes n WHERE n.id IN (${new Array(ids.length).fill('?').join(', ')})`, ...ids).map(toNode);
|
|
138
|
+
return new Map(found.map(entry => [entry.id, entry]));
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Walk dependents transitively.
|
|
142
|
+
* @param db - the open graph connection.
|
|
143
|
+
* @param request - the impact query.
|
|
144
|
+
* @param walk - the reverse-reachability walk, injected so the traversal budget stays with the
|
|
145
|
+
* plugin that configures it.
|
|
146
|
+
* @returns the affected declarations, nearest first.
|
|
147
|
+
*/
|
|
148
|
+
export function impact(db, request, walk) {
|
|
149
|
+
const subject = resolveSymbol(db, request.symbol, 1)[0];
|
|
150
|
+
if (subject === undefined) {
|
|
151
|
+
return { kind: 'impact', subject: null, entries: [], total: 0, truncated: false };
|
|
152
|
+
}
|
|
153
|
+
const { hits, exhausted } = walk(subject.id, request.depth);
|
|
154
|
+
const kept = hits.slice(0, request.limit);
|
|
155
|
+
const byId = nodesByIds(db, kept.map(hit => hit.node));
|
|
156
|
+
const entries = [];
|
|
157
|
+
for (const hit of kept) {
|
|
158
|
+
const affected = byId.get(hit.node);
|
|
159
|
+
// An edge whose endpoint row is gone is a dangling reference in someone else's index, not a
|
|
160
|
+
// reason to fail the whole query; the counts below still report what the walk reached.
|
|
161
|
+
if (affected === undefined)
|
|
162
|
+
continue;
|
|
163
|
+
entries.push({ node: affected, distance: hit.distance, via: hit.via });
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
kind: 'impact',
|
|
167
|
+
subject,
|
|
168
|
+
entries,
|
|
169
|
+
total: hits.length,
|
|
170
|
+
truncated: exhausted || hits.length > entries.length,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Find shortest directed paths between two symbols.
|
|
175
|
+
* @param db - the open graph connection.
|
|
176
|
+
* @param request - the trace query.
|
|
177
|
+
* @param walk - the shortest-path sweep, injected so the traversal budget stays with the plugin that
|
|
178
|
+
* configures it.
|
|
179
|
+
* @returns the paths, each as ordered hops from origin to destination.
|
|
180
|
+
*/
|
|
181
|
+
export function trace(db, request, walk) {
|
|
182
|
+
const from = resolveSymbol(db, request.from, 1)[0];
|
|
183
|
+
const to = resolveSymbol(db, request.to, 1)[0];
|
|
184
|
+
if (from === undefined || to === undefined) {
|
|
185
|
+
return { kind: 'trace', from: from ?? null, to: to ?? null, paths: [] };
|
|
186
|
+
}
|
|
187
|
+
const walked = walk(from.id, to.id, request.maxDepth, request.maxPaths);
|
|
188
|
+
const reached = [...new Set(walked.flatMap(path => path.map(step => step.node)))];
|
|
189
|
+
const byId = nodesByIds(db, reached);
|
|
190
|
+
const paths = [];
|
|
191
|
+
for (const path of walked) {
|
|
192
|
+
const hops = [{ node: from }];
|
|
193
|
+
let complete = true;
|
|
194
|
+
for (const step of path) {
|
|
195
|
+
const reachedNode = byId.get(step.node);
|
|
196
|
+
if (reachedNode === undefined) {
|
|
197
|
+
complete = false;
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
hops.push({ node: reachedNode, edge: { ...step.edge } });
|
|
201
|
+
}
|
|
202
|
+
// A path through a dangling endpoint cannot be rendered honestly, so it is dropped rather than
|
|
203
|
+
// reported with a hole in it; other paths in the same answer remain valid.
|
|
204
|
+
if (complete)
|
|
205
|
+
paths.push(hops);
|
|
206
|
+
}
|
|
207
|
+
return { kind: 'trace', from, to, paths };
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* List indexed files under an optional subtree and glob.
|
|
211
|
+
* @param db - the open graph connection.
|
|
212
|
+
* @param request - the files query with its resolved bounds.
|
|
213
|
+
* @returns the matching files ordered by path, with the pre-limit total.
|
|
214
|
+
*/
|
|
215
|
+
export function files(db, request) {
|
|
216
|
+
const prefix = request.path === undefined ? null : `${request.path.replace(/\/+$/, '')}/%`;
|
|
217
|
+
const pattern = request.pattern ?? null;
|
|
218
|
+
const where = 'WHERE (? IS NULL OR path LIKE ?) AND (? IS NULL OR path GLOB ?)';
|
|
219
|
+
const filters = [prefix, prefix, pattern, pattern];
|
|
220
|
+
const total = scalar(db, `SELECT count(*) AS total FROM files ${where}`, ...filters);
|
|
221
|
+
const found = rows(db, `SELECT path, language, size, node_count, modified_at, indexed_at FROM files ${where} ORDER BY path LIMIT ?`, ...filters, request.limit).map(toFile);
|
|
222
|
+
return { kind: 'files', files: found, total, truncated: total > found.length };
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Whether one indexed file's on-disk state no longer matches what the index recorded for it.
|
|
226
|
+
* Missing entirely counts as stale — it is the most unambiguous case there is, and treating a
|
|
227
|
+
* deleted file as merely "unchanged" would hide the one drift a caller most needs to know about.
|
|
228
|
+
*/
|
|
229
|
+
function isStale(projectRoot, relativePath, indexedModifiedAt) {
|
|
230
|
+
try {
|
|
231
|
+
return statSync(join(projectRoot, relativePath)).mtimeMs > indexedModifiedAt;
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Count indexed files a direct filesystem check finds stale, capped for cost.
|
|
239
|
+
* @param db - the open graph connection.
|
|
240
|
+
* @param projectRoot - the project root the indexed paths are relative to.
|
|
241
|
+
* @param maxChecks - largest number of indexed files to stat before reporting a lower bound.
|
|
242
|
+
* @returns the stale count among the checked files, and whether checking stopped short of every
|
|
243
|
+
* indexed file.
|
|
244
|
+
*/
|
|
245
|
+
function staleness(db, projectRoot, maxChecks) {
|
|
246
|
+
// One extra row beyond the cap distinguishes "exactly this many files" from "more than this many"
|
|
247
|
+
// without a second count query, mirroring the same trick `node`'s alternatives use.
|
|
248
|
+
const candidates = rows(db, 'SELECT path, modified_at FROM files ORDER BY path LIMIT ?', maxChecks + 1);
|
|
249
|
+
const truncated = candidates.length > maxChecks;
|
|
250
|
+
const checked = truncated ? candidates.slice(0, maxChecks) : candidates;
|
|
251
|
+
let count = 0;
|
|
252
|
+
for (const row of checked) {
|
|
253
|
+
const path = row['path'];
|
|
254
|
+
const modifiedAt = row['modified_at'];
|
|
255
|
+
if (typeof path !== 'string') {
|
|
256
|
+
throw new CodegraphError('the code graph has a malformed file path', 'CODEGRAPH_MALFORMED_INDEX');
|
|
257
|
+
}
|
|
258
|
+
if (typeof modifiedAt !== 'number') {
|
|
259
|
+
throw new CodegraphError('the code graph has a malformed file timestamp', 'CODEGRAPH_MALFORMED_INDEX');
|
|
260
|
+
}
|
|
261
|
+
if (isStale(projectRoot, path, modifiedAt))
|
|
262
|
+
count += 1;
|
|
263
|
+
}
|
|
264
|
+
return { count, truncated };
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Report index size, language coverage, and freshness.
|
|
268
|
+
* @param db - the open graph connection.
|
|
269
|
+
* @param projectRoot - the project root the result echoes back, since the graph does not record it.
|
|
270
|
+
* @param maxStalenessChecks - largest number of indexed files to stat on the host filesystem when
|
|
271
|
+
* looking for drift, so a call against a very large index stays cheap.
|
|
272
|
+
* @returns the index summary.
|
|
273
|
+
*/
|
|
274
|
+
export function status(db, projectRoot, maxStalenessChecks) {
|
|
275
|
+
const languages = rows(db, 'SELECT language, count(*) AS file_count FROM files GROUP BY language ORDER BY file_count DESC, language').map((row) => {
|
|
276
|
+
// Only `language` is durable data worth checking; the count beside it is computed by SQLite
|
|
277
|
+
// in this very statement, so it is numeric by construction.
|
|
278
|
+
const language = row['language'];
|
|
279
|
+
if (typeof language !== 'string') {
|
|
280
|
+
throw new CodegraphError('the code graph has a malformed language summary', 'CODEGRAPH_MALFORMED_INDEX');
|
|
281
|
+
}
|
|
282
|
+
return { language, fileCount: Number(row['file_count']) };
|
|
283
|
+
});
|
|
284
|
+
const indexedAt = scalar(db, 'SELECT MAX(indexed_at) AS indexed_at FROM files');
|
|
285
|
+
const { count: staleFileCount, truncated: staleFileCountTruncated } = staleness(db, projectRoot, maxStalenessChecks);
|
|
286
|
+
return {
|
|
287
|
+
kind: 'status',
|
|
288
|
+
projectRoot,
|
|
289
|
+
fileCount: scalar(db, 'SELECT count(*) AS c FROM files'),
|
|
290
|
+
nodeCount: scalar(db, 'SELECT count(*) AS c FROM nodes'),
|
|
291
|
+
edgeCount: scalar(db, 'SELECT count(*) AS c FROM edges'),
|
|
292
|
+
languages,
|
|
293
|
+
// The version actually recorded on disk, not the store's support ceiling: two writers stamp
|
|
294
|
+
// different supported versions (tree-sitter writes v4, the CLI writes v8), so echoing the
|
|
295
|
+
// database's own row is the honest answer.
|
|
296
|
+
formatVersion: scalar(db, 'SELECT MAX(version) AS version FROM schema_versions'),
|
|
297
|
+
indexedAt: indexedAt === 0 ? null : indexedAt,
|
|
298
|
+
staleFileCount,
|
|
299
|
+
staleFileCountTruncated,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
//# sourceMappingURL=queries.js.map
|
package/lib/rows.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable-boundary mapping from SQLite rows to seam records. Every value crossing this module comes
|
|
3
|
+
* from a file an independently versioned indexer wrote, so each field is checked before it becomes a
|
|
4
|
+
* typed record; a row that violates the format fails loud as `CODEGRAPH_MALFORMED_INDEX` rather than
|
|
5
|
+
* reaching a consumer as a plausible-looking wrong value.
|
|
6
|
+
* @module @huanlin/dsh-plugin-codegraph-sqlite/rows
|
|
7
|
+
*/
|
|
8
|
+
import type { CodegraphEdge, CodegraphFile, CodegraphNode } from '@huanlin/dsh-plugin-codegraph-service';
|
|
9
|
+
/** One `nodes` row as `node:sqlite` returns it, before validation. */
|
|
10
|
+
export type NodeRow = Record<string, unknown>;
|
|
11
|
+
/**
|
|
12
|
+
* Build a {@link CodegraphNode} from a `nodes` row.
|
|
13
|
+
* @param row - the raw row, selected with every `nodes` column present.
|
|
14
|
+
* @returns the validated node record.
|
|
15
|
+
*/
|
|
16
|
+
export declare function toNode(row: NodeRow): CodegraphNode;
|
|
17
|
+
/**
|
|
18
|
+
* Build a {@link CodegraphEdge} from an `edges` row selected with the `edge_` column prefix that
|
|
19
|
+
* {@link toNode} joins avoid colliding with.
|
|
20
|
+
* @param row - the raw row carrying `edge_source`, `edge_target`, `edge_kind`, `edge_line`,
|
|
21
|
+
* `edge_col`, and `edge_provenance`.
|
|
22
|
+
* @returns the validated edge record.
|
|
23
|
+
*/
|
|
24
|
+
export declare function toEdge(row: NodeRow): CodegraphEdge;
|
|
25
|
+
/**
|
|
26
|
+
* Build a {@link CodegraphFile} from a `files` row.
|
|
27
|
+
* @param row - the raw row, selected with every `files` column present.
|
|
28
|
+
* @returns the validated file record.
|
|
29
|
+
*/
|
|
30
|
+
export declare function toFile(row: NodeRow): CodegraphFile;
|
|
31
|
+
//# sourceMappingURL=rows.d.ts.map
|
package/lib/rows.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable-boundary mapping from SQLite rows to seam records. Every value crossing this module comes
|
|
3
|
+
* from a file an independently versioned indexer wrote, so each field is checked before it becomes a
|
|
4
|
+
* typed record; a row that violates the format fails loud as `CODEGRAPH_MALFORMED_INDEX` rather than
|
|
5
|
+
* reaching a consumer as a plausible-looking wrong value.
|
|
6
|
+
* @module @huanlin/dsh-plugin-codegraph-sqlite/rows
|
|
7
|
+
*/
|
|
8
|
+
import { CodegraphError, CodegraphNodeId } from '@huanlin/dsh-plugin-codegraph-service';
|
|
9
|
+
/**
|
|
10
|
+
* Read a required text column.
|
|
11
|
+
* @param row - the raw row.
|
|
12
|
+
* @param column - the column name.
|
|
13
|
+
* @param what - the record being built, for the failure message.
|
|
14
|
+
* @returns the column's string value.
|
|
15
|
+
*/
|
|
16
|
+
function text(row, column, what) {
|
|
17
|
+
const value = row[column];
|
|
18
|
+
if (typeof value !== 'string') {
|
|
19
|
+
throw new CodegraphError(`${what} has a non-text "${column}"`, 'CODEGRAPH_MALFORMED_INDEX');
|
|
20
|
+
}
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Read a required integer column.
|
|
25
|
+
* @param row - the raw row.
|
|
26
|
+
* @param column - the column name.
|
|
27
|
+
* @param what - the record being built, for the failure message.
|
|
28
|
+
* @returns the column's numeric value.
|
|
29
|
+
*/
|
|
30
|
+
function integer(row, column, what) {
|
|
31
|
+
const value = row[column];
|
|
32
|
+
if (typeof value === 'number')
|
|
33
|
+
return value;
|
|
34
|
+
// A stored INTEGER outside the safe range never reaches here: `node:sqlite` raises a RangeError
|
|
35
|
+
// while reading the row, because this store opens connections without bigint reads.
|
|
36
|
+
throw new CodegraphError(`${what} has a non-integer "${column}"`, 'CODEGRAPH_MALFORMED_INDEX');
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Read an optional text column, treating SQL NULL and the empty string alike as absent.
|
|
40
|
+
* @param row - the raw row.
|
|
41
|
+
* @param column - the column name.
|
|
42
|
+
* @param what - the record being built, for the failure message.
|
|
43
|
+
* @returns the column's string value, or undefined when absent.
|
|
44
|
+
*/
|
|
45
|
+
function optionalText(row, column, what) {
|
|
46
|
+
const value = row[column];
|
|
47
|
+
if (value === null || value === undefined || value === '')
|
|
48
|
+
return undefined;
|
|
49
|
+
if (typeof value !== 'string') {
|
|
50
|
+
throw new CodegraphError(`${what} has a non-text "${column}"`, 'CODEGRAPH_MALFORMED_INDEX');
|
|
51
|
+
}
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Read an optional integer column.
|
|
56
|
+
* @param row - the raw row.
|
|
57
|
+
* @param column - the column name.
|
|
58
|
+
* @param what - the record being built, for the failure message.
|
|
59
|
+
* @returns the column's numeric value, or undefined when NULL.
|
|
60
|
+
*/
|
|
61
|
+
function optionalInteger(row, column, what) {
|
|
62
|
+
const value = row[column];
|
|
63
|
+
if (value === null || value === undefined)
|
|
64
|
+
return undefined;
|
|
65
|
+
return integer(row, column, what);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Read a boolean stored as INTEGER 0/1, treating NULL as false.
|
|
69
|
+
* @param row - the raw row.
|
|
70
|
+
* @param column - the column name.
|
|
71
|
+
* @returns whether the flag is set.
|
|
72
|
+
*/
|
|
73
|
+
function flag(row, column) {
|
|
74
|
+
return row[column] === 1;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Read a JSON-array text column.
|
|
78
|
+
* @param row - the raw row.
|
|
79
|
+
* @param column - the column name.
|
|
80
|
+
* @param what - the record being built, for the failure message.
|
|
81
|
+
* @returns the parsed strings, or an empty array when the column is NULL or empty.
|
|
82
|
+
*/
|
|
83
|
+
function stringArray(row, column, what) {
|
|
84
|
+
const raw = optionalText(row, column, what);
|
|
85
|
+
if (raw === undefined)
|
|
86
|
+
return [];
|
|
87
|
+
let parsed;
|
|
88
|
+
try {
|
|
89
|
+
parsed = JSON.parse(raw);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// Only JSON.parse runs in the try; any throw here is a syntax error in the stored text, which
|
|
93
|
+
// is exactly the malformed-index case reported below.
|
|
94
|
+
throw new CodegraphError(`${what} has an unparseable "${column}"`, 'CODEGRAPH_MALFORMED_INDEX');
|
|
95
|
+
}
|
|
96
|
+
if (!Array.isArray(parsed) || parsed.some(entry => typeof entry !== 'string')) {
|
|
97
|
+
throw new CodegraphError(`${what} has a non-string-array "${column}"`, 'CODEGRAPH_MALFORMED_INDEX');
|
|
98
|
+
}
|
|
99
|
+
return parsed;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Build a {@link CodegraphNode} from a `nodes` row.
|
|
103
|
+
* @param row - the raw row, selected with every `nodes` column present.
|
|
104
|
+
* @returns the validated node record.
|
|
105
|
+
*/
|
|
106
|
+
export function toNode(row) {
|
|
107
|
+
const what = 'a graph node';
|
|
108
|
+
const id = text(row, 'id', what);
|
|
109
|
+
const named = `graph node "${id}"`;
|
|
110
|
+
const docstring = optionalText(row, 'docstring', named);
|
|
111
|
+
const signature = optionalText(row, 'signature', named);
|
|
112
|
+
const visibility = optionalText(row, 'visibility', named);
|
|
113
|
+
return {
|
|
114
|
+
id: CodegraphNodeId(id),
|
|
115
|
+
kind: text(row, 'kind', named),
|
|
116
|
+
name: text(row, 'name', named),
|
|
117
|
+
qualifiedName: text(row, 'qualified_name', named),
|
|
118
|
+
filePath: text(row, 'file_path', named),
|
|
119
|
+
language: text(row, 'language', named),
|
|
120
|
+
startLine: integer(row, 'start_line', named),
|
|
121
|
+
endLine: integer(row, 'end_line', named),
|
|
122
|
+
startColumn: integer(row, 'start_column', named),
|
|
123
|
+
endColumn: integer(row, 'end_column', named),
|
|
124
|
+
...docstring === undefined ? {} : { docstring },
|
|
125
|
+
...signature === undefined ? {} : { signature },
|
|
126
|
+
...visibility === undefined ? {} : { visibility },
|
|
127
|
+
isExported: flag(row, 'is_exported'),
|
|
128
|
+
isAsync: flag(row, 'is_async'),
|
|
129
|
+
isStatic: flag(row, 'is_static'),
|
|
130
|
+
isAbstract: flag(row, 'is_abstract'),
|
|
131
|
+
decorators: stringArray(row, 'decorators', named),
|
|
132
|
+
typeParameters: stringArray(row, 'type_parameters', named),
|
|
133
|
+
updatedAt: integer(row, 'updated_at', named),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Build a {@link CodegraphEdge} from an `edges` row selected with the `edge_` column prefix that
|
|
138
|
+
* {@link toNode} joins avoid colliding with.
|
|
139
|
+
* @param row - the raw row carrying `edge_source`, `edge_target`, `edge_kind`, `edge_line`,
|
|
140
|
+
* `edge_col`, and `edge_provenance`.
|
|
141
|
+
* @returns the validated edge record.
|
|
142
|
+
*/
|
|
143
|
+
export function toEdge(row) {
|
|
144
|
+
const what = 'a graph edge';
|
|
145
|
+
const line = optionalInteger(row, 'edge_line', what);
|
|
146
|
+
const column = optionalInteger(row, 'edge_col', what);
|
|
147
|
+
const provenance = optionalText(row, 'edge_provenance', what);
|
|
148
|
+
return {
|
|
149
|
+
source: CodegraphNodeId(text(row, 'edge_source', what)),
|
|
150
|
+
target: CodegraphNodeId(text(row, 'edge_target', what)),
|
|
151
|
+
kind: text(row, 'edge_kind', what),
|
|
152
|
+
...line === undefined ? {} : { line },
|
|
153
|
+
...column === undefined ? {} : { column },
|
|
154
|
+
...provenance === undefined ? {} : { provenance },
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Build a {@link CodegraphFile} from a `files` row.
|
|
159
|
+
* @param row - the raw row, selected with every `files` column present.
|
|
160
|
+
* @returns the validated file record.
|
|
161
|
+
*/
|
|
162
|
+
export function toFile(row) {
|
|
163
|
+
const path = text(row, 'path', 'an indexed file');
|
|
164
|
+
const named = `indexed file "${path}"`;
|
|
165
|
+
return {
|
|
166
|
+
path,
|
|
167
|
+
language: text(row, 'language', named),
|
|
168
|
+
size: integer(row, 'size', named),
|
|
169
|
+
nodeCount: integer(row, 'node_count', named),
|
|
170
|
+
modifiedAt: integer(row, 'modified_at', named),
|
|
171
|
+
indexedAt: integer(row, 'indexed_at', named),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
//# sourceMappingURL=rows.js.map
|
package/lib/sql.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQL fragments shared by the store's queries: the column projections the row mappers expect, the
|
|
3
|
+
* ranking expressions that decide which declaration a bare symbol name means, and the escaping that
|
|
4
|
+
* makes an arbitrary model-supplied string a safe FTS5 query.
|
|
5
|
+
*
|
|
6
|
+
* Ranking is expressed in SQL rather than in TypeScript so ordering and truncation happen in the
|
|
7
|
+
* same statement: a `LIMIT` must keep the most relevant matches, which it can only do if the
|
|
8
|
+
* database already knows the order.
|
|
9
|
+
* @module @huanlin/dsh-plugin-codegraph-sqlite/sql
|
|
10
|
+
*/
|
|
11
|
+
/** Every `nodes` column {@link toNode} reads, aliased `n`. */
|
|
12
|
+
export declare const NODE_COLUMNS: string;
|
|
13
|
+
/**
|
|
14
|
+
* Order candidates for a symbol lookup: match exactness first, then declaration relevance, then
|
|
15
|
+
* exported over internal, then file and line so equally ranked results never reorder between runs.
|
|
16
|
+
* Binds the symbol three times, ahead of any other parameter in the statement.
|
|
17
|
+
*/
|
|
18
|
+
export declare const SYMBOL_ORDER = "CASE\n WHEN n.qualified_name = ? THEN 0\n WHEN n.name = ? THEN 1\n WHEN lower(n.name) = lower(?) THEN 2\n ELSE 3\nEND, CASE n.kind\n WHEN 'function' THEN 0\n WHEN 'method' THEN 0\n WHEN 'class' THEN 1\n WHEN 'component' THEN 1\n WHEN 'struct' THEN 1\n WHEN 'interface' THEN 2\n WHEN 'trait' THEN 2\n WHEN 'protocol' THEN 2\n WHEN 'type_alias' THEN 3\n WHEN 'enum' THEN 3\n WHEN 'route' THEN 3\n WHEN 'constant' THEN 4\n WHEN 'variable' THEN 4\n WHEN 'property' THEN 5\n WHEN 'field' THEN 5\n WHEN 'enum_member' THEN 5\n WHEN 'parameter' THEN 6\n WHEN 'namespace' THEN 6\n WHEN 'module' THEN 6\n WHEN 'import' THEN 7\n WHEN 'export' THEN 7\n WHEN 'file' THEN 8\n ELSE 6\nEND, n.is_exported DESC, n.file_path, n.start_line";
|
|
19
|
+
/** Order candidates for a free-text search. Binds the query four times. */
|
|
20
|
+
export declare const SEARCH_ORDER = "CASE\n WHEN n.name = ? THEN 0\n WHEN lower(n.name) = lower(?) THEN 1\n WHEN lower(n.name) LIKE lower(?) || '%' THEN 2\n WHEN lower(n.qualified_name) LIKE '%' || lower(?) || '%' THEN 3\n ELSE 4\nEND, CASE n.kind\n WHEN 'function' THEN 0\n WHEN 'method' THEN 0\n WHEN 'class' THEN 1\n WHEN 'component' THEN 1\n WHEN 'struct' THEN 1\n WHEN 'interface' THEN 2\n WHEN 'trait' THEN 2\n WHEN 'protocol' THEN 2\n WHEN 'type_alias' THEN 3\n WHEN 'enum' THEN 3\n WHEN 'route' THEN 3\n WHEN 'constant' THEN 4\n WHEN 'variable' THEN 4\n WHEN 'property' THEN 5\n WHEN 'field' THEN 5\n WHEN 'enum_member' THEN 5\n WHEN 'parameter' THEN 6\n WHEN 'namespace' THEN 6\n WHEN 'module' THEN 6\n WHEN 'import' THEN 7\n WHEN 'export' THEN 7\n WHEN 'file' THEN 8\n ELSE 6\nEND, n.is_exported DESC, n.file_path, n.start_line";
|
|
21
|
+
/**
|
|
22
|
+
* Turn a model-supplied string into an FTS5 prefix query that cannot be misread as FTS syntax.
|
|
23
|
+
* Doubling the quote characters and wrapping the whole value in quotes makes it one literal phrase,
|
|
24
|
+
* so `AND`, `*`, `:` and parentheses in a symbol name search for themselves instead of changing the
|
|
25
|
+
* query's meaning.
|
|
26
|
+
* @param query - the raw search text.
|
|
27
|
+
* @returns an FTS5 MATCH expression matching the text as a prefix phrase.
|
|
28
|
+
*/
|
|
29
|
+
export declare function ftsPhrase(query: string): string;
|
|
30
|
+
/**
|
|
31
|
+
* Wrap a raw string as a SQL `LIKE` pattern matching it anywhere, escaping the wildcard characters
|
|
32
|
+
* so a symbol containing `%` or `_` matches literally.
|
|
33
|
+
* @param query - the raw search text.
|
|
34
|
+
* @returns the escaped pattern, for use with `LIKE ? ESCAPE '\\'`.
|
|
35
|
+
*/
|
|
36
|
+
export declare function likeAnywhere(query: string): string;
|
|
37
|
+
//# sourceMappingURL=sql.d.ts.map
|
package/lib/sql.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQL fragments shared by the store's queries: the column projections the row mappers expect, the
|
|
3
|
+
* ranking expressions that decide which declaration a bare symbol name means, and the escaping that
|
|
4
|
+
* makes an arbitrary model-supplied string a safe FTS5 query.
|
|
5
|
+
*
|
|
6
|
+
* Ranking is expressed in SQL rather than in TypeScript so ordering and truncation happen in the
|
|
7
|
+
* same statement: a `LIMIT` must keep the most relevant matches, which it can only do if the
|
|
8
|
+
* database already knows the order.
|
|
9
|
+
* @module @huanlin/dsh-plugin-codegraph-sqlite/sql
|
|
10
|
+
*/
|
|
11
|
+
/** Every `nodes` column {@link toNode} reads, aliased `n`. */
|
|
12
|
+
export const NODE_COLUMNS = [
|
|
13
|
+
'n.id',
|
|
14
|
+
'n.kind',
|
|
15
|
+
'n.name',
|
|
16
|
+
'n.qualified_name',
|
|
17
|
+
'n.file_path',
|
|
18
|
+
'n.language',
|
|
19
|
+
'n.start_line',
|
|
20
|
+
'n.end_line',
|
|
21
|
+
'n.start_column',
|
|
22
|
+
'n.end_column',
|
|
23
|
+
'n.docstring',
|
|
24
|
+
'n.signature',
|
|
25
|
+
'n.visibility',
|
|
26
|
+
'n.is_exported',
|
|
27
|
+
'n.is_async',
|
|
28
|
+
'n.is_static',
|
|
29
|
+
'n.is_abstract',
|
|
30
|
+
'n.decorators',
|
|
31
|
+
'n.type_parameters',
|
|
32
|
+
'n.updated_at',
|
|
33
|
+
].join(', ');
|
|
34
|
+
/**
|
|
35
|
+
* Relevance among declarations that share a name. A bare name in a query means the thing that
|
|
36
|
+
* declares behaviour, so callable and type declarations outrank the values and re-exports that
|
|
37
|
+
* merely mention the same identifier: an `import { parse }` node must never win over the `parse`
|
|
38
|
+
* function it imports, and a `file` node never wins at all.
|
|
39
|
+
*/
|
|
40
|
+
const KIND_RANK_CASE = `CASE n.kind
|
|
41
|
+
WHEN 'function' THEN 0
|
|
42
|
+
WHEN 'method' THEN 0
|
|
43
|
+
WHEN 'class' THEN 1
|
|
44
|
+
WHEN 'component' THEN 1
|
|
45
|
+
WHEN 'struct' THEN 1
|
|
46
|
+
WHEN 'interface' THEN 2
|
|
47
|
+
WHEN 'trait' THEN 2
|
|
48
|
+
WHEN 'protocol' THEN 2
|
|
49
|
+
WHEN 'type_alias' THEN 3
|
|
50
|
+
WHEN 'enum' THEN 3
|
|
51
|
+
WHEN 'route' THEN 3
|
|
52
|
+
WHEN 'constant' THEN 4
|
|
53
|
+
WHEN 'variable' THEN 4
|
|
54
|
+
WHEN 'property' THEN 5
|
|
55
|
+
WHEN 'field' THEN 5
|
|
56
|
+
WHEN 'enum_member' THEN 5
|
|
57
|
+
WHEN 'parameter' THEN 6
|
|
58
|
+
WHEN 'namespace' THEN 6
|
|
59
|
+
WHEN 'module' THEN 6
|
|
60
|
+
WHEN 'import' THEN 7
|
|
61
|
+
WHEN 'export' THEN 7
|
|
62
|
+
WHEN 'file' THEN 8
|
|
63
|
+
ELSE 6
|
|
64
|
+
END`;
|
|
65
|
+
/**
|
|
66
|
+
* How exactly a candidate matched the requested symbol: a fully qualified name beats a
|
|
67
|
+
* case-sensitive simple name, which beats a case-insensitive one. Binds the symbol three times.
|
|
68
|
+
*/
|
|
69
|
+
const SYMBOL_TIER_CASE = `CASE
|
|
70
|
+
WHEN n.qualified_name = ? THEN 0
|
|
71
|
+
WHEN n.name = ? THEN 1
|
|
72
|
+
WHEN lower(n.name) = lower(?) THEN 2
|
|
73
|
+
ELSE 3
|
|
74
|
+
END`;
|
|
75
|
+
/**
|
|
76
|
+
* How a candidate matched a free-text search: an exact name beats a case-insensitive one, which
|
|
77
|
+
* beats a prefix, which beats any other match (a substring, or a documentation or signature hit).
|
|
78
|
+
* Binds the query four times.
|
|
79
|
+
*/
|
|
80
|
+
const SEARCH_TIER_CASE = `CASE
|
|
81
|
+
WHEN n.name = ? THEN 0
|
|
82
|
+
WHEN lower(n.name) = lower(?) THEN 1
|
|
83
|
+
WHEN lower(n.name) LIKE lower(?) || '%' THEN 2
|
|
84
|
+
WHEN lower(n.qualified_name) LIKE '%' || lower(?) || '%' THEN 3
|
|
85
|
+
ELSE 4
|
|
86
|
+
END`;
|
|
87
|
+
/**
|
|
88
|
+
* Order candidates for a symbol lookup: match exactness first, then declaration relevance, then
|
|
89
|
+
* exported over internal, then file and line so equally ranked results never reorder between runs.
|
|
90
|
+
* Binds the symbol three times, ahead of any other parameter in the statement.
|
|
91
|
+
*/
|
|
92
|
+
export const SYMBOL_ORDER = `${SYMBOL_TIER_CASE}, ${KIND_RANK_CASE}, n.is_exported DESC, n.file_path, n.start_line`;
|
|
93
|
+
/** Order candidates for a free-text search. Binds the query four times. */
|
|
94
|
+
export const SEARCH_ORDER = `${SEARCH_TIER_CASE}, ${KIND_RANK_CASE}, n.is_exported DESC, n.file_path, n.start_line`;
|
|
95
|
+
/**
|
|
96
|
+
* Turn a model-supplied string into an FTS5 prefix query that cannot be misread as FTS syntax.
|
|
97
|
+
* Doubling the quote characters and wrapping the whole value in quotes makes it one literal phrase,
|
|
98
|
+
* so `AND`, `*`, `:` and parentheses in a symbol name search for themselves instead of changing the
|
|
99
|
+
* query's meaning.
|
|
100
|
+
* @param query - the raw search text.
|
|
101
|
+
* @returns an FTS5 MATCH expression matching the text as a prefix phrase.
|
|
102
|
+
*/
|
|
103
|
+
export function ftsPhrase(query) {
|
|
104
|
+
return `"${query.replaceAll('"', '""')}"*`;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Wrap a raw string as a SQL `LIKE` pattern matching it anywhere, escaping the wildcard characters
|
|
108
|
+
* so a symbol containing `%` or `_` matches literally.
|
|
109
|
+
* @param query - the raw search text.
|
|
110
|
+
* @returns the escaped pattern, for use with `LIKE ? ESCAPE '\\'`.
|
|
111
|
+
*/
|
|
112
|
+
export function likeAnywhere(query) {
|
|
113
|
+
const escaped = query.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_');
|
|
114
|
+
return `%${escaped.toLowerCase()}%`;
|
|
115
|
+
}
|
|
116
|
+
//# sourceMappingURL=sql.js.map
|