├── .eslintrc.cjs
├── .github
└── workflows
│ └── push.yml
├── .gitignore
├── .npmignore
├── .npmrc
├── .releaserc
├── CHANGELOG.md
├── LICENSE
├── README.md
├── commitlint.config.cjs
├── package.json
├── src
├── database.ts
├── index.doc.md
├── index.ts
├── property.ts
├── resource.ts
└── utils
│ ├── convert-filter.ts
│ ├── create-cast-error.ts
│ ├── create-duplicate-error.ts
│ ├── create-validation-error.ts
│ ├── errors.ts
│ ├── filter.types.ts
│ └── index.ts
├── test
├── database.spec.ts
├── errors
│ ├── create-cast-error.spec.ts
│ └── create-validation-error.spec.ts
├── fixtures
│ ├── duplicate-error.ts
│ ├── mongoose-cast-array-error.ts
│ ├── mongoose-cast-error.ts
│ ├── mongoose-nested-validation-error.ts
│ ├── mongoose-validation-error.ts
│ └── valid-user-record.ts
├── jest.json
├── property.spec.ts
├── resource
│ ├── constructor.ts
│ ├── count.spec.ts
│ ├── create.spec.ts
│ ├── delete.spec.ts
│ ├── find.spec.ts
│ ├── name.spec.ts
│ ├── parseParams.spec.ts
│ ├── position.spec.ts
│ ├── properties.spec.ts
│ ├── property.spec.ts
│ └── update.spec.ts
└── utils
│ ├── beforeEach.ts
│ ├── models.ts
│ └── teardown.ts
├── tsconfig.json
├── tsconfig.test.json
└── yarn.lock
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | 'env': {
3 | 'es6': true,
4 | 'node': true,
5 | 'mocha': true
6 | },
7 | 'extends': [
8 | 'airbnb-base'
9 | ],
10 | parser: "@typescript-eslint/parser",
11 | plugins: ["@typescript-eslint/eslint-plugin"],
12 | 'parserOptions': {
13 | 'ecmaVersion': 20,
14 | 'sourceType': 'module'
15 | },
16 | 'rules': {
17 | 'indent': [
18 | 'error',
19 | 2
20 | ],
21 | 'linebreak-style': [
22 | 'error',
23 | 'unix'
24 | ],
25 | 'quotes': [
26 | 'error',
27 | 'single'
28 | ],
29 | 'semi': [
30 | 'error',
31 | 'never'
32 | ],
33 | 'import/no-unresolved': 'off',
34 | 'import/prefer-default-export': 'off',
35 | 'no-underscore-dangle': 'off',
36 | 'guard-for-in': 'off',
37 | 'no-restricted-syntax': 'off',
38 | 'no-await-in-loop': 'off',
39 | 'no-param-reassign': 'off',
40 | "import/extensions": 'off'
41 | },
42 | overrides: [
43 | {
44 | files: ['*-test.js', '*.spec.js'],
45 | rules: {
46 | 'no-unused-expressions': 'off',
47 | 'func-names': 'off',
48 | 'prefer-arrow-callback': 'off'
49 | }
50 | }
51 | ],
52 | globals: {
53 | 'expect': true,
54 | 'factory': true,
55 | 'sandbox': true,
56 | 'Pesel': true,
57 | 'User': true,
58 | 'Article': true
59 | }
60 | }
--------------------------------------------------------------------------------
/.github/workflows/push.yml:
--------------------------------------------------------------------------------
1 | name: CI/CD
2 | on: push
3 | jobs:
4 | test:
5 | name: Test
6 | runs-on: ubuntu-latest
7 | services:
8 | mongo:
9 | image: mongo
10 | ports:
11 | - 27017:27017
12 | steps:
13 | - name: Checkout
14 | uses: actions/checkout@v2
15 | - name: Setup
16 | uses: actions/setup-node@v2
17 | with:
18 | node-version: '18'
19 | - uses: actions/cache@v2
20 | id: yarn-cache
21 | with:
22 | path: node_modules
23 | key: ${{ runner.os }}-node_modules-${{ hashFiles('**/yarn.lock') }}
24 | restore-keys: |
25 | ${{ runner.os }}-node_modules-
26 | - name: Install
27 | if: steps.yarn-cache.outputs.cache-hit != 'true'
28 | run: yarn install
29 | - name: Lint
30 | run: yarn lint
31 | - name: Build
32 | run: yarn build
33 | - name: test
34 | run: yarn test
35 | - name: Release
36 | env:
37 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
39 | SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_URL }}
40 | run: yarn release
41 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 |
9 | # Diagnostic reports (https://nodejs.org/api/report.html)
10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
11 |
12 | # Runtime data
13 | pids
14 | *.pid
15 | *.seed
16 | *.pid.lock
17 |
18 | # Directory for instrumented libs generated by jscoverage/JSCover
19 | lib-cov
20 |
21 | # Coverage directory used by tools like istanbul
22 | coverage
23 | *.lcov
24 |
25 | # nyc test coverage
26 | .nyc_output
27 |
28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
29 | .grunt
30 |
31 | # Bower dependency directory (https://bower.io/)
32 | bower_components
33 |
34 | # node-waf configuration
35 | .lock-wscript
36 |
37 | # Compiled binary addons (https://nodejs.org/api/addons.html)
38 | build/Release
39 |
40 | # Dependency directories
41 | node_modules/
42 | jspm_packages/
43 |
44 | # Snowpack dependency directory (https://snowpack.dev/)
45 | web_modules/
46 |
47 | # TypeScript cache
48 | *.tsbuildinfo
49 |
50 | # Optional npm cache directory
51 | .npm
52 |
53 | # Optional eslint cache
54 | .eslintcache
55 |
56 | # Microbundle cache
57 | .rpt2_cache/
58 | .rts2_cache_cjs/
59 | .rts2_cache_es/
60 | .rts2_cache_umd/
61 |
62 | # Optional REPL history
63 | .node_repl_history
64 |
65 | # Output of 'npm pack'
66 | *.tgz
67 |
68 | # Yarn Integrity file
69 | .yarn-integrity
70 |
71 | # dotenv environment variables file
72 | .env
73 | .env.test
74 |
75 | # parcel-bundler cache (https://parceljs.org/)
76 | .cache
77 | .parcel-cache
78 |
79 | # Next.js build output
80 | .next
81 | out
82 |
83 | # Nuxt.js build / generate output
84 | .nuxt
85 | dist
86 |
87 | # Gatsby files
88 | .cache/
89 | # Comment in the public line in if your project uses Gatsby and not Next.js
90 | # https://nextjs.org/blog/next-9-1#public-directory-support
91 | # public
92 |
93 | # vuepress build output
94 | .vuepress/dist
95 |
96 | # Serverless directories
97 | .serverless/
98 |
99 | # FuseBox cache
100 | .fusebox/
101 |
102 | # DynamoDB Local files
103 | .dynamodb/
104 |
105 | # TernJS port file
106 | .tern-port
107 |
108 | # Stores VSCode versions used for testing VSCode extensions
109 | .vscode-test
110 |
111 | # yarn v2
112 | .yarn/cache
113 | .yarn/unplugged
114 | .yarn/build-state.yml
115 | .yarn/install-state.gz
116 | .pnp.*
117 |
118 | .DS_store
119 |
120 | # build files
121 | /lib
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | \.idea/
2 | node_modules/
3 | spec/
4 | test/
5 | example-app/
6 | src/
7 | package-lock\.json
8 | *.db
9 | *.log
10 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | access=public
--------------------------------------------------------------------------------
/.releaserc:
--------------------------------------------------------------------------------
1 | {
2 | "branches": [
3 | "+([0-9])?(.{+([0-9]),x}).x",
4 | "master",
5 | "next",
6 | "next-major",
7 | {
8 | "name": "beta",
9 | "prerelease": true
10 | }
11 | ],
12 | "plugins": [
13 | "@semantic-release/commit-analyzer",
14 | "@semantic-release/release-notes-generator",
15 | "@semantic-release/npm",
16 | "@semantic-release/github",
17 | "@semantic-release/git",
18 | [
19 | "semantic-release-slack-bot",
20 | {
21 | "notifyOnSuccess": true,
22 | "notifyOnFail": false
23 | }
24 | ]
25 | ]
26 | }
27 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 | All notable changes to this project will be documented in this file.
3 |
4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6 |
7 | Changelog maintained after version 0.3.1
8 |
9 | ## Version v0.5.2b - 02.05.2020
10 |
11 | ### Added
12 |
13 | * [#318] added isRequired field
14 |
15 | ## Version 0.5.1 - 09.04.2020
16 |
17 | ### Added
18 |
19 | * handling of duplication error (unique constrain)
20 |
21 | ## [0.5.0] - 04.04.2020
22 |
23 | ### Changed
24 |
25 | * change ValidationError interface that it is aligned with AdminBro v2.2
26 | * parse params works recursively
27 |
28 | ### Fixed
29 |
30 | * fix error where old params were returned after updating the record
31 |
32 | ## [0.4.2] - 14.03.2020
33 |
34 | ### Changed
35 |
36 | * Resource.build changes IDs from BSON object to strings by default
37 |
38 | ## [0.4.1] - 04.03.2020
39 |
40 | ### Changed
41 |
42 | * remove obsolete console.warn
43 |
44 | ## [0.4.0] - 01.01.2020
45 |
46 | ### Added
47 |
48 | - add position field
49 | - add findMany to resource
50 |
51 | ## [0.3.1] - 2019-12-17
52 |
53 | ### Fixed
54 |
55 | - multiple fixes on nested objects
56 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2018 SoftwareBrothers.co
2 |
3 | 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:
4 |
5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6 |
7 | 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.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## @adminjs/mongoose
2 |
3 | This is an official [AdminJS](https://github.com/SoftwareBrothers/adminjs) adapter which integrates [mongoose ORM](https://mongoosejs.com/) into AdminJS.
4 |
5 | ## OpenSource SoftwareBrothers community
6 |
7 | - [Join the community](https://join.slack.com/t/adminbro/shared_invite/zt-czfb79t1-0U7pn_KCqd5Ts~lbJK0_RA) to get help and be inspired.
8 |
9 | ## Usage
10 |
11 | The plugin can be registered using standard `AdminJS.registerAdapter` method.
12 |
13 | ```javascript
14 | const AdminJS = require('adminjs')
15 | const AdminJSMongoose = require('@adminjs/mongoose')
16 |
17 | AdminJS.registerAdapter(AdminJSMongoose)
18 | ```
19 |
20 | More options can be found on [AdminJS](https://github.com/SoftwareBrothers/adminjs) official website.
21 |
22 | ## License
23 |
24 | AdminJS is copyrighted © 2023 rst.software. It is a free software, and may be redistributed under the terms specified in the [LICENSE](LICENSE.md) file.
25 |
26 | ## About rst.software
27 |
28 |
29 |
30 | We’re an open, friendly team that helps clients from all over the world to transform their businesses and create astonishing products.
31 |
32 | * We are available for [hire](https://www.rst.software/estimate-your-project).
33 | * If you want to work for us - check out the [career page](https://www.rst.software/join-us).
34 |
--------------------------------------------------------------------------------
/commitlint.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: [
3 | '@commitlint/config-conventional',
4 | ],
5 | }
6 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@adminjs/mongoose",
3 | "version": "4.1.0",
4 | "description": "Mongoose adapter for adminjs",
5 | "type": "module",
6 | "exports": {
7 | ".": {
8 | "import": "./lib/index.js",
9 | "types": "./lib/index.d.ts"
10 | }
11 | },
12 | "scripts": {
13 | "dev": "rm -rf lib && tsc --watch",
14 | "clean": "rm -rf lib",
15 | "build": "tsc",
16 | "test": "NODE_OPTIONS=--experimental-vm-modules jest --config ./test/jest.json --runInBand",
17 | "cover": "jest --config ./test/jest.json --runInBand --coverage",
18 | "lint": "eslint './test/**/*.ts' './src/**/*.ts'",
19 | "check:all": "yarn lint && yarn build && yarn test",
20 | "release": "semantic-release"
21 | },
22 | "repository": {
23 | "type": "git",
24 | "url": "git+https://github.com/SoftwareBrothers/adminjs-mongoose.git"
25 | },
26 | "author": "Wojciech Krysiak",
27 | "license": "MIT",
28 | "bugs": {
29 | "url": "https://github.com/SoftwareBrothers/adminjs-mongoose/issues"
30 | },
31 | "homepage": "https://github.com/SoftwareBrothers/adminjs-mongoose#readme",
32 | "peerDependencies": {
33 | "adminjs": "^7.0.0",
34 | "mongoose": ">=8"
35 | },
36 | "dependencies": {
37 | "escape-regexp": "0.0.1",
38 | "lodash": "^4.17.21"
39 | },
40 | "devDependencies": {
41 | "@commitlint/cli": "^17.4.4",
42 | "@commitlint/config-conventional": "^17.4.4",
43 | "@semantic-release/git": "^10.0.1",
44 | "@types/jest": "^29.5.0",
45 | "@typescript-eslint/eslint-plugin": "^5.55.0",
46 | "@typescript-eslint/parser": "^5.55.0",
47 | "adminjs": "^7.0.0",
48 | "eslint": "^8.36.0",
49 | "eslint-config-airbnb-base": "^15.0.0",
50 | "eslint-plugin-import": "^2.27.5",
51 | "eslint-plugin-react": "^7.32.2",
52 | "factory-girl": "^5.0.4",
53 | "husky": "^4.2.5",
54 | "jest": "^29.5.0",
55 | "mongoose": "^8.1.0",
56 | "semantic-release": "^20.1.3",
57 | "semantic-release-slack-bot": "^4.0.0",
58 | "ts-jest": "^29.0.5",
59 | "typescript": "^4.9.5"
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/database.ts:
--------------------------------------------------------------------------------
1 | import { BaseDatabase } from 'adminjs'
2 | import type { Connection } from 'mongoose'
3 |
4 | import Resource from './resource.js'
5 |
6 | class Database extends BaseDatabase {
7 | private readonly connection: Connection
8 |
9 | constructor(connection) {
10 | super(connection)
11 | this.connection = connection
12 | }
13 |
14 | static isAdapterFor(connection) {
15 | return connection.constructor.name === 'Mongoose'
16 | }
17 |
18 | resources() {
19 | return this.connection.modelNames().map((name) => (
20 | new Resource(this.connection.model(name))
21 | ))
22 | }
23 | }
24 |
25 | export default Database
26 |
--------------------------------------------------------------------------------
/src/index.doc.md:
--------------------------------------------------------------------------------
1 | ### A Mongoose database adapter for AdminJS.
2 |
3 | #### Installation
4 |
5 | To install the adapter run
6 |
7 | ```
8 | yarn add @adminjs/mongoose
9 | ```
10 |
11 | ### Usage
12 |
13 | In order to use it in your project register the adapter first:
14 |
15 | ```javascript
16 | const AdminJS = require('adminjs')
17 | const AdminJSMongoose = require('@adminjs/mongoose')
18 |
19 | AdminJS.registerAdapter(AdminJSMongoose)
20 | ```
21 |
22 | ### Passing an entire database
23 |
24 | You can now pass an entire database to {@link AdminJSOptions}
25 |
26 | ```javascript
27 | const mongoose = require('mongoose')
28 |
29 | // even if we pass entire database, models have to be in scope
30 | require('path-to-your/mongoose-model1')
31 | require('path-to-your/mongoose-model2')
32 |
33 | const run = async () => {
34 | const connection = await mongoose.connect('mongodb://localhost:27017/test', {
35 | useNewUrlParser: true,
36 | })
37 | const AdminJS = new AdminJS({
38 | databases: [connection],
39 | //... other AdminJSOptions
40 | })
41 | //...
42 | }
43 | run()
44 | ```
45 |
46 | > Notice, that we connected with the database BEFORE passing it to
47 | > the **AdminJS({})** options. This is very important. Otherwise,
48 | > AdminJS might not find any resources.
49 |
50 | ### Passing each resource
51 |
52 | Passing via _resource_ gives you the ability to add additional {@link ResourceOptions}
53 |
54 | ```javascript
55 | const User = mongoose.model('User', { name: String, email: String, surname: String })
56 |
57 | const run = async () => {
58 | await mongoose.connect('mongodb://localhost:27017/test', {
59 | useNewUrlParser: true,
60 | })
61 | const AdminJS = new AdminJS({
62 | resources: [{
63 | resource: User,
64 | options: {
65 | //...
66 | }
67 | }],
68 | //... other AdminJSOptions
69 | })
70 | }
71 | ```
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @module @adminjs/mongoose
3 | * @subcategory Adapters
4 | * @section modules
5 | * @load ./index.doc.md
6 | */
7 |
8 | export { default as Database } from './database.js'
9 | export { default as Resource } from './resource.js'
10 | export { default as Property } from './property.js'
11 | export * from './utils/index.js'
12 |
--------------------------------------------------------------------------------
/src/property.ts:
--------------------------------------------------------------------------------
1 | import { BaseProperty, PropertyType } from 'adminjs'
2 |
3 | const ID_PROPERTY = '_id'
4 | const VERSION_KEY_PROPERTY = '__v'
5 |
6 | class Property extends BaseProperty {
7 | // TODO: Fix typings
8 | public mongoosePath: any
9 |
10 | /**
11 | * Crates an object from mongoose schema path
12 | *
13 | * @param {SchemaString} path
14 | * @param {String[]} path.enumValues
15 | * @param {String} path.regExp
16 | * @param {String} path.path
17 | * @param {String} path.instance
18 | * @param {Object[]} path.validators
19 | * @param {Object[]} path.setters
20 | * @param {Object[]} path.getters
21 | * @param {Object} path.options
22 | * @param {Object} path._index
23 | * @param {number} position
24 | *
25 | * @private
26 | *
27 | * @example
28 | *
29 | * const schema = new mongoose.Schema({
30 | * email: String,
31 | * })
32 | *
33 | * property = new Property(schema.paths.email))
34 | */
35 | constructor(path, position = 0) {
36 | super({ path: path.path, position })
37 | this.mongoosePath = path
38 | }
39 |
40 | instanceToType(mongooseInstance) {
41 | switch (mongooseInstance) {
42 | case 'String':
43 | return 'string'
44 | case 'Boolean':
45 | return 'boolean'
46 | case 'Number':
47 | return 'number'
48 | case 'Date':
49 | return 'datetime'
50 | case 'Embedded':
51 | return 'mixed'
52 | case 'ObjectID':
53 | case 'ObjectId':
54 | if (this.reference()) {
55 | return 'reference'
56 | }
57 | return 'id' as PropertyType
58 | case 'Decimal128':
59 | return 'float'
60 | default:
61 | return 'string'
62 | }
63 | }
64 |
65 | name() {
66 | return this.mongoosePath.path
67 | }
68 |
69 | isEditable() {
70 | return this.name() !== VERSION_KEY_PROPERTY && this.name() !== ID_PROPERTY
71 | }
72 |
73 | reference() {
74 | const ref = this.isArray()
75 | ? this.mongoosePath.caster.options?.ref
76 | : this.mongoosePath.options?.ref
77 |
78 | if (typeof ref === 'function') return ref.modelName
79 |
80 | return ref
81 | }
82 |
83 | isVisible() {
84 | return this.name() !== VERSION_KEY_PROPERTY
85 | }
86 |
87 | isId() {
88 | return this.name() === ID_PROPERTY
89 | }
90 |
91 | availableValues() {
92 | return this.mongoosePath.enumValues?.length ? this.mongoosePath.enumValues : null
93 | }
94 |
95 | isArray() {
96 | return this.mongoosePath.instance === 'Array'
97 | }
98 |
99 | subProperties() {
100 | if (this.type() === 'mixed') {
101 | const subPaths = Object.values(this.mongoosePath.caster.schema.paths)
102 | return subPaths.map((p) => new Property(p))
103 | }
104 | return []
105 | }
106 |
107 | type() {
108 | if (this.isArray()) {
109 | let { instance } = this.mongoosePath.caster
110 | // For array of embedded schemas mongoose returns null for caster.instance
111 | // That is why we have to check if caster has a schema
112 | if (!instance && this.mongoosePath.caster.schema) {
113 | instance = 'Embedded'
114 | }
115 | return this.instanceToType(instance)
116 | }
117 | return this.instanceToType(this.mongoosePath.instance)
118 | }
119 |
120 | isSortable() {
121 | return this.type() !== 'mixed' && !this.isArray()
122 | }
123 |
124 | isRequired() {
125 | return !!this.mongoosePath.validators?.find?.((validator) => validator.type === 'required')
126 | }
127 | }
128 |
129 | export default Property
130 |
--------------------------------------------------------------------------------
/src/resource.ts:
--------------------------------------------------------------------------------
1 | import { BaseRecord, BaseResource, flat } from 'adminjs'
2 | import get from 'lodash/get.js'
3 | import mongoose from 'mongoose'
4 |
5 | import Property from './property.js'
6 | import { convertFilter } from './utils/convert-filter.js'
7 | import { createCastError } from './utils/create-cast-error.js'
8 | import { createDuplicateError } from './utils/create-duplicate-error.js'
9 | import { createValidationError } from './utils/create-validation-error.js'
10 | import { FindOptions } from './utils/filter.types.js'
11 | import errors from './utils/errors.js'
12 |
13 | const { MONGOOSE_CAST_ERROR, MONGOOSE_DUPLICATE_ERROR_CODE, MONGOOSE_VALIDATION_ERROR } = errors
14 |
15 | /**
16 | * Adapter for mongoose resource
17 | * @private
18 | */
19 | class Resource extends BaseResource {
20 | private readonly dbType: string = 'mongodb'
21 |
22 | /**
23 | * @typedef {Object} MongooseModel
24 | * @private
25 | * @see https://mongoosejs.com/docs/models.html
26 | */
27 | public readonly MongooseModel: mongoose.Model
28 |
29 | /**
30 | * Initialize the class with the Resource name
31 | * @param {MongooseModel} MongooseModel Class which subclass mongoose.Model
32 | * @memberof Resource
33 | */
34 | constructor(MongooseModel) {
35 | super(MongooseModel)
36 | this.MongooseModel = MongooseModel
37 | }
38 |
39 | static isAdapterFor(MoongooseModel) {
40 | return get(MoongooseModel, 'base.constructor.name') === 'Mongoose'
41 | }
42 |
43 | databaseName() {
44 | return this.MongooseModel.db.name
45 | }
46 |
47 | databaseType() {
48 | return this.dbType
49 | }
50 |
51 | name() {
52 | return this.MongooseModel.modelName
53 | }
54 |
55 | id() {
56 | return this.MongooseModel.modelName
57 | }
58 |
59 | properties() {
60 | return Object.entries(this.MongooseModel.schema.paths).map(([, path], position) => (
61 | new Property(path, position)
62 | ))
63 | }
64 |
65 | property(name: string) {
66 | return this.properties().find((property) => property.path() === name) ?? null
67 | }
68 |
69 | async count(filters = null) {
70 | if (Object.keys(convertFilter(filters)).length > 0) {
71 | return this.MongooseModel.countDocuments(convertFilter(filters))
72 | }
73 | return this.MongooseModel.estimatedDocumentCount()
74 | }
75 |
76 | // eslint-disable-next-line default-param-last
77 | async find(filters = {}, { limit = 20, offset = 0, sort = {} }: FindOptions) {
78 | const { direction, sortBy } = sort
79 | const sortingParam = {
80 | [sortBy]: direction,
81 | }
82 | const mongooseObjects = await this.MongooseModel
83 | .find(convertFilter(filters), {}, {
84 | skip: offset, limit, sort: sortingParam,
85 | })
86 | // eslint-disable-next-line max-len
87 | return mongooseObjects.map((mongooseObject) => new BaseRecord(Resource.stringifyId(mongooseObject), this))
88 | }
89 |
90 | async findOne(id: string) {
91 | const mongooseObject = await this.MongooseModel.findById(id)
92 | return new BaseRecord(Resource.stringifyId(mongooseObject), this)
93 | }
94 |
95 | async findMany(ids: string[]) {
96 | const mongooseObjects = await this.MongooseModel.find(
97 | { _id: ids },
98 | {},
99 | )
100 | return mongooseObjects.map((mongooseObject) => (
101 | new BaseRecord(Resource.stringifyId(mongooseObject), this)
102 | ))
103 | }
104 |
105 | build(params) {
106 | return new BaseRecord(Resource.stringifyId(params), this)
107 | }
108 |
109 | async create(params) {
110 | const parsedParams = this.parseParams(params)
111 | let mongooseDocument = new this.MongooseModel(parsedParams)
112 | try {
113 | mongooseDocument = await mongooseDocument.save()
114 | } catch (error) {
115 | if (error.name === MONGOOSE_VALIDATION_ERROR) {
116 | throw createValidationError(error)
117 | }
118 | if (error.code === MONGOOSE_DUPLICATE_ERROR_CODE) {
119 | throw createDuplicateError(error, mongooseDocument.toJSON())
120 | }
121 | throw error
122 | }
123 | return Resource.stringifyId(mongooseDocument.toObject())
124 | }
125 |
126 | async update(id, params) {
127 | const parsedParams = this.parseParams(params)
128 | const unflattedParams = flat.unflatten(parsedParams)
129 | try {
130 | const mongooseObject = await this.MongooseModel.findOneAndUpdate({
131 | _id: id,
132 | }, {
133 | $set: unflattedParams,
134 | }, {
135 | new: true,
136 | runValidators: true,
137 | context: 'query',
138 | })
139 | return Resource.stringifyId(mongooseObject.toObject())
140 | } catch (error) {
141 | if (error.name === MONGOOSE_VALIDATION_ERROR) {
142 | throw createValidationError(error)
143 | }
144 | if (error.code === MONGOOSE_DUPLICATE_ERROR_CODE) {
145 | throw createDuplicateError(error, unflattedParams)
146 | }
147 | // In update cast errors are not wrapped into a validation errors (as it happens in create).
148 | // that is why we have to have a different way of handling them - check out tests to see
149 | // example error
150 | if (error.name === MONGOOSE_CAST_ERROR) {
151 | throw createCastError(error)
152 | }
153 | throw error
154 | }
155 | }
156 |
157 | async delete(id) {
158 | return this.MongooseModel.findOneAndDelete({ _id: id })
159 | }
160 |
161 | static stringifyId(mongooseObj) {
162 | // By default Id field is an ObjectID and when we change entire mongoose model to
163 | // raw object it changes _id field not to a string but to an object.
164 | // stringify/parse is a path found here: https://github.com/Automattic/mongoose/issues/2790
165 | // @todo We can somehow speed this up
166 | const strinigified = JSON.stringify(mongooseObj)
167 | return JSON.parse(strinigified)
168 | }
169 |
170 | /**
171 | * Check all params against values they hold. In case of wrong value it corrects it.
172 | *
173 | * What it does exactly:
174 | * - changes all empty strings to `null`s for the ObjectID properties.
175 | * - changes all empty strings to [] for array fields
176 | *
177 | * @param {Object} params received from AdminJS form
178 | *
179 | * @return {Object} converted params
180 | */
181 | parseParams(params) {
182 | const parsedParams = { ...params }
183 |
184 | // this function handles ObjectIDs and Arrays recursively
185 | const handleProperty = (prefix = '') => (property) => {
186 | const {
187 | path,
188 | schema,
189 | instance,
190 | } = property
191 | // mongoose doesn't supply us with the same path as we're using in our data
192 | // so we need to improvise
193 | const fullPath = [prefix, path].filter(Boolean).join('.')
194 | const value = parsedParams[fullPath]
195 |
196 | // this handles missing ObjectIDs
197 | if (instance === 'ObjectID') {
198 | if (value === '') {
199 | parsedParams[fullPath] = null
200 | } else if (value) {
201 | // this works similar as this.stringifyId
202 | parsedParams[fullPath] = value.toString()
203 | }
204 | }
205 |
206 | // this handles empty Arrays or recurse into all properties of a filled Array
207 | if (instance === 'Array') {
208 | if (value === '') {
209 | parsedParams[fullPath] = []
210 | } else if (schema && schema.paths) { // we only want arrays of objects (with sub-paths)
211 | const subProperties = Object.values(schema.paths)
212 | // eslint-disable-next-line no-plusplus, no-constant-condition
213 | for (let i = 0; true; i++) { // loop over every item
214 | const newPrefix = `${fullPath}.${i}`
215 | if (parsedParams[newPrefix] === '') {
216 | // this means we have an empty object here
217 | parsedParams[newPrefix] = {}
218 | } else if (!Object.keys(parsedParams).some((key) => key.startsWith(newPrefix))) {
219 | // we're past the last index of this array
220 | break
221 | } else {
222 | // recurse into the object
223 | subProperties.forEach(handleProperty(newPrefix))
224 | }
225 | }
226 | }
227 | }
228 |
229 | // this handles all properties of an object
230 | if (instance === 'Embedded') {
231 | if (parsedParams[fullPath] === '') {
232 | parsedParams[fullPath] = {}
233 | } else {
234 | const subProperties = Object.values(schema.paths)
235 | subProperties.forEach(handleProperty(fullPath))
236 | }
237 | }
238 | }
239 |
240 | this.properties().forEach(({ mongoosePath }) => handleProperty()(mongoosePath))
241 |
242 | return parsedParams
243 | }
244 | }
245 |
246 | export default Resource
247 |
--------------------------------------------------------------------------------
/src/utils/convert-filter.ts:
--------------------------------------------------------------------------------
1 | import escape from 'escape-regexp'
2 | import mongoose from 'mongoose'
3 |
4 | /**
5 | * Changes AdminJS's {@link Filter} to an object acceptible by a mongoose query.
6 | *
7 | * @param {Filter} filter
8 | * @private
9 | */
10 | export const convertFilter = (filter) => {
11 | if (!filter) {
12 | return {}
13 | }
14 | return filter.reduce((memo, filterProperty) => {
15 | const { property, value } = filterProperty
16 | switch (property.type()) {
17 | case 'string':
18 | return {
19 | [property.name()]: { $regex: escape(value), $options: 'i' },
20 | ...memo,
21 | }
22 | case 'date':
23 | case 'datetime':
24 | if (value.from || value.to) {
25 | return {
26 | [property.name()]: {
27 | ...value.from && { $gte: value.from },
28 | ...value.to && { $lte: value.to },
29 | },
30 | ...memo,
31 | }
32 | }
33 | break
34 | case 'id':
35 | if (mongoose.Types.ObjectId.isValid(value)) {
36 | return {
37 | [property.name()]: value,
38 | ...memo,
39 | }
40 | }
41 | return {}
42 | default:
43 | break
44 | }
45 | return {
46 | [property.name()]: value,
47 | ...memo,
48 | }
49 | }, {})
50 | }
51 |
--------------------------------------------------------------------------------
/src/utils/create-cast-error.ts:
--------------------------------------------------------------------------------
1 | import { ValidationError } from 'adminjs'
2 |
3 | export const createCastError = (originalError): ValidationError => {
4 | // cas error has only the nested path. So when an actual path is 'parents.age'
5 | // originalError will have just a 'age'. That is why we are finding first param
6 | // with the same value as the error has and path ending the same like path in
7 | // originalError or ending with path with array notation: "${path}.0"
8 | const errors = {
9 | [originalError.path]: {
10 | message: originalError.message,
11 | type: originalError.kind || originalError.name,
12 | },
13 | }
14 | return new ValidationError(errors)
15 | }
16 |
--------------------------------------------------------------------------------
/src/utils/create-duplicate-error.ts:
--------------------------------------------------------------------------------
1 | import { ValidationError } from 'adminjs'
2 |
3 | const createDuplicateMessage = (message) => ({
4 | type: 'duplicate',
5 | message,
6 | })
7 |
8 | const createDuplicateError = ({ keyValue: duplicateEntry, errmsg }, document): ValidationError => {
9 | if (!duplicateEntry) {
10 | const duplicatedKey = Object.keys(document).find((key) => errmsg.includes(key))
11 |
12 | return new ValidationError({
13 | [duplicatedKey]: createDuplicateMessage(`Record with that ${duplicatedKey} already exists`),
14 | })
15 | }
16 |
17 | const [[keyName]] = Object.entries(duplicateEntry)
18 |
19 | return new ValidationError({
20 | [keyName]: createDuplicateMessage(`Record with that ${keyName} already exists`),
21 | })
22 | }
23 |
24 | export { createDuplicateError }
25 |
--------------------------------------------------------------------------------
/src/utils/create-validation-error.ts:
--------------------------------------------------------------------------------
1 | import { ValidationError } from 'adminjs'
2 |
3 | export const createValidationError = (originalError): ValidationError => {
4 | const errors = Object.keys(originalError.errors).reduce((memo, key) => {
5 | const { message, kind, name } = originalError.errors[key]
6 | return {
7 | ...memo,
8 | [key]: {
9 | message,
10 | type: kind || name,
11 | },
12 | }
13 | }, {})
14 | return new ValidationError(errors)
15 | }
16 |
--------------------------------------------------------------------------------
/src/utils/errors.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | // Error thrown by mongoose in case of validation error
3 | MONGOOSE_VALIDATION_ERROR: 'ValidationError',
4 |
5 | // Error thrown by mongoose in case of casting error (writing string to Number field)
6 | MONGOOSE_CAST_ERROR: 'CastError',
7 |
8 | // Error thrown by mongoose in case of inserting duplicate record
9 | // with some property marked as `unique`
10 | MONGOOSE_DUPLICATE_ERROR_CODE: 11000,
11 | }
12 |
--------------------------------------------------------------------------------
/src/utils/filter.types.ts:
--------------------------------------------------------------------------------
1 | export type FindOptions = {
2 | limit?: number;
3 | offset?: number;
4 | sort?: {
5 | sortBy?: string;
6 | direction?: 'asc' | 'desc';
7 | };
8 | };
9 |
--------------------------------------------------------------------------------
/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | export * from './convert-filter.js'
2 | export * from './create-cast-error.js'
3 | export * from './create-duplicate-error.js'
4 | export * from './create-validation-error.js'
5 | export { default as MongooseAdapterErrors } from './errors.js'
6 | export * from './filter.types.js'
7 |
--------------------------------------------------------------------------------
/test/database.spec.ts:
--------------------------------------------------------------------------------
1 | import mongoose from 'mongoose'
2 | import MongooseDatabase from '../src/database.js'
3 | import Resource from '../src/resource.js'
4 |
5 | describe('Database', () => {
6 | describe('#resources', () => {
7 | let resources: Resource[]
8 |
9 | beforeEach(() => {
10 | resources = new MongooseDatabase(mongoose.connection).resources()
11 | })
12 |
13 | it('return all resources', () => {
14 | expect(resources.length).toEqual(3)
15 | })
16 | })
17 | })
18 |
--------------------------------------------------------------------------------
/test/errors/create-cast-error.spec.ts:
--------------------------------------------------------------------------------
1 | import { createCastError } from '../../src/utils/create-cast-error.js'
2 | import { SAMPLE_CAST_ARRAY_ERROR } from '../fixtures/mongoose-cast-array-error.js'
3 | import { SAMPLE_CAST_ERROR } from '../fixtures/mongoose-cast-error.js'
4 |
5 | describe('createCastError', () => {
6 | describe('throwin cast error on update after one key has error', () => {
7 | it('has error for nested "parent.age" (errorKey) field', () => {
8 | const error = createCastError(SAMPLE_CAST_ERROR)
9 |
10 | expect(error.propertyErrors.age.type).toEqual('Number')
11 | })
12 | })
13 |
14 | describe('throwing cast error on update when one array field has error', () => {
15 | it('throws an error for root field', () => {
16 | const error = createCastError(SAMPLE_CAST_ARRAY_ERROR)
17 |
18 | expect(error.propertyErrors.authors.type).toEqual('ObjectId')
19 | })
20 | })
21 | })
22 |
--------------------------------------------------------------------------------
/test/errors/create-validation-error.spec.ts:
--------------------------------------------------------------------------------
1 | import { createValidationError } from '../../src/utils/create-validation-error.js'
2 | import { SAMPLE_NESTED_VALIDATION_ERROR } from '../fixtures/mongoose-nested-validation-error.js'
3 | import { SAMPLE_VALIDATION_ERROR } from '../fixtures/mongoose-validation-error.js'
4 |
5 | describe('#createValidationError', () => {
6 | describe('regular error', () => {
7 | it('has errors', () => {
8 | const error = createValidationError(SAMPLE_VALIDATION_ERROR)
9 |
10 | expect(Object.keys(error.propertyErrors).length).toEqual(2)
11 | })
12 |
13 | it('has error for email', () => {
14 | const error = createValidationError(SAMPLE_VALIDATION_ERROR)
15 |
16 | expect(error.propertyErrors.email.type).toEqual('required')
17 | })
18 | })
19 |
20 | describe('error for nested field', () => {
21 | it('2 errors, one for root field and one for an actual nested field', () => {
22 | const error = createValidationError(SAMPLE_NESTED_VALIDATION_ERROR)
23 |
24 | expect(Object.keys(error.propertyErrors).length).toEqual(2)
25 | })
26 |
27 | it('has error for nested "parent.age" field', () => {
28 | const error = createValidationError(SAMPLE_NESTED_VALIDATION_ERROR)
29 |
30 | expect(error.propertyErrors['parent.age'].type).toEqual('Number')
31 | })
32 |
33 | it('has error for "parent" field', () => {
34 | const error = createValidationError(SAMPLE_NESTED_VALIDATION_ERROR)
35 |
36 | expect(error.propertyErrors.parent.type).toEqual('ValidationError')
37 | })
38 | })
39 | })
40 |
--------------------------------------------------------------------------------
/test/fixtures/duplicate-error.ts:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | driver: true,
3 | name: 'MongoError',
4 | index: 0,
5 | code: 11000,
6 | errmsg: 'insertDocument :: caused by :: 11000 E11000 duplicate key error index: adminjs-mongoose.pesels.$pesel_1 dup key: { : "1" }',
7 | }
8 |
--------------------------------------------------------------------------------
/test/fixtures/mongoose-cast-array-error.ts:
--------------------------------------------------------------------------------
1 | export const SAMPLE_CAST_ARRAY_ERROR = {
2 | stringValue: '""',
3 | kind: 'ObjectId',
4 | value: '',
5 | path: 'authors',
6 | reason: {
7 |
8 | },
9 | message: 'Cast to ObjectId failed for value "" at path "authors"',
10 | name: 'CastError',
11 | }
12 |
--------------------------------------------------------------------------------
/test/fixtures/mongoose-cast-error.ts:
--------------------------------------------------------------------------------
1 | export const SAMPLE_CAST_ERROR = {
2 | stringValue: '"asdasd"',
3 | kind: 'Number',
4 | value: 'asdasd',
5 | path: 'age',
6 | reason: {
7 | stringValue: '"asdasd"',
8 | kind: 'number',
9 | value: 'asdasd',
10 | path: 'age',
11 | reason: {
12 | generatedMessage: true,
13 | name: 'AssertionError [ERR_ASSERTION]',
14 | code: 'ERR_ASSERTION',
15 | actual: false,
16 | expected: true,
17 | operator: '==',
18 | },
19 | message: 'Cast to number failed for value "asdasd" at path "age"',
20 | name: 'CastError',
21 | },
22 | message: 'Cast to Number failed for value "asdasd" at path "age"',
23 | name: 'CastError',
24 | }
25 |
--------------------------------------------------------------------------------
/test/fixtures/mongoose-nested-validation-error.ts:
--------------------------------------------------------------------------------
1 | export const SAMPLE_NESTED_VALIDATION_ERROR = {
2 | errors: {
3 | 'parent.age': {
4 | message: 'Cast to Number failed for value "not a number" at path "age"',
5 | name: 'CastError',
6 | stringValue: '"not a number"',
7 | kind: 'Number',
8 | value: 'not a number',
9 | path: 'age',
10 | reason: {
11 | message: 'Cast to number failed for value "not a number" at path "age"',
12 | name: 'CastError',
13 | stringValue: '"not a number"',
14 | kind: 'number',
15 | value: 'not a number',
16 | path: 'age',
17 | },
18 | },
19 | parent: {
20 | errors: {
21 | age: {
22 | message: 'Cast to Number failed for value "not a number" at path "age"',
23 | name: 'CastError',
24 | stringValue: '"not a number"',
25 | kind: 'Number',
26 | value: 'not a number',
27 | path: 'age',
28 | reason: {
29 | message: 'Cast to number failed for value "not a number" at path "age"',
30 | name: 'CastError',
31 | stringValue: '"not a number"',
32 | kind: 'number',
33 | value: 'not a number',
34 | path: 'age',
35 | },
36 | },
37 | },
38 | _message: 'Validation failed',
39 | message: 'Validation failed: age: Cast to Number failed for value "not a number" at path "age"',
40 | name: 'ValidationError',
41 | },
42 | },
43 | _message: 'User validation failed',
44 | message: 'User validation failed: parent.age: Cast to Number failed for value "not a number" at path "age", parent: Validation failed: age: Cast to Number failed for value "not a number" at path "age"',
45 | name: 'ValidationError',
46 | }
47 |
--------------------------------------------------------------------------------
/test/fixtures/mongoose-validation-error.ts:
--------------------------------------------------------------------------------
1 | export const SAMPLE_VALIDATION_ERROR = {
2 | _message: 'User validation failed',
3 | errors: {
4 | email: {
5 | $isValidatorError: true,
6 | kind: 'required',
7 | message: 'Path `email` is required.',
8 | name: 'ValidatorError',
9 | path: 'email',
10 | properties: {
11 | message: 'Path `email` is required.',
12 | path: 'email',
13 | type: 'required',
14 | value: '',
15 | },
16 | value: '',
17 | },
18 | passwordHash: {
19 | $isValidatorError: true,
20 | kind: 'required',
21 | message: 'Path `passwordHash` is required.',
22 | name: 'ValidatorError',
23 | path: 'passwordHash',
24 | properties: {
25 | message: 'Path `passwordHash` is required.',
26 | path: 'passwordHash',
27 | type: 'required',
28 | value: '',
29 | },
30 | value: '',
31 | },
32 | },
33 | message: 'User validation failed: email: Path `email` is required., passwordHash: Path `passwordHash` is required.',
34 | name: 'ValidationError',
35 | }
36 |
--------------------------------------------------------------------------------
/test/fixtures/valid-user-record.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | email: 'john@doe.com',
3 | passwordHash: 'somesecretpasswordhash',
4 | 'genre.type': 'some type',
5 | 'genre.enum': 'male',
6 | 'arrayed.0': 'first',
7 | 'arrayed.1': 'second',
8 | 'parent.name': 'name',
9 | 'parent.surname': 'surname',
10 | 'parent.age': 12,
11 | 'parent.nestedArray.0.someProperty': 12,
12 | 'parent.nestedArray.1.someProperty': 12,
13 | 'parent.nestedObject.someProperty': 12,
14 | 'family.0.name': 'some string',
15 | 'family.0.surname': 'some string',
16 | 'family.0.age': 13,
17 | 'family.0.nestedArray.0.someProperty': 12,
18 | 'family.0.nestedArray.1.someProperty': 12,
19 | 'family.0.nestedObject.someProperty': 12,
20 | 'family.1.name': 'some string',
21 | 'family.1.surname': 'some string',
22 | 'family.1.age': 14,
23 | 'family.1.nestedArray.0.someProperty': 12,
24 | 'family.1.nestedArray.1.someProperty': 12,
25 | 'family.1.nestedObject.someProperty': 12,
26 | }
27 |
--------------------------------------------------------------------------------
/test/jest.json:
--------------------------------------------------------------------------------
1 | {
2 | "moduleFileExtensions": ["js", "json", "ts", "tsx"],
3 | "rootDir": ".",
4 | "testEnvironment": "node",
5 | "testRegex": ".spec.ts$",
6 | "extensionsToTreatAsEsm": [".ts"],
7 | "transformIgnorePatterns": ["node_modules"],
8 | "transform": {
9 | "^.+\\.(t|j)sx?$": [
10 | "ts-jest",
11 | {
12 | "useESM": true,
13 | "tsconfig": "./tsconfig.test.json",
14 | "isolatedModules": true
15 | }
16 | ]
17 | },
18 | "moduleNameMapper": {
19 | "^(\\.{1,2}/.*)\\.js$": "$1"
20 | },
21 | "testTimeout": 10000,
22 | "preset": "ts-jest/presets/default-esm",
23 | "verbose": true,
24 | "silent": true,
25 | "forceExit": true,
26 | "setupFilesAfterEnv": ["./utils/beforeEach.ts"],
27 | "globalTeardown": "./utils/teardown.ts"
28 | }
--------------------------------------------------------------------------------
/test/property.spec.ts:
--------------------------------------------------------------------------------
1 | import { Article, User } from './utils/models.js'
2 |
3 | import Property from '../src/property.js'
4 |
5 | describe('Property', () => {
6 | describe('#availableValues', () => {
7 | it('returns null for all standard (Non enum) values', () => {
8 | const property = new Property(User.schema.paths.email)
9 | expect(property.availableValues()).toEqual(null)
10 | })
11 |
12 | it('returns array of values for the enum field', () => {
13 | const property = new Property(User.schema.paths.genre)
14 | expect(property.availableValues()).toEqual(['male', 'female'])
15 | })
16 | })
17 |
18 | describe('#isArray', () => {
19 | it('returns false for regular (not arrayed) property', () => {
20 | const property = new Property(User.schema.paths.email)
21 | expect(property.isArray()).toEqual(false)
22 | })
23 |
24 | it('returns true for array property', () => {
25 | const property = new Property(User.schema.paths.arrayed)
26 | expect(property.isArray()).toEqual(true)
27 | })
28 | })
29 |
30 | describe('#type', () => {
31 | it('returns string type for string property', () => {
32 | const property = new Property(User.schema.paths.email)
33 | expect(property.type()).toEqual('string')
34 | })
35 |
36 | it('returns string when property is an array of strings', () => {
37 | const property = new Property(User.schema.paths.arrayed)
38 | expect(property.type()).toEqual('string')
39 | })
40 |
41 | it('returns mixed when prooperty is an array of embeded schemas', () => {
42 | const property = new Property(User.schema.paths.family)
43 | expect(property.type()).toEqual('mixed')
44 | })
45 | })
46 |
47 | describe('#reference', () => {
48 | it('returns undefined when property without a reference is given', () => {
49 | const property = new Property(User.schema.paths.email)
50 | expect(property.reference()).toEqual(undefined)
51 | })
52 |
53 | it('returns reference to User when field with it is given', () => {
54 | const property = new Property(Article.schema.paths.createdBy)
55 | expect(property.reference()).toEqual('User')
56 | })
57 |
58 | it('returns reference to User when field is an array fields with references', () => {
59 | const property = new Property(Article.schema.paths.owners)
60 | expect(property.reference()).toEqual('User')
61 | })
62 | })
63 |
64 | describe('#subProperties', () => {
65 | it('returns empty array for regular (not mixed) property', () => {
66 | const property = new Property(User.schema.paths.email)
67 | expect(property.subProperties()).toEqual([])
68 | })
69 |
70 | it('returns an array of all subproperties when nested schema is given', () => {
71 | const property = new Property(User.schema.paths.parent)
72 | const subProperties = property.subProperties()
73 | expect(subProperties.length).toEqual(6)
74 | })
75 |
76 | it('returns an array of all subproperties when array of nested schema is given', () => {
77 | const property = new Property(User.schema.paths.family)
78 | const subProperties = property.subProperties()
79 | expect(subProperties.length).toEqual(6)
80 | })
81 | })
82 |
83 | describe('#isRequired', () => {
84 | it('returns true for required property', () => {
85 | const property = new Property(User.schema.paths.email)
86 | expect(property.isRequired()).toEqual(true)
87 | })
88 |
89 | it('returns string when property is an array of strings', () => {
90 | const property = new Property(User.schema.paths.genre)
91 | expect(property.isRequired()).toEqual(false)
92 | })
93 | })
94 | })
95 |
--------------------------------------------------------------------------------
/test/resource/constructor.ts:
--------------------------------------------------------------------------------
1 | import Resource from '../../src/resource.js'
2 | import { User } from '../utils/models.js'
3 |
4 | describe('Resource', () => {
5 | describe('#constructor', () => {
6 | it('stores original model', () => {
7 | const userResource = new Resource(User)
8 | expect(userResource.MongooseModel).toEqual(User)
9 | })
10 | })
11 | })
12 |
--------------------------------------------------------------------------------
/test/resource/count.spec.ts:
--------------------------------------------------------------------------------
1 | import { Filter } from 'adminjs'
2 | import { factory } from 'factory-girl'
3 | import Resource from '../../src/resource.js'
4 | import { User } from '../utils/models.js'
5 |
6 | describe('Resource #count', () => {
7 | let resource
8 |
9 | beforeEach(() => {
10 | resource = new Resource(User)
11 | })
12 |
13 | it('returns given count without filters', async () => {
14 | const NUMBER_OF_RECORDS = 12
15 | await factory.createMany('user', NUMBER_OF_RECORDS)
16 |
17 | const countedRecords = await resource.count(new Filter({}, resource))
18 |
19 | expect(countedRecords).toEqual(NUMBER_OF_RECORDS)
20 | })
21 |
22 | it('returns given count for given filters', async () => {
23 | const filterOutAllRecords = new Filter({
24 | email: 'some-not-existing-email',
25 | }, resource)
26 |
27 | const counterRecords = await resource.count(filterOutAllRecords)
28 |
29 | expect(counterRecords).toEqual(0)
30 | })
31 | })
32 |
--------------------------------------------------------------------------------
/test/resource/create.spec.ts:
--------------------------------------------------------------------------------
1 | import { ValidationError } from 'adminjs'
2 | import { factory } from 'factory-girl'
3 | import Resource from '../../src/resource.js'
4 | import validUserRecord from '../fixtures/valid-user-record.js'
5 | import { Article, Pesel, User } from '../utils/models.js'
6 |
7 | describe('Resource #create', () => {
8 | it('creates new record with valid parameters', async () => {
9 | const resource = new Resource(User)
10 |
11 | const record = await resource.create(validUserRecord)
12 |
13 | expect(await resource.count()).toEqual(1)
14 | expect(record).toBeInstanceOf(Object)
15 | })
16 |
17 | it('throws validation error for record with invalid parameters', async () => {
18 | const resource = new Resource(User)
19 |
20 | await expect(() => resource.create({ email: '', passwordHash: '' })).rejects.toThrow(ValidationError)
21 | })
22 |
23 | it('throws validation error for record with cast errors in nested schema', async () => {
24 | const resource = new Resource(User)
25 |
26 | try {
27 | await resource.create({
28 | email: 'a@a.pl',
29 | passwordHash: 'asdasdasd',
30 | 'parent.age': 'not a number',
31 | })
32 |
33 | throw new Error('Should throw validation error')
34 | } catch (error) {
35 | expect(error).toBeInstanceOf(ValidationError)
36 | expect(error.propertyErrors['parent.age'].type).toEqual('Number')
37 | expect(error.propertyErrors.parent.type).toEqual('ValidationError')
38 | }
39 | })
40 |
41 | it('throws duplicate error for record with unique field', async () => {
42 | const peselResource = new Resource(Pesel)
43 |
44 | try {
45 | await peselResource.create({ pesel: '1' })
46 | await peselResource.create({ pesel: '1' })
47 | } catch (error) {
48 | expect(error).toBeInstanceOf(ValidationError)
49 | expect(error.propertyErrors.pesel.type).toEqual('duplicate')
50 | }
51 | })
52 |
53 | it('creates resource with id field passed as an empty string', async () => {
54 | const resource = new Resource(Article)
55 |
56 | await resource.create({ content: 'some content', createdBy: '' })
57 |
58 | const recordsCount = await resource.count()
59 | expect(recordsCount).toEqual(1)
60 | })
61 |
62 | it('creates new resource for record with reference', async () => {
63 | const resource = new Resource(Article)
64 | const userRecords = await factory.createMany('user', 1)
65 |
66 | const createdRecord = await resource.create({ content: '', createdBy: userRecords[0]._id })
67 |
68 | expect(createdRecord.createdBy.toString()).toEqual(userRecords[0]._id.toString())
69 | })
70 |
71 | it('creates new object for record with nested array', async () => {
72 | const resource = new Resource(User)
73 | await factory.createMany('user', 1)
74 | const countBefore = await resource.count()
75 |
76 | await resource.create({
77 | email: 'john@doe.com',
78 | passwordHash: 'somesecretpasswordhash',
79 | 'parent.name': 'name',
80 | 'parent.nestedArray': '',
81 | 'parent.nestedObject': '',
82 | 'family.0.name': 'some string',
83 | 'family.0.nestedArray.0': '',
84 | 'family.1': '',
85 | })
86 |
87 | const countAfter = await resource.count()
88 | expect(countAfter - countBefore).toEqual(1)
89 | })
90 | })
91 |
--------------------------------------------------------------------------------
/test/resource/delete.spec.ts:
--------------------------------------------------------------------------------
1 | import { factory } from 'factory-girl'
2 | import Resource from '../../src/resource.js'
3 | import { User } from '../utils/models.js'
4 |
5 | describe('Resource #delete', () => {
6 | it('removes the item from the database', async () => {
7 | const resource = new Resource(User)
8 | const records = await factory.createMany('user', 12)
9 | const initialNumberOfRecords = await User.countDocuments()
10 | const idOfItemToDelete = records[0]._id
11 |
12 | await resource.delete(idOfItemToDelete)
13 |
14 | expect(await User.countDocuments()).toEqual(initialNumberOfRecords - 1)
15 | })
16 | })
17 |
--------------------------------------------------------------------------------
/test/resource/find.spec.ts:
--------------------------------------------------------------------------------
1 | import { BaseRecord, Filter } from 'adminjs'
2 | import { factory } from 'factory-girl'
3 | import Resource from '../../src/resource.js'
4 | import { User } from '../utils/models.js'
5 |
6 | describe('Resource #find', () => {
7 | it('returns first n items', async () => {
8 | await factory.createMany('user', 10)
9 | const resource = new Resource(User)
10 | const limit = 5
11 | const offset = 0
12 |
13 | const returnedItems = await resource.find(new Filter({}, User), {
14 | limit,
15 | offset,
16 | })
17 |
18 | expect(returnedItems.length).toEqual(limit)
19 | expect(returnedItems[0]).toBeInstanceOf(BaseRecord)
20 | })
21 |
22 | it('searches by id', async () => {
23 | const users = await factory.createMany('user', 10)
24 | const resource = new Resource(User)
25 | const limit = 5
26 | const offset = 0
27 |
28 | const idFilters = new Filter({ _id: users[0]._id.toHexString() }, resource)
29 | const returnedItems = await resource.find(idFilters, { limit, offset })
30 |
31 | expect(returnedItems.length).toEqual(1)
32 | expect(returnedItems[0]).toBeInstanceOf(BaseRecord)
33 | })
34 | })
35 |
--------------------------------------------------------------------------------
/test/resource/name.spec.ts:
--------------------------------------------------------------------------------
1 | import Resource from '../../src/resource.js'
2 | import { User } from '../utils/models.js'
3 |
4 | describe('Resource #name', () => {
5 | it('returns name of the model', () => {
6 | const resource = new Resource(User)
7 |
8 | expect(resource.name()).toEqual('User')
9 | })
10 | })
11 |
--------------------------------------------------------------------------------
/test/resource/parseParams.spec.ts:
--------------------------------------------------------------------------------
1 | import Resource from '../../src/resource.js'
2 | import { User } from '../utils/models.js'
3 |
4 | describe('Resource #parseParams', () => {
5 | let resource
6 |
7 | beforeEach(() => {
8 | resource = new Resource(User)
9 | })
10 |
11 | it('converts empty strings to nulls for ObjectIDs', () => {
12 | expect(resource.parseParams({ _id: '' })).toHaveProperty('_id', null)
13 | })
14 |
15 | it('converts empty strings to [] for arrays', () => {
16 | expect(resource.parseParams({ family: '' })).toHaveProperty('family', [])
17 | })
18 | })
19 |
--------------------------------------------------------------------------------
/test/resource/position.spec.ts:
--------------------------------------------------------------------------------
1 | import Resource from '../../src/resource.js'
2 | import { User } from '../utils/models.js'
3 |
4 | describe('Resource #position', () => {
5 | it('returns position of a parent field', () => {
6 | const property = new Resource(User).property('parent')
7 |
8 | expect(property.position()).toEqual(4)
9 | })
10 | })
11 |
--------------------------------------------------------------------------------
/test/resource/properties.spec.ts:
--------------------------------------------------------------------------------
1 | import mongoose from 'mongoose'
2 | import Property from '../../src/property.js'
3 | import Resource from '../../src/resource.js'
4 | import { User } from '../utils/models.js'
5 |
6 | describe('Resource #properties', () => {
7 | let resource
8 | let returnedProperties
9 |
10 | beforeEach(() => {
11 | resource = new Resource(User)
12 | returnedProperties = resource.properties()
13 | })
14 |
15 | it('returns correct amount of properties', () => {
16 | // 8 because of implicit _id and __v properties
17 | expect(returnedProperties.length).toEqual(8)
18 | })
19 |
20 | it('sets the position of properties', () => {
21 | expect(returnedProperties.map((p) => p.position())).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
22 | })
23 |
24 | it('returns instances of Property class', async () => {
25 | expect(returnedProperties[0]).toBeInstanceOf(Property)
26 | })
27 |
28 | it('returns all fields for nested properties', () => {
29 | const Nested = mongoose.model('Nested', new mongoose.Schema({
30 | field: {
31 | subfield: String,
32 | anotherSubField: String,
33 | },
34 | }))
35 | const nestedResource = new Resource(Nested)
36 |
37 | const propertiesOfNestedResource = nestedResource.properties()
38 |
39 | expect(propertiesOfNestedResource.length).toEqual(4)
40 | })
41 | })
42 |
--------------------------------------------------------------------------------
/test/resource/property.spec.ts:
--------------------------------------------------------------------------------
1 | import Property from '../../src/property.js'
2 | import Resource from '../../src/resource.js'
3 | import { User } from '../utils/models.js'
4 |
5 | describe('Resource #property', () => {
6 | let resource
7 | let returnedProperty
8 |
9 | beforeEach(() => {
10 | resource = new Resource(User)
11 | returnedProperty = resource.property('email')
12 | })
13 |
14 | it('returns selected property for an email', () => {
15 | expect(returnedProperty.name()).toEqual('email')
16 | })
17 |
18 | it('returns instance of Property class', () => {
19 | expect(returnedProperty).toBeInstanceOf(Property)
20 | })
21 | })
22 |
--------------------------------------------------------------------------------
/test/resource/update.spec.ts:
--------------------------------------------------------------------------------
1 | import Resource from '../../src/resource.js'
2 | import { Article } from '../utils/models.js'
3 |
4 | describe('Resource #update', () => {
5 | it('changes record and returns updated', async () => {
6 | const resource = new Resource(Article)
7 | const initialRecord = await resource.create({
8 | content: 'Test content',
9 | })
10 |
11 | const updatedRecord = await resource.update(
12 | initialRecord._id,
13 | { content: 'Updated content' },
14 | )
15 |
16 | expect(updatedRecord.content).toEqual('Updated content')
17 | })
18 | })
19 |
--------------------------------------------------------------------------------
/test/utils/beforeEach.ts:
--------------------------------------------------------------------------------
1 | import mongoose from 'mongoose'
2 | import { Article, Pesel, User } from './models.js'
3 |
4 | const dropAllCollections = async (): Promise => {
5 | await mongoose.connect('mongodb://localhost/e2e_test', {})
6 | await Promise.all([
7 | Pesel.deleteMany({}),
8 | User.deleteMany({}),
9 | Article.deleteMany({}),
10 | ])
11 | }
12 | beforeEach(dropAllCollections)
13 |
--------------------------------------------------------------------------------
/test/utils/models.ts:
--------------------------------------------------------------------------------
1 | import { factory } from 'factory-girl'
2 | import mongoose from 'mongoose'
3 |
4 | // const globalAny = global as any
5 |
6 | // @ts-ignore
7 | const NestedObject = new mongoose.Schema({
8 | someProperty: Number,
9 | })
10 |
11 | // @ts-ignore
12 | const SubType = new mongoose.Schema({
13 | name: String,
14 | surname: String,
15 | age: Number,
16 | nestedArray: [NestedObject],
17 | nestedObject: NestedObject,
18 | })
19 |
20 | export const User = mongoose.model('User', new mongoose.Schema({
21 | email: { type: String, required: true },
22 | passwordHash: { type: String, required: true },
23 | genre: { type: String, enum: ['male', 'female'] },
24 | arrayed: [String],
25 | parent: SubType,
26 | family: [SubType],
27 | }))
28 |
29 | export const Pesel = mongoose.model('Pesel', new mongoose.Schema({
30 | pesel: {
31 | type: String, unique: true, required: true, sparse: true,
32 | },
33 | }))
34 |
35 | export const Article = mongoose.model('Article', new mongoose.Schema({
36 | content: String,
37 | owners: [{
38 | type: mongoose.Schema.Types.ObjectId,
39 | ref: 'User',
40 | }],
41 | createdBy: {
42 | type: mongoose.Schema.Types.ObjectId,
43 | ref: 'User',
44 | },
45 | }))
46 |
47 | // export const { User, Article, Pesel }: Record> = globalAny
48 |
49 | factory.define('user', User, {
50 | email: factory.sequence('User.email', (n) => `john@doe${n}.com`),
51 | passwordHash: 'somehashedpassword',
52 | })
53 |
--------------------------------------------------------------------------------
/test/utils/teardown.ts:
--------------------------------------------------------------------------------
1 | import mongoose from 'mongoose'
2 |
3 | const teardownE2ETests = async (): Promise => {
4 | await Promise.all(
5 | mongoose.connections.map((connection) => connection.close(true)),
6 | )
7 | await mongoose.connection.close()
8 | await mongoose.disconnect()
9 | process.exit()
10 | }
11 |
12 | export default teardownE2ETests
13 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "moduleResolution": "nodenext",
4 | "module": "nodenext",
5 | "target": "esnext",
6 | "sourceMap": false,
7 | "outDir": "lib",
8 | "esModuleInterop": true,
9 | "allowSyntheticDefaultImports": true,
10 | "declaration": true,
11 | "skipLibCheck": true,
12 | "allowJs": true
13 | },
14 | "include": [
15 | "./src/**/*.ts"
16 | ],
17 | "exclude": [
18 | "node_modules",
19 | "lib",
20 | "spec",
21 | "commitlint.config.cjs"
22 | ]
23 | }
24 |
--------------------------------------------------------------------------------
/tsconfig.test.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "include": [
4 | "./src/**/*.ts",
5 | "./spec/**/*.ts"
6 | ]
7 | }
--------------------------------------------------------------------------------