@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.
- package/odbc.html +40 -10
- package/odbc.js +141 -31
- 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
|
|
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,
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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;
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|