├── .env ├── .github └── workflows │ ├── lint.yml │ ├── nodejs.yml │ ├── release.yml │ └── typedoc.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc.json ├── .release-please-manifest.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── adonis-typings ├── container.ts ├── database.ts ├── decorators.ts ├── index.ts ├── migration.ts ├── objectid.ts ├── odm.ts └── transaction.ts ├── commands ├── MongodbListMigrations.ts ├── MongodbMakeMigration.ts ├── MongodbMigrate.ts ├── index.ts └── util │ ├── MigrationCommand.ts │ ├── __tests__ │ └── transformMigrations.test.ts │ └── transformMigrations.ts ├── docker-compose.yml ├── eslint.config.mjs ├── instructions.md ├── jest.config.js ├── package.json ├── providers └── MongodbProvider.ts ├── release-please-config.json ├── reset-dev.mjs ├── src ├── .npmignore ├── Auth │ └── MongodbModelAuthProvider.ts ├── Database │ ├── Connection.ts │ ├── ConnectionManager.ts │ ├── Database.ts │ └── TransactionEventEmitter.ts ├── Migration.ts ├── Model │ ├── Model.ts │ ├── __tests__ │ │ ├── Model.query.test.ts │ │ ├── Model.test.ts │ │ └── __snapshots__ │ │ │ └── Model.test.ts.snap │ └── proxyHandler.ts ├── Odm │ └── decorators.ts └── __tests__ │ ├── Connection.test.ts │ ├── ConnectionManager.test.ts │ ├── Database.test.ts │ └── Migration.test.ts ├── templates ├── migration.txt └── mongodb.txt ├── test-utils ├── TestUtils.ts └── contracts.ts ├── tsconfig.json └── tsconfig.prod.json /.env: -------------------------------------------------------------------------------- 1 | MONGO_VERSION=8.0 2 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | nodejs: 11 | # Documentation: https://github.com/zakodium/workflows#nodejs-ci 12 | uses: zakodium/workflows/.github/workflows/nodejs.yml@nodejs-v1 13 | with: 14 | lint-check-types: true 15 | disable-tests: true 16 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | test: 11 | timeout-minutes: 10 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | node-version: [18.x, 20.x, 22.x, 23.x] 16 | mongo-version: ['5.0', '6.0', '7.0', '8.0'] 17 | fail-fast: false 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | - name: Use MongoDB ${{ matrix.mongo-version }} 25 | run: echo "MONGO_VERSION=${{ matrix.mongo-version }}" > .env 26 | - name: Init docker 27 | run: docker compose up -d 28 | - name: Install dependencies 29 | run: npm install 30 | - name: Initialize MongoDB 31 | run: node reset-dev.mjs 32 | - name: Run tests 33 | run: npm run test-only 34 | - name: Send coverage report to Codecov 35 | uses: codecov/codecov-action@v4 36 | - name: Teardown docker 37 | run: docker compose down 38 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | release: 10 | # Documentation: https://github.com/zakodium/workflows#release 11 | uses: zakodium/workflows/.github/workflows/release.yml@release-v1 12 | with: 13 | npm: true 14 | public: true 15 | release-type: '' 16 | secrets: 17 | github-token: ${{ secrets.BOT_TOKEN }} 18 | npm-token: ${{ secrets.NPM_BOT_TOKEN }} 19 | -------------------------------------------------------------------------------- /.github/workflows/typedoc.yml: -------------------------------------------------------------------------------- 1 | name: Deploy TypeDoc on GitHub pages 2 | 3 | on: 4 | workflow_dispatch: 5 | release: 6 | types: [published] 7 | 8 | env: 9 | NODE_VERSION: 20.x 10 | ENTRY_FILE: 'adonis-typings/database.ts adonis-typings/migration.ts adonis-typings/odm.ts' 11 | 12 | jobs: 13 | deploy: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: ${{ env.NODE_VERSION }} 20 | - name: Install dependencies 21 | run: npm install 22 | - name: Build documentation 23 | uses: zakodium/typedoc-action@v2 24 | with: 25 | entry: ${{ env.ENTRY_FILE }} 26 | - name: Deploy to GitHub pages 27 | uses: JamesIves/github-pages-deploy-action@releases/v4 28 | with: 29 | token: ${{ secrets.BOT_TOKEN }} 30 | branch: gh-pages 31 | folder: docs 32 | clean: true 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # parcel-bundler cache (https://parceljs.org/) 58 | .cache 59 | 60 | # next.js build output 61 | .next 62 | 63 | # nuxt.js build output 64 | .nuxt 65 | 66 | # vuepress build output 67 | .vuepress/dist 68 | 69 | # Serverless directories 70 | .serverless 71 | 72 | # FuseBox cache 73 | .fusebox/ 74 | 75 | lib 76 | docs 77 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /lib 3 | /CHANGELOG.md 4 | /coverage 5 | /.release-please-manifest.json 6 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "semi": true, 4 | "singleQuote": true, 5 | "tabWidth": 2, 6 | "trailingComma": "all" 7 | } 8 | -------------------------------------------------------------------------------- /.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | {".":"0.20.2"} -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.20.2](https://github.com/zakodium/adonis-mongodb/compare/v0.20.1...v0.20.2) (2025-01-15) 4 | 5 | 6 | ### Bug Fixes 7 | 8 | * add missing triple-slash reference for transaction types ([#171](https://github.com/zakodium/adonis-mongodb/issues/171)) ([640eb39](https://github.com/zakodium/adonis-mongodb/commit/640eb39e2c75c4a68992dc36537e667b17680a14)) 9 | 10 | ## [0.20.1](https://github.com/zakodium/adonis-mongodb/compare/v0.20.0...v0.20.1) (2025-01-15) 11 | 12 | 13 | ### Bug Fixes 14 | 15 | * preserve TS triple-slash references ([#169](https://github.com/zakodium/adonis-mongodb/issues/169)) ([5c01a0e](https://github.com/zakodium/adonis-mongodb/commit/5c01a0e1118d3d7f035d59f676c02855be896dca)) 16 | 17 | ## [0.20.0](https://github.com/zakodium/adonis-mongodb/compare/v0.19.0...v0.20.0) (2025-01-15) 18 | 19 | 20 | ### ⚠ BREAKING CHANGES 21 | 22 | * remove support for EoL Node.js 16 23 | 24 | ### Features 25 | 26 | * add `dropCollection` migration method ([#168](https://github.com/zakodium/adonis-mongodb/issues/168)) ([2928b95](https://github.com/zakodium/adonis-mongodb/commit/2928b95fd84dcb956e91a2520bcefd76279a5836)) 27 | 28 | 29 | ### Miscellaneous Chores 30 | 31 | * remove support for EoL Node.js 16 ([d3bcf9b](https://github.com/zakodium/adonis-mongodb/commit/d3bcf9b2155e1bee88f063ae50b83ae208d6c5ad)) 32 | 33 | ## [0.19.0](https://github.com/zakodium/adonis-mongodb/compare/v0.18.1...v0.19.0) (2024-06-17) 34 | 35 | 36 | ### Features 37 | 38 | * **Connection:** add an observer api on transaction ([#160](https://github.com/zakodium/adonis-mongodb/issues/160)) ([721fe35](https://github.com/zakodium/adonis-mongodb/commit/721fe354103fc53277d9fef1c92e5835fd3a22b8)) 39 | 40 | ## [0.18.1](https://github.com/zakodium/adonis-mongodb/compare/v0.18.0...v0.18.1) (2023-11-10) 41 | 42 | 43 | ### Bug Fixes 44 | 45 | * export `@computed` in typings and bound in Provider ([#155](https://github.com/zakodium/adonis-mongodb/issues/155)) ([3d136b9](https://github.com/zakodium/adonis-mongodb/commit/3d136b9c477d4cee9968e4a7f219573fbf60eb70)) 46 | 47 | ## [0.18.0](https://github.com/zakodium/adonis-mongodb/compare/v0.17.0...v0.18.0) (2023-11-09) 48 | 49 | 50 | ### Features 51 | 52 | * support `@computed` decorator ([#153](https://github.com/zakodium/adonis-mongodb/issues/153)) ([412af03](https://github.com/zakodium/adonis-mongodb/commit/412af036b3251c0a115ff7f2264ad853a7552f03)) 53 | 54 | ## [0.17.0](https://github.com/zakodium/adonis-mongodb/compare/v0.16.0...v0.17.0) (2023-09-22) 55 | 56 | 57 | ### Features 58 | 59 | * add `useTransaction` to BaseModel ([#151](https://github.com/zakodium/adonis-mongodb/issues/151)) ([92fa525](https://github.com/zakodium/adonis-mongodb/commit/92fa5250a445117f0d3d87bb825a6b42d4839bef)) 60 | * add `transaction` shortcut on Database 61 | * add support for transaction options on Connection 62 | 63 | ## [0.16.0](https://github.com/zakodium/adonis-mongodb/compare/v0.15.0...v0.16.0) (2023-09-19) 64 | 65 | 66 | ### ⚠ BREAKING CHANGES 67 | 68 | * `mongodb` was updated to v6. See the changelog at https://github.com/mongodb/node-mongodb-native/releases/tag/v6.0.0 69 | 70 | ### Features 71 | 72 | * update dependencies ([#149](https://github.com/zakodium/adonis-mongodb/issues/149)) ([546051d](https://github.com/zakodium/adonis-mongodb/commit/546051dbdc5d29132e34f9dfdb6034ea637d8b2c)) 73 | 74 | ## [0.15.0](https://github.com/zakodium/adonis-mongodb/compare/v0.14.4...v0.15.0) (2023-05-22) 75 | 76 | 77 | ### Features 78 | 79 | * add `afterUpSuccess` method to migrations ([8e70e59](https://github.com/zakodium/adonis-mongodb/commit/8e70e593c99050ef10b91b771c00e005f78d3ebe)) 80 | 81 | ## [0.14.4](https://github.com/zakodium/adonis-mongodb/compare/v0.14.3...v0.14.4) (2023-05-08) 82 | 83 | 84 | ### Bug Fixes 85 | 86 | * publish on npm and GPR ([51c6370](https://github.com/zakodium/adonis-mongodb/commit/51c63704c3927de6d6efbfdd16f6934e11c64e2b)) 87 | 88 | ## [0.14.3](https://github.com/zakodium/adonis-mongodb/compare/v0.14.2...v0.14.3) (2023-03-07) 89 | 90 | 91 | ### Bug Fixes 92 | 93 | * set exit code to 1 if migration failed ([#140](https://github.com/zakodium/adonis-mongodb/issues/140)) ([f07017c](https://github.com/zakodium/adonis-mongodb/commit/f07017ca95cd87795dfc815ccbb8377f9884e94b)) 94 | 95 | ## [0.14.2](https://github.com/zakodium/adonis-mongodb/compare/v0.14.1...v0.14.2) (2023-03-07) 96 | 97 | 98 | ### Bug Fixes 99 | 100 | * find highest batch correctly ([#138](https://github.com/zakodium/adonis-mongodb/issues/138)) ([cffa673](https://github.com/zakodium/adonis-mongodb/commit/cffa6739cd2a5fb8f6da093643c975cc2141d7ee)) 101 | 102 | ## [0.14.1](https://github.com/zakodium/adonis-mongodb/compare/v0.14.0...v0.14.1) (2023-03-06) 103 | 104 | 105 | ### Bug Fixes 106 | 107 | * add "override" to migration template ([#132](https://github.com/zakodium/adonis-mongodb/issues/132)) ([d8f8dc7](https://github.com/zakodium/adonis-mongodb/commit/d8f8dc73efc0afd0a0e538052c4fec7ca9094527)) 108 | * ignore type declaration files in migrations directory ([#137](https://github.com/zakodium/adonis-mongodb/issues/137)) ([b7eda71](https://github.com/zakodium/adonis-mongodb/commit/b7eda718256c295ca3117f6ba16ef87db6e56c3c)) 109 | 110 | ## [0.14.0](https://github.com/zakodium/adonis-mongodb/compare/v0.13.0...v0.14.0) (2023-02-13) 111 | 112 | 113 | ### ⚠ BREAKING CHANGES 114 | 115 | * Drop support for Node.js 14.x and MongoDB 4.x 116 | 117 | ### Miscellaneous Chores 118 | 119 | * update dependencies ([#129](https://github.com/zakodium/adonis-mongodb/issues/129)) ([9be758c](https://github.com/zakodium/adonis-mongodb/commit/9be758ca4b536467bf2cbdcefda24bd7e804372e)) 120 | 121 | ## [0.13.0](https://www.github.com/zakodium/adonis-mongodb/compare/v0.12.0...v0.13.0) (2021-11-22) 122 | 123 | 124 | ### Features 125 | 126 | * **migrations:** add dropIndex method ([16652d8](https://www.github.com/zakodium/adonis-mongodb/commit/16652d8c136758f9a8758fe3f0bc97917fcbf5fc)) 127 | 128 | ## [0.12.0](https://www.github.com/zakodium/adonis-mongodb/compare/v0.11.0...v0.12.0) (2021-09-23) 129 | 130 | 131 | ### Features 132 | 133 | * implement `model.$original` and `model.$attributes` ([952a139](https://www.github.com/zakodium/adonis-mongodb/commit/952a13904160b5cc163e8555f1c170182b766d44)) 134 | * improve custom inspect output ([7fb3a3e](https://www.github.com/zakodium/adonis-mongodb/commit/7fb3a3e7544e8e455437839e8f0d406acda2cbd2)) 135 | 136 | 137 | ### Bug Fixes 138 | 139 | * throw an error when user attempts to spread a model ([ffacaca](https://www.github.com/zakodium/adonis-mongodb/commit/ffacacad0c598cff59c4a63eaebdddb1cf967592)) 140 | 141 | ## [0.11.0](https://www.github.com/zakodium/adonis-mongodb/compare/v0.10.6...v0.11.0) (2021-09-16) 142 | 143 | 144 | ### ⚠ BREAKING CHANGES 145 | 146 | * It is no longer possible to pass explain to the query() method's driver options. Use the new `explain` method instead. 147 | * It is no longer possible to pass sort, skip or limit to the query() method's driver options. Use the new `sort`, `sortBy`, `skip` and `limit` methods instead. 148 | 149 | ### Features 150 | 151 | * add explain query method ([42491b5](https://www.github.com/zakodium/adonis-mongodb/commit/42491b59106aaf9748ead305cbfbda3b09acd068)) 152 | * add sort, sortBy, skip and limit query methods ([29cc49c](https://www.github.com/zakodium/adonis-mongodb/commit/29cc49c2011b4f5717674523d282d83e7c6e644c)) 153 | * implement $isPersisted, $isNew and $isLocal model properties ([4c7b36e](https://www.github.com/zakodium/adonis-mongodb/commit/4c7b36eb054163c7a7b78eb6dd63e5bea9b7e7cf)) 154 | 155 | ### [0.10.6](https://www.github.com/zakodium/adonis-mongodb/compare/v0.10.5...v0.10.6) (2021-09-01) 156 | 157 | 158 | ### Bug Fixes 159 | 160 | * correct types of MongodbModelAuthProvider ([#104](https://www.github.com/zakodium/adonis-mongodb/issues/104)) ([e913e53](https://www.github.com/zakodium/adonis-mongodb/commit/e913e5314ae01a2e53e5a60ae41bc86a1241fa7d)) 161 | 162 | ### [0.10.5](https://www.github.com/zakodium/adonis-mongodb/compare/v0.10.4...v0.10.5) (2021-09-01) 163 | 164 | 165 | ### Bug Fixes 166 | 167 | * update mongodb client to v4.1.1 ([#102](https://www.github.com/zakodium/adonis-mongodb/issues/102)) ([32edca2](https://www.github.com/zakodium/adonis-mongodb/commit/32edca2831d2eb21f77e312c3f2eaccf2bd64e71)) 168 | 169 | ### [0.10.4](https://www.github.com/zakodium/adonis-mongodb/compare/v0.10.3...v0.10.4) (2021-07-20) 170 | 171 | 172 | ### Bug Fixes 173 | 174 | * **typings:** make query filter optional ([#94](https://www.github.com/zakodium/adonis-mongodb/issues/94)) ([fc1c6f6](https://www.github.com/zakodium/adonis-mongodb/commit/fc1c6f61586fee88d02318375960ceefbba1bf07)) 175 | 176 | ### [0.10.3](https://www.github.com/zakodium/adonis-mongodb/compare/v0.10.2...v0.10.3) (2021-07-19) 177 | 178 | 179 | ### Bug Fixes 180 | 181 | * include src in distribution ([4fd53d4](https://www.github.com/zakodium/adonis-mongodb/commit/4fd53d414cd0353164d6cb9d3c23948f3ea7a72c)) 182 | 183 | ### [0.10.2](https://www.github.com/zakodium/adonis-mongodb/compare/v0.10.1...v0.10.2) (2021-07-15) 184 | 185 | 186 | ### Bug Fixes 187 | 188 | * correctly compute ModelAttributes type ([af9d775](https://www.github.com/zakodium/adonis-mongodb/commit/af9d775df7f1e655f27eeaf5e91ef8705d52a623)) 189 | * make `id` reference the type of `_id` ([8148481](https://www.github.com/zakodium/adonis-mongodb/commit/8148481b230e944aad5c99505a14ba496f9c4c41)) 190 | 191 | ### [0.10.1](https://www.github.com/zakodium/adonis-mongodb/compare/v0.10.0...v0.10.1) (2021-07-14) 192 | 193 | 194 | ### Bug Fixes 195 | 196 | * update mongodb to 4.0.0 and test on MongDB 5.0 too ([#87](https://www.github.com/zakodium/adonis-mongodb/issues/87)) ([d57f0d8](https://www.github.com/zakodium/adonis-mongodb/commit/d57f0d8030db56f6425a67940fd2b8f28f4203f5)) 197 | 198 | ## [0.10.0](https://www.github.com/zakodium/adonis-mongodb/compare/v0.9.1...v0.10.0) (2021-07-14) 199 | 200 | 201 | ### ⚠ BREAKING CHANGES 202 | 203 | * move ObjectId export to Zakodium/Mongodb/Odm 204 | * rename isDirty to $isDirty 205 | * move mongodb driver options to options.driverOptions 206 | * The model API has been reworked to be closer to Lucid's. 207 | * move count() method out of find result, make find() method return synchronously 208 | * The MongoDB driver has been upgraded to version 4. Types are now included and many have changed. 209 | * rename Model binding to Odm, and rename Model to BaseModel 210 | 211 | ### Features 212 | 213 | * add model boot method and start working on field decorator ([18d41c9](https://www.github.com/zakodium/adonis-mongodb/commit/18d41c98afdfa6206d7f90f50ba5b520d3e32cf5)) 214 | * add model.$dirty ([d218456](https://www.github.com/zakodium/adonis-mongodb/commit/d218456adbbb7cb492b01184c3b3f9a17922a23d)) 215 | * allow to change the connection used in Model.getCollection ([16c4180](https://www.github.com/zakodium/adonis-mongodb/commit/16c4180f8357420eed7f55f17c5b4d5d4ebd34aa)) 216 | * expose model.$isDeleted ([39f01d3](https://www.github.com/zakodium/adonis-mongodb/commit/39f01d364ab4067227fb3e98f9edbe6f7f84df6d)) 217 | * make query filter optional, add query.count and query.distinct ([d7a65b1](https://www.github.com/zakodium/adonis-mongodb/commit/d7a65b17fa1577c21430762d0e4820c93e7b3839)) 218 | * rename Model binding to Odm, and rename Model to BaseModel ([0fa9da6](https://www.github.com/zakodium/adonis-mongodb/commit/0fa9da614ee9d2050dee380ade1ca0b4bc95abf0)) 219 | * sort find results by descending id by default ([d9162b3](https://www.github.com/zakodium/adonis-mongodb/commit/d9162b3cd5110210148d5c22a4755c64c5a3766a)) 220 | * upgrade mongodb driver to version 4 ([1e2e403](https://www.github.com/zakodium/adonis-mongodb/commit/1e2e4038fe447b5f9267379c2aa70b740b81afa4)) 221 | 222 | 223 | ### Bug Fixes 224 | 225 | * correct types and suppress unavoidable anys ([7d95cee](https://www.github.com/zakodium/adonis-mongodb/commit/7d95cee7bb88defdd3090a3bcae9aaf0648c993f)) 226 | 227 | 228 | ### Code Refactoring 229 | 230 | * move count() method out of find result, make find() method return synchronously ([cf07ae7](https://www.github.com/zakodium/adonis-mongodb/commit/cf07ae739cacb5e1d8a547b9a6a76cdc38129200)) 231 | * move mongodb driver options to options.driverOptions ([edb0587](https://www.github.com/zakodium/adonis-mongodb/commit/edb05871d375db2663d84381654505468792f64d)) 232 | * move ObjectId export to Zakodium/Mongodb/Odm ([093eef3](https://www.github.com/zakodium/adonis-mongodb/commit/093eef34799544aedc929837690409fbdbe38582)) 233 | * rename isDirty to $isDirty ([2a3bc6a](https://www.github.com/zakodium/adonis-mongodb/commit/2a3bc6aaa5005fa90e9cf6479d33ec166c98fa5b)) 234 | * rework model API ([4092ca6](https://www.github.com/zakodium/adonis-mongodb/commit/4092ca630367d43064aa4e1c340c3a062950e828)) 235 | 236 | ### [0.9.1](https://www.github.com/zakodium/adonis-mongodb/compare/v0.9.0...v0.9.1) (2021-07-06) 237 | 238 | 239 | ### Bug Fixes 240 | 241 | * implement workaround to allow closing and reopening connections ([48d3ad5](https://www.github.com/zakodium/adonis-mongodb/commit/48d3ad5634e6736e97fdd43309d8c284158b4ef5)) 242 | 243 | ## [0.9.0](https://www.github.com/zakodium/adonis-mongodb/compare/v0.8.0...v0.9.0) (2021-06-30) 244 | 245 | 246 | ### ⚠ BREAKING CHANGES 247 | 248 | * implement Connection manager 249 | * rename DatabaseContract and add primaryConnectionName 250 | * rename "default" config field to "connection" 251 | * add Zakodium/ prefix to IoC binding names 252 | 253 | ### Features 254 | 255 | * expose container binding types ([de33f9e](https://www.github.com/zakodium/adonis-mongodb/commit/de33f9edb5fa2bcabfc552e6bb37fbfa5e5eee8a)) 256 | * implement Database.connection method ([91b0686](https://www.github.com/zakodium/adonis-mongodb/commit/91b0686fa218c1c8f26b932c05ba5df8aa02c075)) 257 | 258 | 259 | ### Bug Fixes 260 | 261 | * treat model instances created from iterator as already saved ([#67](https://www.github.com/zakodium/adonis-mongodb/issues/67)) ([57474a9](https://www.github.com/zakodium/adonis-mongodb/commit/57474a96cd552a1a0c561361790ca0b20a06c136)) 262 | 263 | 264 | ### Code Refactoring 265 | 266 | * add Zakodium/ prefix to IoC binding names ([966a7a1](https://www.github.com/zakodium/adonis-mongodb/commit/966a7a10fd6b64ce583e70c1ddf7a048943e0f78)) 267 | * implement Connection manager ([749ccca](https://www.github.com/zakodium/adonis-mongodb/commit/749ccca1dc414a9f2a0b96c8eadcae679d93349c)) 268 | * rename "default" config field to "connection" ([bcfda31](https://www.github.com/zakodium/adonis-mongodb/commit/bcfda3151fb41cdc58ef7bef7ccf89772e9fa237)) 269 | * rename DatabaseContract and add primaryConnectionName ([5a1a914](https://www.github.com/zakodium/adonis-mongodb/commit/5a1a9148681946aad5715d2f5b79086b1bebf91e)) 270 | 271 | ## [0.8.0](https://www.github.com/zakodium/adonis-mongodb/compare/v0.7.0...v0.8.0) (2021-06-15) 272 | 273 | 274 | ### Features 275 | 276 | * do not cancel successful migrations ([#65](https://www.github.com/zakodium/adonis-mongodb/issues/65)) ([0b4fb92](https://www.github.com/zakodium/adonis-mongodb/commit/0b4fb928798e19c831f86ee1d26d59d5473fac75)) 277 | 278 | ## [0.7.0](https://www.github.com/zakodium/adonis-mongodb/compare/v0.6.0...v0.7.0) (2021-04-27) 279 | 280 | 281 | ### ⚠ BREAKING CHANGES 282 | 283 | * The module now depends on @adonisjs/auth v8 284 | 285 | ### Features 286 | 287 | * bump @adonisjs/auth to version 8 ([#60](https://www.github.com/zakodium/adonis-mongodb/issues/60)) ([de23012](https://www.github.com/zakodium/adonis-mongodb/commit/de230126e50637516363302e0e28bf7d32ba0a44)) 288 | 289 | ## [0.6.0](https://www.github.com/zakodium/adonis-mongodb/compare/v0.5.0...v0.6.0) (2021-03-23) 290 | 291 | 292 | ### Bug Fixes 293 | 294 | * add toJSON method type ([#56](https://www.github.com/zakodium/adonis-mongodb/issues/56)) ([a570ac0](https://www.github.com/zakodium/adonis-mongodb/commit/a570ac00eefa9a35c46d1e74b719cd331cd5d0b5)) 295 | * update dependencies ([f52991e](https://www.github.com/zakodium/adonis-mongodb/commit/f52991edd2597e38d2e6c59b5fd4015fd856b00b)) 296 | 297 | ## [0.5.0](https://www.github.com/zakodium/adonis-mongodb/compare/v0.4.1...v0.5.0) (2021-03-15) 298 | 299 | 300 | ### Features 301 | 302 | * add a toJSON method on Models ([#54](https://www.github.com/zakodium/adonis-mongodb/issues/54)) ([1f0c199](https://www.github.com/zakodium/adonis-mongodb/commit/1f0c199cc3ba89b61b81b1f3af58fa3acefd9c9c)) 303 | 304 | ### [0.4.1](https://www.github.com/zakodium/adonis-mongodb/compare/v0.4.0...v0.4.1) (2021-03-04) 305 | 306 | 307 | ### Bug Fixes 308 | 309 | * do not put templates in subdirectories ([a7e2a34](https://www.github.com/zakodium/adonis-mongodb/commit/a7e2a34dd968ffb1bf72db27225b25f1535a9070)) 310 | 311 | ## [0.4.0](https://www.github.com/zakodium/adonis-mongodb/compare/v0.3.6...v0.4.0) (2021-02-23) 312 | 313 | 314 | ### Features 315 | 316 | * add authentication provider using model ([8fb56a7](https://www.github.com/zakodium/adonis-mongodb/commit/8fb56a7d0284f044d02341125565d336289047c7)) 317 | 318 | 319 | ### Bug Fixes 320 | 321 | * abort migration transaction in case of error ([#47](https://www.github.com/zakodium/adonis-mongodb/issues/47)) ([8a46ef1](https://www.github.com/zakodium/adonis-mongodb/commit/8a46ef14c62edbae9d2c7acacb538ca2f4dee0b8)) 322 | * correctly handle already running migrations in migrate command ([b6efc7c](https://www.github.com/zakodium/adonis-mongodb/commit/b6efc7ca0d092c144a571882b6593ebf7b6241b2)) 323 | 324 | ### [0.3.6](https://www.github.com/zakodium/adonis-mongodb/compare/v0.3.5...v0.3.6) (2021-01-08) 325 | 326 | 327 | ### Bug Fixes 328 | 329 | * remove peer dependency on adonis core ([fa35ba6](https://www.github.com/zakodium/adonis-mongodb/commit/fa35ba6a7149474d3a63df6abbf0b568565ce91b)) 330 | 331 | ### [0.3.5](https://www.github.com/zakodium/adonis-mongodb/compare/v0.3.4...v0.3.5) (2020-11-02) 332 | 333 | 334 | ### Bug Fixes 335 | 336 | * fix types ([#37](https://www.github.com/zakodium/adonis-mongodb/issues/37)) ([d66ff32](https://www.github.com/zakodium/adonis-mongodb/commit/d66ff3237b18b5cdaa81ceba3272520bdc0cbd75)), closes [#5](https://www.github.com/zakodium/adonis-mongodb/issues/5) 337 | 338 | ### [0.3.4](https://www.github.com/zakodium/adonis-mongodb/compare/v0.3.3...v0.3.4) (2020-10-14) 339 | 340 | 341 | ### Bug Fixes 342 | 343 | * in-memory typescript keeps migrations as TS files ([#36](https://www.github.com/zakodium/adonis-mongodb/issues/36)) ([7f4fd20](https://www.github.com/zakodium/adonis-mongodb/commit/7f4fd20e4df965ed3eab9407065506ef9721c638)) 344 | * rename handle with run ([#34](https://www.github.com/zakodium/adonis-mongodb/issues/34)) ([70c1b53](https://www.github.com/zakodium/adonis-mongodb/commit/70c1b53428f4902ca61036c14dcaf3f21db5f665)) 345 | 346 | ### [0.3.3](https://www.github.com/zakodium/adonis-mongodb/compare/v0.3.2...v0.3.3) (2020-10-14) 347 | 348 | 349 | ### Bug Fixes 350 | 351 | * **migration:** correctly extract name from migrations and check for dups ([7c6dec1](https://www.github.com/zakodium/adonis-mongodb/commit/7c6dec1942c0f22096ad603b63f39451dc13ae5b)) 352 | 353 | ### [0.3.2](https://github.com/zakodium/adonis-mongodb/compare/v0.3.1...v0.3.2) (2020-10-12) 354 | 355 | 356 | ### Bug Fixes 357 | 358 | * allow custom IDs ([#26](https://github.com/zakodium/adonis-mongodb/issues/26)) ([7cd80c9](https://github.com/zakodium/adonis-mongodb/commit/7cd80c98a43866be4d97e00a32c7fe22851647e5)) 359 | * fix merge and fill method typings ([#28](https://github.com/zakodium/adonis-mongodb/issues/28)) ([97cf4db](https://github.com/zakodium/adonis-mongodb/commit/97cf4dbd3783590ac004929aca81d5677dc2cd6f)) 360 | 361 | ## [0.3.1](https://github.com/zakodium/adonis-mongodb/compare/v0.3.0...v0.3.1) (2020-10-07) 362 | 363 | 364 | ### Features 365 | 366 | * add merge and fill methods ([#23](https://github.com/zakodium/adonis-mongodb/issues/23)) ([0b9d3ef](https://github.com/zakodium/adonis-mongodb/commit/0b9d3ef80111b28010efaf24708415329fa4194b)) 367 | * support instantiating models before saving ([#17](https://github.com/zakodium/adonis-mongodb/issues/17)) ([25d194a](https://github.com/zakodium/adonis-mongodb/commit/25d194a26b7d19c1e498b46c79b6172bcb5e58f2)) 368 | 369 | 370 | 371 | # [0.3.0](https://github.com/zakodium/adonis-mongodb/compare/v0.2.2...v0.3.0) (2020-09-29) 372 | 373 | 374 | ### Features 375 | 376 | * migrations paths can be configured in the config file ([#8](https://github.com/zakodium/adonis-mongodb/issues/8)) ([fb8934d](https://github.com/zakodium/adonis-mongodb/commit/fb8934d3a6e1ac7a334bcf244c5b3ed0ef1c9dd6)) 377 | * pass session on object instantiation ([#16](https://github.com/zakodium/adonis-mongodb/issues/16)) ([1395ba0](https://github.com/zakodium/adonis-mongodb/commit/1395ba0ac095a36818f84557afe7fce17c6caf25)) 378 | 379 | 380 | 381 | ## [0.2.2](https://github.com/zakodium/adonis-mongodb/compare/v0.2.1...v0.2.2) (2020-09-09) 382 | 383 | 384 | ### Bug Fixes 385 | 386 | * correct incremental id in AutoIncrementModel ([8a20201](https://github.com/zakodium/adonis-mongodb/commit/8a20201c1d86618c2f068304c2b109b5a86a33d6)) 387 | * do not create a config subfolder ([#4](https://github.com/zakodium/adonis-mongodb/issues/4)) ([a86e79b](https://github.com/zakodium/adonis-mongodb/commit/a86e79b4df34b97084e23204423d012e393432d0)) 388 | * show accurate information in status command ([6580db9](https://github.com/zakodium/adonis-mongodb/commit/6580db92bfa7a4c752cf39c2c084ad2d8b67b500)) 389 | 390 | 391 | 392 | ## [0.2.1](https://github.com/zakodium/adonis-mongodb/compare/v0.2.0...v0.2.1) (2020-09-02) 393 | 394 | 395 | 396 | # [0.2.0](https://github.com/zakodium/adonis-mongodb/compare/v0.1.7...v0.2.0) (2020-09-02) 397 | 398 | 399 | ### Bug Fixes 400 | 401 | * correct migration batch number ([66af888](https://github.com/zakodium/adonis-mongodb/commit/66af8882011ec0b14e7567d66231ab14f4b7f50e)) 402 | * don't log description twice ([923048f](https://github.com/zakodium/adonis-mongodb/commit/923048f0963d1dc5f80c1dc9cca7760331a6bcea)) 403 | * only use transaction when creating indexes if collection does not exist ([94fa3fb](https://github.com/zakodium/adonis-mongodb/commit/94fa3fb7b69cf079f372f51813a5dbaf08b0bde0)) 404 | * use original type on id getter ([78317c1](https://github.com/zakodium/adonis-mongodb/commit/78317c12ea25c624e85b7deb094966c1e2f852c7)) 405 | 406 | 407 | ### Features 408 | 409 | * add command show migration status ([0ef66d2](https://github.com/zakodium/adonis-mongodb/commit/0ef66d2a31e5c9782f80383dd48ec72276b4eac1)) 410 | * add defer method to migration module ([ff7c60a](https://github.com/zakodium/adonis-mongodb/commit/ff7c60a89d0c92cedaba4c4e918fcfab6ee3e0a6)) 411 | * add incremental model ([e7574f6](https://github.com/zakodium/adonis-mongodb/commit/e7574f6bcd2b3840f1cd3c8f6d195d3ccd781e64)) 412 | * allow to add description to migration ([7c075e7](https://github.com/zakodium/adonis-mongodb/commit/7c075e77dde28a2c3337b27e7abbc7833a6af793)) 413 | * execute all pending migrations in one transaction ([1581854](https://github.com/zakodium/adonis-mongodb/commit/1581854a4b95dd285d6f3ac86002cf293511b2da)) 414 | 415 | 416 | * rename migrate command ([c6ce51b](https://github.com/zakodium/adonis-mongodb/commit/c6ce51bb281b408d3a6afde4ae2245ad96f6c5b9)) 417 | 418 | 419 | ### BREAKING CHANGES 420 | 421 | * do not convert to string in id getter 422 | * Model is no longer a default export but a named export 423 | * renamed the migrate command to match how lucid names migration commands 424 | 425 | 426 | 427 | ## [0.1.7](https://github.com/zakodium/adonis-mongodb/compare/v0.1.6...v0.1.7) (2020-04-14) 428 | 429 | 430 | 431 | ## [0.1.6](https://github.com/zakodium/adonis-mongodb/compare/v0.1.5...v0.1.6) (2020-01-13) 432 | 433 | 434 | ### Bug Fixes 435 | 436 | * skip lib checks ([7fd8507](https://github.com/zakodium/adonis-mongodb/commit/7fd8507c85c45c2c2bdbe1e6ac9be5b0114dc233)) 437 | * **commands:** inject db in handle method ([303fdf1](https://github.com/zakodium/adonis-mongodb/commit/303fdf17b6381050859380ba473ebfab49903528)) 438 | 439 | 440 | 441 | ## [0.1.5](https://github.com/zakodium/adonis-mongodb/compare/v0.1.4...v0.1.5) (2019-12-06) 442 | 443 | 444 | ### Bug Fixes 445 | 446 | * actually execute the up() method ([3d8740f](https://github.com/zakodium/adonis-mongodb/commit/3d8740f4c380086818c5fe888d2bbeb1f01d4e8a)) 447 | 448 | 449 | 450 | ## [0.1.4](https://github.com/zakodium/adonis-mongodb/compare/v0.1.3...v0.1.4) (2019-12-03) 451 | 452 | 453 | ### Bug Fixes 454 | 455 | * enable emitDecoratorMetadata ([407554e](https://github.com/zakodium/adonis-mongodb/commit/407554e579197b52f16621ddd062668840407f07)) 456 | 457 | 458 | 459 | ## [0.1.3](https://github.com/zakodium/adonis-mongodb/compare/v0.1.2...v0.1.3) (2019-12-03) 460 | 461 | 462 | ### Bug Fixes 463 | 464 | * transpile optional properties ([d22d8d1](https://github.com/zakodium/adonis-mongodb/commit/d22d8d15981a33eb9c0928574e7f0c36e18a9c6b)) 465 | 466 | 467 | 468 | ## [0.1.2](https://github.com/zakodium/adonis-mongodb/compare/v0.1.1...v0.1.2) (2019-12-03) 469 | 470 | 471 | ### Bug Fixes 472 | 473 | * really correctly read templates ([ad4c812](https://github.com/zakodium/adonis-mongodb/commit/ad4c81217b8b51196aa8da72f11f35e7a0d02f02)) 474 | 475 | 476 | 477 | ## [0.1.1](https://github.com/zakodium/adonis-mongodb/compare/v0.1.0...v0.1.1) (2019-12-03) 478 | 479 | 480 | ### Bug Fixes 481 | 482 | * correctly refer to template directory ([dab86ad](https://github.com/zakodium/adonis-mongodb/commit/dab86ad199d5a7c9b9dc825035dad2875410b0d7)) 483 | 484 | 485 | 486 | # 0.1.0 (2019-12-03) 487 | 488 | 489 | ### Bug Fixes 490 | 491 | * rename types from .d.ts to .ts ([4a0cd71](https://github.com/zakodium/adonis-mongodb/commit/4a0cd7179e52fb49c28a49e9ac8781afc0f7335e)) 492 | 493 | 494 | ### Features 495 | 496 | * initial library ([6c917cf](https://github.com/zakodium/adonis-mongodb/commit/6c917cf8bb76c01ba02ed90036c293f0667f6d81)) 497 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 zakodium 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 | 23 | 24 | Parts of this software are copied or adapted from AdonisJS Lucid, 25 | hosted at https://github.com/adonisjs/lucid and licensed as follows: 26 | 27 | # The MIT License 28 | 29 | Copyright 2021 Harminder Virk, contributors 30 | 31 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 32 | 33 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 34 | 35 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Adonis MongoDB 2 | 3 | MongoDB provider for AdonisJS 5. 4 | 5 |

6 | 7 | 8 | Zakodium logo 9 | 10 | 11 |

12 | Maintained by Zakodium 13 |

14 | 15 | [![NPM version][npm-image]][npm-url] 16 | [![build status][ci-image]][ci-url] 17 | [![Test coverage][codecov-image]][codecov-url] 18 | [![npm download][download-image]][download-url] 19 | 20 | | :warning: This module is unstable and in active development. Use at your own risk. | 21 | | ---------------------------------------------------------------------------------- | 22 | 23 |

24 | 25 | ## Prerequisites 26 | 27 | This provider requires AdonisJS v5 and won't work with AdonisJS v4. 28 | 29 | We recommend using MongoDB >=5.0. Earlier versions are not tested. 30 | 31 | ## Installation 32 | 33 | ```console 34 | npm i @zakodium/adonis-mongodb 35 | node ace configure @zakodium/adonis-mongodb 36 | ``` 37 | 38 | ## Documentation 39 | 40 | ### Using with the authentication provider 41 | 42 | Adonis MongoDB can be used to authenticate users with the `@adonisjs/auth` addon. 43 | To enable it, edit the following files: 44 | 45 | #### `contracts/auth.ts` 46 | 47 | Example of a configuration with the session guard: 48 | 49 | ```ts 50 | import { 51 | MongodbModelAuthProviderContract, 52 | MongodbModelAuthProviderConfig, 53 | } from '@ioc:Zakodium/Mongodb/Odm'; 54 | 55 | import User from 'App/Models/User'; 56 | 57 | declare module '@ioc:Adonis/Addons/Auth' { 58 | interface ProvidersList { 59 | user: { 60 | implementation: MongodbModelAuthProviderContract; 61 | config: MongodbModelAuthProviderConfig; 62 | }; 63 | } 64 | 65 | interface GuardsList { 66 | web: { 67 | implementation: SessionGuardContract<'user', 'web'>; 68 | config: SessionGuardConfig<'user'>; 69 | }; 70 | } 71 | } 72 | ``` 73 | 74 | #### `config/auth.ts` 75 | 76 | ```ts 77 | import { AuthConfig } from '@ioc:Adonis/Addons/Auth'; 78 | 79 | const authConfig: AuthConfig = { 80 | guard: 'web', 81 | guards: { 82 | web: { 83 | driver: 'session', 84 | provider: { 85 | driver: 'mongodb-model', 86 | }, 87 | }, 88 | }, 89 | }; 90 | 91 | export default authConfig; 92 | ``` 93 | 94 | ## Development 95 | 96 | To run tests locally: 97 | 98 | ```bash 99 | docker compose up -d 100 | node reset-dev.mjs 101 | npm test 102 | docker compose down 103 | ``` 104 | 105 | ## License 106 | 107 | [MIT](./LICENSE) 108 | 109 | [npm-image]: https://img.shields.io/npm/v/@zakodium/adonis-mongodb.svg 110 | [npm-url]: https://www.npmjs.com/package/@zakodium/adonis-mongodb 111 | [ci-image]: https://github.com/zakodium/adonis-mongodb/workflows/Node.js%20CI/badge.svg?branch=main 112 | [ci-url]: https://github.com/zakodium/adonis-mongodb/actions?query=workflow%3A%22Node.js+CI%22 113 | [codecov-image]: https://img.shields.io/codecov/c/github/zakodium/adonis-mongodb.svg 114 | [codecov-url]: https://codecov.io/gh/zakodium/adonis-mongodb 115 | [download-image]: https://img.shields.io/npm/dm/@zakodium/adonis-mongodb.svg 116 | [download-url]: https://www.npmjs.com/package/@zakodium/adonis-mongodb 117 | -------------------------------------------------------------------------------- /adonis-typings/container.ts: -------------------------------------------------------------------------------- 1 | declare module '@ioc:Adonis/Core/Application' { 2 | import type { DatabaseContract } from '@ioc:Zakodium/Mongodb/Database'; 3 | import type Migration from '@ioc:Zakodium/Mongodb/Migration'; 4 | import type * as Odm from '@ioc:Zakodium/Mongodb/Odm'; 5 | 6 | export interface ContainerBindings { 7 | /* eslint-disable @typescript-eslint/naming-convention */ 8 | 'Zakodium/Mongodb/Database': DatabaseContract; 9 | 'Zakodium/Mongodb/Odm': typeof Odm; 10 | 'Zakodium/Mongodb/Migration': typeof Migration; 11 | /* eslint-enable @typescript-eslint/naming-convention */ 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /adonis-typings/database.ts: -------------------------------------------------------------------------------- 1 | declare module '@ioc:Zakodium/Mongodb/Database' { 2 | import type { EventEmitter } from 'node:events'; 3 | 4 | import type { 5 | ClientSession, 6 | Collection, 7 | Db, 8 | Document, 9 | MongoClient, 10 | MongoClientOptions, 11 | TransactionOptions, 12 | } from 'mongodb'; 13 | 14 | import type { TransactionEventEmitter } from '@ioc:Zakodium/Mongodb/Database/Transaction'; 15 | 16 | /** 17 | * Shape of the configuration in `config/mongodb.ts`. 18 | */ 19 | export interface MongodbConfig { 20 | /** 21 | * Primary connection name. 22 | */ 23 | connection: string; 24 | /** 25 | * Connection configurations. 26 | */ 27 | connections: Record; 28 | } 29 | 30 | /** 31 | * Configuration of a MongoDB connection. 32 | */ 33 | export interface MongodbConnectionConfig { 34 | url: string; 35 | database: string; 36 | clientOptions?: MongoClientOptions; 37 | migrations?: string[]; 38 | } 39 | 40 | export interface DatabaseContract { 41 | connection(connectionName?: string): ConnectionContract; 42 | 43 | /** 44 | * Name of the primary connection defined inside `config/mongodb.ts`. 45 | */ 46 | primaryConnectionName: string; 47 | 48 | /** 49 | * Connection manager. 50 | */ 51 | manager: ConnectionManagerContract; 52 | 53 | /** 54 | * Shortcut to `Database.connection().transaction()` 55 | * 56 | * @param handler 57 | * @param options 58 | */ 59 | transaction( 60 | handler: (client: ClientSession, db: Db) => Promise, 61 | options?: TransactionOptions, 62 | ): Promise; 63 | } 64 | 65 | /** 66 | * Connection manager to manage database connections. 67 | */ 68 | export interface ConnectionManagerContract { 69 | /** 70 | * List of registered connections. 71 | */ 72 | connections: Map; 73 | 74 | /** 75 | * Add a new connection. 76 | */ 77 | add(connectionName: string, config: MongodbConnectionConfig): void; 78 | 79 | /** 80 | * Initiate a connection. It is a noop if the connection is already initiated. 81 | */ 82 | connect(connectionName: string): void; 83 | 84 | /** 85 | * Get a connection. 86 | */ 87 | get(connectionName: string): ConnectionNode; 88 | 89 | /** 90 | * Returns whether the connection is managed by the manager. 91 | */ 92 | has(connectionName: string): boolean; 93 | 94 | /** 95 | * Returns whether the connection is connected. 96 | */ 97 | isConnected(connectionName: string): boolean; 98 | 99 | /** 100 | * Close a connection. 101 | */ 102 | close(connectionName: string): Promise; 103 | 104 | /** 105 | * Close all managed connections. 106 | */ 107 | closeAll(): Promise; 108 | } 109 | 110 | export interface ConnectionNode { 111 | name: string; 112 | config: MongodbConnectionConfig; 113 | connection: ConnectionContract; 114 | state: 'registered' | 'open' | 'closing' | 'closed'; 115 | } 116 | 117 | export interface ConnectionContract extends EventEmitter { 118 | /** 119 | * Instance of the MongoDB client. 120 | */ 121 | readonly client: MongoClient; 122 | 123 | /** 124 | * Name of the connection. 125 | */ 126 | readonly name: string; 127 | 128 | /** 129 | * Whether the connection is ready. 130 | */ 131 | readonly ready: boolean; 132 | 133 | /** 134 | * Config of the connection. 135 | */ 136 | readonly config: MongodbConnectionConfig; 137 | 138 | /** 139 | * Initiate the connection. 140 | */ 141 | connect(): Promise; 142 | 143 | /** 144 | * Close the connection. 145 | */ 146 | disconnect(): Promise; 147 | 148 | on( 149 | event: 'connect', 150 | callback: (connection: ConnectionContract) => void, 151 | ): this; 152 | on( 153 | event: 'error', 154 | callback: (error: Error, connection: ConnectionContract) => void, 155 | ): this; 156 | on( 157 | event: 'disconnect', 158 | callback: (connection: ConnectionContract) => void, 159 | ): this; 160 | on( 161 | event: 'disconnect:start', 162 | callback: (connection: ConnectionContract) => void, 163 | ): this; 164 | on( 165 | event: 'disconnect:error', 166 | callback: (error: Error, connection: ConnectionContract) => void, 167 | ): this; 168 | 169 | database(): Promise; 170 | collection( 171 | collectionName: string, 172 | ): Promise>; 173 | transaction( 174 | handler: ( 175 | client: ClientSession, 176 | db: Db, 177 | tx: TransactionEventEmitter, 178 | ) => Promise, 179 | options?: TransactionOptions, 180 | ): Promise; 181 | } 182 | 183 | const Database: DatabaseContract; 184 | export default Database; 185 | } 186 | -------------------------------------------------------------------------------- /adonis-typings/decorators.ts: -------------------------------------------------------------------------------- 1 | declare module '@ioc:Zakodium/Mongodb/Odm' { 2 | export type DecoratorFn = (target: unknown, property: unknown) => void; 3 | 4 | // eslint-disable-next-line @typescript-eslint/no-empty-object-type 5 | export interface FieldOptions { 6 | // TODO: Enable options. 7 | /** 8 | * Database field name 9 | */ 10 | // fieldName: string; 11 | /** 12 | * Null means do not serialize 13 | */ 14 | // serializeAs: string | null; 15 | /** 16 | * Invoked before serializing process happens 17 | */ 18 | // serialize?: (value: any, attribute: string, model: LucidRow) => any 19 | /** 20 | * Invoked before create or update happens 21 | */ 22 | // prepare?: (value: any, attribute: string, model: LucidRow) => any 23 | /** 24 | * Invoked when row is fetched from the database 25 | */ 26 | // consume?: (value: any, attribute: string, model: LucidRow) => any 27 | } 28 | 29 | /** 30 | * Represents a computed property on the model 31 | */ 32 | export interface ComputedOptions { 33 | /** 34 | * if null, will not serialize 35 | * default to getter name 36 | */ 37 | serializeAs: string | null; 38 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 39 | meta?: any; 40 | } 41 | 42 | export type FieldDecorator = (options?: FieldOptions) => DecoratorFn; 43 | 44 | export type ComputedDecorator = ( 45 | options?: Partial, 46 | ) => DecoratorFn; 47 | 48 | export const field: FieldDecorator; 49 | export const computed: ComputedDecorator; 50 | } 51 | -------------------------------------------------------------------------------- /adonis-typings/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/triple-slash-reference */ 2 | 3 | /// 4 | /// 5 | /// 6 | /// 7 | /// 8 | /// 9 | /// 10 | -------------------------------------------------------------------------------- /adonis-typings/migration.ts: -------------------------------------------------------------------------------- 1 | declare module '@ioc:Zakodium/Mongodb/Migration' { 2 | import type { 3 | ClientSession, 4 | CreateIndexesOptions, 5 | Db, 6 | DropIndexesOptions, 7 | IndexSpecification, 8 | } from 'mongodb'; 9 | 10 | export default abstract class Migration { 11 | public createCollections(collectionNames: string[]): void; 12 | 13 | /** 14 | * Drop a collection. 15 | * This operation will be done last in the migration. 16 | * It cannot be run in a transaction, so we recommend doing it in a separate migration file. 17 | * @param collectionName 18 | */ 19 | public dropCollection(collectionName: string): void; 20 | public createCollection(collectionName: string): void; 21 | public createIndex( 22 | collectionName: string, 23 | index: IndexSpecification, 24 | options?: Omit, 25 | ): void; 26 | public dropIndex( 27 | collectionName: string, 28 | indexName: string, 29 | options?: Omit, 30 | ): void; 31 | public defer( 32 | callback: (db: Db, client: ClientSession) => Promise, 33 | ): void; 34 | public abstract up(): void; 35 | public afterUpSuccess?(): unknown; 36 | public execUp(session: ClientSession): Promise; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /adonis-typings/objectid.ts: -------------------------------------------------------------------------------- 1 | declare module '@ioc:Zakodium/Mongodb/Odm' { 2 | export { ObjectId } from 'mongodb'; 3 | } 4 | -------------------------------------------------------------------------------- /adonis-typings/odm.ts: -------------------------------------------------------------------------------- 1 | declare module '@ioc:Zakodium/Mongodb/Odm' { 2 | import type { 3 | BulkWriteOptions, 4 | ClientSession, 5 | Collection, 6 | CountDocumentsOptions, 7 | DeleteOptions, 8 | Document, 9 | ExplainVerbosityLike, 10 | Filter, 11 | FindOptions, 12 | InsertOneOptions, 13 | SortDirection, 14 | } from 'mongodb'; 15 | 16 | import type { UserProviderContract } from '@ioc:Adonis/Addons/Auth'; 17 | import type { HashersList } from '@ioc:Adonis/Core/Hash'; 18 | 19 | type DollarProperties = Extract; 20 | type FunctionProperties = { 21 | // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type 22 | [K in keyof T]: T[K] extends Function ? K : never; 23 | }[keyof T]; 24 | type ModelSpecial = 'id'; 25 | 26 | type ModelAttributes = Omit< 27 | T, 28 | | ModelSpecial 29 | | DollarProperties 30 | | FunctionProperties>> 31 | >; 32 | 33 | export type ForbiddenQueryOptions = 'sort' | 'skip' | 'limit' | 'explain'; 34 | 35 | /** 36 | * Model adapter options 37 | */ 38 | export interface ModelAdapterOptions< 39 | DriverOptionType extends { session?: ClientSession }, 40 | > { 41 | client?: ClientSession; 42 | // TODO: add connection option. 43 | // https://docs.adonisjs.com/reference/orm/base-model#model-adapter-options 44 | driverOptions?: Omit; 45 | } 46 | 47 | /** 48 | * Shape of the model static properties. 49 | * 50 | */ 51 | export interface MongodbModel { 52 | /** 53 | * Map of the fields that exist on the model. 54 | */ 55 | readonly $fieldsDefinitions: Map; 56 | 57 | /** 58 | * Add a field on the model. 59 | * This is usually done by the `@field` decorator. 60 | */ 61 | $addField(name: string, options?: Partial): FieldOptions; 62 | 63 | /** 64 | * Returns whether the field exists on the model. 65 | */ 66 | $hasField(name: string): boolean; 67 | 68 | /** 69 | * Returns the field options if it exists. 70 | */ 71 | $getField(name: string): FieldOptions | undefined; 72 | 73 | /** 74 | * Managing computed columns 75 | */ 76 | $addComputed( 77 | name: string, 78 | options: Partial, 79 | ): ComputedOptions; 80 | $hasComputed(name: string): boolean; 81 | $getComputed(name: string): ComputedOptions | undefined; 82 | 83 | /** 84 | * Custom database connection to use. 85 | */ 86 | readonly connection?: string; 87 | 88 | /** 89 | * Name of the collection to use. 90 | */ 91 | readonly collectionName?: string; 92 | 93 | /** 94 | * Boot the model. 95 | */ 96 | boot(): void; 97 | 98 | /** 99 | * Whether the model has been booted. 100 | */ 101 | readonly booted: boolean; 102 | 103 | /** 104 | * Count the number of documents in the collection that match the filter. 105 | */ 106 | count>( 107 | this: ModelType, 108 | filter: Filter>>, 109 | options?: ModelAdapterOptions, 110 | ): Promise; 111 | 112 | /** 113 | * Create a new document in the collection. 114 | */ 115 | create>( 116 | this: ModelType, 117 | value: Partial>>, 118 | options?: ModelAdapterOptions, 119 | ): Promise>; 120 | 121 | /** 122 | * Create many documents in the collection. 123 | */ 124 | createMany>( 125 | this: ModelType, 126 | values: Array>>>, 127 | options?: ModelAdapterOptions, 128 | ): Promise>>; 129 | 130 | /** 131 | * Find a document by its id. 132 | */ 133 | find>( 134 | this: ModelType, 135 | id: InstanceType['_id'], 136 | options?: ModelAdapterOptions< 137 | FindOptions>> 138 | >, 139 | ): Promise | null>; 140 | 141 | /** 142 | * Find a document by its id. Throw if no document is found. 143 | */ 144 | findOrFail>( 145 | this: ModelType, 146 | id: InstanceType['_id'], 147 | options?: ModelAdapterOptions< 148 | FindOptions>> 149 | >, 150 | ): Promise>; 151 | 152 | /** 153 | * Find a document using a key-value pair. 154 | */ 155 | findBy>( 156 | this: ModelType, 157 | key: string, 158 | value: unknown, 159 | options?: ModelAdapterOptions< 160 | FindOptions>> 161 | >, 162 | ): Promise | null>; 163 | 164 | /** 165 | * Find a document using a key-value pair. Throw if no document is found. 166 | */ 167 | findByOrFail>( 168 | this: ModelType, 169 | key: string, 170 | value: unknown, 171 | options?: ModelAdapterOptions< 172 | FindOptions>> 173 | >, 174 | ): Promise>; 175 | 176 | /** 177 | * Find many documents by their ids. 178 | */ 179 | findMany>( 180 | this: ModelType, 181 | ids: Array['_id']>, 182 | options?: ModelAdapterOptions< 183 | FindOptions>> 184 | >, 185 | ): Promise>>; 186 | 187 | /** 188 | * Fetch all documents in the collection. 189 | */ 190 | all>( 191 | this: ModelType, 192 | options?: ModelAdapterOptions< 193 | FindOptions>> 194 | >, 195 | ): Promise>>; 196 | 197 | /** 198 | * Returns a query 199 | */ 200 | query>( 201 | this: ModelType, 202 | filter?: Filter>>, 203 | options?: ModelAdapterOptions< 204 | Omit< 205 | FindOptions>>, 206 | ForbiddenQueryOptions 207 | > 208 | >, 209 | ): QueryContract>; 210 | 211 | /** 212 | * Get the collection object from the MongoDB driver. 213 | */ 214 | getCollection>( 215 | this: ModelType, 216 | connection?: string, 217 | ): Promise>>>; 218 | 219 | new (): MongodbDocument; 220 | } 221 | 222 | export interface ModelDocumentOptions { 223 | driverOptions?: Omit; 224 | } 225 | 226 | export interface MongodbDocument { 227 | readonly _id: IdType; 228 | readonly id: this['_id']; 229 | 230 | readonly createdAt: Date; 231 | readonly updatedAt: Date; 232 | 233 | readonly $original: ModelAttributes; 234 | readonly $attributes: ModelAttributes; 235 | 236 | /** 237 | * `true` if the entry has been persisted to the database. 238 | */ 239 | readonly $isPersisted: boolean; 240 | 241 | /** 242 | * Opposite of `$isPersisted`. 243 | */ 244 | readonly $isNew: boolean; 245 | 246 | /** 247 | * `true` if the entry has been created locally. Similar to `$isNew`, but 248 | * stays `true` after the entry is persisted to the database. 249 | */ 250 | readonly $isLocal: boolean; 251 | 252 | /** 253 | * `true` if the entry has been removed from the database. 254 | */ 255 | readonly $isDeleted: boolean; 256 | 257 | /** 258 | * Returns an object with the field values that have been changed. 259 | */ 260 | readonly $dirty: Partial>; 261 | 262 | /** 263 | * `true` if the entry has unsaved modifications. 264 | */ 265 | readonly $isDirty: boolean; 266 | 267 | /** 268 | * Return the client session of the transaction 269 | */ 270 | readonly $trx: ClientSession | undefined; 271 | readonly $isTransaction: boolean; 272 | 273 | /** 274 | * Assign client to model options for transactions use. 275 | * Will throw an error if model instance already linked to a session 276 | * 277 | * It allows to use model init outside a transaction, but save it within a transaction. 278 | * 279 | * @param client 280 | * 281 | * @example 282 | * ```ts 283 | * const label = await Label.findOrFail(1); 284 | * // edit some label props 285 | * 286 | * Database.transaction((client) => { 287 | * const documents = await Document.query({ labels: label._id }, { client }).all() 288 | * // remove label from documents when new label definition is incompatible 289 | * // call .save() for each changed documents (aware of transaction because is from query with client option) 290 | * 291 | * label.useTransaction(client); 292 | * label.save(); 293 | * }) 294 | * ``` 295 | */ 296 | useTransaction(client: ClientSession): this; 297 | 298 | /** 299 | * Returns the Model's current data 300 | */ 301 | toJSON(): unknown; 302 | 303 | /** 304 | * Save the entry to the database. 305 | * @returns - whether the entry was changed. 306 | */ 307 | save(options?: ModelDocumentOptions): Promise; 308 | 309 | /** 310 | * Delete the entry from the database. 311 | * @returns - whether the entry was deleted. 312 | */ 313 | delete(options?: ModelDocumentOptions): Promise; 314 | 315 | /** 316 | * Merge given values into the model instance. 317 | * @param values - Values to merge with. 318 | * @returns - modified model instance. 319 | */ 320 | merge, '_id'>>>( 321 | values: NoExtraProperties, '_id'>>, T>, 322 | ): this; 323 | 324 | /** 325 | * Remove all field in instance and replace it by provided values. 326 | * @param values - Values to fill in. 327 | * @returns - modified model instance. 328 | */ 329 | fill, '_id'>>>( 330 | values: NoExtraProperties, '_id'>>, T>, 331 | ): this; 332 | } 333 | 334 | export type QuerySortObject = Record; 335 | 336 | export interface QueryContract { 337 | /** 338 | * Add new criteria to the sort. 339 | */ 340 | sort(sort: QuerySortObject): this; 341 | 342 | /** 343 | * Add a new criterion to the sort. 344 | */ 345 | sortBy(field: string, direction?: SortDirection): this; 346 | 347 | /** 348 | * Skip `number` entries. 349 | * Cancels any previous skip call. 350 | */ 351 | skip(number: number): this; 352 | 353 | /** 354 | * Limit the result to `number` entries. 355 | * Cancels any previous limit call. 356 | */ 357 | limit(number: number): this; 358 | 359 | /** 360 | * Returns the first matching document or null. 361 | */ 362 | first(): Promise; 363 | 364 | /** 365 | * Returns the first matching document or throws. 366 | */ 367 | firstOrFail(): Promise; 368 | 369 | /** 370 | * Returns all matching documents. 371 | */ 372 | all(): Promise; 373 | 374 | /** 375 | * Counts all matching documents. 376 | * Calling this method after `skip` or `limit` might not count everything. 377 | */ 378 | count(): Promise; 379 | 380 | /** 381 | * Performs a `distinct` query. 382 | */ 383 | distinct(key: string): Promise; 384 | 385 | /** 386 | * Performs an `explain` query. 387 | */ 388 | explain(verbosity?: ExplainVerbosityLike): Promise; 389 | 390 | /** 391 | * Returns an iterator on all matching documents. 392 | */ 393 | [Symbol.asyncIterator](): AsyncIterableIterator; 394 | } 395 | 396 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 397 | type Impossible = Record; 398 | 399 | type NoExtraProperties = U & 400 | Impossible>; 401 | 402 | export const BaseModel: MongodbModel; 403 | export const BaseAutoIncrementModel: MongodbModel; 404 | 405 | // eslint-disable-next-line @typescript-eslint/no-empty-object-type 406 | export interface MongodbModelAuthProviderContract< 407 | User extends MongodbModel, 408 | > extends UserProviderContract> {} 409 | 410 | export interface MongodbModelAuthProviderConfig< 411 | User extends MongodbModel, 412 | > { 413 | driver: 'mongodb-model'; 414 | /** 415 | * Function that imports the user model. 416 | * @default () => import('App/Models/User') 417 | */ 418 | model?: () => 419 | | Promise 420 | | Promise<{ 421 | default: User; 422 | }>; 423 | /** 424 | * List of keys used to search the user. 425 | * @default ['email'] 426 | */ 427 | uids?: Array>>; 428 | /** 429 | * Unique key on the user object. 430 | * @default _id 431 | */ 432 | identifierKey?: keyof ModelAttributes>; 433 | /** 434 | * Value type for `identifierKey`. 435 | * @default 'objectid' 436 | */ 437 | identifierKeyType?: 'objectid' | 'string' | 'number'; 438 | /** 439 | * Hash driver used to hash the password. 440 | */ 441 | hashDriver?: keyof HashersList; 442 | } 443 | } 444 | -------------------------------------------------------------------------------- /adonis-typings/transaction.ts: -------------------------------------------------------------------------------- 1 | declare module '@ioc:Zakodium/Mongodb/Database/Transaction' { 2 | import { EventEmitter } from 'node:events'; 3 | 4 | import type { ClientSession, Db } from 'mongodb'; 5 | 6 | export interface TransactionEvents { 7 | /** 8 | * The transaction commits successfully. 9 | * 10 | * @example 11 | * Consider you have a collection of items storing metadata of file is filesystem. 12 | * Consider when you delete an item from this collection, you must delete associated file. 13 | * 14 | * ```ts 15 | * const item = await connection.transaction((session, db, tx) => { 16 | * const item = await db.collection('test').findOneAndDelete({ _id }, { session }); 17 | * 18 | * tx.on('commit', () => { 19 | * Drive.delete(deletedItem.file.path); 20 | * }); 21 | * 22 | * // some other logic that could fail 23 | * // or await session.abortTransaction() 24 | * // commit will not emit in this case 25 | * 26 | * return item; 27 | * }) 28 | * ``` 29 | */ 30 | commit: [session: ClientSession, db: Db]; 31 | 32 | /** 33 | * The transaction aborted (optional error). 34 | * Two cases of abortion are possible: 35 | * - if from `session.abortTransaction()`, no error 36 | * - if from a throw, error is set 37 | */ 38 | abort: [session: ClientSession, db: Db, error?: Error]; 39 | } 40 | 41 | export class TransactionEventEmitter extends EventEmitter {} 42 | } 43 | -------------------------------------------------------------------------------- /commands/MongodbListMigrations.ts: -------------------------------------------------------------------------------- 1 | import { inject } from '@adonisjs/core/build/standalone'; 2 | import CliTable from 'cli-table3'; 3 | 4 | import { DatabaseContract } from '@ioc:Zakodium/Mongodb/Database'; 5 | 6 | import MigrationCommand from './util/MigrationCommand'; 7 | 8 | export default class MongodbListMigrations extends MigrationCommand { 9 | public static commandName = 'mongodb:migration:status'; 10 | public static description = 'Show pending migrations'; 11 | public static settings = { 12 | loadApp: true, 13 | }; 14 | 15 | @inject(['Zakodium/Mongodb/Database']) 16 | public async run(db: DatabaseContract): Promise { 17 | try { 18 | const connection = await this.getConnection(db); 19 | const database = await connection.database(); 20 | const coll = database.collection('__adonis_mongodb'); 21 | const migrations = await this.getMigrations(connection.config); 22 | 23 | const migrationDocuments = await coll.find({}).toArray(); 24 | 25 | const table = new CliTable({ 26 | head: ['Name', 'Status', 'Batch', 'Message'], 27 | }); 28 | 29 | const imports = await Promise.all( 30 | migrations.map(({ file }) => this.importMigration(file)), 31 | ); 32 | 33 | /** 34 | * Push a new row to the table 35 | */ 36 | for (const [idx, { name, file }] of migrations.entries()) { 37 | const document = migrationDocuments.find((doc) => doc.name === name); 38 | 39 | const { description } = imports[idx]; 40 | table.push([ 41 | file, 42 | document 43 | ? this.colors.green('completed') 44 | : this.colors.yellow('pending'), 45 | document ? document.batch : 'NA', 46 | description || '', 47 | ]); 48 | } 49 | 50 | // eslint-disable-next-line no-console 51 | console.log(table.toString()); 52 | } finally { 53 | await db.manager.closeAll(); 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /commands/MongodbMakeMigration.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | 3 | import { BaseCommand, args, flags } from '@adonisjs/core/build/standalone'; 4 | 5 | export default class MongodbMakeMigration extends BaseCommand { 6 | public static commandName = 'mongodb:make:migration'; 7 | public static description = 'Make a new migration file'; 8 | public static settings = { 9 | loadApp: true, 10 | }; 11 | 12 | @args.string({ description: 'Name of the migration file' }) 13 | public name: string; 14 | 15 | @flags.string({ description: 'Database connection to use for the migration' }) 16 | public connection: string; 17 | 18 | public async run(): Promise { 19 | if (this.name.includes('/')) { 20 | this.logger.error('name argument should not contain any slash'); 21 | process.exitCode = 1; 22 | return; 23 | } 24 | 25 | const folder = 'mongodb/migrations'; 26 | 27 | const stub = path.join(__dirname, '../../templates/migration.txt'); 28 | 29 | this.generator 30 | .addFile(this.name, { prefix: String(Date.now()), pattern: 'snakecase' }) 31 | .stub(stub) 32 | .destinationDir(folder) 33 | .appRoot(this.application.appRoot) 34 | .apply({ 35 | className: `${this.name[0].toUpperCase()}${this.name.slice( 36 | 1, 37 | )}Migration`, 38 | }); 39 | await this.generator.run(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /commands/MongodbMigrate.ts: -------------------------------------------------------------------------------- 1 | import { inject } from '@adonisjs/core/build/standalone'; 2 | import { ObjectId } from 'mongodb'; 3 | 4 | import { DatabaseContract } from '@ioc:Zakodium/Mongodb/Database'; 5 | import Migration from '@ioc:Zakodium/Mongodb/Migration'; 6 | 7 | import MigrationCommand, { 8 | migrationCollectionName, 9 | migrationLockCollectionName, 10 | } from './util/MigrationCommand'; 11 | 12 | interface IMigration { 13 | _id: ObjectId | undefined; 14 | name: string; 15 | date: Date; 16 | batch: number; 17 | } 18 | 19 | interface MigrationLock { 20 | _id: string; 21 | running: boolean; 22 | } 23 | 24 | export default class MongodbMigrate extends MigrationCommand { 25 | public static commandName = 'mongodb:migration:run'; 26 | public static description = 'Execute pending migrations'; 27 | public static settings = { 28 | loadApp: true, 29 | }; 30 | 31 | private async _executeMigration(db: DatabaseContract): Promise { 32 | const connection = await this.getConnection(db); 33 | const migrations = await this.getMigrations(connection.config); 34 | 35 | const migrationLockColl = await connection.collection( 36 | migrationLockCollectionName, 37 | ); 38 | 39 | const migrationColl = await connection.collection( 40 | migrationCollectionName, 41 | ); 42 | 43 | const lock = await migrationLockColl.updateOne( 44 | { 45 | _id: 'migration_lock', 46 | }, 47 | { 48 | $set: { running: true }, 49 | }, 50 | { 51 | upsert: true, 52 | }, 53 | ); 54 | 55 | if (lock.modifiedCount === 0 && lock.upsertedCount === 0) { 56 | this.logger.error('A migration is already running'); 57 | this.exitCode = 1; 58 | return; 59 | } 60 | 61 | const migrationDocs = await migrationColl.find({}).toArray(); 62 | const dbMigrationNames = new Set(migrationDocs.map((m) => m.name)); 63 | 64 | // Keep migrations that are not yet registered 65 | const unregisteredMigrations = migrations.filter( 66 | (migration) => !dbMigrationNames.has(migration.name), 67 | ); 68 | 69 | // Keep migrations that are not yet registered 70 | let successfullyExecuted = 0; 71 | 72 | // Get the next incremental batch value 73 | const value = await migrationColl 74 | .find({}) 75 | .sort({ batch: -1 }) 76 | .project<{ batch: number }>({ batch: 1 }) 77 | .limit(1) 78 | .toArray(); 79 | 80 | let newBatch = 1; 81 | if (value.length === 1) { 82 | newBatch = value[0].batch + 1; 83 | } 84 | 85 | let lastMigrationError = null; 86 | for (const { name, file } of unregisteredMigrations) { 87 | let migration: Migration; 88 | try { 89 | const { Migration: MigrationConstructor, description } = 90 | await this.importMigration(file); 91 | this.logger.info( 92 | `Executing migration: ${name}${ 93 | description ? ` - ${description}` : '' 94 | }`, 95 | ); 96 | migration = new MigrationConstructor(connection.name, this.logger); 97 | } catch (error) { 98 | lastMigrationError = error; 99 | break; 100 | } 101 | 102 | // eslint-disable-next-line @typescript-eslint/no-loop-func 103 | await connection.transaction(async (session) => { 104 | try { 105 | await migration.execUp(session); 106 | 107 | await migrationColl.insertOne( 108 | { 109 | _id: new ObjectId(), 110 | name, 111 | date: new Date(), 112 | batch: newBatch, 113 | }, 114 | { session }, 115 | ); 116 | } catch (error) { 117 | lastMigrationError = error; 118 | await session.abortTransaction(); 119 | } 120 | }); 121 | 122 | if (lastMigrationError) { 123 | break; 124 | } 125 | 126 | if (migration.afterUpSuccess) { 127 | try { 128 | await migration.afterUpSuccess(); 129 | } catch (error) { 130 | this.logger.warning(`Migration's afterUpSuccess call failed`); 131 | // TODO: See if there can be a way in Ace commands to print error stack traces 132 | // eslint-disable-next-line no-console 133 | console.warn(error); 134 | } 135 | } 136 | 137 | successfullyExecuted++; 138 | } 139 | 140 | await migrationLockColl.updateOne( 141 | { 142 | _id: 'migration_lock', 143 | running: true, 144 | }, 145 | { 146 | $set: { running: false }, 147 | }, 148 | ); 149 | 150 | if (successfullyExecuted > 0) { 151 | const remainingMigrations = 152 | unregisteredMigrations.length - successfullyExecuted; 153 | this.logger.info( 154 | `Successfully executed ${successfullyExecuted} migrations${ 155 | lastMigrationError ? `, 1 migration failed` : '' 156 | }${ 157 | remainingMigrations > 0 158 | ? `, ${ 159 | remainingMigrations - (lastMigrationError ? 1 : 0) 160 | } pending migrations remaining` 161 | : '' 162 | }`, 163 | ); 164 | } else if (lastMigrationError === null) { 165 | this.logger.info('No pending migration'); 166 | } 167 | 168 | if (lastMigrationError) { 169 | this.logger.error('Migration failed'); 170 | // TODO: See if there can be a way in Ace commands to print error stack traces 171 | // eslint-disable-next-line no-console 172 | console.error(lastMigrationError); 173 | this.exitCode = 1; 174 | } 175 | } 176 | 177 | @inject(['Zakodium/Mongodb/Database']) 178 | public async run(db: DatabaseContract): Promise { 179 | try { 180 | await this._executeMigration(db); 181 | } finally { 182 | await db.manager.closeAll(); 183 | } 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /commands/index.ts: -------------------------------------------------------------------------------- 1 | const commands = [ 2 | '@zakodium/adonis-mongodb/lib/commands/MongodbMakeMigration.js', 3 | '@zakodium/adonis-mongodb/lib/commands/MongodbMigrate.js', 4 | '@zakodium/adonis-mongodb/lib/commands/MongodbListMigrations.js', 5 | ]; 6 | 7 | export default commands; 8 | -------------------------------------------------------------------------------- /commands/util/MigrationCommand.ts: -------------------------------------------------------------------------------- 1 | import { readdir } from 'node:fs/promises'; 2 | import path from 'node:path'; 3 | 4 | import { BaseCommand, flags } from '@adonisjs/core/build/standalone'; 5 | import { Logger } from '@poppinss/cliui/build/src/Logger'; 6 | 7 | import type { 8 | ConnectionContract, 9 | DatabaseContract, 10 | MongodbConnectionConfig, 11 | } from '@ioc:Zakodium/Mongodb/Database'; 12 | import type BaseMigration from '@ioc:Zakodium/Mongodb/Migration'; 13 | 14 | import transformMigrations, { 15 | MigrationDescription, 16 | } from './transformMigrations'; 17 | 18 | const folder = 'mongodb/migrations'; 19 | 20 | export const migrationCollectionName = '__adonis_mongodb'; 21 | export const migrationLockCollectionName = '__adonis_mongodb_lock'; 22 | 23 | interface MigrationModule { 24 | default: new ( 25 | connection: string | undefined, 26 | logger: Logger, 27 | ) => BaseMigration; 28 | description?: string; 29 | } 30 | 31 | export default abstract class MigrationCommand extends BaseCommand { 32 | public static settings = { 33 | loadApp: true, 34 | }; 35 | 36 | public static commandName = 'commandName'; 37 | public static description = 'description'; 38 | 39 | @flags.string({ description: 'Database connection to use for the migration' }) 40 | public connection: string; 41 | 42 | protected async getConnection( 43 | db: DatabaseContract, 44 | ): Promise { 45 | if (this.connection && !db.manager.has(this.connection)) { 46 | this.logger.error( 47 | `No MongoDB connection registered with name "${this.connection}"`, 48 | ); 49 | this.exitCode = 1; 50 | await this.exit(); 51 | } 52 | return db.connection(this.connection); 53 | } 54 | 55 | protected async getMigrations( 56 | config: MongodbConnectionConfig, 57 | ): Promise { 58 | const folders = 59 | config.migrations && config.migrations.length > 0 60 | ? config.migrations 61 | : [folder]; 62 | 63 | const rawMigrationFiles = await Promise.all( 64 | folders 65 | .map((folder) => path.join(this.application.appRoot, folder)) 66 | .map(async (migrationsPath) => { 67 | try { 68 | const files = await readdir(migrationsPath); 69 | return files 70 | .filter((file) => { 71 | return ( 72 | // Only include code and exclude type declaration files. 73 | /\.[cm]?[jt]s$/.test(file) && !/\.d\.[cm]?ts$/.test(file) 74 | ); 75 | }) 76 | .map((file) => path.join(migrationsPath, file)); 77 | } catch { 78 | return []; 79 | } 80 | }), 81 | ); 82 | 83 | return transformMigrations(rawMigrationFiles, this.logger); 84 | } 85 | 86 | protected async importMigration( 87 | file: string, 88 | ): Promise<{ Migration: MigrationModule['default']; description?: string }> { 89 | const module: MigrationModule = await import(file); 90 | const { default: Migration, description } = module; 91 | if (!Migration || typeof Migration !== 'function') { 92 | throw new Error(`Migration in ${file} must export a default class`); 93 | } 94 | return { Migration, description }; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /commands/util/__tests__/transformMigrations.test.ts: -------------------------------------------------------------------------------- 1 | import transformMigrations from '../transformMigrations'; 2 | 3 | const okMigrations = [ 4 | ['mongodb/migrations/1_test.js', 'mongodb/migrations/2_test.js'], 5 | ['mongodb/alternative/3_test.js', 'mongodb/alternative/4_test.js'], 6 | ]; 7 | 8 | test('migration transform ok when everything is ok', () => { 9 | expect(transformMigrations(okMigrations)).toStrictEqual([ 10 | { name: '1_test', file: 'mongodb/migrations/1_test.js' }, 11 | { name: '2_test', file: 'mongodb/migrations/2_test.js' }, 12 | { name: '3_test', file: 'mongodb/alternative/3_test.js' }, 13 | { name: '4_test', file: 'mongodb/alternative/4_test.js' }, 14 | ]); 15 | }); 16 | 17 | test('throws if missing timestamp in migration file name', () => { 18 | const badMigrations = [...okMigrations]; 19 | badMigrations[0][0] = 'mongodb/migration/test.js'; 20 | 21 | const t = () => { 22 | transformMigrations(badMigrations); 23 | }; 24 | expect(t).toThrow('some migration files are malformed'); 25 | }); 26 | 27 | test('throws if migration filename are duplicate', () => { 28 | const badMigrations = [...okMigrations]; 29 | badMigrations[0][0] = badMigrations[1][0]; 30 | 31 | const t = () => { 32 | transformMigrations(badMigrations); 33 | }; 34 | expect(t).toThrow('found duplicate migration file names: 3_test'); 35 | }); 36 | -------------------------------------------------------------------------------- /commands/util/transformMigrations.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | 3 | import type { Logger } from '@poppinss/cliui/build/src/Logger'; 4 | 5 | const matchTimestamp = /^(?\d+)_.*$/; 6 | 7 | export interface MigrationDescription { 8 | name: string; 9 | file: string; 10 | } 11 | 12 | export default function transformMigrations( 13 | rawMigrations: string[][], 14 | logger?: Logger, 15 | ) { 16 | // Separate name and file fields 17 | const migrations: MigrationDescription[] = rawMigrations 18 | .flat() 19 | .sort((a, b) => 20 | path 21 | .basename(a, path.extname(a)) 22 | .localeCompare(path.basename(b, path.extname(a))), 23 | ) 24 | .map((migrationFile) => ({ 25 | name: path.basename(migrationFile, path.extname(migrationFile)), 26 | file: migrationFile, 27 | })); 28 | 29 | // Check migration file names 30 | let hadBadName = false; 31 | for (const { name, file } of migrations) { 32 | const match = matchTimestamp.exec(name); 33 | const timestamp = Number(match?.groups?.timestamp); 34 | if (Number.isNaN(timestamp) || timestamp === 0) { 35 | hadBadName = true; 36 | if (logger) { 37 | logger.error( 38 | `Invalid migration file: ${file}. Name must start with a timestamp`, 39 | ); 40 | } 41 | } 42 | } 43 | if (hadBadName) { 44 | throw new Error('some migration files are malformed'); 45 | } 46 | 47 | // Check duplicates migration file names 48 | const duplicates = new Set( 49 | migrations.filter( 50 | ({ name }, index) => 51 | migrations.map((migration) => migration.name).indexOf(name) !== index, 52 | ), 53 | ); 54 | if (duplicates.size > 0) { 55 | throw new Error( 56 | `found duplicate migration file names: ${[...duplicates] 57 | .map(({ name }) => name) 58 | .join(', ')}`, 59 | ); 60 | } 61 | 62 | return migrations; 63 | } 64 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | mongodb: 3 | image: mongo:${MONGO_VERSION} 4 | command: --replSet rs0 --port 33333 5 | ports: 6 | - 33333:33333 7 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { globals } from 'eslint-config-zakodium'; 2 | import adonisV5 from 'eslint-config-zakodium/adonis-v5'; 3 | import ts from 'eslint-config-zakodium/ts'; 4 | import unicorn from 'eslint-config-zakodium/unicorn'; 5 | 6 | export default [ 7 | { 8 | ignores: ['lib', 'coverage'], 9 | }, 10 | ...ts, 11 | ...unicorn, 12 | ...adonisV5, 13 | { 14 | languageOptions: { 15 | globals: { 16 | ...globals.nodeBuiltin, 17 | }, 18 | }, 19 | rules: { 20 | 'no-await-in-loop': 'off', 21 | '@typescript-eslint/use-unknown-in-catch-callback-variable': 'off', 22 | 'unicorn/prefer-event-target': 'off', 23 | }, 24 | }, 25 | ]; 26 | -------------------------------------------------------------------------------- /instructions.md: -------------------------------------------------------------------------------- 1 | The package has been configured successfully. 2 | The database configuration stored inside `config/mongodb.ts` file relies on the 3 | following environment variables and hence we recommend validating them. 4 | 5 | **Open the `env.ts` file and paste the following code inside the `Env.rules` object.** 6 | 7 | ``` 8 | MONGODB_CONNECTION: Env.schema.string(), 9 | MONGODB_URL: Env.schema.string(), 10 | MONGODB_DATABASE: Env.schema.string(), 11 | ``` 12 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | }; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@zakodium/adonis-mongodb", 3 | "version": "0.20.2", 4 | "description": "MongoDB provider for AdonisJs", 5 | "main": "./lib/providers/MongodbProvider.js", 6 | "types": "./lib/adonis-typings/index.d.ts", 7 | "files": [ 8 | "lib", 9 | "src", 10 | "templates", 11 | "instructions.md" 12 | ], 13 | "keywords": [ 14 | "adonisjs", 15 | "adonis", 16 | "mongo", 17 | "mongodb", 18 | "orm", 19 | "provider" 20 | ], 21 | "author": "Michaël Zasso", 22 | "license": "MIT", 23 | "adonisjs": { 24 | "templates": { 25 | "basePath": "./templates", 26 | "config": "mongodb.txt" 27 | }, 28 | "env": { 29 | "MONGODB_CONNECTION": "mongodb", 30 | "MONGODB_URL": "mongodb://localhost:27017?directConnection=true", 31 | "MONGODB_DATABASE": "test" 32 | }, 33 | "instructionsMd": "./instructions.md", 34 | "types": "@zakodium/adonis-mongodb", 35 | "providers": [ 36 | "@zakodium/adonis-mongodb" 37 | ], 38 | "commands": [ 39 | "@zakodium/adonis-mongodb/lib/commands" 40 | ] 41 | }, 42 | "scripts": { 43 | "clean": "rimraf lib", 44 | "check-types": "tsc --noEmit", 45 | "eslint": "eslint . --cache", 46 | "eslint-fix": "npm run eslint -- --fix", 47 | "prepack": "npm run tsc", 48 | "prettier": "prettier --check .", 49 | "prettier-write": "prettier --write .", 50 | "test": "npm run test-only && npm run eslint && npm run prettier && npm run check-types", 51 | "test-only": "jest --coverage", 52 | "tsc": "npm run clean && npm run tsc-cjs", 53 | "tsc-cjs": "tsc --project tsconfig.prod.json" 54 | }, 55 | "repository": { 56 | "type": "git", 57 | "url": "git+https://github.com/zakodium/adonis-mongodb.git" 58 | }, 59 | "bugs": { 60 | "url": "https://github.com/zakodium/adonis-mongodb/issues" 61 | }, 62 | "homepage": "https://github.com/zakodium/adonis-mongodb#readme", 63 | "devDependencies": { 64 | "@adonisjs/auth": "^8.2.3", 65 | "@adonisjs/core": "^5.9.0", 66 | "@adonisjs/logger": "^4.1.5", 67 | "@poppinss/cliui": "^3.0.5", 68 | "@types/jest": "^29.5.14", 69 | "@types/lodash": "^4.17.13", 70 | "@types/pluralize": "0.0.29", 71 | "eslint": "^9.16.0", 72 | "eslint-config-zakodium": "^14.0.0", 73 | "jest": "^29.7.0", 74 | "prettier": "^3.4.1", 75 | "rimraf": "^6.0.1", 76 | "ts-jest": "^29.2.5", 77 | "typescript": "^5.7.2" 78 | }, 79 | "dependencies": { 80 | "@poppinss/utils": "^5.0.0", 81 | "cli-table3": "^0.6.5", 82 | "lodash": "^4.17.21", 83 | "mongodb": "^6.11.0", 84 | "pluralize": "^8.0.0" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /providers/MongodbProvider.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from 'mongodb'; 2 | 3 | import type { ApplicationContract } from '@ioc:Adonis/Core/Application'; 4 | import type { 5 | BaseModel as BaseModelType, 6 | BaseAutoIncrementModel as BaseAutoIncrementModelType, 7 | } from '@ioc:Zakodium/Mongodb/Odm'; 8 | 9 | import { getMongodbModelAuthProvider } from '../src/Auth/MongodbModelAuthProvider'; 10 | import { Database } from '../src/Database/Database'; 11 | import { TransactionEventEmitter } from '../src/Database/TransactionEventEmitter'; 12 | import createMigration from '../src/Migration'; 13 | import { BaseModel, BaseAutoIncrementModel } from '../src/Model/Model'; 14 | import { field, computed } from '../src/Odm/decorators'; 15 | 16 | export default class MongodbProvider { 17 | public constructor(protected app: ApplicationContract) {} 18 | 19 | private registerOdm(): void { 20 | this.app.container.singleton('Zakodium/Mongodb/Odm', () => { 21 | BaseModel.$setDatabase( 22 | this.app.container.resolveBinding('Zakodium/Mongodb/Database'), 23 | ); 24 | BaseAutoIncrementModel.$setDatabase( 25 | this.app.container.resolveBinding('Zakodium/Mongodb/Database'), 26 | ); 27 | 28 | return { 29 | ObjectId, 30 | BaseModel: BaseModel as unknown as typeof BaseModelType, 31 | BaseAutoIncrementModel: 32 | BaseAutoIncrementModel as unknown as typeof BaseAutoIncrementModelType, 33 | field, 34 | computed, 35 | }; 36 | }); 37 | } 38 | 39 | private registerDatabase(): void { 40 | this.app.container.singleton('Zakodium/Mongodb/Database', () => { 41 | const { config, logger } = this.app; 42 | return new Database(config.get('mongodb', {}), logger); 43 | }); 44 | } 45 | 46 | private registerTransactionEvent(): void { 47 | this.app.container.singleton( 48 | 'Zakodium/Mongodb/Database/Transaction', 49 | () => { 50 | return { 51 | TransactionEventEmitter, 52 | }; 53 | }, 54 | ); 55 | } 56 | 57 | private registerMigration(): void { 58 | this.app.container.singleton('Zakodium/Mongodb/Migration', () => { 59 | return createMigration( 60 | this.app.container.resolveBinding('Zakodium/Mongodb/Database'), 61 | ); 62 | }); 63 | } 64 | 65 | public register(): void { 66 | this.registerOdm(); 67 | this.registerTransactionEvent(); 68 | this.registerDatabase(); 69 | this.registerMigration(); 70 | } 71 | 72 | public boot(): void { 73 | if (this.app.container.hasBinding('Adonis/Addons/Auth')) { 74 | const Auth = this.app.container.resolveBinding('Adonis/Addons/Auth'); 75 | Auth.extend('provider', 'mongodb-model', getMongodbModelAuthProvider); 76 | } 77 | } 78 | 79 | public async shutdown(): Promise { 80 | const Database = this.app.container.resolveBinding( 81 | 'Zakodium/Mongodb/Database', 82 | ); 83 | return Database.manager.closeAll(); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": { 3 | ".": { 4 | "bump-minor-pre-major": true, 5 | "include-component-in-tag": false 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /reset-dev.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /* eslint-env node */ 4 | import childProcess from 'node:child_process'; 5 | 6 | try { 7 | childProcess.execFileSync( 8 | 'docker', 9 | [ 10 | 'compose', 11 | 'exec', 12 | // Do not try to allocate a TTY so it works in GitHub actions too. 13 | '-T', 14 | 'mongodb', 15 | 'mongosh', 16 | '127.0.0.1:33333', 17 | '--eval', 18 | 'rs.initiate({ _id: "rs0", members: [{ _id: 0, host: "127.0.0.1:33333" }] });', 19 | ], 20 | { 21 | stdio: 'inherit', 22 | }, 23 | ); 24 | } catch { 25 | // Ignore error, it is already piped to the console. 26 | process.exit(1); 27 | } 28 | -------------------------------------------------------------------------------- /src/.npmignore: -------------------------------------------------------------------------------- 1 | __tests__ 2 | .npmignore 3 | -------------------------------------------------------------------------------- /src/Auth/MongodbModelAuthProvider.ts: -------------------------------------------------------------------------------- 1 | import { esmResolver } from '@poppinss/utils'; 2 | import { ObjectId } from 'mongodb'; 3 | 4 | import type { 5 | AuthManagerContract, 6 | ProviderUserContract, 7 | UserProviderContract, 8 | } from '@ioc:Adonis/Addons/Auth'; 9 | import type { HashDriverContract } from '@ioc:Adonis/Core/Hash'; 10 | import type { 11 | MongodbDocument, 12 | MongodbModel, 13 | MongodbModelAuthProviderConfig, 14 | } from '@ioc:Zakodium/Mongodb/Odm'; 15 | 16 | class MongodbModelAuthProviderUser 17 | implements ProviderUserContract> 18 | { 19 | public constructor( 20 | // `this.user` can be any Model, so we use `any` to avoid indexing issues later. 21 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 22 | public user: any, 23 | private identifierKey: string, 24 | private identifierKeyType: 'objectid' | 'string' | 'number', 25 | private hash: HashDriverContract, 26 | ) {} 27 | 28 | public getId(): string | number | null { 29 | if (this.user === null) return null; 30 | const value = this.user[this.identifierKey]; 31 | if (this.identifierKeyType === 'objectid') { 32 | return value.toString(); 33 | } 34 | return value; 35 | } 36 | 37 | public verifyPassword(plainPassword: string): Promise { 38 | if (this.user === null) { 39 | throw new Error('Cannot "verifyPassword for non-existing user'); 40 | } 41 | if (!this.user.password) { 42 | throw new Error( 43 | 'Auth user object must have a password in order to call "verifyPassword"', 44 | ); 45 | } 46 | 47 | return this.hash.verify(this.user.password, plainPassword); 48 | } 49 | 50 | public getRememberMeToken(): string | null { 51 | return null; 52 | } 53 | public setRememberMeToken(): void { 54 | throw new Error('unimplemented setRememberMeToken'); 55 | } 56 | } 57 | 58 | class MongodbModelAuthUserProvider 59 | implements UserProviderContract> 60 | { 61 | private uids = ['email']; 62 | private identifierKey = '_id'; 63 | private identifierKeyType: 'objectid' | 'string' | 'number' = 'objectid'; 64 | private hash: HashDriverContract; 65 | 66 | public constructor( 67 | private auth: AuthManagerContract, 68 | private config: MongodbModelAuthProviderConfig>, 69 | ) { 70 | if (config.uids) { 71 | if (config.uids.length === 0) { 72 | throw new Error('config.uids must have at least one element'); 73 | } 74 | this.uids = config.uids as string[]; 75 | } 76 | 77 | if (config.identifierKey) { 78 | this.identifierKey = config.identifierKey as string; 79 | } 80 | 81 | if (config.identifierKeyType) { 82 | this.identifierKeyType = config.identifierKeyType; 83 | } 84 | 85 | const Hash = 86 | this.auth.application.container.resolveBinding('Adonis/Core/Hash'); 87 | this.hash = config.hashDriver ? Hash.use(config.hashDriver) : Hash; 88 | } 89 | 90 | private async getModel(): Promise> { 91 | if (this.config.model) { 92 | return esmResolver(await this.config.model()); 93 | } else { 94 | return esmResolver( 95 | await this.auth.application.container.useAsync('App/Models/User'), 96 | ); 97 | } 98 | } 99 | 100 | public async getUserFor( 101 | user: MongodbDocument, 102 | ): Promise { 103 | return new MongodbModelAuthProviderUser( 104 | user, 105 | this.identifierKey, 106 | this.identifierKeyType, 107 | this.hash, 108 | ); 109 | } 110 | 111 | public async findById( 112 | id: string | number, 113 | ): Promise { 114 | const Model = await this.getModel(); 115 | const user = await Model.findByOrFail( 116 | this.identifierKey, 117 | this.identifierKeyType === 'objectid' ? new ObjectId(id) : id, 118 | ); 119 | return new MongodbModelAuthProviderUser( 120 | user, 121 | this.identifierKey, 122 | this.identifierKeyType, 123 | this.hash, 124 | ); 125 | } 126 | 127 | public async findByUid( 128 | uid: string | number, 129 | ): Promise { 130 | const Model = await this.getModel(); 131 | const $or = this.uids.map((uidKey) => ({ [uidKey]: uid })); 132 | const user = await Model.query({ $or }).first(); 133 | return new MongodbModelAuthProviderUser( 134 | user, 135 | this.identifierKey, 136 | this.identifierKeyType, 137 | this.hash, 138 | ); 139 | } 140 | 141 | public async findByRememberMeToken(/* userId: string | number, token: string */): Promise { 142 | throw new Error('unimplemented findByRememberMeToken'); 143 | // return new MongodbModelAuthProviderUser(null); 144 | } 145 | 146 | public updateRememberMeToken(/* authenticatable: MongodbModelAuthProviderUser */): Promise { 147 | throw new Error('unimplemented updateRememberMeToken'); 148 | } 149 | } 150 | 151 | export function getMongodbModelAuthProvider( 152 | auth: AuthManagerContract, 153 | _mapping: string, 154 | config: MongodbModelAuthProviderConfig>, 155 | ) { 156 | return new MongodbModelAuthUserProvider(auth, config); 157 | } 158 | -------------------------------------------------------------------------------- /src/Database/Connection.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'node:events'; 2 | 3 | import { Exception } from '@poppinss/utils'; 4 | import type { 5 | Db, 6 | Collection, 7 | ClientSession, 8 | Document, 9 | TransactionOptions, 10 | } from 'mongodb'; 11 | import { MongoClient } from 'mongodb'; 12 | 13 | import type { LoggerContract } from '@ioc:Adonis/Core/Logger'; 14 | import type { 15 | MongodbConnectionConfig, 16 | ConnectionContract, 17 | } from '@ioc:Zakodium/Mongodb/Database'; 18 | 19 | import { TransactionEventEmitter } from './TransactionEventEmitter'; 20 | 21 | enum ConnectionStatus { 22 | CONNECTED = 'CONNECTED', 23 | DISCONNECTED = 'DISCONNECTED', 24 | } 25 | 26 | // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging 27 | export declare interface Connection { 28 | on( 29 | event: 'connect', 30 | callback: (connection: ConnectionContract) => void, 31 | ): this; 32 | on( 33 | event: 'error', 34 | callback: (error: Error, connection: ConnectionContract) => void, 35 | ): this; 36 | on( 37 | event: 'disconnect', 38 | callback: (connection: ConnectionContract) => void, 39 | ): this; 40 | on( 41 | event: 'disconnect:start', 42 | callback: (connection: ConnectionContract) => void, 43 | ): this; 44 | on( 45 | event: 'disconnect:error', 46 | callback: (error: Error, connection: ConnectionContract) => void, 47 | ): this; 48 | } 49 | 50 | // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging 51 | export class Connection extends EventEmitter implements ConnectionContract { 52 | public readonly client: MongoClient; 53 | public readonly name: string; 54 | public ready: boolean; 55 | public readonly config: MongodbConnectionConfig; 56 | 57 | private logger: LoggerContract; 58 | private status: ConnectionStatus; 59 | private connectPromise: Promise | null; 60 | 61 | public constructor( 62 | name: string, 63 | config: MongodbConnectionConfig, 64 | logger: LoggerContract, 65 | ) { 66 | super(); 67 | 68 | this.name = name; 69 | this.config = config; 70 | this.logger = logger; 71 | this.status = ConnectionStatus.DISCONNECTED; 72 | this.client = new MongoClient(this.config.url, { 73 | ...this.config.clientOptions, 74 | }); 75 | this.connectPromise = null; 76 | } 77 | 78 | private async _ensureDb(): Promise { 79 | void this.connect(); 80 | if (!this.connectPromise) { 81 | throw new Exception( 82 | `unexpected MongoDB connection error`, 83 | 500, 84 | 'E_MONGODB_CONNECTION', 85 | ); 86 | } 87 | return this.connectPromise; 88 | } 89 | 90 | public connect(): Promise { 91 | if (this.status === ConnectionStatus.CONNECTED) { 92 | return this.connectPromise as Promise; 93 | } 94 | this.status = ConnectionStatus.CONNECTED; 95 | this.connectPromise = this.client.connect().then((client) => { 96 | return client.db(this.config.database); 97 | }); 98 | this.connectPromise.catch((error) => { 99 | this.connectPromise = null; 100 | this.status = ConnectionStatus.DISCONNECTED; 101 | this.logger.fatal(`could not connect to database "${this.name}"`, error); 102 | this.emit('error', error, this); 103 | }); 104 | this.emit('connect', this); 105 | return this.connectPromise; 106 | } 107 | 108 | public async disconnect(): Promise { 109 | if (this.status === ConnectionStatus.DISCONNECTED) { 110 | return; 111 | } 112 | this.connectPromise = null; 113 | this.status = ConnectionStatus.DISCONNECTED; 114 | this.emit('disconnect:start', this); 115 | try { 116 | await this.client.close(); 117 | this.emit('disconnect', this); 118 | } catch (error) { 119 | this.emit('disconnect:error', error, this); 120 | throw error; 121 | } 122 | } 123 | 124 | public async database(): Promise { 125 | return this._ensureDb(); 126 | } 127 | 128 | public async collection( 129 | collectionName: string, 130 | ): Promise> { 131 | const db = await this._ensureDb(); 132 | return db.collection(collectionName); 133 | } 134 | 135 | public async transaction( 136 | handler: ( 137 | session: ClientSession, 138 | db: Db, 139 | transactionEventEmitter: TransactionEventEmitter, 140 | ) => Promise, 141 | options?: TransactionOptions, 142 | ): Promise { 143 | const db = await this._ensureDb(); 144 | 145 | let session: ClientSession; 146 | const emitter = new TransactionEventEmitter(); 147 | 148 | return this.client 149 | .withSession((_session) => 150 | _session.withTransaction(async (_session) => { 151 | session = _session; 152 | return handler(session, db, emitter); 153 | }, options), 154 | ) 155 | .then( 156 | (result) => { 157 | // https://github.com/mongodb/node-mongodb-native/blob/v6.7.0/src/transactions.ts#L147 158 | // https://github.com/mongodb/node-mongodb-native/blob/v6.7.0/src/transactions.ts#L54 159 | // session.transaction.isCommitted is not a sufficient indicator, 160 | // because it's true if transaction commits or aborts. 161 | const isCommitted = session.transaction.isCommitted; 162 | const isAborted = 163 | // https://github.com/mongodb/node-mongodb-native/blob/v6.7.0/src/transactions.ts#L11 164 | Reflect.get(session.transaction, 'state') === 'TRANSACTION_ABORTED'; 165 | 166 | emitter.emit( 167 | isCommitted && isAborted ? 'abort' : 'commit', 168 | session, 169 | db, 170 | ); 171 | 172 | return result; 173 | // If an error occurs in this scope, 174 | // it will not be caught by this then's error handler, but by the caller's catch. 175 | // This is what we want, as an error in this scope should not trigger the abort event. 176 | }, 177 | (error) => { 178 | emitter.emit('abort', session, db, error); 179 | throw error; 180 | }, 181 | ); 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /src/Database/ConnectionManager.ts: -------------------------------------------------------------------------------- 1 | import { Exception } from '@poppinss/utils'; 2 | 3 | import type { LoggerContract } from '@ioc:Adonis/Core/Logger'; 4 | import type { 5 | ConnectionContract, 6 | ConnectionManagerContract, 7 | ConnectionNode, 8 | MongodbConnectionConfig, 9 | } from '@ioc:Zakodium/Mongodb/Database'; 10 | 11 | import { Connection } from './Connection'; 12 | 13 | export class ConnectionManager implements ConnectionManagerContract { 14 | public connections: ConnectionManagerContract['connections'] = new Map(); 15 | 16 | public constructor(private logger: LoggerContract) {} 17 | 18 | private validateConnection(connectionName: string): ConnectionNode { 19 | validateConnectionName(connectionName); 20 | const connection = this.connections.get(connectionName); 21 | if (!connection) { 22 | throw new Exception( 23 | `no MongoDB connection registered with name "${connectionName}"`, 24 | 500, 25 | 'E_NO_MONGODB_CONNECTION', 26 | ); 27 | } 28 | return connection; 29 | } 30 | 31 | private handleConnect(connection: ConnectionContract): void { 32 | const connectionNode = this.connections.get(connection.name); 33 | if (connectionNode) { 34 | connectionNode.state = 'open'; 35 | } 36 | } 37 | 38 | private handleClose(connection: ConnectionContract): void { 39 | const connectionNode = this.connections.get(connection.name); 40 | if (connectionNode) { 41 | connectionNode.state = 'closed'; 42 | } 43 | } 44 | 45 | private handleClosing(connection: ConnectionContract): void { 46 | const connectionNode = this.connections.get(connection.name); 47 | if (connectionNode) { 48 | connectionNode.state = 'closing'; 49 | } 50 | } 51 | 52 | public add(connectionName: string, config: MongodbConnectionConfig): void { 53 | validateConnectionName(connectionName); 54 | if (this.connections.has(connectionName)) { 55 | throw new Error( 56 | `a connection with name "${connectionName}" already exists`, 57 | ); 58 | } 59 | 60 | const connection = new Connection(connectionName, config, this.logger); 61 | connection.on('connect', (connection) => this.handleConnect(connection)); 62 | connection.on('error', (_, connection) => this.handleClose(connection)); 63 | connection.on('disconnect', (connection) => this.handleClose(connection)); 64 | connection.on('disconnect:start', (connection) => 65 | this.handleClosing(connection), 66 | ); 67 | connection.on('disconnect:error', (_, connection) => 68 | this.handleClosing(connection), 69 | ); 70 | 71 | this.connections.set(connectionName, { 72 | name: connectionName, 73 | config, 74 | connection, 75 | state: 'registered', 76 | }); 77 | } 78 | 79 | public connect(connectionName: string): void { 80 | const connection = this.validateConnection(connectionName); 81 | // Connection error is handled by the `error` event listener. 82 | // eslint-disable-next-line @typescript-eslint/no-floating-promises 83 | connection.connection.connect(); 84 | } 85 | 86 | public get(connectionName: string): ConnectionNode { 87 | const connection = this.validateConnection(connectionName); 88 | return connection; 89 | } 90 | 91 | public has(connectionName: string): boolean { 92 | validateConnectionName(connectionName); 93 | return this.connections.has(connectionName); 94 | } 95 | 96 | public isConnected(connectionName: string): boolean { 97 | const connection = this.validateConnection(connectionName); 98 | return connection.state === 'open'; 99 | } 100 | 101 | public async close(connectionName: string): Promise { 102 | const connection = this.validateConnection(connectionName); 103 | return connection.connection.disconnect(); 104 | } 105 | 106 | public async closeAll(): Promise { 107 | await Promise.all( 108 | [...this.connections.values()].map((connection) => 109 | connection.connection.disconnect(), 110 | ), 111 | ); 112 | } 113 | } 114 | 115 | function validateConnectionName(connectionName: string): void { 116 | if (typeof connectionName !== 'string' || connectionName === '') { 117 | throw new TypeError('connectionName must be a non-empty string'); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/Database/Database.ts: -------------------------------------------------------------------------------- 1 | import type { ClientSession, Db, TransactionOptions } from 'mongodb'; 2 | 3 | import type { LoggerContract } from '@ioc:Adonis/Core/Logger'; 4 | import type { 5 | ConnectionContract, 6 | ConnectionManagerContract, 7 | DatabaseContract, 8 | MongodbConfig, 9 | } from '@ioc:Zakodium/Mongodb/Database'; 10 | 11 | import { ConnectionManager } from './ConnectionManager'; 12 | 13 | export class Database implements DatabaseContract { 14 | public readonly manager: ConnectionManagerContract; 15 | public readonly primaryConnectionName: string; 16 | 17 | public constructor( 18 | private config: MongodbConfig, 19 | private logger: LoggerContract, 20 | ) { 21 | if (typeof config.connection !== 'string') { 22 | throw new TypeError('config.connection must be a string'); 23 | } 24 | if (typeof config.connections !== 'object' || config.connections === null) { 25 | throw new TypeError('config.connections must be an object'); 26 | } 27 | 28 | this.primaryConnectionName = config.connection; 29 | if (typeof config.connections[this.primaryConnectionName] !== 'object') { 30 | throw new TypeError( 31 | `config.connections must contain a key with the primary connection name (${this.primaryConnectionName})`, 32 | ); 33 | } 34 | 35 | this.manager = new ConnectionManager(this.logger); 36 | this.registerConnections(); 37 | } 38 | 39 | private registerConnections(): void { 40 | const config = this.config.connections; 41 | for (const [connectionName, connectionConfig] of Object.entries(config)) { 42 | this.manager.add(connectionName, connectionConfig); 43 | } 44 | } 45 | 46 | public connection( 47 | connectionName = this.primaryConnectionName, 48 | ): ConnectionContract { 49 | return this.manager.get(connectionName).connection; 50 | } 51 | 52 | public transaction( 53 | handler: (client: ClientSession, db: Db) => Promise, 54 | options?: TransactionOptions, 55 | ): Promise { 56 | const client = this.connection(); 57 | return client.transaction(handler, options); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Database/TransactionEventEmitter.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'node:events'; 2 | 3 | import type { ClientSession, Db } from 'mongodb'; 4 | 5 | export interface TransactionEvents { 6 | commit: [session: ClientSession, db: Db]; 7 | abort: [session: ClientSession, db: Db, error?: Error]; 8 | } 9 | 10 | export class TransactionEventEmitter extends EventEmitter {} 11 | -------------------------------------------------------------------------------- /src/Migration.ts: -------------------------------------------------------------------------------- 1 | import type { Logger } from '@poppinss/cliui/build/src/Logger'; 2 | import type { 3 | ClientSession, 4 | CreateIndexesOptions, 5 | Db, 6 | DropIndexesOptions, 7 | IndexSpecification, 8 | } from 'mongodb'; 9 | 10 | import type { 11 | ConnectionContract, 12 | DatabaseContract, 13 | } from '@ioc:Zakodium/Mongodb/Database'; 14 | 15 | enum MigrationType { 16 | DropCollection = 'DropCollection', 17 | CreateCollection = 'CreateCollection', 18 | DropIndex = 'DropIndex', 19 | CreateIndex = 'CreateIndex', 20 | Custom = 'Custom', 21 | } 22 | 23 | interface DropCollectionOperation { 24 | type: MigrationType.DropCollection; 25 | collectionName: string; 26 | } 27 | 28 | interface CreateCollectionOperation { 29 | type: MigrationType.CreateCollection; 30 | collectionName: string; 31 | } 32 | 33 | interface DropIndexOperation { 34 | type: MigrationType.DropIndex; 35 | collectionName: string; 36 | indexName: string; 37 | options?: DropIndexesOptions; 38 | } 39 | 40 | interface CreateIndexOperation { 41 | type: MigrationType.CreateIndex; 42 | collectionName: string; 43 | index: IndexSpecification; 44 | options?: CreateIndexesOptions; 45 | } 46 | 47 | interface CustomOperation { 48 | type: MigrationType.Custom; 49 | callback: (db: Db, session: ClientSession) => Promise; 50 | } 51 | 52 | type MigrationOperation = 53 | | DropCollectionOperation 54 | | CreateCollectionOperation 55 | | DropIndexOperation 56 | | CreateIndexOperation 57 | | CustomOperation; 58 | 59 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 60 | export default function createMigration(Database: DatabaseContract): any { 61 | abstract class Migration { 62 | private $operations: MigrationOperation[] = []; 63 | private $connection: ConnectionContract; 64 | private $logger: Logger; 65 | private $collectionList: string[]; 66 | 67 | public constructor(connection: string | undefined, logger: Logger) { 68 | this.$connection = Database.connection(connection); 69 | this.$logger = logger; 70 | } 71 | 72 | public createCollections(collectionNames: string[]): void { 73 | for (const collectionName of collectionNames) { 74 | this.createCollection(collectionName); 75 | } 76 | } 77 | 78 | public dropCollection(collectionName: string): void { 79 | this.$operations.push({ 80 | type: MigrationType.DropCollection, 81 | collectionName, 82 | }); 83 | } 84 | 85 | public createCollection(collectionName: string): void { 86 | this.$operations.push({ 87 | type: MigrationType.CreateCollection, 88 | collectionName, 89 | }); 90 | } 91 | 92 | public dropIndex( 93 | collectionName: string, 94 | indexName: string, 95 | options?: DropIndexesOptions, 96 | ): void { 97 | this.$operations.push({ 98 | type: MigrationType.DropIndex, 99 | collectionName, 100 | indexName, 101 | options, 102 | }); 103 | } 104 | 105 | public createIndex( 106 | collectionName: string, 107 | index: IndexSpecification, 108 | options?: CreateIndexesOptions, 109 | ): void { 110 | this.$operations.push({ 111 | type: MigrationType.CreateIndex, 112 | collectionName, 113 | index, 114 | options, 115 | }); 116 | } 117 | 118 | public defer(callback: (db: Db, session: ClientSession) => Promise) { 119 | this.$operations.push({ 120 | type: MigrationType.Custom, 121 | callback, 122 | }); 123 | } 124 | 125 | public async execUp(session: ClientSession): Promise { 126 | this.up(); 127 | await this._createCollections(session); 128 | await this._dropIndexes(session); 129 | await this._createIndexes(session); 130 | await this._executeDeferred(session); 131 | await this._dropCollections(); 132 | } 133 | 134 | private async _listCollections() { 135 | if (this.$collectionList) return this.$collectionList; 136 | const db = await this.$connection.database(); 137 | const list = await db 138 | .listCollections(undefined, { 139 | nameOnly: true, 140 | }) 141 | .toArray(); 142 | this.$collectionList = list.map((element) => element.name); 143 | return this.$collectionList; 144 | } 145 | 146 | private async _dropCollections(): Promise { 147 | const db = await this.$connection.database(); 148 | for (const op of this.$operations.filter(isDropCollection)) { 149 | this.$logger.info(`Dropping collection ${op.collectionName}`); 150 | await db.dropCollection(op.collectionName); 151 | } 152 | } 153 | 154 | private async _createCollections(session: ClientSession): Promise { 155 | const db = await this.$connection.database(); 156 | for (const op of this.$operations.filter(isCreateCollection)) { 157 | this.$logger.info(`Creating collection ${op.collectionName}`); 158 | await db.createCollection(op.collectionName, { 159 | session, 160 | }); 161 | } 162 | } 163 | 164 | private async _executeDeferred(session: ClientSession): Promise { 165 | const db = await this.$connection.database(); 166 | for (const op of this.$operations.filter(isCustom)) { 167 | await op.callback(db, session); 168 | } 169 | } 170 | 171 | private async _dropIndexes(session: ClientSession): Promise { 172 | const db = await this.$connection.database(); 173 | for (const op of this.$operations.filter(isDropIndex)) { 174 | this.$logger.info( 175 | `Dropping index ${op.indexName} on ${op.collectionName}`, 176 | ); 177 | const collection = db.collection(op.collectionName); 178 | // Index deletion cannot be done in a transaction. 179 | await collection.dropIndex(op.indexName, { ...op.options, session }); 180 | } 181 | } 182 | 183 | private async _createIndexes(session: ClientSession): Promise { 184 | const db = await this.$connection.database(); 185 | const collections = await this._listCollections(); 186 | for (const op of this.$operations.filter(isCreateIndex)) { 187 | this.$logger.info(`Creating index on ${op.collectionName}`); 188 | await db.createIndex(op.collectionName, op.index, { 189 | ...op.options, 190 | // Index creation will fail if collection pre-exists the transaction. 191 | session: collections.includes(op.collectionName) 192 | ? undefined 193 | : session, 194 | }); 195 | } 196 | } 197 | 198 | public abstract up(): void; 199 | public afterUpSuccess?(): void; 200 | } 201 | 202 | return Migration; 203 | } 204 | 205 | function isDropCollection( 206 | op: MigrationOperation, 207 | ): op is DropCollectionOperation { 208 | return op.type === MigrationType.DropCollection; 209 | } 210 | 211 | function isCreateCollection( 212 | op: MigrationOperation, 213 | ): op is CreateCollectionOperation { 214 | return op.type === MigrationType.CreateCollection; 215 | } 216 | 217 | function isCreateIndex(op: MigrationOperation): op is CreateIndexOperation { 218 | return op.type === MigrationType.CreateIndex; 219 | } 220 | 221 | function isDropIndex(op: MigrationOperation): op is DropIndexOperation { 222 | return op.type === MigrationType.DropIndex; 223 | } 224 | 225 | function isCustom(op: MigrationOperation): op is CustomOperation { 226 | return op.type === MigrationType.Custom; 227 | } 228 | -------------------------------------------------------------------------------- /src/Model/Model.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert'; 2 | 3 | import { defineStaticProperty, Exception } from '@poppinss/utils'; 4 | import { cloneDeep, isEqual, pickBy, snakeCase } from 'lodash'; 5 | import type { 6 | BulkWriteOptions, 7 | ClientSession, 8 | Collection, 9 | CountDocumentsOptions, 10 | CountOptions, 11 | DeleteOptions, 12 | DistinctOptions, 13 | Document, 14 | ExplainVerbosityLike, 15 | Filter, 16 | FindOptions, 17 | InsertOneOptions, 18 | SortDirection, 19 | } from 'mongodb'; 20 | import pluralize from 'pluralize'; 21 | 22 | import type { DatabaseContract } from '@ioc:Zakodium/Mongodb/Database'; 23 | import type { 24 | ComputedOptions, 25 | FieldOptions, 26 | ForbiddenQueryOptions, 27 | ModelAdapterOptions, 28 | ModelAttributes, 29 | ModelDocumentOptions, 30 | MongodbDocument, 31 | NoExtraProperties, 32 | QueryContract, 33 | QuerySortObject, 34 | } from '@ioc:Zakodium/Mongodb/Odm'; 35 | 36 | import { proxyHandler } from './proxyHandler'; 37 | 38 | function mergeDriverOptions< 39 | DriverOptionType extends { session?: ClientSession | undefined }, 40 | >(options?: ModelAdapterOptions): DriverOptionType { 41 | if (!options) return {} as DriverOptionType; 42 | return { 43 | ...options.driverOptions, 44 | session: options.client, 45 | } as DriverOptionType; 46 | } 47 | 48 | interface QueryLocalOptions { 49 | sort: QuerySortObject; 50 | skip?: number; 51 | limit?: number; 52 | } 53 | 54 | const forbiddenQueryOptions: ForbiddenQueryOptions[] = [ 55 | 'sort', 56 | 'skip', 57 | 'limit', 58 | 'explain', 59 | ]; 60 | 61 | class Query 62 | implements QueryContract> 63 | { 64 | private localCustomSort = false; 65 | private localOptions: QueryLocalOptions = { 66 | sort: { 67 | _id: 'descending', 68 | }, 69 | }; 70 | 71 | private getDriverOptions(): FindOptions< 72 | ModelAttributes> 73 | > { 74 | return { ...mergeDriverOptions(this.options), ...this.localOptions }; 75 | } 76 | 77 | public constructor( 78 | private filter: Filter>>, 79 | private options: 80 | | ModelAdapterOptions< 81 | Omit< 82 | FindOptions>>, 83 | ForbiddenQueryOptions 84 | > 85 | > 86 | | undefined, 87 | // eslint-disable-next-line @typescript-eslint/naming-convention 88 | private ModelConstructor: ModelType, 89 | ) { 90 | if (options?.driverOptions) { 91 | for (const key of forbiddenQueryOptions) { 92 | if (key in options.driverOptions) { 93 | throw new TypeError(`${key} is not allowed in query's driverOptions`); 94 | } 95 | } 96 | } 97 | } 98 | 99 | public sort(sort: QuerySortObject): this { 100 | if (!this.localCustomSort) { 101 | this.localCustomSort = true; 102 | this.localOptions.sort = sort; 103 | } else { 104 | Object.assign(this.localOptions.sort, sort); 105 | } 106 | return this; 107 | } 108 | 109 | public sortBy(field: string, direction: SortDirection = 'ascending'): this { 110 | return this.sort({ [field]: direction }); 111 | } 112 | 113 | public skip(skip: number): this { 114 | if (!Number.isInteger(skip)) { 115 | throw new TypeError(`skip must be an integer`); 116 | } 117 | if (skip < 0) { 118 | throw new TypeError(`skip must be at least zero`); 119 | } 120 | this.localOptions.skip = skip; 121 | return this; 122 | } 123 | 124 | public limit(limit: number): this { 125 | if (!Number.isInteger(limit)) { 126 | throw new TypeError(`limit must be an integer`); 127 | } 128 | if (limit < 1) { 129 | throw new TypeError(`limit must be at least one`); 130 | } 131 | this.localOptions.limit = limit; 132 | return this; 133 | } 134 | 135 | public async first(): Promise | null> { 136 | const collection = await this.ModelConstructor.getCollection(); 137 | const driverOptions = this.getDriverOptions(); 138 | const result = await collection.findOne(this.filter, driverOptions); 139 | if (result === null) { 140 | return null; 141 | } 142 | const instance = new this.ModelConstructor( 143 | result, 144 | { 145 | // @ts-expect-error Unavoidable error. 146 | collection, 147 | session: driverOptions.session, 148 | }, 149 | true, 150 | ) as InstanceType; 151 | return instance; 152 | } 153 | 154 | public async firstOrFail(): Promise> { 155 | const result = await this.first(); 156 | if (!result) { 157 | throw new Exception('Document not found', 404, 'E_DOCUMENT_NOT_FOUND'); 158 | } 159 | return result; 160 | } 161 | 162 | public async all(): Promise>> { 163 | const collection = await this.ModelConstructor.getCollection(); 164 | const driverOptions = this.getDriverOptions(); 165 | const result = await collection.find(this.filter, driverOptions).toArray(); 166 | return result.map( 167 | (value) => 168 | new this.ModelConstructor( 169 | value, 170 | { 171 | // @ts-expect-error Unavoidable error. 172 | collection, 173 | session: driverOptions.session, 174 | }, 175 | true, 176 | ) as InstanceType, 177 | ); 178 | } 179 | 180 | public async count(): Promise { 181 | const collection = await this.ModelConstructor.getCollection(); 182 | const driverOptions = this.getDriverOptions(); 183 | return collection.countDocuments( 184 | this.filter, 185 | driverOptions as CountOptions, 186 | ); 187 | } 188 | 189 | public async distinct(key: string): Promise { 190 | const collection = await this.ModelConstructor.getCollection(); 191 | const driverOptions = this.getDriverOptions(); 192 | const result = await collection.distinct( 193 | key, 194 | this.filter, 195 | driverOptions as DistinctOptions, 196 | ); 197 | return result; 198 | } 199 | 200 | public async explain(verbosity?: ExplainVerbosityLike): Promise { 201 | const collection = await this.ModelConstructor.getCollection(); 202 | const driverOptions = this.getDriverOptions(); 203 | return collection 204 | .find(this.filter, driverOptions) 205 | .explain(verbosity as ExplainVerbosityLike); 206 | } 207 | 208 | public async *[Symbol.asyncIterator](): AsyncIterableIterator< 209 | InstanceType 210 | > { 211 | const collection = await this.ModelConstructor.getCollection(); 212 | const driverOptions = this.getDriverOptions(); 213 | for await (const value of collection.find(this.filter, driverOptions)) { 214 | if (value === null) continue; 215 | yield new this.ModelConstructor( 216 | value, 217 | { 218 | // @ts-expect-error Unavoidable error. 219 | collection, 220 | session: driverOptions.session, 221 | }, 222 | true, 223 | ) as InstanceType; 224 | } 225 | } 226 | } 227 | 228 | function computeCollectionName(constructorName: string): string { 229 | return snakeCase(pluralize(constructorName)); 230 | } 231 | 232 | interface InternalModelConstructorOptions { 233 | collection: Collection>>; 234 | session?: ClientSession; 235 | } 236 | 237 | function ensureSort(options?: FindOptions): void { 238 | if (!options || options.sort) return; 239 | options.sort = { 240 | _id: -1, 241 | }; 242 | } 243 | 244 | interface DataToSet { 245 | [key: string]: unknown; 246 | createdAt: Date; 247 | updatedAt: Date; 248 | } 249 | 250 | export class BaseModel { 251 | public static readonly connection?: string; 252 | public static readonly collectionName: string; 253 | public static booted: boolean; 254 | public static readonly $fieldsDefinitions: Map; 255 | 256 | /** 257 | * A set of properties marked as computed. Computed properties are included in 258 | * the `toJSON` result, else they behave the same way as any other instance 259 | * property. 260 | */ 261 | public static $computedDefinitions: Map; 262 | 263 | public readonly _id: unknown; 264 | public readonly createdAt: Date; 265 | public readonly updatedAt: Date; 266 | 267 | public $original: Record; 268 | public $attributes: Record; 269 | 270 | public $isPersisted = false; 271 | public $isLocal = true; 272 | public $isDeleted = false; 273 | 274 | protected $collection: Collection< 275 | ModelAttributes> 276 | > | null = null; 277 | protected $options: InternalModelConstructorOptions; 278 | 279 | public constructor( 280 | dbObj?: Record, 281 | options?: InternalModelConstructorOptions, 282 | alreadyExists = false, 283 | ) { 284 | if (dbObj) { 285 | this.$original = alreadyExists ? cloneDeep(dbObj) : {}; 286 | this.$attributes = dbObj; 287 | } else { 288 | this.$original = {}; 289 | this.$attributes = {}; 290 | } 291 | 292 | if (options !== undefined) { 293 | this.$options = options; 294 | this.$collection = options.collection; 295 | } 296 | 297 | if (alreadyExists) { 298 | this.$isPersisted = true; 299 | this.$isLocal = false; 300 | } 301 | 302 | // eslint-disable-next-line no-constructor-return 303 | return new Proxy(this, proxyHandler); 304 | } 305 | 306 | public static $database: DatabaseContract; 307 | public static $setDatabase(database: DatabaseContract): void { 308 | this.$database = database; 309 | } 310 | 311 | public static $addField( 312 | name: string, 313 | options: Partial = {}, 314 | ): FieldOptions { 315 | this.$fieldsDefinitions.set(name, options); 316 | return options; 317 | } 318 | 319 | public static $hasField(name: string): boolean { 320 | return this.$fieldsDefinitions.has(name); 321 | } 322 | 323 | public static $getField(name: string): FieldOptions | undefined { 324 | return this.$fieldsDefinitions.get(name); 325 | } 326 | 327 | /** 328 | * Adds a computed node 329 | */ 330 | public static $addComputed(name: string, options: Partial) { 331 | const computed: ComputedOptions = { 332 | serializeAs: 333 | options.serializeAs === null ? null : options.serializeAs || name, 334 | meta: options.meta, 335 | }; 336 | this.$computedDefinitions.set(name, computed); 337 | return computed; 338 | } 339 | 340 | /** 341 | * Find if some property is marked as computed 342 | */ 343 | public static $hasComputed(name: string): boolean { 344 | return this.$computedDefinitions.has(name); 345 | } 346 | 347 | /** 348 | * Get computed node 349 | */ 350 | public static $getComputed(name: string): ComputedOptions | undefined { 351 | return this.$computedDefinitions.get(name); 352 | } 353 | 354 | public static boot(): void { 355 | /** 356 | * Define the property when not defined on self. This makes sure that all 357 | * subclasses boot on their own. 358 | */ 359 | if (!Object.hasOwn(this, 'booted')) { 360 | this.booted = false; 361 | } 362 | 363 | /** 364 | * No-op when already booted. 365 | */ 366 | if (this.booted) { 367 | return; 368 | } 369 | 370 | this.booted = true; 371 | 372 | defineStaticProperty(this, BaseModel, { 373 | propertyName: 'collectionName', 374 | defaultValue: computeCollectionName(this.name), 375 | strategy: 'define', 376 | }); 377 | 378 | defineStaticProperty(this, BaseModel, { 379 | propertyName: '$fieldsDefinitions', 380 | defaultValue: new Map(), 381 | strategy: 'inherit', 382 | }); 383 | 384 | /** 385 | * Define computed properties 386 | */ 387 | defineStaticProperty(this, BaseModel, { 388 | propertyName: '$computedDefinitions', 389 | defaultValue: new Map(), 390 | strategy: 'inherit', 391 | }); 392 | } 393 | 394 | public static async count( 395 | this: ModelType, 396 | filter: Filter>>, 397 | options?: ModelAdapterOptions, 398 | ): Promise { 399 | const collection = await this.getCollection(); 400 | const driverOptions = mergeDriverOptions(options); 401 | return collection.countDocuments(filter, driverOptions); 402 | } 403 | 404 | public static async create( 405 | this: ModelType, 406 | value: Partial>>, 407 | options?: ModelAdapterOptions, 408 | ): Promise> { 409 | const collection = await this.getCollection(); 410 | const driverOptions = mergeDriverOptions(options); 411 | const instance = new this(value, { 412 | // @ts-expect-error Unavoidable error. 413 | collection, 414 | session: driverOptions.session, 415 | }) as InstanceType; 416 | await instance.save({ driverOptions }); 417 | return instance; 418 | } 419 | 420 | public static async createMany( 421 | this: ModelType, 422 | values: Array>>>, 423 | options?: ModelAdapterOptions, 424 | ): Promise>> { 425 | const collection = await this.getCollection(); 426 | const driverOptions = mergeDriverOptions(options); 427 | const instances = values.map( 428 | (value) => 429 | new this(value, { 430 | // @ts-expect-error Unavoidable error. 431 | collection, 432 | session: driverOptions.session, 433 | }) as InstanceType, 434 | ); 435 | for (const instance of instances) { 436 | await instance.save({ driverOptions }); 437 | } 438 | return instances; 439 | } 440 | 441 | public static async find( 442 | this: ModelType, 443 | id: InstanceType['_id'], 444 | options?: ModelAdapterOptions< 445 | FindOptions>> 446 | >, 447 | ): Promise | null> { 448 | const collection = await this.getCollection(); 449 | const driverOptions = mergeDriverOptions(options); 450 | const filter = { _id: id } as Filter< 451 | ModelAttributes> 452 | >; 453 | const result = await collection.findOne(filter, driverOptions); 454 | if (result === null) return null; 455 | const instance = new this( 456 | result, 457 | // @ts-expect-error Unavoidable error. 458 | { collection, session: driverOptions.session }, 459 | true, 460 | ) as InstanceType; 461 | return instance; 462 | } 463 | 464 | public static async findOrFail( 465 | this: ModelType, 466 | id: InstanceType['_id'], 467 | options?: ModelAdapterOptions< 468 | FindOptions>> 469 | >, 470 | ): Promise> { 471 | const result = await this.find(id, options); 472 | if (!result) { 473 | throw new Exception('Document not found', 404, 'E_DOCUMENT_NOT_FOUND'); 474 | } 475 | return result; 476 | } 477 | 478 | public static async findBy( 479 | this: ModelType, 480 | key: string, 481 | value: unknown, 482 | options?: ModelAdapterOptions< 483 | FindOptions>> 484 | >, 485 | ): Promise | null> { 486 | const collection = await this.getCollection(); 487 | const driverOptions = mergeDriverOptions(options); 488 | const filter = { [key]: value } as Filter< 489 | ModelAttributes> 490 | >; 491 | const result = await collection.findOne(filter, driverOptions); 492 | if (result === null) return null; 493 | const instance = new this( 494 | result, 495 | // @ts-expect-error Unavoidable error. 496 | { collection, session: driverOptions.session }, 497 | true, 498 | ) as InstanceType; 499 | return instance; 500 | } 501 | 502 | public static async findByOrFail( 503 | this: ModelType, 504 | key: string, 505 | value: unknown, 506 | options?: ModelAdapterOptions< 507 | FindOptions>> 508 | >, 509 | ): Promise> { 510 | const result = await this.findBy(key, value, options); 511 | if (!result) { 512 | throw new Exception('Document not found', 404, 'E_DOCUMENT_NOT_FOUND'); 513 | } 514 | return result; 515 | } 516 | 517 | public static async findMany( 518 | this: ModelType, 519 | ids: Array['_id']>, 520 | options?: ModelAdapterOptions< 521 | FindOptions>> 522 | >, 523 | ): Promise>> { 524 | const collection = await this.getCollection(); 525 | const driverOptions = mergeDriverOptions(options); 526 | const result = await collection 527 | // @ts-expect-error Unavoidable error. 528 | .find({ _id: { $in: ids } }, driverOptions) 529 | .toArray(); 530 | const instances = result.map( 531 | (result) => 532 | new this(result, { 533 | // @ts-expect-error Unavoidable error. 534 | collection, 535 | session: driverOptions.session, 536 | }) as InstanceType, 537 | ); 538 | return instances; 539 | } 540 | 541 | public static async all( 542 | this: ModelType, 543 | options?: ModelAdapterOptions< 544 | FindOptions>> 545 | >, 546 | ): Promise>> { 547 | const collection = await this.getCollection(); 548 | const driverOptions = mergeDriverOptions(options); 549 | ensureSort(driverOptions); 550 | const result = await collection.find({}, driverOptions).toArray(); 551 | const instances = result.map( 552 | (result) => 553 | new this(result, { 554 | // @ts-expect-error Unavoidable error. 555 | collection, 556 | session: driverOptions.session, 557 | }) as InstanceType, 558 | ); 559 | return instances; 560 | } 561 | 562 | public static query( 563 | this: ModelType, 564 | filter: Filter>> = {}, 565 | options?: ModelAdapterOptions< 566 | Omit< 567 | FindOptions>>, 568 | ForbiddenQueryOptions 569 | > 570 | >, 571 | ): Query { 572 | return new Query(filter, options, this); 573 | } 574 | 575 | public static async getCollection( 576 | this: ModelType, 577 | connection = this.connection, 578 | ): Promise>>> { 579 | assert(this.$database, 'Model should only be accessed from IoC container'); 580 | const connectionInstance = this.$database.connection(connection); 581 | return connectionInstance.collection(this.collectionName); 582 | } 583 | 584 | public [Symbol.for('nodejs.util.inspect.custom')](): unknown { 585 | return { 586 | Model: this.constructor.name, 587 | $original: this.$original, 588 | $attributes: this.$attributes, 589 | $isPersisted: this.$isPersisted, 590 | $isNew: this.$isNew, 591 | $isLocal: this.$isLocal, 592 | $isDeleted: this.$isDeleted, 593 | $dirty: this.$dirty, 594 | $isDirty: this.$isDirty, 595 | $isTransaction: this.$isTransaction, 596 | }; 597 | } 598 | 599 | public get $isNew(): boolean { 600 | return !this.$isPersisted; 601 | } 602 | 603 | public get $dirty(): Partial> { 604 | return pickBy(this.$attributes, (value, key) => { 605 | return ( 606 | this.$original[key] === undefined || 607 | !isEqual(this.$original[key], value) 608 | ); 609 | }) as Partial>; 610 | } 611 | 612 | public $ensureNotDeleted(): void { 613 | if (this.$isDeleted) { 614 | throw new Exception('Document was deleted', 500, 'E_DOCUMENT_DELETED'); 615 | } 616 | } 617 | 618 | public async $ensureCollection(): Promise< 619 | Collection>> 620 | > { 621 | if (this.$collection !== null) { 622 | return this.$collection; 623 | } 624 | 625 | const constructor = this.constructor as typeof BaseModel; 626 | this.$collection = 627 | (await constructor.getCollection()) as unknown as Collection< 628 | ModelAttributes> 629 | >; 630 | return this.$collection; 631 | } 632 | 633 | public $prepareToSet() { 634 | const dirty = this.$dirty; 635 | const dirtyEntries = Object.entries(dirty); 636 | if (dirtyEntries.length === 0 && this.$isPersisted) { 637 | return null; 638 | } 639 | 640 | // We cheat a little bit with the assertion. This is necessary because the 641 | // value returned by this function can be used in a MongoDB update query 642 | // which shouldn't reset the createdAt field. 643 | const toSet = {} as DataToSet; 644 | const now = new Date(); 645 | if (this.$attributes.createdAt === undefined) { 646 | this.$attributes.createdAt = now; 647 | toSet.createdAt = now; 648 | } 649 | this.$attributes.updatedAt = now; 650 | toSet.updatedAt = now; 651 | 652 | for (const [dirtyKey, dirtyValue] of dirtyEntries) { 653 | toSet[dirtyKey] = dirtyValue; 654 | } 655 | return toSet; 656 | } 657 | 658 | public get id() { 659 | return this.$attributes._id; 660 | } 661 | 662 | public get $isDirty(): boolean { 663 | return Object.keys(this.$dirty).length > 0; 664 | } 665 | 666 | public toJSON(): unknown { 667 | const Model = this.constructor as typeof BaseModel; 668 | 669 | const computed: Record = {}; 670 | for (const [key, def] of Model.$computedDefinitions.entries()) { 671 | if (def.serializeAs === null) continue; 672 | // @ts-expect-error polymorphic getter 673 | computed[def.serializeAs] = this[key]; 674 | } 675 | 676 | return { 677 | ...this.$attributes, 678 | ...computed, 679 | }; 680 | } 681 | 682 | public async save( 683 | options?: ModelDocumentOptions, 684 | ): Promise { 685 | this.$ensureNotDeleted(); 686 | const collection = await this.$ensureCollection(); 687 | 688 | const toSet = this.$prepareToSet(); 689 | if (toSet === null) return false; 690 | const driverOptions = { 691 | ...options?.driverOptions, 692 | session: this.$options?.session, 693 | }; 694 | if (!this.$isPersisted) { 695 | // @ts-expect-error Unavoidable error, as _id is unknown here. 696 | const result = await collection.insertOne(toSet, driverOptions); 697 | this.$attributes._id = result.insertedId; 698 | this.$isPersisted = true; 699 | } else { 700 | await collection.updateOne( 701 | // @ts-expect-error Unavoidable error, as _id is unknown here. 702 | { _id: this.$attributes._id }, 703 | { $set: toSet }, 704 | driverOptions, 705 | ); 706 | } 707 | this.$original = cloneDeep(this.$attributes); 708 | return true; 709 | } 710 | 711 | public async delete( 712 | options?: ModelDocumentOptions, 713 | ): Promise { 714 | this.$ensureNotDeleted(); 715 | const collection = await this.$ensureCollection(); 716 | const driverOptions = { 717 | ...options?.driverOptions, 718 | session: this.$options?.session, 719 | }; 720 | const result = await collection.deleteOne( 721 | { 722 | // @ts-expect-error Unavoidable error, as _id is unknown here. 723 | _id: this.$attributes._id, 724 | }, 725 | driverOptions, 726 | ); 727 | this.$isDeleted = true; 728 | return result.deletedCount === 1; 729 | } 730 | 731 | public merge, '_id'>>>( 732 | values: NoExtraProperties, '_id'>>, T>, 733 | ): this { 734 | for (const [key, value] of Object.entries(values)) { 735 | this.$attributes[key] = value; 736 | } 737 | return this; 738 | } 739 | 740 | public fill, '_id'>>>( 741 | values: NoExtraProperties, '_id'>>, T>, 742 | ) { 743 | const createdAt = this.$attributes.createdAt; 744 | this.$attributes = { 745 | _id: this.id, 746 | }; 747 | if (createdAt) this.$attributes.createdAt = createdAt; 748 | return this.merge(values); 749 | } 750 | 751 | public get $trx(): ClientSession | undefined { 752 | return this.$options.session; 753 | } 754 | 755 | public get $isTransaction(): boolean { 756 | return Boolean(this.$trx); 757 | } 758 | 759 | public useTransaction(client: ClientSession): this { 760 | if (this.$isTransaction) { 761 | const model = this.constructor.name; 762 | const id = String(this.id); 763 | const message = this.$isNew 764 | ? `This new instance ${model} is already linked to a transaction` 765 | : `This instance ${id} ${model} is already linked to a transaction`; 766 | throw new Error(message); 767 | } 768 | 769 | this.$options.session = client; 770 | 771 | return this; 772 | } 773 | } 774 | 775 | export class BaseAutoIncrementModel extends BaseModel { 776 | declare public readonly _id: number; 777 | 778 | public async save( 779 | options?: ModelDocumentOptions, 780 | ): Promise { 781 | this.$ensureNotDeleted(); 782 | const collection = await this.$ensureCollection(); 783 | 784 | const toSet = this.$prepareToSet(); 785 | if (toSet === null) return false; 786 | const driverOptions = { 787 | ...options?.driverOptions, 788 | session: this.$options?.session, 789 | }; 790 | 791 | if (this._id === undefined) { 792 | const connection = BaseAutoIncrementModel.$database.connection(); 793 | const counterCollection = await connection.collection<{ count: number }>( 794 | '__adonis_mongodb_counters', 795 | ); 796 | 797 | const doc = await counterCollection.findOneAndUpdate( 798 | // @ts-expect-error Unavoidable error, as _id is unknown here. 799 | { _id: (this.constructor as typeof BaseModel).collectionName }, 800 | { $inc: { count: 1 } }, 801 | { ...driverOptions, upsert: true, returnDocument: 'after' }, 802 | ); 803 | assert(doc, 'upsert should always create a document'); 804 | toSet._id = doc.count; 805 | // @ts-expect-error Unavoidable error, as _id is unknown here. 806 | await collection.insertOne(toSet, driverOptions); 807 | this.$attributes._id = doc.count; 808 | this.$isPersisted = true; 809 | } else { 810 | await collection.updateOne( 811 | // @ts-expect-error Unavoidable error, as _id is unknown here. 812 | { _id: this.$attributes._id }, 813 | { $set: toSet }, 814 | driverOptions, 815 | ); 816 | } 817 | this.$original = cloneDeep(this.$attributes); 818 | return true; 819 | } 820 | } 821 | -------------------------------------------------------------------------------- /src/Model/__tests__/Model.query.test.ts: -------------------------------------------------------------------------------- 1 | import { setupDatabase } from '../../../test-utils/TestUtils'; 2 | import { field } from '../../Odm/decorators'; 3 | import { BaseAutoIncrementModel, BaseModel } from '../Model'; 4 | 5 | setupDatabase(); 6 | 7 | class TestModel extends BaseAutoIncrementModel { 8 | @field() 9 | public testField: string; 10 | 11 | @field() 12 | public otherField?: boolean; 13 | 14 | @field() 15 | public numberField: number; 16 | } 17 | 18 | class EmptyTestModel extends BaseModel { 19 | @field() 20 | public someField: string; 21 | } 22 | 23 | beforeAll(async () => { 24 | await TestModel.createMany([ 25 | { testField: 'test1', numberField: 1 }, 26 | { testField: 'test2', numberField: 1 }, 27 | { testField: 'test3', numberField: 1 }, 28 | { testField: 'test4', numberField: 2 }, 29 | { testField: 'test5', numberField: 2 }, 30 | ]); 31 | }); 32 | 33 | test('query.all', async () => { 34 | const results = await TestModel.query().all(); 35 | expect(results).toHaveLength(5); 36 | expect(results[0].testField).toBe('test5'); 37 | expect(results[0]).toBeInstanceOf(TestModel); 38 | }); 39 | 40 | test('query.first', async () => { 41 | const result = await TestModel.query().first(); 42 | expect(result).toBeInstanceOf(TestModel); 43 | expect((result as TestModel).testField).toBe('test5'); 44 | }); 45 | 46 | test('query.firstOrFail', async () => { 47 | const result = await TestModel.query().firstOrFail(); 48 | expect(result).toBeInstanceOf(TestModel); 49 | expect(result.testField).toBe('test5'); 50 | }); 51 | 52 | test('query.firstOrFail - uses the filter', async () => { 53 | const result = await TestModel.query({ testField: 'test2' }).firstOrFail(); 54 | expect(result).toBeInstanceOf(TestModel); 55 | expect(result.testField).toBe('test2'); 56 | }); 57 | 58 | test('query.firstOrFail - fail if collection is empty', async () => { 59 | await expect(EmptyTestModel.query().firstOrFail()).rejects.toThrow( 60 | /E_DOCUMENT_NOT_FOUND/, 61 | ); 62 | }); 63 | 64 | test('query.firstOrFail - fail if filter matches nothing', async () => { 65 | await expect( 66 | TestModel.query({ testField: 'bad' }).firstOrFail(), 67 | ).rejects.toThrow(/E_DOCUMENT_NOT_FOUND/); 68 | }); 69 | 70 | test('query.count - all', async () => { 71 | const count = await TestModel.query().count(); 72 | expect(count).toBe(5); 73 | }); 74 | 75 | test('query.count - with filter', async () => { 76 | const count = await TestModel.query({ 77 | testField: { $regex: /test[1-3]/ }, 78 | }).count(); 79 | expect(count).toBe(3); 80 | }); 81 | 82 | test('query.distinct', async () => { 83 | const results = await TestModel.query().distinct('testField'); 84 | expect(results).toHaveLength(5); 85 | expect(results[0]).toBe('test1'); 86 | }); 87 | 88 | test('query async iterator', async () => { 89 | const results = TestModel.query(); 90 | let count = 0; 91 | for await (const result of results) { 92 | expect(result).toBeInstanceOf(TestModel); 93 | // It should be sorted by default 94 | expect(result.testField).toBe(`test${5 - count}`); 95 | result.otherField = true; 96 | await result.save(); 97 | expect(result.otherField).toBe(true); 98 | count++; 99 | } 100 | expect(count).toBe(5); 101 | }); 102 | 103 | describe('query.sort', () => { 104 | it('should sort by descending _id by default', async () => { 105 | const result = await TestModel.query().firstOrFail(); 106 | expect(result._id).toBe(5); 107 | }); 108 | 109 | it('should sort by custom field with sort()', async () => { 110 | const result = await TestModel.query() 111 | .sort({ numberField: 1 }) 112 | .firstOrFail(); 113 | expect(result._id).toBe(1); 114 | }); 115 | 116 | it('should sort by custom field with sortBy()', async () => { 117 | const result = await TestModel.query() 118 | .sortBy('numberField', -1) 119 | .firstOrFail(); 120 | expect(result._id).toBe(4); 121 | }); 122 | 123 | it('should sort by combination of fields', async () => { 124 | const result = await TestModel.query() 125 | .sortBy('numberField', 1) 126 | .sort({ _id: 'desc' }) 127 | .firstOrFail(); 128 | expect(result._id).toBe(3); 129 | }); 130 | 131 | it('should sort by custom field ascending by default', async () => { 132 | const result = await TestModel.query().sortBy('numberField').firstOrFail(); 133 | expect(result._id).toBe(1); 134 | }); 135 | }); 136 | 137 | describe('query.skip', () => { 138 | it('should not skip by default', async () => { 139 | expect(await TestModel.query().count()).toBe(5); 140 | }); 141 | 142 | it('should not skip anything if zero is passed', async () => { 143 | expect(await TestModel.query().skip(0).count()).toBe(5); 144 | }); 145 | 146 | it('should skip everything if a big number is passed', async () => { 147 | expect(await TestModel.query().skip(1000).count()).toBe(0); 148 | }); 149 | 150 | it('should skip properly with smaller number', async () => { 151 | expect(await TestModel.query().skip(2).count()).toBe(3); 152 | }); 153 | 154 | it('should throw if skip is smaller than zero', async () => { 155 | expect(() => TestModel.query().skip(-1).count()).toThrow( 156 | /skip must be at least zero/, 157 | ); 158 | }); 159 | 160 | it('should throw if skip is not an integer', async () => { 161 | expect(() => TestModel.query().skip(1.5).count()).toThrow( 162 | /skip must be an integer/, 163 | ); 164 | }); 165 | }); 166 | 167 | describe('query.limit', () => { 168 | it('should not limit by default', async () => { 169 | expect(await TestModel.query().count()).toBe(5); 170 | }); 171 | 172 | it('should return everything with large limit', async () => { 173 | expect(await TestModel.query().limit(1000).count()).toBe(5); 174 | }); 175 | 176 | it('should limit properly with exact number', async () => { 177 | expect(await TestModel.query().limit(5).count()).toBe(5); 178 | }); 179 | 180 | it('should limit properly with smaller number', async () => { 181 | expect(await TestModel.query().limit(2).count()).toBe(2); 182 | }); 183 | 184 | it('should throw if limit is smaller than one', async () => { 185 | expect(() => TestModel.query().limit(0)).toThrow( 186 | /limit must be at least one/, 187 | ); 188 | }); 189 | 190 | it('should throw if limit is not an integer', async () => { 191 | expect(() => TestModel.query().limit(1.5)).toThrow( 192 | /limit must be an integer/, 193 | ); 194 | }); 195 | }); 196 | 197 | test('query.sort/skip/limit', async () => { 198 | const result = await TestModel.query() 199 | .sort({ _id: 'desc' }) 200 | .skip(1) 201 | .limit(2) 202 | .all(); 203 | expect(result).toHaveLength(2); 204 | expect(result[0]._id).toBe(4); 205 | expect(result[1]._id).toBe(3); 206 | }); 207 | 208 | test('query.explain', async () => { 209 | const result = await TestModel.query().explain(); 210 | expect(result.queryPlanner.winningPlan.inputStage.stage).toBe('IXSCAN'); 211 | }); 212 | 213 | test('query should pass additional driver options', async () => { 214 | const query = TestModel.query( 215 | { _id: 1 }, 216 | { 217 | driverOptions: { 218 | showRecordId: true, 219 | }, 220 | }, 221 | ); 222 | const result = await query.firstOrFail(); 223 | expect(result.$attributes.$recordId).toBe(1); 224 | }); 225 | 226 | test('query should throw if forbidden options are passed', () => { 227 | expect(() => 228 | // @ts-expect-error Testing bad options 229 | TestModel.query({}, { driverOptions: { sort: 'test' } }), 230 | ).toThrow(/sort is not allowed in query's driverOptions/); 231 | }); 232 | -------------------------------------------------------------------------------- /src/Model/__tests__/Model.test.ts: -------------------------------------------------------------------------------- 1 | import { inspect } from 'node:util'; 2 | 3 | import { ObjectId } from 'mongodb'; 4 | 5 | import { setupDatabase } from '../../../test-utils/TestUtils'; 6 | import { computed, field } from '../../Odm/decorators'; 7 | import { BaseAutoIncrementModel, BaseModel } from '../Model'; 8 | 9 | const db = setupDatabase(); 10 | 11 | class User extends BaseModel { 12 | @field() 13 | declare public _id: ObjectId | string; 14 | 15 | @field() 16 | public username: string; 17 | 18 | @field() 19 | public password: string; 20 | } 21 | 22 | class Post extends BaseAutoIncrementModel { 23 | @field() 24 | public title: string; 25 | 26 | @field() 27 | public content: string; 28 | 29 | public notAField?: string; 30 | 31 | @computed() 32 | public get titleUpperCase() { 33 | return this.title.toUpperCase(); 34 | } 35 | } 36 | 37 | class Empty extends BaseAutoIncrementModel {} 38 | Empty.boot(); 39 | 40 | class Something extends BaseModel { 41 | public static collectionName = 'somethingElse'; 42 | 43 | public test: boolean; 44 | } 45 | Something.boot(); 46 | 47 | let usernameCounter = 0; 48 | function nextUsername() { 49 | return `root${++usernameCounter}`; 50 | } 51 | 52 | let postTitleCounter = 0; 53 | function nextTitle() { 54 | return `post title ${++postTitleCounter}`; 55 | } 56 | 57 | describe('$hasField', () => { 58 | it('should return true if field exists', () => { 59 | expect(Post.$hasField('title')).toBe(true); 60 | }); 61 | 62 | it('should return false if field does not exist', () => { 63 | expect(Post.$hasField('notAField')).toBe(false); 64 | }); 65 | }); 66 | 67 | describe('$getField', () => { 68 | it('should return the field if it exists', () => { 69 | const field = Post.$getField('title'); 70 | expect(field).toStrictEqual({}); 71 | }); 72 | 73 | it('should return undefined if the field does not exist', () => { 74 | const field = Post.$getField('notAField'); 75 | expect(field).toBeUndefined(); 76 | }); 77 | }); 78 | 79 | test('can create', async () => { 80 | const newUser = await User.create({ 81 | username: nextUsername(), 82 | password: 'root', 83 | }); 84 | expect(newUser).toBeDefined(); 85 | }); 86 | 87 | test('get collection by model class', async () => { 88 | const collection = await User.getCollection(); 89 | expect(collection).toBeDefined(); 90 | }); 91 | 92 | test('count all', async () => { 93 | const count = await User.count({}); 94 | expect(count).toBe(1); 95 | }); 96 | 97 | test('find by id should work', async () => { 98 | const user = await User.create({ 99 | username: nextUsername(), 100 | password: 'root', 101 | }); 102 | const secondUser = await User.find(user._id); 103 | expect(secondUser).not.toBeNull(); 104 | }); 105 | 106 | test('find all', async () => { 107 | const users = await User.all(); 108 | expect(users).toHaveLength(2); 109 | expect(users[0].username).toBe('root2'); 110 | }); 111 | 112 | test('saved changes should be sent to database', async () => { 113 | const user = await User.create({ 114 | username: nextUsername(), 115 | password: 'root', 116 | }); 117 | user.password = 'rootroot'; 118 | await user.save(); 119 | 120 | const sameUser = await User.find(user._id); 121 | expect(sameUser).not.toBeNull(); 122 | expect((sameUser as User).password).toBe('rootroot'); 123 | }); 124 | 125 | test('id is an ObjectId', async () => { 126 | const user = await User.create({ 127 | username: nextUsername(), 128 | password: 'root', 129 | }); 130 | await user.save(); 131 | 132 | expect(user.id).toBeInstanceOf(ObjectId); 133 | }); 134 | 135 | test('delete on model', async () => { 136 | const user = await User.create({ 137 | username: nextUsername(), 138 | password: 'root', 139 | }); 140 | 141 | expect(user.$isDeleted).toBe(false); 142 | await user.delete(); 143 | expect(user.$isDeleted).toBe(true); 144 | 145 | await expect(user.save()).rejects.toThrow(/E_DOCUMENT_DELETED/); 146 | await expect(user.delete()).rejects.toThrow(/E_DOCUMENT_DELETED/); 147 | 148 | const sameUserButDeleted = await User.find(user._id); 149 | expect(sameUserButDeleted).toBeNull(); 150 | }); 151 | 152 | test('persistence boolean properties should behave correctly with new instances', async () => { 153 | const user = new User(); 154 | user.username = nextUsername(); 155 | user.password = 'root'; 156 | expect(user.$isPersisted).toBe(false); 157 | expect(user.$isNew).toBe(true); 158 | expect(user.$isLocal).toBe(true); 159 | await user.save(); 160 | expect(user.$isPersisted).toBe(true); 161 | expect(user.$isNew).toBe(false); 162 | expect(user.$isLocal).toBe(true); 163 | }); 164 | 165 | test('create an empty document', async () => { 166 | const empty = await Empty.create({}); 167 | expect(empty).toBeDefined(); 168 | expect(empty.$isNew).toBe(false); 169 | expect(empty.$isPersisted).toBe(true); 170 | expect(empty.$isLocal).toBe(true); 171 | expect(empty.id).toBe(1); 172 | }); 173 | 174 | test('id is a number on AutoIncrementModel', async () => { 175 | const firstPost = await Post.create({ 176 | title: nextTitle(), 177 | content: 'post content', 178 | }); 179 | expect(firstPost.id).toBe(1); 180 | expect(typeof firstPost.id).toBe('number'); 181 | }); 182 | 183 | test('AutoIncrementModel id increments', async () => { 184 | const firstPost = await Post.create({ 185 | title: nextTitle(), 186 | content: 'post content', 187 | }); 188 | const secondPost = await Post.create({ 189 | title: nextTitle(), 190 | content: 'post content', 191 | }); 192 | expect(firstPost.id).toBe(secondPost._id - 1); 193 | }); 194 | 195 | test('passing client should run requests within the same transaction session', async () => { 196 | const username = nextUsername(); 197 | await db.connection('mongo').transaction(async (client) => { 198 | const user = await User.create( 199 | { 200 | username, 201 | password: 'rootroot', 202 | }, 203 | { client }, 204 | ); 205 | 206 | user.password = 'root'; 207 | 208 | await user.save(); 209 | 210 | const shouldNotExist = await User.findBy('username', username); 211 | expect(shouldNotExist).toBeNull(); 212 | }); 213 | 214 | const shouldExistNow = await User.findBy('username', username); 215 | expect(shouldExistNow).not.toBeNull(); 216 | expect(shouldExistNow?.password).toBe('root'); 217 | }); 218 | 219 | test('class instantiation Model should create an entry', async () => { 220 | const user = new User(); 221 | user.username = nextUsername(); 222 | user.password = 'rootroot'; 223 | await user.save(); 224 | 225 | const shouldExist = await User.findBy('username', 'root7'); 226 | expect(shouldExist).not.toBeNull(); 227 | expect(user.id).toBeInstanceOf(ObjectId); 228 | }); 229 | 230 | test('class instantiation Model should be updatable', async () => { 231 | const username = nextUsername(); 232 | const user = new User(); 233 | user.username = username; 234 | user.password = 'rootroot'; 235 | await user.save(); 236 | 237 | user.password = 'root'; 238 | await user.save(); 239 | 240 | const shouldHaveNewPassword = await User.findBy('username', username); 241 | expect(shouldHaveNewPassword?.password).toBe('root'); 242 | }); 243 | 244 | test('find one returns should not be dirty', async () => { 245 | const username = nextUsername(); 246 | await User.create({ 247 | username, 248 | password: 'rootroot', 249 | }); 250 | 251 | const foundUser = await User.findByOrFail('username', username); 252 | expect(foundUser.$isDirty).toBe(false); 253 | }); 254 | 255 | test('class instantiation auto incremented model', async () => { 256 | const post = new Post(); 257 | post.title = nextTitle(); 258 | post.content = 'post content'; 259 | await post.save(); 260 | 261 | expect(typeof post.id).toBe('number'); 262 | }); 263 | 264 | test('custom collection name - class', async () => { 265 | const something = await Something.create({ test: false }); 266 | await something.save(); 267 | const collection = await Something.getCollection(); 268 | expect(collection.collectionName).toBe(Something.collectionName); 269 | }); 270 | 271 | test('custom collection name - instance', async () => { 272 | const something = new Something(); 273 | something.test = true; 274 | await something.save(); 275 | 276 | const collection = await BaseModel.$database.manager 277 | .get(BaseModel.$database.primaryConnectionName) 278 | .connection.collection(Something.collectionName); 279 | 280 | // @ts-expect-error _id is unknown here but that's internal. 281 | const found = await collection.findOne({ _id: something.id }); 282 | 283 | expect(found).not.toBeNull(); 284 | }); 285 | 286 | test('created user should not be dirty', async () => { 287 | const user = await User.create({ 288 | username: nextUsername(), 289 | password: 'rootroot', 290 | }); 291 | expect(user.$isDirty).toBe(false); 292 | }); 293 | 294 | test('$isDirty should reflect the save status', async () => { 295 | // Never dirty after fetch. 296 | const user = await User.findByOrFail('username', 'root1'); 297 | expect(user.$isDirty).toBe(false); 298 | 299 | // Dirty after changing an attribute. 300 | user.password = 'different'; 301 | expect(user.$isDirty).toBe(true); 302 | 303 | // Not dirty after restoring attribute to orignal value. 304 | user.password = 'root'; 305 | expect(user.$isDirty).toBe(false); 306 | 307 | // Dirty after changing attribute again. 308 | user.password = 'different'; 309 | expect(user.$isDirty).toBe(true); 310 | 311 | // Not dirty after saving. 312 | await user.save(); 313 | expect(user.$isDirty).toBe(false); 314 | }); 315 | 316 | test('$dirty should contain the diff between original and current', async () => { 317 | // Empty after fetch. 318 | const user = await User.findByOrFail('username', 'root1'); 319 | expect(user.$dirty).toStrictEqual({}); 320 | 321 | // Contains the changed attribute. 322 | user.password = 'root'; 323 | expect(user.$dirty).toStrictEqual({ password: 'root' }); 324 | 325 | // Contains all the changed attributes. 326 | user.username = 'root2'; 327 | expect(user.$dirty).toStrictEqual({ 328 | password: 'root', 329 | username: 'root2', 330 | }); 331 | 332 | // Contains the remaining changed attribute. 333 | user.username = 'root1'; 334 | expect(user.$dirty).toStrictEqual({ 335 | password: 'root', 336 | }); 337 | 338 | // Empty after saving. 339 | await user.save(); 340 | expect(user.$dirty).toStrictEqual({}); 341 | }); 342 | 343 | test('merge method', async () => { 344 | const username = nextUsername(); 345 | const myContent = { 346 | username, 347 | password: 'rootroot', 348 | }; 349 | 350 | const user = new User(); 351 | await user.merge(myContent).save(); 352 | 353 | expect(user).toHaveProperty(['username']); 354 | expect(user.username).toBe(username); 355 | 356 | expect(user).toHaveProperty(['password']); 357 | expect(user.password).toBe('rootroot'); 358 | }); 359 | 360 | test('fill method', async () => { 361 | const user = new User(); 362 | user.password = 'rootroot'; 363 | 364 | await user.fill({ username: nextUsername() }).save(); 365 | 366 | expect(user.password).toBeUndefined(); 367 | expect(user.username).toBeDefined(); 368 | }); 369 | 370 | test('merge and fill accept no extra properties', async () => { 371 | const user = new User(); 372 | 373 | user.merge({ 374 | username: 'test', 375 | // @ts-expect-error Testing extra property 376 | bad: 'property', 377 | }); 378 | 379 | const bad = { 380 | password: 'xxx', 381 | other: 'bad', 382 | }; 383 | 384 | // @ts-expect-error Testing extra property 385 | user.merge(bad); 386 | 387 | user.fill({ 388 | username: 'test', 389 | // @ts-expect-error Testing extra property 390 | bad: 'property', 391 | }); 392 | 393 | // @ts-expect-error Testing extra property 394 | user.merge(bad); 395 | }); 396 | 397 | test('fill method after save', async () => { 398 | const user = new User(); 399 | user.password = 'rootroot'; 400 | await user.save(); 401 | const createdAt = user.createdAt; 402 | await user.fill({ username: nextUsername() }).save(); 403 | 404 | expect(user.password).toBeUndefined(); 405 | expect(user.username).toBeDefined(); 406 | expect(user.createdAt).toBe(createdAt); 407 | }); 408 | 409 | test('pass custom id', async () => { 410 | const username = nextUsername(); 411 | const user = await User.create({ 412 | _id: 'test', 413 | username, 414 | password: 'mypass', 415 | }); 416 | 417 | await user.save(); 418 | 419 | const newUser = await User.findBy('username', username); 420 | expect(newUser?._id).toBe('test'); 421 | }); 422 | 423 | test('toJSON method', async () => { 424 | const post = await Post.create({ 425 | _id: 42, 426 | title: 'mytitle', 427 | content: 'mycontent', 428 | }); 429 | 430 | const jsonPost = post.toJSON(); 431 | 432 | const expected = { 433 | _id: 42, 434 | title: 'mytitle', 435 | content: 'mycontent', 436 | createdAt: post.createdAt, 437 | updatedAt: post.updatedAt, 438 | // computed prop 439 | titleUpperCase: post.titleUpperCase, 440 | }; 441 | 442 | expect(JSON.stringify(jsonPost)).toStrictEqual(JSON.stringify(expected)); 443 | }); 444 | 445 | test('spreading a model should throw', async () => { 446 | const post = await Post.query().firstOrFail(); 447 | // eslint-disable-next-line @typescript-eslint/no-misused-spread 448 | expect(() => ({ ...post })).toThrow(/Getting model keys is disallowed/); 449 | }); 450 | 451 | test('custom inspect function', async () => { 452 | const post = await Post.query().sort({ id: 1 }).firstOrFail(); 453 | post.content = 'new content'; 454 | 455 | // Delete dates to have a reproducible snapshot. 456 | delete post.$original.createdAt; 457 | delete post.$original.updatedAt; 458 | delete post.$attributes.createdAt; 459 | delete post.$attributes.updatedAt; 460 | 461 | const inspected = inspect(post); 462 | expect(inspected).toMatchSnapshot(); 463 | }); 464 | 465 | describe('findMany', () => { 466 | it('should accept an empty list', async () => { 467 | expect(await Post.findMany([])).toStrictEqual([]); 468 | }); 469 | 470 | it('should find all results', async () => { 471 | const results = await Post.findMany([2, 1, 3]); 472 | expect(results).toHaveLength(3); 473 | expect(results[0]).toBeInstanceOf(Post); 474 | expect(results.map((value) => value.id)).toStrictEqual([1, 2, 3]); 475 | }); 476 | 477 | it('should not duplicate results', async () => { 478 | const results = await Post.findMany([1, 1, 1]); 479 | expect(results).toHaveLength(1); 480 | expect(results[0].id).toBe(1); 481 | }); 482 | }); 483 | 484 | describe('findOrFail', () => { 485 | it('should return instance if found', async () => { 486 | const post = await Post.findOrFail(1); 487 | expect(post).toBeInstanceOf(Post); 488 | expect(post.id).toBe(1); 489 | }); 490 | 491 | it('should throw if not found', async () => { 492 | await expect(Post.findOrFail(-1)).rejects.toThrow(/E_DOCUMENT_NOT_FOUND/); 493 | }); 494 | }); 495 | 496 | describe('findBy', () => { 497 | it('should return instance if found', async () => { 498 | const user = await User.findBy('username', 'root1'); 499 | expect(user).toBeInstanceOf(User); 500 | expect(user?.username).toBe('root1'); 501 | }); 502 | 503 | it('should return null if not found', async () => { 504 | const user = await User.findBy('username', 'bad'); 505 | expect(user).toBeNull(); 506 | }); 507 | }); 508 | 509 | describe('findByOrFail', () => { 510 | it('should return instance if found', async () => { 511 | const user = await User.findByOrFail('username', 'root1'); 512 | expect(user).toBeInstanceOf(User); 513 | expect(user.username).toBe('root1'); 514 | }); 515 | 516 | it('should throw if not found', async () => { 517 | await expect(User.findByOrFail('username', 'bad')).rejects.toThrow( 518 | /E_DOCUMENT_NOT_FOUND/, 519 | ); 520 | }); 521 | }); 522 | 523 | describe('save', () => { 524 | it('should return true if something was saved', async () => { 525 | const post = await Post.findOrFail(1); 526 | post.title = 'new title'; 527 | expect(await post.save()).toBe(true); 528 | }); 529 | 530 | it('should return false if nothing was saved', async () => { 531 | const post = await Post.findOrFail(1); 532 | const title = post.title; 533 | // no-op 534 | post.title = title; 535 | expect(await post.save()).toBe(false); 536 | }); 537 | }); 538 | 539 | describe('transaction', () => { 540 | it('should apply transaction and save', async () => { 541 | const post = await Post.findOrFail(1); 542 | post.title = 'transaction title'; 543 | 544 | await db.transaction(async (client) => { 545 | post.useTransaction(client); 546 | expect(post.$isTransaction).toBe(true); 547 | 548 | return post.save(); 549 | }); 550 | 551 | const refreshPost = await Post.findOrFail(1); 552 | expect(post.title).toBe(refreshPost.title); 553 | }); 554 | 555 | it('should apply transaction, save, throw error so rollback', async () => { 556 | const post = await Post.findOrFail(1); 557 | post.title = 'transaction title v2'; 558 | 559 | await db 560 | .transaction(async (client) => { 561 | post.useTransaction(client); 562 | 563 | await post.save(); 564 | 565 | throw new Error('Need to rollback transaction'); 566 | }) 567 | .catch(() => { 568 | // ignore transaction failed 569 | }); 570 | 571 | const refreshPost = await Post.findOrFail(1); 572 | expect(post.title).not.toBe(refreshPost.title); 573 | }); 574 | 575 | it('should return model instance', async () => { 576 | const post = await db.transaction(async (client) => { 577 | const post = await Post.findOrFail(1, { client }); 578 | 579 | post.title = 'transaction title v3'; 580 | await post.save(); 581 | 582 | return post; 583 | }); 584 | 585 | expect(post.title).toBe('transaction title v3'); 586 | }); 587 | }); 588 | 589 | describe('computed getter', () => { 590 | class PostComputed extends BaseModel { 591 | @field() 592 | public title: string; 593 | 594 | @field() 595 | public body: string; 596 | 597 | @computed({ serializeAs: null }) 598 | public get titleUpperCase() { 599 | return this.title.toUpperCase(); 600 | } 601 | 602 | @computed({ serializeAs: '__JSON_MARKDOWN' }) 603 | public get markdown() { 604 | return `#${this.titleUpperCase}\n\n${this.body}`; 605 | } 606 | 607 | @computed() 608 | public get html() { 609 | return `

${this.title}

${this.body}

`; 610 | } 611 | } 612 | 613 | const post = new PostComputed().merge({ title: 'Test', body: 'content' }); 614 | 615 | it('support Lucid standard $hasComputed', () => { 616 | expect(PostComputed.$hasComputed('titleUpperCase')).toBe(true); 617 | expect(PostComputed.$hasComputed('title')).toBe(false); 618 | }); 619 | 620 | it('support Lucid standard $getComputed', () => { 621 | expect(PostComputed.$getComputed('titleUpperCase')).toStrictEqual({ 622 | serializeAs: null, 623 | meta: undefined, 624 | }); 625 | expect(PostComputed.$getComputed('title')).toBe(undefined); 626 | }); 627 | 628 | it('should be able to access instance context', () => { 629 | expect(post.titleUpperCase).toBe('TEST'); 630 | }); 631 | 632 | it('should support serializeAs fallback to getter name', () => { 633 | const serialization = post.toJSON(); 634 | // @ts-expect-error polymorphic testing 635 | expect(serialization.html).toBe(post.html); 636 | }); 637 | 638 | it('should support serializeAs null', () => { 639 | const serialization = post.toJSON(); 640 | // @ts-expect-error polymorphic testing 641 | expect('titleUpperCase' in serialization).toBe(false); 642 | }); 643 | 644 | it('should support serializeAs string', () => { 645 | const serialization = post.toJSON(); 646 | // @ts-expect-error polymorphic testing 647 | expect(serialization.__JSON_MARKDOWN).toBe(post.markdown); 648 | }); 649 | }); 650 | -------------------------------------------------------------------------------- /src/Model/__tests__/__snapshots__/Model.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`custom inspect function 1`] = ` 4 | "{ 5 | Model: 'Post', 6 | '$original': { _id: 1, title: 'post title 1', content: 'post content' }, 7 | '$attributes': { _id: 1, title: 'post title 1', content: 'new content' }, 8 | '$isPersisted': true, 9 | '$isNew': false, 10 | '$isLocal': false, 11 | '$isDeleted': false, 12 | '$dirty': { content: 'new content' }, 13 | '$isDirty': true, 14 | '$isTransaction': false 15 | }" 16 | `; 17 | -------------------------------------------------------------------------------- /src/Model/proxyHandler.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import type { BaseModel } from './Model'; 3 | 4 | export const proxyHandler: ProxyHandler = { 5 | get(target: any, prop: string | symbol, receiver: any) { 6 | const Model = target.constructor as typeof BaseModel; 7 | if (Model.$hasComputed(prop as string)) { 8 | return Reflect.get(target, prop, receiver); 9 | } 10 | 11 | if (target[prop] !== undefined) { 12 | return Reflect.get(target, prop, receiver); 13 | } 14 | 15 | return Reflect.get(target.$attributes, prop, receiver); 16 | }, 17 | set(target: any, prop: string | symbol, value: any) { 18 | if (target[prop] !== undefined) { 19 | return Reflect.set(target, prop, value); 20 | } 21 | return Reflect.set(target.$attributes, prop, value); 22 | }, 23 | ownKeys() { 24 | throw new Error( 25 | 'Getting model keys is disallowed. If you want to use object spread on the current data, do { ...model.$attributes }', 26 | ); 27 | }, 28 | }; 29 | -------------------------------------------------------------------------------- /src/Odm/decorators.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ComputedDecorator, 3 | ComputedOptions, 4 | FieldDecorator, 5 | FieldOptions, 6 | MongodbModel, 7 | } from '@ioc:Zakodium/Mongodb/Odm'; 8 | 9 | export const field: FieldDecorator = (options?: FieldOptions) => { 10 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 11 | return function decorateField(target: any, property: string) { 12 | const Model = target.constructor as MongodbModel; 13 | Model.boot(); 14 | Model.$addField(property, options); 15 | }; 16 | }; 17 | 18 | /** 19 | * Define computed property on a model. The decorator needs a 20 | * proper model class inheriting the base model 21 | */ 22 | export const computed: ComputedDecorator = (options?: ComputedOptions) => { 23 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 24 | return function decorateAsComputed(target: any, property: string) { 25 | const Model = target.constructor as MongodbModel; 26 | 27 | Model.boot(); 28 | Model.$addComputed(property, options ?? {}); 29 | }; 30 | }; 31 | -------------------------------------------------------------------------------- /src/__tests__/Connection.test.ts: -------------------------------------------------------------------------------- 1 | import * as crypto from 'node:crypto'; 2 | import { setTimeout as sleep } from 'node:timers/promises'; 3 | 4 | import { getConnection, getLogger } from '../../test-utils/TestUtils'; 5 | 6 | const logger = getLogger(); 7 | const connection = getConnection(logger); 8 | 9 | afterAll(async () => { 10 | await connection.disconnect(); 11 | }); 12 | 13 | test('try to connect with good config', async () => { 14 | await connection.connect(); 15 | await sleep(500); 16 | expect(logger.logs.at(-1)).toBeUndefined(); 17 | }); 18 | 19 | test('get collection', async () => { 20 | const collection = await connection.collection('test'); 21 | expect(collection).toBeDefined(); 22 | }); 23 | 24 | test('reconnect automatically', async () => { 25 | let collection = await connection.collection('test'); 26 | await collection.find({}).toArray(); 27 | await connection.disconnect(); 28 | collection = await connection.collection('test'); 29 | // Should connect automatically 30 | await collection.find({}).toArray(); 31 | }); 32 | 33 | test('get database', async () => { 34 | const db = await connection.database(); 35 | expect(db).toBeDefined(); 36 | }); 37 | 38 | describe('transactions', () => { 39 | const id = crypto.randomUUID(); 40 | beforeEach(async () => { 41 | const Test = await connection.collection('test'); 42 | await Test.deleteMany({}); 43 | await Test.insertOne({ id }); 44 | }); 45 | 46 | test('commit event', async () => { 47 | const txCommitController = promiseWithResolvers(); 48 | 49 | const [txResult, count] = await Promise.all([ 50 | connection.transaction(async (session, db, tx) => { 51 | await db.collection('test').findOneAndDelete({ id }, { session }); 52 | 53 | tx.on('commit', (session, db) => { 54 | expect(session.transaction.isCommitted).toBe(true); 55 | 56 | let count: number | null = null; 57 | db.collection('test') 58 | .countDocuments({}) 59 | .then((_count) => { 60 | count = _count; 61 | }) 62 | // eslint-disable-next-line no-console 63 | .catch(console.error) 64 | .finally(() => txCommitController.resolve(count)); 65 | }); 66 | 67 | return true; 68 | }), 69 | txCommitController.promise, 70 | ]); 71 | 72 | expect(txResult).toBe(true); 73 | expect(count).toBe(0); 74 | }); 75 | 76 | test('abort manual event', async () => { 77 | const txAbortController = promiseWithResolvers(); 78 | 79 | const [txResult, count] = await Promise.all([ 80 | connection.transaction(async (session, db, tx) => { 81 | await db.collection('test').deleteOne({ id }, { session }); 82 | await session.abortTransaction(); 83 | 84 | tx.on('abort', (session, db) => { 85 | expect(Reflect.get(session.transaction, 'state')).toBe( 86 | 'TRANSACTION_ABORTED', 87 | ); 88 | 89 | let count: number | null = null; 90 | db.collection('test') 91 | .countDocuments({}) 92 | .then((_count) => { 93 | count = _count; 94 | }) 95 | // eslint-disable-next-line no-console 96 | .catch(console.error) 97 | .finally(() => txAbortController.resolve(count)); 98 | }); 99 | 100 | return 'aborted'; 101 | }), 102 | txAbortController.promise, 103 | ]); 104 | 105 | expect(txResult).toBe('aborted'); 106 | expect(count).toBe(1); 107 | }); 108 | 109 | test('abort error event', async () => { 110 | const txAbortController = promiseWithResolvers(); 111 | const error = new Error('Unexpected error'); 112 | 113 | const [txResult, count] = await Promise.allSettled([ 114 | connection.transaction(async (session, db, tx) => { 115 | await db.collection('test').deleteOne({ id }, { session }); 116 | 117 | tx.on('abort', (session, db, err) => { 118 | expect(Reflect.get(session.transaction, 'state')).toBe( 119 | 'TRANSACTION_ABORTED', 120 | ); 121 | expect(err).toBe(error); 122 | 123 | let count: number | null = null; 124 | db.collection('test') 125 | .countDocuments({}) 126 | .then((_count) => { 127 | count = _count; 128 | }) 129 | // eslint-disable-next-line no-console 130 | .catch(console.error) 131 | .finally(() => txAbortController.resolve(count)); 132 | }); 133 | 134 | throw error; 135 | }), 136 | txAbortController.promise, 137 | ]); 138 | 139 | expect(txResult.status).toBe('rejected'); 140 | expect(count.status === 'fulfilled' && count.value).toBe(1); 141 | }); 142 | }); 143 | 144 | /** 145 | * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/withResolvers#browser_compatibility 146 | * TODO: use ES api when this project target Node.js >=v22 147 | */ 148 | function promiseWithResolvers() { 149 | let resolve: (value: R | PromiseLike) => void; 150 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 151 | let reject: (reason: any) => void; 152 | const promise = new Promise((_resolve, _reject) => { 153 | resolve = _resolve; 154 | reject = _reject; 155 | }); 156 | 157 | return { 158 | // @ts-expect-error The Promise executor is synchronous 159 | resolve, 160 | // @ts-expect-error The Promise executor is synchronous 161 | reject, 162 | promise, 163 | }; 164 | } 165 | -------------------------------------------------------------------------------- /src/__tests__/ConnectionManager.test.ts: -------------------------------------------------------------------------------- 1 | import { getMongodb } from '../../test-utils/TestUtils'; 2 | 3 | const { manager } = getMongodb(); 4 | 5 | afterAll(async () => { 6 | await manager.closeAll(); 7 | }); 8 | 9 | test("has should return false if connection doesn't exist", () => { 10 | expect(manager.has('idontexist')).toBe(false); 11 | }); 12 | 13 | test('has should return true if connection exists', () => { 14 | expect(manager.has('mongo')).toBe(true); 15 | }); 16 | 17 | test("connection should throw an error if connection doesn't exist", () => { 18 | expect(() => { 19 | manager.get('idontexist'); 20 | }).toThrow('no MongoDB connection registered with name "idontexist"'); 21 | }); 22 | 23 | test('connection should return a connection if it exists', () => { 24 | expect(() => { 25 | manager.get('mongo'); 26 | }).not.toThrow(); 27 | }); 28 | -------------------------------------------------------------------------------- /src/__tests__/Database.test.ts: -------------------------------------------------------------------------------- 1 | import { getLogger, getMongodb } from '../../test-utils/TestUtils'; 2 | import { Database } from '../Database/Database'; 3 | 4 | const db = getMongodb(); 5 | const logger = getLogger(); 6 | 7 | afterAll(async () => { 8 | await db.manager.closeAll(); 9 | }); 10 | 11 | test('primaryConnectionName', () => { 12 | expect(db.primaryConnectionName).toBe('mongo'); 13 | }); 14 | 15 | describe('connection', () => { 16 | it('should throw if connection does not exist', () => { 17 | expect(() => db.connection('idontexist')).toThrow( 18 | 'E_NO_MONGODB_CONNECTION', 19 | ); 20 | }); 21 | 22 | it('should return the requested connection', () => { 23 | const connection = db.connection('other'); 24 | expect(connection.name).toBe('other'); 25 | }); 26 | 27 | it('should return the default connection', () => { 28 | const connection = db.connection(); 29 | expect(connection.name).toBe('mongo'); 30 | expect(connection).toBe(db.connection('mongo')); 31 | }); 32 | 33 | it('should automatically reconnect', async () => { 34 | const connection = db.connection(); 35 | let collection = await connection.collection('test'); 36 | await collection.find({}).toArray(); 37 | await db.manager.closeAll(); 38 | collection = await connection.collection('test'); 39 | await collection.find({}).toArray(); 40 | }); 41 | }); 42 | 43 | describe('Database constructor errors', () => { 44 | it.each([undefined, null, 42, {}])( 45 | 'error if connection is not a string (%s)', 46 | (value) => { 47 | expect( 48 | // @ts-expect-error Testing invalid input 49 | () => new Database({ connection: value, connections: {} }, logger), 50 | ).toThrow('config.connection must be a string'); 51 | }, 52 | ); 53 | 54 | it.each([undefined, null, 42, 'test'])( 55 | 'error if connections is not an object (%s)', 56 | (value) => { 57 | expect( 58 | // @ts-expect-error Testing invalid input 59 | () => new Database({ connection: 'test', connections: value }, logger), 60 | ).toThrow('config.connections must be an object'); 61 | }, 62 | ); 63 | 64 | it('error if primary connection is not in connections', () => { 65 | expect( 66 | () => 67 | new Database( 68 | { 69 | connection: 'test', 70 | connections: { test1: { database: 'test', url: 'test' } }, 71 | }, 72 | logger, 73 | ), 74 | ).toThrow( 75 | 'config.connections must contain a key with the primary connection name (test)', 76 | ); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /src/__tests__/Migration.test.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '@poppinss/cliui/build/src/Logger'; 2 | 3 | import { getMongodb } from '../../test-utils/TestUtils'; 4 | import createMigration from '../Migration'; 5 | 6 | const logger = new Logger(); 7 | const db = getMongodb(); 8 | const BaseMigration = createMigration(db); 9 | 10 | class TestMigration1 extends BaseMigration { 11 | public constructor(connection: string | undefined, logger: Logger) { 12 | super(connection, logger); 13 | } 14 | public up(): void { 15 | this.createCollection('migration1'); 16 | this.createCollections(['migration2', 'migration3']); 17 | } 18 | } 19 | 20 | class TestMigration2 extends BaseMigration { 21 | public constructor(connection: string | undefined, logger: Logger) { 22 | super(connection, logger); 23 | } 24 | public up(): void { 25 | this.dropCollection('migration2'); 26 | } 27 | } 28 | 29 | describe('running migrations correctly changes database', () => { 30 | afterAll(async () => { 31 | const database = await db.connection('mongo').database(); 32 | await database.dropDatabase(); 33 | await db.manager.closeAll(); 34 | }); 35 | 36 | it('should create collections', async () => { 37 | await db.connection('mongo').transaction(async (session) => { 38 | const migration1 = new TestMigration1('mongo', logger); 39 | await migration1.execUp(session); 40 | }); 41 | const database = await db.connection('mongo').database(); 42 | const collections = await database.listCollections().map(getName).toArray(); 43 | expect(collections.sort()).toStrictEqual([ 44 | 'migration1', 45 | 'migration2', 46 | 'migration3', 47 | ]); 48 | }); 49 | 50 | it('should drop collection', async () => { 51 | await db.connection('mongo').transaction(async (session) => { 52 | const migration2 = new TestMigration2('mongo', logger); 53 | await migration2.execUp(session); 54 | }); 55 | const database = await db.connection('mongo').database(); 56 | const collections = await database.listCollections().map(getName).toArray(); 57 | expect(collections.sort()).toStrictEqual(['migration1', 'migration3']); 58 | }); 59 | }); 60 | 61 | function getName(collection: { name: string }) { 62 | return collection.name; 63 | } 64 | -------------------------------------------------------------------------------- /templates/migration.txt: -------------------------------------------------------------------------------- 1 | import BaseMigration from '@ioc:Zakodium/Mongodb/Migration'; 2 | 3 | export default class ${className} extends BaseMigration { 4 | public override up(): void { 5 | // Write your migration here. 6 | // For example: 7 | // this.createCollection('my_coll'); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /templates/mongodb.txt: -------------------------------------------------------------------------------- 1 | import Env from '@ioc:Adonis/Core/Env'; 2 | import { MongodbConfig } from '@ioc:Zakodium/Mongodb/Database'; 3 | 4 | const mongodbConfig: MongodbConfig = { 5 | connection: Env.get('MONGODB_CONNECTION'), 6 | connections: { 7 | mongodb: { 8 | url: Env.get('MONGODB_URL'), 9 | database: Env.get('MONGODB_DATABASE'), 10 | }, 11 | }, 12 | }; 13 | 14 | export default mongodbConfig; 15 | -------------------------------------------------------------------------------- /test-utils/TestUtils.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | 3 | import { FakeLogger } from '@adonisjs/logger'; 4 | 5 | import type { MongodbConfig } from '@ioc:Zakodium/Mongodb/Database'; 6 | 7 | import { Connection } from '../src/Database/Connection'; 8 | import { Database } from '../src/Database/Database'; 9 | import { BaseAutoIncrementModel, BaseModel } from '../src/Model/Model'; 10 | 11 | export function getLogger() { 12 | const loggerConfig = { 13 | name: 'adonis-logger', 14 | level: 'trace', 15 | messageKey: 'msg', 16 | enabled: true, 17 | }; 18 | return new FakeLogger(loggerConfig); 19 | } 20 | 21 | export function getConnection(logger = getLogger()) { 22 | const connectionConfig = { 23 | url: 'mongodb://localhost:33333?directConnection=true', 24 | database: `test-runner-${path.basename( 25 | expect.getState().testPath as string, 26 | '.test.ts', 27 | )}`, 28 | }; 29 | return new Connection('mongo', connectionConfig, logger); 30 | } 31 | 32 | export function getMongodb(logger = getLogger()) { 33 | const database = `test-runner-${path 34 | .basename(expect.getState().testPath as string, '.test.ts') 35 | .replaceAll('.', '_')}`; 36 | const mongoConfig: MongodbConfig = { 37 | connection: 'mongo', 38 | connections: { 39 | mongo: { 40 | url: 'mongodb://127.0.0.1:33333?directConnection=true', 41 | database, 42 | }, 43 | other: { 44 | url: 'mongodb://127.0.0.1:33333?directConnection=true', 45 | database, 46 | }, 47 | }, 48 | }; 49 | 50 | return new Database(mongoConfig, logger); 51 | } 52 | 53 | export function setupDatabase() { 54 | const db = getMongodb(); 55 | BaseModel.$setDatabase(db); 56 | BaseAutoIncrementModel.$setDatabase(db); 57 | 58 | afterAll(async () => { 59 | const database = await db.connection('mongo').database(); 60 | await database.dropDatabase(); 61 | await db.manager.closeAll(); 62 | }); 63 | 64 | return db; 65 | } 66 | -------------------------------------------------------------------------------- /test-utils/contracts.ts: -------------------------------------------------------------------------------- 1 | declare module '@ioc:Adonis/Core/Hash' { 2 | interface HashersList { 3 | bcrypt: HashDrivers['bcrypt']; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "esModuleInterop": true, 5 | "module": "commonjs", 6 | "moduleResolution": "node", 7 | "outDir": "./lib", 8 | "inlineSourceMap": true, 9 | "strict": true, 10 | "lib": ["ES2022"], 11 | "target": "es2022", 12 | "strictFunctionTypes": false, 13 | "experimentalDecorators": true, 14 | "emitDecoratorMetadata": true, 15 | "strictPropertyInitialization": false, 16 | "skipLibCheck": true, 17 | "types": ["@adonisjs/auth", "@adonisjs/core", "@types/jest"] 18 | }, 19 | "include": [ 20 | "./adonis-typings/**/*", 21 | "./commands/**/*", 22 | "./src/**/*", 23 | "./providers/**/*", 24 | "./test-utils/*" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /tsconfig.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["**/__tests__/**", "./test-utils/*"] 4 | } 5 | --------------------------------------------------------------------------------