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.
@@ -1,5 +1,9 @@
1
1
  import Const from './const';
2
+ import { BlrReader } from './serialize';
3
+ import { getCodec } from './codepages';
4
+ import type { TextCodec } from './codepages';
2
5
  import type { XdrReader, XdrWriter, BlrWriter } from './serialize';
6
+ import type { RecordCounts } from '../types';
3
7
 
4
8
  /***************************************
5
9
  *
@@ -14,6 +18,8 @@ const
14
18
  TimeCoeff = 86400000,
15
19
  MsPerMinute = 60000;
16
20
 
21
+ const EMPTY_BUFFER = Buffer.alloc(0);
22
+
17
23
  /**
18
24
  * Maps Firebird character-set names (upper-case) to the Node.js Buffer
19
25
  * encoding string used by Buffer.toString() / Buffer.from().
@@ -39,11 +45,19 @@ const FirebirdToNodeEncoding: Readonly<Record<string, string>> = Object.freeze({
39
45
  const FirebirdCharsetWidths: Record<string, number> = {
40
46
  'UTF8': 4,
41
47
  'UNICODE_FSS': 3,
42
- 'SJIS': 2,
43
- 'EUCJ': 2
48
+ // real Firebird names — the bare 'SJIS'/'EUCJ' keys never matched a
49
+ // valid encoding option and silently resolved to width 1
50
+ 'SJIS_0208': 2,
51
+ 'EUCJ_0208': 2,
52
+ 'KSC_5601': 2,
53
+ 'BIG_5': 2,
54
+ 'GB_2312': 2,
55
+ 'GBK': 2,
56
+ 'CP943C': 2,
57
+ 'GB18030': 4,
44
58
  };
45
59
 
46
- function getFirebirdCharsetWidth(charset?: string): number {
60
+ export function getFirebirdCharsetWidth(charset?: string): number {
47
61
  if (!charset) return 4;
48
62
  const upper = charset.toUpperCase();
49
63
  return FirebirdCharsetWidths[upper] || 1;
@@ -56,13 +70,85 @@ function getFirebirdCharsetWidth(charset?: string): number {
56
70
  * @param {object|null} options Connection options object (may be falsy).
57
71
  * @returns {string} A Node.js-compatible encoding string.
58
72
  */
59
- function resolveTextEncoding(options?: any): BufferEncoding {
73
+ export function resolveTextEncoding(options?: any): BufferEncoding {
60
74
  const encoding = (options && options.encoding)
61
75
  ? options.encoding.toUpperCase()
62
76
  : Const.DEFAULT_ENCODING;
63
77
  return (FirebirdToNodeEncoding[encoding] || Const.DEFAULT_ENCODING.toLowerCase()) as BufferEncoding;
64
78
  }
65
79
 
80
+ /**
81
+ * Codec for the CONNECTION charset when it is a codepage Node cannot
82
+ * handle natively (WIN1251, ISO8859_7, KOI8R, …); null on the native
83
+ * path (UTF8/latin1/ascii) and for unknown charsets. With a codec
84
+ * connection charset the server transliterates all text to that
85
+ * codepage, so every text column, parameter, SQL string and text blob
86
+ * goes through the codec (issues #319/#301).
87
+ */
88
+ export function resolveTextCodec(options?: any): TextCodec | null {
89
+ return resolveTextState(options).codec;
90
+ }
91
+
92
+ interface TextState {
93
+ key: string | undefined;
94
+ codec: TextCodec | null;
95
+ enc: BufferEncoding;
96
+ width: number;
97
+ }
98
+
99
+ /**
100
+ * Per-connection text handling, resolved once and memoized on the
101
+ * long-lived options object: the decode loop calls this per CELL, and
102
+ * recomputing uppercased names + map lookups a million times per large
103
+ * fetch is pure waste. Invalidated if options.encoding ever changes.
104
+ */
105
+ export function resolveTextState(options?: any): TextState {
106
+ const key = options && options.encoding;
107
+ if (options && options.__textState && options.__textState.key === key) {
108
+ return options.__textState;
109
+ }
110
+ const encoding = (key || Const.DEFAULT_ENCODING).toUpperCase();
111
+ const state: TextState = {
112
+ key,
113
+ codec: FirebirdToNodeEncoding[encoding] ? null : getCodec(encoding),
114
+ enc: (FirebirdToNodeEncoding[encoding] || Const.DEFAULT_ENCODING.toLowerCase()) as BufferEncoding,
115
+ width: getFirebirdCharsetWidth(encoding),
116
+ };
117
+ if (options) {
118
+ options.__textState = state;
119
+ }
120
+ return state;
121
+ }
122
+
123
+ /**
124
+ * Encode text in the CONNECTION charset — the byte form the server
125
+ * expects for parameters, SQL statement text and text-blob content.
126
+ */
127
+ export function encodeConnectionText(options: any, value: string): Buffer {
128
+ const state = resolveTextState(options);
129
+ if (state.codec) {
130
+ return state.codec.encode(value);
131
+ }
132
+ if (state.enc === 'ascii') {
133
+ // Node's 'ascii' encoding masks high bits (0xE4 → 'd') — replace
134
+ // non-ASCII with '?' instead, matching the codec policy
135
+ value = value.replace(/[^\x00-\x7F]/g, '?');
136
+ }
137
+ return Buffer.from(value, state.enc);
138
+ }
139
+
140
+ /**
141
+ * Decode connection-charset bytes to text (the read counterpart of
142
+ * encodeConnectionText — used for text blobs).
143
+ */
144
+ export function decodeConnectionText(options: any, buffer: Buffer): string {
145
+ const codec = resolveTextCodec(options);
146
+ if (codec) {
147
+ return codec.decode(buffer);
148
+ }
149
+ return buffer.toString(resolveTextEncoding(options));
150
+ }
151
+
66
152
  //------------------------------------------------------
67
153
 
68
154
  /**
@@ -86,6 +172,9 @@ export abstract class SQLVarBase {
86
172
  owner?: string;
87
173
  charSetId?: number;
88
174
  collationId?: number;
175
+ /** Original declared byte length when scaleOutputLengths widened
176
+ * `length` for the fetch capacity check (issue #422). */
177
+ nativeLength?: number;
89
178
 
90
179
  abstract decode(data: XdrReader, lowerV13: boolean, options?: any): any;
91
180
  abstract calcBlr(blr: BlrWriter): void;
@@ -117,13 +206,17 @@ export interface ColumnKey {
117
206
  export function computeColumnKeys(
118
207
  output: SQLVarBase[],
119
208
  nestTables: boolean | string | undefined,
120
- lowercaseKeys: boolean | undefined
209
+ lowercaseKeys: boolean | undefined,
210
+ transform?: (key: string) => string
121
211
  ): ColumnKey[] {
122
212
  return output.map((column) => {
123
213
  let key = column.alias || '';
124
214
  if (lowercaseKeys) {
125
215
  key = key.toLowerCase();
126
216
  }
217
+ if (transform) {
218
+ key = transform(key);
219
+ }
127
220
  if (nestTables !== true && typeof nestTables !== 'string') {
128
221
  return { key };
129
222
  }
@@ -131,6 +224,9 @@ export function computeColumnKeys(
131
224
  if (lowercaseKeys) {
132
225
  table = table.toLowerCase();
133
226
  }
227
+ if (transform) {
228
+ table = transform(table);
229
+ }
134
230
  if (nestTables === true) {
135
231
  return { table, key };
136
232
  }
@@ -138,6 +234,67 @@ export function computeColumnKeys(
138
234
  });
139
235
  }
140
236
 
237
+ /** transformKeys option value: the built-in 'camel', or a custom mapper. */
238
+ export type KeyTransform = 'camel' | ((key: string) => string);
239
+
240
+ /** FIRST_NAME → firstName (the transformKeys: 'camel' built-in). */
241
+ export function camelizeKey(key: string): string {
242
+ const parts = String(key).toLowerCase().split('_');
243
+ let out = parts[0] || '';
244
+ for (let i = 1; i < parts.length; i++) {
245
+ const part = parts[i];
246
+ if (part) {
247
+ out += part.charAt(0).toUpperCase() + part.slice(1);
248
+ }
249
+ }
250
+ return out;
251
+ }
252
+
253
+ /**
254
+ * Resolve the effective transformKeys value (per-query wins over the
255
+ * connection option) into a callable mapper, or undefined when off.
256
+ * A custom mapper is guarded like the typeCast hook: a throw inside the
257
+ * row-decode loop would be mistaken for an incomplete packet and desync
258
+ * the response queue, so failures fall back to the untransformed key.
259
+ */
260
+ export function resolveKeyTransform(
261
+ queryOptions: { transformKeys?: KeyTransform } | undefined,
262
+ connectionOptions: { transformKeys?: KeyTransform } | undefined
263
+ ): ((key: string) => string) | undefined {
264
+ const value = resolveQueryOption<KeyTransform>('transformKeys', queryOptions, connectionOptions);
265
+ if (value === 'camel') {
266
+ return camelizeKey;
267
+ }
268
+ if (typeof value !== 'function') {
269
+ return undefined;
270
+ }
271
+ return (key: string) => {
272
+ try {
273
+ return String(value(key));
274
+ } catch (err: any) {
275
+ console.warn('[node-firebird] transformKeys mapper threw for key "%s": %s — using the untransformed key',
276
+ key, err && err.message);
277
+ return key;
278
+ }
279
+ };
280
+ }
281
+
282
+ /**
283
+ * Shared precedence rule for per-query-overridable connection options:
284
+ * the per-query value wins whenever it is present (even if falsy), the
285
+ * connection option applies otherwise.
286
+ */
287
+ function resolveQueryOption<T>(
288
+ name: string,
289
+ queryOptions: Record<string, any> | undefined,
290
+ connectionOptions: Record<string, any> | undefined
291
+ ): T | undefined {
292
+ if (queryOptions && queryOptions[name] !== undefined) {
293
+ return queryOptions[name];
294
+ }
295
+ return connectionOptions ? connectionOptions[name] : undefined;
296
+ }
297
+
141
298
  /**
142
299
  * Resolve the effective nestTables value: the per-query option wins over
143
300
  * the connection option. The decoder and fetchBlobSyncRow both use this —
@@ -148,10 +305,7 @@ export function resolveNestTables(
148
305
  queryOptions: { nestTables?: boolean | string } | undefined,
149
306
  connectionOptions: { nestTables?: boolean | string } | undefined
150
307
  ): boolean | string | undefined {
151
- if (queryOptions && queryOptions.nestTables !== undefined) {
152
- return queryOptions.nestTables;
153
- }
154
- return connectionOptions && connectionOptions.nestTables;
308
+ return resolveQueryOption('nestTables', queryOptions, connectionOptions);
155
309
  }
156
310
 
157
311
  /**
@@ -169,25 +323,115 @@ export function nestCell(row: any, table: string | undefined) {
169
323
 
170
324
  //------------------------------------------------------
171
325
 
326
+ /** Human-readable names for the SQL_* wire type codes. */
327
+ export const SQL_TYPE_NAMES: Record<number, string> = {
328
+ [Const.SQL_TEXT]: 'TEXT',
329
+ [Const.SQL_VARYING]: 'VARYING',
330
+ [Const.SQL_SHORT]: 'SHORT',
331
+ [Const.SQL_LONG]: 'LONG',
332
+ [Const.SQL_FLOAT]: 'FLOAT',
333
+ [Const.SQL_DOUBLE]: 'DOUBLE',
334
+ [Const.SQL_D_FLOAT]: 'D_FLOAT',
335
+ [Const.SQL_TIMESTAMP]: 'TIMESTAMP',
336
+ [Const.SQL_BLOB]: 'BLOB',
337
+ [Const.SQL_ARRAY]: 'ARRAY',
338
+ [Const.SQL_QUAD]: 'QUAD',
339
+ [Const.SQL_TYPE_TIME]: 'TIME',
340
+ [Const.SQL_TYPE_DATE]: 'DATE',
341
+ [Const.SQL_INT64]: 'INT64',
342
+ [Const.SQL_INT128]: 'INT128',
343
+ [Const.SQL_TIMESTAMP_TZ]: 'TIMESTAMP_TZ',
344
+ [Const.SQL_TIMESTAMP_TZ_EX]: 'TIMESTAMP_TZ_EX',
345
+ [Const.SQL_TIME_TZ]: 'TIME_TZ',
346
+ [Const.SQL_TIME_TZ_EX]: 'TIME_TZ_EX',
347
+ [Const.SQL_DEC16]: 'DEC16',
348
+ [Const.SQL_DEC34]: 'DEC34',
349
+ [Const.SQL_BOOLEAN]: 'BOOLEAN',
350
+ [Const.SQL_NULL]: 'NULL',
351
+ };
352
+
353
+ /**
354
+ * Public column-metadata shape for one output descriptor: the vocabulary
355
+ * both the typeCast hook and withMeta `fields` deliver. Keep the two in
356
+ * lockstep by building both through here.
357
+ */
358
+ export function describeField(meta: Partial<SQLVarBase>) {
359
+ return {
360
+ type: meta.type!,
361
+ typeName: SQL_TYPE_NAMES[meta.type!] || 'UNKNOWN',
362
+ subType: meta.subType,
363
+ scale: meta.scale,
364
+ // report the column's true declared length, not the widened fetch
365
+ // buffer (see scaleOutputLengths)
366
+ length: meta.nativeLength !== undefined ? meta.nativeLength : meta.length,
367
+ nullable: meta.nullable,
368
+ field: meta.field,
369
+ relation: meta.relation,
370
+ relationAlias: meta.relationAlias,
371
+ relationSchema: meta.relationSchema,
372
+ alias: meta.alias,
373
+ };
374
+ }
375
+
376
+ /**
377
+ * Map a statement's output descriptors to the column-metadata array
378
+ * delivered in withMeta results ({ rows, fields, ... }).
379
+ */
380
+ export function describeFields(output: SQLVarBase[]) {
381
+ return (output || []).map(describeField);
382
+ }
383
+
384
+ /**
385
+ * Parse the op_info_sql response buffer of a Const.RECORDS_INFO request
386
+ * into per-verb row counts. The buffer holds an isc_info_sql_records
387
+ * cluster (2-byte total length, then nested isc_info_req_*_count items,
388
+ * each 2-byte length + little-endian integer) terminated by isc_info_end.
389
+ */
390
+ export function parseRecordCounts(buffer: Buffer | undefined): RecordCounts {
391
+ const counts = { selectCount: 0, insertCount: 0, updateCount: 0, deleteCount: 0 };
392
+ if (!buffer || !buffer.length) {
393
+ return counts;
394
+ }
395
+ // this runs inside a response callback — a malformed/truncated buffer
396
+ // must yield partial counts, never a throw
397
+ try {
398
+ const br = new BlrReader(buffer);
399
+ while (br.pos < br.buffer.length) {
400
+ const item = br.readByteCode();
401
+ if (item === Const.isc_info_end || item === Const.isc_info_truncated) {
402
+ break;
403
+ }
404
+ if (item === Const.isc_info_sql_records) {
405
+ br.pos += 2; // skip the cluster's total length; nested items follow
406
+ continue;
407
+ }
408
+ switch (item) {
409
+ case Const.isc_info_req_select_count: counts.selectCount = br.readInt() || 0; break;
410
+ case Const.isc_info_req_insert_count: counts.insertCount = br.readInt() || 0; break;
411
+ case Const.isc_info_req_update_count: counts.updateCount = br.readInt() || 0; break;
412
+ case Const.isc_info_req_delete_count: counts.deleteCount = br.readInt() || 0; break;
413
+ default:
414
+ // unknown item: its 2-byte length prefix tells us how far to skip
415
+ br.pos += 2 + br.buffer.readUInt16LE(br.pos);
416
+ }
417
+ }
418
+ } catch (e) {
419
+ // fall through with whatever was parsed so far
420
+ }
421
+ return counts;
422
+ }
423
+
424
+ //------------------------------------------------------
425
+
172
426
  export class SQLVarText extends SQLVarBase {
173
427
  decode(data: XdrReader, lowerV13: boolean, options?: any) {
174
428
  let ret;
175
- const textEncoding = resolveTextEncoding(options);
176
- if (this.subType > 1) {
177
- // ToDo: with column charset
178
- ret = data.readText(this.length, textEncoding);
179
- const encoding = options && options.encoding ? options.encoding : 'UTF8';
180
- const width = getFirebirdCharsetWidth(encoding);
181
- const charLength = Math.floor(this.length / width);
182
- if (ret.length > charLength) {
183
- ret = ret.substring(0, charLength);
184
- }
185
- } else if (this.subType === 0) {
186
- // without charset definition
187
- ret = data.readText(this.length, textEncoding);
188
- const encoding = options && options.encoding ? options.encoding : 'UTF8';
189
- const width = getFirebirdCharsetWidth(encoding);
190
- const charLength = Math.floor(this.length / width);
429
+ if (this.subType > 1 || this.subType === 0) {
430
+ const state = resolveTextState(options);
431
+ ret = state.codec
432
+ ? state.codec.decode(data.readBuffer(this.length) || EMPTY_BUFFER)
433
+ : data.readText(this.length, state.enc);
434
+ const charLength = Math.floor(this.length / state.width);
191
435
  if (ret.length > charLength) {
192
436
  ret = ret.substring(0, charLength);
193
437
  }
@@ -218,13 +462,11 @@ export class SQLVarNull extends SQLVarText {
218
462
  export class SQLVarString extends SQLVarBase {
219
463
  decode(data: XdrReader, lowerV13: boolean, options?: any) {
220
464
  let ret;
221
- const textEncoding = resolveTextEncoding(options);
222
- if (this.subType > 1) {
223
- // ToDo: with column charset
224
- ret = data.readString(textEncoding);
225
- } else if (this.subType === 0) {
226
- // without charset definition
227
- ret = data.readString(textEncoding);
465
+ if (this.subType > 1 || this.subType === 0) {
466
+ const state = resolveTextState(options);
467
+ ret = state.codec
468
+ ? state.codec.decode(data.readArray() || EMPTY_BUFFER)
469
+ : data.readString(state.enc);
228
470
  } else {
229
471
  ret = data.readBuffer();
230
472
  }
@@ -872,12 +1114,30 @@ export class SQLParamDate {
872
1114
 
873
1115
  export class SQLParamBool {
874
1116
  value: any;
875
-
876
- constructor(value: any) {
1117
+ /**
1118
+ * Encode as a real BOOLEAN (blr_bool + xdr opaque byte) instead of the
1119
+ * legacy blr_short 0/1. Set when the DESCRIBED parameter type is
1120
+ * SQL_BOOLEAN: Firebird refuses smallint→BOOLEAN conversion
1121
+ * ("conversion error from string", issue #122), and conversely BOOLEAN
1122
+ * does not convert to numbers — so smallint targets keep the legacy
1123
+ * form for compatibility.
1124
+ */
1125
+ asBoolean: boolean;
1126
+
1127
+ constructor(value: any, asBoolean = false) {
877
1128
  this.value = value;
1129
+ this.asBoolean = asBoolean;
878
1130
  }
879
1131
 
880
1132
  encode(data: XdrWriter): void {
1133
+ if (this.asBoolean) {
1134
+ // xdr_datum sends booleans as 1 opaque value byte + 3 pad bytes
1135
+ // (NOT a big-endian int: the value byte comes FIRST — addInt(1)
1136
+ // would decode server-side as false). Matches the batch encoder.
1137
+ data.addBuffer(Buffer.from([this.value ? 1 : 0]));
1138
+ data.addAlignment(1);
1139
+ return;
1140
+ }
881
1141
  if (this.value != null) {
882
1142
  data.addInt(this.value ? 1 : 0);
883
1143
  } else {
@@ -887,6 +1147,10 @@ export class SQLParamBool {
887
1147
  }
888
1148
 
889
1149
  calcBlr(blr: BlrWriter): void {
1150
+ if (this.asBoolean) {
1151
+ blr.addByte(Const.blr_bool);
1152
+ return;
1153
+ }
890
1154
  blr.addByte(Const.blr_short);
891
1155
  blr.addShort(0);
892
1156
  }