@bkmj/node-red-contrib-odbcmj 2.2.0 → 2.2.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.
Files changed (3) hide show
  1. package/odbc.html +40 -10
  2. package/odbc.js +141 -31
  3. package/package.json +1 -1
package/odbc.html CHANGED
@@ -71,12 +71,36 @@
71
71
 
72
72
  $('#node-config-test-connection').on('click', function() {
73
73
  var button = $(this);
74
+ var connectionMode = $("#node-config-input-connectionMode").val();
75
+
76
+ if (connectionMode === 'structured') {
77
+ var server = $("#node-config-input-server").val().trim();
78
+ if (!server) {
79
+ RED.notify("Le champ 'Server' est requis pour le test.", {type: "warning", timeout: 3000});
80
+ return;
81
+ }
82
+ } else {
83
+ var connStr = $("#node-config-input-connectionString").val().trim();
84
+ if (!connStr) {
85
+ RED.notify("La chaîne de connexion est requise pour le test.", {type: "warning", timeout: 3000});
86
+ return;
87
+ }
88
+ // Nouvelle validation :
89
+ // Elle est invalide SEULEMENT si elle ne contient PAS de DSN ET qu'elle ne respecte PAS l'ancien critère pour les chaînes DSN-less.
90
+ var isDsnString = /DSN=[^;]+/i.test(connStr);
91
+ var isDriverBasedString = /DRIVER=\{.+?\}/i.test(connStr) && /(SERVER|DATABASE|UID|PWD)=/i.test(connStr); // Un peu plus complet
92
+
93
+ if (!isDsnString && !isDriverBasedString) {
94
+ RED.notify("La chaîne de connexion semble invalide ou incomplète (ex: DSN=value; ou DRIVER={...};SERVER=...;).", {type: "warning", timeout: 4000});
95
+ return;
96
+ }
97
+ }
98
+
74
99
  var originalText = "Test Connection";
75
100
  var icon = button.find("i");
76
101
  icon.removeClass('fa-bolt').addClass('fa-spinner fa-spin');
77
102
  button.text(' Testing...').prop('disabled', true);
78
103
 
79
- var connectionMode = $("#node-config-input-connectionMode").val();
80
104
  var configData = {
81
105
  connectionMode: connectionMode,
82
106
  dbType: $("#node-config-input-dbType").val(),
@@ -272,7 +296,6 @@
272
296
  outputObj: { value: "payload" },
273
297
  streaming: { value: false },
274
298
  streamChunkSize: { value: 1, validate: RED.validators.number() },
275
- // Nouveaux `defaults` pour les Typed Inputs
276
299
  querySource: { value: "query", required: false },
277
300
  querySourceType: { value: "msg", required: false },
278
301
  paramsSource: { value: "parameters", required: false },
@@ -295,7 +318,6 @@
295
318
  $(".stream-options").toggle(this.checked);
296
319
  }).trigger("change");
297
320
 
298
- // Initialisation des Typed Inputs
299
321
  $("#node-input-querySource").typedInput({
300
322
  default: 'msg',
301
323
  typeField: "#node-input-querySourceType",
@@ -414,7 +436,7 @@ Executes a query against a configured ODBC data source.
414
436
  ### Properties
415
437
 
416
438
  - **Connection**: The `odbc config` node to use.
417
- - **Query (fallback)**: A static SQL query to run if the "Query Source" does not provide one. Can contain Mustache syntax (e.g., `{{{payload.id}}}`).
439
+ - **Query (fallback)**: A static SQL query to run if the "Query (fallback)" does not provide one. Can contain Mustache syntax (e.g., `{{{payload.id}}}`).
418
440
  - **Result to**: The `msg` property where the query result will be stored. Default: `payload`.
419
441
 
420
442
  ### Streaming Results
@@ -425,10 +447,18 @@ For queries that return a large number of rows, streaming prevents high memory u
425
447
  - **`Chunk Size`**: The number of rows to include in each output message. A value of `1` means one message will be sent for every single row.
426
448
 
427
449
  #### Streaming Output Format
428
- When streaming is active, each output message will contain:
429
- - A payload (or the configured output property) containing an array of rows for the current chunk.
430
- - A `msg.odbc_stream` object with metadata for tracking progress:
431
- - `index`: The starting index of the current chunk (e.g., 0, 100, 200...).
432
- - `count`: The number of rows in the current chunk.
433
- - `complete`: A boolean that is `true` only on the very last message of the stream, useful for triggering a final action. The last payload will always be an empty array.
450
+ When streaming is active, the node sends messages in sequence:
451
+
452
+ 1. **Data Messages**: One or more messages where the payload (or the configured output property) contains an array of rows for the current chunk. For these messages, `msg.odbc_stream.complete` will be **`false`**.
453
+
454
+ 2. **Completion Message**: A single, final message indicating the end of the stream. For this message:
455
+ - The payload will be an **empty array `[]`**.
456
+ - `msg.odbc_stream.complete` will be **`true`**.
457
+
458
+ The `msg.odbc_stream` object contains metadata for tracking:
459
+ - `index`: The starting index of the current chunk. For the completion message, this will be the total number of rows processed.
460
+ - `count`: The number of rows in the chunk. This will be `0` for the completion message.
461
+ - `complete`: The boolean flag (`true`/`false`).
462
+
463
+ This pattern ensures you can reliably trigger a final action (like closing a file or calculating an aggregate) only when the message with `complete: true` is received.
434
464
  </script>
package/odbc.js CHANGED
@@ -12,7 +12,6 @@ module.exports = function (RED) {
12
12
 
13
13
  this.credentials = RED.nodes.getCredentials(this.id);
14
14
 
15
- // Cette fonction est maintenant cruciale pour le mode streaming
16
15
  this._buildConnectionString = function() {
17
16
  if (this.config.connectionMode === 'structured') {
18
17
  if (!this.config.dbType || !this.config.server) {
@@ -119,9 +118,61 @@ module.exports = function (RED) {
119
118
  });
120
119
 
121
120
  RED.httpAdmin.post("/odbc_config/:id/test", RED.auth.needsPermission("odbc.write"), async function(req, res) {
122
- // ... (Pas de changement dans cette section)
123
- });
121
+ const tempConfig = req.body;
122
+
123
+ const buildTestConnectionString = () => {
124
+ if (tempConfig.connectionMode === 'structured') {
125
+ if (!tempConfig.dbType || !tempConfig.server) {
126
+ throw new Error("En mode structuré, le type de base de données et le serveur sont requis.");
127
+ }
128
+ let driver;
129
+ let parts = [];
130
+ switch (tempConfig.dbType) {
131
+ case 'sqlserver': driver = 'ODBC Driver 17 for SQL Server'; break;
132
+ case 'postgresql': driver = 'PostgreSQL Unicode'; break;
133
+ case 'mysql': driver = 'MySQL ODBC 8.0 Unicode Driver'; break;
134
+ default: driver = tempConfig.driver || ''; break;
135
+ }
136
+ if(driver) parts.unshift(`DRIVER={${driver}}`);
137
+ parts.push(`SERVER=${tempConfig.server}`);
138
+ if (tempConfig.database) parts.push(`DATABASE=${tempConfig.database}`);
139
+ if (tempConfig.user) parts.push(`UID=${tempConfig.user}`);
140
+ if (tempConfig.password) parts.push(`PWD=${tempConfig.password}`);
141
+ return parts.join(';');
142
+ } else {
143
+ let connStr = tempConfig.connectionString || "";
144
+ if (!connStr) {
145
+ throw new Error("La chaîne de connexion ne peut pas être vide.");
146
+ }
147
+ return connStr;
148
+ }
149
+ };
150
+
151
+ let connection;
152
+ try {
153
+ const testConnectionString = buildTestConnectionString();
154
+
155
+ // ==============================================================
156
+ // LIGNE DE DÉBOGAGE AJOUTÉE
157
+ // ==============================================================
158
+ console.log("[ODBC Test] Attempting to connect with string:", testConnectionString);
159
+ // ==============================================================
124
160
 
161
+ const connectionOptions = {
162
+ connectionString: testConnectionString,
163
+ loginTimeout: 10
164
+ };
165
+ connection = await odbcModule.connect(connectionOptions);
166
+ res.sendStatus(200);
167
+ } catch (err) {
168
+ console.error("[ODBC Test] Connection failed:", err); // Ajout d'un log d'erreur
169
+ res.status(500).send(err.message || "Erreur inconnue durant le test.");
170
+ } finally {
171
+ if (connection) {
172
+ await connection.close();
173
+ }
174
+ }
175
+ });
125
176
 
126
177
  // --- ODBC Query Node ---
127
178
  function odbc(config) {
@@ -130,7 +181,10 @@ module.exports = function (RED) {
130
181
  this.poolNode = RED.nodes.getNode(this.config.connection);
131
182
  this.name = this.config.name;
132
183
 
133
- // ... (enhanceError et executeQueryAndProcess restent inchangés)
184
+ // Propriétés pour la logique de retry temporisée
185
+ this.isAwaitingRetry = false;
186
+ this.retryTimer = null;
187
+
134
188
  this.enhanceError = (error, query, params, defaultMessage = "Query error") => {
135
189
  const queryContext = (() => {
136
190
  let s = "";
@@ -154,6 +208,7 @@ module.exports = function (RED) {
154
208
  };
155
209
 
156
210
  this.executeQueryAndProcess = async (dbConnection, queryString, queryParams, msg) => {
211
+ // ... (contenu de cette fonction inchangé par rapport à la dernière version)
157
212
  const result = await dbConnection.query(queryString, queryParams);
158
213
  if (typeof result === "undefined") { throw new Error("Query returned undefined."); }
159
214
  const newMsg = RED.util.cloneMessage(msg);
@@ -195,25 +250,20 @@ module.exports = function (RED) {
195
250
  if (Object.keys(otherParams).length) { newMsg.odbc = otherParams; }
196
251
  return newMsg;
197
252
  };
198
-
253
+
199
254
  this.executeStreamQuery = async (dbConnection, queryString, queryParams, msg, send) => {
255
+ // ... (contenu de cette fonction inchangé par rapport à la dernière version avec le message de complétion final)
200
256
  const chunkSize = parseInt(this.config.streamChunkSize) || 1;
201
- const fetchSize = chunkSize > 100 ? 100 : chunkSize; // Optimisation du fetch
257
+ const fetchSize = chunkSize > 100 ? 100 : chunkSize;
202
258
  let cursor;
203
-
204
259
  try {
205
260
  cursor = await dbConnection.query(queryString, queryParams, { cursor: true, fetchSize: fetchSize });
206
261
  this.status({ fill: "blue", shape: "dot", text: "streaming rows..." });
207
-
208
262
  let rowCount = 0;
209
263
  let chunk = [];
210
-
211
264
  while (true) {
212
265
  const rows = await cursor.fetch();
213
- if (!rows || rows.length === 0) {
214
- break;
215
- }
216
-
266
+ if (!rows || rows.length === 0) { break; }
217
267
  for (const row of rows) {
218
268
  rowCount++;
219
269
  chunk.push(row);
@@ -226,51 +276,63 @@ module.exports = function (RED) {
226
276
  }
227
277
  }
228
278
  }
229
-
230
279
  if (chunk.length > 0) {
231
280
  const newMsg = RED.util.cloneMessage(msg);
232
281
  objPath.set(newMsg, this.config.outputObj, chunk);
233
282
  newMsg.odbc_stream = { index: rowCount - chunk.length, count: chunk.length, complete: false };
234
283
  send(newMsg);
235
284
  }
236
-
237
285
  const finalMsg = RED.util.cloneMessage(msg);
238
286
  objPath.set(finalMsg, this.config.outputObj, []);
239
287
  finalMsg.odbc_stream = { index: rowCount, count: 0, complete: true };
240
288
  send(finalMsg);
241
-
242
289
  this.status({ fill: "green", shape: "dot", text: `success (${rowCount} rows)` });
243
-
244
290
  } finally {
245
- if (cursor) {
246
- await cursor.close();
247
- }
291
+ if (cursor) await cursor.close();
248
292
  }
249
293
  };
250
294
 
251
295
  this.on("input", async (msg, send, done) => {
296
+ // --- NOUVEAU : GESTION DE retryOnMsg ---
297
+ if (this.isAwaitingRetry) {
298
+ if (this.poolNode && this.poolNode.config.retryOnMsg === true) { // s'assurer que c'est bien un booléen true
299
+ this.log("New message received, overriding retry timer and attempting query now.");
300
+ clearTimeout(this.retryTimer);
301
+ this.retryTimer = null;
302
+ this.isAwaitingRetry = false;
303
+ // Laisser l'exécution se poursuivre
304
+ } else {
305
+ this.warn("Node is in a retry-wait state. New message ignored as per configuration.");
306
+ if (done) done(); // Terminer le traitement pour CE message
307
+ return;
308
+ }
309
+ }
310
+ // S'assurer que les états de retry sont propres si on n'est pas dans un retry forcé par un nouveau message
311
+ this.isAwaitingRetry = false;
312
+ if(this.retryTimer) {
313
+ clearTimeout(this.retryTimer);
314
+ this.retryTimer = null;
315
+ }
316
+ // --- FIN DE LA GESTION DE retryOnMsg ---
317
+
252
318
  if (!this.poolNode) {
319
+ this.status({ fill: "red", shape: "ring", text: "No config node" });
253
320
  return done(new Error("ODBC Config node not properly configured."));
254
321
  }
255
322
 
256
323
  const execute = async (connection) => {
257
324
  this.config.outputObj = this.config.outputObj || "payload";
258
-
259
325
  const querySourceType = this.config.querySourceType || 'msg';
260
326
  const querySource = this.config.querySource || 'query';
261
327
  const paramsSourceType = this.config.paramsSourceType || 'msg';
262
328
  const paramsSource = this.config.paramsSource || 'parameters';
263
-
264
329
  const params = await new Promise(resolve => RED.util.evaluateNodeProperty(paramsSource, paramsSourceType, this, msg, (err, val) => resolve(err ? undefined : val)));
265
330
  let query = await new Promise(resolve => RED.util.evaluateNodeProperty(querySource, querySourceType, this, msg, (err, val) => resolve(err ? undefined : (val || this.config.query || ""))));
266
-
267
331
  if (!query) throw new Error("No query to execute");
268
-
269
332
  const isPreparedStatement = params || (query && query.includes("?"));
270
333
  if (!isPreparedStatement && query) {
271
334
  query = mustache.render(query, msg);
272
335
  }
273
-
274
336
  this.status({ fill: "blue", shape: "dot", text: "executing..." });
275
337
  if (this.config.streaming) {
276
338
  await this.executeStreamQuery(connection, query, params, msg, send);
@@ -282,11 +344,13 @@ module.exports = function (RED) {
282
344
  };
283
345
 
284
346
  let connectionFromPool;
347
+ let errorAfterInitialAttempts = null;
348
+
285
349
  try {
286
350
  this.status({ fill: "yellow", shape: "dot", text: "connecting..." });
287
351
  connectionFromPool = await this.poolNode.connect();
288
352
  await execute(connectionFromPool);
289
- return done();
353
+ return done(); // Succès de la première tentative
290
354
  } catch (poolError) {
291
355
  this.warn(`First attempt with pooled connection failed: ${poolError.message}`);
292
356
  if (this.poolNode.config.retryFreshConnection) {
@@ -300,23 +364,69 @@ module.exports = function (RED) {
300
364
  await execute(freshConnection);
301
365
  this.log("Query successful with fresh connection. Resetting pool.");
302
366
  await this.poolNode.resetPool();
303
- return done();
367
+ return done(); // Succès de la tentative avec connexion fraîche
304
368
  } catch (freshError) {
305
- this.status({ fill: "red", shape: "ring", text: "retry failed" });
306
- return done(this.enhanceError(freshError, null, null, "Retry with fresh connection also failed"));
369
+ errorAfterInitialAttempts = this.enhanceError(freshError, null, null, "Retry with fresh connection also failed");
307
370
  } finally {
308
371
  if (freshConnection) await freshConnection.close();
309
372
  }
310
373
  } else {
311
- this.status({ fill: "red", shape: "ring", text: "query error" });
312
- return done(this.enhanceError(poolError));
374
+ errorAfterInitialAttempts = this.enhanceError(poolError);
313
375
  }
314
376
  } finally {
315
377
  if (connectionFromPool) await connectionFromPool.close();
316
378
  }
379
+
380
+ // --- NOUVEAU : GESTION DE retryDelay ---
381
+ if (errorAfterInitialAttempts) {
382
+ const retryDelaySeconds = parseInt(this.poolNode.config.retryDelay, 10); // S'assurer que c'est un nombre
383
+
384
+ if (retryDelaySeconds > 0) {
385
+ this.warn(`Query failed. Scheduling retry in ${retryDelaySeconds} seconds. Error: ${errorAfterInitialAttempts.message}`);
386
+ this.status({ fill: "red", shape: "ring", text: `Retry in ${retryDelaySeconds}s...` });
387
+ this.isAwaitingRetry = true;
388
+
389
+ // Important: `this.receive(msg)` ne peut pas être appelé directement dans un `setTimeout`
390
+ // sans s'assurer que `this` est correctement lié. Utiliser une arrow function ou .bind(this).
391
+ // De plus, `this.receive` est une méthode non documentée pour réinjecter un message.
392
+ // La méthode standard pour retenter est que le nœud se renvoie le message à lui-même.
393
+ // Pour cela, le `done()` de l'invocation actuelle doit être appelé.
394
+
395
+ this.retryTimer = setTimeout(() => {
396
+ this.isAwaitingRetry = false; // Prêt pour une nouvelle tentative
397
+ this.retryTimer = null;
398
+ this.log(`Retry timer expired for message. Re-emitting for node ${this.id || this.name}.`);
399
+ // Réinjecter le message pour une nouvelle tentative de traitement.
400
+ // Le message original `msg` est utilisé.
401
+ this.receive(msg);
402
+ }, retryDelaySeconds * 1000);
403
+
404
+ // L'invocation actuelle du message se termine ici, sans erreur si un retry est planifié.
405
+ // L'erreur sera gérée par la prochaine invocation si elle échoue à nouveau.
406
+ if (done) return done();
407
+
408
+ } else {
409
+ // Pas de retryDelay configuré ou il est à 0. C'est une défaillance définitive pour CE message.
410
+ this.status({ fill: "red", shape: "ring", text: "query error" });
411
+ if (done) return done(errorAfterInitialAttempts);
412
+ }
413
+ } else {
414
+ // Normalement, on ne devrait pas arriver ici si done() a été appelé après un succès.
415
+ // C'est une sécurité.
416
+ if (done) return done();
417
+ }
418
+ // --- FIN DE LA GESTION DE retryDelay ---
317
419
  });
318
420
 
319
421
  this.on("close", (done) => {
422
+ // --- NOUVEAU : Nettoyage du timer ---
423
+ if (this.retryTimer) {
424
+ clearTimeout(this.retryTimer);
425
+ this.retryTimer = null;
426
+ this.isAwaitingRetry = false;
427
+ this.log("Cleared pending retry timer on node close/redeploy.");
428
+ }
429
+ // --- FIN DU NETTOYAGE ---
320
430
  this.status({});
321
431
  done();
322
432
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bkmj/node-red-contrib-odbcmj",
3
- "version": "2.2.0",
3
+ "version": "2.2.2",
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",