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.
package/src/types.ts CHANGED
@@ -6,6 +6,9 @@
6
6
  // published declaration files.
7
7
 
8
8
  import type { Readable } from 'stream';
9
+ import type { SqlTag } from './sql-template';
10
+
11
+ export type { SqlTag, SqlQuery, SqlIdentifier, CompiledQuery } from './sql-template';
9
12
 
10
13
  export type DatabaseCallback = (err: any, db: Database) => void;
11
14
  export type TransactionCallback = (err: any, transaction: Transaction) => void;
@@ -127,6 +130,85 @@ export type QueryOptions = {
127
130
  * it cancels whatever is currently executing on the connection.
128
131
  */
129
132
  signal?: AbortSignal;
133
+ /**
134
+ * Per-query override of the `nestTables` connection option (mysql2
135
+ * semantics). `true` nests each object row by source table:
136
+ * `row[table][column]` — the table key is the query's relation alias
137
+ * when one is used (`FROM emp e` → `row.E`), the table name otherwise,
138
+ * and `''` for expression columns. A string separator flattens keys
139
+ * instead: `nestTables: '_'` → `row.EMP_NAME`; expression columns get
140
+ * the bare separator prefix (`row._ANSWER`, as in mysql2). Keys honour
141
+ * `lowercase_keys`. Object rows only — `db.execute` array rows are
142
+ * unaffected.
143
+ */
144
+ nestTables?: boolean | string;
145
+ /**
146
+ * Per-query override of the `transformKeys` connection option: rewrite
147
+ * object-row keys — `'camel'` turns `FIRST_NAME` into `firstName`, or
148
+ * pass a custom `(key) => key` mapper. Applied after `lowercase_keys`
149
+ * and to both parts of `nestTables` keys. Column metadata (`fields`,
150
+ * typeCast) keeps the raw server aliases.
151
+ */
152
+ transformKeys?: 'camel' | ((key: string) => string);
153
+ /**
154
+ * Deliver a full result object `{ rows, fields, affectedRows,
155
+ * recordCounts, warnings }` instead of the bare rows (callback and
156
+ * promise APIs). For DML, `affectedRows` is what the server actually
157
+ * changed (`isc_info_sql_records`, one extra lightweight info request
158
+ * per statement — hence opt-in) and `recordCounts` breaks it down per
159
+ * verb; for SELECT it is the number of rows returned (pg's `rowCount`
160
+ * convention) with no extra round-trip. `warnings` carries any
161
+ * `isc_arg_warning` entries from the execute response. Honoured by
162
+ * query/execute and their *Async wrappers only — ignored by the
163
+ * streaming APIs (sequentially/queryStream, where rows bypass the
164
+ * result) and executeBatch (which has its own completion shape).
165
+ */
166
+ withMeta?: boolean;
167
+ }
168
+
169
+ /** Column metadata delivered in withMeta results (`fields`) — the same
170
+ * vocabulary the typeCast hook receives, plus nullable and the relation
171
+ * alias/schema. */
172
+ export interface FieldMetadata {
173
+ type: number;
174
+ typeName: string;
175
+ subType?: number;
176
+ scale?: number;
177
+ length?: number;
178
+ nullable?: boolean;
179
+ field?: string;
180
+ relation?: string;
181
+ relationAlias?: string;
182
+ relationSchema?: string;
183
+ alias?: string;
184
+ }
185
+
186
+ /** Per-verb server row counts of an executed DML statement. */
187
+ export interface RecordCounts {
188
+ selectCount: number;
189
+ insertCount: number;
190
+ updateCount: number;
191
+ deleteCount: number;
192
+ }
193
+
194
+ /** An isc_arg_warning entry from a server response ('warning' driver event
195
+ * and withMeta `warnings`). */
196
+ export interface ServerWarning {
197
+ gdscode: number;
198
+ params?: (string | number)[];
199
+ message: string;
200
+ }
201
+
202
+ /** Full result shape delivered when `withMeta: true` is set. */
203
+ export interface QueryResult<T = any> {
204
+ /** Rows array (SELECT), single row object (RETURNING / procedures), or undefined (plain DML). */
205
+ rows: T[] | T | undefined;
206
+ fields: FieldMetadata[];
207
+ /** DML: rows the server changed; SELECT: rows returned. */
208
+ affectedRows: number;
209
+ /** Set for DML statements only. */
210
+ recordCounts?: RecordCounts;
211
+ warnings: ServerWarning[];
130
212
  }
131
213
 
132
214
  export type QueryStreamOptions = QueryOptions & {
@@ -140,6 +222,13 @@ export type QueryStreamOptions = QueryOptions & {
140
222
  }
141
223
 
142
224
  export interface Database {
225
+ /**
226
+ * Tagged-template query API (Postgres.js-style): interpolated values
227
+ * become positional parameters, `sql('NAME')` quotes an identifier,
228
+ * embedded `sql` fragments compose, arrays expand to `?, ?, ?` lists.
229
+ * The returned query is a lazy thenable — it executes once, on await.
230
+ */
231
+ sql: SqlTag;
143
232
  detach(callback?: SimpleCallback): Database;
144
233
  transaction(options: TransactionOptions|Isolation|TransactionCallback, callback?: TransactionCallback): Database;
145
234
  newStatement(query: string, callback: (err: Error | null, statement: Statement) => void): Database;
@@ -164,8 +253,11 @@ export interface Database {
164
253
  createSchema(schemaName: string, tablespaceName?: string | QueryCallback, callback?: QueryCallback): Database;
165
254
 
166
255
  // Promise / async-await API (see README § Promises / async–await).
167
- // Result metadata is only available through the callback API.
256
+ // Pass { withMeta: true } to resolve with the full QueryResult
257
+ // (rows + fields + affectedRows + warnings) instead of bare rows.
258
+ queryAsync<T = any>(query: string, params: QueryParams | undefined, options: QueryOptions & { withMeta: true }): Promise<QueryResult<T>>;
168
259
  queryAsync<T = any>(query: string, params?: QueryParams, options?: QueryOptions): Promise<T[]>;
260
+ executeAsync<T = any>(query: string, params: QueryParams | undefined, options: QueryOptions & { withMeta: true }): Promise<QueryResult<T>>;
169
261
  executeAsync<T = any>(query: string, params?: QueryParams, options?: QueryOptions): Promise<T[]>;
170
262
  executeBatchAsync(query: string, rows: QueryParams[], options?: BatchOptions): Promise<BatchResult>;
171
263
  sequentiallyAsync(query: string, params: QueryParams | undefined, rowCallback: SequentialCallback, options?: QueryOptions | boolean): Promise<void>;
@@ -189,6 +281,14 @@ export interface Database {
189
281
  }
190
282
 
191
283
  export interface Transaction {
284
+ /** Tagged-template query API running inside this transaction (see Database.sql). */
285
+ sql: SqlTag;
286
+ /**
287
+ * Run `work` inside a savepoint: released on resolve, rolled back TO
288
+ * (undoing only work's changes) on reject — the transaction stays
289
+ * usable either way. Nestable.
290
+ */
291
+ savepoint<T>(work: (transaction: Transaction) => Promise<T> | T): Promise<T>;
192
292
  newStatement(query: string, callback: (err: Error | null, statement: Statement) => void): void;
193
293
  query(query: string, params: QueryParams, callback: QueryCallback, options?: QueryOptions): void;
194
294
  execute(query: string, params: QueryParams, callback: QueryCallback, options?: QueryOptions): void;
@@ -207,7 +307,9 @@ export interface Transaction {
207
307
  rollbackRetaining(callback?: SimpleCallback): void;
208
308
 
209
309
  // Promise / async-await API
310
+ queryAsync<T = any>(query: string, params: QueryParams | undefined, options: QueryOptions & { withMeta: true }): Promise<QueryResult<T>>;
210
311
  queryAsync<T = any>(query: string, params?: QueryParams, options?: QueryOptions): Promise<T[]>;
312
+ executeAsync<T = any>(query: string, params: QueryParams | undefined, options: QueryOptions & { withMeta: true }): Promise<QueryResult<T>>;
211
313
  executeAsync<T = any>(query: string, params?: QueryParams, options?: QueryOptions): Promise<T[]>;
212
314
  executeBatchAsync(query: string, rows: QueryParams[], options?: BatchOptions): Promise<BatchResult>;
213
315
  sequentiallyAsync(query: string, params: QueryParams | undefined, rowCallback: SequentialCallback, options?: QueryOptions | boolean): Promise<void>;
@@ -314,6 +416,24 @@ export interface Options {
314
416
  * per-query `namedPlaceholders: false` override.
315
417
  */
316
418
  namedPlaceholders?: boolean;
419
+ /**
420
+ * Qualify object-row keys by source table (same option as mysql2), so
421
+ * JOINed columns with the same name stop overwriting each other:
422
+ * `true` nests each row as `row[table][column]`; a string separator
423
+ * flattens to `row['table' + sep + 'column']`. See
424
+ * `QueryOptions.nestTables` for the exact key rules. Applies wherever
425
+ * object rows are produced (query / sequentially / queryStream);
426
+ * array rows (execute) are unaffected. Overridable per query.
427
+ */
428
+ nestTables?: boolean | string;
429
+ /**
430
+ * Rewrite object-row keys (Postgres.js `transform` counterpart):
431
+ * `'camel'` turns `FIRST_NAME` into `firstName`, or pass a custom
432
+ * `(key) => key` mapper. Applied after `lowercase_keys` and to both
433
+ * parts of `nestTables` keys; column metadata keeps raw aliases.
434
+ * Overridable per query.
435
+ */
436
+ transformKeys?: 'camel' | ((key: string) => string);
317
437
  /**
318
438
  * TCP keepalive probing to detect dead/stale connections (same option
319
439
  * names as mysql2). On by default; set false to disable.
@@ -352,6 +472,20 @@ export interface Options {
352
472
  * Default 0 (idle connections are kept forever).
353
473
  */
354
474
  idleTimeoutMillis?: number;
475
+ /**
476
+ * Pool only: retire a physical connection after this many checkouts
477
+ * (pg's `maxUses`) — it is closed for good when returned to the pool
478
+ * and replaced on demand. Bounds server-side resource drift on
479
+ * long-lived connections. Default 0 (unlimited uses).
480
+ */
481
+ maxUses?: number;
482
+ /**
483
+ * Pool only: retire a physical connection this many milliseconds after
484
+ * it was created (Postgres.js's `max_lifetime`), on return to the pool
485
+ * or by the idle sweep — even below `min`; replacements are created on
486
+ * demand. Default 0 (unlimited lifetime).
487
+ */
488
+ maxLifetimeMillis?: number;
355
489
  /**
356
490
  * **Firebird 6.0+ only (Protocol 20+)**
357
491
  *
package/src/uri.ts CHANGED
@@ -198,7 +198,59 @@ export function parseConnectionString(str: string): Options {
198
198
  */
199
199
  export function normalizeOptions<T>(options: T | string): T {
200
200
  if (typeof options === 'string') {
201
- return parseConnectionString(options) as T;
201
+ options = parseConnectionString(options) as T;
202
202
  }
203
- return options;
203
+ return applyEnvDefaults(options as any);
204
+ }
205
+
206
+ /**
207
+ * Fall back to environment variables for connection settings the caller
208
+ * did not provide — the pg-style convention using Firebird's own names:
209
+ * ISC_USER / ISC_PASSWORD (honoured by isql and every official tool) plus
210
+ * FIREBIRD_HOST / FIREBIRD_PORT / FIREBIRD_DATABASE / FIREBIRD_ROLE.
211
+ * Explicit options always win; the driver's built-in defaults (SYSDBA /
212
+ * masterkey / 127.0.0.1) still apply when neither is set. A fresh object
213
+ * is returned so caller-owned options objects are never mutated.
214
+ */
215
+ const ENV_FALLBACKS: [string, string][] = [
216
+ ['user', 'ISC_USER'],
217
+ ['password', 'ISC_PASSWORD'],
218
+ ['host', 'FIREBIRD_HOST'],
219
+ ['port', 'FIREBIRD_PORT'],
220
+ ['database', 'FIREBIRD_DATABASE'],
221
+ ['role', 'FIREBIRD_ROLE'],
222
+ ];
223
+
224
+ function applyEnvDefaults<T extends Record<string, any>>(options: T): T {
225
+ let out: any = options;
226
+ for (const [key, envName] of ENV_FALLBACKS) {
227
+ const value = process.env[envName];
228
+ // empty-string env vars (common in CI: `export ISC_PASSWORD=`)
229
+ // count as unset
230
+ if (value === undefined || value === '') {
231
+ continue;
232
+ }
233
+ // a service-manager connection's `database` selects the TARGET of
234
+ // backup/restore — never let a leftover env var pick that silently
235
+ if (key === 'database' && (options as any).manager) {
236
+ continue;
237
+ }
238
+ if (out[key] === undefined || out[key] === null || out[key] === '') {
239
+ if (out === options) {
240
+ out = { ...(options as any) };
241
+ }
242
+ if (key === 'port') {
243
+ const port = Number(value);
244
+ if (!Number.isFinite(port) || port <= 0) {
245
+ // NaN is falsy: it would silently fall back to 3050
246
+ // downstream instead of surfacing the typo
247
+ throw new Error('Invalid FIREBIRD_PORT environment variable: ' + value);
248
+ }
249
+ out[key] = port;
250
+ } else {
251
+ out[key] = value;
252
+ }
253
+ }
254
+ }
255
+ return out;
204
256
  }
@@ -75,31 +75,8 @@ function statementCacheLimit(options: InternalOptions): number {
75
75
  return 0;
76
76
  }
77
77
 
78
- const SQL_TYPE_NAMES: Record<number, string> = {
79
- [Const.SQL_TEXT]: 'TEXT',
80
- [Const.SQL_VARYING]: 'VARYING',
81
- [Const.SQL_SHORT]: 'SHORT',
82
- [Const.SQL_LONG]: 'LONG',
83
- [Const.SQL_FLOAT]: 'FLOAT',
84
- [Const.SQL_DOUBLE]: 'DOUBLE',
85
- [Const.SQL_D_FLOAT]: 'D_FLOAT',
86
- [Const.SQL_TIMESTAMP]: 'TIMESTAMP',
87
- [Const.SQL_BLOB]: 'BLOB',
88
- [Const.SQL_ARRAY]: 'ARRAY',
89
- [Const.SQL_QUAD]: 'QUAD',
90
- [Const.SQL_TYPE_TIME]: 'TIME',
91
- [Const.SQL_TYPE_DATE]: 'DATE',
92
- [Const.SQL_INT64]: 'INT64',
93
- [Const.SQL_INT128]: 'INT128',
94
- [Const.SQL_TIMESTAMP_TZ]: 'TIMESTAMP_TZ',
95
- [Const.SQL_TIMESTAMP_TZ_EX]: 'TIMESTAMP_TZ_EX',
96
- [Const.SQL_TIME_TZ]: 'TIME_TZ',
97
- [Const.SQL_TIME_TZ_EX]: 'TIME_TZ_EX',
98
- [Const.SQL_DEC16]: 'DEC16',
99
- [Const.SQL_DEC34]: 'DEC34',
100
- [Const.SQL_BOOLEAN]: 'BOOLEAN',
101
- [Const.SQL_NULL]: 'NULL',
102
- };
78
+ // SQL type-code names live in xsqlvar.ts alongside the descriptors
79
+ const SQL_TYPE_NAMES = Xsql.SQL_TYPE_NAMES;
103
80
 
104
81
  /**
105
82
  * Run the user's typeCast hook (options.typeCast) for one column value.
@@ -114,16 +91,7 @@ function applyTypeCast(options: InternalOptions, meta: Partial<Xsql.SQLVarBase>,
114
91
  if (typeof typeCast !== 'function') {
115
92
  return defaultValue;
116
93
  }
117
- const column = {
118
- type: meta.type!,
119
- typeName: SQL_TYPE_NAMES[meta.type!] || 'UNKNOWN',
120
- subType: meta.subType,
121
- scale: meta.scale,
122
- length: meta.length,
123
- field: meta.field,
124
- relation: meta.relation,
125
- alias: meta.alias,
126
- };
94
+ const column = Xsql.describeField(meta);
127
95
  // A hook exception must never escape into the row-decode loop: there it
128
96
  // would be mistaken for an incomplete packet and desync the response
129
97
  // queue (the same failure mode as issue #341). Fall back to the default
@@ -456,13 +424,38 @@ class Connection {
456
424
  self._queue.length, self._pending.length, xdr.pos);
457
425
  }
458
426
 
427
+ // Surface isc_arg_warning entries (parsed since 2.10.0 but
428
+ // dropped here): resolve their message text and emit them on
429
+ // the Database on the next tick, so a listener registered
430
+ // inside this very response's callback (e.g. right after
431
+ // attach) still receives them.
432
+ if (obj && obj.warnings && obj.warnings.length && self.db && typeof self.db.emit === 'function') {
433
+ const warnings = obj.warnings;
434
+ for (const w of warnings) {
435
+ if (w.message === undefined) {
436
+ w.message = lookupMessages([w]);
437
+ if (!w.message || w.message === 'Unknow error') {
438
+ // codes newer than the bundled firebird.msg:
439
+ // still say something actionable
440
+ w.message = 'Firebird warning ' + w.gdscode +
441
+ (w.params && w.params.length ? ': ' + w.params.join(', ') : '');
442
+ }
443
+ }
444
+ }
445
+ process.nextTick(function () {
446
+ for (const w of warnings) {
447
+ self.db.emit('warning', w);
448
+ }
449
+ });
450
+ }
451
+
459
452
  if (obj && obj.status) {
460
453
  obj.message = lookupMessages(obj.status);
461
454
  doCallback(obj, cb);
462
455
  } else {
463
456
  doCallback(obj, cb);
464
457
  }
465
-
458
+
466
459
  });
467
460
 
468
461
  if (xdr.pos === 0) {
@@ -1984,6 +1977,34 @@ class Connection {
1984
1977
  }
1985
1978
 
1986
1979
 
1980
+ /**
1981
+ * Query runtime information about a prepared statement via op_info_sql
1982
+ * (e.g. Const.RECORDS_INFO for the per-verb DML row counts). The
1983
+ * response is a plain op_response whose buffer holds the info clusters.
1984
+ */
1985
+ statementInfo(statement: Statement, items: number[], callback?: QueueCallback) {
1986
+ if (this._isClosed)
1987
+ return this.throwClosed(callback);
1988
+
1989
+ this._pending.push('statementInfo');
1990
+
1991
+ var msg = this._msg;
1992
+ var blr = this._blr;
1993
+ msg.pos = 0;
1994
+ blr.pos = 0;
1995
+
1996
+ blr.addBytes(items);
1997
+
1998
+ msg.addInt(Const.op_info_sql);
1999
+ msg.addInt(statement.handle);
2000
+ msg.addInt(0); // incarnation
2001
+ msg.addBlr(blr);
2002
+ msg.addInt(65535); // buffer_length
2003
+
2004
+ this._queueEvent(callback);
2005
+ }
2006
+
2007
+
1987
2008
  fetchAll(statement: Statement, transaction: Transaction, callback: Callback<any[]>) {
1988
2009
  const self = this;
1989
2010
  const custom = statement.options || {};
@@ -2015,7 +2036,9 @@ class Connection {
2015
2036
  readBlobsSequentially(0, []).then((arrBlob: any) => {
2016
2037
  for (let i = 0; i < arrBlob.length; i++) {
2017
2038
  const blob = arrBlob[i];
2018
- ret.data[blob.row][blob.column] = applyTypeCast(
2039
+ // nestTables === true rows: the value lives in the
2040
+ // per-table sub-object, not on the row itself
2041
+ Xsql.nestCell(ret.data[blob.row], blob.table)[blob.column] = applyTypeCast(
2019
2042
  statement.connection.options, blob.meta || {},
2020
2043
  parseValueIfJson(blob.value, statement.connection.options));
2021
2044
  }
@@ -2469,6 +2492,7 @@ function decodeResponse(data: XdrReader, callback: QueueCallback | undefined, cn
2469
2492
  delete data.frow;
2470
2493
  delete data.frows;
2471
2494
  delete data.fcols;
2495
+ delete data.ftables;
2472
2496
 
2473
2497
  if (isOpFetch && data.fop) { // could be set when a packet is not complete
2474
2498
  data.readBuffer(68); // ??
@@ -2490,10 +2514,13 @@ function decodeResponse(data: XdrReader, callback: QueueCallback | undefined, cn
2490
2514
  data.frows = data.frows || [];
2491
2515
 
2492
2516
  if (custom.asObject && !data.fcols) {
2493
- if (lowercase_keys) {
2494
- data.fcols = output.map((column: any) => column.alias.toLowerCase());
2495
- } else {
2496
- data.fcols = output.map((column: any) => column.alias);
2517
+ const nest = Xsql.resolveNestTables(custom, cnx.options);
2518
+ const transform = Xsql.resolveKeyTransform(custom, cnx.options);
2519
+ const columnKeys = Xsql.computeColumnKeys(output, nest, lowercase_keys, transform);
2520
+ data.fcols = columnKeys.map((k) => k.key);
2521
+ if (nest === true) {
2522
+ // computeColumnKeys always sets table when nesting
2523
+ data.ftables = columnKeys.map((k) => k.table!);
2497
2524
  }
2498
2525
  }
2499
2526
 
@@ -2521,7 +2548,10 @@ function decodeResponse(data: XdrReader, callback: QueueCallback | undefined, cn
2521
2548
 
2522
2549
  if (!lowerV13 && nullBitSet!.get(data.fcolumn)) {
2523
2550
  const nullKey = custom.asObject ? data.fcols![data.fcolumn!] : data.fcolumn;
2524
- data.frow[nullKey] = applyTypeCast(cnx.options, item, null);
2551
+ // ftables is only set when nestTables === true, so
2552
+ // the default path writes straight into the row
2553
+ (data.ftables ? Xsql.nestCell(data.frow, data.ftables[data.fcolumn!]) : data.frow)[nullKey] =
2554
+ applyTypeCast(cnx.options, item, null);
2525
2555
 
2526
2556
  continue;
2527
2557
  }
@@ -2538,7 +2568,8 @@ function decodeResponse(data: XdrReader, callback: QueueCallback | undefined, cn
2538
2568
 
2539
2569
  if (item.type === Const.SQL_BLOB && value !== null) {
2540
2570
  if (item.subType === Const.isc_blob_text && cnx.options.blobAsText) {
2541
- value = fetch_blob_async_transaction(statement, value, key, row, item);
2571
+ value = fetch_blob_async_transaction(statement, value, key, row, item,
2572
+ data.ftables && data.ftables[data.fcolumn!]);
2542
2573
  arrBlob.push(value);
2543
2574
  pendingTextBlob = true;
2544
2575
  } else {
@@ -2546,7 +2577,7 @@ function decodeResponse(data: XdrReader, callback: QueueCallback | undefined, cn
2546
2577
  }
2547
2578
  }
2548
2579
 
2549
- data.frow[key] = pendingTextBlob
2580
+ (data.ftables ? Xsql.nestCell(data.frow, data.ftables[data.fcolumn!]) : data.frow)[key] = pendingTextBlob
2550
2581
  ? value
2551
2582
  : applyTypeCast(cnx.options, item, parseValueIfJson(value, cnx.options));
2552
2583
  } catch (e) {
@@ -3475,8 +3506,8 @@ function CalcBlr(blr: BlrWriter, xsqlda: any[]) {
3475
3506
  blr.addByte(Const.blr_eoc);
3476
3507
  }
3477
3508
 
3478
- function fetch_blob_async_transaction(statement: Statement, id: Quad, column: string | number, row: number, meta?: Xsql.SQLVarBase) {
3479
- const infoValue = { row, column, value: '', meta };
3509
+ function fetch_blob_async_transaction(statement: Statement, id: Quad, column: string | number, row: number, meta?: Xsql.SQLVarBase, table?: string) {
3510
+ const infoValue = { row, column, value: '', meta, table };
3480
3511
 
3481
3512
  return (transactionArg: any) => {
3482
3513
  const cacheKey = `${id.high}:${id.low}`;
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
@@ -626,6 +632,7 @@ const DESCRIBE = [
626
632
  sqlInfo.isc_info_sql_length,
627
633
  sqlInfo.isc_info_sql_field,
628
634
  sqlInfo.isc_info_sql_relation,
635
+ sqlInfo.isc_info_sql_relation_alias, // FB 2.0+: query alias of the source relation (nestTables)
629
636
  //isc_info_sql_owner,
630
637
  sqlInfo.isc_info_sql_alias,
631
638
  sqlInfo.isc_info_sql_describe_end,
@@ -654,6 +661,7 @@ const DESCRIBE_WITH_SCHEMA = [
654
661
  sqlInfo.isc_info_sql_field,
655
662
  sqlInfo.isc_info_sql_relation,
656
663
  sqlInfo.isc_info_sql_relation_schema, // FB 6.0: schema of source relation
664
+ sqlInfo.isc_info_sql_relation_alias, // query alias of the source relation (nestTables)
657
665
  //isc_info_sql_owner,
658
666
  sqlInfo.isc_info_sql_alias,
659
667
  sqlInfo.isc_info_sql_describe_end,
@@ -667,6 +675,12 @@ const DESCRIBE_WITH_SCHEMA = [
667
675
  sqlInfo.isc_info_sql_describe_end
668
676
  ];
669
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
+
670
684
  /***********************/
671
685
  /* ISC Services */
672
686
  /***********************/
@@ -908,6 +922,7 @@ const Const = Object.freeze({
908
922
  ...defaultOptions,
909
923
  DESCRIBE,
910
924
  DESCRIBE_WITH_SCHEMA,
925
+ RECORDS_INFO,
911
926
  ...dpb,
912
927
  ...dsql,
913
928
  ...fetchOp,
@@ -2,6 +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 { makeSqlTag, type SqlTag } from '../sql-template';
6
+ import { computeColumnKeys, nestCell, resolveKeyTransform, resolveNestTables } from './xsqlvar';
5
7
  import EventConnection from './eventConnection';
6
8
  import FbEventManager from './fbEventManager';
7
9
  import makeQueryStream from './query-stream';
@@ -94,35 +96,47 @@ function readblob(blob: any, callback: Callback): void {
94
96
  });
95
97
  }
96
98
 
97
- function fetchBlobSyncRow(row: any, meta: any[], callback: Callback): void {
98
- if (!row || !meta || !meta.length) {
99
+ function fetchBlobSyncRow(row: any, meta: any[], nestTables: boolean | string | undefined, lowercaseKeys: boolean | undefined, transform: ((key: string) => string) | undefined, callback: Callback): void {
100
+ if (!row || !meta || !meta.length || !meta.some((m) => m && m.type === Const.SQL_BLOB)) {
99
101
  callback(null, row);
100
102
  return;
101
103
  }
102
104
 
103
- const rowKeys = Object.keys(row);
104
- const blobColumns: string[] = [];
105
+ // locate blob cells by the same key computation the fetch decoder used,
106
+ // rather than assuming Object.keys(row) is index-aligned with meta —
107
+ // duplicate JOIN column names (and nested rows) break that alignment.
108
+ // Array rows (sequentially's legacy boolean form) are keyed by index.
109
+ const isArrayRow = Array.isArray(row);
110
+ const keys = isArrayRow ? null : computeColumnKeys(meta, nestTables, lowercaseKeys, transform);
111
+ const blobCells: { target: any; key: string | number }[] = [];
105
112
 
106
113
  for (let i = 0; i < meta.length; i++) {
107
- if (meta[i] && meta[i].type === Const.SQL_BLOB && rowKeys[i] !== undefined) {
108
- blobColumns.push(rowKeys[i]);
114
+ if (!meta[i] || meta[i].type !== Const.SQL_BLOB) {
115
+ continue;
116
+ }
117
+ const target = keys ? nestCell(row, keys[i].table) : row;
118
+ const key = keys ? keys[i].key : i;
119
+ // duplicate aliases collapse onto one cell — read it only once
120
+ if (typeof target[key] === 'function' &&
121
+ !blobCells.some((cell) => cell.target === target && cell.key === key)) {
122
+ blobCells.push({ target, key });
109
123
  }
110
124
  }
111
125
 
112
- if (!blobColumns.length) {
126
+ if (!blobCells.length) {
113
127
  callback(null, row);
114
128
  return;
115
129
  }
116
130
 
117
- let pending = blobColumns.length;
131
+ let pending = blobCells.length;
118
132
  let blobErr: any;
119
133
 
120
- blobColumns.forEach(function(columnName) {
121
- readblob(row[columnName], function(err: any, data: any) {
134
+ blobCells.forEach(function(cell) {
135
+ readblob(cell.target[cell.key], function(err: any, data: any) {
122
136
  if (err && !blobErr) {
123
137
  blobErr = err;
124
138
  }
125
- row[columnName] = data;
139
+ cell.target[cell.key] = data;
126
140
  pending--;
127
141
  if (pending === 0) {
128
142
  callback(blobErr, row);
@@ -134,6 +148,7 @@ function fetchBlobSyncRow(row: any, meta: any[], callback: Callback): void {
134
148
  class Database extends Events.EventEmitter {
135
149
  connection: Connection;
136
150
  eventid: number;
151
+ private _sql?: SqlTag;
137
152
 
138
153
  constructor(connection: Connection) {
139
154
  super();
@@ -142,6 +157,17 @@ class Database extends Events.EventEmitter {
142
157
  this.eventid = 1;
143
158
  }
144
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
+
145
171
  escape(value: any): string {
146
172
  return escape(value, this.connection.accept.protocolVersion);
147
173
  }
@@ -312,6 +338,9 @@ class Database extends Events.EventEmitter {
312
338
  }
313
339
 
314
340
  var self = this;
341
+ var keyResolutionDone = false;
342
+ var resolvedNest: boolean | string | undefined;
343
+ var resolvedTransform: ((key: string) => string) | undefined;
315
344
  var _on = function(row: any, i: number, meta: any[], next: (err?: any) => void) {
316
345
  var done = false;
317
346
  var finish = function(err?: any) {
@@ -322,7 +351,15 @@ class Database extends Events.EventEmitter {
322
351
  next(err);
323
352
  };
324
353
 
325
- fetchBlobSyncRow(row, meta, 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) {
326
363
  if (blobErr) {
327
364
  finish(blobErr);
328
365
  return;
@@ -537,8 +574,9 @@ class Database extends Events.EventEmitter {
537
574
  /*
538
575
  * Promise / async-await API.
539
576
  * Each *Async method wraps its callback counterpart; the callback API
540
- * stays untouched. Result metadata is only available through the
541
- * 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.
542
580
  */
543
581
 
544
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;
@@ -440,8 +444,10 @@ export class XdrReader {
440
444
  frow?: any;
441
445
  /** rows decoded so far in this call */
442
446
  frows?: any[];
443
- /** cached object-row keys (column aliases) */
447
+ /** cached object-row keys (column aliases, qualified when nestTables is set) */
444
448
  fcols?: string[];
449
+ /** cached per-column table keys when nestTables === true */
450
+ ftables?: string[];
445
451
 
446
452
  constructor(buffer: Buffer) {
447
453
  this.buffer = buffer;