@bkmj/node-red-contrib-odbcmj 2.5.0 → 2.6.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.
Files changed (2) hide show
  1. package/odbc.js +299 -308
  2. package/package.json +1 -1
package/odbc.js CHANGED
@@ -3,7 +3,9 @@ module.exports = function (RED) {
3
3
  const mustache = require("mustache");
4
4
  const objPath = require("object-path");
5
5
 
6
- // --- ODBC Configuration Node ---
6
+ // =========================================================================
7
+ // CONFIG NODE (Gère le Pool de Connexion)
8
+ // =========================================================================
7
9
  function poolConfig(config) {
8
10
  RED.nodes.createNode(this, config);
9
11
  this.config = config;
@@ -11,13 +13,16 @@ module.exports = function (RED) {
11
13
  this.connecting = false;
12
14
  this.credentials = RED.nodes.getCredentials(this.id);
13
15
 
16
+ // Validation des timeouts
14
17
  this.config.queryTimeoutSeconds = parseInt(config.queryTimeoutSeconds, 10);
15
18
  if (isNaN(this.config.queryTimeoutSeconds) || this.config.queryTimeoutSeconds < 0) {
16
- this.config.queryTimeoutSeconds = 0;
19
+ this.config.queryTimeoutSeconds = 0; // 0 = infini / défaut driver
17
20
  }
18
- this.closeOperationTimeout = 10000; // 10 secondes
21
+
22
+ // Timeout de sécurité interne pour forcer la fermeture si le driver pend
23
+ this.closeOperationTimeout = 10000;
19
24
 
20
- // NOUVEAU: Récupérer et s'assurer que l'option fireAndForgetOnClose est un booléen
25
+ // Option Fire-and-Forget
21
26
  this.config.fireAndForgetOnClose = config.fireAndForgetOnClose === true;
22
27
 
23
28
  this._buildConnectionString = function() {
@@ -27,13 +32,15 @@ module.exports = function (RED) {
27
32
  }
28
33
  let driver;
29
34
  let parts = [];
35
+ // Mapping des drivers communs
30
36
  switch (this.config.dbType) {
31
37
  case 'sqlserver': driver = 'ODBC Driver 17 for SQL Server'; break;
32
38
  case 'postgresql': driver = 'PostgreSQL Unicode'; break;
33
39
  case 'mysql': driver = 'MySQL ODBC 8.0 Unicode Driver'; break;
34
40
  default: driver = this.config.driver || ''; break;
35
41
  }
36
- if(driver) parts.unshift(`DRIVER={${driver}}`);
42
+
43
+ if(driver) parts.push(`DRIVER={${driver}}`);
37
44
  parts.push(`SERVER=${this.config.server}`);
38
45
  if (this.config.database) parts.push(`DATABASE=${this.config.database}`);
39
46
 
@@ -56,20 +63,24 @@ module.exports = function (RED) {
56
63
  try {
57
64
  const finalConnectionString = this._buildConnectionString();
58
65
  if (!finalConnectionString) throw new Error("La chaîne de connexion est vide.");
66
+
59
67
  const poolParams = {
60
68
  connectionString: finalConnectionString,
61
- initialSize: parseInt(this.config.initialSize, 10) || undefined,
62
- incrementSize: parseInt(this.config.incrementSize, 10) || undefined,
63
- maxSize: parseInt(this.config.maxSize, 10) || undefined,
69
+ initialSize: parseInt(this.config.initialSize, 10) || 5,
70
+ incrementSize: parseInt(this.config.incrementSize, 10) || 5,
71
+ maxSize: parseInt(this.config.maxSize, 10) || 15,
64
72
  shrink: typeof this.config.shrink === 'boolean' ? this.config.shrink : true,
65
- connectionTimeout: (parseInt(this.config.connectionTimeout, 10) * 1000) || undefined,
66
- loginTimeout: parseInt(this.config.loginTimeout, 10) || undefined
73
+ connectionTimeout: (parseInt(this.config.connectionTimeout, 10) * 1000) || 3000,
74
+ loginTimeout: parseInt(this.config.loginTimeout, 10) || 5
67
75
  };
76
+
77
+ // Nettoyage des undefined
68
78
  Object.keys(poolParams).forEach(key => poolParams[key] === undefined && delete poolParams[key]);
79
+
69
80
  this.pool = await odbcModule.pool(poolParams);
70
81
  this.connecting = false;
71
82
  this.status({ fill: "green", shape: "dot", text: "Pool ready" });
72
- this.log("Connection pool initialized successfully.");
83
+ this.log(`Pool initialized for ${this.name || this.id}`);
73
84
  } catch (error) {
74
85
  this.connecting = false;
75
86
  this.error(`Error creating connection pool: ${error.message}`, error);
@@ -77,15 +88,16 @@ module.exports = function (RED) {
77
88
  throw error;
78
89
  }
79
90
  }
91
+ // Récupération d'une connexion du pool
80
92
  try {
81
93
  return await this.pool.connect();
82
94
  } catch (poolConnectError) {
83
- this.error(`Error connecting to pool: ${poolConnectError.message}`, poolConnectError);
84
95
  this.status({ fill: "red", shape: "ring", text: "Pool connect err" });
85
96
  throw poolConnectError;
86
97
  }
87
98
  };
88
99
 
100
+ // Configuration pour une connexion "fraîche" (hors pool)
89
101
  this.getFreshConnectionConfig = function() {
90
102
  return {
91
103
  connectionString: this._buildConnectionString(),
@@ -96,80 +108,74 @@ module.exports = function (RED) {
96
108
 
97
109
  this.resetPool = async () => {
98
110
  if (this.pool) {
99
- this.log("Resetting connection pool.");
111
+ this.log("Resetting connection pool requested.");
100
112
  this.status({ fill: "yellow", shape: "ring", text: "Resetting pool..." });
101
- let closedSuccessfully = false;
113
+
114
+ const oldPool = this.pool;
115
+ this.pool = null; // On détache immédiatement pour forcer la recréation au prochain appel
116
+ this.connecting = false;
117
+
102
118
  try {
103
119
  await Promise.race([
104
- this.pool.close(),
120
+ oldPool.close(),
105
121
  new Promise((_, reject) => setTimeout(() => reject(new Error('Pool close timeout')), this.closeOperationTimeout))
106
122
  ]);
107
- this.log("Connection pool closed successfully for reset.");
108
- closedSuccessfully = true;
123
+ this.log("Old connection pool closed successfully.");
109
124
  } catch (closeError) {
110
- this.error(`Error or timeout closing pool during reset: ${closeError.message}`, closeError);
125
+ this.warn(`Error closing old pool during reset: ${closeError.message}`);
111
126
  } finally {
112
- this.pool = null;
113
- this.connecting = false;
114
- this.status({ fill: closedSuccessfully ? "grey" : "red", shape: "ring", text: closedSuccessfully ? "Pool reset" : "Pool reset failed" });
127
+ this.status({ fill: "grey", shape: "ring", text: "Pool reset" });
115
128
  }
116
- } else {
117
- this.log("Pool reset requested, but no active pool to reset.");
118
129
  }
119
130
  };
120
131
 
121
- // MODIFIÉ pour inclure l'option fireAndForgetOnClose
122
132
  this.on("close", async (removed, done) => {
123
133
  const nodeName = this.name || this.id;
124
- this.log(`[${nodeName}] Closing ODBC config node. Pool present: ${!!this.pool}. Fire-and-forget: ${this.config.fireAndForgetOnClose}`);
125
-
134
+
126
135
  if (this.pool) {
127
- const currentPool = this.pool;
128
- this.pool = null;
136
+ const currentPool = this.pool;
137
+ this.pool = null;
129
138
  this.connecting = false;
130
-
139
+
131
140
  const closePromise = Promise.race([
132
141
  currentPool.close(),
133
- new Promise((_, reject) =>
134
- setTimeout(() => reject(new Error('Pool close timeout')), this.closeOperationTimeout)
135
- )
142
+ new Promise((_, reject) => setTimeout(() => reject(new Error('Pool close timeout')), this.closeOperationTimeout))
136
143
  ]);
137
-
144
+
138
145
  if (this.config.fireAndForgetOnClose) {
139
- this.log(`[${nodeName}] Initiating fire-and-forget pool close. Calling done() immediately.`);
146
+ // FAST CLOSE: On libère Node-RED immédiatement
147
+ this.log(`[${nodeName}] Fire-and-forget close initiated.`);
140
148
  done();
141
-
142
- closePromise
143
- .then(() => {
144
- this.log(`[${nodeName}] Background fire-and-forget pool close for ${nodeName} completed successfully.`);
145
- })
146
- .catch(error => {
147
- this.error(`[${nodeName}] Background fire-and-forget pool close for ${nodeName} failed or timed out: ${error.message}`, error);
148
- });
149
- } else {
150
- this.log(`[${nodeName}] Awaiting pool close operation...`);
149
+ // On continue en arrière-plan (best effort)
150
+ closePromise.catch(e => this.warn(`[${nodeName}] Background pool close error: ${e.message}`));
151
+ } else {
152
+ // NORMAL CLOSE: On attend la fermeture propre
153
+ this.log(`[${nodeName}] Closing pool...`);
151
154
  try {
152
155
  await closePromise;
153
- this.log(`[${nodeName}] Pool close operation completed successfully for ${nodeName}.`);
154
- } catch (error) {
155
- this.error(`[${nodeName}] Pool close operation for ${nodeName} failed or timed out: ${error.message}`, error);
156
+ this.log(`[${nodeName}] Pool closed.`);
157
+ } catch (e) {
158
+ this.error(`[${nodeName}] Error closing pool: ${e.message}`);
156
159
  }
157
- this.log(`[${nodeName}] Calling done() after awaiting pool close for ${nodeName}.`);
158
160
  done();
159
161
  }
160
- } else {
161
- this.log(`[${nodeName}] No pool to close. Calling done().`);
162
+ } else {
162
163
  done();
163
164
  }
164
165
  });
165
166
  }
166
- RED.nodes.registerType("odbc config", poolConfig, { credentials: { password: { type: "password" } } });
167
167
 
168
+ RED.nodes.registerType("odbc config", poolConfig, {
169
+ credentials: { password: { type: "password" } }
170
+ });
171
+
172
+ // Endpoint pour le bouton "Test Connection"
168
173
  RED.httpAdmin.post("/odbc_config/:id/test", RED.auth.needsPermission("odbc.write"), async function(req, res) {
169
174
  const tempConfig = req.body;
175
+ // Reconstitution locale de la chaîne de connexion pour le test
170
176
  const buildTestConnectionString = () => {
171
177
  if (tempConfig.connectionMode === 'structured') {
172
- if (!tempConfig.dbType || !tempConfig.server) { throw new Error("En mode structuré, le type de base de données et le serveur sont requis."); }
178
+ if (!tempConfig.dbType || !tempConfig.server) throw new Error("Missing params.");
173
179
  let driver;
174
180
  let parts = [];
175
181
  switch (tempConfig.dbType) {
@@ -178,373 +184,358 @@ module.exports = function (RED) {
178
184
  case 'mysql': driver = 'MySQL ODBC 8.0 Unicode Driver'; break;
179
185
  default: driver = tempConfig.driver || ''; break;
180
186
  }
181
- if(driver) parts.unshift(`DRIVER={${driver}}`);
187
+ if(driver) parts.push(`DRIVER={${driver}}`);
182
188
  parts.push(`SERVER=${tempConfig.server}`);
183
189
  if (tempConfig.database) parts.push(`DATABASE=${tempConfig.database}`);
184
190
  if (tempConfig.user) {
185
191
  parts.push(`UID=${tempConfig.user}`);
186
- if (typeof tempConfig.password === 'string') {
187
- parts.push(`PWD=${tempConfig.password}`);
188
- }
192
+ if (typeof tempConfig.password === 'string') parts.push(`PWD=${tempConfig.password}`);
189
193
  }
190
194
  return parts.join(';');
191
195
  } else {
192
- let connStr = tempConfig.connectionString || "";
193
- if (!connStr) { throw new Error("La chaîne de connexion ne peut pas être vide."); }
194
- return connStr;
196
+ return tempConfig.connectionString || "";
195
197
  }
196
198
  };
199
+
197
200
  let connection;
198
201
  try {
199
- const testConnectionString = buildTestConnectionString();
200
- const connectionOptions = { connectionString: testConnectionString, loginTimeout: 10 };
201
- connection = await odbcModule.connect(connectionOptions);
202
+ const cs = buildTestConnectionString();
203
+ if (!cs) throw new Error("Connection string empty");
204
+ // Connexion directe (sans pool) pour le test
205
+ connection = await odbcModule.connect({
206
+ connectionString: cs,
207
+ loginTimeout: 10
208
+ });
209
+ await connection.query("SELECT 1"); // Test SQL simple
202
210
  res.sendStatus(200);
203
211
  } catch (err) {
204
- res.status(500).send(err.message || "Erreur inconnue durant le test.");
212
+ res.status(500).send(err.message);
205
213
  } finally {
206
- if (connection) await connection.close();
214
+ if (connection) {
215
+ try { await connection.close(); } catch(e){}
216
+ }
207
217
  }
208
218
  });
209
219
 
210
- // --- ODBC Query Node ---
220
+ // =========================================================================
221
+ // QUERY NODE (Le noeud fonctionnel)
222
+ // =========================================================================
211
223
  function odbc(config) {
212
224
  RED.nodes.createNode(this, config);
213
225
  this.config = config;
214
226
  this.poolNode = RED.nodes.getNode(this.config.connection);
215
227
  this.name = this.config.name;
228
+
229
+ // Gestion des retry
216
230
  this.isAwaitingRetry = false;
217
231
  this.retryTimer = null;
218
- this.cursorCloseOperationTimeout = 5000;
232
+
233
+ // Contextes d'erreur
219
234
  this.currentQueryForErrorContext = null;
220
235
  this.currentParamsForErrorContext = null;
221
236
 
222
- this.enhanceError = (error, query, params, defaultMessage = "Query error") => {
237
+ // Helper pour enrichir les erreurs
238
+ this.enhanceError = (error, query, params, contextMsg) => {
223
239
  const q = query || this.currentQueryForErrorContext;
224
240
  const p = params || this.currentParamsForErrorContext;
225
- const queryContext = (() => {
226
- let s = "";
227
- if (q || p) {
228
- s += " {";
229
- if (q) s += `"query": '${String(q).substring(0, 100)}${String(q).length > 100 ? "..." : ""}'`;
230
- if (p) s += `, "params": '${JSON.stringify(p)}'`;
231
- s += "}";
232
- return s;
233
- }
234
- return "";
235
- })();
241
+ let msg = contextMsg ? `[${contextMsg}] ` : "";
242
+
236
243
  let finalError;
237
- if (typeof error === "object" && error !== null && error.message) { finalError = error; }
238
- else if (typeof error === "string") { finalError = new Error(error); }
239
- else { finalError = new Error(defaultMessage); }
240
- finalError.message = `${finalError.message}${queryContext}`;
241
- if (q) finalError.query = String(q).substring(0,200);
242
- if (p) finalError.params = p;
244
+ if (typeof error === "object" && error !== null && error.message) {
245
+ finalError = error;
246
+ finalError.message = msg + finalError.message;
247
+ } else {
248
+ finalError = new Error(msg + (error || "Unknown error"));
249
+ }
250
+
251
+ if (q) finalError.query = String(q).substring(0, 500);
243
252
  return finalError;
244
253
  };
245
254
 
255
+ // Exécution standard (Non-streaming)
246
256
  this.executeQueryAndProcess = async (dbConnection, queryString, queryParams, msg) => {
247
257
  const result = await dbConnection.query(queryString, queryParams);
248
- if (typeof result === "undefined") { throw new Error("Query returned undefined."); }
258
+
249
259
  const newMsg = RED.util.cloneMessage(msg);
250
260
  const outputProperty = this.config.outputObj || "payload";
251
261
  const otherParams = {};
252
262
  let actualDataRows = [];
253
- if (result !== null && typeof result === "object") {
254
- if (Array.isArray(result)) {
255
- actualDataRows = result.map(row => (typeof row === 'object' && row !== null) ? { ...row } : row);
256
- for (const [key, value] of Object.entries(result)) {
257
- if (isNaN(parseInt(key))) { otherParams[key] = value; }
258
- }
259
- } else {
260
- for (const [key, value] of Object.entries(result)) { otherParams[key] = value; }
263
+
264
+ // Traitement du Result Set
265
+ if (Array.isArray(result)) {
266
+ // Conversion des objets Row en objets JS purs
267
+ actualDataRows = result.map(row => (typeof row === 'object' && row !== null) ? { ...row } : row);
268
+ // Extraction des métadonnées (Statement, count, columns, etc.)
269
+ for (const [key, value] of Object.entries(result)) {
270
+ if (isNaN(parseInt(key))) { otherParams[key] = value; }
261
271
  }
272
+ } else if (typeof result === 'object' && result !== null) {
273
+ // Cas rares ou updates sans retour
274
+ for (const [key, value] of Object.entries(result)) { otherParams[key] = value; }
262
275
  }
276
+
277
+ // Gestion spécifique SQL_BIT -> Boolean
263
278
  const columnMetadata = otherParams.columns;
264
- if (Array.isArray(columnMetadata) && Array.isArray(actualDataRows) && actualDataRows.length > 0) {
265
- const sqlBitColumnNames = new Set();
266
- columnMetadata.forEach(col => { if (col && typeof col.name === "string" && col.dataTypeName === "SQL_BIT") sqlBitColumnNames.add(col.name); });
267
- if (sqlBitColumnNames.size > 0) {
279
+ if (Array.isArray(columnMetadata) && actualDataRows.length > 0) {
280
+ const sqlBitCols = columnMetadata
281
+ .filter(c => c.dataTypeName === "SQL_BIT")
282
+ .map(c => c.name);
283
+
284
+ if (sqlBitCols.length > 0) {
268
285
  actualDataRows.forEach(row => {
269
- if (typeof row === "object" && row !== null) {
270
- for (const columnName of sqlBitColumnNames) {
271
- if (row.hasOwnProperty(columnName)) {
272
- const value = row[columnName];
273
- if (value === "1" || value === 1) row[columnName] = true;
274
- else if (value === "0" || value === 0) row[columnName] = false;
275
- }
276
- }
277
- }
286
+ sqlBitCols.forEach(col => {
287
+ if (row[col] == 1) row[col] = true;
288
+ else if (row[col] == 0) row[col] = false;
289
+ });
278
290
  });
279
291
  }
280
292
  }
293
+
281
294
  objPath.set(newMsg, outputProperty, actualDataRows);
295
+ // Ajout des métadonnées (optionnel, dans msg.odbc)
282
296
  if (Object.keys(otherParams).length) newMsg.odbc = otherParams;
297
+
283
298
  return newMsg;
284
299
  };
285
-
300
+
301
+ // Exécution Streaming
286
302
  this.executeStreamQuery = async (dbConnection, queryString, queryParams, msg, send) => {
287
303
  const chunkSize = parseInt(this.config.streamChunkSize) || 1;
288
- const fetchSize = chunkSize > 100 ? 100 : chunkSize;
304
+ // FetchSize un peu plus grand pour l'efficacité réseau
305
+ const fetchSize = chunkSize < 100 ? 100 : chunkSize;
306
+
307
+ // Préparation d'un squelette de message pour éviter de tout cloner en boucle
308
+ const skeletonMsg = RED.util.cloneMessage(msg);
309
+ const outProp = this.config.outputObj || "payload";
310
+ // On vide le payload du squelette pour ne pas le traîner
311
+ objPath.set(skeletonMsg, outProp, null);
312
+
289
313
  let cursor;
290
314
  try {
291
315
  cursor = await dbConnection.query(queryString, queryParams, { cursor: true, fetchSize: fetchSize });
292
- this.status({ fill: "blue", shape: "dot", text: "streaming rows..." });
316
+ this.status({ fill: "blue", shape: "dot", text: "streaming..." });
317
+
293
318
  let rowCount = 0;
294
- let chunk = [];
319
+ let buffer = [];
320
+
295
321
  while (true) {
296
- const rows = await cursor.fetch();
322
+ const rows = await cursor.fetch();
297
323
  if (!rows || rows.length === 0) break;
324
+
298
325
  for (const row of rows) {
299
326
  rowCount++;
300
- const cleanRow = (typeof row === 'object' && row !== null) ? { ...row } : row;
301
- chunk.push(cleanRow);
302
- if (chunk.length >= chunkSize) {
303
- const newMsg = RED.util.cloneMessage(msg);
304
- objPath.set(newMsg, this.config.outputObj || "payload", chunk);
305
- newMsg.odbc_stream = { index: rowCount - chunk.length, count: chunk.length, complete: false };
306
- send(newMsg);
307
- chunk = [];
327
+ buffer.push({ ...row }); // Copie propre de la ligne
328
+
329
+ if (buffer.length >= chunkSize) {
330
+ // Envoi du chunk
331
+ const chunkMsg = RED.util.cloneMessage(skeletonMsg);
332
+ objPath.set(chunkMsg, outProp, buffer);
333
+ chunkMsg.odbc_stream = { index: rowCount - buffer.length, count: buffer.length, complete: false };
334
+ send(chunkMsg);
335
+ buffer = [];
308
336
  }
309
337
  }
310
338
  }
311
- if (chunk.length > 0) {
312
- const newMsg = RED.util.cloneMessage(msg);
313
- objPath.set(newMsg, this.config.outputObj || "payload", chunk);
314
- newMsg.odbc_stream = { index: rowCount - chunk.length, count: chunk.length, complete: false };
315
- send(newMsg);
339
+
340
+ // Reliquat
341
+ if (buffer.length > 0) {
342
+ const chunkMsg = RED.util.cloneMessage(skeletonMsg);
343
+ objPath.set(chunkMsg, outProp, buffer);
344
+ chunkMsg.odbc_stream = { index: rowCount - buffer.length, count: buffer.length, complete: false };
345
+ send(chunkMsg);
316
346
  }
317
- const finalMsg = RED.util.cloneMessage(msg);
318
- objPath.set(finalMsg, this.config.outputObj || "payload", []);
347
+
348
+ // Message de fin
349
+ const finalMsg = RED.util.cloneMessage(skeletonMsg);
350
+ objPath.set(finalMsg, outProp, []);
319
351
  finalMsg.odbc_stream = { index: rowCount, count: 0, complete: true };
320
352
  send(finalMsg);
321
- this.status({ fill: "green", shape: "dot", text: `success (${rowCount} rows)` });
322
- } finally {
323
- if (cursor) {
324
- try {
325
- await Promise.race([
326
- cursor.close(),
327
- new Promise((_, reject) => setTimeout(() => reject(new Error('Cursor close timeout')), this.cursorCloseOperationTimeout))
328
- ]);
329
- } catch (cursorCloseError) { this.warn(`Error or timeout closing cursor: ${cursorCloseError.message}`); }
330
- }
331
- }
332
- };
353
+
354
+ this.status({ fill: "green", shape: "dot", text: `done (${rowCount} rows)` });
333
355
 
334
- this.testBasicConnectivity = async function(connection) {
335
- if (!connection || typeof connection.query !== 'function') {
336
- this.warn("Test de connectivité basique : connexion invalide fournie.");
337
- return false;
338
- }
339
- let originalTimeout;
340
- try {
341
- originalTimeout = connection.queryTimeout;
342
- connection.queryTimeout = 5;
343
- await connection.query("SELECT 1");
344
- this.log("Test de connectivité basique (SELECT 1) : Réussi.");
345
- return true;
346
- } catch (testError) {
347
- this.warn(`Test de connectivité basique (SELECT 1) : Échoué - ${testError.message}`);
348
- return false;
349
356
  } finally {
350
- if (typeof originalTimeout !== 'undefined' && connection && typeof connection.query === 'function') {
351
- try { connection.queryTimeout = originalTimeout; }
352
- catch(e) { this.warn("Impossible de restaurer le queryTimeout original après le test de connectivité.")}
357
+ // Fermeture garantie du curseur
358
+ if (cursor) {
359
+ try { await cursor.close(); } catch(e) { this.warn("Cursor close warning: " + e.message); }
353
360
  }
354
361
  }
355
362
  };
356
363
 
364
+ // Récupération dynamique de la Query et des Params
357
365
  this.getRenderedQueryAndParams = async function(msg) {
358
- const querySourceType = this.config.querySourceType || 'msg';
359
- const querySource = this.config.querySource || 'query';
360
- const paramsSourceType = this.config.paramsSourceType || 'msg';
361
- const paramsSource = this.config.paramsSource || 'parameters';
362
-
363
- this.currentParamsForErrorContext = await new Promise(resolve => RED.util.evaluateNodeProperty(paramsSource, paramsSourceType, this, msg, (err, val) => resolve(err ? undefined : val)));
364
- this.currentQueryForErrorContext = await new Promise(resolve => RED.util.evaluateNodeProperty(querySource, querySourceType, this, msg, (err, val) => resolve(err ? undefined : (val || this.config.query || ""))));
365
-
366
- if (!this.currentQueryForErrorContext) {
367
- throw new Error("No query to execute. Please provide a query in the node's configuration or via msg." + (querySourceType === 'msg' ? querySource : 'querySource (non-msg)'));
368
- }
366
+ const qSource = this.config.querySource || 'query';
367
+ const qType = this.config.querySourceType || 'msg';
368
+ const pSource = this.config.paramsSource || 'parameters';
369
+ const pType = this.config.paramsSourceType || 'msg';
370
+
371
+ const paramsVal = await new Promise((resolve, reject) => {
372
+ RED.util.evaluateNodeProperty(pSource, pType, this, msg, (err, res) => {
373
+ if(err) resolve(undefined); else resolve(res);
374
+ });
375
+ });
376
+
377
+ const queryVal = await new Promise((resolve, reject) => {
378
+ RED.util.evaluateNodeProperty(qSource, qType, this, msg, (err, res) => {
379
+ if(err) resolve(undefined); else resolve(res || this.config.query || "");
380
+ });
381
+ });
369
382
 
370
- let finalQuery = this.currentQueryForErrorContext;
371
- const isPreparedStatement = this.currentParamsForErrorContext || (finalQuery && finalQuery.includes("?"));
372
- if (!isPreparedStatement && finalQuery) {
383
+ this.currentParamsForErrorContext = paramsVal;
384
+ this.currentQueryForErrorContext = queryVal;
385
+
386
+ if (!queryVal) throw new Error("No SQL query provided via config or input msg.");
387
+
388
+ let finalQuery = queryVal;
389
+ // Si pas de ? (paramétré) et qu'on a un msg, on tente Mustache
390
+ // Note: Si paramsVal existe, on suppose que c'est une requête préparée, on évite Mustache pour la sécurité
391
+ if (!paramsVal && finalQuery.includes("{{")) {
373
392
  finalQuery = mustache.render(finalQuery, msg);
374
393
  }
375
- return { query: finalQuery, params: this.currentParamsForErrorContext };
376
- };
377
394
 
378
- this.executeUserQuery = async function(connection, query, params, msg, send) {
379
- const configuredTimeout = parseInt(this.poolNode.config.queryTimeoutSeconds, 10);
380
- if (configuredTimeout > 0) {
381
- try { connection.queryTimeout = configuredTimeout; }
382
- catch (e) { this.warn(`Could not set queryTimeout on connection: ${e.message}`); }
383
- } else {
384
- connection.queryTimeout = 0;
385
- }
386
-
387
- this.status({ fill: "blue", shape: "dot", text: "executing..." });
388
- if (this.config.streaming) {
389
- await this.executeStreamQuery(connection, query, params, msg, send);
390
- } else {
391
- const newMsg = await this.executeQueryAndProcess(connection, query, params, msg);
392
- this.status({ fill: "green", shape: "dot", text: "success" });
393
- send(newMsg);
394
- }
395
+ return { query: finalQuery, params: paramsVal };
395
396
  };
396
397
 
398
+ // =====================================================================
399
+ // LOGIQUE PRINCIPALE (SAFE PATTERN & NON-BLOCKING UI)
400
+ // =====================================================================
397
401
  this.on("input", async (msg, send, done) => {
398
- this.currentQueryForErrorContext = null;
399
- this.currentParamsForErrorContext = null;
400
-
401
- if (this.isAwaitingRetry) {
402
- if (this.poolNode && this.poolNode.config.retryOnMsg === true) {
403
- this.log("New message received, overriding retry timer and attempting query now.");
404
- clearTimeout(this.retryTimer); this.retryTimer = null; this.isAwaitingRetry = false;
405
- } else {
406
- this.warn("Node is in a retry-wait state. New message ignored.");
407
- if (done) done(); return;
408
- }
409
- }
402
+ // Nettoyage état précédent
403
+ if (this.retryTimer) { clearTimeout(this.retryTimer); this.retryTimer = null; }
410
404
  this.isAwaitingRetry = false;
411
- if(this.retryTimer) { clearTimeout(this.retryTimer); this.retryTimer = null; }
412
405
 
413
406
  if (!this.poolNode) {
414
- this.status({ fill: "red", shape: "ring", text: "No config node" });
415
- return done(this.enhanceError(new Error("ODBC Config node not properly configured.")));
407
+ this.status({ fill: "red", shape: "ring", text: "Config missing" });
408
+ return done(new Error("ODBC Config node not configured"));
416
409
  }
417
410
 
418
- let queryToExecute;
419
- let paramsToExecute;
411
+ // 1. Préparation Données
412
+ let queryData;
420
413
  try {
421
- const queryData = await this.getRenderedQueryAndParams(msg);
422
- queryToExecute = queryData.query;
423
- paramsToExecute = queryData.params;
424
- } catch (inputValidationError) {
414
+ queryData = await this.getRenderedQueryAndParams(msg);
415
+ } catch (e) {
425
416
  this.status({ fill: "red", shape: "ring", text: "Input Error" });
426
- return done(this.enhanceError(inputValidationError));
417
+ return done(e);
427
418
  }
428
-
429
- let activeConnection = null;
430
- let errorForUser = null;
431
- let shouldProceedToTimedRetry = false;
432
-
433
- try {
434
- this.status({ fill: "yellow", shape: "dot", text: "connecting (pool)..." });
435
- activeConnection = await this.poolNode.connect();
436
- await this.executeUserQuery(activeConnection, queryToExecute, paramsToExecute, msg, send);
437
-
438
- done();
439
-
440
- if (activeConnection) {
441
- try { await activeConnection.close(); } catch(e) { this.warn("Error closing pool connection after success: " + e.message); }
442
- activeConnection = null;
443
- }
444
- return;
445
-
446
- } catch (initialDbError) {
447
- this.warn(`Initial DB attempt failed: ${initialDbError.message}`);
448
- if (activeConnection) {
449
- const connStillGood = await this.testBasicConnectivity(activeConnection);
450
- try { await activeConnection.close(); activeConnection = null; }
451
- catch(e){ this.warn("Error closing pool conn after initial error: "+e.message); activeConnection = null; }
452
-
453
- if (connStillGood) {
454
- this.status({ fill: "red", shape: "ring", text: "SQL error" });
455
- return done(this.enhanceError(initialDbError));
419
+
420
+ let connection = null;
421
+ let isFreshConnection = false;
422
+ let errorOccurred = null;
423
+ let shouldScheduleRetry = false;
424
+
425
+ // 2. Exécution avec bloc FINALLY pour anti-fuite
426
+ try {
427
+ // A. Obtention de la connexion
428
+ try {
429
+ this.status({ fill: "yellow", shape: "dot", text: "connecting..." });
430
+ connection = await this.poolNode.connect();
431
+ } catch (poolErr) {
432
+ // Si le pool échoue et que l'option RetryFresh est active
433
+ if (this.poolNode.config.retryFreshConnection) {
434
+ this.warn("Pool connection failed, attempting fresh connection...");
435
+ const freshCfg = this.poolNode.getFreshConnectionConfig();
436
+ connection = await odbcModule.connect(freshCfg);
437
+ isFreshConnection = true;
438
+ this.log("Fresh connection established.");
439
+ } else {
440
+ throw poolErr;
456
441
  }
457
442
  }
443
+
444
+ // B. Configuration du Timeout (si supporté par l'objet connexion)
445
+ const qTimeout = parseInt(this.poolNode.config.queryTimeoutSeconds, 10);
446
+ if (qTimeout > 0) {
447
+ connection.queryTimeout = qTimeout;
448
+ } else {
449
+ connection.queryTimeout = 0; // Défaut
450
+ }
451
+
452
+ // C. Exécution (NON-BLOCKING UI)
453
+ this.status({ fill: "blue", shape: "dot", text: "querying..." });
458
454
 
459
- if (this.poolNode.config.retryFreshConnection) {
460
- this.warn("Attempting retry with a fresh connection.");
461
- this.status({ fill: "yellow", shape: "dot", text: "Retrying (fresh)..." });
462
- try {
463
- const freshConnectConfig = this.poolNode.getFreshConnectionConfig();
464
- activeConnection = await odbcModule.connect(freshConnectConfig);
465
- this.log("Fresh connection established.");
455
+ // FORCE UI REFRESH: On rend la main à l'Event Loop pour afficher "querying"
456
+ // avant que le driver ODBC (C++) ne bloque le thread lors de la préparation.
457
+ await new Promise(resolve => setImmediate(resolve));
466
458
 
467
- const freshConnGood = await this.testBasicConnectivity(activeConnection);
468
- if (!freshConnGood) {
469
- errorForUser = this.enhanceError(new Error("Basic connectivity (SELECT 1) failed on fresh connection."), null, null, "Fresh Connection Test Failed");
470
- shouldProceedToTimedRetry = true;
471
- throw errorForUser;
472
- }
473
-
474
- await this.executeUserQuery(activeConnection, queryToExecute, paramsToExecute, msg, send);
475
-
476
- this.log("Query successful with fresh connection. Resetting pool.");
477
- done();
478
-
479
- await this.poolNode.resetPool();
480
- if (activeConnection) {
481
- try { await activeConnection.close(); } catch(e) { this.warn("Error closing fresh connection after success: " + e.message); }
482
- activeConnection = null;
483
- }
484
- return;
485
-
486
- } catch (freshErrorOrConnectivityFail) {
487
- if (activeConnection) { try { await activeConnection.close(); activeConnection = null; } catch(e){this.warn("Error closing fresh conn after error: "+e.message);} }
488
-
489
- if (shouldProceedToTimedRetry) {
490
- // errorForUser est déjà l'erreur de test de connectivité et sera utilisé ci-dessous
491
- } else {
492
- this.status({ fill: "red", shape: "ring", text: "SQL error (on retry)" });
493
- return done(this.enhanceError(freshErrorOrConnectivityFail));
459
+ if (this.config.streaming) {
460
+ await this.executeStreamQuery(connection, queryData.query, queryData.params, msg, send);
461
+ } else {
462
+ const resultMsg = await this.executeQueryAndProcess(connection, queryData.query, queryData.params, msg);
463
+ send(resultMsg);
464
+ this.status({ fill: "green", shape: "dot", text: "success" });
465
+ }
466
+
467
+ // D. Si on a réussi avec une Fresh Connection, le pool est probablement cassé, on le reset
468
+ if (isFreshConnection) {
469
+ this.poolNode.resetPool();
470
+ }
471
+
472
+ done(); // Succès
473
+
474
+ } catch (err) {
475
+ errorOccurred = err;
476
+ // Analyse de l'erreur pour voir si c'est une erreur de connexion (réseau)
477
+ const isConnectionError = err.message && (
478
+ err.message.match(/connection|network|timeout|communication|link|provider/i)
479
+ || (err.odbcErrors && err.odbcErrors.length > 0)
480
+ );
481
+
482
+ if (isConnectionError || this.poolNode.config.retryFreshConnection) {
483
+ shouldScheduleRetry = true;
484
+ }
485
+ } finally {
486
+ // E. NETTOYAGE CRITIQUE (Le "Fix" principal)
487
+ if (connection) {
488
+ try {
489
+ // Si c'était une connexion du pool, on réinitialise le timeout pour ne pas polluer le pool
490
+ if (!isFreshConnection) {
491
+ connection.queryTimeout = 0;
494
492
  }
493
+ await connection.close();
494
+ } catch (closeErr) {
495
+ this.warn("Error ensuring connection closed: " + closeErr.message);
495
496
  }
496
- } else {
497
- errorForUser = this.enhanceError(initialDbError, null, null, "Connection Error (no fresh retry)");
498
- shouldProceedToTimedRetry = true;
499
497
  }
500
498
  }
501
-
502
- if (activeConnection) {
503
- try { await activeConnection.close(); } catch(e) { this.warn("Final cleanup: Error closing activeConnection: " + e.message); }
504
- activeConnection = null;
505
- }
506
-
507
- if (shouldProceedToTimedRetry && errorForUser) {
508
- const retryDelaySeconds = parseInt(this.poolNode.config.retryDelay, 10);
509
- if (retryDelaySeconds > 0) {
510
- this.warn(`Connection issue. Scheduling retry in ${retryDelaySeconds}s. Error: ${errorForUser.message}`);
511
- this.status({ fill: "red", shape: "ring", text: `Retry in ${retryDelaySeconds}s...` });
499
+
500
+ // 3. Gestion post-exécution des erreurs et Retry
501
+ if (errorOccurred) {
502
+ const retryDelay = parseInt(this.poolNode.config.retryDelay, 10);
503
+
504
+ if (shouldScheduleRetry && retryDelay > 0) {
505
+ this.warn(`Query failed (${errorOccurred.message}). Retrying in ${retryDelay}s...`);
506
+ this.status({ fill: "red", shape: "ring", text: `Retry in ${retryDelay}s` });
507
+
512
508
  this.isAwaitingRetry = true;
513
509
  this.retryTimer = setTimeout(() => {
514
- this.isAwaitingRetry = false; this.retryTimer = null;
515
- this.log(`Retry timer expired. Re-emitting message for node ${this.id || this.name}.`);
516
- this.receive(msg);
517
- }, retryDelaySeconds * 1000);
518
- return done();
519
- } else {
520
- this.status({ fill: "red", shape: "ring", text: "Connection Error" });
521
- return done(errorForUser);
510
+ this.isAwaitingRetry = false;
511
+ this.log("Retry timer expired. Re-processing message.");
512
+ this.emit("input", msg, send, done);
513
+ }, retryDelay * 1000);
514
+
515
+ done();
516
+ } else {
517
+ this.status({ fill: "red", shape: "dot", text: "Error" });
518
+ done(this.enhanceError(errorOccurred));
522
519
  }
523
- } else if (errorForUser) {
524
- this.status({ fill: "red", shape: "ring", text: "Error (No Timed Retry)" });
525
- return done(errorForUser);
526
- } else {
527
- this.log("[ODBC Node] DEBUG: Reached end of on('input') path. Calling done().");
528
- return done();
529
520
  }
530
521
  });
531
-
522
+
532
523
  this.on("close", (done) => {
533
524
  if (this.retryTimer) {
534
- clearTimeout(this.retryTimer); this.retryTimer = null; this.isAwaitingRetry = false;
535
- this.log("Cleared pending retry timer on node close/redeploy.");
525
+ clearTimeout(this.retryTimer);
526
+ this.retryTimer = null;
536
527
  }
537
528
  this.status({});
538
529
  done();
539
530
  });
540
-
531
+
532
+ // Status initial
541
533
  if (this.poolNode) {
542
534
  this.status({ fill: "green", shape: "dot", text: "ready" });
543
535
  } else {
544
- this.status({ fill: "red", shape: "ring", text: "No config node" });
545
- this.warn("ODBC Config node not found or not deployed.");
536
+ this.status({ fill: "red", shape: "ring", text: "No config" });
546
537
  }
547
538
  }
548
-
539
+
549
540
  RED.nodes.registerType("odbc", odbc);
550
541
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bkmj/node-red-contrib-odbcmj",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "A powerful Node-RED node to connect to any ODBC data source, with connection pooling, advanced retry logic, and result streaming.",
5
5
  "keywords": [
6
6
  "node-red",