├── .editorconfig ├── .gitattributes ├── .gitignore ├── .markdownlint.json ├── .markdownlintignore ├── LICENSE.md ├── README.md ├── eslint.config.mjs ├── examples ├── flow.json └── flow.png ├── icons └── postgresql.png ├── locales └── en-US │ ├── postgresql.html │ └── postgresql.json ├── node-postgres-named.js ├── package-lock.json ├── package.json ├── postgresql.html ├── postgresql.js └── test └── node-postgres-named.test.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://EditorConfig.org 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | 9 | [*.html] 10 | indent_style = tab 11 | 12 | [*.{js,mjs}] 13 | indent_style = tab 14 | 15 | [*.json] 16 | indent_style = tab 17 | 18 | [*.md] 19 | indent_size = 4 20 | indent_style = tab 21 | 22 | [*.yml] 23 | indent_size = 2 24 | indent_style = space 25 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | 3 | /.github/ export-ignore 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "default": true, 3 | "blanks-around-fences": false, 4 | "blanks-around-lists": false, 5 | "emphasis-style": false, 6 | "first-line-heading": false, 7 | "line-length": false, 8 | "no-hard-tabs": false, 9 | "no-inline-html": { 10 | "allowed_elements": ["br", "img", "kbd"] 11 | }, 12 | "no-multiple-blanks": { 13 | "maximum": 2 14 | }, 15 | "no-trailing-spaces": true, 16 | "ul-indent": false, 17 | "ul-style": { 18 | "style": "consistent" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.markdownlintignore: -------------------------------------------------------------------------------- 1 | .git/ 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # node-red-contrib-postgresql 2 | 3 | [node-red-contrib-postgresql](https://github.com/alexandrainst/node-red-contrib-postgresql) 4 | is a [**Node-RED**](https://nodered.org/) node to query a [**PostgreSQL**](https://www.postgresql.org/) 🐘 database. 5 | 6 | It supports *splitting* the resultset and *backpressure* (flow control), to allow working with large datasets. 7 | 8 | It supports *parameterized queries* and *multiple queries*. 9 | 10 | ## Outputs 11 | 12 | The response (rows) is provided in `msg.payload` as an array. 13 | 14 | An exception is if the *Split results* option is enabled and the *Number of rows per message* is set to **1**, 15 | then `msg.payload` is not an array but the single-row response. 16 | 17 | Additional information is provided as `msg.pgsql.rowCount` and `msg.pgsql.command`. 18 | See the [underlying documentation](https://node-postgres.com/apis/result) for details. 19 | 20 | In the case of multiple queries, then `msg.pgsql` is an array. 21 | 22 | ## Inputs 23 | 24 | ### SQL query template 25 | 26 | This node uses the [Mustache template system](https://github.com/janl/mustache.js) to generate queries based on the message: 27 | 28 | ```sql 29 | -- INTEGER id column 30 | SELECT * FROM table WHERE id = {{{ msg.id }}}; 31 | 32 | -- TEXT id column 33 | SELECT * FROM table WHERE id = '{{{ msg.id }}}'; 34 | ``` 35 | 36 | ### Dynamic SQL queries 37 | 38 | As an alternative to using the query template above, this node also accepts an SQL query via the `msg.query` parameter. 39 | 40 | ### Parameterized query (numeric) 41 | 42 | Parameters for parameterized queries can be passed as a parameter array `msg.params`: 43 | 44 | ```js 45 | // In a function, provide parameters for the parameterized query 46 | msg.params = [ msg.id ]; 47 | ``` 48 | 49 | ```sql 50 | -- In this node, use a parameterized query 51 | SELECT * FROM table WHERE id = $1; 52 | ``` 53 | 54 | ### Named parameterized query 55 | 56 | As an alternative to numeric parameters, 57 | named parameters for parameterized queries can be passed as a parameter object `msg.queryParameters`: 58 | 59 | ```js 60 | // In a function, provide parameters for the named parameterized query 61 | msg.queryParameters.id = msg.id; 62 | ``` 63 | 64 | ```sql 65 | -- In this node, use a named parameterized query 66 | SELECT * FROM table WHERE id = $id; 67 | ``` 68 | 69 | *Note*: named parameters are not natively supported by PostgreSQL, and this library just emulates them, 70 | so this is less robust than numeric parameters. 71 | 72 | ### Dynamic PostgreSQL connection parameters 73 | 74 | If the information about which database server to connect and how needs to be dynamic, 75 | it is possible to pass a [custom client configuration](https://node-postgres.com/apis/client) in the message: 76 | 77 | ```js 78 | msg.pgConfig = { 79 | user?: string, // default process.env.PGUSER || process.env.USER 80 | password?: string, //or function, default process.env.PGPASSWORD 81 | host?: string, // default process.env.PGHOST 82 | database?: string, // default process.env.PGDATABASE || process.env.USER 83 | port?: number, // default process.env.PGPORT 84 | connectionString?: string, // e.g. postgres://user:password@host:5432/database 85 | ssl?: any, // passed directly to node.TLSSocket, supports all tls.connect options 86 | types?: any, // custom type parsers 87 | statement_timeout?: number, // number of milliseconds before a statement in query will time out, default is no timeout 88 | query_timeout?: number, // number of milliseconds before a query call will timeout, default is no timeout 89 | application_name?: string, // The name of the application that created this Client instance 90 | connectionTimeoutMillis?: number, // number of milliseconds to wait for connection, default is no timeout 91 | idle_in_transaction_session_timeout?: number, // number of milliseconds before terminating any session with an open idle transaction, default is no timeout 92 | }; 93 | ``` 94 | 95 | However, this does not use a [connection pool](https://node-postgres.com/features/pooling), and is therefore less efficient. 96 | It is therefore recommended in most cases not to use `msg.pgConfig` at all and instead stick to the built-in configuration node. 97 | 98 | ## Installation 99 | 100 | ### Using the Node-RED Editor 101 | 102 | You can install [**node-red-contrib-postgresql**](https://flows.nodered.org/node/node-red-contrib-postgresql) directly using the editor: 103 | Select *Manage Palette* from the menu (top right), and then select the *Install* tab in the palette. 104 | 105 | ### Using npm 106 | 107 | You can alternatively install the [npm-packaged node](https://www.npmjs.com/package/node-red-contrib-postgresql): 108 | 109 | * Locally within your user data directory (by default, `$HOME/.node-red`): 110 | 111 | ```sh 112 | cd $HOME/.node-red 113 | npm i node-red-contrib-postgresql 114 | ``` 115 | 116 | * or globally alongside Node-RED: 117 | 118 | ```sh 119 | npm i -g node-red-contrib-postgresql 120 | ``` 121 | 122 | You will then need to restart Node-RED. 123 | 124 | ## Backpressure 125 | 126 | This node supports *backpressure* / *flow control*: 127 | when the *Split results* option is enabled, it waits for a *tick* before releasing the next batch of lines, 128 | to make sure the rest of your Node-RED flow is ready to process more data 129 | (instead of risking an out-of-memory condition), and also conveys this information upstream. 130 | 131 | So when the *Split results* option is enabled, this node will only output one message at first, 132 | and then awaits a message containing a truthy `msg.tick` before releasing the next message. 133 | 134 | To make this behaviour potentially automatic (avoiding manual wires), this node declares its ability by exposing a truthy `node.tickConsumer` 135 | for downstream nodes to detect this feature, and a truthy `node.tickProvider` for upstream nodes. 136 | Likewise, this node detects upstream nodes using the same back-pressure convention, and automatically sends ticks. 137 | 138 | ### Example of flow 139 | 140 | Example adding a new column in a table, then streaming (split) many lines from that table, batch-updating several lines at a time, 141 | then getting a sample consisting of a few lines: 142 | 143 | Example: [flow.json](examples/flow.json) 144 | 145 | ![Node-RED flow](examples/flow.png) 146 | 147 | The *debug* nodes illustrate some relevant information to look at. 148 | 149 | ## Sequences for split results 150 | 151 | When the *Split results* option is enabled (streaming), the messages contain some information following the 152 | conventions for [*messages sequences*](https://nodered.org/docs/user-guide/messages#message-sequences). 153 | 154 | ```js 155 | { 156 | payload: '...', 157 | parts: { 158 | id: 0.1234, // sequence ID, randomly generated (changes for every sequence) 159 | index: 5, // incremented for each message of the same sequence 160 | count: 6, // total number of messages; only available in the last message of a sequence 161 | parts: {}, // optional upstream parts information 162 | }, 163 | complete: true, // True only for the last message of a sequence 164 | } 165 | ``` 166 | 167 | ## Credits 168 | 169 | Major rewrite in July 2021 by [Alexandre Alapetite](https://alexandra.dk/alexandre.alapetite) ([Alexandra Institute](https://alexandra.dk)), 170 | of parents forks: 171 | [andreabat](https://github.com/andreabat/node-red-contrib-postgrestor) / 172 | [ymedlop](https://github.com/doing-things-with-node-red/node-red-contrib-postgrestor) / 173 | [HySoaKa](https://github.com/HySoaKa/node-red-contrib-postgrestor), 174 | with inspiration from [node-red-contrib-re-postgres](https://flows.nodered.org/node/node-red-contrib-re-postgres) 175 | ([code](https://github.com/elmagopy/node-red-contrib-re2-postgres)). 176 | 177 | This node builds uppon the [node-postgres](https://github.com/brianc/node-postgres) (`pg`) library. 178 | 179 | Contributions and collaboration welcome. 180 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import html from "eslint-plugin-html"; 3 | import js from "@eslint/js"; 4 | import neostandard, { resolveIgnoresFromGitignore } from 'neostandard'; 5 | import stylistic from '@stylistic/eslint-plugin'; 6 | 7 | export default [ 8 | { 9 | files: ["**/*.js"], 10 | languageOptions: { 11 | globals: { 12 | ...globals.browser, 13 | }, 14 | sourceType: "script", 15 | }, 16 | }, 17 | { 18 | files: ["**/*.html"], 19 | plugins: { html }, 20 | settings: { 21 | "html/indent": "tab", 22 | "html/report-bad-indent": "error", 23 | }, 24 | }, 25 | { 26 | ignores: [ 27 | ...resolveIgnoresFromGitignore(), 28 | ], 29 | }, 30 | js.configs.recommended, 31 | // stylistic.configs['recommended-flat'], 32 | ...neostandard(), 33 | { 34 | plugins: { 35 | "@stylistic": stylistic, 36 | }, 37 | rules: { 38 | "camelcase": "off", 39 | "eqeqeq": "off", 40 | "no-empty": ["error", { "allowEmptyCatch": true }], 41 | "no-unused-vars": ["error", { 42 | "args": "none", 43 | "caughtErrors": "none", 44 | }], 45 | "object-shorthand": ["warn", "consistent"], 46 | "yoda": "off", 47 | "@stylistic/indent": ["warn", "tab", { "SwitchCase": 1 }], 48 | "@stylistic/linebreak-style": ["error", "unix"], 49 | "@stylistic/max-len": ["warn", 165], 50 | "@stylistic/no-tabs": "off", 51 | "@stylistic/quotes": ["off", "single", { "avoidEscape": true }], 52 | "@stylistic/quote-props": ["warn", "consistent"], 53 | "@stylistic/semi": ["warn", "always"], 54 | "@stylistic/space-before-function-paren": ["warn", { 55 | "anonymous": "always", 56 | "asyncArrow": "always", 57 | "named": "never", 58 | }], 59 | }, 60 | }, 61 | ]; 62 | -------------------------------------------------------------------------------- /examples/flow.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "36d7a2e7.38e4de", 4 | "type": "inject", 5 | "z": "6bd3da1a.7e2b84", 6 | "name": "Start", 7 | "props": [ 8 | { 9 | "p": "payload" 10 | }, 11 | { 12 | "p": "topic", 13 | "vt": "str" 14 | } 15 | ], 16 | "repeat": "", 17 | "crontab": "", 18 | "once": false, 19 | "onceDelay": 0.1, 20 | "topic": "", 21 | "payloadType": "date", 22 | "x": 190, 23 | "y": 1620, 24 | "wires": [ 25 | [ 26 | "1b00f74dc3098005" 27 | ] 28 | ] 29 | }, 30 | { 31 | "id": "ee38d447.1c13a8", 32 | "type": "debug", 33 | "z": "6bd3da1a.7e2b84", 34 | "name": "Done", 35 | "active": true, 36 | "tosidebar": true, 37 | "console": false, 38 | "tostatus": true, 39 | "complete": "true", 40 | "targetType": "full", 41 | "statusVal": "complete", 42 | "statusType": "msg", 43 | "x": 1210, 44 | "y": 1620, 45 | "wires": [] 46 | }, 47 | { 48 | "id": "12f229bfef5ad2a5", 49 | "type": "function", 50 | "z": "6bd3da1a.7e2b84", 51 | "name": "Ready for next lines", 52 | "func": "return [\n msg.complete || msg.abort ? msg : null,\n { tick: true },\n];\n", 53 | "outputs": 2, 54 | "noerr": 0, 55 | "initialize": "", 56 | "finalize": "", 57 | "libs": [], 58 | "x": 980, 59 | "y": 1560, 60 | "wires": [ 61 | [ 62 | "ee38d447.1c13a8" 63 | ], 64 | [ 65 | "1b00f74dc3098005" 66 | ] 67 | ] 68 | }, 69 | { 70 | "id": "178252a8d3c54b16", 71 | "type": "function", 72 | "z": "6bd3da1a.7e2b84", 73 | "name": "", 74 | "func": "let payload = `(0, FALSE),`;\nif (msg.payload && msg.payload.length > 0) {\n for (const line of msg.payload) {\n const valid = 'TRUE'; // Call some kind of test\n payload += `(${line['id']}, ${valid}),`;\n }\n}\nmsg.payload = payload.slice(0, - 1);\nreturn msg;\n", 75 | "outputs": 1, 76 | "noerr": 0, 77 | "initialize": "", 78 | "finalize": "", 79 | "libs": [], 80 | "x": 560, 81 | "y": 1620, 82 | "wires": [ 83 | [ 84 | "6d2073ec4db26f2f" 85 | ] 86 | ] 87 | }, 88 | { 89 | "id": "4fd30ba36702842a", 90 | "type": "debug", 91 | "z": "6bd3da1a.7e2b84", 92 | "name": "Progress", 93 | "active": true, 94 | "tosidebar": true, 95 | "console": false, 96 | "tostatus": true, 97 | "complete": "true", 98 | "targetType": "full", 99 | "statusVal": "parts.index", 100 | "statusType": "msg", 101 | "x": 940, 102 | "y": 1620, 103 | "wires": [] 104 | }, 105 | { 106 | "id": "1b00f74dc3098005", 107 | "type": "postgresql", 108 | "z": "6bd3da1a.7e2b84", 109 | "name": "SELECT many", 110 | "query": "SELECT * FROM mytable\nORDER BY id ASC\nLIMIT 2000;\n", 111 | "postgreSQLConfig": "20ae1e52d1eef983", 112 | "split": true, 113 | "rowsPerMsg": "100", 114 | "outputs": 1, 115 | "x": 380, 116 | "y": 1620, 117 | "wires": [ 118 | [ 119 | "178252a8d3c54b16" 120 | ] 121 | ] 122 | }, 123 | { 124 | "id": "6d2073ec4db26f2f", 125 | "type": "postgresql", 126 | "z": "6bd3da1a.7e2b84", 127 | "name": "UPDATE many", 128 | "query": "UPDATE mytable AS c\nSET validity = v.validity\nFROM (VALUES\n\t{{{ msg.payload }}}\n) AS v (id, validity)\nWHERE v.id = c.id;\n", 129 | "postgreSQLConfig": "20ae1e52d1eef983", 130 | "split": false, 131 | "rowsPerMsg": "1", 132 | "outputs": 1, 133 | "x": 740, 134 | "y": 1620, 135 | "wires": [ 136 | [ 137 | "12f229bfef5ad2a5", 138 | "4fd30ba36702842a" 139 | ] 140 | ] 141 | }, 142 | { 143 | "id": "64a657de3954a4b5", 144 | "type": "debug", 145 | "z": "6bd3da1a.7e2b84", 146 | "name": "Results", 147 | "active": true, 148 | "tosidebar": true, 149 | "console": false, 150 | "tostatus": true, 151 | "complete": "true", 152 | "targetType": "full", 153 | "statusVal": "pgsql.rowCount", 154 | "statusType": "msg", 155 | "x": 560, 156 | "y": 1700, 157 | "wires": [] 158 | }, 159 | { 160 | "id": "adf069475c5e0ba3", 161 | "type": "postgresql", 162 | "z": "6bd3da1a.7e2b84", 163 | "name": "SELECT", 164 | "query": "SELECT * FROM mytable\nWHERE id < 100;\n", 165 | "postgreSQLConfig": "20ae1e52d1eef983", 166 | "split": false, 167 | "rowsPerMsg": "1", 168 | "outputs": 1, 169 | "x": 360, 170 | "y": 1700, 171 | "wires": [ 172 | [ 173 | "64a657de3954a4b5" 174 | ] 175 | ] 176 | }, 177 | { 178 | "id": "3134bfc0f12e13c3", 179 | "type": "inject", 180 | "z": "6bd3da1a.7e2b84", 181 | "name": "Start", 182 | "props": [ 183 | { 184 | "p": "payload" 185 | }, 186 | { 187 | "p": "topic", 188 | "vt": "str" 189 | } 190 | ], 191 | "repeat": "", 192 | "crontab": "", 193 | "once": false, 194 | "onceDelay": 0.1, 195 | "topic": "", 196 | "payloadType": "date", 197 | "x": 190, 198 | "y": 1700, 199 | "wires": [ 200 | [ 201 | "adf069475c5e0ba3" 202 | ] 203 | ] 204 | }, 205 | { 206 | "id": "d04c65ee97e3a273", 207 | "type": "inject", 208 | "z": "6bd3da1a.7e2b84", 209 | "name": "Prepare", 210 | "props": [ 211 | { 212 | "p": "payload" 213 | }, 214 | { 215 | "p": "topic", 216 | "vt": "str" 217 | } 218 | ], 219 | "repeat": "", 220 | "crontab": "", 221 | "once": false, 222 | "onceDelay": 0.1, 223 | "topic": "", 224 | "payloadType": "date", 225 | "x": 180, 226 | "y": 1520, 227 | "wires": [ 228 | [ 229 | "82b7c689d6682f72" 230 | ] 231 | ] 232 | }, 233 | { 234 | "id": "c5f0b4b2442e3137", 235 | "type": "debug", 236 | "z": "6bd3da1a.7e2b84", 237 | "name": "Done", 238 | "active": true, 239 | "tosidebar": true, 240 | "console": false, 241 | "tostatus": true, 242 | "complete": "true", 243 | "targetType": "full", 244 | "statusVal": "pgsql", 245 | "statusType": "msg", 246 | "x": 550, 247 | "y": 1520, 248 | "wires": [] 249 | }, 250 | { 251 | "id": "82b7c689d6682f72", 252 | "type": "postgresql", 253 | "z": "6bd3da1a.7e2b84", 254 | "name": "ADD COLUMN", 255 | "query": "ALTER TABLE mytable\n DROP COLUMN IF EXISTS validity;\n\nALTER TABLE mytable\n ADD COLUMN validity BOOLEAN;\n", 256 | "postgreSQLConfig": "20ae1e52d1eef983", 257 | "split": false, 258 | "rowsPerMsg": "10", 259 | "outputs": 1, 260 | "x": 380, 261 | "y": 1520, 262 | "wires": [ 263 | [ 264 | "c5f0b4b2442e3137" 265 | ] 266 | ] 267 | }, 268 | { 269 | "id": "20ae1e52d1eef983", 270 | "type": "postgreSQLConfig", 271 | "name": "myuser@timescale:5432/iot", 272 | "host": "timescale", 273 | "hostFieldType": "str", 274 | "port": "5432", 275 | "portFieldType": "num", 276 | "database": "iot", 277 | "databaseFieldType": "str", 278 | "ssl": "false", 279 | "sslFieldType": "bool", 280 | "max": "10", 281 | "maxFieldType": "num", 282 | "idle": "1000", 283 | "idleFieldType": "num", 284 | "connectionTimeout": "10000", 285 | "connectionTimeoutFieldType": "num", 286 | "user": "myuser", 287 | "userFieldType": "str", 288 | "password": "???", 289 | "passwordFieldType": "str" 290 | } 291 | ] 292 | -------------------------------------------------------------------------------- /examples/flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexandrainst/node-red-contrib-postgresql/9d3922e2a16b5bf7bb7f546153d14e783f29fbd4/examples/flow.png -------------------------------------------------------------------------------- /icons/postgresql.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexandrainst/node-red-contrib-postgresql/9d3922e2a16b5bf7bb7f546153d14e783f29fbd4/icons/postgresql.png -------------------------------------------------------------------------------- /locales/en-US/postgresql.html: -------------------------------------------------------------------------------- 1 | 129 | -------------------------------------------------------------------------------- /locales/en-US/postgresql.json: -------------------------------------------------------------------------------- 1 | { 2 | "postgresql": { 3 | "label": { 4 | "name": "Name", 5 | "host": "Host", 6 | "port": "Port", 7 | "database": "Database", 8 | "ssl": "SSL", 9 | "user": "User", 10 | "password": "Password", 11 | "applicationName": "Application name", 12 | "max": "Maximum size", 13 | "idle": "Idle Timeout", 14 | "connectionTimeout": "Connection Timeout", 15 | "server": "Server", 16 | "query": "Query", 17 | "split": "Split results in multiple messages", 18 | "rowsPerMsg": "Number of rows per message" 19 | }, 20 | "placeholder": { 21 | "name": "dbConnection", 22 | "host": "127.0.0.1", 23 | "port": "5432", 24 | "database": "dbExample", 25 | "user": "dbUser", 26 | "password": "dbPassword", 27 | "applicationName": "", 28 | "max": "10", 29 | "idle": "1000 (Milliseconds)", 30 | "connectionTimeout": "10000 (Milliseconds)" 31 | }, 32 | "tab": { 33 | "connection": "Connection", 34 | "security": "Security", 35 | "pool": "Pool" 36 | }, 37 | "title": { 38 | "applicationName": "The name of the application that created this Client instance.", 39 | "max": "Maximum number of physical database connections that this connection pool can contain." 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /node-postgres-named.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Rewritten subset of the library https://github.com/bwestergard/node-postgres-named/blob/master/main.js 5 | * https://github.com/ksteckert/node-postgres-named/tree/patch-1 6 | */ 7 | 8 | const tokenPattern = /(?<=\$)[a-zA-Z]([a-zA-Z0-9_]*)\b/g; 9 | 10 | function numericFromNamed(sql, parameters) { 11 | const fillableTokens = new Set(Object.keys(parameters)); 12 | const matchedTokens = new Set(sql.match(tokenPattern)); 13 | 14 | const unmatchedTokens = Array.from(matchedTokens).filter((token) => !fillableTokens.has(token)); 15 | if (unmatchedTokens.length > 0) { 16 | throw new Error('Missing Parameters: ' + unmatchedTokens.join(', ')); 17 | } 18 | 19 | const fillTokens = Array.from(matchedTokens).filter((token) => fillableTokens.has(token)).sort(); 20 | const fillValues = fillTokens.map((token) => parameters[token]); 21 | 22 | const interpolatedSql = fillTokens.reduce((partiallyInterpolated, token, index) => { 23 | const replaceAllPattern = new RegExp('\\$' + fillTokens[index] + '\\b', 'g'); 24 | return partiallyInterpolated.replace(replaceAllPattern, '$' + (index + 1)); // PostgreSQL parameters are 1-indexed 25 | }, sql); 26 | 27 | return { 28 | text: interpolatedSql, 29 | values: fillValues, 30 | }; 31 | } 32 | 33 | module.exports.convert = numericFromNamed; 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-red-contrib-postgresql", 3 | "version": "0.15.1", 4 | "description": "Node-RED node for PostgreSQL, supporting parameters, split, back-pressure", 5 | "author": { 6 | "name": "Alexandre Alapetite", 7 | "url": "https://github.com/Alkarex" 8 | }, 9 | "contributors": [ 10 | { 11 | "name": "Andrea Batazzi", 12 | "url": "https://github.com/andreabat" 13 | }, 14 | { 15 | "name": "Yeray Medina López", 16 | "url": "https://github.com/ymedlop" 17 | }, 18 | { 19 | "name": "Hamza Jalouaja", 20 | "url": "https://github.com/HySoaKa" 21 | } 22 | ], 23 | "license": "Apache-2.0", 24 | "repository": { 25 | "type": "git", 26 | "url": "https://github.com/alexandrainst/node-red-contrib-postgresql" 27 | }, 28 | "keywords": [ 29 | "backpressure", 30 | "node-red-contrib", 31 | "node-red", 32 | "nodered", 33 | "postgres", 34 | "postgresql", 35 | "timescale" 36 | ], 37 | "engines": { 38 | "node": ">=8" 39 | }, 40 | "node-red": { 41 | "version": ">=0.20.0", 42 | "nodes": { 43 | "postgresql": "postgresql.js" 44 | } 45 | }, 46 | "dependencies": { 47 | "mustache": "^4.2.0", 48 | "pg": "^8.14.1", 49 | "pg-cursor": "^2.13.1" 50 | }, 51 | "devDependencies": { 52 | "eslint": "^9.23.0", 53 | "@eslint/js": "^9.23.0", 54 | "eslint-plugin-html": "^8.1.2", 55 | "globals": "^16.0.0", 56 | "markdownlint-cli": "^0.45.0", 57 | "neostandard": "^0.12.1", 58 | "mocha": "^11.1.0" 59 | }, 60 | "scripts": { 61 | "eslint": "eslint .", 62 | "eslint_fix": "eslint --fix .", 63 | "markdownlint": "markdownlint '**/*.md'", 64 | "markdownlint_fix": "markdownlint --fix '**/*.md'", 65 | "fix": "npm run rtlcss && npm run eslint_fix && npm run markdownlint_fix", 66 | "pretest": "npm run eslint && npm run markdownlint", 67 | "mocha": "mocha", 68 | "test": "npm run mocha" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /postgresql.html: -------------------------------------------------------------------------------- 1 | 99 | 100 | 255 | 293 | 294 | 345 | -------------------------------------------------------------------------------- /postgresql.js: -------------------------------------------------------------------------------- 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 { Client, Pool } = require('pg'); 33 | const Cursor = require('pg-cursor'); 34 | const named = require('./node-postgres-named.js'); 35 | 36 | function getField(node, kind, value) { 37 | switch (kind) { 38 | case 'flow': // Legacy 39 | return node.context().flow.get(value); 40 | case 'global': 41 | return node.context().global.get(value); 42 | case 'num': 43 | return parseInt(value); 44 | case 'bool': 45 | case 'json': 46 | return JSON.parse(value); 47 | case 'env': 48 | return process.env[value]; 49 | default: 50 | return value; 51 | } 52 | } 53 | 54 | function PostgreSQLConfigNode(n) { 55 | const node = this; 56 | RED.nodes.createNode(node, n); 57 | node.name = n.name; 58 | node.host = n.host; 59 | node.hostFieldType = n.hostFieldType; 60 | node.port = n.port; 61 | node.portFieldType = n.portFieldType; 62 | node.database = n.database; 63 | node.databaseFieldType = n.databaseFieldType; 64 | node.ssl = n.ssl; 65 | node.sslFieldType = n.sslFieldType; 66 | node.applicationName = n.applicationName; 67 | node.applicationNameType = n.applicationNameType; 68 | node.max = n.max; 69 | node.maxFieldType = n.maxFieldType; 70 | node.idle = n.idle; 71 | node.idleFieldType = n.idleFieldType; 72 | node.user = n.user; 73 | node.userFieldType = n.userFieldType; 74 | node.password = n.password; 75 | node.passwordFieldType = n.passwordFieldType; 76 | node.connectionTimeout = n.connectionTimeout; 77 | node.connectionTimeoutFieldType = n.connectionTimeoutFieldType; 78 | 79 | this.pgPool = new Pool({ 80 | user: getField(node, n.userFieldType, n.user), 81 | password: getField(node, n.passwordFieldType, n.password), 82 | host: getField(node, n.hostFieldType, n.host), 83 | port: getField(node, n.portFieldType, n.port), 84 | database: getField(node, n.databaseFieldType, n.database), 85 | ssl: getField(node, n.sslFieldType, n.ssl), 86 | application_name: getField(node, n.applicationNameType, n.applicationName), 87 | max: getField(node, n.maxFieldType, n.max), 88 | idleTimeoutMillis: getField(node, n.idleFieldType, n.idle), 89 | connectionTimeoutMillis: getField(node, n.connectionTimeoutFieldType, n.connectionTimeout), 90 | }); 91 | this.pgPool.on('error', (err, _) => { 92 | node.error(err.message); 93 | }); 94 | } 95 | 96 | RED.nodes.registerType('postgreSQLConfig', PostgreSQLConfigNode); 97 | 98 | function PostgreSQLNode(config) { 99 | const node = this; 100 | RED.nodes.createNode(node, config); 101 | node.topic = config.topic; 102 | node.query = config.query; 103 | node.split = config.split; 104 | node.rowsPerMsg = config.rowsPerMsg; 105 | node.config = RED.nodes.getNode(config.postgreSQLConfig) || { 106 | pgPool: { 107 | totalCount: 0, 108 | }, 109 | }; 110 | 111 | // Declare the ability of this node to provide ticks upstream for back-pressure 112 | node.tickProvider = true; 113 | let tickUpstreamId; 114 | let tickUpstreamNode; 115 | 116 | // Declare the ability of this node to consume ticks from downstream for back-pressure 117 | node.tickConsumer = true; 118 | let downstreamReady = true; 119 | 120 | // For streaming from PostgreSQL 121 | let cursor; 122 | let getNextRows; 123 | 124 | // Do not update status faster than x ms 125 | const updateStatusPeriodMs = 1000; 126 | 127 | let nbQueue = 0; 128 | let hasError = false; 129 | let statusTimer = null; 130 | const updateStatus = (incQueue = 0, isError = false) => { 131 | nbQueue += incQueue; 132 | hasError |= isError; 133 | if (!statusTimer) { 134 | statusTimer = setTimeout(() => { 135 | let fill = 'grey'; 136 | if (hasError) { 137 | fill = 'red'; 138 | } else if (nbQueue <= 0) { 139 | fill = 'blue'; 140 | } else if (nbQueue <= node.config.pgPool.totalCount) { 141 | fill = 'green'; 142 | } else { 143 | fill = 'yellow'; 144 | } 145 | node.status({ 146 | fill: fill, 147 | shape: hasError || nbQueue > node.config.pgPool.totalCount ? 'ring' : 'dot', 148 | text: 'Queue: ' + nbQueue + (hasError ? ' Error!' : ''), 149 | }); 150 | hasError = false; 151 | statusTimer = null; 152 | }, updateStatusPeriodMs); 153 | } 154 | }; 155 | updateStatus(0, false); 156 | 157 | node.on('input', async (msg, send, done) => { 158 | // 'send' and 'done' require Node-RED 1.0+ 159 | send = send || function () { node.send.apply(node, arguments); }; 160 | 161 | if (tickUpstreamId === undefined) { 162 | // TODO: Test with older versions of Node-RED: 163 | tickUpstreamId = findInputNodeId(node, (n) => n && n.tickConsumer); 164 | tickUpstreamNode = tickUpstreamId ? RED.nodes.getNode(tickUpstreamId) : null; 165 | } 166 | 167 | if (msg.tick) { 168 | downstreamReady = true; 169 | if (getNextRows) { 170 | getNextRows(); 171 | } 172 | } else { 173 | const partsId = Math.random(); 174 | let query = msg.query ? msg.query : Mustache.render(node.query, { msg }); 175 | 176 | let client = null; 177 | 178 | const handleDone = async (isError = false) => { 179 | if (cursor) { 180 | cursor.close(); 181 | cursor = null; 182 | } 183 | if (client) { 184 | if (client.release) { 185 | client.release(isError); 186 | } else if (client.end) { 187 | await client.end(); 188 | } 189 | client = null; 190 | updateStatus(-1, isError); 191 | } else if (isError) { 192 | updateStatus(-1, isError); 193 | } 194 | getNextRows = null; 195 | }; 196 | 197 | const handleError = (err) => { 198 | const error = (err ? err.toString() : 'Unknown error!') + ' ' + query; 199 | handleDone(true); 200 | msg.payload = error; 201 | msg.parts = { 202 | id: partsId, 203 | abort: true, 204 | }; 205 | downstreamReady = false; 206 | if (err) { 207 | if (done) { 208 | // Node-RED 1.0+ 209 | done(err); 210 | } else { 211 | // Node-RED 0.x 212 | node.error(err, msg); 213 | } 214 | } 215 | }; 216 | 217 | handleDone(); 218 | updateStatus(+1); 219 | downstreamReady = true; 220 | 221 | try { 222 | if (msg.pgConfig) { 223 | client = new Client(msg.pgConfig); 224 | client.on('error', (err) => { 225 | node.error(err.message); 226 | }); 227 | await client.connect(); 228 | } else { 229 | client = await node.config.pgPool.connect(); 230 | } 231 | 232 | let params = []; 233 | if (msg.params && msg.params.length > 0) { 234 | params = msg.params; 235 | } else if (msg.queryParameters && (typeof msg.queryParameters === 'object')) { 236 | ({ text: query, values: params } = named.convert(query, msg.queryParameters)); 237 | } 238 | 239 | if (node.split) { 240 | let partsIndex = 0; 241 | delete msg.complete; 242 | 243 | cursor = client.query(new Cursor(query, params)); 244 | 245 | const cursorcallback = (err, rows, result) => { 246 | if (err) { 247 | handleError(err); 248 | } else { 249 | const complete = rows.length < node.rowsPerMsg; 250 | if (complete) { 251 | handleDone(false); 252 | } 253 | const msg2 = Object.assign({}, msg, { 254 | payload: (node.rowsPerMsg || 1) > 1 ? rows : rows[0], 255 | pgsql: { 256 | command: result.command, 257 | rowCount: result.rowCount, 258 | }, 259 | parts: { 260 | id: partsId, 261 | type: 'array', 262 | index: partsIndex, 263 | }, 264 | }); 265 | if (msg.parts) { 266 | msg2.parts.parts = msg.parts; 267 | } 268 | if (complete) { 269 | msg2.parts.count = partsIndex + 1; 270 | msg2.complete = true; 271 | } 272 | partsIndex++; 273 | downstreamReady = false; 274 | send(msg2); 275 | if (complete) { 276 | if (tickUpstreamNode) { 277 | tickUpstreamNode.receive({ tick: true }); 278 | } 279 | if (done) { 280 | done(); 281 | } 282 | } else { 283 | getNextRows(); 284 | } 285 | } 286 | }; 287 | 288 | getNextRows = () => { 289 | if (downstreamReady) { 290 | cursor.read(node.rowsPerMsg || 1, cursorcallback); 291 | } 292 | }; 293 | } else { 294 | getNextRows = async () => { 295 | try { 296 | const result = await client.query(query, params); 297 | if (result.length) { 298 | // Multiple queries 299 | msg.payload = []; 300 | msg.pgsql = []; 301 | for (const r of result) { 302 | msg.payload = msg.payload.concat(r.rows); 303 | msg.pgsql.push({ 304 | command: r.command, 305 | rowCount: r.rowCount, 306 | rows: r.rows, 307 | }); 308 | } 309 | } else { 310 | msg.payload = result.rows; 311 | msg.pgsql = { 312 | command: result.command, 313 | rowCount: result.rowCount, 314 | }; 315 | } 316 | 317 | handleDone(); 318 | downstreamReady = false; 319 | send(msg); 320 | if (tickUpstreamNode) { 321 | tickUpstreamNode.receive({ tick: true }); 322 | } 323 | if (done) { 324 | done(); 325 | } 326 | } catch (ex) { 327 | handleError(ex); 328 | } 329 | }; 330 | } 331 | 332 | getNextRows(); 333 | } catch (err) { 334 | handleError(err); 335 | } 336 | } 337 | }); 338 | } 339 | 340 | RED.nodes.registerType('postgresql', PostgreSQLNode); 341 | }; 342 | -------------------------------------------------------------------------------- /test/node-postgres-named.test.js: -------------------------------------------------------------------------------- 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 | }); 64 | --------------------------------------------------------------------------------