├── .env-example ├── .eslintrc.cjs ├── .github └── workflows │ └── push.yml ├── .gitignore ├── .npmignore ├── .npmrc ├── .releaserc ├── LICENSE ├── README.md ├── commitlint.config.cjs ├── package.json ├── src ├── database.ts ├── index.ts ├── property.ts ├── resource.ts └── utils │ ├── convert-filter.ts │ ├── create-validation-error.ts │ └── index.ts ├── tsconfig.json ├── types ├── database.d.ts ├── database.spec.d.ts ├── hooks.spec.d.ts ├── property.d.ts ├── property.spec.d.ts ├── resource.d.ts ├── resource.spec.d.ts └── utils │ ├── available-test-models.d.ts │ ├── convert-filter.d.ts │ └── create-validation-error.d.ts └── yarn.lock /.env-example: -------------------------------------------------------------------------------- 1 | NODE_ENV=test 2 | POSTGRES_USER="adminjs" 3 | POSTGRES_PASSWORD="adminjs" 4 | POSTGRES_DATABASE="adminjs-sequelize-test" 5 | POSTGRES_HOST 6 | POSTGRES_PORT -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | es2020: true, 4 | }, 5 | extends: [ 6 | 'airbnb', 7 | 'plugin:react/recommended', 8 | 'plugin:@typescript-eslint/recommended', 9 | ], 10 | parser: '@typescript-eslint/parser', 11 | parserOptions: { 12 | ecmaFeatures: { 13 | jsx: true, 14 | }, 15 | ecmaVersion: 20, 16 | sourceType: 'module', 17 | }, 18 | plugins: ['react', '@typescript-eslint'], 19 | rules: { 20 | semi: ['error', 'always'], 21 | 'no-unused-vars': 'off', 22 | 'import/extensions': 'off', 23 | 'import/no-unresolved': 'off', 24 | 'react/jsx-filename-extension': 'off', 25 | indent: ['error', 2], 26 | 'linebreak-style': ['error', 'unix'], 27 | 'object-curly-newline': 'off', 28 | '@typescript-eslint/no-explicit-any': 'off', 29 | }, 30 | overrides: [ 31 | { 32 | files: ['*.tsx'], 33 | rules: { 34 | 'react/prop-types': 'off', 35 | 'react/jsx-props-no-spreading': 'off', 36 | 'import/no-extraneous-dependencies': 'off', 37 | }, 38 | }, 39 | { 40 | files: ['./src/**/*.spec.ts', 'spec/*.ts'], 41 | rules: { 42 | 'no-unused-expressions': 'off', 43 | 'prefer-arrow-callback': 'off', 44 | 'func-names': 'off', 45 | 'import/no-extraneous-dependencies': 'off', 46 | }, 47 | }, 48 | ], 49 | }; 50 | -------------------------------------------------------------------------------- /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | name: CI/CD 2 | on: [push, pull_request] 3 | jobs: 4 | setup: 5 | name: setup 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: Dump GitHub context 9 | env: 10 | GITHUB_CONTEXT: ${{ toJson(github) }} 11 | run: echo "$GITHUB_CONTEXT" 12 | - name: Checkout 13 | uses: actions/checkout@v2 14 | - name: Setup 15 | uses: actions/setup-node@v2 16 | with: 17 | node-version: '18.x' 18 | - uses: actions/cache@v2 19 | id: yarn-cache 20 | with: 21 | path: node_modules 22 | key: ${{ runner.os }}-node_modules-${{ hashFiles('**/yarn.lock') }} 23 | restore-keys: | 24 | ${{ runner.os }}-node_modules- 25 | - name: Install 26 | if: steps.yarn-cache.outputs.cache-hit != 'true' 27 | run: yarn install 28 | 29 | test: 30 | name: Test 31 | runs-on: ubuntu-latest 32 | needs: setup 33 | steps: 34 | - name: Checkout 35 | uses: actions/checkout@v2 36 | - name: Setup 37 | uses: actions/setup-node@v2 38 | with: 39 | node-version: '18.x' 40 | - uses: actions/cache@v2 41 | id: yarn-cache 42 | with: 43 | path: node_modules 44 | key: ${{ runner.os }}-node_modules-${{ hashFiles('**/yarn.lock') }} 45 | restore-keys: | 46 | ${{ runner.os }}-node_modules- 47 | - name: Install 48 | if: steps.yarn-cache.outputs.cache-hit != 'true' 49 | run: yarn install 50 | - name: Lint 51 | run: yarn lint 52 | - name: Build 53 | run: yarn build 54 | - name: Release 55 | if: github.event_name == 'push' 56 | env: 57 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 58 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 59 | SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} 60 | run: yarn release 61 | -------------------------------------------------------------------------------- /.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 | lib 119 | .vscode -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | example-app 2 | migrations 3 | models 4 | .github 5 | ./src/ -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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-sequelizejs 2 | 3 | This is an official [AdminJS](https://github.com/SoftwareBrothers/adminjs) adapter which integrates [sequelize ORM](http://docs.sequelizejs.com/) into AdminJS. 4 | 5 | ## Usage 6 | 7 | The plugin can be registered using standard `AdminJS.registerAdapter` method. 8 | 9 | ```javascript 10 | const AdminJS = require('adminjs') 11 | const AdminJSSequelize = require('@adminjs/sequelize') 12 | 13 | AdminJS.registerAdapter(AdminJSSequelize) 14 | ``` 15 | 16 | More options can be found in [AdminJS](https://github.com/SoftwareBrothers/adminjs) repository. 17 | 18 | ## Testing 19 | 20 | Integration tests require running database. Database connection data are given in `config/config.js`. Make sure you have following env variables set: POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_PORT, POSTGRES_DATABASE, POSTGRES_HOST. Take a look at `config/config.js` to see default values. 21 | 22 | 23 | Than you will have to create database and run migrations 24 | 25 | ``` 26 | npm run sequelize db:create 27 | npm run sequelize db:migrate 28 | ``` 29 | 30 | ## License 31 | 32 | 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. 33 | 34 | ## About rst.software 35 | 36 | 37 | 38 | We’re an open, friendly team that helps clients from all over the world to transform their businesses and create astonishing products. 39 | 40 | * We are available for [hire](https://www.rst.software/estimate-your-project). 41 | * If you want to work for us - check out the [career page](https://www.rst.software/join-us). 42 | -------------------------------------------------------------------------------- /commitlint.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | '@commitlint/config-conventional', 4 | ], 5 | }; 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@adminjs/sequelize", 3 | "version": "4.1.1", 4 | "description": "Sequelize 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 | "build": "tsc", 14 | "dev": "tsc --watch", 15 | "check:all": "yarn build && yarn test && yarn lint", 16 | "sequelize": "sequelize", 17 | "lint": "eslint ./src/**/*.ts", 18 | "release": "semantic-release" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "git+https://github.com/SoftwareBrothers/adminjs-sequelizejs.git" 23 | }, 24 | "author": "Wojciech Krysiak", 25 | "license": "MIT", 26 | "peerDependencies": { 27 | "adminjs": "^7.0.0", 28 | "sequelize": ">=6" 29 | }, 30 | "husky": { 31 | "hooks": { 32 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" 33 | } 34 | }, 35 | "devDependencies": { 36 | "@commitlint/cli": "^17.4.4", 37 | "@commitlint/config-conventional": "^17.4.4", 38 | "@semantic-release/git": "^10.0.1", 39 | "@types/chai": "^4.3.4", 40 | "@types/mocha": "^10.0.1", 41 | "@types/node": "^18.15.3", 42 | "@types/sinon": "^10.0.13", 43 | "@types/sinon-chai": "^3.2.9", 44 | "@typescript-eslint/eslint-plugin": "^5.55.0", 45 | "@typescript-eslint/parser": "^5.55.0", 46 | "adminjs": "^7.0.0", 47 | "chai": "^4.3.7", 48 | "eslint": "^8.36.0", 49 | "eslint-config-airbnb": "^19.0.4", 50 | "eslint-plugin-import": "^2.27.5", 51 | "eslint-plugin-jsx-a11y": "^6.7.1", 52 | "eslint-plugin-react": "^7.32.2", 53 | "eslint-plugin-react-hooks": "^4.6.0", 54 | "husky": "^4.2.5", 55 | "mocha": "^10.2.0", 56 | "nyc": "^15.1.0", 57 | "pg": "^8.10.0", 58 | "semantic-release": "^20.1.3", 59 | "semantic-release-slack-bot": "^4.0.0", 60 | "sequelize": "^6.29.3", 61 | "sequelize-cli": "^6.6.0", 62 | "sinon": "^15.0.2", 63 | "sinon-chai": "^3.7.0", 64 | "ts-node": "^10.9.1", 65 | "typescript": "^4.9.5" 66 | }, 67 | "dependencies": { 68 | "escape-regexp": "0.0.1" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/database.ts: -------------------------------------------------------------------------------- 1 | import { BaseDatabase, BaseResource } from 'adminjs'; 2 | import { Sequelize } from 'sequelize'; 3 | 4 | import Resource from './resource.js'; 5 | 6 | class Database extends BaseDatabase { 7 | private sequelize: Sequelize; 8 | 9 | static isAdapterFor(database: any): boolean { 10 | return (database.sequelize 11 | && database.sequelize.constructor.name === 'Sequelize') 12 | || database.constructor.name === 'Sequelize'; 13 | } 14 | 15 | constructor(database: any) { 16 | super(database); 17 | this.sequelize = database.sequelize || database; 18 | } 19 | 20 | resources(): Array { 21 | return Object.keys(this.sequelize.models).map((key) => ( 22 | new Resource(this.sequelize.models[key]) 23 | )); 24 | } 25 | } 26 | 27 | export default Database; 28 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module @adminjs/sequelize 3 | * @subcategory Adapters 4 | * @section modules 5 | * 6 | * @description 7 | * ### A Sequelize database adapter for AdminJS. 8 | * 9 | * #### Installation 10 | * 11 | * To install the adapter run 12 | * 13 | * ``` 14 | * yarn add @adminjs/sequelize 15 | * ``` 16 | * 17 | * ### Usage 18 | * 19 | * In order to use it in your project register the adapter first: 20 | * 21 | * ```javascript 22 | * const AdminJS = require('adminjs') 23 | * const AdminJSSequelize = require('@adminjs/sequelize') 24 | * 25 | * AdminJS.registerAdapter(AdminJSSequelize) 26 | * ``` 27 | * 28 | * ### Passing an entire database 29 | * 30 | * Sequelize generates folder in your app called `./models` and there is an `index.js` file. 31 | * You can require it and pass to {@link AdminJSOptions} like this: 32 | * 33 | * ```javascript 34 | * const db = require('../models'); 35 | * const AdminJS = new AdminJS({ 36 | * databases: [db], 37 | * //... other AdminJSOptions 38 | * }) 39 | * //... 40 | * ``` 41 | * 42 | * ### Passing each resource 43 | * 44 | * Also you can pass a single resource and adjust it to your needs via {@link ResourceOptions}. 45 | * 46 | * So let say you have a model called `vendor` and there is a `vendor.js` file in your `./models`. 47 | * Within this file there is 48 | * 49 | * ```javascript 50 | * //... 51 | * sequelize.define('vendor', //... 52 | * //... 53 | * ``` 54 | * 55 | * In order to pass it directly, run this code: 56 | * 57 | * ```javascript 58 | * const db = require('../models'); 59 | * const AdminJS = new AdminJS({ 60 | * databases: [db], // you can still load an entire database and adjust just one resource 61 | * resources: [{ 62 | * resource: db.vendor, 63 | * options: { 64 | * //... 65 | * } 66 | * }] 67 | * }) 68 | * //... 69 | * ``` 70 | * 71 | * 72 | */ 73 | 74 | /** 75 | * Implementation of {@link BaseDatabase} for Sequelize Adapter 76 | * 77 | * @memberof module:@adminjs/sequelize 78 | * @type {typeof BaseDatabase} 79 | * @static 80 | */ 81 | import Database from './database.js'; 82 | 83 | /** 84 | * Implementation of {@link BaseResource} for Sequelize Adapter 85 | * 86 | * @memberof module:@adminjs/sequelize 87 | * @type {typeof BaseResource} 88 | * @static 89 | */ 90 | import Resource from './resource.js'; 91 | 92 | export { default as Database } from './database.js'; 93 | export { default as Resource } from './resource.js'; 94 | export * from './utils/index.js'; 95 | 96 | export default { Database, Resource }; 97 | -------------------------------------------------------------------------------- /src/property.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-underscore-dangle */ 2 | import { BaseProperty, PropertyType } from 'adminjs'; 3 | import { ModelAttributeColumnOptions } from 'sequelize'; 4 | 5 | const TYPES_MAPPING = [ 6 | ['STRING', 'string'], 7 | ['TEXT', 'string'], 8 | ['INTEGER', 'number'], 9 | ['BIGINT', 'number'], 10 | ['FLOAT', 'float'], 11 | ['REAL', 'float'], 12 | ['DOUBLE', 'float'], 13 | ['DECIMAL', 'float'], 14 | ['DATE', 'datetime'], 15 | ['DATEONLY', 'date'], 16 | ['ENUM', 'string'], 17 | ['ARRAY', 'array'], 18 | ['JSON', 'object'], 19 | ['JSONB', 'object'], 20 | ['BLOB', 'string'], 21 | ['UUID', 'string'], 22 | ['CIDR', 'string'], 23 | ['INET', 'string'], 24 | ['MACADDR', 'string'], 25 | ['RANGE', 'string'], 26 | ['GEOMETRY', 'string'], 27 | ['BOOLEAN', 'boolean'], 28 | ]; 29 | 30 | class Property extends BaseProperty { 31 | private sequelizePath: ModelAttributeColumnOptions; 32 | 33 | private fieldName: string; 34 | 35 | constructor(sequelizePath: ModelAttributeColumnOptions) { 36 | const { fieldName } = sequelizePath as any; 37 | super({ path: fieldName }); 38 | this.fieldName = fieldName; 39 | this.sequelizePath = sequelizePath; 40 | } 41 | 42 | name(): string { 43 | return this.fieldName; 44 | } 45 | 46 | isEditable(): boolean { 47 | if ((this.sequelizePath as any)._autoGenerated) { 48 | return false; 49 | } 50 | if (this.sequelizePath.autoIncrement) { 51 | return false; 52 | } 53 | if (this.isId()) { 54 | return false; 55 | } 56 | return true; 57 | } 58 | 59 | isVisible(): boolean { 60 | // fields containing password are hidden by default 61 | return !this.name().match('password'); 62 | } 63 | 64 | isId(): boolean { 65 | return !!this.sequelizePath.primaryKey; 66 | } 67 | 68 | reference(): string | null { 69 | if (this.isArray()) { 70 | return null; 71 | } 72 | 73 | if (this.sequelizePath.references === 'string') { 74 | return this.sequelizePath.references as string; 75 | } if (this.sequelizePath.references && typeof this.sequelizePath.references !== 'string') { 76 | // Sequelize v7 77 | if ((this.sequelizePath.references as any).table?.tableName) { 78 | return (this.sequelizePath.references as any).table?.tableName as string; 79 | } 80 | // Sequelize v4+ 81 | if (this.sequelizePath.references.model && typeof this.sequelizePath.references.model !== 'string') { 82 | return (this.sequelizePath.references?.model as any)?.tableName as string; 83 | } 84 | return this.sequelizePath.references?.model as string; 85 | } 86 | return null; 87 | } 88 | 89 | availableValues(): Array | null { 90 | return this.sequelizePath.values && this.sequelizePath.values.length 91 | ? this.sequelizePath.values as Array 92 | : null; 93 | } 94 | 95 | isArray(): boolean { 96 | return this.sequelizePath.type.constructor.name === 'ARRAY'; 97 | } 98 | 99 | /** 100 | * @returns {PropertyType} 101 | */ 102 | type(): PropertyType { 103 | let sequelizeType = this.sequelizePath.type; 104 | 105 | if (this.isArray()) { 106 | sequelizeType = (sequelizeType as any).type; 107 | } 108 | 109 | const key = TYPES_MAPPING.find((element) => ( 110 | sequelizeType.constructor.name === element[0] 111 | )); 112 | 113 | if (this.reference()) { 114 | return 'reference' as PropertyType; 115 | } 116 | 117 | const type = key && key[1]; 118 | return (type || 'string') as PropertyType; 119 | } 120 | 121 | isSortable(): boolean { 122 | return this.type() !== 'mixed' && !this.isArray(); 123 | } 124 | 125 | isRequired(): boolean { 126 | return !(typeof this.sequelizePath.allowNull === 'undefined' || this.sequelizePath.allowNull); 127 | } 128 | } 129 | 130 | export default Property; 131 | -------------------------------------------------------------------------------- /src/resource.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | import { BaseProperty, BaseRecord, BaseResource, Filter, flat } from 'adminjs'; 3 | import { Op, Model, ModelAttributeColumnOptions } from 'sequelize'; 4 | 5 | import Property from './property.js'; 6 | import convertFilter from './utils/convert-filter.js'; 7 | import createValidationError from './utils/create-validation-error.js'; 8 | 9 | const SEQUELIZE_VALIDATION_ERROR = 'SequelizeValidationError'; 10 | const SEQUELIZE_UNIQUE_ERROR = 'SequelizeUniqueConstraintError'; 11 | 12 | // this fixes problem with unbound this when you setup type of Mode as a member of another 13 | // class: https://stackoverflow.com/questions/55166230/sequelize-typescript-typeof-model 14 | type Constructor = new (...args: any[]) => T; 15 | export type ModelType> = Constructor & typeof Model; 16 | 17 | type FindOptions = { 18 | limit?: number; 19 | offset?: number; 20 | sort?: { 21 | sortBy?: string; 22 | direction?: 'asc' | 'desc'; 23 | }; 24 | } 25 | 26 | class Resource extends BaseResource { 27 | private SequelizeModel: ModelType; 28 | 29 | static isAdapterFor(rawResource): boolean { 30 | return rawResource.sequelize && rawResource.sequelize.constructor.name === 'Sequelize'; 31 | } 32 | 33 | constructor(SequelizeModel: typeof Model) { 34 | super(SequelizeModel); 35 | this.SequelizeModel = SequelizeModel as ModelType; 36 | } 37 | 38 | rawAttributes(): Record { 39 | // different sequelize versions stores attributes in different places 40 | // .modelDefinition.attributes => sequelize ^7.0.0 41 | // .rawAttributes => sequelize ^5.0.0 42 | // .attributes => sequelize ^4.0.0 43 | return ((this.SequelizeModel as any).attributes 44 | || ((this.SequelizeModel as any).modelDefinition?.attributes && Object.fromEntries((this.SequelizeModel as any).modelDefinition?.attributes)) 45 | || (this.SequelizeModel as any).rawAttributes) as Record; 46 | } 47 | 48 | databaseName(): string { 49 | return (this.SequelizeModel.sequelize as any).options.database 50 | || (this.SequelizeModel.sequelize as any).options.host 51 | || 'Sequelize'; 52 | } 53 | 54 | databaseType(): string { 55 | return (this.SequelizeModel.sequelize as any).options.dialect || 'other'; 56 | } 57 | 58 | name(): string { 59 | // different sequelize versions stores attributes in different places 60 | // .modelDefinition.table => sequelize ^7.0.0 61 | // .tableName => sequelize ^4.0.0 62 | return ( 63 | (this.SequelizeModel as any).modelDefinition?.table?.tableName 64 | || this.SequelizeModel.tableName 65 | ); 66 | } 67 | 68 | id(): string { 69 | return this.name(); 70 | } 71 | 72 | properties(): Array { 73 | return Object.keys(this.rawAttributes()).map((key) => ( 74 | new Property(this.rawAttributes()[key]) 75 | )); 76 | } 77 | 78 | property(path: string): BaseProperty | null { 79 | const nested = path.split('.'); 80 | 81 | // if property is an array return the array property 82 | if (nested.length > 1 && this.rawAttributes()[nested[0]]) { 83 | return new Property(this.rawAttributes()[nested[0]]); 84 | } 85 | 86 | if (!this.rawAttributes()[path]) { 87 | return null; 88 | } 89 | return new Property(this.rawAttributes()[path]); 90 | } 91 | 92 | async count(filter: Filter) { 93 | return this.SequelizeModel.count(({ 94 | where: convertFilter(filter), 95 | })); 96 | } 97 | 98 | primaryKey(): string { 99 | return (this.SequelizeModel as any).primaryKeyField || this.SequelizeModel.primaryKeyAttribute; 100 | } 101 | 102 | async populate(baseRecords, property): Promise> { 103 | const ids = baseRecords.map((baseRecord) => ( 104 | baseRecord.param(property.name()) 105 | )); 106 | const records = await this.SequelizeModel.findAll({ 107 | where: { [this.primaryKey()]: ids }, 108 | }); 109 | const recordsHash = records.reduce((memo, record) => { 110 | memo[record[this.primaryKey()]] = record; 111 | return memo; 112 | }, {}); 113 | baseRecords.forEach((baseRecord) => { 114 | const id = baseRecord.param(property.name()); 115 | if (recordsHash[id]) { 116 | const referenceRecord = new BaseRecord( 117 | recordsHash[id].toJSON(), 118 | this, 119 | ); 120 | baseRecord.populated[property.name()] = referenceRecord; 121 | } 122 | }); 123 | return baseRecords; 124 | } 125 | 126 | async find(filter, { limit = 20, offset = 0, sort = {} }: FindOptions) { 127 | const { direction, sortBy } = sort; 128 | const sequelizeObjects = await this.SequelizeModel 129 | .findAll({ 130 | where: convertFilter(filter), 131 | limit, 132 | offset, 133 | order: [[sortBy as string, (direction || 'asc').toUpperCase()]], 134 | }); 135 | return sequelizeObjects.map( 136 | (sequelizeObject) => new BaseRecord(sequelizeObject.toJSON(), this), 137 | ); 138 | } 139 | 140 | async findOne(id): Promise { 141 | const sequelizeObject = await this.findById(id); 142 | if (!sequelizeObject) { 143 | return null; 144 | } 145 | return new BaseRecord(sequelizeObject.toJSON(), this); 146 | } 147 | 148 | async findMany(ids) { 149 | const sequelizeObjects = await this.SequelizeModel.findAll({ 150 | where: { 151 | [this.primaryKey()]: { [Op.in]: ids }, 152 | }, 153 | }); 154 | return sequelizeObjects.map( 155 | (sequelizeObject) => new BaseRecord(sequelizeObject.toJSON(), this), 156 | ); 157 | } 158 | 159 | async findById(id) { 160 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 161 | // @ts-ignore versions of Sequelize before 5 had findById method - after that there was findByPk 162 | const method = this.SequelizeModel.findByPk ? 'findByPk' : 'findById'; 163 | return this.SequelizeModel[method](id); 164 | } 165 | 166 | async create(params): Promise> { 167 | const parsedParams = this.parseParams(params); 168 | const unflattedParams = flat.unflatten(parsedParams); 169 | try { 170 | const record = await this.SequelizeModel.create(unflattedParams); 171 | return record.toJSON(); 172 | } catch (error) { 173 | if (error.name === SEQUELIZE_VALIDATION_ERROR) { 174 | throw createValidationError(error); 175 | } 176 | if (error.name === SEQUELIZE_UNIQUE_ERROR) { 177 | throw createValidationError(error); 178 | } 179 | throw error; 180 | } 181 | } 182 | 183 | async update(id, params) { 184 | const parsedParams = this.parseParams(params); 185 | const unflattedParams = flat.unflatten(parsedParams); 186 | try { 187 | await this.SequelizeModel.update(unflattedParams, { 188 | where: { 189 | [this.primaryKey()]: id, 190 | }, 191 | individualHooks: true, 192 | hooks: true, 193 | }); 194 | const record = await this.findById(id); 195 | return record.toJSON(); 196 | } catch (error) { 197 | if (error.name === SEQUELIZE_VALIDATION_ERROR) { 198 | throw createValidationError(error); 199 | } 200 | if (error.name === SEQUELIZE_UNIQUE_ERROR) { 201 | throw createValidationError(error); 202 | } 203 | throw error; 204 | } 205 | } 206 | 207 | async delete(id): Promise { 208 | // we find first because we need to invoke destroy on model, so all hooks 209 | // instance hooks (not bulk) are called. 210 | // We cannot set {individualHooks: true, hooks: false} in this.SequelizeModel.destroy, 211 | // as it is in #update method because for some reason it wont delete the record 212 | const model = await this.SequelizeModel.findByPk(id); 213 | await model.destroy(); 214 | } 215 | 216 | /** 217 | * Check all params against values they hold. In case of wrong value it corrects it. 218 | * 219 | * What it does exactly: 220 | * - removes keys with empty strings for the `number`, `float` and 'reference' properties. 221 | * 222 | * @param {Object} params received from AdminJS form 223 | * 224 | * @return {Object} converted params 225 | */ 226 | parseParams(params) { 227 | const parsedParams = { ...params }; 228 | this.properties().forEach((property) => { 229 | const value = parsedParams[property.name()]; 230 | if (value === '') { 231 | if (property.isArray() || property.type() !== 'string') { 232 | delete parsedParams[property.name()]; 233 | } 234 | } 235 | }); 236 | return parsedParams; 237 | } 238 | } 239 | 240 | export default Resource; 241 | -------------------------------------------------------------------------------- /src/utils/convert-filter.ts: -------------------------------------------------------------------------------- 1 | import escape from 'escape-regexp'; 2 | import { Op } from 'sequelize'; 3 | 4 | export const uuidRegex = /^[0-9A-F]{8}-[0-9A-F]{4}-[5|4|3|2|1][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i; 5 | 6 | export const convertFilter = (filter) => { 7 | if (!filter) { 8 | return {}; 9 | } 10 | return filter.reduce((memo, filterProperty) => { 11 | const { property, value, path: filterPath } = filterProperty; 12 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 13 | const [_, index] = filterPath.split('.'); 14 | const isArray = typeof index !== 'undefined' && !Number.isNaN(Number(index)); 15 | const previousValue = memo[property.name()] || {}; 16 | switch (property.type()) { 17 | case 'string': { 18 | if (property.sequelizePath.values || uuidRegex.test(value.toString())) { 19 | return { 20 | [property.name()]: { [Op.eq]: `${escape(value)}` }, 21 | ...memo, 22 | }; 23 | } 24 | if (isArray) { 25 | return { 26 | ...memo, 27 | [property.name()]: { 28 | [Op.in]: [...(previousValue[Op.in] || []), escape(value)], 29 | }, 30 | }; 31 | } 32 | return { 33 | ...memo, 34 | [Op.and]: [ 35 | ...(memo[Op.and] || []), 36 | { 37 | [property.name()]: { 38 | [(Op.like as unknown) as string]: `%${escape(value)}%`, 39 | }, 40 | }, 41 | ], 42 | }; 43 | } 44 | case 'boolean': { 45 | let bool; 46 | if (value === 'true') bool = true; 47 | if (value === 'false') bool = false; 48 | if (bool === undefined) return memo; 49 | 50 | if (isArray) { 51 | return { 52 | ...memo, 53 | [property.name()]: { 54 | [Op.in]: [ 55 | ...(previousValue[Op.in] || []), 56 | bool, 57 | ], 58 | }, 59 | }; 60 | } 61 | return { 62 | ...memo, 63 | [property.name()]: bool, 64 | }; 65 | } 66 | case 'number': { 67 | if (!Number.isNaN(Number(value))) { 68 | if (isArray) { 69 | return { 70 | ...memo, 71 | [property.name()]: { 72 | [Op.in]: [...(previousValue[Op.in] || []), Number(value)], 73 | }, 74 | }; 75 | } 76 | return { 77 | [property.name()]: Number(value), 78 | ...memo, 79 | }; 80 | } 81 | return memo; 82 | } 83 | case 'date': 84 | case 'datetime': { 85 | if (value.from || value.to) { 86 | return { 87 | [property.name()]: { 88 | ...(value.from && { [Op.gte]: value.from }), 89 | ...(value.to && { [Op.lte]: value.to }), 90 | }, 91 | ...memo, 92 | }; 93 | } 94 | break; 95 | } 96 | default: 97 | break; 98 | } 99 | if (isArray) { 100 | return { 101 | ...memo, 102 | [property.name()]: { 103 | [Op.in]: [...(previousValue[Op.in] || []), value], 104 | }, 105 | }; 106 | } 107 | return { 108 | [property.name()]: value, 109 | ...memo, 110 | }; 111 | }, {}); 112 | }; 113 | 114 | export default convertFilter; 115 | -------------------------------------------------------------------------------- /src/utils/create-validation-error.ts: -------------------------------------------------------------------------------- 1 | import { ValidationError } from 'adminjs'; 2 | 3 | export const createValidationError = (originalError: any): ValidationError => { 4 | const errors = Object.keys(originalError.errors).reduce((memo, key) => { 5 | const { path, message, validatorKey } = originalError.errors[key]; 6 | memo[path] = { message, kind: validatorKey }; // eslint-disable-line no-param-reassign 7 | return memo; 8 | }, {}); 9 | return new ValidationError(errors); 10 | }; 11 | 12 | export default createValidationError; 13 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './convert-filter.js'; 2 | export * from './create-validation-error.js'; 3 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "preserve", 4 | "strictNullChecks": true, 5 | "strictPropertyInitialization": true, 6 | "strictFunctionTypes": true, 7 | "strictBindCallApply": true, 8 | "noImplicitThis": true, 9 | "moduleResolution": "nodenext", 10 | "module": "nodenext", 11 | "baseUrl": "./src", 12 | "skipLibCheck": true, 13 | "target": "esnext", 14 | "sourceMap": false, 15 | "outDir": "lib", 16 | "esModuleInterop": true, 17 | "allowSyntheticDefaultImports": true, 18 | "declaration": true, 19 | "allowJs": true 20 | }, 21 | "exclude": [ 22 | "node_modules", 23 | "lib", 24 | "spec", 25 | "commitlint.config.cjs", 26 | "**/*.spec.ts", 27 | "models", 28 | "migrations", 29 | "config", 30 | "example-app" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /types/database.d.ts: -------------------------------------------------------------------------------- 1 | import { BaseDatabase, BaseResource } from 'adminjs'; 2 | declare class Database extends BaseDatabase { 3 | private sequelize; 4 | static isAdapterFor(database: any): boolean; 5 | constructor(database: any); 6 | resources(): Array; 7 | } 8 | export default Database; 9 | -------------------------------------------------------------------------------- /types/database.spec.d.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | -------------------------------------------------------------------------------- /types/hooks.spec.d.ts: -------------------------------------------------------------------------------- 1 | declare const db: any; 2 | -------------------------------------------------------------------------------- /types/property.d.ts: -------------------------------------------------------------------------------- 1 | import { BaseProperty, PropertyType } from 'adminjs'; 2 | import { ModelAttributeColumnOptions } from 'sequelize/types'; 3 | declare class Property extends BaseProperty { 4 | private sequelizePath; 5 | private fieldName; 6 | constructor(sequelizePath: ModelAttributeColumnOptions); 7 | name(): string; 8 | isEditable(): boolean; 9 | isVisible(): boolean; 10 | isId(): boolean; 11 | reference(): string | null; 12 | availableValues(): Array | null; 13 | isArray(): boolean; 14 | /** 15 | * @returns {PropertyType} 16 | */ 17 | type(): PropertyType; 18 | isSortable(): boolean; 19 | isRequired(): boolean; 20 | } 21 | export default Property; 22 | -------------------------------------------------------------------------------- /types/property.spec.d.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | -------------------------------------------------------------------------------- /types/resource.d.ts: -------------------------------------------------------------------------------- 1 | import { BaseResource, BaseRecord, BaseProperty, Filter } from 'adminjs'; 2 | import { Model, ModelAttributeColumnOptions } from 'sequelize/types'; 3 | declare type Constructor = new (...args: any[]) => T; 4 | export declare type ModelType> = Constructor & typeof Model; 5 | declare type FindOptions = { 6 | limit?: number; 7 | offset?: number; 8 | sort?: { 9 | sortBy?: string; 10 | direction?: 'asc' | 'desc'; 11 | }; 12 | }; 13 | declare class Resource extends BaseResource { 14 | private SequelizeModel; 15 | static isAdapterFor(rawResource: any): boolean; 16 | constructor(SequelizeModel: typeof Model); 17 | rawAttributes(): Record; 18 | databaseName(): string; 19 | databaseType(): string; 20 | name(): string; 21 | id(): string; 22 | properties(): Array; 23 | property(path: string): BaseProperty | null; 24 | count(filter: Filter): Promise; 25 | primaryKey(): string; 26 | populate(baseRecords: any, property: any): Promise>; 27 | find(filter: any, { limit, offset, sort }: FindOptions): any; 28 | findOne(id: any): Promise; 29 | findMany(ids: any): Promise; 30 | findById(id: any): Promise; 31 | create(params: any): Promise>; 32 | update(id: any, params: any): Promise; 33 | delete(id: any): Promise; 34 | /** 35 | * Check all params against values they hold. In case of wrong value it corrects it. 36 | * 37 | * What it does exactly: 38 | * - removes keys with empty strings for the `number`, `float` and 'reference' properties. 39 | * 40 | * @param {Object} params received from AdminJS form 41 | * 42 | * @return {Object} converted params 43 | */ 44 | parseParams(params: any): any; 45 | } 46 | export default Resource; 47 | -------------------------------------------------------------------------------- /types/resource.spec.d.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | -------------------------------------------------------------------------------- /types/utils/available-test-models.d.ts: -------------------------------------------------------------------------------- 1 | export declare type AvailableTestModels = 'User' | 'Post' | 'Comment'; 2 | -------------------------------------------------------------------------------- /types/utils/convert-filter.d.ts: -------------------------------------------------------------------------------- 1 | declare const convertFilter: (filter: any) => any; 2 | export default convertFilter; 3 | -------------------------------------------------------------------------------- /types/utils/create-validation-error.d.ts: -------------------------------------------------------------------------------- 1 | import { ValidationError } from 'adminjs'; 2 | declare const createValidationError: (originalError: any) => ValidationError; 3 | export default createValidationError; 4 | --------------------------------------------------------------------------------