@bdkinc/knex-ibmi 0.4.4 → 0.5.2

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/README.md CHANGED
@@ -396,11 +396,49 @@ Native `RETURNING` is not broadly supported over ODBC on IBM i. The dialect prov
396
396
  interface IbmiDialectConfig {
397
397
  multiRowInsert?: 'auto' | 'sequential' | 'disabled';
398
398
  sequentialInsertTransactional?: boolean; // if true, wraps sequential loop in BEGIN/COMMIT
399
+ preparedStatementCache?: boolean; // Enable per-connection statement caching (default: false)
400
+ preparedStatementCacheSize?: number; // Max cached statements per connection (default: 100)
401
+ readUncommitted?: boolean; // Append WITH UR to SELECT queries (default: false)
399
402
  }
400
403
  ```
401
404
 
402
405
  Attach under the root knex config as `ibmi`.
403
406
 
407
+ ### Performance Tuning
408
+
409
+ #### Prepared Statement Caching (v0.5.0+)
410
+
411
+ Enable optional prepared statement caching to reduce parse overhead for repeated queries:
412
+
413
+ ```ts
414
+ const db = knex({
415
+ client: DB2Dialect,
416
+ connection: { /* ... */ },
417
+ ibmi: {
418
+ preparedStatementCache: true, // Enable caching
419
+ preparedStatementCacheSize: 100, // Max statements per connection
420
+ }
421
+ });
422
+ ```
423
+
424
+ When enabled, the dialect maintains a per-connection LRU cache of prepared statements. Statements are automatically closed when evicted or when the connection is destroyed.
425
+
426
+ #### Read Uncommitted Isolation (v0.5.0+)
427
+
428
+ For read-heavy workloads, enable uncommitted read isolation to improve concurrency:
429
+
430
+ ```ts
431
+ const db = knex({
432
+ client: DB2Dialect,
433
+ connection: { /* ... */ },
434
+ ibmi: {
435
+ readUncommitted: true // Appends WITH UR to all SELECT queries
436
+ }
437
+ });
438
+ ```
439
+
440
+ This appends `WITH UR` to all SELECT queries, allowing reads without waiting for locks. Only use this if your application can tolerate reading uncommitted data.
441
+
404
442
  ### Transactional Sequential Inserts
405
443
 
406
444
  When `ibmi.sequentialInsertTransactional` is `true`, the dialect will attempt `BEGIN` before the per-row loop and `COMMIT` after. On commit failure it will attempt a `ROLLBACK`. If `BEGIN` is not supported, it logs a warning and continues non-transactionally.
package/dist/cli.cjs CHANGED
@@ -63,7 +63,7 @@ var IBMiMigrationRunner = class {
63
63
  console.log(`\u{1F4DD} Creating migration table: ${tableName}`);
64
64
  await this.knex.schema.createTable(tableName, (table) => {
65
65
  table.increments("id").primary();
66
- table.string("name");
66
+ table.string("name").unique();
67
67
  table.integer("batch");
68
68
  table.timestamp("migration_time");
69
69
  });
package/dist/index.d.mts CHANGED
@@ -43,6 +43,7 @@ declare enum SqlMethod {
43
43
  COUNTER = "counter"
44
44
  }
45
45
  declare class DB2Client extends knex.Client {
46
+ private statementCaches;
46
47
  constructor(config: Knex.Config<DB2Config>);
47
48
  private safeStringify;
48
49
  _driver(): typeof odbc;
@@ -50,7 +51,7 @@ declare class DB2Client extends knex.Client {
50
51
  printDebug(message: string): void;
51
52
  printError(message: string): void;
52
53
  printWarn(message: string): void;
53
- acquireRawConnection(): Promise<void | odbc.Connection>;
54
+ acquireRawConnection(): Promise<any>;
54
55
  destroyRawConnection(connection: any): Promise<any>;
55
56
  _getConnectionString(connectionConfig: DB2ConnectionConfig): string;
56
57
  _query(connection: Connection, obj: any): Promise<any>;
@@ -93,6 +94,10 @@ declare class DB2Client extends knex.Client {
93
94
  private wrapError;
94
95
  private shouldRetryQuery;
95
96
  private retryQuery;
97
+ /**
98
+ * Extract SQLSTATE from ODBC error if available
99
+ */
100
+ private getSQLState;
96
101
  private isConnectionError;
97
102
  private isTimeoutError;
98
103
  private isSQLError;
@@ -149,6 +154,9 @@ interface DB2Config extends Knex.Config {
149
154
  ibmi?: {
150
155
  multiRowInsert?: "auto" | "sequential" | "disabled";
151
156
  sequentialInsertTransactional?: boolean;
157
+ preparedStatementCache?: boolean;
158
+ preparedStatementCacheSize?: number;
159
+ readUncommitted?: boolean;
152
160
  };
153
161
  }
154
162
  declare const DB2Dialect: typeof DB2Client;
package/dist/index.d.ts CHANGED
@@ -43,6 +43,7 @@ declare enum SqlMethod {
43
43
  COUNTER = "counter"
44
44
  }
45
45
  declare class DB2Client extends knex.Client {
46
+ private statementCaches;
46
47
  constructor(config: Knex.Config<DB2Config>);
47
48
  private safeStringify;
48
49
  _driver(): typeof odbc;
@@ -50,7 +51,7 @@ declare class DB2Client extends knex.Client {
50
51
  printDebug(message: string): void;
51
52
  printError(message: string): void;
52
53
  printWarn(message: string): void;
53
- acquireRawConnection(): Promise<void | odbc.Connection>;
54
+ acquireRawConnection(): Promise<any>;
54
55
  destroyRawConnection(connection: any): Promise<any>;
55
56
  _getConnectionString(connectionConfig: DB2ConnectionConfig): string;
56
57
  _query(connection: Connection, obj: any): Promise<any>;
@@ -93,6 +94,10 @@ declare class DB2Client extends knex.Client {
93
94
  private wrapError;
94
95
  private shouldRetryQuery;
95
96
  private retryQuery;
97
+ /**
98
+ * Extract SQLSTATE from ODBC error if available
99
+ */
100
+ private getSQLState;
96
101
  private isConnectionError;
97
102
  private isTimeoutError;
98
103
  private isSQLError;
@@ -149,6 +154,9 @@ interface DB2Config extends Knex.Config {
149
154
  ibmi?: {
150
155
  multiRowInsert?: "auto" | "sequential" | "disabled";
151
156
  sequentialInsertTransactional?: boolean;
157
+ preparedStatementCache?: boolean;
158
+ preparedStatementCacheSize?: number;
159
+ readUncommitted?: boolean;
152
160
  };
153
161
  }
154
162
  declare const DB2Dialect: typeof DB2Client;
package/dist/index.js CHANGED
@@ -120,11 +120,16 @@ var ibmi_compiler_default = IBMiSchemaCompiler;
120
120
  var import_tablecompiler = __toESM(require("knex/lib/schema/tablecompiler.js"));
121
121
  var IBMiTableCompiler = class extends import_tablecompiler.default {
122
122
  createQuery(columns, ifNot, like) {
123
- let createStatement = ifNot ? `if object_id('${this.tableName()}', 'U') is null ` : "";
123
+ if (ifNot && this.client?.logger?.warn) {
124
+ this.client.logger.warn(
125
+ "IBM i DB2: IF NOT EXISTS is not natively supported. Use hasTable() check instead."
126
+ );
127
+ }
128
+ let createStatement = "";
124
129
  if (like) {
125
- createStatement += `select * into ${this.tableName()} from ${this.tableNameLike()} WHERE 0=1`;
130
+ createStatement = `create table ${this.tableName()} as (select * from ${this.tableNameLike()}) with no data`;
126
131
  } else {
127
- createStatement += "create table " + this.tableName() + (this._formatting ? " (\n " : " (") + columns.sql.join(this._formatting ? ",\n " : ", ") + this._addChecks() + ")";
132
+ createStatement = "create table " + this.tableName() + (this._formatting ? " (\n " : " (") + columns.sql.join(this._formatting ? ",\n " : ", ") + this._addChecks() + ")";
128
133
  }
129
134
  this.pushQuery(createStatement);
130
135
  if (this.single.comment) {
@@ -221,9 +226,12 @@ var IBMiColumnCompiler = class extends import_columncompiler.default {
221
226
  return "decimal(10, 2)";
222
227
  }
223
228
  // IBM i DB2 timestamp
229
+ // Note: IBM i DB2 does not support TIMESTAMP WITH TIME ZONE
224
230
  timestamp(options) {
225
- if (options?.useTz) {
226
- return "timestamp with time zone";
231
+ if (options?.useTz && this.client?.logger?.warn) {
232
+ this.client.logger.warn(
233
+ "IBM i DB2 does not support TIMESTAMP WITH TIME ZONE. Using plain TIMESTAMP instead."
234
+ );
227
235
  }
228
236
  return "timestamp";
229
237
  }
@@ -238,11 +246,13 @@ var IBMiColumnCompiler = class extends import_columncompiler.default {
238
246
  return "time";
239
247
  }
240
248
  // JSON support (IBM i 7.3+)
249
+ // Note: CHECK constraints with column references are not supported in this context
250
+ // Users should add validation constraints separately if needed
241
251
  json() {
242
- return "clob(16M) check (json_valid(json_column))";
252
+ return "clob(16M)";
243
253
  }
244
254
  jsonb() {
245
- return "clob(16M) check (json_valid(jsonb_column))";
255
+ return "clob(16M)";
246
256
  }
247
257
  // UUID support using CHAR(36)
248
258
  uuid() {
@@ -313,8 +323,12 @@ var IBMiQueryCompiler = class extends import_querycompiler.default {
313
323
  }
314
324
  // Override select method to add IBM i optimization hints
315
325
  select() {
316
- const originalResult = super.select.call(this);
317
- return originalResult;
326
+ let sql = super.select.call(this);
327
+ const readUncommitted = this.client?.config?.ibmi?.readUncommitted === true;
328
+ if (readUncommitted && typeof sql === "string") {
329
+ sql = sql + " WITH UR";
330
+ }
331
+ return sql;
318
332
  }
319
333
  formatTimestampLocal(date) {
320
334
  const pad = (n) => String(n).padStart(2, "0");
@@ -348,12 +362,15 @@ var IBMiQueryCompiler = class extends import_querycompiler.default {
348
362
  const standardInsert = super.insert();
349
363
  const insertSql = typeof standardInsert === "object" && standardInsert.sql ? standardInsert.sql : standardInsert;
350
364
  const multiRow = isArrayInsert && !forceSingleRow;
365
+ if (multiRow && !returning) {
366
+ return { sql: insertSql, returning: void 0 };
367
+ }
351
368
  if (multiRow && returning === "*") {
352
369
  if (this.client?.printWarn) {
353
370
  this.client.printWarn("multi-row insert with returning * may be large");
354
371
  }
355
372
  }
356
- const selectColumns = returning ? this.formatter.columnize(returning) : multiRow ? "*" : "IDENTITY_VAL_LOCAL()";
373
+ const selectColumns = returning ? this.formatter.columnize(returning) : "IDENTITY_VAL_LOCAL()";
357
374
  const sql = `select ${selectColumns} from FINAL TABLE(${insertSql})`;
358
375
  if (multiRowStrategy === "sequential" && isArrayInsert) {
359
376
  const first = originalValues[0];
@@ -608,7 +625,7 @@ var IBMiMigrationRunner = class {
608
625
  console.log(`\u{1F4DD} Creating migration table: ${tableName}`);
609
626
  await this.knex.schema.createTable(tableName, (table) => {
610
627
  table.increments("id").primary();
611
- table.string("name");
628
+ table.string("name").unique();
612
629
  table.integer("batch");
613
630
  table.timestamp("migration_time");
614
631
  });
@@ -776,9 +793,51 @@ function createIBMiMigrationRunner(knex2, config) {
776
793
  }
777
794
 
778
795
  // src/index.ts
796
+ var StatementCache = class {
797
+ constructor(maxSize = 100) {
798
+ __publicField(this, "cache", /* @__PURE__ */ new Map());
799
+ __publicField(this, "maxSize");
800
+ this.maxSize = maxSize;
801
+ }
802
+ get(sql) {
803
+ const stmt = this.cache.get(sql);
804
+ if (stmt) {
805
+ this.cache.delete(sql);
806
+ this.cache.set(sql, stmt);
807
+ }
808
+ return stmt;
809
+ }
810
+ set(sql, stmt) {
811
+ if (this.cache.size >= this.maxSize) {
812
+ const firstKey = this.cache.keys().next().value;
813
+ const oldStmt = this.cache.get(firstKey);
814
+ this.cache.delete(firstKey);
815
+ if (oldStmt && typeof oldStmt.close === "function") {
816
+ oldStmt.close().catch(() => {
817
+ });
818
+ }
819
+ }
820
+ this.cache.set(sql, stmt);
821
+ }
822
+ async clear() {
823
+ const statements = Array.from(this.cache.values());
824
+ this.cache.clear();
825
+ await Promise.all(
826
+ statements.map(
827
+ (stmt) => stmt && typeof stmt.close === "function" ? stmt.close().catch(() => {
828
+ }) : Promise.resolve()
829
+ )
830
+ );
831
+ }
832
+ size() {
833
+ return this.cache.size;
834
+ }
835
+ };
779
836
  var DB2Client = class extends import_knex.default.Client {
780
837
  constructor(config) {
781
838
  super(config);
839
+ // Per-connection statement cache (WeakMap so it's GC'd with connections)
840
+ __publicField(this, "statementCaches", /* @__PURE__ */ new WeakMap());
782
841
  this.driverName = "odbc";
783
842
  if (this.dialect && !this.config.client) {
784
843
  this.printWarn(
@@ -851,44 +910,45 @@ var DB2Client = class extends import_knex.default.Client {
851
910
  this.printDebug("acquiring raw connection");
852
911
  const connectionConfig = this.config.connection;
853
912
  if (!connectionConfig) {
854
- return this.printError("There is no connection config defined");
913
+ throw new Error("There is no connection config defined");
855
914
  }
856
915
  this.printDebug(
857
916
  "connection config: " + this._getConnectionString(connectionConfig)
858
917
  );
859
- let connection;
860
- if (this.config?.pool) {
861
- const poolConfig = {
862
- connectionString: this._getConnectionString(connectionConfig),
863
- connectionTimeout: this.config?.acquireConnectionTimeout || 6e4,
864
- initialSize: this.config?.pool?.min || 2,
865
- maxSize: this.config?.pool?.max || 10,
866
- reuseConnection: true
867
- };
868
- const pool = await this.driver.pool(poolConfig);
869
- connection = await pool.connect();
870
- } else {
871
- connection = await this.driver.connect(
872
- this._getConnectionString(connectionConfig)
873
- );
874
- }
918
+ const connection = await this.driver.connect(
919
+ this._getConnectionString(connectionConfig)
920
+ );
875
921
  return connection;
876
922
  }
877
923
  // Used to explicitly close a connection, called internally by the pool manager
878
924
  // when a connection times out or the pool is shutdown.
879
925
  async destroyRawConnection(connection) {
880
926
  this.printDebug("destroy connection");
927
+ const cache = this.statementCaches.get(connection);
928
+ if (cache) {
929
+ await cache.clear();
930
+ this.statementCaches.delete(connection);
931
+ }
881
932
  return await connection.close();
882
933
  }
883
934
  _getConnectionString(connectionConfig) {
884
- const connectionStringParams = connectionConfig.connectionStringParams || {};
935
+ const defaults = {
936
+ BLOCKFETCH: 1,
937
+ // Enable block fetch for better performance
938
+ TRUEAUTOCOMMIT: 0
939
+ // Use proper transaction handling
940
+ };
941
+ const connectionStringParams = {
942
+ ...defaults,
943
+ ...connectionConfig.connectionStringParams || {}
944
+ };
885
945
  const connectionStringExtension = Object.keys(
886
946
  connectionStringParams
887
947
  ).reduce((result, key) => {
888
948
  const value = connectionStringParams[key];
889
949
  return `${result}${key}=${value};`;
890
950
  }, "");
891
- return `DRIVER=${connectionConfig.driver};SYSTEM=${connectionConfig.host};HOSTNAME=${connectionConfig.host};PORT=${connectionConfig.port};DATABASE=${connectionConfig.database};UID=${connectionConfig.user};PWD=${connectionConfig.password};` + connectionStringExtension;
951
+ return `DRIVER=${connectionConfig.driver};SYSTEM=${connectionConfig.host};PORT=${connectionConfig.port || 8471};DATABASE=${connectionConfig.database};UID=${connectionConfig.user};PWD=${connectionConfig.password};` + connectionStringExtension;
892
952
  }
893
953
  // Runs the query on the specified connection, providing the bindings
894
954
  async _query(connection, obj) {
@@ -909,7 +969,9 @@ var DB2Client = class extends import_knex.default.Client {
909
969
  `Executing ${method} query: ${queryObject.sql.substring(0, 200)}...`
910
970
  );
911
971
  if (queryObject.bindings?.length) {
912
- this.printDebug(`Bindings: ${JSON.stringify(queryObject.bindings)}`);
972
+ this.printDebug(
973
+ `Bindings: ${this.safeStringify(queryObject.bindings)}`
974
+ );
913
975
  }
914
976
  }
915
977
  try {
@@ -1086,9 +1148,30 @@ var DB2Client = class extends import_knex.default.Client {
1086
1148
  }
1087
1149
  async executeStatementQuery(connection, obj) {
1088
1150
  let statement;
1151
+ let usedCache = false;
1152
+ const cacheEnabled = this.config?.ibmi?.preparedStatementCache === true;
1089
1153
  try {
1090
- statement = await connection.createStatement();
1091
- await statement.prepare(obj.sql);
1154
+ if (cacheEnabled) {
1155
+ let cache = this.statementCaches.get(connection);
1156
+ if (!cache) {
1157
+ const cacheSize = this.config?.ibmi?.preparedStatementCacheSize || 100;
1158
+ cache = new StatementCache(cacheSize);
1159
+ this.statementCaches.set(connection, cache);
1160
+ }
1161
+ statement = cache.get(obj.sql);
1162
+ if (statement) {
1163
+ usedCache = true;
1164
+ this.printDebug(`Using cached statement for: ${obj.sql.substring(0, 50)}...`);
1165
+ } else {
1166
+ statement = await connection.createStatement();
1167
+ await statement.prepare(obj.sql);
1168
+ cache.set(obj.sql, statement);
1169
+ this.printDebug(`Cached new statement (cache size: ${cache.size()})`);
1170
+ }
1171
+ } else {
1172
+ statement = await connection.createStatement();
1173
+ await statement.prepare(obj.sql);
1174
+ }
1092
1175
  if (obj.bindings) {
1093
1176
  await statement.bind(obj.bindings);
1094
1177
  }
@@ -1113,7 +1196,7 @@ var DB2Client = class extends import_knex.default.Client {
1113
1196
  this.printError(this.safeStringify(err));
1114
1197
  throw err;
1115
1198
  } finally {
1116
- if (statement && typeof statement.close === "function") {
1199
+ if (!usedCache && statement && typeof statement.close === "function") {
1117
1200
  try {
1118
1201
  await statement.close();
1119
1202
  } catch (closeErr) {
@@ -1222,7 +1305,7 @@ var DB2Client = class extends import_knex.default.Client {
1222
1305
  isClosed = true;
1223
1306
  cursor.close((closeError) => {
1224
1307
  if (closeError) {
1225
- parentThis.printError(JSON.stringify(closeError, null, 2));
1308
+ parentThis.printError(parentThis.safeStringify(closeError, 2));
1226
1309
  }
1227
1310
  if (result) {
1228
1311
  this.push(result);
@@ -1295,11 +1378,11 @@ var DB2Client = class extends import_knex.default.Client {
1295
1378
  }
1296
1379
  validateResponse(obj) {
1297
1380
  if (!obj.response) {
1298
- this.printDebug("response undefined" + JSON.stringify(obj));
1381
+ this.printDebug("response undefined " + this.safeStringify(obj));
1299
1382
  return null;
1300
1383
  }
1301
1384
  if (!obj.response.rows) {
1302
- this.printError("rows undefined" + JSON.stringify(obj));
1385
+ this.printError("rows undefined " + this.safeStringify(obj));
1303
1386
  return null;
1304
1387
  }
1305
1388
  return null;
@@ -1310,23 +1393,24 @@ var DB2Client = class extends import_knex.default.Client {
1310
1393
  sql: queryObject.sql ? queryObject.sql.substring(0, 100) + "..." : "unknown",
1311
1394
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
1312
1395
  };
1396
+ const contextStr = this.safeStringify(context);
1313
1397
  if (this.isConnectionError(error)) {
1314
1398
  return new Error(
1315
- `IBM i DB2 connection error during ${method}: ${error.message} | Context: ${JSON.stringify(context)}`
1399
+ `IBM i DB2 connection error during ${method}: ${error.message} | Context: ${contextStr}`
1316
1400
  );
1317
1401
  }
1318
1402
  if (this.isTimeoutError(error)) {
1319
1403
  return new Error(
1320
- `IBM i DB2 timeout during ${method}: ${error.message} | Context: ${JSON.stringify(context)}`
1404
+ `IBM i DB2 timeout during ${method}: ${error.message} | Context: ${contextStr}`
1321
1405
  );
1322
1406
  }
1323
1407
  if (this.isSQLError(error)) {
1324
1408
  return new Error(
1325
- `IBM i DB2 SQL error during ${method}: ${error.message} | Context: ${JSON.stringify(context)}`
1409
+ `IBM i DB2 SQL error during ${method}: ${error.message} | Context: ${contextStr}`
1326
1410
  );
1327
1411
  }
1328
1412
  return new Error(
1329
- `IBM i DB2 error during ${method}: ${error.message} | Context: ${JSON.stringify(context)}`
1413
+ `IBM i DB2 error during ${method}: ${error.message} | Context: ${contextStr}`
1330
1414
  );
1331
1415
  }
1332
1416
  shouldRetryQuery(queryObject, method) {
@@ -1348,15 +1432,44 @@ var DB2Client = class extends import_knex.default.Client {
1348
1432
  return queryObject;
1349
1433
  }
1350
1434
  }
1435
+ /**
1436
+ * Extract SQLSTATE from ODBC error if available
1437
+ */
1438
+ getSQLState(error) {
1439
+ if (error?.odbcErrors && Array.isArray(error.odbcErrors)) {
1440
+ for (const odbcErr of error.odbcErrors) {
1441
+ const state = odbcErr?.state || odbcErr?.SQLSTATE;
1442
+ if (state) return String(state).toUpperCase();
1443
+ }
1444
+ }
1445
+ return null;
1446
+ }
1351
1447
  isConnectionError(error) {
1448
+ const sqlState = this.getSQLState(error);
1449
+ if (sqlState) {
1450
+ return sqlState.startsWith("08") || // 08001, 08003, 08007, 08S01, etc.
1451
+ sqlState === "40003";
1452
+ }
1352
1453
  const errorMessage = (error.message || error.toString || error).toLowerCase();
1353
1454
  return errorMessage.includes("connection") && (errorMessage.includes("closed") || errorMessage.includes("invalid") || errorMessage.includes("terminated") || errorMessage.includes("not connected"));
1354
1455
  }
1355
1456
  isTimeoutError(error) {
1457
+ const sqlState = this.getSQLState(error);
1458
+ if (sqlState) {
1459
+ return sqlState === "HYT00" || // Timeout expired
1460
+ sqlState === "HYT01";
1461
+ }
1356
1462
  const errorMessage = (error.message || error.toString || error).toLowerCase();
1357
1463
  return errorMessage.includes("timeout") || errorMessage.includes("timed out");
1358
1464
  }
1359
1465
  isSQLError(error) {
1466
+ const sqlState = this.getSQLState(error);
1467
+ if (sqlState) {
1468
+ return sqlState.startsWith("42") || // Syntax error or access violation
1469
+ sqlState.startsWith("22") || // Data exception
1470
+ sqlState.startsWith("23") || // Integrity constraint violation
1471
+ sqlState.startsWith("21");
1472
+ }
1360
1473
  const errorMessage = (error.message || error.toString || error).toLowerCase();
1361
1474
  return errorMessage.includes("sql") || errorMessage.includes("syntax") || errorMessage.includes("table") || errorMessage.includes("column");
1362
1475
  }