@bkmj/node-red-contrib-odbcmj 2.5.0 → 2.6.1
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.js +312 -306
- 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
|
-
//
|
|
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
|
-
|
|
21
|
+
|
|
22
|
+
// Timeout de sécurité interne pour forcer la fermeture si le driver pend
|
|
23
|
+
this.closeOperationTimeout = 10000;
|
|
19
24
|
|
|
20
|
-
//
|
|
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
|
-
|
|
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) ||
|
|
62
|
-
incrementSize: parseInt(this.config.incrementSize, 10) ||
|
|
63
|
-
maxSize: parseInt(this.config.maxSize, 10) ||
|
|
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) ||
|
|
66
|
-
loginTimeout: parseInt(this.config.loginTimeout, 10) ||
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
120
|
+
oldPool.close(),
|
|
105
121
|
new Promise((_, reject) => setTimeout(() => reject(new Error('Pool close timeout')), this.closeOperationTimeout))
|
|
106
122
|
]);
|
|
107
|
-
this.log("
|
|
108
|
-
closedSuccessfully = true;
|
|
123
|
+
this.log("Old connection pool closed successfully.");
|
|
109
124
|
} catch (closeError) {
|
|
110
|
-
this.
|
|
125
|
+
this.warn(`Error closing old pool during reset: ${closeError.message}`);
|
|
111
126
|
} finally {
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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
|
|
154
|
-
} catch (
|
|
155
|
-
this.error(`[${nodeName}]
|
|
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)
|
|
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,373 @@ 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.
|
|
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
|
-
|
|
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
|
|
200
|
-
|
|
201
|
-
|
|
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
|
|
212
|
+
res.status(500).send(err.message);
|
|
205
213
|
} finally {
|
|
206
|
-
if (connection)
|
|
214
|
+
if (connection) {
|
|
215
|
+
try { await connection.close(); } catch(e){}
|
|
216
|
+
}
|
|
207
217
|
}
|
|
208
218
|
});
|
|
209
219
|
|
|
210
|
-
//
|
|
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
|
-
|
|
232
|
+
|
|
233
|
+
// Contextes d'erreur
|
|
219
234
|
this.currentQueryForErrorContext = null;
|
|
220
235
|
this.currentParamsForErrorContext = null;
|
|
221
236
|
|
|
222
|
-
|
|
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
|
-
|
|
226
|
-
|
|
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) {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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) &&
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
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
|
|
316
|
+
this.status({ fill: "blue", shape: "dot", text: "streaming..." });
|
|
317
|
+
|
|
293
318
|
let rowCount = 0;
|
|
294
|
-
let
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
if (
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
318
|
-
|
|
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
|
-
|
|
322
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
catch(e) { this.warn("
|
|
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
|
|
359
|
-
const
|
|
360
|
-
const
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
+
});
|
|
369
376
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
+
});
|
|
382
|
+
|
|
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
|
-
|
|
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
|
-
|
|
399
|
-
|
|
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
|
-
}
|
|
402
|
+
// Initialisation du compteur de retry sur le message s'il n'existe pas
|
|
403
|
+
if (!msg.hasOwnProperty('_odbc_retry_attempt')) {
|
|
404
|
+
msg._odbc_retry_attempt = 0;
|
|
409
405
|
}
|
|
406
|
+
|
|
407
|
+
// Nettoyage état précédent
|
|
408
|
+
if (this.retryTimer) { clearTimeout(this.retryTimer); this.retryTimer = null; }
|
|
410
409
|
this.isAwaitingRetry = false;
|
|
411
|
-
if(this.retryTimer) { clearTimeout(this.retryTimer); this.retryTimer = null; }
|
|
412
410
|
|
|
413
411
|
if (!this.poolNode) {
|
|
414
|
-
this.status({ fill: "red", shape: "ring", text: "
|
|
415
|
-
return done(
|
|
412
|
+
this.status({ fill: "red", shape: "ring", text: "Config missing" });
|
|
413
|
+
return done(new Error("ODBC Config node not configured"));
|
|
416
414
|
}
|
|
417
415
|
|
|
418
|
-
|
|
419
|
-
let
|
|
416
|
+
// 1. Préparation Données
|
|
417
|
+
let queryData;
|
|
420
418
|
try {
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
paramsToExecute = queryData.params;
|
|
424
|
-
} catch (inputValidationError) {
|
|
419
|
+
queryData = await this.getRenderedQueryAndParams(msg);
|
|
420
|
+
} catch (e) {
|
|
425
421
|
this.status({ fill: "red", shape: "ring", text: "Input Error" });
|
|
426
|
-
return done(
|
|
422
|
+
return done(e);
|
|
427
423
|
}
|
|
428
|
-
|
|
429
|
-
let
|
|
430
|
-
let
|
|
431
|
-
let
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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));
|
|
424
|
+
|
|
425
|
+
let connection = null;
|
|
426
|
+
let isFreshConnection = false;
|
|
427
|
+
let errorOccurred = null;
|
|
428
|
+
let shouldScheduleRetry = false;
|
|
429
|
+
|
|
430
|
+
// 2. Exécution avec bloc FINALLY pour anti-fuite
|
|
431
|
+
try {
|
|
432
|
+
// A. Obtention de la connexion
|
|
433
|
+
try {
|
|
434
|
+
this.status({ fill: "yellow", shape: "dot", text: "connecting..." });
|
|
435
|
+
connection = await this.poolNode.connect();
|
|
436
|
+
} catch (poolErr) {
|
|
437
|
+
// Si le pool échoue et que l'option RetryFresh est active
|
|
438
|
+
if (this.poolNode.config.retryFreshConnection) {
|
|
439
|
+
this.warn("Pool connection failed, attempting fresh connection...");
|
|
440
|
+
const freshCfg = this.poolNode.getFreshConnectionConfig();
|
|
441
|
+
connection = await odbcModule.connect(freshCfg);
|
|
442
|
+
isFreshConnection = true;
|
|
443
|
+
this.log("Fresh connection established.");
|
|
444
|
+
} else {
|
|
445
|
+
throw poolErr;
|
|
456
446
|
}
|
|
457
447
|
}
|
|
448
|
+
|
|
449
|
+
// B. Configuration du Timeout (si supporté par l'objet connexion)
|
|
450
|
+
const qTimeout = parseInt(this.poolNode.config.queryTimeoutSeconds, 10);
|
|
451
|
+
if (qTimeout > 0) {
|
|
452
|
+
connection.queryTimeout = qTimeout;
|
|
453
|
+
} else {
|
|
454
|
+
connection.queryTimeout = 0; // Défaut
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// C. Exécution (NON-BLOCKING UI)
|
|
458
|
+
this.status({ fill: "blue", shape: "dot", text: "querying..." });
|
|
458
459
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
try {
|
|
463
|
-
const freshConnectConfig = this.poolNode.getFreshConnectionConfig();
|
|
464
|
-
activeConnection = await odbcModule.connect(freshConnectConfig);
|
|
465
|
-
this.log("Fresh connection established.");
|
|
460
|
+
// FORCE UI REFRESH: On rend la main à l'Event Loop pour afficher "querying"
|
|
461
|
+
// avant que le driver ODBC (C++) ne bloque le thread lors de la préparation.
|
|
462
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
466
463
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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;
|
|
464
|
+
if (this.config.streaming) {
|
|
465
|
+
await this.executeStreamQuery(connection, queryData.query, queryData.params, msg, send);
|
|
466
|
+
} else {
|
|
467
|
+
const resultMsg = await this.executeQueryAndProcess(connection, queryData.query, queryData.params, msg);
|
|
468
|
+
send(resultMsg);
|
|
469
|
+
this.status({ fill: "green", shape: "dot", text: "success" });
|
|
470
|
+
}
|
|
485
471
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
472
|
+
// D. Si on a réussi avec une Fresh Connection, le pool est probablement cassé, on le reset
|
|
473
|
+
if (isFreshConnection) {
|
|
474
|
+
this.poolNode.resetPool();
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Succès : On peut retirer le compteur de l'objet msg (optionnel) ou le laisser
|
|
478
|
+
delete msg._odbc_retry_attempt;
|
|
479
|
+
done();
|
|
480
|
+
|
|
481
|
+
} catch (err) {
|
|
482
|
+
errorOccurred = err;
|
|
483
|
+
// Analyse de l'erreur pour voir si c'est une erreur de connexion (réseau)
|
|
484
|
+
const isConnectionError = err.message && (
|
|
485
|
+
err.message.match(/connection|network|timeout|communication|link|provider/i)
|
|
486
|
+
|| (err.odbcErrors && err.odbcErrors.length > 0)
|
|
487
|
+
);
|
|
488
|
+
|
|
489
|
+
// CORRECTION BUG BOUCLE INFINIE :
|
|
490
|
+
// On planifie un retry SEULEMENT si :
|
|
491
|
+
// 1. C'est une erreur de connexion OU l'option retryFresh est active
|
|
492
|
+
// 2. ET on n'a pas encore fait de retry pour ce message (compteur < 1)
|
|
493
|
+
if ((isConnectionError || this.poolNode.config.retryFreshConnection) && msg._odbc_retry_attempt < 1) {
|
|
494
|
+
shouldScheduleRetry = true;
|
|
495
|
+
}
|
|
496
|
+
} finally {
|
|
497
|
+
// E. NETTOYAGE CRITIQUE (Le "Fix" principal)
|
|
498
|
+
if (connection) {
|
|
499
|
+
try {
|
|
500
|
+
// Si c'était une connexion du pool, on réinitialise le timeout pour ne pas polluer le pool
|
|
501
|
+
if (!isFreshConnection) {
|
|
502
|
+
connection.queryTimeout = 0;
|
|
494
503
|
}
|
|
504
|
+
await connection.close();
|
|
505
|
+
} catch (closeErr) {
|
|
506
|
+
this.warn("Error ensuring connection closed: " + closeErr.message);
|
|
495
507
|
}
|
|
496
|
-
} else {
|
|
497
|
-
errorForUser = this.enhanceError(initialDbError, null, null, "Connection Error (no fresh retry)");
|
|
498
|
-
shouldProceedToTimedRetry = true;
|
|
499
508
|
}
|
|
500
509
|
}
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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...` });
|
|
510
|
+
|
|
511
|
+
// 3. Gestion post-exécution des erreurs et Retry
|
|
512
|
+
if (errorOccurred) {
|
|
513
|
+
const retryDelay = parseInt(this.poolNode.config.retryDelay, 10);
|
|
514
|
+
|
|
515
|
+
if (shouldScheduleRetry && retryDelay > 0) {
|
|
516
|
+
this.warn(`Query failed (${errorOccurred.message}). Retrying once in ${retryDelay}s...`);
|
|
517
|
+
this.status({ fill: "red", shape: "ring", text: `Retry in ${retryDelay}s` });
|
|
518
|
+
|
|
512
519
|
this.isAwaitingRetry = true;
|
|
513
520
|
this.retryTimer = setTimeout(() => {
|
|
514
|
-
this.isAwaitingRetry = false;
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
521
|
+
this.isAwaitingRetry = false;
|
|
522
|
+
|
|
523
|
+
// Incrémenter le compteur de tentatives AVANT de réémettre
|
|
524
|
+
msg._odbc_retry_attempt++;
|
|
525
|
+
|
|
526
|
+
this.log(`Retry timer expired. Re-processing message (Attempt ${msg._odbc_retry_attempt + 1}).`);
|
|
527
|
+
this.emit("input", msg, send, done);
|
|
528
|
+
}, retryDelay * 1000);
|
|
529
|
+
|
|
530
|
+
done();
|
|
531
|
+
} else {
|
|
532
|
+
this.status({ fill: "red", shape: "dot", text: "Error" });
|
|
533
|
+
done(this.enhanceError(errorOccurred));
|
|
522
534
|
}
|
|
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
535
|
}
|
|
530
536
|
});
|
|
531
|
-
|
|
537
|
+
|
|
532
538
|
this.on("close", (done) => {
|
|
533
539
|
if (this.retryTimer) {
|
|
534
|
-
clearTimeout(this.retryTimer);
|
|
535
|
-
this.
|
|
540
|
+
clearTimeout(this.retryTimer);
|
|
541
|
+
this.retryTimer = null;
|
|
536
542
|
}
|
|
537
543
|
this.status({});
|
|
538
544
|
done();
|
|
539
545
|
});
|
|
540
|
-
|
|
546
|
+
|
|
547
|
+
// Status initial
|
|
541
548
|
if (this.poolNode) {
|
|
542
549
|
this.status({ fill: "green", shape: "dot", text: "ready" });
|
|
543
550
|
} else {
|
|
544
|
-
this.status({ fill: "red", shape: "ring", text: "No config
|
|
545
|
-
this.warn("ODBC Config node not found or not deployed.");
|
|
551
|
+
this.status({ fill: "red", shape: "ring", text: "No config" });
|
|
546
552
|
}
|
|
547
553
|
}
|
|
548
|
-
|
|
554
|
+
|
|
549
555
|
RED.nodes.registerType("odbc", odbc);
|
|
550
556
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bkmj/node-red-contrib-odbcmj",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.1",
|
|
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",
|