node-firebird 2.11.0 → 2.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.
@@ -2,7 +2,8 @@ import Events from 'events';
2
2
  import { doError, fromCallback, type Callback, type SimpleCallback } from '../callback';
3
3
  import { 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';
@@ -95,7 +96,7 @@ function readblob(blob: any, callback: Callback): void {
95
96
  });
96
97
  }
97
98
 
98
- function fetchBlobSyncRow(row: any, meta: any[], nestTables: boolean | string | undefined, lowercaseKeys: boolean | undefined, callback: Callback): void {
99
+ function fetchBlobSyncRow(row: any, meta: any[], nestTables: boolean | string | undefined, lowercaseKeys: boolean | undefined, transform: ((key: string) => string) | undefined, callback: Callback): void {
99
100
  if (!row || !meta || !meta.length || !meta.some((m) => m && m.type === Const.SQL_BLOB)) {
100
101
  callback(null, row);
101
102
  return;
@@ -106,7 +107,7 @@ function fetchBlobSyncRow(row: any, meta: any[], nestTables: boolean | string |
106
107
  // duplicate JOIN column names (and nested rows) break that alignment.
107
108
  // Array rows (sequentially's legacy boolean form) are keyed by index.
108
109
  const isArrayRow = Array.isArray(row);
109
- const keys = isArrayRow ? null : computeColumnKeys(meta, nestTables, lowercaseKeys);
110
+ const keys = isArrayRow ? null : computeColumnKeys(meta, nestTables, lowercaseKeys, transform);
110
111
  const blobCells: { target: any; key: string | number }[] = [];
111
112
 
112
113
  for (let i = 0; i < meta.length; i++) {
@@ -147,6 +148,7 @@ function fetchBlobSyncRow(row: any, meta: any[], nestTables: boolean | string |
147
148
  class Database extends Events.EventEmitter {
148
149
  connection: Connection;
149
150
  eventid: number;
151
+ private _sql?: SqlTag;
150
152
 
151
153
  constructor(connection: Connection) {
152
154
  super();
@@ -155,6 +157,17 @@ class Database extends Events.EventEmitter {
155
157
  this.eventid = 1;
156
158
  }
157
159
 
160
+ /**
161
+ * Tagged-template query API: db.sql`SELECT ... ${value}` (see README).
162
+ * Built lazily on first access; the compiled text is positional-only,
163
+ * so the namedPlaceholders rewriter is disabled — any `:token` in the
164
+ * template is PSQL (EXECUTE BLOCK), not a placeholder.
165
+ */
166
+ get sql(): SqlTag {
167
+ return this._sql || (this._sql = makeSqlTag((text, params, options) =>
168
+ this.queryAsync(text, params, { ...options, namedPlaceholders: false })));
169
+ }
170
+
158
171
  escape(value: any): string {
159
172
  return escape(value, this.connection.accept.protocolVersion);
160
173
  }
@@ -325,6 +338,9 @@ class Database extends Events.EventEmitter {
325
338
  }
326
339
 
327
340
  var self = this;
341
+ var keyResolutionDone = false;
342
+ var resolvedNest: boolean | string | undefined;
343
+ var resolvedTransform: ((key: string) => string) | undefined;
328
344
  var _on = function(row: any, i: number, meta: any[], next: (err?: any) => void) {
329
345
  var done = false;
330
346
  var finish = function(err?: any) {
@@ -335,9 +351,15 @@ class Database extends Events.EventEmitter {
335
351
  next(err);
336
352
  };
337
353
 
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) {
354
+ // options is read at call time, after the normalization below;
355
+ // both values are query-invariant, so resolve them once on the
356
+ // first row instead of allocating per row
357
+ if (!keyResolutionDone) {
358
+ resolvedNest = resolveNestTables(options as any, self.connection.options);
359
+ resolvedTransform = resolveKeyTransform(options as any, self.connection.options);
360
+ keyResolutionDone = true;
361
+ }
362
+ fetchBlobSyncRow(row, meta, resolvedNest, self.connection._lowercase_keys, resolvedTransform, function(blobErr: any) {
341
363
  if (blobErr) {
342
364
  finish(blobErr);
343
365
  return;
@@ -552,8 +574,9 @@ class Database extends Events.EventEmitter {
552
574
  /*
553
575
  * Promise / async-await API.
554
576
  * 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.
577
+ * stays untouched. The promises resolve with the rows alone unless
578
+ * { withMeta: true } is passed, which resolves the full
579
+ * { rows, fields, affectedRows, recordCounts, warnings } result.
557
580
  */
558
581
 
559
582
  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;
@@ -2,12 +2,14 @@ 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';
6
8
  import type Connection from './connection';
7
9
  import type Database from './database';
8
10
  import type Statement from './statement';
9
11
  import type { BatchCb, StatementCb, InternalQueryOptions } from './wire-types';
10
- import type { BatchOptions, BatchResult, QueryOptions, QueryParams, QueryStreamOptions, SequentialCallback } from '../types';
12
+ import type { BatchOptions, BatchResult, QueryOptions, QueryParams, QueryStreamOptions, RecordCounts, SequentialCallback } from '../types';
11
13
 
12
14
  /***************************************
13
15
  *
@@ -53,11 +55,80 @@ class Transaction {
53
55
  // populated externally from the op_transaction response
54
56
  handle!: number;
55
57
 
58
+ private _sql?: SqlTag;
59
+
56
60
  constructor(connection: Connection) {
57
61
  this.connection = connection;
58
62
  this.db = connection.db;
59
63
  }
60
64
 
65
+ /**
66
+ * Tagged-template query API: tx.sql`SELECT ... ${value}` (see README).
67
+ * Built lazily — transactions are created per-query internally, and
68
+ * those throwaway instances must not pay for the tag. The compiled text
69
+ * is positional-only, so the namedPlaceholders rewriter is disabled:
70
+ * any `:token` in the template is PSQL (EXECUTE BLOCK), not a
71
+ * placeholder.
72
+ */
73
+ get sql(): SqlTag {
74
+ return this._sql || (this._sql = makeSqlTag((text, params, options) =>
75
+ this.queryAsync(text, params, { ...options, namedPlaceholders: false })));
76
+ }
77
+
78
+ /** Current savepoint nesting depth (names savepoints, see savepoint()). */
79
+ private _savepointDepth = 0;
80
+
81
+ /**
82
+ * Run `work` inside a savepoint (Firebird 1.5+): on resolve the
83
+ * savepoint is released, on reject the transaction rolls back TO the
84
+ * savepoint — undoing only work's changes — and the error is rethrown,
85
+ * leaving the transaction itself usable. Nestable (each call generates
86
+ * a fresh NF_SP_n name), mirroring db.withTransaction's style and
87
+ * Postgres.js's sql.savepoint().
88
+ *
89
+ * Do NOT run sibling savepoints concurrently on one transaction
90
+ * (Promise.all): Firebird's RELEASE SAVEPOINT also releases every
91
+ * savepoint created after it, so interleaved siblings release each
92
+ * other. Nested (awaited) savepoints are fine.
93
+ */
94
+ async savepoint<T>(work: (transaction: this) => Promise<T> | T): Promise<T> {
95
+ if (typeof work !== 'function') {
96
+ throw new Error('savepoint(work) expects a function');
97
+ }
98
+ // named by nesting depth, not a global counter: sequential
99
+ // savepoints at the same depth reuse the same three SQL strings, so
100
+ // the statement cache serves them instead of accumulating
101
+ // single-use entries (redefining a released savepoint name is legal)
102
+ const name = 'NF_SP_' + (++this._savepointDepth);
103
+ try {
104
+ await this.queryAsync('SAVEPOINT ' + name);
105
+
106
+ let result: T;
107
+ try {
108
+ result = await work(this);
109
+ } catch (err: any) {
110
+ // only a work() failure rolls back to the savepoint — a
111
+ // RELEASE failure below must NOT undo work's successful
112
+ // changes
113
+ try {
114
+ await this.queryAsync('ROLLBACK TO SAVEPOINT ' + name);
115
+ } catch (rollbackErr: any) {
116
+ // the original failure matters more; keep the rollback
117
+ // failure attached for diagnosis
118
+ if (err && typeof err === 'object') {
119
+ err.savepointRollbackError = rollbackErr;
120
+ }
121
+ }
122
+ throw err;
123
+ }
124
+
125
+ await this.queryAsync('RELEASE SAVEPOINT ' + name);
126
+ return result;
127
+ } finally {
128
+ this._savepointDepth--;
129
+ }
130
+ }
131
+
61
132
  /** Per-call options.namedPlaceholders overrides the connection option. */
62
133
  private namedPlaceholdersEnabled(options?: InternalQueryOptions): boolean {
63
134
  if (options && options.namedPlaceholders !== undefined)
@@ -138,6 +209,69 @@ class Transaction {
138
209
  return;
139
210
  }
140
211
 
212
+ // withMeta applies to query/execute only: in streaming mode
213
+ // (sequentially/queryStream, which spread user options) rows
214
+ // bypass fetchAll's array, so a result object here would
215
+ // carry rows: [] and a meaningless affectedRows
216
+ var withMeta = Boolean(options && typeof options === 'object' &&
217
+ (options as any).withMeta && !(options as any).asStream);
218
+
219
+ // Deliver the historic result shape, or — when options.withMeta
220
+ // is set — request the per-verb DML row counts while the
221
+ // statement handle is still open and wrap everything in a
222
+ // { rows, fields, affectedRows, recordCounts, warnings } object.
223
+ function deliver(rows: any, isSelect: boolean, plainDml?: boolean) {
224
+ if (!withMeta) {
225
+ statement!.release();
226
+ if (callback) {
227
+ if (plainDml) {
228
+ // plain DML historically calls back with no args
229
+ callback();
230
+ } else {
231
+ callback(undefined, rows, statement!.output, isSelect);
232
+ }
233
+ }
234
+ return;
235
+ }
236
+
237
+ var execWarnings = (ret && ret.warnings) || [];
238
+ var finalize = function(counts?: RecordCounts) {
239
+ statement!.release();
240
+ if (!callback) {
241
+ return;
242
+ }
243
+ // DML: what the server actually changed; SELECT: rows
244
+ // returned (pg's rowCount convention)
245
+ var affectedRows = counts
246
+ ? counts.insertCount + counts.updateCount + counts.deleteCount
247
+ : (Array.isArray(rows) ? rows.length : (rows !== undefined ? 1 : 0));
248
+ callback(undefined, {
249
+ rows: rows,
250
+ fields: describeFields(statement!.output),
251
+ affectedRows: affectedRows,
252
+ recordCounts: counts,
253
+ warnings: execWarnings,
254
+ }, statement!.output, isSelect);
255
+ };
256
+
257
+ var t = statement!.type;
258
+ var isDml = t === Const.isc_info_sql_stmt_insert ||
259
+ t === Const.isc_info_sql_stmt_update ||
260
+ t === Const.isc_info_sql_stmt_delete ||
261
+ t === Const.isc_info_sql_stmt_exec_procedure;
262
+ if (!isDml) {
263
+ finalize();
264
+ return;
265
+ }
266
+ self.connection.statementInfo(statement!, Const.RECORDS_INFO, function(err: any, info: any) {
267
+ if (err) {
268
+ dropError(err);
269
+ return;
270
+ }
271
+ finalize(parseRecordCounts(info && info.buffer));
272
+ });
273
+ }
274
+
141
275
  switch (statement.type) {
142
276
  case Const.isc_info_sql_stmt_select:
143
277
  statement.fetchAll(self, function(err: any, r: any) {
@@ -146,34 +280,23 @@ class Transaction {
146
280
  return;
147
281
  }
148
282
 
149
- statement.release();
150
-
151
- if (callback)
152
- callback(undefined, r, statement.output, true);
153
-
283
+ deliver(r, true);
154
284
  });
155
285
 
156
286
  break;
157
287
 
158
288
  case Const.isc_info_sql_stmt_exec_procedure:
159
289
  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
-
290
+ deliver(ret.data[0], true);
165
291
  break;
166
292
  } else if (statement.output.length) {
167
- statement.fetch(self, 1, function(err: any, ret: any) {
293
+ statement.fetch(self, 1, function(err: any, fret: any) {
168
294
  if (err) {
169
295
  dropError(err);
170
296
  return;
171
297
  }
172
298
 
173
- statement.release();
174
-
175
- if (callback)
176
- callback(undefined, ret.data[0], statement.output, false);
299
+ deliver(fret.data[0], false);
177
300
  });
178
301
 
179
302
  break;
@@ -181,9 +304,7 @@ class Transaction {
181
304
 
182
305
  // Fall through is normal
183
306
  default:
184
- statement.release();
185
- if (callback)
186
- callback()
307
+ deliver(undefined, false, true);
187
308
  break;
188
309
  }
189
310
 
@@ -1,5 +1,7 @@
1
1
  import Const from './const';
2
+ import { BlrReader } from './serialize';
2
3
  import type { XdrReader, XdrWriter, BlrWriter } from './serialize';
4
+ import type { RecordCounts } from '../types';
3
5
 
4
6
  /***************************************
5
7
  *
@@ -117,13 +119,17 @@ export interface ColumnKey {
117
119
  export function computeColumnKeys(
118
120
  output: SQLVarBase[],
119
121
  nestTables: boolean | string | undefined,
120
- lowercaseKeys: boolean | undefined
122
+ lowercaseKeys: boolean | undefined,
123
+ transform?: (key: string) => string
121
124
  ): ColumnKey[] {
122
125
  return output.map((column) => {
123
126
  let key = column.alias || '';
124
127
  if (lowercaseKeys) {
125
128
  key = key.toLowerCase();
126
129
  }
130
+ if (transform) {
131
+ key = transform(key);
132
+ }
127
133
  if (nestTables !== true && typeof nestTables !== 'string') {
128
134
  return { key };
129
135
  }
@@ -131,6 +137,9 @@ export function computeColumnKeys(
131
137
  if (lowercaseKeys) {
132
138
  table = table.toLowerCase();
133
139
  }
140
+ if (transform) {
141
+ table = transform(table);
142
+ }
134
143
  if (nestTables === true) {
135
144
  return { table, key };
136
145
  }
@@ -138,6 +147,67 @@ export function computeColumnKeys(
138
147
  });
139
148
  }
140
149
 
150
+ /** transformKeys option value: the built-in 'camel', or a custom mapper. */
151
+ export type KeyTransform = 'camel' | ((key: string) => string);
152
+
153
+ /** FIRST_NAME → firstName (the transformKeys: 'camel' built-in). */
154
+ export function camelizeKey(key: string): string {
155
+ const parts = String(key).toLowerCase().split('_');
156
+ let out = parts[0] || '';
157
+ for (let i = 1; i < parts.length; i++) {
158
+ const part = parts[i];
159
+ if (part) {
160
+ out += part.charAt(0).toUpperCase() + part.slice(1);
161
+ }
162
+ }
163
+ return out;
164
+ }
165
+
166
+ /**
167
+ * Resolve the effective transformKeys value (per-query wins over the
168
+ * connection option) into a callable mapper, or undefined when off.
169
+ * A custom mapper is guarded like the typeCast hook: a throw inside the
170
+ * row-decode loop would be mistaken for an incomplete packet and desync
171
+ * the response queue, so failures fall back to the untransformed key.
172
+ */
173
+ export function resolveKeyTransform(
174
+ queryOptions: { transformKeys?: KeyTransform } | undefined,
175
+ connectionOptions: { transformKeys?: KeyTransform } | undefined
176
+ ): ((key: string) => string) | undefined {
177
+ const value = resolveQueryOption<KeyTransform>('transformKeys', queryOptions, connectionOptions);
178
+ if (value === 'camel') {
179
+ return camelizeKey;
180
+ }
181
+ if (typeof value !== 'function') {
182
+ return undefined;
183
+ }
184
+ return (key: string) => {
185
+ try {
186
+ return String(value(key));
187
+ } catch (err: any) {
188
+ console.warn('[node-firebird] transformKeys mapper threw for key "%s": %s — using the untransformed key',
189
+ key, err && err.message);
190
+ return key;
191
+ }
192
+ };
193
+ }
194
+
195
+ /**
196
+ * Shared precedence rule for per-query-overridable connection options:
197
+ * the per-query value wins whenever it is present (even if falsy), the
198
+ * connection option applies otherwise.
199
+ */
200
+ function resolveQueryOption<T>(
201
+ name: string,
202
+ queryOptions: Record<string, any> | undefined,
203
+ connectionOptions: Record<string, any> | undefined
204
+ ): T | undefined {
205
+ if (queryOptions && queryOptions[name] !== undefined) {
206
+ return queryOptions[name];
207
+ }
208
+ return connectionOptions ? connectionOptions[name] : undefined;
209
+ }
210
+
141
211
  /**
142
212
  * Resolve the effective nestTables value: the per-query option wins over
143
213
  * the connection option. The decoder and fetchBlobSyncRow both use this —
@@ -148,10 +218,7 @@ export function resolveNestTables(
148
218
  queryOptions: { nestTables?: boolean | string } | undefined,
149
219
  connectionOptions: { nestTables?: boolean | string } | undefined
150
220
  ): boolean | string | undefined {
151
- if (queryOptions && queryOptions.nestTables !== undefined) {
152
- return queryOptions.nestTables;
153
- }
154
- return connectionOptions && connectionOptions.nestTables;
221
+ return resolveQueryOption('nestTables', queryOptions, connectionOptions);
155
222
  }
156
223
 
157
224
  /**
@@ -169,6 +236,104 @@ export function nestCell(row: any, table: string | undefined) {
169
236
 
170
237
  //------------------------------------------------------
171
238
 
239
+ /** Human-readable names for the SQL_* wire type codes. */
240
+ export const SQL_TYPE_NAMES: Record<number, string> = {
241
+ [Const.SQL_TEXT]: 'TEXT',
242
+ [Const.SQL_VARYING]: 'VARYING',
243
+ [Const.SQL_SHORT]: 'SHORT',
244
+ [Const.SQL_LONG]: 'LONG',
245
+ [Const.SQL_FLOAT]: 'FLOAT',
246
+ [Const.SQL_DOUBLE]: 'DOUBLE',
247
+ [Const.SQL_D_FLOAT]: 'D_FLOAT',
248
+ [Const.SQL_TIMESTAMP]: 'TIMESTAMP',
249
+ [Const.SQL_BLOB]: 'BLOB',
250
+ [Const.SQL_ARRAY]: 'ARRAY',
251
+ [Const.SQL_QUAD]: 'QUAD',
252
+ [Const.SQL_TYPE_TIME]: 'TIME',
253
+ [Const.SQL_TYPE_DATE]: 'DATE',
254
+ [Const.SQL_INT64]: 'INT64',
255
+ [Const.SQL_INT128]: 'INT128',
256
+ [Const.SQL_TIMESTAMP_TZ]: 'TIMESTAMP_TZ',
257
+ [Const.SQL_TIMESTAMP_TZ_EX]: 'TIMESTAMP_TZ_EX',
258
+ [Const.SQL_TIME_TZ]: 'TIME_TZ',
259
+ [Const.SQL_TIME_TZ_EX]: 'TIME_TZ_EX',
260
+ [Const.SQL_DEC16]: 'DEC16',
261
+ [Const.SQL_DEC34]: 'DEC34',
262
+ [Const.SQL_BOOLEAN]: 'BOOLEAN',
263
+ [Const.SQL_NULL]: 'NULL',
264
+ };
265
+
266
+ /**
267
+ * Public column-metadata shape for one output descriptor: the vocabulary
268
+ * both the typeCast hook and withMeta `fields` deliver. Keep the two in
269
+ * lockstep by building both through here.
270
+ */
271
+ export function describeField(meta: Partial<SQLVarBase>) {
272
+ return {
273
+ type: meta.type!,
274
+ typeName: SQL_TYPE_NAMES[meta.type!] || 'UNKNOWN',
275
+ subType: meta.subType,
276
+ scale: meta.scale,
277
+ length: meta.length,
278
+ nullable: meta.nullable,
279
+ field: meta.field,
280
+ relation: meta.relation,
281
+ relationAlias: meta.relationAlias,
282
+ relationSchema: meta.relationSchema,
283
+ alias: meta.alias,
284
+ };
285
+ }
286
+
287
+ /**
288
+ * Map a statement's output descriptors to the column-metadata array
289
+ * delivered in withMeta results ({ rows, fields, ... }).
290
+ */
291
+ export function describeFields(output: SQLVarBase[]) {
292
+ return (output || []).map(describeField);
293
+ }
294
+
295
+ /**
296
+ * Parse the op_info_sql response buffer of a Const.RECORDS_INFO request
297
+ * into per-verb row counts. The buffer holds an isc_info_sql_records
298
+ * cluster (2-byte total length, then nested isc_info_req_*_count items,
299
+ * each 2-byte length + little-endian integer) terminated by isc_info_end.
300
+ */
301
+ export function parseRecordCounts(buffer: Buffer | undefined): RecordCounts {
302
+ const counts = { selectCount: 0, insertCount: 0, updateCount: 0, deleteCount: 0 };
303
+ if (!buffer || !buffer.length) {
304
+ return counts;
305
+ }
306
+ // this runs inside a response callback — a malformed/truncated buffer
307
+ // must yield partial counts, never a throw
308
+ try {
309
+ const br = new BlrReader(buffer);
310
+ while (br.pos < br.buffer.length) {
311
+ const item = br.readByteCode();
312
+ if (item === Const.isc_info_end || item === Const.isc_info_truncated) {
313
+ break;
314
+ }
315
+ if (item === Const.isc_info_sql_records) {
316
+ br.pos += 2; // skip the cluster's total length; nested items follow
317
+ continue;
318
+ }
319
+ switch (item) {
320
+ case Const.isc_info_req_select_count: counts.selectCount = br.readInt() || 0; break;
321
+ case Const.isc_info_req_insert_count: counts.insertCount = br.readInt() || 0; break;
322
+ case Const.isc_info_req_update_count: counts.updateCount = br.readInt() || 0; break;
323
+ case Const.isc_info_req_delete_count: counts.deleteCount = br.readInt() || 0; break;
324
+ default:
325
+ // unknown item: its 2-byte length prefix tells us how far to skip
326
+ br.pos += 2 + br.buffer.readUInt16LE(br.pos);
327
+ }
328
+ }
329
+ } catch (e) {
330
+ // fall through with whatever was parsed so far
331
+ }
332
+ return counts;
333
+ }
334
+
335
+ //------------------------------------------------------
336
+
172
337
  export class SQLVarText extends SQLVarBase {
173
338
  decode(data: XdrReader, lowerV13: boolean, options?: any) {
174
339
  let ret;