├── .env-example ├── .eslintrc.cjs ├── .github └── workflows │ └── push.yml ├── .gitignore ├── .npmignore ├── .npmrc ├── .releaserc ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── commitlint.config.cjs ├── example-app ├── .env-example ├── .gitignore ├── README.md ├── cypress.json ├── cypress │ ├── fixtures │ │ └── example.json │ ├── integration │ │ └── example.spec.ts │ ├── plugins │ │ └── index.js │ └── support │ │ ├── commands.js │ │ └── index.js ├── ormconfig.js ├── package.json ├── src │ ├── entity │ │ ├── Car.ts │ │ ├── Seller.ts │ │ └── User.ts │ └── index.ts ├── tsconfig.json └── yarn.lock ├── package.json ├── spec ├── Database.spec.ts ├── Property.spec.ts ├── Resource.spec.ts ├── entities │ ├── Car.ts │ ├── CarBuyer.ts │ └── CarDealer.ts └── utils │ └── test-data-source.ts ├── src ├── Database.ts ├── Property.ts ├── Resource.ts ├── index.ts └── utils │ ├── data-types.ts │ ├── filter │ ├── custom-filter.parser.ts │ ├── date-filter.parser.ts │ ├── default-filter.parser.ts │ ├── enum-filter.parser.ts │ ├── filter.converter.ts │ ├── filter.types.ts │ ├── filter.utils.ts │ ├── json-filter.parser.ts │ └── reference-filter.parser.ts │ └── safe-parse-number.ts ├── tsconfig.json ├── tsconfig.test.json └── yarn.lock /.env-example: -------------------------------------------------------------------------------- 1 | POSTGRES_USER="adminjs" 2 | POSTGRES_PASSWORD="adminjs" 3 | POSTGRES_DATABASE="adminjs-typeorm-app" 4 | POSTGRES_PORT=5432 5 | POSTGRES_HOST="localhost" -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2020: true, 5 | }, 6 | extends: [ 7 | 'airbnb', 8 | 'plugin:react/recommended', 9 | 'plugin:@typescript-eslint/recommended', 10 | ], 11 | parser: '@typescript-eslint/parser', 12 | parserOptions: { 13 | ecmaFeatures: { 14 | jsx: true, 15 | }, 16 | ecmaVersion: 20, 17 | sourceType: 'module', 18 | }, 19 | plugins: ['react', '@typescript-eslint'], 20 | rules: { 21 | semi: ['error', 'never'], 22 | 'no-unused-vars': 'off', 23 | 'import/prefer-default-export': 'off', 24 | // TODO remove following 2 lines 25 | 'guard-for-in': 'off', 26 | 'no-restricted-syntax': 'off', 27 | 'import/extensions': 'off', 28 | 'import/no-unresolved': 'off', 29 | 'react/jsx-filename-extension': 'off', 30 | indent: ['error', 2], 31 | 'linebreak-style': ['error', 'unix'], 32 | 'object-curly-newline': 'off', 33 | '@typescript-eslint/no-explicit-any': 'off', 34 | }, 35 | overrides: [ 36 | { 37 | files: ['*.tsx'], 38 | rules: { 39 | 'react/prop-types': 'off', 40 | 'react/jsx-props-no-spreading': 'off', 41 | }, 42 | }, 43 | ], 44 | } 45 | -------------------------------------------------------------------------------- /.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: Checkout 9 | uses: actions/checkout@v2 10 | - name: Setup 11 | uses: actions/setup-node@v2 12 | with: 13 | node-version: '18.x' 14 | - uses: actions/cache@v2 15 | id: yarn-cache 16 | with: 17 | path: node_modules 18 | key: ${{ runner.os }}-node_modules-${{ hashFiles('**/yarn.lock') }} 19 | restore-keys: | 20 | ${{ runner.os }}-node_modules- 21 | - name: Install 22 | if: steps.yarn-cache.outputs.cache-hit != 'true' 23 | run: yarn install 24 | 25 | test: 26 | name: Test 27 | runs-on: ubuntu-latest 28 | needs: setup 29 | env: 30 | NODE_ENV: test 31 | POSTGRES_PASSWORD: postgres 32 | POSTGRES_DATABASE: postgres 33 | POSTGRES_USER: postgres 34 | services: 35 | postgres: 36 | image: postgres:10.8 37 | env: 38 | POSTGRES_USER: postgres 39 | POSTGRES_PASSWORD: postgres 40 | POSTGRES_DB: postgres 41 | ports: 42 | - 5432:5432 43 | # needed because the postgres container does not provide a healthcheck 44 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 45 | steps: 46 | - name: Checkout 47 | uses: actions/checkout@v2 48 | - name: Setup 49 | uses: actions/setup-node@v2 50 | with: 51 | node-version: '18.x' 52 | - uses: actions/cache@v2 53 | id: yarn-cache 54 | with: 55 | path: node_modules 56 | key: ${{ runner.os }}-node_modules-${{ hashFiles('**/yarn.lock') }} 57 | restore-keys: | 58 | ${{ runner.os }}-node_modules- 59 | - name: Install 60 | if: steps.yarn-cache.outputs.cache-hit != 'true' 61 | run: yarn install 62 | - name: Lint 63 | run: yarn lint 64 | - name: Migrate 65 | run: yarn run migrate 66 | - name: Test 67 | run: yarn test 68 | 69 | publish: 70 | name: Publish 71 | needs: test 72 | runs-on: ubuntu-latest 73 | steps: 74 | - name: Checkout 75 | uses: actions/checkout@v2 76 | - name: Setup 77 | uses: actions/setup-node@v2 78 | with: 79 | node-version: '18.x' 80 | - uses: actions/cache@v2 81 | id: yarn-cache 82 | with: 83 | path: node_modules 84 | key: ${{ runner.os }}-node_modules-${{ hashFiles('**/yarn.lock') }} 85 | restore-keys: | 86 | ${{ runner.os }}-node_modules- 87 | - name: Install 88 | if: steps.yarn-cache.outputs.cache-hit != 'true' 89 | run: yarn install 90 | - name: build 91 | run: yarn build 92 | - name: Release 93 | env: 94 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 95 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 96 | SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} 97 | run: yarn release -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # File created using '.gitignore Generator' for Visual Studio Code: https://bit.ly/vscode-gig 2 | 3 | # Created by https://www.toptal.com/developers/gitignore/api/visualstudiocode,macos,node 4 | # Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode,macos,node 5 | 6 | ### macOS ### 7 | # General 8 | .DS_Store 9 | .AppleDouble 10 | .LSOverride 11 | 12 | # Icon must end with two \r 13 | Icon 14 | 15 | # Thumbnails 16 | ._* 17 | 18 | # Files that might appear in the root of a volume 19 | .DocumentRevisions-V100 20 | .fseventsd 21 | .Spotlight-V100 22 | .TemporaryItems 23 | .Trashes 24 | .VolumeIcon.icns 25 | .com.apple.timemachine.donotpresent 26 | 27 | # Directories potentially created on remote AFP share 28 | .AppleDB 29 | .AppleDesktop 30 | Network Trash Folder 31 | Temporary Items 32 | .apdisk 33 | 34 | ### Node ### 35 | # Logs 36 | logs 37 | *.log 38 | npm-debug.log* 39 | yarn-debug.log* 40 | yarn-error.log* 41 | lerna-debug.log* 42 | 43 | # Diagnostic reports (https://nodejs.org/api/report.html) 44 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 45 | 46 | # Runtime data 47 | pids 48 | *.pid 49 | *.seed 50 | *.pid.lock 51 | 52 | # Directory for instrumented libs generated by jscoverage/JSCover 53 | lib-cov 54 | 55 | # Coverage directory used by tools like istanbul 56 | coverage 57 | *.lcov 58 | 59 | # nyc test coverage 60 | .nyc_output 61 | 62 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 63 | .grunt 64 | 65 | # Bower dependency directory (https://bower.io/) 66 | bower_components 67 | 68 | # node-waf configuration 69 | .lock-wscript 70 | 71 | # Compiled binary addons (https://nodejs.org/api/addons.html) 72 | build/Release 73 | 74 | # Dependency directories 75 | node_modules/ 76 | jspm_packages/ 77 | 78 | # TypeScript v1 declaration files 79 | typings/ 80 | 81 | # TypeScript cache 82 | *.tsbuildinfo 83 | 84 | # Optional npm cache directory 85 | .npm 86 | 87 | # Optional eslint cache 88 | .eslintcache 89 | 90 | # Microbundle cache 91 | .rpt2_cache/ 92 | .rts2_cache_cjs/ 93 | .rts2_cache_es/ 94 | .rts2_cache_umd/ 95 | 96 | # Optional REPL history 97 | .node_repl_history 98 | 99 | # Output of 'npm pack' 100 | *.tgz 101 | 102 | # Yarn Integrity file 103 | .yarn-integrity 104 | 105 | # dotenv environment variables file 106 | .env 107 | .env.test 108 | 109 | # parcel-bundler cache (https://parceljs.org/) 110 | .cache 111 | 112 | # Next.js build output 113 | .next 114 | 115 | # Nuxt.js build / generate output 116 | .nuxt 117 | dist 118 | 119 | # Gatsby files 120 | .cache/ 121 | # Comment in the public line in if your project uses Gatsby and not Next.js 122 | # https://nextjs.org/blog/next-9-1#public-directory-support 123 | # public 124 | 125 | # vuepress build output 126 | .vuepress/dist 127 | 128 | # Serverless directories 129 | .serverless/ 130 | 131 | # FuseBox cache 132 | .fusebox/ 133 | 134 | # DynamoDB Local files 135 | .dynamodb/ 136 | 137 | # TernJS port file 138 | .tern-port 139 | 140 | # Stores VSCode versions used for testing VSCode extensions 141 | .vscode-test 142 | 143 | ### VisualStudioCode ### 144 | .vscode/* 145 | !.vscode/settings.json 146 | !.vscode/tasks.json 147 | !.vscode/launch.json 148 | !.vscode/extensions.json 149 | *.code-workspace 150 | 151 | ### VisualStudioCode Patch ### 152 | # Ignore all local history of files 153 | .history 154 | 155 | # End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,macos,node 156 | 157 | # Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option) 158 | 159 | types 160 | build 161 | lib 162 | .adminjs 163 | 164 | example-app/cypress/videos 165 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | \.idea/ 2 | node_modules/ 3 | spec/ 4 | example-app/ 5 | src/ 6 | package-lock\.json 7 | *.db -------------------------------------------------------------------------------- /.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": true 23 | } 24 | ] 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } -------------------------------------------------------------------------------- /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-typeorm 2 | 3 | This is an official [AdminJS](https://github.com/SoftwareBrothers/adminjs) adapter which integrates [TypeORM](https://typeorm.io/) into AdminJS. (originally forked from [Arteha/admin-bro-typeorm](https://github.com/Arteha/admin-bro-typeorm)) 4 | 5 | Installation: `yarn add @adminjs/typeorm` 6 | 7 | ## Usage 8 | 9 | The plugin can be registered using standard `AdminJS.registerAdapter` method. 10 | 11 | ```typescript 12 | import { Database, Resource } from '@adminjs/typeorm' 13 | import AdminJS from 'adminjs' 14 | 15 | AdminJS.registerAdapter({ Database, Resource }); 16 | 17 | // Optional: if you use class-validator you have to inject this to resource. 18 | import { validate } from 'class-validator' 19 | Resource.validate = validate 20 | ``` 21 | 22 | ## Example 23 | 24 | ```typescript 25 | import { 26 | BaseEntity, 27 | Entity, PrimaryGeneratedColumn, Column, 28 | ManyToOne, 29 | RelationId 30 | } from 'typeorm' 31 | import { MyDataSource } from './my-data-source' 32 | import * as express from 'express' 33 | import { Database, Resource } from '@adminjs/typeorm' 34 | import { validate } from 'class-validator' 35 | 36 | import AdminJS from 'adminjs' 37 | import * as AdminJSExpress from '@adminjs/express' 38 | 39 | Resource.validate = validate 40 | AdminJS.registerAdapter({ Database, Resource }) 41 | 42 | @Entity() 43 | export class Person extends BaseEntity 44 | { 45 | @PrimaryGeneratedColumn() 46 | public id: number; 47 | 48 | @Column({type: 'varchar'}) 49 | public firstName: string; 50 | 51 | @Column({type: 'varchar'}) 52 | public lastName: string; 53 | 54 | @ManyToOne(type => CarDealer, carDealer => carDealer.cars) 55 | organization: Organization; 56 | 57 | // in order be able to fetch resources in adminjs - we have to have id available 58 | @RelationId((person: Person) => person.organization) 59 | organizationId: number; 60 | 61 | // For fancy clickable relation links: 62 | public toString(): string 63 | { 64 | return `${firstName} ${lastName}`; 65 | } 66 | } 67 | 68 | ( async () => 69 | { 70 | await MyDataSource.initialize(); 71 | 72 | const adminJs = new AdminJS({ 73 | // databases: [MyDataSource], 74 | resources: [ 75 | { resource: Person, options: { parent: { name: 'foobar' } } } 76 | ], 77 | rootPath: '/admin', 78 | }) 79 | 80 | const app = express() 81 | const router = AdminJSExpress.buildRouter(adminJs) 82 | app.use(adminJs.options.rootPath, router) 83 | app.listen(3000) 84 | })() 85 | ``` 86 | 87 | ## ManyToOne 88 | 89 | Admin supports ManyToOne relationship but you also have to define @RealationId as stated in the example above. 90 | 91 | ## Contribution 92 | 93 | ### Running the example app 94 | 95 | If you want to set this up locally this is the suggested process: 96 | 97 | 1. fork the repo 98 | 2. Install dependencies 99 | 100 | ``` 101 | yarn install 102 | ``` 103 | 104 | 3. register this package as a (linked package)[https://classic.yarnpkg.com/en/docs/cli/link/] 105 | 106 | ``` 107 | yarn link 108 | ``` 109 | 110 | 4. Setup example app 111 | 112 | Install all dependencies and use previously linked version of `@adminjs/typeorm`. 113 | 114 | ``` 115 | cd example-app 116 | yarn install 117 | yarn link @adminjs/typeorm 118 | ``` 119 | 120 | Optionally you might want to link your local version of `adminjs` package 121 | 122 | 5. Make sure you have all the envs set (which are defined in `example-app/ormconfig.js`) 123 | 124 | 6. Build the package in watch mode 125 | 126 | (in the root folder) 127 | 128 | ``` 129 | yarn dev 130 | ``` 131 | 132 | 6. run the app in the dev mode 133 | 134 | ``` 135 | cd example-app 136 | yarn dev 137 | ``` 138 | 139 | ### Pull request 140 | 141 | Before you make a PR make sure all tests pass and your code wont causes linter errors. 142 | You can do this by running: 143 | 144 | ``` 145 | yarn lint 146 | yarn test 147 | ``` 148 | 149 | or with proper envs: `POSTGRES_USER=yourtestuser POSTGRES_DATABASE="database_test" yarn test` 150 | 151 | ## License 152 | 153 | 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. 154 | 155 | ## About rst.software 156 | 157 | 158 | 159 | We’re an open, friendly team that helps clients from all over the world to transform their businesses and create astonishing products. 160 | 161 | * We are available for [hire](https://www.rst.software/estimate-your-project). 162 | * If you want to work for us - check out the [career page](https://www.rst.software/join-us). 163 | -------------------------------------------------------------------------------- /commitlint.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | '@commitlint/config-conventional', 4 | ], 5 | } 6 | -------------------------------------------------------------------------------- /example-app/.env-example: -------------------------------------------------------------------------------- 1 | POSTGRES_USER="adminjs" 2 | POSTGRES_PASSWORD="adminjs" 3 | POSTGRES_DATABASE="adminjs-typeorm-app" 4 | POSTGRES_PORT=5432 5 | POSTGRES_HOST="localhost" -------------------------------------------------------------------------------- /example-app/.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .vscode/ 3 | node_modules/ 4 | build/ 5 | tmp/ 6 | temp/ -------------------------------------------------------------------------------- /example-app/README.md: -------------------------------------------------------------------------------- 1 | # Awesome Project Build with TypeORM 2 | 3 | Steps to run this project: 4 | 5 | 1. Run `npm i` command 6 | 2. Setup database settings inside `ormconfig.json` file 7 | 3. Run `npm start` command 8 | -------------------------------------------------------------------------------- /example-app/cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "http://localhost:3000/admin" 3 | } -------------------------------------------------------------------------------- /example-app/cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } -------------------------------------------------------------------------------- /example-app/cypress/integration/example.spec.ts: -------------------------------------------------------------------------------- 1 | context('Dashboard Page', () => { 2 | beforeEach(() => { 3 | cy.visit('/') 4 | }) 5 | 6 | it('shows overridden sidebar footer', () => { 7 | cy.contains('Welcome on Board') 8 | }) 9 | }) 10 | -------------------------------------------------------------------------------- /example-app/cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************************** 3 | // This example plugins/index.js can be used to load plugins 4 | // 5 | // You can change the location of this file or turn off loading 6 | // the plugins file with the 'pluginsFile' configuration option. 7 | // 8 | // You can read more here: 9 | // https://on.cypress.io/plugins-guide 10 | // *********************************************************** 11 | 12 | // This function is called when a project is opened or re-opened (e.g. due to 13 | // the project's config changing) 14 | 15 | /** 16 | * @type {Cypress.PluginConfig} 17 | */ 18 | module.exports = (on, config) => { 19 | // `on` is used to hook into various events Cypress emits 20 | // `config` is the resolved Cypress config 21 | } 22 | -------------------------------------------------------------------------------- /example-app/cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add("login", (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This will overwrite an existing command -- 25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 26 | -------------------------------------------------------------------------------- /example-app/cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /example-app/ormconfig.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-var-requires 2 | const path = require('path') 3 | 4 | const rootDir = 'build/src' 5 | 6 | module.exports = { 7 | type: 'postgres', 8 | host: process.env.POSTGRES_HOST || 'localhost', 9 | port: +(process.env.POSTGRES_PORT || 5432), 10 | username: process.env.POSTGRES_USER || 'postgres', 11 | password: process.env.POSTGRES_PASSWORD || '', 12 | database: process.env.POSTGRES_DATABASE || 'database_test', 13 | entities: [path.join(rootDir, '/entity/**/*.js')], 14 | migrations: [path.join(rootDir, '/migration/**/*.js')], 15 | subscribers: [path.join(rootDir, '/subscriber/**/*.js')], 16 | synchronize: true, 17 | logging: true, 18 | cli: { 19 | migrationsDir: path.join(rootDir, '/migration'), 20 | entitiesDir: path.join(rootDir, '/entity'), 21 | subscribersDir: path.join(rootDir, '/subscriber'), 22 | }, 23 | } 24 | -------------------------------------------------------------------------------- /example-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example-app", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "build": "tsc", 8 | "start": "node build/src/index.js", 9 | "dev": "yarn build && concurrently \"yarn build --watch\" \"nodemon --ext '.js' --watch ../lib --watch ./build --ignore 'cypress/**/*.js' node build/src/index.js\"", 10 | "cypress:open": "cypress open", 11 | "cypress:run": "cypress run" 12 | }, 13 | "devDependencies": { 14 | "@types/express": "^4.17.7", 15 | "@types/node": "^8.0.29", 16 | "concurrently": "^5.2.0", 17 | "cypress": "^4.11.0", 18 | "nodemon": "^2.0.4", 19 | "ts-node": "3.3.0", 20 | "typescript": "3.9.7" 21 | }, 22 | "dependencies": { 23 | "adminjs": "^3.3.1", 24 | "@adminjs/express": "^3.0.0", 25 | "@adminjs/typeorm": "^1.0.1", 26 | "express": "^4.17.1", 27 | "express-formidable": "^1.2.0", 28 | "express-session": "^1.17.1", 29 | "pg": "^8.3.0", 30 | "reflect-metadata": "^0.1.10", 31 | "typeorm": "0.2.28" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /example-app/src/entity/Car.ts: -------------------------------------------------------------------------------- 1 | import { BaseEntity, Entity, PrimaryGeneratedColumn, Column, RelationId, ManyToOne } from 'typeorm' 2 | import { User } from './User' 3 | import { Seller } from './Seller' 4 | 5 | @Entity() 6 | export class Car extends BaseEntity { 7 | @PrimaryGeneratedColumn() 8 | id: number 9 | 10 | @Column() 11 | name: string 12 | 13 | @Column({ 14 | type: 'jsonb', 15 | nullable: true, 16 | }) 17 | meta: any 18 | 19 | @ManyToOne((type) => User, (user) => user.cars) 20 | owner: User 21 | 22 | @ManyToOne((type) => Seller, (seller) => seller.cars) 23 | seller: User 24 | 25 | // in order be able to fetch resources in adminjs - we have to have id available 26 | @RelationId((car: Car) => car.owner) 27 | ownerId: number 28 | 29 | @RelationId((car: Car) => car.seller) 30 | sellerId: string 31 | } 32 | -------------------------------------------------------------------------------- /example-app/src/entity/Seller.ts: -------------------------------------------------------------------------------- 1 | import { BaseEntity, Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm' 2 | import { Car } from './Car' 3 | 4 | export enum UserRoles { 5 | DESIGNER = 'designer', 6 | CLIENT = 'client' 7 | } 8 | 9 | @Entity() 10 | export class Seller extends BaseEntity { 11 | @PrimaryGeneratedColumn('uuid') 12 | id: string 13 | 14 | @Column() 15 | name: string 16 | 17 | @OneToMany((type) => Car, (car) => car.seller) 18 | cars: Array 19 | } 20 | -------------------------------------------------------------------------------- /example-app/src/entity/User.ts: -------------------------------------------------------------------------------- 1 | import { BaseEntity, Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm' 2 | import { Car } from './Car' 3 | 4 | export enum UserRoles { 5 | DESIGNER = 'designer', 6 | CLIENT = 'client' 7 | } 8 | 9 | @Entity() 10 | export class User extends BaseEntity { 11 | @PrimaryGeneratedColumn() 12 | id: number 13 | 14 | @Column() 15 | firstName: string 16 | 17 | @Column() 18 | lastName: string 19 | 20 | @Column() 21 | age: number 22 | 23 | @Column({ 24 | type: 'enum', 25 | enum: UserRoles, 26 | }) 27 | role: UserRoles 28 | 29 | @OneToMany((type) => Car, (car) => car.owner) 30 | cars: Array 31 | } 32 | -------------------------------------------------------------------------------- /example-app/src/index.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | import { createConnection } from 'typeorm' 3 | import express from 'express' 4 | import AdminJS from 'adminjs' 5 | import { buildRouter } from '@adminjs/express' 6 | import * as TypeormAdapter from '@adminjs/typeorm' 7 | import { User } from './entity/User' 8 | import { Car } from './entity/Car' 9 | import { Seller } from './entity/Seller' 10 | 11 | AdminJS.registerAdapter(TypeormAdapter) 12 | 13 | const PORT = 3000 14 | 15 | const run = async () => { 16 | await createConnection() 17 | const app = express() 18 | const admin = new AdminJS({ 19 | resources: [{ 20 | resource: User, 21 | options: { 22 | properties: { 23 | firstName: { 24 | isTitle: true, 25 | }, 26 | }, 27 | }, 28 | }, { 29 | resource: Car, 30 | options: { 31 | properties: { 32 | 'meta.title': { 33 | type: 'string', 34 | }, 35 | 'meta.description': { 36 | type: 'string', 37 | }, 38 | }, 39 | }, 40 | }, Seller], 41 | }) 42 | const router = buildRouter(admin) 43 | 44 | app.use(admin.options.rootPath, router) 45 | 46 | app.listen(PORT, () => { 47 | // eslint-disable-next-line no-console 48 | console.log(`Example app listening at http://localhost:${PORT}`) 49 | }) 50 | } 51 | 52 | run() 53 | -------------------------------------------------------------------------------- /example-app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "outDir": "./build", 5 | "target": "es6", 6 | "esModuleInterop": true, 7 | "jsx": "react", 8 | "strictNullChecks": true, 9 | "strictFunctionTypes": true, 10 | "strictBindCallApply": true, 11 | "noImplicitThis": true, 12 | "moduleResolution": "node", 13 | "module": "commonjs", 14 | "types": ["cypress"], 15 | "emitDecoratorMetadata": true, 16 | "experimentalDecorators": true, 17 | "strictPropertyInitialization": false, 18 | "sourceMap": true, 19 | "paths": { 20 | "react": ["node_modules/@types/react"], 21 | "adminjs": ["node_modules/adminjs"] 22 | } 23 | }, 24 | "include": [ 25 | "./src/**/*", 26 | "./cypress/**/*" 27 | ], 28 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@adminjs/typeorm", 3 | "type": "module", 4 | "exports": { 5 | ".": { 6 | "import": "./lib/index.js", 7 | "types": "./lib/index.d.ts" 8 | } 9 | }, 10 | "version": "5.0.1", 11 | "description": "TypeORM adapter for AdminJS", 12 | "keywords": [ 13 | "typeorm", 14 | "provider", 15 | "adminjs", 16 | "orm admin", 17 | "typeorm admin", 18 | "admin panel" 19 | ], 20 | "scripts": { 21 | "clean": "rm -fR lib", 22 | "build": "tsc", 23 | "dev": "yarn clean && tsc -w", 24 | "test": "mocha --loader=ts-node/esm ./spec/**/*.spec.ts", 25 | "ts-node": "ts-node", 26 | "lint": "eslint './src/**/*.{ts,js}' --ignore-pattern 'build' --ignore-pattern 'yarn.lock'", 27 | "check:all": "yarn lint && yarn test && yarn build", 28 | "typeorm": "typeorm-ts-node-esm -d \"./spec/utils/test-data-source.ts\"", 29 | "migrate": "yarn typeorm migration:run", 30 | "release": "semantic-release" 31 | }, 32 | "repository": { 33 | "type": "git", 34 | "url": "git+https://github.com/SoftwareBrothers/adminjs-typeorm.git" 35 | }, 36 | "husky": { 37 | "hooks": { 38 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" 39 | } 40 | }, 41 | "author": "Artem Zabolotnyi <1arteha1@gmail.com>", 42 | "license": "MIT", 43 | "peerDependencies": { 44 | "adminjs": "^7.0.0", 45 | "typeorm": "~0.3.0" 46 | }, 47 | "optionalDependencies": {}, 48 | "devDependencies": { 49 | "@commitlint/cli": "^17.4.4", 50 | "@commitlint/config-conventional": "^17.4.4", 51 | "@semantic-release/git": "^10.0.1", 52 | "@types/chai": "^4.3.4", 53 | "@types/mocha": "^10.0.1", 54 | "@types/node": "^18.15.3", 55 | "@typescript-eslint/eslint-plugin": "^5.55.0", 56 | "@typescript-eslint/parser": "^5.55.0", 57 | "adminjs": "^7.0.0", 58 | "chai": "^4.3.7", 59 | "class-validator": "^0.14.0", 60 | "eslint": "^8.36.0", 61 | "eslint-config-airbnb": "^19.0.4", 62 | "eslint-plugin-import": "^2.27.5", 63 | "eslint-plugin-jsx-a11y": "^6.7.1", 64 | "eslint-plugin-react": "^7.32.2", 65 | "eslint-plugin-react-hooks": "^4.6.0", 66 | "husky": "^4.2.5", 67 | "mocha": "^10.2.0", 68 | "pg": "^8.10.0", 69 | "semantic-release": "^20.1.3", 70 | "semantic-release-slack-bot": "^4.0.0", 71 | "ts-node": "^10.9.1", 72 | "tsconfig-paths": "^4.1.2", 73 | "typeorm": "^0.3.12", 74 | "typescript": "^4.9.5" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /spec/Database.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | 3 | import { Database } from '../src/Database.js' 4 | import { dataSource } from './utils/test-data-source.js' 5 | 6 | describe('Database', () => { 7 | before(async () => { 8 | await dataSource.initialize() 9 | }) 10 | 11 | after(() => { dataSource.destroy() }) 12 | 13 | describe('.isAdapterFor', () => { 14 | it('returns true when typeorm DataSource is given', () => { 15 | expect(Database.isAdapterFor(dataSource)).to.equal(true) 16 | }) 17 | // Test is irrelevent because isAdapterFor is typed 18 | // it('returns false for any other data', () => { 19 | // expect(Database.isAdapterFor()).to.equal(false) 20 | // }) 21 | }) 22 | 23 | describe('#resources', () => { 24 | it('returns all entities', async () => { 25 | expect(new Database(dataSource).resources()).to.have.lengthOf(3) 26 | }) 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /spec/Property.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | 3 | import { ColumnMetadata } from 'typeorm/metadata/ColumnMetadata.js' 4 | import { Property } from '../src/Property.js' 5 | import { Car } from './entities/Car.js' 6 | import { dataSource } from './utils/test-data-source.js' 7 | 8 | describe('Property', () => { 9 | let columns: Array 10 | 11 | before(async () => { 12 | await dataSource.initialize() 13 | }) 14 | 15 | beforeEach(() => { 16 | columns = Car.getRepository().metadata.columns 17 | }) 18 | 19 | after(async () => { 20 | await Car.delete({}) 21 | dataSource.destroy() 22 | }) 23 | 24 | describe('#name', () => { 25 | it('returns a name of the property', () => { 26 | const column = columns.find((c) => c.propertyName === 'carId') as ColumnMetadata 27 | 28 | expect(new Property(column).name()).to.equal('carId') 29 | }) 30 | }) 31 | 32 | describe('#path', () => { 33 | it('returns the path of the property', () => { 34 | const column = columns.find((c) => c.propertyName === 'name') as ColumnMetadata 35 | 36 | expect(new Property(column).path()).to.equal('name') 37 | }) 38 | 39 | it('returns the path of the property', () => { 40 | const column = columns.find((c) => c.propertyName === 'carDealerId') as ColumnMetadata 41 | 42 | expect(new Property(column).path()).to.equal('carDealerId') 43 | }) 44 | }) 45 | 46 | describe('#isId', () => { 47 | it('returns true for primary key', () => { 48 | const column = columns.find((c) => c.propertyName === 'carId') as ColumnMetadata 49 | 50 | expect(new Property(column).isId()).to.equal(true) 51 | }) 52 | 53 | it('returns false for regular column', () => { 54 | const column = columns.find((c) => c.propertyName === 'name') as ColumnMetadata 55 | 56 | expect(new Property(column).isId()).to.equal(false) 57 | }) 58 | }) 59 | 60 | describe('#isEditable', () => { 61 | it('returns false for id field', async () => { 62 | const column = columns.find((c) => c.propertyName === 'carId') as ColumnMetadata 63 | 64 | expect(new Property(column).isEditable()).to.equal(false) 65 | }) 66 | 67 | it('returns false for createdAt and updatedAt fields', async () => { 68 | const createdAt = columns.find((c) => c.propertyName === 'createdAt') as ColumnMetadata 69 | const updatedAt = columns.find((c) => c.propertyName === 'updatedAt') as ColumnMetadata 70 | 71 | expect(new Property(createdAt).isEditable()).to.equal(false) 72 | expect(new Property(updatedAt).isEditable()).to.equal(false) 73 | }) 74 | 75 | it('returns true for a regular field', async () => { 76 | const column = columns.find((c) => c.propertyName === 'name') as ColumnMetadata 77 | 78 | expect(new Property(column).isEditable()).to.equal(true) 79 | }) 80 | }) 81 | 82 | describe('#reference', () => { 83 | it('returns the name of the referenced resource if any', () => { 84 | const column = columns.find((c) => c.propertyName === 'carDealerId') as ColumnMetadata 85 | 86 | expect(new Property(column).reference()).to.equal('CarDealer') 87 | }) 88 | 89 | it('returns null for regular field', () => { 90 | const column = columns.find((c) => c.propertyName === 'name') as ColumnMetadata 91 | 92 | expect(new Property(column).reference()).to.equal(null) 93 | }) 94 | }) 95 | 96 | describe('#availableValues', () => { 97 | it('returns null for regular field', () => { 98 | const column = columns.find((c) => c.propertyName === 'name') as ColumnMetadata 99 | 100 | expect(new Property(column).availableValues()).to.equal(null) 101 | }) 102 | 103 | it('returns available values when enum is given', () => { 104 | const column = columns.find((c) => c.propertyName === 'carType') as ColumnMetadata 105 | 106 | expect(new Property(column).availableValues()).to.deep.equal([ 107 | 'modern', 'old', 'ghost', 108 | ]) 109 | }) 110 | }) 111 | 112 | describe('#type', () => { 113 | it('returns mixed type for an jsonb property', () => { 114 | const column = columns.find((c) => c.propertyName === 'meta') as ColumnMetadata 115 | 116 | expect(new Property(column).type()).to.equal('mixed') 117 | }) 118 | }) 119 | }) 120 | -------------------------------------------------------------------------------- /spec/Resource.spec.ts: -------------------------------------------------------------------------------- 1 | import { BaseProperty, BaseRecord, Filter, ValidationError } from 'adminjs' 2 | import { expect } from 'chai' 3 | import { validate } from 'class-validator' 4 | 5 | import { Car } from './entities/Car.js' 6 | import { CarDealer } from './entities/CarDealer.js' 7 | import { dataSource } from './utils/test-data-source.js' 8 | 9 | import { Resource } from '../src/Resource.js' 10 | import { CarBuyer } from './entities/CarBuyer.js' 11 | 12 | describe('Resource', () => { 13 | let resource: Resource 14 | const data = { 15 | model: 'Tucson', 16 | name: 'Hyundai', 17 | streetNumber: 'something', 18 | age: 4, 19 | stringAge: '4', 20 | 'meta.title': 'Hyundai', 21 | 'meta.description': 'Hyundai Tucson', 22 | } 23 | 24 | before(async () => { 25 | await dataSource.initialize() 26 | }) 27 | 28 | beforeEach(async () => { 29 | resource = new Resource(Car) 30 | await Car.delete({}) 31 | await CarDealer.delete({}) 32 | await CarBuyer.delete({}) 33 | }) 34 | 35 | after(async () => { 36 | dataSource.destroy() 37 | }) 38 | 39 | describe('.isAdapterFor', () => { 40 | it('returns true when Entity is give', () => { 41 | expect(Resource.isAdapterFor(Car)).to.equal(true) 42 | }) 43 | 44 | it('returns false for any other kind of resources', () => { 45 | expect(Resource.isAdapterFor({ Car: true })).to.equal(false) 46 | }) 47 | }) 48 | 49 | describe('#databaseName', () => { 50 | it('returns correct database name', () => { 51 | expect(resource.databaseName()).to.equal( 52 | process.env.POSTGRES_DATABASE || 'database_test', 53 | ) 54 | }) 55 | }) 56 | 57 | describe('#databaseType', () => { 58 | it('returns database dialect', () => { 59 | expect(resource.databaseType()).to.equal('postgres') 60 | }) 61 | }) 62 | 63 | describe('#name', () => { 64 | it('returns the name of the entity', () => { 65 | expect(resource.name()).to.equal('Car') 66 | }) 67 | }) 68 | 69 | describe('#properties', () => { 70 | it('returns all the properties', () => { 71 | expect(resource.properties()).to.have.lengthOf(12) 72 | }) 73 | 74 | it('returns all properties with the correct position', () => { 75 | expect(resource.properties().map((property) => property.position())).to.deep.equal([ 76 | 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 77 | ]) 78 | }) 79 | }) 80 | 81 | describe('#property', () => { 82 | it('returns selected property', () => { 83 | const property = resource.property('carId') 84 | 85 | expect(property).to.be.an.instanceOf(BaseProperty) 86 | }) 87 | }) 88 | 89 | describe('#find', () => { 90 | beforeEach(async () => { 91 | await resource.create(data) 92 | await resource.create({ ...data, name: 'other' }) 93 | }) 94 | 95 | it('returns all record when no query', async () => { 96 | const filter = new Filter({}, resource) 97 | return expect(await resource.find(filter, { sort: { sortBy: 'name' } })).to.have.lengthOf(2) 98 | }) 99 | 100 | it('returns matched record when filter is provided', async () => { 101 | const filter = new Filter({ name: 'other' }, resource) 102 | return expect(await resource.find(filter, { sort: { sortBy: 'name' } })).to.have.lengthOf(1) 103 | }) 104 | 105 | it('returns no records when filter does not match', async () => { 106 | const filter = new Filter({ name: 'others' }, resource) 107 | return expect(await resource.find(filter, { sort: { sortBy: 'name' } })).to.have.lengthOf(0) 108 | }) 109 | }) 110 | 111 | describe('#count', () => { 112 | it('returns number of records', async () => { 113 | expect(await resource.count({} as Filter)).to.eq(0) 114 | }) 115 | }) 116 | 117 | describe('#build flow with errors', () => { 118 | it('creates record with build flow', async () => { 119 | const record = await resource.build({ 120 | ...data, 121 | age: 'someAge', 122 | }) 123 | 124 | await record.save() 125 | 126 | // TODO handle undefined column 127 | expect(record.error('undefined')).not.to.equal(undefined) 128 | }) 129 | }) 130 | 131 | describe('#create', () => { 132 | it('returns params', async () => { 133 | const params = await resource.create(data) 134 | 135 | // eslint-disable-next-line no-unused-expressions 136 | expect(params.carId).not.to.be.undefined 137 | }) 138 | 139 | it('stores Column with defined name property', async () => { 140 | const params = await resource.create(data) 141 | const reference: any = {} 142 | reference[resource.idName()] = params.carId 143 | const storedRecord: Car | null = await Car.findOneBy(reference) 144 | 145 | expect(storedRecord?.streetNumber).to.equal(data.streetNumber) 146 | }) 147 | 148 | it('stores number Column with property as string', async () => { 149 | const params = await resource.create(data) 150 | const reference: any = {} 151 | reference[resource.idName()] = params.carId 152 | const storedRecord: Car | null = await Car.findOneBy(reference) 153 | 154 | expect(storedRecord?.stringAge).to.equal(4) 155 | }) 156 | 157 | it('stores mixed type properties', async () => { 158 | const params = await resource.create(data) 159 | const reference: any = {} 160 | reference[resource.idName()] = params.carId 161 | const storedRecord: Car | null = await Car.findOneBy(reference) 162 | 163 | expect(storedRecord?.meta).to.deep.equal({ 164 | title: data['meta.title'], 165 | description: data['meta.description'], 166 | }) 167 | }) 168 | 169 | it('throws ValidationError for defined validations', async () => { 170 | Resource.validate = validate 171 | try { 172 | await resource.create({ 173 | model: 'Tucson', 174 | age: 200, 175 | stringAge: 'abc', 176 | }) 177 | } catch (error) { 178 | expect(error).to.be.instanceOf(ValidationError) 179 | const errors = (error as ValidationError).propertyErrors 180 | expect(Object.keys(errors)).to.have.lengthOf(3) 181 | expect(errors.name.type).to.equal('isDefined') 182 | expect(errors.age.type).to.equal('max') 183 | expect(errors.stringAge.type).to.equal('max') 184 | } 185 | Resource.validate = undefined 186 | }) 187 | 188 | it('throws ValidationError for missing "model" property', async () => { 189 | try { 190 | await resource.create({ 191 | name: 'Tucson', 192 | age: 10, 193 | stringAge: '10', 194 | }) 195 | } catch (error) { 196 | expect(error).to.be.instanceOf(ValidationError) 197 | const errors = (error as ValidationError).propertyErrors 198 | expect(Object.keys(errors)[0]).to.equal('model') 199 | expect(Object.keys(errors)).to.have.lengthOf(1) 200 | 201 | // eslint-disable-next-line no-unused-expressions 202 | expect(errors.model.message).not.to.be.null 203 | } 204 | }) 205 | }) 206 | 207 | describe('#update', () => { 208 | let record: BaseRecord | null 209 | 210 | beforeEach(async () => { 211 | const params = await resource.create({ 212 | model: 'Tucson', 213 | name: 'Hyundai', 214 | age: 4, 215 | stringAge: '4', 216 | }) 217 | record = await resource.findOne(params.carId) 218 | }) 219 | 220 | it('updates record name', async () => { 221 | const ford = 'Ford' 222 | await resource.update((record && record.id()) as string, { 223 | name: ford, 224 | }) 225 | const recordInDb = await resource.findOne((record && record.id()) as string) 226 | 227 | expect(recordInDb && recordInDb.get('name')).to.equal(ford) 228 | }) 229 | 230 | it('throws error when wrong name is given', async () => { 231 | const age = 123131 232 | try { 233 | await resource.update((record && record.id()) as string, { age }) 234 | } catch (error) { 235 | expect(error).to.be.instanceOf(ValidationError) 236 | } 237 | }) 238 | }) 239 | 240 | describe('references', () => { 241 | let carDealer: CarDealer 242 | let carBuyer: CarBuyer 243 | let carParams 244 | beforeEach(async () => { 245 | carDealer = CarDealer.create({ name: 'dealPimp' }) 246 | await carDealer.save() 247 | 248 | carBuyer = CarBuyer.create({ name: 'johnDoe' }) 249 | await carBuyer.save() 250 | }) 251 | 252 | it('creates new resource', async () => { 253 | carParams = await resource.create({ 254 | ...data, 255 | carDealerId: carDealer.id, 256 | }) 257 | 258 | expect(carParams.carDealerId).to.equal(carDealer.id) 259 | }) 260 | 261 | it('creates new resource with uuid', async () => { 262 | carParams = await resource.create({ 263 | ...data, 264 | carBuyerId: carBuyer.carBuyerId, 265 | }) 266 | 267 | expect(carParams.carBuyerId).to.equal(carBuyer.carBuyerId) 268 | }) 269 | }) 270 | 271 | describe('#delete', () => { 272 | let carDealer: CarDealer 273 | let carParams 274 | 275 | beforeEach(async () => { 276 | carDealer = CarDealer.create({ name: 'dealPimp' }) 277 | await carDealer.save() 278 | carParams = await resource.create({ ...data, carDealerId: carDealer.id }) 279 | }) 280 | 281 | afterEach(async () => { 282 | await Car.delete(carParams.carId) 283 | await CarDealer.delete(carDealer.id) 284 | }) 285 | 286 | it('deletes the resource', async () => { 287 | await resource.delete(carParams.carId) 288 | expect(await resource.count({} as Filter)).to.eq(0) 289 | }) 290 | 291 | it('throws validation error when deleting record to which other record is related', async () => { 292 | const carDealerResource = new Resource(CarDealer) 293 | try { 294 | await carDealerResource.delete(carDealer.id) 295 | } catch (error) { 296 | expect(error).to.be.instanceOf(ValidationError) 297 | const { baseError } = error as ValidationError 298 | expect(baseError && baseError.type).to.equal('QueryFailedError') 299 | expect(baseError && baseError.message).not.to.equal(null) 300 | } 301 | }) 302 | }) 303 | }) 304 | -------------------------------------------------------------------------------- /spec/entities/Car.ts: -------------------------------------------------------------------------------- 1 | import { IsDefined, Max, Min } from 'class-validator' 2 | import { 3 | BaseEntity, 4 | Column, 5 | CreateDateColumn, 6 | Entity, 7 | JoinColumn, 8 | ManyToOne, 9 | PrimaryGeneratedColumn, 10 | RelationId, 11 | UpdateDateColumn, 12 | } from 'typeorm' 13 | import { CarBuyer } from './CarBuyer.js' 14 | import { CarDealer } from './CarDealer.js' 15 | 16 | export enum CarType { 17 | MODERN = 'modern', 18 | OLD = 'old', 19 | GHOST = 'ghost' 20 | } 21 | 22 | @Entity() 23 | export class Car extends BaseEntity { 24 | @PrimaryGeneratedColumn() 25 | public carId: number 26 | 27 | @Column() 28 | @IsDefined() 29 | public name: string 30 | 31 | @Column() 32 | public model: string 33 | 34 | @Column() 35 | @Min(0) 36 | @Max(15) 37 | public age: number 38 | 39 | @Column() 40 | @Min(0) 41 | @Max(15) 42 | public stringAge: number 43 | 44 | @Column({ name: 'street_number', nullable: true }) 45 | public streetNumber: string 46 | 47 | @Column({ 48 | type: 'enum', 49 | enum: CarType, 50 | default: CarType.GHOST, 51 | }) 52 | public carType: CarType 53 | 54 | @Column({ 55 | type: 'jsonb', 56 | nullable: true, 57 | }) 58 | public meta 59 | 60 | @ManyToOne(() => CarDealer, (carDealer) => carDealer.cars) 61 | @JoinColumn({ 62 | name: 'car_dealer_id', 63 | }) 64 | public carDealer: CarDealer 65 | 66 | @Column({ name: 'car_dealer_id', type: 'integer', nullable: true }) 67 | public carDealerId: number 68 | 69 | @ManyToOne(() => CarBuyer, (carBuyer) => carBuyer.cars) 70 | @JoinColumn({ 71 | name: 'car_buyer_id', 72 | }) 73 | public carBuyer: CarBuyer 74 | 75 | @Column({ name: 'car_buyer_id', type: 'uuid', nullable: true }) 76 | @RelationId((car: Car) => car.carBuyer) 77 | public carBuyerId: string 78 | 79 | @CreateDateColumn({ name: 'created_at' }) 80 | public createdAt: Date 81 | 82 | @UpdateDateColumn({ name: 'updated_at' }) 83 | public updatedAt: Date 84 | } 85 | -------------------------------------------------------------------------------- /spec/entities/CarBuyer.ts: -------------------------------------------------------------------------------- 1 | import { IsDefined } from 'class-validator' 2 | import { BaseEntity, Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm' 3 | import { Car } from './Car.js' 4 | 5 | @Entity() 6 | export class CarBuyer extends BaseEntity { 7 | @PrimaryGeneratedColumn('uuid', { 8 | name: 'car_buyer_id', 9 | }) 10 | public carBuyerId: string 11 | 12 | @Column() 13 | @IsDefined() 14 | public name: string 15 | 16 | @OneToMany(() => Car, (car) => car.carDealer, { 17 | cascade: true, 18 | }) 19 | public cars: Array 20 | } 21 | -------------------------------------------------------------------------------- /spec/entities/CarDealer.ts: -------------------------------------------------------------------------------- 1 | import { IsDefined } from 'class-validator' 2 | import { BaseEntity, Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm' 3 | import { Car } from './Car.js' 4 | 5 | @Entity() 6 | export class CarDealer extends BaseEntity { 7 | @PrimaryGeneratedColumn({ 8 | name: 'car_dealer_id', 9 | }) 10 | public id: string 11 | 12 | @Column() 13 | @IsDefined() 14 | public name: string 15 | 16 | @OneToMany(() => Car, (car) => car.carDealer, { 17 | cascade: true, 18 | }) 19 | public cars: Array 20 | } 21 | -------------------------------------------------------------------------------- /spec/utils/test-data-source.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | // eslint-disable-next-line import/no-extraneous-dependencies 3 | import 'reflect-metadata' 4 | import { DataSource } from 'typeorm' 5 | 6 | export const dataSource = new DataSource({ 7 | type: 'postgres', 8 | host: process.env.POSTGRES_HOST || 'localhost', 9 | port: +(process.env.POSTGRES_PORT || 5432), 10 | username: process.env.POSTGRES_USER || '', 11 | password: process.env.POSTGRES_PASSWORD || 'mysecretpassword', 12 | database: process.env.POSTGRES_DATABASE || 'database_test', 13 | entities: ['spec/entities/**/*.ts'], 14 | migrations: ['spec/migrations/**/*.ts'], 15 | subscribers: ['spec/subscribers/**/*.ts'], 16 | synchronize: true, 17 | logging: false, 18 | }) 19 | -------------------------------------------------------------------------------- /src/Database.ts: -------------------------------------------------------------------------------- 1 | import { BaseEntity, DataSource } from 'typeorm' 2 | 3 | import { BaseDatabase } from 'adminjs' 4 | import { Resource } from './Resource.js' 5 | 6 | export class Database extends BaseDatabase { 7 | public constructor(public readonly dataSource: DataSource) { 8 | super(dataSource) 9 | } 10 | 11 | public resources(): Array { 12 | const resources: Array = [] 13 | // eslint-disable-next-line no-restricted-syntax 14 | for (const entityMetadata of this.dataSource.entityMetadatas) { 15 | resources.push(new Resource(entityMetadata.target as typeof BaseEntity)) 16 | } 17 | 18 | return resources 19 | } 20 | 21 | public static isAdapterFor(dataSource: DataSource): boolean { 22 | return !!dataSource.entityMetadatas 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Property.ts: -------------------------------------------------------------------------------- 1 | import { BaseProperty, PropertyType } from 'adminjs' 2 | import { ColumnMetadata } from 'typeorm/metadata/ColumnMetadata.js' 3 | import { DATA_TYPES } from './utils/data-types.js' 4 | 5 | export class Property extends BaseProperty { 6 | public column: ColumnMetadata 7 | 8 | private columnPosition: number 9 | 10 | constructor(column: ColumnMetadata, columnPosition = 0) { 11 | const path = column.propertyPath 12 | super({ path }) 13 | this.column = column 14 | this.columnPosition = columnPosition 15 | } 16 | 17 | public isEditable(): boolean { 18 | return !this.column.isGenerated 19 | && !this.column.isCreateDate 20 | && !this.column.isUpdateDate 21 | } 22 | 23 | public isId(): boolean { 24 | return this.column.isPrimary 25 | } 26 | 27 | public isSortable(): boolean { 28 | return this.type() !== 'reference' 29 | } 30 | 31 | public reference(): string | null { 32 | const ref = this.column.referencedColumn 33 | if (ref) return ref.entityMetadata.name 34 | return null 35 | } 36 | 37 | public availableValues(): Array | null { 38 | const values = this.column.enum 39 | if (values) { return values.map((val) => val.toString()) } 40 | return null 41 | } 42 | 43 | public position(): number { 44 | return this.columnPosition || 0 45 | } 46 | 47 | public type(): PropertyType { 48 | let type: PropertyType = DATA_TYPES[this.column.type as any] 49 | 50 | if (typeof this.column.type === 'function') { 51 | if (this.column.type === Number) { type = 'number' } 52 | if (this.column.type === String) { type = 'string' } 53 | if (this.column.type === Date) { type = 'datetime' } 54 | if (this.column.type === Boolean) { type = 'boolean' } 55 | } 56 | 57 | if (this.reference()) { type = 'reference' } 58 | 59 | // eslint-disable-next-line no-console 60 | if (!type) { console.warn(`Unhandled type: ${this.column.type}`) } 61 | 62 | return type 63 | } 64 | 65 | public isArray(): boolean { 66 | return this.column.isArray 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Resource.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | import { BaseRecord, BaseResource, Filter, flat, ValidationError } from 'adminjs' 3 | import { BaseEntity, In } from 'typeorm' 4 | 5 | import { Property } from './Property.js' 6 | import { convertFilter } from './utils/filter/filter.converter.js' 7 | import safeParseNumber from './utils/safe-parse-number.js' 8 | 9 | type ParamsType = Record; 10 | 11 | export class Resource extends BaseResource { 12 | public static validate: any 13 | 14 | private model: typeof BaseEntity 15 | 16 | private propsObject: Record = {} 17 | 18 | constructor(model: typeof BaseEntity) { 19 | super(model) 20 | 21 | this.model = model 22 | this.propsObject = this.prepareProps() 23 | } 24 | 25 | public databaseName(): string { 26 | return this.model.getRepository().metadata.connection.options.database as string || 'typeorm' 27 | } 28 | 29 | public databaseType(): string { 30 | return this.model.getRepository().metadata.connection.options.type || 'typeorm' 31 | } 32 | 33 | public name(): string { 34 | return this.model.name 35 | } 36 | 37 | public id(): string { 38 | return this.model.name 39 | } 40 | 41 | public idName(): string { 42 | return this.model.getRepository().metadata.primaryColumns[0].propertyName 43 | } 44 | 45 | public properties(): Array { 46 | return [...Object.values(this.propsObject)] 47 | } 48 | 49 | public property(path: string): Property { 50 | return this.propsObject[path] 51 | } 52 | 53 | public async count(filter: Filter): Promise { 54 | return this.model.count(({ 55 | where: convertFilter(filter), 56 | })) 57 | } 58 | 59 | public async find( 60 | filter: Filter, 61 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 62 | params, 63 | ): Promise> { 64 | const { limit = 10, offset = 0, sort = {} } = params 65 | const { direction, sortBy } = sort 66 | const instances = await this.model.find({ 67 | where: convertFilter(filter), 68 | take: limit, 69 | skip: offset, 70 | order: { 71 | [sortBy]: (direction || 'asc').toUpperCase(), 72 | }, 73 | }) 74 | return instances.map((instance) => new BaseRecord(instance, this)) 75 | } 76 | 77 | public async findOne(id: string | number): Promise { 78 | const reference: any = {} 79 | reference[this.idName()] = id 80 | 81 | const instance = await this.model.findOneBy(reference) 82 | if (!instance) { 83 | return null 84 | } 85 | return new BaseRecord(instance, this) 86 | } 87 | 88 | public async findMany(ids: Array): Promise> { 89 | const reference: any = {} 90 | reference[this.idName()] = In(ids) 91 | const instances = await this.model.findBy(reference) 92 | 93 | return instances.map((instance) => new BaseRecord(instance, this)) 94 | } 95 | 96 | public async create(params: Record): Promise { 97 | const unflattenedParams = flat.unflatten(this.prepareParams(params)) as Record 98 | const instance = this.model.create(unflattenedParams) 99 | 100 | await this.validateAndSave(instance) 101 | 102 | return instance 103 | } 104 | 105 | public async update(pk: string | number, params: any = {}): Promise { 106 | const reference: any = {} 107 | reference[this.idName()] = pk 108 | const instance = await this.model.findOneBy(reference) 109 | if (instance) { 110 | const preparedParams = flat.unflatten(this.prepareParams(params)) 111 | Object.keys(preparedParams).forEach((paramName) => { 112 | instance[paramName] = preparedParams[paramName] 113 | }) 114 | await this.validateAndSave(instance) 115 | return instance 116 | } 117 | throw new Error('Instance not found.') 118 | } 119 | 120 | public async delete(pk: string | number): Promise { 121 | const reference: any = {} 122 | reference[this.idName()] = pk 123 | try { 124 | const instance = await this.model.findOneBy(reference) 125 | if (instance) { 126 | await instance.remove() 127 | } 128 | } catch (error) { 129 | if (error.name === 'QueryFailedError') { 130 | throw new ValidationError({}, { 131 | type: 'QueryFailedError', 132 | message: error.message, 133 | }) 134 | } 135 | throw error 136 | } 137 | } 138 | 139 | private prepareProps() { 140 | const { columns } = this.model.getRepository().metadata 141 | return columns.reduce((memo, col, index) => { 142 | const property = new Property(col, index) 143 | return { 144 | ...memo, 145 | [property.path()]: property, 146 | } 147 | }, {}) 148 | } 149 | 150 | /** Converts params from string to final type */ 151 | private prepareParams(params: Record): Record { 152 | const preparedParams: Record = { ...params } 153 | 154 | this.properties().forEach((property) => { 155 | const param = flat.get(preparedParams, property.path()) 156 | const key = property.path() 157 | 158 | // eslint-disable-next-line no-continue 159 | if (param === undefined) { return } 160 | 161 | const type = property.type() 162 | 163 | if (type === 'mixed') { 164 | preparedParams[key] = param 165 | } 166 | 167 | if (type === 'number') { 168 | if (property.isArray()) { 169 | preparedParams[key] = param ? param.map((p) => safeParseNumber(p)) : param 170 | } else { 171 | preparedParams[key] = safeParseNumber(param) 172 | } 173 | } 174 | 175 | if (type === 'reference') { 176 | if (param === null) { 177 | preparedParams[property.column.propertyName] = null 178 | } else { 179 | const [ref, foreignKey] = property.column.propertyPath.split('.') 180 | const id = (property.column.type === Number) ? Number(param) : param 181 | preparedParams[ref] = foreignKey ? { 182 | [foreignKey]: id, 183 | } : id 184 | } 185 | } 186 | }) 187 | 188 | return preparedParams 189 | } 190 | 191 | // eslint-disable-next-line class-methods-use-this 192 | async validateAndSave(instance: BaseEntity): Promise { 193 | if (Resource.validate) { 194 | const errors = await Resource.validate(instance) 195 | if (errors && errors.length) { 196 | const validationErrors = errors.reduce((memo, error) => ({ 197 | ...memo, 198 | [error.property]: { 199 | type: Object.keys(error.constraints)[0], 200 | message: Object.values(error.constraints)[0], 201 | }, 202 | }), {}) 203 | throw new ValidationError(validationErrors) 204 | } 205 | } 206 | try { 207 | await instance.save() 208 | } catch (error) { 209 | if (error.name === 'QueryFailedError') { 210 | throw new ValidationError({ 211 | [error.column]: { 212 | type: 'QueryFailedError', 213 | message: error.message, 214 | }, 215 | }) 216 | } 217 | } 218 | } 219 | 220 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 221 | public static isAdapterFor(rawResource: any): boolean { 222 | try { 223 | return !!rawResource.getRepository().metadata 224 | } catch (e) { 225 | return false 226 | } 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module @adminjs/typeorm 3 | * @subcategory Adapters 4 | * @section modules 5 | * 6 | * @classdesc 7 | * Database adapter which integrates [TypeORM](https://typeorm.io/) into adminjs 8 | * 9 | * ## installation 10 | * 11 | * ``` 12 | * yarn add @adminjs/typeorm 13 | * ``` 14 | * 15 | * ## Usage 16 | * 17 | * The plugin can be registered using standard `AdminJS.registerAdapter` method. 18 | * 19 | * ``` 20 | * import { Database, Resource } from '@adminjs/typeorm'; 21 | * import AdminJS from 'adminjs' 22 | * 23 | * AdminJS.registerAdapter(TypeOrmAdapter); 24 | * 25 | * //... 26 | * ``` 27 | * 28 | * if you use class-validator you have to inject this to resource: 29 | * 30 | * ``` 31 | * import { validate } from 'class-validator' 32 | * 33 | * //... 34 | * Resource.validate = validate; 35 | * ``` 36 | * 37 | * ## Example 38 | * 39 | * Let see the entire working example of one file typeorm application running on express server: 40 | * 41 | * ``` 42 | * import { 43 | * BaseEntity, 44 | * Entity, PrimaryGeneratedColumn, Column, 45 | * createConnection, 46 | * ManyToOne, 47 | * RelationId 48 | * } from 'typeorm'; 49 | * import * as express from 'express'; 50 | * import { Database, Resource } from '@adminjs/typeorm'; 51 | * import { validate } from 'class-validator' 52 | * 53 | * import AdminJS from 'adminjs'; 54 | * import * as AdminJSExpress from '@adminjs/express' 55 | * 56 | * Resource.validate = validate; 57 | * AdminJS.registerAdapter({ Database, Resource }); 58 | * 59 | * \@Entity() 60 | * export class Person extends BaseEntity 61 | * { 62 | * \@PrimaryGeneratedColumn() 63 | * public id: number; 64 | * 65 | * \@Column({type: 'varchar'}) 66 | * public firstName: string; 67 | * 68 | * \@Column({type: 'varchar'}) 69 | * public lastName: string; 70 | * 71 | * \@ManyToOne(type => CarDealer, carDealer => carDealer.cars) 72 | * organization: Organization; 73 | * 74 | * // in order be able to fetch resources in adminjs - we have to have id available 75 | * \@RelationId((person: Person) => person.organization) 76 | * organizationId: number; 77 | * } 78 | * 79 | * ( async () => 80 | * { 81 | * const connection = await createConnection({...}); 82 | * 83 | * // Applying connection to model 84 | * Person.useConnection(connection); 85 | * 86 | * const adminJs = new AdminJS({ 87 | * // databases: [connection], 88 | * resources: [ 89 | * { resource: Person, options: { parent: { name: 'foobar' } } } 90 | * ], 91 | * rootPath: '/admin', 92 | * }); 93 | * 94 | * const app = express(); 95 | * const router = AdminJSExpress.buildRouter(adminJs); 96 | * app.use(adminJs.options.rootPath, router); 97 | * app.listen(3000); 98 | * })(); 99 | * ``` 100 | * 101 | * ## ManyToOne 102 | * 103 | * Admin supports ManyToOne relationship but you also have to define @RealationId 104 | * as stated in the example above. 105 | * 106 | * So 2 properties are needed: 107 | * 108 | * ``` 109 | * \@ManyToOne(type => CarDealer, carDealer => carDealer.cars) 110 | * organization: Organization; 111 | * ``` 112 | * 113 | * and, because TypeORM doesn't publish raw IDs of references, the relation id is also needed: 114 | * 115 | * ``` 116 | * \@RelationId((person: Person) => person.organization) 117 | * organizationId: number; 118 | * ``` 119 | * 120 | * in our case external ID was is a number. 121 | */ 122 | 123 | export * from './Database.js' 124 | export * from './Resource.js' 125 | -------------------------------------------------------------------------------- /src/utils/data-types.ts: -------------------------------------------------------------------------------- 1 | import { PropertyType } from 'adminjs' 2 | 3 | export type DataType = 'string' | 'number' | 'float' | 'datetime' | 'date' 4 | | 'array' | 'object' | 'boolean'; 5 | 6 | const NUMBER = [ 7 | // PrimaryGeneratedColumnType: 8 | 'int', 'int2', 'int4', 'int8', 'integer', 'tinyint', 'smallint', 9 | 'mediumint', 'dec', 'decimal', 'fixed', 'numeric', 'number', 10 | 11 | // WithWidthColumnType: 12 | 'tinyint', 'smallint', 'mediumint', 'int', 13 | 14 | // SimpleColumnType: 15 | 'int2', 'integer', 'int4', 'int8', 'int64', 'unsigned big int', 'float4', 'float8', 16 | ] 17 | 18 | const STRING = [ 19 | // PrimaryGeneratedColumnType 20 | 'bigint', 21 | 22 | // SpatialColumnType: 23 | 'geometry', 'geography', 24 | 25 | // WithPrecisionColumnType: 26 | 'float', 'double', 'dec', 'decimal', 'fixed', 'numeric', 'real', 'double precision', 'number', 27 | 28 | // WithLengthColumnType: 29 | 'character varying', 'varying character', 'char varying', 'nvarchar', 'national varchar', 30 | 'character', 'native character', 'varchar', 'char', 'nchar', 'national char', 'varchar2', 31 | 'nvarchar2', 'raw', 'binary', 'varbinary', 'string', 32 | 33 | // SimpleColumnType: 34 | 'simple-enum', 'smallmoney', 'money', 'tinyblob', 'tinytext', 'mediumblob', 'mediumtext', 35 | 'blob', 'text', 'ntext', 'citext', 'hstore', 'longblob', 'longtext', 'bytes', 'bytea', 36 | 'long', 'raw', 'long raw', 'bfile', 'clob', 'nclob', 'image', 'timetz', 'timestamptz', 37 | 'interval year to month', 'interval day to second', 'interval', 'year', 'point', 'line', 38 | 'lseg', 'box', 'circle', 'path', 'polygon', 'geography', 'geometry', 'linestring', 39 | 'multipoint', 'multilinestring', 'multipolygon', 'geometrycollection', 'int4range', 40 | 'int8range', 'numrange', 'tsrange', 'tstzrange', 'daterange', 'enum', 'set', 'cidr', 41 | 'inet', 'macaddr', 'tsvector', 'tsquery', 'uuid', 'xml', 'varbinary', 'hierarchyid', 42 | 'sql_variant', 'rowid', 'urowid', 'uniqueidentifier', 'rowversion', 'cube', 43 | ] 44 | 45 | const DATE = [ 46 | // WithPrecisionColumnType: 47 | 'datetime', 'datetime2', 'datetimeoffset', 'time', 'time with time zone', 48 | 'time without time zone', 'timestamp', 'timestamp without time zone', 49 | 'timestamp with time zone', 'timestamp with local time zone', 'timestamptz', 50 | 51 | // SimpleColumnType: 52 | 'timestamp with local time zone', 'smalldatetime', 'date', 53 | ] 54 | 55 | const BOOLEAN = [ 56 | // SimpleColumnType: 57 | 'bit', 'bool', 'boolean', 'bit varying', 'varbit', 58 | ] 59 | 60 | // const ARRAY = [ 61 | // // SimpleColumnType: 62 | // "simple-array", "array" 63 | // ]; 64 | 65 | const OBJECT = [ 66 | // SimpleColumnType: 67 | 'simple-json', 'json', 'jsonb', 68 | ] 69 | 70 | const DATA_TYPES: Record = {} 71 | 72 | function extend(types: Array, dataType: PropertyType): void { 73 | for (const t of types) { DATA_TYPES[t] = dataType } 74 | } 75 | 76 | extend(NUMBER, 'number') 77 | extend(STRING, 'string') 78 | extend(DATE, 'datetime') 79 | extend(BOOLEAN, 'boolean') 80 | // extend(ARRAY, "array"); 81 | extend(OBJECT, 'mixed') 82 | 83 | export { DATA_TYPES } 84 | -------------------------------------------------------------------------------- /src/utils/filter/custom-filter.parser.ts: -------------------------------------------------------------------------------- 1 | import { FilterParser } from './filter.types.js' 2 | 3 | /** 4 | * It wasn't possible to pass raw filters to adapters with AdminJS 5 | * This solution allows you to pass custom filters to typeorm adapter modyfing list handler 6 | * 7 | * In your custom list handler modify creating filters in this way: 8 | * 9 | * ``` 10 | * // That makes `Filter` class to create proper filter object. 11 | * filters[propertyToFilterBy] = 1; 12 | * const filter = await new Filter(filters, resource).populate(); 13 | * // This parser recognizes `custom` field and passes the value directly to typeorm 14 | * filter.filters[propertyToFilterBy].custom = In([1,2,3]); 15 | * 16 | */ 17 | export const CustomParser: FilterParser = { 18 | isParserForType: (filter) => (filter as any)?.custom, 19 | parse: (filter, fieldKey) => ({ 20 | filterKey: fieldKey, 21 | filterValue: (filter as any)?.custom, 22 | }), 23 | } 24 | -------------------------------------------------------------------------------- /src/utils/filter/date-filter.parser.ts: -------------------------------------------------------------------------------- 1 | import { Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm' 2 | import { FilterParser } from './filter.types.js' 3 | 4 | export const DateParser: FilterParser = { 5 | isParserForType: (filter) => ['date', 'datetime'].includes(filter.property.type()), 6 | parse: (filter, fieldKey) => { 7 | if ( 8 | typeof filter.value !== 'string' 9 | && filter.value.from 10 | && filter.value.to 11 | ) { 12 | return { 13 | filterKey: fieldKey, 14 | filterValue: Between( 15 | new Date(filter.value.from), 16 | new Date(filter.value.to), 17 | ), 18 | } 19 | } 20 | if (typeof filter.value !== 'string' && filter.value.from) { 21 | return { 22 | filterKey: fieldKey, 23 | filterValue: MoreThanOrEqual(new Date(filter.value.from).toISOString()), 24 | } 25 | } 26 | if (typeof filter.value !== 'string' && filter.value.to) { 27 | return { 28 | filterKey: fieldKey, 29 | filterValue: LessThanOrEqual(new Date(filter.value.to)), 30 | } 31 | } 32 | 33 | throw new Error('Cannot parse date filter') 34 | }, 35 | } 36 | -------------------------------------------------------------------------------- /src/utils/filter/default-filter.parser.ts: -------------------------------------------------------------------------------- 1 | import { Like, Raw } from 'typeorm' 2 | import { Property } from '../../Property.js' 3 | import { FilterParser } from './filter.types.js' 4 | 5 | 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 6 | 7 | export const DefaultParser: FilterParser = { 8 | isParserForType: (filter) => filter.property.type() === 'string', 9 | parse: (filter, fieldKey) => { 10 | if ( 11 | uuidRegex.test(filter.value.toString()) 12 | || (filter.property as Property).column.type === 'uuid' 13 | ) { 14 | return { 15 | filterKey: fieldKey, 16 | filterValue: Raw((alias) => `CAST(${alias} AS CHAR(36)) = :value`, { 17 | value: filter.value, 18 | }), 19 | } 20 | } 21 | return { filterKey: fieldKey, filterValue: Like(`%${filter.value}%`) } 22 | }, 23 | } 24 | -------------------------------------------------------------------------------- /src/utils/filter/enum-filter.parser.ts: -------------------------------------------------------------------------------- 1 | import { Property } from '../../Property.js' 2 | import { FilterParser } from './filter.types.js' 3 | 4 | export const EnumParser: FilterParser = { 5 | isParserForType: (filter) => (filter.property as Property).column.type === 'enum', 6 | parse: (filter, fieldKey) => ({ 7 | filterKey: fieldKey, 8 | filterValue: filter.value, 9 | }), 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/filter/filter.converter.ts: -------------------------------------------------------------------------------- 1 | import { Filter } from 'adminjs' 2 | import { BaseEntity, FindOptionsWhere } from 'typeorm' 3 | import { DefaultParser } from './default-filter.parser.js' 4 | import { parsers } from './filter.utils.js' 5 | 6 | export const convertFilter = ( 7 | filterObject?: Filter, 8 | ): FindOptionsWhere => { 9 | if (!filterObject) { 10 | return {} 11 | } 12 | 13 | const { filters } = filterObject ?? {} 14 | const where = {} 15 | 16 | Object.entries(filters ?? {}).forEach(([fieldKey, filter]) => { 17 | const parser = parsers.find((p) => p.isParserForType(filter)) 18 | 19 | if (parser) { 20 | const { filterValue, filterKey } = parser.parse(filter, fieldKey) 21 | where[filterKey] = filterValue 22 | } else { 23 | const { filterValue, filterKey } = DefaultParser.parse(filter, fieldKey) 24 | where[filterKey] = filterValue 25 | } 26 | }) 27 | 28 | return where 29 | } 30 | -------------------------------------------------------------------------------- /src/utils/filter/filter.types.ts: -------------------------------------------------------------------------------- 1 | import { FilterElement } from 'adminjs' 2 | 3 | export type FilterParser = { 4 | isParserForType: (filter: FilterElement) => boolean; 5 | parse: ( 6 | filter: FilterElement, 7 | fieldKey: string 8 | ) => { filterKey: string; filterValue: any }; 9 | }; 10 | -------------------------------------------------------------------------------- /src/utils/filter/filter.utils.ts: -------------------------------------------------------------------------------- 1 | import { CustomParser } from './custom-filter.parser.js' 2 | import { DateParser } from './date-filter.parser.js' 3 | import { EnumParser } from './enum-filter.parser.js' 4 | import { JSONParser } from './json-filter.parser.js' 5 | import { ReferenceParser } from './reference-filter.parser.js' 6 | 7 | export const safeParseJSON = (json: string): any | null => { 8 | try { 9 | return JSON.parse(json) 10 | } catch (e) { 11 | return null 12 | } 13 | } 14 | 15 | export const parsers = [ 16 | // Has to be the first one, as it is intended to use custom filter if user overrides that 17 | CustomParser, 18 | DateParser, 19 | EnumParser, 20 | ReferenceParser, 21 | JSONParser, 22 | ] 23 | -------------------------------------------------------------------------------- /src/utils/filter/json-filter.parser.ts: -------------------------------------------------------------------------------- 1 | import { FilterParser } from './filter.types.js' 2 | import { safeParseJSON } from './filter.utils.js' 3 | 4 | export const JSONParser: FilterParser = { 5 | isParserForType: (filter) => ['boolean', 'number', 'float', 'object', 'array'].includes( 6 | filter.property.type(), 7 | ), 8 | parse: (filter, fieldKey) => ({ 9 | filterKey: fieldKey, 10 | filterValue: safeParseJSON(filter.value as string), 11 | }), 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/filter/reference-filter.parser.ts: -------------------------------------------------------------------------------- 1 | import { FilterElement } from 'adminjs' 2 | import { Property } from '../../Property.js' 3 | import { FilterParser } from './filter.types.js' 4 | 5 | export const ReferenceParser: FilterParser = { 6 | isParserForType: (filter) => filter.property.type() === 'reference', 7 | parse: (filter: FilterElement) => { 8 | const [column] = (filter.property as Property).column.propertyPath.split( 9 | '.', 10 | ) 11 | return { filterKey: column, filterValue: filter.value as any } 12 | }, 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/safe-parse-number.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-restricted-globals */ 2 | const isNumeric = (value: null | string | number | undefined): boolean => { 3 | const stringValue = (String(value)).replace(/,/g, '.') 4 | 5 | if (isNaN(parseFloat(stringValue))) return false 6 | 7 | return isFinite(Number(stringValue)) 8 | } 9 | 10 | const safeParseNumber = (value?: null | string | number): string | number | null | undefined => { 11 | if (isNumeric(value)) return Number(value) 12 | 13 | return value 14 | } 15 | 16 | export default safeParseNumber 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "nodenext", 4 | "module": "nodenext", 5 | "target": "esnext", 6 | "lib": ["esnext", "DOM"], 7 | "skipLibCheck": true, 8 | "sourceMap": true, 9 | "noImplicitAny": false, 10 | "strictNullChecks": true, 11 | "esModuleInterop": true, 12 | "declaration": true, 13 | "declarationDir": "./lib", 14 | "outDir": "./lib", 15 | "emitDecoratorMetadata": true, 16 | "experimentalDecorators": true, 17 | "baseUrl": "." 18 | }, 19 | "include": ["./src/**/*.ts"], 20 | "exclude": ["node_modules", "lib"] 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": [ 4 | "./src/**/*.ts", 5 | "./spec/**/*.ts" 6 | ] 7 | } 8 | --------------------------------------------------------------------------------