| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206 |
- /**
- * Exact-name lookups must seek `idx_nodes_lower_name`
- *
- * `nodes` carries two name indexes and neither one can serve
- * `WHERE name = ? COLLATE NOCASE`:
- *
- * - `idx_nodes_name` is BINARY-collated, so NOCASE equality can't use it;
- * - `idx_nodes_lower_name` is an expression index on `lower(name)`, and the
- * planner only matches it against the same expression.
- *
- * So every exact-name lookup written that way degrades to a full table scan.
- * The `LIMIT`s on those queries do not save them: SQLite can only stop early
- * once it has produced `LIMIT` rows, and the common cases — a query term that
- * is not a symbol at all, or a name with only a handful of definitions — never
- * reach it and scan the whole table.
- *
- * These tests read the planner's own verdict rather than a wall-clock number,
- * so they are deterministic and fail loudly if a lookup regresses to a scan.
- * `lower(name) = lower(?)` (not a JS-side `.toLowerCase()`) is the required
- * form — see the folding-parity test at the bottom for why.
- */
- import { describe, it, expect, beforeAll, afterAll } from 'vitest';
- import * as fs from 'fs';
- import * as path from 'path';
- import * as os from 'os';
- import { DatabaseConnection } from '../src/db';
- import { QueryBuilder } from '../src/db/queries';
- import { SqliteDatabase } from '../src/db/sqlite-adapter';
- import { Node } from '../src/types';
- function makeNode(id: string, name: string, filePath = 'src/a.ts'): Node {
- return {
- id,
- kind: 'function',
- name,
- qualifiedName: name,
- filePath,
- language: 'typescript',
- startLine: 1,
- endLine: 2,
- startColumn: 0,
- endColumn: 0,
- updatedAt: Date.now(),
- };
- }
- /** Wraps a db so every `prepare()` is recorded, then delegates unchanged. */
- function recordingDb(raw: SqliteDatabase): { db: SqliteDatabase; sqls: string[] } {
- const sqls: string[] = [];
- const db: SqliteDatabase = {
- prepare(sql: string) {
- sqls.push(sql);
- return raw.prepare(sql);
- },
- exec: (sql: string) => raw.exec(sql),
- pragma: (str: string, options?: { simple?: boolean }) => raw.pragma(str, options),
- transaction: <T>(fn: (...args: any[]) => T) => raw.transaction(fn),
- close: () => raw.close(),
- get open() {
- return raw.open;
- },
- };
- return { db, sqls };
- }
- /** SQL that filters `nodes` on whole-name equality, in either spelling. */
- function exactNameLookups(sqls: string[]): string[] {
- return sqls.filter(
- (s) =>
- /\bFROM\s+nodes\b/i.test(s) &&
- (/\bname\s*(COLLATE\s+NOCASE\s*)?=\s*\?(\s*COLLATE\s+NOCASE)?/i.test(s) ||
- /\blower\(name\)\s*=/i.test(s))
- );
- }
- /** The planner's access path for the `nodes` table in a statement. */
- function nodesAccessPath(raw: SqliteDatabase, sql: string): string {
- const args = new Array((sql.match(/\?/g) ?? []).length).fill('x');
- const rows = raw.prepare(`EXPLAIN QUERY PLAN ${sql}`).all(...args) as { detail: string }[];
- const detail = rows.map((r) => r.detail).find((d) => /\bnodes\b/.test(d));
- return detail ?? rows.map((r) => r.detail).join(' | ');
- }
- describe('exact-name lookups seek idx_nodes_lower_name', () => {
- let dir: string;
- let conn: DatabaseConnection;
- let raw: SqliteDatabase;
- beforeAll(() => {
- dir = fs.mkdtempSync(path.join(os.tmpdir(), 'name-lookup-index-'));
- conn = DatabaseConnection.initialize(path.join(dir, 'test.db'));
- raw = conn.getDb();
- const seed = new QueryBuilder(raw);
- // A corpus wide enough that a scan and a seek can't accidentally agree on
- // ordering, with `handleRequest` deliberately rare (2 nodes) — the shape
- // the LIMITs never short-circuit on.
- const nodes: Node[] = [];
- for (let i = 0; i < 300; i++) {
- nodes.push(makeNode(`filler-${i}`, `filler${i}Symbol`, `src/pkg${i % 7}/f${i}.ts`));
- }
- nodes.push(makeNode('hr-1', 'handleRequest', 'src/server/router.ts'));
- nodes.push(makeNode('hr-2', 'HandleRequest', 'src/server/legacy.ts'));
- for (const n of nodes) seed.insertNode(n);
- });
- afterAll(() => {
- conn.close();
- fs.rmSync(dir, { recursive: true, force: true });
- });
- it('searchNodes issues its exact-name supplement as an index seek', () => {
- const { db, sqls } = recordingDb(raw);
- const q = new QueryBuilder(db);
- const results = q.searchNodes('handleRequest');
- expect(results.length).toBeGreaterThan(0);
- const lookups = exactNameLookups(sqls);
- // Guard against a vacuous pass: the supplement must actually have run.
- expect(lookups.length).toBeGreaterThan(0);
- for (const sql of lookups) {
- expect(nodesAccessPath(raw, sql)).toMatch(/SEARCH nodes USING .*idx_nodes_lower_name/);
- }
- });
- it('findNodesByExactName issues both of its passes as index seeks', () => {
- const { db, sqls } = recordingDb(raw);
- const q = new QueryBuilder(db);
- const results = q.findNodesByExactName(['handleRequest']);
- expect(results.length).toBeGreaterThan(0);
- const lookups = exactNameLookups(sqls);
- // Two passes: the file_path probe and the row fetch.
- expect(lookups.length).toBeGreaterThanOrEqual(2);
- for (const sql of lookups) {
- expect(nodesAccessPath(raw, sql)).toMatch(/SEARCH nodes USING .*idx_nodes_lower_name/);
- }
- });
- it('getNodesByLowerName seeks the index and does not depend on the caller lowering', () => {
- const { db, sqls } = recordingDb(raw);
- const q = new QueryBuilder(db);
- // Previously this took an already-lowered string on trust: anything with an
- // uppercase letter in it silently returned nothing.
- expect(q.getNodesByLowerName('handlerequest').map((n) => n.id).sort()).toEqual([
- 'hr-1',
- 'hr-2',
- ]);
- expect(q.getNodesByLowerName('HandleRequest').map((n) => n.id).sort()).toEqual([
- 'hr-1',
- 'hr-2',
- ]);
- expect(q.getNodesByLowerName('HANDLEREQUEST').map((n) => n.id).sort()).toEqual([
- 'hr-1',
- 'hr-2',
- ]);
- const lookups = exactNameLookups(sqls);
- expect(lookups.length).toBeGreaterThan(0);
- for (const sql of lookups) {
- expect(nodesAccessPath(raw, sql)).toMatch(/SEARCH nodes USING .*idx_nodes_lower_name/);
- }
- });
- it('still matches case-insensitively across both call sites', () => {
- const q = new QueryBuilder(raw);
- const exact = q.findNodesByExactName(['HANDLEREQUEST']);
- expect(exact.map((r) => r.node.id).sort()).toEqual(['hr-1', 'hr-2']);
- const searched = q.searchNodes('HandleRequest');
- const ids = new Set(searched.map((r) => r.node.id));
- expect(ids.has('hr-1')).toBe(true);
- expect(ids.has('hr-2')).toBe(true);
- });
- it('folds exactly what COLLATE NOCASE folded — ASCII only', () => {
- // SQLite's NOCASE and its `lower()` are both ASCII-only. JavaScript's
- // `.toLowerCase()` is not, so lowering the parameter in JS and comparing
- // against `lower(name)` would silently stop matching non-ASCII names that
- // NOCASE used to match. `lower(?)` keeps both sides on SQLite's rules.
- const probe = new QueryBuilder(raw);
- probe.insertNode(makeNode('uni-1', 'Ünïcode', 'src/i18n/a.ts'));
- const found = probe.findNodesByExactName(['Ünïcode']);
- expect(found.map((r) => r.node.id)).toContain('uni-1');
- // The mixed-ASCII half still folds, as NOCASE did.
- probe.insertNode(makeNode('uni-2', 'Ünïcodeloader', 'src/i18n/b.ts'));
- const folded = probe.findNodesByExactName(['ÜnïcodeLOADER']);
- expect(folded.map((r) => r.node.id)).toContain('uni-2');
- // Same rule for the fuzzy-match lookup. Note what this does NOT claim: a
- // caller that lowers in JavaScript first still hands over `ünïcode`, which
- // is not what SQLite's `lower()` makes of `Ünïcode`, so the gap stays open
- // on that side.
- expect(probe.getNodesByLowerName('Ünïcode').map((n) => n.id)).toContain('uni-1');
- expect(probe.getNodesByLowerName('ÜnïcodeLOADER').map((n) => n.id)).toContain('uni-2');
- });
- });
|