├── .eslintignore ├── .eslintrc.json ├── .github └── workflows │ ├── integration-tests.yml │ └── unit-tests.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── docker-compose.yml ├── index.d.ts ├── index.js ├── package-lock.json ├── package.json ├── scripts ├── run-all-tests.sh └── run-integration-tests.sh └── test ├── README.md ├── integration ├── README.md ├── change-user.spec.js ├── connection-management.spec.js ├── connection-string.spec.js ├── connection.spec.js ├── features.spec.js ├── helpers │ └── setup.js ├── query-retries.spec.js └── return-final-sql-query.spec.js └── unit ├── README.md ├── connection-config.spec.js └── helpers └── mocks.js /.eslintignore: -------------------------------------------------------------------------------- 1 | coverage 2 | node_modules 3 | test 4 | scripts 5 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es6": true, 4 | "node": true 5 | }, 6 | "extends": "eslint:recommended", 7 | "parserOptions": { 8 | "ecmaVersion": 8, 9 | "sourceType": "module" 10 | }, 11 | "rules": { 12 | "indent": [ 13 | "error", 14 | 2 15 | ], 16 | "linebreak-style": [ 17 | "error", 18 | "unix" 19 | ], 20 | "quotes": [ 21 | "error", 22 | "single" 23 | ], 24 | "semi": [ 25 | "error", 26 | "never" 27 | ], 28 | "indent": [ 29 | "error", 30 | 2, 31 | { "SwitchCase": 1 } 32 | ] 33 | }, 34 | "globals": { 35 | "expect": true, 36 | "it": true 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /.github/workflows/integration-tests.yml: -------------------------------------------------------------------------------- 1 | name: "Integration Tests" 2 | on: 3 | pull_request: 4 | types: [opened, reopened, synchronize] 5 | push: 6 | branches: 7 | - master 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref_name }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | integration-test: 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | node: [18, 20, 22] 19 | mysql: ["5", "lts"] 20 | name: Node ${{ matrix.node }} with MySQL ${{ matrix.mysql }} Integration Tests 21 | timeout-minutes: 5 22 | 23 | services: 24 | mysql: 25 | image: mysql:${{ matrix.mysql }} 26 | env: 27 | MYSQL_ROOT_PASSWORD: password 28 | MYSQL_DATABASE: serverless_mysql_test 29 | MYSQL_ROOT_HOST: "%" 30 | ports: 31 | - 3306:3306 32 | options: >- 33 | --health-cmd="mysqladmin ping" 34 | --health-interval=10s 35 | --health-timeout=5s 36 | --health-retries=3 37 | 38 | steps: 39 | - name: "Checkout latest code" 40 | uses: actions/checkout@v3 41 | with: 42 | ref: ${{ github.event.pull_request.head.sha }} 43 | 44 | - name: Set up node 45 | uses: actions/setup-node@v3 46 | with: 47 | node-version: ${{ matrix.node }} 48 | 49 | - name: Install dependencies 50 | run: npm ci 51 | 52 | - name: Run unit tests 53 | run: npm run test:unit 54 | 55 | - name: Run integration tests 56 | run: npm run test:integration 57 | env: 58 | MYSQL_HOST: 127.0.0.1 59 | MYSQL_PORT: 3306 60 | MYSQL_DATABASE: serverless_mysql_test 61 | MYSQL_USER: root 62 | MYSQL_PASSWORD: password 63 | 64 | - name: Run all tests with coverage 65 | if: matrix.node == 22 && matrix.mysql == 'lts' 66 | run: npm run test-cov 67 | env: 68 | MYSQL_HOST: 127.0.0.1 69 | MYSQL_PORT: 3306 70 | MYSQL_DATABASE: serverless_mysql_test 71 | MYSQL_USER: root 72 | MYSQL_PASSWORD: password 73 | -------------------------------------------------------------------------------- /.github/workflows/unit-tests.yml: -------------------------------------------------------------------------------- 1 | name: "Unit Tests" 2 | on: 3 | pull_request: 4 | types: [opened, reopened, synchronize] 5 | push: 6 | branches: 7 | - master 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref_name }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | test: 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | node: [18, 20, 22] 19 | name: Node ${{ matrix.node }} 20 | steps: 21 | - name: "Checkout latest code" 22 | uses: actions/checkout@v3 23 | with: 24 | ref: ${{ github.event.pull_request.head.sha }} 25 | - name: Set up node 26 | uses: actions/setup-node@v3 27 | with: 28 | node-version: ${{ matrix.node }} 29 | - name: Install npm 30 | run: | 31 | npm install -g npm@$NPM_VERSION && 32 | npm --version && 33 | npm list -g --depth 0 34 | - name: Install dependencies 35 | run: npm ci 36 | - name: Run unit tests 37 | run: npm run test:unit 38 | 39 | lint: 40 | name: "ESLint" 41 | runs-on: ubuntu-latest 42 | steps: 43 | - name: Checkout latest code 44 | uses: actions/checkout@v3 45 | with: 46 | ref: ${{ github.event.pull_request.head.sha }} 47 | - name: Set up node 48 | uses: actions/setup-node@v3 49 | with: 50 | node-version: "22" 51 | - name: Install dependencies 52 | run: npm ci 53 | - name: Run ESLint 54 | run: npm run lint 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependency directories 2 | node_modules 3 | 4 | # Optional npm cache directory 5 | .npm 6 | 7 | # Local REDIS test data 8 | dump.rdb 9 | 10 | # Coverage reports 11 | .nyc_output 12 | coverage 13 | 14 | # Serverless 15 | .serverless 16 | 17 | # IDE settings 18 | .idea 19 | .vscode 20 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to serverless-mysql 2 | 3 | Thank you for considering contributing to serverless-mysql! This document provides guidelines and instructions for contributing to the project. 4 | 5 | ## Code of Conduct 6 | 7 | Please be respectful and considerate of others when contributing to this project. We aim to foster an inclusive and welcoming community. 8 | 9 | ## How to Contribute 10 | 11 | 1. Fork the repository 12 | 2. Create a feature branch (`git checkout -b feature/amazing-feature`) 13 | 3. Commit your changes (`git commit -m 'Add some amazing feature'`) 14 | 4. Push to the branch (`git push origin feature/amazing-feature`) 15 | 5. Open a Pull Request 16 | 17 | ## Development Setup 18 | 19 | 1. Clone the repository 20 | 2. Install dependencies with `npm install` 21 | 3. Set up MySQL for testing: 22 | - Option 1: Install MySQL locally 23 | - Option 2: Use Docker (recommended): 24 | ```bash 25 | docker compose up -d 26 | ``` 27 | 4. Run unit tests with `npm run test:unit` 28 | 5. Run integration tests with `npm run test:integration` (requires MySQL) 29 | 6. Or run all tests with Docker handling MySQL automatically: 30 | ```bash 31 | npm run test:docker 32 | ``` 33 | 34 | ## Running Tests 35 | 36 | The project includes both unit tests and integration tests to ensure everything works correctly. 37 | 38 | ### Unit Tests 39 | 40 | Unit tests don't require any external dependencies and can be run with: 41 | 42 | ```bash 43 | npm run test:unit 44 | ``` 45 | 46 | ### Integration Tests 47 | 48 | Integration tests require a MySQL database. You can use the included Docker Compose file to start a MySQL instance: 49 | 50 | ```bash 51 | # Start MySQL container 52 | docker compose up -d 53 | 54 | # Run integration tests 55 | npm run test:integration 56 | 57 | # Stop MySQL container when done 58 | docker compose down 59 | ``` 60 | 61 | For convenience, you can use the provided script to run the integration tests with Docker: 62 | 63 | ```bash 64 | # This will start MySQL, run the tests, and stop MySQL automatically 65 | npm run test:integration:docker 66 | ``` 67 | 68 | The script includes a watchdog process that will automatically terminate tests if they run for too long (60 seconds), which helps prevent hanging test processes. 69 | 70 | You can also configure the MySQL connection using environment variables: 71 | 72 | ```bash 73 | MYSQL_HOST=127.0.0.1 MYSQL_PORT=3306 MYSQL_DATABASE=serverless_mysql_test MYSQL_USER=root MYSQL_PASSWORD=password npm run test:integration 74 | ``` 75 | 76 | ### Running All Tests 77 | 78 | To run both unit and integration tests: 79 | 80 | ```bash 81 | npm test 82 | ``` 83 | 84 | **Important:** The `npm test` command requires a running MySQL instance, as it runs both unit and integration tests. If you don't have MySQL running, the tests will fail with connection errors. Use one of these approaches: 85 | 86 | 1. Start MySQL manually before running tests: 87 | ```bash 88 | docker compose up -d 89 | npm test 90 | docker compose down 91 | ``` 92 | 93 | 2. Use the Docker-managed test script instead, which handles MySQL automatically: 94 | ```bash 95 | npm run test:docker 96 | ``` 97 | 98 | If you want to run all tests with Docker handling the MySQL database: 99 | 100 | ```bash 101 | npm run test:docker 102 | ``` 103 | 104 | This will run the unit tests first, and if they pass, it will run the integration tests with Docker. 105 | 106 | ### Test Coverage 107 | 108 | To run tests with coverage reporting: 109 | 110 | ```bash 111 | npm run test-cov 112 | ``` 113 | 114 | This will generate an HTML coverage report in the `coverage` directory. 115 | 116 | ## Test Structure 117 | 118 | - Unit tests are located in `test/unit/*.spec.js` 119 | - Integration tests are located in `test/integration/*.spec.js` 120 | - Test helpers are in: 121 | - `test/unit/helpers/` - Helpers for unit tests (mocks, etc.) 122 | - `test/integration/helpers/` - Helpers for integration tests (database setup, etc.) 123 | 124 | ## Integration Test Environment 125 | 126 | The integration tests support multiple MySQL versions in the CI environment to ensure compatibility across different database environments. The GitHub Actions workflow is configured to test against: 127 | 128 | - MySQL 5 (latest in the 5.x series) 129 | - MySQL LTS (Long Term Support version) 130 | 131 | For local development, the `docker-compose.yml` file includes a single MySQL service using the `mysql:lts` image to ensure we always test against the most recent Long Term Support version. The connection details are: 132 | 133 | - Host: 127.0.0.1 134 | - Port: 3306 135 | - Database: serverless_mysql_test 136 | - User: root 137 | - Password: password 138 | 139 | ```bash 140 | # Start MySQL container 141 | docker compose up -d 142 | 143 | # Run integration tests 144 | npm run test:integration 145 | 146 | # Stop MySQL container when done 147 | docker compose down 148 | ``` 149 | 150 | The MySQL container is configured with: 151 | - 1000 max connections 152 | - Extended wait timeout (28800 seconds) 153 | 154 | The GitHub Actions workflow runs integration tests against both MySQL versions to ensure compatibility. 155 | 156 | ## Continuous Integration 157 | 158 | The project uses GitHub Actions for continuous integration. Two workflows are configured: 159 | 160 | 1. **Unit Tests** - Runs unit tests and linting on pull requests and pushes to master 161 | 2. **Integration Tests** - Runs both unit and integration tests with a MySQL service container 162 | 163 | Both workflows run on Node.js versions 18, 20, and 22 to ensure compatibility across supported versions. 164 | 165 | ## Pull Request Process 166 | 167 | 1. Ensure your code passes all tests and linting 168 | 2. Update documentation if necessary 169 | 3. The PR should work in all supported Node.js versions (currently 18, 20, 22) 170 | 4. Your PR will be reviewed by maintainers who may request changes 171 | 172 | ## Coding Standards 173 | 174 | - Follow the existing code style 175 | - Write tests for new features 176 | - Keep the code simple and maintainable 177 | - Document public APIs 178 | - Run `npm run lint` to check your code against our ESLint rules 179 | 180 | ## Connection Management 181 | 182 | When working on connection management features, be particularly careful about: 183 | - Properly closing connections to prevent leaks 184 | - Handling timeouts and error conditions 185 | - Testing with concurrent connections 186 | - Ensuring compatibility with serverless environments 187 | 188 | ## License 189 | 190 | By contributing to serverless-mysql, you agree that your contributions will be licensed under the project's MIT License. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Jeremy Daly 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Serverless MySQL](https://user-images.githubusercontent.com/2053544/79284452-ac531700-7e88-11ea-8970-81e3e649e00a.png)](https://github.com/jeremydaly/serverless-mysql/) 2 | 3 | [![npm](https://img.shields.io/npm/v/serverless-mysql.svg)](https://www.npmjs.com/package/serverless-mysql) 4 | [![npm](https://img.shields.io/npm/l/serverless-mysql.svg)](https://www.npmjs.com/package/serverless-mysql) 5 | 6 | ### A module for managing MySQL connections at *serverless* scale. 7 | 8 | Serverless MySQL is a wrapper for Doug Wilson's amazing **[mysql2](https://github.com/mysqljs/mysql2)** Node.js module. Normally, using the `mysql2` module with Node apps would be just fine. However, serverless functions (like AWS Lambda, Google Cloud Functions, and Azure Functions) scale almost infinitely by creating separate instances for each concurrent user. This is a **MAJOR PROBLEM** for RDBS solutions like MySQL, because available connections can be quickly maxed out by competing functions. Not anymore. 😀 9 | 10 | Serverless MySQL adds a connection management component to the `mysql2` module that is designed specifically for use with serverless applications. This module constantly monitors the number of connections being utilized, and then based on your settings, manages those connections to allow thousands of concurrent executions to share them. It will clean up zombies, enforce connection limits per user, and retry connections using trusted backoff algorithms. 11 | 12 | In addition, Serverless MySQL also adds modern `async/await` support to the `mysql2` module, eliminating callback hell or the need to wrap calls in promises. It also dramatically simplifies **transactions**, giving you a simple and consistent pattern to handle common workflows. 13 | 14 | **NOTE:** This module *should* work with any standards-based MySQL server. It has been tested with AWS's RDS MySQL, Aurora MySQL, and Aurora Serverless. 15 | 16 | ## Simple Example 17 | 18 | ```javascript 19 | // Require and initialize outside of your main handler 20 | const mysql = require('serverless-mysql')({ 21 | config: { 22 | host : process.env.ENDPOINT, 23 | database : process.env.DATABASE, 24 | user : process.env.USERNAME, 25 | password : process.env.PASSWORD 26 | } 27 | }) 28 | 29 | // Main handler function 30 | exports.handler = async (event, context) => { 31 | // Run your query 32 | let results = await mysql.query('SELECT * FROM table') 33 | 34 | // Run clean up function 35 | await mysql.end() 36 | 37 | // Return the results 38 | return results 39 | } 40 | ``` 41 | 42 | ## Logging SQL Queries 43 | 44 | You can enable logging of the final SQL query (with all parameter values substituted) by setting the `returnFinalSqlQuery` option to `true`: 45 | 46 | ```javascript 47 | // Require and initialize outside of your main handler 48 | const mysql = require('serverless-mysql')({ 49 | config: { 50 | host : process.env.ENDPOINT, 51 | database : process.env.DATABASE, 52 | user : process.env.USERNAME, 53 | password : process.env.PASSWORD 54 | }, 55 | returnFinalSqlQuery: true // Enable SQL query logging 56 | }) 57 | 58 | // Main handler function 59 | exports.handler = async (event, context) => { 60 | // Run your query with parameters 61 | const results = await mysql.query('SELECT * FROM users WHERE id = ?', [userId]) 62 | 63 | // Access the SQL query with substituted values 64 | console.log('Executed query:', results.sql) 65 | 66 | // Run clean up function 67 | await mysql.end() 68 | 69 | // Return the results 70 | return results 71 | } 72 | ``` 73 | 74 | When `returnFinalSqlQuery` is enabled, the SQL query with substituted values is also attached to error objects when a query fails, making it easier to debug: 75 | 76 | ```javascript 77 | try { 78 | const results = await mysql.query('SELECT * FROM nonexistent_table') 79 | } catch (error) { 80 | // The error object will have the SQL property 81 | console.error('Failed query:', error.sql) 82 | console.error('Error message:', error.message) 83 | } 84 | ``` 85 | 86 | ## Installation 87 | ``` 88 | npm i serverless-mysql 89 | ``` 90 | 91 | ## Requirements 92 | - Node 8.10+ 93 | - MySQL server/cluster 94 | 95 | ## Considerations for this module 96 | - Return promises for easy async request handling 97 | - Exponential backoff (using [Jitter](https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/)) to handle failed connections 98 | - Monitor active connections and disconnect if more than X% of connections are being used 99 | - Support transactions 100 | - Support JIT connections 101 | - Assume AWS endorsed best practices from [here](https://github.com/aws-samples/aws-appsync-rds-aurora-sample/blob/master/src/lamdaresolver/index.js) 102 | 103 | ## How to use this module 104 | Serverless MySQL wraps the **[mysql](https://github.com/mysqljs/mysql)** module, so this module supports pretty much everything that the `mysql2` module does. It uses all the same [connection options](https://github.com/mysqljs/mysql#connection-options), provides a `query()` method that accepts the same arguments when [performing queries](https://github.com/mysqljs/mysql#performing-queries) (except the callback), and passes back the query results exactly as the `mysql2` module returns them. There are a few things that don't make sense in serverless environments, like streaming rows, so there is no support for that yet. 105 | 106 | To use Serverless MySQL, require it **OUTSIDE** your main function handler. This will allow for connection reuse between executions. The module must be initialized before its methods are available. [Configuration options](#configuration-options) must be passed in during initialization. 107 | 108 | ```javascript 109 | // Require and initialize with default options 110 | const mysql = require('serverless-mysql')() // <-- initialize with function call 111 | 112 | // OR include configuration options 113 | const mysql = require('serverless-mysql')({ 114 | backoff: 'decorrelated', 115 | base: 5, 116 | cap: 200 117 | }) 118 | ``` 119 | 120 | MySQL [connection options](https://github.com/mysqljs/mysql#connection-options) can be passed in at initialization or later using the `config()` method. 121 | 122 | ```javascript 123 | mysql.config({ 124 | host : process.env.ENDPOINT, 125 | database : process.env.DATABASE, 126 | user : process.env.USERNAME, 127 | password : process.env.PASSWORD 128 | }) 129 | ``` 130 | 131 | You can explicitly establish a connection using the `connect()` method if you want to, though it isn't necessary. This method returns a promise, so you'll need to `await` the response or wrap it in a promise chain. 132 | 133 | ```javascript 134 | await mysql.connect() 135 | ``` 136 | 137 | Running queries is super simple using the `query()` method. It supports all [query options](https://github.com/mysqljs/mysql#performing-queries) supported by the `mysql2` module, but returns a promise instead of using the standard callbacks. You either need to `await` them or wrap them in a promise chain. 138 | 139 | ```javascript 140 | // Simple query 141 | let results = await query('SELECT * FROM table') 142 | 143 | // Query with placeholder values 144 | let results = await query('SELECT * FROM table WHERE name = ?', ['serverless']) 145 | 146 | // Query with advanced options 147 | let results = await query({ 148 | sql: 'SELECT * FROM table WHERE name = ?', 149 | timeout: 10000, 150 | values: ['serverless'] 151 | }) 152 | ``` 153 | 154 | Once you've run all your queries and your serverless function is ready to return data, call the `end()` method to perform connection management. This will do things like check the current number of connections, clean up zombies, or even disconnect if there are too many connections being used. Be sure to `await` its results before continuing. 155 | 156 | ```javascript 157 | // Perform connection management tasks 158 | await mysql.end() 159 | ``` 160 | 161 | Note that `end()` will **NOT** necessarily terminate the connection. Only if it has to to manage the connections. If you'd like to explicitly terminate connections, use the `quit()` method. 162 | 163 | ```javascript 164 | // Gracefully terminate the connection 165 | mysql.quit() 166 | ``` 167 | 168 | If you need access to the `connection` object, you can use the `getClient()` method. This will allow you to use any supported feature of the `mysql2` module directly. 169 | 170 | ```javascript 171 | // Connect to your MySQL instance first 172 | await mysql.connect() 173 | // Get the connection object 174 | let connection = mysql.getClient() 175 | 176 | // Use it to escape a value 177 | let value = connection.escape('Some value to be escaped') 178 | ``` 179 | 180 | You can change the user of an existing connection using the `changeUser()` method. This is useful when you need to switch to a different MySQL user with different permissions. 181 | 182 | ```javascript 183 | // Change to a different user 184 | await mysql.changeUser({ 185 | user: 'newuser', 186 | password: 'newpassword' 187 | }) 188 | 189 | // Now queries will be executed as the new user 190 | let results = await mysql.query('SELECT * FROM restricted_table') 191 | ``` 192 | 193 | You can also use the `changeUser()` method to change the current database, which is equivalent to the `USE DATABASE` SQL statement: 194 | 195 | ```javascript 196 | // Change to a different database 197 | await mysql.changeUser({ 198 | database: 'new_database' // Change the database only 199 | }) 200 | 201 | // Now queries will be executed against the new database 202 | let results = await mysql.query('SELECT * FROM new_database_table') 203 | ``` 204 | 205 | Alternatively, you can use the standard SQL `USE DATABASE` statement with the `query()` method: 206 | 207 | ```javascript 208 | // Change to a different database using SQL 209 | await mysql.query('USE new_database') 210 | 211 | // Now queries will be executed against the new database 212 | let results = await mysql.query('SELECT * FROM new_database_table') 213 | ``` 214 | 215 | ## Configuration Options 216 | 217 | There are two ways to provide a configuration. 218 | 219 | The one way is using a connection string at initialization time. 220 | 221 | ```javascript 222 | const mysql = require('serverless-mysql')(`mysql://${process.env.USERNAME}:${process.env.PASSWORD}@${process.env.ENDPOINT}:${process.env.PORT}/${process.env.DATABASE}`) 223 | ``` 224 | 225 | The other way is to pass in the options defined in the below table. 226 | 227 | Below is a table containing all of the possible configuration options for `serverless-mysql`. Additional details are provided throughout the documentation. 228 | 229 | | Property | Type | Description | Default | 230 | | -------- | ---- | ----------- | ------- | 231 | | library | `Function` | Custom mysql library | `require('mysql2')` | 232 | | promise | `Function` | Custom promise library | `Promise` | 233 | | backoff | `String` or `Function` | Backoff algorithm to be used when retrying connections. Possible values are `full` and `decorrelated`, or you can also specify your own algorithm. See [Connection Backoff](#connection-backoff) for more information. | `full` | 234 | | base | `Integer` | Number of milliseconds added to random backoff values. | `2` | 235 | | cap | `Integer` | Maximum number of milliseconds between connection retries. | `100` | 236 | | config | `Object` | A `mysql2` configuration object as defined [here](https://github.com/mysqljs/mysql#connection-options) | `{}` | 237 | | connUtilization | `Number` | The percentage of total connections to use when connecting to your MySQL server. A value of `0.75` would use 75% of your total available connections. | `0.8` | 238 | | manageConns | `Boolean` | Flag indicating whether or not you want `serverless-mysql` to manage MySQL connections for you. | `true` | 239 | | maxConnsFreq | `Integer` | The number of milliseconds to cache lookups of @@max_connections. | `15000` | 240 | | maxRetries | `Integer` | Maximum number of times to retry a connection before throwing an error. | `50` | 241 | | onError | `function` | [Event](#events) callback when the MySQL connection fires an error. | | 242 | | onClose | `function` | [Event](#events) callback when MySQL connections are explicitly closed. | | 243 | | onConnect | `function` | [Event](#events) callback when connections are succesfully established. | | 244 | | onConnectError | `function` | [Event](#events) callback when connection fails. | | 245 | | onKill | `function` | [Event](#events) callback when connections are explicitly killed. | | 246 | | onKillError | `function` | [Event](#events) callback when a connection cannot be killed. | | 247 | | onRetry | `function` | [Event](#events) callback when connections are retried. | | 248 | | usedConnsFreq | `Integer` | The number of milliseconds to cache lookups of current connection usage. | `0` | 249 | | zombieMaxTimeout | `Integer` | The maximum number of seconds that a connection can stay idle before being recycled. | `900` | 250 | | zombieMinTimeout | `Integer` | The minimum number of *seconds* that a connection must be idle before the module will recycle it. | `3` | 251 | | returnFinalSqlQuery | `Boolean` | Flag indicating whether to attach the final SQL query (with substituted values) to the results. When enabled, the SQL query will be available as a non-enumerable `sql` property on array results or as a regular property on object results. | `false` | 252 | | maxQueryRetries | `Integer` | Maximum number of times to retry a query before giving up. | `0` | 253 | | queryRetryBackoff | `String` or `Function` | Backoff algorithm to be used when retrying queries. Possible values are `full` and `decorrelated`, or you can also specify your own algorithm. See [Connection Backoff](#connection-backoff) for more information. | `full` | 254 | | onQueryRetry | `function` | [Event](#events) callback when queries are retried. | | 255 | 256 | ### Connection Backoff 257 | If `manageConns` is not set to `false`, then this module will automatically kill idle connections or disconnect the current connection if the `connUtilization` limit is reached. Even with this aggressive strategy, it is possible that multiple functions will be competing for available connections. The `backoff` setting uses the strategy outlined [here](https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/) to use *Jitter* instead of *Exponential Backoff* when attempting connection retries. 258 | 259 | The two supported methods are `full` and `decorrelated` Jitter. Both are effective in reducing server strain and minimize retries. The module defaults to `full`. 260 | 261 | **Full Jitter:** LESS work, MORE time 262 | ```javascript 263 | sleep = random_between(0, min(cap, base * 2 ** attempts)) 264 | ``` 265 | 266 | **Decorrelated Jitter:** MORE work, LESS time 267 | ```javascript 268 | sleep = min(cap, random_between(base, sleep * 3)) 269 | ``` 270 | 271 | In addition to the two built-in algorithms, you can also provide your own by setting the value of `backoff` to an anonymous function. The function will receive the last `wait` value (how long the previous connection delay was) and `retries` (the number of retries attempted). Your function must return an `Integer` that represents the number of milliseconds to delay the next retry. 272 | 273 | ```javascript 274 | backoff: (wait,retries) => { 275 | console.log('CUSTOM BACKOFF',wait,retries) 276 | return 20 // return integer 277 | } 278 | ``` 279 | 280 | ## Custom libraries 281 | 282 | Set your own promise library 283 | ```javascript 284 | promise: require('bluebird') 285 | ``` 286 | 287 | Set your own mysql library, wrapped with AWS x-ray for instance 288 | ```javascript 289 | library: require('aws-sdk-xray-node')(require('mysql2')); 290 | ``` 291 | 292 | ### Consideration when using TypeScript 293 | Currently, our type definitions rely on the `mysql2` module. 294 | In order to use a custom library, you will need to do something along the following snippet: 295 | 296 | ```typescript 297 | import * as serverlessMysql from 'serverless-mysql'; 298 | import * as mysql2 from 'mysql2'; 299 | 300 | const slsMysql = serverlessMysql({ 301 | library: mysql2 as any 302 | }) 303 | 304 | // OR 305 | 306 | const slsMysql = serverlessMysql({ 307 | // @ts-ignore 308 | library: mysql2 309 | }) 310 | ``` 311 | 312 | ## Events 313 | The module fires seven different types of events: `onConnect`, `onConnectError`, `onRetry`, `onClose`, `onError`, `onKill`, and `onKillError`. These are *reporting* events that allow you to add logging or perform additional actions. You could use these events to short-circuit your handler execution, but using `catch` blocks is preferred. For example, `onError` and `onKillError` are not fatal and will be handled by `serverless-mysql`. Therefore, they will **NOT** `throw` an error and trigger a `catch` block. 314 | 315 | Error events (`onConnectError`, `onError` and `onKillError`) all receive one argument containing the `mysql2` module error object. 316 | 317 | ```javascript 318 | onConnectError: (e) => { console.log('Connect Error: ' + e.code) } 319 | ``` 320 | 321 | The `onConnect` event recieves the MySQL `connection` object, `onKill` receives the `threadId` of the connection killed, and `onClose` doesn't receive any arguments. 322 | 323 | `onRetry` receives *four* arguments. The `error` object, the number of `retries`, the `delay` until the next retry, and the `backoff` algorithm used (`full`, `decorrelated` or `custom`). 324 | 325 | ```javascript 326 | onRetry: (err,retries,delay,type) => { console.log('RETRY') } 327 | ``` 328 | 329 | `onQueryRetry` also receives *four* arguments. The `error` object, the number of `retries`, the `delay` until the next retry, and the `backoff` algorithm used (`full`, `decorrelated` or `custom`). 330 | 331 | ```javascript 332 | onQueryRetry: (err,retries,delay,type) => { console.log('QUERY RETRY') } 333 | ``` 334 | 335 | ## MySQL Server Configuration 336 | There really isn't anything special that needs to be done in order for your MySQL server (including RDS, Aurora, and Aurora Serverless) to use `serverless-mysql`. You should just be aware of the following two scenarios. 337 | 338 | If you set max `user_connections`, the module will only manage connections for that user. This is useful if you have multiple clients connecting to the same MySQL server (or cluster) and you want to make sure your serverless app doesn't use all of the available connections. 339 | 340 | If you're not setting max `user_connections`, the user **MUST BE** granted the `PROCESS` privilege in order to count other connections. Otherwise it will assume that its connections are the only ones being used. Granting `PROCESS` is fairly safe as it is a *read only* permission and doesn't expose any sensitive data. 341 | 342 | ## Query Timeouts 343 | The `mysql2` module allows you to specify a "[timeout](https://github.com/mysqljs/mysql#timeouts)" with each query. Typically this will disconnect the connection and prevent you from running additional queries. `serverless-mysql` handles timeouts a bit more elegantly by throwing an error and `destroy()`ing the connection. This will reset the connection completely, allowing you to run additional queries **AFTER** you catch the error. 344 | 345 | ## Transaction Support 346 | Transaction support in `serverless-mysql` has been dramatically simplified. Start a new transaction using the `transaction()` method, and then chain queries using the `query()` method. The `query()` method supports all standard query options. Alternatively, you can specify a function as the only argument in a `query()` method call and return the arguments as an array of values. The function receives two arguments, the result of the last query executed and an array containing all the previous query results. This is useful if you need values from a previous query as part of your transaction. 347 | 348 | You can specify an optional `rollback()` method in the chain. This will receive the `error` object, allowing you to add additional logging or perform some other action. Call the `commit()` method when you are ready to execute the queries. 349 | 350 | ```javascript 351 | let results = await mysql.transaction() 352 | .query('INSERT INTO table (x) VALUES(?)', [1]) 353 | .query('UPDATE table SET x = 1') 354 | .rollback(e => { /* do something with the error */ }) // optional 355 | .commit() // execute the queries 356 | ``` 357 | 358 | With a function to get the `insertId` from the previous query: 359 | 360 | ```javascript 361 | let results = await mysql.transaction() 362 | .query('INSERT INTO table (x) VALUES(?)', [1]) 363 | .query((r) => ['UPDATE table SET x = 1 WHERE id = ?', r.insertId]) 364 | .rollback(e => { /* do something with the error */ }) // optional 365 | .commit() // execute the queries 366 | ``` 367 | 368 | You can also return a `null` or empty response from `.query()` calls within a transaction. This lets you perform conditional transactions like this: 369 | 370 | ```javascript 371 | let results = await mysql.transaction() 372 | .query('DELETE FROM table WHERE id = ?', [someVar]) 373 | .query((r) => { 374 | if (r.affectedRows > 0) { 375 | return ['UPDATE anotherTable SET x = 1 WHERE id = ?', [someVar]] 376 | } else { 377 | return null 378 | } 379 | }) 380 | .rollback(e => { /* do something with the error */ }) // optional 381 | .commit() // execute the queries 382 | ``` 383 | 384 | If the record to `DELETE` doesn't exist, the `UPDATE` will not be performed. If the `UPDATE` fails, the `DELETE` will be rolled back. 385 | 386 | **NOTE:** Transaction support is designed for InnoDB tables (default). Other table types may not behave as expected. 387 | 388 | ## Reusing Persistent Connections 389 | If you're using AWS Lambda with **callbacks**, be sure to set `context.callbackWaitsForEmptyEventLoop = false;` in your main handler. This will allow the freezing of connections and will prevent Lambda from hanging on open connections. See [here](https://www.jeremydaly.com/reuse-database-connections-aws-lambda/) for more information. If you are using `async` functions, this is no longer necessary. 390 | 391 | ## Tests 392 | I've run *a lot* of tests using a number of different configurations. Ramp ups appear to work best, but once there are several warm containers, the response times are much better. Below is an example test I ran using AWS Lambda and Aurora Serverless. Aurora Serverless was configured with *2 ACUs* (and it didn't autoscale), so there were only **90 connections** available to the MySQL cluster. The Lambda function was configured with 1,024 MB of memory. This test simulated **500 users** per second for one minute. Each user ran a sample query retrieving a few rows from a table. 393 | 394 | From the graph below you can see that the average response time was **41 ms** (min 20 ms, max 3743 ms) with **ZERO** errors. 395 | 396 | ![Serverless MySQL test - 500 connections per second w/ 90 connections available](https://www.jeremydaly.com/wp-content/uploads/2018/09/serverless-mysql-test-500users-90-connections.png) 397 | 398 | Other tests that use larger configurations were extremely successful too, but I'd appreciate other independent tests to verify my assumptions. 399 | 400 | ## Contributions 401 | Contributions, ideas and bug reports are welcome and greatly appreciated. Please add [issues](https://github.com/jeremydaly/serverless-mysql/issues) for suggestions and bug reports or create a pull request. 402 | 403 | ## Query Retries 404 | The module supports automatic retries for transient query errors. When a query fails with a retryable error (such as deadlocks, timeouts, or connection issues), the module will automatically retry the query using the configured backoff strategy. 405 | 406 | By default, query retries are disabled (maxQueryRetries = 0) for backward compatibility with previous versions. To enable this feature, set `maxQueryRetries` to a value greater than 0. 407 | 408 | You can configure the maximum number of retries with the `maxQueryRetries` option (default: 0) and the backoff strategy with the `queryRetryBackoff` option (default: 'full'). The module will use the same backoff algorithms as for connection retries. 409 | 410 | ```javascript 411 | const mysql = require('serverless-mysql')({ 412 | config: { 413 | host: process.env.ENDPOINT, 414 | database: process.env.DATABASE, 415 | user: process.env.USERNAME, 416 | password: process.env.PASSWORD 417 | }, 418 | maxQueryRetries: 5, // Enable query retries with 5 maximum attempts 419 | queryRetryBackoff: 'decorrelated', 420 | onQueryRetry: (err, retries, delay, type) => { 421 | console.log(`Retrying query after error: ${err.code}, attempt: ${retries}, delay: ${delay}ms`) 422 | } 423 | }) 424 | ``` -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | mysql: 3 | image: mysql:lts 4 | container_name: serverless-mysql-test-db 5 | environment: 6 | MYSQL_ROOT_PASSWORD: password 7 | MYSQL_DATABASE: serverless_mysql_test 8 | MYSQL_USER: testuser 9 | MYSQL_PASSWORD: testpassword 10 | # Allow connections from any host 11 | MYSQL_ROOT_HOST: "%" 12 | TZ: UTC 13 | ports: 14 | - "3306:3306" 15 | command: > 16 | --max_connections=1000 17 | --wait_timeout=28800 18 | healthcheck: 19 | test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] 20 | timeout: 5s 21 | retries: 10 22 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | // Type definitions for serverless-mysql 2 | 3 | import * as MySQL from "mysql2"; 4 | 5 | // https://github.com/microsoft/TypeScript/issues/8335#issuecomment-215194561 6 | declare namespace serverlessMysql { 7 | export type Config = { 8 | /** 9 | * Function mysql library 10 | */ 11 | library?: Function; 12 | 13 | /** 14 | * Function promise library 15 | */ 16 | promise?: Function; 17 | 18 | /** 19 | * String or Function Backoff algorithm to be used when retrying connections. Possible values are full and decorrelated, or you can also specify your own algorithm. See Connection Backoff for more information. full 20 | */ 21 | backoff?: string | Function; 22 | /** 23 | * Integer Number of milliseconds added to random backoff values. 2 24 | */ 25 | base?: number; 26 | /** 27 | * Integer Maximum number of milliseconds between connection retries. 100 28 | */ 29 | cap?: number; 30 | /** 31 | * Object A mysql configuration object as defined here or a connection string {} 32 | */ 33 | config?: string | MySQL.ConnectionOptions; 34 | /** 35 | * Number The percentage of total connections to use when connecting to your MySQL server. A value of 0.75 would use 75% of your total available connections. 0.8 36 | */ 37 | connUtilization?: number; 38 | /** 39 | * Boolean Flag indicating whether or not you want serverless-mysql to manage MySQL connections for you. true 40 | */ 41 | manageConns?: boolean; 42 | /** 43 | * Integer The number of milliseconds to cache lookups of @@max_connections. 15000 44 | */ 45 | maxConnsFreq?: number; 46 | /** 47 | * Integer Maximum number of times to retry a connection before throwing an error. 50 48 | */ 49 | maxRetries?: number; 50 | /** 51 | * function Event callback when the MySQL connection fires an error. 52 | */ 53 | onError?: Function; 54 | /** 55 | * function Event callback when MySQL connections are explicitly closed. 56 | */ 57 | onClose?: Function; 58 | /** 59 | * function Event callback when connections are succesfully established. 60 | */ 61 | onConnect?: Function; 62 | /** 63 | * function Event callback when connection fails. 64 | */ 65 | onConnectError?: Function; 66 | /** 67 | * function Event callback when connections are explicitly killed. 68 | */ 69 | onKill?: Function; 70 | /** 71 | * function Event callback when a connection cannot be killed. 72 | */ 73 | onKillError?: Function; 74 | /** 75 | * function Event callback when connections are retried. 76 | */ 77 | onRetry?: Function; 78 | /** 79 | * Integer The number of milliseconds to cache lookups of current connection usage. 0 80 | */ 81 | usedConnsFreq?: number; 82 | /** 83 | * Integer The maximum number of seconds that a connection can stay idle before being recycled. 900 84 | */ 85 | zombieMaxTimeout?: number; 86 | /** 87 | * Integer The minimum number of seconds that a connection must be idle before the module will recycle it. 3 88 | */ 89 | zombieMinTimeout?: number; 90 | /** 91 | * Boolean Flag indicating whether to attach the final SQL query with substituted values to the results. When enabled, the SQL query will be available as a non-enumerable `sql` property on array results or as a regular property on object results. 92 | * This also attaches the SQL query to error objects when a query fails, making it easier to debug. false 93 | */ 94 | returnFinalSqlQuery?: boolean; 95 | /** 96 | * Integer Maximum number of times to retry a query before giving up. 0 97 | */ 98 | maxQueryRetries?: number; 99 | /** 100 | * String or Function Backoff algorithm to be used when retrying queries. Possible values are full and decorrelated, or you can also specify your own algorithm. full 101 | */ 102 | queryRetryBackoff?: string | Function; 103 | /** 104 | * function Event callback when queries are retried. 105 | */ 106 | onQueryRetry?: Function; 107 | }; 108 | 109 | class Transaction { 110 | query(...args: any[]): this; 111 | rollback(fn: Function): this; 112 | commit(): Promise; 113 | } 114 | 115 | export type ServerlessMysql = { 116 | connect(wait?: number): Promise; 117 | config(config?: string | MySQL.ConnectionOptions): MySQL.ConnectionOptions; 118 | query(...args: any[]): Promise; 119 | end(): Promise; 120 | escape: typeof MySQL.escape; 121 | escapeId: typeof MySQL.escapeId; 122 | format: typeof MySQL.format; 123 | quit(): void; 124 | transaction(): Transaction; 125 | getCounter(): number; 126 | getClient(): MySQL.Connection; 127 | getConfig(): MySQL.ConnectionOptions; 128 | getErrorCount(): number; 129 | changeUser(options: MySQL.ConnectionOptions): Promise; 130 | }; 131 | } 132 | 133 | declare function serverlessMysql( 134 | cfg?: string | serverlessMysql.Config 135 | ): serverlessMysql.ServerlessMysql; 136 | export = serverlessMysql; 137 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const NodeURL = require('url') 4 | 5 | /** 6 | * This module manages MySQL connections in serverless applications. 7 | * More detail regarding the MySQL module can be found here: 8 | * https://github.com/mysqljs/mysql 9 | * @author Jeremy Daly 10 | * @license MIT 11 | */ 12 | 13 | module.exports = (params) => { 14 | 15 | // Mutable values 16 | let client = null // Init null client object 17 | let counter = 0 // Total reuses counter 18 | let errors = 0 // Error count 19 | let retries = 0 // Retry count 20 | let _cfg = {} // MySQL config globals 21 | 22 | let _maxConns = { updated: 0 } // Cache max connections 23 | let _usedConns = { updated: 0 } // Cache used connections 24 | 25 | // Common Too Many Connections Errors 26 | const tooManyConnsErrors = [ 27 | 'ER_TOO_MANY_USER_CONNECTIONS', 28 | 'ER_CON_COUNT_ERROR', 29 | 'ER_USER_LIMIT_REACHED', 30 | 'ER_OUT_OF_RESOURCES', 31 | 'PROTOCOL_CONNECTION_LOST', // if the connection is lost 32 | 'PROTOCOL_SEQUENCE_TIMEOUT', // if the connection times out 33 | 'ETIMEDOUT' // if the connection times out 34 | ] 35 | 36 | // Common Transient Query Errors that can be retried 37 | const retryableQueryErrors = [ 38 | 'ER_LOCK_DEADLOCK', // Deadlock found when trying to get lock 39 | 'ER_LOCK_WAIT_TIMEOUT', // Lock wait timeout exceeded 40 | 'ER_QUERY_INTERRUPTED', // Query execution was interrupted 41 | 'ER_QUERY_TIMEOUT', // Query execution time exceeded 42 | 'ER_CONNECTION_KILLED', // Connection was killed 43 | 'ER_LOCKING_SERVICE_TIMEOUT', // Locking service timeout 44 | 'ER_LOCKING_SERVICE_DEADLOCK', // Locking service deadlock 45 | 'ER_ABORTING_CONNECTION', // Aborted connection 46 | 'PROTOCOL_CONNECTION_LOST', // Connection lost 47 | 'PROTOCOL_SEQUENCE_TIMEOUT', // Connection timeout 48 | 'ETIMEDOUT', // Connection timeout 49 | 'ECONNRESET' // Connection reset 50 | ] 51 | 52 | // Init setting values 53 | let MYSQL, manageConns, cap, base, maxRetries, connUtilization, backoff, 54 | zombieMinTimeout, zombieMaxTimeout, maxConnsFreq, usedConnsFreq, 55 | onConnect, onConnectError, onRetry, onClose, onError, onKill, onKillError, PromiseLibrary, returnFinalSqlQuery, 56 | maxQueryRetries, onQueryRetry, queryRetryBackoff 57 | 58 | /********************************************************************/ 59 | /** HELPER/CONVENIENCE FUNCTIONS **/ 60 | /********************************************************************/ 61 | 62 | const getCounter = () => counter 63 | const incCounter = () => counter++ 64 | const resetCounter = () => counter = 0 65 | const getClient = () => client 66 | const resetClient = () => client = null 67 | const resetRetries = () => retries = 0 68 | const getErrorCount = () => errors 69 | const getConfig = () => _cfg 70 | const config = (args) => { 71 | if (typeof args === 'string') { 72 | return Object.assign(_cfg, uriToConnectionConfig(args)) 73 | } 74 | return Object.assign(_cfg, args) 75 | } 76 | const delay = ms => new PromiseLibrary(res => setTimeout(res, ms)) 77 | const randRange = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min 78 | const fullJitter = () => randRange(0, Math.min(cap, base * 2 ** retries)) 79 | const decorrelatedJitter = (sleep = 0) => Math.min(cap, randRange(base, sleep * 3)) 80 | const uriToConnectionConfig = (connectionString) => { 81 | let uri = undefined 82 | 83 | try { 84 | uri = new NodeURL.URL(connectionString) 85 | } catch (error) { 86 | throw new Error('Invalid data source URL provided') 87 | } 88 | 89 | const extraFields = {} 90 | 91 | for (const [name, value] of uri.searchParams) { 92 | extraFields[name] = value 93 | } 94 | 95 | const database = uri.pathname && uri.pathname.startsWith('/') ? uri.pathname.slice(1) : undefined 96 | 97 | const connectionFields = { 98 | host: uri.hostname ? uri.hostname : undefined, 99 | user: uri.username ? uri.username : undefined, 100 | port: uri.port ? Number(uri.port) : undefined, 101 | password: uri.password ? uri.password : undefined, 102 | database 103 | } 104 | 105 | return Object.assign(connectionFields, extraFields) 106 | } 107 | 108 | 109 | /********************************************************************/ 110 | /** CONNECTION MANAGEMENT FUNCTIONS **/ 111 | /********************************************************************/ 112 | 113 | // Public connect method, handles backoff and catches 114 | // TOO MANY CONNECTIONS errors 115 | const connect = async (wait) => { 116 | try { 117 | await _connect() 118 | } catch (e) { 119 | if (tooManyConnsErrors.includes(e.code) && retries < maxRetries) { 120 | retries++ 121 | wait = Number.isInteger(wait) ? wait : 0 122 | let sleep = backoff === 'decorrelated' ? decorrelatedJitter(wait) : 123 | typeof backoff === 'function' ? backoff(wait, retries) : 124 | fullJitter() 125 | onRetry(e, retries, sleep, typeof backoff === 'function' ? 'custom' : backoff) // fire onRetry event 126 | await delay(sleep).then(() => connect(sleep)) 127 | } else { 128 | onConnectError(e) // Fire onConnectError event 129 | throw new Error(e) 130 | } 131 | } 132 | } // end connect 133 | 134 | // Internal connect method 135 | const _connect = () => { 136 | 137 | if (client === null) { // if no client connection exists 138 | 139 | resetCounter() // Reset the total use counter 140 | 141 | // Return a new promise 142 | return new PromiseLibrary((resolve, reject) => { 143 | 144 | // Connect to the MySQL database 145 | client = MYSQL.createConnection(_cfg) 146 | 147 | // Wait until MySQL is connected and ready before moving on 148 | client.connect(function (err) { 149 | if (err) { 150 | resetClient() 151 | reject(err) 152 | } else { 153 | resetRetries() 154 | onConnect(client) 155 | return resolve(true) 156 | } 157 | }) 158 | 159 | // Add error listener (reset client on failures) 160 | client.on('error', async err => { 161 | errors++ 162 | resetClient() // reset client 163 | resetCounter() // reset counter 164 | onError(err) // fire onError event (PROTOCOL_CONNECTION_LOST) 165 | }) 166 | }) // end promise 167 | 168 | // Else the client already exists 169 | } else { 170 | return PromiseLibrary.resolve() 171 | } // end if-else 172 | 173 | } // end _connect 174 | 175 | 176 | // Function called at the end that attempts to clean up zombies 177 | // and maintain proper connection limits 178 | const end = async () => { 179 | 180 | if (client !== null && manageConns) { 181 | 182 | incCounter() // increment the reuse counter 183 | 184 | // Check the number of max connections 185 | let maxConns = await getMaxConnections() 186 | 187 | // Check the number of used connections 188 | let usedConns = await getTotalConnections() 189 | 190 | // If over utilization threshold, try and clean up zombies 191 | if (usedConns.total / maxConns.total > connUtilization) { 192 | 193 | // Calculate the zombie timeout 194 | let timeout = Math.min(Math.max(usedConns.maxAge, zombieMinTimeout), zombieMaxTimeout) 195 | 196 | // Kill zombies if they are within the timeout 197 | let killedZombies = timeout <= usedConns.maxAge ? await killZombieConnections(timeout) : 0 198 | 199 | // If no zombies were cleaned up, close this connection 200 | if (killedZombies === 0) { 201 | quit() 202 | } 203 | 204 | // If zombies exist that are more than the max timeout, kill them 205 | } else if (usedConns.maxAge > zombieMaxTimeout) { 206 | await killZombieConnections(zombieMaxTimeout) 207 | } 208 | } // end if client 209 | } // end end() method 210 | 211 | 212 | // Function that explicitly closes the MySQL connection. 213 | const quit = () => { 214 | if (client !== null) { 215 | client.end() // Quit the connection. 216 | resetClient() // reset the client to null 217 | resetCounter() // reset the reuse counter 218 | onClose() // fire onClose event 219 | } 220 | } 221 | 222 | 223 | /********************************************************************/ 224 | /** QUERY FUNCTIONS **/ 225 | /********************************************************************/ 226 | 227 | // Main query function 228 | const query = async function (...args) { 229 | // Establish connection 230 | await connect() 231 | 232 | // Track query retries 233 | let queryRetries = 0 234 | 235 | // Function to execute the query with retry logic 236 | const executeQuery = async () => { 237 | return new PromiseLibrary((resolve, reject) => { 238 | if (client !== null) { 239 | // If no args are passed in a transaction, ignore query 240 | if (this && this.rollback && args.length === 0) { return resolve([]) } 241 | 242 | const queryObj = client.query(...args, async (err, results) => { 243 | if (returnFinalSqlQuery && queryObj.sql && err) { 244 | err.sql = queryObj.sql 245 | } 246 | 247 | if (err && err.code === 'PROTOCOL_SEQUENCE_TIMEOUT') { 248 | client.destroy() // destroy connection on timeout 249 | resetClient() // reset the client 250 | reject(err) // reject the promise with the error 251 | } else if ( 252 | err && (/^PROTOCOL_ENQUEUE_AFTER_/.test(err.code) 253 | || err.code === 'PROTOCOL_CONNECTION_LOST' 254 | || err.code === 'EPIPE' 255 | || err.code === 'ECONNRESET') 256 | ) { 257 | resetClient() // reset the client 258 | return resolve(query(...args)) // attempt the query again 259 | } else if (err && retryableQueryErrors.includes(err.code) && queryRetries < maxQueryRetries) { 260 | // Increment retry counter 261 | queryRetries++ 262 | 263 | // Calculate backoff time 264 | let wait = 0 265 | let sleep = queryRetryBackoff === 'decorrelated' ? decorrelatedJitter(wait) : 266 | typeof queryRetryBackoff === 'function' ? queryRetryBackoff(wait, queryRetries) : 267 | fullJitter() 268 | 269 | // Fire onQueryRetry event 270 | onQueryRetry(err, queryRetries, sleep, typeof queryRetryBackoff === 'function' ? 'custom' : queryRetryBackoff) 271 | 272 | // Wait and retry 273 | await delay(sleep) 274 | return resolve(executeQuery()) 275 | } else if (err) { 276 | if (this && this.rollback) { 277 | await query('ROLLBACK') 278 | this.rollback(err) 279 | } 280 | reject(err) 281 | } 282 | 283 | if (returnFinalSqlQuery && queryObj.sql) { 284 | if (Array.isArray(results)) { 285 | Object.defineProperty(results, 'sql', { 286 | enumerable: false, 287 | value: queryObj.sql 288 | }) 289 | } else if (results && typeof results === 'object') { 290 | results.sql = queryObj.sql 291 | } 292 | } 293 | 294 | return resolve(results) 295 | }) 296 | } 297 | }) 298 | } 299 | 300 | // Execute the query with retry logic 301 | return executeQuery() 302 | } // end query 303 | 304 | // Change user method 305 | const changeUser = async (options) => { 306 | // Ensure we have a connection 307 | await connect() 308 | 309 | // Return a new promise 310 | return new PromiseLibrary((resolve, reject) => { 311 | if (client !== null) { 312 | // Call the underlying changeUser method 313 | client.changeUser(options, (err) => { 314 | if (err) { 315 | // If connection error, reset client and reject 316 | if (err.code === 'PROTOCOL_CONNECTION_LOST' || 317 | err.code === 'EPIPE' || 318 | err.code === 'ECONNRESET') { 319 | resetClient() // reset the client 320 | reject(err) 321 | } else { 322 | // For other errors, just reject 323 | reject(err) 324 | } 325 | } else { 326 | // Successfully changed user 327 | resolve(true) 328 | } 329 | }) 330 | } else { 331 | // No client connection exists 332 | reject(new Error('No connection available to change user')) 333 | } 334 | }) 335 | } // end changeUser 336 | 337 | // Get the max connections (either for this user or total) 338 | const getMaxConnections = async () => { 339 | 340 | // If cache is expired 341 | if (Date.now() - _maxConns.updated > maxConnsFreq) { 342 | 343 | let results = await query( 344 | `SELECT IF(@@max_user_connections > 0, 345 | LEAST(@@max_user_connections,@@max_connections), 346 | @@max_connections) AS total, 347 | IF(@@max_user_connections > 0,true,false) AS userLimit` 348 | ) 349 | 350 | // Update _maxConns 351 | _maxConns = { 352 | total: results[0].total || 0, 353 | userLimit: results[0].userLimit === 1 ? true : false, 354 | updated: Date.now() 355 | } 356 | 357 | } // end if renewing cache 358 | 359 | return _maxConns 360 | 361 | } // end getMaxConnections 362 | 363 | 364 | // Get the total connections being used and the longest sleep time 365 | const getTotalConnections = async () => { 366 | 367 | // If cache is expired 368 | if (Date.now() - _usedConns.updated > usedConnsFreq) { 369 | 370 | let results = await query( 371 | `SELECT COUNT(ID) as total, MAX(time) as max_age 372 | FROM information_schema.processlist 373 | WHERE (user = ? AND @@max_user_connections > 0) OR true`, [_cfg.user]) 374 | 375 | _usedConns = { 376 | total: results[0].total || 0, 377 | maxAge: results[0].max_age || 0, 378 | updated: Date.now() 379 | } 380 | 381 | } // end if refreshing cache 382 | 383 | return _usedConns 384 | 385 | } // end getTotalConnections 386 | 387 | 388 | // Kill all zombie connections that are older than the threshold 389 | const killZombieConnections = async (timeout) => { 390 | 391 | let killedZombies = 0 392 | 393 | // Hunt for zombies (just the sleeping ones that this user owns) 394 | let zombies = await query( 395 | `SELECT ID,time FROM information_schema.processlist 396 | WHERE command = 'Sleep' AND time >= ? AND user = ? 397 | ORDER BY time DESC`, 398 | [!isNaN(timeout) ? timeout : 60 * 15, _cfg.user]) 399 | 400 | // Kill zombies 401 | for (let i = 0; i < zombies.length; i++) { 402 | try { 403 | await query('KILL ?', zombies[i].ID) 404 | onKill(zombies[i]) // fire onKill event 405 | killedZombies++ 406 | } catch (e) { 407 | // if (e.code !== 'ER_NO_SUCH_THREAD') console.log(e) 408 | onKillError(e) // fire onKillError event 409 | } 410 | } // end for 411 | 412 | return killedZombies 413 | 414 | } // end killZombieConnections 415 | 416 | 417 | /********************************************************************/ 418 | /** TRANSACTION MANAGEMENT **/ 419 | /********************************************************************/ 420 | 421 | // Init a transaction object and return methods 422 | const transaction = () => { 423 | 424 | let queries = [] // keep track of queries 425 | let rollback = () => { } // default rollback event 426 | 427 | return { 428 | query: function (...args) { 429 | if (typeof args[0] === 'function') { 430 | queries.push(args[0]) 431 | } else { 432 | queries.push(() => [...args]) 433 | } 434 | return this 435 | }, 436 | rollback: function (fn) { 437 | if (typeof fn === 'function') { rollback = fn } 438 | return this 439 | }, 440 | commit: async function () { return await commit(queries, rollback) } 441 | } 442 | } 443 | 444 | // Commit transaction by running queries 445 | const commit = async (queries, rollback) => { 446 | 447 | let results = [] // keep track of results 448 | 449 | // Start a transaction 450 | await query('START TRANSACTION') 451 | 452 | // Loop through queries 453 | for (let i = 0; i < queries.length; i++) { 454 | // Execute the queries, pass the rollback as context 455 | let result = await query.apply({ rollback }, queries[i](results[results.length - 1], results)) 456 | // Add the result to the main results accumulator 457 | results.push(result) 458 | } 459 | 460 | // Commit our transaction 461 | await query('COMMIT') 462 | 463 | // Return the results 464 | return results 465 | } 466 | 467 | 468 | /********************************************************************/ 469 | /** INITIALIZATION **/ 470 | /********************************************************************/ 471 | const cfg = typeof params === 'object' && !Array.isArray(params) ? params : {} 472 | 473 | MYSQL = cfg.library || require('mysql2') 474 | PromiseLibrary = cfg.promise || Promise 475 | 476 | // Set defaults for connection management 477 | manageConns = cfg.manageConns === false ? false : true // default to true 478 | cap = Number.isInteger(cfg.cap) ? cfg.cap : 100 // default to 100 ms 479 | base = Number.isInteger(cfg.base) ? cfg.base : 2 // default to 2 ms 480 | maxRetries = Number.isInteger(cfg.maxRetries) ? cfg.maxRetries : 50 // default to 50 attempts 481 | backoff = typeof cfg.backoff === 'function' ? cfg.backoff : 482 | cfg.backoff && ['full', 'decorrelated'].includes(cfg.backoff.toLowerCase()) ? 483 | cfg.backoff.toLowerCase() : 'full' // default to full Jitter 484 | connUtilization = !isNaN(cfg.connUtilization) ? cfg.connUtilization : 0.8 // default to 0.7 485 | zombieMinTimeout = Number.isInteger(cfg.zombieMinTimeout) ? cfg.zombieMinTimeout : 3 // default to 3 seconds 486 | zombieMaxTimeout = Number.isInteger(cfg.zombieMaxTimeout) ? cfg.zombieMaxTimeout : 60 * 15 // default to 15 minutes 487 | maxConnsFreq = Number.isInteger(cfg.maxConnsFreq) ? cfg.maxConnsFreq : 15 * 1000 // default to 15 seconds 488 | usedConnsFreq = Number.isInteger(cfg.usedConnsFreq) ? cfg.usedConnsFreq : 0 // default to 0 ms 489 | returnFinalSqlQuery = cfg.returnFinalSqlQuery === true // default to false 490 | 491 | // Query retry settings 492 | maxQueryRetries = Number.isInteger(cfg.maxQueryRetries) ? cfg.maxQueryRetries : 0 // default to 0 attempts (disabled for backward compatibility) 493 | queryRetryBackoff = typeof cfg.queryRetryBackoff === 'function' ? cfg.queryRetryBackoff : 494 | cfg.queryRetryBackoff && ['full', 'decorrelated'].includes(cfg.queryRetryBackoff.toLowerCase()) ? 495 | cfg.queryRetryBackoff.toLowerCase() : 'full' // default to full Jitter 496 | 497 | // Event handlers 498 | onConnect = typeof cfg.onConnect === 'function' ? cfg.onConnect : () => { } 499 | onConnectError = typeof cfg.onConnectError === 'function' ? cfg.onConnectError : () => { } 500 | onRetry = typeof cfg.onRetry === 'function' ? cfg.onRetry : () => { } 501 | onClose = typeof cfg.onClose === 'function' ? cfg.onClose : () => { } 502 | onError = typeof cfg.onError === 'function' ? cfg.onError : () => { } 503 | onKill = typeof cfg.onKill === 'function' ? cfg.onKill : () => { } 504 | onKillError = typeof cfg.onKillError === 'function' ? cfg.onKillError : () => { } 505 | onQueryRetry = typeof cfg.onQueryRetry === 'function' ? cfg.onQueryRetry : () => { } 506 | 507 | let connCfg = {} 508 | 509 | const isConfigAnObject = typeof cfg.config === 'object' && !Array.isArray(cfg.config) 510 | const isConfigAString = typeof cfg.config === 'string' 511 | 512 | if (isConfigAnObject || isConfigAString) { 513 | connCfg = cfg.config 514 | } else if (typeof params === 'string') { 515 | connCfg = params 516 | } 517 | 518 | let escape = MYSQL.escape 519 | let escapeId = MYSQL.escapeId 520 | let format = MYSQL.format 521 | 522 | // Set MySQL configs 523 | config(connCfg) 524 | 525 | 526 | // Return public methods 527 | return { 528 | connect, 529 | config, 530 | query, 531 | end, 532 | escape, 533 | escapeId, 534 | format, 535 | quit, 536 | transaction, 537 | getCounter, 538 | getClient, 539 | getConfig, 540 | getErrorCount, 541 | changeUser 542 | } 543 | 544 | } // end exports 545 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "serverless-mysql", 3 | "version": "2.1.0", 4 | "description": "A module for managing MySQL connections at serverless scale.", 5 | "main": "index.js", 6 | "types": "index.d.ts", 7 | "scripts": { 8 | "test:unit": "TZ=UTC mocha --check-leaks --recursive test/unit/*.spec.js", 9 | "test:unit:debug": "TZ=UTC NODE_DEBUG=mysql,net,stream mocha --check-leaks --recursive test/unit/*.spec.js", 10 | "test:integration": "TZ=UTC mocha --check-leaks --recursive test/integration/*.spec.js", 11 | "test:integration:debug": "TZ=UTC NODE_DEBUG=mysql,net,stream mocha --check-leaks --recursive test/integration/*.spec.js", 12 | "test:integration:docker": "./scripts/run-integration-tests.sh", 13 | "test:integration:docker:debug": "./scripts/run-integration-tests.sh debug", 14 | "test:docker": "./scripts/run-all-tests.sh", 15 | "test:docker:debug": "./scripts/run-all-tests.sh debug", 16 | "test": "TZ=UTC mocha --check-leaks --recursive test/{unit,integration}/*.spec.js", 17 | "test:debug": "TZ=UTC NODE_DEBUG=mysql,net,stream mocha --check-leaks --recursive test/{unit,integration}/*.spec.js", 18 | "test-cov": "TZ=UTC nyc --reporter=html mocha --check-leaks --recursive test/{unit,integration}/*.spec.js", 19 | "test-cov:debug": "TZ=UTC NODE_DEBUG=mysql,net,stream nyc --reporter=html mocha --check-leaks --recursive test/{unit,integration}/*.spec.js", 20 | "lint": "eslint .", 21 | "prepublishOnly": "npm run lint && npm run test:docker" 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "git+https://github.com/jeremydaly/serverless-mysql.git" 26 | }, 27 | "keywords": [ 28 | "serverless", 29 | "mysql", 30 | "max_connections", 31 | "scalability", 32 | "rds", 33 | "aurora serverless", 34 | "aurora" 35 | ], 36 | "author": "Jeremy Daly ", 37 | "maintainers": [ 38 | { 39 | "name": "Naor Peled", 40 | "email": "me@naor.dev", 41 | "url": "https://naor.dev" 42 | } 43 | ], 44 | "license": "MIT", 45 | "bugs": { 46 | "url": "https://github.com/jeremydaly/serverless-mysql/issues" 47 | }, 48 | "homepage": "https://github.com/jeremydaly/serverless-mysql#readme", 49 | "devDependencies": { 50 | "chai": "^4.2.0", 51 | "coveralls": "^3.0.11", 52 | "eslint": "^5.16.0", 53 | "mocha": "^11.1.0", 54 | "mocha-lcov-reporter": "^1.3.0", 55 | "nyc": "^17.1.0", 56 | "rewire": "^7.0.0", 57 | "sinon": "^6.3.5" 58 | }, 59 | "files": [ 60 | "LICENSE", 61 | "README.md", 62 | "CONTRIBUTING.md", 63 | "index.js", 64 | "index.d.ts" 65 | ], 66 | "dependencies": { 67 | "mysql2": "^3.12.0" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /scripts/run-all-tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Script to run all tests (unit and integration) locally 4 | 5 | # Check if debug mode is enabled 6 | DEBUG_ENV="" 7 | DEBUG_PARAM="" 8 | if [ "$1" = "debug" ]; then 9 | DEBUG_ENV="NODE_DEBUG=mysql,net,stream" 10 | DEBUG_PARAM="debug" 11 | echo "Debug mode enabled. Debug logs will be displayed." 12 | fi 13 | 14 | # Set max retry count for integration tests 15 | MAX_RETRIES=2 16 | RETRY_COUNT=0 17 | 18 | # First run unit tests 19 | echo "Running unit tests..." 20 | env $DEBUG_ENV npm run test:unit 21 | 22 | # Capture the unit tests exit code 23 | UNIT_EXIT_CODE=$? 24 | 25 | if [ $UNIT_EXIT_CODE -ne 0 ]; then 26 | echo "Unit tests failed with exit code $UNIT_EXIT_CODE" 27 | if [ "$1" = "debug" ]; then 28 | echo "Debug information: Unit tests exited with code $UNIT_EXIT_CODE" 29 | fi 30 | exit $UNIT_EXIT_CODE 31 | fi 32 | 33 | # Then run integration tests with Docker, with retry logic 34 | echo "Running integration tests with Docker..." 35 | 36 | run_integration_tests() { 37 | ./scripts/run-integration-tests.sh $DEBUG_PARAM 38 | return $? 39 | } 40 | 41 | while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do 42 | run_integration_tests 43 | INTEGRATION_EXIT_CODE=$? 44 | 45 | if [ $INTEGRATION_EXIT_CODE -eq 0 ]; then 46 | # Tests passed, exit the loop 47 | break 48 | else 49 | RETRY_COUNT=$((RETRY_COUNT + 1)) 50 | if [ $RETRY_COUNT -lt $MAX_RETRIES ]; then 51 | echo "Integration tests failed with exit code $INTEGRATION_EXIT_CODE. Retrying ($RETRY_COUNT/$MAX_RETRIES)..." 52 | # Wait a bit before retrying 53 | sleep 5 54 | else 55 | echo "Integration tests failed after $MAX_RETRIES attempts." 56 | if [ "$1" = "debug" ]; then 57 | echo "Debug information: Integration tests exited with code $INTEGRATION_EXIT_CODE" 58 | fi 59 | fi 60 | fi 61 | done 62 | 63 | # Exit with the integration tests exit code 64 | exit $INTEGRATION_EXIT_CODE -------------------------------------------------------------------------------- /scripts/run-integration-tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Script to run integration tests locally 4 | 5 | # Check if debug mode is enabled 6 | DEBUG_ENV="" 7 | if [ "$1" = "debug" ]; then 8 | DEBUG_ENV="NODE_DEBUG=mysql,net,stream" 9 | echo "Debug mode enabled. Debug logs will be displayed." 10 | fi 11 | 12 | # Check if Docker is installed 13 | if ! command -v docker &> /dev/null; then 14 | echo "Docker is not installed. Please install Docker to run the integration tests." 15 | exit 1 16 | fi 17 | 18 | # Check if Docker Compose is installed 19 | if command -v docker-compose &> /dev/null; then 20 | DOCKER_COMPOSE="docker-compose" 21 | elif docker compose version &> /dev/null; then 22 | DOCKER_COMPOSE="docker compose" 23 | else 24 | echo "Docker Compose is not installed. Please install Docker Compose to run the integration tests." 25 | exit 1 26 | fi 27 | 28 | # Start MySQL container 29 | echo "Starting MySQL container..." 30 | $DOCKER_COMPOSE up -d 31 | 32 | # Wait for MySQL to be ready 33 | echo "Waiting for MySQL to be ready..." 34 | for i in {1..30}; do 35 | if $DOCKER_COMPOSE exec -T mysql mysqladmin ping -h localhost -u root -ppassword &> /dev/null; then 36 | echo "MySQL is ready!" 37 | break 38 | fi 39 | echo "Waiting for MySQL to start... ($i/30)" 40 | sleep 1 41 | if [ $i -eq 30 ]; then 42 | echo "MySQL failed to start within 30 seconds." 43 | $DOCKER_COMPOSE down 44 | exit 1 45 | fi 46 | done 47 | 48 | # Add a more robust check to ensure MySQL is fully ready for connections 49 | echo "Verifying MySQL connection stability..." 50 | connection_success=false 51 | for i in {1..5}; do 52 | echo "Connection test $i/5..." 53 | if ! $DOCKER_COMPOSE exec -T mysql mysql -uroot -ppassword -e "SELECT 1;" &> /dev/null; then 54 | echo "MySQL connection test failed. Waiting a bit longer..." 55 | sleep 2 56 | else 57 | echo "Connection test successful." 58 | connection_success=true 59 | break 60 | fi 61 | done 62 | 63 | if [ "$connection_success" = false ]; then 64 | echo "Warning: All connection tests failed. Proceeding anyway, but tests might fail." 65 | fi 66 | 67 | # Final stabilization delay 68 | echo "Waiting for MySQL to stabilize..." 69 | sleep 5 70 | 71 | # Show MySQL configuration for debugging 72 | if [ "$1" = "debug" ]; then 73 | echo "MySQL configuration:" 74 | $DOCKER_COMPOSE exec -T mysql mysql -uroot -ppassword -e "SHOW VARIABLES LIKE '%timeout%';" 75 | $DOCKER_COMPOSE exec -T mysql mysql -uroot -ppassword -e "SHOW VARIABLES LIKE '%max_connections%';" 76 | $DOCKER_COMPOSE exec -T mysql mysql -uroot -ppassword -e "SHOW VARIABLES LIKE '%max_allowed_packet%';" 77 | fi 78 | 79 | # Prepare the database for tests 80 | echo "Preparing test database..." 81 | $DOCKER_COMPOSE exec -T mysql mysql -uroot -ppassword -e "DROP DATABASE IF EXISTS serverless_mysql_test; CREATE DATABASE serverless_mysql_test;" 82 | 83 | # Run the integration tests 84 | echo "Running integration tests..." 85 | ( 86 | ( 87 | sleep 90 # Increased timeout for tests 88 | echo "Tests are taking too long, killing process..." 89 | pkill -P $$ || true 90 | for pid in $(ps -o pid= --ppid $$); do 91 | echo "Killing process $pid" 92 | kill -9 $pid 2>/dev/null || true 93 | done 94 | ) & 95 | WATCHDOG_PID=$! 96 | 97 | if [ "$1" = "debug" ]; then 98 | echo "Starting tests with process ID: $$" 99 | fi 100 | 101 | # Add connection retry parameters to MySQL connection 102 | TZ=UTC \ 103 | MYSQL_HOST=127.0.0.1 \ 104 | MYSQL_PORT=3306 \ 105 | MYSQL_DATABASE=serverless_mysql_test \ 106 | MYSQL_USER=root \ 107 | MYSQL_PASSWORD=password \ 108 | MYSQL_CONNECT_TIMEOUT=10000 \ 109 | MYSQL_RETRY_COUNT=3 \ 110 | env $DEBUG_ENV npm run test:integration 111 | 112 | TEST_EXIT_CODE=$? 113 | 114 | echo "Tests completed with exit code: $TEST_EXIT_CODE" 115 | 116 | if [ "$1" = "debug" ]; then 117 | echo "Killing watchdog process: $WATCHDOG_PID" 118 | fi 119 | 120 | kill $WATCHDOG_PID 2>/dev/null || true 121 | 122 | exit $TEST_EXIT_CODE 123 | ) || true 124 | 125 | # Ensure we clean up regardless of test result 126 | echo "Cleaning up..." 127 | $DOCKER_COMPOSE down 128 | 129 | # Make sure no node processes are left hanging 130 | if [ "$1" = "debug" ]; then 131 | echo "Checking for hanging Node.js processes..." 132 | ps aux | grep node | grep -v grep || true 133 | fi 134 | 135 | exit 0 -------------------------------------------------------------------------------- /test/README.md: -------------------------------------------------------------------------------- 1 | # Tests 2 | 3 | This directory contains tests for the serverless-mysql module. The tests are organized into two main categories: 4 | 5 | - **Unit Tests** - Tests that focus on individual components in isolation 6 | - **Integration Tests** - Tests that verify the module works correctly with a real MySQL database 7 | 8 | ## Running Tests 9 | 10 | ```bash 11 | # Run unit tests only 12 | npm run test:unit 13 | 14 | # Run integration tests only (requires MySQL) 15 | npm run test:integration 16 | 17 | # Run integration tests with Docker 18 | npm run test:integration:docker 19 | 20 | # Run all tests (requires MySQL) 21 | npm test 22 | 23 | # Run all tests with Docker 24 | npm run test:docker 25 | ``` 26 | 27 | ## Directory Structure 28 | 29 | ``` 30 | test/ 31 | ├── README.md # This file 32 | ├── unit/ # Unit tests 33 | │ ├── README.md # Unit tests documentation 34 | │ ├── *.spec.js # Unit test files 35 | │ └── helpers/ # Helper functions for unit tests 36 | │ └── mocks.js # Mock objects for unit tests 37 | └── integration/ # Integration tests 38 | ├── README.md # Integration tests documentation 39 | ├── *.spec.js # Integration test files 40 | └── helpers/ # Helper functions for integration tests 41 | └── setup.js # Database setup helpers 42 | ``` 43 | 44 | ## Writing Tests 45 | 46 | See the README.md files in the respective directories for more information on writing unit and integration tests. 47 | 48 | For more information on contributing to the project, including testing guidelines, see [CONTRIBUTING.md](../CONTRIBUTING.md). -------------------------------------------------------------------------------- /test/integration/README.md: -------------------------------------------------------------------------------- 1 | # Integration Tests 2 | 3 | This directory contains integration tests for the serverless-mysql module. Integration tests verify that the module works correctly with a real MySQL database. 4 | 5 | ## Running Integration Tests 6 | 7 | ```bash 8 | # With a local MySQL instance 9 | npm run test:integration 10 | 11 | # With Docker (recommended) 12 | npm run test:integration:docker 13 | ``` 14 | 15 | ## Directory Structure 16 | 17 | - `*.spec.js` - Integration test files 18 | - `helpers/` - Helper functions for integration tests (database setup, connection management, etc.) 19 | 20 | ## Writing Integration Tests 21 | 22 | When writing integration tests, use the helpers in the `helpers/` directory to set up the database and manage connections. This makes the tests more consistent and easier to maintain. 23 | 24 | Example: 25 | 26 | ```javascript 27 | const { expect } = require('chai'); 28 | const { 29 | createTestConnection, 30 | setupTestTable, 31 | cleanupTestTable, 32 | closeConnection 33 | } = require('./helpers/setup'); 34 | 35 | describe('My Integration Test Suite', function() { 36 | let db; 37 | const TEST_TABLE = 'test_table'; 38 | const TABLE_SCHEMA = ` 39 | id INT AUTO_INCREMENT PRIMARY KEY, 40 | name VARCHAR(255) NOT NULL 41 | `; 42 | 43 | before(async function() { 44 | // Initialize the serverless-mysql instance 45 | db = createTestConnection(); 46 | 47 | // Create and set up the test table 48 | await setupTestTable(db, TEST_TABLE, TABLE_SCHEMA); 49 | }); 50 | 51 | after(async function() { 52 | // Clean up the test table 53 | await cleanupTestTable(db, TEST_TABLE); 54 | 55 | // Close the connection after tests 56 | await closeConnection(db); 57 | }); 58 | 59 | it('should do something with the database', async function() { 60 | // Test code that interacts with the database 61 | const result = await db.query('SELECT 1 AS value'); 62 | expect(result[0].value).to.equal(1); 63 | }); 64 | }); -------------------------------------------------------------------------------- /test/integration/change-user.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { expect } = require('chai'); 4 | const { 5 | createTestConnection, 6 | closeConnection, 7 | setupTestTable 8 | } = require('./helpers/setup'); 9 | 10 | describe('MySQL changeUser Integration Tests', function () { 11 | this.timeout(10000); 12 | 13 | let db; 14 | 15 | before(function () { 16 | db = createTestConnection(); 17 | }); 18 | 19 | after(async function () { 20 | try { 21 | if (db) { 22 | await db.end({ timeout: 5000 }); 23 | await closeConnection(db); 24 | } 25 | } catch (err) { 26 | console.error('Error closing connection:', err); 27 | } 28 | }); 29 | 30 | it('should change user successfully if second user credentials are provided', async function () { 31 | const secondUser = process.env.MYSQL_SECOND_USER; 32 | const secondPassword = process.env.MYSQL_SECOND_PASSWORD; 33 | 34 | if (!secondUser || !secondPassword) { 35 | console.warn('Skipping full changeUser test - no second user credentials provided'); 36 | console.warn('Set MYSQL_SECOND_USER and MYSQL_SECOND_PASSWORD environment variables to test actual user switching'); 37 | 38 | const changeUserPromise = db.changeUser({ 39 | user: db.getConfig().user, 40 | password: db.getConfig().password 41 | }); 42 | 43 | expect(changeUserPromise).to.be.a('promise'); 44 | await changeUserPromise; 45 | return; 46 | } 47 | 48 | try { 49 | const initialUserResult = await db.query('SELECT CURRENT_USER() AS user'); 50 | const initialUser = initialUserResult[0].user; 51 | 52 | await db.changeUser({ 53 | user: secondUser, 54 | password: secondPassword 55 | }); 56 | 57 | const newUserResult = await db.query('SELECT CURRENT_USER() AS user'); 58 | const newUser = newUserResult[0].user; 59 | 60 | expect(newUser).to.include(secondUser); 61 | expect(newUser).to.not.equal(initialUser); 62 | 63 | const config = db.getConfig(); 64 | await db.changeUser({ 65 | user: config.user, 66 | password: config.password 67 | }); 68 | } catch (error) { 69 | if (error.code === 'ER_ACCESS_DENIED_ERROR') { 70 | console.error('Access denied when changing user. Check that the provided credentials are correct and have proper permissions.'); 71 | console.error('Error details:', error.message); 72 | } 73 | 74 | throw error; 75 | } 76 | }); 77 | 78 | it('should handle errors when changing to non-existent user', async function () { 79 | try { 80 | const nonExistentUser = 'non_existent_user_' + Date.now(); 81 | await db.changeUser({ 82 | user: nonExistentUser, 83 | password: 'wrong_password' 84 | }); 85 | 86 | expect.fail('Should have thrown an error'); 87 | } catch (error) { 88 | expect(error).to.be.an('error'); 89 | 90 | // In MySQL 8.4+ (LTS), the mysql_native_password plugin is not loaded by default 91 | // In older MySQL versions, we get an access denied error 92 | expect(error.code).to.be.oneOf([ 93 | 'ER_ACCESS_DENIED_ERROR', // Older MySQL versions 94 | 'ER_PLUGIN_IS_NOT_LOADED' // MySQL 8.4+ (LTS) 95 | ]); 96 | 97 | if (error.code === 'ER_PLUGIN_IS_NOT_LOADED') { 98 | expect(error.message).to.include('Plugin'); 99 | expect(error.message).to.include('mysql_native_password'); 100 | expect(error.message).to.include('not loaded'); 101 | } else { 102 | expect(error.message).to.include('Access denied for user'); 103 | } 104 | } 105 | }); 106 | 107 | it('should support changing the database', async function () { 108 | const testDbName = 'serverless_mysql_test_db_' + Date.now().toString().slice(-6); 109 | 110 | try { 111 | await db.query(`CREATE DATABASE IF NOT EXISTS ${testDbName}`); 112 | 113 | const initialDbResult = await db.query('SELECT DATABASE() AS db'); 114 | const initialDb = initialDbResult[0].db; 115 | 116 | await db.changeUser({ 117 | user: db.getConfig().user, 118 | password: db.getConfig().password, 119 | database: testDbName 120 | }); 121 | 122 | const newDbResult = await db.query('SELECT DATABASE() AS db'); 123 | const newDb = newDbResult[0].db; 124 | 125 | expect(newDb).to.equal(testDbName); 126 | expect(newDb).to.not.equal(initialDb); 127 | 128 | await db.query(` 129 | CREATE TABLE IF NOT EXISTS test_table ( 130 | id INT AUTO_INCREMENT PRIMARY KEY, 131 | name VARCHAR(50) NOT NULL 132 | ) 133 | `); 134 | 135 | await db.query(`INSERT INTO test_table (name) VALUES ('test')`); 136 | 137 | const result = await db.query('SELECT * FROM test_table'); 138 | expect(result).to.be.an('array'); 139 | expect(result).to.have.lengthOf(1); 140 | expect(result[0].name).to.equal('test'); 141 | 142 | await db.changeUser({ 143 | user: db.getConfig().user, 144 | password: db.getConfig().password, 145 | database: initialDb 146 | }); 147 | 148 | const finalDbResult = await db.query('SELECT DATABASE() AS db'); 149 | expect(finalDbResult[0].db).to.equal(initialDb); 150 | 151 | } catch (error) { 152 | throw error; 153 | } finally { 154 | try { 155 | await db.query(`USE ${db.getConfig().database}`); 156 | await db.query(`DROP DATABASE IF EXISTS ${testDbName}`); 157 | } catch (cleanupError) { 158 | console.error('Error during cleanup:', cleanupError); 159 | } 160 | } 161 | }); 162 | 163 | it('should support changing the database with only database parameter', async function () { 164 | const testDbName = 'serverless_mysql_test_db_' + Date.now().toString().slice(-6); 165 | 166 | try { 167 | await db.query(`CREATE DATABASE IF NOT EXISTS ${testDbName}`); 168 | 169 | const initialDbResult = await db.query('SELECT DATABASE() AS db'); 170 | const initialDb = initialDbResult[0].db; 171 | 172 | await db.changeUser({ 173 | database: testDbName 174 | }); 175 | 176 | const newDbResult = await db.query('SELECT DATABASE() AS db'); 177 | const newDb = newDbResult[0].db; 178 | 179 | expect(newDb).to.equal(testDbName); 180 | expect(newDb).to.not.equal(initialDb); 181 | 182 | await db.query(` 183 | CREATE TABLE IF NOT EXISTS test_table_db_only ( 184 | id INT AUTO_INCREMENT PRIMARY KEY, 185 | name VARCHAR(50) NOT NULL 186 | ) 187 | `); 188 | 189 | await db.query(`INSERT INTO test_table_db_only (name) VALUES ('db_only_test')`); 190 | 191 | const result = await db.query('SELECT * FROM test_table_db_only'); 192 | expect(result).to.be.an('array'); 193 | expect(result).to.have.lengthOf(1); 194 | expect(result[0].name).to.equal('db_only_test'); 195 | 196 | const userResult = await db.query('SELECT CURRENT_USER() AS user'); 197 | const currentUser = userResult[0].user; 198 | 199 | const configUser = db.getConfig().user; 200 | expect(currentUser).to.include(configUser.split('@')[0]); 201 | 202 | await db.changeUser({ 203 | database: initialDb 204 | }); 205 | 206 | const finalDbResult = await db.query('SELECT DATABASE() AS db'); 207 | expect(finalDbResult[0].db).to.equal(initialDb); 208 | 209 | } catch (error) { 210 | throw error; 211 | } finally { 212 | try { 213 | await db.query(`USE ${db.getConfig().database}`); 214 | await db.query(`DROP DATABASE IF EXISTS ${testDbName}`); 215 | } catch (cleanupError) { 216 | console.error('Error during cleanup:', cleanupError); 217 | } 218 | } 219 | }); 220 | }); -------------------------------------------------------------------------------- /test/integration/connection-management.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { expect } = require('chai'); 4 | const { createTestConnection, closeConnection } = require('./helpers/setup'); 5 | 6 | describe('MySQL Connection Management Tests', function () { 7 | this.timeout(20000); 8 | 9 | let db; 10 | const allConnections = []; 11 | 12 | beforeEach(function () { 13 | db = createTestConnection({ 14 | maxRetries: 5 15 | }); 16 | allConnections.push(db); 17 | }); 18 | 19 | afterEach(async function () { 20 | try { 21 | if (db) { 22 | const closePromise = new Promise((resolve) => { 23 | const timeout = setTimeout(() => { 24 | console.log('Connection end timed out, forcing destroy'); 25 | if (db._conn) { 26 | db._conn.destroy(); 27 | } 28 | resolve(); 29 | }, 2000); 30 | 31 | db.end() 32 | .then(() => { 33 | clearTimeout(timeout); 34 | resolve(); 35 | }) 36 | .catch((err) => { 37 | console.error('Error ending connection:', err); 38 | clearTimeout(timeout); 39 | if (db._conn) { 40 | db._conn.destroy(); 41 | } 42 | resolve(); 43 | }); 44 | }); 45 | 46 | await closePromise; 47 | await closeConnection(db); 48 | } 49 | } catch (err) { 50 | console.error('Error closing connection:', err); 51 | } 52 | }); 53 | 54 | after(async function () { 55 | console.log('Running final cleanup for all connections...'); 56 | 57 | for (const connection of allConnections) { 58 | if (connection) { 59 | try { 60 | console.log('Closing connection...'); 61 | await connection.end({ timeout: 1000 }).catch(err => { 62 | console.error('Error ending connection in final cleanup:', err); 63 | }); 64 | 65 | await closeConnection(connection); 66 | 67 | setTimeout(() => { 68 | console.log('Forcing process exit to prevent hanging'); 69 | process.exit(0); 70 | }, 1000); 71 | } catch (err) { 72 | console.error('Final cleanup error:', err); 73 | } 74 | } 75 | } 76 | }); 77 | 78 | it('should handle multiple concurrent connections', async function () { 79 | const promises = []; 80 | const queryCount = 5; 81 | 82 | try { 83 | for (let i = 0; i < queryCount; i++) { 84 | promises.push(db.query('SELECT SLEEP(0.1) as result, ? as id', [i])); 85 | } 86 | 87 | const results = await Promise.all(promises); 88 | 89 | expect(results).to.have.lengthOf(queryCount); 90 | 91 | for (let i = 0; i < queryCount; i++) { 92 | const matchingResult = results.find(r => r[0].id === i); 93 | expect(matchingResult).to.exist; 94 | expect(matchingResult[0].result).to.equal(0); 95 | } 96 | } catch (error) { 97 | console.error('Error in concurrent connections test:', error); 98 | if (error.message && error.message.includes('Connection lost')) { 99 | this.skip(); 100 | } else { 101 | throw error; 102 | } 103 | } 104 | }); 105 | 106 | it('should reuse connections efficiently', async function () { 107 | await db.query('SELECT 1 as test'); 108 | 109 | const initialCounter = db.getCounter(); 110 | 111 | await db.query('SELECT 2 as test'); 112 | await db.end(); 113 | 114 | const counterAfterOneReuse = db.getCounter(); 115 | 116 | expect(counterAfterOneReuse).to.be.greaterThan(initialCounter); 117 | 118 | await db.query('SELECT 3 as test'); 119 | await db.end(); 120 | 121 | const counterAfterTwoReuses = db.getCounter(); 122 | 123 | expect(counterAfterTwoReuses).to.be.greaterThan(counterAfterOneReuse); 124 | }); 125 | 126 | it('should handle query timeouts gracefully', async function () { 127 | const timeoutDb = createTestConnection(); 128 | allConnections.push(timeoutDb); 129 | 130 | try { 131 | await timeoutDb.query({ 132 | sql: 'SELECT SLEEP(0.2) as result', 133 | timeout: 50 134 | }); 135 | expect.fail('Query should have timed out'); 136 | } catch (error) { 137 | expect(error.message).to.include('timeout'); 138 | } finally { 139 | try { 140 | console.log('Cleaning up timeout test connection'); 141 | const closePromise = new Promise((resolve) => { 142 | const timeout = setTimeout(() => { 143 | console.log('Timeout connection end timed out, forcing destroy'); 144 | if (timeoutDb._conn) { 145 | timeoutDb._conn.destroy(); 146 | } 147 | resolve(); 148 | }, 1000); 149 | 150 | timeoutDb.end() 151 | .then(() => { 152 | clearTimeout(timeout); 153 | resolve(); 154 | }) 155 | .catch((err) => { 156 | console.error('Error ending timeout connection:', err); 157 | clearTimeout(timeout); 158 | if (timeoutDb._conn) { 159 | timeoutDb._conn.destroy(); 160 | } 161 | resolve(); 162 | }); 163 | }); 164 | 165 | await closePromise; 166 | await closeConnection(timeoutDb); 167 | } catch (err) { 168 | console.error('Error closing timeout test connection:', err); 169 | } 170 | } 171 | }); 172 | 173 | it('should handle connection errors and retry', async function () { 174 | const retryDb = createTestConnection({ 175 | maxRetries: 3, 176 | backoff: 'decorrelated-jitter', 177 | base: 50, 178 | cap: 500 179 | }); 180 | allConnections.push(retryDb); 181 | 182 | try { 183 | const result = await retryDb.query('SELECT 1 as success'); 184 | expect(result[0].success).to.equal(1); 185 | } finally { 186 | try { 187 | console.log('Cleaning up retry test connection'); 188 | const closePromise = new Promise((resolve) => { 189 | const timeout = setTimeout(() => { 190 | console.log('Retry connection end timed out, forcing destroy'); 191 | if (retryDb._conn) { 192 | retryDb._conn.destroy(); 193 | } 194 | resolve(); 195 | }, 1000); 196 | 197 | retryDb.end() 198 | .then(() => { 199 | clearTimeout(timeout); 200 | resolve(); 201 | }) 202 | .catch((err) => { 203 | console.error('Error ending retry connection:', err); 204 | clearTimeout(timeout); 205 | if (retryDb._conn) { 206 | retryDb._conn.destroy(); 207 | } 208 | resolve(); 209 | }); 210 | }); 211 | 212 | await closePromise; 213 | await closeConnection(retryDb); 214 | } catch (err) { 215 | console.error('Error closing retry test connection:', err); 216 | } 217 | } 218 | }); 219 | }); -------------------------------------------------------------------------------- /test/integration/connection-string.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { expect } = require('chai'); 4 | const mysql = require('../../index'); 5 | const { closeConnection, createTestConnection, createTestConnectionString } = require('./helpers/setup'); 6 | 7 | describe('Connection String Integration Tests', function () { 8 | this.timeout(10000); 9 | 10 | let db; 11 | 12 | afterEach(async function () { 13 | try { 14 | if (db) { 15 | await db.end({ timeout: 5000 }); 16 | await closeConnection(db); 17 | db = null; 18 | } 19 | } catch (err) { 20 | console.error('Error closing connection:', err); 21 | } 22 | }); 23 | 24 | it('should connect using a valid connection string', async function () { 25 | const connectionString = createTestConnectionString(); 26 | db = createTestConnection(connectionString); 27 | 28 | try { 29 | const result = await db.query('SELECT 1 AS value'); 30 | expect(result).to.be.an('array'); 31 | expect(result).to.have.lengthOf(1); 32 | expect(result[0]).to.have.property('value', 1); 33 | } catch (error) { 34 | console.error('Connection error:', error); 35 | throw error; 36 | } 37 | }); 38 | 39 | it('should connect using a connection string with additional parameters', async function () { 40 | const connectionString = createTestConnectionString({ 41 | connectTimeout: 10000, 42 | dateStrings: 'true' 43 | }); 44 | 45 | db = createTestConnection(connectionString); 46 | 47 | try { 48 | const result = await db.query('SELECT 1 AS value'); 49 | expect(result).to.be.an('array'); 50 | expect(result).to.have.lengthOf(1); 51 | expect(result[0]).to.have.property('value', 1); 52 | } catch (error) { 53 | console.error('Connection error:', error); 54 | throw error; 55 | } 56 | }); 57 | 58 | it('should connect using a connection string as part of sls-mysql.config property', async function () { 59 | const connectionString = createTestConnectionString(); 60 | db = mysql({ 61 | config: connectionString 62 | }) 63 | 64 | try { 65 | const result = await db.query('SELECT 1 AS value'); 66 | expect(result).to.be.an('array'); 67 | expect(result).to.have.lengthOf(1); 68 | expect(result[0]).to.have.property('value', 1); 69 | } catch (error) { 70 | console.error('Connection error:', error); 71 | throw error; 72 | } 73 | }); 74 | }); -------------------------------------------------------------------------------- /test/integration/connection.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { expect } = require('chai'); 4 | const { 5 | createTestConnection, 6 | closeConnection 7 | } = require('./helpers/setup'); 8 | 9 | describe('MySQL Connection Integration Tests', function () { 10 | // Increase timeout for integration tests 11 | this.timeout(10000); 12 | 13 | let db; 14 | 15 | before(function () { 16 | // Initialize the serverless-mysql instance 17 | db = createTestConnection(); 18 | }); 19 | 20 | after(async function () { 21 | // Close the connection after tests 22 | try { 23 | if (db) { 24 | await db.end({ timeout: 5000 }); // Force end with timeout 25 | await closeConnection(db); 26 | } 27 | } catch (err) { 28 | console.error('Error closing connection:', err); 29 | } 30 | }); 31 | 32 | it('should connect to the database without errors', async function () { 33 | try { 34 | // Execute a simple query to test the connection 35 | const result = await db.query('SELECT 1 AS value'); 36 | 37 | // Verify the result 38 | expect(result).to.be.an('array'); 39 | expect(result).to.have.lengthOf(1); 40 | expect(result[0]).to.have.property('value', 1); 41 | } catch (error) { 42 | // Fail the test if there's an error 43 | console.error('Connection error:', error); 44 | throw error; 45 | } 46 | }); 47 | 48 | it('should handle multiple queries in sequence', async function () { 49 | // Execute multiple queries in sequence 50 | const result1 = await db.query('SELECT 1 AS value'); 51 | const result2 = await db.query('SELECT 2 AS value'); 52 | 53 | expect(result1[0].value).to.equal(1); 54 | expect(result2[0].value).to.equal(2); 55 | }); 56 | }); -------------------------------------------------------------------------------- /test/integration/features.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { expect } = require('chai'); 4 | const { 5 | createTestConnection, 6 | setupTestTable, 7 | cleanupTestTable, 8 | closeConnection 9 | } = require('./helpers/setup'); 10 | 11 | describe('MySQL Features Integration Tests', function () { 12 | this.timeout(15000); 13 | 14 | let db; 15 | const TEST_TABLE = 'test_table'; 16 | const TABLE_SCHEMA = ` 17 | id INT AUTO_INCREMENT PRIMARY KEY, 18 | name VARCHAR(255) NOT NULL, 19 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 20 | `; 21 | 22 | before(async function () { 23 | db = createTestConnection(); 24 | await setupTestTable(db, TEST_TABLE, TABLE_SCHEMA); 25 | }); 26 | 27 | after(async function () { 28 | try { 29 | await cleanupTestTable(db, TEST_TABLE); 30 | 31 | if (db) { 32 | await db.end({ timeout: 5000 }); 33 | await closeConnection(db); 34 | } 35 | } catch (err) { 36 | console.error('Error during cleanup:', err); 37 | } 38 | }); 39 | 40 | it('should insert and retrieve data', async function () { 41 | const insertResult = await db.query( 42 | 'INSERT INTO test_table (name) VALUES (?), (?), (?)', 43 | ['Test 1', 'Test 2', 'Test 3'] 44 | ); 45 | 46 | expect(insertResult.affectedRows).to.equal(3); 47 | 48 | const selectResult = await db.query('SELECT * FROM test_table ORDER BY id'); 49 | 50 | expect(selectResult).to.be.an('array'); 51 | expect(selectResult).to.have.lengthOf(3); 52 | expect(selectResult[0].name).to.equal('Test 1'); 53 | expect(selectResult[1].name).to.equal('Test 2'); 54 | expect(selectResult[2].name).to.equal('Test 3'); 55 | }); 56 | 57 | it('should handle transactions correctly', async function () { 58 | const transaction = db.transaction(); 59 | 60 | try { 61 | await transaction.query('INSERT INTO test_table (name) VALUES (?)', ['Transaction Test 1']); 62 | await transaction.query('INSERT INTO test_table (name) VALUES (?)', ['Transaction Test 2']); 63 | 64 | await transaction.commit(); 65 | 66 | const result = await db.query('SELECT * FROM test_table WHERE name LIKE ?', ['Transaction Test%']); 67 | expect(result).to.have.lengthOf(2); 68 | } catch (error) { 69 | await transaction.rollback(); 70 | throw error; 71 | } 72 | }); 73 | 74 | it('should rollback transactions on error', async function () { 75 | const initialCount = (await db.query('SELECT COUNT(*) as count FROM test_table'))[0].count; 76 | 77 | const transaction = db.transaction(); 78 | 79 | try { 80 | await transaction.query('INSERT INTO test_table (name) VALUES (?)', ['Should Not Exist']); 81 | await transaction.query('INSERT INTO non_existent_table (name) VALUES (?)', ['Error']); 82 | 83 | await transaction.commit(); 84 | expect.fail('Transaction should have failed'); 85 | } catch (error) { 86 | await transaction.rollback(); 87 | 88 | const finalCount = (await db.query('SELECT COUNT(*) as count FROM test_table'))[0].count; 89 | expect(finalCount).to.equal(initialCount); 90 | } 91 | }); 92 | 93 | it('should handle connection management', async function () { 94 | const promises = []; 95 | 96 | for (let i = 0; i < 5; i++) { 97 | promises.push(db.query('SELECT SLEEP(0.1) as result')); 98 | } 99 | 100 | const results = await Promise.all(promises); 101 | 102 | results.forEach(result => { 103 | expect(result[0].result).to.equal(0); 104 | }); 105 | }); 106 | 107 | it('should support changing database with USE statement', async function () { 108 | const testDbName = 'serverless_mysql_test_db_' + Date.now().toString().slice(-6); 109 | 110 | try { 111 | await db.query(`CREATE DATABASE IF NOT EXISTS ${testDbName}`); 112 | 113 | const initialDbResult = await db.query('SELECT DATABASE() AS db'); 114 | const initialDb = initialDbResult[0].db; 115 | 116 | await db.query(`USE ${testDbName}`); 117 | 118 | const newDbResult = await db.query('SELECT DATABASE() AS db'); 119 | const newDb = newDbResult[0].db; 120 | 121 | expect(newDb).to.equal(testDbName); 122 | expect(newDb).to.not.equal(initialDb); 123 | 124 | await db.query(` 125 | CREATE TABLE IF NOT EXISTS use_test_table ( 126 | id INT AUTO_INCREMENT PRIMARY KEY, 127 | name VARCHAR(50) NOT NULL 128 | ) 129 | `); 130 | 131 | await db.query(`INSERT INTO use_test_table (name) VALUES ('use_test')`); 132 | 133 | const result = await db.query('SELECT * FROM use_test_table'); 134 | expect(result).to.be.an('array'); 135 | expect(result).to.have.lengthOf(1); 136 | expect(result[0].name).to.equal('use_test'); 137 | 138 | await db.query(`USE ${initialDb}`); 139 | 140 | const finalDbResult = await db.query('SELECT DATABASE() AS db'); 141 | expect(finalDbResult[0].db).to.equal(initialDb); 142 | 143 | } catch (error) { 144 | throw error; 145 | } finally { 146 | try { 147 | await db.query(`USE ${db.getConfig().database}`); 148 | await db.query(`DROP DATABASE IF EXISTS ${testDbName}`); 149 | } catch (cleanupError) { 150 | console.error('Error during cleanup:', cleanupError); 151 | } 152 | } 153 | }); 154 | }); -------------------------------------------------------------------------------- /test/integration/helpers/setup.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const mysql = require('../../../index'); 4 | 5 | /** 6 | * Creates a MySQL connection for testing 7 | * @param {Object|string} options - Additional options for the MySQL connection or a connection string 8 | * @returns {Object} The MySQL connection object 9 | */ 10 | function createTestConnection(options = {}) { 11 | if (typeof options === 'string') { 12 | return mysql(options); 13 | } 14 | 15 | const config = { 16 | host: process.env.MYSQL_HOST || '127.0.0.1', 17 | database: process.env.MYSQL_DATABASE || 'serverless_mysql_test', 18 | user: process.env.MYSQL_USER || 'root', 19 | password: process.env.MYSQL_PASSWORD || 'password', 20 | port: process.env.MYSQL_PORT || 3306 21 | }; 22 | 23 | return mysql({ 24 | config, 25 | ...options 26 | }); 27 | } 28 | 29 | /** 30 | * Creates a connection string from environment variables or defaults 31 | * @param {Object} additionalParams - Additional URL parameters to include 32 | * @returns {string} The connection string 33 | */ 34 | function createTestConnectionString(additionalParams = {}) { 35 | const host = process.env.MYSQL_HOST || '127.0.0.1'; 36 | const database = process.env.MYSQL_DATABASE || 'serverless_mysql_test'; 37 | const user = process.env.MYSQL_USER || 'root'; 38 | const password = process.env.MYSQL_PASSWORD || 'password'; 39 | const port = process.env.MYSQL_PORT || 3306; 40 | 41 | let connectionString = `mysql://${user}:${password}@${host}:${port}/${database}`; 42 | 43 | if (Object.keys(additionalParams).length > 0) { 44 | connectionString += '?'; 45 | connectionString += Object.entries(additionalParams) 46 | .map(([key, value]) => `${key}=${value}`) 47 | .join('&'); 48 | } 49 | 50 | return connectionString; 51 | } 52 | 53 | /** 54 | * Sets up a test table 55 | * @param {Object} db - The MySQL connection 56 | * @param {string} tableName - The name of the table to create 57 | * @param {string} schema - The schema for the table 58 | * @returns {Promise} 59 | */ 60 | async function setupTestTable(db, tableName, schema) { 61 | await db.query(` 62 | CREATE TABLE IF NOT EXISTS ${tableName} ( 63 | ${schema} 64 | ) 65 | `); 66 | 67 | await db.query(`TRUNCATE TABLE ${tableName}`); 68 | } 69 | 70 | /** 71 | * Cleans up a test table 72 | * @param {Object} db - The MySQL connection 73 | * @param {string} tableName - The name of the table to drop 74 | * @returns {Promise} 75 | */ 76 | async function cleanupTestTable(db, tableName) { 77 | await db.query(`DROP TABLE IF EXISTS ${tableName}`); 78 | } 79 | 80 | /** 81 | * Closes the database connection 82 | * @param {Object} db - The MySQL connection 83 | * @returns {Promise} 84 | */ 85 | async function closeConnection(db) { 86 | if (db) { 87 | try { 88 | const endPromise = new Promise((resolve) => { 89 | const timeout = setTimeout(() => { 90 | console.log('Connection end timed out, forcing destroy'); 91 | resolve(); 92 | }, 1000); 93 | 94 | db.end() 95 | .then(() => { 96 | clearTimeout(timeout); 97 | resolve(); 98 | }) 99 | .catch((err) => { 100 | console.error('Error ending connection:', err); 101 | clearTimeout(timeout); 102 | resolve(); 103 | }); 104 | }); 105 | 106 | await endPromise; 107 | 108 | if (db._conn) { 109 | db._conn.destroy(); 110 | 111 | if (db._conn.connection && db._conn.connection.stream) { 112 | db._conn.connection.stream.destroy(); 113 | } 114 | } 115 | 116 | if (typeof db._reset === 'function') { 117 | db._reset(); 118 | } 119 | } catch (err) { 120 | console.error('Error in closeConnection:', err); 121 | try { 122 | if (db._conn) { 123 | db._conn.destroy(); 124 | if (db._conn.connection && db._conn.connection.stream) { 125 | db._conn.connection.stream.destroy(); 126 | } 127 | } 128 | } catch (destroyErr) { 129 | console.error('Error destroying connection:', destroyErr); 130 | } 131 | } 132 | } 133 | } 134 | 135 | module.exports = { 136 | createTestConnection, 137 | createTestConnectionString, 138 | setupTestTable, 139 | cleanupTestTable, 140 | closeConnection 141 | }; -------------------------------------------------------------------------------- /test/integration/query-retries.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { expect } = require('chai'); 4 | const sinon = require('sinon'); 5 | const { 6 | createTestConnection, 7 | setupTestTable, 8 | cleanupTestTable, 9 | closeConnection 10 | } = require('./helpers/setup'); 11 | 12 | describe('Query Retries Integration Tests', function () { 13 | this.timeout(15000); 14 | 15 | let db; 16 | let originalQuery; 17 | let queryStub; 18 | let onQueryRetrySpy; 19 | const TEST_TABLE = 'retry_test_table'; 20 | const TABLE_SCHEMA = ` 21 | id INT AUTO_INCREMENT PRIMARY KEY, 22 | name VARCHAR(255) NOT NULL, 23 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 24 | `; 25 | 26 | beforeEach(async function () { 27 | onQueryRetrySpy = sinon.spy(); 28 | 29 | db = createTestConnection({ 30 | maxQueryRetries: 3, 31 | onQueryRetry: onQueryRetrySpy 32 | }); 33 | 34 | await setupTestTable(db, TEST_TABLE, TABLE_SCHEMA); 35 | 36 | // Store the original query method 37 | originalQuery = db.getClient().query; 38 | }); 39 | 40 | afterEach(async function () { 41 | // Restore the original query method if it was stubbed 42 | if (queryStub && queryStub.restore) { 43 | queryStub.restore(); 44 | } 45 | 46 | try { 47 | await cleanupTestTable(db, TEST_TABLE); 48 | 49 | if (db) { 50 | await db.end({ timeout: 5000 }); 51 | await closeConnection(db); 52 | } 53 | } catch (err) { 54 | console.error('Error during cleanup:', err); 55 | } 56 | }); 57 | 58 | it('should retry queries that fail with retryable errors', async function () { 59 | // Create a counter to track the number of query attempts 60 | let attempts = 0; 61 | 62 | // Stub the client's query method to simulate a deadlock error on first attempt 63 | queryStub = sinon.stub(db.getClient(), 'query').callsFake(function (sql, values, callback) { 64 | attempts++; 65 | 66 | // If this is the first or second attempt, simulate a deadlock error 67 | if (attempts <= 2) { 68 | const error = new Error('Deadlock found when trying to get lock'); 69 | error.code = 'ER_LOCK_DEADLOCK'; 70 | 71 | // Call the callback with the error 72 | if (typeof values === 'function') { 73 | values(error); 74 | } else { 75 | callback(error); 76 | } 77 | 78 | // Return a mock query object 79 | return { sql: typeof sql === 'string' ? sql : sql.sql }; 80 | } 81 | 82 | // On the third attempt, succeed 83 | return originalQuery.apply(this, arguments); 84 | }); 85 | 86 | // Execute a query that should be retried 87 | await db.query('INSERT INTO retry_test_table (name) VALUES (?)', ['Test Retry']); 88 | 89 | // Verify the query was attempted multiple times 90 | expect(attempts).to.be.at.least(3); 91 | 92 | // Verify the onQueryRetry callback was called 93 | expect(onQueryRetrySpy.callCount).to.equal(2); 94 | 95 | // Verify the data was actually inserted 96 | const result = await db.query('SELECT * FROM retry_test_table WHERE name = ?', ['Test Retry']); 97 | expect(result).to.have.lengthOf(1); 98 | expect(result[0].name).to.equal('Test Retry'); 99 | }); 100 | 101 | it('should give up after maxQueryRetries attempts', async function () { 102 | // Create a counter to track the number of query attempts 103 | let attempts = 0; 104 | 105 | // Stub the client's query method to always fail with a retryable error 106 | queryStub = sinon.stub(db.getClient(), 'query').callsFake(function (sql, values, callback) { 107 | attempts++; 108 | 109 | const error = new Error('Lock wait timeout exceeded'); 110 | error.code = 'ER_LOCK_WAIT_TIMEOUT'; 111 | 112 | // Call the callback with the error 113 | if (typeof values === 'function') { 114 | values(error); 115 | } else { 116 | callback(error); 117 | } 118 | 119 | // Return a mock query object 120 | return { sql: typeof sql === 'string' ? sql : sql.sql }; 121 | }); 122 | 123 | // Execute a query that should be retried but eventually fail 124 | try { 125 | await db.query('INSERT INTO retry_test_table (name) VALUES (?)', ['Should Fail']); 126 | expect.fail('Query should have failed after max retries'); 127 | } catch (error) { 128 | // Verify the error is the expected one 129 | expect(error.code).to.equal('ER_LOCK_WAIT_TIMEOUT'); 130 | 131 | // Verify the query was attempted the maximum number of times (initial + retries) 132 | expect(attempts).to.equal(4); // 1 initial + 3 retries 133 | 134 | // Verify the onQueryRetry callback was called the expected number of times 135 | expect(onQueryRetrySpy.callCount).to.equal(3); 136 | } 137 | }); 138 | }); -------------------------------------------------------------------------------- /test/integration/return-final-sql-query.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { expect } = require('chai'); 4 | const { 5 | createTestConnection, 6 | setupTestTable, 7 | cleanupTestTable, 8 | closeConnection 9 | } = require('./helpers/setup'); 10 | 11 | describe('Return Final SQL Query Integration Tests', function () { 12 | this.timeout(15000); 13 | 14 | let db, dbWithoutLogging; 15 | const TEST_TABLE = 'final_sql_query_test_table'; 16 | const TABLE_SCHEMA = ` 17 | id INT AUTO_INCREMENT PRIMARY KEY, 18 | name VARCHAR(255) NOT NULL, 19 | active BOOLEAN DEFAULT true, 20 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 21 | `; 22 | 23 | before(async function () { 24 | try { 25 | db = createTestConnection({ returnFinalSqlQuery: true }); 26 | dbWithoutLogging = createTestConnection({ returnFinalSqlQuery: false }); 27 | await setupTestTable(db, TEST_TABLE, TABLE_SCHEMA); 28 | } catch (err) { 29 | this.skip(); 30 | } 31 | }); 32 | 33 | after(async function () { 34 | try { 35 | await cleanupTestTable(db, TEST_TABLE); 36 | 37 | if (db) { 38 | await db.end(); 39 | await closeConnection(db); 40 | } 41 | if (dbWithoutLogging) { 42 | await dbWithoutLogging.end(); 43 | await closeConnection(dbWithoutLogging); 44 | } 45 | } catch (err) { 46 | } 47 | }); 48 | 49 | it('should include SQL with substituted parameters when returnFinalSqlQuery is enabled', async function () { 50 | const insertResult = await db.query( 51 | 'INSERT INTO ?? (name, active) VALUES (?, ?)', 52 | [TEST_TABLE, 'Test User', true] 53 | ); 54 | 55 | expect(insertResult).to.have.property('sql'); 56 | const expectedInsertSql = `INSERT INTO \`${TEST_TABLE}\` (name, active) VALUES ('Test User', true)`; 57 | expect(insertResult.sql).to.equal(expectedInsertSql); 58 | 59 | const selectResult = await db.query( 60 | 'SELECT * FROM ?? WHERE name = ? AND active = ?', 61 | [TEST_TABLE, 'Test User', true] 62 | ); 63 | 64 | expect(selectResult).to.have.property('sql'); 65 | const expectedSelectSql = `SELECT * FROM \`${TEST_TABLE}\` WHERE name = 'Test User' AND active = true`; 66 | expect(selectResult.sql).to.equal(expectedSelectSql); 67 | }); 68 | 69 | it('should not include SQL when returnFinalSqlQuery is disabled', async function () { 70 | const insertResult = await dbWithoutLogging.query( 71 | 'INSERT INTO ?? (name, active) VALUES (?, ?)', 72 | [TEST_TABLE, 'No Log User', true] 73 | ); 74 | 75 | expect(insertResult).to.not.have.property('sql'); 76 | 77 | const selectResult = await dbWithoutLogging.query( 78 | 'SELECT * FROM ?? WHERE name = ? AND active = ?', 79 | [TEST_TABLE, 'No Log User', true] 80 | ); 81 | 82 | expect(selectResult).to.not.have.property('sql'); 83 | }); 84 | 85 | it('should include SQL with complex parameter types correctly', async function () { 86 | const testDate = new Date('2020-01-01T00:00:00Z'); 87 | 88 | const insertResult = await db.query( 89 | 'INSERT INTO ?? (name, active, created_at) VALUES (?, ?, ?)', 90 | [TEST_TABLE, 'Date User', true, testDate] 91 | ); 92 | 93 | expect(insertResult).to.have.property('sql'); 94 | const expectedInsertSql = `INSERT INTO \`${TEST_TABLE}\` (name, active, created_at) VALUES ('Date User', true, '2020-01-01 00:00:00.000')`; 95 | expect(insertResult.sql).to.equal(expectedInsertSql); 96 | 97 | const selectResult = await db.query( 98 | 'SELECT * FROM ?? WHERE name = ?', 99 | [TEST_TABLE, 'Date User'] 100 | ); 101 | 102 | expect(selectResult).to.have.property('sql'); 103 | const expectedSelectSql = `SELECT * FROM \`${TEST_TABLE}\` WHERE name = 'Date User'`; 104 | expect(selectResult.sql).to.equal(expectedSelectSql); 105 | }); 106 | 107 | it('should include SQL with object parameters correctly', async function () { 108 | const userData = { 109 | name: 'Object User', 110 | active: false 111 | }; 112 | 113 | const insertResult = await db.query( 114 | 'INSERT INTO ?? SET ?', 115 | [TEST_TABLE, userData] 116 | ); 117 | 118 | expect(insertResult).to.have.property('sql'); 119 | const expectedInsertSql = `INSERT INTO \`${TEST_TABLE}\` SET \`name\` = 'Object User', \`active\` = false`; 120 | expect(insertResult.sql).to.equal(expectedInsertSql); 121 | 122 | const selectResult = await db.query( 123 | 'SELECT * FROM ?? WHERE name = ? AND active = ?', 124 | [TEST_TABLE, 'Object User', false] 125 | ); 126 | 127 | expect(selectResult).to.have.property('sql'); 128 | const expectedSelectSql = `SELECT * FROM \`${TEST_TABLE}\` WHERE name = 'Object User' AND active = false`; 129 | expect(selectResult.sql).to.equal(expectedSelectSql); 130 | }); 131 | 132 | it('should include SQL in error objects when a query fails', async function () { 133 | try { 134 | await db.query( 135 | 'SELECT * FROM ?? WHERE nonexistent_column = ?', 136 | [TEST_TABLE, 'Test Value'] 137 | ); 138 | 139 | expect.fail('Query should have thrown an error'); 140 | } catch (error) { 141 | expect(error).to.have.property('sql'); 142 | const expectedSql = `SELECT * FROM \`${TEST_TABLE}\` WHERE nonexistent_column = 'Test Value'`; 143 | expect(error.sql).to.equal(expectedSql); 144 | expect(error).to.have.property('code'); 145 | expect(error.code).to.match(/^ER_/); 146 | } 147 | }); 148 | }); -------------------------------------------------------------------------------- /test/unit/README.md: -------------------------------------------------------------------------------- 1 | # Unit Tests 2 | 3 | This directory contains unit tests for the serverless-mysql module. Unit tests focus on testing individual components in isolation, without requiring a database connection. 4 | 5 | ## Running Unit Tests 6 | 7 | ```bash 8 | npm run test:unit 9 | ``` 10 | 11 | ## Directory Structure 12 | 13 | - `*.spec.js` - Unit test files 14 | - `helpers/` - Helper functions and mocks for unit tests 15 | 16 | ## Writing Unit Tests 17 | 18 | When writing unit tests, use the helpers in the `helpers/` directory to mock MySQL connections and other dependencies. This allows you to test the module's functionality without requiring a real database connection. 19 | 20 | Example: 21 | 22 | ```javascript 23 | const { expect } = require('chai'); 24 | const sinon = require('sinon'); 25 | const { createMockMySQLModule } = require('./helpers/mocks'); 26 | 27 | describe('My Test Suite', function() { 28 | it('should do something', function() { 29 | // Arrange 30 | const mockMySQL = createMockMySQLModule(); 31 | 32 | // Act 33 | // ... test code ... 34 | 35 | // Assert 36 | expect(result).to.equal(expectedValue); 37 | }); 38 | }); -------------------------------------------------------------------------------- /test/unit/connection-config.spec.js: -------------------------------------------------------------------------------- 1 | const assert = require("assert"); 2 | const mysql = require("../../index"); 3 | 4 | describe("Test a connection config", () => { 5 | it("Should get a valid connection config when a config is passed in", () => { 6 | const configs = [ 7 | mysql({ 8 | config: { 9 | host: "localhost", 10 | database: "database", 11 | user: "user", 12 | password: "password", 13 | }, 14 | }).getConfig(), 15 | mysql().config({ 16 | host: "localhost", 17 | database: "database", 18 | user: "user", 19 | password: "password", 20 | }), 21 | ]; 22 | 23 | configs.forEach((config) => { 24 | assert.deepEqual(config, { 25 | host: "localhost", 26 | database: "database", 27 | user: "user", 28 | password: "password", 29 | }); 30 | }); 31 | }); 32 | 33 | it("Should get a valid connection config when a connection string is passed in", () => { 34 | const configs = [ 35 | mysql("mysql://user:password@localhost:3306/database").getConfig(), 36 | mysql().config("mysql://user:password@localhost:3306/database"), 37 | ]; 38 | 39 | configs.forEach((config) => { 40 | assert.deepEqual(config, { 41 | host: "localhost", 42 | database: "database", 43 | user: "user", 44 | password: "password", 45 | port: 3306, 46 | }); 47 | }); 48 | }); 49 | 50 | it("Should throw an exception when an invalid connection string is passed in", () => { 51 | assert.throws(() => mysql("mysql://:3306/database").getConfig()); 52 | assert.throws(() => mysql("mysql://:3306").getConfig()); 53 | }); 54 | 55 | it("Should throw an error with an invalid connection string format", () => { 56 | assert.throws(() => { 57 | mysql("invalid-connection-string"); 58 | }, /Invalid data source URL provided/); 59 | }); 60 | 61 | it("Should throw an error with a malformed connection string", () => { 62 | assert.throws(() => { 63 | mysql("mysql://user:password@"); 64 | }, /Invalid data source URL provided/); 65 | }); 66 | 67 | it("Should handle connection string with missing credentials gracefully", () => { 68 | const db = mysql("mysql://localhost:3306/"); 69 | const config = db.getConfig(); 70 | 71 | assert.strictEqual(config.host, "localhost"); 72 | assert.strictEqual(config.port, 3306); 73 | }); 74 | 75 | it("Should parse additional parameters from connection string", () => { 76 | const db = mysql("mysql://user:password@localhost:3306/database?connectTimeout=10000&dateStrings=true"); 77 | const config = db.getConfig(); 78 | 79 | assert.strictEqual(config.host, "localhost"); 80 | assert.strictEqual(config.database, "database"); 81 | assert.strictEqual(config.user, "user"); 82 | assert.strictEqual(config.password, "password"); 83 | assert.strictEqual(config.port, 3306); 84 | assert.strictEqual(config.connectTimeout, "10000"); 85 | assert.strictEqual(config.dateStrings, "true"); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /test/unit/helpers/mocks.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const sinon = require('sinon'); 4 | 5 | /** 6 | * Mock MySQL connection for unit tests 7 | * @returns {Object} Mock MySQL connection object 8 | */ 9 | function createMockConnection() { 10 | return { 11 | connect: sinon.stub().resolves({}), 12 | query: sinon.stub().resolves([{ value: 1 }]), 13 | end: sinon.stub().resolves({}), 14 | destroy: sinon.stub(), 15 | on: sinon.stub(), 16 | escape: sinon.stub().callsFake(value => `'${value}'`), 17 | format: sinon.stub().callsFake((sql, values) => { 18 | if (!values) return sql; 19 | const vals = [...values]; // Create a copy to avoid modifying the original 20 | return sql.replace(/\?/g, () => { 21 | if (!vals.length) return '?'; 22 | return `'${vals.shift()}'`; 23 | }); 24 | }) 25 | }; 26 | } 27 | 28 | /** 29 | * Mock MySQL module for unit tests 30 | * @returns {Object} Mock MySQL module 31 | */ 32 | function createMockMySQLModule() { 33 | const mockConnection = createMockConnection(); 34 | 35 | return { 36 | createConnection: sinon.stub().returns(mockConnection), 37 | createPool: sinon.stub().returns({ 38 | getConnection: sinon.stub().resolves(mockConnection), 39 | end: sinon.stub().resolves({}) 40 | }), 41 | format: mockConnection.format, 42 | escape: mockConnection.escape 43 | }; 44 | } 45 | 46 | module.exports = { 47 | createMockConnection, 48 | createMockMySQLModule 49 | }; --------------------------------------------------------------------------------