@juit/pgproxy-persister 1.0.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/model.ts ADDED
@@ -0,0 +1,462 @@
1
+ import type { PGQueryable } from '@juit/pgproxy-client'
2
+
3
+ /* ========================================================================== *
4
+ * SIMPLE ASSERTIONS *
5
+ * ========================================================================== */
6
+
7
+ function assert(assertion: any, message: string): asserts assertion {
8
+ if (! assertion) throw new Error(message)
9
+ }
10
+
11
+ function assertArray(value: any, message: string): asserts value is any[] {
12
+ assert(Array.isArray(value), message)
13
+ }
14
+
15
+ function assertObject(value: any, message: string): asserts value is object {
16
+ assert(value && (typeof value === 'object'), message)
17
+ }
18
+
19
+ /* ========================================================================== *
20
+ * TYPE INFERENCE: FROM SCHEMA->TABLE->COLUMN->... TO JS TYPES *
21
+ * ========================================================================== */
22
+
23
+ type SimplifyIntersection<T> = { [ K in keyof T ]: T[K] }
24
+ type OnlyStrings<T> = T extends string ? T : never
25
+
26
+ /** The definition of a column */
27
+ export interface ColumnDefinition<T = any> {
28
+ /** The TypeScript type of the column (from the type parser) */
29
+ type: T,
30
+ /** Whether the column is _nulable_ or not */
31
+ isNullable?: boolean,
32
+ /** Whether the column _specifies a default value_ or not */
33
+ hasDefault?: boolean,
34
+ }
35
+
36
+ /** Infer the TypeScript type suitable for an `INSERT` in a table */
37
+ export type InferInsertType<Table extends Record<string, ColumnDefinition>> =
38
+ SimplifyIntersection<{
39
+ /* First part: all nullable or defaulted columns are optional */
40
+ [ Column in keyof Table as
41
+ Column extends string ?
42
+ Table[Column]['isNullable'] extends true ? Column :
43
+ Table[Column]['hasDefault'] extends true ? Column :
44
+ never :
45
+ never
46
+ ] ? :
47
+ Table[Column]['isNullable'] extends true ?
48
+ Table[Column]['type'] | null :
49
+ Table[Column]['type']
50
+ } & {
51
+ /* Second part: all non-nullable or non-defaulted columns are required */
52
+ [ Column in keyof Table as
53
+ Column extends string ?
54
+ Table[Column]['isNullable'] extends true ? never :
55
+ Table[Column]['hasDefault'] extends true ? never :
56
+ Column :
57
+ never
58
+ ] -? :
59
+ Table[Column]['isNullable'] extends true ?
60
+ Table[Column]['type'] | null :
61
+ Table[Column]['type']
62
+ }>
63
+
64
+ /** Infer the TypeScript type suitable for a `SELECT` from a table */
65
+ export type InferSelectType<Table extends Record<string, ColumnDefinition>> =
66
+ { [ Column in keyof Table as
67
+ Column extends string ? Column : never
68
+ ] -? :
69
+ Table[Column]['isNullable'] extends true ?
70
+ Table[Column]['type'] | null :
71
+ Table[Column]['type']
72
+ }
73
+
74
+ /** Infer the TypeScript type suitable for a `UPDATE` in a table */
75
+ export type InferUpdateType<Table extends Record<string, ColumnDefinition>> =
76
+ { [ Column in keyof Table as
77
+ Column extends string ? Column : never
78
+ ] ? :
79
+ Table[Column]['isNullable'] extends true ?
80
+ Table[Column]['type'] | null :
81
+ Table[Column]['type']
82
+ }
83
+
84
+ /** Infer the available sort values for a table (as required by `ORDER BY`) */
85
+ export type InferSort<Table extends Record<string, ColumnDefinition>> =
86
+ `${OnlyStrings<keyof Table>}${' ASC' | ' asc' | ' DESC' | ' desc' | ''}`
87
+
88
+ /* ========================================================================== *
89
+ * MODEL INTERFACE *
90
+ * ========================================================================== */
91
+
92
+ /** The model interface defines a CRUD interface to PosgreSQL tables */
93
+ export interface Model<Table extends Record<string, ColumnDefinition>> {
94
+ /**
95
+ * Create a row in the table.
96
+ *
97
+ * @param data - The data to insert in the table
98
+ * @returns A record containing all colums from the table (including defaults)
99
+ */
100
+ create(
101
+ data: InferInsertType<Table>,
102
+ ): Promise<InferSelectType<Table>>
103
+
104
+ /**
105
+ * Insert a row in the database or update its contents on conflict.
106
+ *
107
+ * @param keys - The data uniquely identifying the row to upsert (primary key)
108
+ * @param data - The data to associate with the given key (all extra columns)
109
+ * @returns A record containing all colums from the table (including defaults)
110
+ */
111
+ upsert<K extends InferUpdateType<Table>>(
112
+ keys: K,
113
+ data: Omit<InferInsertType<Table>, keyof K>,
114
+ ): Promise<InferSelectType<Table>>
115
+
116
+ /**
117
+ * Read all rows in the table associated with the specified query
118
+ *
119
+ * @param query - The columns whose values need to be queried (for equality)
120
+ * @param sort - Any sort criteria to order the data
121
+ * @param offset - The offset of the results to return
122
+ * @param length - The maximum number of rows to return
123
+ * @returns An array of records containing all columns from the table
124
+ */
125
+ read(
126
+ query?: InferUpdateType<Table>,
127
+ sort?: InferSort<Table> | InferSort<Table>[],
128
+ offset?: number,
129
+ limit?: number,
130
+ ): Promise<InferSelectType<Table>[]>
131
+
132
+ /**
133
+ * Find the _first_ rows in the table associated with the specified query
134
+ *
135
+ * @param query - The columns whose values need to be queried (for equality)
136
+ * @param sort - Any sort criteria to order the data
137
+ * @returns The first records matching the query or `undefined`
138
+ */
139
+ find(
140
+ query?: InferUpdateType<Table>,
141
+ sort?: InferSort<Table> | InferSort<Table>[],
142
+ ): Promise<InferSelectType<Table> | undefined>
143
+
144
+ /**
145
+ * Update all rows in the table matching the specified query.
146
+ *
147
+ * This method _will fail_ when query is the empty object `{}` as we cowardly
148
+ * refuse to update all records in a table (by design).
149
+ *
150
+ * @param query - The columns whose values need to be queried (for equality)
151
+ * @param patch - The updated data to persist in the table
152
+ * @returns An array of updated records containing all columns from the table
153
+ */
154
+ update(
155
+ query: InferUpdateType<Table>,
156
+ patch: InferUpdateType<Table>,
157
+ ): Promise<InferSelectType<Table>[]>
158
+
159
+ /**
160
+ * Delete all rows in the table matching the specified query.
161
+ *
162
+ * This method _will fail_ when query is the empty object `{}` as we cowardly
163
+ * refuse to delete all records in a table (by design).
164
+ *
165
+ * @param query - The columns whose values need to be queried (for equality)
166
+ * @returns The number of rows deleted
167
+ */
168
+ delete(
169
+ query: InferUpdateType<Table>,
170
+ ): Promise<number>
171
+ }
172
+
173
+ /** Constructor for model instances */
174
+ export interface ModelConstructor {
175
+ new <Schema extends Record<string, ColumnDefinition>>(
176
+ queryable: PGQueryable,
177
+ table: string,
178
+ ): Model<Schema>
179
+ }
180
+
181
+ /* ========================================================================== *
182
+ * IMPLEMENTATION *
183
+ * ========================================================================== */
184
+
185
+ /** The tuple `[ SQL, parameters ]` for `query(...)` */
186
+ type Query = [ sql: string, params: any[] ]
187
+
188
+ /** Prepare a `WHERE` partial statement */
189
+ function where(
190
+ query: Record<string, any>,
191
+ params: any[],
192
+ ) : [ ...Query, count: number ] {
193
+ const conditions = []
194
+
195
+ let count = 0
196
+ for (const [ column, value ] of Object.entries(query)) {
197
+ if (value === undefined) continue
198
+ if (value === null) {
199
+ conditions.push(`${escape(column)} IS NULL`)
200
+ } else {
201
+ const index = params.push(value)
202
+ conditions.push(`${escape(column)}=$${index}`)
203
+ }
204
+ count ++
205
+ }
206
+
207
+ return [
208
+ conditions.length ? ` WHERE ${conditions.join(' AND ')}` : '',
209
+ params,
210
+ count,
211
+ ]
212
+ }
213
+
214
+ /** Prepare an `INSERT` statement for a table */
215
+ function insert(
216
+ schema: string,
217
+ table: string,
218
+ query: Record<string, any>,
219
+ ): Query {
220
+ assertObject(query, 'Called INSERT with a non-object')
221
+
222
+ const columns = []
223
+ const placeholders = []
224
+ const values = []
225
+
226
+ for (const [ column, value ] of Object.entries(query)) {
227
+ if (value === undefined) continue
228
+ const index = columns.push(`${escape(column)}`)
229
+ placeholders.push(`$${index}`)
230
+ values.push(value)
231
+ }
232
+
233
+ return [
234
+ columns.length == 0 ?
235
+ `INSERT INTO ${escape(schema)}.${escape(table)} DEFAULT VALUES RETURNING *` :
236
+ `INSERT INTO ${escape(schema)}.${escape(table)} (${columns.join()}) VALUES (${placeholders.join()}) RETURNING *`,
237
+ values,
238
+ ]
239
+ }
240
+
241
+ /** Prepare an _upsert_ (`INSERT ... ON CONFLICT`) statement for a table */
242
+ function upsert(
243
+ schema: string,
244
+ table: string,
245
+ keys: Record<string, any>,
246
+ data: Record<string, any>,
247
+ ): Query {
248
+ assertObject(keys, 'Called UPSERT with a non-object for keys')
249
+ assertObject(data, 'Called UPSERT with a non-object for data')
250
+
251
+ assert(Object.keys(keys).length > 0, 'Called UPSERT with no conflict keys')
252
+ assert(Object.keys(data).length > 0, 'Called UPSERT with no updateable data')
253
+
254
+ /* Keys twice, they go first and override! */
255
+ const object: Record<string, any> = { ...keys, ...data, ...keys }
256
+
257
+ /* For "insert" */
258
+ const columns: string[] = []
259
+ const placeholders: string[] = []
260
+ const values: any[] = []
261
+ for (const [ column, value ] of Object.entries(object)) {
262
+ if (value === undefined) continue
263
+ const index = columns.push(`${escape(column)}`)
264
+ placeholders.push(`$${index}`)
265
+ values.push(value)
266
+ }
267
+
268
+ /* For "on conflict" */
269
+ const conflictKeys: string[] = []
270
+ for (const [ column, value ] of Object.entries(keys)) {
271
+ if (value !== undefined) conflictKeys.push(escape(column))
272
+ }
273
+
274
+ /* For "update" */
275
+ const updates: string[] = []
276
+ for (const [ column, value ] of Object.entries(data)) {
277
+ if (value === undefined) continue
278
+ updates.push(`${escape(column)}=$${updates.length + columns.length + 1}`)
279
+ values.push(value)
280
+ }
281
+
282
+ /* Our "upsert" statement */
283
+ return [
284
+ `INSERT INTO ${escape(schema)}.${escape(table)} (${columns.join()}) VALUES (${placeholders.join()}) ` +
285
+ `ON CONFLICT (${conflictKeys.join(',')}) ` +
286
+ `DO UPDATE SET ${updates.join(',')} RETURNING *`,
287
+ values,
288
+ ]
289
+ }
290
+
291
+ /** Prepare a `SELECT` statement for a table */
292
+ function select(
293
+ schema: string,
294
+ table: string,
295
+ query: Record<string, any>,
296
+ sort: string | string[],
297
+ offset: number,
298
+ limit: number,
299
+ ): Query {
300
+ if (typeof sort === 'string') sort = [ sort ]
301
+ assertObject(query, 'Called SELECT with a non-object query')
302
+ assertArray(sort, 'Called SELECT with a non-array sort')
303
+
304
+ const [ conditions, values ] = where(query, [])
305
+
306
+ const order = []
307
+ for (const field of sort) {
308
+ if (field.toLowerCase().endsWith(' desc')) {
309
+ order.push(`${escape(field.slice(0, -5))} DESC`)
310
+ } else if (field.toLowerCase().endsWith(' asc')) {
311
+ order.push(`${escape(field.slice(0, -4))} ASC`)
312
+ } else {
313
+ order.push(escape(field))
314
+ }
315
+ }
316
+
317
+ const orderby = order.length == 0 ? '' : ` ORDER BY ${order.join(',')}`
318
+
319
+ let sql = `SELECT * FROM ${escape(schema)}.${escape(table)}${conditions}${orderby}`
320
+
321
+ if (offset && (offset > 0)) {
322
+ sql += ` OFFSET $${values.length + 1}`
323
+ values.push(Math.floor(offset))
324
+ }
325
+
326
+ if (limit && (limit > 0)) {
327
+ sql += ` LIMIT $${values.length + 1}`
328
+ values.push(Math.floor(limit))
329
+ }
330
+
331
+ return [ sql, values ]
332
+ }
333
+
334
+ /** Prepare an `UPDATE` statement for a table */
335
+ function update(
336
+ schema: string,
337
+ table: string,
338
+ query: Record<string, any>,
339
+ patch: Record<string, any>,
340
+ ): Query {
341
+ assertObject(query, 'Called UPDATE with a non-object query')
342
+ assertObject(patch, 'Called UPDATE with a non-object patch')
343
+
344
+ const patches = []
345
+ const values = []
346
+
347
+ for (const [ column, value ] of Object.entries(patch)) {
348
+ if (value === undefined) continue
349
+ const index = values.push(value)
350
+ patches.push(`${escape(column)}=$${index}`)
351
+ }
352
+
353
+ if (patches.length === 0) return select(schema, table, query, [], 0, 0)
354
+
355
+ const [ conditions, , count ] = where(query, values)
356
+ assert(count > 0, 'Cowardly refusing to run UPDATE with empty query')
357
+
358
+ const statement = `UPDATE ${escape(schema)}.${escape(table)} SET ${patches.join()}${conditions} RETURNING *`
359
+ return [ statement, values ]
360
+ }
361
+
362
+ /** Prepare a `DELETE` statement for a table */
363
+ function del(
364
+ schema: string,
365
+ table: string,
366
+ query: Record<string, any>,
367
+ ): Query {
368
+ assertObject(query, 'Called DELETE with a non-object query')
369
+
370
+ const [ conditions, values, count ] = where(query, [])
371
+
372
+ assert(count > 0, 'Cowardly refusing to run DELETE with empty query')
373
+
374
+ return [ `DELETE FROM ${escape(schema)}.${escape(table)}${conditions} RETURNING *`, values ]
375
+ }
376
+
377
+ /* ===== MODEL IMPLEMENTATION =============================================== */
378
+
379
+ class ModelImpl<Table extends Record<string, ColumnDefinition>> implements Model<Table> {
380
+ private _connection: PGQueryable
381
+ private _schema: string
382
+ private _table: string
383
+
384
+ constructor(connection: PGQueryable, name: string) {
385
+ this._connection = connection
386
+
387
+ const [ schemaOrTable, maybeTable, ...extra ] = name.split('.')
388
+ assert(extra.length === 0, `Invalid table name "${name}"`)
389
+
390
+ const [ schema, table ] = maybeTable ?
391
+ [ schemaOrTable, maybeTable ] :
392
+ [ 'public', schemaOrTable ]
393
+ assert(table, `Invalid table name "${name}"`)
394
+
395
+ this._schema = schema || 'public'
396
+ this._table = table
397
+ }
398
+
399
+ async create(
400
+ data: InferInsertType<Table>,
401
+ ): Promise<InferSelectType<Table>> {
402
+ const [ sql, params ] = insert(this._schema, this._table, data)
403
+ const result = await this._connection.query<InferSelectType<Table>>(sql, params)
404
+ return result.rows[0]!
405
+ }
406
+
407
+ async upsert<K extends InferUpdateType<Table>>(
408
+ keys: K,
409
+ data: Omit<InferInsertType<Table>, keyof K>,
410
+ ): Promise<InferSelectType<Table>> {
411
+ const [ sql, params ] = upsert(this._schema, this._table, keys, data)
412
+ const result = await this._connection.query<InferSelectType<Table>>(sql, params)
413
+ return result.rows[0]!
414
+ }
415
+
416
+ async read(
417
+ query: InferUpdateType<Table> = {},
418
+ sort: InferSort<Table> | InferSort<Table>[] = [],
419
+ offset: number = 0,
420
+ limit: number = 0,
421
+ ): Promise<InferSelectType<Table>[]> {
422
+ const [ sql, params ] = select(this._schema, this._table, query, sort, offset, limit)
423
+ const result = await this._connection.query<InferSelectType<Table>>(sql, params)
424
+ return result.rows
425
+ }
426
+
427
+ async find(
428
+ query?: InferUpdateType<Table>,
429
+ sort?: InferSort<Table> | InferSort<Table>[],
430
+ ): Promise<InferSelectType<Table> | undefined> {
431
+ const result = await this.read(query, sort, 0, 1)
432
+ return result[0]
433
+ }
434
+
435
+ async update(
436
+ query: InferUpdateType<Table>,
437
+ patch: InferUpdateType<Table>,
438
+ ): Promise<InferSelectType<Table>[]> {
439
+ const [ sql, params ] = update(this._schema, this._table, query, patch)
440
+ const result = await this._connection.query<InferSelectType<Table>>(sql, params)
441
+ return result.rows
442
+ }
443
+
444
+ async delete(
445
+ query: InferUpdateType<Table>,
446
+ ): Promise<number> {
447
+ const [ sql, params ] = del(this._schema, this._table, query)
448
+ const result = await this._connection.query(sql, params)
449
+ return result.rowCount
450
+ }
451
+ }
452
+
453
+ /* ========================================================================== *
454
+ * EXPORTS *
455
+ * ========================================================================== */
456
+
457
+ /** Escape a PostgreSQL identifier (table, column, ... names) */
458
+ export function escape(str: string): string {
459
+ return `"${str.replaceAll('"', '""').trim()}"`
460
+ }
461
+
462
+ export const Model: ModelConstructor = ModelImpl
@@ -0,0 +1,147 @@
1
+ import { PGClient } from '@juit/pgproxy-client'
2
+
3
+ import { Model } from './model'
4
+
5
+ import type { PGQueryable, PGResult, PGTransactionable } from '@juit/pgproxy-client'
6
+ import type { Registry } from '@juit/pgproxy-types'
7
+ import type { ColumnDefinition } from './model'
8
+
9
+ /* ========================================================================== *
10
+ * TYPES *
11
+ * ========================================================================== */
12
+
13
+ /* Infer the `Model` type from a schema and column name */
14
+ export type InferModelType<Schema, Table extends string & keyof Schema> =
15
+ Schema[Table] extends Record<string, ColumnDefinition> ?
16
+ Model<Schema[Table]> :
17
+ never
18
+
19
+ export interface ModelProvider<Schema> {
20
+ // Syntax sugar: "Table" here is not bound to "keyof Schema" as we want to
21
+ // return "never" in case the table does not exist in our schema, rather than
22
+ // a "Model" bound to the union of all tables in the schema...
23
+ in<Table extends string>(table: Table & keyof Schema): InferModelType<Schema, Table & keyof Schema>
24
+ }
25
+
26
+ /**
27
+ * A query interface guaranteeing that all operations will be performed on the
28
+ * _same_ database connection (transaction safe)
29
+ */
30
+ export interface Connection<Schema> extends ModelProvider<Schema>, PGTransactionable {
31
+ /**
32
+ * Return the {@link Model} view associated with the specified table.
33
+ *
34
+ * All operations performed by this {@link Model} will share the same
35
+ * {@link Connection} (transaction safe).
36
+ */
37
+ in<Table extends string>(table: Table & keyof Schema): InferModelType<Schema, Table & keyof Schema>
38
+ }
39
+
40
+ /** A consumer for a {@link Connection} */
41
+ export type Consumer<Schema, T> = (connection: Connection<Schema>) => T | PromiseLike<T>
42
+
43
+ /** Our main `Persister` interface */
44
+ export interface Persister<Schema> extends ModelProvider<Schema>, PGClient {
45
+ /** Ping... Just ping the database. */
46
+ ping(): Promise<void>;
47
+
48
+ /**
49
+ * Connect to the database to execute a number of different queries.
50
+ *
51
+ * The `consumer` will be passed a {@link Connection} instance backed by the
52
+ * _same_ connection to the database, therefore transactions can be safely
53
+ * executed in the context of the consumer function itself.
54
+ */
55
+ connect<T>(consumer: Consumer<Schema, T>): Promise<T>
56
+
57
+ /**
58
+ * Return the {@link Model} view associated with the specified table.
59
+ *
60
+ * All operations performed by this {@link Model} will potentially use
61
+ * different connections to the database (not transaction safe).
62
+ */
63
+ in<Table extends string>(table: Table & keyof Schema): InferModelType<Schema, Table & keyof Schema>
64
+ }
65
+
66
+ /** Constructor for {@link Persister} instances */
67
+ export interface PersisterConstructor {
68
+ new <Schema = Record<string, Record<string, ColumnDefinition>>>(url?: string | URL): Persister<Schema>
69
+ }
70
+
71
+ /* ========================================================================== *
72
+ * IMPLEMENTATION *
73
+ * ========================================================================== */
74
+
75
+ class ConnectionImpl<Schema> implements Connection<Schema> {
76
+ constructor(
77
+ private _queryable: PGQueryable,
78
+ ) {}
79
+
80
+ async begin(): Promise<this> {
81
+ await this._queryable.query('BEGIN')
82
+ return this
83
+ }
84
+
85
+ async commit(): Promise<this> {
86
+ await this._queryable.query('COMMIT')
87
+ return this
88
+ }
89
+
90
+ async rollback(): Promise<this> {
91
+ await this._queryable.query('ROLLBACK')
92
+ return this
93
+ }
94
+
95
+ query<
96
+ Row extends Record<string, any> = Record<string, any>,
97
+ Tuple extends readonly any[] = readonly any [],
98
+ >(text: string, params: any[] | undefined = []): Promise<PGResult<Row, Tuple>> {
99
+ return this._queryable.query(text, params)
100
+ }
101
+
102
+ in<Table extends string>(table: Table & keyof Schema): InferModelType<Schema, Table & keyof Schema> {
103
+ return new Model(this._queryable, table) as InferModelType<Schema, Table & keyof Schema>
104
+ }
105
+ }
106
+
107
+ class PersisterImpl<Schema> implements PGClient, Persister<Schema> {
108
+ private _client: PGClient
109
+
110
+ constructor(url?: string | URL) {
111
+ this._client = new PGClient(url)
112
+ }
113
+
114
+ get registry(): Registry {
115
+ return this._client.registry
116
+ }
117
+
118
+ async ping(): Promise<void> {
119
+ await this._client.query('SELECT now()')
120
+ }
121
+
122
+ async query<
123
+ Row extends Record<string, any> = Record<string, any>,
124
+ Tuple extends readonly any[] = readonly any [],
125
+ >(text: string, params: any[] | undefined = []): Promise<PGResult<Row, Tuple>> {
126
+ const result = this._client.query<Row, Tuple>(text, params)
127
+ return result
128
+ }
129
+
130
+ async destroy(): Promise<void> {
131
+ await this._client.destroy()
132
+ }
133
+
134
+ async connect<T>(consumer: Consumer<Schema, T>): Promise<T> {
135
+ return await this._client.connect((conn) => consumer(new ConnectionImpl(conn)))
136
+ }
137
+
138
+ in<Table extends string>(table: Table & keyof Schema): InferModelType<Schema, Table & keyof Schema> {
139
+ return new Model(this._client, table) as InferModelType<Schema, Table & keyof Schema>
140
+ }
141
+ }
142
+
143
+ /* ========================================================================== *
144
+ * EXPORTS *
145
+ * ========================================================================== */
146
+
147
+ export const Persister: PersisterConstructor = PersisterImpl