node-firebird 2.11.0 → 2.13.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/wire/const.ts CHANGED
@@ -587,6 +587,12 @@ const sqlInfo = {
587
587
  isc_info_sql_stmt_type : 21,
588
588
  isc_info_sql_get_plan : 22,
589
589
  isc_info_sql_records : 23,
590
+ // per-verb row counts nested inside an isc_info_sql_records cluster
591
+ // (inf_pub.h isc_info_req_*)
592
+ isc_info_req_select_count : 13,
593
+ isc_info_req_insert_count : 14,
594
+ isc_info_req_update_count : 15,
595
+ isc_info_req_delete_count : 16,
590
596
  isc_info_sql_batch_fetch : 24,
591
597
  isc_info_sql_relation_alias : 25, // >: 2.0
592
598
  isc_info_sql_explain_plan : 26, // >= 3.0
@@ -669,6 +675,12 @@ const DESCRIBE_WITH_SCHEMA = [
669
675
  sqlInfo.isc_info_sql_describe_end
670
676
  ];
671
677
 
678
+ // op_info_sql request for the per-verb DML row counts of an executed
679
+ // statement (withMeta / affectedRows).
680
+ const RECORDS_INFO = [
681
+ sqlInfo.isc_info_sql_records,
682
+ ];
683
+
672
684
  /***********************/
673
685
  /* ISC Services */
674
686
  /***********************/
@@ -910,6 +922,7 @@ const Const = Object.freeze({
910
922
  ...defaultOptions,
911
923
  DESCRIBE,
912
924
  DESCRIBE_WITH_SCHEMA,
925
+ RECORDS_INFO,
913
926
  ...dpb,
914
927
  ...dsql,
915
928
  ...fetchOp,
@@ -1,11 +1,13 @@
1
1
  import Events from 'events';
2
2
  import { doError, fromCallback, type Callback, type SimpleCallback } from '../callback';
3
- import { escape } from '../utils';
3
+ import { batchResultToError, escape } from '../utils';
4
4
  import Const from './const';
5
- import { computeColumnKeys, nestCell, resolveNestTables } from './xsqlvar';
5
+ import { makeSqlTag, type SqlTag } from '../sql-template';
6
+ import { computeColumnKeys, nestCell, resolveKeyTransform, resolveNestTables } from './xsqlvar';
6
7
  import EventConnection from './eventConnection';
7
8
  import FbEventManager from './fbEventManager';
8
9
  import makeQueryStream from './query-stream';
10
+ import makeBatchStream from './batch-stream';
9
11
  import type Connection from './connection';
10
12
  import type Transaction from './transaction';
11
13
  import type Statement from './statement';
@@ -95,7 +97,7 @@ function readblob(blob: any, callback: Callback): void {
95
97
  });
96
98
  }
97
99
 
98
- function fetchBlobSyncRow(row: any, meta: any[], nestTables: boolean | string | undefined, lowercaseKeys: boolean | undefined, callback: Callback): void {
100
+ function fetchBlobSyncRow(row: any, meta: any[], nestTables: boolean | string | undefined, lowercaseKeys: boolean | undefined, transform: ((key: string) => string) | undefined, callback: Callback): void {
99
101
  if (!row || !meta || !meta.length || !meta.some((m) => m && m.type === Const.SQL_BLOB)) {
100
102
  callback(null, row);
101
103
  return;
@@ -106,7 +108,7 @@ function fetchBlobSyncRow(row: any, meta: any[], nestTables: boolean | string |
106
108
  // duplicate JOIN column names (and nested rows) break that alignment.
107
109
  // Array rows (sequentially's legacy boolean form) are keyed by index.
108
110
  const isArrayRow = Array.isArray(row);
109
- const keys = isArrayRow ? null : computeColumnKeys(meta, nestTables, lowercaseKeys);
111
+ const keys = isArrayRow ? null : computeColumnKeys(meta, nestTables, lowercaseKeys, transform);
110
112
  const blobCells: { target: any; key: string | number }[] = [];
111
113
 
112
114
  for (let i = 0; i < meta.length; i++) {
@@ -147,6 +149,7 @@ function fetchBlobSyncRow(row: any, meta: any[], nestTables: boolean | string |
147
149
  class Database extends Events.EventEmitter {
148
150
  connection: Connection;
149
151
  eventid: number;
152
+ private _sql?: SqlTag;
150
153
 
151
154
  constructor(connection: Connection) {
152
155
  super();
@@ -155,6 +158,17 @@ class Database extends Events.EventEmitter {
155
158
  this.eventid = 1;
156
159
  }
157
160
 
161
+ /**
162
+ * Tagged-template query API: db.sql`SELECT ... ${value}` (see README).
163
+ * Built lazily on first access; the compiled text is positional-only,
164
+ * so the namedPlaceholders rewriter is disabled — any `:token` in the
165
+ * template is PSQL (EXECUTE BLOCK), not a placeholder.
166
+ */
167
+ get sql(): SqlTag {
168
+ return this._sql || (this._sql = makeSqlTag((text, params, options) =>
169
+ this.queryAsync(text, params, { ...options, namedPlaceholders: false })));
170
+ }
171
+
158
172
  escape(value: any): string {
159
173
  return escape(value, this.connection.accept.protocolVersion);
160
174
  }
@@ -287,12 +301,7 @@ class Database extends Events.EventEmitter {
287
301
 
288
302
  if (!result.success) {
289
303
  transaction.rollback(function() {
290
- var first = result.errors.length ? result.errors[0] : null;
291
- var batchError: any = first
292
- ? first.error
293
- : new Error('Batch failed for record(s) ' + result.errorRecordNumbers.join(', '));
294
- batchError.batchCompletion = result;
295
- doError(batchError, callback);
304
+ doError(batchResultToError(result), callback);
296
305
  });
297
306
  return;
298
307
  }
@@ -325,6 +334,9 @@ class Database extends Events.EventEmitter {
325
334
  }
326
335
 
327
336
  var self = this;
337
+ var keyResolutionDone = false;
338
+ var resolvedNest: boolean | string | undefined;
339
+ var resolvedTransform: ((key: string) => string) | undefined;
328
340
  var _on = function(row: any, i: number, meta: any[], next: (err?: any) => void) {
329
341
  var done = false;
330
342
  var finish = function(err?: any) {
@@ -335,9 +347,15 @@ class Database extends Events.EventEmitter {
335
347
  next(err);
336
348
  };
337
349
 
338
- // options is read at call time, after the normalization below
339
- const nest = resolveNestTables(options as any, self.connection.options);
340
- fetchBlobSyncRow(row, meta, nest, self.connection._lowercase_keys, function(blobErr: any) {
350
+ // options is read at call time, after the normalization below;
351
+ // both values are query-invariant, so resolve them once on the
352
+ // first row instead of allocating per row
353
+ if (!keyResolutionDone) {
354
+ resolvedNest = resolveNestTables(options as any, self.connection.options);
355
+ resolvedTransform = resolveKeyTransform(options as any, self.connection.options);
356
+ keyResolutionDone = true;
357
+ }
358
+ fetchBlobSyncRow(row, meta, resolvedNest, self.connection._lowercase_keys, resolvedTransform, function(blobErr: any) {
341
359
  if (blobErr) {
342
360
  finish(blobErr);
343
361
  return;
@@ -393,6 +411,18 @@ class Database extends Events.EventEmitter {
393
411
  return makeQueryStream(this, query, params, options);
394
412
  }
395
413
 
414
+ /**
415
+ * Bulk-insert Writable (the COPY FROM analogue, Firebird 4.0+): write
416
+ * parameter-array rows, they are flushed in chunks through the batch
417
+ * API on one prepared statement. Runs its own transaction — committed
418
+ * on finish, rolled back on error/destroy (all-or-nothing for the
419
+ * whole stream). BLOB columns accept Buffers/strings. After 'finish',
420
+ * stream.recordCount / stream.affectedRows carry the totals.
421
+ */
422
+ batchStream(query: string, options?: any) {
423
+ return makeBatchStream(this, query, options, true);
424
+ }
425
+
396
426
  query(query: string, params?: QueryParams | Callback, callback?: any, options: InternalQueryOptions = {}): this {
397
427
  if (params instanceof Function) {
398
428
  options = callback || {};
@@ -552,8 +582,9 @@ class Database extends Events.EventEmitter {
552
582
  /*
553
583
  * Promise / async-await API.
554
584
  * Each *Async method wraps its callback counterpart; the callback API
555
- * stays untouched. Result metadata is only available through the
556
- * callback API — the promises resolve with the rows alone.
585
+ * stays untouched. The promises resolve with the rows alone unless
586
+ * { withMeta: true } is passed, which resolves the full
587
+ * { rows, fields, affectedRows, recordCounts, warnings } result.
557
588
  */
558
589
 
559
590
  queryAsync(query: string, params?: QueryParams, options?: InternalQueryOptions): Promise<any[]> {
@@ -197,7 +197,11 @@ export class BlrReader {
197
197
  value = this.buffer.readInt16LE(this.pos);
198
198
  break;
199
199
  case 4:
200
- value = this.buffer.readInt32LE(this.pos)
200
+ value = this.buffer.readInt32LE(this.pos);
201
+ break;
202
+ case 8:
203
+ // e.g. record counts above 2^31 (isc_info_sql_records)
204
+ value = Number(this.buffer.readBigInt64LE(this.pos));
201
205
  }
202
206
  this.pos += len;
203
207
  return value;
@@ -342,6 +346,17 @@ export class XdrWriter {
342
346
  this.pos += alen;
343
347
  }
344
348
 
349
+ /** addString for pre-encoded bytes (codepage connection charsets). */
350
+ addStringBuffer(b: Buffer): void {
351
+ var alen = align(b.length);
352
+ this.ensure(alen + 4);
353
+ this.buffer.writeInt32BE(b.length, this.pos);
354
+ this.pos += 4;
355
+ b.copy(this.buffer, this.pos);
356
+ this.buffer.fill(0, this.pos + b.length, this.pos + alen);
357
+ this.pos += alen;
358
+ }
359
+
345
360
  addText(s: string, encoding: BufferEncoding): void {
346
361
  var len = Buffer.byteLength(s, encoding);
347
362
  var alen = align(len);
@@ -163,9 +163,15 @@ class Socket {
163
163
  * Compress and/or encrypt data before sending to socket.
164
164
  */
165
165
  write(data: Buffer | Uint8Array, defer = false): void {
166
+ // Callers pass views of the connection's shared _msg buffer, and both
167
+ // net.Socket (when it cannot flush immediately) and zlib keep a
168
+ // REFERENCE to the chunk — a later sender rebuilding _msg would then
169
+ // corrupt this queued packet (intermittent, load-dependent). Own the
170
+ // bytes at the send boundary, once.
171
+ data = Buffer.from(data);
166
172
  if (process.env.FIREBIRD_DEBUG) {
167
173
  console.log('[fb-debug] socket.write: length=%d bytes=%s encrypt=%s defer=%s',
168
- data.length, Buffer.from(data).toString('hex'), this.encrypt, defer);
174
+ data.length, (data as Buffer).toString('hex'), this.encrypt, defer);
169
175
  }
170
176
  if (defer) {
171
177
  // Accumulate deferred packets instead of overwriting. Multiple
@@ -174,8 +180,8 @@ class Socket {
174
180
  // overwriting the buffer silently drops packets and desynchronises
175
181
  // the request/response queue, causing the connection to hang.
176
182
  this.buffer = this.buffer
177
- ? Buffer.concat([this.buffer, Buffer.from(data)])
178
- : Buffer.from(data);
183
+ ? Buffer.concat([this.buffer, data])
184
+ : (data as Buffer);
179
185
  return;
180
186
  }
181
187
 
@@ -2,12 +2,15 @@ import { doCallback, doError, fromCallback, type Callback, type SimpleCallback }
2
2
  import { parseNamedPlaceholders } from '../named-params';
3
3
  import { noop } from '../utils';
4
4
  import Const from './const';
5
+ import { makeSqlTag, type SqlTag } from '../sql-template';
6
+ import { describeFields, parseRecordCounts } from './xsqlvar';
5
7
  import makeQueryStream from './query-stream';
8
+ import makeBatchStream from './batch-stream';
6
9
  import type Connection from './connection';
7
10
  import type Database from './database';
8
11
  import type Statement from './statement';
9
12
  import type { BatchCb, StatementCb, InternalQueryOptions } from './wire-types';
10
- import type { BatchOptions, BatchResult, QueryOptions, QueryParams, QueryStreamOptions, SequentialCallback } from '../types';
13
+ import type { BatchOptions, BatchResult, QueryOptions, QueryParams, QueryStreamOptions, RecordCounts, SequentialCallback } from '../types';
11
14
 
12
15
  /***************************************
13
16
  *
@@ -53,11 +56,80 @@ class Transaction {
53
56
  // populated externally from the op_transaction response
54
57
  handle!: number;
55
58
 
59
+ private _sql?: SqlTag;
60
+
56
61
  constructor(connection: Connection) {
57
62
  this.connection = connection;
58
63
  this.db = connection.db;
59
64
  }
60
65
 
66
+ /**
67
+ * Tagged-template query API: tx.sql`SELECT ... ${value}` (see README).
68
+ * Built lazily — transactions are created per-query internally, and
69
+ * those throwaway instances must not pay for the tag. The compiled text
70
+ * is positional-only, so the namedPlaceholders rewriter is disabled:
71
+ * any `:token` in the template is PSQL (EXECUTE BLOCK), not a
72
+ * placeholder.
73
+ */
74
+ get sql(): SqlTag {
75
+ return this._sql || (this._sql = makeSqlTag((text, params, options) =>
76
+ this.queryAsync(text, params, { ...options, namedPlaceholders: false })));
77
+ }
78
+
79
+ /** Current savepoint nesting depth (names savepoints, see savepoint()). */
80
+ private _savepointDepth = 0;
81
+
82
+ /**
83
+ * Run `work` inside a savepoint (Firebird 1.5+): on resolve the
84
+ * savepoint is released, on reject the transaction rolls back TO the
85
+ * savepoint — undoing only work's changes — and the error is rethrown,
86
+ * leaving the transaction itself usable. Nestable (each call generates
87
+ * a fresh NF_SP_n name), mirroring db.withTransaction's style and
88
+ * Postgres.js's sql.savepoint().
89
+ *
90
+ * Do NOT run sibling savepoints concurrently on one transaction
91
+ * (Promise.all): Firebird's RELEASE SAVEPOINT also releases every
92
+ * savepoint created after it, so interleaved siblings release each
93
+ * other. Nested (awaited) savepoints are fine.
94
+ */
95
+ async savepoint<T>(work: (transaction: this) => Promise<T> | T): Promise<T> {
96
+ if (typeof work !== 'function') {
97
+ throw new Error('savepoint(work) expects a function');
98
+ }
99
+ // named by nesting depth, not a global counter: sequential
100
+ // savepoints at the same depth reuse the same three SQL strings, so
101
+ // the statement cache serves them instead of accumulating
102
+ // single-use entries (redefining a released savepoint name is legal)
103
+ const name = 'NF_SP_' + (++this._savepointDepth);
104
+ try {
105
+ await this.queryAsync('SAVEPOINT ' + name);
106
+
107
+ let result: T;
108
+ try {
109
+ result = await work(this);
110
+ } catch (err: any) {
111
+ // only a work() failure rolls back to the savepoint — a
112
+ // RELEASE failure below must NOT undo work's successful
113
+ // changes
114
+ try {
115
+ await this.queryAsync('ROLLBACK TO SAVEPOINT ' + name);
116
+ } catch (rollbackErr: any) {
117
+ // the original failure matters more; keep the rollback
118
+ // failure attached for diagnosis
119
+ if (err && typeof err === 'object') {
120
+ err.savepointRollbackError = rollbackErr;
121
+ }
122
+ }
123
+ throw err;
124
+ }
125
+
126
+ await this.queryAsync('RELEASE SAVEPOINT ' + name);
127
+ return result;
128
+ } finally {
129
+ this._savepointDepth--;
130
+ }
131
+ }
132
+
61
133
  /** Per-call options.namedPlaceholders overrides the connection option. */
62
134
  private namedPlaceholdersEnabled(options?: InternalQueryOptions): boolean {
63
135
  if (options && options.namedPlaceholders !== undefined)
@@ -138,6 +210,69 @@ class Transaction {
138
210
  return;
139
211
  }
140
212
 
213
+ // withMeta applies to query/execute only: in streaming mode
214
+ // (sequentially/queryStream, which spread user options) rows
215
+ // bypass fetchAll's array, so a result object here would
216
+ // carry rows: [] and a meaningless affectedRows
217
+ var withMeta = Boolean(options && typeof options === 'object' &&
218
+ (options as any).withMeta && !(options as any).asStream);
219
+
220
+ // Deliver the historic result shape, or — when options.withMeta
221
+ // is set — request the per-verb DML row counts while the
222
+ // statement handle is still open and wrap everything in a
223
+ // { rows, fields, affectedRows, recordCounts, warnings } object.
224
+ function deliver(rows: any, isSelect: boolean, plainDml?: boolean) {
225
+ if (!withMeta) {
226
+ statement!.release();
227
+ if (callback) {
228
+ if (plainDml) {
229
+ // plain DML historically calls back with no args
230
+ callback();
231
+ } else {
232
+ callback(undefined, rows, statement!.output, isSelect);
233
+ }
234
+ }
235
+ return;
236
+ }
237
+
238
+ var execWarnings = (ret && ret.warnings) || [];
239
+ var finalize = function(counts?: RecordCounts) {
240
+ statement!.release();
241
+ if (!callback) {
242
+ return;
243
+ }
244
+ // DML: what the server actually changed; SELECT: rows
245
+ // returned (pg's rowCount convention)
246
+ var affectedRows = counts
247
+ ? counts.insertCount + counts.updateCount + counts.deleteCount
248
+ : (Array.isArray(rows) ? rows.length : (rows !== undefined ? 1 : 0));
249
+ callback(undefined, {
250
+ rows: rows,
251
+ fields: describeFields(statement!.output),
252
+ affectedRows: affectedRows,
253
+ recordCounts: counts,
254
+ warnings: execWarnings,
255
+ }, statement!.output, isSelect);
256
+ };
257
+
258
+ var t = statement!.type;
259
+ var isDml = t === Const.isc_info_sql_stmt_insert ||
260
+ t === Const.isc_info_sql_stmt_update ||
261
+ t === Const.isc_info_sql_stmt_delete ||
262
+ t === Const.isc_info_sql_stmt_exec_procedure;
263
+ if (!isDml) {
264
+ finalize();
265
+ return;
266
+ }
267
+ self.connection.statementInfo(statement!, Const.RECORDS_INFO, function(err: any, info: any) {
268
+ if (err) {
269
+ dropError(err);
270
+ return;
271
+ }
272
+ finalize(parseRecordCounts(info && info.buffer));
273
+ });
274
+ }
275
+
141
276
  switch (statement.type) {
142
277
  case Const.isc_info_sql_stmt_select:
143
278
  statement.fetchAll(self, function(err: any, r: any) {
@@ -146,34 +281,39 @@ class Transaction {
146
281
  return;
147
282
  }
148
283
 
149
- statement.release();
150
-
151
- if (callback)
152
- callback(undefined, r, statement.output, true);
153
-
284
+ deliver(r, true);
154
285
  });
155
286
 
156
287
  break;
157
288
 
158
289
  case Const.isc_info_sql_stmt_exec_procedure:
159
290
  if (ret && ret.data && ret.data.length > 0) {
160
- statement.release();
161
-
162
- if (callback)
163
- callback(undefined, ret.data[0], statement.output, true);
164
-
291
+ // singleton op_execute2 rows never pass through
292
+ // fetchAll, so their blobAsText fetches must be
293
+ // resolved here (issue #305: EXECUTE PROCEDURE
294
+ // returned text blobs as unresolved functions)
295
+ self.connection.resolveTextBlobs(self, ret, function(blobErr?: any) {
296
+ if (blobErr) {
297
+ dropError(blobErr);
298
+ return;
299
+ }
300
+ deliver(ret.data[0], true);
301
+ });
165
302
  break;
166
303
  } else if (statement.output.length) {
167
- statement.fetch(self, 1, function(err: any, ret: any) {
304
+ statement.fetch(self, 1, function(err: any, fret: any) {
168
305
  if (err) {
169
306
  dropError(err);
170
307
  return;
171
308
  }
172
309
 
173
- statement.release();
174
-
175
- if (callback)
176
- callback(undefined, ret.data[0], statement.output, false);
310
+ self.connection.resolveTextBlobs(self, fret, function(blobErr?: any) {
311
+ if (blobErr) {
312
+ dropError(blobErr);
313
+ return;
314
+ }
315
+ deliver(fret.data[0], false);
316
+ });
177
317
  });
178
318
 
179
319
  break;
@@ -181,9 +321,7 @@ class Transaction {
181
321
 
182
322
  // Fall through is normal
183
323
  default:
184
- statement.release();
185
- if (callback)
186
- callback()
324
+ deliver(undefined, false, true);
187
325
  break;
188
326
  }
189
327
 
@@ -265,6 +403,15 @@ class Transaction {
265
403
  return makeQueryStream(this, query, params, options);
266
404
  }
267
405
 
406
+ /**
407
+ * Bulk-insert Writable running inside this transaction (see
408
+ * Database.batchStream). The transaction is NOT committed or rolled
409
+ * back by the stream — settle it yourself after 'finish'/'error'.
410
+ */
411
+ batchStream(query: string, options?: any) {
412
+ return makeBatchStream(this, query, options, false);
413
+ }
414
+
268
415
  query(query: string, params?: QueryParams | Callback, callback?: any, options: InternalQueryOptions = {}): void {
269
416
  if (params instanceof Function) {
270
417
  callback = params;