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