1
0

name-lookup-index.test.ts 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. /**
  2. * Exact-name lookups must seek `idx_nodes_lower_name`
  3. *
  4. * `nodes` carries two name indexes and neither one can serve
  5. * `WHERE name = ? COLLATE NOCASE`:
  6. *
  7. * - `idx_nodes_name` is BINARY-collated, so NOCASE equality can't use it;
  8. * - `idx_nodes_lower_name` is an expression index on `lower(name)`, and the
  9. * planner only matches it against the same expression.
  10. *
  11. * So every exact-name lookup written that way degrades to a full table scan.
  12. * The `LIMIT`s on those queries do not save them: SQLite can only stop early
  13. * once it has produced `LIMIT` rows, and the common cases — a query term that
  14. * is not a symbol at all, or a name with only a handful of definitions — never
  15. * reach it and scan the whole table.
  16. *
  17. * These tests read the planner's own verdict rather than a wall-clock number,
  18. * so they are deterministic and fail loudly if a lookup regresses to a scan.
  19. * `lower(name) = lower(?)` (not a JS-side `.toLowerCase()`) is the required
  20. * form — see the folding-parity test at the bottom for why.
  21. */
  22. import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  23. import * as fs from 'fs';
  24. import * as path from 'path';
  25. import * as os from 'os';
  26. import { DatabaseConnection } from '../src/db';
  27. import { QueryBuilder } from '../src/db/queries';
  28. import { SqliteDatabase } from '../src/db/sqlite-adapter';
  29. import { Node } from '../src/types';
  30. function makeNode(id: string, name: string, filePath = 'src/a.ts'): Node {
  31. return {
  32. id,
  33. kind: 'function',
  34. name,
  35. qualifiedName: name,
  36. filePath,
  37. language: 'typescript',
  38. startLine: 1,
  39. endLine: 2,
  40. startColumn: 0,
  41. endColumn: 0,
  42. updatedAt: Date.now(),
  43. };
  44. }
  45. /** Wraps a db so every `prepare()` is recorded, then delegates unchanged. */
  46. function recordingDb(raw: SqliteDatabase): { db: SqliteDatabase; sqls: string[] } {
  47. const sqls: string[] = [];
  48. const db: SqliteDatabase = {
  49. prepare(sql: string) {
  50. sqls.push(sql);
  51. return raw.prepare(sql);
  52. },
  53. exec: (sql: string) => raw.exec(sql),
  54. pragma: (str: string, options?: { simple?: boolean }) => raw.pragma(str, options),
  55. transaction: <T>(fn: (...args: any[]) => T) => raw.transaction(fn),
  56. close: () => raw.close(),
  57. get open() {
  58. return raw.open;
  59. },
  60. };
  61. return { db, sqls };
  62. }
  63. /** SQL that filters `nodes` on whole-name equality, in either spelling. */
  64. function exactNameLookups(sqls: string[]): string[] {
  65. return sqls.filter(
  66. (s) =>
  67. /\bFROM\s+nodes\b/i.test(s) &&
  68. (/\bname\s*(COLLATE\s+NOCASE\s*)?=\s*\?(\s*COLLATE\s+NOCASE)?/i.test(s) ||
  69. /\blower\(name\)\s*=/i.test(s))
  70. );
  71. }
  72. /** The planner's access path for the `nodes` table in a statement. */
  73. function nodesAccessPath(raw: SqliteDatabase, sql: string): string {
  74. const args = new Array((sql.match(/\?/g) ?? []).length).fill('x');
  75. const rows = raw.prepare(`EXPLAIN QUERY PLAN ${sql}`).all(...args) as { detail: string }[];
  76. const detail = rows.map((r) => r.detail).find((d) => /\bnodes\b/.test(d));
  77. return detail ?? rows.map((r) => r.detail).join(' | ');
  78. }
  79. describe('exact-name lookups seek idx_nodes_lower_name', () => {
  80. let dir: string;
  81. let conn: DatabaseConnection;
  82. let raw: SqliteDatabase;
  83. beforeAll(() => {
  84. dir = fs.mkdtempSync(path.join(os.tmpdir(), 'name-lookup-index-'));
  85. conn = DatabaseConnection.initialize(path.join(dir, 'test.db'));
  86. raw = conn.getDb();
  87. const seed = new QueryBuilder(raw);
  88. // A corpus wide enough that a scan and a seek can't accidentally agree on
  89. // ordering, with `handleRequest` deliberately rare (2 nodes) — the shape
  90. // the LIMITs never short-circuit on.
  91. const nodes: Node[] = [];
  92. for (let i = 0; i < 300; i++) {
  93. nodes.push(makeNode(`filler-${i}`, `filler${i}Symbol`, `src/pkg${i % 7}/f${i}.ts`));
  94. }
  95. nodes.push(makeNode('hr-1', 'handleRequest', 'src/server/router.ts'));
  96. nodes.push(makeNode('hr-2', 'HandleRequest', 'src/server/legacy.ts'));
  97. for (const n of nodes) seed.insertNode(n);
  98. });
  99. afterAll(() => {
  100. conn.close();
  101. fs.rmSync(dir, { recursive: true, force: true });
  102. });
  103. it('searchNodes issues its exact-name supplement as an index seek', () => {
  104. const { db, sqls } = recordingDb(raw);
  105. const q = new QueryBuilder(db);
  106. const results = q.searchNodes('handleRequest');
  107. expect(results.length).toBeGreaterThan(0);
  108. const lookups = exactNameLookups(sqls);
  109. // Guard against a vacuous pass: the supplement must actually have run.
  110. expect(lookups.length).toBeGreaterThan(0);
  111. for (const sql of lookups) {
  112. expect(nodesAccessPath(raw, sql)).toMatch(/SEARCH nodes USING .*idx_nodes_lower_name/);
  113. }
  114. });
  115. it('findNodesByExactName issues both of its passes as index seeks', () => {
  116. const { db, sqls } = recordingDb(raw);
  117. const q = new QueryBuilder(db);
  118. const results = q.findNodesByExactName(['handleRequest']);
  119. expect(results.length).toBeGreaterThan(0);
  120. const lookups = exactNameLookups(sqls);
  121. // Two passes: the file_path probe and the row fetch.
  122. expect(lookups.length).toBeGreaterThanOrEqual(2);
  123. for (const sql of lookups) {
  124. expect(nodesAccessPath(raw, sql)).toMatch(/SEARCH nodes USING .*idx_nodes_lower_name/);
  125. }
  126. });
  127. it('getNodesByLowerName seeks the index and does not depend on the caller lowering', () => {
  128. const { db, sqls } = recordingDb(raw);
  129. const q = new QueryBuilder(db);
  130. // Previously this took an already-lowered string on trust: anything with an
  131. // uppercase letter in it silently returned nothing.
  132. expect(q.getNodesByLowerName('handlerequest').map((n) => n.id).sort()).toEqual([
  133. 'hr-1',
  134. 'hr-2',
  135. ]);
  136. expect(q.getNodesByLowerName('HandleRequest').map((n) => n.id).sort()).toEqual([
  137. 'hr-1',
  138. 'hr-2',
  139. ]);
  140. expect(q.getNodesByLowerName('HANDLEREQUEST').map((n) => n.id).sort()).toEqual([
  141. 'hr-1',
  142. 'hr-2',
  143. ]);
  144. const lookups = exactNameLookups(sqls);
  145. expect(lookups.length).toBeGreaterThan(0);
  146. for (const sql of lookups) {
  147. expect(nodesAccessPath(raw, sql)).toMatch(/SEARCH nodes USING .*idx_nodes_lower_name/);
  148. }
  149. });
  150. it('still matches case-insensitively across both call sites', () => {
  151. const q = new QueryBuilder(raw);
  152. const exact = q.findNodesByExactName(['HANDLEREQUEST']);
  153. expect(exact.map((r) => r.node.id).sort()).toEqual(['hr-1', 'hr-2']);
  154. const searched = q.searchNodes('HandleRequest');
  155. const ids = new Set(searched.map((r) => r.node.id));
  156. expect(ids.has('hr-1')).toBe(true);
  157. expect(ids.has('hr-2')).toBe(true);
  158. });
  159. it('folds exactly what COLLATE NOCASE folded — ASCII only', () => {
  160. // SQLite's NOCASE and its `lower()` are both ASCII-only. JavaScript's
  161. // `.toLowerCase()` is not, so lowering the parameter in JS and comparing
  162. // against `lower(name)` would silently stop matching non-ASCII names that
  163. // NOCASE used to match. `lower(?)` keeps both sides on SQLite's rules.
  164. const probe = new QueryBuilder(raw);
  165. probe.insertNode(makeNode('uni-1', 'Ünïcode', 'src/i18n/a.ts'));
  166. const found = probe.findNodesByExactName(['Ünïcode']);
  167. expect(found.map((r) => r.node.id)).toContain('uni-1');
  168. // The mixed-ASCII half still folds, as NOCASE did.
  169. probe.insertNode(makeNode('uni-2', 'Ünïcodeloader', 'src/i18n/b.ts'));
  170. const folded = probe.findNodesByExactName(['ÜnïcodeLOADER']);
  171. expect(folded.map((r) => r.node.id)).toContain('uni-2');
  172. // Same rule for the fuzzy-match lookup. Note what this does NOT claim: a
  173. // caller that lowers in JavaScript first still hands over `ünïcode`, which
  174. // is not what SQLite's `lower()` makes of `Ünïcode`, so the gap stays open
  175. // on that side.
  176. expect(probe.getNodesByLowerName('Ünïcode').map((n) => n.id)).toContain('uni-1');
  177. expect(probe.getNodesByLowerName('ÜnïcodeLOADER').map((n) => n.id)).toContain('uni-2');
  178. });
  179. });