@flowfuse/nr-tables-nodes 0.1.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.
package/nodes/query.js ADDED
@@ -0,0 +1,320 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Return an incoming node ID if the node has any input wired to it, false otherwise.
5
+ * If filter callback is not null, then this function filters incoming nodes.
6
+ * @param {Object} toNode
7
+ * @param {function} filter
8
+ * @return {(number|boolean)}
9
+ */
10
+ function findInputNodeId(toNode, filter = null) {
11
+ if (toNode && toNode._flow && toNode._flow.global) {
12
+ const allNodes = toNode._flow.global.allNodes;
13
+ for (const fromNodeId of Object.keys(allNodes)) {
14
+ const fromNode = allNodes[fromNodeId];
15
+ if (fromNode && fromNode.wires) {
16
+ for (const wireId of Object.keys(fromNode.wires)) {
17
+ const wire = fromNode.wires[wireId];
18
+ for (const toNodeId of wire) {
19
+ if (toNode.id === toNodeId && (!filter || filter(fromNode))) {
20
+ return fromNode.id;
21
+ }
22
+ }
23
+ }
24
+ }
25
+ }
26
+ }
27
+ return false;
28
+ }
29
+
30
+ module.exports = function (RED) {
31
+ const Mustache = require('mustache');
32
+ const Cursor = require('pg-cursor');
33
+ const { Pool } = require('pg');
34
+ const named = require('../node-postgres-named.js');
35
+ const ffAPI = require('./utils/ff-api.js');
36
+
37
+ // are we running on FlowFuse?
38
+ const ffHost = RED.settings.flowforge?.forgeURL || null;
39
+ const ffTeamId = RED.settings.flowforge?.teamID || null;
40
+ const ffTablesToken = RED.settings.flowforge?.tables?.token || null;
41
+
42
+ function QueryNode(config) {
43
+ const node = this;
44
+ RED.nodes.createNode(node, config);
45
+ node.topic = config.topic;
46
+ node.query = config.query;
47
+ node.split = config.split;
48
+ node.rowsPerMsg = config.rowsPerMsg;
49
+
50
+ node.pgPool = {
51
+ totalCount: 0
52
+ };
53
+
54
+ // Declare the ability of this node to provide ticks upstream for back-pressure
55
+ node.tickProvider = true;
56
+ let tickUpstreamId;
57
+ let tickUpstreamNode;
58
+
59
+ // Declare the ability of this node to consume ticks from downstream for back-pressure
60
+ node.tickConsumer = true;
61
+ let downstreamReady = true;
62
+
63
+ // For streaming from PostgreSQL
64
+ let cursor;
65
+ let getNextRows;
66
+
67
+ // Do not update status faster than x ms
68
+ const updateStatusPeriodMs = 1000;
69
+
70
+ let nbQueue = 0;
71
+ let hasError = false;
72
+ let statusTimer = null;
73
+ const updateStatus = (incQueue = 0, isError = false) => {
74
+ nbQueue += incQueue;
75
+ hasError |= isError;
76
+ if (!statusTimer) {
77
+ statusTimer = setTimeout(() => {
78
+ let fill = 'grey';
79
+ if (hasError) {
80
+ fill = 'red';
81
+ } else if (nbQueue <= 0) {
82
+ fill = 'blue';
83
+ } else if (nbQueue <= node.pgPool.totalCount) {
84
+ fill = 'green';
85
+ } else {
86
+ fill = 'yellow';
87
+ }
88
+ node.status({
89
+ fill: fill,
90
+ shape: hasError || nbQueue > node.pgPool.totalCount ? 'ring' : 'dot',
91
+ text: 'Queue: ' + nbQueue + (hasError ? ' Error!' : ''),
92
+ });
93
+ hasError = false;
94
+ statusTimer = null;
95
+ }, updateStatusPeriodMs);
96
+ }
97
+ };
98
+ if (ffTablesToken) {
99
+ try {
100
+ ffAPI.getDatabases(ffHost, ffTeamId, ffTablesToken).then((databases) => {
101
+ if (databases.length > 0) {
102
+ const creds = databases[0].credentials;
103
+ node.pgPool = new Pool({
104
+ user: creds.user,
105
+ password: creds.password,
106
+ host: creds.host,
107
+ port: creds.port,
108
+ database: creds.database,
109
+ ssl: creds.ssl
110
+ });
111
+ updateStatus(0, false);
112
+ } else {
113
+ node.warn('No databases found in FlowFuse Tables for your team.');
114
+ node.status({
115
+ fill: 'red',
116
+ shape: 'ring',
117
+ text: 'No Databases',
118
+ });
119
+ }
120
+ });
121
+ } catch (err) {
122
+ console.error('Error getting FlowFuse Tables', err);
123
+ }
124
+ } else {
125
+ node.status({
126
+ fill: 'red',
127
+ shape: 'ring',
128
+ text: 'Not Available',
129
+ });
130
+ node.warn('FlowFuse Tables is not available to this Instance. You may need to upgrade your Instance, or upgrade your Team to a higher plan.');
131
+ }
132
+
133
+ node.on('input', async (msg, send, done) => {
134
+ // 'send' and 'done' require Node-RED 1.0+
135
+ send = send || function () { node.send.apply(node, arguments); };
136
+
137
+ if (tickUpstreamId === undefined) {
138
+ // TODO: Test with older versions of Node-RED:
139
+ tickUpstreamId = findInputNodeId(node, (n) => n && n.tickConsumer);
140
+ tickUpstreamNode = tickUpstreamId ? RED.nodes.getNode(tickUpstreamId) : null;
141
+ }
142
+
143
+ if (msg.tick) {
144
+ downstreamReady = true;
145
+ if (getNextRows) {
146
+ getNextRows();
147
+ }
148
+ } else {
149
+ const partsId = Math.random();
150
+ let query = msg.query ? msg.query : Mustache.render(node.query, { msg });
151
+
152
+ let client = null;
153
+
154
+ const handleDone = async (isError = false) => {
155
+ if (cursor) {
156
+ cursor.close();
157
+ cursor = null;
158
+ }
159
+ if (client) {
160
+ if (client.release) {
161
+ client.release(isError);
162
+ } else if (client.end) {
163
+ await client.end();
164
+ }
165
+ client = null;
166
+ updateStatus(-1, isError);
167
+ } else if (isError) {
168
+ updateStatus(-1, isError);
169
+ }
170
+ getNextRows = null;
171
+ };
172
+
173
+ const handleError = (err) => {
174
+ const error = (err ? err.toString() : 'Unknown error!') + ' ' + query;
175
+ handleDone(true);
176
+ msg.payload = error;
177
+ msg.parts = {
178
+ id: partsId,
179
+ abort: true,
180
+ };
181
+ downstreamReady = false;
182
+ if (err) {
183
+ if (done) {
184
+ // Node-RED 1.0+
185
+ done(err);
186
+ } else {
187
+ // Node-RED 0.x
188
+ node.error(err, msg);
189
+ }
190
+ }
191
+ };
192
+
193
+ handleDone();
194
+ updateStatus(+1);
195
+ downstreamReady = true;
196
+
197
+ if (node.pgPool && node.pgPool.connect) {
198
+ try {
199
+ // connect to the database
200
+ client = await node.pgPool.connect();
201
+
202
+ let params = [];
203
+ if (msg.params && msg.params.length > 0) {
204
+ params = msg.params;
205
+ } else if (msg.queryParameters && (typeof msg.queryParameters === 'object')) {
206
+ ({ text: query, values: params } = named.convert(query, msg.queryParameters));
207
+ }
208
+
209
+ if (node.split) {
210
+ let partsIndex = 0;
211
+ delete msg.complete;
212
+
213
+ cursor = client.query(new Cursor(query, params));
214
+
215
+ const cursorcallback = (err, rows, result) => {
216
+ if (err) {
217
+ handleError(err);
218
+ } else {
219
+ const complete = rows.length < node.rowsPerMsg;
220
+ if (complete) {
221
+ handleDone(false);
222
+ }
223
+ const msg2 = Object.assign({}, msg, {
224
+ payload: (node.rowsPerMsg || 1) > 1 ? rows : rows[0],
225
+ pgsql: {
226
+ command: result.command,
227
+ rowCount: result.rowCount,
228
+ },
229
+ parts: {
230
+ id: partsId,
231
+ type: 'array',
232
+ index: partsIndex,
233
+ },
234
+ });
235
+ if (msg.parts) {
236
+ msg2.parts.parts = msg.parts;
237
+ }
238
+ if (complete) {
239
+ msg2.parts.count = partsIndex + 1;
240
+ msg2.complete = true;
241
+ }
242
+ partsIndex++;
243
+ downstreamReady = false;
244
+ send(msg2);
245
+ if (complete) {
246
+ if (tickUpstreamNode) {
247
+ tickUpstreamNode.receive({ tick: true });
248
+ }
249
+ if (done) {
250
+ done();
251
+ }
252
+ } else {
253
+ getNextRows();
254
+ }
255
+ }
256
+ };
257
+
258
+ getNextRows = () => {
259
+ if (downstreamReady) {
260
+ cursor.read(node.rowsPerMsg || 1, cursorcallback);
261
+ }
262
+ };
263
+ } else {
264
+ getNextRows = async () => {
265
+ try {
266
+ const result = await client.query(query, params);
267
+ if (result.length) {
268
+ // Multiple queries
269
+ msg.payload = [];
270
+ msg.pgsql = [];
271
+ for (const r of result) {
272
+ msg.payload = msg.payload.concat(r.rows);
273
+ msg.pgsql.push({
274
+ command: r.command,
275
+ rowCount: r.rowCount,
276
+ rows: r.rows,
277
+ });
278
+ }
279
+ } else {
280
+ msg.payload = result.rows;
281
+ msg.pgsql = {
282
+ command: result.command,
283
+ rowCount: result.rowCount,
284
+ };
285
+ }
286
+
287
+ handleDone();
288
+ downstreamReady = false;
289
+ send(msg);
290
+ if (tickUpstreamNode) {
291
+ tickUpstreamNode.receive({ tick: true });
292
+ }
293
+ if (done) {
294
+ done();
295
+ }
296
+ } catch (ex) {
297
+ handleError(ex);
298
+ }
299
+ };
300
+ }
301
+
302
+ getNextRows();
303
+ } catch (err) {
304
+ handleError(err);
305
+ }
306
+ } else {
307
+ // User has not setup a database in FlowFuse yet
308
+ node.error('No database found. Please setup a database in FlowFuse Tables.');
309
+ }
310
+ }
311
+ });
312
+ }
313
+
314
+ if (ffHost) {
315
+ RED.nodes.registerType('tables-query', QueryNode);
316
+ } else {
317
+ // report as warning that the node is not configured
318
+ RED.log.warn('@flowfuse/tables-query: This node can only be used in Node-RED Instances running with FlowFuse');
319
+ }
320
+ };
@@ -0,0 +1,15 @@
1
+ const getDatabases = async (host, teamId, token) => {
2
+ const response = await fetch(`${host}/api/v1/teams/${teamId}/databases`,
3
+ {
4
+ headers: {
5
+ 'Authorization': `Bearer ${token}`
6
+ }
7
+ }
8
+ );
9
+ const data = await response.json();
10
+ return data;
11
+ };
12
+
13
+ module.exports = {
14
+ getDatabases: getDatabases
15
+ };
package/package.json ADDED
@@ -0,0 +1,79 @@
1
+ {
2
+ "name": "@flowfuse/nr-tables-nodes",
3
+ "version": "0.1.0",
4
+ "description": "Nodes for use with the FlowFuse Tables offering, allowing developers to write and run queries against databases inside FlowFuse Tables.",
5
+ "homepage": "https://github.com/FlowFuse/nr-tables-nodes#readme",
6
+ "author": {
7
+ "name": "FlowFuse Inc.",
8
+ "url": "https://github.com/flowfuse"
9
+ },
10
+ "contributors": [
11
+ {
12
+ "name": "Alexandre Alapetite",
13
+ "url": "https://github.com/Alkarex"
14
+ },
15
+ {
16
+ "name": "Andrea Batazzi",
17
+ "url": "https://github.com/andreabat"
18
+ },
19
+ {
20
+ "name": "Yeray Medina López",
21
+ "url": "https://github.com/ymedlop"
22
+ },
23
+ {
24
+ "name": "Hamza Jalouaja",
25
+ "url": "https://github.com/HySoaKa"
26
+ }
27
+ ],
28
+ "license": "Apache-2.0",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "https://github.com/FlowFuse/nr-tables-nodes"
32
+ },
33
+ "bugs": {
34
+ "url": "https://github.com/FlowFuse/nr-tables-nodes/issues"
35
+ },
36
+ "keywords": [
37
+ "backpressure",
38
+ "node-red-contrib",
39
+ "node-red",
40
+ "nodered",
41
+ "postgres",
42
+ "postgresql",
43
+ "timescale",
44
+ "flowfuse"
45
+ ],
46
+ "engines": {
47
+ "node": ">=16.x"
48
+ },
49
+ "node-red": {
50
+ "version": ">=3.1.0",
51
+ "nodes": {
52
+ "tables-query": "nodes/query.js"
53
+ }
54
+ },
55
+ "dependencies": {
56
+ "mustache": "^4.2.0",
57
+ "pg": "^8.14.1",
58
+ "pg-cursor": "^2.13.1"
59
+ },
60
+ "devDependencies": {
61
+ "eslint": "^9.23.0",
62
+ "@eslint/js": "^9.23.0",
63
+ "eslint-plugin-html": "^8.1.2",
64
+ "globals": "^16.0.0",
65
+ "markdownlint-cli": "^0.45.0",
66
+ "neostandard": "^0.12.1",
67
+ "mocha": "^11.1.0"
68
+ },
69
+ "scripts": {
70
+ "eslint": "eslint .",
71
+ "eslint_fix": "eslint --fix .",
72
+ "markdownlint": "markdownlint '**/*.md'",
73
+ "markdownlint_fix": "markdownlint --fix '**/*.md'",
74
+ "fix": "npm run rtlcss && npm run eslint_fix && npm run markdownlint_fix",
75
+ "pretest": "npm run eslint && npm run markdownlint",
76
+ "mocha": "mocha",
77
+ "test": "npm run mocha"
78
+ }
79
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Modified subset of https://github.com/bwestergard/node-postgres-named/blob/master/test/test.js
3
+ */
4
+
5
+ /* globals it: false, describe: false */
6
+ const assert = require('assert');
7
+ const named = require('../node-postgres-named.js');
8
+
9
+ describe('node-postgres-named', function () {
10
+ describe('Parameter translation', function () {
11
+ it('Basic Interpolation', function () {
12
+ const results = named.convert('$a $b $c', { a: 10, b: 20, c: 30 });
13
+ assert.deepEqual(results.values, [10, 20, 30]);
14
+ assert.equal(results.text, '$1 $2 $3');
15
+ });
16
+
17
+ it('Lexicographic order of parameter keys differs from order of appearance in SQL string', function () {
18
+ const results = named.convert('$z $y $x', { z: 10, y: 20, x: 30 });
19
+ assert.deepEqual(results.values, [30, 20, 10]);
20
+ assert.equal(results.text, '$3 $2 $1');
21
+ });
22
+
23
+ it('Missing Parameters', function () {
24
+ const flawedCall = function () {
25
+ named.convert('$z $y $x', { z: 10, y: 20 });
26
+ };
27
+ assert.throws(flawedCall, /^Error: Missing Parameters: x$/);
28
+ });
29
+
30
+ it('Extra Parameters', function () {
31
+ const okayCall = function () {
32
+ named.convert('$x $y $z', { w: 0, x: 10, y: 20, z: 30 });
33
+ };
34
+ assert.doesNotThrow(okayCall);
35
+ });
36
+
37
+ it('Handles word boundaries', function () {
38
+ const results = named.convert('$a $aa', { a: 5, aa: 23 });
39
+ assert.deepEqual(results.values, [5, 23]);
40
+ assert.equal(results.text, ['$1 $2']);
41
+ });
42
+ });
43
+
44
+ describe('Monkeypatched Dispatch', function () {
45
+ it('Call with no values results in unchanged call to original function', function () {
46
+ const sql = 'SELECT name FORM person WHERE name = $1 AND tenure <= $2 AND age <= $3';
47
+ const results = named.convert(sql, []);
48
+ assert.equal(results.text, sql);
49
+ assert.deepEqual(results.values, []);
50
+ });
51
+ it('Named parameter call dispatched correctly', function () {
52
+ const sql = 'SELECT name FORM person WHERE name = $name AND tenure <= $tenure AND age <= $age';
53
+ const values = {
54
+ name: 'Ursus Oestergardii',
55
+ tenure: 3,
56
+ age: 24,
57
+ };
58
+ const results = named.convert(sql, values);
59
+ assert.equal(results.text, 'SELECT name FORM person WHERE name = $2 AND tenure <= $3 AND age <= $1');
60
+ assert.deepEqual(results.values, [24, 'Ursus Oestergardii', 3]);
61
+ });
62
+ });
63
+ });