├── .gitignore ├── LICENSE ├── README.md ├── compute ├── deployment.js └── pg_compute.js ├── examples ├── basic_samples.js ├── manual_deployment_sample.js └── savings_interest_sample.js ├── package.json └── test └── pg_compute.test.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | package-lock.json 132 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 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 | [![Twitter URL](https://img.shields.io/twitter/url/https/twitter.com/denismagda.svg?style=social&label=Follow%20%40DenisMagda)](https://twitter.com/DenisMagda) 2 | 3 | # PgCompute: a Client-Side PostgreSQL Extension for Database Functions 4 | 5 | PgCompute is a client-side PostgreSQL extension that lets you execute JavaScript functions on the database directly from the application logic. 6 | 7 | This means you can create, optimize, and maintain database functions similarly to the rest of the application logic by using your preferred IDE and programming language. 8 | 9 | ## Quick Example 10 | 11 | Imagine you have the following function in your Node.js app: 12 | ```javascript 13 | function sum(a, b) { 14 | let c = a + b; 15 | return c; 16 | } 17 | ``` 18 | 19 | Now, suppose you want this function to run on PostgreSQL. Simply pass it to the `PgCompute` API like this: 20 | ```javascript 21 | const dbClient = // an instance of the node-postgres module's Client or Pool. 22 | 23 | // Create and configure a PgCompute instance 24 | let compute = new PgCompute(); 25 | await compute.init(dbClient); 26 | 27 | // Execute the `sum` function on the database 28 | let result = await compute.run(dbClient, sum, 1, 2); 29 | console.log(result); // prints `3` 30 | ``` 31 | 32 | By default, PgCompute operates in `DeploymentMode.AUTO` mode. This mode ensures a JavaScript function is automatically deployed to the database if it doesn't exist. Additionally, if you modify the function's implementation in your source code, PgCompute will handle the redeployment. 33 | 34 | **Note**: PgCompute relies on [plv8 extension](https://github.com/plv8/plv8) of PostgreSQL. This extension enables JavaScript support within the database and must be installed prior to using PgCompute. 35 | 36 | ## Getting Started 37 | 38 | Follow this guide to create a functional example from scratch. 39 | 40 | First, start a PostgreSQL instance with the plv8 extensions. Let's use Docker: 41 | 42 | 1. Start a Postgres instance with plv8: 43 | ```shell 44 | mkdir ~/postgresql_data/ 45 | 46 | docker run --name postgresql \ 47 | -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=password \ 48 | -p 5432:5432 \ 49 | -v ~/postgresql_data/:/var/lib/postgresql/data -d sibedge/postgres-plv8 50 | ``` 51 | 52 | 2. Connect to the database and enable the plv8 extension: 53 | ```shell 54 | psql -h 127.0.0.1 -U postgres 55 | 56 | create extension plv8; 57 | ``` 58 | 59 | Next, create a Node.js project: 60 | 61 | 1. Initialize the project: 62 | ```shell 63 | npm init 64 | ``` 65 | 2. Install the `pg` and `pg-compute` modules: 66 | ```shell 67 | npm install pg 68 | npm install pg-compute 69 | ``` 70 | 71 | Next, create the `index.js` file with the following logic: 72 | 73 | 1. Import node-postgres with PgCompute modules and create a database client configuration: 74 | ```javascript 75 | const { Client, ClientConfig } = require("pg"); 76 | 77 | const { PgCompute } = require("pg-compute"); 78 | 79 | const dbEndpoint = { 80 | host: "localhost", 81 | port: 5432, 82 | database: "postgres", 83 | user: "postgres", 84 | password: "password" 85 | } 86 | ``` 87 | 2. Add a function that needs to be executed on the Postgres side: 88 | ```javascript 89 | function sum(a, b) { 90 | let c = a + b; 91 | return c; 92 | } 93 | ``` 94 | 3. Add the following snippet to instantiate `Client` and `PgCompute` objects and to execute the `sum` function on Postgres: 95 | ```javascript 96 | (async () => { 97 | // Open a database connection 98 | const dbClient = new Client(dbEndpoint); 99 | await dbClient.connect(); 100 | 101 | 102 | // Create and configure a PgCompute instance 103 | let compute = new PgCompute(); 104 | await compute.init(dbClient); 105 | 106 | let result = await compute.run(dbClient, sum, 1, 2); 107 | console.log("Result:" + result); 108 | 109 | await dbClient.end(); 110 | })(); 111 | ``` 112 | 4. Run the sample: 113 | ```shell 114 | node index.js 115 | 116 | // Result:3 117 | ``` 118 | 119 | Finally, give a try to the auto-redeployment feature: 120 | 121 | 1. Change the `sum` implementation as follows: 122 | ```javascript 123 | function sum(a, b) { 124 | return (a + b) * 10; 125 | } 126 | ``` 127 | 2. Restart the app, the function will be redeployed and a new result will be printed out to the terminal: 128 | ```shell 129 | node index.js 130 | 131 | // Result:30 132 | ``` 133 | 134 | ## More Examples 135 | 136 | Explore the `examples` folder for more code samples: 137 | 138 | * `basic_samples.js` - comes with various small samples that show PgCompute capabilities. 139 | * `savings_interest_sample.js` - calculates the monthly compound interest rate on the database end for all savings accounts. This is one of real-world scenarious when you should prefer using database functions. 140 | * `manual_deployment_sample.js` - shows how to use the `DeploymentMode.MANUAL` mode. With that mode, the functions are pre-created manually on the database side but still can be invoked seamlessly from the application logic using PgCompute. 141 | 142 | To start any example: 143 | 144 | 1. Import all required packages: 145 | ```shell 146 | npm i 147 | ``` 148 | 2. Start an example: 149 | ```shell 150 | node {example_name.js} 151 | ``` 152 | 153 | **Note**, the examples include the PgCompute module from sources. If you'd like to run the examples as part of your own project, then import the module form the npm registry: 154 | ```javascript 155 | const { PgCompute, DeploymentMode } = require("pg-compute"); 156 | ``` 157 | 158 | ## Testing 159 | 160 | PgCompute uses Jest and Testcontainers for testing. 161 | 162 | So, if you decide to contribute to the project: 163 | 164 | * Make sure to put new tests under the `test` folder 165 | * Do a test run after introducing any changes: `npm test` 166 | 167 | [![Twitter URL](https://img.shields.io/twitter/url/https/twitter.com/denismagda.svg?style=social&label=Questions%26Feedback)](https://twitter.com/DenisMagda) 168 | -------------------------------------------------------------------------------- /compute/deployment.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2023 Denis Magda 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | const crypto = require('crypto') 18 | 19 | let DEBUG = false; 20 | 21 | if (!DEBUG) { 22 | console.debug = function () { } 23 | } 24 | 25 | /** 26 | * Deployment mode for database functions. 27 | */ 28 | class DeploymentMode { 29 | /** 30 | * Deploys a JavaScript function on the database automatically if it doesn't 31 | * already exist. The function is re-deployed if its implementation changes. 32 | */ 33 | static AUTO = "AUTO"; 34 | 35 | /** 36 | * Functions are pre-created manually on the database side. The PgCompute 37 | * API then allows these functions to be invoked seamlessly from the application logic. 38 | */ 39 | static MANUAL = "MANUAL"; 40 | } 41 | 42 | /** 43 | * The object implementing the deployment modes. 44 | */ 45 | class Deployment { 46 | 47 | static #DEPLOYMENT_TABLE_NAME = "pg_compute"; 48 | static #DEPLOYMENT_TABLE_COLUMNS = 49 | "(name text NOT NULL," + 50 | "args text," + 51 | "body_hashcode text," + 52 | "PRIMARY KEY(name, args));"; 53 | 54 | /** Deployment mode. */ 55 | #deploymentMode; 56 | 57 | /** Schema name. */ 58 | #schema; 59 | 60 | /** Full meta table name */ 61 | #deploymentTableFullName; 62 | 63 | /** */ 64 | #deploymentTable = {}; 65 | 66 | constructor(mode = DeploymentMode.AUTO, schema = "public") { 67 | this.#deploymentMode = mode; 68 | this.#schema = schema; 69 | } 70 | 71 | async init(connection) { 72 | console.debug("Initialized '" + this.#deploymentMode + "' deployment mode for schema '" + this.#schema + "'"); 73 | 74 | try { 75 | 76 | this.#schema = connection.escapeIdentifier(this.#schema); 77 | this.#deploymentTableFullName = this.#schema + "." + Deployment.#DEPLOYMENT_TABLE_NAME; 78 | 79 | await connection.query("CREATE SCHEMA IF NOT EXISTS " + this.#schema); 80 | 81 | await connection.query("CREATE TABLE IF NOT EXISTS " + 82 | this.#deploymentTableFullName + Deployment.#DEPLOYMENT_TABLE_COLUMNS); 83 | 84 | await this.#loadDeploymentTable(connection); 85 | } catch (error) { 86 | error.message = "Failed to initialize pg_compute. Reason:\n" + error.message; 87 | throw error; 88 | } 89 | } 90 | 91 | async checkExists(connection, funcName, funcArgs, funcBody) { 92 | if (this.#deploymentMode == DeploymentMode.MANUAL) { 93 | console.debug("Skipping the function validation for the 'MANUAL' deployment mode"); 94 | return; 95 | } 96 | 97 | let funcRecord = this.#deploymentTable[funcName]; 98 | 99 | if (funcArgs == undefined || funcArgs == null) 100 | funcArgs = ""; 101 | 102 | if (funcRecord && funcRecord.checked) { 103 | console.debug("Skipping function impl check. Function '" + funcName + "' has already been verified during this session."); 104 | return; 105 | } 106 | 107 | const bodyHashCode = crypto.createHash('md5').update(funcBody).digest("hex"); 108 | 109 | if (funcRecord == undefined) { 110 | await this.#createFunction(connection, funcName, funcArgs, funcBody, false); 111 | 112 | console.debug("Function '" + funcName + "' has been deployed"); 113 | 114 | } else if ((funcRecord['args'] != funcArgs && funcRecord['bodyHashCode'] != bodyHashCode) 115 | || funcRecord['bodyHashCode'] != bodyHashCode) { 116 | 117 | await this.#createFunction(connection, funcName, funcArgs, funcBody, true); 118 | 119 | console.debug("Function '" + funcName + "' has been redeployed"); 120 | } else { 121 | console.debug("Function '" + funcName + "' exists"); 122 | } 123 | 124 | // No need to compare the function logic changes next time as long as 125 | // an application instance needs to be restarted to use the new function version at runtime. 126 | // 127 | // This ticket will adress scenarious when a function is changed by other app instances: 128 | // https://github.com/dmagda/pg_compute_nodejs/issues/4 129 | this.#deploymentTable[funcName].checked = true; 130 | } 131 | 132 | async #loadDeploymentTable(connection) { 133 | const result = await connection.query({ 134 | text: "SELECT * FROM " + this.#deploymentTableFullName, 135 | name: "get_meta_" + this.#deploymentTableFullName 136 | }); 137 | 138 | if (result.rows.length > 0) { 139 | result.rows.forEach(row => { 140 | this.#deploymentTable[row['name']] = { "args": row['args'], "bodyHashCode": row["body_hashcode"] }; 141 | }); 142 | } 143 | 144 | console.debug("Loaded the meta table:\n %j", this.#deploymentTable); 145 | } 146 | 147 | async #createFunction(connection, funcName, funcArgs, funcBody, redeploy) { 148 | let stmt; 149 | 150 | if (funcArgs == undefined) { 151 | stmt = "create or replace function " + this.#schema + "." + funcName + "() returns JSON as $$" + 152 | funcBody + 153 | "$$ language plv8;" 154 | } else { 155 | stmt = "create or replace function " + this.#schema + "." + funcName + "(" + funcArgs + ") returns JSON as $$" + 156 | funcBody + 157 | "$$ language plv8;" 158 | } 159 | 160 | const bodyHashCode = crypto.createHash('md5').update(funcBody).digest("hex"); 161 | 162 | 163 | await connection.query("BEGIN;"); 164 | await connection.query(stmt); 165 | 166 | if (redeploy) { 167 | await connection.query( 168 | { 169 | name: "pg_compute_delete_" + this.#deploymentTableFullName, 170 | text: "DELETE FROM " + this.#deploymentTableFullName + " WHERE name = $1 and args = $2;", 171 | values: [funcName, this.#deploymentTable[funcName]["args"]] 172 | } 173 | ); 174 | 175 | } 176 | 177 | await connection.query( 178 | { 179 | name: "pg_compute_insert_" + this.#deploymentTableFullName, 180 | text: "INSERT INTO " + this.#deploymentTableFullName + " VALUES($1,$2,$3);", 181 | values: [funcName, funcArgs, bodyHashCode] 182 | } 183 | ); 184 | 185 | await connection.query("COMMIT;"); 186 | 187 | this.#deploymentTable[funcName] = { "args": funcArgs, "bodyHashCode": bodyHashCode }; 188 | 189 | console.debug("Meta table updated:\n %j", this.#deploymentTable); 190 | } 191 | 192 | 193 | } 194 | 195 | module.exports.Deployment = Deployment; 196 | module.exports.DeploymentMode = DeploymentMode; -------------------------------------------------------------------------------- /compute/pg_compute.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2023 Denis Magda 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | const { Deployment, DeploymentMode } = require("./deployment.js"); 17 | const { Client, Pool } = require("pg"); 18 | 19 | /** 20 | * PgCompute is a client-side PostgreSQL extension that lets you execute JavaScript functions on the database side directly from the application logic. 21 | * 22 | * PgCompute supports two deployment modes: 23 | * 24 | * - @type {DeploymentMode.AUTO}: This mode is the default and automatically deploys JavaScript functions 25 | * to the database, redeploying them whenever their implementation changes. 26 | * 27 | * - @type {DeploymentMode.MANUAL}: This mode is designed for users who prefer pre-deploying 28 | * all database functions manually but still wish to use the PgCompute API for function 29 | * execution. In this mode, PgCompute doesn't monitor changes in function implementations 30 | * and won't attempt any redeployments. 31 | * 32 | * **Note**: PgCompute relies on the plv8 extension of PostgreSQL. 33 | * This extension enables JavaScript support within the database and must be installed 34 | * prior to using PgCompute. 35 | */ 36 | class PgCompute { 37 | static #JS_TO_POSTGRES_TYPE_MAPPING = { 38 | "int": "int4", 39 | "long": "int8", 40 | "bigint": "bigint", 41 | "float": "float4", 42 | "boolean": "bool", 43 | "string": "text", 44 | "[object Boolean]": "bool", 45 | "[object Date]": "date", 46 | "[object String]": "text" 47 | }; 48 | 49 | static #MIN_INT = Math.pow(-2, 31) // -2147483648 50 | static #MAX_INT = Math.pow(2, 31) - 1 // 2147483647 51 | 52 | /** 53 | * @property {string} 54 | * Database schema to store the functions in. The `public` schema is used by default. 55 | */ 56 | #dbSchema; 57 | 58 | /** 59 | * @type {DeploymentMode.AUTO | DeploymentMode.MANUAL} 60 | * 61 | * Functions deployment mode. 62 | */ 63 | #deploymentMode; 64 | 65 | /** Deployment object for the current sesison. */ 66 | #deployment; 67 | 68 | /** 69 | * Create a new instance of PgCompute. An application can have multiple 70 | * PgCompute instances in use simultaneously. This is handy for microservice architectures 71 | * where a single database instance is shared across several microservices. Each microservice 72 | * might require its own PgCompute configuration. 73 | * 74 | * @param {DeploymentMode} deploymentMode - DeploymentMode.AUTO or DeploymentMode.MANUAL. 75 | * @param {string} dbSchema - The database schema name where functions will be created and maintained. 76 | */ 77 | constructor(deploymentMode = DeploymentMode.AUTO, dbSchema = "public") { 78 | this.#dbSchema = dbSchema; 79 | this.#deploymentMode = deploymentMode; 80 | } 81 | 82 | /** 83 | * Initialize the PgCompute instance. 84 | * 85 | * @param {Client | Pool} dbClient - A Client or Pool instance from the node-postgres module. After initialization, 86 | * PgCompute does not retain this instance internally. If a Pool instance is provided, the connection is returned 87 | * to the Pool after use. 88 | */ 89 | async init(dbClient) { 90 | if (dbClient == undefined) 91 | throw new Error("Undefined client connection. Make sure to pass a valid client connection"); 92 | 93 | let connection = await this.#getConnection(dbClient); 94 | 95 | this.#deployment = new Deployment(this.#deploymentMode, this.#dbSchema); 96 | 97 | try { 98 | await this.#deployment.init(connection); 99 | } finally { 100 | this.#releaseConnection(dbClient, connection); 101 | } 102 | } 103 | 104 | /** 105 | * Execute a function on the database. 106 | * 107 | * @param {Client | Pool} dbClient - A Client or Pool instance from the node-postgres module. After the function 108 | * execution, PgCompute does not retain this instance internally. If a Pool instance is provided, the connection 109 | * is returned to the Pool after use. 110 | * @param {Object} plv8Func - A function object intended for execution. 111 | * @param {...any} args - Optional arguments for the function. 112 | * 113 | * @returns {any} The result of the executed function, specific to the function's behavior. 114 | */ 115 | async run(dbClient, plv8Func, ...args) { 116 | let connection = await this.#getConnection(dbClient); 117 | 118 | try { 119 | const funcStr = plv8Func.toString(); 120 | 121 | const funcName = plv8Func.name; 122 | const funcArgsCnt = plv8Func.length; 123 | 124 | const funcBody = funcStr.substring( 125 | funcStr.indexOf("{") + 1, 126 | funcStr.lastIndexOf("}") 127 | ); 128 | 129 | if ((funcArgsCnt > 0 && args === undefined) || (args !== undefined && args.length != funcArgsCnt)) { 130 | throw new Error("Function arguments mismatch. Expected " + funcArgsCnt + ", received " + args.length); 131 | } 132 | 133 | let funcExecStmt; 134 | 135 | if (funcArgsCnt > 0) { 136 | const argNames = PgCompute.#parseFunctionArguments(funcStr); 137 | 138 | await this.#checkFunctionWithArgsExists(connection, funcName, funcBody, argNames, args); 139 | funcExecStmt = PgCompute.#prepareExecStmtWithArgs(this.#dbSchema, funcName, args); 140 | } else { 141 | await this.#checkFunctionExists(connection, funcName, funcBody); 142 | funcExecStmt = PgCompute.#prepareExecStmt(this.#dbSchema, funcName); 143 | } 144 | 145 | let result = await connection.query(funcExecStmt); 146 | 147 | return result.rows[0][funcName.toLowerCase()]; 148 | } finally { 149 | this.#releaseConnection(dbClient, connection); 150 | } 151 | } 152 | 153 | async #getConnection(dbClient) { 154 | let connection; 155 | 156 | if (dbClient instanceof Pool) 157 | connection = await dbClient.connect(); 158 | else 159 | connection = dbClient; 160 | 161 | return connection; 162 | } 163 | 164 | #releaseConnection(dbClient, connection) { 165 | if (dbClient instanceof Pool && connection != undefined) 166 | connection.release(); 167 | } 168 | 169 | async #checkFunctionExists(connection, funcName, funcBody) { 170 | await this.#deployment.checkExists(connection, funcName, null, funcBody); 171 | } 172 | 173 | async #checkFunctionWithArgsExists(connection, funcName, funcBody, argsNames, argsValues) { 174 | let argsStr = ""; 175 | let arg, pgType; 176 | 177 | for (let i = 0; i < argsValues.length; i++) { 178 | arg = argsValues[i]; 179 | pgType = PgCompute.#getPostgresType(arg); 180 | 181 | argsStr += argsNames[i] + " " + pgType + ", "; 182 | } 183 | 184 | argsStr = argsStr.slice(0, argsStr.length - 2).trim(); 185 | 186 | await this.#deployment.checkExists(connection, funcName, argsStr, funcBody); 187 | } 188 | 189 | static #prepareExecStmt(schema, funcName) { 190 | return "select " + schema + "." + funcName + "();" 191 | } 192 | 193 | static #prepareExecStmtWithArgs(schema, funcName, argsValues) { 194 | let argsStr = ""; 195 | let pgType; 196 | 197 | argsValues.forEach(arg => { 198 | pgType = PgCompute.#getPostgresType(arg); 199 | 200 | if (pgType == "text") 201 | argsStr += "'" + arg + "',"; 202 | else 203 | argsStr += arg + ","; 204 | }); 205 | 206 | argsStr = argsStr.slice(0, argsStr.length - 1); 207 | 208 | return "select " + schema + "." + funcName + "(" + argsStr + ");" 209 | } 210 | 211 | static #parseFunctionArguments(funcStr) { 212 | const funcArgs = funcStr.substring(funcStr.indexOf("(") + 1, funcStr.indexOf(")")).split(","); 213 | 214 | for (let i = 0; i < funcArgs.length; i++) 215 | funcArgs[i] = funcArgs[i].trim(); 216 | 217 | return funcArgs; 218 | } 219 | 220 | static #getPostgresType(arg) { 221 | let type = typeof (arg); 222 | let pgType = undefined; 223 | 224 | if (type == "number") { 225 | if (Number.isInteger(arg)) { 226 | pgType = PgCompute.#JS_TO_POSTGRES_TYPE_MAPPING[arg < PgCompute.#MIN_INT || arg > PgCompute.#MAX_INT ? "long" : "int"]; 227 | } else { 228 | pgType = PgCompute.#JS_TO_POSTGRES_TYPE_MAPPING["float"]; 229 | } 230 | } else if (type == "object") { 231 | type = Object.prototype.toString.call(arg); 232 | pgType = PgCompute.#JS_TO_POSTGRES_TYPE_MAPPING[type]; 233 | } else { 234 | pgType = PgCompute.#JS_TO_POSTGRES_TYPE_MAPPING[type]; 235 | } 236 | 237 | if (pgType == undefined) 238 | throw new Error("Unsupported argument type: " + type); 239 | 240 | return pgType; 241 | } 242 | } 243 | 244 | module.exports.PgCompute = PgCompute; 245 | module.exports.DeploymentMode = DeploymentMode; -------------------------------------------------------------------------------- /examples/basic_samples.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2023 Denis Magda 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | /** 18 | * Basic samples for the pg-compute module. 19 | * 20 | * These samples are hello-world-style JavaScript functions that can be automatically 21 | * deployed and executed on your Postgres instance. Feel free to modify the functions' 22 | * logic or argument list, the updated version of the function will be automatically redeployed for you. 23 | */ 24 | const { Client, ClientConfig } = require("pg"); 25 | 26 | const { PgCompute } = require("../compute/pg_compute"); 27 | 28 | /** 29 | * @type {ClientConfig} - database connectivity settings. 30 | * 31 | * Make sure your PostgreSQL instance has the plv8 extension installed and configured 32 | * with the `create extension plv8` command. 33 | */ 34 | const dbEndpoint = { 35 | host: "localhost", 36 | port: 5432, 37 | database: "postgres", 38 | user: "postgres", 39 | password: "password" 40 | } 41 | 42 | function helloWorld(name) { 43 | let msg = "Hello World From " + name; 44 | 45 | // need to wrap a String into JSON 46 | return { msg }; 47 | } 48 | 49 | function getPostgresVersion() { 50 | let json_result = plv8.execute('SELECT version()'); 51 | 52 | // returns JSON in the [{"version":"version_value"}] format 53 | return json_result; 54 | } 55 | 56 | function getDatabaseTime() { 57 | let json_result = plv8.execute('SELECT now() as time'); 58 | 59 | // returns JSON in the [{"time":"time_value"}] format 60 | return json_result; 61 | } 62 | 63 | function sumOfThree(a, b, c) { 64 | let sum = a + b + c; 65 | 66 | // do NOT need to wrap a Number into JSON 67 | return sum; 68 | } 69 | 70 | (async () => { 71 | // Open a database connection 72 | const dbClient = new Client(dbEndpoint); 73 | await dbClient.connect(); 74 | 75 | 76 | // Create and configure a PgCompute instance 77 | let compute = new PgCompute(); 78 | await compute.init(dbClient); 79 | 80 | // Executing JS functions on the database. 81 | // Feel free to modify their implementation, the function will be redeployed automatically. 82 | let result; 83 | 84 | result = await compute.run(dbClient, helloWorld, "Groot"); 85 | console.log("Sample 1:\n " + result.msg + "\n"); 86 | 87 | result = await compute.run(dbClient, getPostgresVersion); 88 | console.log("Sample 2:\n Postgres version: " + result[0].version + "\n"); 89 | 90 | result = await compute.run(dbClient, getDatabaseTime); 91 | console.log("Sample 3:\n Database time: " + JSON.stringify(result) + "\n"); 92 | 93 | result = await compute.run(dbClient, sumOfThree, 1, 2, 3); 94 | console.log("Sample 4:\n Sum of three: " + result + "\n"); 95 | 96 | await dbClient.end(); 97 | })(); -------------------------------------------------------------------------------- /examples/manual_deployment_sample.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2023 Denis Magda 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | /** 18 | * Demonstrates the use of the manual deployment mode with the PgCompute APIs. 19 | * 20 | * The @type {DeploymentMode.MANUAL} mode assumes that a database function (stored procedure) 21 | * has already been manually created on the database side. With this mode, the application 22 | * only needs to invoke the function by providing its name and the required arguments. 23 | * 24 | */ 25 | const { Client, ClientConfig } = require("pg"); 26 | const { sprintf } = require('sprintf-js') 27 | const { PgCompute, DeploymentMode } = require("../compute/pg_compute"); 28 | 29 | /** 30 | * @type {ClientConfig} - database connectivity settings. 31 | * 32 | * Make sure your PostgreSQL instance has the plv8 extension installed and configured 33 | * with the `create extension plv8` command. 34 | */ 35 | const dbEndpoint = { 36 | host: "localhost", 37 | port: 5432, 38 | database: "postgres", 39 | user: "postgres", 40 | password: "password" 41 | } 42 | 43 | async function initDatabase(name) { 44 | // Open a database connection 45 | const dbClient = new Client(dbEndpoint); 46 | await dbClient.connect(); 47 | 48 | // Pre-creating (manually deploying) the database function. 49 | await dbClient.query( 50 | "create or replace function helloWorldPreCreated (name text) returns JSON as $$" + 51 | " let msg = 'Hello World from ' + name; " + 52 | " return {msg};" + 53 | "$$ language plv8;" 54 | ); 55 | 56 | console.log("Pre-created the `helloWorldPreCreated` function on the database\n"); 57 | 58 | return dbClient; 59 | } 60 | 61 | /** 62 | * A database function interface with no implementation. 63 | * @type {PgCompute} can use such interfaces to execute database functions 64 | * that are pre-created (deployed) manually. 65 | * 66 | * @param {string} name - name to add to the hello world message. 67 | * @returns {JSON} a JSON object containing the `msg` field. 68 | */ 69 | function helloWorldPreCreated(name) { } 70 | 71 | (async () => { 72 | // Open a database connection 73 | const dbClient = await initDatabase(); 74 | 75 | // Creating a PgCompute instance that can execute manually pre-created database functions. 76 | let compute = new PgCompute(DeploymentMode.MANUAL); 77 | await compute.init(dbClient); 78 | 79 | // Execute the pre-created function. 80 | console.log("Executing the pre-created function:") 81 | const result = await compute.run(dbClient, helloWorldPreCreated, 'Mary'); 82 | console.log(" Result: " + result.msg); 83 | 84 | await dbClient.end(); 85 | })(); -------------------------------------------------------------------------------- /examples/savings_interest_sample.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2023 Denis Magda 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | /** 18 | * This sample demonstrates how to create a JavaScript function that calculates the monthly compound 19 | * interest rate on the database end for all savings accounts. 20 | * https://www.cuemath.com/monthly-compound-interest-formula/ 21 | * 22 | * This example embodies a real-world use case for functions executed on the database side. Typically, 23 | * financial institutions handle thousands, even millions, of customer accounts and must 24 | * compute interest rates either monthly or based on other terms. Such computations are data-intensive 25 | * and computationally demanding. The logic must traverse all the existing accounts, compute interest based on 26 | * various criteria, and then save the updated data back to the database. 27 | * 28 | * Implementing this logic on the application side would be less efficient and performant. Doing so would require 29 | * transferring all the account details over the network from the database to the application and then back again after 30 | * interest calculations are completed. 31 | */ 32 | const { Client } = require("pg"); 33 | const { sprintf } = require('sprintf-js') 34 | const { PgCompute } = require("../compute/pg_compute"); 35 | 36 | /** 37 | * @type {ClientConfig} - database connectivity settings. 38 | * 39 | * Make sure your PostgreSQL instance has the plv8 extension installed and configured 40 | * with the `create extension plv8` command. 41 | */ 42 | const dbEndpoint = { 43 | host: "localhost", 44 | port: 5432, 45 | database: "postgres", 46 | user: "postgres", 47 | password: "password" 48 | } 49 | 50 | async function initDatabase(name) { 51 | // Open a database connection 52 | const dbClient = new Client(dbEndpoint); 53 | await dbClient.connect(); 54 | 55 | // Create a sample database 56 | await dbClient.query( 57 | "CREATE TABLE IF NOT EXISTS savings_account (" + 58 | "id int," + 59 | "principal numeric(13, 2)," + 60 | "annual_rate numeric(5, 2))" 61 | ); 62 | 63 | await dbClient.query("TRUNCATE TABLE savings_account"); 64 | 65 | await dbClient.query( 66 | "INSERT INTO savings_account VALUES " + 67 | "(1, 5000, 4.5)," + 68 | "(2, 3000, 4.5)," + 69 | "(3, 11000, 3.8)," + 70 | "(4, 32000, 4.0)," + 71 | "(5, 4500, 3.5)," + 72 | "(6, 31000, 3.6)," + 73 | "(7, 50000, 4.2)," + 74 | "(8, 1000, 3.5)," + 75 | "(9, 15000, 4.5)," + 76 | "(10, 10000, 4.1)" 77 | ); 78 | 79 | return dbClient; 80 | } 81 | 82 | async function printAccounts(dbClient) { 83 | const result = await dbClient.query("SELECT * FROM savings_account"); 84 | 85 | for (let i = 0; i < result.rowCount; i++) 86 | console.log(result.rows[i]); 87 | } 88 | 89 | /** 90 | * This function is executed on the database to calculate and add compound interest to all savings accounts on a monthly basis. 91 | * It's important to implement additional checks to ensure that the interest is applied exactly once each month. 92 | * 93 | * @return {number} The total number of updated savings accounts. 94 | */ 95 | function addMontlyInterestRate() { 96 | const query = plv8.prepare('SELECT * FROM savings_account'); 97 | let accountsCnt = 0; 98 | 99 | try { 100 | const cursor = query.cursor(); 101 | 102 | try { 103 | let account, monthlyRate, interestForTheMonth; 104 | 105 | while (account = cursor.fetch()) { 106 | // Calculate monthly interest rate by divide the annual rate by 12. 107 | monthlyRate = (account.annual_rate / 100) / 12; 108 | 109 | // Calculate interest for the month 110 | interestForTheMonth = account.principal * monthlyRate; 111 | 112 | // Updating the principal by adding the calculated interest rate 113 | plv8.execute( 114 | 'UPDATE savings_account SET principal = $1 WHERE id = $2', 115 | [account.principal + interestForTheMonth, account.id]); 116 | 117 | accountsCnt++; 118 | } 119 | 120 | } finally { 121 | cursor.close(); 122 | } 123 | } finally { 124 | query.free(); 125 | } 126 | 127 | return accountsCnt; 128 | } 129 | 130 | (async () => { 131 | // Open a database connection 132 | const dbClient = await initDatabase(); 133 | 134 | console.log("Accounts before the interest calculation:"); 135 | await printAccounts(dbClient); 136 | 137 | // Create and configure a PgCompute instance 138 | let compute = new PgCompute(); 139 | await compute.init(dbClient); 140 | 141 | // Calculate and add the interest rate on the database end 142 | const result = await compute.run(dbClient, addMontlyInterestRate); 143 | console.log(sprintf('\nAdded monthly interest rate to %d accounts\n', result)); 144 | 145 | console.log("Accounts after the calculation:"); 146 | await printAccounts(dbClient); 147 | 148 | await dbClient.end(); 149 | })(); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pg-compute", 3 | "version": "1.0.4", 4 | "description": "A client-side PostgreSQL extension that lets you execute JavaScript functions on the database side directly from the application logic.", 5 | "main": "compute/pg_compute.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/dmagda/pg-compute-node" 9 | }, 10 | "author": "Denis Magda", 11 | "license": "Apache-2.0", 12 | "bugs": { 13 | "url": "https://github.com/dmagda/pg-compute-node/issues" 14 | }, 15 | "homepage": "https://github.com/dmagda/pg-compute-node#readme", 16 | "dependencies": { 17 | "pg": "^8.11.2" 18 | }, 19 | "devDependencies": { 20 | "@testcontainers/postgresql": "^10.2.1", 21 | "jest": "^29.6.3" 22 | }, 23 | "scripts": { 24 | "test": "jest" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /test/pg_compute.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2023 Denis Magda 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | const { Client, Pool } = require("pg"); 18 | const { PostgreSqlContainer } = require("@testcontainers/postgresql"); 19 | const { PgCompute, DeploymentMode } = require("../compute/pg_compute"); 20 | 21 | describe("PgCompute Tests", () => { 22 | jest.setTimeout(60000); 23 | 24 | let pgContainer; 25 | let pgClient; 26 | let pgPool; 27 | 28 | beforeAll(async () => { 29 | pgContainer = await new PostgreSqlContainer("sibedge/postgres-plv8").start(); 30 | 31 | pgClient = new Client({ connectionString: pgContainer.getConnectionUri() }); 32 | await pgClient.connect(); 33 | await pgClient.query("create extension plv8"); 34 | 35 | pgPool = new Pool({ connectionString: pgContainer.getConnectionUri() }); 36 | }); 37 | 38 | afterAll(async () => { 39 | await pgClient.end(); 40 | await pgPool.end(); 41 | await pgContainer.stop(); 42 | }); 43 | 44 | it("should run calculation logic", async () => { 45 | let pgCompute = new PgCompute(); 46 | await pgCompute.init(pgClient); 47 | 48 | let result = await pgCompute.run(pgClient, plv8TestSum); 49 | expect(result).toBe(5); 50 | 51 | result = await checkFunctionDeployed(pgClient, plv8TestSum); 52 | expect(result.rows[0].args).toMatch(""); 53 | }); 54 | 55 | it("should return pg version", async () => { 56 | let pgCompute = new PgCompute(); 57 | await pgCompute.init(pgClient); 58 | 59 | let result = await pgCompute.run(pgClient, plv8GetPostgresVersion); 60 | 61 | expect(result[0].version).toContain("PostgreSQL"); 62 | expect(result[0].plv8_version).toMatch(new RegExp('^([1-9]\d*|0)(\.(([1-9]\d*)|0)){2}$')); 63 | 64 | result = await checkFunctionDeployed(pgClient, plv8GetPostgresVersion); 65 | expect(result.rows[0].args).toMatch(""); 66 | }) 67 | 68 | it("should pass arg values", async () => { 69 | let pgCompute = new PgCompute(); 70 | await pgCompute.init(pgClient); 71 | 72 | let a = 1, b = 2, c = 3; 73 | let result = await pgCompute.run(pgClient, plv8SumOfThree, a, b, c); 74 | 75 | expect(result).toBe(a + b + c); 76 | 77 | result = await checkFunctionDeployed(pgClient, plv8SumOfThree); 78 | expect(result.rows[0].args).toContain("int"); 79 | }) 80 | 81 | it("should fail due to invalid arguments", async () => { 82 | let pgCompute = new PgCompute(); 83 | await pgCompute.init(pgClient); 84 | 85 | await expect( 86 | pgCompute.run(pgClient, plv8SumOfThree, 1, 2, 'test')).rejects.toThrow("invalid input syntax for type integer"); 87 | 88 | 89 | await expect( 90 | pgCompute.run(pgClient, plv8SumOfThree, 1, 2)).rejects.toThrow("Function arguments mismatch"); 91 | 92 | await expect( 93 | pgCompute.run(pgClient, plv8SumOfThree, 1, true, 3)). 94 | rejects.toThrow("does not exist"); 95 | }) 96 | 97 | it("should create custom schema", async () => { 98 | let schema = "tracker"; 99 | pgComputeCustomSchema = new PgCompute(DeploymentMode.AUTO, schema); 100 | 101 | await pgComputeCustomSchema.init(pgClient); 102 | 103 | let result = await pgClient.query({ 104 | text: "SELECT schema_name FROM information_schema.schemata WHERE schema_name = $1", 105 | values: [schema] 106 | }); 107 | 108 | expect(result.rows.length).toBe(1); 109 | expect(result.rows[0].schema_name).toMatch(schema); 110 | 111 | result = await pgComputeCustomSchema.run(pgClient, trackerSchemaFunction); 112 | 113 | expect(result[0].now).toBeDefined(); 114 | 115 | await checkFunctionDeployed(pgClient, trackerSchemaFunction, schema); 116 | 117 | //check the function doesn't exist in the public schema 118 | result = await pgClient.query({ 119 | text: "select * from pg_compute where name = $1", 120 | values: [trackerSchemaFunction.name] 121 | }); 122 | expect(result.rows.length).toBe(0); 123 | }) 124 | 125 | it("should not redeploy function", async () => { 126 | let pgCompute = new PgCompute(); 127 | await pgCompute.init(pgClient); 128 | 129 | 130 | let result = await pgCompute.run(pgClient, plv8TestSum); 131 | expect(result).toBe(5); 132 | 133 | result = await checkFunctionDeployed(pgClient, plv8TestSum); 134 | const oldHashCode = result.rows[0].body_hashcode; 135 | 136 | // Should not redeploy the function because the implementation is 137 | // re-checked only when your recreate the compute object or restart the app. 138 | result = await deployTestSumV2(pgClient, pgCompute); 139 | expect(result).toBe(5); 140 | 141 | result = await checkFunctionDeployed(pgClient, plv8TestSum); 142 | expect(result.rows[0].body_hashcode).toMatch(oldHashCode); 143 | }) 144 | 145 | it("should redeploy function", async () => { 146 | let pgCompute = new PgCompute(); 147 | await pgCompute.init(pgClient); 148 | 149 | 150 | let result = await pgCompute.run(pgClient, plv8TestSum); 151 | expect(result).toBe(5); 152 | 153 | result = await checkFunctionDeployed(pgClient, plv8TestSum); 154 | const oldHashCode = result.rows[0].body_hashcode; 155 | 156 | // Presently, the function implementation is re-checked 157 | // only when you recreate the compute object or restart the app. 158 | pgCompute = new PgCompute(); 159 | await pgCompute.init(pgClient); 160 | 161 | result = await deployTestSumV2(pgClient, pgCompute); 162 | expect(result).toBe(15); 163 | 164 | result = await checkFunctionDeployed(pgClient, plv8TestSum); 165 | expect(result.rows[0].body_hashcode).not.toMatch(oldHashCode); 166 | }) 167 | 168 | it("should fail because function is not deployed manually", async () => { 169 | let pgCompute = new PgCompute(DeploymentMode.MANUAL); 170 | await pgCompute.init(pgClient); 171 | 172 | await expect(pgCompute.run(pgClient, sampleManualDeployFunction, 5)). 173 | rejects.toThrow("function public.samplemanualdeployfunction(integer) does not exist"); 174 | }) 175 | 176 | it("should execute manually deployed function", async () => { 177 | let pgCompute = new PgCompute(DeploymentMode.MANUAL); 178 | await pgCompute.init(pgClient); 179 | 180 | const stmt = "create function sampleManualDeployFunction(a int) returns JSON as $$" + 181 | "let b = a + 5; return b;" + 182 | "$$ language plv8;" 183 | 184 | await pgClient.query(stmt); 185 | 186 | let result = await pgCompute.run(pgClient, sampleManualDeployFunction, 5); 187 | expect(result).toBe(10); 188 | }) 189 | 190 | it("should support a connection pool", async () => { 191 | let pgCompute = new PgCompute(); 192 | await pgCompute.init(pgPool); 193 | 194 | let result = await pgCompute.run(pgPool, plv8TestSum); 195 | expect(result).toBe(5); 196 | }) 197 | }); 198 | 199 | async function checkFunctionDeployed(pgClient, func, schema) { 200 | let funcName = func.name; 201 | let result; 202 | 203 | if (schema != undefined) { 204 | result = await pgClient.query({ 205 | text: "select * from " + schema + ".pg_compute where name = $1", 206 | values: [funcName] 207 | }); 208 | } else { 209 | result = await pgClient.query({ 210 | text: "select * from pg_compute where name = $1", 211 | values: [funcName] 212 | }); 213 | } 214 | 215 | expect(result.rows.length).toBe(1); 216 | expect(result.rows[0].name).toMatch(funcName); 217 | expect(result.rows[0].body_hashcode.length).toBeGreaterThan(5); 218 | 219 | return result; 220 | } 221 | 222 | function plv8TestSum() { 223 | let a = 2; 224 | let b = 3; 225 | 226 | return a + b; 227 | } 228 | 229 | function plv8GetPostgresVersion() { 230 | let json_result = plv8.execute('SELECT version(), plv8_version()'); 231 | return json_result; 232 | } 233 | 234 | function plv8SumOfThree(a, b, c) { 235 | return a + b + c; 236 | } 237 | 238 | function trackerSchemaFunction() { 239 | return plv8.execute('select now()'); 240 | } 241 | 242 | async function deployTestSumV2(pgClient, pgCompute) { 243 | function plv8TestSum() { 244 | let a = 2; 245 | let b = 3; 246 | 247 | return (a + b) + 10; 248 | } 249 | 250 | console.log(plv8TestSum.toString()); 251 | 252 | return await pgCompute.run(pgClient, plv8TestSum); 253 | } 254 | 255 | function sampleManualDeployFunction(a) { 256 | 257 | } --------------------------------------------------------------------------------