@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/README.md +228 -0
- package/dist/index.cjs +27 -0
- package/dist/index.cjs.map +6 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.mjs +4 -0
- package/dist/index.mjs.map +6 -0
- package/dist/model.cjs +211 -0
- package/dist/model.cjs.map +6 -0
- package/dist/model.d.ts +95 -0
- package/dist/model.mjs +185 -0
- package/dist/model.mjs.map +6 -0
- package/dist/persister.cjs +81 -0
- package/dist/persister.cjs.map +6 -0
- package/dist/persister.d.ts +49 -0
- package/dist/persister.mjs +56 -0
- package/dist/persister.mjs.map +6 -0
- package/package.json +58 -0
- package/src/index.ts +3 -0
- package/src/model.ts +462 -0
- package/src/persister.ts +147 -0
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
|
package/src/persister.ts
ADDED
|
@@ -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
|