@juit/pgproxy-persister 1.3.7 → 1.4.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/dist/index.cjs +3 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.mjs +1 -0
- package/dist/index.mjs.map +1 -1
- package/dist/model.cjs +31 -45
- package/dist/model.cjs.map +1 -1
- package/dist/model.mjs +19 -33
- package/dist/model.mjs.map +1 -1
- package/dist/search.cjs +215 -0
- package/dist/search.cjs.map +6 -0
- package/dist/search.d.ts +211 -0
- package/dist/search.mjs +189 -0
- package/dist/search.mjs.map +6 -0
- package/dist/utils.cjs +53 -0
- package/dist/utils.cjs.map +6 -0
- package/dist/utils.d.ts +4 -0
- package/dist/utils.mjs +25 -0
- package/dist/utils.mjs.map +6 -0
- package/package.json +3 -3
- package/src/index.ts +1 -0
- package/src/model.ts +21 -51
- package/src/search.ts +587 -0
- package/src/utils.ts +33 -0
package/src/search.ts
ADDED
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
import { escape } from '@juit/pgproxy-client'
|
|
2
|
+
|
|
3
|
+
import { assert, encodeSchemaAndName } from './utils'
|
|
4
|
+
|
|
5
|
+
import type { ColumnDefinition, InferSelectType } from './model'
|
|
6
|
+
import type { Persister } from './persister'
|
|
7
|
+
|
|
8
|
+
/* ========================================================================== *
|
|
9
|
+
* TYPES & INTERFACES *
|
|
10
|
+
* ========================================================================== */
|
|
11
|
+
|
|
12
|
+
/* ===== JOINS ============================================================== */
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Definition for a simple (straight) join in a {@link Search}
|
|
16
|
+
*/
|
|
17
|
+
export interface SearchJoin<Schema> {
|
|
18
|
+
/**
|
|
19
|
+
* The column in _the search table_ (passed to the constructor of
|
|
20
|
+
* {@link Search}) referencing the specified `refTable` (defined here).
|
|
21
|
+
*
|
|
22
|
+
* ```sql
|
|
23
|
+
* ... LEFT JOIN "refTable" ON "table"."column" = "refTable"."refColumn"
|
|
24
|
+
* ^^^^^^
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
column: string
|
|
28
|
+
/**
|
|
29
|
+
* The name of the table to _left join_.
|
|
30
|
+
*
|
|
31
|
+
* ```sql
|
|
32
|
+
* ... LEFT JOIN "refTable" ON "table"."column" = "refTable"."refColumn"
|
|
33
|
+
* ^^^^^^^^ ^^^^^^^^
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
refTable: string & keyof Schema
|
|
37
|
+
/**
|
|
38
|
+
* The column in the `refTable` referenced by the _the search table_.
|
|
39
|
+
*
|
|
40
|
+
* ```sql
|
|
41
|
+
* ... LEFT JOIN "refTable" ON "table"."column" = "refTable"."refColumn"
|
|
42
|
+
* ^^^^^^^^^
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
refColumn: string
|
|
46
|
+
/**
|
|
47
|
+
* The column in the referenced table to use as default sort column, when
|
|
48
|
+
* sorting by this join.
|
|
49
|
+
*/
|
|
50
|
+
sortColumn?: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Definition for joins in a {@link Search}
|
|
55
|
+
*
|
|
56
|
+
* Each key is the name of the join as it will appear in the results, and the
|
|
57
|
+
* value defines how to perform the join.
|
|
58
|
+
*
|
|
59
|
+
* See {@link StraightJoin} and {@link LinkedJoin} for details on the fields.
|
|
60
|
+
*/
|
|
61
|
+
export interface SearchJoins<Schema> {
|
|
62
|
+
[ key: string ]: SearchJoin<Schema>
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/* ===== SEARCH OPTIONS ===================================================== */
|
|
66
|
+
|
|
67
|
+
/** Internal interface defining operators available to *single values* */
|
|
68
|
+
interface ValueSearchFilter<
|
|
69
|
+
Schema,
|
|
70
|
+
Table extends string & keyof Schema,
|
|
71
|
+
> {
|
|
72
|
+
name: string & keyof Schema[Table]
|
|
73
|
+
field?: string
|
|
74
|
+
op?: '=' | '!=' | '>' | '>=' | '<' | '<=' | '~' | 'like' | 'ilike'
|
|
75
|
+
value: string | number | Date | boolean | null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Internal interface defining operators available to *array values* */
|
|
79
|
+
interface ArraySearchFilter<
|
|
80
|
+
Schema,
|
|
81
|
+
Table extends string & keyof Schema,
|
|
82
|
+
> {
|
|
83
|
+
name: string & keyof Schema[Table]
|
|
84
|
+
field?: string
|
|
85
|
+
op: 'in' | 'not in'
|
|
86
|
+
value: (string | number | Date | boolean | null)[]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Internal interface defining operators available to *json values* */
|
|
90
|
+
interface JsonSearchFilter<
|
|
91
|
+
Schema,
|
|
92
|
+
Table extends string & keyof Schema,
|
|
93
|
+
> {
|
|
94
|
+
name: string & keyof Schema[Table]
|
|
95
|
+
field?: never
|
|
96
|
+
op: '@>' | '<@'
|
|
97
|
+
value: any
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A filter for a search that matches a single value
|
|
102
|
+
*
|
|
103
|
+
* - `name` is the column name to filter on
|
|
104
|
+
* - `field` is a field to filter on when the column is a complex type (JSONB)
|
|
105
|
+
* - `op` is the operator to use for the filter (default: `=`)
|
|
106
|
+
* - `value` is the value to filter for
|
|
107
|
+
*
|
|
108
|
+
* All operators are defined as per PostgreSQL documentation, with few notable
|
|
109
|
+
* exceptions:
|
|
110
|
+
*
|
|
111
|
+
* - `~` is an alias to the `ilike` operator
|
|
112
|
+
* - `in` and `not in` are used to match a value against an array of possible
|
|
113
|
+
* values using the `... = ANY(...)` or `... != ALL(...)` constructs
|
|
114
|
+
* - `@>` and `<@` will accept single values as well as arrays.
|
|
115
|
+
* - `!=` and `=` will use the PostgreSQL `IS (NOT) DISTINCT FROM` semantics
|
|
116
|
+
* to properly handle `NULL` comparisons.
|
|
117
|
+
*/
|
|
118
|
+
export type SearchFilter<
|
|
119
|
+
Schema,
|
|
120
|
+
Table extends string & keyof Schema,
|
|
121
|
+
> = ValueSearchFilter<Schema, Table> | ArraySearchFilter<Schema, Table> | JsonSearchFilter<Schema, Table>
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Base interface for querying results via our {@link Search}.
|
|
125
|
+
*/
|
|
126
|
+
export interface SearchQuery<
|
|
127
|
+
Schema,
|
|
128
|
+
Table extends string & keyof Schema,
|
|
129
|
+
Joins extends SearchJoins<Schema> = {},
|
|
130
|
+
> {
|
|
131
|
+
/** An optional set of filters to apply */
|
|
132
|
+
filters?: SearchFilter<Schema, Table>[]
|
|
133
|
+
/** An optional column to sort by */
|
|
134
|
+
sort?: string & (keyof Schema[Table] | keyof Joins)
|
|
135
|
+
/** The order to sort by (if `sort` is specified, default: 'asc') */
|
|
136
|
+
order?: 'asc' | 'desc'
|
|
137
|
+
/** An optional full-text search query, available for full-text search */
|
|
138
|
+
q?: string
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Full options for querying a limited set of results via our {@link Search}.
|
|
143
|
+
*/
|
|
144
|
+
export interface SearchOptions<
|
|
145
|
+
Schema,
|
|
146
|
+
Table extends string & keyof Schema,
|
|
147
|
+
Joins extends SearchJoins<Schema> = {},
|
|
148
|
+
> extends SearchQuery<Schema, Table, Joins> {
|
|
149
|
+
/** Offset to start returning rows from (default: 0) */
|
|
150
|
+
offset?: number
|
|
151
|
+
/** Maximum number of rows to return (default: 20, unlimited if 0) */
|
|
152
|
+
limit?: number
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Extra (manual) SQL to further customize our {@link Search} queries.
|
|
157
|
+
*/
|
|
158
|
+
export interface SearchExtra {
|
|
159
|
+
/** Extra `WHERE` clause to add to our search */
|
|
160
|
+
where: string
|
|
161
|
+
/** Parameters for the extra `WHERE` clause */
|
|
162
|
+
params: any[]
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/* ===== SEARCH RESULTS ===================================================== */
|
|
166
|
+
|
|
167
|
+
/** A single search result row (with joins) */
|
|
168
|
+
export type SearchResult<
|
|
169
|
+
Schema,
|
|
170
|
+
Table extends string & keyof Schema,
|
|
171
|
+
Joins extends SearchJoins<Schema> = {},
|
|
172
|
+
> =
|
|
173
|
+
Schema[Table] extends Record<string, ColumnDefinition> ?
|
|
174
|
+
// This is the main table's column field
|
|
175
|
+
InferSelectType<Schema[Table]> & {
|
|
176
|
+
// For each join, add a field with the joined table's inferred type
|
|
177
|
+
[ key in keyof Joins ] : Joins[key]['refTable'] extends keyof Schema ?
|
|
178
|
+
// If the column referencing this join is nullable, the result can be null
|
|
179
|
+
Schema[Joins[key]['refTable']] extends Record<string, ColumnDefinition> ?
|
|
180
|
+
Schema[Table][Joins[key]['column']]['isNullable'] extends true ?
|
|
181
|
+
InferSelectType<Schema[Joins[key]['refTable']]> | null :
|
|
182
|
+
InferSelectType<Schema[Joins[key]['refTable']]> :
|
|
183
|
+
// If the joined table isn't a column def, we can't infer anything
|
|
184
|
+
unknown :
|
|
185
|
+
// If the table doesn't exist in the schema, we can't infer anything
|
|
186
|
+
unknown
|
|
187
|
+
} : never
|
|
188
|
+
|
|
189
|
+
/** What's being returned by our `search` */
|
|
190
|
+
export interface SearchResults<
|
|
191
|
+
Schema,
|
|
192
|
+
Table extends string & keyof Schema,
|
|
193
|
+
Joins extends SearchJoins<Schema> = {},
|
|
194
|
+
> {
|
|
195
|
+
/** The total length of all available results (without offset or limit) */
|
|
196
|
+
total: number
|
|
197
|
+
/** The lines queried (truncated by offset and limit) */
|
|
198
|
+
rows: SearchResult<Schema, Table, Joins>[]
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/* ===== SEARCH ============================================================= */
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* An object to perform searches on a given table in our {@link Persister}
|
|
205
|
+
*/
|
|
206
|
+
export interface Search<
|
|
207
|
+
Schema,
|
|
208
|
+
Table extends string & keyof Schema,
|
|
209
|
+
Joins extends SearchJoins<Schema>,
|
|
210
|
+
> {
|
|
211
|
+
/**
|
|
212
|
+
* Return the first result (if any) matching the specified query.
|
|
213
|
+
*
|
|
214
|
+
* This will intrinsically limit the search to 1 result.
|
|
215
|
+
*
|
|
216
|
+
* @param query The query to filter results by
|
|
217
|
+
* @param extra Optional extra SQL to customize the search
|
|
218
|
+
* @returns The first matching result, or `undefined` if no results matched
|
|
219
|
+
*/
|
|
220
|
+
find(query: SearchQuery<Schema, Table, Joins>, extra?: SearchExtra): Promise<SearchResult<Schema, Table, Joins> | undefined>
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Return the raw SQL query and parameters for the specified options.
|
|
224
|
+
*
|
|
225
|
+
* @param options The search options to generate SQL for
|
|
226
|
+
* @param extra Optional extra SQL to customize the search
|
|
227
|
+
* @returns A tuple containing the SQL string and its parameters
|
|
228
|
+
*/
|
|
229
|
+
query(options: SearchOptions<Schema, Table, Joins>, extra?: SearchExtra): [ sql: string, params: any[] ]
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Perform a search with the specified options.
|
|
233
|
+
*
|
|
234
|
+
* @param options The search options to use
|
|
235
|
+
* @param extra Optional extra SQL to customize the search
|
|
236
|
+
* @returns The search results
|
|
237
|
+
*/
|
|
238
|
+
search(options: SearchOptions<Schema, Table, Joins>, extra?: SearchExtra): Promise<SearchResults<Schema, Table, Joins>>
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* A constructor for our {@link Search} object
|
|
243
|
+
*/
|
|
244
|
+
export interface SearchConstructor {
|
|
245
|
+
/**
|
|
246
|
+
* Construct a {@link Search} object using the specified {@link Persister},
|
|
247
|
+
* operating on the specified table.
|
|
248
|
+
*
|
|
249
|
+
* @param persister The {@link Persister} instance to use
|
|
250
|
+
* @param table The table to perform searches on
|
|
251
|
+
*/
|
|
252
|
+
new<
|
|
253
|
+
P extends Persister,
|
|
254
|
+
T extends string & (P extends Persister<infer S> ? keyof S : never),
|
|
255
|
+
>(
|
|
256
|
+
persister: P,
|
|
257
|
+
table: T,
|
|
258
|
+
): Search<P extends Persister<infer S> ? S : never, T, {}>;
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Construct a {@link Search} object using the specified {@link Persister},
|
|
262
|
+
* operating on the specified table, and using the specified full-text search
|
|
263
|
+
* column (TSVECTOR) to perform `q` searches.
|
|
264
|
+
*
|
|
265
|
+
* @param persister The {@link Persister} instance to use
|
|
266
|
+
* @param table The table to perform searches on
|
|
267
|
+
* @param fullTextSearchColumn The column to use for full-text searches
|
|
268
|
+
*/
|
|
269
|
+
new<
|
|
270
|
+
P extends Persister,
|
|
271
|
+
T extends string & (P extends Persister<infer S> ? keyof S : never),
|
|
272
|
+
>(
|
|
273
|
+
persister: P,
|
|
274
|
+
table: T,
|
|
275
|
+
fullTextSearchColumn: string,
|
|
276
|
+
): Search<P extends Persister<infer S> ? S : never, T, {}>;
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Construct a {@link Search} object using the specified {@link Persister},
|
|
280
|
+
* operating on the specified table, joining external tables.
|
|
281
|
+
*
|
|
282
|
+
* @param persister The {@link Persister} instance to use
|
|
283
|
+
* @param table The table to perform searches on
|
|
284
|
+
* @param joins The joins to perform
|
|
285
|
+
*/
|
|
286
|
+
new<
|
|
287
|
+
P extends Persister,
|
|
288
|
+
T extends string & (P extends Persister<infer S> ? keyof S : never),
|
|
289
|
+
J extends SearchJoins<P extends Persister<infer S> ? S : never>,
|
|
290
|
+
>(
|
|
291
|
+
persister: P,
|
|
292
|
+
table: T,
|
|
293
|
+
joins: J,
|
|
294
|
+
): Search<P extends Persister<infer S> ? S : never, T, J>;
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Construct a {@link Search} object using the specified {@link Persister},
|
|
298
|
+
* operating on the specified table, joining external tables, and using the
|
|
299
|
+
* specified full-text search column (TSVECTOR) to perform `q` searches.
|
|
300
|
+
*
|
|
301
|
+
* @param persister The {@link Persister} instance to use
|
|
302
|
+
* @param table The table to perform searches on
|
|
303
|
+
* @param joins The joins to perform
|
|
304
|
+
* @param fullTextSearchColumn The column to use for full-text searches
|
|
305
|
+
*/
|
|
306
|
+
new<
|
|
307
|
+
P extends Persister,
|
|
308
|
+
T extends string & (P extends Persister<infer S> ? keyof S : never),
|
|
309
|
+
J extends SearchJoins<P extends Persister<infer S> ? S : never>,
|
|
310
|
+
>(
|
|
311
|
+
persister: P,
|
|
312
|
+
table: T,
|
|
313
|
+
joins: J,
|
|
314
|
+
fullTextSearchColumn: string,
|
|
315
|
+
): Search<P extends Persister<infer S> ? S : never, T, J>;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/* ========================================================================== *
|
|
319
|
+
* INTERNAL IMPLEMENTATION *
|
|
320
|
+
* ========================================================================== */
|
|
321
|
+
|
|
322
|
+
/** A regular expression to match ISO dates */
|
|
323
|
+
const ISO_RE = /^(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+([+-][0-2]\d:[0-5]\d|Z))|(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d([+-][0-2]\d:[0-5]\d|Z))|(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d([+-][0-2]\d:[0-5]\d|Z))$/
|
|
324
|
+
|
|
325
|
+
/** Revive a JSON, parsing ISO dates as {@link Date} objects */
|
|
326
|
+
export function reviver(_key: string, data: any): any {
|
|
327
|
+
if ((typeof data === 'string') && ISO_RE.test(data)) return new Date(data)
|
|
328
|
+
return data
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
class SearchImpl<
|
|
332
|
+
Schema,
|
|
333
|
+
Table extends string & keyof Schema,
|
|
334
|
+
Joins extends SearchJoins<Schema> = {},
|
|
335
|
+
> implements Search<Schema, Table, Joins> {
|
|
336
|
+
/** Our persister instance */
|
|
337
|
+
#persister: Persister<Schema>
|
|
338
|
+
/** The escaped table name */
|
|
339
|
+
#eTable: string
|
|
340
|
+
/** The escaped joins */
|
|
341
|
+
#eJoins: SearchJoins<any>
|
|
342
|
+
/** The full-text search column (if any) */
|
|
343
|
+
#fullTextSearchColumn: string | undefined
|
|
344
|
+
|
|
345
|
+
constructor(persister: Persister<Schema>, table: Table)
|
|
346
|
+
constructor(persister: Persister<Schema>, table: Table, fullTextSearchColumn: string)
|
|
347
|
+
constructor(persister: Persister<Schema>, table: Table, joins: Joins)
|
|
348
|
+
constructor(persister: Persister<Schema>, table: Table, joins: Joins, fullTextSearchColumn: string)
|
|
349
|
+
|
|
350
|
+
constructor(
|
|
351
|
+
persister: Persister<Schema>,
|
|
352
|
+
table: Table,
|
|
353
|
+
joinsOrFullTextSearchColumn?: Joins | string,
|
|
354
|
+
maybeFullTextSearchColumn?: string,
|
|
355
|
+
) {
|
|
356
|
+
this.#persister = persister
|
|
357
|
+
this.#eTable = encodeSchemaAndName(table)
|
|
358
|
+
|
|
359
|
+
let joins: Joins = {} as Joins
|
|
360
|
+
let fullTextSearchColumn: string | undefined = undefined
|
|
361
|
+
|
|
362
|
+
if (typeof joinsOrFullTextSearchColumn === 'string') {
|
|
363
|
+
fullTextSearchColumn = joinsOrFullTextSearchColumn
|
|
364
|
+
} else if (joinsOrFullTextSearchColumn) {
|
|
365
|
+
joins = joinsOrFullTextSearchColumn
|
|
366
|
+
fullTextSearchColumn = maybeFullTextSearchColumn
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
this.#fullTextSearchColumn = fullTextSearchColumn || undefined
|
|
370
|
+
|
|
371
|
+
this.#eJoins = Object.fromEntries(Object.entries(joins).map(([ key, def ]) => {
|
|
372
|
+
return [ key, {
|
|
373
|
+
column: escape(def.column),
|
|
374
|
+
refTable: encodeSchemaAndName(def.refTable),
|
|
375
|
+
refColumn: escape(def.refColumn),
|
|
376
|
+
sortColumn: def.sortColumn ? escape(def.sortColumn) : undefined,
|
|
377
|
+
} as SearchJoin<Schema> ]
|
|
378
|
+
}))
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
#query(
|
|
382
|
+
count: boolean | 'only',
|
|
383
|
+
options: SearchOptions<Schema, Table, Joins>,
|
|
384
|
+
extra?: SearchExtra,
|
|
385
|
+
): [ sql: string, params: any[] ] {
|
|
386
|
+
const {
|
|
387
|
+
offset = 0,
|
|
388
|
+
limit = 20,
|
|
389
|
+
filters = [],
|
|
390
|
+
sort,
|
|
391
|
+
order,
|
|
392
|
+
q,
|
|
393
|
+
} = options
|
|
394
|
+
|
|
395
|
+
const etable = this.#eTable
|
|
396
|
+
const ejoins = this.#eJoins
|
|
397
|
+
|
|
398
|
+
const fields: string[] = []
|
|
399
|
+
const where: string[] = []
|
|
400
|
+
const orderby: string[] = []
|
|
401
|
+
const params: any[] = []
|
|
402
|
+
|
|
403
|
+
// Extra manual SQL *always* goes FIRST in our WHERE clause, its
|
|
404
|
+
// parameters always start at $1
|
|
405
|
+
if (extra) {
|
|
406
|
+
where.push(extra.where)
|
|
407
|
+
params.push(...extra.params)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
let esearch = '' // falsy!
|
|
411
|
+
if (count === 'only') {
|
|
412
|
+
if (this.#fullTextSearchColumn) esearch = escape(this.#fullTextSearchColumn)
|
|
413
|
+
} else if (this.#fullTextSearchColumn) {
|
|
414
|
+
fields.push(`(TO_JSONB(${etable}.*) - $${params.push(this.#fullTextSearchColumn)})`)
|
|
415
|
+
esearch = escape(this.#fullTextSearchColumn)
|
|
416
|
+
} else {
|
|
417
|
+
fields.push( `TO_JSONB(${etable}.*)`)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// The first part of "SELECT ... FROM ..." is our table and its joins
|
|
421
|
+
const from: string[] = [ etable ]
|
|
422
|
+
|
|
423
|
+
// Process our joins, to be added to our table definition
|
|
424
|
+
let joinIndex = 0
|
|
425
|
+
const joinedTables: Record<string, string> = {}
|
|
426
|
+
Object.entries(ejoins).forEach(([ as, { column, refTable, refColumn } ]) => {
|
|
427
|
+
const ealias = escape(`__$${(++ joinIndex).toString(16).padStart(4, '0')}$__`)
|
|
428
|
+
|
|
429
|
+
joinedTables[as] ??= ealias
|
|
430
|
+
|
|
431
|
+
if (count !== 'only') {
|
|
432
|
+
const index = params.push(as)
|
|
433
|
+
fields.push(`JSONB_BUILD_OBJECT($${index}::TEXT, TO_JSONB(${ealias}))`)
|
|
434
|
+
}
|
|
435
|
+
from.push(`LEFT JOIN ${refTable} ${ealias} ON ${etable}.${column} = ${ealias}.${refColumn}`)
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
// Convert sort order into `ORDER BY` components, those come _before_ the
|
|
439
|
+
// default rank-based ordering applied below if the "q" field is present
|
|
440
|
+
if (sort) {
|
|
441
|
+
const joinedOrder = order?.toLocaleLowerCase() === 'desc' ? ' DESC' : ''
|
|
442
|
+
|
|
443
|
+
// Remap sorting by joined column
|
|
444
|
+
if (ejoins[sort]) {
|
|
445
|
+
assert(ejoins[sort].sortColumn, `Sort column for joined field "${sort}" not defined`)
|
|
446
|
+
const joinedTableAlias = joinedTables[sort]
|
|
447
|
+
const joinedColumn = ejoins[sort].sortColumn
|
|
448
|
+
orderby.push(`${joinedTableAlias}.${joinedColumn}${joinedOrder} NULLS LAST`)
|
|
449
|
+
} else {
|
|
450
|
+
orderby.push(`${etable}.${escape(sort)}${joinedOrder}`)
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// See if we have to do something with "q" (full text search)
|
|
455
|
+
if (q) {
|
|
456
|
+
assert(esearch, 'Full-text search column not defined')
|
|
457
|
+
|
|
458
|
+
// simple strings (e.g. "foobar") become prefix matches ("foobar:*")
|
|
459
|
+
// we use a _cast_ here in order to avoid stopwords (e.g. "and:*")
|
|
460
|
+
if (q.match(/^[\w][-@\w]*$/)) {
|
|
461
|
+
from.push(`CROSS JOIN LATERAL CAST(LOWER($${params.push(q + ':*')}) AS tsquery) AS "__query"`)
|
|
462
|
+
|
|
463
|
+
// everything else (e.g. "foo bar") are parsed as "web searches"
|
|
464
|
+
} else {
|
|
465
|
+
from.push(`CROSS JOIN LATERAL websearch_to_tsquery($${params.push(q)}) AS "__query"`)
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Add our ranking order and where clause
|
|
469
|
+
orderby.push(`ts_rank(${etable}.${esearch}, "__query") DESC`)
|
|
470
|
+
where.push(`"__query" @@ ${etable}.${esearch}`)
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// All remaining columns are simple "WHERE column = ..."
|
|
474
|
+
for (const { name, field, op = '=', value } of filters) {
|
|
475
|
+
// Here we have to determine how to build our "column" reference...
|
|
476
|
+
//
|
|
477
|
+
// When we have a field (i.e. JSONB), and the operator is one of the
|
|
478
|
+
// text-matching ones, we have to use the `->>` operator to extract the
|
|
479
|
+
// field as text, and the value (supposedly a string) is used as-is.
|
|
480
|
+
//
|
|
481
|
+
// Otherwise, we use the `->` operator to extract the field as JSONB and
|
|
482
|
+
// the value is stringified as a JSON string, for PostgreSQL to compare.
|
|
483
|
+
//
|
|
484
|
+
// If we don't have a field, we just use the column and value as-is.
|
|
485
|
+
const [ ecolumn, evalue ] =
|
|
486
|
+
(field && [ 'like', 'ilike', '~' ].includes(op))
|
|
487
|
+
? [ `${escape(name)}->>$${params.push(field)}`, value ]
|
|
488
|
+
: field
|
|
489
|
+
? [ `${escape(name)}->$${params.push(field)}`, JSON.stringify(value) ]
|
|
490
|
+
: [ escape(name), value ]
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
// The "in" operator is a special case, as we use the ANY function
|
|
494
|
+
if (op === 'in') {
|
|
495
|
+
const evalue = (field && Array.isArray(value)) ? value.map((v) => JSON.stringify(v)) : value
|
|
496
|
+
where.push(`${etable}.${ecolumn} = ANY($${params.push(evalue)})`)
|
|
497
|
+
continue
|
|
498
|
+
|
|
499
|
+
// The "not in" operator is a special case, as we use the ALL function
|
|
500
|
+
} else if (op === 'not in') {
|
|
501
|
+
const evalue = (field && Array.isArray(value)) ? value.map((v) => JSON.stringify(v)) : value
|
|
502
|
+
where.push(`${etable}.${ecolumn} != ALL($${params.push(evalue)})`)
|
|
503
|
+
continue
|
|
504
|
+
|
|
505
|
+
// The JSONB operators are also special cases
|
|
506
|
+
} else if ((op === '@>') || (op === '<@')) {
|
|
507
|
+
assert(!field, `Field "${field}" cannot be specified when using JSONB operator "${op}" for column "${name}"`)
|
|
508
|
+
where.push(`${etable}.${ecolumn} ${op} ($${params.push(JSON.stringify(value))})::JSONB`)
|
|
509
|
+
continue
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Anything else is a straight operator
|
|
513
|
+
let operator: string
|
|
514
|
+
switch (op) {
|
|
515
|
+
case '>': operator = '>'; break
|
|
516
|
+
case '>=': operator = '>='; break
|
|
517
|
+
case '<': operator = '<'; break
|
|
518
|
+
case '<=': operator = '<='; break
|
|
519
|
+
case 'like': operator = 'LIKE'; break
|
|
520
|
+
case 'ilike': operator = 'ILIKE'; break
|
|
521
|
+
case '~': operator = 'ILIKE'; break
|
|
522
|
+
case '!=': operator = 'IS DISTINCT FROM'; break
|
|
523
|
+
case '=': operator = 'IS NOT DISTINCT FROM'; break
|
|
524
|
+
default: throw new Error(`Unsupported operator "${op}" for column "${name}"`)
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// If we are querying a JSONB field, we need to stringify the value
|
|
528
|
+
where.push(`${etable}.${ecolumn} ${operator} $${params.push(evalue)}`)
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Start building the query
|
|
532
|
+
const result = `(${fields.join(' || ')})::TEXT AS "result"`
|
|
533
|
+
const clauses =
|
|
534
|
+
count === 'only' ? 'COUNT(*) AS "total"' :
|
|
535
|
+
count ? `COUNT(*) OVER() AS "total", ${result}` :
|
|
536
|
+
result
|
|
537
|
+
|
|
538
|
+
let sql = `SELECT ${clauses} FROM ${from.join(' ')}`
|
|
539
|
+
if (where.length) sql += ` WHERE ${where.join(' AND ')}`
|
|
540
|
+
if (orderby.length && (count !== 'only')) sql += ` ORDER BY ${orderby.join(', ')}`
|
|
541
|
+
|
|
542
|
+
// If we have an offset, add it
|
|
543
|
+
if (offset) sql += ` OFFSET $${params.push(offset)}`
|
|
544
|
+
if (limit) sql += ` LIMIT $${params.push(limit)}`
|
|
545
|
+
return [ sql, params ]
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
query(options: SearchOptions<Schema, Table, Joins>, extra?: SearchExtra): [ sql: string, params: any[] ] {
|
|
549
|
+
return this.#query(false, options, extra)
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
async find(options: SearchQuery<Schema, Table, Joins>, extra?: SearchExtra): Promise<SearchResult<Schema, Table, Joins> | undefined> {
|
|
553
|
+
const [ sql, params ] = this.#query(false, { ...options, offset: 0, limit: 1 }, extra)
|
|
554
|
+
|
|
555
|
+
const result = await this.#persister.query<{ total?: number, result: string }>(sql, params)
|
|
556
|
+
if (result.rows[0]) return JSON.parse(result.rows[0].result, reviver)
|
|
557
|
+
return undefined
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
async search(options: SearchOptions<Schema, Table, Joins>, extra?: SearchExtra): Promise<SearchResults<Schema, Table, Joins>> {
|
|
561
|
+
const [ sql, params ] = this.#query(true, options, extra)
|
|
562
|
+
|
|
563
|
+
const result = await this.#persister.query<{ total: number, result: string }>(sql, params).catch((error) => {
|
|
564
|
+
throw new Error(`Error executing search query: ${error.message}`, { cause: { sql, params, error } })
|
|
565
|
+
})
|
|
566
|
+
|
|
567
|
+
if ((result.rows.length === 0) && ((options.offset || 0) > 0)) {
|
|
568
|
+
const [ sql, params ] = this.#query('only', { ...options, offset: 0, limit: undefined }, extra)
|
|
569
|
+
const result = await this.#persister.query<{ total: number }>(sql, params)
|
|
570
|
+
assert(result.rows[0], 'Expected total row in count query')
|
|
571
|
+
const total = Number(result.rows[0].total)
|
|
572
|
+
return { total, rows: [] }
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const rows = result.rows.map((row) => JSON.parse(row.result, reviver))
|
|
576
|
+
const total = Number(result.rows[0]?.total) || 0
|
|
577
|
+
|
|
578
|
+
return { total, rows }
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
/* ========================================================================== *
|
|
584
|
+
* EXPORTS *
|
|
585
|
+
* ========================================================================== */
|
|
586
|
+
|
|
587
|
+
export const Search: SearchConstructor = SearchImpl
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { escape } from '@juit/pgproxy-client'
|
|
2
|
+
|
|
3
|
+
/* ========================================================================== *
|
|
4
|
+
* SIMPLE ASSERTIONS *
|
|
5
|
+
* ========================================================================== */
|
|
6
|
+
|
|
7
|
+
export function assert(assertion: any, message: string): asserts assertion {
|
|
8
|
+
if (! assertion) throw new Error(message)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function assertArray(value: any, message: string): asserts value is any[] {
|
|
12
|
+
assert(Array.isArray(value), message)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function assertObject(value: any, message: string): asserts value is object {
|
|
16
|
+
assert(value && (typeof value === 'object'), message)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/* ========================================================================== *
|
|
20
|
+
* HELPERS *
|
|
21
|
+
* ========================================================================== */
|
|
22
|
+
|
|
23
|
+
export function encodeSchemaAndName(name: string): string {
|
|
24
|
+
const [ schemaOrTable, maybeTable, ...extra ] = name.split('.')
|
|
25
|
+
assert(extra.length === 0, `Invalid table name "${name}"`)
|
|
26
|
+
|
|
27
|
+
const [ schema, table ] = maybeTable ?
|
|
28
|
+
[ schemaOrTable, maybeTable ] :
|
|
29
|
+
[ 'public', schemaOrTable ]
|
|
30
|
+
assert(table, `Invalid table name "${name}"`)
|
|
31
|
+
|
|
32
|
+
return `${escape(schema || 'public')}.${escape(table)}`
|
|
33
|
+
}
|