postgrejs 3.11.1 → 3.12.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/CHANGELOG.md CHANGED
@@ -1,6 +1,33 @@
1
1
  ## Changelog
2
2
 
3
- ### [v3.11.1](https://github.com/panates/postgrejs/compare/v3.11.0...v3.11.1) -
3
+ ### [v3.12.0](https://github.com/panates/postgrejs/compare/v3.11.1...v3.12.0) -
4
+
5
+ #### Breaking Changes :warning:
6
+
7
+ - fix!: refuse what the server refuses instead of storing another number @Eray Hanoğlu
8
+
9
+ #### 🚀 New Features
10
+
11
+ - feat(benchmark): count the bytes sent, and what a run keeps @Eray Hanoğlu
12
+ - feat: report the parameter types the server resolved @Eray Hanoğlu
13
+
14
+ #### 🪲 Fixes
15
+
16
+ - fix(benchmark): a figure nobody could measure is not a bar of zero @Eray Hanoğlu
17
+ - fix: take the value out of a BindParam handed to a prepared statement @Eray Hanoğlu
18
+
19
+ #### 📖 Documentation Changes
20
+
21
+ - docs(benchmark): update benchmarks to include new memory and network metrics @Eray Hanoğlu
22
+ - docs: say what an untyped numeric array actually costs @Eray Hanoğlu
23
+ - docs(benchmark): say what Peak Heap is not, and what survives a re-run @Eray Hanoğlu
24
+ - docs(benchmark): Retained is a working set, not a permanent footprint @Eray Hanoğlu
25
+
26
+ #### ⚡️ Performance Improvments
27
+
28
+ - perf: stop quoting and re-escaping every element of an array literal @Eray Hanoğlu
29
+
30
+ ### [v3.11.1](https://github.com/panates/postgrejs/compare/v3.11.0...v3.11.1) - 24 September 2026
4
31
 
5
32
  #### 🪲 Fixes
6
33
 
@@ -332,9 +332,17 @@ export declare class IntlConnection extends SafeEventEmitter {
332
332
  * a separately-awaited Sync, and fetches the RowDescription/NoData in the
333
333
  * same round trip so the caller can cache it (see executeReused()) rather
334
334
  * than every later execute() re-Describing its own portal.
335
+ *
336
+ * The Describe answers with the parameters too, and that answer is worth
337
+ * keeping: it is the only place the server says what it made of a
338
+ * parameter the client declared nothing for. `insert into t(v)
339
+ * values($1)` resolves `$1` from the column, not from the value - which
340
+ * is why it is stable across executions and why a caller who wants the
341
+ * binary encoding back can read it and name that type with BindParam.
335
342
  */
336
343
  prepareOnce(sql: string, paramTypes: Maybe<Maybe<OID>[]>, statementName: string): Promise<{
337
344
  fields?: Protocol.RowDescription[];
345
+ resolvedParamTypes?: OID[];
338
346
  }>;
339
347
  /**
340
348
  * Bind+Execute+Sync as a single round trip against an unnamed portal, for
@@ -1239,12 +1239,20 @@ export class IntlConnection extends SafeEventEmitter {
1239
1239
  * a separately-awaited Sync, and fetches the RowDescription/NoData in the
1240
1240
  * same round trip so the caller can cache it (see executeReused()) rather
1241
1241
  * than every later execute() re-Describing its own portal.
1242
+ *
1243
+ * The Describe answers with the parameters too, and that answer is worth
1244
+ * keeping: it is the only place the server says what it made of a
1245
+ * parameter the client declared nothing for. `insert into t(v)
1246
+ * values($1)` resolves `$1` from the column, not from the value - which
1247
+ * is why it is stable across executions and why a caller who wants the
1248
+ * binary encoding back can read it and name that type with BindParam.
1242
1249
  */
1243
1250
  async prepareOnce(sql, paramTypes, statementName) {
1244
1251
  this.assertConnected();
1245
1252
  this.ref();
1246
1253
  try {
1247
1254
  let fields;
1255
+ let resolvedParamTypes;
1248
1256
  let error;
1249
1257
  this.runningQueryCount++;
1250
1258
  await this.socket
@@ -1257,8 +1265,10 @@ export class IntlConnection extends SafeEventEmitter {
1257
1265
  // Not an error: handed over, and the loop carries on.
1258
1266
  this.emit('notice', msg);
1259
1267
  break;
1260
- case Protocol.BackendMessageCode.ParseComplete:
1261
1268
  case Protocol.BackendMessageCode.ParameterDescription:
1269
+ resolvedParamTypes = msg.parameterIds;
1270
+ break;
1271
+ case Protocol.BackendMessageCode.ParseComplete:
1262
1272
  case Protocol.BackendMessageCode.NoData:
1263
1273
  break;
1264
1274
  case Protocol.BackendMessageCode.RowDescription:
@@ -1280,7 +1290,7 @@ export class IntlConnection extends SafeEventEmitter {
1280
1290
  .finally(() => {
1281
1291
  this.runningQueryCount--;
1282
1292
  });
1283
- return { fields };
1293
+ return { fields, resolvedParamTypes };
1284
1294
  }
1285
1295
  finally {
1286
1296
  this.unref();
@@ -10,6 +10,7 @@ export declare class PreparedStatement extends SafeEventEmitter implements Async
10
10
  private readonly _sql;
11
11
  private readonly _name;
12
12
  private readonly _paramTypes?;
13
+ private _resolvedParamTypes?;
13
14
  private _fields?;
14
15
  protected _onErrorSavePoint: string;
15
16
  private _refCount;
@@ -20,6 +21,24 @@ export declare class PreparedStatement extends SafeEventEmitter implements Async
20
21
  get name(): Maybe<string>;
21
22
  get sql(): string;
22
23
  get paramTypes(): Maybe<Maybe<OID>[]>;
24
+ /**
25
+ * What the server made of each parameter, in order, as its Describe
26
+ * answered - not what was asked for in `paramTypes`.
27
+ *
28
+ * A parameter the caller declared nothing for is resolved by the
29
+ * server from where it appears: the column it is inserted into, the
30
+ * operator it sits beside, the function it is passed to. It never
31
+ * looks at the value, which is still three messages away, so this is
32
+ * a property of the statement and does not change between executions.
33
+ *
34
+ * Useful for two things. It says what a parameter with no context
35
+ * became - `select $1` resolves to `text`, which is why an array
36
+ * there comes back as an array literal rather than an array. And it
37
+ * is the type to name with `new BindParam(oid, v)` for a caller who
38
+ * wants the binary encoding a large numeric array gives up by going
39
+ * out untyped (see `isUnspecifiedParam`).
40
+ */
41
+ get resolvedParamTypes(): Maybe<OID[]>;
23
42
  execute(options?: QueryOptions): Promise<QueryResult>;
24
43
  /**
25
44
  * Runs this statement once per parameter set, sending every Bind/Execute
@@ -78,6 +97,28 @@ export declare class PreparedStatement extends SafeEventEmitter implements Async
78
97
  * holding a `Connection` never saw. Hard to provoke - `DEALLOCATE`
79
98
  * does not normally raise one - which is why there is no test for it.
80
99
  */
100
+ /**
101
+ * Takes the value out of a `BindParam`, and refuses one that names a
102
+ * type this statement is not.
103
+ *
104
+ * A prepared statement's parameter types are fixed by its Parse, so a
105
+ * `BindParam` here cannot change one: the Bind's format code says
106
+ * binary or text, and the server decodes with the type it already
107
+ * resolved. Naming a different one and being believed would read the
108
+ * bytes as something else - which is why the mismatch is an error
109
+ * rather than something to quietly honour.
110
+ *
111
+ * It used to be neither: nothing unwrapped the object, so it reached
112
+ * the encoder as a value with no type of its own and went out as
113
+ * `'' + v`. A text column took `"[object Object]"` and said nothing.
114
+ *
115
+ * What it is compared against is what the caller declared at
116
+ * `prepare()`, or failing that what the server resolved - which is the
117
+ * same type either way, and is why an agreeing `BindParam` is simply
118
+ * unwrapped. Where there is nothing to compare against, the value is
119
+ * taken as given.
120
+ */
121
+ protected _unwrapBindParams(params: Maybe<any[]>): Maybe<any[]>;
81
122
  protected _emitNotice(msg: any): void;
82
123
  protected _executeBatchWithTransaction(paramSets: any[][], options: QueryOptions): Promise<BatchResult>;
83
124
  protected _executeWithTransaction(options?: QueryOptions): Promise<QueryResult>;
@@ -1,4 +1,4 @@
1
- import { DataTypeOIDs } from '../constants.js';
1
+ import { DataTypeNames, DataTypeOIDs } from '../constants.js';
2
2
  import { GlobalTypeMap } from '../data-type-map.js';
3
3
  import { Protocol } from '../protocol/protocol.js';
4
4
  import { SafeEventEmitter } from '../safe-event-emitter.js';
@@ -8,6 +8,7 @@ import { resolveColumnFormats } from '../util/resolve-column-formats.js';
8
8
  import { resolveRowType } from '../util/row-decoder.js';
9
9
  import { isTransactionCommand, refusesSavepoint, } from '../util/transaction-command.js';
10
10
  import { wrapRowDescription } from '../util/wrap-row-description.js';
11
+ import { BindParam } from './bind-param.js';
11
12
  import { Cursor } from './cursor.js';
12
13
  import { getIntlConnection } from './intl-connection.js';
13
14
  import { Portal } from './portal.js';
@@ -18,6 +19,7 @@ export class PreparedStatement extends SafeEventEmitter {
18
19
  _sql = '';
19
20
  _name = '';
20
21
  _paramTypes;
22
+ _resolvedParamTypes;
21
23
  _fields;
22
24
  _onErrorSavePoint;
23
25
  _refCount = 0;
@@ -34,8 +36,9 @@ export class PreparedStatement extends SafeEventEmitter {
34
36
  const intoCon = getIntlConnection(connection);
35
37
  intoCon.assertConnected();
36
38
  const statement = new PreparedStatement(connection, sql, options?.paramTypes);
37
- const { fields } = await intoCon.prepareOnce(sql, statement.paramTypes, statement.name);
39
+ const { fields, resolvedParamTypes } = await intoCon.prepareOnce(sql, statement.paramTypes, statement.name);
38
40
  statement._fields = fields;
41
+ statement._resolvedParamTypes = resolvedParamTypes;
39
42
  statement._refCount = 1;
40
43
  return statement;
41
44
  }
@@ -51,6 +54,26 @@ export class PreparedStatement extends SafeEventEmitter {
51
54
  get paramTypes() {
52
55
  return this._paramTypes;
53
56
  }
57
+ /**
58
+ * What the server made of each parameter, in order, as its Describe
59
+ * answered - not what was asked for in `paramTypes`.
60
+ *
61
+ * A parameter the caller declared nothing for is resolved by the
62
+ * server from where it appears: the column it is inserted into, the
63
+ * operator it sits beside, the function it is passed to. It never
64
+ * looks at the value, which is still three messages away, so this is
65
+ * a property of the statement and does not change between executions.
66
+ *
67
+ * Useful for two things. It says what a parameter with no context
68
+ * became - `select $1` resolves to `text`, which is why an array
69
+ * there comes back as an array literal rather than an array. And it
70
+ * is the type to name with `new BindParam(oid, v)` for a caller who
71
+ * wants the binary encoding a large numeric array gives up by going
72
+ * out untyped (see `isUnspecifiedParam`).
73
+ */
74
+ get resolvedParamTypes() {
75
+ return this._resolvedParamTypes;
76
+ }
54
77
  async execute(options = {}) {
55
78
  const intlCon = getIntlConnection(this.connection);
56
79
  // Folded in here rather than deeper: a cursor keeps these options
@@ -130,13 +153,58 @@ export class PreparedStatement extends SafeEventEmitter {
130
153
  * holding a `Connection` never saw. Hard to provoke - `DEALLOCATE`
131
154
  * does not normally raise one - which is why there is no test for it.
132
155
  */
156
+ /**
157
+ * Takes the value out of a `BindParam`, and refuses one that names a
158
+ * type this statement is not.
159
+ *
160
+ * A prepared statement's parameter types are fixed by its Parse, so a
161
+ * `BindParam` here cannot change one: the Bind's format code says
162
+ * binary or text, and the server decodes with the type it already
163
+ * resolved. Naming a different one and being believed would read the
164
+ * bytes as something else - which is why the mismatch is an error
165
+ * rather than something to quietly honour.
166
+ *
167
+ * It used to be neither: nothing unwrapped the object, so it reached
168
+ * the encoder as a value with no type of its own and went out as
169
+ * `'' + v`. A text column took `"[object Object]"` and said nothing.
170
+ *
171
+ * What it is compared against is what the caller declared at
172
+ * `prepare()`, or failing that what the server resolved - which is the
173
+ * same type either way, and is why an agreeing `BindParam` is simply
174
+ * unwrapped. Where there is nothing to compare against, the value is
175
+ * taken as given.
176
+ */
177
+ _unwrapBindParams(params) {
178
+ if (!params)
179
+ return params;
180
+ let out;
181
+ const l = params.length;
182
+ let i;
183
+ let prm;
184
+ let expected;
185
+ for (i = 0; i < l; i++) {
186
+ prm = params[i];
187
+ if (!(prm instanceof BindParam))
188
+ continue;
189
+ expected = this._paramTypes?.[i] ?? this._resolvedParamTypes?.[i];
190
+ if (expected != null && prm.oid !== expected)
191
+ throw new TypeError(`Parameter $${i + 1} was given as ${typeLabel(prm.oid)}, but this ` +
192
+ `prepared statement's $${i + 1} is ${typeLabel(expected)}. A ` +
193
+ 'prepared statement takes its parameter types at ' +
194
+ 'prepare(sql, { paramTypes }), and they cannot be changed per ' +
195
+ 'execution.');
196
+ out = out || params.slice();
197
+ out[i] = prm.value;
198
+ }
199
+ return out || params;
200
+ }
133
201
  _emitNotice(msg) {
134
202
  this.emit('notice', msg);
135
203
  getIntlConnection(this.connection).emit('notice', msg);
136
204
  }
137
205
  _executeBatchWithTransaction(paramSets, options) {
138
206
  const intlCon = getIntlConnection(this.connection);
139
- return this._withTransaction(options, () => intlCon.executeBatchReused(this.name, this._fields, this.paramTypes, paramSets, options));
207
+ return this._withTransaction(options, () => intlCon.executeBatchReused(this.name, this._fields, this.paramTypes, paramSets.map(set => this._unwrapBindParams(set)), options));
140
208
  }
141
209
  async _executeWithTransaction(options = {}) {
142
210
  // The Bind+Execute+Sync path can carry the rollbackOnError savepoint
@@ -238,6 +306,7 @@ export class PreparedStatement extends SafeEventEmitter {
238
306
  }
239
307
  async _execute(options = {}, savepoint) {
240
308
  const intlCon = getIntlConnection(this.connection);
309
+ const params = this._unwrapBindParams(options.params);
241
310
  if (options.cursor && this._fields) {
242
311
  intlCon.ref();
243
312
  let portal = new Portal(this, 'P_' + ++portalCounter);
@@ -247,7 +316,7 @@ export class PreparedStatement extends SafeEventEmitter {
247
316
  // codes on the Bind below with no round trip of its own.
248
317
  const typeMap = options.typeMap || GlobalTypeMap;
249
318
  const columnFormat = resolveColumnFormats(this._fields, options, typeMap);
250
- const fields = await portal.bindAndRetrieveFields(options.params, options, columnFormat);
319
+ const fields = await portal.bindAndRetrieveFields(params, options, columnFormat);
251
320
  const parsers = getParsers(typeMap, fields, options);
252
321
  const resultFields = wrapRowDescription(typeMap, fields, columnFormat, options);
253
322
  const result = {
@@ -270,7 +339,7 @@ export class PreparedStatement extends SafeEventEmitter {
270
339
  // unnamed portal, reusing the RowDescription prepare() already fetched
271
340
  // instead of re-Describing (and re-Close-ing) a fresh named portal on
272
341
  // every call.
273
- return intlCon.executeReused(this.name, this._fields, this.paramTypes, options.params, options, savepoint);
342
+ return intlCon.executeReused(this.name, this._fields, this.paramTypes, params, options, savepoint);
274
343
  }
275
344
  /**
276
345
  * Called by Cursor.close() instead of separately awaiting
@@ -372,3 +441,8 @@ export class PreparedStatement extends SafeEventEmitter {
372
441
  return this.close();
373
442
  }
374
443
  }
444
+ /** `int2 (21)` - the name when there is one, and always the oid. */
445
+ function typeLabel(oid) {
446
+ const name = DataTypeNames[oid];
447
+ return name ? `${name} (${oid})` : String(oid);
448
+ }
@@ -9,7 +9,7 @@ export const Int2Type = {
9
9
  return '' + v;
10
10
  },
11
11
  encodeBinary(buf, v) {
12
- buf.writeInt16BE(assertInteger(fastParseInt(v), 'int2'));
12
+ buf.writeInt16BE(assertInteger(v, 'int2', -32768, 32767));
13
13
  },
14
14
  decodeBinary(v, offset = 0) {
15
15
  return v.readInt16BE(offset);
@@ -9,7 +9,7 @@ export const Int4Type = {
9
9
  return '' + v;
10
10
  },
11
11
  encodeBinary(buf, v) {
12
- buf.writeInt32BE(assertInteger(fastParseInt(v), 'int4'));
12
+ buf.writeInt32BE(assertInteger(v, 'int4', -2147483648, 2147483647));
13
13
  },
14
14
  decodeBinary(v, offset = 0) {
15
15
  return v.readInt32BE(offset);
@@ -1,5 +1,5 @@
1
1
  import { DataTypeOIDs } from '../constants.js';
2
- import { assertBigIntSource } from '../util/assert-integer.js';
2
+ import { assertBigInt } from '../util/assert-integer.js';
3
3
  import { readBigInt64BE } from '../util/bigint-methods.js';
4
4
  import { fastParseIntBuffer } from '../util/fast-parseint.js';
5
5
  const maxSafeInteger = BigInt(Number.MAX_SAFE_INTEGER);
@@ -11,7 +11,7 @@ export const Int8Type = {
11
11
  return '' + v;
12
12
  },
13
13
  encodeBinary(buf, v) {
14
- buf.writeBigInt64BE(assertBigIntSource(v, 'int8'));
14
+ buf.writeBigInt64BE(assertBigInt(v, 'int8'));
15
15
  },
16
16
  decodeBinary(buf, offset = 0) {
17
17
  const v = typeof buf.readBigInt64BE === 'function'
@@ -9,7 +9,7 @@ export const OidType = {
9
9
  return '' + v;
10
10
  },
11
11
  encodeBinary(buf, v) {
12
- buf.writeUInt32BE(assertInteger(fastParseInt(v), 'oid'));
12
+ buf.writeUInt32BE(assertInteger(v, 'oid', 0, 4294967295));
13
13
  },
14
14
  decodeBinary(v, offset = 0) {
15
15
  return v.readUInt32BE(offset);
@@ -1,4 +1,5 @@
1
1
  import { DataTypeOIDs } from '../constants.js';
2
+ import { assertInteger } from '../util/assert-integer.js';
2
3
  import { fastParseInt, fastParseIntBuffer } from '../util/fast-parseint.js';
3
4
  /**
4
5
  * `xid`, `xid8` and `cid` - the transaction and command counters behind
@@ -41,7 +42,7 @@ function createUInt32Type(name, oid) {
41
42
  return '' + v;
42
43
  },
43
44
  encodeBinary(buf, v) {
44
- buf.writeUInt32BE(fastParseInt(v));
45
+ buf.writeUInt32BE(assertInteger(v, name, 0, 4294967295));
45
46
  },
46
47
  decodeBinary(v, offset = 0) {
47
48
  return v.readUInt32BE(offset);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "postgrejs",
3
3
  "description": "Professional PostgreSQL client NodeJS",
4
- "version": "3.11.1",
4
+ "version": "3.12.0",
5
5
  "author": "Panates",
6
6
  "license": "BSD-3-Clause",
7
7
  "dependencies": {
@@ -1,22 +1,48 @@
1
1
  /**
2
- * Returns `v` coerced to an integer, or throws when it cannot be one.
2
+ * Returns `v` as the integer an integer column can store, or throws.
3
3
  *
4
- * `fastParseInt()` already answers NaN for a value with no integer reading
5
- * - `'abc'`, `{}`, `[]`, `true` - but `Buffer.writeInt32BE(NaN)` then
6
- * stores a silent zero, so the answer is lost exactly where it matters.
7
- * Nothing downstream can tell that apart from a real zero afterwards: the
8
- * row inserts, the copy succeeds, and the wrong number is in the table.
4
+ * The rule is that the binary encoding must accept exactly what the text
5
+ * one does and refuse exactly what the server refuses, because which of
6
+ * the two a value goes out as is not the caller's choice: it depends on
7
+ * whether the parameter's type was declared, which statement cache the
8
+ * query hit, and what the server resolved. A value that means one thing
9
+ * through one path and another through the other is the worst outcome
10
+ * there is, and that is what this used to be:
9
11
  *
10
- * Floats are deliberately not rejected: truncating 3.7 to 3 is the
11
- * long-standing behaviour of these encoders and is usually what a caller
12
- * writing into an integer column means. Only "this is not a number at all"
13
- * is refused.
12
+ * ```
13
+ * text binary (before)
14
+ * [1.5] -> int4[] 22P02 error stored 1
15
+ * [-1.5] -> int4[] 22P02 error stored -2 (a floor, not a truncation)
16
+ * ['0x10'] -> int4[] 22P02 error stored 0
17
+ * [''] -> int8[] 22P02 error stored 0
18
+ * [40000] -> int2[] 22003 error RangeError, from Buffer
19
+ * ```
14
20
  *
15
- * Unlike the integer types, float4/float8/numeric leave NaN alone - it is a
16
- * value PostgreSQL stores and returns as NaN, distinct from NULL, on every
17
- * supported server version.
21
+ * Only the last of those was safe. The rest put a number in the table
22
+ * that the caller never wrote and the server would have refused, and
23
+ * silently - the row inserted, the copy succeeded.
24
+ *
25
+ * So a fractional value is refused rather than floored. Truncating 3.7
26
+ * to 3 was the long-standing behaviour here and the argument for it was
27
+ * that it is usually what a caller writing into an integer column means;
28
+ * what that argument misses is that the same caller writing the same
29
+ * value through the same client gets an error whenever the encoding
30
+ * happens to be text, so it cannot be what they meant. Round or truncate
31
+ * at the call site, or let the server do it with a cast.
32
+ *
33
+ * `min`/`max` are checked here as well, rather than left to Buffer's own
34
+ * bounds check, so that the message names the type and the value.
35
+ */
36
+ export declare function assertInteger(v: any, typeName: string, min: number, max: number): number;
37
+ /**
38
+ * The same rule for int8, which counts past what a `number` holds.
39
+ *
40
+ * BigInt is happy to convert things that are not numbers at all -
41
+ * `BigInt('')` is 0n because an empty string reads as zero, `BigInt(true)`
42
+ * is 1n - so a bad value in a bigint column used to land as a plausible
43
+ * 0 or 1. Only what genuinely carries an exact integer is let through.
18
44
  */
19
- export declare function assertInteger(v: number, typeName: string): number;
45
+ export declare function assertBigInt(v: any, typeName: string): bigint;
20
46
  /**
21
47
  * Returns `parsed` when a value that was NOT already a number coerced to
22
48
  * one, and throws when it did not.
@@ -30,14 +56,3 @@ export declare function assertInteger(v: number, typeName: string): number;
30
56
  * as NaN would be indistinguishable afterwards from a NaN that was meant.
31
57
  */
32
58
  export declare function assertCoercedNumber(parsed: number, typeName: string): number;
33
- /**
34
- * Refuses a value that only has an integer reading through a coercion
35
- * nobody asked for.
36
- *
37
- * int8 goes to `BigInt` rather than through `fastParseInt()`, and BigInt is
38
- * happy to convert things that are not numbers at all: `BigInt([])` is 0n
39
- * because an empty array stringifies to "", and `BigInt(true)` is 1n. A
40
- * bad value in a bigint column would land as a plausible-looking 0 or 1,
41
- * so only the types that genuinely carry an integer are let through.
42
- */
43
- export declare function assertBigIntSource(v: any, typeName: string): bigint | number;
@@ -1,26 +1,107 @@
1
1
  /**
2
- * Returns `v` coerced to an integer, or throws when it cannot be one.
2
+ * What the server reads as an integer from text: optional sign, digits,
3
+ * nothing else. `'1.5'`, `'1e3'` and `'0x10'` are all refused by
4
+ * `int4in()`, and surrounding whitespace is trimmed before it looks.
5
+ */
6
+ const DECIMAL_INTEGER = /^[+-]?\d+$/;
7
+ /**
8
+ * Returns `v` as the integer an integer column can store, or throws.
9
+ *
10
+ * The rule is that the binary encoding must accept exactly what the text
11
+ * one does and refuse exactly what the server refuses, because which of
12
+ * the two a value goes out as is not the caller's choice: it depends on
13
+ * whether the parameter's type was declared, which statement cache the
14
+ * query hit, and what the server resolved. A value that means one thing
15
+ * through one path and another through the other is the worst outcome
16
+ * there is, and that is what this used to be:
3
17
  *
4
- * `fastParseInt()` already answers NaN for a value with no integer reading
5
- * - `'abc'`, `{}`, `[]`, `true` - but `Buffer.writeInt32BE(NaN)` then
6
- * stores a silent zero, so the answer is lost exactly where it matters.
7
- * Nothing downstream can tell that apart from a real zero afterwards: the
8
- * row inserts, the copy succeeds, and the wrong number is in the table.
18
+ * ```
19
+ * text binary (before)
20
+ * [1.5] -> int4[] 22P02 error stored 1
21
+ * [-1.5] -> int4[] 22P02 error stored -2 (a floor, not a truncation)
22
+ * ['0x10'] -> int4[] 22P02 error stored 0
23
+ * [''] -> int8[] 22P02 error stored 0
24
+ * [40000] -> int2[] 22003 error RangeError, from Buffer
25
+ * ```
9
26
  *
10
- * Floats are deliberately not rejected: truncating 3.7 to 3 is the
11
- * long-standing behaviour of these encoders and is usually what a caller
12
- * writing into an integer column means. Only "this is not a number at all"
13
- * is refused.
27
+ * Only the last of those was safe. The rest put a number in the table
28
+ * that the caller never wrote and the server would have refused, and
29
+ * silently - the row inserted, the copy succeeded.
14
30
  *
15
- * Unlike the integer types, float4/float8/numeric leave NaN alone - it is a
16
- * value PostgreSQL stores and returns as NaN, distinct from NULL, on every
17
- * supported server version.
31
+ * So a fractional value is refused rather than floored. Truncating 3.7
32
+ * to 3 was the long-standing behaviour here and the argument for it was
33
+ * that it is usually what a caller writing into an integer column means;
34
+ * what that argument misses is that the same caller writing the same
35
+ * value through the same client gets an error whenever the encoding
36
+ * happens to be text, so it cannot be what they meant. Round or truncate
37
+ * at the call site, or let the server do it with a cast.
38
+ *
39
+ * `min`/`max` are checked here as well, rather than left to Buffer's own
40
+ * bounds check, so that the message names the type and the value.
18
41
  */
19
- export function assertInteger(v, typeName) {
20
- if (Number.isNaN(v)) {
42
+ export function assertInteger(v, typeName, min, max) {
43
+ let n;
44
+ const t = typeof v;
45
+ if (t === 'number') {
46
+ n = v;
47
+ if (Number.isNaN(n))
48
+ throw new TypeError(`Cannot encode value as ${typeName}: it has no integer value`);
49
+ }
50
+ else if (t === 'bigint') {
51
+ // Exact up to 2^53; beyond that the range check below refuses it
52
+ // anyway, since nothing this function guards is that wide.
53
+ n = Number(v);
54
+ }
55
+ else if (t === 'string') {
56
+ const s = v.trim();
57
+ if (!DECIMAL_INTEGER.test(s))
58
+ throw new TypeError(`Cannot encode "${v}" as ${typeName}: it is not an integer`);
59
+ n = +s;
60
+ }
61
+ else {
21
62
  throw new TypeError(`Cannot encode value as ${typeName}: it has no integer value`);
22
63
  }
23
- return v;
64
+ if (!Number.isInteger(n))
65
+ throw new TypeError(`Cannot encode ${n} as ${typeName}: it is not an integer`);
66
+ if (n < min || n > max)
67
+ throw new RangeError(`Cannot encode ${n} as ${typeName}: it is out of range (${min} to ${max})`);
68
+ return n;
69
+ }
70
+ const INT8_MIN = BigInt('-9223372036854775808');
71
+ const INT8_MAX = BigInt('9223372036854775807');
72
+ /**
73
+ * The same rule for int8, which counts past what a `number` holds.
74
+ *
75
+ * BigInt is happy to convert things that are not numbers at all -
76
+ * `BigInt('')` is 0n because an empty string reads as zero, `BigInt(true)`
77
+ * is 1n - so a bad value in a bigint column used to land as a plausible
78
+ * 0 or 1. Only what genuinely carries an exact integer is let through.
79
+ */
80
+ export function assertBigInt(v, typeName) {
81
+ let n;
82
+ const t = typeof v;
83
+ if (t === 'bigint') {
84
+ n = v;
85
+ }
86
+ else if (t === 'number') {
87
+ if (!Number.isInteger(v))
88
+ throw new TypeError(Number.isNaN(v)
89
+ ? `Cannot encode value as ${typeName}: it has no integer value`
90
+ : `Cannot encode ${v} as ${typeName}: it is not an integer`);
91
+ n = BigInt(v);
92
+ }
93
+ else if (t === 'string') {
94
+ const s = v.trim();
95
+ if (!DECIMAL_INTEGER.test(s))
96
+ throw new TypeError(`Cannot encode "${v}" as ${typeName}: it is not an integer`);
97
+ n = BigInt(s);
98
+ }
99
+ else {
100
+ throw new TypeError(`Cannot encode value as ${typeName}: it has no integer value`);
101
+ }
102
+ if (n < INT8_MIN || n > INT8_MAX)
103
+ throw new RangeError(`Cannot encode ${n} as ${typeName}: it is out of range (${INT8_MIN} to ${INT8_MAX})`);
104
+ return n;
24
105
  }
25
106
  /**
26
107
  * Returns `parsed` when a value that was NOT already a number coerced to
@@ -40,19 +121,3 @@ export function assertCoercedNumber(parsed, typeName) {
40
121
  }
41
122
  return parsed;
42
123
  }
43
- /**
44
- * Refuses a value that only has an integer reading through a coercion
45
- * nobody asked for.
46
- *
47
- * int8 goes to `BigInt` rather than through `fastParseInt()`, and BigInt is
48
- * happy to convert things that are not numbers at all: `BigInt([])` is 0n
49
- * because an empty array stringifies to "", and `BigInt(true)` is 1n. A
50
- * bad value in a bigint column would land as a plausible-looking 0 or 1,
51
- * so only the types that genuinely carry an integer are let through.
52
- */
53
- export function assertBigIntSource(v, typeName) {
54
- const t = typeof v;
55
- if (t === 'bigint' || t === 'number' || t === 'string')
56
- return v;
57
- throw new TypeError(`Cannot encode value as ${typeName}: it has no integer value`);
58
- }
@@ -1,4 +1,20 @@
1
1
  import { arrayCalculateDim } from './array-calculatedim.js';
2
+ /**
3
+ * What a number's own text looks like: digits, one optional dot, an
4
+ * optional exponent, an optional leading minus. Nothing in it is special
5
+ * to the array literal grammar and it cannot be read as `NULL`, so an
6
+ * element that matches goes out bare - `{1,2}` rather than `{"1","2"}`,
7
+ * which is what the server itself prints and what every other client
8
+ * writes.
9
+ *
10
+ * Deliberately narrower than "has no special characters": it is applied
11
+ * only to what a JS number or bigint encoded to, and matching it is the
12
+ * evidence that the encoder really did produce a number's text. `NaN`
13
+ * and `Infinity` fail it and stay quoted (both forms parse the same, so
14
+ * this costs nothing), and so does anything an unexpected encoder made
15
+ * of a number - a date type handed a timestamp, say.
16
+ */
17
+ const BARE_NUMBER = /^-?\d+(\.\d+)?([eE][-+]?\d+)?$/;
2
18
  export function stringifyArrayLiteral(value, options, encode) {
3
19
  const dim = arrayCalculateDim(value);
4
20
  // `dim` is built once above and never mutated, so its last index is
@@ -9,19 +25,24 @@ export function stringifyArrayLiteral(value, options, encode) {
9
25
  const lastLevel = dim.length - 1;
10
26
  const writeDim = (arr, level) => {
11
27
  const elemCount = dim[level];
12
- const out = [];
13
28
  const isLeafLevel = level >= lastLevel;
29
+ // Concatenated rather than collected and joined: V8 builds a rope
30
+ // and flattens it once, where an array of one string per element is
31
+ // a second allocation per element before the join sees any of them.
32
+ let out = '{';
14
33
  for (let i = 0; i < elemCount; i++) {
34
+ if (i)
35
+ out += ',';
15
36
  let x = arr && arr[i];
16
37
  if (!isLeafLevel) {
17
38
  if (x != null && !Array.isArray(x))
18
39
  x = [x];
19
- out.push(writeDim(x, level + 1));
40
+ out += writeDim(x, level + 1);
20
41
  continue;
21
42
  }
22
43
  // if value is null
23
44
  if (x == null) {
24
- out.push('NULL');
45
+ out += 'NULL';
25
46
  continue;
26
47
  }
27
48
  /* c8 ignore start - dim is (re)computed above from a DFS that visits
@@ -29,18 +50,32 @@ export function stringifyArrayLiteral(value, options, encode) {
29
50
  can never actually be an array once `level` reaches the leaf -
30
51
  if it were, dim would already have gone one level deeper there. */
31
52
  if (Array.isArray(x)) {
32
- out.push(stringifyArrayLiteral(x, options, encode));
53
+ out += stringifyArrayLiteral(x, options, encode);
33
54
  continue;
34
55
  }
35
56
  /* c8 ignore stop */
57
+ // Asked before the encoder runs, so the test below is only ever
58
+ // applied to a value that really was a number.
59
+ const isNumber = typeof x === 'number' || typeof x === 'bigint';
36
60
  if (encode)
37
61
  x = encode(x, options || {});
38
- out.push(escapeArrayItem('' + x));
62
+ const s = '' + x;
63
+ out += isNumber && BARE_NUMBER.test(s) ? s : escapeArrayItem(s);
39
64
  }
40
- return '{' + out.join(',') + '}';
65
+ return out + '}';
41
66
  };
42
67
  return writeDim(value, 0);
43
68
  }
69
+ /**
70
+ * Backslash and double quote are the only two characters the literal
71
+ * grammar gives meaning to inside a quoted element, and neither is in
72
+ * most values - so they are looked for before anything is rewritten. The
73
+ * two `replace()` calls each walk the string and build another one even
74
+ * when they change nothing, which for a column of ordinary text was
75
+ * half the cost of writing the literal.
76
+ */
44
77
  function escapeArrayItem(str) {
45
- return '"' + str.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
78
+ return str.indexOf('\\') < 0 && str.indexOf('"') < 0
79
+ ? '"' + str + '"'
80
+ : '"' + str.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
46
81
  }
@@ -36,7 +36,31 @@
36
36
  * or of one of this client's own classes has one type it can be, so
37
37
  * naming it says nothing the server would have decided differently -
38
38
  * and it keeps the binary encoding, which the text literal gives up.
39
- * A caller who wants that back for a large numeric array names the type
40
- * with `new BindParam(DataTypeOIDs._float8, v)`.
39
+ *
40
+ * What that giving up costs, which the commit making this change put at
41
+ * nothing measurable and is not: inserting the same value over a
42
+ * loopback connection, alternating within each round, medians of nine -
43
+ *
44
+ * ```
45
+ * as text as BindParam
46
+ * float8[] 1 000 2.33ms 0.91ms 2.6x
47
+ * float8[] 10 000 17.34ms 3.75ms 4.6x
48
+ * float8[] 100 000 143.0ms 26.9ms 5.3x
49
+ * int4[] 100 000 24.5ms 15.5ms 1.6x
50
+ * ```
51
+ *
52
+ * It grows with the array and with how wide an element's text is
53
+ * against its binary: a float8 is 8 bytes against about 19 characters,
54
+ * an int4 is 4 against up to 11 - and most of what a float8[] costs is
55
+ * the server's own parse of that text, not the writing of it. The
56
+ * original measurement was of the smallest of these shapes and read it
57
+ * as level; it is not, at any size.
58
+ *
59
+ * So this is a real price, paid for a correctness the client cannot buy
60
+ * any other way before the server has spoken. A caller who knows the
61
+ * column - and for a large numeric array that is worth knowing - names
62
+ * the type with `new BindParam(DataTypeOIDs._float8, v)` and gets the
63
+ * binary encoding back with nothing given up, since a named type is an
64
+ * answer rather than a guess.
41
65
  */
42
66
  export declare function isUnspecifiedParam(v: any): boolean;
@@ -37,8 +37,32 @@ import { arrayLeaf } from './array-leaf.js';
37
37
  * or of one of this client's own classes has one type it can be, so
38
38
  * naming it says nothing the server would have decided differently -
39
39
  * and it keeps the binary encoding, which the text literal gives up.
40
- * A caller who wants that back for a large numeric array names the type
41
- * with `new BindParam(DataTypeOIDs._float8, v)`.
40
+ *
41
+ * What that giving up costs, which the commit making this change put at
42
+ * nothing measurable and is not: inserting the same value over a
43
+ * loopback connection, alternating within each round, medians of nine -
44
+ *
45
+ * ```
46
+ * as text as BindParam
47
+ * float8[] 1 000 2.33ms 0.91ms 2.6x
48
+ * float8[] 10 000 17.34ms 3.75ms 4.6x
49
+ * float8[] 100 000 143.0ms 26.9ms 5.3x
50
+ * int4[] 100 000 24.5ms 15.5ms 1.6x
51
+ * ```
52
+ *
53
+ * It grows with the array and with how wide an element's text is
54
+ * against its binary: a float8 is 8 bytes against about 19 characters,
55
+ * an int4 is 4 against up to 11 - and most of what a float8[] costs is
56
+ * the server's own parse of that text, not the writing of it. The
57
+ * original measurement was of the smallest of these shapes and read it
58
+ * as level; it is not, at any size.
59
+ *
60
+ * So this is a real price, paid for a correctness the client cannot buy
61
+ * any other way before the server has spoken. A caller who knows the
62
+ * column - and for a large numeric array that is worth knowing - names
63
+ * the type with `new BindParam(DataTypeOIDs._float8, v)` and gets the
64
+ * binary encoding back with nothing given up, since a named type is an
65
+ * answer rather than a guess.
42
66
  */
43
67
  export function isUnspecifiedParam(v) {
44
68
  const x = arrayLeaf(v);