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