@porulle/adapter-pg-search 0.10.8 → 0.13.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.
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,aAAa,EAKnB,MAAM,eAAe,CAAC;AAEvB,MAAM,WAAW,sBAAsB;IACrC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,sBAAsB;IACrC,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC;QAAE,IAAI,EAAE,sBAAsB,EAAE,CAAA;KAAE,CAAC,CAAC;IACvF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAqND,wBAAgB,eAAe,CAAC,OAAO,EAAE,sBAAsB,GAAG,aAAa,CAwK9E"}
|
|
@@ -39,9 +39,46 @@ function parseBrands(value) {
|
|
|
39
39
|
}
|
|
40
40
|
return [];
|
|
41
41
|
}
|
|
42
|
+
function parseAttributes(value) {
|
|
43
|
+
let parsed = value;
|
|
44
|
+
if (typeof value === "string") {
|
|
45
|
+
try {
|
|
46
|
+
parsed = JSON.parse(value);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return {};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
53
|
+
return {};
|
|
54
|
+
const attributes = {};
|
|
55
|
+
for (const [key, entry] of Object.entries(parsed)) {
|
|
56
|
+
if (typeof entry === "string") {
|
|
57
|
+
attributes[key] = entry;
|
|
58
|
+
}
|
|
59
|
+
else if (Array.isArray(entry) && entry.every((item) => typeof item === "string")) {
|
|
60
|
+
attributes[key] = entry;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return attributes;
|
|
64
|
+
}
|
|
65
|
+
function attributeValues(document, name) {
|
|
66
|
+
const value = document.attributes?.[name];
|
|
67
|
+
if (value == null)
|
|
68
|
+
return [];
|
|
69
|
+
return Array.isArray(value) ? value : [value];
|
|
70
|
+
}
|
|
42
71
|
function toDocument(row) {
|
|
72
|
+
const attributes = parseAttributes(row.attributes);
|
|
73
|
+
const payload = row.payload && typeof row.payload === "object"
|
|
74
|
+
? row.payload
|
|
75
|
+
: {};
|
|
76
|
+
const organizationId = typeof payload.organizationId === "string"
|
|
77
|
+
? payload.organizationId
|
|
78
|
+
: undefined;
|
|
43
79
|
return {
|
|
44
80
|
id: String(row.id ?? ""),
|
|
81
|
+
...(organizationId ? { organizationId } : {}),
|
|
45
82
|
type: String(row.type ?? ""),
|
|
46
83
|
slug: String(row.slug ?? ""),
|
|
47
84
|
title: String(row.title ?? ""),
|
|
@@ -50,7 +87,8 @@ function toDocument(row) {
|
|
|
50
87
|
categories: parseCategories(row.categories),
|
|
51
88
|
brands: parseBrands(row.brands),
|
|
52
89
|
text: String(row.text ?? ""),
|
|
53
|
-
...(
|
|
90
|
+
...(Object.keys(attributes).length > 0 ? { attributes } : {}),
|
|
91
|
+
...(Object.keys(payload).length > 0 ? { payload } : {}),
|
|
54
92
|
};
|
|
55
93
|
}
|
|
56
94
|
function buildWhere(params, dictionary) {
|
|
@@ -64,6 +102,10 @@ function buildWhere(params, dictionary) {
|
|
|
64
102
|
values.push(params.filters.type);
|
|
65
103
|
clauses.push(`type = $${values.length}`);
|
|
66
104
|
}
|
|
105
|
+
if (params.filters?.organizationId) {
|
|
106
|
+
values.push(params.filters.organizationId);
|
|
107
|
+
clauses.push(`payload ->> 'organizationId' = $${values.length}`);
|
|
108
|
+
}
|
|
67
109
|
if (params.filters?.status) {
|
|
68
110
|
values.push(params.filters.status);
|
|
69
111
|
clauses.push(`status = $${values.length}`);
|
|
@@ -76,6 +118,20 @@ function buildWhere(params, dictionary) {
|
|
|
76
118
|
values.push(params.filters.brand);
|
|
77
119
|
clauses.push(`$${values.length} = ANY(brands)`);
|
|
78
120
|
}
|
|
121
|
+
for (const [name, requested] of Object.entries(params.filters?.attributes ?? {})) {
|
|
122
|
+
values.push(name);
|
|
123
|
+
const keyPlaceholder = `$${values.length}`;
|
|
124
|
+
const requestedValues = Array.isArray(requested) ? requested : [requested];
|
|
125
|
+
if (requestedValues.length === 0) {
|
|
126
|
+
clauses.push("FALSE");
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const valuePlaceholders = requestedValues.map((value) => {
|
|
130
|
+
values.push(value);
|
|
131
|
+
return `$${values.length}`;
|
|
132
|
+
});
|
|
133
|
+
clauses.push(`EXISTS (SELECT 1 FROM jsonb_array_elements_text(CASE WHEN jsonb_typeof(attributes -> ${keyPlaceholder}) = 'array' THEN attributes -> ${keyPlaceholder} ELSE jsonb_build_array(attributes ->> ${keyPlaceholder}) END) AS attribute_value WHERE attribute_value IN (${valuePlaceholders.join(", ")}))`);
|
|
134
|
+
}
|
|
79
135
|
return {
|
|
80
136
|
sql: clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "",
|
|
81
137
|
values,
|
|
@@ -113,6 +169,21 @@ function computeFacets(documents, requested) {
|
|
|
113
169
|
}
|
|
114
170
|
}
|
|
115
171
|
}
|
|
172
|
+
for (const facet of facets) {
|
|
173
|
+
const attributeName = facet.startsWith("attributes.")
|
|
174
|
+
? facet.slice("attributes.".length)
|
|
175
|
+
: facet;
|
|
176
|
+
if (["type", "status", "category", "categories", "brand", "brands"].includes(attributeName))
|
|
177
|
+
continue;
|
|
178
|
+
const values = {};
|
|
179
|
+
for (const document of documents) {
|
|
180
|
+
for (const value of new Set(attributeValues(document, attributeName))) {
|
|
181
|
+
values[value] = (values[value] ?? 0) + 1;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (Object.keys(values).length > 0)
|
|
185
|
+
output[attributeName] = values;
|
|
186
|
+
}
|
|
116
187
|
return output;
|
|
117
188
|
}
|
|
118
189
|
export function pgSearchAdapter(options) {
|
|
@@ -125,8 +196,8 @@ export function pgSearchAdapter(options) {
|
|
|
125
196
|
if (documents.length === 0)
|
|
126
197
|
return Ok(undefined);
|
|
127
198
|
for (const document of documents) {
|
|
128
|
-
await options.query(`INSERT INTO ${table} (id, type, slug, title, description, status, categories, brands, text, payload)
|
|
129
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb)
|
|
199
|
+
await options.query(`INSERT INTO ${table} (id, type, slug, title, description, status, categories, brands, text, attributes, payload)
|
|
200
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11::jsonb)
|
|
130
201
|
ON CONFLICT (id)
|
|
131
202
|
DO UPDATE SET
|
|
132
203
|
type = EXCLUDED.type,
|
|
@@ -137,6 +208,7 @@ export function pgSearchAdapter(options) {
|
|
|
137
208
|
categories = EXCLUDED.categories,
|
|
138
209
|
brands = EXCLUDED.brands,
|
|
139
210
|
text = EXCLUDED.text,
|
|
211
|
+
attributes = EXCLUDED.attributes,
|
|
140
212
|
payload = EXCLUDED.payload`, [
|
|
141
213
|
document.id,
|
|
142
214
|
document.type,
|
|
@@ -147,7 +219,11 @@ export function pgSearchAdapter(options) {
|
|
|
147
219
|
document.categories,
|
|
148
220
|
document.brands,
|
|
149
221
|
document.text,
|
|
150
|
-
JSON.stringify(document.
|
|
222
|
+
JSON.stringify(document.attributes ?? {}),
|
|
223
|
+
JSON.stringify({
|
|
224
|
+
...(document.payload ?? {}),
|
|
225
|
+
...(document.organizationId ? { organizationId: document.organizationId } : {}),
|
|
226
|
+
}),
|
|
151
227
|
]);
|
|
152
228
|
}
|
|
153
229
|
return Ok(undefined);
|
|
@@ -182,14 +258,14 @@ export function pgSearchAdapter(options) {
|
|
|
182
258
|
const scoreExpr = params.query.trim().length > 0
|
|
183
259
|
? `ts_rank(to_tsvector('${dictionary}', text), plainto_tsquery('${dictionary}', $1))`
|
|
184
260
|
: "0";
|
|
185
|
-
const rows = await options.query(`SELECT id, type, slug, title, description, status, categories, brands, text, payload, ${scoreExpr} AS score
|
|
261
|
+
const rows = await options.query(`SELECT id, type, slug, title, description, status, categories, brands, text, attributes, payload, ${scoreExpr} AS score
|
|
186
262
|
FROM ${table}
|
|
187
263
|
${where.sql}
|
|
188
264
|
ORDER BY score DESC, title ASC
|
|
189
265
|
LIMIT $${where.values.length + 1}
|
|
190
266
|
OFFSET $${where.values.length + 2}`, [...where.values, limit, offset]);
|
|
191
267
|
const countRows = await options.query(`SELECT COUNT(*)::int AS total FROM ${table} ${where.sql}`, where.values);
|
|
192
|
-
const facetRows = await options.query(`SELECT id, type, slug, title, description, status, categories, brands, text, payload
|
|
268
|
+
const facetRows = await options.query(`SELECT id, type, slug, title, description, status, categories, brands, text, attributes, payload
|
|
193
269
|
FROM ${table}
|
|
194
270
|
${where.sql}`, where.values);
|
|
195
271
|
const documents = facetRows.rows.map((row) => toDocument(row));
|
|
@@ -225,6 +301,10 @@ export function pgSearchAdapter(options) {
|
|
|
225
301
|
values.push(params.type);
|
|
226
302
|
conditions.push(`type = $${values.length}`);
|
|
227
303
|
}
|
|
304
|
+
if (params.organizationId) {
|
|
305
|
+
values.push(params.organizationId);
|
|
306
|
+
conditions.push(`payload ->> 'organizationId' = $${values.length}`);
|
|
307
|
+
}
|
|
228
308
|
values.push(limit);
|
|
229
309
|
const rows = await options.query(`SELECT DISTINCT title
|
|
230
310
|
FROM ${table}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@porulle/adapter-pg-search",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -11,15 +11,15 @@
|
|
|
11
11
|
}
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@porulle/core": "0.
|
|
14
|
+
"@porulle/core": "0.13.0"
|
|
15
15
|
},
|
|
16
16
|
"devDependencies": {
|
|
17
17
|
"@types/node": "^24.5.2",
|
|
18
18
|
"eslint": "^9.39.1",
|
|
19
19
|
"typescript": "5.9.2",
|
|
20
20
|
"vitest": "^3.2.4",
|
|
21
|
-
"@porulle/
|
|
22
|
-
"@porulle/
|
|
21
|
+
"@porulle/typescript-config": "0.1.0",
|
|
22
|
+
"@porulle/eslint-config": "0.1.0"
|
|
23
23
|
},
|
|
24
24
|
"publishConfig": {
|
|
25
25
|
"access": "public"
|
package/src/index.ts
CHANGED
|
@@ -64,9 +64,45 @@ function parseBrands(value: unknown): string[] {
|
|
|
64
64
|
return [];
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
function parseAttributes(value: unknown): Record<string, string | string[]> {
|
|
68
|
+
let parsed: unknown = value;
|
|
69
|
+
if (typeof value === "string") {
|
|
70
|
+
try {
|
|
71
|
+
parsed = JSON.parse(value) as unknown;
|
|
72
|
+
} catch {
|
|
73
|
+
return {};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
77
|
+
|
|
78
|
+
const attributes: Record<string, string | string[]> = {};
|
|
79
|
+
for (const [key, entry] of Object.entries(parsed)) {
|
|
80
|
+
if (typeof entry === "string") {
|
|
81
|
+
attributes[key] = entry;
|
|
82
|
+
} else if (Array.isArray(entry) && entry.every((item) => typeof item === "string")) {
|
|
83
|
+
attributes[key] = entry as string[];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return attributes;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function attributeValues(document: SearchDocument, name: string): string[] {
|
|
90
|
+
const value = document.attributes?.[name];
|
|
91
|
+
if (value == null) return [];
|
|
92
|
+
return Array.isArray(value) ? value : [value];
|
|
93
|
+
}
|
|
94
|
+
|
|
67
95
|
function toDocument(row: PgSearchQueryResultRow): SearchDocument {
|
|
96
|
+
const attributes = parseAttributes(row.attributes);
|
|
97
|
+
const payload = row.payload && typeof row.payload === "object"
|
|
98
|
+
? row.payload as Record<string, unknown>
|
|
99
|
+
: {};
|
|
100
|
+
const organizationId = typeof payload.organizationId === "string"
|
|
101
|
+
? payload.organizationId
|
|
102
|
+
: undefined;
|
|
68
103
|
return {
|
|
69
104
|
id: String(row.id ?? ""),
|
|
105
|
+
...(organizationId ? { organizationId } : {}),
|
|
70
106
|
type: String(row.type ?? ""),
|
|
71
107
|
slug: String(row.slug ?? ""),
|
|
72
108
|
title: String(row.title ?? ""),
|
|
@@ -75,7 +111,8 @@ function toDocument(row: PgSearchQueryResultRow): SearchDocument {
|
|
|
75
111
|
categories: parseCategories(row.categories),
|
|
76
112
|
brands: parseBrands(row.brands),
|
|
77
113
|
text: String(row.text ?? ""),
|
|
78
|
-
...(
|
|
114
|
+
...(Object.keys(attributes).length > 0 ? { attributes } : {}),
|
|
115
|
+
...(Object.keys(payload).length > 0 ? { payload } : {}),
|
|
79
116
|
};
|
|
80
117
|
}
|
|
81
118
|
|
|
@@ -96,6 +133,11 @@ function buildWhere(
|
|
|
96
133
|
clauses.push(`type = $${values.length}`);
|
|
97
134
|
}
|
|
98
135
|
|
|
136
|
+
if (params.filters?.organizationId) {
|
|
137
|
+
values.push(params.filters.organizationId);
|
|
138
|
+
clauses.push(`payload ->> 'organizationId' = $${values.length}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
99
141
|
if (params.filters?.status) {
|
|
100
142
|
values.push(params.filters.status);
|
|
101
143
|
clauses.push(`status = $${values.length}`);
|
|
@@ -111,6 +153,23 @@ function buildWhere(
|
|
|
111
153
|
clauses.push(`$${values.length} = ANY(brands)`);
|
|
112
154
|
}
|
|
113
155
|
|
|
156
|
+
for (const [name, requested] of Object.entries(params.filters?.attributes ?? {})) {
|
|
157
|
+
values.push(name);
|
|
158
|
+
const keyPlaceholder = `$${values.length}`;
|
|
159
|
+
const requestedValues = Array.isArray(requested) ? requested : [requested];
|
|
160
|
+
if (requestedValues.length === 0) {
|
|
161
|
+
clauses.push("FALSE");
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const valuePlaceholders = requestedValues.map((value) => {
|
|
165
|
+
values.push(value);
|
|
166
|
+
return `$${values.length}`;
|
|
167
|
+
});
|
|
168
|
+
clauses.push(
|
|
169
|
+
`EXISTS (SELECT 1 FROM jsonb_array_elements_text(CASE WHEN jsonb_typeof(attributes -> ${keyPlaceholder}) = 'array' THEN attributes -> ${keyPlaceholder} ELSE jsonb_build_array(attributes ->> ${keyPlaceholder}) END) AS attribute_value WHERE attribute_value IN (${valuePlaceholders.join(", ")}))`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
114
173
|
return {
|
|
115
174
|
sql: clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "",
|
|
116
175
|
values,
|
|
@@ -154,6 +213,20 @@ function computeFacets(documents: SearchDocument[], requested?: string[]): Recor
|
|
|
154
213
|
}
|
|
155
214
|
}
|
|
156
215
|
|
|
216
|
+
for (const facet of facets) {
|
|
217
|
+
const attributeName = facet.startsWith("attributes.")
|
|
218
|
+
? facet.slice("attributes.".length)
|
|
219
|
+
: facet;
|
|
220
|
+
if (["type", "status", "category", "categories", "brand", "brands"].includes(attributeName)) continue;
|
|
221
|
+
const values: Record<string, number> = {};
|
|
222
|
+
for (const document of documents) {
|
|
223
|
+
for (const value of new Set(attributeValues(document, attributeName))) {
|
|
224
|
+
values[value] = (values[value] ?? 0) + 1;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (Object.keys(values).length > 0) output[attributeName] = values;
|
|
228
|
+
}
|
|
229
|
+
|
|
157
230
|
return output;
|
|
158
231
|
}
|
|
159
232
|
|
|
@@ -170,8 +243,8 @@ export function pgSearchAdapter(options: PgSearchAdapterOptions): SearchAdapter
|
|
|
170
243
|
|
|
171
244
|
for (const document of documents) {
|
|
172
245
|
await options.query(
|
|
173
|
-
`INSERT INTO ${table} (id, type, slug, title, description, status, categories, brands, text, payload)
|
|
174
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb)
|
|
246
|
+
`INSERT INTO ${table} (id, type, slug, title, description, status, categories, brands, text, attributes, payload)
|
|
247
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11::jsonb)
|
|
175
248
|
ON CONFLICT (id)
|
|
176
249
|
DO UPDATE SET
|
|
177
250
|
type = EXCLUDED.type,
|
|
@@ -182,6 +255,7 @@ export function pgSearchAdapter(options: PgSearchAdapterOptions): SearchAdapter
|
|
|
182
255
|
categories = EXCLUDED.categories,
|
|
183
256
|
brands = EXCLUDED.brands,
|
|
184
257
|
text = EXCLUDED.text,
|
|
258
|
+
attributes = EXCLUDED.attributes,
|
|
185
259
|
payload = EXCLUDED.payload`,
|
|
186
260
|
[
|
|
187
261
|
document.id,
|
|
@@ -193,7 +267,11 @@ export function pgSearchAdapter(options: PgSearchAdapterOptions): SearchAdapter
|
|
|
193
267
|
document.categories,
|
|
194
268
|
document.brands,
|
|
195
269
|
document.text,
|
|
196
|
-
JSON.stringify(document.
|
|
270
|
+
JSON.stringify(document.attributes ?? {}),
|
|
271
|
+
JSON.stringify({
|
|
272
|
+
...(document.payload ?? {}),
|
|
273
|
+
...(document.organizationId ? { organizationId: document.organizationId } : {}),
|
|
274
|
+
}),
|
|
197
275
|
],
|
|
198
276
|
);
|
|
199
277
|
}
|
|
@@ -232,7 +310,7 @@ export function pgSearchAdapter(options: PgSearchAdapterOptions): SearchAdapter
|
|
|
232
310
|
: "0";
|
|
233
311
|
|
|
234
312
|
const rows = await options.query(
|
|
235
|
-
`SELECT id, type, slug, title, description, status, categories, brands, text, payload, ${scoreExpr} AS score
|
|
313
|
+
`SELECT id, type, slug, title, description, status, categories, brands, text, attributes, payload, ${scoreExpr} AS score
|
|
236
314
|
FROM ${table}
|
|
237
315
|
${where.sql}
|
|
238
316
|
ORDER BY score DESC, title ASC
|
|
@@ -247,7 +325,7 @@ export function pgSearchAdapter(options: PgSearchAdapterOptions): SearchAdapter
|
|
|
247
325
|
);
|
|
248
326
|
|
|
249
327
|
const facetRows = await options.query(
|
|
250
|
-
`SELECT id, type, slug, title, description, status, categories, brands, text, payload
|
|
328
|
+
`SELECT id, type, slug, title, description, status, categories, brands, text, attributes, payload
|
|
251
329
|
FROM ${table}
|
|
252
330
|
${where.sql}`,
|
|
253
331
|
where.values,
|
|
@@ -290,6 +368,11 @@ export function pgSearchAdapter(options: PgSearchAdapterOptions): SearchAdapter
|
|
|
290
368
|
conditions.push(`type = $${values.length}`);
|
|
291
369
|
}
|
|
292
370
|
|
|
371
|
+
if (params.organizationId) {
|
|
372
|
+
values.push(params.organizationId);
|
|
373
|
+
conditions.push(`payload ->> 'organizationId' = $${values.length}`);
|
|
374
|
+
}
|
|
375
|
+
|
|
293
376
|
values.push(limit);
|
|
294
377
|
|
|
295
378
|
const rows = await options.query(
|
package/dist/src/index.d.ts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,aAAa,EAKnB,MAAM,eAAe,CAAC;AAEvB,MAAM,WAAW,sBAAsB;IACrC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,sBAAsB;IACrC,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC;QAAE,IAAI,EAAE,sBAAsB,EAAE,CAAA;KAAE,CAAC,CAAC;IACvF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AA4ID,wBAAgB,eAAe,CAAC,OAAO,EAAE,sBAAsB,GAAG,aAAa,CA8J9E"}
|