node-firebird 2.10.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,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
  *
@@ -93,6 +95,245 @@ export abstract class SQLVarBase {
93
95
 
94
96
  //------------------------------------------------------
95
97
 
98
+ /** Effective object-row key(s) of one output column (see computeColumnKeys). */
99
+ export interface ColumnKey {
100
+ /** Top-level table key when nestTables === true; undefined otherwise. */
101
+ table?: string;
102
+ /** Property key: the column alias, or 'table<sep>alias' in separator mode. */
103
+ key: string;
104
+ }
105
+
106
+ /**
107
+ * Compute the object-row property keys for a statement's output columns,
108
+ * honouring the nestTables and lowercase_keys options. The table qualifier
109
+ * is the query's relation alias when one is used (relationAlias, requested
110
+ * via isc_info_sql_relation_alias), the relation name otherwise, so
111
+ * self-joins nest under their query aliases. Expression columns (no source
112
+ * relation) qualify as '' exactly like mysql2: they nest under the '' key,
113
+ * and in separator mode become '<sep>alias' — always prefixing keeps
114
+ * qualified keys collision-free (a bare expression alias could otherwise
115
+ * collide with a real column's 'table<sep>column' key). Used by the fetch
116
+ * decoder and by fetchBlobSyncRow, which must agree on where each column
117
+ * landed in the row.
118
+ */
119
+ export function computeColumnKeys(
120
+ output: SQLVarBase[],
121
+ nestTables: boolean | string | undefined,
122
+ lowercaseKeys: boolean | undefined,
123
+ transform?: (key: string) => string
124
+ ): ColumnKey[] {
125
+ return output.map((column) => {
126
+ let key = column.alias || '';
127
+ if (lowercaseKeys) {
128
+ key = key.toLowerCase();
129
+ }
130
+ if (transform) {
131
+ key = transform(key);
132
+ }
133
+ if (nestTables !== true && typeof nestTables !== 'string') {
134
+ return { key };
135
+ }
136
+ let table = column.relationAlias || column.relation || '';
137
+ if (lowercaseKeys) {
138
+ table = table.toLowerCase();
139
+ }
140
+ if (transform) {
141
+ table = transform(table);
142
+ }
143
+ if (nestTables === true) {
144
+ return { table, key };
145
+ }
146
+ return { key: table + nestTables + key };
147
+ });
148
+ }
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
+
211
+ /**
212
+ * Resolve the effective nestTables value: the per-query option wins over
213
+ * the connection option. The decoder and fetchBlobSyncRow both use this —
214
+ * they must agree on whether nesting is active or blob cells are looked
215
+ * up in the wrong place.
216
+ */
217
+ export function resolveNestTables(
218
+ queryOptions: { nestTables?: boolean | string } | undefined,
219
+ connectionOptions: { nestTables?: boolean | string } | undefined
220
+ ): boolean | string | undefined {
221
+ return resolveQueryOption('nestTables', queryOptions, connectionOptions);
222
+ }
223
+
224
+ /**
225
+ * The object a column's value lives in: the row itself, or — when the
226
+ * column carries a nestTables table qualifier — the row's per-table
227
+ * sub-object, created on first use. Every site that reads or writes a
228
+ * cell by ColumnKey must resolve it through here.
229
+ */
230
+ export function nestCell(row: any, table: string | undefined) {
231
+ if (table === undefined) {
232
+ return row;
233
+ }
234
+ return row[table] || (row[table] = {});
235
+ }
236
+
237
+ //------------------------------------------------------
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
+
96
337
  export class SQLVarText extends SQLVarBase {
97
338
  decode(data: XdrReader, lowerV13: boolean, options?: any) {
98
339
  let ret;