@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/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
+ }