@prairielearn/postgres 1.6.0 → 1.7.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/pool.test.ts CHANGED
@@ -1,8 +1,17 @@
1
1
  import chai from 'chai';
2
2
  import chaiAsPromised from 'chai-as-promised';
3
+ import { Writable } from 'node:stream';
4
+ import { pipeline } from 'node:stream/promises';
3
5
  import { z, ZodError } from 'zod';
4
6
 
5
- import { queryAsync, queryCursor, queryValidatedCursor } from './default-pool';
7
+ import {
8
+ queryAsync,
9
+ queryRows,
10
+ queryRow,
11
+ queryOptionalRow,
12
+ queryCursor,
13
+ queryValidatedCursor,
14
+ } from './default-pool';
6
15
  import { makePostgresTestUtils } from './test-utils';
7
16
 
8
17
  chai.use(chaiAsPromised);
@@ -12,24 +21,118 @@ const postgresTestUtils = makePostgresTestUtils({
12
21
  database: 'prairielearn_postgres',
13
22
  });
14
23
 
24
+ const WorkspaceSchema = z.object({
25
+ id: z.string(),
26
+ created_at: z.date(),
27
+ });
28
+
15
29
  describe('@prairielearn/postgres', function () {
16
30
  before(async () => {
17
31
  await postgresTestUtils.createDatabase();
18
- await queryAsync('CREATE TABLE workspaces (id BIGSERIAL PRIMARY KEY, state TEXT);', {});
19
- await queryAsync("INSERT INTO workspaces (id, state) VALUES (1,'uninitialized');", {});
20
- await queryAsync("INSERT INTO workspaces (id, state) VALUES (2, 'stopped');", {});
21
- await queryAsync("INSERT INTO workspaces (id, state) VALUES (3, 'launching');", {});
22
- await queryAsync("INSERT INTO workspaces (id, state) VALUES (4, 'running');", {});
32
+ await queryAsync(
33
+ 'CREATE TABLE workspaces (id BIGSERIAL PRIMARY KEY, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP);',
34
+ {}
35
+ );
36
+ await queryAsync('INSERT INTO workspaces (id) SELECT s FROM generate_series(1, 100) AS s', {});
23
37
  });
24
38
 
25
39
  after(async () => {
26
40
  await postgresTestUtils.dropDatabase();
27
41
  });
28
42
 
43
+ describe('queryRows', () => {
44
+ it('handles single column', async () => {
45
+ const rows = await queryRows('SELECT id FROM workspaces WHERE id <= 10;', z.string());
46
+ assert.lengthOf(rows, 10);
47
+ assert.equal(rows[0], '1');
48
+ });
49
+
50
+ it('handles multiple columns', async () => {
51
+ const rows = await queryRows('SELECT * FROM workspaces WHERE id <= 10;', WorkspaceSchema);
52
+ assert.lengthOf(rows, 10);
53
+ assert.equal(rows[0].id, '1');
54
+ assert.isNotNull(rows[0].created_at);
55
+ });
56
+
57
+ it('handles parameters', async () => {
58
+ const rows = await queryRows(
59
+ 'SELECT * FROM workspaces WHERE id <= $1;',
60
+ [10],
61
+ WorkspaceSchema
62
+ );
63
+ assert.lengthOf(rows, 10);
64
+ });
65
+ });
66
+
67
+ describe('queryRow', () => {
68
+ it('handles single column', async () => {
69
+ const row = await queryRow('SELECT id FROM workspaces WHERE id = 1;', z.string());
70
+ assert.equal(row, '1');
71
+ });
72
+
73
+ it('handles multiple columns', async () => {
74
+ const row = await queryRow('SELECT * FROM workspaces WHERE id = 1;', WorkspaceSchema);
75
+ assert.equal(row.id, '1');
76
+ assert.isNotNull(row.created_at);
77
+ });
78
+
79
+ it('handles parameters', async () => {
80
+ const row = await queryRow('SELECT * FROM workspaces WHERE id = $1;', [1], WorkspaceSchema);
81
+ assert.equal(row.id, '1');
82
+ });
83
+
84
+ it('rejects results with zero rows', async () => {
85
+ const rows = queryRow('SELECT * FROM workspaces WHERE id = -1;', WorkspaceSchema);
86
+ await assert.isRejected(rows, 'Incorrect rowCount: 0');
87
+ });
88
+
89
+ it('rejects results with multiple rows', async () => {
90
+ const rows = queryRow('SELECT * FROM workspaces', WorkspaceSchema);
91
+ await assert.isRejected(rows, 'Incorrect rowCount: 100');
92
+ });
93
+ });
94
+
95
+ describe('queryOptionalRow', () => {
96
+ it('handles single column', async () => {
97
+ const row = await queryRow('SELECT id FROM workspaces WHERE id = 1;', z.string());
98
+ assert.equal(row, '1');
99
+ });
100
+
101
+ it('handles multiple columns', async () => {
102
+ const row = await queryOptionalRow('SELECT * FROM workspaces WHERE id = 1;', WorkspaceSchema);
103
+ assert.isNotNull(row);
104
+ assert.equal(row?.id, '1');
105
+ assert.isNotNull(row?.created_at);
106
+ });
107
+
108
+ it('handles parameters', async () => {
109
+ const row = await queryOptionalRow(
110
+ 'SELECT * FROM workspaces WHERE id = $1;',
111
+ [1],
112
+ WorkspaceSchema
113
+ );
114
+ assert.isNotNull(row);
115
+ assert.equal(row?.id, '1');
116
+ });
117
+
118
+ it('handles missing result', async () => {
119
+ const row = await queryOptionalRow(
120
+ 'SELECT * FROM workspaces WHERE id = -1;',
121
+ WorkspaceSchema
122
+ );
123
+ assert.isNull(row);
124
+ });
125
+
126
+ it('rejects with multiple rows', async () => {
127
+ const rows = queryOptionalRow('SELECT * FROM workspaces', WorkspaceSchema);
128
+ await assert.isRejected(rows, 'Incorrect rowCount: 100');
129
+ });
130
+ });
131
+
29
132
  describe('queryCursor', () => {
30
133
  it('returns zero rows', async () => {
31
- const cursor = await queryCursor('SELECT * FROM workspaces WHERE id = 5;', {});
32
- let rowBatches = [];
134
+ const cursor = await queryCursor('SELECT * FROM workspaces WHERE id = 10000;', {});
135
+ const rowBatches = [];
33
136
  for await (const rows of cursor.iterate(10)) {
34
137
  rowBatches.push(rows);
35
138
  }
@@ -48,13 +151,13 @@ describe('@prairielearn/postgres', function () {
48
151
  });
49
152
 
50
153
  it('returns all rows at once', async () => {
51
- const cursor = queryCursor('SELECT * FROM workspaces;', {});
154
+ const cursor = queryCursor('SELECT * FROM workspaces WHERE id <= 10;', {});
52
155
  const rowBatches = [];
53
156
  for await (const rows of (await cursor).iterate(10)) {
54
157
  rowBatches.push(rows);
55
158
  }
56
159
  assert.lengthOf(rowBatches, 1);
57
- assert.lengthOf(rowBatches[0], 4);
160
+ assert.lengthOf(rowBatches[0], 10);
58
161
  });
59
162
 
60
163
  it('handles errors', async () => {
@@ -80,88 +183,111 @@ describe('@prairielearn/postgres', function () {
80
183
  });
81
184
 
82
185
  describe('queryValidatedCursor', () => {
83
- it('validates with provided schema', async () => {
84
- const WorkspaceSchema = z.object({
85
- id: z.string(),
86
- });
87
- const cursor = await queryValidatedCursor(
88
- 'SELECT * FROM workspaces ORDER BY id ASC;',
89
- {},
90
- WorkspaceSchema
91
- );
92
- const allRows = [];
93
- for await (const rows of cursor.iterate(10)) {
94
- allRows.push(...rows);
95
- }
96
- assert.lengthOf(allRows, 4);
97
- const workspace = allRows[0] as any;
98
- assert.equal(workspace.id, '1');
99
- assert.isUndefined(workspace.state);
186
+ const WorkspaceSchema = z.object({
187
+ id: z.string(),
100
188
  });
101
189
 
102
- it('throws error when validation fails', async () => {
103
- const BadWorkspaceSchema = z.object({
104
- badProperty: z.string(),
105
- });
106
- const cursor = await queryValidatedCursor(
107
- 'SELECT * FROM workspaces ORDER BY id ASC;',
108
- {},
109
- BadWorkspaceSchema
110
- );
190
+ const BadWorkspaceSchema = z.object({
191
+ badProperty: z.string(),
192
+ });
111
193
 
112
- async function readAllRows() {
194
+ describe('iterator', () => {
195
+ it('validates with provided schema', async () => {
196
+ const cursor = await queryValidatedCursor(
197
+ 'SELECT * FROM workspaces WHERE id <= 10 ORDER BY id ASC;',
198
+ {},
199
+ WorkspaceSchema
200
+ );
113
201
  const allRows = [];
114
202
  for await (const rows of cursor.iterate(10)) {
115
203
  allRows.push(...rows);
116
204
  }
117
- return allRows;
118
- }
119
-
120
- const maybeError = await readAllRows().catch((err) => err);
121
- assert.instanceOf(maybeError, ZodError);
122
- assert.lengthOf(maybeError.errors, 4);
123
- });
124
-
125
- it('returns a stream', async () => {
126
- const WorkspaceSchema = z.object({
127
- id: z.string(),
205
+ assert.lengthOf(allRows, 10);
206
+ const workspace = allRows[0] as any;
207
+ assert.equal(workspace.id, '1');
208
+ assert.isUndefined(workspace.state);
128
209
  });
129
- const cursor = await queryValidatedCursor(
130
- 'SELECT * FROM workspaces ORDER BY id ASC;',
131
- {},
132
- WorkspaceSchema
133
- );
134
- const stream = cursor.stream(1);
135
- const allRows = [];
136
- for await (const row of stream) {
137
- allRows.push(row);
138
- }
139
210
 
140
- assert.lengthOf(allRows, 4);
141
- });
211
+ it('throws error when validation fails', async () => {
212
+ const cursor = await queryValidatedCursor(
213
+ 'SELECT * FROM workspaces WHERE id <= 10 ORDER BY id ASC;',
214
+ {},
215
+ BadWorkspaceSchema
216
+ );
142
217
 
143
- it('emits an error when validation fails', async () => {
144
- const BadWorkspaceSchema = z.object({
145
- badProperty: z.string(),
218
+ async function readAllRows() {
219
+ const allRows = [];
220
+ for await (const rows of cursor.iterate(10)) {
221
+ allRows.push(...rows);
222
+ }
223
+ return allRows;
224
+ }
225
+
226
+ const maybeError = await readAllRows().catch((err) => err);
227
+ assert.instanceOf(maybeError, ZodError);
228
+ assert.lengthOf(maybeError.errors, 10);
146
229
  });
147
- const cursor = await queryValidatedCursor(
148
- 'SELECT * FROM workspaces ORDER BY id ASC;',
149
- {},
150
- BadWorkspaceSchema
151
- );
152
- const stream = cursor.stream(1);
230
+ });
153
231
 
154
- async function readAllRows() {
232
+ describe('stream', () => {
233
+ it('validates with provided schema', async () => {
234
+ const cursor = await queryValidatedCursor(
235
+ 'SELECT * FROM workspaces WHERE id <= 10 ORDER BY id ASC;',
236
+ {},
237
+ WorkspaceSchema
238
+ );
239
+ const stream = cursor.stream(1);
155
240
  const allRows = [];
156
241
  for await (const row of stream) {
157
242
  allRows.push(row);
158
243
  }
159
- return allRows;
160
- }
161
244
 
162
- const maybeError = await readAllRows().catch((err) => err);
163
- assert.instanceOf(maybeError, ZodError);
164
- assert.lengthOf(maybeError.errors, 1);
245
+ assert.lengthOf(allRows, 10);
246
+ });
247
+
248
+ it('emits an error when validation fails', async () => {
249
+ const cursor = await queryValidatedCursor(
250
+ 'SELECT * FROM workspaces ORDER BY id ASC;',
251
+ {},
252
+ BadWorkspaceSchema
253
+ );
254
+ const stream = cursor.stream(1);
255
+
256
+ async function readAllRows() {
257
+ const allRows = [];
258
+ for await (const row of stream) {
259
+ allRows.push(row);
260
+ }
261
+ return allRows;
262
+ }
263
+
264
+ const maybeError = await readAllRows().catch((err) => err);
265
+ assert.instanceOf(maybeError, ZodError);
266
+ assert.lengthOf(maybeError.errors, 1);
267
+ });
268
+
269
+ it('closes the cursor when the stream is closed', async () => {
270
+ const cursor = await queryValidatedCursor('SELECT * FROM workspaces;', {}, WorkspaceSchema);
271
+ const stream = cursor.stream(1);
272
+
273
+ const rows: any[] = [];
274
+ const ac = new AbortController();
275
+ const writable = new Writable({
276
+ objectMode: true,
277
+ write: function (chunk, _encoding, callback) {
278
+ rows.push(chunk);
279
+
280
+ // After receiving the first row, abort the stream. This lets us test
281
+ // that the underlying cursor is closed. If it is *not* closed, this
282
+ // `after` hook will fail with a timeout.
283
+ ac.abort();
284
+ callback();
285
+ },
286
+ });
287
+
288
+ await assert.isRejected(pipeline(stream, writable, { signal: ac.signal }));
289
+ assert.lengthOf(rows, 1);
290
+ });
165
291
  });
166
292
  });
167
293
  });
package/src/pool.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import _ from 'lodash';
2
2
  import pg, { QueryResult } from 'pg';
3
3
  import Cursor from 'pg-cursor';
4
- import path from 'node:path';
5
4
  import debugFactory from 'debug';
6
5
  import { callbackify } from 'node:util';
7
6
  import { AsyncLocalStorage } from 'node:async_hooks';
@@ -16,7 +15,7 @@ export interface CursorIterator<T> {
16
15
  stream: (batchSize: number) => NodeJS.ReadWriteStream;
17
16
  }
18
17
 
19
- const debug = debugFactory('prairielib:' + path.basename(__filename, '.js'));
18
+ const debug = debugFactory('@prairielearn/postgres');
20
19
  const lastQueryMap: WeakMap<pg.PoolClient, string> = new WeakMap();
21
20
  const searchSchemaMap: WeakMap<pg.PoolClient, string> = new WeakMap();
22
21
 
@@ -239,7 +238,7 @@ export class PostgresPool {
239
238
 
240
239
  // If we're inside a transaction, we'll reuse the same client to avoid a
241
240
  // potential deadlock.
242
- let client = this.alsClient.getStore() ?? (await this.pool.connect());
241
+ const client = this.alsClient.getStore() ?? (await this.pool.connect());
243
242
 
244
243
  // If we're configured to use a particular schema, we'll store whether or
245
244
  // not the search path has already been configured for this particular
@@ -475,7 +474,7 @@ export class PostgresPool {
475
474
  async runInTransactionAsync<T>(fn: (client: pg.PoolClient) => Promise<T>): Promise<T> {
476
475
  // Check if we're already inside a transaction. If so, we won't start another one,
477
476
  // as Postgres doesn't support nested transactions.
478
- let client = this.alsClient.getStore();
477
+ const client = this.alsClient.getStore();
479
478
  const isNestedTransaction = client !== undefined;
480
479
  const transactionClient = client ?? (await this.beginTransactionAsync());
481
480
 
@@ -754,7 +753,7 @@ export class PostgresPool {
754
753
  model: Model
755
754
  ): Promise<z.infer<Model> | null> {
756
755
  const results = await this.queryZeroOrOneRowAsync(query, params);
757
- if (results.rows.length == 0) {
756
+ if (results.rows.length === 0) {
758
757
  return null;
759
758
  } else {
760
759
  return model.parse(results.rows[0]);
@@ -772,7 +771,7 @@ export class PostgresPool {
772
771
  model: Model
773
772
  ): Promise<z.infer<Model>[]> {
774
773
  const results = await this.queryAsync(query, params);
775
- if (results.fields.length != 1) {
774
+ if (results.fields.length !== 1) {
776
775
  throw new Error(`Expected one column, got ${results.fields.length}`);
777
776
  }
778
777
  const columnName = results.fields[0].name;
@@ -791,7 +790,7 @@ export class PostgresPool {
791
790
  model: Model
792
791
  ): Promise<z.infer<Model>> {
793
792
  const results = await this.queryOneRowAsync(query, params);
794
- if (results.fields.length != 1) {
793
+ if (results.fields.length !== 1) {
795
794
  throw new Error(`Expected one column, got ${results.fields.length}`);
796
795
  }
797
796
  const columnName = results.fields[0].name;
@@ -809,10 +808,10 @@ export class PostgresPool {
809
808
  model: Model
810
809
  ): Promise<z.infer<Model> | null> {
811
810
  const results = await this.queryZeroOrOneRowAsync(query, params);
812
- if (results.fields.length != 1) {
811
+ if (results.fields.length !== 1) {
813
812
  throw new Error(`Expected one column, got ${results.fields.length}`);
814
813
  }
815
- if (results.rows.length == 0) {
814
+ if (results.rows.length === 0) {
816
815
  return null;
817
816
  } else {
818
817
  const columnName = results.fields[0].name;
@@ -856,13 +855,85 @@ export class PostgresPool {
856
855
  model: Model
857
856
  ): Promise<z.infer<Model> | null> {
858
857
  const results = await this.callZeroOrOneRowAsync(sprocName, params);
859
- if (results.rows.length == 0) {
858
+ if (results.rows.length === 0) {
860
859
  return null;
861
860
  } else {
862
861
  return model.parse(results.rows[0]);
863
862
  }
864
863
  }
865
864
 
865
+ async queryRows<Model extends z.ZodTypeAny>(sql: string, model: Model): Promise<z.infer<Model>[]>;
866
+ async queryRows<Model extends z.ZodTypeAny>(
867
+ sql: string,
868
+ params: QueryParams,
869
+ model: Model
870
+ ): Promise<z.infer<Model>[]>;
871
+ async queryRows<Model extends z.ZodTypeAny>(
872
+ sql: string,
873
+ paramsOrSchema: QueryParams | Model,
874
+ maybeModel?: Model
875
+ ) {
876
+ const params = maybeModel === undefined ? {} : (paramsOrSchema as QueryParams);
877
+ const model = maybeModel === undefined ? (paramsOrSchema as Model) : maybeModel;
878
+ const results = await this.queryAsync(sql, params);
879
+ if (results.fields.length === 1) {
880
+ const columnName = results.fields[0].name;
881
+ const rawData = results.rows.map((row) => row[columnName]);
882
+ return z.array(model).parse(rawData);
883
+ } else {
884
+ return z.array(model).parse(results.rows);
885
+ }
886
+ }
887
+
888
+ async queryRow<Model extends z.ZodTypeAny>(sql: string, model: Model): Promise<z.infer<Model>>;
889
+ async queryRow<Model extends z.ZodTypeAny>(
890
+ sql: string,
891
+ params: QueryParams,
892
+ model: Model
893
+ ): Promise<z.infer<Model>>;
894
+ async queryRow<Model extends z.ZodTypeAny>(
895
+ sql: string,
896
+ paramsOrSchema: QueryParams | Model,
897
+ maybeModel?: Model
898
+ ) {
899
+ const params = maybeModel === undefined ? {} : (paramsOrSchema as QueryParams);
900
+ const model = maybeModel === undefined ? (paramsOrSchema as Model) : maybeModel;
901
+ const results = await this.queryOneRowAsync(sql, params);
902
+ if (results.fields.length === 1) {
903
+ const columnName = results.fields[0].name;
904
+ return model.parse(results.rows[0][columnName]);
905
+ } else {
906
+ return model.parse(results.rows[0]);
907
+ }
908
+ }
909
+
910
+ async queryOptionalRow<Model extends z.ZodTypeAny>(
911
+ sql: string,
912
+ model: Model
913
+ ): Promise<z.infer<Model> | null>;
914
+ async queryOptionalRow<Model extends z.ZodTypeAny>(
915
+ sql: string,
916
+ params: QueryParams,
917
+ model: Model
918
+ ): Promise<z.infer<Model> | null>;
919
+ async queryOptionalRow<Model extends z.ZodTypeAny>(
920
+ sql: string,
921
+ paramsOrSchema: QueryParams | Model,
922
+ maybeModel?: Model
923
+ ) {
924
+ const params = maybeModel === undefined ? {} : (paramsOrSchema as QueryParams);
925
+ const model = maybeModel === undefined ? (paramsOrSchema as Model) : maybeModel;
926
+ const results = await this.queryZeroOrOneRowAsync(sql, params);
927
+ if (results.rows.length === 0) {
928
+ return null;
929
+ } else if (results.fields.length === 1) {
930
+ const columnName = results.fields[0].name;
931
+ return model.parse(results.rows[0][columnName]);
932
+ } else {
933
+ return model.parse(results.rows[0]);
934
+ }
935
+ }
936
+
866
937
  /**
867
938
  * Returns a {@link Cursor} for the given query. The cursor can be used to
868
939
  * read results in batches, which is useful for large result sets.
@@ -938,7 +1009,11 @@ export class PostgresPool {
938
1009
  } catch (err: any) {
939
1010
  throw enhanceError(err, sql, params);
940
1011
  } finally {
941
- client.release();
1012
+ try {
1013
+ await cursor.close();
1014
+ } finally {
1015
+ client.release();
1016
+ }
942
1017
  }
943
1018
  },
944
1019
  stream: function (batchSize: number) {
@@ -954,7 +1029,20 @@ export class PostgresPool {
954
1029
  });
955
1030
 
956
1031
  // TODO: use native `node:stream#compose` once it's stable.
957
- return multipipe(Readable.from(iterator.iterate(batchSize)), transform);
1032
+ const generator = iterator.iterate(batchSize);
1033
+ const pipe = multipipe(Readable.from(generator), transform);
1034
+
1035
+ // When the underlying stream is closed, we need to make sure that the
1036
+ // cursor is also closed. We do this by calling `return()` on the generator,
1037
+ // which will trigger its `finally` block, which will in turn release
1038
+ // the client and close the cursor. The fact that the stream is already
1039
+ // closed by this point means that someone reading from the stream will
1040
+ // never actually see the `null` value that's returned.
1041
+ pipe.once('close', () => {
1042
+ generator.return(null);
1043
+ });
1044
+
1045
+ return pipe;
958
1046
  },
959
1047
  };
960
1048
  return iterator;
package/tsconfig.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "extends": "@prairielearn/tsconfig",
2
+ "extends": "@prairielearn/tsconfig/tsconfig.package.json",
3
3
  "compilerOptions": {
4
4
  "outDir": "./dist",
5
5
  "rootDir": "./src",