├── .editorconfig ├── .eslintrc.js ├── .github └── workflows │ ├── benchmark.yml │ ├── dependencies.yml │ ├── integration.yml │ ├── notification.yml │ └── unit.yml ├── .gitignore ├── .npmignore ├── .vscode ├── launch.json └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── TODO.md ├── benchmark ├── .eslintrc.js ├── generate-result.js ├── index.js ├── results │ ├── common │ │ ├── README.md │ │ └── benchmark_results.json │ └── transform │ │ ├── README.md │ │ └── benchmark_results.json ├── suites │ ├── .gitignore │ ├── common.js │ ├── native-nedb.js │ └── transform.js └── utils.js ├── docs ├── README.md └── adapters │ ├── Knex.md │ ├── MongoDB.md │ └── NeDB.md ├── examples ├── connect │ └── index.js ├── global-pool │ └── index.js ├── index.js ├── knex-migration │ ├── index.js │ └── migrations │ │ ├── 20210425191836_init.js │ │ └── 20210425193759_author.js ├── many │ └── index.js ├── multi-tenants │ ├── .gitignore │ ├── index.js │ └── plenty.js └── simple │ └── index.js ├── index.js ├── package-lock.json ├── package.json ├── prettier.config.js ├── src ├── actions.js ├── adapters │ ├── base.js │ ├── index.js │ ├── knex.js │ ├── mongodb.js │ └── nedb.js ├── constants.js ├── errors.js ├── index.js ├── methods.js ├── monitoring.js ├── schema.js ├── transform.js ├── utils.js └── validation.js └── test ├── docker-compose.yml ├── integration ├── actions.test.js ├── adapter.test.js ├── index.spec.js ├── methods.test.js ├── populate.test.js ├── rest.test.js ├── scopes.test.js ├── tenants.test.js ├── transform.test.js ├── utils.js └── validation.test.js ├── leak-detection ├── index.spec.js └── self-check.spec.js ├── scripts ├── mysql-create-databases.sql └── pg-create-databases.sql └── unit ├── schema.spec.js ├── scoping.spec.js ├── utils.spec.js └── validation.spec.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | 9 | # Change these settings to your own preference 10 | indent_style = tab 11 | indent_size = 4 12 | space_after_anon_function = true 13 | 14 | # We recommend you to keep these unchanged 15 | end_of_line = lf 16 | charset = utf-8 17 | trim_trailing_whitespace = true 18 | insert_final_newline = true 19 | 20 | [*.md] 21 | trim_trailing_whitespace = false 22 | indent_style = space 23 | indent_size = 4 24 | 25 | [{package,bower}.json] 26 | indent_style = space 27 | indent_size = 2 28 | 29 | [*.{yml,yaml}] 30 | indent_style = space 31 | indent_size = 2 32 | 33 | [*.js] 34 | quote_type = "double" 35 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | node: true, 4 | commonjs: true, 5 | es6: true, 6 | jquery: false, 7 | jest: true, 8 | jasmine: true 9 | }, 10 | extends: ["eslint:recommended", "plugin:security/recommended", "plugin:prettier/recommended"], 11 | parserOptions: { 12 | sourceType: "module", 13 | ecmaVersion: 2018 14 | }, 15 | plugins: ["node", "promise", "security"], 16 | rules: { 17 | "no-var": ["error"], 18 | "no-console": ["warn"], 19 | "no-unused-vars": ["warn"], 20 | "no-trailing-spaces": ["error"], 21 | "security/detect-object-injection": ["off"], 22 | "security/detect-non-literal-require": ["off"], 23 | "security/detect-non-literal-fs-filename": ["off"], 24 | "no-process-exit": ["off"], 25 | "node/no-unpublished-require": 0, 26 | "require-atomic-updates": 0, 27 | "object-curly-spacing": ["warn", "always"] 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /.github/workflows/benchmark.yml: -------------------------------------------------------------------------------- 1 | name: Benchmark Test 2 | 3 | on: [workflow_dispatch] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v4 11 | 12 | - name: Use Node.js 13 | uses: actions/setup-node@v4 14 | with: 15 | node-version: 22.x 16 | 17 | - name: Install dependencies 18 | run: npm ci 19 | 20 | - name: Start containers 21 | run: docker compose up -d 22 | working-directory: ./test 23 | 24 | - name: Sleeping 30 secs 25 | run: sleep 30 26 | 27 | - name: Check containers 28 | run: docker compose ps 29 | working-directory: ./test 30 | 31 | - name: Run benchmark tests 32 | run: npm run bench 33 | timeout-minutes: 15 34 | env: 35 | GITHUB_ACTIONS_CI: true 36 | 37 | - name: Show container logs (in case of failure) 38 | run: docker compose logs 39 | if: failure() 40 | working-directory: ./test 41 | 42 | - name: Stop containers 43 | run: docker compose down -v 44 | working-directory: ./test 45 | 46 | - name: Commit the result 47 | if: success() 48 | uses: EndBug/add-and-commit@v7 # You can change this to use a specific version 49 | with: 50 | # The arguments for the `git add` command (see the paragraph below for more info) 51 | # Default: '.' 52 | add: './benchmark/results' 53 | default_author: github_actions 54 | push: true 55 | -------------------------------------------------------------------------------- /.github/workflows/dependencies.yml: -------------------------------------------------------------------------------- 1 | name: Update dependencies 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 1 * *' 6 | workflow_dispatch: 7 | jobs: 8 | update-dependencies: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Use Node.js 13 | uses: actions/setup-node@v4 14 | with: 15 | node-version: '22.x' 16 | 17 | - run: npm ci 18 | 19 | - run: | 20 | git config user.name "GitHub Actions Bot" 21 | git config user.email "hello@moleculer.services" 22 | git checkout -b update-deps-$GITHUB_RUN_ID 23 | 24 | - run: npm run ci-update-deps 25 | - run: npm i 26 | - run: npm audit fix || true 27 | 28 | - run: | 29 | git commit -am "Update dependencies (auto)" 30 | git push origin update-deps-$GITHUB_RUN_ID 31 | - run: | 32 | gh pr create --title "[deps]: Update all dependencies" --body "" 33 | env: 34 | GITHUB_TOKEN: ${{secrets.CI_ACCESS_TOKEN}} 35 | -------------------------------------------------------------------------------- /.github/workflows/integration.yml: -------------------------------------------------------------------------------- 1 | name: Integration Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - '**' 7 | paths-ignore: 8 | - '.vscode/**' 9 | - 'benchmark/**' 10 | - 'docs/**' 11 | - 'examples/**' 12 | - '*.md' 13 | 14 | pull_request: 15 | branches: 16 | - master 17 | paths-ignore: 18 | - '.vscode/**' 19 | - 'benchmark/**' 20 | - 'docs/**' 21 | - 'examples/**' 22 | - '*.md' 23 | 24 | jobs: 25 | test: 26 | runs-on: ubuntu-latest 27 | 28 | strategy: 29 | matrix: 30 | node-version: [20.x, 22.x, 24.x] 31 | adapter: ["NeDB", "MongoDB", "Knex-SQLite", "Knex-Postgresql", "Knex-MySQL", "Knex-MySQL2", "Knex-MSSQL"] 32 | fail-fast: false 33 | steps: 34 | - uses: actions/checkout@v4 35 | 36 | - name: Use Node.js ${{ matrix.node-version }} 37 | uses: actions/setup-node@v4 38 | with: 39 | node-version: ${{ matrix.node-version }} 40 | 41 | - name: Install dependencies 42 | run: npm ci 43 | 44 | - name: Start containers for ${{ matrix.adapter }} adapter 45 | run: docker compose up -d mongo && sleep 30 46 | if: ${{ matrix.adapter == 'MongoDB' }} 47 | working-directory: ./test 48 | 49 | - name: Start containers for ${{ matrix.adapter }} adapter 50 | run: docker compose up -d postgres && sleep 30 51 | if: ${{ matrix.adapter == 'Knex-Postgresql' }} 52 | working-directory: ./test 53 | 54 | - name: Start containers for ${{ matrix.adapter }} adapter 55 | run: docker compose up -d mysql && sleep 30 56 | if: ${{ matrix.adapter == 'Knex-MySQL' || matrix.adapter == 'Knex-MySQL2' }} 57 | working-directory: ./test 58 | 59 | - name: Start containers for ${{ matrix.adapter }} adapter 60 | run: docker compose up -d mssql mssql-create-db && sleep 60 61 | if: ${{ matrix.adapter == 'Knex-MSSQL' }} 62 | working-directory: ./test 63 | 64 | - name: Check containers 65 | run: docker compose ps 66 | working-directory: ./test 67 | 68 | - name: Check logs 69 | run: docker compose logs 70 | working-directory: ./test 71 | 72 | - name: Run integration tests 73 | run: npm run test:integration 74 | timeout-minutes: 10 75 | env: 76 | GITHUB_ACTIONS_CI: true 77 | ADAPTER: ${{ matrix.adapter }} 78 | 79 | # - name: Run leak detection tests 80 | # run: npm run test:leak 81 | # env: 82 | # GITHUB_ACTIONS_CI: true 83 | 84 | - name: Show container logs (in case of failure) 85 | run: docker compose logs 86 | if: failure() 87 | working-directory: ./test 88 | 89 | - name: Stop containers 90 | run: docker compose down -v 91 | working-directory: ./test 92 | 93 | # - name: Upload code coverage 94 | # run: npm run coverall 95 | # if: success() && github.ref == 'refs/heads/master' 96 | # env: 97 | # COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_TOKEN }} 98 | -------------------------------------------------------------------------------- /.github/workflows/notification.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: Discord Notification 4 | 5 | on: 6 | release: 7 | types: 8 | - published 9 | 10 | jobs: 11 | notify: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Discord notification 16 | env: 17 | DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} 18 | uses: Ilshidur/action-discord@master 19 | with: 20 | args: ":tada: **The {{ EVENT_PAYLOAD.repository.name }} {{ EVENT_PAYLOAD.release.tag_name }} has been released.**:tada:\nChangelog: {{EVENT_PAYLOAD.release.html_url}}" 21 | -------------------------------------------------------------------------------- /.github/workflows/unit.yml: -------------------------------------------------------------------------------- 1 | name: Unit Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - '**' 7 | paths-ignore: 8 | - '.vscode/**' 9 | - 'benchmark/**' 10 | - 'docs/**' 11 | - 'examples/**' 12 | - '*.md' 13 | 14 | pull_request: 15 | branches: 16 | - master 17 | paths-ignore: 18 | - '.vscode/**' 19 | - 'benchmark/**' 20 | - 'docs/**' 21 | - 'examples/**' 22 | - '*.md' 23 | 24 | jobs: 25 | test: 26 | runs-on: ubuntu-latest 27 | 28 | strategy: 29 | matrix: 30 | node-version: [20.x, 22.x, 24.x] 31 | fail-fast: false 32 | steps: 33 | - uses: actions/checkout@v4 34 | 35 | - name: Use Node.js ${{ matrix.node-version }} 36 | uses: actions/setup-node@v4 37 | with: 38 | node-version: ${{ matrix.node-version }} 39 | 40 | - name: Install dependencies 41 | run: npm ci 42 | 43 | - name: Run unit tests 44 | run: npm run test:unit 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | # Sqlite DB files 107 | *.db-journal 108 | *.sqlite3 109 | *.sqlite3-journal 110 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | .github/ 3 | benchmark/ 4 | coverage/ 5 | dev/ 6 | docs/ 7 | example/ 8 | examples/ 9 | typings/ 10 | test/ 11 | *.db 12 | .editorconfig 13 | .eslintrc.js 14 | prettier.config.js 15 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible Node.js debug attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | 8 | { 9 | "type": "node", 10 | "request": "launch", 11 | "name": "Launch demo", 12 | "program": "${workspaceRoot}/examples/index.js", 13 | "cwd": "${workspaceRoot}", 14 | "args": [ 15 | "simple" 16 | ] 17 | }, 18 | { 19 | "type": "node", 20 | "request": "launch", 21 | "name": "Launch selected demo", 22 | "program": "${file}", 23 | "cwd": "${workspaceRoot}" 24 | }, 25 | { 26 | "type": "node", 27 | "request": "launch", 28 | "name": "Jest", 29 | "program": "${workspaceRoot}/node_modules/jest-cli/bin/jest.js", 30 | "args": ["--testMatch", "\"**/integration/**/*.spec.js\"", "--runInBand"], 31 | "cwd": "${workspaceRoot}", 32 | "runtimeArgs": [ 33 | "--nolazy" 34 | ], 35 | "env": { 36 | "ADAPTER": "Knex-Postgresql" 37 | } 38 | }, 39 | { 40 | "type": "node", 41 | "request": "launch", 42 | "name": "Jest single", 43 | "program": "${workspaceRoot}/node_modules/jest-cli/bin/jest.js", 44 | "args": ["--runInBand", "${fileBasenameNoExtension}"], 45 | "console": "internalConsole", 46 | "cwd": "${workspaceRoot}", 47 | "runtimeArgs": [ 48 | "--nolazy" 49 | ] 50 | }, 51 | ] 52 | } 53 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | 3 | "jest.autoRun": "off" 4 | } 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # [0.3.0](https://github.com/moleculerjs/database/compare/v0.2.1...v0.3.0) (2025-05-31) 4 | 5 | - update dependencies. 6 | - Minimum node version is 20. 7 | - switch from `nedb` to `@seald-io/nedb` to support Node 24+. 8 | - 9 | 10 | 11 | # [0.2.1](https://github.com/moleculerjs/database/compare/v0.2.0...v0.2.1) (2024-07-28) 12 | 13 | - update dependencies 14 | - add moleculer 0.15 peer dependency 15 | 16 | 17 | 18 | # [0.2.0](https://github.com/moleculerjs/database/compare/v0.1.1...v0.2.0) (2024-04-01) 19 | 20 | **Breaking changes** 21 | - upgrade `knex` to 3.1.0 22 | - upgrade `mongodb` to 6.5.0 23 | - minimum Node version bumped to 18 24 | 25 | 26 | 27 | # [0.1.1](https://github.com/moleculerjs/database/compare/v0.1.0...v0.1.1) (2023-04-23) 28 | 29 | - fix permission/permissive type [#31](https://github.com/moleculerjs/moleculer-channels/pull/31) 30 | - fix TypeError `createFromHexString` [#40](https://github.com/moleculerjs/moleculer-channels/pull/40) 31 | - fix waiting for adapter in connecting state [#2d9888e](https://github.com/moleculerjs/database/commit/2d9888e497363ac88aa3b62c354d680d53b3213b) 32 | 33 | 34 | 35 | # v0.1.0 (2022-10-02) 36 | 37 | First public version. 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 MoleculerJS 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 | ![Moleculer logo](http://moleculer.services/images/banner.png) 2 | 3 | ![Integration Test](https://github.com/moleculerjs/database/workflows/Integration%20Test/badge.svg) 4 | [![Coverage Status](https://coveralls.io/repos/github/moleculerjs/database/badge.svg?branch=master)](https://coveralls.io/github/moleculerjs/database?branch=master) 5 | [![Known Vulnerabilities](https://snyk.io/test/github/moleculerjs/database/badge.svg)](https://snyk.io/test/github/moleculerjs/database) 6 | [![NPM version](https://badgen.net/npm/v/@moleculer/database)](https://www.npmjs.com/package/@moleculer/database) 7 | 8 | # @moleculer/database 9 | Advanced Database Access Service for Moleculer microservices framework. Use it to persist your data in a database. 10 | 11 | >this module follows the *one database per service* pattern. To learn more about this design pattern and its implications check this [article](https://microservices.io/patterns/data/database-per-service.html). For *multiple entities/tables per service* approach check [FAQ](https://moleculer.services/docs/0.14/faq.html#How-can-I-manage-multiple-entities-tables-per-service). 12 | 13 | ## Features 14 | - multiple pluggable adapters (NeDB, MongoDB, Knex) 15 | - common CRUD actions for RESTful API with caching 16 | - pagination, field filtering support 17 | - field sanitizations, validations 18 | - read-only, immutable, virtual fields 19 | - field permissions (read/write) 20 | - ID field encoding 21 | - data transformation 22 | - populating between Moleculer services 23 | - create/update/remove hooks 24 | - soft delete mode 25 | - scopes support 26 | - entity lifecycle events 27 | - Multi-tenancy 28 | 29 | ## Install 30 | ``` 31 | npm i @moleculer/database @seald-io/nedb 32 | ``` 33 | > Installing `@seald-io/nedb` is optional. It can be good for prototyping. 34 | 35 | ## Usage 36 | 37 | **Define the service** 38 | ```js 39 | // posts.service.js 40 | 41 | const DbService = require("@moleculer/database").Service; 42 | 43 | module.exports = { 44 | name: "posts", 45 | mixins: [ 46 | DbService({ 47 | adapter: "NeDB" 48 | }) 49 | ], 50 | 51 | settings: { 52 | fields: { 53 | id: { type: "string", primaryKey: true, columnName: "_id" }, 54 | title: { type: "string", max: 255, trim: true, required: true }, 55 | content: { type: "string" }, 56 | votes: { type: "number", integer: true, min: 0, default: 0 }, 57 | status: { type: "boolean", default: true }, 58 | createdAt: { type: "number", readonly: true, onCreate: () => Date.now() }, 59 | updatedAt: { type: "number", readonly: true, onUpdate: () => Date.now() } 60 | } 61 | } 62 | } 63 | ``` 64 | 65 | **Call the actions** 66 | ```js 67 | // sample.js 68 | 69 | // Create a new post 70 | let post = await broker.call("posts.create", { 71 | title: "My first post", 72 | content: "Content of my first post..." 73 | }); 74 | console.log("New post:", post); 75 | /* Results: 76 | New post: { 77 | id: 'Zrpjq8B1XTSywUgT', 78 | title: 'My first post', 79 | content: 'Content of my first post...', 80 | votes: 0, 81 | status: true, 82 | createdAt: 1618065551990 83 | } 84 | */ 85 | 86 | // Get all posts 87 | let posts = await broker.call("posts.find", { sort: "-createdAt" }); 88 | console.log("Find:", posts); 89 | 90 | // List posts with pagination 91 | posts = await broker.call("posts.list", { page: 1, pageSize: 10 }); 92 | console.log("List:", posts); 93 | 94 | // Get a post by ID 95 | post = await broker.call("posts.get", { id: post.id }); 96 | console.log("Get:", post); 97 | 98 | // Update the post 99 | post = await broker.call("posts.update", { id: post.id, title: "Modified post" }); 100 | console.log("Updated:", post); 101 | 102 | // Delete a user 103 | const res = await broker.call("posts.remove", { id: post.id }); 104 | console.log("Deleted:", res); 105 | ``` 106 | 107 | [**Try it in your browser on repl.it**](https://replit.com/@icebob/moleculer-database-common) 108 | 109 | ## Documentation 110 | You can find [here the documentation](docs/README.md). 111 | 112 | ## Benchmark 113 | There is some benchmark with all adapters. [You can find the results here.](benchmark/results/common/README.md) 114 | 115 | ## License 116 | The project is available under the [MIT license](https://tldrlegal.com/license/mit-license). 117 | 118 | ## Contact 119 | Copyright (c) 2025 MoleculerJS 120 | 121 | [![@MoleculerJS](https://img.shields.io/badge/github-moleculerjs-green.svg)](https://github.com/moleculerjs) [![@MoleculerJS](https://img.shields.io/badge/twitter-MoleculerJS-blue.svg)](https://twitter.com/MoleculerJS) 122 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | 3 | - [ ] **More integration tests** 4 | - [ ] createIndex, removeIndex 5 | - [ ] Knex createCursor operators `$` 6 | - [ ] global pool handling 7 | - [x] soft delete 8 | 9 | ## Actions 10 | - [ ] `aggregate` action with params: `type: "sum", "avg", "count", "min", "max"` & `field: "price"` 11 | 12 | ## Fields 13 | - [ ] `hidden: "inLists"` 14 | - [ ] using projections, get only required fields in adapters. 15 | - [ ] `projection: []` for getter/setters 16 | 17 | ## Methods 18 | - [x] wrap the args to obj in custom functions: `({ ctx, id, field })` 19 | - [ ] same for `get` 20 | - [ ] multiple get option for `get` in transform (same as populating to avoid hundreds sub-calls) 21 | - [x] if `field.validate` is string, call the method by name 22 | - [ ] same for `get` 23 | - [x] same for `set` 24 | - [ ] option to disable validators `opts.validation: false` 25 | - [x] add `scope` param for `removeEntities` and `updateEntities` and pass to the `findEntities` call inside the method. 26 | - [x] skipping softDelete in methods `opts.softDelete: false` to make real delete in maintenance processes 27 | - [ ] Add `events: false` to options to disable entity changed events. (e.g. when `softDelete: false` we don't want to send event about changes) 28 | 29 | 30 | ## Just if I bored to death 31 | - [ ] generate OpenAPI schema 32 | - [ ] `introspect: true` 33 | - [ ] create REST API to get the field definitions, and actions with params 34 | - [ ] Generate sample data based on fields with Fakerator. `username: { type: "string", fake: "entity.user.userName" }` 35 | - [ ] validate `raw: true` update fields (`$set`, `$inc`) 36 | - [ ] client-side (Vue) module which can communicate with service via REST or GraphQL. 37 | - [ ] ad-hoc populate in find/list actions `populate: ["author", { key: "createdBy", action: "users.resolve", fields: ["name", "avatar"] }]` { } 38 | - [ ] auto revision handling (`_rev: 1`). Autoincrement on every update/replace and always checks the rev value to avoid concurrent updating. 39 | - [ ] 40 | -------------------------------------------------------------------------------- /benchmark/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | node: true, 4 | commonjs: true, 5 | es6: true 6 | }, 7 | extends: ["eslint:recommended"], 8 | parserOptions: { 9 | sourceType: "module", 10 | ecmaVersion: 2017, 11 | ecmaFeatures: { 12 | experimentalObjectRestSpread: true 13 | } 14 | }, 15 | rules: { 16 | indent: ["warn", "tab", { SwitchCase: 1 }], 17 | quotes: ["warn", "double"], 18 | semi: ["error", "always"], 19 | "no-var": ["warn"], 20 | "no-console": ["off"], 21 | "no-unused-vars": ["off"], 22 | "no-trailing-spaces": ["error"], 23 | "security/detect-possible-timing-attacks": ["off"] 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /benchmark/generate-result.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const fs = require("fs"); 4 | const path = require("path"); 5 | const globby = require("globby"); 6 | const humanize = require("tiny-human-time"); 7 | const { saveMarkdown, createChartURL, numToStr, makeTableRow } = require("./utils"); 8 | 9 | async function generateMarkdown(folderName) { 10 | const folder = path.join(__dirname, "results", folderName); 11 | const files = await globby(["*.json"], { cwd: folder }); 12 | if (files.length == 0) return; 13 | 14 | console.log("Found files:", files); 15 | 16 | const results = files 17 | .map(filename => fs.readFileSync(path.join(folder, filename), "utf8")) 18 | .map(content => JSON.parse(content)); 19 | 20 | const rows = ["", ""]; 21 | 22 | for (const result of results) { 23 | console.log("Process test:", result.name); 24 | 25 | rows.push(`# ${result.name}`); 26 | rows.push(`${result.description || {}}`); 27 | 28 | if (result.meta.adapters) { 29 | rows.push( 30 | "## Test configurations", 31 | "", 32 | "| Name | Adapter | Options |", 33 | "| ---- | ------- | ------- |" 34 | ); 35 | for (const adapter of result.meta.adapters) { 36 | let options = JSON.stringify(adapter.options); 37 | if (options) { 38 | //options = options.replace(/\n/g, "
").replace(/ /g, " "); 39 | options = "`" + options + "`"; 40 | } 41 | rows.push( 42 | makeTableRow([adapter.name || adapter.type, adapter.type, options || "-"]) 43 | ); 44 | } 45 | } 46 | 47 | for (const suite of result.suites) { 48 | rows.push(`## ${suite.name}`); 49 | if (suite.meta && suite.meta.description) rows.push(suite.meta.description); 50 | rows.push("", "### Result"); 51 | rows.push(""); 52 | 53 | rows.push( 54 | "", 55 | "| Adapter config | Time | Diff | ops/sec |", 56 | "| -------------- | ----:| ----:| -------:|" 57 | ); 58 | 59 | suite.tests.forEach(test => { 60 | rows.push( 61 | makeTableRow([ 62 | test.name, 63 | humanize.short(test.stat.avg * 1000), 64 | numToStr(test.stat.percent) + "%", 65 | numToStr(test.stat.rps) 66 | ]) 67 | ); 68 | }); 69 | rows.push(""); 70 | 71 | rows.push( 72 | "![chart](" + 73 | createChartURL({ 74 | chs: "999x500", 75 | chtt: `${suite.name}|(ops/sec)`, 76 | chf: "b0,lg,90,03a9f4,0,3f51b5,1", 77 | chg: "0,50", 78 | chma: "0,0,10,10", 79 | cht: "bvs", 80 | chxt: "x,y", 81 | chxs: "0,333,10|1,333,10", 82 | 83 | chxl: "0:|" + suite.tests.map(s => s.name).join("|"), 84 | chd: "a:" + suite.tests.map(s => s.stat.rps).join(",") 85 | }) + 86 | ")" 87 | ); 88 | rows.push(""); 89 | } 90 | } 91 | 92 | rows.push("--------------------"); 93 | rows.push(`_Generated at ${new Date().toISOString()}_`); 94 | 95 | const filename = path.join(folder, "README.md"); 96 | console.log("Write to file..."); 97 | saveMarkdown(filename, rows); 98 | 99 | console.log("Done. Result:", filename); 100 | } 101 | 102 | module.exports = { generateMarkdown }; 103 | 104 | //generateMarkdown("common"); 105 | -------------------------------------------------------------------------------- /benchmark/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | require("./suites/" + (process.argv[2] || "common")); -------------------------------------------------------------------------------- /benchmark/results/common/README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Moleculer Database benchmark - Common 4 | This is a common benchmark which create, list, get, update, replace and delete entities via service actions. 5 | ## Test configurations 6 | 7 | | Name | Adapter | Options | 8 | | ---- | ------- | ------- | 9 | | NeDB (memory) | NeDB | - | 10 | | NeDB (file) | NeDB | `"/home/runner/work/database/database/benchmark/suites/tmp/common.db"` | 11 | | MongoDB | MongoDB | `{"dbName":"bench_test","collection":"users"}` | 12 | | Knex SQLite (memory) | Knex | `{"knex":{"client":"sqlite3","connection":{"filename":":memory:"},"useNullAsDefault":true,"log":{}}}` | 13 | | Knex SQLite (file) | Knex | `{"knex":{"client":"sqlite3","connection":{"filename":"/home/runner/work/database/database/benchmark/suites/tmp/common.sqlite3"},"useNullAsDefault":true,"pool":{"min":1,"max":1},"log":{}}}` | 14 | | Knex-Postgresql | Knex | `{"knex":{"client":"pg","connection":{"host":"127.0.0.1","port":5432,"user":"postgres","password":"moleculer","database":"bench_test"}}}` | 15 | | Knex-MySQL | Knex | `{"knex":{"client":"mysql","connection":{"host":"127.0.0.1","user":"root","password":"moleculer","database":"bench_test"},"log":{}}}` | 16 | | Knex-MySQL2 | Knex | `{"knex":{"client":"mysql2","connection":{"host":"127.0.0.1","user":"root","password":"moleculer","database":"bench_test"},"log":{}}}` | 17 | | Knex-MSSQL | Knex | `{"knex":{"client":"mssql","connection":{"host":"127.0.0.1","port":1433,"user":"sa","password":"Moleculer@Pass1234","database":"bench_test","encrypt":false}}}` | 18 | ## Entity creation 19 | 20 | ### Result 21 | 22 | 23 | | Adapter config | Time | Diff | ops/sec | 24 | | -------------- | ----:| ----:| -------:| 25 | | NeDB (memory) | 73μs | 728.68% | 13,586.12 | 26 | | NeDB (file) | 229μs | 165.51% | 4,353.07 | 27 | | MongoDB | 609μs | 0% | 1,639.5 | 28 | | Knex SQLite (memory) | 476μs | 27.9% | 2,096.89 | 29 | | Knex SQLite (file) | 2ms | -71.43% | 468.47 | 30 | | Knex-Postgresql | 1ms | -61% | 639.48 | 31 | | Knex-MySQL | 2ms | -73.05% | 441.89 | 32 | | Knex-MySQL2 | 2ms | -71.85% | 461.47 | 33 | | Knex-MSSQL | 3ms | -82.74% | 282.95 | 34 | 35 | ![chart](https://image-charts.com/chart?chs=999x500&chtt=Entity%20creation%7C%28ops%2Fsec%29&chf=b0%2Clg%2C90%2C03a9f4%2C0%2C3f51b5%2C1&chg=0%2C50&chma=0%2C0%2C10%2C10&cht=bvs&chxt=x%2Cy&chxs=0%2C333%2C10%7C1%2C333%2C10&chxl=0%3A%7CNeDB%20%28memory%29%7CNeDB%20%28file%29%7CMongoDB%7CKnex%20SQLite%20%28memory%29%7CKnex%20SQLite%20%28file%29%7CKnex-Postgresql%7CKnex-MySQL%7CKnex-MySQL2%7CKnex-MSSQL&chd=a%3A13586.116635435921%2C4353.0703478040405%2C1639.4984632475391%2C2096.8892025713135%2C468.4705145169576%2C639.4804870754687%2C441.8904653751201%2C461.4701662257132%2C282.94751100467715) 36 | 37 | ## Entity finding 38 | 39 | ### Result 40 | 41 | 42 | | Adapter config | Time | Diff | ops/sec | 43 | | -------------- | ----:| ----:| -------:| 44 | | NeDB (memory) | 290μs | 248.58% | 3,438.72 | 45 | | NeDB (file) | 286μs | 253.88% | 3,491.05 | 46 | | MongoDB | 1ms | 0% | 986.5 | 47 | | Knex SQLite (memory) | 412μs | 145.77% | 2,424.54 | 48 | | Knex SQLite (file) | 404μs | 150.31% | 2,469.34 | 49 | | Knex-Postgresql | 717μs | 41.2% | 1,392.97 | 50 | | Knex-MySQL | 989μs | 2.4% | 1,010.17 | 51 | | Knex-MySQL2 | 914μs | 10.85% | 1,093.54 | 52 | | Knex-MSSQL | 1ms | -23.76% | 752.09 | 53 | 54 | ![chart](https://image-charts.com/chart?chs=999x500&chtt=Entity%20finding%7C%28ops%2Fsec%29&chf=b0%2Clg%2C90%2C03a9f4%2C0%2C3f51b5%2C1&chg=0%2C50&chma=0%2C0%2C10%2C10&cht=bvs&chxt=x%2Cy&chxs=0%2C333%2C10%7C1%2C333%2C10&chxl=0%3A%7CNeDB%20%28memory%29%7CNeDB%20%28file%29%7CMongoDB%7CKnex%20SQLite%20%28memory%29%7CKnex%20SQLite%20%28file%29%7CKnex-Postgresql%7CKnex-MySQL%7CKnex-MySQL2%7CKnex-MSSQL&chd=a%3A3438.720435314497%2C3491.04772807352%2C986.5041158429495%2C2424.5355020939173%2C2469.3384265781087%2C1392.9730000461293%2C1010.1657141060454%2C1093.5356900715144%2C752.0861968126092) 55 | 56 | ## Entity listing 57 | 58 | ### Result 59 | 60 | 61 | | Adapter config | Time | Diff | ops/sec | 62 | | -------------- | ----:| ----:| -------:| 63 | | NeDB (memory) | 1ms | 30.07% | 646.22 | 64 | | NeDB (file) | 1ms | 26.12% | 626.57 | 65 | | MongoDB | 2ms | 0% | 496.82 | 66 | | Knex SQLite (memory) | 581μs | 246.1% | 1,719.49 | 67 | | Knex SQLite (file) | 572μs | 251.74% | 1,747.53 | 68 | | Knex-Postgresql | 1ms | 70.99% | 849.53 | 69 | | Knex-MySQL | 2ms | -10.87% | 442.81 | 70 | | Knex-MySQL2 | 1ms | 4.68% | 520.05 | 71 | | Knex-MSSQL | 2ms | -1.95% | 487.15 | 72 | 73 | ![chart](https://image-charts.com/chart?chs=999x500&chtt=Entity%20listing%7C%28ops%2Fsec%29&chf=b0%2Clg%2C90%2C03a9f4%2C0%2C3f51b5%2C1&chg=0%2C50&chma=0%2C0%2C10%2C10&cht=bvs&chxt=x%2Cy&chxs=0%2C333%2C10%7C1%2C333%2C10&chxl=0%3A%7CNeDB%20%28memory%29%7CNeDB%20%28file%29%7CMongoDB%7CKnex%20SQLite%20%28memory%29%7CKnex%20SQLite%20%28file%29%7CKnex-Postgresql%7CKnex-MySQL%7CKnex-MySQL2%7CKnex-MSSQL&chd=a%3A646.2219685225141%2C626.5683437388533%2C496.8209454464171%2C1719.4931774295515%2C1747.534639197647%2C849.5332460543152%2C442.8133769268087%2C520.0481264809382%2C487.146704275622) 74 | 75 | ## Entity counting 76 | 77 | ### Result 78 | 79 | 80 | | Adapter config | Time | Diff | ops/sec | 81 | | -------------- | ----:| ----:| -------:| 82 | | NeDB (memory) | 1ms | -13.77% | 831.2 | 83 | | NeDB (file) | 1ms | -15.89% | 810.78 | 84 | | MongoDB | 1ms | 0% | 963.92 | 85 | | Knex SQLite (memory) | 143μs | 625.02% | 6,988.64 | 86 | | Knex SQLite (file) | 151μs | 583.66% | 6,589.99 | 87 | | Knex-Postgresql | 440μs | 135.65% | 2,271.52 | 88 | | Knex-MySQL | 1ms | -5.48% | 911.13 | 89 | | Knex-MySQL2 | 1ms | -4.57% | 919.91 | 90 | | Knex-MSSQL | 855μs | 21.28% | 1,169.05 | 91 | 92 | ![chart](https://image-charts.com/chart?chs=999x500&chtt=Entity%20counting%7C%28ops%2Fsec%29&chf=b0%2Clg%2C90%2C03a9f4%2C0%2C3f51b5%2C1&chg=0%2C50&chma=0%2C0%2C10%2C10&cht=bvs&chxt=x%2Cy&chxs=0%2C333%2C10%7C1%2C333%2C10&chxl=0%3A%7CNeDB%20%28memory%29%7CNeDB%20%28file%29%7CMongoDB%7CKnex%20SQLite%20%28memory%29%7CKnex%20SQLite%20%28file%29%7CKnex-Postgresql%7CKnex-MySQL%7CKnex-MySQL2%7CKnex-MSSQL&chd=a%3A831.1995554133015%2C810.7785171080417%2C963.9249192333431%2C6988.642646414758%2C6589.992957304481%2C2271.5158991109574%2C911.1336865539267%2C919.9055488670326%2C1169.0504833850632) 93 | 94 | ## Entity getting 95 | 96 | ### Result 97 | 98 | 99 | | Adapter config | Time | Diff | ops/sec | 100 | | -------------- | ----:| ----:| -------:| 101 | | NeDB (memory) | 38μs | 1,551.67% | 26,201.46 | 102 | | NeDB (file) | 39μs | 1,484.88% | 25,141.91 | 103 | | MongoDB | 630μs | 0% | 1,586.36 | 104 | | Knex SQLite (memory) | 222μs | 183.11% | 4,491.16 | 105 | | Knex SQLite (file) | 218μs | 189.08% | 4,585.79 | 106 | | Knex-Postgresql | 531μs | 18.59% | 1,881.31 | 107 | | Knex-MySQL | 559μs | 12.65% | 1,786.96 | 108 | | Knex-MySQL2 | 518μs | 21.46% | 1,926.84 | 109 | | Knex-MSSQL | 839μs | -24.91% | 1,191.25 | 110 | 111 | ![chart](https://image-charts.com/chart?chs=999x500&chtt=Entity%20getting%7C%28ops%2Fsec%29&chf=b0%2Clg%2C90%2C03a9f4%2C0%2C3f51b5%2C1&chg=0%2C50&chma=0%2C0%2C10%2C10&cht=bvs&chxt=x%2Cy&chxs=0%2C333%2C10%7C1%2C333%2C10&chxl=0%3A%7CNeDB%20%28memory%29%7CNeDB%20%28file%29%7CMongoDB%7CKnex%20SQLite%20%28memory%29%7CKnex%20SQLite%20%28file%29%7CKnex-Postgresql%7CKnex-MySQL%7CKnex-MySQL2%7CKnex-MSSQL&chd=a%3A26201.46181357678%2C25141.906667955463%2C1586.3606189299776%2C4491.160647871473%2C4585.79176799256%2C1881.3127107019675%2C1786.9627983294185%2C1926.838708444168%2C1191.2543005310188) 112 | 113 | ## Entity resolving 114 | 115 | ### Result 116 | 117 | 118 | | Adapter config | Time | Diff | ops/sec | 119 | | -------------- | ----:| ----:| -------:| 120 | | NeDB (memory) | 38μs | 1,534.53% | 25,873.6 | 121 | | NeDB (file) | 39μs | 1,519.21% | 25,631.06 | 122 | | MongoDB | 631μs | 0% | 1,582.93 | 123 | | Knex SQLite (memory) | 200μs | 214.6% | 4,979.87 | 124 | | Knex SQLite (file) | 210μs | 200.73% | 4,760.43 | 125 | | Knex-Postgresql | 572μs | 10.38% | 1,747.2 | 126 | | Knex-MySQL | 627μs | 0.65% | 1,593.23 | 127 | | Knex-MySQL2 | 506μs | 24.77% | 1,974.99 | 128 | | Knex-MSSQL | 895μs | -29.47% | 1,116.44 | 129 | 130 | ![chart](https://image-charts.com/chart?chs=999x500&chtt=Entity%20resolving%7C%28ops%2Fsec%29&chf=b0%2Clg%2C90%2C03a9f4%2C0%2C3f51b5%2C1&chg=0%2C50&chma=0%2C0%2C10%2C10&cht=bvs&chxt=x%2Cy&chxs=0%2C333%2C10%7C1%2C333%2C10&chxl=0%3A%7CNeDB%20%28memory%29%7CNeDB%20%28file%29%7CMongoDB%7CKnex%20SQLite%20%28memory%29%7CKnex%20SQLite%20%28file%29%7CKnex-Postgresql%7CKnex-MySQL%7CKnex-MySQL2%7CKnex-MSSQL&chd=a%3A25873.59519750248%2C25631.06390463975%2C1582.93382344257%2C4979.869273862033%2C4760.433769923776%2C1747.1995132963764%2C1593.2297847968807%2C1974.98603010419%2C1116.442491607565) 131 | 132 | ## Entity updating 133 | 134 | ### Result 135 | 136 | 137 | | Adapter config | Time | Diff | ops/sec | 138 | | -------------- | ----:| ----:| -------:| 139 | | NeDB (memory) | 147μs | 750.83% | 6,798.18 | 140 | | NeDB (file) | 326μs | 282.97% | 3,060 | 141 | | MongoDB | 1ms | 0% | 799.01 | 142 | | Knex SQLite (memory) | 610μs | 104.98% | 1,637.85 | 143 | | Knex SQLite (file) | 1ms | -21.92% | 623.89 | 144 | | Knex-Postgresql | 1ms | -34.87% | 520.37 | 145 | | Knex-MySQL | 2ms | -50.09% | 398.78 | 146 | | Knex-MySQL2 | 2ms | -43.65% | 450.23 | 147 | | Knex-MSSQL | 3ms | -64.19% | 286.15 | 148 | 149 | ![chart](https://image-charts.com/chart?chs=999x500&chtt=Entity%20updating%7C%28ops%2Fsec%29&chf=b0%2Clg%2C90%2C03a9f4%2C0%2C3f51b5%2C1&chg=0%2C50&chma=0%2C0%2C10%2C10&cht=bvs&chxt=x%2Cy&chxs=0%2C333%2C10%7C1%2C333%2C10&chxl=0%3A%7CNeDB%20%28memory%29%7CNeDB%20%28file%29%7CMongoDB%7CKnex%20SQLite%20%28memory%29%7CKnex%20SQLite%20%28file%29%7CKnex-Postgresql%7CKnex-MySQL%7CKnex-MySQL2%7CKnex-MSSQL&chd=a%3A6798.181357321471%2C3059.9955171448173%2C799.0084019686561%2C1637.8467101331457%2C623.8871108540351%2C520.3684293126131%2C398.783891932143%2C450.23084575848014%2C286.1541599676672) 150 | 151 | ## Entity replacing 152 | 153 | ### Result 154 | 155 | 156 | | Adapter config | Time | Diff | ops/sec | 157 | | -------------- | ----:| ----:| -------:| 158 | | NeDB (memory) | 126μs | 901.68% | 7,925.66 | 159 | | NeDB (file) | 310μs | 307.32% | 3,222.89 | 160 | | MongoDB | 1ms | 0% | 791.24 | 161 | | Knex SQLite (memory) | 605μs | 108.63% | 1,650.73 | 162 | | Knex SQLite (file) | 1ms | -22.23% | 615.35 | 163 | | Knex-Postgresql | 1ms | -36.42% | 503.1 | 164 | | Knex-MySQL | 2ms | -46.99% | 419.45 | 165 | | Knex-MySQL2 | 2ms | -40.83% | 468.15 | 166 | | Knex-MSSQL | 3ms | -68.36% | 250.36 | 167 | 168 | ![chart](https://image-charts.com/chart?chs=999x500&chtt=Entity%20replacing%7C%28ops%2Fsec%29&chf=b0%2Clg%2C90%2C03a9f4%2C0%2C3f51b5%2C1&chg=0%2C50&chma=0%2C0%2C10%2C10&cht=bvs&chxt=x%2Cy&chxs=0%2C333%2C10%7C1%2C333%2C10&chxl=0%3A%7CNeDB%20%28memory%29%7CNeDB%20%28file%29%7CMongoDB%7CKnex%20SQLite%20%28memory%29%7CKnex%20SQLite%20%28file%29%7CKnex-Postgresql%7CKnex-MySQL%7CKnex-MySQL2%7CKnex-MSSQL&chd=a%3A7925.656731171178%2C3222.8905239820365%2C791.2356158525378%2C1650.732763547692%2C615.3450897814607%2C503.1007612572573%2C419.4450694082768%2C468.1509126900255%2C250.35920750461258) 169 | 170 | ## Entity deleting 171 | 172 | ### Result 173 | 174 | 175 | | Adapter config | Time | Diff | ops/sec | 176 | | -------------- | ----:| ----:| -------:| 177 | | NeDB (memory) | 136μs | 148.38% | 7,309.39 | 178 | | NeDB (file) | 219μs | 54.97% | 4,560.63 | 179 | | MongoDB | 339μs | 0% | 2,942.84 | 180 | | Knex SQLite (memory) | 567μs | -40.12% | 1,762.03 | 181 | | Knex SQLite (file) | 1ms | -68.58% | 924.67 | 182 | | Knex-Postgresql | 263μs | 29.09% | 3,798.79 | 183 | | Knex-MySQL | 505μs | -32.83% | 1,976.77 | 184 | | Knex-MySQL2 | 307μs | 10.42% | 3,249.56 | 185 | | Knex-MSSQL | 1ms | -67.57% | 954.26 | 186 | 187 | ![chart](https://image-charts.com/chart?chs=999x500&chtt=Entity%20deleting%7C%28ops%2Fsec%29&chf=b0%2Clg%2C90%2C03a9f4%2C0%2C3f51b5%2C1&chg=0%2C50&chma=0%2C0%2C10%2C10&cht=bvs&chxt=x%2Cy&chxs=0%2C333%2C10%7C1%2C333%2C10&chxl=0%3A%7CNeDB%20%28memory%29%7CNeDB%20%28file%29%7CMongoDB%7CKnex%20SQLite%20%28memory%29%7CKnex%20SQLite%20%28file%29%7CKnex-Postgresql%7CKnex-MySQL%7CKnex-MySQL2%7CKnex-MSSQL&chd=a%3A7309.38964344987%2C4560.627308555684%2C2942.8383925432877%2C1762.0286745255794%2C924.6729043520112%2C3798.7895411313184%2C1976.7681481197972%2C3249.555422672661%2C954.25962063556) 188 | 189 | -------------------- 190 | _Generated at 2022-09-02T19:29:11.443Z_ -------------------------------------------------------------------------------- /benchmark/results/transform/README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Moleculer Database benchmark - Transformation benchmark 4 | This is a transformation benchmark. It tests all service methods with and without transformation. 5 | ## Entity creation 6 | 7 | ### Result 8 | 9 | 10 | | Adapter config | Time | Diff | ops/sec | 11 | | -------------- | ----:| ----:| -------:| 12 | | Without transform | 55μs | 0% | 18,168.9 | 13 | | With transform | 52μs | 4.99% | 19,076.02 | 14 | 15 | ![chart](https://image-charts.com/chart?chs=800x450&chtt=Entity%20creation%7C%28ops%2Fsec%29&chf=b0%2Clg%2C90%2C03a9f4%2C0%2C3f51b5%2C1&chg=0%2C50&chma=0%2C0%2C10%2C10&cht=bvs&chxt=x%2Cy&chxl=0%3A%7CWithout%20transform%7CWith%20transform&chd=a%3A18168.900293367842%2C19076.019228229965) 16 | 17 | ## Entity listing 18 | 19 | ### Result 20 | 21 | 22 | | Adapter config | Time | Diff | ops/sec | 23 | | -------------- | ----:| ----:| -------:| 24 | | Without transform | 131μs | 0% | 7,591.59 | 25 | | With transform | 148μs | -11.38% | 6,727.93 | 26 | 27 | ![chart](https://image-charts.com/chart?chs=800x450&chtt=Entity%20listing%7C%28ops%2Fsec%29&chf=b0%2Clg%2C90%2C03a9f4%2C0%2C3f51b5%2C1&chg=0%2C50&chma=0%2C0%2C10%2C10&cht=bvs&chxt=x%2Cy&chxl=0%3A%7CWithout%20transform%7CWith%20transform&chd=a%3A7591.591553195681%2C6727.932672707071) 28 | 29 | ## Entity counting 30 | 31 | ### Result 32 | 33 | 34 | | Adapter config | Time | Diff | ops/sec | 35 | | -------------- | ----:| ----:| -------:| 36 | | Without transform | 726μs | 0% | 1,376.87 | 37 | 38 | ![chart](https://image-charts.com/chart?chs=800x450&chtt=Entity%20counting%7C%28ops%2Fsec%29&chf=b0%2Clg%2C90%2C03a9f4%2C0%2C3f51b5%2C1&chg=0%2C50&chma=0%2C0%2C10%2C10&cht=bvs&chxt=x%2Cy&chxl=0%3A%7CWithout%20transform&chd=a%3A1376.8735905731837) 39 | 40 | ## Entity getting 41 | 42 | ### Result 43 | 44 | 45 | | Adapter config | Time | Diff | ops/sec | 46 | | -------------- | ----:| ----:| -------:| 47 | | Without transform | 21μs | 0% | 46,501.41 | 48 | | With transform | 28μs | -25.63% | 34,581.01 | 49 | | Direct adapter access | 20μs | 7.33% | 49,908.36 | 50 | 51 | ![chart](https://image-charts.com/chart?chs=800x450&chtt=Entity%20getting%7C%28ops%2Fsec%29&chf=b0%2Clg%2C90%2C03a9f4%2C0%2C3f51b5%2C1&chg=0%2C50&chma=0%2C0%2C10%2C10&cht=bvs&chxt=x%2Cy&chxl=0%3A%7CWithout%20transform%7CWith%20transform%7CDirect%20adapter%20access&chd=a%3A46501.41142760411%2C34581.00678348184%2C49908.36425056682) 52 | 53 | ## Entity updating 54 | 55 | ### Result 56 | 57 | 58 | | Adapter config | Time | Diff | ops/sec | 59 | | -------------- | ----:| ----:| -------:| 60 | | Without transform | 93μs | 0% | 10,694.34 | 61 | | With transform | 99μs | -6.38% | 10,011.73 | 62 | 63 | ![chart](https://image-charts.com/chart?chs=800x450&chtt=Entity%20updating%7C%28ops%2Fsec%29&chf=b0%2Clg%2C90%2C03a9f4%2C0%2C3f51b5%2C1&chg=0%2C50&chma=0%2C0%2C10%2C10&cht=bvs&chxt=x%2Cy&chxl=0%3A%7CWithout%20transform%7CWith%20transform&chd=a%3A10694.336321073446%2C10011.729428104494) 64 | 65 | ## Entity replacing 66 | 67 | ### Result 68 | 69 | 70 | | Adapter config | Time | Diff | ops/sec | 71 | | -------------- | ----:| ----:| -------:| 72 | | Without transform | 93μs | 0% | 10,662.15 | 73 | | With transform | 100μs | -7.11% | 9,904.25 | 74 | 75 | ![chart](https://image-charts.com/chart?chs=800x450&chtt=Entity%20replacing%7C%28ops%2Fsec%29&chf=b0%2Clg%2C90%2C03a9f4%2C0%2C3f51b5%2C1&chg=0%2C50&chma=0%2C0%2C10%2C10&cht=bvs&chxt=x%2Cy&chxl=0%3A%7CWithout%20transform%7CWith%20transform&chd=a%3A10662.152232432549%2C9904.24653682173) 76 | 77 | ## Entity deleting 78 | 79 | ### Result 80 | 81 | 82 | | Adapter config | Time | Diff | ops/sec | 83 | | -------------- | ----:| ----:| -------:| 84 | | Without transform | 51μs | 0% | 19,603.08 | 85 | | With transform | 183μs | -72.15% | 5,459.5 | 86 | 87 | ![chart](https://image-charts.com/chart?chs=800x450&chtt=Entity%20deleting%7C%28ops%2Fsec%29&chf=b0%2Clg%2C90%2C03a9f4%2C0%2C3f51b5%2C1&chg=0%2C50&chma=0%2C0%2C10%2C10&cht=bvs&chxt=x%2Cy&chxl=0%3A%7CWithout%20transform%7CWith%20transform&chd=a%3A19603.075297822932%2C5459.497019815262) 88 | 89 | -------------------- 90 | _Generated at 2021-05-10T16:10:04.759Z_ -------------------------------------------------------------------------------- /benchmark/results/transform/benchmark_results.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Moleculer Database benchmark - Transformation benchmark", 3 | "description": "This is a transformation benchmark. It tests all service methods with and without transformation.", 4 | "meta": {}, 5 | "suites": [ 6 | { 7 | "name": "Entity creation", 8 | "tests": [ 9 | { 10 | "name": "Without transform", 11 | "meta": {}, 12 | "reference": true, 13 | "stat": { 14 | "duration": 5.0085585, 15 | "cycle": 0, 16 | "count": 91000, 17 | "avg": 0.0000550391043956044, 18 | "rps": 18168.900293367842, 19 | "percent": 0 20 | } 21 | }, 22 | { 23 | "name": "With transform", 24 | "meta": {}, 25 | "fastest": true, 26 | "stat": { 27 | "duration": 5.0324965, 28 | "cycle": 0, 29 | "count": 96000, 30 | "avg": 0.000052421838541666664, 31 | "rps": 19076.019228229965, 32 | "percent": 4.992701375510578 33 | } 34 | } 35 | ] 36 | }, 37 | { 38 | "name": "Entity listing", 39 | "tests": [ 40 | { 41 | "name": "Without transform", 42 | "meta": {}, 43 | "fastest": true, 44 | "reference": true, 45 | "stat": { 46 | "duration": 5.005538, 47 | "cycle": 0, 48 | "count": 38000, 49 | "avg": 0.00013172468421052632, 50 | "rps": 7591.591553195681, 51 | "percent": 0 52 | } 53 | }, 54 | { 55 | "name": "With transform", 56 | "meta": {}, 57 | "stat": { 58 | "duration": 5.0535583, 59 | "cycle": 0, 60 | "count": 34000, 61 | "avg": 0.00014863406764705883, 62 | "rps": 6727.932672707071, 63 | "percent": -11.376519329797873 64 | } 65 | } 66 | ] 67 | }, 68 | { 69 | "name": "Entity counting", 70 | "tests": [ 71 | { 72 | "name": "Without transform", 73 | "meta": {}, 74 | "fastest": true, 75 | "reference": true, 76 | "stat": { 77 | "duration": 5.0839816, 78 | "cycle": 0, 79 | "count": 7000, 80 | "avg": 0.0007262830857142858, 81 | "rps": 1376.8735905731837, 82 | "percent": 0 83 | } 84 | } 85 | ] 86 | }, 87 | { 88 | "name": "Entity getting", 89 | "tests": [ 90 | { 91 | "name": "Without transform", 92 | "meta": {}, 93 | "reference": true, 94 | "stat": { 95 | "duration": 5.0106006, 96 | "cycle": 0, 97 | "count": 233000, 98 | "avg": 0.000021504723605150215, 99 | "rps": 46501.41142760411, 100 | "percent": 0 101 | } 102 | }, 103 | { 104 | "name": "With transform", 105 | "meta": {}, 106 | "stat": { 107 | "duration": 5.0316638, 108 | "cycle": 0, 109 | "count": 174000, 110 | "avg": 0.00002891760804597701, 111 | "rps": 34581.00678348184, 112 | "percent": -25.634500713339833 113 | } 114 | }, 115 | { 116 | "name": "Direct adapter access", 117 | "meta": {}, 118 | "fastest": true, 119 | "stat": { 120 | "duration": 5.0091804, 121 | "cycle": 0, 122 | "count": 250000, 123 | "avg": 0.0000200367216, 124 | "rps": 49908.36425056682, 125 | "percent": 7.326557879359939 126 | } 127 | } 128 | ] 129 | }, 130 | { 131 | "name": "Entity updating", 132 | "tests": [ 133 | { 134 | "name": "Without transform", 135 | "meta": {}, 136 | "fastest": true, 137 | "reference": true, 138 | "stat": { 139 | "duration": 5.0494017, 140 | "cycle": 0, 141 | "count": 54000, 142 | "avg": 0.00009350743888888888, 143 | "rps": 10694.336321073446, 144 | "percent": 0 145 | } 146 | }, 147 | { 148 | "name": "With transform", 149 | "meta": {}, 150 | "stat": { 151 | "duration": 5.094025, 152 | "cycle": 0, 153 | "count": 51000, 154 | "avg": 0.00009988284313725491, 155 | "rps": 10011.729428104494, 156 | "percent": -6.382882232942848 157 | } 158 | } 159 | ] 160 | }, 161 | { 162 | "name": "Entity replacing", 163 | "tests": [ 164 | { 165 | "name": "Without transform", 166 | "meta": {}, 167 | "fastest": true, 168 | "reference": true, 169 | "stat": { 170 | "duration": 5.0646435, 171 | "cycle": 0, 172 | "count": 54000, 173 | "avg": 0.00009378969444444445, 174 | "rps": 10662.152232432549, 175 | "percent": 0 176 | } 177 | }, 178 | { 179 | "name": "With transform", 180 | "meta": {}, 181 | "stat": { 182 | "duration": 5.0483396, 183 | "cycle": 0, 184 | "count": 50000, 185 | "avg": 0.000100966792, 186 | "rps": 9904.24653682173, 187 | "percent": -7.108374360904264 188 | } 189 | } 190 | ] 191 | }, 192 | { 193 | "name": "Entity deleting", 194 | "tests": [ 195 | { 196 | "name": "Without transform", 197 | "meta": {}, 198 | "fastest": true, 199 | "reference": true, 200 | "stat": { 201 | "duration": 9.7211788, 202 | "cycle": 0, 203 | "count": 190565, 204 | "avg": 0.000051012404166557344, 205 | "rps": 19603.075297822932, 206 | "percent": 0 207 | } 208 | }, 209 | { 210 | "name": "With transform", 211 | "meta": {}, 212 | "stat": { 213 | "duration": 5.4950117, 214 | "cycle": 0, 215 | "count": 30000, 216 | "avg": 0.00018316705666666667, 217 | "rps": 5459.497019815262, 218 | "percent": -72.14979314790685 219 | } 220 | } 221 | ] 222 | } 223 | ], 224 | "timestamp": 1620663004740, 225 | "generated": "Mon May 10 2021 18:10:04 GMT+0200 (GMT+02:00)", 226 | "elapsedMs": 75379 227 | } -------------------------------------------------------------------------------- /benchmark/suites/.gitignore: -------------------------------------------------------------------------------- 1 | tmp/ 2 | -------------------------------------------------------------------------------- /benchmark/suites/native-nedb.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const Datastore = require("nedb"); 4 | 5 | const Fakerator = require("fakerator"); 6 | const fakerator = new Fakerator(); 7 | 8 | const Benchmarkify = require("benchmarkify"); 9 | const benchmark = new Benchmarkify("NeDB benchmark").printHeader(); 10 | 11 | const COUNT = 1000; 12 | 13 | function createDatastore() { 14 | const db = new Datastore(); // in-memory 15 | 16 | return new Promise((resolve, reject) => { 17 | db.loadDatabase(err => { 18 | if (err) return reject(err); 19 | resolve(db); 20 | }); 21 | }); 22 | } 23 | 24 | function insertFakeEntities(db, count) { 25 | const entities = fakerator.times(fakerator.entity.user, count); 26 | return new Promise((resolve, reject) => { 27 | db.insert(entities, (err, docs) => { 28 | if (err) return reject(err); 29 | resolve(docs); 30 | }); 31 | /*}).then(docs => { 32 | return new Promise((resolve, reject) => { 33 | db.count({}, (err, count) => { 34 | if (err) return reject(err); 35 | resolve(docs); 36 | console.log("Number of entities:", count); 37 | }); 38 | });*/ 39 | }); 40 | } 41 | 42 | const bench1 = benchmark.createSuite(`NeDB: Insert (${COUNT})`); 43 | (function (bench) { 44 | const entity1 = { 45 | firstName: "John", 46 | lastName: "Doe", 47 | username: "john.doe81", 48 | email: "john.doe@moleculer.services", 49 | password: "pass1234", 50 | status: 1 51 | }; 52 | let db; 53 | 54 | bench.setup(async () => { 55 | db = await createDatastore(); 56 | }); 57 | 58 | bench.add("db.insert", done => { 59 | db.insert(entity1, done); 60 | }); 61 | })(bench1); 62 | 63 | const bench2 = benchmark.createSuite(`NeDB: Entity listing (${COUNT})`); 64 | (function (bench) { 65 | let docs; 66 | let db; 67 | 68 | bench.setup(async () => { 69 | db = await createDatastore(); 70 | docs = await insertFakeEntities(db, COUNT); 71 | }); 72 | 73 | bench.ref("db.find", done => { 74 | const offset = Math.floor(Math.random() * 80); 75 | db.find().limit(20).skip(offset).exec(done); 76 | }); 77 | })(bench2); 78 | 79 | const bench3 = benchmark.createSuite(`NeDB: Entity counting (${COUNT})`); 80 | (function (bench) { 81 | let docs; 82 | let db; 83 | 84 | bench.setup(async () => { 85 | db = await createDatastore(); 86 | docs = await insertFakeEntities(db, COUNT); 87 | }); 88 | 89 | bench.ref("db.count", done => { 90 | db.count({}, done); 91 | }); 92 | })(bench3); 93 | 94 | const bench4 = benchmark.createSuite(`NeDB: Entity getting (${COUNT})`); 95 | (function (bench) { 96 | let docs; 97 | let db; 98 | 99 | bench.setup(async () => { 100 | db = await createDatastore(); 101 | docs = await insertFakeEntities(db, COUNT); 102 | }); 103 | 104 | bench.add("db.find", done => { 105 | const entity = docs[Math.floor(Math.random() * docs.length)]; 106 | db.find({ _id: entity.id }).exec(done); 107 | }); 108 | })(bench4); 109 | 110 | const bench5 = benchmark.createSuite(`NeDB: Entity updating (${COUNT})`); 111 | (function (bench) { 112 | let docs; 113 | let db; 114 | 115 | bench.setup(async () => { 116 | db = await createDatastore(); 117 | docs = await insertFakeEntities(db, COUNT); 118 | }); 119 | 120 | bench.ref("db.update", done => { 121 | const entity = docs[Math.floor(Math.random() * docs.length)]; 122 | const newStatus = Math.round(Math.random()); 123 | db.update( 124 | { _id: entity._id }, 125 | { 126 | $set: { 127 | status: newStatus 128 | } 129 | }, 130 | { returnUpdatedDocs: true }, 131 | done 132 | ); 133 | }); 134 | })(bench5); 135 | 136 | const bench6 = benchmark.createSuite(`NeDB: Entity replacing (${COUNT})`); 137 | (function (bench) { 138 | let docs; 139 | let db; 140 | 141 | bench.setup(async () => { 142 | db = await createDatastore(); 143 | docs = await insertFakeEntities(db, COUNT); 144 | }); 145 | 146 | bench.ref("db.update", done => { 147 | const entity = docs[Math.floor(Math.random() * docs.length)]; 148 | entity.status = Math.round(Math.random()); 149 | db.update({ _id: entity._id }, entity, { returnUpdatedDocs: true }, done); 150 | }); 151 | })(bench6); 152 | 153 | const bench7 = benchmark.createSuite(`NeDB: Entity deleting (${COUNT})`); 154 | (function (bench) { 155 | let docs; 156 | let db; 157 | 158 | bench.setup(async () => { 159 | db = await createDatastore(); 160 | docs = await insertFakeEntities(db, COUNT); 161 | }); 162 | 163 | bench.ref("db.remove", done => { 164 | const entity = docs[Math.floor(Math.random() * docs.length)]; 165 | db.remove({ _id: entity._id }, done); 166 | }); 167 | })(bench7); 168 | 169 | benchmark.run([bench1, bench2, bench3, bench4, bench5, bench6, bench7]); 170 | 171 | /* RESULT 172 | 173 | ================== 174 | NeDB benchmark 175 | ================== 176 | 177 | Platform info: 178 | ============== 179 | Windows_NT 10.0.19041 x64 180 | Node.JS: 12.14.1 181 | V8: 7.7.299.13-node.16 182 | Intel(R) Core(TM) i7-4770K CPU @ 3.50GHz × 8 183 | 184 | Suite: NeDB: Insert (1000) 185 | √ db.insert* 52,813 rps 186 | 187 | db.insert* 0% (52,813 rps) (avg: 18μs) 188 | ----------------------------------------------------------------------- 189 | 190 | Suite: NeDB: Entity listing (1000) 191 | √ db.find* 7,791 rps 192 | 193 | db.find* (#) 0% (7,791 rps) (avg: 128μs) 194 | ----------------------------------------------------------------------- 195 | 196 | Suite: NeDB: Entity counting (1000) 197 | √ db.count* 6,684 rps 198 | 199 | db.count* (#) 0% (6,684 rps) (avg: 149μs) 200 | ----------------------------------------------------------------------- 201 | 202 | Suite: NeDB: Entity getting (1000) 203 | √ db.find* 2,838 rps 204 | 205 | db.find* 0% (2,838 rps) (avg: 352μs) 206 | ----------------------------------------------------------------------- 207 | 208 | Suite: NeDB: Entity updating (1000) 209 | √ db.update* 35,457 rps 210 | 211 | db.update* (#) 0% (35,457 rps) (avg: 28μs) 212 | ----------------------------------------------------------------------- 213 | 214 | Suite: NeDB: Entity replacing (1000) 215 | √ db.update* 34,325 rps 216 | 217 | db.update* (#) 0% (34,325 rps) (avg: 29μs) 218 | ----------------------------------------------------------------------- 219 | 220 | Suite: NeDB: Entity deleting (1000) 221 | √ db.remove* 128,375 rps 222 | 223 | db.remove* (#) 0% (128,375 rps) (avg: 7μs) 224 | ----------------------------------------------------------------------- 225 | 226 | 227 | */ 228 | -------------------------------------------------------------------------------- /benchmark/suites/transform.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const { ServiceBroker, Context } = require("moleculer"); 4 | const DbService = require("../..").Service; 5 | const { writeResult } = require("../utils"); 6 | const { generateMarkdown } = require("../generate-result"); 7 | 8 | const Fakerator = require("fakerator"); 9 | const fakerator = new Fakerator(); 10 | 11 | const Benchmarkify = require("benchmarkify"); 12 | const benchmark = new Benchmarkify("Moleculer Database benchmark - Transformation benchmark", { 13 | description: 14 | "This is a transformation benchmark. It tests all service methods with and without transformation." 15 | }).printHeader(); 16 | 17 | const COUNT = 1000; 18 | const SUITE_NAME = "transform"; 19 | 20 | const suites = []; 21 | 22 | const UserServiceSchema = { 23 | name: "users", 24 | mixins: [ 25 | DbService({ 26 | adapter: { type: "NeDB" } 27 | }) 28 | ], 29 | settings: { 30 | fields: { 31 | id: { type: "string", primaryKey: true, columnName: "_id" }, 32 | firstName: { type: "string" }, 33 | lastName: { type: "string" }, 34 | fullName: { 35 | type: "string", 36 | get: ({ entity }) => entity.firstName + " " + entity.lastName 37 | }, 38 | username: { type: "string" }, 39 | email: { type: "string" }, 40 | password: { type: "string", hidden: true }, 41 | status: { type: "number", default: 1 } 42 | } 43 | } 44 | }; 45 | 46 | const bench1 = benchmark.createSuite("Entity creation"); 47 | (function (bench) { 48 | const broker = new ServiceBroker({ logger: false }); 49 | const svc = broker.createService(UserServiceSchema); 50 | const ctx = Context.create(broker, null, {}); 51 | 52 | const entity1 = { 53 | firstName: "John", 54 | lastName: "Doe", 55 | username: "john.doe81", 56 | email: "john.doe@moleculer.services", 57 | password: "pass1234", 58 | status: 1 59 | }; 60 | 61 | bench.setup(() => broker.start()); 62 | bench.tearDown(() => broker.stop()); 63 | 64 | bench.ref("Without transform", done => 65 | svc.createEntity(ctx, entity1, { transform: false }).then(done) 66 | ); 67 | bench.add("With transform", done => svc.createEntity(ctx, entity1).then(done)); 68 | })(bench1); 69 | suites.push(bench1); 70 | 71 | const bench2 = benchmark.createSuite("Entity listing"); 72 | (function (bench) { 73 | const broker = new ServiceBroker({ logger: false }); 74 | const svc = broker.createService(UserServiceSchema); 75 | 76 | let docs; 77 | 78 | bench.setup(async () => { 79 | await broker.start(); 80 | 81 | await svc.clearEntities(ctx); 82 | docs = await svc.createEntities(ctx, fakerator.times(fakerator.entity.user, COUNT), { 83 | returnEntities: true 84 | }); 85 | }); 86 | bench.tearDown(() => broker.stop()); 87 | 88 | const ctx = Context.create(broker, null, {}); 89 | 90 | bench.ref("Without transform", done => { 91 | const offset = Math.floor(Math.random() * 80); 92 | return svc.findEntities(ctx, { offset, limit: 20 }, { transform: false }).then(done); 93 | }); 94 | 95 | bench.add("With transform", done => { 96 | const offset = Math.floor(Math.random() * 80); 97 | return svc.findEntities(ctx, { offset, limit: 20 }).then(done); 98 | }); 99 | })(bench2); 100 | suites.push(bench2); 101 | 102 | const bench3 = benchmark.createSuite("Entity counting"); 103 | (function (bench) { 104 | const broker = new ServiceBroker({ logger: false }); 105 | const svc = broker.createService(UserServiceSchema); 106 | 107 | let docs; 108 | 109 | bench.setup(async () => { 110 | await broker.start(); 111 | 112 | await svc.clearEntities(ctx); 113 | docs = await svc.createEntities(ctx, fakerator.times(fakerator.entity.user, COUNT), { 114 | returnEntities: true 115 | }); 116 | }); 117 | bench.tearDown(() => broker.stop()); 118 | 119 | const ctx = Context.create(broker, null, {}); 120 | 121 | bench.ref("Without transform", done => { 122 | return svc.countEntities(ctx).then(done); 123 | }); 124 | 125 | /*bench.add("Direct adapter access", done => { 126 | return svc.adapter.collection.countDocuments().then(done); 127 | });*/ 128 | })(bench3); 129 | suites.push(bench3); 130 | 131 | const bench4 = benchmark.createSuite("Entity getting"); 132 | (function (bench) { 133 | const broker = new ServiceBroker({ logger: false }); 134 | const svc = broker.createService(UserServiceSchema); 135 | 136 | let docs; 137 | 138 | bench.setup(async () => { 139 | await broker.start(); 140 | 141 | await svc.clearEntities(ctx); 142 | docs = await svc.createEntities(ctx, fakerator.times(fakerator.entity.user, COUNT), { 143 | returnEntities: true 144 | }); 145 | }); 146 | bench.tearDown(() => broker.stop()); 147 | 148 | const ctx = Context.create(broker, null, {}); 149 | 150 | bench.ref("Without transform", done => { 151 | const entity = docs[Math.floor(Math.random() * docs.length)]; 152 | return svc.resolveEntities(ctx, { id: entity.id }, { transform: false }).then(done); 153 | }); 154 | 155 | bench.add("With transform", done => { 156 | const entity = docs[Math.floor(Math.random() * docs.length)]; 157 | return svc.resolveEntities(ctx, { id: entity.id }, { transform: true }).then(done); 158 | }); 159 | 160 | bench.add("Direct adapter access", done => { 161 | const entity = docs[Math.floor(Math.random() * docs.length)]; 162 | return svc 163 | .getAdapter(ctx) 164 | .then(adapter => adapter.findById(entity.id)) 165 | .then(done); 166 | }); 167 | })(bench4); 168 | suites.push(bench4); 169 | 170 | const bench5 = benchmark.createSuite("Entity updating"); 171 | (function (bench) { 172 | const broker = new ServiceBroker({ logger: false }); 173 | const svc = broker.createService(UserServiceSchema); 174 | 175 | let docs; 176 | 177 | bench.setup(async () => { 178 | await broker.start(); 179 | 180 | await svc.clearEntities(ctx); 181 | docs = await svc.createEntities(ctx, fakerator.times(fakerator.entity.user, COUNT), { 182 | returnEntities: true 183 | }); 184 | }); 185 | bench.tearDown(() => broker.stop()); 186 | 187 | const ctx = Context.create(broker, null, {}); 188 | 189 | bench.ref("Without transform", done => { 190 | const entity = docs[Math.floor(Math.random() * docs.length)]; 191 | const newStatus = Math.round(Math.random()); 192 | return svc 193 | .updateEntity(ctx, { id: entity.id, status: newStatus }, { transform: false }) 194 | .then(done); 195 | }); 196 | 197 | bench.add("With transform", done => { 198 | const entity = docs[Math.floor(Math.random() * docs.length)]; 199 | const newStatus = Math.round(Math.random()); 200 | return svc 201 | .updateEntity(ctx, { id: entity.id, status: newStatus }, { transform: true }) 202 | .then(done); 203 | }); 204 | })(bench5); 205 | suites.push(bench5); 206 | 207 | const bench6 = benchmark.createSuite("Entity replacing"); 208 | (function (bench) { 209 | const broker = new ServiceBroker({ logger: false }); 210 | const svc = broker.createService(UserServiceSchema); 211 | 212 | let docs; 213 | 214 | bench.setup(async () => { 215 | await broker.start(); 216 | 217 | await svc.clearEntities(ctx); 218 | docs = await svc.createEntities(ctx, fakerator.times(fakerator.entity.user, COUNT), { 219 | returnEntities: true 220 | }); 221 | }); 222 | bench.tearDown(() => broker.stop()); 223 | 224 | const ctx = Context.create(broker, null, {}); 225 | 226 | bench.ref("Without transform", done => { 227 | const entity = docs[Math.floor(Math.random() * docs.length)]; 228 | entity.status = Math.round(Math.random()); 229 | return svc.replaceEntity(ctx, entity, { transform: false }).then(done); 230 | }); 231 | 232 | bench.add("With transform", done => { 233 | const entity = docs[Math.floor(Math.random() * docs.length)]; 234 | entity.status = Math.round(Math.random()); 235 | return svc.replaceEntity(ctx, entity, { transform: true }).then(done); 236 | }); 237 | })(bench6); 238 | suites.push(bench6); 239 | 240 | const bench7 = benchmark.createSuite("Entity deleting"); 241 | (function (bench) { 242 | const broker = new ServiceBroker({ logger: false }); 243 | const svc = broker.createService(UserServiceSchema); 244 | 245 | let docs; 246 | 247 | bench.setup(async () => { 248 | await broker.start(); 249 | 250 | await svc.clearEntities(ctx); 251 | docs = await svc.createEntities(ctx, fakerator.times(fakerator.entity.user, COUNT), { 252 | returnEntities: true 253 | }); 254 | }); 255 | bench.tearDown(async () => { 256 | console.log("Remaining record", await svc.countEntities(ctx)); 257 | await broker.stop(); 258 | }); 259 | 260 | const ctx = Context.create(broker, null, {}); 261 | 262 | bench.ref("Without transform", done => { 263 | const entity = docs[Math.floor(Math.random() * docs.length)]; 264 | return svc 265 | .removeEntity(ctx, { id: entity.id }, { transform: false }) 266 | .catch(done) 267 | .then(done); 268 | }); 269 | 270 | bench.add("With transform", done => { 271 | const entity = docs[Math.floor(Math.random() * docs.length)]; 272 | return svc.removeEntity(ctx, { id: entity.id }, { transform: true }).catch(done).then(done); 273 | }); 274 | })(bench7); 275 | suites.push(bench7); 276 | 277 | async function run() { 278 | console.log("Running suites..."); 279 | const results = await benchmark.run(suites); 280 | console.log("Save the results to file..."); 281 | writeResult(SUITE_NAME, "benchmark_results.json", results); 282 | console.log("Generate README.md..."); 283 | await generateMarkdown(SUITE_NAME); 284 | console.log("Done."); 285 | } 286 | 287 | run(); 288 | 289 | /* RESULT 290 | 291 | ================================================= 292 | Moleculer Database - Transformation benchmark 293 | ================================================= 294 | 295 | Platform info: 296 | ============== 297 | Windows_NT 10.0.19041 x64 298 | Node.JS: 12.14.1 299 | V8: 7.7.299.13-node.16 300 | Intel(R) Core(TM) i7-4770K CPU @ 3.50GHz × 8 301 | 302 | Suite: Entity creation (1000) 303 | √ Without transform* 32,042 rps 304 | √ With transform* 24,336 rps 305 | 306 | Without transform* (#) 0% (32,042 rps) (avg: 31μs) 307 | With transform* -24.05% (24,336 rps) (avg: 41μs) 308 | ----------------------------------------------------------------------- 309 | 310 | Suite: Entity listing (1000) 311 | √ Without transform* 8,736 rps 312 | √ With transform* 6,508 rps 313 | 314 | Without transform* (#) 0% (8,736 rps) (avg: 114μs) 315 | With transform* -25.51% (6,508 rps) (avg: 153μs) 316 | ----------------------------------------------------------------------- 317 | 318 | Suite: Entity counting (1000) 319 | √ Without transform* 1,708 rps 320 | 321 | Without transform* (#) 0% (1,708 rps) (avg: 585μs) 322 | ----------------------------------------------------------------------- 323 | 324 | Suite: Entity getting (1000) 325 | √ Without transform* 86,826 rps 326 | √ With transform* 52,076 rps 327 | √ Direct adapter access* 91,199 rps 328 | 329 | Without transform* (#) 0% (86,826 rps) (avg: 11μs) 330 | With transform* -40.02% (52,076 rps) (avg: 19μs) 331 | Direct adapter access* +5.04% (91,199 rps) (avg: 10μs) 332 | ----------------------------------------------------------------------- 333 | 334 | Suite: Entity updating (1000) 335 | √ Without transform* 20,704 rps 336 | √ With transform* 18,048 rps 337 | 338 | Without transform* (#) 0% (20,704 rps) (avg: 48μs) 339 | With transform* -12.83% (18,048 rps) (avg: 55μs) 340 | ----------------------------------------------------------------------- 341 | 342 | Suite: Entity replacing (1000) 343 | √ Without transform* 19,130 rps 344 | √ With transform* 16,604 rps 345 | 346 | Without transform* (#) 0% (19,130 rps) (avg: 52μs) 347 | With transform* -13.21% (16,604 rps) (avg: 60μs) 348 | ----------------------------------------------------------------------- 349 | 350 | Suite: Entity deleting (1000) 351 | √ Without transform* 19,787 rps 352 | √ With transform* 5,769 rps 353 | Remaining record 0 354 | 355 | Without transform* (#) 0% (21,151 rps) (avg: 47μs) 356 | With transform* -62.06% (8,025 rps) (avg: 124μs) 357 | ----------------------------------------------------------------------- 358 | 359 | */ 360 | -------------------------------------------------------------------------------- /benchmark/utils.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const { makeDirs } = require("moleculer").Utils; 4 | const path = require("path"); 5 | const fs = require("fs"); 6 | const qs = require("qs"); 7 | 8 | module.exports = { 9 | slug(text) { 10 | return text 11 | .toString() 12 | .toLowerCase() 13 | .replace(/\s+/g, "-") // Replace spaces with - 14 | .replace(/[^\w\-]+/g, "") // Remove all non-word chars 15 | .replace(/\-\-+/g, "-") // Replace multiple - with single - 16 | .replace(/^-+/, "") // Trim - from start of text 17 | .replace(/-+$/, ""); // Trim - from end of text 18 | }, 19 | 20 | writeResult(folderName, filename, content) { 21 | const folder = path.join(__dirname, "results", folderName); 22 | makeDirs(folder); 23 | 24 | fs.writeFileSync(path.join(folder, filename), JSON.stringify(content, null, 2), "utf8"); 25 | }, 26 | 27 | saveMarkdown(filename, rows) { 28 | const content = rows.join("\n"); 29 | fs.writeFileSync(filename, content, "utf8"); 30 | }, 31 | 32 | createChartURL(opts) { 33 | return `https://image-charts.com/chart?${qs.stringify(opts)}`; 34 | }, 35 | 36 | numToStr(num, digits = 2) { 37 | return new Intl.NumberFormat("en-US", { maximumFractionDigits: digits }).format(num); 38 | }, 39 | 40 | makeTableRow(cells) { 41 | return "| " + cells.join(" | ") + " |"; 42 | } 43 | }; 44 | -------------------------------------------------------------------------------- /docs/adapters/Knex.md: -------------------------------------------------------------------------------- 1 | # Knex adapter 2 | This adapter gives access to SQL database engines using the [Knex.js](https://knexjs.org/) library. 3 | 4 | > Knex.js (pronounced /kəˈnɛks/) is a "batteries included" SQL query builder for Postgres, MSSQL, MySQL, MariaDB, SQLite3, Oracle, and Amazon Redshift designed to be flexible, portable, and fun to use. 5 | 6 | ## Install 7 | This module contains the source code of the adapter. You just need to install the dependent library. 8 | 9 | ```bash 10 | npm install knex@^1.0.1 11 | ``` 12 | 13 | ## Usage 14 | 15 | ### Connect to SQLite memory database 16 | > To connect SQLite database, you should install the `@vscode/sqlite3` module with `npm install @vscode/sqlite3` command (or `better-sqlite3`). 17 | 18 | ```js 19 | // posts.service.js 20 | const DbService = require("@moleculer/database").Service; 21 | 22 | module.exports = { 23 | name: "posts", 24 | mixins: [DbService({ 25 | adapter: { 26 | type: "Knex", 27 | options: { 28 | knex: { 29 | client: "sqlite3", 30 | connection: { 31 | filename: ":memory:" 32 | }, 33 | useNullAsDefault: true 34 | } 35 | } 36 | } 37 | })] 38 | } 39 | ``` 40 | 41 | ### Connect to PostgreSQL database 42 | > To connect PostgreSQL database you should install the `pg` module with `npm install pg` command. 43 | 44 | ```js 45 | // posts.service.js 46 | const DbService = require("@moleculer/database").Service; 47 | 48 | module.exports = { 49 | name: "posts", 50 | mixins: [DbService({ 51 | adapter: { 52 | type: "Knex", 53 | options: { 54 | knex: { 55 | client: "pg", 56 | connection: "postgres://postgres@localhost:5432/moleculer" 57 | } 58 | } 59 | } 60 | })] 61 | } 62 | ``` 63 | 64 | ### Connect to MySQL database 65 | > To connect MySQL database you should install the `mysql` module with `npm install mysql` command. 66 | 67 | ```js 68 | // posts.service.js 69 | const DbService = require("@moleculer/database").Service; 70 | 71 | module.exports = { 72 | name: "posts", 73 | mixins: [DbService({ 74 | adapter: { 75 | type: "Knex", 76 | options: { 77 | knex: { 78 | client: "pg", 79 | connection: { 80 | host: "127.0.0.1", 81 | user: "root", 82 | password: "pass1234", 83 | database: "moleculer" 84 | } 85 | } 86 | } 87 | } 88 | })] 89 | } 90 | ``` 91 | 92 | ### Connect to MSSQL database 93 | > To connect MSSQL database you should install the `tedious` module with `npm install tedious` command. 94 | 95 | ```js 96 | // posts.service.js 97 | const DbService = require("@moleculer/database").Service; 98 | 99 | module.exports = { 100 | name: "posts", 101 | mixins: [DbService({ 102 | adapter: { 103 | type: "Knex", 104 | options: { 105 | knex: { 106 | client: "mssql", 107 | connection: { 108 | host: "127.0.0.1", 109 | port: 1433, 110 | user: "sa", 111 | password: "Moleculer@Pass1234", 112 | database: "moleculer", 113 | encrypt: false 114 | } 115 | } 116 | } 117 | } 118 | })] 119 | } 120 | ``` 121 | 122 | 123 | ## Options 124 | | Property | Type | Default | Description | 125 | | -------- | ---- | ------- | ----------- | 126 | | `knex` | `Object` | `null` | Available options: http://knexjs.org/#Installation-client | 127 | | `tableName` | `String` | `null` | Table name. If empty, use the service name. | 128 | 129 | ## Raw update 130 | If you want to update an entity and using raw changes, use the [`updateEntity`](../README.md#updateentity) method with `{ raw: true }` options. In this case, you can use the MongoDB-like `$set` and `$inc` operators in the `params` parameter. 131 | 132 | ### Example 133 | ```js 134 | const row = await this.updateEntity(ctx, { 135 | id: "YVdnh5oQCyEIRja0", 136 | 137 | $set: { 138 | status: false, 139 | height: 192 140 | }, 141 | $inc: { 142 | age: 1 143 | } 144 | }, { raw: true }); 145 | ``` 146 | 147 | ## Additional methods 148 | 149 | ### `createTable` 150 | `createTable(fields?: Array, opts?: Object): Promise` 151 | It creates the table based on the specified fields in the service settings. 152 | 153 | > Use it in testing & prototyping. In production use the [Knex migration](http://knexjs.org/#Migrations) feature. 154 | 155 | #### Options 156 | | Property | Type | Default | Description | 157 | | -------- | ---- | ------- | ----------- | 158 | | `dropTableIfExists` | `boolean` | `true` | Drop the table if exists. | 159 | | `createIndexes` | `boolean` | `false` | Create indexes based on service `settings.indexes`. | 160 | 161 | 162 | #### Example 163 | 164 | ```js 165 | // posts.service.js 166 | const DbService = require("@moleculer/database").Service; 167 | 168 | module.exports = { 169 | name: "posts", 170 | mixins: [DbService({ 171 | adapter: { 172 | type: "Knex", 173 | options: { 174 | knex: { 175 | client: "mssql", 176 | connection: { 177 | host: "127.0.0.1", 178 | port: 1433, 179 | user: "sa", 180 | password: "Moleculer@Pass1234", 181 | database: "moleculer", 182 | encrypt: false 183 | } 184 | } 185 | } 186 | } 187 | })], 188 | 189 | settings: { 190 | fields: { 191 | id: { type: "number", primaryKey: true, columnName: "_id", columnType: "integer" }, 192 | title: { type: "string", required: true, max: 100, trim: true }, 193 | content: { type: "string", columnType: "text" } 194 | } 195 | }, 196 | 197 | async started() { 198 | const adapter = await this.getAdapter(); 199 | await adapter.createTable(); 200 | } 201 | } 202 | ``` 203 | 204 | ### `dropTable` 205 | `dropTable(tableName?: String): Promise` 206 | 207 | It drops the table. If the `tableName` is empty, uses the `opts.tableName` property. 208 | 209 | > Use it in testing & prototyping. In production use the [Knex migration](http://knexjs.org/#Migrations) feature. 210 | -------------------------------------------------------------------------------- /docs/adapters/MongoDB.md: -------------------------------------------------------------------------------- 1 | # MongoDB adapter 2 | This adapter gives access to MongoDB databases. It uses the official [`mongodb`](https://docs.mongodb.com/drivers/node/current/) library. 3 | 4 | ## Install 5 | This module contains the source code of the adapter. You just need to install the dependent library. 6 | 7 | ```bash 8 | npm install mongodb@^4.1.4 9 | ``` 10 | 11 | ## Usage 12 | 13 | ### Use the default localhost URI 14 | If you not define any options, the adapter uses the `"mongodb://127.0.0.1:27017"` connection string. 15 | 16 | ```js 17 | // posts.service.js 18 | const DbService = require("@moleculer/database").Service; 19 | 20 | module.exports = { 21 | name: "posts", 22 | mixins: [DbService({ 23 | adapter: { type: "MongoDB" } 24 | })] 25 | } 26 | ``` 27 | 28 | ## With options 29 | ```js 30 | // posts.service.js 31 | const DbService = require("@moleculer/database").Service; 32 | 33 | module.exports = { 34 | name: "posts", 35 | mixins: [DbService({ 36 | adapter: { 37 | type: "MongoDB", 38 | options: { 39 | uri: "mongodb+srv://server_name:27017/?maxPoolSize=20", 40 | mongoClientOptions: { 41 | auth: { 42 | username: "user", 43 | password: "secret" 44 | } 45 | } 46 | } 47 | } 48 | })] 49 | } 50 | ``` 51 | 52 | ## Options 53 | | Property | Type | Default | Description | 54 | | -------- | ---- | ------- | ----------- | 55 | | `uri` | `String` | `"mongodb://127.0.0.1:27017"` | MongoDB connection URI. | 56 | | `mongoClientOptions` | `Object` | `null` | Available options: https://mongodb.github.io/node-mongodb-native/4.1/interfaces/MongoClientOptions.html | 57 | | `dbOptions` | `Object` | `null` | Available options: https://mongodb.github.io/node-mongodb-native/4.1/interfaces/DbOptions.html | 58 | 59 | 60 | ## Raw update 61 | If you want to update entity and using raw changes, use the [`updateEntity`](../README.md#updateentity) method with `{ raw: true }` options. In this case, you can use [MongoDB update operators](https://docs.mongodb.com/manual/reference/operator/update/) in the `params` parameter. 62 | 63 | ### Example 64 | ```js 65 | const row = await this.updateEntity(ctx, { 66 | id: "YVdnh5oQCyEIRja0", 67 | 68 | $set: { 69 | status: false, 70 | height: 192 71 | }, 72 | $inc: { 73 | age: 1 74 | }, 75 | $unset: { 76 | dob: true 77 | } 78 | }, { raw: true }); 79 | ``` 80 | 81 | ## Additional methods 82 | 83 | ### `stringToObjectID` 84 | `stringToObjectID(id: any): ObjectID|any` 85 | 86 | This method convert the `id` parameter to `ObjectID` if the `id` is `String` as a valid `ObjectID` hex string. Otherwise returns the intact `id` value. 87 | 88 | ### `objectIDToString` 89 | `objectIDToString(id: ObjectID): String` 90 | 91 | This method convert the `id` parameter which is an `ObjectID` to `String`. 92 | -------------------------------------------------------------------------------- /docs/adapters/NeDB.md: -------------------------------------------------------------------------------- 1 | # NeDB adapter 2 | The NeDB adapter is the default adapter. It uses the [@seald-io/nedb](https://github.com/seald/nedb) library which is a lightweight embedded persistent or in-memory database. The [API](https://github.com/seald/nedb/blob/master/API.md) is a subset of MongoDB's API. 3 | 4 | > Use this adapter for prototyping and testing. 5 | 6 | ## Install 7 | This module contains the source code of the adapter. You just need to install the dependent library. 8 | 9 | ```bash 10 | npm install @seald-io/nedb 11 | ``` 12 | 13 | ## Usage 14 | 15 | ### In memory NeDB adapter 16 | This is the default adapter, no need any configuration. 17 | 18 | ```js 19 | // posts.service.js 20 | const DbService = require("@moleculer/database").Service; 21 | 22 | module.exports = { 23 | name: "posts", 24 | mixins: [DbService()] 25 | } 26 | ``` 27 | 28 | ### Persistent database 29 | To use a persistent database, set the filename. 30 | ```js 31 | // posts.service.js 32 | const DbService = require("@moleculer/database").Service; 33 | 34 | module.exports = { 35 | name: "posts", 36 | mixins: [DbService({ 37 | adapter: { 38 | type: "NeDB", 39 | options: "./posts.db" 40 | } 41 | })] 42 | } 43 | ``` 44 | 45 | ### Using NeDB options 46 | ```js 47 | // posts.service.js 48 | const DbService = require("@moleculer/database").Service; 49 | 50 | module.exports = { 51 | name: "posts", 52 | mixins: [DbService({ 53 | adapter: { 54 | type: "NeDB", 55 | options: { 56 | neDB: { 57 | inMemoryOnly: true, 58 | corruptAlertThreshold: 0.5 59 | } 60 | } 61 | } 62 | })] 63 | } 64 | ``` 65 | 66 | ### Using custom NeDB instance 67 | ```js 68 | // posts.service.js 69 | const DbService = require("@moleculer/database").Service; 70 | const MyNeDB = require("..."); 71 | 72 | module.exports = { 73 | name: "posts", 74 | mixins: [DbService({ 75 | adapter: { 76 | type: "NeDB", 77 | options: { 78 | neDB: new MyNeDB({ filename: "./posts.db" }) 79 | } 80 | } 81 | })] 82 | } 83 | ``` 84 | 85 | ## Options 86 | | Property | Type | Default | Description | 87 | | -------- | ---- | ------- | ----------- | 88 | | `neDB` | `Object|DataStore` | `null` | [NeDB constructor options](https://github.com/seald/nedb?tab=readme-ov-file#creatingloading-a-database). If it's a `DataStore` instance, it uses it instead of creating a new one. | 89 | 90 | 91 | ## Raw update 92 | If you want to update entity and using raw changes, use the [`updateEntity`](../README.md#updateentity) method with `{ raw: true }` options. In this case, you can use [NeDB modifiers](https://github.com/seald/nedb?tab=readme-ov-file#updating-documents) in the `params` parameter. 93 | 94 | ### Example 95 | ```js 96 | const row = await this.updateEntity(ctx, { 97 | id: "YVdnh5oQCyEIRja0", 98 | 99 | $set: { 100 | status: false, 101 | height: 192 102 | }, 103 | $inc: { 104 | age: 1 105 | }, 106 | $unset: { 107 | dob: true 108 | } 109 | }, { raw: true }); 110 | ``` 111 | 112 | ## Additional methods 113 | No any additional methods. 114 | -------------------------------------------------------------------------------- /examples/connect/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | "use strict"; 3 | 4 | /** 5 | * It's an example to test the connect/disconnect logic of adapters. 6 | */ 7 | 8 | const { ServiceBroker } = require("moleculer"); 9 | const { inspect } = require("util"); 10 | const DbService = require("../../index").Service; 11 | 12 | // Create broker 13 | const broker = new ServiceBroker({ 14 | logger: { 15 | type: "Console", 16 | options: { 17 | level: { 18 | POSTS: "debug", 19 | "*": "info" 20 | }, 21 | objectPrinter: obj => 22 | inspect(obj, { 23 | breakLength: 50, 24 | colors: true, 25 | depth: 3 26 | }) 27 | } 28 | } 29 | }); 30 | 31 | // Create a service 32 | broker.createService({ 33 | name: "posts", 34 | mixins: [ 35 | DbService({ 36 | adapter: { 37 | type: "MongoDB" 38 | } 39 | }) 40 | ], 41 | 42 | settings: { 43 | fields: { 44 | id: { type: "string", primaryKey: true, columnName: "_id" }, 45 | title: { 46 | type: "string", 47 | max: 255, 48 | trim: true, 49 | required: true 50 | }, 51 | content: { type: "string" }, 52 | votes: { type: "number", integer: true, min: 0, default: 0, columnType: "int" }, 53 | status: { type: "boolean", default: true } 54 | } 55 | }, 56 | 57 | async started() { 58 | this.logger.info("Creating multiple adapters..."); 59 | const adapters = await Promise.all([ 60 | this.getAdapter(), 61 | this.getAdapter(), 62 | this.getAdapter() 63 | ]); 64 | this.logger.info( 65 | "Adapters created.", 66 | adapters.map(a => a.constructor.name) 67 | ); 68 | } 69 | }); 70 | 71 | // Start server 72 | broker 73 | .start() 74 | .then(() => broker.repl()) 75 | .catch(err => { 76 | broker.logger.error(err); 77 | process.exit(1); 78 | }); 79 | -------------------------------------------------------------------------------- /examples/global-pool/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /** 4 | * Global pool handling. 5 | * 6 | * As all services are loaded to the same broker and all services use the same adapter, 7 | * the service only establish one connection and all services use this one. 8 | * The connection remains open even if a service is destroyed. Only the last service destroy 9 | * will close the connection. 10 | */ 11 | 12 | const { ServiceBroker } = require("moleculer"); 13 | const DbService = require("../../index").Service; 14 | 15 | const broker = new ServiceBroker(); 16 | 17 | const adapter = { 18 | type: "MongoDB", 19 | options: "mongodb://127.0.0.1:27017/example-pool" 20 | }; 21 | 22 | const fields = { 23 | id: { type: "string", primaryKey: true, columnName: "_id" }, 24 | title: "string" 25 | }; 26 | 27 | broker.createService({ 28 | name: "posts", 29 | mixins: [DbService({ adapter })], 30 | settings: { fields } 31 | }); 32 | broker.createService({ 33 | name: "users", 34 | mixins: [DbService({ adapter })], 35 | settings: { fields } 36 | }); 37 | broker.createService({ 38 | name: "comments", 39 | mixins: [DbService({ adapter })], 40 | settings: { fields } 41 | }); 42 | broker.createService({ 43 | name: "likes", 44 | mixins: [DbService({ adapter })], 45 | settings: { fields } 46 | }); 47 | 48 | // Start server 49 | broker 50 | .start() 51 | .then(() => broker.repl()) 52 | .then(async () => { 53 | await broker.call("posts.list"); 54 | await broker.call("users.list"); 55 | await broker.call("comments.list"); 56 | await broker.call("likes.list"); 57 | 58 | await broker.Promise.delay(5000); 59 | 60 | await broker.destroyService("posts"); 61 | await broker.Promise.delay(1000); 62 | await broker.call("users.list"); 63 | await broker.destroyService("users"); 64 | await broker.Promise.delay(1000); 65 | await broker.destroyService("comments"); 66 | await broker.Promise.delay(1000); 67 | await broker.destroyService("likes"); 68 | }) 69 | .catch(err => broker.logger.error(err)); 70 | -------------------------------------------------------------------------------- /examples/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const moduleName = process.argv[2] || "simple"; 4 | process.argv.splice(2, 1); 5 | 6 | require("./" + moduleName); 7 | -------------------------------------------------------------------------------- /examples/knex-migration/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /** 4 | * This example demonstrates how to use the Knex 5 | * migration with this service. 6 | * The migrations scripts are in the "migration" folder. 7 | */ 8 | 9 | const { ServiceBroker } = require("moleculer"); 10 | const DbService = require("../../index").Service; 11 | 12 | const broker = new ServiceBroker(); 13 | 14 | broker.createService({ 15 | name: "posts", 16 | mixins: [ 17 | DbService({ 18 | adapter: { 19 | type: "Knex", 20 | options: { 21 | knex: { 22 | client: "sqlite3", 23 | connection: { 24 | filename: "posts.db" 25 | }, 26 | useNullAsDefault: true 27 | } 28 | } 29 | } 30 | }) 31 | ], 32 | 33 | settings: { 34 | fields: { 35 | id: { type: "number", primaryKey: true }, 36 | title: { 37 | type: "string", 38 | max: 255, 39 | trim: true, 40 | required: true 41 | }, 42 | content: { type: "string" }, 43 | author: { type: "number" }, 44 | votes: { type: "number", integer: true, min: 0, default: 0 }, 45 | status: { type: "boolean", default: true }, 46 | createdAt: { type: "number", readonly: true, onCreate: () => Date.now() }, 47 | updatedAt: { type: "number", readonly: true, onUpdate: () => Date.now() } 48 | } 49 | }, 50 | 51 | async started() { 52 | const adapter = await this.getAdapter(); 53 | 54 | // Migrate to the latest version. 55 | await adapter.client.migrate.latest(); 56 | } 57 | }); 58 | 59 | // Start server 60 | broker 61 | .start() 62 | .then(async () => { 63 | broker.repl(); 64 | // Create a new post 65 | /*let post = await broker.call("posts.create", { 66 | title: "My first post", 67 | content: "Content of my first post..." 68 | });*/ 69 | 70 | // Get all posts 71 | let posts = await broker.call("posts.find"); 72 | console.log("Find:", posts); 73 | }) 74 | .catch(err => broker.logger.error(err)); 75 | -------------------------------------------------------------------------------- /examples/knex-migration/migrations/20210425191836_init.js: -------------------------------------------------------------------------------- 1 | exports.up = function (knex) { 2 | return knex.schema.createTable("posts", table => { 3 | table.increments("id"); 4 | table.string("title"); 5 | table.string("content"); 6 | table.integer("votes"); 7 | table.boolean("status"); 8 | table.bigInteger("createdAt"); 9 | table.bigInteger("updatedAt"); 10 | }); 11 | }; 12 | 13 | exports.down = function (knex) { 14 | return knex.schema.dropTable("posts"); 15 | }; 16 | -------------------------------------------------------------------------------- /examples/knex-migration/migrations/20210425193759_author.js: -------------------------------------------------------------------------------- 1 | exports.up = function (knex) { 2 | return knex.schema.alterTable("posts", table => { 3 | table.integer("author"); 4 | }); 5 | }; 6 | 7 | exports.down = function (knex) { 8 | return knex.schema.alterTable("posts", table => { 9 | table.dropColumn("author"); 10 | }); 11 | }; 12 | -------------------------------------------------------------------------------- /examples/many/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /** 4 | * This example created a lot of (1000) entities 5 | * and measure the exection times. 6 | */ 7 | 8 | const { ServiceBroker } = require("moleculer"); 9 | const DbService = require("../../index").Service; 10 | const path = require("path"); 11 | const fs = require("fs"); 12 | 13 | const Fakerator = require("fakerator"); 14 | const fakerator = new Fakerator(); 15 | 16 | // Create broker 17 | const broker = new ServiceBroker(); 18 | 19 | const sqliteFilename = path.join(__dirname, "db.sqlite3"); 20 | 21 | if (fs.existsSync(sqliteFilename)) fs.unlinkSync(sqliteFilename); 22 | if (fs.existsSync(sqliteFilename + "-journal")) fs.unlinkSync(sqliteFilename + "-journal"); 23 | 24 | // Create a service 25 | const svc = broker.createService({ 26 | name: "posts", 27 | mixins: [ 28 | DbService({ 29 | adapter: { 30 | //type: "MongoDB" 31 | type: "Knex", 32 | options: { 33 | knex: { 34 | client: "sqlite3", 35 | connection: { 36 | filename: sqliteFilename 37 | }, 38 | useNullAsDefault: true, 39 | pool: { 40 | min: 1, 41 | max: 1 42 | }, 43 | log: { 44 | warn(message) {}, 45 | error(message) {}, 46 | deprecate(message) {}, 47 | debug(message) {} 48 | } 49 | } 50 | } 51 | } 52 | }) 53 | ], 54 | 55 | settings: { 56 | fields: { 57 | id: { type: "string", primaryKey: true, columnName: "_id" }, 58 | title: { 59 | type: "string", 60 | max: 255, 61 | trim: true, 62 | required: true 63 | }, 64 | content: { type: "string" }, 65 | votes: { type: "number", integer: true, min: 0, default: 0, columnType: "integer" }, 66 | status: { type: "boolean", default: true }, 67 | createdAt: { 68 | type: "number", 69 | readonly: true, 70 | onCreate: () => Date.now(), 71 | columnType: "real" 72 | }, 73 | updatedAt: { 74 | type: "number", 75 | readonly: true, 76 | onUpdate: () => Date.now(), 77 | columnType: "real" 78 | } 79 | } 80 | }, 81 | async started() { 82 | const adapter = await this.getAdapter(); 83 | await adapter.createTable(); 84 | 85 | await this.clearEntities(); 86 | } 87 | }); 88 | 89 | // Start server 90 | broker 91 | .start() 92 | .then(async () => { 93 | // Create posts 94 | 95 | const COUNT = 1000; 96 | const POSTS = fakerator.times(fakerator.entity.post, COUNT); 97 | 98 | const span = broker.tracer.startSpan("create posts"); 99 | 100 | /*for (const post of POSTS) { 101 | await broker.call("posts.create", post); 102 | }*/ 103 | const res = await broker.call("posts.createMany", POSTS); 104 | span.finish(); 105 | 106 | //broker.logger.info("Inserted result", res); 107 | broker.logger.info(`Created ${COUNT} posts. Time:`, span.duration.toFixed(2), "ms"); 108 | 109 | // List posts with pagination 110 | const posts = await broker.call("posts.list", { 111 | fields: ["id", "title", "votes", "status"] 112 | }); 113 | broker.logger.info("List:", posts); 114 | }) 115 | .catch(err => broker.logger.error(err)); 116 | -------------------------------------------------------------------------------- /examples/multi-tenants/.gitignore: -------------------------------------------------------------------------------- 1 | posts/ 2 | -------------------------------------------------------------------------------- /examples/multi-tenants/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /** 4 | * This example demonstrates the multi-tenant feature. 5 | */ 6 | 7 | const { ServiceBroker } = require("moleculer"); 8 | const { inspect } = require("util"); 9 | const DbService = require("../../index").Service; 10 | 11 | const tenant1Meta = { meta: { tenantId: 1001 }, broadcast: () => {} }; 12 | const tenant2Meta = { meta: { tenantId: 1002 }, broadcast: () => {} }; 13 | const tenant3Meta = { meta: { tenantId: 1003 }, broadcast: () => {} }; 14 | 15 | // Create broker 16 | const broker = new ServiceBroker({ 17 | logger: { 18 | type: "Console", 19 | options: { 20 | objectPrinter: obj => 21 | inspect(obj, { 22 | breakLength: 50, 23 | colors: true, 24 | depth: 3 25 | }) 26 | } 27 | } 28 | }); 29 | 30 | // Create a service 31 | broker.createService({ 32 | name: "posts", 33 | mixins: [ 34 | DbService({ 35 | adapter: { 36 | //type: "MongoDB", 37 | type: "Knex", 38 | options: { 39 | //uri: "mongodb://127.0.0.1:27017", 40 | //dbName: "tenants", 41 | //collection: "posts", 42 | tableName: "tenant_posts", 43 | knex: { 44 | client: "mssql", 45 | connection: { 46 | host: "127.0.0.1", 47 | port: 1433, 48 | user: "sa", 49 | password: "Moleculer@Pass1234", 50 | database: "db_int_test", 51 | encrypt: false 52 | } 53 | } 54 | } 55 | } 56 | }) 57 | ], 58 | settings: { 59 | fields: { 60 | id: { type: "number", increment: true, primaryKey: true, columnType: "bigint" }, 61 | title: { type: "string", required: true }, 62 | content: { type: "string", required: false }, 63 | tenantId: { 64 | type: "number", 65 | columnType: "integer", 66 | set: ({ ctx }) => ctx.meta.tenantId, 67 | required: true 68 | } 69 | }, 70 | scopes: { 71 | tenant(q, ctx) { 72 | const tenantId = ctx.meta.tenantId; 73 | if (!tenantId) throw new Error("Missing tenantId!"); 74 | 75 | q.tenantId = ctx.meta.tenantId; 76 | return q; 77 | } 78 | }, 79 | defaultScopes: ["tenant"] 80 | }, 81 | methods: { 82 | getAdapterByContext(ctx) { 83 | const tenantId = ctx.meta.tenantId; 84 | if (!tenantId) throw new Error("Missing tenantId!"); 85 | 86 | return [ 87 | tenantId, 88 | { 89 | //type: "MongoDB", 90 | type: "Knex", 91 | options: { 92 | //uri: "mongodb://127.0.0.1:27017", 93 | //dbName: `tenant-posts-${tenantId}`, 94 | //collection: `posts` 95 | 96 | tableName: "tenant_posts", 97 | schema: "tenant_" + tenantId, 98 | knex: { 99 | client: "mssql", 100 | connection: { 101 | host: "127.0.0.1", 102 | port: 1433, 103 | user: "sa", 104 | password: "Moleculer@Pass1234", 105 | database: "db_int_test", 106 | encrypt: false 107 | } 108 | } 109 | } 110 | } 111 | ]; 112 | } 113 | }, 114 | 115 | hooks: { 116 | customs: { 117 | async adapterConnected(adapter) { 118 | await adapter.createTable(); 119 | } 120 | } 121 | }, 122 | 123 | async started() { 124 | await this.clearEntities(tenant1Meta); 125 | await this.clearEntities(tenant2Meta); 126 | await this.clearEntities(tenant3Meta); 127 | } 128 | }); 129 | 130 | // Start server 131 | broker.start().then(async () => { 132 | await broker.call("posts.find", null, tenant1Meta); 133 | 134 | await broker.call( 135 | "posts.create", 136 | { title: "First post", content: "First content" }, 137 | tenant1Meta 138 | ); 139 | await broker.call( 140 | "posts.create", 141 | { title: "Second post", content: "Second content" }, 142 | tenant2Meta 143 | ); 144 | await broker.call( 145 | "posts.create", 146 | { title: "Third post", content: "Third content" }, 147 | tenant3Meta 148 | ); 149 | await broker.call( 150 | "posts.create", 151 | { title: "Fourth post", content: "Fourth content" }, 152 | tenant1Meta 153 | ); 154 | await broker.call( 155 | "posts.create", 156 | { title: "Fifth post", content: "Fifth content" }, 157 | tenant2Meta 158 | ); 159 | 160 | broker.logger.info("Find tenant1: ", await broker.call("posts.find", null, tenant1Meta)); 161 | broker.logger.info("Find tenant2: ", await broker.call("posts.find", null, tenant2Meta)); 162 | broker.logger.info("Find tenant3: ", await broker.call("posts.find", null, tenant3Meta)); 163 | }); 164 | -------------------------------------------------------------------------------- /examples/multi-tenants/plenty.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /** 4 | * In collection-based or database-based multi-tenant mode each tenant 5 | * opens a DB connection to the own database/collection. It can generates 6 | * a lot of connections. This example demonstrates how to work the `maximumAdapters` 7 | * mixin option which limits the number of connections. 8 | */ 9 | 10 | const _ = require("lodash"); 11 | const { ServiceBroker } = require("moleculer"); 12 | const DbService = require("../../index").Service; 13 | 14 | // Create broker 15 | const broker = new ServiceBroker(); 16 | 17 | // Create a service 18 | const svc = broker.createService({ 19 | name: "posts", 20 | mixins: [DbService({ maximumAdapters: 5 })], 21 | settings: { 22 | fields: { 23 | title: { type: "string", required: true }, 24 | content: { type: "string", required: false }, 25 | tenantId: { 26 | type: "number", 27 | set: ({ ctx }) => ctx.meta.tenantId, 28 | required: true 29 | } 30 | }, 31 | scopes: { 32 | tenant(q, ctx) { 33 | const tenantId = ctx.meta.tenantId; 34 | if (!tenantId) throw new Error("Missing tenantId!"); 35 | 36 | q.tenantId = ctx.meta.tenantId; 37 | return q; 38 | } 39 | }, 40 | defaultScopes: ["tenant"] 41 | }, 42 | methods: { 43 | getAdapterByContext(ctx) { 44 | const tenantId = ctx.meta.tenantId; 45 | if (!tenantId) throw new Error("Missing tenantId!"); 46 | 47 | return [ 48 | tenantId, 49 | /*{ 50 | type: "NeDB", 51 | options: __dirname + `/posts/${_.padStart(tenantId, 4, "0")}.db` 52 | }*/ 53 | { 54 | type: "MongoDB", 55 | options: { 56 | uri: "mongodb://127.0.0.1:27017", 57 | dbName: `tenant-posts`, 58 | collection: `posts-${tenantId}` 59 | } 60 | } 61 | ]; 62 | } 63 | }, 64 | async started() {} 65 | }); 66 | 67 | // Start server 68 | let count = 0; 69 | broker.start().then(async () => { 70 | setInterval(async () => { 71 | const tenantId = 1 + Math.floor(Math.random() * 10); 72 | 73 | await broker.call( 74 | "posts.create", 75 | { title: `New post ${++count}` }, 76 | { 77 | meta: { tenantId } 78 | } 79 | ); 80 | 81 | console.log("TenantId:", tenantId, " Adapters:", svc.adapters.size); 82 | }, 2000); 83 | }); 84 | -------------------------------------------------------------------------------- /examples/simple/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | "use strict"; 3 | 4 | /** 5 | * It's a simple example which demonstrates how to 6 | * use this services and make CRUD actions. 7 | */ 8 | 9 | const { ServiceBroker } = require("moleculer"); 10 | const { inspect } = require("util"); 11 | const DbService = require("../../index").Service; 12 | 13 | // Create broker 14 | const broker = new ServiceBroker({ 15 | logger: { 16 | type: "Console", 17 | options: { 18 | level: { 19 | POSTS: "debug", 20 | "*": "info" 21 | }, 22 | objectPrinter: obj => 23 | inspect(obj, { 24 | breakLength: 50, 25 | colors: true, 26 | depth: 3 27 | }) 28 | } 29 | }, 30 | metrics: { 31 | enabled: false, 32 | reporter: { 33 | type: "Console", 34 | options: { 35 | includes: ["moleculer.database.**"] 36 | } 37 | } 38 | }, 39 | 40 | tracing: { 41 | enabled: false, 42 | exporter: { 43 | type: "Console" 44 | } 45 | } 46 | }); 47 | 48 | // Create a service 49 | broker.createService({ 50 | name: "posts", 51 | mixins: [ 52 | DbService({ 53 | adapter: { 54 | /*type: "Knex", 55 | options: { 56 | knex: { 57 | client: "sqlite3", 58 | connection: { 59 | filename: ":memory:" 60 | } 61 | } 62 | }*/ 63 | type: "MongoDB" 64 | } 65 | }) 66 | ], 67 | 68 | settings: { 69 | fields: { 70 | id: { type: "string", primaryKey: true, columnName: "_id" /*, generated: "user"*/ }, 71 | title: { 72 | type: "string", 73 | max: 255, 74 | trim: true, 75 | required: true 76 | }, 77 | content: { type: "string" }, 78 | votes: { type: "number", integer: true, min: 0, default: 0, columnType: "int" }, 79 | status: { type: "boolean", default: true }, 80 | createdAt: { 81 | type: "number", 82 | readonly: true, 83 | onCreate: () => Date.now(), 84 | columnType: "double" 85 | }, 86 | updatedAt: { 87 | type: "number", 88 | readonly: true, 89 | onUpdate: () => Date.now(), 90 | columnType: "double" 91 | } 92 | } 93 | }, 94 | 95 | async started() { 96 | const adapter = await this.getAdapter(); 97 | if (adapter.createTable) await adapter.createTable(); 98 | 99 | await this.clearEntities(); 100 | } 101 | }); 102 | 103 | // Start server 104 | broker 105 | .start() 106 | .then(async () => { 107 | // Create a new post 108 | let post = await broker.call("posts.create", { 109 | id: "63397c8302751c0c0abd3609", 110 | title: "My first post", 111 | content: "Content of my first post..." 112 | }); 113 | console.log("First post:", post); 114 | 115 | await broker.Promise.delay(500); 116 | 117 | post = await broker.call("posts.create", { 118 | title: "My second post", 119 | content: "Content of my second post...", 120 | votes: 3 121 | }); 122 | console.log("Second post:", post); 123 | 124 | post = await broker.call("posts.create", { 125 | title: "Third post", 126 | content: "Content of my 3rd post...", 127 | status: false 128 | }); 129 | console.log("3rd post:", post); 130 | 131 | let posts = await broker.call("posts.createMany", [ 132 | { 133 | title: "Forth post", 134 | content: "Content of my 4th post...", 135 | status: true 136 | }, 137 | { 138 | title: "Fifth post", 139 | content: "Content of my 5th post...", 140 | status: true 141 | } 142 | ]); 143 | console.log("4. & 5. posts:", posts); 144 | 145 | // Get all posts 146 | //let posts = await broker.call("posts.find", { limit: 2, sort: "-createdAt" }); 147 | posts = await broker.call("posts.find", { query: { status: false } }); 148 | console.log("Find:", posts); 149 | 150 | // List posts with pagination 151 | posts = await broker.call("posts.list", { page: 1, pageSize: 10 }); 152 | console.log("List:", posts); 153 | 154 | // Get a post by ID 155 | post = await broker.call("posts.get", { id: post.id }); 156 | console.log("Get:", post); 157 | 158 | // Update the post 159 | post = await broker.call("posts.update", { id: post.id, title: "Modified post" }); 160 | console.log("Updated:", post); 161 | 162 | // Delete a user 163 | const res = await broker.call("posts.remove", { id: post.id }); 164 | console.log("Deleted:", res); 165 | }) 166 | .then(() => broker.repl()) 167 | .catch(err => { 168 | broker.logger.error(err); 169 | process.exit(1); 170 | }); 171 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /* 2 | * @moleculer/database 3 | * Copyright (c) 2022 MoleculerJS (https://github.com/moleculerjs/database) 4 | * MIT Licensed 5 | */ 6 | 7 | "use strict"; 8 | 9 | const Schema = require("./src/schema"); 10 | 11 | module.exports = { 12 | Service: require("./src"), 13 | Adapters: require("./src/adapters"), 14 | Errors: require("./src/errors"), 15 | generateValidatorSchemaFromFields: Schema.generateValidatorSchemaFromFields, 16 | generateFieldValidatorSchema: Schema.generateFieldValidatorSchema 17 | }; 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@moleculer/database", 3 | "version": "0.3.0", 4 | "description": "Advanced Database Access Service for Moleculer microservices framework", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "nodemon examples/index.js", 8 | "ci:unit": "jest --testMatch \"**/unit/**/*.spec.js\" --watch", 9 | "ci:integration": "jest --testMatch \"**/integration/**/*.spec.js\" --runInBand --watch", 10 | "ci:leak": "jest --testMatch \"**/leak-detection/**/index.spec.js\" --runInBand --watch", 11 | "test:unit": "jest --testMatch \"**/unit/**/*.spec.js\"", 12 | "test:integration": "jest --testMatch \"**/integration/**/*.spec.js\" --runInBand --coverage", 13 | "test:leak": "jest --testMatch \"**/leak-detection/**/*.spec.js\" --runInBand", 14 | "test": "jest --testMatch \"**/*.spec.js\" --runInBand --coverage", 15 | "lint": "eslint --ext=.js src examples test", 16 | "bench": "node benchmark/index.js", 17 | "bench:watch": "nodemon benchmark/index.js", 18 | "deps": "npm-check -u && npm audit fix", 19 | "ci-update-deps": "ncu -u --target minor", 20 | "coverall": "cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js", 21 | "release": "npm publish --access public && git push --tags" 22 | }, 23 | "keywords": [ 24 | "moleculer", 25 | "microservice" 26 | ], 27 | "repository": { 28 | "type": "git", 29 | "url": "https://github.com/moleculerjs/database.git" 30 | }, 31 | "author": "MoleculerJS", 32 | "license": "MIT", 33 | "peerDependencies": { 34 | "moleculer": "^0.14.12 || ^0.15.0-0" 35 | }, 36 | "devDependencies": { 37 | "@seald-io/nedb": "^4.1.1", 38 | "@vscode/sqlite3": "^5.1.2", 39 | "axios": "^1.9.0", 40 | "benchmarkify": "^4.0.0", 41 | "coveralls": "^3.1.1", 42 | "eslint": "^8.57.0", 43 | "eslint-config-prettier": "^9.1.0", 44 | "eslint-plugin-node": "^11.1.0", 45 | "eslint-plugin-prettier": "^5.2.6", 46 | "eslint-plugin-promise": "^6.6.0", 47 | "eslint-plugin-security": "^2.1.1", 48 | "fakerator": "^0.3.6", 49 | "globby": "^13.2.2", 50 | "jest": "^29.7.0", 51 | "jest-cli": "^29.7.0", 52 | "kleur": "^4.1.5", 53 | "knex": "^3.1.0", 54 | "moleculer": "^0.14.35", 55 | "moleculer-repl": "^0.7.4", 56 | "moleculer-web": "^0.10.8", 57 | "mongodb": "^6.16.0", 58 | "mysql": "^2.18.1", 59 | "mysql2": "^3.14.1", 60 | "nodemon": "^3.1.10", 61 | "npm-check-updates": "^16.14.20", 62 | "pg": "^8.15.6", 63 | "prettier": "^3.5.3", 64 | "qs": "^6.14.0", 65 | "sequelize": "^6.37.7", 66 | "tedious": "^18.6.2" 67 | }, 68 | "jest": { 69 | "testEnvironment": "node", 70 | "rootDir": "./src", 71 | "roots": [ 72 | "../test" 73 | ], 74 | "coverageDirectory": "../coverage", 75 | "coveragePathIgnorePatterns": [ 76 | "/node_modules/", 77 | "/test/services/" 78 | ] 79 | }, 80 | "engines": { 81 | "node": ">= 20.x.x" 82 | }, 83 | "dependencies": { 84 | "fastest-validator": "^1.19.1", 85 | "lodash": "^4.17.21", 86 | "semver": "^7.7.1", 87 | "sqlite3": "^5.1.7" 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | useTabs: true, 3 | printWidth: 100, 4 | trailingComma: "none", 5 | tabWidth: 4, 6 | singleQuote: false, 7 | semi: true, 8 | bracketSpacing: true, 9 | arrowParens: "avoid" 10 | }; 11 | -------------------------------------------------------------------------------- /src/actions.js: -------------------------------------------------------------------------------- 1 | /* 2 | * @moleculer/database 3 | * Copyright (c) 2024 MoleculerJS (https://github.com/moleculerjs/database) 4 | * MIT Licensed 5 | */ 6 | 7 | "use strict"; 8 | 9 | const { isActionEnabled } = require("./schema"); 10 | 11 | const PARAMS_FIELDS = [ 12 | { type: "string", optional: true }, 13 | { type: "array", optional: true, items: "string" } 14 | ]; 15 | 16 | const PARAMS_SEARCHFIELDS = [ 17 | { type: "string", optional: true }, 18 | { type: "array", optional: true, items: "string" } 19 | ]; 20 | 21 | const PARAMS_SORT = [ 22 | { type: "string", optional: true }, 23 | { type: "array", optional: true, items: "string" } 24 | ]; 25 | 26 | const PARAMS_POPULATE = [ 27 | { type: "string", optional: true }, 28 | { type: "array", optional: true, items: "string" } 29 | ]; 30 | 31 | const PARAMS_SCOPE = [ 32 | { type: "boolean", optional: true }, 33 | { type: "string", optional: true }, 34 | { type: "array", optional: true, items: "string" } 35 | ]; 36 | 37 | const PARAMS_QUERY = [ 38 | { type: "object", optional: true }, 39 | { type: "string", optional: true } 40 | ]; 41 | 42 | module.exports = function (mixinOpts) { 43 | const res = {}; 44 | 45 | const cacheOpts = mixinOpts.cache && mixinOpts.cache.enabled ? mixinOpts.cache : null; 46 | const maxLimit = mixinOpts.maxLimit > 0 ? mixinOpts.maxLimit : null; 47 | 48 | const generateCacheOptions = minimalCacheKeys => { 49 | if (cacheOpts && cacheOpts.enabled) { 50 | const keys = Array.from(minimalCacheKeys); 51 | if (cacheOpts.additionalKeys) keys.push(...cacheOpts.additionalKeys); 52 | return { enabled: true, keys }; 53 | } 54 | return null; 55 | }; 56 | 57 | /** 58 | * Find entities by query. 59 | * 60 | * @actions 61 | * @cached 62 | * 63 | * @param {Number} limit - Max count of rows. 64 | * @param {Number} offset - Count of skipped rows. 65 | * @param {Array?} fields - Fields filter. 66 | * @param {String} sort - Sorted fields. 67 | * @param {String} search - Search text. 68 | * @param {String} searchFields - Fields for searching. 69 | * @param {String|Array|Boolean?} scope - Scoping 70 | * @param {Array?} populate - Populated fields. 71 | * @param {Object} query - Query object. Passes to adapter. 72 | * 73 | * @returns {Array} List of found entities. 74 | */ 75 | if (isActionEnabled(mixinOpts, "find")) { 76 | res.find = { 77 | visibility: mixinOpts.actionVisibility, 78 | rest: mixinOpts.rest ? "GET /all" : null, 79 | cache: generateCacheOptions([ 80 | "limit", 81 | "offset", 82 | "fields", 83 | "sort", 84 | "search", 85 | "searchFields", 86 | "collation", 87 | "scope", 88 | "populate", 89 | "query" 90 | ]), 91 | params: { 92 | limit: { 93 | type: "number", 94 | integer: true, 95 | min: 0, 96 | max: maxLimit, 97 | optional: true, 98 | convert: true 99 | }, 100 | offset: { type: "number", integer: true, min: 0, optional: true, convert: true }, 101 | fields: PARAMS_FIELDS, 102 | sort: PARAMS_SORT, 103 | search: { type: "string", optional: true }, 104 | searchFields: PARAMS_SEARCHFIELDS, 105 | collation: { type: "object", optional: true }, 106 | scope: PARAMS_SCOPE, 107 | populate: PARAMS_POPULATE, 108 | query: PARAMS_QUERY 109 | }, 110 | async handler(ctx) { 111 | return this.findEntities(ctx); 112 | } 113 | }; 114 | } 115 | 116 | /** 117 | * Get count of entities by query. 118 | * 119 | * @actions 120 | * @cached 121 | * 122 | * @param {String} search - Search text. 123 | * @param {String} searchFields - Fields list for searching. 124 | * @param {String|Array|Boolean?} scope - Scoping 125 | * @param {Object} query - Query object. Passes to adapter. 126 | * 127 | * @returns {Number} Count of found entities. 128 | */ 129 | if (isActionEnabled(mixinOpts, "count")) { 130 | res.count = { 131 | visibility: mixinOpts.actionVisibility, 132 | rest: mixinOpts.rest ? "GET /count" : null, 133 | cache: generateCacheOptions(["search", "searchFields", "scope", "query"]), 134 | params: { 135 | search: { type: "string", optional: true }, 136 | searchFields: PARAMS_SEARCHFIELDS, 137 | scope: PARAMS_SCOPE, 138 | query: PARAMS_QUERY 139 | }, 140 | async handler(ctx) { 141 | return this.countEntities(ctx); 142 | } 143 | }; 144 | } 145 | 146 | /** 147 | * List entities by filters and pagination results. 148 | * 149 | * @actions 150 | * @cached 151 | * 152 | * @param {Number} page - Page number. 153 | * @param {Number} pageSize - Size of a page. 154 | * @param {Array?} fields - Fields filter. 155 | * @param {String} sort - Sorted fields. 156 | * @param {String} search - Search text. 157 | * @param {String} searchFields - Fields for searching. 158 | * @param {String|Array|Boolean?} scope - Scoping 159 | * @param {Array?} populate - Populated fields. 160 | * @param {Object} query - Query object. Passes to adapter. 161 | * 162 | * @returns {Object} List of found entities and total count. 163 | */ 164 | if (isActionEnabled(mixinOpts, "list")) { 165 | res.list = { 166 | visibility: mixinOpts.actionVisibility, 167 | rest: mixinOpts.rest ? "GET /" : null, 168 | cache: generateCacheOptions([ 169 | "page", 170 | "pageSize", 171 | "fields", 172 | "sort", 173 | "search", 174 | "searchFields", 175 | "collation", 176 | "scope", 177 | "populate", 178 | "query" 179 | ]), 180 | params: { 181 | page: { type: "number", integer: true, min: 1, optional: true, convert: true }, 182 | pageSize: { 183 | type: "number", 184 | integer: true, 185 | min: 1, 186 | max: maxLimit, 187 | optional: true, 188 | convert: true 189 | }, 190 | fields: PARAMS_FIELDS, 191 | sort: PARAMS_SORT, 192 | search: { type: "string", optional: true }, 193 | searchFields: PARAMS_SEARCHFIELDS, 194 | collation: { type: "object", optional: true }, 195 | scope: PARAMS_SCOPE, 196 | populate: PARAMS_POPULATE, 197 | query: PARAMS_QUERY 198 | }, 199 | async handler(ctx) { 200 | const params = this.sanitizeParams(ctx.params, { list: true }); 201 | const rows = await this.findEntities(ctx, params); 202 | const total = await this.countEntities(ctx, params); 203 | 204 | return { 205 | // Rows 206 | rows, 207 | // Total rows 208 | total, 209 | // Page 210 | page: params.page, 211 | // Page size 212 | pageSize: params.pageSize, 213 | // Total pages 214 | totalPages: Math.floor((total + params.pageSize - 1) / params.pageSize) 215 | }; 216 | } 217 | }; 218 | } 219 | 220 | /** 221 | * Get entity by ID. 222 | * 223 | * @actions 224 | * @cached 225 | * 226 | * @param {any} id - ID of entity. 227 | * @param {Array?} fields - Fields filter. 228 | * @param {Array?} populate - Field list for populate. 229 | * @param {String|Array|Boolean?} scope - Scoping 230 | * 231 | * @returns {Object} Found entity. 232 | * 233 | * @throws {EntityNotFoundError} - 404 Entity not found 234 | */ 235 | if (isActionEnabled(mixinOpts, "get")) { 236 | res.get = { 237 | visibility: mixinOpts.actionVisibility, 238 | rest: mixinOpts.rest ? "GET /:id" : null, 239 | cache: generateCacheOptions(["id", "fields", "scope", "populate"]), 240 | params: { 241 | // The "id" field get from `fields` 242 | fields: PARAMS_FIELDS, 243 | scope: PARAMS_SCOPE, 244 | populate: PARAMS_POPULATE 245 | }, 246 | async handler(ctx) { 247 | return this.resolveEntities(ctx, ctx.params, { throwIfNotExist: true }); 248 | } 249 | }; 250 | } 251 | 252 | /** 253 | * Resolve entity(ies) by ID(s). 254 | * 255 | * @actions 256 | * @cached 257 | * 258 | * @param {any|Array} id - ID(s) of entity. 259 | * @param {Array?} fields - Fields filter. 260 | * @param {Array?} populate - Field list for populate. 261 | * @param {String|Array|Boolean?} scope - Scoping 262 | * @param {Boolean?} mapping - Convert the returned `Array` to `Object` where the key is the value of `id`. 263 | * 264 | * @returns {Object|Array} Found entity(ies). 265 | * 266 | * @throws {EntityNotFoundError} - 404 Entity not found 267 | */ 268 | if (isActionEnabled(mixinOpts, "resolve")) { 269 | res.resolve = { 270 | visibility: mixinOpts.actionVisibility, 271 | cache: generateCacheOptions([ 272 | "id", 273 | "fields", 274 | "scope", 275 | "populate", 276 | "mapping", 277 | "throwIfNotExist", 278 | "reorderResult" 279 | ]), 280 | params: { 281 | // The "id" field get from `fields` 282 | fields: PARAMS_FIELDS, 283 | scope: PARAMS_SCOPE, 284 | populate: PARAMS_POPULATE, 285 | mapping: { type: "boolean", optional: true }, 286 | throwIfNotExist: { type: "boolean", optional: true }, 287 | reorderResult: { type: "boolean", optional: true } 288 | }, 289 | async handler(ctx) { 290 | return this.resolveEntities(ctx, ctx.params, { 291 | throwIfNotExist: ctx.params.throwIfNotExist, 292 | reorderResult: ctx.params.reorderResult 293 | }); 294 | } 295 | }; 296 | } 297 | 298 | /** 299 | * Create a new entity. 300 | * 301 | * @actions 302 | * 303 | * @returns {Object} Saved entity. 304 | */ 305 | if (isActionEnabled(mixinOpts, "create")) { 306 | res.create = { 307 | visibility: mixinOpts.actionVisibility, 308 | rest: mixinOpts.rest ? "POST /" : null, 309 | // params: {}, generate from `fields` in the `merged` 310 | async handler(ctx) { 311 | return this.createEntity(ctx); 312 | } 313 | }; 314 | } 315 | 316 | /** 317 | * Create multiple entities. 318 | * 319 | * @actions 320 | * 321 | * @returns {Array} Saved entities. 322 | */ 323 | if (isActionEnabled(mixinOpts, "createMany")) { 324 | res.createMany = { 325 | visibility: mixinOpts.actionVisibility, 326 | rest: null, 327 | // params: {}, generate from `fields` in the `merged` 328 | async handler(ctx) { 329 | return this.createEntities(ctx, ctx.params, { returnEntities: true }); 330 | } 331 | }; 332 | } 333 | 334 | /** 335 | * Update an entity by ID. It's patch only the received fields. 336 | * 337 | * @actions 338 | * 339 | * @returns {Object} Updated entity. 340 | * 341 | * @throws {EntityNotFoundError} - 404 Entity not found 342 | */ 343 | if (isActionEnabled(mixinOpts, "update")) { 344 | res.update = { 345 | visibility: mixinOpts.actionVisibility, 346 | rest: mixinOpts.rest ? "PATCH /:id" : null, 347 | // params: {}, generate from `fields` in the `merged` 348 | async handler(ctx) { 349 | return this.updateEntity(ctx); 350 | } 351 | }; 352 | } 353 | 354 | /** 355 | * Replace an entity by ID. Replace the whole entity. 356 | * 357 | * @actions 358 | * 359 | * @returns {Object} Replaced entity. 360 | * 361 | * @throws {EntityNotFoundError} - 404 Entity not found 362 | */ 363 | if (isActionEnabled(mixinOpts, "replace")) { 364 | res.replace = { 365 | visibility: mixinOpts.actionVisibility, 366 | rest: mixinOpts.rest ? "PUT /:id" : null, 367 | // params: {}, generate from `fields` in the `merged` 368 | async handler(ctx) { 369 | return this.replaceEntity(ctx); 370 | } 371 | }; 372 | } 373 | 374 | /** 375 | * Remove an entity by ID. 376 | * 377 | * @actions 378 | * 379 | * @param {any} id - ID of entity. 380 | * @returns {any} ID of removed entities. 381 | * 382 | * @throws {EntityNotFoundError} - 404 Entity not found 383 | */ 384 | if (isActionEnabled(mixinOpts, "remove")) { 385 | res.remove = { 386 | visibility: mixinOpts.actionVisibility, 387 | rest: mixinOpts.rest ? "DELETE /:id" : null, 388 | params: { 389 | // The "id" field get from `fields` 390 | }, 391 | async handler(ctx) { 392 | return this.removeEntity(ctx); 393 | } 394 | }; 395 | } 396 | 397 | return res; 398 | }; 399 | -------------------------------------------------------------------------------- /src/adapters/base.js: -------------------------------------------------------------------------------- 1 | /* 2 | * @moleculer/database 3 | * Copyright (c) 2022 MoleculerJS (https://github.com/moleculerjs/database) 4 | * MIT Licensed 5 | */ 6 | 7 | "use strict"; 8 | 9 | const semver = require("semver"); 10 | 11 | const clientStore = Symbol("db-adapter-client-store"); 12 | 13 | class BaseAdapter { 14 | /** 15 | * Constructor of adapter 16 | * @param {Object?} opts 17 | */ 18 | constructor(opts) { 19 | this.opts = opts || {}; 20 | } 21 | 22 | /** 23 | * The adapter has nested-field support. 24 | */ 25 | get hasNestedFieldSupport() { 26 | return false; 27 | } 28 | 29 | /** 30 | * Initialize the adapter. 31 | * 32 | * @param {Service} service 33 | */ 34 | init(service) { 35 | this.service = service; 36 | this.logger = service.logger; 37 | this.broker = service.broker; 38 | this.Promise = this.broker.Promise; 39 | 40 | // Global adapter store to share the adapters between services on the same broker if they use the same database. 41 | if (!this.broker[clientStore]) this.broker[clientStore] = new Map(); 42 | } 43 | 44 | /** 45 | * Check the installed client library version. 46 | * https://github.com/npm/node-semver#usage 47 | * 48 | * @param {String} installedVersion 49 | * @param {String} requiredVersions 50 | * @returns {Boolean} 51 | */ 52 | checkClientLibVersion(library, requiredVersions) { 53 | const pkg = require(`${library}/package.json`); 54 | const installedVersion = pkg.version; 55 | 56 | if (semver.satisfies(installedVersion, requiredVersions)) { 57 | return true; 58 | } else { 59 | this.logger.warn( 60 | `The installed ${library} library is not supported officially. Proper functionality cannot be guaranteed. Supported versions:`, 61 | requiredVersions 62 | ); 63 | return false; 64 | } 65 | } 66 | 67 | /** 68 | * Get a DB client from global store. If it exists, set the adapter as a reference to it. 69 | * @param {String} key 70 | * @returns 71 | */ 72 | getClientFromGlobalStore(key) { 73 | const res = this.broker[clientStore].get(key); 74 | if (res) res.adapters.add(this); 75 | return res ? res.client : null; 76 | } 77 | 78 | /** 79 | * Set a DB client to the global store 80 | * @param {String} key 81 | * @param {any} client 82 | * @returns 83 | */ 84 | setClientToGlobalStore(key, client) { 85 | const adapters = new Set().add(this); 86 | return this.broker[clientStore].set(key, { 87 | client, 88 | adapters 89 | }); 90 | } 91 | 92 | /** 93 | * Remove a referenced adapter from the DB client in the global store. 94 | * @param {String} key 95 | * @returns {Boolean} If `true`, the adapter should close the connection, 96 | */ 97 | removeAdapterFromClientGlobalStore(key) { 98 | const res = this.broker[clientStore].get(key); 99 | if (res) { 100 | res.adapters.delete(this); 101 | if (res.adapters.size > 0) return false; // Adapter does't close the connection yet 102 | 103 | // Remove the client from store because there is no adapter 104 | this.broker[clientStore].delete(key); 105 | } 106 | // Asapter closes the connection 107 | return true; 108 | } 109 | 110 | /** 111 | * Connect adapter to database 112 | */ 113 | connect() { 114 | /* istanbul ignore next */ 115 | throw new Error("This method is not implemented."); 116 | } 117 | 118 | /** 119 | * Disconnect adapter from database 120 | */ 121 | disconnect() { 122 | /* istanbul ignore next */ 123 | throw new Error("This method is not implemented."); 124 | } 125 | 126 | /** 127 | * Find all entities by filters. 128 | * 129 | * @param {Object} params 130 | * @returns {Promise} 131 | */ 132 | find(/*params*/) { 133 | /* istanbul ignore next */ 134 | throw new Error("This method is not implemented."); 135 | } 136 | 137 | /** 138 | * Find an entity by query & sort 139 | * 140 | * @param {Object} params 141 | * @returns {Promise} 142 | */ 143 | findOne(/*params*/) { 144 | /* istanbul ignore next */ 145 | throw new Error("This method is not implemented."); 146 | } 147 | 148 | /** 149 | * Find an entities by ID. 150 | * 151 | * @param {any} id 152 | * @returns {Promise} Return with the found document. 153 | * 154 | */ 155 | findById(/*id*/) { 156 | /* istanbul ignore next */ 157 | throw new Error("This method is not implemented."); 158 | } 159 | 160 | /** 161 | * Find any entities by IDs. 162 | * 163 | * @param {Array} idList 164 | * @returns {Promise} Return with the found documents in an Array. 165 | * 166 | */ 167 | findByIds(/*idList*/) { 168 | /* istanbul ignore next */ 169 | throw new Error("This method is not implemented."); 170 | } 171 | 172 | /** 173 | * Find all entities by filters and returns a Stream. 174 | * 175 | * @param {Object} params 176 | * @returns {Promise} 177 | */ 178 | findStream(/*params*/) { 179 | /* istanbul ignore next */ 180 | throw new Error("This method is not implemented."); 181 | } 182 | 183 | /** 184 | * Get count of filtered entites. 185 | * @param {Object} [filters={}] 186 | * @returns {Promise} Return with the count of documents. 187 | * 188 | */ 189 | count(/*filters*/) { 190 | /* istanbul ignore next */ 191 | throw new Error("This method is not implemented."); 192 | } 193 | 194 | /** 195 | * Insert an entity. 196 | * 197 | * @param {Object} entity 198 | * @returns {Promise} Return with the inserted document. 199 | * 200 | */ 201 | insert(/*entity*/) { 202 | /* istanbul ignore next */ 203 | throw new Error("This method is not implemented."); 204 | } 205 | 206 | /** 207 | * Insert many entities 208 | * 209 | * @param {Array} entities 210 | * @param {Object?} opts 211 | * @param {Boolean?} opts.returnEntities 212 | * @returns {Promise>} Return with the inserted IDs or entities. 213 | * 214 | */ 215 | insertMany(/*entities, opts*/) { 216 | /* istanbul ignore next */ 217 | throw new Error("This method is not implemented."); 218 | } 219 | 220 | /** 221 | * Update an entity by ID 222 | * 223 | * @param {String} id 224 | * @param {Object} changes 225 | * @returns {Promise} Return with the updated document. 226 | * 227 | */ 228 | updateById(/*id, changes*/) { 229 | /* istanbul ignore next */ 230 | throw new Error("This method is not implemented."); 231 | } 232 | 233 | /** 234 | * Update many entities 235 | * 236 | * @param {Object} query 237 | * @param {Object} changes 238 | * @returns {Promise} Return with the count of modified documents. 239 | * 240 | */ 241 | updateMany(/*query, changes*/) { 242 | /* istanbul ignore next */ 243 | throw new Error("This method is not implemented."); 244 | } 245 | 246 | /** 247 | * Replace an entity by ID 248 | * 249 | * @param {String} id 250 | * @param {Object} entity 251 | * @returns {Promise} Return with the updated document. 252 | * 253 | */ 254 | replaceById(/*id, entity*/) { 255 | /* istanbul ignore next */ 256 | throw new Error("This method is not implemented."); 257 | } 258 | 259 | /** 260 | * Remove an entity by ID 261 | * 262 | * @param {String} id 263 | * @returns {Promise} Return with the removed document. 264 | * 265 | */ 266 | removeById(/*id*/) { 267 | /* istanbul ignore next */ 268 | throw new Error("This method is not implemented."); 269 | } 270 | 271 | /** 272 | * Remove entities which are matched by `query` 273 | * 274 | * @param {Object} query 275 | * @returns {Promise} Return with the count of deleted documents. 276 | * 277 | */ 278 | removeMany(/*query*/) { 279 | /* istanbul ignore next */ 280 | throw new Error("This method is not implemented."); 281 | } 282 | 283 | /** 284 | * Clear all entities from collection 285 | * 286 | * @returns {Promise} 287 | */ 288 | clear() { 289 | /* istanbul ignore next */ 290 | throw new Error("This method is not implemented."); 291 | } 292 | 293 | /** 294 | * Convert DB entity to JSON object. 295 | * 296 | * @param {Object} entity 297 | * @returns {Object} 298 | */ 299 | entityToJSON(entity) { 300 | return entity; 301 | } 302 | 303 | /** 304 | * Create an index. 305 | * 306 | * @param {Object} def 307 | * @param {String|Array|Object} def.fields 308 | * @param {String?} def.name 309 | * @param {String?} def.type The type can be optionally specified for PostgreSQL and MySQL 310 | * @param {Boolean?} def.unique 311 | * @param {Boolean?} def.sparse The `sparse` can be optionally specified for MongoDB and NeDB 312 | * @param {Number?} def.expireAfterSeconds The `expireAfterSeconds` can be optionally specified for MongoDB and NeDB 313 | * @returns {Promise} 314 | */ 315 | createIndex(/*def*/) { 316 | /* istanbul ignore next */ 317 | throw new Error("This method is not implemented."); 318 | } 319 | 320 | /** 321 | * Remove an index. 322 | * 323 | * @param {Object} def 324 | * @param {String|Array|Object} def.fields 325 | * @param {String?} def.name 326 | * @returns {Promise} 327 | */ 328 | removeIndex(/*def*/) { 329 | /* istanbul ignore next */ 330 | throw new Error("This method is not implemented."); 331 | } 332 | } 333 | 334 | module.exports = BaseAdapter; 335 | -------------------------------------------------------------------------------- /src/adapters/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | * @moleculer/database 3 | * Copyright (c) 2022 MoleculerJS (https://github.com/moleculerjs/database) 4 | * MIT Licensed 5 | */ 6 | 7 | "use strict"; 8 | 9 | const { isObject, isString } = require("lodash"); 10 | const { ServiceSchemaError } = require("moleculer").Errors; 11 | 12 | const Adapters = { 13 | Base: require("./base"), 14 | Knex: require("./knex"), 15 | MongoDB: require("./mongodb"), 16 | NeDB: require("./nedb") 17 | }; 18 | 19 | function getByName(name) { 20 | if (!name) return null; 21 | 22 | let n = Object.keys(Adapters).find(n => n.toLowerCase() == name.toLowerCase()); 23 | if (n) return Adapters[n]; 24 | } 25 | 26 | /** 27 | * Resolve adapter by name 28 | * 29 | * @param {object|string} opt 30 | * @returns {Adapter} 31 | */ 32 | function resolve(opt) { 33 | if (opt instanceof Adapters.Base) { 34 | return opt; 35 | } else if (isString(opt)) { 36 | const AdapterClass = getByName(opt); 37 | if (AdapterClass) { 38 | return new AdapterClass(); 39 | } else { 40 | throw new ServiceSchemaError(`Invalid Adapter type '${opt}'.`, { type: opt }); 41 | } 42 | } else if (isObject(opt)) { 43 | const AdapterClass = getByName(opt.type || "NeDB"); 44 | if (AdapterClass) { 45 | return new AdapterClass(opt.options); 46 | } else { 47 | throw new ServiceSchemaError(`Invalid Adapter type '${opt.type}'.`, { 48 | type: opt.type 49 | }); 50 | } 51 | } 52 | 53 | return new Adapters.NeDB(); 54 | } 55 | 56 | function register(name, value) { 57 | Adapters[name] = value; 58 | } 59 | 60 | module.exports = Object.assign(Adapters, { resolve, register }); 61 | -------------------------------------------------------------------------------- /src/adapters/nedb.js: -------------------------------------------------------------------------------- 1 | /* 2 | * @moleculer/database 3 | * Copyright (c) 2025 MoleculerJS (https://github.com/moleculerjs/database) 4 | * MIT Licensed 5 | */ 6 | 7 | "use strict"; 8 | 9 | const _ = require("lodash"); 10 | const { flatten } = require("../utils"); 11 | const BaseAdapter = require("./base"); 12 | 13 | let Datastore; 14 | 15 | class NeDBAdapter extends BaseAdapter { 16 | /** 17 | * Constructor of adapter 18 | * 19 | * @param {String|Object?} opts 20 | * @param {Object?} opts.neDB More info: https://github.com/louischatriot/nedb#creatingloading-a-database 21 | */ 22 | constructor(opts) { 23 | if (_.isString(opts)) opts = { neDB: { filename: opts } }; 24 | 25 | super(opts); 26 | 27 | this.db = null; 28 | } 29 | 30 | /** 31 | * The adapter has nested-field support. 32 | */ 33 | get hasNestedFieldSupport() { 34 | return true; 35 | } 36 | 37 | /** 38 | * Initialize the adapter. 39 | * 40 | * @param {Service} service 41 | */ 42 | init(service) { 43 | super.init(service); 44 | 45 | try { 46 | Datastore = require("@seald-io/nedb"); 47 | } catch (err) { 48 | /* istanbul ignore next */ 49 | this.broker.fatal( 50 | "The '@seald-io/nedb' package is missing! Please install it with 'npm install @seald-io/nedb --save' command.", 51 | err, 52 | true 53 | ); 54 | } 55 | 56 | this.checkClientLibVersion("@seald-io/nedb", "^4.0.0"); 57 | } 58 | 59 | /** 60 | * Connect adapter to database 61 | */ 62 | async connect() { 63 | if (this.opts.neDB instanceof Datastore) this.db = this.opts.neDB; 64 | else this.db = new Datastore(this.opts.neDB); 65 | 66 | return new this.Promise((resolve, reject) => { 67 | this.db.loadDatabase(err => { 68 | if (err) return reject(err); 69 | resolve(); 70 | }); 71 | }); 72 | } 73 | 74 | /** 75 | * Disconnect adapter from database 76 | */ 77 | async disconnect() { 78 | this.db = null; 79 | } 80 | 81 | /** 82 | * Find all entities by filters. 83 | * 84 | * @param {Object} params 85 | * @returns {Promise} 86 | */ 87 | find(params) { 88 | return new this.Promise((resolve, reject) => { 89 | this.createQuery(params).exec((err, docs) => { 90 | if (err) return reject(err); 91 | resolve(docs); 92 | }); 93 | }); 94 | } 95 | 96 | /** 97 | * Find an entity by query & sort 98 | * 99 | * @param {Object} params 100 | * @returns {Promise} 101 | */ 102 | findOne(params) { 103 | return new this.Promise((resolve, reject) => { 104 | if (params.sort) { 105 | this.createQuery(params).exec((err, docs) => { 106 | if (err) return reject(err); 107 | resolve(docs.length > 0 ? docs[0] : null); 108 | }); 109 | } else { 110 | this.db.findOne(params.query, (err, docs) => { 111 | if (err) return reject(err); 112 | resolve(docs); 113 | }); 114 | } 115 | }); 116 | } 117 | 118 | /** 119 | * Find an entities by ID. 120 | * 121 | * @param {any} id 122 | * @returns {Promise} Return with the found document. 123 | * 124 | */ 125 | findById(id) { 126 | return new this.Promise((resolve, reject) => { 127 | this.db.findOne({ _id: id }, (err, docs) => { 128 | if (err) return reject(err); 129 | resolve(docs); 130 | }); 131 | }); 132 | } 133 | 134 | /** 135 | * Find any entities by IDs. 136 | * 137 | * @param {Array} idList 138 | * @returns {Promise} Return with the found documents in an Array. 139 | * 140 | */ 141 | findByIds(idList) { 142 | return new this.Promise((resolve, reject) => { 143 | this.db.find({ _id: { $in: idList } }).exec((err, docs) => { 144 | if (err) return reject(err); 145 | resolve(docs); 146 | }); 147 | }); 148 | } 149 | 150 | /** 151 | * Get count of filtered entites. 152 | * @param {Object} [params] 153 | * @returns {Promise} Return with the count of documents. 154 | * 155 | */ 156 | count(params) { 157 | return new Promise((resolve, reject) => { 158 | this.createQuery(params).exec((err, docs) => { 159 | if (err) return reject(err); 160 | 161 | resolve(docs.length); 162 | }); 163 | }); 164 | } 165 | 166 | /** 167 | * Insert an entity. 168 | * 169 | * @param {Object} entity 170 | * @returns {Promise} Return with the inserted document. 171 | * 172 | */ 173 | insert(entity) { 174 | return new this.Promise((resolve, reject) => { 175 | this.db.insert(entity, (err, doc) => { 176 | if (err) return reject(err); 177 | resolve(doc); 178 | }); 179 | }); 180 | } 181 | 182 | /** 183 | * Insert many entities 184 | * 185 | * @param {Array} entities 186 | * @param {Object?} opts 187 | * @param {Boolean?} opts.returnEntities 188 | * @returns {Promise>} Return with the inserted IDs or entities. 189 | * 190 | */ 191 | insertMany(entities, opts = {}) { 192 | return new this.Promise((resolve, reject) => { 193 | this.db.insert(entities, (err, docs) => { 194 | if (err) return reject(err); 195 | 196 | if (opts.returnEntities) resolve(docs); 197 | else resolve(docs.map(doc => doc._id)); 198 | }); 199 | }); 200 | } 201 | 202 | /** 203 | * Update an entity by ID 204 | * 205 | * @param {String} id 206 | * @param {Object} changes 207 | * @param {Object} opts 208 | * @returns {Promise} Return with the updated document. 209 | * 210 | */ 211 | updateById(id, changes, opts) { 212 | const raw = opts && opts.raw ? true : false; 213 | if (!raw) { 214 | // Flatten the changes to dot notation 215 | changes = flatten(changes, { safe: true }); 216 | } 217 | 218 | return new this.Promise((resolve, reject) => { 219 | this.db.update( 220 | { _id: id }, 221 | raw ? changes : { $set: changes }, 222 | { returnUpdatedDocs: true }, 223 | (err, numAffected, affectedDocuments) => { 224 | if (err) return reject(err); 225 | resolve(affectedDocuments); 226 | } 227 | ); 228 | }); 229 | } 230 | 231 | /** 232 | * Update many entities 233 | * 234 | * @param {Object} query 235 | * @param {Object} changes 236 | * @param {Object} opts 237 | * @returns {Promise} Return with the count of modified documents. 238 | * 239 | */ 240 | updateMany(query, changes, opts) { 241 | const raw = opts && opts.raw ? true : false; 242 | if (!raw) { 243 | // Flatten the changes to dot notation 244 | changes = flatten(changes, { safe: true }); 245 | } 246 | 247 | return new this.Promise((resolve, reject) => { 248 | this.db.update( 249 | query, 250 | raw ? changes : { $set: changes }, 251 | { multi: true }, 252 | (err, numAffected /*, affectedDocuments*/) => { 253 | if (err) return reject(err); 254 | resolve(numAffected); 255 | } 256 | ); 257 | }); 258 | } 259 | 260 | /** 261 | * Replace an entity by ID 262 | * 263 | * @param {String} id 264 | * @param {Object} entity 265 | * @returns {Promise} Return with the updated document. 266 | * 267 | */ 268 | replaceById(id, entity) { 269 | return new this.Promise((resolve, reject) => { 270 | this.db.update( 271 | { _id: id }, 272 | entity, 273 | { returnUpdatedDocs: true }, 274 | (err, numAffected, affectedDocuments) => { 275 | if (err) return reject(err); 276 | resolve(affectedDocuments); 277 | } 278 | ); 279 | }); 280 | } 281 | 282 | /** 283 | * Remove an entity by ID 284 | * 285 | * @param {String} id 286 | * @returns {Promise} Return with ID of the deleted document. 287 | * 288 | */ 289 | removeById(id) { 290 | return new this.Promise((resolve, reject) => { 291 | this.db.remove({ _id: id }, err => { 292 | if (err) return reject(err); 293 | resolve(id); 294 | }); 295 | }); 296 | } 297 | 298 | /** 299 | * Remove entities which are matched by `query` 300 | * 301 | * @param {Object} query 302 | * @returns {Promise} Return with the number of deleted documents. 303 | * 304 | */ 305 | removeMany(query) { 306 | return new this.Promise((resolve, reject) => { 307 | this.db.remove(query, { multi: true }, (err, numRemoved) => { 308 | if (err) return reject(err); 309 | resolve(numRemoved); 310 | }); 311 | }); 312 | } 313 | 314 | /** 315 | * Clear all entities from collection 316 | * 317 | * @returns {Promise} 318 | * 319 | */ 320 | clear() { 321 | return new this.Promise((resolve, reject) => { 322 | this.db.remove({}, { multi: true }, (err, numRemoved) => { 323 | if (err) return reject(err); 324 | resolve(numRemoved); 325 | }); 326 | }); 327 | } 328 | 329 | /** 330 | * Createa query based on filters 331 | * 332 | * Available filters: 333 | * - search 334 | * - searchFields 335 | * - sort 336 | * - limit 337 | * - offset 338 | * - query 339 | * 340 | * @param {Object} params 341 | * @returns {Query} 342 | * @memberof MemoryDbAdapter 343 | */ 344 | createQuery(params) { 345 | if (params) { 346 | let q; 347 | 348 | // Text search 349 | let query = params.query || {}; 350 | 351 | if (_.isString(params.search) && params.search !== "") { 352 | query.$where = function () { 353 | let doc = this; 354 | const s = params.search.toLowerCase(); 355 | if (params.searchFields) doc = _.pick(this, params.searchFields); 356 | 357 | const res = _.values(doc).find(v => String(v).toLowerCase().indexOf(s) !== -1); 358 | return res != null; 359 | }; 360 | } 361 | q = this.db.find(query); 362 | 363 | // Sort 364 | if (params.sort) { 365 | let pSort = params.sort; 366 | if (typeof pSort == "string") pSort = [pSort]; 367 | 368 | const sortFields = {}; 369 | pSort.forEach(field => { 370 | if (field.startsWith("-")) sortFields[field.slice(1)] = -1; 371 | else sortFields[field] = 1; 372 | }); 373 | q.sort(sortFields); 374 | } 375 | 376 | // Limit 377 | if (_.isNumber(params.limit) && params.limit > 0) q.limit(params.limit); 378 | 379 | // Offset 380 | if (_.isNumber(params.offset) && params.offset > 0) q.skip(params.offset); 381 | 382 | return q; 383 | } 384 | 385 | return this.db.find({}); 386 | } 387 | 388 | /** 389 | * Create an index. 390 | * More info: https://github.com/louischatriot/nedb#indexing 391 | * 392 | * @param {Object} def 393 | * @param {String?} def.fields 394 | * @param {String?} def.fieldName 395 | * @param {Boolean?} def.unique 396 | * @param {Boolean?} def.sparse 397 | * @param {Number?} def.expireAfterSeconds 398 | * @param {String?} def.name 399 | */ 400 | createIndex(def) { 401 | return new this.Promise((resolve, reject) => { 402 | let fieldName = def.fieldName || def.fields; 403 | if (_.isPlainObject(fieldName)) { 404 | fieldName = Object.keys(fieldName); 405 | } 406 | this.db.ensureIndex( 407 | { 408 | fieldName: fieldName, 409 | ..._.omit(def, ["fieldName", "fields"]) 410 | }, 411 | err => { 412 | if (err) return reject(err); 413 | resolve(); 414 | } 415 | ); 416 | }); 417 | } 418 | 419 | /** 420 | * Remove an index. 421 | * 422 | * @param {Object} def 423 | * @param {String|Array|Object} def.fields 424 | * @param {String?} def.name 425 | * @returns {Promise} 426 | */ 427 | removeIndex(def) { 428 | return new this.Promise((resolve, reject) => { 429 | this.db.removeIndex({ fieldName: def.fieldName || def.fields }, err => { 430 | if (err) return reject(err); 431 | resolve(); 432 | }); 433 | }); 434 | } 435 | } 436 | 437 | module.exports = NeDBAdapter; 438 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = { 4 | METRIC_ADAPTER_TOTAL: "moleculer.database.adapter.total", 5 | METRIC_ADAPTER_ACTIVE: "moleculer.database.adapter.active", 6 | 7 | METRIC_ENTITIES_FIND_TOTAL: "moleculer.database.entities.find.total", 8 | METRIC_ENTITIES_FIND_TIME: "moleculer.database.entities.find.time", 9 | 10 | METRIC_ENTITIES_STREAM_TOTAL: "moleculer.database.entities.stream.total", 11 | METRIC_ENTITIES_STREAM_TIME: "moleculer.database.entities.stream.time", 12 | 13 | METRIC_ENTITIES_COUNT_TOTAL: "moleculer.database.entities.count.total", 14 | METRIC_ENTITIES_COUNT_TIME: "moleculer.database.entities.count.time", 15 | 16 | METRIC_ENTITIES_FINDONE_TOTAL: "moleculer.database.entities.find-one.total", 17 | METRIC_ENTITIES_FINDONE_TIME: "moleculer.database.entities.find-one.time", 18 | 19 | METRIC_ENTITIES_RESOLVE_TOTAL: "moleculer.database.entities.resolve.total", 20 | METRIC_ENTITIES_RESOLVE_TIME: "moleculer.database.entities.resolve.time", 21 | 22 | METRIC_ENTITIES_CREATEONE_TOTAL: "moleculer.database.entities.create-one.total", 23 | METRIC_ENTITIES_CREATEONE_TIME: "moleculer.database.entities.create-one.time", 24 | 25 | METRIC_ENTITIES_CREATEMANY_TOTAL: "moleculer.database.entities.create-many.total", 26 | METRIC_ENTITIES_CREATEMANY_TIME: "moleculer.database.entities.create-many.time", 27 | 28 | METRIC_ENTITIES_UPDATEONE_TOTAL: "moleculer.database.entities.update-one.total", 29 | METRIC_ENTITIES_UPDATEONE_TIME: "moleculer.database.entities.update-one.time", 30 | 31 | METRIC_ENTITIES_UPDATEMANY_TOTAL: "moleculer.database.entities.update-many.total", 32 | METRIC_ENTITIES_UPDATEMANY_TIME: "moleculer.database.entities.update-many.time", 33 | 34 | METRIC_ENTITIES_REPLACEONE_TOTAL: "moleculer.database.entities.replace-one.total", 35 | METRIC_ENTITIES_REPLACEONE_TIME: "moleculer.database.entities.replace-one.time", 36 | 37 | METRIC_ENTITIES_REMOVEONE_TOTAL: "moleculer.database.entities.remove-one.total", 38 | METRIC_ENTITIES_REMOVEONE_TIME: "moleculer.database.entities.remove-one.time", 39 | 40 | METRIC_ENTITIES_REMOVEMANY_TOTAL: "moleculer.database.entities.remove-many.total", 41 | METRIC_ENTITIES_REMOVEMANY_TIME: "moleculer.database.entities.remove-many.time", 42 | 43 | METRIC_ENTITIES_CLEAR_TOTAL: "moleculer.database.entities.clear.total", 44 | METRIC_ENTITIES_CLEAR_TIME: "moleculer.database.entities.clear.time" 45 | }; 46 | -------------------------------------------------------------------------------- /src/errors.js: -------------------------------------------------------------------------------- 1 | /* 2 | * @moleculer/database 3 | * Copyright (c) 2022 MoleculerJS (https://github.com/moleculerjs/database) 4 | * MIT Licensed 5 | */ 6 | 7 | "use strict"; 8 | 9 | const { MoleculerClientError } = require("moleculer").Errors; 10 | 11 | class EntityNotFoundError extends MoleculerClientError { 12 | constructor(id) { 13 | super("Entity not found", 404, "ENTITY_NOT_FOUND", { 14 | id 15 | }); 16 | } 17 | } 18 | 19 | module.exports = { EntityNotFoundError }; 20 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | /* 3 | * @moleculer/database 4 | * Copyright (c) 2024 MoleculerJS (https://github.com/moleculerjs/database) 5 | * MIT Licensed 6 | */ 7 | 8 | "use strict"; 9 | 10 | const _ = require("lodash"); 11 | 12 | const Actions = require("./actions"); 13 | const DbMethods = require("./methods"); 14 | const Validation = require("./validation"); 15 | const Transform = require("./transform"); 16 | const Monitoring = require("./monitoring"); 17 | const { 18 | generateValidatorSchemaFromFields, 19 | getPrimaryKeyFromFields, 20 | fixIDInRestPath, 21 | fixIDInCacheKeys, 22 | isActionEnabled 23 | } = require("./schema"); 24 | const pkg = require("../package.json"); 25 | 26 | module.exports = function DatabaseMixin(mixinOpts) { 27 | mixinOpts = _.defaultsDeep(mixinOpts, { 28 | /** @type {Boolean} Generate CRUD actions */ 29 | createActions: true, 30 | 31 | /** @type {String} Default visibility of generated actions */ 32 | actionVisibility: "published", 33 | 34 | /** @type {Boolean} Generate `params` schema for generated actions based on the `fields` */ 35 | generateActionParams: true, 36 | 37 | /** @type {Boolean} Enable to convert input params to the specified type */ 38 | enableParamsConversion: true, 39 | 40 | /** @type {Boolean|String} Strict mode in validation schema for objects. Values: true|false|"remove" */ 41 | strict: "remove", 42 | 43 | /** @type {Object} Caching settings */ 44 | cache: { 45 | /** @type {Boolean} Enable caching of actions */ 46 | enabled: true, 47 | /** @type {String} Name of event for clearing cache */ 48 | eventName: null, 49 | /** @type {String} Type of event for clearing cache */ 50 | eventType: "broadcast", 51 | /** @type {Boolean|Array} Subscribe to cache clean event of service dependencies and clear the local cache entries */ 52 | cacheCleanOnDeps: true, 53 | /** @type {Array?} Additional cache keys */ 54 | additionalKeys: null, 55 | /** @type {Function?} Custom cache cleaner function */ 56 | cacheCleaner: null 57 | }, 58 | /** @type {Boolean} Set auto-aliasing fields */ 59 | rest: true, 60 | 61 | // Entity changed lifecycle event mode. Values: null, "broadcast", "emit". The `null` disables event sending. 62 | entityChangedEventType: "broadcast", 63 | 64 | // Add previous entity data to the entity changed event payload in case of update or replace. 65 | entityChangedOldEntity: false, 66 | 67 | /** @type {Number} Auto reconnect if the DB server is not available at first connecting */ 68 | autoReconnect: true, 69 | 70 | /** @type {Number} Maximum number of connected adapters. In case of multi-tenancy */ 71 | maximumAdapters: null, 72 | 73 | /** @type {Number} Maximum value of limit in `find` action. Default: `-1` (no limit) */ 74 | maxLimit: -1, 75 | 76 | /** @type {Number} Default page size in `list` action. */ 77 | defaultPageSize: 10 78 | }); 79 | 80 | const schema = { 81 | // Must overwrite it 82 | name: "", 83 | 84 | /** 85 | * Metadata 86 | */ 87 | // Service's metadata 88 | metadata: { 89 | $category: "database", 90 | $description: "Official Data Access service", 91 | $official: true, 92 | $package: { 93 | name: pkg.name, 94 | version: pkg.version, 95 | repo: pkg.repository ? pkg.repository.url : null 96 | } 97 | }, 98 | 99 | /** 100 | * Default settings 101 | */ 102 | settings: { 103 | /** @type {Object?} Field filtering list. It must be an `Object`. If the value is `null` it won't filter the fields of entities. */ 104 | fields: null, 105 | 106 | /** @type {Object?} Predefined scopes */ 107 | scopes: {}, 108 | 109 | /** @type {Array?} Default scopes which applies to `find` & `list` actions */ 110 | defaultScopes: null, 111 | 112 | /** @type {Array?} Default populated fields */ 113 | defaultPopulates: null, 114 | 115 | /** @type {Object?} Index definitions */ 116 | indexes: null 117 | }, 118 | 119 | /** 120 | * Actions 121 | */ 122 | actions: { 123 | ...Actions(mixinOpts) 124 | }, 125 | 126 | /** 127 | * Methods 128 | */ 129 | methods: { 130 | ...DbMethods(mixinOpts), 131 | ...Transform(mixinOpts), 132 | ...Validation(mixinOpts), 133 | ...Monitoring(mixinOpts) 134 | }, 135 | 136 | /** 137 | * Create lifecycle hook of service 138 | */ 139 | created() { 140 | this.adapters = new Map(); 141 | 142 | // Process custom DB hooks 143 | this.$hooks = {}; 144 | if (this.schema.hooks && this.schema.hooks.customs) { 145 | _.map(this.schema.hooks.customs, (hooks, name) => { 146 | if (!Array.isArray(hooks)) hooks = [hooks]; 147 | 148 | hooks = _.compact( 149 | hooks.map(h => { 150 | return _.isString(h) ? (_.isFunction(this[h]) ? this[h] : null) : h; 151 | }) 152 | ); 153 | 154 | this.$hooks[name] = (...args) => { 155 | return hooks.reduce( 156 | (p, fn) => p.then(() => fn.apply(this, args)), 157 | this.Promise.resolve() 158 | ); 159 | }; 160 | }); 161 | } 162 | }, 163 | 164 | /** 165 | * Start lifecycle hook of service 166 | */ 167 | async started() { 168 | this._registerMoleculerMetrics(); 169 | this._processFields(); 170 | }, 171 | 172 | /** 173 | * Stop lifecycle hook of service 174 | */ 175 | async stopped() { 176 | return this._disconnectAll(); 177 | }, 178 | 179 | /** 180 | * It is called when the Service schema mixins are merged. At this 181 | * point, we can generate the validator schemas for the actions. 182 | * 183 | * @param {Object} schema 184 | */ 185 | merged(schema) { 186 | // Generate action `params` 187 | if (mixinOpts.createActions && schema.actions && schema.settings.fields) { 188 | const fields = schema.settings.fields; 189 | const primaryKeyField = getPrimaryKeyFromFields(fields); 190 | 191 | if (mixinOpts.generateActionParams) { 192 | // Generate action params 193 | if (Object.keys(fields).length > 0) { 194 | if (isActionEnabled(mixinOpts, "create") && schema.actions.create) { 195 | schema.actions.create.params = generateValidatorSchemaFromFields( 196 | fields, 197 | { 198 | type: "create", 199 | strict: mixinOpts.strict, 200 | enableParamsConversion: mixinOpts.enableParamsConversion 201 | } 202 | ); 203 | } 204 | 205 | if (isActionEnabled(mixinOpts, "createMany") && schema.actions.createMany) { 206 | schema.actions.createMany.params = { 207 | // TODO! 208 | $$root: true, 209 | type: "array", 210 | empty: false, 211 | items: { 212 | type: "object", 213 | strict: mixinOpts.strict, 214 | properties: generateValidatorSchemaFromFields(fields, { 215 | type: "create", 216 | level: 1, 217 | strict: mixinOpts.strict, 218 | enableParamsConversion: mixinOpts.enableParamsConversion 219 | }) 220 | } 221 | }; 222 | } 223 | 224 | if (isActionEnabled(mixinOpts, "update") && schema.actions.update) { 225 | schema.actions.update.params = generateValidatorSchemaFromFields( 226 | fields, 227 | { 228 | type: "update", 229 | strict: mixinOpts.strict, 230 | enableParamsConversion: mixinOpts.enableParamsConversion 231 | } 232 | ); 233 | } 234 | 235 | if (isActionEnabled(mixinOpts, "replace") && schema.actions.replace) { 236 | schema.actions.replace.params = generateValidatorSchemaFromFields( 237 | fields, 238 | { 239 | type: "replace", 240 | strict: mixinOpts.strict, 241 | enableParamsConversion: mixinOpts.enableParamsConversion 242 | } 243 | ); 244 | } 245 | } 246 | } 247 | 248 | if (primaryKeyField) { 249 | // Set `id` field name & type in `get`, `resolve` and `remove` actions 250 | if (isActionEnabled(mixinOpts, "get") && schema.actions.get && schema.actions.get.params) { 251 | schema.actions.get.params[primaryKeyField.name] = { 252 | type: primaryKeyField.type, 253 | convert: true 254 | }; 255 | } 256 | if (isActionEnabled(mixinOpts, "resolve") && schema.actions.resolve && schema.actions.resolve.params) { 257 | schema.actions.resolve.params[primaryKeyField.name] = [ 258 | { type: "array", items: { type: primaryKeyField.type, convert: true } }, 259 | { type: primaryKeyField.type, convert: true } 260 | ]; 261 | } 262 | if (isActionEnabled(mixinOpts, "remove") && schema.actions.remove && schema.actions.remove.params) { 263 | schema.actions.remove.params[primaryKeyField.name] = { 264 | type: primaryKeyField.type, 265 | convert: true 266 | }; 267 | } 268 | 269 | // Fix the ":id" variable name in the actions 270 | if (isActionEnabled(mixinOpts, "create")) { 271 | fixIDInRestPath(schema.actions.get, primaryKeyField); 272 | } 273 | if (isActionEnabled(mixinOpts, "update")) { 274 | fixIDInRestPath(schema.actions.update, primaryKeyField); 275 | } 276 | if (isActionEnabled(mixinOpts, "replace")) { 277 | fixIDInRestPath(schema.actions.replace, primaryKeyField); 278 | } 279 | if (isActionEnabled(mixinOpts, "remove")) { 280 | fixIDInRestPath(schema.actions.remove, primaryKeyField); 281 | } 282 | 283 | // Fix the "id" key name in the cache keys 284 | if (isActionEnabled(mixinOpts, "get")) { 285 | fixIDInCacheKeys(schema.actions.get, primaryKeyField); 286 | } 287 | if (isActionEnabled(mixinOpts, "resolve")) { 288 | fixIDInCacheKeys(schema.actions.resolve, primaryKeyField); 289 | } 290 | } 291 | } 292 | 293 | if (mixinOpts.cache && mixinOpts.cache.enabled) { 294 | if (mixinOpts.cache.eventName !== false) { 295 | const eventName = mixinOpts.cache.eventName || `cache.clean.${schema.name}`; 296 | if (!schema.events) schema.events = {}; 297 | /** 298 | * Subscribe to the cache clean event. If it's triggered 299 | * clean the cache entries for this service. 300 | */ 301 | schema.events[eventName] = 302 | mixinOpts.cache.cacheCleaner || 303 | async function () { 304 | if (this.broker.cacher) { 305 | await this.broker.cacher.clean(`${this.fullName}.**`); 306 | } 307 | }; 308 | } 309 | 310 | // Subscribe to additional service cache clean events 311 | if (mixinOpts.cache.cacheCleanOnDeps) { 312 | const additionalEventNames = []; 313 | if (Array.isArray(mixinOpts.cache.cacheCleanOnDeps)) { 314 | additionalEventNames.push(...mixinOpts.cache.cacheCleanOnDeps); 315 | } else if (mixinOpts.cache.cacheCleanOnDeps === true) { 316 | // Traverse dependencies and collect the service names 317 | const svcDeps = schema.dependencies; 318 | if (Array.isArray(svcDeps)) { 319 | additionalEventNames.push( 320 | ...svcDeps 321 | .map(s => (_.isPlainObject(s) && s.name ? s.name : s)) 322 | .map(s => `cache.clean.${s}`) 323 | ); 324 | } 325 | } 326 | 327 | if (additionalEventNames.length > 0) { 328 | additionalEventNames.forEach(eventName => { 329 | schema.events[eventName] = 330 | mixinOpts.cache.cacheCleaner || 331 | async function () { 332 | if (this.broker.cacher) { 333 | await this.broker.cacher.clean(`${this.fullName}.**`); 334 | } 335 | }; 336 | }); 337 | } 338 | } 339 | } 340 | } 341 | }; 342 | 343 | return schema; 344 | }; 345 | -------------------------------------------------------------------------------- /src/monitoring.js: -------------------------------------------------------------------------------- 1 | /* 2 | /* 3 | * @moleculer/database 4 | * Copyright (c) 2022 MoleculerJS (https://github.com/moleculerjs/database) 5 | * MIT Licensed 6 | */ 7 | 8 | "use strict"; 9 | 10 | const { METRIC } = require("moleculer"); 11 | const C = require("./constants"); 12 | 13 | module.exports = function (mixinOpts) { 14 | return { 15 | /** 16 | * Register Moleculer Transit Core metrics. 17 | */ 18 | _registerMoleculerMetrics() { 19 | this.broker.metrics.register({ 20 | name: C.METRIC_ADAPTER_TOTAL, 21 | type: METRIC.TYPE_COUNTER, 22 | labelNames: ["service"], 23 | rate: true 24 | }); 25 | this.broker.metrics.register({ 26 | name: C.METRIC_ADAPTER_ACTIVE, 27 | type: METRIC.TYPE_GAUGE, 28 | labelNames: ["service"], 29 | rate: true 30 | }); 31 | 32 | this.broker.metrics.register({ 33 | name: C.METRIC_ENTITIES_FIND_TOTAL, 34 | type: METRIC.TYPE_COUNTER, 35 | labelNames: ["service"], 36 | rate: true 37 | }); 38 | this.broker.metrics.register({ 39 | name: C.METRIC_ENTITIES_FIND_TIME, 40 | type: METRIC.TYPE_HISTOGRAM, 41 | labelNames: ["service"], 42 | quantiles: true, 43 | unit: METRIC.UNIT_MILLISECONDS 44 | }); 45 | 46 | this.broker.metrics.register({ 47 | name: C.METRIC_ENTITIES_STREAM_TOTAL, 48 | type: METRIC.TYPE_COUNTER, 49 | labelNames: ["service"], 50 | rate: true 51 | }); 52 | this.broker.metrics.register({ 53 | name: C.METRIC_ENTITIES_STREAM_TIME, 54 | type: METRIC.TYPE_HISTOGRAM, 55 | labelNames: ["service"], 56 | quantiles: true, 57 | unit: METRIC.UNIT_MILLISECONDS 58 | }); 59 | 60 | this.broker.metrics.register({ 61 | name: C.METRIC_ENTITIES_COUNT_TOTAL, 62 | type: METRIC.TYPE_COUNTER, 63 | labelNames: ["service"], 64 | rate: true 65 | }); 66 | this.broker.metrics.register({ 67 | name: C.METRIC_ENTITIES_COUNT_TIME, 68 | type: METRIC.TYPE_HISTOGRAM, 69 | labelNames: ["service"], 70 | quantiles: true, 71 | unit: METRIC.UNIT_MILLISECONDS 72 | }); 73 | 74 | this.broker.metrics.register({ 75 | name: C.METRIC_ENTITIES_FINDONE_TOTAL, 76 | type: METRIC.TYPE_COUNTER, 77 | labelNames: ["service"], 78 | rate: true 79 | }); 80 | this.broker.metrics.register({ 81 | name: C.METRIC_ENTITIES_FINDONE_TIME, 82 | type: METRIC.TYPE_HISTOGRAM, 83 | labelNames: ["service"], 84 | quantiles: true, 85 | unit: METRIC.UNIT_MILLISECONDS 86 | }); 87 | 88 | this.broker.metrics.register({ 89 | name: C.METRIC_ENTITIES_RESOLVE_TOTAL, 90 | type: METRIC.TYPE_COUNTER, 91 | labelNames: ["service"], 92 | rate: true 93 | }); 94 | this.broker.metrics.register({ 95 | name: C.METRIC_ENTITIES_RESOLVE_TIME, 96 | type: METRIC.TYPE_HISTOGRAM, 97 | labelNames: ["service"], 98 | quantiles: true, 99 | unit: METRIC.UNIT_MILLISECONDS 100 | }); 101 | 102 | this.broker.metrics.register({ 103 | name: C.METRIC_ENTITIES_CREATEONE_TOTAL, 104 | type: METRIC.TYPE_COUNTER, 105 | labelNames: ["service"], 106 | rate: true 107 | }); 108 | this.broker.metrics.register({ 109 | name: C.METRIC_ENTITIES_CREATEONE_TIME, 110 | type: METRIC.TYPE_HISTOGRAM, 111 | labelNames: ["service"], 112 | quantiles: true, 113 | unit: METRIC.UNIT_MILLISECONDS 114 | }); 115 | this.broker.metrics.register({ 116 | name: C.METRIC_ENTITIES_CREATEMANY_TOTAL, 117 | type: METRIC.TYPE_COUNTER, 118 | labelNames: ["service"], 119 | rate: true 120 | }); 121 | this.broker.metrics.register({ 122 | name: C.METRIC_ENTITIES_CREATEMANY_TIME, 123 | type: METRIC.TYPE_HISTOGRAM, 124 | labelNames: ["service"], 125 | quantiles: true, 126 | unit: METRIC.UNIT_MILLISECONDS 127 | }); 128 | 129 | this.broker.metrics.register({ 130 | name: C.METRIC_ENTITIES_UPDATEONE_TOTAL, 131 | type: METRIC.TYPE_COUNTER, 132 | labelNames: ["service"], 133 | rate: true 134 | }); 135 | this.broker.metrics.register({ 136 | name: C.METRIC_ENTITIES_UPDATEONE_TIME, 137 | type: METRIC.TYPE_HISTOGRAM, 138 | labelNames: ["service"], 139 | quantiles: true, 140 | unit: METRIC.UNIT_MILLISECONDS 141 | }); 142 | this.broker.metrics.register({ 143 | name: C.METRIC_ENTITIES_UPDATEMANY_TOTAL, 144 | type: METRIC.TYPE_COUNTER, 145 | labelNames: ["service"], 146 | rate: true 147 | }); 148 | this.broker.metrics.register({ 149 | name: C.METRIC_ENTITIES_UPDATEMANY_TIME, 150 | type: METRIC.TYPE_HISTOGRAM, 151 | labelNames: ["service"], 152 | quantiles: true, 153 | unit: METRIC.UNIT_MILLISECONDS 154 | }); 155 | 156 | this.broker.metrics.register({ 157 | name: C.METRIC_ENTITIES_REPLACEONE_TOTAL, 158 | type: METRIC.TYPE_COUNTER, 159 | labelNames: ["service"], 160 | rate: true 161 | }); 162 | this.broker.metrics.register({ 163 | name: C.METRIC_ENTITIES_REPLACEONE_TIME, 164 | type: METRIC.TYPE_HISTOGRAM, 165 | labelNames: ["service"], 166 | quantiles: true, 167 | unit: METRIC.UNIT_MILLISECONDS 168 | }); 169 | 170 | this.broker.metrics.register({ 171 | name: C.METRIC_ENTITIES_REMOVEONE_TOTAL, 172 | type: METRIC.TYPE_COUNTER, 173 | labelNames: ["service"], 174 | rate: true 175 | }); 176 | this.broker.metrics.register({ 177 | name: C.METRIC_ENTITIES_REMOVEONE_TIME, 178 | type: METRIC.TYPE_HISTOGRAM, 179 | labelNames: ["service"], 180 | quantiles: true, 181 | unit: METRIC.UNIT_MILLISECONDS 182 | }); 183 | this.broker.metrics.register({ 184 | name: C.METRIC_ENTITIES_REMOVEMANY_TOTAL, 185 | type: METRIC.TYPE_COUNTER, 186 | labelNames: ["service"], 187 | rate: true 188 | }); 189 | this.broker.metrics.register({ 190 | name: C.METRIC_ENTITIES_REMOVEMANY_TIME, 191 | type: METRIC.TYPE_HISTOGRAM, 192 | labelNames: ["service"], 193 | quantiles: true, 194 | unit: METRIC.UNIT_MILLISECONDS 195 | }); 196 | 197 | this.broker.metrics.register({ 198 | name: C.METRIC_ENTITIES_CLEAR_TOTAL, 199 | type: METRIC.TYPE_COUNTER, 200 | labelNames: ["service"], 201 | rate: true 202 | }); 203 | this.broker.metrics.register({ 204 | name: C.METRIC_ENTITIES_CLEAR_TIME, 205 | type: METRIC.TYPE_HISTOGRAM, 206 | labelNames: ["service"], 207 | quantiles: true, 208 | unit: METRIC.UNIT_MILLISECONDS 209 | }); 210 | }, 211 | 212 | _metricInc(name) { 213 | if (!this.broker.isMetricsEnabled()) return; 214 | return this.broker.metrics.increment(name, { service: this.fullName }); 215 | }, 216 | 217 | _metricDec(name) { 218 | if (!this.broker.isMetricsEnabled()) return; 219 | return this.broker.metrics.decrement(name, { service: this.fullName }); 220 | }, 221 | 222 | _metricTime(name) { 223 | if (!this.broker.isMetricsEnabled()) return () => {}; 224 | return this.broker.metrics.timer(name, { service: this.fullName }); 225 | }, 226 | 227 | startSpan(ctx, name, tags = {}) { 228 | if (!this.broker.isTracingEnabled()) return {}; 229 | const m = ctx ? ctx : this.broker.tracer; 230 | tags.service = this.fullName; 231 | tags.nodeID = this.broker.nodeID; 232 | 233 | const span = m.startSpan(name, { tags }); 234 | return span; 235 | }, 236 | 237 | finishSpan(ctx, span) { 238 | if (!this.broker.isTracingEnabled()) return {}; 239 | if (ctx) return ctx.finishSpan(span); 240 | return span.finish(); 241 | } 242 | }; 243 | }; 244 | -------------------------------------------------------------------------------- /src/schema.js: -------------------------------------------------------------------------------- 1 | /* 2 | * @moleculer/database 3 | * Copyright (c) 2024 MoleculerJS (https://github.com/moleculerjs/database) 4 | * MIT Licensed 5 | */ 6 | 7 | "use strict"; 8 | 9 | const _ = require("lodash"); 10 | 11 | const Validator = require("fastest-validator"); 12 | const validator = new Validator({ 13 | useNewCustomCheckerFunction: true 14 | }); 15 | 16 | function getPrimaryKeyFromFields(fields) { 17 | let field = null; 18 | _.forEach(fields, (f, name) => { 19 | if (!field && f.primaryKey) { 20 | field = { 21 | ...f, 22 | name 23 | }; 24 | } 25 | }); 26 | 27 | return field || { name: "_id", type: "string", columnName: "_id" }; 28 | } 29 | 30 | function fixIDInRestPath(def, primaryKeyField) { 31 | if (def && def.rest) { 32 | if (_.isObject(def.rest)) { 33 | def.rest.path = def.rest.path 34 | ? def.rest.path.replace(/:id/, `:${primaryKeyField.name}`) 35 | : null; 36 | } else if (_.isString(def.rest)) { 37 | def.rest = def.rest.replace(/:id/, `:${primaryKeyField.name}`); 38 | } 39 | } 40 | } 41 | 42 | function fixIDInCacheKeys(def, primaryKeyField) { 43 | if (def && def.cache && def.cache.keys) { 44 | def.cache.keys = def.cache.keys.map(key => (key == "id" ? primaryKeyField.name : key)); 45 | } 46 | } 47 | 48 | function isActionEnabled(mixinOpts, actionName) { 49 | if (typeof mixinOpts.createActions == "object") { 50 | return mixinOpts.createActions[actionName] !== false; 51 | } 52 | return mixinOpts.createActions !== false; 53 | }; 54 | 55 | function generateValidatorSchemaFromFields(fields, opts) { 56 | const res = {}; 57 | 58 | if (fields == null || Object.keys(fields).length == 0) return res; 59 | 60 | opts == opts || {}; 61 | if (opts.level == null) opts.level = 0; 62 | 63 | if (opts.level == 0) res.$$strict = opts.strict; 64 | 65 | _.map(fields, (field, name) => { 66 | if (field === false) return; 67 | if (typeof field == "string") field = validator.parseShortHand(field); 68 | 69 | const schema = generateFieldValidatorSchema(field, opts); 70 | if (schema != null) res[name] = schema; 71 | }); 72 | 73 | return res; 74 | } 75 | 76 | function generateFieldValidatorSchema(field, opts) { 77 | const schema = _.omit(field, [ 78 | "name", 79 | "required", 80 | "optional", 81 | "columnName", 82 | "primaryKey", 83 | "optional", 84 | "hidden", 85 | "readonly", 86 | "required", 87 | "immutable", 88 | "onCreate", 89 | "onUpdate", 90 | "onReplace", 91 | "onRemove", 92 | "permission", 93 | "readPermission", 94 | "populate", 95 | "itemProperties", 96 | "set", 97 | "get", 98 | "validate", 99 | "default" 100 | ]); 101 | 102 | // Type 103 | schema.type = field.type || "any"; 104 | 105 | // Readonly or virtual field -> Forbidden 106 | if (field.readonly || field.virtual) return null; 107 | 108 | // Primary key forbidden on create 109 | if (field.primaryKey && opts.type == "create" && field.generated != "user") return null; 110 | 111 | // Required 112 | // If there is `set` we can't set the required maybe the value will be set in the `set` 113 | if (!field.required || field.set) schema.optional = true; 114 | 115 | // On update, every field is optional except primaryKey 116 | if (opts.type == "update") schema.optional = !field.primaryKey; 117 | 118 | // On replace, the primaryKey is required 119 | if (opts.type == "replace" && field.primaryKey) schema.optional = false; 120 | 121 | // Type conversion (enable by default) 122 | if (opts.enableParamsConversion && ["string", "number", "date", "boolean"].includes(field.type)) 123 | schema.convert = field.convert != null ? field.convert : true; 124 | 125 | // Default value (if not "update"), Function default value is not supported by FV 126 | if (field.default !== undefined && !_.isFunction(field.default) && opts.type != "update") 127 | schema.default = _.cloneDeep(field.default); 128 | 129 | // Nested object 130 | if (field.type == "object" && field.properties) { 131 | schema.strict = opts.strict; 132 | schema.properties = generateValidatorSchemaFromFields(field.properties, { 133 | ...opts, 134 | level: opts.level + 1 135 | }); 136 | } 137 | 138 | // Array 139 | if (field.type == "array" && field.items) { 140 | schema.items = generateFieldValidatorSchema(field.items, opts); 141 | } 142 | 143 | return schema; 144 | } 145 | 146 | module.exports = { 147 | getPrimaryKeyFromFields, 148 | fixIDInRestPath, 149 | fixIDInCacheKeys, 150 | generateValidatorSchemaFromFields, 151 | generateFieldValidatorSchema, 152 | isActionEnabled 153 | }; 154 | -------------------------------------------------------------------------------- /src/transform.js: -------------------------------------------------------------------------------- 1 | /* 2 | * @moleculer/database 3 | * Copyright (c) 2022 MoleculerJS (https://github.com/moleculerjs/database) 4 | * MIT Licensed 5 | */ 6 | 7 | "use strict"; 8 | 9 | const { ServiceSchemaError, ValidationError } = require("moleculer").Errors; 10 | const _ = require("lodash"); 11 | 12 | function deepResolve(values, resolvedObj) { 13 | if (!resolvedObj) return values; 14 | 15 | return values.map(v => { 16 | if (v != null) { 17 | if (Array.isArray(v)) return deepResolve(v, resolvedObj); 18 | else { 19 | const res = resolvedObj[v]; 20 | // If not found, return the original value (which can be `null`) 21 | return res != null ? res : v; 22 | } 23 | } 24 | return v; 25 | }); 26 | } 27 | 28 | module.exports = function (mixinOpts) { 29 | return { 30 | /** 31 | * Transform the result rows. 32 | * 33 | * @param {Adapter?} adapter 34 | * @param {Object|Array} docs 35 | * @param {Object?} params 36 | * @param {Context?} ctx 37 | */ 38 | async transformResult(adapter, docs, params, ctx) { 39 | let isDoc = false; 40 | if (!Array.isArray(docs)) { 41 | if (_.isObject(docs)) { 42 | isDoc = true; 43 | docs = [docs]; 44 | } else { 45 | // Any other primitive value 46 | return docs; 47 | } 48 | } 49 | 50 | const span = this.startSpan(ctx, "Transforming result", { params }); 51 | 52 | if (!adapter) adapter = await this.getAdapter(ctx); 53 | docs = docs.map(doc => adapter.entityToJSON(doc)); 54 | 55 | if (this.$fields) { 56 | docs = await this._transformFields(adapter, docs, params, ctx); 57 | } 58 | 59 | this.finishSpan(ctx, span); 60 | 61 | return isDoc ? docs[0] : docs; 62 | }, 63 | 64 | /** 65 | * Transform fields on documents. 66 | * 67 | * @param {Adapter} adapter 68 | * @param {Array} docs 69 | * @param {Object} params 70 | * @param {Context?} ctx 71 | */ 72 | async _transformFields(adapter, docs, params, ctx) { 73 | let customFieldList = false; 74 | let selectedFields = this.$fields; 75 | if (Array.isArray(params.fields)) { 76 | selectedFields = this.$fields.filter(f => params.fields.includes(f.name)); 77 | customFieldList = true; 78 | } 79 | const authorizedFields = await this._authorizeFields(selectedFields, ctx, params, { 80 | isWrite: false 81 | }); 82 | 83 | const res = Array.from(docs).map(() => ({})); 84 | 85 | let needPopulates = this.settings.defaultPopulates; 86 | if (params.populate === false) needPopulates = null; 87 | else if (_.isString(params.populate)) needPopulates = [params.populate]; 88 | else if (Array.isArray(params.populate)) needPopulates = params.populate; 89 | 90 | await Promise.all( 91 | authorizedFields.map(async field => { 92 | if (field.hidden === true) return; 93 | else if (field.hidden == "byDefault" && !customFieldList) return; 94 | 95 | // Field values 96 | let values = docs.map(doc => _.get(doc, field.columnName)); 97 | 98 | if (!adapter.hasNestedFieldSupport) { 99 | if (field.type == "array" || field.type == "object") { 100 | values = values.map(v => { 101 | if (typeof v === "string") { 102 | try { 103 | return JSON.parse(v); 104 | } catch (e) { 105 | this.logger.warn("Unable to parse the JSON value", v); 106 | } 107 | } 108 | return v; 109 | }); 110 | } 111 | } 112 | 113 | // Populating 114 | if ( 115 | field.populate && 116 | needPopulates != null && 117 | needPopulates.includes(field.name) 118 | ) { 119 | if (field.populate.keyField) { 120 | // Using different field values as key values 121 | const keyField = this.$fields.find( 122 | f => f.name == field.populate.keyField 123 | ); 124 | if (!keyField) { 125 | throw new ServiceSchemaError( 126 | `The 'keyField' is not exist in populate definition of '${field.name}' field.`, 127 | { field } 128 | ); 129 | } 130 | 131 | values = docs.map(doc => _.get(doc, keyField.columnName)); 132 | } 133 | 134 | const resolvedObj = await this._populateValues(field, values, docs, ctx); 135 | // Received the values from custom populate 136 | if (Array.isArray(resolvedObj)) values = resolvedObj; 137 | // Received the values from action resolving 138 | else values = deepResolve(values, resolvedObj); 139 | } 140 | 141 | // Virtual or formatted field 142 | if (_.isFunction(field.get)) { 143 | values = await Promise.all( 144 | values.map(async (value, i) => 145 | this._callCustomFunction(field.get, [ 146 | { value, entity: docs[i], field, ctx } 147 | ]) 148 | ) 149 | ); 150 | } 151 | 152 | // Secure ID field 153 | if (field.secure) { 154 | values = values.map(v => this.encodeID(v)); 155 | } 156 | 157 | // Set values to result 158 | res.map((doc, i) => { 159 | if (values[i] !== undefined) _.set(doc, field.name, values[i]); 160 | }); 161 | }) 162 | ); 163 | 164 | return res; 165 | }, 166 | 167 | /** 168 | * Populate values. 169 | * 170 | * @param {Object} field 171 | * @param {Array} values 172 | * @param {Array} docs 173 | * @param {Context?} ctx 174 | * @returns {Object} 175 | */ 176 | async _populateValues(field, values, docs, ctx) { 177 | values = _.uniq(_.compact(_.flattenDeep(values))); 178 | 179 | const rule = field.populate; 180 | if (rule.handler) { 181 | return await rule.handler.call(this, ctx, values, docs, field); 182 | } 183 | 184 | if (values.length == 0) return {}; 185 | 186 | const params = { 187 | ...(rule.params || {}), 188 | id: values, 189 | mapping: true, 190 | throwIfNotExist: false 191 | }; 192 | 193 | return await (ctx || this.broker).call(rule.action, params, rule.callOptions); 194 | } 195 | }; 196 | }; 197 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | /* 2 | * @moleculer/database 3 | * Copyright (c) 2022 MoleculerJS (https://github.com/moleculerjs/database) 4 | * MIT Licensed 5 | */ 6 | 7 | "use strict"; 8 | 9 | module.exports = { 10 | /** 11 | * Flatten an object. 12 | * Credits: "flat" library 13 | * 14 | * @param {Object} target 15 | * @param {Object?} opts 16 | * @returns {Object} 17 | */ 18 | flatten: (target, opts) => { 19 | opts = opts || {}; 20 | 21 | const delimiter = opts.delimiter || "."; 22 | const maxDepth = opts.maxDepth; 23 | const transformKey = opts.transformKey || (key => key); 24 | const output = {}; 25 | 26 | const step = (object, prev, currentDepth) => { 27 | currentDepth = currentDepth || 1; 28 | Object.keys(object).forEach(key => { 29 | const value = object[key]; 30 | const isarray = opts.safe && Array.isArray(value); 31 | const type = Object.prototype.toString.call(value); 32 | const isbuffer = Buffer.isBuffer(value); 33 | const isobject = type === "[object Object]" || type === "[object Array]"; 34 | 35 | const newKey = prev ? prev + delimiter + transformKey(key) : transformKey(key); 36 | 37 | if ( 38 | !isarray && 39 | !isbuffer && 40 | isobject && 41 | Object.keys(value).length && 42 | (!opts.maxDepth || currentDepth < maxDepth) 43 | ) { 44 | return step(value, newKey, currentDepth + 1); 45 | } 46 | 47 | output[newKey] = value; 48 | }); 49 | }; 50 | 51 | step(target); 52 | 53 | return output; 54 | } 55 | }; 56 | -------------------------------------------------------------------------------- /test/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.3" 2 | 3 | services: 4 | 5 | mongo: 6 | image: mongo:4 7 | ports: 8 | - "27017:27017" 9 | 10 | postgres: 11 | image: postgres:12 12 | ports: 13 | - "5432:5432" 14 | environment: 15 | - POSTGRES_PASSWORD=moleculer 16 | healthcheck: 17 | test: /usr/bin/psql postgres://postgres:moleculer@localhost/postgres -c "SELECT 1" 18 | volumes: 19 | - ./scripts/pg-create-databases.sql:/docker-entrypoint-initdb.d/pg-create-databases.sql 20 | 21 | # pgadmin: 22 | # image: dpage/pgadmin4 23 | # environment: 24 | # PGADMIN_DEFAULT_EMAIL: "admin@example.com" 25 | # PGADMIN_DEFAULT_PASSWORD: "admin" 26 | # ports: 27 | # - "8080:80" 28 | # depends_on: 29 | # - postgres 30 | 31 | mysql: 32 | image: mysql:8.3 33 | ports: 34 | - "3306:3306" 35 | environment: 36 | - MYSQL_ROOT_PASSWORD=moleculer 37 | - MYSQL_DATABASE=db_int_test 38 | healthcheck: 39 | test: /usr/bin/mysql -hlocalhost -uroot -pmoleculer -e "SELECT 1" 40 | command: --default-authentication-plugin=mysql_native_password 41 | volumes: 42 | - ./scripts/mysql-create-databases.sql:/docker-entrypoint-initdb.d/mysql-create-databases.sql 43 | 44 | # adminer: 45 | # image: adminer 46 | # ports: 47 | # - 8081:8080 48 | 49 | mssql: 50 | image: mcr.microsoft.com/mssql/server:2022-latest 51 | ports: 52 | - '1433:1433' 53 | environment: 54 | - ACCEPT_EULA=Y 55 | - MSSQL_PID=Developer 56 | - SA_PASSWORD=Moleculer@Pass1234 57 | healthcheck: 58 | test: 59 | [ 60 | 'CMD', 61 | '/opt/mssql-tools18/bin/sqlcmd', 62 | '-S', 63 | 'localhost', 64 | '-No', 65 | '-U', 66 | 'SA', 67 | '-P', 68 | 'Moleculer@Pass1234', 69 | '-l', 70 | '30', 71 | '-Q', 72 | 'SELECT 1', 73 | ] 74 | interval: 3s 75 | timeout: 1s 76 | retries: 10 77 | 78 | mssql-create-db: 79 | image: mcr.microsoft.com/mssql/server:2022-latest 80 | links: 81 | - mssql 82 | depends_on: 83 | - mssql 84 | entrypoint: 85 | - bash 86 | - -c 87 | # https://docs.microsoft.com/en-us/sql/relational-databases/logs/control-transaction-durability?view=sql-server-ver15#bkmk_DbControl 88 | - 'until /opt/mssql-tools18/bin/sqlcmd -C -S mssql -U sa -P Moleculer@Pass1234 -d master -No -Q "CREATE DATABASE bench_test; CREATE DATABASE db_int_test; CREATE DATABASE db_int_posts_1000; CREATE DATABASE db_int_posts_1001; CREATE DATABASE db_int_posts_1002; CREATE DATABASE db_int_posts_1003;"; do sleep 5; done' 89 | -------------------------------------------------------------------------------- /test/integration/adapter.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const { ServiceBroker } = require("moleculer"); 4 | const ObjectId = require("mongodb").ObjectId; 5 | const { Stream } = require("stream"); 6 | const DbService = require("../..").Service; 7 | const { addExpectAnyFields } = require("./utils"); 8 | 9 | module.exports = (getAdapter, adapterType) => { 10 | let expectedID; 11 | if (["Knex"].includes(adapterType)) { 12 | expectedID = expect.any(Number); 13 | } else if (["MongoDB"].includes(adapterType)) { 14 | expectedID = expect.any(ObjectId); 15 | } else { 16 | expectedID = expect.any(String); 17 | } 18 | 19 | describe("Test adapter methods", () => { 20 | let adapter; 21 | const broker = new ServiceBroker({ logger: false }); 22 | const svc = broker.createService({ 23 | name: "posts", 24 | mixins: [DbService({ adapter: getAdapter({ collection: "posts" }) })], 25 | settings: { 26 | createActions: false, 27 | fields: { 28 | id: { 29 | type: "number", 30 | primaryKey: true, 31 | columnName: "_id", 32 | columnType: "integer" 33 | }, 34 | title: { type: "string", trim: true, required: true }, 35 | content: { type: "string", max: 200, columnType: "string" }, 36 | votes: { type: "number", default: 0, columnType: "integer" }, 37 | comments: { type: "number", default: 0, columnType: "integer" }, 38 | status: { type: "number", default: 1, columnType: "integer" } 39 | }, 40 | indexes: [{ fields: { title: "text", content: "text" } }] 41 | }, 42 | 43 | async started() { 44 | adapter = await this.getAdapter(); 45 | 46 | if (adapterType == "Knex") { 47 | await adapter.createTable(null, { createIndexes: true }); 48 | } else { 49 | await this.createIndexes(adapter); 50 | } 51 | 52 | await this.clearEntities(); 53 | } 54 | }); 55 | 56 | beforeAll(() => broker.start()); 57 | afterAll(() => broker.stop()); 58 | 59 | let docs = []; 60 | 61 | describe("Set up", () => { 62 | it("should return empty array", async () => { 63 | const rows = await adapter.find(); 64 | expect(rows).toEqual([]); 65 | 66 | const count = await adapter.count(); 67 | expect(count).toEqual(0); 68 | }); 69 | }); 70 | 71 | describe("Common flow", () => { 72 | it("should create an entity", async () => { 73 | const res = await adapter.insert({ 74 | title: "First post", 75 | content: "Content of 1rd post", 76 | votes: 5, 77 | comments: 0, 78 | status: 1 79 | }); 80 | expect(res).toEqual({ 81 | _id: expectedID, 82 | title: "First post", 83 | content: "Content of 1rd post", 84 | votes: 5, 85 | comments: 0, 86 | status: 1 87 | }); 88 | docs.push(res); 89 | }); 90 | 91 | it("should create more entities and return IDs", async () => { 92 | const res = await adapter.insertMany([ 93 | { 94 | title: "Second post", 95 | content: "Content of 2nd post", 96 | votes: 0, 97 | comments: 5, 98 | status: 0 99 | }, 100 | { 101 | title: "Third post", 102 | content: "Content of 3rd post", 103 | votes: 10, 104 | comments: 2, 105 | status: 1 106 | } 107 | ]); 108 | expect(res).toEqual([expectedID, expectedID]); 109 | 110 | const entities = await adapter.findByIds(res); 111 | 112 | expect(entities).toEqual( 113 | expect.arrayContaining([ 114 | { 115 | _id: expectedID, 116 | title: "Second post", 117 | content: "Content of 2nd post", 118 | votes: 0, 119 | comments: 5, 120 | status: 0 121 | }, 122 | { 123 | _id: expectedID, 124 | title: "Third post", 125 | content: "Content of 3rd post", 126 | votes: 10, 127 | comments: 2, 128 | status: 1 129 | } 130 | ]) 131 | ); 132 | docs.push(...entities); 133 | }); 134 | 135 | it("should create more entities and return entities", async () => { 136 | const res = await adapter.insertMany( 137 | [ 138 | { 139 | title: "Forth post", 140 | content: "Content of 4th post", 141 | votes: 3, 142 | comments: 13, 143 | status: 1 144 | }, 145 | { 146 | title: "Fifth post", 147 | content: "Content of 5th post", 148 | votes: 7, 149 | comments: 3, 150 | status: 0 151 | } 152 | ], 153 | { returnEntities: true } 154 | ); 155 | 156 | expect(res).toEqual( 157 | expect.arrayContaining([ 158 | { 159 | _id: expectedID, 160 | title: "Forth post", 161 | content: "Content of 4th post", 162 | votes: 3, 163 | comments: 13, 164 | status: 1 165 | }, 166 | { 167 | _id: expectedID, 168 | title: "Fifth post", 169 | content: "Content of 5th post", 170 | votes: 7, 171 | comments: 3, 172 | status: 0 173 | } 174 | ]) 175 | ); 176 | docs.push(...res); 177 | }); 178 | 179 | it("should find all records", async () => { 180 | const rows = await adapter.find(); 181 | expect(rows).toEqual(expect.arrayContaining(docs)); 182 | 183 | const count = await adapter.count(); 184 | expect(count).toEqual(5); 185 | }); 186 | 187 | it("should find by query", async () => { 188 | const rows = await adapter.find({ query: { status: 0 }, sort: "-votes" }); 189 | expect(rows).toEqual([docs[4], docs[1]]); 190 | }); 191 | 192 | it("should find with sort", async () => { 193 | const rows = await adapter.find({ sort: ["status", "-votes"] }); 194 | expect(rows).toEqual([docs[4], docs[1], docs[2], docs[0], docs[3]]); 195 | }); 196 | 197 | it("should find with limit & offset", async () => { 198 | const rows = await adapter.find({ sort: ["votes"], limit: 2, offset: 2 }); 199 | expect(rows).toEqual([docs[0], docs[4]]); 200 | }); 201 | 202 | it("should find with search", async () => { 203 | const rows = await adapter.find({ 204 | search: "th post", 205 | searchFields: ["title"], 206 | sort: ["title"] 207 | }); 208 | if (adapterType == "MongoDB") { 209 | expect(rows).toEqual( 210 | expect.arrayContaining([ 211 | addExpectAnyFields(docs[4], { _score: Number }), 212 | addExpectAnyFields(docs[3], { _score: Number }) 213 | ]) 214 | ); 215 | } else { 216 | expect(rows).toEqual([docs[4], docs[3]]); 217 | } 218 | }); 219 | 220 | it("should find by ID", async () => { 221 | const rows = await adapter.findById(docs[3]._id); 222 | expect(rows).toEqual(docs[3]); 223 | }); 224 | 225 | it("should find by IDs", async () => { 226 | const rows = await adapter.findByIds([docs[3]._id, docs[1]._id, docs[4]._id]); 227 | expect(rows).toEqual(expect.arrayContaining([docs[3], docs[1], docs[4]])); 228 | }); 229 | 230 | it("should find one", async () => { 231 | const row = await adapter.findOne({ query: { votes: 10, status: 1 } }); 232 | expect(row).toEqual(docs[2]); 233 | }); 234 | 235 | it("should find one with sort", async () => { 236 | const row = await adapter.findOne({ query: { status: 1 }, sort: "votes" }); 237 | expect(row).toEqual(docs[3]); 238 | }); 239 | 240 | it("should count by query", async () => { 241 | const row = await adapter.count({ query: { status: 1 } }); 242 | expect(row).toBe(3); 243 | }); 244 | 245 | it("should update by ID", async () => { 246 | const row = await adapter.updateById(docs[1]._id, { 247 | title: "Modified second post", 248 | votes: 1, 249 | comments: 9 250 | }); 251 | expect(row).toEqual({ 252 | _id: docs[1]._id, 253 | title: "Modified second post", 254 | content: "Content of 2nd post", 255 | votes: 1, 256 | comments: 9, 257 | status: 0 258 | }); 259 | docs[1] = row; 260 | 261 | const res = await adapter.findById(docs[1]._id); 262 | expect(res).toEqual(row); 263 | }); 264 | 265 | it("should update many", async () => { 266 | const res = await adapter.updateMany( 267 | { status: 0 }, 268 | { 269 | votes: 3 270 | } 271 | ); 272 | expect(res).toBe(2); 273 | 274 | const res2 = await adapter.findById(docs[1]._id); 275 | expect(res2).toEqual({ 276 | _id: docs[1]._id, 277 | content: "Content of 2nd post", 278 | title: "Modified second post", 279 | votes: 3, 280 | comments: 9, 281 | status: 0 282 | }); 283 | }); 284 | 285 | //if (adapterType == "MongoDB") { 286 | it("should raw update by ID", async () => { 287 | const row = await adapter.updateById( 288 | docs[1]._id, 289 | { 290 | $set: { 291 | title: "Raw modified second post" 292 | }, 293 | $inc: { votes: 1, comments: -2 } 294 | }, 295 | { raw: true } 296 | ); 297 | expect(row).toEqual({ 298 | _id: docs[1]._id, 299 | title: "Raw modified second post", 300 | content: "Content of 2nd post", 301 | votes: 4, 302 | comments: 7, 303 | status: 0 304 | }); 305 | docs[1] = row; 306 | 307 | const res = await adapter.findById(docs[1]._id); 308 | expect(res).toEqual(row); 309 | }); 310 | 311 | it("should raw update many", async () => { 312 | const res = await adapter.updateMany( 313 | { status: 0 }, 314 | { 315 | $set: { 316 | title: "Raw many modified second post" 317 | }, 318 | $inc: { votes: 2, comments: -1 } 319 | }, 320 | { raw: true } 321 | ); 322 | expect(res).toBe(2); 323 | 324 | const res2 = await adapter.findById(docs[1]._id); 325 | expect(res2).toEqual({ 326 | _id: docs[1]._id, 327 | content: "Content of 2nd post", 328 | status: 0, 329 | title: "Raw many modified second post", 330 | votes: 6, 331 | comments: 6 332 | }); 333 | }); 334 | //} 335 | 336 | it("Update docs in local store", async () => { 337 | docs[0] = await adapter.findById(docs[0]._id); 338 | docs[1] = await adapter.findById(docs[1]._id); 339 | docs[2] = await adapter.findById(docs[2]._id); 340 | docs[3] = await adapter.findById(docs[3]._id); 341 | docs[4] = await adapter.findById(docs[4]._id); 342 | }); 343 | 344 | it("should find all records", async () => { 345 | const rows = await adapter.find(); 346 | expect(rows).toEqual(expect.arrayContaining(docs)); 347 | }); 348 | 349 | it("should replace by ID", async () => { 350 | const row = await adapter.replaceById(docs[3]._id, { 351 | title: "Modified forth post", 352 | content: "Content of modified 4th post", 353 | votes: 99, 354 | comments: 66, 355 | status: 0 356 | }); 357 | expect(row).toEqual({ 358 | _id: docs[3]._id, 359 | title: "Modified forth post", 360 | content: "Content of modified 4th post", 361 | votes: 99, 362 | comments: 66, 363 | status: 0 364 | }); 365 | docs[3] = row; 366 | 367 | const res = await adapter.findById(docs[3]._id); 368 | expect(res).toEqual(row); 369 | }); 370 | 371 | it("should remove by ID", async () => { 372 | const row = await adapter.removeById(docs[3]._id); 373 | expect(row).toBe(docs[3]._id); 374 | 375 | let res = await adapter.findById(docs[3]._id); 376 | expect(res == null).toBeTruthy(); 377 | 378 | res = await adapter.count(); 379 | expect(res).toBe(4); 380 | }); 381 | 382 | it("should remove many", async () => { 383 | let res = await adapter.removeMany({ status: 1 }); 384 | expect(res).toBe(2); 385 | 386 | res = await adapter.find({ sort: "comments" }); 387 | expect(res).toEqual([docs[4], docs[1]]); 388 | 389 | res = await adapter.count(); 390 | expect(res).toBe(2); 391 | }); 392 | 393 | it("should clear all records", async () => { 394 | let res = await adapter.clear(); 395 | expect(res).toBe(2); 396 | 397 | res = await adapter.find(); 398 | expect(res).toEqual([]); 399 | 400 | res = await adapter.count(); 401 | expect(res).toBe(0); 402 | }); 403 | }); 404 | }); 405 | 406 | // TODO Knex extra tests for 407 | // - createQuery whereXY 408 | // - createTable? 409 | }; 410 | -------------------------------------------------------------------------------- /test/integration/index.spec.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const _ = require("lodash"); 4 | const AdapterTests = require("./adapter.test"); 5 | const MethodTests = require("./methods.test"); 6 | const ScopeTests = require("./scopes.test"); 7 | const ActionsTests = require("./actions.test"); 8 | const TransformTests = require("./transform.test"); 9 | const PopulateTests = require("./populate.test"); 10 | const ValidationTests = require("./validation.test"); 11 | const RESTTests = require("./rest.test"); 12 | const TenantTests = require("./tenants.test"); 13 | 14 | let Adapters; 15 | if (process.env.GITHUB_ACTIONS_CI) { 16 | Adapters = [ 17 | { type: "NeDB" }, 18 | { type: "MongoDB", options: { dbName: "db_int_test" } }, 19 | { 20 | name: "Knex-SQLite", 21 | type: "Knex", 22 | options: { 23 | knex: { 24 | client: "sqlite3", 25 | connection: { 26 | filename: ":memory:" 27 | }, 28 | useNullAsDefault: true, 29 | log: { 30 | warn() {}, 31 | error() {}, 32 | deprecate() {}, 33 | debug() {} 34 | } 35 | } 36 | } 37 | }, 38 | { 39 | name: "Knex-Postgresql", 40 | type: "Knex", 41 | options: { 42 | knex: { 43 | client: "pg", 44 | connection: { 45 | //"postgres://postgres:moleculer@127.0.0.1:5432/db_int_test" 46 | host: "127.0.0.1", 47 | port: 5432, 48 | user: "postgres", 49 | password: "moleculer", 50 | database: "db_int_test" 51 | } 52 | } 53 | } 54 | }, 55 | { 56 | name: "Knex-MySQL", 57 | type: "Knex", 58 | options: { 59 | knex: { 60 | client: "mysql", 61 | connection: { 62 | host: "127.0.0.1", 63 | user: "root", 64 | password: "moleculer", 65 | database: "db_int_test" 66 | }, 67 | log: { 68 | warn() {}, 69 | error() {}, 70 | deprecate() {}, 71 | debug() {} 72 | } 73 | } 74 | } 75 | }, 76 | { 77 | name: "Knex-MySQL2", 78 | type: "Knex", 79 | options: { 80 | knex: { 81 | client: "mysql2", 82 | connection: { 83 | host: "127.0.0.1", 84 | user: "root", 85 | password: "moleculer", 86 | database: "db_int_test" 87 | }, 88 | log: { 89 | warn() {}, 90 | error() {}, 91 | deprecate() {}, 92 | debug() {} 93 | } 94 | } 95 | } 96 | }, 97 | { 98 | name: "Knex-MSSQL", 99 | type: "Knex", 100 | options: { 101 | knex: { 102 | client: "mssql", 103 | connection: { 104 | host: "127.0.0.1", 105 | port: 1433, 106 | user: "sa", 107 | password: "Moleculer@Pass1234", 108 | database: "db_int_test", 109 | encrypt: false 110 | } 111 | } 112 | } 113 | } 114 | ]; 115 | } else { 116 | // Local development tests 117 | Adapters = [ 118 | { 119 | type: "NeDB" 120 | }, 121 | { type: "MongoDB", options: { dbName: "db_int_test" } }, 122 | { 123 | name: "Knex-SQLite", 124 | type: "Knex", 125 | options: { 126 | knex: { 127 | client: "sqlite3", 128 | connection: { 129 | filename: ":memory:" 130 | }, 131 | useNullAsDefault: true, 132 | log: { 133 | warn() {}, 134 | error() {}, 135 | deprecate() {}, 136 | debug() {} 137 | } 138 | } 139 | } 140 | }, 141 | { 142 | name: "Knex-Postgresql", 143 | type: "Knex", 144 | options: { 145 | knex: { 146 | client: "pg", 147 | connection: { 148 | host: "127.0.0.1", 149 | port: 5432, 150 | user: "postgres", 151 | password: "moleculer", 152 | database: "db_int_test" 153 | } 154 | } 155 | } 156 | }, 157 | { 158 | name: "Knex-MySQL", 159 | type: "Knex", 160 | options: { 161 | knex: { 162 | client: "mysql", 163 | connection: { 164 | host: "127.0.0.1", 165 | user: "root", 166 | password: "moleculer", 167 | database: "db_int_test" 168 | }, 169 | log: { 170 | warn() {}, 171 | error() {}, 172 | deprecate() {}, 173 | debug() {} 174 | } 175 | } 176 | } 177 | }, 178 | { 179 | name: "Knex-MySQL2", 180 | type: "Knex", 181 | options: { 182 | knex: { 183 | client: "mysql2", 184 | connection: { 185 | host: "127.0.0.1", 186 | user: "root", 187 | password: "moleculer", 188 | database: "db_int_test" 189 | }, 190 | log: { 191 | warn() {}, 192 | error() {}, 193 | deprecate() {}, 194 | debug() {} 195 | } 196 | } 197 | } 198 | }, 199 | { 200 | name: "Knex-MSSQL", 201 | type: "Knex", 202 | options: { 203 | knex: { 204 | client: "mssql", 205 | connection: { 206 | host: "127.0.0.1", 207 | port: 1433, 208 | user: "sa", 209 | password: "Moleculer@Pass1234", 210 | database: "db_int_test", 211 | encrypt: false 212 | } 213 | } 214 | } 215 | } 216 | ]; 217 | } 218 | 219 | describe(`Integration tests (${process.env.ADAPTER})`, () => { 220 | for (const adapter of Adapters) { 221 | const name = adapter.name || adapter.type; 222 | if (process.env.ADAPTER && name !== process.env.ADAPTER) continue; 223 | 224 | const getAdapter = options => { 225 | if (adapter.options) return _.defaultsDeep({}, { options }, adapter); 226 | 227 | return adapter; 228 | }; 229 | 230 | getAdapter.adapterName = adapter.name; 231 | getAdapter.isNoSQL = ["NeDB", "MongoDB"].includes(adapter.type); 232 | getAdapter.isSQL = ["Knex"].includes(adapter.type); 233 | getAdapter.IdColumnType = ["Knex"].includes(adapter.type) ? "integer" : "string"; 234 | 235 | describe(`Adapter: ${name}`, () => { 236 | describe("Test adapter", () => AdapterTests(getAdapter, adapter.type)); 237 | describe("Test methods", () => MethodTests(getAdapter, adapter.type)); 238 | describe("Test scopes", () => ScopeTests(getAdapter, adapter.type)); 239 | describe("Test actions", () => ActionsTests(getAdapter, adapter.type)); 240 | describe("Test transformations", () => TransformTests(getAdapter, adapter.type)); 241 | describe("Test populating", () => PopulateTests(getAdapter, adapter.type)); 242 | describe("Test Validations", () => ValidationTests(getAdapter, adapter.type)); 243 | describe("Test REST", () => RESTTests(getAdapter, adapter.type)); 244 | describe("Test Tenants", () => TenantTests(getAdapter, adapter.type)); 245 | }); 246 | } 247 | }); 248 | -------------------------------------------------------------------------------- /test/integration/transform.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const { ServiceBroker, Context } = require("moleculer"); 4 | const DbService = require("../..").Service; 5 | 6 | module.exports = (getAdapter, adapterType) => { 7 | let expectedID; 8 | if (["Knex"].includes(adapterType)) { 9 | expectedID = expect.any(Number); 10 | } else { 11 | expectedID = expect.any(String); 12 | } 13 | 14 | describe("Test transformations", () => { 15 | const broker = new ServiceBroker({ logger: false }); 16 | const svc = broker.createService({ 17 | name: "users", 18 | mixins: [ 19 | DbService({ 20 | adapter: getAdapter() 21 | }) 22 | ], 23 | settings: { 24 | fields: { 25 | myID: { 26 | type: "string", 27 | primaryKey: true, 28 | columnName: "_id", 29 | columnType: "integer" 30 | }, 31 | name: { type: "string" }, 32 | upperName: { 33 | type: "string", 34 | readonly: true, 35 | virtual: true, 36 | get: ({ entity }) => (entity.name ? entity.name.toUpperCase() : entity.name) 37 | }, 38 | password: { type: "string", hidden: true }, 39 | token: { type: "string", hidden: "byDefault" }, 40 | email: { type: "string", readPermission: "admin" }, 41 | phone: { type: "string", permission: "admin" } 42 | } 43 | }, 44 | 45 | async started() { 46 | const adapter = await this.getAdapter(); 47 | 48 | if (adapterType == "Knex") { 49 | await adapter.createTable(); 50 | } 51 | 52 | await this.clearEntities(); 53 | } 54 | }); 55 | 56 | beforeAll(() => broker.start()); 57 | afterAll(() => broker.stop()); 58 | 59 | const ctx = Context.create(broker, null, {}); 60 | const docs = {}; 61 | 62 | describe("Set up", () => { 63 | it("should return empty array", async () => { 64 | await svc.clearEntities(); 65 | 66 | const rows = await svc.findEntities(ctx); 67 | expect(rows).toEqual([]); 68 | 69 | const count = await svc.countEntities(ctx); 70 | expect(count).toEqual(0); 71 | }); 72 | }); 73 | 74 | describe("Test hidden fields, getter, readPermission", () => { 75 | it("create test entity", async () => { 76 | const res = await svc.createEntity(ctx, { 77 | name: "John Doe", 78 | upperName: "Nothing", 79 | email: "john.doe@moleculer.services", 80 | phone: "+1-555-1234", 81 | password: "johnDoe1234", 82 | token: "token1234" 83 | }); 84 | docs.johnDoe = res; 85 | 86 | expect(res).toEqual({ 87 | myID: expectedID, 88 | name: "John Doe", 89 | upperName: "JOHN DOE", 90 | email: "john.doe@moleculer.services", 91 | phone: "+1-555-1234" 92 | }); 93 | }); 94 | 95 | it("should hide e-mail address", async () => { 96 | svc.checkFieldAuthority = jest.fn(async () => false); 97 | const res = await svc.resolveEntities(ctx, { myID: docs.johnDoe.myID }); 98 | expect(res).toEqual({ 99 | myID: expectedID, 100 | name: "John Doe", 101 | upperName: "JOHN DOE" 102 | }); 103 | }); 104 | 105 | it("should not transform the entity", async () => { 106 | const res = await svc.resolveEntities( 107 | ctx, 108 | { myID: docs.johnDoe.myID }, 109 | { transform: false } 110 | ); 111 | expect(res).toEqual({ 112 | _id: expect.anything(), 113 | name: "John Doe", 114 | email: "john.doe@moleculer.services", 115 | phone: "+1-555-1234", 116 | password: "johnDoe1234", 117 | token: "token1234" 118 | }); 119 | }); 120 | 121 | it("should filter fields", async () => { 122 | const res = await svc.resolveEntities(ctx, { 123 | myID: docs.johnDoe.myID, 124 | fields: ["upperName", "asdasdasd", "password", "email", "token"] 125 | }); 126 | expect(res).toEqual({ 127 | upperName: "JOHN DOE", 128 | token: "token1234" 129 | }); 130 | }); 131 | 132 | it("should filter all fields", async () => { 133 | const res = await svc.resolveEntities(ctx, { 134 | myID: docs.johnDoe.myID, 135 | fields: [] 136 | }); 137 | expect(res).toEqual({}); 138 | }); 139 | }); 140 | }); 141 | }; 142 | -------------------------------------------------------------------------------- /test/integration/utils.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | function equalAtLeast(test, orig) { 4 | Object.keys(orig).forEach(key => { 5 | expect(test[key]).toEqual(orig[key]); 6 | }); 7 | } 8 | 9 | function addExpectAnyFields(doc, def) { 10 | const res = Object.assign({}, doc); 11 | Object.keys(def).forEach(key => { 12 | res[key] = expect.any(def[key]); 13 | }); 14 | return res; 15 | } 16 | 17 | module.exports = { 18 | equalAtLeast, 19 | addExpectAnyFields 20 | }; 21 | -------------------------------------------------------------------------------- /test/leak-detection/index.spec.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const memwatch = require("@icebob/node-memwatch"); 4 | const { ServiceBroker } = require("moleculer"); 5 | const DbService = require("../..").Service; 6 | 7 | const ACCEPTABLE_LIMIT = 1 * 1024 * 1024; // 1MB 8 | 9 | jest.setTimeout(60000); 10 | 11 | describe("Moleculer Database memory leak test", () => { 12 | const broker = new ServiceBroker({ logger: false }); 13 | const svc = broker.createService({ 14 | name: "posts", 15 | mixins: [ 16 | DbService({ 17 | adapter: { 18 | type: "MongoDB", 19 | options: { dbName: "db_int_test", collection: "posts" } 20 | }, 21 | createActions: true 22 | }) 23 | ], 24 | settings: { 25 | fields: { 26 | id: { type: "string", primaryKey: true, columnName: "_id" }, 27 | title: { type: "string", required: true }, 28 | content: { type: "string", required: true } 29 | } 30 | } 31 | }); 32 | 33 | const posts = []; 34 | 35 | beforeAll(async () => { 36 | await broker.start(); 37 | 38 | // Warm up 39 | for (let i = 0; i < 100; i++) { 40 | await broker.call("posts.create", { 41 | title: "Post title", 42 | content: "Post content" 43 | }); 44 | } 45 | memwatch.gc(); 46 | }); 47 | 48 | afterAll(() => broker.stop()); 49 | 50 | async function execute(actionName, params) { 51 | const hd = new memwatch.HeapDiff(); 52 | 53 | const paramsIsFunc = typeof params == "function"; 54 | 55 | for (let i = 0; i < 1000; i++) { 56 | const p = paramsIsFunc ? params() : params; 57 | const res = await broker.call(actionName, p); 58 | if (actionName == "posts.create") { 59 | posts.push(res.id); 60 | } 61 | } 62 | 63 | memwatch.gc(); 64 | const diff = hd.end(); 65 | if (diff.change.size_bytes >= ACCEPTABLE_LIMIT) console.log("Diff:", diff); // eslint-disable-line no-console 66 | 67 | expect(diff.change.size_bytes).toBeLessThan(ACCEPTABLE_LIMIT); 68 | } 69 | 70 | it("should not leak when create records", async () => { 71 | await execute("posts.create", { 72 | title: "Post title", 73 | content: "Post content" 74 | }); 75 | }); 76 | 77 | it("should not leak when find records", async () => { 78 | await execute("posts.find", { offset: 0, limit: 20 }); 79 | }); 80 | 81 | it("should not leak when list records", async () => { 82 | await execute("posts.list", { page: 1, pageSize: 20 }); 83 | }); 84 | 85 | it("should not leak when count records", async () => { 86 | await execute("posts.count"); 87 | }); 88 | 89 | it("should not leak when get a record", async () => { 90 | await execute("posts.get", { id: posts[5] }); 91 | }); 92 | 93 | it("should not leak when resolve a record", async () => { 94 | await execute("posts.resolve", { id: posts[5] }); 95 | }); 96 | 97 | it("should not leak when update a record", async () => { 98 | await execute("posts.update", { id: posts[5], title: "Modified title" }); 99 | }); 100 | 101 | it("should not leak when replace a record", async () => { 102 | await execute("posts.replace", { 103 | id: posts[5], 104 | title: "Replaced title", 105 | content: "Replaced content" 106 | }); 107 | }); 108 | 109 | it("should not leak when remove a record", async () => { 110 | console.log("posts", posts.length); 111 | await execute("posts.remove", () => { 112 | return { id: posts.pop() }; 113 | }); 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /test/leak-detection/self-check.spec.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const memwatch = require("@icebob/node-memwatch"); 4 | 5 | jest.setTimeout(3 * 60 * 1000); // 3mins 6 | 7 | describe("leak detector", function () { 8 | // let leakCB = jest.fn(); 9 | // memwatch.on("leak", leakCB); 10 | 11 | it("should detect a basic leak", done => { 12 | const hd = new memwatch.HeapDiff(); 13 | let iterations = 0; 14 | const leaks = []; 15 | const interval = setInterval(() => { 16 | if (iterations >= 10) { 17 | memwatch.gc(); 18 | const diff = hd.end(); 19 | console.log(diff); // eslint-disable-line no-console 20 | expect(diff.change.size_bytes).toBeGreaterThan(50 * 1024 * 1024); 21 | clearInterval(interval); 22 | return done(); 23 | } 24 | iterations++; 25 | for (let i = 0; i < 1000000; i++) { 26 | const str = "leaky string"; 27 | leaks.push(str); 28 | } 29 | }, 10); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /test/scripts/mysql-create-databases.sql: -------------------------------------------------------------------------------- 1 | CREATE DATABASE IF NOT EXISTS bench_test; 2 | CREATE DATABASE IF NOT EXISTS db_int_test; 3 | CREATE DATABASE IF NOT EXISTS db_int_posts_1000; 4 | CREATE DATABASE IF NOT EXISTS db_int_posts_1001; 5 | CREATE DATABASE IF NOT EXISTS db_int_posts_1002; 6 | CREATE DATABASE IF NOT EXISTS db_int_posts_1003; 7 | -------------------------------------------------------------------------------- /test/scripts/pg-create-databases.sql: -------------------------------------------------------------------------------- 1 | CREATE DATABASE bench_test; 2 | CREATE DATABASE db_int_test; 3 | CREATE DATABASE db_int_posts_1000; 4 | CREATE DATABASE db_int_posts_1001; 5 | CREATE DATABASE db_int_posts_1002; 6 | CREATE DATABASE db_int_posts_1003; 7 | 8 | \connect db_int_test; 9 | CREATE SCHEMA tenant_1000; 10 | CREATE SCHEMA tenant_1001; 11 | CREATE SCHEMA tenant_1002; 12 | CREATE SCHEMA tenant_1003; 13 | -------------------------------------------------------------------------------- /test/unit/schema.spec.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const { generateValidatorSchemaFromFields } = require("../.."); 4 | 5 | describe("Test validator schema generation", () => { 6 | const fields = { 7 | id: { type: "string", primaryKey: true, columnName: "_id" }, 8 | name: { type: "string", required: true }, 9 | username: { type: "string", required: true, min: 3, max: 100 }, 10 | email: "email", 11 | password: { type: "string", hidden: true, min: 6 }, 12 | age: { type: "number", positive: true, integer: true }, 13 | bio: true, 14 | token: false, 15 | address: { 16 | type: "object", 17 | properties: { 18 | zip: { type: "number" }, 19 | street: { type: "string" }, 20 | state: { type: "string" }, 21 | city: { type: "string", required: true }, 22 | country: { type: "string" }, 23 | primary: { type: "boolean", default: true } 24 | } 25 | }, 26 | roles: { 27 | type: "array", 28 | max: 3, 29 | items: { type: "string" } 30 | }, 31 | 32 | phones: { 33 | type: "array", 34 | items: { 35 | type: "object", 36 | properties: { 37 | type: "string", 38 | number: { type: "string", required: true }, 39 | primary: { type: "boolean", default: false } 40 | } 41 | } 42 | }, 43 | settings: { 44 | type: "object", 45 | optional: true, 46 | default: {} 47 | }, 48 | 49 | createdAt: { type: "date", readonly: true, onCreate: () => new Date() }, 50 | updatedAt: { type: "date", readonly: true, onUpdate: () => new Date() }, 51 | replacedAt: { type: "date", readonly: true, onReplace: () => new Date() }, 52 | status: { type: "string", default: "A", onRemove: "D" } 53 | }; 54 | 55 | it("generate validator schema for 'create'", async () => { 56 | expect( 57 | generateValidatorSchemaFromFields(fields, { 58 | type: "create", 59 | strict: "remove", 60 | enableParamsConversion: true 61 | }) 62 | ).toEqual({ 63 | $$strict: "remove", 64 | name: { type: "string", convert: true }, 65 | username: { type: "string", max: 100, min: 3, convert: true }, 66 | email: { type: "email", optional: true }, 67 | password: { type: "string", min: 6, optional: true, convert: true }, 68 | age: { 69 | type: "number", 70 | positive: true, 71 | integer: true, 72 | optional: true, 73 | convert: true 74 | }, 75 | address: { 76 | type: "object", 77 | optional: true, 78 | strict: "remove", 79 | properties: { 80 | zip: { type: "number", optional: true, convert: true }, 81 | street: { type: "string", optional: true, convert: true }, 82 | state: { type: "string", optional: true, convert: true }, 83 | city: { type: "string", convert: true }, 84 | country: { type: "string", optional: true, convert: true }, 85 | primary: { type: "boolean", convert: true, optional: true, default: true } 86 | } 87 | }, 88 | roles: { 89 | type: "array", 90 | max: 3, 91 | optional: true, 92 | items: { 93 | type: "string", 94 | optional: true, 95 | convert: true 96 | } 97 | }, 98 | phones: { 99 | type: "array", 100 | optional: true, 101 | items: { 102 | type: "object", 103 | optional: true, 104 | strict: "remove", 105 | properties: { 106 | type: { type: "string", optional: true, convert: true }, 107 | number: { type: "string", convert: true }, 108 | primary: { 109 | type: "boolean", 110 | convert: true, 111 | optional: true, 112 | default: false 113 | } 114 | } 115 | } 116 | }, 117 | settings: { 118 | type: "object", 119 | optional: true, 120 | default: {} 121 | }, 122 | bio: { type: "any", optional: true }, 123 | status: { type: "string", default: "A", optional: true, convert: true } 124 | }); 125 | }); 126 | 127 | it("generate validator schema for 'create' with strict && disable convert", async () => { 128 | expect( 129 | generateValidatorSchemaFromFields(fields, { 130 | type: "create", 131 | strict: true, 132 | enableParamsConversion: false 133 | }) 134 | ).toEqual({ 135 | $$strict: true, 136 | name: { type: "string" }, 137 | username: { type: "string", max: 100, min: 3 }, 138 | email: { type: "email", optional: true }, 139 | password: { type: "string", min: 6, optional: true }, 140 | age: { 141 | type: "number", 142 | positive: true, 143 | integer: true, 144 | optional: true 145 | }, 146 | address: { 147 | type: "object", 148 | optional: true, 149 | strict: true, 150 | properties: { 151 | zip: { type: "number", optional: true }, 152 | street: { type: "string", optional: true }, 153 | state: { type: "string", optional: true }, 154 | city: { type: "string" }, 155 | country: { type: "string", optional: true }, 156 | primary: { type: "boolean", optional: true, default: true } 157 | } 158 | }, 159 | roles: { 160 | type: "array", 161 | max: 3, 162 | optional: true, 163 | items: { 164 | type: "string", 165 | optional: true 166 | } 167 | }, 168 | phones: { 169 | type: "array", 170 | optional: true, 171 | items: { 172 | type: "object", 173 | optional: true, 174 | strict: true, 175 | properties: { 176 | type: { type: "string", optional: true }, 177 | number: { type: "string" }, 178 | primary: { 179 | type: "boolean", 180 | 181 | optional: true, 182 | default: false 183 | } 184 | } 185 | } 186 | }, 187 | settings: { 188 | type: "object", 189 | optional: true, 190 | default: {} 191 | }, 192 | bio: { type: "any", optional: true }, 193 | status: { type: "string", default: "A", optional: true } 194 | }); 195 | }); 196 | 197 | it("generate validator schema for 'update'", async () => { 198 | expect( 199 | generateValidatorSchemaFromFields(fields, { 200 | type: "update", 201 | strict: "remove", 202 | enableParamsConversion: true 203 | }) 204 | ).toEqual({ 205 | $$strict: "remove", 206 | id: { type: "string", optional: false, convert: true }, 207 | name: { type: "string", optional: true, convert: true }, 208 | username: { type: "string", max: 100, min: 3, optional: true, convert: true }, 209 | email: { type: "email", optional: true }, 210 | password: { type: "string", min: 6, optional: true, convert: true }, 211 | age: { 212 | type: "number", 213 | positive: true, 214 | integer: true, 215 | optional: true, 216 | convert: true 217 | }, 218 | address: { 219 | type: "object", 220 | optional: true, 221 | strict: "remove", 222 | properties: { 223 | zip: { type: "number", optional: true, convert: true }, 224 | street: { type: "string", optional: true, convert: true }, 225 | state: { type: "string", optional: true, convert: true }, 226 | city: { type: "string", optional: true, convert: true }, 227 | country: { type: "string", optional: true, convert: true }, 228 | primary: { type: "boolean", convert: true, optional: true } 229 | } 230 | }, 231 | roles: { 232 | type: "array", 233 | max: 3, 234 | optional: true, 235 | items: { 236 | type: "string", 237 | optional: true, 238 | convert: true 239 | } 240 | }, 241 | phones: { 242 | type: "array", 243 | optional: true, 244 | items: { 245 | type: "object", 246 | optional: true, 247 | strict: "remove", 248 | properties: { 249 | type: { type: "string", optional: true, convert: true }, 250 | number: { type: "string", optional: true, convert: true }, 251 | primary: { 252 | type: "boolean", 253 | convert: true, 254 | optional: true 255 | } 256 | } 257 | } 258 | }, 259 | settings: { 260 | type: "object", 261 | optional: true 262 | }, 263 | bio: { type: "any", optional: true }, 264 | status: { type: "string", optional: true, convert: true } 265 | }); 266 | }); 267 | 268 | it("generate validator schema for 'replace'", async () => { 269 | expect( 270 | generateValidatorSchemaFromFields(fields, { 271 | type: "replace", 272 | strict: "remove", 273 | enableParamsConversion: true 274 | }) 275 | ).toEqual({ 276 | $$strict: "remove", 277 | id: { type: "string", optional: false, convert: true }, 278 | name: { type: "string", convert: true }, 279 | username: { type: "string", max: 100, min: 3, convert: true }, 280 | email: { type: "email", optional: true }, 281 | password: { type: "string", min: 6, optional: true, convert: true }, 282 | age: { 283 | type: "number", 284 | positive: true, 285 | integer: true, 286 | optional: true, 287 | convert: true 288 | }, 289 | address: { 290 | type: "object", 291 | optional: true, 292 | strict: "remove", 293 | properties: { 294 | zip: { type: "number", optional: true, convert: true }, 295 | street: { type: "string", optional: true, convert: true }, 296 | state: { type: "string", optional: true, convert: true }, 297 | city: { type: "string", convert: true }, 298 | country: { type: "string", optional: true, convert: true }, 299 | primary: { type: "boolean", convert: true, optional: true, default: true } 300 | } 301 | }, 302 | roles: { 303 | type: "array", 304 | max: 3, 305 | optional: true, 306 | items: { 307 | type: "string", 308 | optional: true, 309 | convert: true 310 | } 311 | }, 312 | phones: { 313 | type: "array", 314 | optional: true, 315 | items: { 316 | type: "object", 317 | optional: true, 318 | strict: "remove", 319 | properties: { 320 | type: { type: "string", optional: true, convert: true }, 321 | number: { type: "string", convert: true }, 322 | primary: { 323 | type: "boolean", 324 | convert: true, 325 | optional: true, 326 | default: false 327 | } 328 | } 329 | } 330 | }, 331 | settings: { 332 | type: "object", 333 | optional: true, 334 | default: {} 335 | }, 336 | bio: { type: "any", optional: true }, 337 | status: { type: "string", default: "A", optional: true, convert: true } 338 | }); 339 | }); 340 | }); 341 | -------------------------------------------------------------------------------- /test/unit/utils.spec.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const utils = require("../../src/utils"); 4 | 5 | describe("Test Utils", () => { 6 | describe("Test flatten method", () => { 7 | it("should flatten objects", async () => { 8 | const obj = { 9 | name: "John Doe", 10 | email: "john.doe@moleculer.services", 11 | address: { 12 | zip: "1234", 13 | street: "Main Street 15", 14 | city: "London", 15 | country: "England", 16 | extra: "some" 17 | }, 18 | roles: ["admin", 1234], 19 | phones: [ 20 | { type: "home", number: "+1-555-1234", primary: true }, 21 | { type: "mobile", number: "+1-555-9999" } 22 | ] 23 | }; 24 | 25 | expect(utils.flatten(obj)).toStrictEqual({ 26 | name: "John Doe", 27 | email: "john.doe@moleculer.services", 28 | "address.city": "London", 29 | "address.country": "England", 30 | "address.extra": "some", 31 | "address.street": "Main Street 15", 32 | "address.zip": "1234", 33 | "phones.0.number": "+1-555-1234", 34 | "phones.0.primary": true, 35 | "phones.0.type": "home", 36 | "phones.1.number": "+1-555-9999", 37 | "phones.1.type": "mobile", 38 | "roles.0": "admin", 39 | "roles.1": 1234 40 | }); 41 | }); 42 | }); 43 | }); 44 | --------------------------------------------------------------------------------