@prairielearn/postgres 1.2.0 → 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/pool.ts CHANGED
@@ -1,12 +1,17 @@
1
1
  import _ from 'lodash';
2
2
  import pg, { QueryResult } from 'pg';
3
+ import Cursor from 'pg-cursor';
3
4
  import path from 'node:path';
4
5
  import debugFactory from 'debug';
5
6
  import { callbackify } from 'node:util';
6
7
  import { AsyncLocalStorage } from 'node:async_hooks';
7
8
  import { z } from 'zod';
8
9
 
9
- type Params = Record<string, any> | any[];
10
+ export type QueryParams = Record<string, any> | any[];
11
+
12
+ export interface CursorIterator<T> {
13
+ iterate: (batchSize: number) => AsyncGenerator<T[]>;
14
+ }
10
15
 
11
16
  const debug = debugFactory('prairielib:' + path.basename(__filename, '.js'));
12
17
  const lastQueryMap: WeakMap<pg.PoolClient, string> = new WeakMap();
@@ -44,7 +49,7 @@ function debugString(s: string): string {
44
49
  /**
45
50
  * Formats a set of params for debugging.
46
51
  */
47
- function debugParams(params: Params): string {
52
+ function debugParams(params: QueryParams): string {
48
53
  let s;
49
54
  try {
50
55
  s = JSON.stringify(params);
@@ -58,7 +63,10 @@ function debugParams(params: Params): string {
58
63
  * Given an SQL string and params, creates an array of params and an SQL string
59
64
  * with any named dollar-sign placeholders replaced with parameters.
60
65
  */
61
- function paramsToArray(sql: string, params: Params): { processedSql: string; paramsArray: any } {
66
+ function paramsToArray(
67
+ sql: string,
68
+ params: QueryParams
69
+ ): { processedSql: string; paramsArray: any } {
62
70
  if (typeof sql !== 'string') throw new Error('SQL must be a string');
63
71
  if (Array.isArray(params)) {
64
72
  return {
@@ -114,6 +122,22 @@ function escapeIdentifier(identifier: string): string {
114
122
  return pg.Client.prototype.escapeIdentifier(identifier);
115
123
  }
116
124
 
125
+ function enhanceError(err: Error, sql: string, params: QueryParams): Error {
126
+ // Copy the error so we don't end up with a circular reference in the
127
+ // final error.
128
+ const sqlError = { ...err };
129
+
130
+ // `message` is a non-enumerable property, so we need to copy it manually to
131
+ // the error object.
132
+ sqlError.message = err.message;
133
+
134
+ return addDataToError(err, {
135
+ sqlError: sqlError,
136
+ sql: sql,
137
+ sqlParams: params,
138
+ });
139
+ }
140
+
117
141
  export class PostgresPool {
118
142
  /** The pool from which clients will be acquired. */
119
143
  private pool: pg.Pool | null = null;
@@ -125,6 +149,8 @@ export class PostgresPool {
125
149
  */
126
150
  private alsClient: AsyncLocalStorage<pg.PoolClient> = new AsyncLocalStorage();
127
151
  private searchSchema: string | null = null;
152
+ /** Tracks the total number of queries executed by this pool. */
153
+ private _queryCount = 0;
128
154
 
129
155
  /**
130
156
  * Creates a new connection pool and attempts to connect to the database.
@@ -260,8 +286,9 @@ export class PostgresPool {
260
286
  async queryWithClientAsync(
261
287
  client: pg.PoolClient,
262
288
  sql: string,
263
- params: Params
289
+ params: QueryParams
264
290
  ): Promise<pg.QueryResult> {
291
+ this._queryCount += 1;
265
292
  debug('queryWithClient()', 'sql:', debugString(sql));
266
293
  debug('queryWithClient()', 'params:', debugParams(params));
267
294
  const { processedSql, paramsArray } = paramsToArray(sql, params);
@@ -271,14 +298,7 @@ export class PostgresPool {
271
298
  debug('queryWithClient() success', 'rowCount:', result.rowCount);
272
299
  return result;
273
300
  } catch (err: any) {
274
- // TODO: why do we do this?
275
- const sqlError = JSON.parse(JSON.stringify(err));
276
- sqlError.message = err.message;
277
- throw addDataToError(err, {
278
- sqlError: sqlError,
279
- sql: sql,
280
- sqlParams: params,
281
- });
301
+ throw enhanceError(err, sql, params);
282
302
  }
283
303
  }
284
304
 
@@ -294,7 +314,7 @@ export class PostgresPool {
294
314
  async queryWithClientOneRowAsync(
295
315
  client: pg.PoolClient,
296
316
  sql: string,
297
- params: Params
317
+ params: QueryParams
298
318
  ): Promise<pg.QueryResult> {
299
319
  debug('queryWithClientOneRow()', 'sql:', debugString(sql));
300
320
  debug('queryWithClientOneRow()', 'params:', debugParams(params));
@@ -323,7 +343,7 @@ export class PostgresPool {
323
343
  async queryWithClientZeroOrOneRowAsync(
324
344
  client: pg.PoolClient,
325
345
  sql: string,
326
- params: Params
346
+ params: QueryParams
327
347
  ): Promise<QueryResult> {
328
348
  debug('queryWithClientZeroOrOneRow()', 'sql:', debugString(sql));
329
349
  debug('queryWithClientZeroOrOneRow()', 'params:', debugParams(params));
@@ -470,7 +490,7 @@ export class PostgresPool {
470
490
  /**
471
491
  * Executes a query with the specified parameters.
472
492
  */
473
- async queryAsync(sql: string, params: Params): Promise<QueryResult> {
493
+ async queryAsync(sql: string, params: QueryParams): Promise<QueryResult> {
474
494
  debug('query()', 'sql:', debugString(sql));
475
495
  debug('query()', 'params:', debugParams(params));
476
496
  const client = await this.getClientAsync();
@@ -493,7 +513,7 @@ export class PostgresPool {
493
513
  * Executes a query with the specified parameters. Errors if the query does
494
514
  * not return exactly one row.
495
515
  */
496
- async queryOneRowAsync(sql: string, params: Params): Promise<pg.QueryResult> {
516
+ async queryOneRowAsync(sql: string, params: QueryParams): Promise<pg.QueryResult> {
497
517
  debug('queryOneRow()', 'sql:', debugString(sql));
498
518
  debug('queryOneRow()', 'params:', debugParams(params));
499
519
  const result = await this.queryAsync(sql, params);
@@ -517,7 +537,7 @@ export class PostgresPool {
517
537
  * Executes a query with the specified parameters. Errors if the query
518
538
  * returns more than one row.
519
539
  */
520
- async queryZeroOrOneRowAsync(sql: string, params: Params): Promise<pg.QueryResult> {
540
+ async queryZeroOrOneRowAsync(sql: string, params: QueryParams): Promise<pg.QueryResult> {
521
541
  debug('queryZeroOrOneRow()', 'sql:', debugString(sql));
522
542
  debug('queryZeroOrOneRow()', 'params:', debugParams(params));
523
543
  const result = await this.queryAsync(sql, params);
@@ -682,12 +702,12 @@ export class PostgresPool {
682
702
  callWithClientZeroOrOneRow = callbackify(this.callWithClientZeroOrOneRowAsync);
683
703
 
684
704
  /**
685
- * Wrapper around {@link queryAsync} that validates that the returned data
686
- * matches the given validation model. Returns only the rows of the query.
705
+ * Wrapper around {@link queryAsync} that parses the resulting rows with the
706
+ * given Zod schema. Returns only the rows of the query.
687
707
  */
688
708
  async queryValidatedRows<Model extends z.ZodTypeAny>(
689
709
  query: string,
690
- params: Record<string, any>,
710
+ params: QueryParams,
691
711
  model: Model
692
712
  ): Promise<z.infer<Model>[]> {
693
713
  const results = await this.queryAsync(query, params);
@@ -695,12 +715,12 @@ export class PostgresPool {
695
715
  }
696
716
 
697
717
  /**
698
- * Wrapper around {@link queryOneRowAsync} that validates that the returned data
699
- * matches the given validation model. Returns only a single row of the query.
718
+ * Wrapper around {@link queryOneRowAsync} that parses the resulting row with
719
+ * the given Zod schema. Returns only a single row of the query.
700
720
  */
701
721
  async queryValidatedOneRow<Model extends z.ZodTypeAny>(
702
722
  query: string,
703
- params: Record<string, any>,
723
+ params: QueryParams,
704
724
  model: Model
705
725
  ): Promise<z.infer<Model>> {
706
726
  const results = await this.queryOneRowAsync(query, params);
@@ -708,13 +728,12 @@ export class PostgresPool {
708
728
  }
709
729
 
710
730
  /**
711
- * Wrapper around {@link queryZeroOrOneRowAsync} that validates that the
712
- * returned data matches the given validation model, if it return anything.
713
- * Returns either the single row of the query or `null`.
731
+ * Wrapper around {@link queryZeroOrOneRowAsync} that parses the resulting row
732
+ * (if any) with the given Zod schema. Returns either a single row or `null`.
714
733
  */
715
734
  async queryValidatedZeroOrOneRow<Model extends z.ZodTypeAny>(
716
735
  query: string,
717
- params: Record<string, any>,
736
+ params: QueryParams,
718
737
  model: Model
719
738
  ): Promise<z.infer<Model> | null> {
720
739
  const results = await this.queryZeroOrOneRowAsync(query, params);
@@ -727,12 +746,12 @@ export class PostgresPool {
727
746
 
728
747
  /**
729
748
  * Wrapper around {@link queryAsync} that validates that only one column is
730
- * returned and the data in it matches the given validation model. Returns only
749
+ * returned and parses the data in it with the given Zod schema. Returns only
731
750
  * the single column of the query as an array.
732
751
  */
733
752
  async queryValidatedSingleColumnRows<Model extends z.ZodTypeAny>(
734
753
  query: string,
735
- params: Record<string, any>,
754
+ params: QueryParams,
736
755
  model: Model
737
756
  ): Promise<z.infer<Model>[]> {
738
757
  const results = await this.queryAsync(query, params);
@@ -746,12 +765,12 @@ export class PostgresPool {
746
765
 
747
766
  /**
748
767
  * Wrapper around {@link queryOneRowAsync} that validates that only one column
749
- * is returned and the data in it matches the given validation model. Returns
768
+ * is returned and parses the data in it with the given Zod schema. Returns
750
769
  * only the single entry.
751
770
  */
752
771
  async queryValidatedSingleColumnOneRow<Model extends z.ZodTypeAny>(
753
772
  query: string,
754
- params: Record<string, any>,
773
+ params: QueryParams,
755
774
  model: Model
756
775
  ): Promise<z.infer<Model>> {
757
776
  const results = await this.queryOneRowAsync(query, params);
@@ -764,12 +783,12 @@ export class PostgresPool {
764
783
 
765
784
  /**
766
785
  * Wrapper around {@link queryZeroOrOneRowAsync} that validates that only one
767
- * column is returned and the data in it matches the given validation model, if
768
- * it return anything. Returns either the single row of the query or `null`.
786
+ * column is returned and parses the data in it (if any) with the given Zod
787
+ * schema. Returns either the single row of the query or `null`.
769
788
  */
770
789
  async queryValidatedSingleColumnZeroOrOneRow<Model extends z.ZodTypeAny>(
771
790
  query: string,
772
- params: Record<string, any>,
791
+ params: QueryParams,
773
792
  model: Model
774
793
  ): Promise<z.infer<Model> | null> {
775
794
  const results = await this.queryZeroOrOneRowAsync(query, params);
@@ -785,8 +804,8 @@ export class PostgresPool {
785
804
  }
786
805
 
787
806
  /**
788
- * Wrapper around {@link callAsync} that validates that the returned data
789
- * matches the given validation model. Returns only the rows.
807
+ * Wrapper around {@link callAsync} that parses the resulting rows with the
808
+ * given Zod schema. Returns only the rows.
790
809
  */
791
810
  async callValidatedRows<Model extends z.ZodTypeAny>(
792
811
  sprocName: string,
@@ -798,8 +817,8 @@ export class PostgresPool {
798
817
  }
799
818
 
800
819
  /**
801
- * Wrapper around {@link callOneRowAsync} that validates that the returned data
802
- * matches the given validation model. Returns only a single row.
820
+ * Wrapper around {@link callOneRowAsync} that parses the resulting rows with
821
+ * the given Zod schema. Returns only a single row.
803
822
  */
804
823
  async callValidatedOneRow<Model extends z.ZodTypeAny>(
805
824
  sprocName: string,
@@ -811,9 +830,8 @@ export class PostgresPool {
811
830
  }
812
831
 
813
832
  /**
814
- * Wrapper around {@link callZeroOrOneRowAsync} that validates that the
815
- * returned data matches the given validation model, if it return anything.
816
- * Returns at most a single row.
833
+ * Wrapper around {@link callZeroOrOneRowAsync} that parses the resulting row
834
+ * (if any) with the given Zod schema. Returns at most a single row.
817
835
  */
818
836
  async callValidatedZeroOrOneRow<Model extends z.ZodTypeAny>(
819
837
  sprocName: string,
@@ -828,6 +846,87 @@ export class PostgresPool {
828
846
  }
829
847
  }
830
848
 
849
+ /**
850
+ * Returns a {@link Cursor} for the given query. The cursor can be used to
851
+ * read results in batches, which is useful for large result sets.
852
+ */
853
+ async queryCursorWithClient(
854
+ client: pg.PoolClient,
855
+ sql: string,
856
+ params: QueryParams
857
+ ): Promise<Cursor> {
858
+ this._queryCount += 1;
859
+ debug('queryCursorWithClient()', 'sql:', debugString(sql));
860
+ debug('queryCursorWithClient()', 'params:', debugParams(params));
861
+ const { processedSql, paramsArray } = paramsToArray(sql, params);
862
+ lastQueryMap.set(client, processedSql);
863
+ return client.query(new Cursor(processedSql, paramsArray));
864
+ }
865
+
866
+ /**
867
+ * Returns an {@link CursorIterator} that can be used to iterate over the
868
+ * results of the query in batches, which is useful for large result sets.
869
+ */
870
+ async queryCursor<Model extends z.ZodTypeAny>(
871
+ sql: string,
872
+ params: QueryParams
873
+ ): Promise<CursorIterator<z.infer<Model>>> {
874
+ return this.queryValidatedCursorInternal(sql, params);
875
+ }
876
+
877
+ /**
878
+ * Returns an {@link CursorIterator} that can be used to iterate over the
879
+ * results of the query in batches, which is useful for large result sets.
880
+ * Each row will be parsed by the given Zod schema.
881
+ */
882
+ async queryValidatedCursor<Model extends z.ZodTypeAny>(
883
+ sql: string,
884
+ params: QueryParams,
885
+ model: Model
886
+ ): Promise<CursorIterator<z.infer<Model>>> {
887
+ return this.queryValidatedCursorInternal(sql, params, model);
888
+ }
889
+
890
+ private async queryValidatedCursorInternal<Model extends z.ZodTypeAny>(
891
+ sql: string,
892
+ params: QueryParams,
893
+ model?: Model
894
+ ): Promise<CursorIterator<z.infer<Model>>> {
895
+ const client = await this.getClientAsync();
896
+ const cursor = await this.queryCursorWithClient(client, sql, params);
897
+
898
+ let iterateCalled = false;
899
+ return {
900
+ iterate: async function* (batchSize: number) {
901
+ // Safety check: if someone calls iterate multiple times, they're
902
+ // definitely doing something wrong.
903
+ if (iterateCalled) {
904
+ throw new Error('iterate() called multiple times');
905
+ }
906
+ iterateCalled = true;
907
+
908
+ try {
909
+ while (true) {
910
+ const rows = await cursor.read(batchSize);
911
+ if (rows.length === 0) {
912
+ break;
913
+ }
914
+
915
+ if (model) {
916
+ yield z.array(model).parse(rows);
917
+ } else {
918
+ yield rows;
919
+ }
920
+ }
921
+ } catch (err: any) {
922
+ throw enhanceError(err, sql, params);
923
+ } finally {
924
+ client.release();
925
+ }
926
+ },
927
+ };
928
+ }
929
+
831
930
  /**
832
931
  * Set the schema to use for the search path.
833
932
  *
@@ -882,4 +981,24 @@ export class PostgresPool {
882
981
  * Generate, set, and return a random schema name.
883
982
  */
884
983
  setRandomSearchSchema = callbackify(this.setRandomSearchSchemaAsync);
984
+
985
+ /** The number of established connections. */
986
+ get totalCount() {
987
+ return this.pool?.totalCount ?? 0;
988
+ }
989
+
990
+ /** The number of idle connections. */
991
+ get idleCount() {
992
+ return this.pool?.idleCount ?? 0;
993
+ }
994
+
995
+ /** The number of queries waiting for a connection to become available. */
996
+ get waitingCount() {
997
+ return this.pool?.waitingCount ?? 0;
998
+ }
999
+
1000
+ /** The total number of queries that have been executed by this pool. */
1001
+ get queryCount() {
1002
+ return this._queryCount;
1003
+ }
885
1004
  }