@porulle/adapter-pg-search 0.13.0 → 0.14.0
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/README.md +1 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +56 -7
- package/package.json +2 -2
- package/src/index.ts +85 -11
package/README.md
CHANGED
|
@@ -11,7 +11,6 @@ import { pgSearchAdapter } from "@porulle/adapter-pg-search";
|
|
|
11
11
|
export default defineConfig({
|
|
12
12
|
search: {
|
|
13
13
|
adapter: pgSearchAdapter({
|
|
14
|
-
query: (sql, params) => yourPgClient.query(sql, params),
|
|
15
14
|
tableName: "search_index", // default
|
|
16
15
|
dictionary: "english", // default; pick "simple" for non-English heavy stores
|
|
17
16
|
}),
|
|
@@ -20,7 +19,7 @@ export default defineConfig({
|
|
|
20
19
|
});
|
|
21
20
|
```
|
|
22
21
|
|
|
23
|
-
|
|
22
|
+
With no options, the adapter uses the configured `databaseAdapter` after the search module starts. To use a separate search database, pass a `query` function as a thin wrapper around your PG client (`postgres`, `pg`, etc.) that returns `{ rows: ... }`; the adapter sends parameterized queries and preserves that callback when the module initializes.
|
|
24
23
|
|
|
25
24
|
## When to pick this over Meilisearch
|
|
26
25
|
|
package/dist/index.d.ts
CHANGED
|
@@ -3,11 +3,11 @@ export interface PgSearchQueryResultRow {
|
|
|
3
3
|
[key: string]: unknown;
|
|
4
4
|
}
|
|
5
5
|
export interface PgSearchAdapterOptions {
|
|
6
|
-
query
|
|
6
|
+
query?: (sql: string, params: unknown[]) => Promise<{
|
|
7
7
|
rows: PgSearchQueryResultRow[];
|
|
8
8
|
}>;
|
|
9
9
|
tableName?: string;
|
|
10
10
|
dictionary?: string;
|
|
11
11
|
}
|
|
12
|
-
export declare function pgSearchAdapter(options
|
|
12
|
+
export declare function pgSearchAdapter(options?: PgSearchAdapterOptions): SearchAdapter;
|
|
13
13
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,aAAa,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,aAAa,EAMnB,MAAM,eAAe,CAAC;AAGvB,MAAM,WAAW,sBAAsB;IACrC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,sBAAsB;IACrC,KAAK,CAAC,EAAE,CACN,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,OAAO,EAAE,KACd,OAAO,CAAC;QAAE,IAAI,EAAE,sBAAsB,EAAE,CAAA;KAAE,CAAC,CAAC;IACjD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AA8PD,wBAAgB,eAAe,CAC7B,OAAO,GAAE,sBAA2B,GACnC,aAAa,CAkMf"}
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,32 @@
|
|
|
1
1
|
import { Err, Ok, } from "@porulle/core";
|
|
2
|
+
import { sql } from "@porulle/core/drizzle";
|
|
3
|
+
function databaseRows(result) {
|
|
4
|
+
if (Array.isArray(result))
|
|
5
|
+
return result;
|
|
6
|
+
if (result && typeof result === "object") {
|
|
7
|
+
const rows = result.rows;
|
|
8
|
+
if (Array.isArray(rows))
|
|
9
|
+
return rows;
|
|
10
|
+
}
|
|
11
|
+
throw new Error("Configured database execute() did not return rows.");
|
|
12
|
+
}
|
|
13
|
+
function toDatabaseQuery(query, params) {
|
|
14
|
+
const chunks = [];
|
|
15
|
+
const placeholders = /\$(\d+)/g;
|
|
16
|
+
let cursor = 0;
|
|
17
|
+
let match;
|
|
18
|
+
while ((match = placeholders.exec(query)) !== null) {
|
|
19
|
+
const parameterIndex = Number(match[1]) - 1;
|
|
20
|
+
if (parameterIndex < 0 || parameterIndex >= params.length) {
|
|
21
|
+
throw new Error(`Query placeholder $${match[1]} has no matching parameter.`);
|
|
22
|
+
}
|
|
23
|
+
chunks.push(sql.raw(query.slice(cursor, match.index)));
|
|
24
|
+
chunks.push(sql.param(params[parameterIndex]));
|
|
25
|
+
cursor = placeholders.lastIndex;
|
|
26
|
+
}
|
|
27
|
+
chunks.push(sql.raw(query.slice(cursor)));
|
|
28
|
+
return sql.join(chunks, sql.empty());
|
|
29
|
+
}
|
|
2
30
|
function safeIdentifier(value) {
|
|
3
31
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(value)) {
|
|
4
32
|
throw new Error(`Invalid SQL identifier: ${value}`);
|
|
@@ -186,17 +214,35 @@ function computeFacets(documents, requested) {
|
|
|
186
214
|
}
|
|
187
215
|
return output;
|
|
188
216
|
}
|
|
189
|
-
export function pgSearchAdapter(options) {
|
|
217
|
+
export function pgSearchAdapter(options = {}) {
|
|
190
218
|
const table = safeIdentifier(options.tableName ?? "search_index");
|
|
191
219
|
const dictionary = options.dictionary ?? "english";
|
|
220
|
+
let execute = options.query;
|
|
221
|
+
function requireExecute() {
|
|
222
|
+
if (!execute) {
|
|
223
|
+
throw new Error("pgSearchAdapter has no way to reach the database: no `query` was supplied and init() has not run. Pass the adapter through config.search.adapter so the search module can wire it.");
|
|
224
|
+
}
|
|
225
|
+
return execute;
|
|
226
|
+
}
|
|
192
227
|
return {
|
|
193
228
|
providerId: "pg-search",
|
|
229
|
+
init({ db }) {
|
|
230
|
+
if (execute)
|
|
231
|
+
return;
|
|
232
|
+
const database = db;
|
|
233
|
+
execute = async (query, params) => ({
|
|
234
|
+
rows: databaseRows(await (database.dialect === undefined
|
|
235
|
+
? database.execute(query, params)
|
|
236
|
+
: database.execute(toDatabaseQuery(query, params)))),
|
|
237
|
+
});
|
|
238
|
+
},
|
|
194
239
|
async index(documents) {
|
|
240
|
+
const query = requireExecute();
|
|
195
241
|
try {
|
|
196
242
|
if (documents.length === 0)
|
|
197
243
|
return Ok(undefined);
|
|
198
244
|
for (const document of documents) {
|
|
199
|
-
await
|
|
245
|
+
await query(`INSERT INTO ${table} (id, type, slug, title, description, status, categories, brands, text, attributes, payload)
|
|
200
246
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11::jsonb)
|
|
201
247
|
ON CONFLICT (id)
|
|
202
248
|
DO UPDATE SET
|
|
@@ -236,10 +282,11 @@ export function pgSearchAdapter(options) {
|
|
|
236
282
|
}
|
|
237
283
|
},
|
|
238
284
|
async remove(ids) {
|
|
285
|
+
const query = requireExecute();
|
|
239
286
|
try {
|
|
240
287
|
if (ids.length === 0)
|
|
241
288
|
return Ok(undefined);
|
|
242
|
-
await
|
|
289
|
+
await query(`DELETE FROM ${table} WHERE id = ANY($1::text[])`, [ids]);
|
|
243
290
|
return Ok(undefined);
|
|
244
291
|
}
|
|
245
292
|
catch (error) {
|
|
@@ -250,6 +297,7 @@ export function pgSearchAdapter(options) {
|
|
|
250
297
|
}
|
|
251
298
|
},
|
|
252
299
|
async search(params) {
|
|
300
|
+
const query = requireExecute();
|
|
253
301
|
try {
|
|
254
302
|
const page = Math.max(1, params.page ?? 1);
|
|
255
303
|
const limit = Math.max(1, Math.min(100, params.limit ?? 20));
|
|
@@ -258,14 +306,14 @@ export function pgSearchAdapter(options) {
|
|
|
258
306
|
const scoreExpr = params.query.trim().length > 0
|
|
259
307
|
? `ts_rank(to_tsvector('${dictionary}', text), plainto_tsquery('${dictionary}', $1))`
|
|
260
308
|
: "0";
|
|
261
|
-
const rows = await
|
|
309
|
+
const rows = await query(`SELECT id, type, slug, title, description, status, categories, brands, text, attributes, payload, ${scoreExpr} AS score
|
|
262
310
|
FROM ${table}
|
|
263
311
|
${where.sql}
|
|
264
312
|
ORDER BY score DESC, title ASC
|
|
265
313
|
LIMIT $${where.values.length + 1}
|
|
266
314
|
OFFSET $${where.values.length + 2}`, [...where.values, limit, offset]);
|
|
267
|
-
const countRows = await
|
|
268
|
-
const facetRows = await
|
|
315
|
+
const countRows = await query(`SELECT COUNT(*)::int AS total FROM ${table} ${where.sql}`, where.values);
|
|
316
|
+
const facetRows = await query(`SELECT id, type, slug, title, description, status, categories, brands, text, attributes, payload
|
|
269
317
|
FROM ${table}
|
|
270
318
|
${where.sql}`, where.values);
|
|
271
319
|
const documents = facetRows.rows.map((row) => toDocument(row));
|
|
@@ -290,6 +338,7 @@ export function pgSearchAdapter(options) {
|
|
|
290
338
|
}
|
|
291
339
|
},
|
|
292
340
|
async suggest(params) {
|
|
341
|
+
const query = requireExecute();
|
|
293
342
|
try {
|
|
294
343
|
const limit = Math.max(1, Math.min(25, params.limit ?? 10));
|
|
295
344
|
const prefix = params.prefix.trim().toLowerCase();
|
|
@@ -306,7 +355,7 @@ export function pgSearchAdapter(options) {
|
|
|
306
355
|
conditions.push(`payload ->> 'organizationId' = $${values.length}`);
|
|
307
356
|
}
|
|
308
357
|
values.push(limit);
|
|
309
|
-
const rows = await
|
|
358
|
+
const rows = await query(`SELECT DISTINCT title
|
|
310
359
|
FROM ${table}
|
|
311
360
|
WHERE ${conditions.join(" AND ")}
|
|
312
361
|
ORDER BY title ASC
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@porulle/adapter-pg-search",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
}
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@porulle/core": "0.
|
|
14
|
+
"@porulle/core": "0.14.0"
|
|
15
15
|
},
|
|
16
16
|
"devDependencies": {
|
|
17
17
|
"@types/node": "^24.5.2",
|
package/src/index.ts
CHANGED
|
@@ -7,18 +7,64 @@ import {
|
|
|
7
7
|
type SearchQueryParams,
|
|
8
8
|
type SearchQueryResult,
|
|
9
9
|
type SearchSuggestParams,
|
|
10
|
+
type PluginDb,
|
|
10
11
|
} from "@porulle/core";
|
|
12
|
+
import { sql, type SQL } from "@porulle/core/drizzle";
|
|
11
13
|
|
|
12
14
|
export interface PgSearchQueryResultRow {
|
|
13
15
|
[key: string]: unknown;
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
export interface PgSearchAdapterOptions {
|
|
17
|
-
query
|
|
19
|
+
query?: (
|
|
20
|
+
sql: string,
|
|
21
|
+
params: unknown[],
|
|
22
|
+
) => Promise<{ rows: PgSearchQueryResultRow[] }>;
|
|
18
23
|
tableName?: string;
|
|
19
24
|
dictionary?: string;
|
|
20
25
|
}
|
|
21
26
|
|
|
27
|
+
type PgSearchQuery = (
|
|
28
|
+
sql: string,
|
|
29
|
+
params: unknown[],
|
|
30
|
+
) => Promise<{ rows: PgSearchQueryResultRow[] }>;
|
|
31
|
+
|
|
32
|
+
type PgSearchDatabase = {
|
|
33
|
+
execute(query: SQL | string, params?: unknown[]): Promise<unknown>;
|
|
34
|
+
dialect?: unknown;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function databaseRows(result: unknown): PgSearchQueryResultRow[] {
|
|
38
|
+
if (Array.isArray(result)) return result as PgSearchQueryResultRow[];
|
|
39
|
+
if (result && typeof result === "object") {
|
|
40
|
+
const rows = (result as { rows?: unknown }).rows;
|
|
41
|
+
if (Array.isArray(rows)) return rows as PgSearchQueryResultRow[];
|
|
42
|
+
}
|
|
43
|
+
throw new Error("Configured database execute() did not return rows.");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function toDatabaseQuery(query: string, params: unknown[]): SQL {
|
|
47
|
+
const chunks = [];
|
|
48
|
+
const placeholders = /\$(\d+)/g;
|
|
49
|
+
let cursor = 0;
|
|
50
|
+
let match: RegExpExecArray | null;
|
|
51
|
+
|
|
52
|
+
while ((match = placeholders.exec(query)) !== null) {
|
|
53
|
+
const parameterIndex = Number(match[1]) - 1;
|
|
54
|
+
if (parameterIndex < 0 || parameterIndex >= params.length) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`Query placeholder $${match[1]} has no matching parameter.`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
chunks.push(sql.raw(query.slice(cursor, match.index)));
|
|
60
|
+
chunks.push(sql.param(params[parameterIndex]));
|
|
61
|
+
cursor = placeholders.lastIndex;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
chunks.push(sql.raw(query.slice(cursor)));
|
|
65
|
+
return sql.join(chunks, sql.empty());
|
|
66
|
+
}
|
|
67
|
+
|
|
22
68
|
function safeIdentifier(value: string): string {
|
|
23
69
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(value)) {
|
|
24
70
|
throw new Error(`Invalid SQL identifier: ${value}`);
|
|
@@ -96,10 +142,10 @@ function toDocument(row: PgSearchQueryResultRow): SearchDocument {
|
|
|
96
142
|
const attributes = parseAttributes(row.attributes);
|
|
97
143
|
const payload = row.payload && typeof row.payload === "object"
|
|
98
144
|
? row.payload as Record<string, unknown>
|
|
99
|
-
|
|
145
|
+
: {};
|
|
100
146
|
const organizationId = typeof payload.organizationId === "string"
|
|
101
|
-
|
|
102
|
-
|
|
147
|
+
? payload.organizationId
|
|
148
|
+
: undefined;
|
|
103
149
|
return {
|
|
104
150
|
id: String(row.id ?? ""),
|
|
105
151
|
...(organizationId ? { organizationId } : {}),
|
|
@@ -230,19 +276,44 @@ function computeFacets(documents: SearchDocument[], requested?: string[]): Recor
|
|
|
230
276
|
return output;
|
|
231
277
|
}
|
|
232
278
|
|
|
233
|
-
export function pgSearchAdapter(
|
|
279
|
+
export function pgSearchAdapter(
|
|
280
|
+
options: PgSearchAdapterOptions = {},
|
|
281
|
+
): SearchAdapter {
|
|
234
282
|
const table = safeIdentifier(options.tableName ?? "search_index");
|
|
235
283
|
const dictionary = options.dictionary ?? "english";
|
|
284
|
+
let execute: PgSearchQuery | undefined = options.query;
|
|
285
|
+
|
|
286
|
+
function requireExecute(): PgSearchQuery {
|
|
287
|
+
if (!execute) {
|
|
288
|
+
throw new Error(
|
|
289
|
+
"pgSearchAdapter has no way to reach the database: no `query` was supplied and init() has not run. Pass the adapter through config.search.adapter so the search module can wire it.",
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
return execute;
|
|
293
|
+
}
|
|
236
294
|
|
|
237
295
|
return {
|
|
238
296
|
providerId: "pg-search",
|
|
239
297
|
|
|
298
|
+
init({ db }: { db: PluginDb }): void {
|
|
299
|
+
if (execute) return;
|
|
300
|
+
const database = db as unknown as PgSearchDatabase;
|
|
301
|
+
execute = async (query, params) => ({
|
|
302
|
+
rows: databaseRows(
|
|
303
|
+
await (database.dialect === undefined
|
|
304
|
+
? database.execute(query, params)
|
|
305
|
+
: database.execute(toDatabaseQuery(query, params))),
|
|
306
|
+
),
|
|
307
|
+
});
|
|
308
|
+
},
|
|
309
|
+
|
|
240
310
|
async index(documents: SearchDocument[]): Promise<Result<void>> {
|
|
311
|
+
const query = requireExecute();
|
|
241
312
|
try {
|
|
242
313
|
if (documents.length === 0) return Ok(undefined);
|
|
243
314
|
|
|
244
315
|
for (const document of documents) {
|
|
245
|
-
await
|
|
316
|
+
await query(
|
|
246
317
|
`INSERT INTO ${table} (id, type, slug, title, description, status, categories, brands, text, attributes, payload)
|
|
247
318
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11::jsonb)
|
|
248
319
|
ON CONFLICT (id)
|
|
@@ -286,9 +357,10 @@ export function pgSearchAdapter(options: PgSearchAdapterOptions): SearchAdapter
|
|
|
286
357
|
},
|
|
287
358
|
|
|
288
359
|
async remove(ids: string[]): Promise<Result<void>> {
|
|
360
|
+
const query = requireExecute();
|
|
289
361
|
try {
|
|
290
362
|
if (ids.length === 0) return Ok(undefined);
|
|
291
|
-
await
|
|
363
|
+
await query(`DELETE FROM ${table} WHERE id = ANY($1::text[])`, [ids]);
|
|
292
364
|
return Ok(undefined);
|
|
293
365
|
} catch (error) {
|
|
294
366
|
return Err({
|
|
@@ -299,6 +371,7 @@ export function pgSearchAdapter(options: PgSearchAdapterOptions): SearchAdapter
|
|
|
299
371
|
},
|
|
300
372
|
|
|
301
373
|
async search(params: SearchQueryParams): Promise<Result<SearchQueryResult>> {
|
|
374
|
+
const query = requireExecute();
|
|
302
375
|
try {
|
|
303
376
|
const page = Math.max(1, params.page ?? 1);
|
|
304
377
|
const limit = Math.max(1, Math.min(100, params.limit ?? 20));
|
|
@@ -309,7 +382,7 @@ export function pgSearchAdapter(options: PgSearchAdapterOptions): SearchAdapter
|
|
|
309
382
|
? `ts_rank(to_tsvector('${dictionary}', text), plainto_tsquery('${dictionary}', $1))`
|
|
310
383
|
: "0";
|
|
311
384
|
|
|
312
|
-
const rows = await
|
|
385
|
+
const rows = await query(
|
|
313
386
|
`SELECT id, type, slug, title, description, status, categories, brands, text, attributes, payload, ${scoreExpr} AS score
|
|
314
387
|
FROM ${table}
|
|
315
388
|
${where.sql}
|
|
@@ -319,12 +392,12 @@ export function pgSearchAdapter(options: PgSearchAdapterOptions): SearchAdapter
|
|
|
319
392
|
[...where.values, limit, offset],
|
|
320
393
|
);
|
|
321
394
|
|
|
322
|
-
const countRows = await
|
|
395
|
+
const countRows = await query(
|
|
323
396
|
`SELECT COUNT(*)::int AS total FROM ${table} ${where.sql}`,
|
|
324
397
|
where.values,
|
|
325
398
|
);
|
|
326
399
|
|
|
327
|
-
const facetRows = await
|
|
400
|
+
const facetRows = await query(
|
|
328
401
|
`SELECT id, type, slug, title, description, status, categories, brands, text, attributes, payload
|
|
329
402
|
FROM ${table}
|
|
330
403
|
${where.sql}`,
|
|
@@ -354,6 +427,7 @@ export function pgSearchAdapter(options: PgSearchAdapterOptions): SearchAdapter
|
|
|
354
427
|
},
|
|
355
428
|
|
|
356
429
|
async suggest(params: SearchSuggestParams): Promise<Result<string[]>> {
|
|
430
|
+
const query = requireExecute();
|
|
357
431
|
try {
|
|
358
432
|
const limit = Math.max(1, Math.min(25, params.limit ?? 10));
|
|
359
433
|
const prefix = params.prefix.trim().toLowerCase();
|
|
@@ -375,7 +449,7 @@ export function pgSearchAdapter(options: PgSearchAdapterOptions): SearchAdapter
|
|
|
375
449
|
|
|
376
450
|
values.push(limit);
|
|
377
451
|
|
|
378
|
-
const rows = await
|
|
452
|
+
const rows = await query(
|
|
379
453
|
`SELECT DISTINCT title
|
|
380
454
|
FROM ${table}
|
|
381
455
|
WHERE ${conditions.join(" AND ")}
|