├── .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 |
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