@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
- ...(row.payload && typeof row.payload === "object" ? { payload: row.payload } : {}),
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.payload ?? {}),
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.10.8",
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.10.8"
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/eslint-config": "0.1.0",
22
- "@porulle/typescript-config": "0.1.0"
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
- ...(row.payload && typeof row.payload === "object" ? { payload: row.payload as Record<string, unknown> } : {}),
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.payload ?? {}),
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(
@@ -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"}