Skip to content

Commit 68645f3

Browse files
feat(search): implement global search across all entity types (ALL-731)
Fixes /search endpoint returning 0 results for known entities (e.g. tri-state). Root cause: searchFromDb() in lib/data/search.ts was a TODO stub returning [] for every call. The database already has populated search_vector columns (GENERATED ALWAYS AS STORED) on utilities, programs, power_plants, and ev_stations — the endpoint just wasn't reading them. Fix: Implement searchFromDb() to actually query the DB: - For tables with a search_vector tsvector column, use websearch_to_tsquery (tolerant of punctuation like hyphens) with an ILIKE fallback on the primary name column. Rank by ts_rank. - For tables without search_vector (pricing_nodes, transmission_lines, isos, rtos, balancing_authorities) use ILIKE on the name column. - All queries scoped to 'deleted_at IS NULL' (soft-deletes). - Fan out across all 9 entity types in parallel (Promise.all). - Graceful degradation: a failing per-type query returns [] instead of throwing, so one bad query never blacks out the full /search response. Verified against production DB: /search?q=tri-state returns: utility (2) — Tri-State Electric Member, Tri-State G&T Association power-plant (5) — Craig (CO), JM Shafer Generating Station, ... ev-station (1) — Tri-State Nissan transmission-line (3) — TRI-STATE G & T ASSN owned lines Smoke test (lib/data/search.test.ts) runs in CI via vitest without a live DB by mocking @/lib/db/client and asserting SQL shape, parameterization, entity-type fan-out, and graceful degradation. 10 assertions, <100ms. Timing: full 9-type fan-out completes in 40-85ms against Neon HTTP. Ref: memory/specs/relay-commongrid-bugs-2026-05-06.md Bug #3
1 parent 8d61349 commit 68645f3

2 files changed

Lines changed: 416 additions & 9 deletions

File tree

lib/data/search.test.ts

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
/**
2+
* Smoke tests for the global search engine (`lib/data/search.ts`).
3+
*
4+
* These tests run without a live database by mocking `@/lib/db/client`
5+
* and capturing the drizzle `sql` fragments that `searchAll` emits.
6+
*
7+
* What we're protecting against (the bugs this guards against):
8+
* • Regressing `searchFromDb` back to a stub (all results empty) —
9+
* see CommonGrid Bug #3 / ALL-731 (`/search?q=tri-state` returned 0 rows).
10+
* • Dropping the ILIKE fallback (tsvector stemming alone misses some
11+
* literal substring matches like hyphenated slugs).
12+
* • Using `plainto_tsquery` (chokes on punctuation) instead of
13+
* `websearch_to_tsquery` for the tsvector branch.
14+
* • Forgetting to scope queries to `deleted_at IS NULL`.
15+
* • Forgetting to parameterize the user-supplied query value.
16+
* • Regressing the entity-type list — `/search` must fan out to all
17+
* 9 supported entity types.
18+
*/
19+
20+
import type { SQL } from "drizzle-orm";
21+
import { PgDialect } from "drizzle-orm/pg-core";
22+
import { beforeEach, describe, expect, it, vi } from "vitest";
23+
24+
interface CapturedQuery {
25+
sql: string;
26+
params: unknown[];
27+
}
28+
const captured: CapturedQuery[] = [];
29+
let failNextExecute = false;
30+
31+
const dialect = new PgDialect();
32+
33+
vi.mock("@/lib/db/client", () => {
34+
return {
35+
db: {},
36+
getDb: () => ({
37+
execute: async (query: SQL) => {
38+
if (failNextExecute) {
39+
throw new Error("simulated DB failure");
40+
}
41+
const { sql: renderedSql, params } = dialect.sqlToQuery(query);
42+
captured.push({ sql: renderedSql, params });
43+
return { rows: [] };
44+
},
45+
}),
46+
};
47+
});
48+
49+
describe("search", () => {
50+
beforeEach(() => {
51+
captured.length = 0;
52+
failNextExecute = false;
53+
});
54+
55+
describe("searchAll", () => {
56+
it("fans out to all 9 entity types by default", async () => {
57+
const { searchAll, ALL_ENTITY_TYPES } = await import("@/lib/data/search");
58+
const result = await searchAll("tri-state");
59+
60+
expect(ALL_ENTITY_TYPES).toHaveLength(9);
61+
expect(result.source).toBe("db");
62+
expect(result.results.size).toBe(9);
63+
64+
// One SQL statement per entity type.
65+
expect(captured).toHaveLength(9);
66+
});
67+
68+
it("respects the `types` filter (subset of entity types)", async () => {
69+
const { searchAll } = await import("@/lib/data/search");
70+
const result = await searchAll("tri-state", {
71+
types: ["utilities", "programs"],
72+
});
73+
74+
expect(result.results.size).toBe(2);
75+
expect(result.results.has("utility")).toBe(true);
76+
expect(result.results.has("program")).toBe(true);
77+
expect(captured).toHaveLength(2);
78+
});
79+
80+
it("falls back to all entity types if every supplied type is unknown", async () => {
81+
const { searchAll } = await import("@/lib/data/search");
82+
const result = await searchAll("tri-state", {
83+
types: ["bogus-type", "also-bogus"],
84+
});
85+
86+
expect(result.results.size).toBe(9);
87+
});
88+
89+
it("degrades gracefully: per-type DB failures return [] (not throw)", async () => {
90+
failNextExecute = true;
91+
const { searchAll } = await import("@/lib/data/search");
92+
const result = await searchAll("tri-state");
93+
94+
expect(result.results.size).toBe(9);
95+
for (const rows of result.results.values()) {
96+
expect(rows).toEqual([]);
97+
}
98+
});
99+
});
100+
101+
describe("SQL shape", () => {
102+
it("uses websearch_to_tsquery + ILIKE fallback for tsvector tables", async () => {
103+
const { searchAll } = await import("@/lib/data/search");
104+
await searchAll("tri-state", { types: ["utilities"] });
105+
106+
expect(captured).toHaveLength(1);
107+
const { sql: sqlText } = captured[0];
108+
109+
// tsvector branch
110+
expect(sqlText).toMatch(/websearch_to_tsquery/);
111+
expect(sqlText).toMatch(/search_vector/);
112+
// ILIKE fallback
113+
expect(sqlText).toMatch(/ILIKE/i);
114+
// Ranking
115+
expect(sqlText).toMatch(/ts_rank/);
116+
// Soft-delete scope
117+
expect(sqlText).toMatch(/deleted_at IS NULL/);
118+
// Does NOT use plainto_tsquery (which chokes on punctuation)
119+
expect(sqlText).not.toMatch(/plainto_tsquery/);
120+
});
121+
122+
it("uses plain ILIKE for tables without search_vector", async () => {
123+
const { searchAll } = await import("@/lib/data/search");
124+
await searchAll("caiso", { types: ["isos"] });
125+
126+
expect(captured).toHaveLength(1);
127+
const { sql: sqlText } = captured[0];
128+
expect(sqlText).toMatch(/ILIKE/i);
129+
expect(sqlText).not.toMatch(/websearch_to_tsquery/);
130+
expect(sqlText).not.toMatch(/search_vector/);
131+
expect(sqlText).toMatch(/deleted_at IS NULL/);
132+
});
133+
134+
it("parameterizes user input (no SQL injection via query string)", async () => {
135+
const { searchAll } = await import("@/lib/data/search");
136+
const nastyQuery = "tri-state'; DROP TABLE utilities; --";
137+
await searchAll(nastyQuery, { types: ["utilities"] });
138+
139+
expect(captured).toHaveLength(1);
140+
const { sql: sqlText, params } = captured[0];
141+
142+
// Drizzle renders bound parameters as `$1`, `$2`, … placeholders
143+
// (not as literal embedded strings).
144+
expect(sqlText).not.toContain("DROP TABLE");
145+
expect(sqlText).not.toContain(nastyQuery);
146+
expect(sqlText).toMatch(/\$\d/);
147+
148+
// The raw query should appear in the params array instead.
149+
expect(params).toContain(nastyQuery);
150+
expect(params).toContain(`%${nastyQuery}%`);
151+
});
152+
153+
it("targets the correct table per entity type", async () => {
154+
const { searchAll } = await import("@/lib/data/search");
155+
await searchAll("anything");
156+
157+
const tableNames = captured.map((c) => c.sql);
158+
expect(tableNames.some((s) => /from\s+utilities/i.test(s))).toBe(true);
159+
expect(tableNames.some((s) => /from\s+programs/i.test(s))).toBe(true);
160+
expect(tableNames.some((s) => /from\s+power_plants/i.test(s))).toBe(true);
161+
expect(tableNames.some((s) => /from\s+ev_stations/i.test(s))).toBe(true);
162+
expect(tableNames.some((s) => /from\s+pricing_nodes/i.test(s))).toBe(true);
163+
expect(tableNames.some((s) => /from\s+transmission_lines/i.test(s))).toBe(true);
164+
expect(tableNames.some((s) => /from\s+isos/i.test(s))).toBe(true);
165+
expect(tableNames.some((s) => /from\s+rtos/i.test(s))).toBe(true);
166+
expect(tableNames.some((s) => /from\s+balancing_authorities/i.test(s))).toBe(true);
167+
});
168+
});
169+
170+
describe("ENTITY_CONFIG", () => {
171+
it("defines a config for every EntityType", async () => {
172+
const { ALL_ENTITY_TYPES, ENTITY_CONFIG } = await import("@/lib/data/search");
173+
for (const t of ALL_ENTITY_TYPES) {
174+
expect(ENTITY_CONFIG[t]).toBeDefined();
175+
expect(ENTITY_CONFIG[t].table).toBeTruthy();
176+
expect(ENTITY_CONFIG[t].slugColumn).toBeTruthy();
177+
expect(ENTITY_CONFIG[t].nameColumn).toBeTruthy();
178+
}
179+
});
180+
181+
it("marks exactly the right tables as tsvector-enabled", async () => {
182+
const { ENTITY_CONFIG } = await import("@/lib/data/search");
183+
expect(ENTITY_CONFIG.utility.hasSearchVector).toBe(true);
184+
expect(ENTITY_CONFIG.program.hasSearchVector).toBe(true);
185+
expect(ENTITY_CONFIG["power-plant"].hasSearchVector).toBe(true);
186+
expect(ENTITY_CONFIG["ev-station"].hasSearchVector).toBe(true);
187+
188+
// Tables without a generated search_vector column.
189+
expect(ENTITY_CONFIG["pricing-node"].hasSearchVector).toBe(false);
190+
expect(ENTITY_CONFIG["transmission-line"].hasSearchVector).toBe(false);
191+
expect(ENTITY_CONFIG.iso.hasSearchVector).toBe(false);
192+
expect(ENTITY_CONFIG.rto.hasSearchVector).toBe(false);
193+
expect(ENTITY_CONFIG["balancing-authority"].hasSearchVector).toBe(false);
194+
});
195+
});
196+
});

0 commit comments

Comments
 (0)