├── .babelrc ├── .eslintrc.js ├── .github ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml └── workflows │ └── build.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .lintstagedrc.json ├── .npmignore ├── .npmrc ├── .releaserc.js ├── CHANGELOG.md ├── LICENSE ├── README.md ├── SECURITY.md ├── commitlint.config.js ├── docker-compose.yml ├── jest.config.js ├── package.json ├── src ├── adapters │ └── sequelize.js ├── index.js ├── public │ └── parse-filter.js ├── services │ ├── apimap-field-builder.js │ ├── apimap-field-type-detector.js │ ├── belongs-to-updater.js │ ├── errors.js │ ├── filters-parser.js │ ├── has-many-associator.js │ ├── has-many-dissociator.js │ ├── has-many-getter.js │ ├── leaderboard-stat-getter.js │ ├── line-stat-getter.js │ ├── pie-stat-getter.js │ ├── primary-keys-manager.js │ ├── query-builder.js │ ├── query-options.js │ ├── query-stat-getter.js │ ├── requested-fields-extractor.js │ ├── resource-creator.js │ ├── resource-getter.js │ ├── resource-remover.js │ ├── resource-updater.js │ ├── resources-exporter.js │ ├── resources-getter.js │ ├── resources-remover.js │ ├── search-builder.js │ └── value-stat-getter.js └── utils │ ├── association-record.js │ ├── database.js │ ├── is-primary-key-a-foreign-key.js │ ├── object-tools.js │ ├── operators.js │ ├── orm.js │ ├── query.js │ ├── records-decorator.js │ └── sequelize-compatibility.js ├── test ├── adapters │ └── sequelize.test.js ├── databases.js ├── databases.test.js ├── fixtures │ ├── db.json │ └── leaderboard-stat-getter.json ├── helpers │ └── run-with-connection.js ├── index.test.js ├── init.test.js ├── integration │ ├── leaderboard-stat-getter.test.js │ └── smart-field.test.js ├── public │ └── parse-filter.test.js ├── services │ ├── apimap-field-builder.test.js │ ├── belongs-to-updater.test.js │ ├── filters-parser.test.js │ ├── has-many-getter.test.js │ ├── primary-keys-manager.test.js │ ├── query-builder.test.js │ ├── query-options.test.js │ ├── requested-fields-extractor.test.js │ ├── resource-creator.test.js │ ├── resources-remover.test.js │ └── resources-updater.test.js └── utils │ ├── operators.test.js │ ├── query.test.js │ └── sequelize-compatibility.test.js ├── types ├── .eslintrc.js └── index.d.ts └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "node": "8" 8 | }, 9 | "useBuiltIns": "usage", 10 | "corejs": 3 11 | } 12 | ] 13 | ], 14 | "plugins": [ 15 | "@babel/plugin-proposal-optional-chaining", 16 | "@babel/plugin-transform-runtime", 17 | "@babel/plugin-transform-arrow-functions", 18 | "@babel/plugin-proposal-class-properties" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: [ 4 | 'airbnb-base', 5 | 'plugin:jest/all', 6 | 'plugin:sonarjs/recommended', 7 | ], 8 | plugins: [ 9 | 'sonarjs', 10 | ], 11 | env: { 12 | node: true, 13 | }, 14 | ignorePatterns: [ 15 | 'dist/**', 16 | '.eslintrc.js', 17 | 'jest.config.js' 18 | ], 19 | rules: { 20 | 'implicit-arrow-linebreak': 0, 21 | 'import/no-extraneous-dependencies': [ 22 | 'error', 23 | { 24 | 'devDependencies': [ 25 | '.eslint-bin/*.js', 26 | 'test/**/*.js' 27 | ] 28 | } 29 | ], 30 | 'no-param-reassign': 0, 31 | "no-underscore-dangle": 0, 32 | 'sonarjs/cognitive-complexity': 1, 33 | 'sonarjs/no-collapsible-if': 0, 34 | 'sonarjs/no-duplicate-string': 0, 35 | 'sonarjs/no-duplicated-branches': 1, 36 | 'sonarjs/no-identical-functions': 0, 37 | 'sonarjs/no-same-line-conditional': 0 38 | }, 39 | parser: "@babel/eslint-parser", 40 | }; 41 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Expected behavior 2 | 3 | TODO: Please describe here the behavior you are expecting. 4 | 5 | ## Actual behavior 6 | 7 | TODO: What is the current behavior? 8 | 9 | ## Failure Logs 10 | 11 | TODO: Please include any relevant log snippets, if necessary. 12 | 13 | ## Context 14 | 15 | TODO: Please provide any relevant information about your setup. 16 | 17 | * Package Version: 18 | * Express Version: 19 | * Sequelize Version: 20 | * Database Dialect: 21 | * Database Version: 22 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Definition of Done 2 | 3 | ### General 4 | 5 | - [ ] Write an explicit title for the Pull Request, following [Conventional Commits specification](https://www.conventionalcommits.org) 6 | - [ ] Test manually the implemented changes 7 | - [ ] Validate the code quality (indentation, syntax, style, simplicity, readability) 8 | - [ ] Ensure that Types have been updated according to your changes (if needed) 9 | 10 | ### Security 11 | 12 | - [ ] Consider the security impact of the changes made 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | ignore: 9 | - dependency-name: "@babel/core" 10 | versions: 11 | - 7.12.10 12 | - 7.12.13 13 | - 7.12.16 14 | - 7.12.17 15 | - 7.13.1 16 | - 7.13.10 17 | - 7.13.13 18 | - 7.13.14 19 | - 7.13.15 20 | - 7.13.8 21 | - dependency-name: "@babel/preset-env" 22 | versions: 23 | - 7.12.11 24 | - 7.12.13 25 | - 7.12.16 26 | - 7.12.17 27 | - 7.13.0 28 | - 7.13.10 29 | - 7.13.12 30 | - 7.13.5 31 | - 7.13.8 32 | - 7.13.9 33 | - dependency-name: ini 34 | versions: 35 | - 1.3.8 36 | - dependency-name: y18n 37 | versions: 38 | - 3.2.2 39 | - dependency-name: simple-git 40 | versions: 41 | - 2.31.0 42 | - 2.32.0 43 | - 2.34.2 44 | - 2.35.0 45 | - 2.35.1 46 | - 2.35.2 47 | - 2.36.0 48 | - 2.36.1 49 | - 2.36.2 50 | - 2.37.0 51 | - dependency-name: semver 52 | versions: 53 | - 7.3.4 54 | - dependency-name: "@babel/plugin-transform-arrow-functions" 55 | versions: 56 | - 7.12.1 57 | - 7.12.13 58 | - dependency-name: lodash 59 | versions: 60 | - 4.17.20 61 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build, Test and Deploy 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - beta 8 | pull_request: 9 | 10 | env: 11 | TZ: 'Europe/Paris' 12 | 13 | jobs: 14 | lint: 15 | name: Linting 16 | runs-on: ubuntu-latest 17 | if: "!contains(github.event.head_commit.message, '[skip ci]')" 18 | steps: 19 | - name: Cancel previous running workflows 20 | uses: fkirc/skip-duplicate-actions@master 21 | - uses: actions/checkout@v4 22 | with: 23 | fetch-depth: 0 24 | - uses: actions/setup-node@v4 25 | with: 26 | node-version: 18.20.1 27 | - uses: actions/cache@v4 28 | with: 29 | path: '**/node_modules' 30 | key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} 31 | - name: install dependencies 32 | run: yarn install --frozen-lockfile --non-interactive --production=false 33 | - name: Lint commit message 34 | uses: wagoid/commitlint-github-action@v2 35 | - name: lint Javascript 36 | run: yarn lint 37 | 38 | test: 39 | name: Test 40 | runs-on: ubuntu-latest 41 | needs: [lint] 42 | steps: 43 | - uses: actions/checkout@v4 44 | - uses: actions/setup-node@v4 45 | - name: Cache node_modules 46 | uses: actions/cache@v4 47 | with: 48 | path: '**/node_modules' 49 | key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} 50 | - name: Login on dockerhub 51 | run: echo ${{ secrets.DOCKER_PASSWORD }} | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin 52 | - name: Start docker container 53 | run: docker compose up -d; sleep 20 54 | - name: Send coverage 55 | uses: paambaati/codeclimate-action@v2.7.4 56 | env: 57 | CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} 58 | with: 59 | coverageCommand: yarn test:coverage 60 | 61 | deploy: 62 | name: Release package 63 | runs-on: ubuntu-latest 64 | needs: [test] 65 | if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/beta') 66 | steps: 67 | - uses: actions/checkout@v4 68 | with: 69 | persist-credentials: false # GITHUB_TOKEN must not be set for the semantic release 70 | - uses: actions/setup-node@v4 71 | with: 72 | node-version: 18.20.1 73 | - uses: actions/cache@v4 74 | with: 75 | path: '**/node_modules' 76 | key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} 77 | - name: Build package 78 | run: yarn build 79 | - name: Semantic Release 80 | uses: cycjimmy/semantic-release-action@v2 81 | id: semantic 82 | with: 83 | semantic_version: 17.3.0 84 | env: 85 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} 86 | GIT_AUTHOR_EMAIL: ${{ secrets.GIT_AUTHOR_EMAIL }} 87 | GIT_AUTHOR_NAME: ${{ secrets.GIT_AUTHOR_NAME }} 88 | GIT_COMMITTER_EMAIL: ${{ secrets.GIT_COMMITTER_EMAIL }} 89 | GIT_COMMITTER_NAME: ${{ secrets.GIT_COMMITTER_NAME }} 90 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 91 | SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} 92 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | 29 | # Build 30 | dist 31 | .eslintcache 32 | 33 | # VS Code 34 | *.code-workspace 35 | .vscode 36 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install commitlint --edit "$1" 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "*.js": ["eslint --cache --quiet --fix"] 3 | } 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | .github 3 | .yarn-error.log 4 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true 2 | -------------------------------------------------------------------------------- /.releaserc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | branches: ['main', '+([0-9])?(.{+([0-9]),x}).x', {name: 'beta', prerelease: true}], 3 | plugins: [ 4 | [ 5 | '@semantic-release/commit-analyzer', { 6 | preset: 'angular', 7 | releaseRules: [ 8 | // Example: `type(scope): subject [force release]` 9 | { subject: '*\\[force release\\]*', release: 'patch' }, 10 | ], 11 | }, 12 | ], 13 | '@semantic-release/release-notes-generator', 14 | '@semantic-release/changelog', 15 | '@semantic-release/npm', 16 | '@semantic-release/git', 17 | '@semantic-release/github', 18 | [ 19 | 'semantic-release-slack-bot', 20 | { 21 | markdownReleaseNotes: true, 22 | notifyOnSuccess: true, 23 | notifyOnFail: false, 24 | onSuccessTemplate: { 25 | text: "📦 $package_name@$npm_package_version has been released!", 26 | blocks: [{ 27 | type: 'section', 28 | text: { 29 | type: 'mrkdwn', 30 | text: '*New `$package_name` package released!*' 31 | } 32 | }, { 33 | type: 'context', 34 | elements: [{ 35 | type: 'mrkdwn', 36 | text: "📦 *Version:* <$repo_url/releases/tag/v$npm_package_version|$npm_package_version>" 37 | }] 38 | }, { 39 | type: 'divider', 40 | }], 41 | attachments: [{ 42 | blocks: [{ 43 | type: 'section', 44 | text: { 45 | type: 'mrkdwn', 46 | text: '*Changes* of version $release_notes', 47 | }, 48 | }], 49 | }], 50 | }, 51 | packageName: 'forest-express-sequelize', 52 | } 53 | ], 54 | [ 55 | "semantic-release-npm-deprecate-old-versions", { 56 | "rules": [ 57 | { 58 | "rule": "supportLatest", 59 | "options": { 60 | "numberOfMajorReleases": 3, 61 | "numberOfMinorReleases": "all", 62 | "numberOfPatchReleases": "all" 63 | } 64 | }, 65 | { 66 | "rule": "supportPreReleaseIfNotReleased", 67 | "options": { 68 | "numberOfPreReleases": 1, 69 | } 70 | }, 71 | "deprecateAll" 72 | ] 73 | } 74 | ] 75 | ], 76 | } 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Forest Admin in Nodejs (Express.js & Sequelize) 2 | 3 | [![npm package](https://badge.fury.io/js/forest-express-sequelize.svg)](https://badge.fury.io/js/forest-express-sequelize) 4 | [![CI status](https://github.com/ForestAdmin/forest-express-sequelize/workflows/Build,%20Test%20and%20Deploy/badge.svg?branch=main)](https://github.com/ForestAdmin/forest-express-sequelize/actions) 5 | [![Test Coverage](https://api.codeclimate.com/v1/badges/42d6d0fce013a6b96ae2/test_coverage)](https://codeclimate.com/github/ForestAdmin/forest-express-sequelize/test_coverage) 6 | [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) 7 | 8 | Forest Admin provides an off-the-shelf administration panel based on a highly-extensible API plugged into your application. 9 | 10 | This project has been designed with scalability in mind to fit requirements from small projects to mature companies. 11 | 12 | ## Who Uses Forest Admin 13 | 14 | - [Apartmentlist](https://www.apartmentlist.com) 15 | - [Carbon Health](https://carbonhealth.com) 16 | - [Ebanx](https://www.ebanx.com) 17 | - [First circle](https://www.firstcircle.ph) 18 | - [Forest Admin](https://www.forestadmin.com) of course :-) 19 | - [Heetch](https://www.heetch.com) 20 | - [Lunchr](https://www.lunchr.co) 21 | - [Pillow](https://www.pillow.com) 22 | - [Qonto](https://www.qonto.eu) 23 | - [Shadow](https://shadow.tech) 24 | - And hundreds more… 25 | 26 | ## Getting started 27 | 28 | [https://docs.forestadmin.com/documentation/how-tos/setup/install](https://docs.forestadmin.com/documentation/how-tos/setup/install) 29 | 30 | ## Documentation 31 | 32 | [https://docs.forestadmin.com/documentation/](https://docs.forestadmin.com/documentation/) 33 | 34 | ## How it works 35 | 36 |

37 | Howitworks 38 |

39 | 40 | Forest Admin consists of two components: 41 | 42 | - The Admin Frontend is the user interface where you'll manage your data and configuration. 43 | - The Admin Backend API hosted on your servers where you can find and extend your data models and all the business logic (routes, actions, …) related to your admin panel. 44 | 45 | The Forest Admin NPM package (aka Forest Liana) introspects all your data model 46 | and dynamically generates the Admin API hosted on your servers. The Forest Admin 47 | interface is a web application that handles communication between the admin 48 | user and your application data through the Admin API. 49 | 50 | ## Features 51 | 52 | ### CRUD 53 | 54 | All of your CRUD operations are natively supported. The API automatically 55 | supports your data models' validation and allows you to easily extend or 56 | override any API routes' with your very own custom logic. 57 | 58 | CRUD 59 | 60 | ### Search & Filters 61 | 62 | Forest Admin has a built-in search allowing you to run basic queries to 63 | retrieve your application's data. Set advanced filters based on fields and 64 | relationships to handle complex search use cases. 65 | 66 | Search and Filters 67 | 68 | ### Sorting & Pagination 69 | 70 | Sorting and pagination features are natively handled by the Admin API. We're 71 | continuously optimizing how queries are run in order to display results faster 72 | and reduce the load of your servers. 73 | 74 | Sorting and Pagination 75 | 76 | ### Custom action 77 | 78 | A custom action is a button which allows you to trigger an API call to execute 79 | a custom logic. With virtually no limitations, you can extend the way you 80 | manipulate data and trigger actions (e.g. refund a customer, apply a coupon, 81 | ban a user, etc.) 82 | 83 | Custom action 84 | 85 | ### Export 86 | 87 | Sometimes you need to export your data to a good old fashioned CSV. Yes, we 88 | know this can come in handy sometimes :-) 89 | 90 | Export 91 | 92 | ### Segments 93 | 94 | Get in app access to a subset of your application data by doing a basic search 95 | or typing an SQL query or implementing an API route. 96 | 97 | Segments 98 | 99 | ### Dashboards 100 | 101 | Forest Admin is able to tap into your actual data to chart out your metrics 102 | using a simple UI panel, a SQL query or a custom API call. 103 | 104 | Dashboard 105 | 106 | ### WYSIWYG 107 | 108 | The WYSIWYG interface saves you a tremendous amount of frontend development 109 | time using drag'n'drop as well as advanced widgets to build customizable views. 110 | 111 | WYSIWYG 112 | 113 | ### Custom HTML/JS/CSS 114 | 115 | Code your own views using JS, HTML, and CSS to display your application data in 116 | a more appropriate way (e.g. Kanban, Map, Calendar, Gallery, etc.). 117 | 118 | Custom views 119 | 120 | ### Team-based permissions 121 | 122 | Without any lines of code, manage directly from the UI who has access or can 123 | act on which data using a team-based permission system. 124 | 125 | Team based permissions 126 | 127 | ### Third-party integrations 128 | 129 | Leverage data from third-party services by reconciling it with your 130 | application’s data and providing it directly to your Admin Panel. All your 131 | actions can be performed at the same place, bringing additional intelligence to 132 | your Admin Panel and ensuring consistency. 133 | 134 | Third-party integrations 135 | 136 | ### Notes & Comments 137 | 138 | Assign your teammates to specific tasks, leave a note or simply comment a 139 | record, thereby simplifying collaboration all across your organization. 140 | 141 | Notes and Comments 142 | 143 | ### Activity logs 144 | 145 | Monitor each action executed and follow the trail of modification on any data 146 | with an extensive activity log system. 147 | 148 | Activity logs 149 | 150 | ## Community 151 | 152 | 👇 Join our Developers community for support and more 153 | 154 | [![Discourse developers community](https://img.shields.io/discourse/posts?label=discourse&server=https%3A%2F%2Fcommunity.forestadmin.com)](https://community.forestadmin.com) 155 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | To report a security vulnerability, please use the [Forest Admin security email](mailto:security@forestadmin.com). 6 | 7 | Our technical team will consider your request carefully. 8 | 9 | If the vulnerability report is accepted, Forest Admin will: 10 | - work on a fix of the current version with the highest priority, 11 | - let you know as soon as a new patched version is published. 12 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | // NOTICE: When a github "squash and merge" is performed, github add the PR link in the commit 2 | // message using the format ` (#)`. Github provide the target branch of the build, 3 | // so authorizing 4+5 = 9 characters more on main for the max header length should work 4 | // until we reach PR #99999. 5 | 6 | let maxLineLength = 100; 7 | 8 | const prExtrasChars = 9; 9 | 10 | const isPushEvent = process.env.GITHUB_EVENT_NAME === 'push'; 11 | 12 | if (isPushEvent) { 13 | maxLineLength += prExtrasChars; 14 | } 15 | 16 | module.exports = { 17 | extends: ['@commitlint/config-conventional'], 18 | rules: { 19 | 'header-max-length': [1, 'always', maxLineLength], 20 | 'body-max-line-length': [1, 'always', maxLineLength], 21 | 'footer-max-line-length': [1, 'always', maxLineLength], 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | postgres: 4 | image: postgres:12.19 5 | container_name: forest_express_sequelize_postgres 6 | ports: 7 | - '5437:5432' 8 | environment: 9 | - POSTGRES_DB=forest-express-sequelize-test 10 | - POSTGRES_USER=forest 11 | - POSTGRES_PASSWORD=secret 12 | 13 | mysql_min: 14 | image: mysql:5.6 15 | container_name: forest_express_sequelize_mysql_min 16 | environment: 17 | MYSQL_ROOT_PASSWORD: secret 18 | MYSQL_DATABASE: forest-express-sequelize-test 19 | MYSQL_USER: forest 20 | MYSQL_PASSWORD: secret 21 | ports: 22 | - '8998:3306' 23 | 24 | mysql_max: 25 | image: mysql:8.0 26 | container_name: forest_express_sequelize_mysql_max 27 | environment: 28 | MYSQL_ROOT_PASSWORD: secret 29 | MYSQL_DATABASE: forest-express-sequelize-test 30 | MYSQL_USER: forest 31 | MYSQL_PASSWORD: secret 32 | ports: 33 | - '8999:3306' 34 | 35 | maria_db: 36 | image: mariadb:10 37 | container_name: forest_express_sequelize_mariadb 38 | environment: 39 | MYSQL_ROOT_PASSWORD: secret 40 | MYSQL_DATABASE: forest-express-sequelize-test 41 | MYSQL_USER: forest 42 | MYSQL_PASSWORD: secret 43 | ports: 44 | - '9000:3306' 45 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | collectCoverageFrom: [ 4 | 'src/**/*.{ts,js}', 5 | ], 6 | setupFilesAfterEnv: [ 7 | 'jest-extended/all', 8 | ], 9 | }; 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "forest-express-sequelize", 3 | "description": "Official Express/Sequelize Liana for Forest", 4 | "version": "9.6.0", 5 | "author": "Sandro Munda ", 6 | "contributors": [ 7 | "Arnaud Besnier ", 8 | "Lucas Scariot ", 9 | "Arnaud Valensi ", 10 | "Vincent Molinié " 11 | ], 12 | "license": "GPL-3.0", 13 | "homepage": "http://www.forestadmin.com", 14 | "keywords": [ 15 | "forest", 16 | "admin", 17 | "panel", 18 | "interface", 19 | "sequelize" 20 | ], 21 | "repository": { 22 | "type": "git", 23 | "url": "git://github.com/ForestAdmin/forest-express-sequelize.git" 24 | }, 25 | "main": "dist/index.js", 26 | "types": "./types/index.d.ts", 27 | "dependencies": { 28 | "@babel/runtime": "7.15.4", 29 | "bluebird": "2.9.25", 30 | "core-js": "3.6.5", 31 | "forest-express": "10.6.6", 32 | "http-errors": "1.6.1", 33 | "lodash": "4.17.21", 34 | "moment": "2.29.4", 35 | "semver": "5.7.2" 36 | }, 37 | "devDependencies": { 38 | "@babel/cli": "7.15.7", 39 | "@babel/core": "7.19.3", 40 | "@babel/eslint-parser": "7.22.15", 41 | "@babel/plugin-proposal-class-properties": "7.14.5", 42 | "@babel/plugin-proposal-optional-chaining": "7.18.9", 43 | "@babel/plugin-transform-arrow-functions": "7.14.5", 44 | "@babel/plugin-transform-runtime": "7.19.1", 45 | "@babel/preset-env": "7.19.4", 46 | "@babel/register": "7.18.9", 47 | "@commitlint/cli": "17.4.2", 48 | "@commitlint/config-conventional": "17.4.2", 49 | "@semantic-release/changelog": "6.0.1", 50 | "@semantic-release/git": "10.0.1", 51 | "@types/express": "4.17.13", 52 | "@types/jest": "26.0.9", 53 | "@typescript-eslint/eslint-plugin": "4.26.0", 54 | "@typescript-eslint/parser": "4.26.0", 55 | "eslint": "6.8.0", 56 | "eslint-config-airbnb-base": "14.0.0", 57 | "eslint-plugin-import": "2.18.2", 58 | "eslint-plugin-jest": "23.0.4", 59 | "eslint-plugin-sonarjs": "0.5.0", 60 | "husky": "7.0.4", 61 | "jest": "29.1.1", 62 | "jest-extended": "3.1.0", 63 | "lint-staged": "12.3.7", 64 | "mysql2": "3.9.8", 65 | "onchange": "6.0.0", 66 | "pg": "8.4.2", 67 | "semantic-release": "19.0.3", 68 | "semantic-release-npm-deprecate-old-versions": "1.3.2", 69 | "semantic-release-slack-bot": "3.5.2", 70 | "sequelize": "6.29.0", 71 | "sequelize-fixtures": "1.1.1", 72 | "tedious": "15.1.3", 73 | "typescript": "4.3.2" 74 | }, 75 | "scripts": { 76 | "build": "babel src --out-dir dist", 77 | "build:watch": "onchange 'src/**/*.js' 'node_modules/forest-express/dist/*' --no-exclude -i -- babel --source-maps inline --out-dir dist src", 78 | "lint": "./node_modules/eslint/bin/eslint.js src test types/index.d.ts", 79 | "prepare": "husky install", 80 | "test": "jest", 81 | "test:coverage": "jest --coverage" 82 | }, 83 | "resolutions": { 84 | "semantic-release-slack-bot/**/micromatch": "^4.0.8" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/adapters/sequelize.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const P = require('bluebird'); 3 | const Interface = require('forest-express'); 4 | const ApimapFieldBuilder = require('../services/apimap-field-builder'); 5 | const ApimapFieldTypeDetector = require('../services/apimap-field-type-detector'); 6 | const isPrimaryKeyAForeignKey = require('../utils/is-primary-key-a-foreign-key'); 7 | 8 | module.exports = (model, opts) => { 9 | const fields = []; 10 | const fieldNamesToExclude = []; 11 | 12 | function getTypeForAssociation(association) { 13 | const attribute = association.target.rawAttributes[association.targetKey]; 14 | const type = attribute ? new ApimapFieldTypeDetector(attribute, opts).perform() : 'Number'; 15 | 16 | switch (association.associationType) { 17 | case 'BelongsTo': 18 | case 'HasOne': 19 | return type; 20 | case 'HasMany': 21 | case 'BelongsToMany': 22 | return [type]; 23 | default: 24 | return null; 25 | } 26 | } 27 | 28 | function getInverseOf(association) { 29 | // Notice: get inverse relation field 30 | // return null if not found 31 | const remoteAssociation = Object.values(association.target.associations) 32 | .find((a) => { 33 | const { identifierField, foreignIdentifierField } = association; 34 | const field = association.associationType === 'BelongsToMany' ? foreignIdentifierField : identifierField; 35 | 36 | return a.identifierField === field && association.source.name === a.target.name; 37 | }); 38 | if (remoteAssociation) { 39 | return remoteAssociation.associationAccessor; 40 | } 41 | return null; 42 | } 43 | 44 | function getSchemaForAssociation(association) { 45 | const schema = { 46 | field: association.associationAccessor, 47 | type: getTypeForAssociation(association), 48 | relationship: association.associationType, 49 | reference: `${association.target.name}.${association.target.primaryKeyField}`, 50 | inverseOf: getInverseOf(association), 51 | }; 52 | 53 | // NOTICE: Detect potential foreign keys that should be excluded, if a 54 | // constraints property is set for example. 55 | if (association.associationType === 'BelongsTo') { 56 | fieldNamesToExclude.push(association.identifierField); 57 | } 58 | 59 | return schema; 60 | } 61 | 62 | // FIXME: In `model.rawAttributes`, TEXT default values loose their inclosing quotes. 63 | // eg: "'quoted string'" => "quoted string" 64 | const columns = P 65 | .each(_.values(model.rawAttributes), (column) => { 66 | try { 67 | if (column.references && !column.primaryKey) { return; } 68 | 69 | const schema = new ApimapFieldBuilder(model, column, opts).perform(); 70 | 71 | if (schema.type) { 72 | fields.push(schema); 73 | } 74 | } catch (error) { 75 | Interface.logger.error(`Cannot fetch properly column ${column.field} of model ${model.name}`, error); 76 | } 77 | }); 78 | 79 | const associations = P 80 | .each(_.values(model.associations), (association) => { 81 | try { 82 | const schema = getSchemaForAssociation(association); 83 | fields.push(schema); 84 | } catch (error) { 85 | Interface.logger.error(`Cannot fetch properly association ${association.associationAccessor} of model ${model.name}`, error); 86 | } 87 | }); 88 | 89 | return P.all([columns, associations]) 90 | .then(() => { 91 | let isCompositePrimary = false; 92 | const primaryKeys = _.keys(model.primaryKeys); 93 | let idField = primaryKeys[0]; 94 | 95 | if (_.keys(model.primaryKeys).length > 1) { 96 | isCompositePrimary = true; 97 | idField = 'forestCompositePrimary'; 98 | } 99 | 100 | Object.entries(model.associations).forEach(([, association]) => { 101 | const primaryKeyIsAForeignKey = isPrimaryKeyAForeignKey(association); 102 | if (primaryKeyIsAForeignKey) { 103 | const FieldWithForeignKey = fields.find((field) => field.reference === `${association.target.name}.${association.target.primaryKeyField}`); 104 | if (FieldWithForeignKey) { 105 | FieldWithForeignKey.foreignAndPrimaryKey = true; 106 | } 107 | } 108 | }); 109 | 110 | _.remove(fields, (field) => 111 | _.includes(fieldNamesToExclude, field.columnName) && !field.isPrimaryKey); 112 | 113 | return { 114 | name: model.name, 115 | idField, 116 | primaryKeys, 117 | isCompositePrimary, 118 | fields, 119 | }; 120 | }); 121 | }; 122 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const P = require('bluebird'); 2 | const Interface = require('forest-express'); 3 | const orm = require('./utils/orm'); 4 | const lianaPackage = require('../package.json'); 5 | 6 | const SchemaAdapter = require('./adapters/sequelize'); 7 | 8 | const ResourcesGetter = require('./services/resources-getter'); 9 | const ResourceGetter = require('./services/resource-getter'); 10 | const ResourceCreator = require('./services/resource-creator'); 11 | const ResourceUpdater = require('./services/resource-updater'); 12 | const ResourceRemover = require('./services/resource-remover'); 13 | const ResourcesExporter = require('./services/resources-exporter'); 14 | const ResourcesRemover = require('./services/resources-remover'); 15 | 16 | const HasManyGetter = require('./services/has-many-getter'); 17 | const HasManyAssociator = require('./services/has-many-associator'); 18 | const HasManyDissociator = require('./services/has-many-dissociator'); 19 | const BelongsToUpdater = require('./services/belongs-to-updater'); 20 | 21 | const ValueStatGetter = require('./services/value-stat-getter'); 22 | const PieStatGetter = require('./services/pie-stat-getter'); 23 | const LineStatGetter = require('./services/line-stat-getter'); 24 | const LeaderboardStatGetter = require('./services/leaderboard-stat-getter'); 25 | const QueryStatGetter = require('./services/query-stat-getter'); 26 | const FiltersParser = require('./services/filters-parser'); 27 | 28 | const RecordsDecorator = require('./utils/records-decorator'); 29 | const makeParseFilter = require('./public/parse-filter'); 30 | 31 | const REGEX_VERSION = /(\d+\.)?(\d+\.)?(\*|\d+)/; 32 | 33 | exports.collection = Interface.collection; 34 | exports.ensureAuthenticated = Interface.ensureAuthenticated; 35 | exports.errorHandler = () => Interface.errorHandler({ logger: Interface.logger }); 36 | exports.StatSerializer = Interface.StatSerializer; 37 | exports.ResourceSerializer = Interface.ResourceSerializer; 38 | exports.ResourceDeserializer = Interface.ResourceDeserializer; 39 | exports.Schemas = Interface.Schemas; 40 | exports.ResourcesRoute = Interface.ResourcesRoute; 41 | 42 | exports.PermissionMiddlewareCreator = Interface.PermissionMiddlewareCreator; 43 | exports.deactivateCountMiddleware = Interface.deactivateCountMiddleware; 44 | exports.RecordsCounter = Interface.RecordsCounter; 45 | exports.RecordsExporter = Interface.RecordsExporter; 46 | exports.RecordsGetter = Interface.RecordsGetter; 47 | exports.RecordGetter = Interface.RecordGetter; 48 | exports.RecordUpdater = Interface.RecordUpdater; 49 | exports.RecordCreator = Interface.RecordCreator; 50 | exports.RecordRemover = Interface.RecordRemover; 51 | exports.RecordsRemover = Interface.RecordsRemover; 52 | exports.RecordSerializer = Interface.RecordSerializer; 53 | exports.BaseOperatorDateParser = Interface.BaseOperatorDateParser; 54 | exports.parseFilter = makeParseFilter(FiltersParser, exports); 55 | 56 | exports.PUBLIC_ROUTES = Interface.PUBLIC_ROUTES; 57 | 58 | exports.init = function init(opts) { 59 | exports.opts = opts; 60 | 61 | if (!opts.objectMapping) { 62 | Interface.logger.error('The objectMapping option appears to be missing. Please make sure it is set correctly.'); 63 | return Promise.resolve(() => {}); 64 | } 65 | 66 | if (opts.sequelize) { 67 | Interface.logger.warn('The sequelize option is not supported anymore. Please remove this option.'); 68 | } 69 | 70 | opts.Sequelize = opts.objectMapping; 71 | opts.useMultipleDatabases = Object.keys(opts.connections).length > 1; 72 | 73 | exports.getLianaName = function getLianaName() { 74 | return 'forest-express-sequelize'; 75 | }; 76 | 77 | exports.getLianaVersion = function getLianaVersion() { 78 | const lianaVersion = lianaPackage.version.match(REGEX_VERSION); 79 | if (lianaVersion && lianaVersion[0]) { 80 | return lianaVersion[0]; 81 | } 82 | return null; 83 | }; 84 | 85 | exports.getOrmVersion = function getOrmVersion() { 86 | return orm.getVersion(opts.Sequelize); 87 | }; 88 | 89 | exports.getDatabaseType = function getDatabaseType() { 90 | if (opts.useMultipleDatabases) return 'multiple'; 91 | 92 | return Object.values(opts.connections)[0].options.dialect; 93 | }; 94 | 95 | exports.SchemaAdapter = SchemaAdapter; 96 | 97 | exports.getModelName = function getModelName(model) { 98 | return model.name; 99 | }; 100 | 101 | // TODO: Remove nameOld attribute once the lianas versions older than 2.0.0 are minority 102 | exports.getModelNameOld = exports.getModelName; 103 | 104 | exports.ResourcesGetter = ResourcesGetter; 105 | exports.ResourceGetter = ResourceGetter; 106 | exports.ResourceCreator = ResourceCreator; 107 | exports.ResourceUpdater = ResourceUpdater; 108 | exports.ResourceRemover = ResourceRemover; 109 | exports.ResourcesExporter = ResourcesExporter; 110 | exports.ResourcesRemover = ResourcesRemover; 111 | 112 | exports.HasManyGetter = HasManyGetter; 113 | exports.HasManyAssociator = HasManyAssociator; 114 | exports.HasManyDissociator = HasManyDissociator; 115 | exports.BelongsToUpdater = BelongsToUpdater; 116 | 117 | exports.ValueStatGetter = ValueStatGetter; 118 | exports.PieStatGetter = PieStatGetter; 119 | exports.LineStatGetter = LineStatGetter; 120 | exports.LeaderboardStatGetter = LeaderboardStatGetter; 121 | exports.QueryStatGetter = QueryStatGetter; 122 | 123 | exports.RecordsDecorator = RecordsDecorator; 124 | 125 | exports.Stripe = { 126 | getCustomer: (customerModel, customerField, customerId) => { 127 | if (customerId) { 128 | return orm.findRecord(customerModel, customerId) 129 | .then((customer) => { 130 | if (customer && customer[customerField]) { 131 | return customer.toJSON(); 132 | } 133 | return P.reject(); 134 | }); 135 | } 136 | return P.resolve(); 137 | }, 138 | getCustomerByUserField: (customerModel, customerField, userField) => { 139 | if (!customerModel) { 140 | return new P((resolve) => resolve()); 141 | } 142 | 143 | const query = {}; 144 | query[customerField] = userField; 145 | 146 | return customerModel 147 | .findOne({ where: query }) 148 | .then((customer) => { 149 | if (!customer) { return null; } 150 | return customer.toJSON(); 151 | }); 152 | }, 153 | }; 154 | 155 | exports.Intercom = { 156 | getCustomer: (userModel, customerId) => orm.findRecord(userModel, customerId), 157 | }; 158 | 159 | exports.Closeio = { 160 | getCustomer: (userModel, customerId) => orm.findRecord(userModel, customerId), 161 | }; 162 | 163 | exports.Layer = { 164 | getUser: (customerModel, customerField, customerId) => 165 | new P((resolve, reject) => { 166 | if (customerId) { 167 | return orm.findRecord(customerModel, customerId) 168 | .then((customer) => { 169 | if (!customer || !customer[customerField]) { return reject(); } 170 | 171 | return resolve(customer); 172 | }); 173 | } 174 | return resolve(); 175 | }), 176 | }; 177 | 178 | exports.Mixpanel = { 179 | getUser: (userModel, userId) => { 180 | if (userId) { 181 | return orm.findRecord(userModel, userId) 182 | .then((user) => user.toJSON()); 183 | } 184 | 185 | return P.resolve(); 186 | }, 187 | }; 188 | 189 | return Interface.init(exports); 190 | }; 191 | -------------------------------------------------------------------------------- /src/public/parse-filter.js: -------------------------------------------------------------------------------- 1 | function makeParseFilter(FiltersParser, publicExports) { 2 | /** 3 | * @param {*} filter 4 | * @param {*} modelSchema 5 | * @param {string} timezone 6 | * @returns {Promise} Sequelize condition 7 | */ 8 | return function parseFilter(filter, modelSchema, timezone) { 9 | if (!publicExports.opts) throw new Error('Liana must be initialized before using parseFilter'); 10 | 11 | const parser = new FiltersParser(modelSchema, timezone, publicExports.opts); 12 | 13 | return parser.perform(JSON.stringify(filter)); 14 | }; 15 | } 16 | 17 | module.exports = makeParseFilter; 18 | -------------------------------------------------------------------------------- /src/services/apimap-field-builder.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const ApimapFieldTypeDetector = require('./apimap-field-type-detector'); 3 | 4 | function ApimapFieldBuilder(model, column, options) { 5 | const DataTypes = options.Sequelize; 6 | 7 | function isRequired() { 8 | // eslint-disable-next-line 9 | return column._autoGenerated !== true 10 | && (column.allowNull === false || Boolean(column.primaryKey)); 11 | } 12 | 13 | function getValidations(automaticValue) { 14 | const validations = []; 15 | 16 | // NOTICE: Do not inspect validation for autogenerated fields, it would 17 | // block the record creation/update. 18 | // eslint-disable-next-line 19 | if (automaticValue || column._autoGenerated === true) { 20 | return validations; 21 | } 22 | 23 | if (column.allowNull === false) { 24 | validations.push({ 25 | type: 'is present', 26 | }); 27 | } 28 | 29 | if (!column.validate) { return validations; } 30 | 31 | if (column.validate.min) { 32 | validations.push({ 33 | type: 'is greater than', 34 | value: column.validate.min.args || column.validate.min, 35 | message: column.validate.min.msg, 36 | }); 37 | } 38 | 39 | if (column.validate.max) { 40 | validations.push({ 41 | type: 'is less than', 42 | value: column.validate.max.args || column.validate.max, 43 | message: column.validate.max.msg, 44 | }); 45 | } 46 | 47 | if (column.validate.isBefore) { 48 | validations.push({ 49 | type: 'is before', 50 | value: column.validate.isBefore.args || column.validate.isBefore, 51 | message: column.validate.isBefore.msg, 52 | }); 53 | } 54 | 55 | if (column.validate.isAfter) { 56 | validations.push({ 57 | type: 'is after', 58 | value: column.validate.isAfter.args || column.validate.isAfter, 59 | message: column.validate.isAfter.msg, 60 | }); 61 | } 62 | 63 | if (column.validate.len) { 64 | const length = column.validate.len.args || column.validate.len; 65 | 66 | if (_.isArray(length) && !_.isNull(length[0]) && !_.isUndefined(length[0])) { 67 | validations.push({ 68 | type: 'is longer than', 69 | value: length[0], 70 | message: column.validate.len.msg, 71 | }); 72 | 73 | if (length[1]) { 74 | validations.push({ 75 | type: 'is shorter than', 76 | value: length[1], 77 | message: column.validate.len.msg, 78 | }); 79 | } 80 | } else { 81 | validations.push({ 82 | type: 'is longer than', 83 | value: length, 84 | message: column.validate.len.msg, 85 | }); 86 | } 87 | } 88 | 89 | if (column.validate.contains) { 90 | validations.push({ 91 | type: 'contains', 92 | value: column.validate.contains.args || column.validate.contains, 93 | message: column.validate.contains.msg, 94 | }); 95 | } 96 | 97 | if (column.validate.is && !_.isArray(column.validate.is)) { 98 | const value = column.validate.is.args || column.validate.is; 99 | 100 | validations.push({ 101 | type: 'is like', 102 | value: value.toString(), 103 | message: column.validate.is.msg, 104 | }); 105 | } 106 | 107 | return validations; 108 | } 109 | 110 | 111 | // NOTICE: Remove Sequelize.Utils.Literal wrapper to display actual value in UI. 112 | // Keep only simple values, and hide expressions. 113 | // Do not export literal values to UI by default. 114 | function unwrapLiteral(literalValue, columnType) { 115 | let value; 116 | 117 | if (_.isString(literalValue)) { 118 | if (['true', 'false'].includes(literalValue.toLowerCase())) { 119 | value = Boolean(literalValue); 120 | } else if (!_.isNaN(_.toNumber(literalValue))) { 121 | if (columnType instanceof DataTypes.NUMBER) { 122 | value = _.toNumber(literalValue); 123 | } else { 124 | value = literalValue; 125 | } 126 | // NOTICE: Only single quotes are widely considered valid to delimitate string values. 127 | } else if (literalValue.match(/^'.*'$/)) { 128 | value = literalValue.substring(1, literalValue.length - 1); 129 | } 130 | } else if (_.isBoolean(literalValue) || _.isNumber(literalValue)) { 131 | value = literalValue; 132 | } 133 | 134 | return value; 135 | } 136 | 137 | this.perform = () => { 138 | const schema = { 139 | field: column.fieldName, 140 | type: new ApimapFieldTypeDetector(column, options).perform(), 141 | // NOTICE: Necessary only for fields with different field and database 142 | // column names 143 | columnName: column.field, 144 | }; 145 | 146 | if (column.primaryKey === true) { 147 | schema.isPrimaryKey = true; 148 | } 149 | 150 | if (schema.type === 'Enum') { 151 | schema.enums = column.values; 152 | } 153 | 154 | // NOTICE: Create enums from sub-type (for ['Enum'] type). 155 | if (Array.isArray(schema.type) && schema.type[0] === 'Enum') { 156 | schema.enums = column.type.type.values; 157 | } 158 | 159 | if (isRequired()) { 160 | schema.isRequired = true; 161 | } 162 | 163 | const canHaveDynamicDefaultValue = ['Date', 'Dateonly'].indexOf(schema.type) !== -1 164 | || column.type instanceof DataTypes.UUID; 165 | const isDefaultValueFunction = (typeof column.defaultValue) === 'function' 166 | || (canHaveDynamicDefaultValue && (typeof column.defaultValue) === 'object'); 167 | 168 | if (!_.isNull(column.defaultValue) && !_.isUndefined(column.defaultValue)) { 169 | // NOTICE: Prevent sequelize.Sequelize.NOW to be defined as the default value as the client 170 | // does not manage it properly so far. 171 | if (isDefaultValueFunction) { 172 | schema.isRequired = false; 173 | // NOTICE: Do not use the primary keys default values to prevent issues with UUID fields 174 | // (defaultValue: DataTypes.UUIDV4). 175 | } else if (!_.includes(_.keys(model.primaryKeys), column.fieldName)) { 176 | // FIXME: `column.defaultValue instanceof Sequelize.Utils.Literal` fails for unknown reason. 177 | if (_.isObject(column.defaultValue) && (column.defaultValue.constructor.name === 'Literal')) { 178 | schema.defaultValue = unwrapLiteral(column.defaultValue.val, column.type); 179 | } else { 180 | schema.defaultValue = column.defaultValue; 181 | } 182 | } 183 | } 184 | 185 | schema.validations = getValidations(isDefaultValueFunction); 186 | 187 | if (schema.validations.length === 0) { 188 | delete schema.validations; 189 | } 190 | 191 | return schema; 192 | }; 193 | } 194 | 195 | module.exports = ApimapFieldBuilder; 196 | -------------------------------------------------------------------------------- /src/services/apimap-field-type-detector.js: -------------------------------------------------------------------------------- 1 | function ApimapFieldTypeDetector(column, options) { 2 | const DataTypes = options.Sequelize; 3 | 4 | this.perform = () => { 5 | if (column.type instanceof DataTypes.STRING 6 | || column.type instanceof DataTypes.TEXT 7 | || (DataTypes.CITEXT && column.type instanceof DataTypes.CITEXT) 8 | || column.type === 'citext') { // TODO: Remove 'citext' once Sequelize 4 has been deprecated. 9 | return 'String'; 10 | } 11 | if (column.type instanceof DataTypes.ENUM) { 12 | return 'Enum'; 13 | } 14 | if (column.type instanceof DataTypes.BOOLEAN) { 15 | return 'Boolean'; 16 | } 17 | if (column.type instanceof DataTypes.DATEONLY) { 18 | return 'Dateonly'; 19 | } 20 | if (column.type instanceof DataTypes.DATE) { 21 | return 'Date'; 22 | } 23 | if (column.type instanceof DataTypes.INTEGER 24 | || column.type instanceof DataTypes.FLOAT 25 | || column.type instanceof DataTypes['DOUBLE PRECISION'] 26 | || column.type instanceof DataTypes.BIGINT 27 | || column.type instanceof DataTypes.DECIMAL) { 28 | return 'Number'; 29 | } 30 | if (column.type instanceof DataTypes.JSONB 31 | || column.type instanceof DataTypes.JSON) { 32 | return 'Json'; 33 | } 34 | if (column.type instanceof DataTypes.TIME) { 35 | return 'Time'; 36 | } 37 | if (column.type instanceof DataTypes.GEOMETRY 38 | && column.type.type === 'POINT') { 39 | return 'Point'; 40 | } 41 | if (column.type instanceof DataTypes.UUID 42 | || column.type instanceof DataTypes.UUIDV1 43 | || column.type instanceof DataTypes.UUIDV4) { 44 | return 'Uuid'; 45 | } 46 | // NOTICE: Detect Array types (Array(String), Array(Integer), ...) 47 | if (column.type.type) { 48 | return [new ApimapFieldTypeDetector({ type: column.type.type }, options).perform()]; 49 | } 50 | return null; 51 | }; 52 | } 53 | 54 | module.exports = ApimapFieldTypeDetector; 55 | -------------------------------------------------------------------------------- /src/services/belongs-to-updater.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const orm = require('../utils/orm'); 3 | const associationRecord = require('../utils/association-record'); 4 | 5 | class BelongsToUpdater { 6 | constructor(model, assoc, opts, params, data) { 7 | this.model = model; 8 | this.assoc = assoc; 9 | this.opts = opts; 10 | this.params = params; 11 | this.data = data; 12 | } 13 | 14 | // WORKAROUND: Make the hasOne associations update work while waiting 15 | // for the Sequelize 4 release with the fix of the following 16 | // issue: https://github.com/sequelize/sequelize/issues/6069 17 | async _getTargetKey(association) { 18 | const pk = this.data.data.id; 19 | const targetKeyIsPrimaryKey = association.targetKey === association.target.primaryKeyAttribute; 20 | let targetKey = pk; 21 | 22 | if (association.associationType === 'HasOne' || !targetKeyIsPrimaryKey) { 23 | const record = await associationRecord.get(association.target, pk); 24 | if (association.associationType === 'HasOne') { 25 | targetKey = record; 26 | } else if (!targetKeyIsPrimaryKey) { 27 | // NOTICE: special use case with foreign key non pointing to a primary key 28 | targetKey = record[association.targetKey]; 29 | } 30 | } 31 | 32 | return targetKey; 33 | } 34 | 35 | async perform() { 36 | const { associationName, recordId } = this.params; 37 | const record = await orm.findRecord(this.model, recordId); 38 | const association = Object.values(this.model.associations) 39 | .find((a) => a.associationAccessor === associationName); 40 | const setterName = `set${_.upperFirst(associationName)}`; 41 | const options = { fields: null }; 42 | 43 | if (association && this.data.data) { 44 | const targetKey = await this._getTargetKey(association); 45 | 46 | // NOTICE: Enable model hooks to change fields values during an association update. 47 | return record[setterName](targetKey, options); 48 | } 49 | 50 | return record[setterName](null, options); 51 | } 52 | } 53 | 54 | module.exports = BelongsToUpdater; 55 | -------------------------------------------------------------------------------- /src/services/errors.js: -------------------------------------------------------------------------------- 1 | function ErrorHTTP422(message) { 2 | this.name = 'ErrorHTTP422'; 3 | this.message = message || 'Unprocessable Entity'; 4 | this.status = 422; 5 | this.stack = (new Error()).stack; 6 | } 7 | ErrorHTTP422.prototype = new Error(); 8 | 9 | function NoMatchingOperatorError(message) { 10 | this.name = 'NoMatchingOperatorError'; 11 | this.message = message || 'The given operator is not handled.'; 12 | this.status = 422; 13 | this.stack = (new Error()).stack; 14 | } 15 | NoMatchingOperatorError.prototype = new Error(); 16 | 17 | function InvalidParameterError(message) { 18 | this.name = 'InvalidParameterError'; 19 | this.message = message || 'The given parameter is invalid.'; 20 | this.status = 422; 21 | this.stack = (new Error()).stack; 22 | } 23 | InvalidParameterError.prototype = new Error(); 24 | 25 | exports.ErrorHTTP422 = ErrorHTTP422; 26 | exports.NoMatchingOperatorError = NoMatchingOperatorError; 27 | exports.InvalidParameterError = InvalidParameterError; 28 | -------------------------------------------------------------------------------- /src/services/filters-parser.js: -------------------------------------------------------------------------------- 1 | import { 2 | BaseFiltersParser, BaseOperatorDateParser, Schemas, SchemaUtils, 3 | } from 'forest-express'; 4 | import Operators from '../utils/operators'; 5 | import { NoMatchingOperatorError } from './errors'; 6 | 7 | const { getReferenceSchema, getReferenceField } = require('../utils/query'); 8 | 9 | function FiltersParser(modelSchema, timezone, options) { 10 | this.OPERATORS = Operators.getInstance(options); 11 | this.operatorDateParser = new BaseOperatorDateParser({ operators: this.OPERATORS, timezone }); 12 | 13 | this.perform = async (filtersString) => 14 | BaseFiltersParser.perform( 15 | filtersString, this.formatAggregation, this.formatCondition, modelSchema, 16 | ); 17 | 18 | this.formatAggregation = async (aggregator, formattedConditions) => { 19 | const aggregatorOperator = this.formatAggregatorOperator(aggregator); 20 | return { [aggregatorOperator]: formattedConditions }; 21 | }; 22 | 23 | this.formatCondition = async (condition, isSmartField = false) => { 24 | const isTextField = this.isTextField(condition.field); 25 | if (isSmartField) { 26 | return this.formatOperatorValue(condition.operator, condition.value, isTextField); 27 | } 28 | 29 | const formattedField = this.formatField(condition.field); 30 | 31 | if (this.operatorDateParser.isDateOperator(condition.operator)) { 32 | return { 33 | [formattedField]: this.operatorDateParser.getDateFilter( 34 | condition.operator, 35 | condition.value, 36 | ), 37 | }; 38 | } 39 | 40 | return { 41 | [formattedField]: this.formatOperatorValue(condition.operator, condition.value, isTextField), 42 | }; 43 | }; 44 | 45 | this.formatAggregatorOperator = (aggregatorOperator) => { 46 | switch (aggregatorOperator) { 47 | case 'and': 48 | return this.OPERATORS.AND; 49 | case 'or': 50 | return this.OPERATORS.OR; 51 | default: 52 | throw new NoMatchingOperatorError(); 53 | } 54 | }; 55 | 56 | this.formatOperatorValue = (operator, value, isTextField = false) => { 57 | switch (operator) { 58 | case 'not': 59 | return { [this.OPERATORS.NOT]: value }; 60 | case 'greater_than': 61 | case 'after': 62 | return { [this.OPERATORS.GT]: value }; 63 | case 'less_than': 64 | case 'before': 65 | return { [this.OPERATORS.LT]: value }; 66 | case 'contains': 67 | return { [this.OPERATORS.LIKE]: `%${value}%` }; 68 | case 'starts_with': 69 | return { [this.OPERATORS.LIKE]: `${value}%` }; 70 | case 'ends_with': 71 | return { [this.OPERATORS.LIKE]: `%${value}` }; 72 | case 'not_contains': 73 | return { [this.OPERATORS.NOT_LIKE]: `%${value}%` }; 74 | case 'present': 75 | return { [this.OPERATORS.NE]: null }; 76 | case 'not_equal': 77 | return { [this.OPERATORS.NE]: value }; 78 | case 'blank': 79 | return isTextField ? { 80 | [this.OPERATORS.OR]: [{ 81 | [this.OPERATORS.EQ]: null, 82 | }, { 83 | [this.OPERATORS.EQ]: '', 84 | }], 85 | } : { [this.OPERATORS.EQ]: null }; 86 | case 'equal': 87 | return { [this.OPERATORS.EQ]: value }; 88 | case 'includes_all': 89 | return { [this.OPERATORS.CONTAINS]: value }; 90 | case 'in': 91 | return typeof value === 'string' 92 | ? { [this.OPERATORS.IN]: value.split(',').map((elem) => elem.trim()) } 93 | : { [this.OPERATORS.IN]: value }; 94 | default: 95 | throw new NoMatchingOperatorError(); 96 | } 97 | }; 98 | 99 | this.formatField = (field) => { 100 | if (field.includes(':')) { 101 | const [associationName, fieldName] = field.split(':'); 102 | return `$${getReferenceField(Schemas.schemas, modelSchema, associationName, fieldName)}$`; 103 | } 104 | return field; 105 | }; 106 | 107 | this.isTextField = (field) => { 108 | if (field.includes(':')) { 109 | const [associationName, fieldName] = field.split(':'); 110 | const associationSchema = getReferenceSchema( 111 | Schemas.schemas, modelSchema, associationName, fieldName, 112 | ); 113 | if (associationSchema) { 114 | return SchemaUtils.getFieldType(associationSchema, field) === 'String'; 115 | } 116 | return false; 117 | } 118 | return SchemaUtils.getFieldType(modelSchema, field) === 'String'; 119 | }; 120 | 121 | 122 | // NOTICE: Look for a previous interval condition matching the following: 123 | // - If the filter is a simple condition at the root the check is done right away. 124 | // - There can't be a previous interval condition if the aggregator is 'or' (no meaning). 125 | // - The condition's operator has to be elligible for a previous interval. 126 | // - There can't be two previous interval condition. 127 | this.getPreviousIntervalCondition = (filtersString) => { 128 | const filters = BaseFiltersParser.parseFiltersString(filtersString); 129 | let currentPreviousInterval = null; 130 | 131 | // NOTICE: Leaf condition at root 132 | if (filters && !filters.aggregator) { 133 | if (this.operatorDateParser.hasPreviousDateInterval(filters.operator)) { 134 | return filters; 135 | } 136 | return null; 137 | } 138 | 139 | // NOTICE: No previous interval condition when 'or' aggregator 140 | if (filters.aggregator === 'and') { 141 | for (let i = 0; i < filters.conditions.length; i += 1) { 142 | const condition = filters.conditions[i]; 143 | 144 | // NOTICE: Nested filters 145 | if (condition.aggregator) { 146 | return null; 147 | } 148 | 149 | if (this.operatorDateParser.hasPreviousDateInterval(condition.operator)) { 150 | // NOTICE: There can't be two previousInterval. 151 | if (currentPreviousInterval) { 152 | return null; 153 | } 154 | currentPreviousInterval = condition; 155 | } 156 | } 157 | } 158 | 159 | return currentPreviousInterval; 160 | }; 161 | 162 | this.getAssociations = async (filtersString) => BaseFiltersParser.getAssociations(filtersString); 163 | } 164 | 165 | module.exports = FiltersParser; 166 | -------------------------------------------------------------------------------- /src/services/has-many-associator.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const orm = require('../utils/orm'); 3 | 4 | function HasManyAssociator(model, association, opts, params, data) { 5 | this.perform = function perform() { 6 | return orm.findRecord(model, params.recordId) 7 | .then((record) => { 8 | const associatedIds = _.map(data.data, (value) => value.id); 9 | 10 | // NOTICE: Deactivate validation to prevent potential issues with custom model validations. 11 | // In this case, the full record attributes are missing which may raise an 12 | // unexpected validation error. 13 | return record[`add${_.upperFirst(params.associationName)}`](associatedIds, { validate: false }); 14 | }); 15 | }; 16 | } 17 | 18 | module.exports = HasManyAssociator; 19 | -------------------------------------------------------------------------------- /src/services/has-many-dissociator.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const Operators = require('../utils/operators'); 3 | const orm = require('../utils/orm'); 4 | const { ErrorHTTP422 } = require('./errors'); 5 | 6 | function HasManyDissociator(model, association, options, params, data) { 7 | const OPERATORS = Operators.getInstance(options); 8 | const isDelete = Boolean(params.delete); 9 | 10 | this.perform = () => { 11 | const associatedIds = _.map(data.data, (value) => value.id); 12 | return orm.findRecord(model, params.recordId) 13 | .then((record) => { 14 | let removeAssociation = false; 15 | 16 | if (isDelete) { 17 | _.each(model.associations, (innerAssociation, associationName) => { 18 | if (associationName === params.associationName) { 19 | removeAssociation = (innerAssociation.associationType === 'belongsToMany'); 20 | } 21 | }); 22 | } else { 23 | removeAssociation = true; 24 | } 25 | 26 | if (removeAssociation) { 27 | return record[`remove${_.upperFirst(params.associationName)}`](associatedIds); 28 | } 29 | return null; 30 | }) 31 | .then(() => { 32 | if (isDelete) { 33 | const primaryKeys = _.keys(association.primaryKeys); 34 | const [idField] = primaryKeys; 35 | const condition = { 36 | [idField]: { 37 | [OPERATORS.IN]: associatedIds, 38 | }, 39 | }; 40 | 41 | return association.destroy({ where: condition }); 42 | } 43 | 44 | return null; 45 | }) 46 | .catch((error) => { 47 | throw new ErrorHTTP422(error.message); 48 | }); 49 | }; 50 | } 51 | 52 | module.exports = HasManyDissociator; 53 | -------------------------------------------------------------------------------- /src/services/has-many-getter.js: -------------------------------------------------------------------------------- 1 | import { pick } from 'lodash'; 2 | import SequelizeCompatibility from '../utils/sequelize-compatibility'; 3 | import PrimaryKeysManager from './primary-keys-manager'; 4 | import ResourcesGetter from './resources-getter'; 5 | 6 | class HasManyGetter extends ResourcesGetter { 7 | constructor(model, association, lianaOptions, params, user) { 8 | super(association, lianaOptions, params, user); 9 | 10 | this._parentModel = model.unscoped(); 11 | } 12 | 13 | async _getRecords() { 14 | const options = await this._buildQueryOptions(); 15 | const parentRecord = await this._parentModel.findOne(options); 16 | const records = parentRecord?.[this._params.associationName] ?? []; 17 | 18 | new PrimaryKeysManager(this._model).annotateRecords(records); 19 | return records; 20 | } 21 | 22 | async count() { 23 | const options = await this._buildQueryOptions({ forCount: true }); 24 | return this._parentModel.count(options); 25 | } 26 | 27 | async _buildQueryOptions(buildOptions = {}) { 28 | const { associationName, recordId } = this._params; 29 | const [model, options] = await super._buildQueryOptions({ 30 | ...buildOptions, tableAlias: associationName, 31 | }); 32 | 33 | const parentOptions = SequelizeCompatibility.postProcess(this._parentModel, { 34 | where: new PrimaryKeysManager(this._parentModel).getRecordsConditions([recordId]), 35 | include: [{ 36 | model, 37 | as: associationName, 38 | scope: false, 39 | required: !!buildOptions.forCount, // Why? 40 | ...pick(options, ['attributes', 'where', 'include']), 41 | }], 42 | }); 43 | 44 | if (!buildOptions.forCount) { 45 | parentOptions.subQuery = false; // Why? 46 | parentOptions.attributes = []; // Don't fetch parent attributes (perf) 47 | parentOptions.offset = options.offset; 48 | parentOptions.limit = options.limit; 49 | 50 | // Order with the relation (https://github.com/sequelize/sequelize/issues/4553) 51 | if (options.order) { 52 | parentOptions.order = options.order.map((fields) => [associationName, ...fields]); 53 | } 54 | } 55 | 56 | return parentOptions; 57 | } 58 | } 59 | 60 | module.exports = HasManyGetter; 61 | -------------------------------------------------------------------------------- /src/services/leaderboard-stat-getter.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import { Schemas, scopeManager } from 'forest-express'; 3 | import Orm from '../utils/orm'; 4 | import SequelizeCompatibility from '../utils/sequelize-compatibility'; 5 | import { InvalidParameterError } from './errors'; 6 | import QueryOptions from './query-options'; 7 | 8 | function getAggregateField({ 9 | aggregateField, parentSchema, parentModel, 10 | }) { 11 | // NOTICE: As MySQL cannot support COUNT(table_name.*) syntax, fieldName cannot be '*'. 12 | const fieldName = aggregateField 13 | || parentSchema.primaryKeys[0] 14 | || parentSchema.fields[0].field; 15 | return `${parentModel.name}.${Orm.getColumnName(parentSchema, fieldName)}`; 16 | } 17 | 18 | async function getSequelizeOptionsForModel(model, user, timezone) { 19 | const queryOptions = new QueryOptions(model); 20 | const scopeFilters = await scopeManager.getScopeForUser(user, model.name, true); 21 | await queryOptions.filterByConditionTree(scopeFilters, timezone); 22 | return queryOptions.sequelizeOptions; 23 | } 24 | 25 | /** 26 | * @param {import('sequelize').Model} childModel 27 | * @param {import('sequelize').Model} parentModel 28 | * @param {{ 29 | * labelFieldName: string; 30 | * aggregator: string; 31 | * aggregateFieldName: string; 32 | * limit: number; 33 | * }} params 34 | */ 35 | function LeaderboardStatGetter(childModel, parentModel, params, user) { 36 | const labelField = params.labelFieldName; 37 | const aggregate = params.aggregator.toUpperCase(); 38 | const { limit } = params; 39 | const childSchema = Schemas.schemas[childModel.name]; 40 | const parentSchema = Schemas.schemas[parentModel.name]; 41 | let associationAs = childSchema.name; 42 | const associationFound = _.find( 43 | parentModel.associations, 44 | (association) => association.target.name === childModel.name, 45 | ); 46 | 47 | const aggregateField = getAggregateField({ 48 | aggregateField: params.aggregateFieldName, 49 | parentSchema, 50 | parentModel, 51 | }); 52 | 53 | if (!associationFound) { 54 | throw new InvalidParameterError(`Association ${childModel.name} not found`); 55 | } 56 | 57 | if (associationFound.as) { 58 | associationAs = associationFound.as; 59 | } 60 | 61 | const labelColumn = Orm.getColumnName(childSchema, labelField); 62 | const groupBy = `${associationAs}.${labelColumn}`; 63 | 64 | this.perform = async () => { 65 | const { timezone } = params; 66 | const parentSequelizeOptions = await getSequelizeOptionsForModel(parentModel, user, timezone); 67 | const childSequelizeOptions = await getSequelizeOptionsForModel(childModel, user, timezone); 68 | 69 | const queryOptions = SequelizeCompatibility.postProcess(parentModel, { 70 | attributes: [ 71 | [childModel.sequelize.col(groupBy), 'key'], 72 | [childModel.sequelize.fn(aggregate, childModel.sequelize.col(aggregateField)), 'value'], 73 | ], 74 | where: parentSequelizeOptions.where, 75 | includeIgnoreAttributes: false, 76 | include: [{ 77 | model: childModel, 78 | attributes: [labelField], 79 | as: associationAs, 80 | required: true, 81 | where: childSequelizeOptions.where, 82 | include: childSequelizeOptions.include || [], 83 | }, ...(parentSequelizeOptions.include || [])], 84 | subQuery: false, 85 | group: groupBy, 86 | order: [[childModel.sequelize.literal('value'), 'DESC']], 87 | limit, 88 | raw: true, 89 | }); 90 | 91 | const records = await parentModel.findAll(queryOptions); 92 | 93 | return { 94 | value: records.map((data) => ({ 95 | key: data.key, 96 | value: Number(data.value), 97 | })), 98 | }; 99 | }; 100 | } 101 | 102 | module.exports = LeaderboardStatGetter; 103 | -------------------------------------------------------------------------------- /src/services/line-stat-getter.js: -------------------------------------------------------------------------------- 1 | import { Schemas, scopeManager } from 'forest-express'; 2 | import _ from 'lodash'; 3 | import moment from 'moment'; 4 | import { isMSSQL, isMySQL, isSQLite } from '../utils/database'; 5 | import Orm from '../utils/orm'; 6 | import QueryOptions from './query-options'; 7 | 8 | function LineStatGetter(model, params, options, user) { 9 | const schema = Schemas.schemas[model.name]; 10 | const timeRange = params.timeRange.toLowerCase(); 11 | 12 | function getAggregateField() { 13 | // NOTICE: As MySQL cannot support COUNT(table_name.*) syntax, fieldName cannot be '*'. 14 | const fieldName = params.aggregateFieldName 15 | || schema.primaryKeys[0] 16 | || schema.fields[0].field; 17 | return `${schema.name}.${Orm.getColumnName(schema, fieldName)}`; 18 | } 19 | 20 | function getGroupByDateField() { 21 | return `${schema.name}.${Orm.getColumnName(schema, params.groupByFieldName)}`; 22 | } 23 | 24 | const groupByDateField = getGroupByDateField(); 25 | 26 | function getGroupByDateFieldFormatedForMySQL(currentTimeRange) { 27 | const groupByDateFieldFormated = `\`${groupByDateField.replace('.', '`.`')}\``; 28 | switch (currentTimeRange) { 29 | case 'day': 30 | return options.Sequelize.fn( 31 | 'DATE_FORMAT', 32 | options.Sequelize.col(groupByDateField), 33 | '%Y-%m-%d 00:00:00', 34 | ); 35 | case 'week': 36 | return options.Sequelize 37 | .literal(`DATE_FORMAT(DATE_SUB(${groupByDateFieldFormated}, \ 38 | INTERVAL ((7 + WEEKDAY(${groupByDateFieldFormated})) % 7) DAY), '%Y-%m-%d 00:00:00')`); 39 | case 'month': 40 | return options.Sequelize.fn( 41 | 'DATE_FORMAT', 42 | options.Sequelize.col(groupByDateField), 43 | '%Y-%m-01 00:00:00', 44 | ); 45 | case 'year': 46 | return options.Sequelize.fn( 47 | 'DATE_FORMAT', 48 | options.Sequelize.col(groupByDateField), 49 | '%Y-01-01 00:00:00', 50 | ); 51 | default: 52 | return null; 53 | } 54 | } 55 | 56 | function getGroupByDateFieldFormatedForMSSQL(currentTimeRange) { 57 | const groupByDateFieldFormated = `[${groupByDateField.replace('.', '].[')}]`; 58 | switch (currentTimeRange) { 59 | case 'day': 60 | return options.Sequelize.fn( 61 | 'FORMAT', 62 | options.Sequelize.col(groupByDateField), 63 | 'yyyy-MM-dd 00:00:00', 64 | ); 65 | case 'week': 66 | return options.Sequelize 67 | .literal(`FORMAT(DATEADD(DAY, -DATEPART(dw,${groupByDateFieldFormated}),\ 68 | ${groupByDateFieldFormated}), 'yyyy-MM-dd 00:00:00')`); 69 | case 'month': 70 | return options.Sequelize.fn( 71 | 'FORMAT', 72 | options.Sequelize.col(groupByDateField), 73 | 'yyyy-MM-01 00:00:00', 74 | ); 75 | case 'year': 76 | return options.Sequelize.fn( 77 | 'FORMAT', 78 | options.Sequelize.col(groupByDateField), 79 | 'yyyy-01-01 00:00:00', 80 | ); 81 | default: 82 | return null; 83 | } 84 | } 85 | 86 | function getGroupByDateFieldFormatedForSQLite(currentTimeRange) { 87 | switch (currentTimeRange) { 88 | case 'day': { 89 | return options.Sequelize.fn( 90 | 'STRFTIME', 91 | '%Y-%m-%d', 92 | options.Sequelize.col(groupByDateField), 93 | ); 94 | } 95 | case 'week': { 96 | return options.Sequelize.fn( 97 | 'STRFTIME', 98 | '%Y-%W', 99 | options.Sequelize.col(groupByDateField), 100 | ); 101 | } 102 | case 'month': { 103 | return options.Sequelize.fn( 104 | 'STRFTIME', 105 | '%Y-%m-01', 106 | options.Sequelize.col(groupByDateField), 107 | ); 108 | } 109 | case 'year': { 110 | return options.Sequelize.fn( 111 | 'STRFTIME', 112 | '%Y-01-01', 113 | options.Sequelize.col(groupByDateField), 114 | ); 115 | } 116 | default: 117 | return null; 118 | } 119 | } 120 | 121 | function getGroupByDateInterval() { 122 | if (isMySQL(model.sequelize)) { 123 | return [getGroupByDateFieldFormatedForMySQL(timeRange), 'date']; 124 | } 125 | if (isMSSQL(model.sequelize)) { 126 | return [getGroupByDateFieldFormatedForMSSQL(timeRange), 'date']; 127 | } 128 | if (isSQLite(model.sequelize)) { 129 | return [getGroupByDateFieldFormatedForSQLite(timeRange), 'date']; 130 | } 131 | return [ 132 | options.Sequelize.fn( 133 | 'to_char', 134 | options.Sequelize.fn( 135 | 'date_trunc', 136 | params.timeRange, 137 | options.Sequelize.literal(`"${getGroupByDateField().replace('.', '"."')}" at time zone '${params.timezone}'`), 138 | ), 139 | 'YYYY-MM-DD 00:00:00', 140 | ), 141 | 'date', 142 | ]; 143 | } 144 | 145 | function getFormat() { 146 | switch (timeRange) { 147 | case 'day': return 'DD/MM/YYYY'; 148 | case 'week': return '[W]W-GGGG'; 149 | case 'month': return 'MMM YY'; 150 | case 'year': return 'YYYY'; 151 | default: return null; 152 | } 153 | } 154 | 155 | function fillEmptyDateInterval(records) { 156 | if (records.length) { 157 | let sqlFormat = 'YYYY-MM-DD 00:00:00'; 158 | if (isSQLite(model.sequelize) && timeRange === 'week') { 159 | sqlFormat = 'YYYY-WW'; 160 | } 161 | 162 | const firstDate = moment(records[0].label, sqlFormat); 163 | const lastDate = moment(records[records.length - 1].label, sqlFormat); 164 | 165 | for (let i = firstDate; i.toDate() <= lastDate.toDate(); i = i.add(1, timeRange)) { 166 | const label = i.format(sqlFormat); 167 | if (!_.find(records, { label })) { 168 | records.push({ label, values: { value: 0 } }); 169 | } 170 | } 171 | 172 | records = _.sortBy(records, 'label'); 173 | return _.map(records, (record) => ({ 174 | label: moment(record.label, sqlFormat).format(getFormat()), 175 | values: record.values, 176 | })); 177 | } 178 | return records; 179 | } 180 | 181 | function getAggregate() { 182 | return [ 183 | options.Sequelize.fn( 184 | params.aggregator.toLowerCase(), 185 | options.Sequelize.col(getAggregateField()), 186 | ), 187 | 'value', 188 | ]; 189 | } 190 | 191 | function getGroupBy() { 192 | return isMSSQL(model.sequelize) ? [getGroupByDateFieldFormatedForMSSQL(timeRange)] : [options.Sequelize.literal('1')]; 193 | } 194 | 195 | function getOrder() { 196 | return isMSSQL(model.sequelize) ? [getGroupByDateFieldFormatedForMSSQL(timeRange)] : [options.Sequelize.literal('1')]; 197 | } 198 | 199 | this.perform = async () => { 200 | const { filter, timezone } = params; 201 | const scopeFilters = await scopeManager.getScopeForUser(user, model.name, true); 202 | 203 | const queryOptions = new QueryOptions(model, { includeRelations: true }); 204 | await queryOptions.filterByConditionTree(filter, timezone); 205 | await queryOptions.filterByConditionTree(scopeFilters, timezone); 206 | 207 | const sequelizeOptions = { 208 | ...queryOptions.sequelizeOptions, 209 | attributes: [getGroupByDateInterval(), getAggregate()], 210 | group: getGroupBy(), 211 | order: getOrder(), 212 | raw: true, 213 | }; 214 | 215 | // do not load related properties 216 | if (sequelizeOptions.include) { 217 | sequelizeOptions.include = sequelizeOptions.include.map( 218 | (includeProperties) => ({ ...includeProperties, attributes: [] }), 219 | ); 220 | } 221 | 222 | const records = await model.unscoped().findAll(sequelizeOptions); 223 | 224 | return { 225 | value: fillEmptyDateInterval( 226 | records.map((record) => ({ 227 | label: record.date, 228 | values: { value: parseInt(record.value, 10) }, 229 | })), 230 | ), 231 | }; 232 | }; 233 | } 234 | 235 | module.exports = LineStatGetter; 236 | -------------------------------------------------------------------------------- /src/services/pie-stat-getter.js: -------------------------------------------------------------------------------- 1 | import { Schemas, scopeManager } from 'forest-express'; 2 | import _ from 'lodash'; 3 | import moment from 'moment'; 4 | import { isMSSQL } from '../utils/database'; 5 | import Orm, { isVersionLessThan } from '../utils/orm'; 6 | import QueryOptions from './query-options'; 7 | 8 | // NOTICE: These aliases are not camelcased to prevent issues with Sequelize. 9 | const ALIAS_GROUP_BY = 'forest_alias_groupby'; 10 | const ALIAS_AGGREGATE = 'forest_alias_aggregate'; 11 | 12 | function PieStatGetter(model, params, options, user) { 13 | const needsDateOnlyFormating = isVersionLessThan(options.Sequelize, '4.0.0'); 14 | 15 | const schema = Schemas.schemas[model.name]; 16 | let associationSplit; 17 | let associationCollection; 18 | let associationField; 19 | let associationSchema; 20 | let field; 21 | 22 | if (params.groupByFieldName.indexOf(':') === -1) { 23 | field = _.find(schema.fields, (currentField) => currentField.field === params.groupByFieldName); 24 | } else { 25 | associationSplit = params.groupByFieldName.split(':'); 26 | associationCollection = model.associations[associationSplit[0]].target.name; 27 | [, associationField] = associationSplit; 28 | associationSchema = Schemas.schemas[associationCollection]; 29 | field = _.find( 30 | associationSchema.fields, 31 | (currentField) => currentField.field === associationField, 32 | ); 33 | } 34 | 35 | function getGroupByField() { 36 | if (params.groupByFieldName.includes(':')) { 37 | const [associationName, fieldName] = params.groupByFieldName.split(':'); 38 | return `${associationName}.${Orm.getColumnName(associationSchema, fieldName)}`; 39 | } 40 | return `${schema.name}.${Orm.getColumnName(schema, params.groupByFieldName)}`; 41 | } 42 | 43 | const groupByField = getGroupByField(); 44 | 45 | function getAggregate() { 46 | return params.aggregator.toLowerCase(); 47 | } 48 | 49 | function getAggregateField() { 50 | // NOTICE: As MySQL cannot support COUNT(table_name.*) syntax, fieldName cannot be '*'. 51 | const fieldName = params.aggregateFieldName 52 | || schema.primaryKeys[0] 53 | || schema.fields[0].field; 54 | return `${schema.name}.${Orm.getColumnName(schema, fieldName)}`; 55 | } 56 | 57 | function getGroupBy() { 58 | return isMSSQL(model.sequelize) ? [options.Sequelize.col(groupByField)] : [ALIAS_GROUP_BY]; 59 | } 60 | 61 | function formatResults(records) { 62 | return records.map((record) => { 63 | let key; 64 | 65 | if (field.type === 'Date') { 66 | key = moment(record[ALIAS_GROUP_BY]).format('DD/MM/YYYY HH:mm:ss'); 67 | } else if (field.type === 'Dateonly' && needsDateOnlyFormating) { 68 | const offsetServer = moment().utcOffset() / 60; 69 | const dateonly = moment.utc(record[ALIAS_GROUP_BY]) 70 | .add(offsetServer, 'h'); 71 | key = dateonly.format('DD/MM/YYYY'); 72 | } else { 73 | key = String(record[ALIAS_GROUP_BY]); 74 | } 75 | 76 | return { 77 | key, 78 | value: record[ALIAS_AGGREGATE], 79 | }; 80 | }); 81 | } 82 | 83 | this.perform = async () => { 84 | const { filter, timezone } = params; 85 | const scopeFilters = await scopeManager.getScopeForUser(user, model.name, true); 86 | 87 | const queryOptions = new QueryOptions(model, { includeRelations: true }); 88 | await queryOptions.filterByConditionTree(filter, timezone); 89 | await queryOptions.filterByConditionTree(scopeFilters, timezone); 90 | 91 | const sequelizeOptions = { 92 | ...queryOptions.sequelizeOptions, 93 | attributes: [ 94 | [options.Sequelize.col(groupByField), ALIAS_GROUP_BY], 95 | [ 96 | options.Sequelize.fn(getAggregate(), options.Sequelize.col(getAggregateField())), 97 | ALIAS_AGGREGATE, 98 | ], 99 | ], 100 | group: getGroupBy(), 101 | order: [[options.Sequelize.literal(ALIAS_AGGREGATE), 'DESC']], 102 | raw: true, 103 | }; 104 | 105 | if (sequelizeOptions.include) { 106 | sequelizeOptions.include = sequelizeOptions.include.map( 107 | (includeProperties) => ({ ...includeProperties, attributes: [] }), 108 | ); 109 | } 110 | 111 | const records = await model.unscoped().findAll(sequelizeOptions); 112 | 113 | return { value: formatResults(records) }; 114 | }; 115 | } 116 | 117 | module.exports = PieStatGetter; 118 | -------------------------------------------------------------------------------- /src/services/primary-keys-manager.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import Operators from '../utils/operators'; 3 | 4 | /** 5 | * This helper class allows abstracting away the complexity 6 | * of using collection which have composite primary keys. 7 | */ 8 | class PrimaryKeysManager { 9 | static _GLUE = '|' 10 | 11 | constructor(model) { 12 | this._primaryKeys = _.keys(model.primaryKeys); 13 | this._Sequelize = model.sequelize.constructor; 14 | } 15 | 16 | /** Build sequelize where condition from a list of packed recordIds */ 17 | getRecordsConditions(recordIds) { 18 | if (recordIds.length === 0) { 19 | return this._Sequelize.literal('(0=1)'); 20 | } 21 | 22 | switch (this._primaryKeys.length) { 23 | case 0: 24 | throw new Error('No primary key was found'); 25 | 26 | case 1: 27 | return this._getRecordsConditionsSimple(recordIds); 28 | 29 | default: 30 | return this._getRecordsConditionComposite(recordIds); 31 | } 32 | } 33 | 34 | /* Annotate records with their packed primary key */ 35 | annotateRecords(records) { 36 | if (this._primaryKeys.length > 1) { 37 | records.forEach((record) => { 38 | record.forestCompositePrimary = this._createCompositePrimary(record); 39 | }); 40 | } 41 | } 42 | 43 | _getRecordsConditionsSimple(recordIds) { 44 | return { [this._primaryKeys[0]]: recordIds.length === 1 ? recordIds[0] : recordIds }; 45 | } 46 | 47 | _getRecordsConditionComposite(recordIds) { 48 | const Ops = Operators.getInstance({ Sequelize: this._Sequelize }); 49 | 50 | return recordIds.length === 1 51 | ? this._getRecordConditions(recordIds[0]) 52 | : { [Ops.OR]: recordIds.map((id) => this._getRecordConditions(id)) }; 53 | } 54 | 55 | /** Build sequelize where condition from a single packed recordId */ 56 | _getRecordConditions(recordId) { 57 | return _.zipObject(this._primaryKeys, this._getPrimaryKeyValues(recordId)); 58 | } 59 | 60 | /** Create packed recordId from record */ 61 | _createCompositePrimary(record) { 62 | return this._primaryKeys.map( 63 | (field) => (record[field] === null ? 'null' : record[field]), 64 | ).join(PrimaryKeysManager._GLUE); 65 | } 66 | 67 | /** Unpack recordId into an array */ 68 | _getPrimaryKeyValues(recordId) { 69 | // Prevent liana to crash when a composite primary keys is null, 70 | // this behaviour should be avoid instead of fixed. 71 | const unpacked = recordId 72 | .split(PrimaryKeysManager._GLUE) 73 | .map((key) => (key === 'null' ? null : key)); 74 | 75 | if (unpacked.length !== this._primaryKeys.length) { 76 | throw new Error('Invalid packed primary key'); 77 | } 78 | 79 | return unpacked; 80 | } 81 | } 82 | 83 | module.exports = PrimaryKeysManager; 84 | -------------------------------------------------------------------------------- /src/services/query-builder.js: -------------------------------------------------------------------------------- 1 | const HAS_ONE = 'HasOne'; 2 | const BELONGS_TO = 'BelongsTo'; 3 | 4 | /** 5 | * @param {string[]} values 6 | * @returns {string[]} 7 | */ 8 | function uniqueValues(values) { 9 | return Array.from(new Set(values)); 10 | } 11 | 12 | /** 13 | * @param {string} key 14 | * @param {import('sequelize').Association} association 15 | * @returns {string} 16 | */ 17 | function getTargetFieldName(key, association) { 18 | // Defensive programming 19 | if (key && association.target.tableAttributes[key]) { 20 | return association.target.tableAttributes[key].fieldName; 21 | } 22 | 23 | return undefined; 24 | } 25 | 26 | /** 27 | * @param {import('sequelize').HasOne|import('sequelize').BelongsTo} association 28 | * @returns {string[]} 29 | */ 30 | function getMandatoryFields(association) { 31 | return association.target.primaryKeyAttributes 32 | .map((attribute) => getTargetFieldName(attribute, association)); 33 | } 34 | 35 | /** 36 | * Compute "includes" parameter which is expected by sequelize from a list of fields. 37 | * The list of fields can contain fields from relations in the form 'author.firstname' 38 | * 39 | * @param {string[]} fieldNames model and relationship field names 40 | */ 41 | function QueryBuilder() { 42 | this.getIncludes = (modelForIncludes, fieldNamesRequested) => { 43 | const includes = []; 44 | 45 | Object.values(modelForIncludes.associations) 46 | .filter((association) => [HAS_ONE, BELONGS_TO].includes(association.associationType)) 47 | .forEach((association) => { 48 | const targetFields = Object.values(association.target.tableAttributes) 49 | .map((attribute) => attribute.fieldName); 50 | 51 | const explicitAttributes = (fieldNamesRequested || []) 52 | .filter((name) => name.startsWith(`${association.as}.`)) 53 | .map((name) => name.replace(`${association.as}.`, '')) 54 | .filter((fieldName) => targetFields.includes(fieldName)); 55 | 56 | if (fieldNamesRequested?.includes(association.as) 57 | || explicitAttributes.length) { 58 | // NOTICE: For performance reasons, we only request the keys 59 | // as they're the only needed fields for the interface 60 | const uniqueExplicitAttributes = uniqueValues([ 61 | ...getMandatoryFields(association), 62 | ...explicitAttributes, 63 | ].filter(Boolean)); 64 | 65 | const attributes = explicitAttributes.length 66 | ? uniqueExplicitAttributes 67 | : undefined; 68 | 69 | includes.push({ 70 | model: association.target.unscoped(), 71 | as: association.associationAccessor, 72 | attributes, 73 | }); 74 | } 75 | }); 76 | 77 | return includes; 78 | }; 79 | } 80 | 81 | module.exports = QueryBuilder; 82 | -------------------------------------------------------------------------------- /src/services/query-stat-getter.js: -------------------------------------------------------------------------------- 1 | 2 | function QueryStatGetter(params, opts) { 3 | this.perform = function perform() { 4 | let rawQuery = params.query.trim(); 5 | const bind = params.contextVariables || {}; 6 | 7 | if (bind.recordId && !rawQuery.includes('$recordId')) { 8 | rawQuery = rawQuery.replace(/\?/g, '$recordId'); 9 | } 10 | 11 | // WARNING: Choosing the first connection might generate issues if the model 12 | // does not belongs to this database. 13 | return Object.values(opts.connections)[0].query(rawQuery, { 14 | type: opts.Sequelize.QueryTypes.SELECT, 15 | bind, 16 | }); 17 | }; 18 | } 19 | 20 | module.exports = QueryStatGetter; 21 | -------------------------------------------------------------------------------- /src/services/requested-fields-extractor.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {{ 3 | * fields: Array<{ 4 | * field: string; 5 | * isVirtual: boolean; 6 | * }> 7 | * }} Schema 8 | */ 9 | 10 | /** 11 | * @param {string} associationName 12 | * @param {string[]} requestedFields 13 | * @param {Schema} schema 14 | * @returns {string[]} 15 | */ 16 | function extractRequestedFieldsForAssociation(associationName, requestedFields, schema) { 17 | const referencesSmartFields = requestedFields.some( 18 | (fieldName) => schema.fields.some((field) => field.field === fieldName && field.isVirtual), 19 | ); 20 | 21 | // TODO: have a way of specifying which actual field is used for the computation of a smart field 22 | // and use that info to only retrieve fields that are needed 23 | if (referencesSmartFields) { 24 | return [`${associationName}`]; 25 | } 26 | 27 | return requestedFields.map((fieldName) => `${associationName}.${fieldName}`); 28 | } 29 | 30 | function extractRequestedSmartField(requestedFields, schema) { 31 | const schemaSmartFields = schema.fields.filter(({ isVirtual }) => isVirtual); 32 | return requestedFields[schema.name].split(',') 33 | .filter((fieldName) => schemaSmartFields.some(({ field }) => field === fieldName)); 34 | } 35 | 36 | /** 37 | * @param {Record} requestedFields 38 | * @param {*} modelOrAssociation 39 | * @param {{ 40 | * [collection: string]: Schema 41 | * }} schemas 42 | * @returns {string[]} 43 | */ 44 | function extractRequestedFields(requestedFields, modelOrAssociation, schemas) { 45 | if (!requestedFields || !requestedFields[modelOrAssociation.name]) { return null; } 46 | 47 | // NOTICE: Force the primaryKey retrieval to store the records properly in 48 | // the client. 49 | const primaryKeyArray = [Object.keys(modelOrAssociation.primaryKeys)[0]]; 50 | 51 | const allAssociationFields = Object.keys(modelOrAssociation.associations) 52 | // NOTICE: Remove fields for which attributes are not explicitly set 53 | // in the requested fields 54 | .filter((associationName) => requestedFields[associationName]) 55 | .map((associationName) => extractRequestedFieldsForAssociation( 56 | associationName, 57 | requestedFields && requestedFields[associationName] 58 | ? requestedFields[associationName].split(',') 59 | : [], 60 | schemas[modelOrAssociation.associations[associationName].target.name], 61 | )) 62 | .flat(); 63 | 64 | const modelFields = requestedFields[modelOrAssociation.name] 65 | .split(',') 66 | .filter((fieldName) => 67 | Object.prototype.hasOwnProperty.call(modelOrAssociation.rawAttributes, fieldName)); 68 | 69 | const smartFields = extractRequestedSmartField(requestedFields, schemas[modelOrAssociation.name]); 70 | 71 | return Array.from(new Set([ 72 | ...primaryKeyArray, 73 | ...modelFields, 74 | ...smartFields, 75 | ...allAssociationFields, 76 | ])); 77 | } 78 | 79 | module.exports = extractRequestedFields; 80 | -------------------------------------------------------------------------------- /src/services/resource-creator.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const P = require('bluebird'); 3 | const Interface = require('forest-express'); 4 | const { ErrorHTTP422 } = require('./errors'); 5 | const ResourceGetter = require('./resource-getter'); 6 | const PrimaryKeysManager = require('./primary-keys-manager'); 7 | const associationRecord = require('../utils/association-record'); 8 | const isPrimaryKeyAForeignKey = require('../utils/is-primary-key-a-foreign-key'); 9 | 10 | class ResourceCreator { 11 | constructor(model, params, body, user) { 12 | this.model = model; 13 | this.params = params; 14 | this.body = body; 15 | this.schema = Interface.Schemas.schemas[model.name]; 16 | this.user = user; 17 | } 18 | 19 | async _getTargetKey(name, association) { 20 | const primaryKey = this.body[name]; 21 | 22 | let targetKey = primaryKey; 23 | if (primaryKey && association.targetKey !== association.target.primaryKeyAttribute) { 24 | const record = await associationRecord.get(association.target, primaryKey); 25 | targetKey = record[association.targetKey]; 26 | } 27 | return targetKey; 28 | } 29 | 30 | async _makePromisesBeforeSave(record, [name, association]) { 31 | if (association.associationType === 'BelongsTo') { 32 | const setterName = `set${_.upperFirst(name)}`; 33 | const targetKey = await this._getTargetKey(name, association); 34 | const primaryKeyIsAForeignKey = isPrimaryKeyAForeignKey(association); 35 | if (primaryKeyIsAForeignKey) { 36 | record[association.source.primaryKeyAttribute] = this.body[name]; 37 | } 38 | return record[setterName](targetKey, { save: false }); 39 | } 40 | return null; 41 | } 42 | 43 | _makePromisesAfterSave(record, [name, association]) { 44 | let setterName; 45 | if (association.associationType === 'HasOne') { 46 | setterName = `set${_.upperFirst(name)}`; 47 | } else if (['BelongsToMany', 'HasMany'].includes(association.associationType)) { 48 | setterName = `add${_.upperFirst(name)}`; 49 | } 50 | if (setterName) { 51 | return record[setterName](this.body[name]); 52 | } 53 | return null; 54 | } 55 | 56 | async _handleSave(record, callback) { 57 | const { associations } = this.model; 58 | if (associations) { 59 | await P.all(Object.entries(associations) 60 | .map((entry) => callback.bind(this)(record, entry))); 61 | } 62 | } 63 | 64 | async perform() { 65 | // buildInstance 66 | const recordCreated = this.model.build(this.body); 67 | 68 | // handleAssociationsBeforeSave 69 | await this._handleSave(recordCreated, this._makePromisesBeforeSave); 70 | 71 | const scopeFilters = await Interface.scopeManager.getScopeForUser( 72 | this.user, 73 | this.model.name, 74 | true, 75 | ); 76 | 77 | // saveInstance (validate then save) 78 | try { 79 | await recordCreated.validate(); 80 | } catch (error) { 81 | throw new ErrorHTTP422(error.message); 82 | } 83 | const record = await recordCreated.save(); 84 | 85 | // handleAssociationsAfterSave 86 | // NOTICE: Many to many associations have to be set after the record creation in order to 87 | // have an id. 88 | await this._handleSave(record, this._makePromisesAfterSave); 89 | 90 | // appendCompositePrimary 91 | new PrimaryKeysManager(this.model).annotateRecords([record]); 92 | 93 | try { 94 | return await new ResourceGetter( 95 | this.model, 96 | { ...this.params, recordId: record[this.schema.idField] }, 97 | this.user, 98 | ).perform(); 99 | } catch (error) { 100 | if (error.statusCode === 404 && scopeFilters) { 101 | return record; 102 | } 103 | throw error; 104 | } 105 | } 106 | } 107 | 108 | module.exports = ResourceCreator; 109 | -------------------------------------------------------------------------------- /src/services/resource-getter.js: -------------------------------------------------------------------------------- 1 | import { scopeManager } from 'forest-express'; 2 | import createError from 'http-errors'; 3 | import PrimaryKeysManager from './primary-keys-manager'; 4 | import QueryOptions from './query-options'; 5 | 6 | class ResourceGetter { 7 | constructor(model, params, user) { 8 | this._model = model.unscoped(); 9 | this._params = params; 10 | this._user = user; 11 | } 12 | 13 | async perform() { 14 | const { timezone } = this._params; 15 | const scopeFilters = await scopeManager.getScopeForUser(this._user, this._model.name, true); 16 | 17 | const queryOptions = new QueryOptions(this._model, { includeRelations: true }); 18 | await queryOptions.filterByIds([this._params.recordId]); 19 | await queryOptions.filterByConditionTree(scopeFilters, timezone); 20 | 21 | const record = await this._model.findOne(queryOptions.sequelizeOptions); 22 | if (!record) { 23 | throw createError(404, `The ${this._model.name} #${this._params.recordId} does not exist.`); 24 | } 25 | 26 | new PrimaryKeysManager(this._model).annotateRecords([record]); 27 | return record; 28 | } 29 | } 30 | 31 | module.exports = ResourceGetter; 32 | -------------------------------------------------------------------------------- /src/services/resource-remover.js: -------------------------------------------------------------------------------- 1 | import ResourcesRemover from './resources-remover'; 2 | 3 | /** 4 | * Kept for retro-compatibility with forest-express. 5 | */ 6 | class ResourceRemover extends ResourcesRemover { 7 | constructor(model, params, user) { 8 | super(model, params, [params.recordId], user); 9 | } 10 | } 11 | 12 | module.exports = ResourceRemover; 13 | -------------------------------------------------------------------------------- /src/services/resource-updater.js: -------------------------------------------------------------------------------- 1 | import { scopeManager } from 'forest-express'; 2 | import createError from 'http-errors'; 3 | import { ErrorHTTP422 } from './errors'; 4 | import QueryOptions from './query-options'; 5 | import ResourceGetter from './resource-getter'; 6 | 7 | class ResourceUpdater { 8 | constructor(model, params, newRecord, user) { 9 | this._model = model.unscoped(); 10 | this._params = params; 11 | this._newRecord = newRecord; 12 | this._user = user; 13 | } 14 | 15 | async perform() { 16 | const { timezone } = this._params; 17 | const scopeFilters = await scopeManager.getScopeForUser(this._user, this._model.name, true); 18 | 19 | const queryOptions = new QueryOptions(this._model); 20 | await queryOptions.filterByIds([this._params.recordId]); 21 | await queryOptions.filterByConditionTree(scopeFilters, timezone); 22 | 23 | const record = await this._model.findOne(queryOptions.sequelizeOptions); 24 | if (record) { 25 | Object.assign(record, this._newRecord); 26 | 27 | try { 28 | await record.validate(); 29 | await record.save(); 30 | } catch (error) { 31 | throw new ErrorHTTP422(error.message); 32 | } 33 | } else { 34 | throw createError(404, `The ${this._model.name} #${this._params.recordId} does not exist.`); 35 | } 36 | 37 | try { 38 | return await new ResourceGetter( 39 | this._model, 40 | { ...this._params, recordId: this._params.recordId }, 41 | this._user, 42 | ).perform(); 43 | } catch (error) { 44 | if (error.statusCode === 404 && scopeFilters) { 45 | return record; 46 | } 47 | throw error; 48 | } 49 | } 50 | } 51 | 52 | module.exports = ResourceUpdater; 53 | -------------------------------------------------------------------------------- /src/services/resources-exporter.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const ResourcesGetter = require('./resources-getter'); 3 | const HasManyGetter = require('./has-many-getter'); 4 | 5 | const BATCH_INITIAL_PAGE = 1; 6 | const BATCH_SIZE = 1000; 7 | 8 | function ResourcesExporter(model, options, params, association, user) { 9 | const primaryKeys = _.keys((association || model).primaryKeys); 10 | params.sort = primaryKeys[0] || 'id'; 11 | params.page = { size: BATCH_SIZE }; 12 | 13 | function getter() { 14 | if (association) { 15 | return new HasManyGetter(model, association, options, params, user); 16 | } 17 | return new ResourcesGetter(model, options, params, user); 18 | } 19 | 20 | function retrieveBatch(dataSender, pageNumber) { 21 | params.page.number = pageNumber; 22 | return getter() 23 | .perform() 24 | .then((results) => { 25 | const records = results[0]; 26 | 27 | return dataSender(records) 28 | .then(() => { 29 | if (records.length === BATCH_SIZE) { 30 | return retrieveBatch(dataSender, pageNumber + 1); 31 | } 32 | return null; 33 | }); 34 | }); 35 | } 36 | 37 | this.perform = (dataSender) => retrieveBatch(dataSender, BATCH_INITIAL_PAGE); 38 | } 39 | 40 | module.exports = ResourcesExporter; 41 | -------------------------------------------------------------------------------- /src/services/resources-getter.js: -------------------------------------------------------------------------------- 1 | import { Schemas, scopeManager } from 'forest-express'; 2 | import _ from 'lodash'; 3 | import PrimaryKeysManager from './primary-keys-manager'; 4 | import QueryOptions from './query-options'; 5 | import extractRequestedFields from './requested-fields-extractor'; 6 | 7 | class ResourcesGetter { 8 | constructor(model, lianaOptions, params, user) { 9 | // lianaOptions is kept for compatibility with forest-express-mongoose 10 | this._model = model.unscoped(); 11 | this._params = params; 12 | this._user = user; 13 | } 14 | 15 | async perform() { 16 | return [ 17 | await this._getRecords(), 18 | await this._getFieldsSearched(), 19 | ]; 20 | } 21 | 22 | /** Count records matching current query (wo/ pagination) */ 23 | async count() { 24 | const [model, options] = await this._buildQueryOptions({ forCount: true }); 25 | 26 | // If no primary key is found, use * as a fallback for Sequelize. 27 | return model.count({ 28 | ...options, 29 | col: _.isEmpty(this._model.primaryKeys) ? '*' : undefined, 30 | }); 31 | } 32 | 33 | /** Load records matching current query (w/ pagination) */ 34 | async _getRecords() { 35 | const [model, options] = await this._buildQueryOptions(); 36 | const records = await model.findAll(options); 37 | new PrimaryKeysManager(this._model).annotateRecords(records); 38 | return records; 39 | } 40 | 41 | /** Get list of fields descriptors which are used when searching (for frontend highlighting). */ 42 | async _getFieldsSearched() { 43 | const { fields, search, searchExtended } = this._params; 44 | const requestedFields = extractRequestedFields(fields, this._model, Schemas.schemas); 45 | 46 | const queryOptions = new QueryOptions(this._model); 47 | await queryOptions.requireFields(requestedFields); 48 | return queryOptions.search(search, searchExtended); 49 | } 50 | 51 | /** Compute query options (shared for count and getRecords) */ 52 | async _buildQueryOptions(buildOptions = {}) { 53 | const { forCount, tableAlias } = buildOptions; 54 | const { 55 | fields, filters, 56 | search, searchExtended, segment, segmentQuery, timezone, 57 | } = this._params; 58 | 59 | const scopeFilters = await scopeManager.getScopeForUser(this._user, this._model.name, true); 60 | 61 | const requestedFields = extractRequestedFields(fields, this._model, Schemas.schemas); 62 | const queryOptions = new QueryOptions(this._model, { 63 | tableAlias, 64 | includeRelations: searchExtended, 65 | }); 66 | if (!forCount) await queryOptions.requireFields(requestedFields); 67 | await queryOptions.search(search, searchExtended); 68 | await queryOptions.filterByConditionTree(filters, timezone); 69 | await queryOptions.filterByConditionTree(scopeFilters, timezone); 70 | await queryOptions.segment(segment); 71 | await queryOptions.segmentQuery(segmentQuery); 72 | 73 | if (!forCount) { 74 | const { sort, page } = this._params; 75 | await queryOptions.sort(sort); 76 | await queryOptions.paginate(page?.number, page?.size); 77 | } 78 | 79 | return [ 80 | // add scopes to model 81 | queryOptions.sequelizeScopes.reduce((m, scope) => m.scope(scope), this._model), 82 | queryOptions.sequelizeOptions, 83 | ]; 84 | } 85 | } 86 | 87 | module.exports = ResourcesGetter; 88 | -------------------------------------------------------------------------------- /src/services/resources-remover.js: -------------------------------------------------------------------------------- 1 | import { scopeManager } from 'forest-express'; 2 | import { InvalidParameterError } from './errors'; 3 | import QueryOptions from './query-options'; 4 | 5 | class ResourcesRemover { 6 | constructor(model, params, ids, user) { 7 | this._model = model.unscoped(); 8 | this._params = params; 9 | this._ids = ids; 10 | this._user = user; 11 | } 12 | 13 | async perform() { 14 | if (!Array.isArray(this._ids) || !this._ids.length) { 15 | throw new InvalidParameterError('`ids` must be a non-empty array.'); 16 | } 17 | 18 | const { timezone } = this._params; 19 | const scopeFilters = await scopeManager.getScopeForUser(this._user, this._model.name, true); 20 | 21 | const queryOptions = new QueryOptions(this._model); 22 | await queryOptions.filterByIds(this._ids); 23 | await queryOptions.filterByConditionTree(scopeFilters, timezone); 24 | 25 | return this._model.destroy(queryOptions.sequelizeOptions); 26 | } 27 | } 28 | 29 | module.exports = ResourcesRemover; 30 | -------------------------------------------------------------------------------- /src/services/value-stat-getter.js: -------------------------------------------------------------------------------- 1 | import Promise from 'bluebird'; 2 | import { BaseOperatorDateParser, Schemas, scopeManager } from 'forest-express'; 3 | import _ from 'lodash'; 4 | import Operators from '../utils/operators'; 5 | import Orm from '../utils/orm'; 6 | import FiltersParser from './filters-parser'; 7 | import QueryOptions from './query-options'; 8 | 9 | class ValueStatGetter { 10 | constructor(model, params, options, user) { 11 | this._model = model; 12 | this._params = params; 13 | this._options = options; 14 | this._user = user; 15 | 16 | this._OPERATORS = Operators.getInstance(options); 17 | this._schema = Schemas.schemas[model.name]; 18 | this._operatorDateParser = new BaseOperatorDateParser({ 19 | operators: this._OPERATORS, timezone: params.timezone, 20 | }); 21 | } 22 | 23 | /** Function used to aggregate results (count, sum, ...) */ 24 | get _aggregateFunction() { 25 | return this._params.aggregator.toLowerCase(); 26 | } 27 | 28 | /** Column name we're aggregating on */ 29 | get _aggregateField() { 30 | // NOTICE: As MySQL cannot support COUNT(table_name.*) syntax, fieldName cannot be '*'. 31 | const fieldName = this._params.aggregateFieldName 32 | || this._schema.primaryKeys[0] 33 | || this._schema.fields[0].field; 34 | 35 | return `${this._schema.name}.${Orm.getColumnName(this._schema, fieldName)}`; 36 | } 37 | 38 | async perform() { 39 | const { filter, timezone } = this._params; 40 | const scopeFilters = await scopeManager.getScopeForUser(this._user, this._model.name, true); 41 | 42 | const queryOptions = new QueryOptions(this._model, { includeRelations: true }); 43 | await queryOptions.filterByConditionTree(filter, timezone); 44 | await queryOptions.filterByConditionTree(scopeFilters, timezone); 45 | 46 | // No attributes should be retrieved from relations for the group by to work. 47 | const options = queryOptions.sequelizeOptions; 48 | options.include = options.include 49 | ? options.include.map((includeProperties) => ({ ...includeProperties, attributes: [] })) 50 | : undefined; 51 | 52 | return { 53 | value: await Promise.props({ 54 | countCurrent: this._getCount(options), 55 | countPrevious: this._getCountPrevious(options), 56 | }), 57 | }; 58 | } 59 | 60 | async _getCount(options) { 61 | const count = await this._model 62 | .unscoped() 63 | .aggregate(this._aggregateField, this._aggregateFunction, options); 64 | 65 | // sequelize@4 returns NaN, while sequelize@5+ returns null 66 | return count || 0; 67 | } 68 | 69 | /** 70 | * Fetch the value for the previous period. 71 | * 72 | * FIXME Will not work on edges cases 73 | * - when the 'rawPreviousInterval.field' appears twice 74 | * - when scopes use the same field as the filter 75 | */ 76 | async _getCountPrevious(options) { 77 | const { filter, timezone } = this._params; 78 | if (!filter) { 79 | return undefined; 80 | } 81 | 82 | const conditionsParser = new FiltersParser(this._schema, timezone, this._options); 83 | const rawInterval = conditionsParser.getPreviousIntervalCondition(filter); 84 | if (!rawInterval) { 85 | return undefined; 86 | } 87 | 88 | const interval = this._operatorDateParser.getPreviousDateFilter( 89 | rawInterval.operator, rawInterval.value, 90 | ); 91 | 92 | const newOptions = _.cloneDeepWith(options, (object) => ( 93 | object && object[rawInterval.field] 94 | ? { ...object, [rawInterval.field]: interval } 95 | : undefined 96 | )); 97 | 98 | return this._getCount(newOptions); 99 | } 100 | } 101 | 102 | module.exports = ValueStatGetter; 103 | -------------------------------------------------------------------------------- /src/utils/association-record.js: -------------------------------------------------------------------------------- 1 | import orm from './orm'; 2 | 3 | async function get(model, pk) { 4 | const record = await orm.findRecord(model, pk); 5 | if (!record) { 6 | throw new Error(`related ${model.name} with pk ${pk} does not exist.`); 7 | } 8 | return record; 9 | } 10 | 11 | exports.get = get; 12 | -------------------------------------------------------------------------------- /src/utils/database.js: -------------------------------------------------------------------------------- 1 | function getConnectionDialect(connection) { 2 | return connection.options.dialect; 3 | } 4 | 5 | exports.isMySQL = (connection) => ['mysql', 'mariadb'].includes(getConnectionDialect(connection)); 6 | 7 | exports.isMSSQL = (connection) => getConnectionDialect(connection) === 'mssql'; 8 | 9 | exports.isSQLite = (connection) => getConnectionDialect(connection) === 'sqlite'; 10 | -------------------------------------------------------------------------------- /src/utils/is-primary-key-a-foreign-key.js: -------------------------------------------------------------------------------- 1 | module.exports = (association) => 2 | Object.values(association.source.rawAttributes).filter((attr) => 3 | attr.field === association.source.primaryKeyField).length > 1; 4 | -------------------------------------------------------------------------------- /src/utils/object-tools.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | 3 | /** Do object1 and object2 have at least one common key or Symbol? */ 4 | exports.plainObjectsShareNoKeys = (object1, object2) => { 5 | if (!_.isPlainObject(object1) || !_.isPlainObject(object2)) { 6 | return false; 7 | } 8 | 9 | const keys1 = [...Object.getOwnPropertyNames(object1), ...Object.getOwnPropertySymbols(object1)]; 10 | const keys2 = [...Object.getOwnPropertyNames(object2), ...Object.getOwnPropertySymbols(object2)]; 11 | const commonKeys = keys1.filter((key) => keys2.includes(key)); 12 | 13 | return commonKeys.length === 0; 14 | }; 15 | 16 | /** 17 | * Clone object recursively while rewriting keys with the callback function. 18 | * Symbols are copied without modification (Sequelize.Ops are javascript symbols). 19 | * 20 | * @example 21 | * mapKeysDeep({a: {b: 1}}, key => `_${key}_`); 22 | * => {_a_: {_b_: 1}} 23 | */ 24 | exports.mapKeysDeep = (object, callback) => { 25 | if (Array.isArray(object)) { 26 | return object.map((child) => exports.mapKeysDeep(child, callback)); 27 | } 28 | 29 | if (_.isPlainObject(object)) { 30 | const newObject = {}; 31 | 32 | Object.getOwnPropertyNames(object).forEach((name) => { 33 | newObject[callback(name)] = exports.mapKeysDeep(object[name], callback); 34 | }); 35 | 36 | Object.getOwnPropertySymbols(object).forEach((symbol) => { 37 | newObject[symbol] = exports.mapKeysDeep(object[symbol], callback); 38 | }); 39 | 40 | return newObject; 41 | } 42 | 43 | return object; 44 | }; 45 | -------------------------------------------------------------------------------- /src/utils/operators.js: -------------------------------------------------------------------------------- 1 | class Operators { 2 | static _instance = null; 3 | 4 | static _isNewSequelizeOp(options) { 5 | return !!(options && options.Sequelize && options.Sequelize.Op); 6 | } 7 | 8 | _setupNewSequelizeOp(options) { 9 | const { Op } = options.Sequelize; 10 | this.AND = Op.and; 11 | this.CONTAINS = Op.contains; 12 | this.EQ = Op.eq; 13 | this.GT = Op.gt; 14 | this.GTE = Op.gte; 15 | this.IN = Op.in; 16 | this.LIKE = Op.like; 17 | this.LT = Op.lt; 18 | this.LTE = Op.lte; 19 | this.NE = Op.ne; 20 | this.NOT = Op.not; 21 | this.NOT_LIKE = Op.notLike; 22 | this.OR = Op.or; 23 | } 24 | 25 | _setupOldSequelizeOp() { 26 | this.AND = '$and'; 27 | this.CONTAINS = '$contains'; 28 | this.EQ = '$eq'; 29 | this.GT = '$gt'; 30 | this.GTE = '$gte'; 31 | this.IN = '$in'; 32 | this.LIKE = '$like'; 33 | this.LT = '$lt'; 34 | this.LTE = '$lte'; 35 | this.NE = '$ne'; 36 | this.NOT = '$not'; 37 | this.NOT_LIKE = '$notLike'; 38 | this.OR = '$or'; 39 | } 40 | 41 | constructor(options) { 42 | if (Operators._isNewSequelizeOp(options)) { 43 | this._setupNewSequelizeOp(options); 44 | } else { 45 | this._setupOldSequelizeOp(); 46 | } 47 | 48 | Operators._instance = this; 49 | } 50 | 51 | static getInstance(options) { 52 | return Operators._instance || new Operators(options); 53 | } 54 | } 55 | 56 | module.exports = Operators; 57 | -------------------------------------------------------------------------------- /src/utils/orm.js: -------------------------------------------------------------------------------- 1 | const { SchemaUtils } = require('forest-express'); 2 | 3 | const semver = require('semver'); 4 | 5 | const REGEX_VERSION = /(\d+\.)?(\d+\.)?(\*|\d+)/; 6 | 7 | const getVersion = (sequelize) => { 8 | const version = sequelize.version.match(REGEX_VERSION); 9 | if (version && version[0]) { 10 | return version[0]; 11 | } 12 | return null; 13 | }; 14 | 15 | const isVersionLessThan = (sequelize, target) => { 16 | try { 17 | return semver.lt(getVersion(sequelize), target); 18 | } catch (error) { 19 | return true; 20 | } 21 | }; 22 | 23 | const findRecord = (model, recordId, options) => { 24 | if (model.findByPk) { 25 | return model.findByPk(recordId, options); 26 | } 27 | return model.findById(recordId, options); 28 | }; 29 | 30 | const getColumnName = (schema, fieldName) => { 31 | const schemaField = SchemaUtils.getField(schema, fieldName); 32 | return (schemaField && schemaField.columnName) ? schemaField.columnName : fieldName; 33 | }; 34 | 35 | const isUUID = (DataTypes, fieldType) => 36 | fieldType instanceof DataTypes.UUID 37 | || fieldType instanceof DataTypes.UUIDV1 38 | || fieldType instanceof DataTypes.UUIDV4; 39 | 40 | exports.getVersion = getVersion; 41 | exports.isVersionLessThan = isVersionLessThan; 42 | exports.findRecord = findRecord; 43 | exports.getColumnName = getColumnName; 44 | exports.isUUID = isUUID; 45 | -------------------------------------------------------------------------------- /src/utils/query.js: -------------------------------------------------------------------------------- 1 | import ObjectTools from './object-tools'; 2 | import Orm from './orm'; 3 | 4 | exports.getReferenceSchema = (schemas, modelSchema, associationName) => { 5 | const schemaField = modelSchema.fields.find((field) => field.field === associationName); 6 | 7 | // NOTICE: No reference field found, no name transformation tried. 8 | if (!schemaField || !schemaField.reference) { return null; } 9 | 10 | const [tableName] = schemaField.reference.split('.'); 11 | return schemas[tableName]; 12 | }; 13 | 14 | exports.getReferenceField = (schemas, modelSchema, associationName, fieldName) => { 15 | const associationSchema = exports.getReferenceSchema( 16 | schemas, modelSchema, associationName, fieldName, 17 | ); 18 | 19 | // NOTICE: No association schema found, no name transformation tried. 20 | if (!associationSchema) { return `${associationName}.${fieldName}`; } 21 | 22 | const belongsToColumnName = Orm.getColumnName(associationSchema, fieldName); 23 | return `${associationName}.${belongsToColumnName}`; 24 | }; 25 | 26 | /** 27 | * When they don't have common keys, merge objects together. 28 | * This is used to avoid having too many nested 'AND' conditions on sequelize queries, which 29 | * makes debugging and testing more painful than it could be. 30 | */ 31 | exports.mergeWhere = (operators, ...wheres) => wheres 32 | .filter(Boolean) 33 | .reduce((where1, where2) => (ObjectTools.plainObjectsShareNoKeys(where1, where2) 34 | ? { ...where1, ...where2 } 35 | : { [operators.AND]: [where1, where2] })); 36 | -------------------------------------------------------------------------------- /src/utils/records-decorator.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | 3 | function decorateForSearch(records, fields, searchValue) { 4 | let matchFields = {}; 5 | records.forEach((record, index) => { 6 | fields.forEach((fieldName) => { 7 | const value = record[fieldName]; 8 | if (value) { 9 | const searchEscaped = searchValue.replace(/[-[\]{}()*+!<=:?./\\^$|#\s,]/g, '\\$&'); 10 | const searchHighlight = new RegExp(searchEscaped, 'i'); 11 | const match = value.toString().match(searchHighlight); 12 | if (match) { 13 | if (!matchFields[index]) { 14 | matchFields[index] = { 15 | id: record.id, 16 | search: [], 17 | }; 18 | } 19 | matchFields[index].search.push(fieldName); 20 | } 21 | } 22 | }); 23 | }); 24 | 25 | if (_.isEmpty(matchFields)) { 26 | matchFields = null; 27 | } 28 | 29 | return matchFields; 30 | } 31 | 32 | exports.decorateForSearch = decorateForSearch; 33 | -------------------------------------------------------------------------------- /src/utils/sequelize-compatibility.js: -------------------------------------------------------------------------------- 1 | import ObjectTools from './object-tools'; 2 | import Operators from './operators'; 3 | import QueryUtils from './query'; 4 | 5 | /** 6 | * Extract all where conditions along the include tree, and bubbles them up to the top in-place. 7 | * This allows to work around a sequelize quirk that cause nested 'where' to fail when they 8 | * refer to relation fields from an intermediary include (ie '$book.id$'). 9 | * 10 | * This happens when forest admin filters on relations are used. 11 | * 12 | * @see https://sequelize.org/master/manual/eager-loading.html#complex-where-clauses-at-the-top-level 13 | * @see https://github.com/ForestAdmin/forest-express-sequelize/blob/7d7ad0/src/services/filters-parser.js#L104 14 | */ 15 | function bubbleWheresInPlace(operators, options) { 16 | const parentIncludeList = options.include ?? []; 17 | 18 | parentIncludeList.forEach((include) => { 19 | bubbleWheresInPlace(operators, include); 20 | 21 | if (include.where) { 22 | const newWhere = ObjectTools.mapKeysDeep(include.where, (key) => { 23 | // Targeting a nested field, simply nest it deeper. 24 | if (key[0] === '$' && key[key.length - 1] === '$') { 25 | return `$${include.as}.${key.substring(1)}`; 26 | } 27 | 28 | // Targeting a simple field. 29 | // Try to resolve the column name, as sequelize does not allow using model aliases here. 30 | return `$${include.as}.${include.model?.rawAttributes?.[key]?.field ?? key}$`; 31 | }); 32 | 33 | options.where = QueryUtils.mergeWhere(operators, options.where, newWhere); 34 | delete include.where; 35 | } 36 | }); 37 | } 38 | 39 | /** 40 | * Includes can be expressed in different ways in sequelize, which is inconvenient to 41 | * remove duplicate associations. 42 | * This convert all valid ways to perform eager loading into [{model: X, as: 'x'}]. 43 | * 44 | * This is necessary as we have no control over which way customer use when writing SmartFields 45 | * search handlers. 46 | * 47 | * Among those: 48 | * - { include: [Book] } 49 | * - { include: [{ association: 'book' }] } 50 | * - { include: ['book'] } 51 | * - { include: [[{ as: 'book' }]] } 52 | * - { include: [[{ model: Book }]] } 53 | */ 54 | function normalizeInclude(model, include) { 55 | if (include.sequelize) { 56 | return { 57 | model: include, 58 | as: Object 59 | .keys(model.associations) 60 | .find((association) => model.associations[association].target.name === include.name), 61 | }; 62 | } 63 | 64 | if (typeof include === 'string' && model.associations[include]) { 65 | return { as: include, model: model.associations[include].target }; 66 | } 67 | 68 | if (typeof include === 'object') { 69 | if (typeof include.association === 'string' && model.associations[include.association]) { 70 | include.as = include.association; 71 | delete include.association; 72 | } 73 | 74 | if (typeof include.as === 'string' && !include.model && model.associations[include.as]) { 75 | const includeModel = model.associations[include.as].target; 76 | include.model = includeModel; 77 | } 78 | 79 | if (include.model && !include.as) { 80 | include.as = Object 81 | .keys(model.associations) 82 | .find((association) => model.associations[association].target.name === include.model.name); 83 | } 84 | } 85 | 86 | // Recurse 87 | if (include.include) { 88 | if (Array.isArray(include.include)) { 89 | include.include = include.include.map( 90 | (childInclude) => normalizeInclude(include.model, childInclude), 91 | ); 92 | } else { 93 | include.include = [normalizeInclude(include.model, include.include)]; 94 | } 95 | } 96 | 97 | return include; 98 | } 99 | 100 | /** 101 | * Remove duplications in a queryOption.include array in-place. 102 | * Using multiple times the same association yields invalid SQL when using sequelize <= 4.x 103 | */ 104 | function removeDuplicateAssociations(model, includeList) { 105 | // Remove duplicates 106 | includeList.sort((include1, include2) => (include1.as < include2.as ? -1 : 1)); 107 | for (let i = 1; i < includeList.length; i += 1) { 108 | if (includeList[i - 1].as === includeList[i].as) { 109 | const newInclude = { ...includeList[i - 1], ...includeList[i] }; 110 | 111 | if (includeList[i - 1].attributes && includeList[i].attributes) { 112 | // Keep 'attributes' only when defined on both sides. 113 | newInclude.attributes = [...new Set([ 114 | ...includeList[i - 1].attributes, 115 | ...includeList[i].attributes, 116 | ])].sort(); 117 | } else { 118 | delete newInclude.attributes; 119 | } 120 | 121 | if (includeList[i - 1].include || includeList[i].include) { 122 | newInclude.include = [ 123 | ...(includeList[i - 1].include ?? []), 124 | ...(includeList[i].include ?? []), 125 | ]; 126 | } 127 | 128 | includeList[i - 1] = newInclude; 129 | includeList.splice(i, 1); 130 | i -= 1; 131 | } 132 | } 133 | 134 | // Recurse 135 | includeList.forEach((include) => { 136 | const association = model.associations[include.as]; 137 | if (include.include && association) { 138 | removeDuplicateAssociations(association.target, include.include); 139 | } 140 | }); 141 | } 142 | 143 | exports.postProcess = (model, rawOptions) => { 144 | if (!rawOptions.include) return rawOptions; 145 | 146 | const options = rawOptions; 147 | const operators = Operators.getInstance({ Sequelize: model.sequelize.constructor }); 148 | 149 | options.include = options.include.map((include) => normalizeInclude(model, include)); 150 | bubbleWheresInPlace(operators, options); 151 | removeDuplicateAssociations(model, options.include); 152 | 153 | return options; 154 | }; 155 | -------------------------------------------------------------------------------- /test/adapters/sequelize.test.js: -------------------------------------------------------------------------------- 1 | const Sequelize = require('sequelize'); 2 | const getSchema = require('../../src/adapters/sequelize'); 3 | const { 4 | sequelizePostgres, sequelizeMySQLMin, sequelizeMySQLMax, sequelizeMariaDB, 5 | } = require('../databases'); 6 | 7 | function getField(schema, name) { 8 | return schema.fields.find((field) => field.field === name); 9 | } 10 | 11 | [ 12 | sequelizePostgres, 13 | sequelizeMySQLMin, 14 | sequelizeMySQLMax, 15 | sequelizeMariaDB, 16 | ].forEach((connectionManager) => { 17 | const sequelize = connectionManager.createConnection(); 18 | const models = {}; 19 | const sequelizeOptions = { 20 | Sequelize, 21 | connections: { sequelize }, 22 | }; 23 | 24 | models.user = sequelize.define('user', { 25 | username: { type: Sequelize.STRING }, 26 | role: { type: Sequelize.ENUM(['admin', 'user']) }, 27 | permissions: { 28 | type: Sequelize.ARRAY(Sequelize.ENUM([ 29 | 'documents:write', 30 | 'documents:read', 31 | ])), 32 | }, 33 | }); 34 | 35 | models.customer = sequelize.define('customer', { 36 | name: { type: Sequelize.STRING }, 37 | }); 38 | 39 | models.picture = sequelize.define('picture', { 40 | name: { type: Sequelize.STRING }, 41 | customerId: { 42 | type: Sequelize.INTEGER, 43 | primaryKey: true, 44 | allowNull: false, 45 | }, 46 | }, { 47 | underscored: true, 48 | }); 49 | 50 | models.customer.hasOne(models.picture, { 51 | foreignKey: { 52 | name: 'customerIdKey', 53 | field: 'customer_id', 54 | }, 55 | as: 'picture', 56 | }); 57 | models.picture.belongsTo(models.customer, { 58 | foreignKey: { 59 | name: 'customerIdKey', 60 | field: 'customer_id', 61 | }, 62 | as: 'customer', 63 | }); 64 | 65 | describe(`with dialect ${connectionManager.getDialect()} (port: ${connectionManager.getPort()})`, () => { 66 | describe('with model `users`', () => { 67 | it('should set name correctly', async () => { 68 | expect.assertions(1); 69 | 70 | const schema = await getSchema(models.user, sequelizeOptions); 71 | expect(schema.name).toStrictEqual('user'); 72 | }); 73 | 74 | it('should set idField to id', async () => { 75 | expect.assertions(1); 76 | 77 | const schema = await getSchema(models.user, sequelizeOptions); 78 | expect(schema.idField).toStrictEqual('id'); 79 | }); 80 | 81 | it('should set primaryKeys to [id]', async () => { 82 | expect.assertions(1); 83 | 84 | const schema = await getSchema(models.user, sequelizeOptions); 85 | expect(schema.primaryKeys).toStrictEqual(['id']); 86 | }); 87 | 88 | it('should set isCompositePrimary to false', async () => { 89 | expect.assertions(1); 90 | 91 | const schema = await getSchema(models.user, sequelizeOptions); 92 | expect(schema.isCompositePrimary).toStrictEqual(false); 93 | }); 94 | 95 | describe('when setting fields values', () => { 96 | it('should generate id field', async () => { 97 | expect.assertions(1); 98 | 99 | const schema = await getSchema(models.user, sequelizeOptions); 100 | expect(getField(schema, 'id')).toStrictEqual({ 101 | field: 'id', 102 | type: 'Number', 103 | columnName: 'id', 104 | isPrimaryKey: true, 105 | }); 106 | }); 107 | 108 | it('should set username field', async () => { 109 | expect.assertions(1); 110 | 111 | const schema = await getSchema(models.user, sequelizeOptions); 112 | expect(getField(schema, 'username')).toStrictEqual({ 113 | field: 'username', 114 | type: 'String', 115 | columnName: 'username', 116 | }); 117 | }); 118 | 119 | it('should handle enum (role field)', async () => { 120 | expect.assertions(1); 121 | 122 | const schema = await getSchema(models.user, sequelizeOptions); 123 | expect(getField(schema, 'role')).toStrictEqual({ 124 | field: 'role', 125 | type: 'Enum', 126 | columnName: 'role', 127 | enums: ['admin', 'user'], 128 | }); 129 | }); 130 | 131 | it('should handle array of enum (role field)', async () => { 132 | expect.assertions(1); 133 | 134 | const schema = await getSchema(models.user, sequelizeOptions); 135 | expect(getField(schema, 'permissions')).toStrictEqual({ 136 | field: 'permissions', 137 | type: ['Enum'], 138 | columnName: 'permissions', 139 | enums: ['documents:write', 'documents:read'], 140 | }); 141 | }); 142 | 143 | it('should generate timestamps', async () => { 144 | expect.assertions(2); 145 | 146 | const schema = await getSchema(models.user, sequelizeOptions); 147 | expect(getField(schema, 'createdAt')).toStrictEqual({ 148 | field: 'createdAt', 149 | type: 'Date', 150 | columnName: 'createdAt', 151 | }); 152 | expect(getField(schema, 'updatedAt')).toStrictEqual({ 153 | field: 'updatedAt', 154 | type: 'Date', 155 | columnName: 'updatedAt', 156 | }); 157 | }); 158 | }); 159 | }); 160 | 161 | describe('with association', () => { 162 | it('should set foreignAndPrimaryKey to true', async () => { 163 | expect.assertions(1); 164 | 165 | const schema = await getSchema(models.picture, sequelizeOptions); 166 | expect(schema.fields.find((x) => x.field === 'customer').foreignAndPrimaryKey).toBeTrue(); 167 | }); 168 | }); 169 | }); 170 | }); 171 | -------------------------------------------------------------------------------- /test/databases.js: -------------------------------------------------------------------------------- 1 | const Sequelize = require('sequelize'); 2 | 3 | /** @typedef {ConnectionManager} ConnectionManager */ 4 | class ConnectionManager { 5 | constructor(dialect, connectionString) { 6 | this.dialect = dialect; 7 | this.connectionString = connectionString; 8 | this.databaseOptions = { 9 | logging: false, 10 | pool: { maxConnections: 10, minConnections: 1 }, 11 | }; 12 | this.connection = null; 13 | } 14 | 15 | getDialect() { 16 | return this.dialect; 17 | } 18 | 19 | getPort() { 20 | return /:(\d+)\//g.exec(this.connectionString)[1]; 21 | } 22 | 23 | /** 24 | * @returns {import('sequelize').Sequelize} 25 | */ 26 | createConnection() { 27 | if (!this.connection) { 28 | this.connection = new Sequelize(this.connectionString, this.databaseOptions); 29 | } 30 | return this.connection; 31 | } 32 | 33 | closeConnection() { 34 | if (this.connection) { 35 | this.connection.close(); 36 | this.connection = null; 37 | } 38 | } 39 | } 40 | 41 | /** 42 | * @type {Record} 43 | */ 44 | module.exports = { 45 | sequelizePostgres: new ConnectionManager('Postgresql 9.4', 'postgres://forest:secret@localhost:5437/forest-express-sequelize-test'), 46 | sequelizeMySQLMin: new ConnectionManager('MySQL 5.6', 'mysql://forest:secret@localhost:8998/forest-express-sequelize-test'), 47 | sequelizeMySQLMax: new ConnectionManager('MySQL 8.0', 'mysql://forest:secret@localhost:8999/forest-express-sequelize-test'), 48 | sequelizeMariaDB: new ConnectionManager('MariaDB 10', 'mysql://forest:secret@localhost:9000/forest-express-sequelize-test'), 49 | }; 50 | -------------------------------------------------------------------------------- /test/fixtures/db.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "model": "counter", 4 | "data": { 5 | "id": 10, 6 | "clicks": 9013084467599484828 7 | } 8 | }, 9 | { 10 | "model": "user", 11 | "data": { 12 | "primaryId": 100, 13 | "email": "richard@piedpiper.com", 14 | "firstName": "Richard", 15 | "lastName": "Hendricks", 16 | "username": "RichaHendr", 17 | "password": "2gh2p9g832hp", 18 | "uuid": "1a11dc05-4e04-4d8f-958b-0a9f23a141a3", 19 | "age": 10 20 | } 21 | }, 22 | { 23 | "model": "bike", 24 | "data": { 25 | "id": "1a11dc05-4e04-4d8f-958b-0a9f23a141a3", 26 | "name": "Triumph Street Triple" 27 | } 28 | }, 29 | { 30 | "model": "address", 31 | "data": { 32 | "id": 100, 33 | "city": "SF", 34 | "country": "USA", 35 | "userId": 100, 36 | "archivedAt": "2020-07-02 09:42:08.893+00" 37 | } 38 | }, 39 | { 40 | "model": "address", 41 | "data": { 42 | "id": 101, 43 | "city": "Paris", 44 | "country": "France", 45 | "userId": 100, 46 | "archivedAt": "2020-07-02 09:42:08.893+00" 47 | } 48 | }, 49 | { 50 | "model": "address", 51 | "data": { 52 | "id": 102, 53 | "city": "Hong Kong", 54 | "userId": 100, 55 | "archivedAt": "2020-07-02 09:42:08.893+00" 56 | } 57 | }, 58 | { 59 | "model": "address", 60 | "data": { 61 | "id": 103, 62 | "city": "London", 63 | "country": "United Kingdom", 64 | "userId": 100 65 | } 66 | }, 67 | { 68 | "model": "address", 69 | "data": { 70 | "id": 104, 71 | "city": "London", 72 | "country": "" 73 | } 74 | }, 75 | { 76 | "model": "user", 77 | "data": { 78 | "primaryId": 102, 79 | "email": "elrich@piedpiper.com", 80 | "firstName": "Elrich", 81 | "lastName": "Bachman", 82 | "username": "Unicorn", 83 | "password": "jinYang", 84 | "emailValid": true, 85 | "age": 0 86 | } 87 | }, 88 | { 89 | "model": "user", 90 | "data": { 91 | "primaryId": 103, 92 | "email": "dinesh@piedpiper.com", 93 | "firstName": "Dinesh", 94 | "lastName": "Chugtai", 95 | "username": "PiperTchat", 96 | "password": "jinYang", 97 | "emailValid": false, 98 | "age": 10 99 | } 100 | }, 101 | { 102 | "model": "order", 103 | "data": { 104 | "id": 100, 105 | "amount": 199, 106 | "comment": "no comment!", 107 | "giftMessage": "Here is your gift" 108 | } 109 | }, 110 | { 111 | "model": "order", 112 | "data": { 113 | "id": 101, 114 | "amount": 1399, 115 | "comment": "this is a gift", 116 | "giftMessage": "Thank you" 117 | } 118 | }, 119 | { 120 | "model": "team", 121 | "data": { 122 | "id": 100, 123 | "name": "lumber" 124 | } 125 | }, 126 | { 127 | "model": "userTeam", 128 | "data": { 129 | "userId": 100, 130 | "teamId": 100 131 | } 132 | }, 133 | { 134 | "model": "georegion", 135 | "data": { 136 | "isocode": "es", 137 | "nameEnglish": "Spain", 138 | "nameFrench": "Espagne" 139 | } 140 | }, 141 | { 142 | "model": "car", 143 | "data": { 144 | "id": 100, 145 | "brand": "Ferrari", 146 | "model": "Enzo" 147 | } 148 | }, 149 | { 150 | "model": "car", 151 | "data": { 152 | "id": 101, 153 | "brand": "Ferrari", 154 | "model": "Monza SP1" 155 | } 156 | }, 157 | { 158 | "model": "car", 159 | "data": { 160 | "id": 102, 161 | "brand": "Ferrari", 162 | "model": "Monza SP2" 163 | } 164 | }, 165 | { 166 | "model": "bird", 167 | "data": { 168 | "id": 9223372036854770000, 169 | "name": "eagle" 170 | } 171 | } 172 | ] 173 | -------------------------------------------------------------------------------- /test/fixtures/leaderboard-stat-getter.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "model": "theVendors", 3 | "data": { 4 | "id": 100, 5 | "firstName": "Alice", 6 | "lastName": "Doe" 7 | } 8 | },{ 9 | "model": "theVendors", 10 | "data": { 11 | "id": 101, 12 | "firstName": "Bob", 13 | "lastName": "Doe" 14 | } 15 | },{ 16 | "model": "theCustomers", 17 | "data": { 18 | "id": 100, 19 | "name": "big customer", 20 | "objectiveScore": 5 21 | } 22 | },{ 23 | "model": "theCustomers", 24 | "data": { 25 | "id": 101, 26 | "name": "small customer", 27 | "objectiveScore": 1 28 | } 29 | },{ 30 | "model": "theirSales", 31 | "data": { 32 | "id": 100, 33 | "vendorId": 100, 34 | "customerId": 100, 35 | "sellingAmount": 100 36 | } 37 | },{ 38 | "model": "theirSales", 39 | "data": { 40 | "id": 101, 41 | "vendorId": 101, 42 | "customerId": 100, 43 | "sellingAmount": 200 44 | } 45 | },{ 46 | "model": "theirSales", 47 | "data": { 48 | "id": 102, 49 | "vendorId": 100, 50 | "customerId": 101, 51 | "sellingAmount": 150 52 | } 53 | }] 54 | -------------------------------------------------------------------------------- /test/helpers/run-with-connection.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param {import("../databases").ConnectionManager} connectionManager 3 | * @param {(connection: import("sequelize").Sequelize) => Promise} testCallback 4 | */ 5 | async function runWithConnection(connectionManager, testCallback) { 6 | try { 7 | await testCallback(connectionManager.createConnection()); 8 | } finally { 9 | connectionManager.closeConnection(); 10 | } 11 | } 12 | 13 | module.exports = runWithConnection; 14 | -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 1 | const forestExpressSequelize = require('../src/index'); 2 | 3 | describe('index', () => { 4 | describe('exported Interface', () => { 5 | it('should export a collection function', () => { 6 | expect.assertions(2); 7 | 8 | expect(forestExpressSequelize.collection).toBeDefined(); 9 | expect(forestExpressSequelize.collection).toBeInstanceOf(Function); 10 | }); 11 | 12 | it('should export Optional Middleware functions', () => { 13 | expect.assertions(2); 14 | 15 | expect(forestExpressSequelize.deactivateCountMiddleware).toBeDefined(); 16 | expect(forestExpressSequelize.deactivateCountMiddleware).toBeInstanceOf(Function); 17 | }); 18 | 19 | it('should export an errorHandler middleware', () => { 20 | expect.assertions(2); 21 | 22 | expect(forestExpressSequelize.errorHandler).toBeDefined(); 23 | expect(forestExpressSequelize.errorHandler).toBeInstanceOf(Function); 24 | }); 25 | 26 | it('should export an ensureAuthenticated middleware', () => { 27 | expect.assertions(2); 28 | 29 | expect(forestExpressSequelize.ensureAuthenticated).toBeDefined(); 30 | expect(forestExpressSequelize.ensureAuthenticated).toBeInstanceOf(Function); 31 | }); 32 | 33 | it('should export a list of serializers and deserializers', () => { 34 | expect.assertions(6); 35 | 36 | expect(forestExpressSequelize.StatSerializer).toBeDefined(); 37 | expect(forestExpressSequelize.StatSerializer).toBeInstanceOf(Function); 38 | 39 | expect(forestExpressSequelize.ResourceSerializer).toBeDefined(); 40 | expect(forestExpressSequelize.ResourceSerializer).toBeInstanceOf(Function); 41 | 42 | expect(forestExpressSequelize.ResourceDeserializer).toBeDefined(); 43 | expect(forestExpressSequelize.ResourceDeserializer).toBeInstanceOf(Function); 44 | }); 45 | 46 | it('should export Schemas & ResourcesRoute objects', () => { 47 | expect.assertions(4); 48 | 49 | expect(forestExpressSequelize.Schemas).toBeDefined(); 50 | expect(forestExpressSequelize.Schemas).toBeInstanceOf(Object); 51 | 52 | expect(forestExpressSequelize.ResourcesRoute).toBeDefined(); 53 | expect(forestExpressSequelize.ResourcesRoute).toBeInstanceOf(Object); 54 | }); 55 | 56 | it('should export a list of records functions', () => { 57 | expect.assertions(20); 58 | 59 | expect(forestExpressSequelize.PermissionMiddlewareCreator).toBeDefined(); 60 | expect(forestExpressSequelize.PermissionMiddlewareCreator).toBeInstanceOf(Function); 61 | 62 | expect(forestExpressSequelize.RecordsCounter).toBeDefined(); 63 | expect(forestExpressSequelize.RecordsCounter).toBeInstanceOf(Function); 64 | 65 | expect(forestExpressSequelize.RecordsExporter).toBeDefined(); 66 | expect(forestExpressSequelize.RecordsExporter).toBeInstanceOf(Function); 67 | 68 | expect(forestExpressSequelize.RecordsGetter).toBeDefined(); 69 | expect(forestExpressSequelize.RecordsGetter).toBeInstanceOf(Function); 70 | 71 | expect(forestExpressSequelize.RecordGetter).toBeDefined(); 72 | expect(forestExpressSequelize.RecordGetter).toBeInstanceOf(Function); 73 | 74 | expect(forestExpressSequelize.RecordUpdater).toBeDefined(); 75 | expect(forestExpressSequelize.RecordUpdater).toBeInstanceOf(Function); 76 | 77 | expect(forestExpressSequelize.RecordCreator).toBeDefined(); 78 | expect(forestExpressSequelize.RecordCreator).toBeInstanceOf(Function); 79 | 80 | expect(forestExpressSequelize.RecordRemover).toBeDefined(); 81 | expect(forestExpressSequelize.RecordRemover).toBeInstanceOf(Function); 82 | 83 | expect(forestExpressSequelize.RecordsRemover).toBeDefined(); 84 | expect(forestExpressSequelize.RecordsRemover).toBeInstanceOf(Function); 85 | 86 | expect(forestExpressSequelize.RecordSerializer).toBeDefined(); 87 | expect(forestExpressSequelize.RecordSerializer).toBeInstanceOf(Function); 88 | }); 89 | 90 | it('should export the PUBLIC_ROUTES', () => { 91 | expect.assertions(2); 92 | expect(forestExpressSequelize.PUBLIC_ROUTES).toBeDefined(); 93 | expect(forestExpressSequelize.PUBLIC_ROUTES).toBeInstanceOf(Array); 94 | }); 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /test/init.test.js: -------------------------------------------------------------------------------- 1 | jest.mock('forest-express', () => ({ 2 | init: jest.fn(), 3 | logger: { 4 | error: jest.fn(), 5 | warn: jest.fn(), 6 | }, 7 | })); 8 | const forestExpressMock = require('forest-express'); 9 | 10 | jest.mock('../package.json'); 11 | const packageJsonMock = require('../package.json'); 12 | 13 | const forestExpressSequelize = require('../src'); 14 | 15 | describe('forest-express-sequelize > init', () => { 16 | const mockPackageJsonVersion = (version) => { 17 | packageJsonMock.version = version; 18 | }; 19 | 20 | const createForestExpressInitSpy = (implementation) => { 21 | jest.resetAllMocks(); 22 | const spy = jest.spyOn(forestExpressMock, 'init'); 23 | spy.mockImplementation(implementation); 24 | }; 25 | 26 | const initForestExpressSequelize = (options) => 27 | forestExpressSequelize.init({ 28 | objectMapping: {}, 29 | connections: {}, 30 | ...options, 31 | }); 32 | 33 | describe('when the given configuration is correct', () => { 34 | it('should call forest-express init function', () => { 35 | expect.assertions(2); 36 | 37 | jest.resetAllMocks(); 38 | initForestExpressSequelize(); 39 | 40 | expect(forestExpressMock.init).toHaveBeenCalledTimes(1); 41 | expect(forestExpressMock.init).toHaveBeenCalledWith(expect.any(Object)); 42 | }); 43 | 44 | describe('when forest-express init is called with exports', () => { 45 | it('should contains an instance of objectMapping and connections options', () => { 46 | expect.assertions(2); 47 | 48 | createForestExpressInitSpy(({ opts }) => { 49 | expect(opts).toHaveProperty('Sequelize'); 50 | expect(opts).toHaveProperty('connections'); 51 | }); 52 | 53 | initForestExpressSequelize(); 54 | }); 55 | 56 | describe('should contains a function getLianaName', () => { 57 | it('should return "forest-express-sequelize"', () => { 58 | expect.assertions(2); 59 | 60 | createForestExpressInitSpy((exports) => { 61 | expect(exports.getLianaName).toStrictEqual(expect.any(Function)); 62 | expect(exports.getLianaName()).toStrictEqual('forest-express-sequelize'); 63 | }); 64 | 65 | initForestExpressSequelize(); 66 | }); 67 | }); 68 | 69 | describe('should contains a function getLianaVersion', () => { 70 | it('should return null if bad version is provided', () => { 71 | expect.assertions(2); 72 | 73 | mockPackageJsonVersion('a bad version'); 74 | 75 | createForestExpressInitSpy((exports) => { 76 | expect(exports.getLianaVersion).toStrictEqual(expect.any(Function)); 77 | expect(exports.getLianaVersion()).toBeNull(); 78 | }); 79 | 80 | initForestExpressSequelize(); 81 | }); 82 | 83 | it('should return the liana version', () => { 84 | expect.assertions(2); 85 | 86 | const LIANA_VERSION = '1.0.0'; 87 | 88 | mockPackageJsonVersion(LIANA_VERSION); 89 | 90 | createForestExpressInitSpy((exports) => { 91 | expect(exports.getLianaVersion).toStrictEqual(expect.any(Function)); 92 | expect(exports.getLianaVersion()).toStrictEqual(LIANA_VERSION); 93 | }); 94 | 95 | initForestExpressSequelize(); 96 | }); 97 | }); 98 | 99 | describe('should contains a function getOrmVersion', () => { 100 | it('should return objectMapping version', () => { 101 | expect.assertions(2); 102 | 103 | const OMV = '1.0.0'; 104 | 105 | createForestExpressInitSpy((exports) => { 106 | expect(exports.getOrmVersion).toStrictEqual(expect.any(Function)); 107 | expect(exports.getOrmVersion()).toStrictEqual(OMV); 108 | }); 109 | 110 | initForestExpressSequelize({ objectMapping: { version: OMV } }); 111 | }); 112 | }); 113 | 114 | describe('should contains a function getDatabaseType', () => { 115 | it('should return the database type for a single database', () => { 116 | expect.assertions(2); 117 | 118 | const DBS_NAME = 'a-sgbd'; 119 | createForestExpressInitSpy((exports) => { 120 | expect(exports.getDatabaseType).toStrictEqual(expect.any(Function)); 121 | expect(exports.getDatabaseType()).toStrictEqual(DBS_NAME); 122 | }); 123 | 124 | initForestExpressSequelize({ 125 | objectMapping: {}, 126 | connections: { database1: { options: { dialect: DBS_NAME } } }, 127 | }); 128 | }); 129 | 130 | it('should return "multiple" type for a multiple databases setup', () => { 131 | expect.assertions(2); 132 | 133 | const SGBG_NAME = 'a-sgbd'; 134 | createForestExpressInitSpy((exports) => { 135 | expect(exports.getDatabaseType).toStrictEqual(expect.any(Function)); 136 | expect(exports.getDatabaseType()).toStrictEqual('multiple'); 137 | }); 138 | 139 | initForestExpressSequelize({ 140 | objectMapping: {}, 141 | connections: { 142 | database1: { options: { dialect: SGBG_NAME } }, 143 | database2: { options: { dialect: SGBG_NAME } }, 144 | }, 145 | }); 146 | }); 147 | }); 148 | 149 | describe('should contains a function getModelName', () => { 150 | it('should return a name of a model', () => { 151 | expect.assertions(2); 152 | 153 | const MODEL_NAME = 'aModelName'; 154 | 155 | createForestExpressInitSpy((exports) => { 156 | expect(exports.getModelName).toStrictEqual(expect.any(Function)); 157 | expect(exports.getModelName({ name: MODEL_NAME })).toStrictEqual(MODEL_NAME); 158 | }); 159 | 160 | initForestExpressSequelize(); 161 | }); 162 | }); 163 | 164 | describe('when providing a correct connections option', () => { 165 | it('should pass a useMultipleDatabases option set to false when a single connection is provided', () => { 166 | expect.assertions(1); 167 | 168 | createForestExpressInitSpy(({ opts }) => { 169 | expect(opts.useMultipleDatabases).toStrictEqual(false); 170 | }); 171 | 172 | initForestExpressSequelize({ connections: { database1: { models: {} } } }); 173 | }); 174 | 175 | it('should pass a useMultipleDatabases option set to true when multiples connections are provided', () => { 176 | expect.assertions(1); 177 | 178 | createForestExpressInitSpy(({ opts }) => { 179 | expect(opts.useMultipleDatabases).toStrictEqual(true); 180 | }); 181 | 182 | initForestExpressSequelize({ 183 | connections: { 184 | database1: {}, 185 | database2: {}, 186 | }, 187 | }); 188 | }); 189 | }); 190 | 191 | it('should contain a list of integrations', () => { 192 | expect.assertions(11); 193 | 194 | createForestExpressInitSpy(({ 195 | Stripe, 196 | Closeio, 197 | Intercom, 198 | Mixpanel, 199 | Layer, 200 | }) => { 201 | expect(Stripe).toBeInstanceOf(Object); 202 | expect(Stripe.getCustomer).toBeInstanceOf(Function); 203 | expect(Stripe.getCustomerByUserField).toBeInstanceOf(Function); 204 | 205 | expect(Intercom).toBeInstanceOf(Object); 206 | expect(Intercom.getCustomer).toBeInstanceOf(Function); 207 | 208 | expect(Closeio).toBeInstanceOf(Object); 209 | expect(Closeio.getCustomer).toBeInstanceOf(Function); 210 | 211 | expect(Mixpanel).toBeInstanceOf(Object); 212 | expect(Mixpanel.getUser).toBeInstanceOf(Function); 213 | 214 | expect(Layer).toBeInstanceOf(Object); 215 | expect(Layer.getUser).toBeInstanceOf(Function); 216 | }); 217 | 218 | initForestExpressSequelize(); 219 | }); 220 | }); 221 | }); 222 | 223 | describe('when the given configuration is incorrect', () => { 224 | describe('when objectMapping option is missing', () => { 225 | it('should log an error', async () => { 226 | expect.assertions(1); 227 | jest.resetAllMocks(); 228 | 229 | const spy = jest.spyOn(forestExpressMock.logger, 'error'); 230 | initForestExpressSequelize({ objectMapping: null }); 231 | expect(spy).toHaveBeenCalledWith('The objectMapping option appears to be missing. Please make sure it is set correctly.'); 232 | }); 233 | 234 | it('should not throw an error', () => { 235 | expect.assertions(1); 236 | 237 | expect(() => initForestExpressSequelize({ objectMapping: null })).not.toThrow(); 238 | }); 239 | 240 | it('should return a promised function', async () => { 241 | expect.assertions(2); 242 | 243 | const result = initForestExpressSequelize({ objectMapping: null }); 244 | expect(result).toBeInstanceOf(Promise); 245 | expect(await result).toBeInstanceOf(Function); 246 | }); 247 | }); 248 | 249 | describe('when sequelize option is provided', () => { 250 | it('should log a warning', async () => { 251 | expect.assertions(1); 252 | jest.resetAllMocks(); 253 | 254 | const spy = jest.spyOn(forestExpressMock.logger, 'warn'); 255 | initForestExpressSequelize({ sequelize: {} }); 256 | expect(spy).toHaveBeenCalledWith('The sequelize option is not supported anymore. Please remove this option.'); 257 | }); 258 | }); 259 | }); 260 | }); 261 | -------------------------------------------------------------------------------- /test/integration/leaderboard-stat-getter.test.js: -------------------------------------------------------------------------------- 1 | const sequelizeFixtures = require('sequelize-fixtures'); 2 | const Interface = require('forest-express'); 3 | const { 4 | DECIMAL, STRING, INTEGER, 5 | } = require('sequelize'); 6 | const databases = require('../databases'); 7 | const runWithConnection = require('../helpers/run-with-connection'); 8 | const LeaderboardStatGetter = require('../../src/services/leaderboard-stat-getter'); 9 | 10 | const runWithCustomMocksAndConnection = async (connectionManager, testCallback) => { 11 | const spyOnScopeManagerGetScopeForUser = jest.spyOn(Interface.scopeManager, 'getScopeForUser') 12 | .mockReturnValue(null); 13 | await runWithConnection(connectionManager, testCallback); 14 | spyOnScopeManagerGetScopeForUser.mockRestore(); 15 | }; 16 | 17 | describe('integration > LeaderboardStatGetter', () => { 18 | const fakeUser = { renderingId: 1 }; 19 | Object.values(databases).forEach((connectionManager) => { 20 | describe(`dialect ${connectionManager.getDialect()}`, () => { 21 | /** 22 | * @param {import('sequelize').Sequelize} sequelize 23 | */ 24 | async function setup(sequelize) { 25 | const models = { 26 | theVendors: sequelize.define('theVendors', { 27 | firstName: { field: 'First name', type: STRING }, 28 | lastName: { field: 'Last name', type: STRING }, 29 | }, { 30 | tableName: 'the vendors', 31 | }), 32 | theirSales: sequelize.define('theirSales', { 33 | id: { type: INTEGER, primaryKey: true }, 34 | sellingAmount: { type: INTEGER, field: 'selling amount' }, 35 | vendorId: { 36 | field: 'vendor id', 37 | type: INTEGER, 38 | }, 39 | customerId: { 40 | field: 'customer id', 41 | type: INTEGER, 42 | }, 43 | }, { 44 | tableName: 'their sales', 45 | }), 46 | theCustomers: sequelize.define('theCustomers', { 47 | name: { type: STRING }, 48 | objectiveScore: { type: DECIMAL, field: 'objective score' }, 49 | }, { tableName: 'the customers' }), 50 | }; 51 | 52 | models.theVendors.hasMany(models.theirSales, { 53 | foreignKey: { 54 | name: 'vendorId', 55 | }, 56 | as: 'vendorsSales', 57 | }); 58 | models.theirSales.belongsTo(models.theVendors, { 59 | foreignKey: { 60 | name: 'vendorId', 61 | }, 62 | as: 'salesVendors', 63 | }); 64 | models.theVendors.belongsToMany(models.theCustomers, { 65 | through: { 66 | model: models.theirSales, 67 | }, 68 | foreignKey: { 69 | name: 'vendorId', 70 | }, 71 | as: 'vendorsCustomers', 72 | }); 73 | models.theCustomers.belongsToMany(models.theVendors, { 74 | through: { 75 | model: models.theirSales, 76 | }, 77 | foreignKey: { 78 | name: 'customerId', 79 | }, 80 | as: 'customersVendors', 81 | }); 82 | 83 | 84 | await sequelize.sync({ force: true }); 85 | 86 | await sequelizeFixtures.loadFile( 87 | 'test/fixtures/leaderboard-stat-getter.json', 88 | models, 89 | { log: () => { } }, 90 | ); 91 | 92 | Interface.Schemas = { 93 | schemas: { 94 | theVendors: { 95 | name: 'theVendors', 96 | idField: 'id', 97 | primaryKeys: ['id'], 98 | isCompositePrimary: false, 99 | fields: [ 100 | { field: 'id', type: 'Number' }, 101 | { field: 'firstName', columnName: 'First name', type: 'String' }, 102 | { field: 'lastName', columnName: 'Last name', type: 'String' }, 103 | ], 104 | }, 105 | theirSales: { 106 | name: 'theirSales', 107 | idField: 'id', 108 | primaryKeys: ['id'], 109 | isCompositePrimary: false, 110 | fields: [ 111 | { field: 'id', type: 'Number' }, 112 | { field: 'sellingAmount', columnName: 'selling amount', type: 'Number' }, 113 | ], 114 | }, 115 | theCustomers: { 116 | name: 'theCustomers', 117 | idField: 'id', 118 | primaryKeys: ['id'], 119 | isCompositePrimary: false, 120 | fields: [ 121 | { field: 'id', type: 'Number' }, 122 | { field: 'name', type: 'String' }, 123 | { field: 'objectiveScore', columnName: 'objective score', type: 'Number' }, 124 | ], 125 | }, 126 | }, 127 | }; 128 | 129 | return { models }; 130 | } 131 | 132 | describe('with a has-many relationship', () => { 133 | it('should correctly return the right count', async () => { 134 | expect.assertions(1); 135 | 136 | await runWithCustomMocksAndConnection(connectionManager, async (sequelize) => { 137 | const { models } = await setup(sequelize); 138 | 139 | const params = { 140 | labelFieldName: 'firstName', 141 | aggregator: 'count', 142 | limit: 10, 143 | }; 144 | 145 | const statGetter = new LeaderboardStatGetter( 146 | models.theVendors, 147 | models.theirSales, 148 | params, 149 | fakeUser, 150 | ); 151 | const result = await statGetter.perform(); 152 | 153 | expect(result).toStrictEqual({ 154 | value: [{ 155 | key: 'Alice', 156 | value: 2, 157 | }, { 158 | key: 'Bob', 159 | value: 1, 160 | }], 161 | }); 162 | }); 163 | }); 164 | 165 | it('should correctly return the right sum', async () => { 166 | expect.assertions(1); 167 | 168 | await runWithCustomMocksAndConnection(connectionManager, async (sequelize) => { 169 | const { models } = await setup(sequelize); 170 | 171 | const params = { 172 | labelFieldName: 'firstName', 173 | aggregator: 'sum', 174 | aggregateFieldName: 'sellingAmount', 175 | limit: 10, 176 | }; 177 | 178 | const statGetter = new LeaderboardStatGetter( 179 | models.theVendors, 180 | models.theirSales, 181 | params, 182 | fakeUser, 183 | ); 184 | const result = await statGetter.perform(); 185 | 186 | expect(result).toStrictEqual({ 187 | value: [{ 188 | key: 'Alice', 189 | value: 250, 190 | }, { 191 | key: 'Bob', 192 | value: 200, 193 | }], 194 | }); 195 | }); 196 | }); 197 | }); 198 | 199 | describe('with a belongs-to-many relationship', () => { 200 | it('should correctly return the right count', async () => { 201 | expect.assertions(1); 202 | 203 | await runWithCustomMocksAndConnection(connectionManager, async (sequelize) => { 204 | const { models } = await setup(sequelize); 205 | 206 | const params = { 207 | labelFieldName: 'firstName', 208 | aggregator: 'count', 209 | limit: 10, 210 | }; 211 | 212 | const statGetter = new LeaderboardStatGetter( 213 | models.theVendors, 214 | models.theCustomers, 215 | params, 216 | fakeUser, 217 | ); 218 | const result = await statGetter.perform(); 219 | 220 | expect(result).toStrictEqual({ 221 | value: [{ 222 | key: 'Alice', 223 | value: 2, 224 | }, { 225 | key: 'Bob', 226 | value: 1, 227 | }], 228 | }); 229 | }); 230 | }); 231 | 232 | it('should correctly return the right sum', async () => { 233 | expect.assertions(1); 234 | 235 | await runWithCustomMocksAndConnection(connectionManager, async (sequelize) => { 236 | const { models } = await setup(sequelize); 237 | 238 | const params = { 239 | labelFieldName: 'firstName', 240 | aggregator: 'sum', 241 | aggregateFieldName: 'objectiveScore', 242 | limit: 10, 243 | }; 244 | 245 | const statGetter = new LeaderboardStatGetter( 246 | models.theVendors, 247 | models.theCustomers, 248 | params, 249 | fakeUser, 250 | ); 251 | const result = await statGetter.perform(); 252 | 253 | expect(result).toStrictEqual({ 254 | value: [{ 255 | key: 'Alice', 256 | value: 6, 257 | }, { 258 | key: 'Bob', 259 | value: 5, 260 | }], 261 | }); 262 | }); 263 | }); 264 | }); 265 | }); 266 | }); 267 | }); 268 | -------------------------------------------------------------------------------- /test/integration/smart-field.test.js: -------------------------------------------------------------------------------- 1 | const Interface = require('forest-express'); 2 | const { STRING, INTEGER } = require('sequelize'); 3 | const { Op } = require('sequelize'); 4 | const databases = require('../databases'); 5 | const runWithConnection = require('../helpers/run-with-connection'); 6 | const ResourcesGetter = require('../../src/services/resources-getter'); 7 | 8 | function rot13(s) { 9 | return s.replace(/[A-Z]/gi, (c) => 10 | 'NOPQRSTUVWXYZABCDEFGHIJKLMnopqrstuvwxyzabcdefghijklm'[ 11 | 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'.indexOf(c)]); 12 | } 13 | 14 | const user = { renderingId: 1 }; 15 | 16 | async function setup(sequelize) { 17 | Interface.Schemas = { schemas: {} }; 18 | 19 | // Shelves 20 | const Shelves = sequelize.define('shelves', { 21 | id: { type: INTEGER, primaryKey: true }, 22 | floor: { field: 'floor', type: INTEGER }, 23 | }, { tableName: 'shelves', timestamps: false }); 24 | 25 | Interface.Schemas.schemas.shelves = { 26 | name: 'shelves', 27 | idField: 'id', 28 | primaryKeys: ['id'], 29 | isCompositePrimary: false, 30 | fields: [ 31 | { field: 'id', type: 'Number' }, 32 | { field: 'floor', columnName: 'floor', type: 'Number' }, 33 | ], 34 | }; 35 | 36 | // Books 37 | const Books = sequelize.define('books', { 38 | id: { type: INTEGER, primaryKey: true }, 39 | title: { field: 'title', type: STRING }, 40 | }, { tableName: 'books', timestamps: false }); 41 | 42 | Interface.Schemas.schemas.books = { 43 | name: 'books', 44 | idField: 'id', 45 | primaryKeys: ['id'], 46 | isCompositePrimary: false, 47 | fields: [ 48 | { field: 'id', type: 'Number' }, 49 | { field: 'title', columnName: 'title', type: 'String' }, 50 | { 51 | field: 'encrypted', 52 | isVirtual: true, 53 | type: 'String', 54 | get: (record) => rot13(record.title), 55 | search: (query, search) => { 56 | query.where[Op.and][0][Op.or].push({ 57 | title: rot13(search), 58 | }); 59 | }, 60 | }, 61 | ], 62 | }; 63 | 64 | // Reviews 65 | const Reviews = sequelize.define('reviews', { 66 | id: { type: INTEGER, primaryKey: true }, 67 | content: { field: 'content', type: STRING }, 68 | }, { tableName: 'reviews', timestamps: false }); 69 | 70 | Interface.Schemas.schemas.reviews = { 71 | name: 'reviews', 72 | idField: 'id', 73 | primaryKeys: ['id'], 74 | isCompositePrimary: false, 75 | fields: [ 76 | { field: 'id', type: 'Number' }, 77 | { field: 'content', columnName: 'content', type: 'String' }, 78 | { 79 | field: 'floor', 80 | isVirtual: true, 81 | type: 'String', 82 | get: () => null, 83 | search: (query, search) => { 84 | query.include.push({ 85 | association: 'book', 86 | include: [{ association: 'shelve' }], 87 | }); 88 | 89 | query.where[Op.and][0][Op.or].push({ 90 | '$book.shelve.floor$': Number.parseInt(search, 10), 91 | }); 92 | }, 93 | }, 94 | ], 95 | }; 96 | 97 | // Relations 98 | 99 | Books.belongsTo(Shelves, { 100 | foreignKey: { name: 'shelveId' }, 101 | as: 'shelve', 102 | }); 103 | 104 | Reviews.belongsTo(Books, { 105 | foreignKey: { name: 'bookId' }, 106 | as: 'book', 107 | }); 108 | 109 | 110 | await sequelize.sync({ force: true }); 111 | 112 | await Shelves.create({ id: 1, floor: 666 }); 113 | await Shelves.create({ id: 2, floor: 667 }); 114 | 115 | await Books.create({ id: 1, shelveId: 1, title: 'nowhere' }); 116 | 117 | await Reviews.create({ id: 1, bookId: 1, content: 'abc' }); 118 | 119 | return { Shelves, Books, Reviews }; 120 | } 121 | 122 | describe('integration > Smart field', () => { 123 | Object.values(databases).forEach((connectionManager) => { 124 | describe(`dialect ${connectionManager.getDialect()}`, () => { 125 | it('should not find books matching the encrypted field', async () => { 126 | expect.assertions(1); 127 | 128 | await runWithConnection(connectionManager, async (sequelize) => { 129 | const spy = jest.spyOn(Interface.scopeManager, 'getScopeForUser').mockReturnValue(null); 130 | const { Books } = await setup(sequelize); 131 | const params = { 132 | fields: { books: 'id,title,encrypted' }, 133 | sort: 'id', 134 | page: { number: '1', size: '30' }, 135 | timezone: 'Europe/Paris', 136 | search: 'hello', 137 | }; 138 | 139 | const count = await new ResourcesGetter(Books, null, params, user).count(); 140 | expect(count).toStrictEqual(0); 141 | spy.mockRestore(); 142 | }); 143 | }); 144 | 145 | it('should find books matching the encrypted field', async () => { 146 | expect.assertions(1); 147 | 148 | await runWithConnection(connectionManager, async (sequelize) => { 149 | const spy = jest.spyOn(Interface.scopeManager, 'getScopeForUser').mockReturnValue(null); 150 | const { Books } = await setup(sequelize); 151 | const params = { 152 | fields: { books: 'id,title,encrypted' }, 153 | sort: 'id', 154 | page: { number: '1', size: '30' }, 155 | timezone: 'Europe/Paris', 156 | search: 'abjurer', 157 | }; 158 | 159 | const count = await new ResourcesGetter(Books, null, params, user).count(); 160 | expect(count).toStrictEqual(1); 161 | spy.mockRestore(); 162 | }); 163 | }); 164 | 165 | it('should not find reviews on the floor 500', async () => { 166 | expect.assertions(1); 167 | 168 | await runWithConnection(connectionManager, async (sequelize) => { 169 | const spy = jest.spyOn(Interface.scopeManager, 'getScopeForUser').mockReturnValue(null); 170 | const { Reviews } = await setup(sequelize); 171 | const params = { 172 | fields: { books: 'id,title,encrypted' }, 173 | sort: 'id', 174 | page: { number: '1', size: '30' }, 175 | timezone: 'Europe/Paris', 176 | search: '500', 177 | }; 178 | 179 | const count = await new ResourcesGetter(Reviews, null, params, user).count(); 180 | expect(count).toStrictEqual(0); 181 | spy.mockRestore(); 182 | }); 183 | }); 184 | 185 | it('should find reviews on the floor 666', async () => { 186 | expect.assertions(1); 187 | 188 | await runWithConnection(connectionManager, async (sequelize) => { 189 | const spy = jest.spyOn(Interface.scopeManager, 'getScopeForUser').mockReturnValue(null); 190 | const { Reviews } = await setup(sequelize); 191 | const params = { 192 | fields: { books: 'id,title,encrypted' }, 193 | sort: 'id', 194 | page: { number: '1', size: '30' }, 195 | timezone: 'Europe/Paris', 196 | search: '666', 197 | }; 198 | 199 | const count = await new ResourcesGetter(Reviews, null, params, user).count(); 200 | expect(count).toStrictEqual(1); 201 | spy.mockRestore(); 202 | }); 203 | }); 204 | }); 205 | }); 206 | }); 207 | -------------------------------------------------------------------------------- /test/public/parse-filter.test.js: -------------------------------------------------------------------------------- 1 | const makeParseFilter = require('../../src/public/parse-filter'); 2 | 3 | describe('parseFilter', () => { 4 | function setup() { 5 | const perform = jest.fn(); 6 | 7 | const FakeFiltersParser = jest.fn(function FakeClass() { 8 | this.perform = perform; 9 | }); 10 | 11 | return { FakeFiltersParser, perform }; 12 | } 13 | 14 | it('should create the parser and call perform', async () => { 15 | expect.assertions(3); 16 | 17 | const { FakeFiltersParser, perform } = setup(); 18 | 19 | const opts = { Sequelize: 'fake' }; 20 | const parseFilter = makeParseFilter(FakeFiltersParser, { opts }); 21 | perform.mockResolvedValue('parsed-filter'); 22 | 23 | const filter = { operator: 'equal', value: 'hello', field: 'label' }; 24 | const modelSchema = { label: {} }; 25 | const timezone = 'Europe/Paris'; 26 | const result = await parseFilter(filter, modelSchema, timezone); 27 | 28 | expect(result).toBe('parsed-filter'); 29 | expect(FakeFiltersParser).toHaveBeenCalledWith(modelSchema, timezone, opts); 30 | expect(perform).toHaveBeenCalledWith(JSON.stringify(filter)); 31 | }); 32 | 33 | it('should throw an error when the liana is not initialized', () => { 34 | expect.assertions(1); 35 | 36 | const { FakeFiltersParser } = setup(); 37 | 38 | const parseFilter = makeParseFilter(FakeFiltersParser, {}); 39 | 40 | expect(() => parseFilter({}, {}, 'Europe/Paris')) 41 | .toThrow('Liana must be initialized before using parseFilter'); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /test/services/belongs-to-updater.test.js: -------------------------------------------------------------------------------- 1 | import Sequelize from 'sequelize'; 2 | import associationRecord from '../../src/utils/association-record'; 3 | import BelongsToUpdater from '../../src/services/belongs-to-updater'; 4 | 5 | describe('services > belongs-to-updater', () => { 6 | const params = { timezone: 'Europe/Paris' }; 7 | 8 | const buildModelMock = () => { 9 | // Sequelize is created here without connection to a database 10 | const sequelize = new Sequelize({ dialect: 'postgres' }); 11 | 12 | const Actor = sequelize.define('actor', { 13 | Id: { 14 | type: Sequelize.DataTypes.INTEGER, 15 | primaryKey: true, 16 | }, 17 | }); 18 | const Author = sequelize.define('author', { 19 | id: { 20 | type: Sequelize.DataTypes.INTEGER, 21 | primaryKey: true, 22 | }, 23 | name: { 24 | type: Sequelize.DataTypes.STRING, 25 | unique: true, 26 | }, 27 | }); 28 | const Film = sequelize.define('film', {}); 29 | 30 | Film.belongsTo(Actor); 31 | Film.belongsTo(Author, { 32 | targetKey: 'name', 33 | }); 34 | 35 | return { Actor, Author, Film }; 36 | }; 37 | 38 | describe('_getTargetKey', () => { 39 | describe('when association does not have entry in data', () => { 40 | it('should return null', async () => { 41 | expect.assertions(2); 42 | 43 | const { Film, Actor } = buildModelMock(); 44 | 45 | const data = {}; 46 | 47 | const spy = jest.spyOn(associationRecord, 'get'); 48 | 49 | const belongsToUpdater = new BelongsToUpdater(Film, null, null, params, { data }); 50 | const targetKey = await belongsToUpdater._getTargetKey( 51 | Film.associations[Actor.name], 52 | ); 53 | 54 | expect(spy).not.toHaveBeenCalled(); 55 | expect(targetKey).toBeNil(); 56 | }); 57 | }); 58 | 59 | describe('when association does not have value in body', () => { 60 | it('should return null', async () => { 61 | expect.assertions(2); 62 | 63 | const { Film, Actor } = buildModelMock(); 64 | 65 | const data = { id: null }; 66 | 67 | const spy = jest.spyOn(associationRecord, 'get'); 68 | 69 | const belongsToUpdater = new BelongsToUpdater(Film, null, null, params, { data }); 70 | const targetKey = await belongsToUpdater._getTargetKey( 71 | Film.associations[Actor.name], 72 | ); 73 | 74 | expect(spy).not.toHaveBeenCalled(); 75 | expect(targetKey).toBeNil(); 76 | }); 77 | }); 78 | 79 | describe('when association target key is the primary key', () => { 80 | it('should return the body value', async () => { 81 | expect.assertions(2); 82 | 83 | const { Film, Actor } = buildModelMock(); 84 | 85 | const data = { id: 2 }; 86 | 87 | const spy = jest.spyOn(associationRecord, 'get'); 88 | 89 | const belongsToUpdater = new BelongsToUpdater(Film, null, null, params, { data }); 90 | const targetKey = await belongsToUpdater._getTargetKey( 91 | Film.associations[Actor.name], 92 | ); 93 | 94 | expect(spy).not.toHaveBeenCalled(); 95 | expect(targetKey).toStrictEqual(2); 96 | }); 97 | }); 98 | 99 | describe('when association target key is not the primary key', () => { 100 | it('should return the right value', async () => { 101 | expect.assertions(2); 102 | 103 | const { Film, Author } = buildModelMock(); 104 | 105 | const data = { id: 2 }; 106 | 107 | const spy = jest.spyOn(associationRecord, 'get').mockResolvedValue({ id: 2, name: 'Scorsese' }); 108 | 109 | const belongsToUpdater = new BelongsToUpdater(Film, null, null, params, { data }); 110 | const targetKey = await belongsToUpdater._getTargetKey( 111 | Film.associations[Author.name], 112 | ); 113 | 114 | expect(spy).toHaveBeenCalledWith(Author, 2); 115 | expect(targetKey).toStrictEqual('Scorsese'); 116 | }); 117 | }); 118 | }); 119 | }); 120 | -------------------------------------------------------------------------------- /test/services/has-many-getter.test.js: -------------------------------------------------------------------------------- 1 | import Interface, { scopeManager } from 'forest-express'; 2 | import Sequelize from 'sequelize'; 3 | import HasManyGetter from '../../src/services/has-many-getter'; 4 | import Operators from '../../src/utils/operators'; 5 | import { sequelizePostgres } from '../databases'; 6 | 7 | describe('services > HasManyGetter', () => { 8 | const lianaOptions = { 9 | sequelize: Sequelize, 10 | Sequelize, 11 | connections: { sequelize: sequelizePostgres.createConnection() }, 12 | }; 13 | const { OR, GT } = Operators.getInstance(lianaOptions); 14 | const timezone = 'Europe/Paris'; 15 | const baseParams = { timezone, associationName: 'users', recordId: 1 }; 16 | const user = { renderingId: 1 }; 17 | 18 | describe('_buildQueryOptions', () => { 19 | const options = { tableAlias: 'users' }; 20 | const UserModel = { 21 | name: 'users', 22 | rawAttributes: [{ field: 'name', type: 'String' }], 23 | sequelize: sequelizePostgres.connection, 24 | unscoped: () => UserModel, 25 | associations: { }, 26 | }; 27 | const CarModel = { 28 | name: 'cars', 29 | unscoped: () => CarModel, 30 | sequelize: sequelizePostgres.connection, 31 | primaryKeys: { id: {} }, 32 | associations: { users: { target: UserModel } }, 33 | }; 34 | Interface.Schemas = { 35 | schemas: { 36 | users: { fields: [{ field: 'name', type: 'String', columnName: 'name' }] }, 37 | cars: { fields: [{ field: 'type' }] }, 38 | }, 39 | }; 40 | 41 | describe('with no filters and search in params', () => { 42 | it('should build an empty where condition', async () => { 43 | expect.assertions(1); 44 | const spy = jest.spyOn(scopeManager, 'getScopeForUser').mockReturnValue(null); 45 | 46 | const getter = new HasManyGetter(CarModel, UserModel, lianaOptions, baseParams, user); 47 | const queryOptions = await getter._buildQueryOptions(options); 48 | 49 | expect(queryOptions.where).toStrictEqual({ id: 1 }); 50 | spy.mockRestore(); 51 | }); 52 | }); 53 | 54 | describe('with filters in params', () => { 55 | it('should build a where condition containing the provided filters formatted', async () => { 56 | expect.assertions(1); 57 | const spy = jest.spyOn(scopeManager, 'getScopeForUser').mockReturnValue(null); 58 | 59 | const params = { 60 | ...baseParams, 61 | filters: '{ "field": "id", "operator": "greater_than", "value": 1 }', 62 | }; 63 | const hasManyGetter = new HasManyGetter(CarModel, UserModel, lianaOptions, params, user); 64 | const queryOptions = await hasManyGetter._buildQueryOptions(options); 65 | 66 | expect(queryOptions.where).toStrictEqual({ 67 | id: 1, 68 | '$users.id$': { [GT]: 1 }, 69 | }); 70 | spy.mockRestore(); 71 | }); 72 | }); 73 | 74 | describe('with search in params', () => { 75 | it('should build a where condition containing the provided search', async () => { 76 | expect.assertions(1); 77 | const spy = jest.spyOn(scopeManager, 'getScopeForUser').mockReturnValue(null); 78 | 79 | const params = { ...baseParams, search: 'test' }; 80 | const hasManyGetter = new HasManyGetter(CarModel, UserModel, lianaOptions, params, user); 81 | const queryOptions = await hasManyGetter._buildQueryOptions(options); 82 | 83 | expect(queryOptions.where).toStrictEqual({ 84 | id: 1, 85 | [OR]: expect.arrayContaining([ 86 | expect.objectContaining({ 87 | attribute: { args: [{ col: 'users.name' }], fn: 'lower' }, 88 | comparator: ' LIKE ', 89 | logic: { args: ['%test%'], fn: 'lower' }, 90 | }), 91 | ]), 92 | }); 93 | spy.mockRestore(); 94 | }); 95 | }); 96 | 97 | describe('with filters and search in params', () => { 98 | it('should build a where condition containing the provided filters and search', async () => { 99 | expect.assertions(1); 100 | const spy = jest.spyOn(scopeManager, 'getScopeForUser').mockReturnValue(null); 101 | 102 | const params = { 103 | ...baseParams, 104 | filters: '{ "field": "id", "operator": "greater_than", "value": 1 }', 105 | search: 'test', 106 | }; 107 | const hasManyGetter = new HasManyGetter(CarModel, UserModel, lianaOptions, params, user); 108 | const queryOptions = await hasManyGetter._buildQueryOptions(options); 109 | 110 | expect(queryOptions.where).toStrictEqual({ 111 | id: 1, 112 | '$users.id$': { [GT]: 1 }, 113 | [OR]: expect.arrayContaining([ 114 | expect.objectContaining({ 115 | attribute: { args: [{ col: 'users.name' }], fn: 'lower' }, 116 | comparator: ' LIKE ', 117 | logic: { args: ['%test%'], fn: 'lower' }, 118 | }), 119 | ]), 120 | }); 121 | spy.mockRestore(); 122 | }); 123 | }); 124 | }); 125 | }); 126 | -------------------------------------------------------------------------------- /test/services/primary-keys-manager.test.js: -------------------------------------------------------------------------------- 1 | import Sequelize, { Op } from 'sequelize'; 2 | import PrimaryKeyManager from '../../src/services/primary-keys-manager'; 3 | 4 | describe('services > primary-keys-manager', () => { 5 | const modelBase = { 6 | sequelize: { constructor: Sequelize }, 7 | }; 8 | 9 | describe('getPrimaryKeyValues', () => { 10 | it('should throw if the number of primary keys does not match the provided packed key', () => { 11 | expect.assertions(1); 12 | const model = { ...modelBase, primaryKeys: { id: {} } }; 13 | const keyManager = new PrimaryKeyManager(model); 14 | expect(() => keyManager._getPrimaryKeyValues('1|1')).toThrow('Invalid packed primary key'); 15 | }); 16 | 17 | it('should return one value for non composite key', () => { 18 | expect.assertions(1); 19 | const model = { ...modelBase, primaryKeys: { id: {} } }; 20 | const keyManager = new PrimaryKeyManager(model); 21 | const primaryKeyValues = keyManager._getPrimaryKeyValues('1'); 22 | expect(primaryKeyValues).toStrictEqual(['1']); 23 | }); 24 | it('should return two values for composite key string with two values', () => { 25 | expect.assertions(1); 26 | const model = { ...modelBase, primaryKeys: { userId: {}, bookId: {} } }; 27 | const keyManager = new PrimaryKeyManager(model); 28 | const primaryKeyValues = keyManager._getPrimaryKeyValues('1|2'); 29 | expect(primaryKeyValues).toStrictEqual(['1', '2']); 30 | }); 31 | it('should return null if `null` string is present', () => { 32 | expect.assertions(1); 33 | const model = { ...modelBase, primaryKeys: { userId: {}, bookId: {} } }; 34 | const keyManager = new PrimaryKeyManager(model); 35 | const primaryKeyValues = keyManager._getPrimaryKeyValues('1|null'); 36 | expect(primaryKeyValues).toStrictEqual(['1', null]); 37 | }); 38 | }); 39 | 40 | describe('getRecordsConditions', () => { 41 | it('should throw if there is no primary key on the model', () => { 42 | expect.assertions(1); 43 | const model = { ...modelBase, primaryKeys: { } }; 44 | const keyManager = new PrimaryKeyManager(model); 45 | expect(() => keyManager.getRecordsConditions(['1'])).toThrow('No primary key was found'); 46 | }); 47 | it('should return a condition that will not match for empty array', () => { 48 | expect.assertions(1); 49 | const model = { ...modelBase, primaryKeys: { id: {} } }; 50 | const keyManager = new PrimaryKeyManager(model); 51 | const conditions = keyManager.getRecordsConditions([]); 52 | expect(conditions.val).toStrictEqual('(0=1)'); 53 | }); 54 | it('should return a where condition with one key for non composite key', () => { 55 | expect.assertions(1); 56 | const model = { ...modelBase, primaryKeys: { id: {} } }; 57 | const keyManager = new PrimaryKeyManager(model); 58 | const conditions = keyManager.getRecordsConditions(['1']); 59 | expect(conditions).toStrictEqual({ id: '1' }); 60 | }); 61 | it('should return a where condition with two keys for composite key', () => { 62 | expect.assertions(1); 63 | const model = { ...modelBase, primaryKeys: { actorId: {}, filmId: {} } }; 64 | const keyManager = new PrimaryKeyManager(model); 65 | const conditions = keyManager.getRecordsConditions(['1|2']); 66 | expect(conditions).toStrictEqual({ actorId: '1', filmId: '2' }); 67 | }); 68 | it('should return a where condition with one key for non composite key (2)', () => { 69 | expect.assertions(1); 70 | const model = { ...modelBase, primaryKeys: { actorId: {} } }; 71 | const keyManager = new PrimaryKeyManager(model); 72 | const conditions = keyManager.getRecordsConditions(['1', '2']); 73 | expect(conditions).toStrictEqual({ actorId: ['1', '2'] }); 74 | }); 75 | it('should return a where condition with two keys for composite key (2)', () => { 76 | expect.assertions(1); 77 | const model = { ...modelBase, primaryKeys: { actorId: {}, filmId: {} } }; 78 | const keyManager = new PrimaryKeyManager(model); 79 | const conditions = keyManager.getRecordsConditions(['1|2', '3|4']); 80 | expect(conditions).toStrictEqual({ [Op.or]: [{ actorId: '1', filmId: '2' }, { actorId: '3', filmId: '4' }] }); 81 | }); 82 | }); 83 | 84 | describe('annotateRecords', () => { 85 | it('should create a simple key for non composite record', () => { 86 | expect.assertions(1); 87 | const model = { ...modelBase, primaryKeys: { actorId: {} } }; 88 | const record = { actorId: '1' }; 89 | const keyManager = new PrimaryKeyManager(model); 90 | keyManager.annotateRecords([record]); 91 | expect(record.forestCompositePrimary).toBeUndefined(); 92 | }); 93 | 94 | it('should create a composite key for composite record', () => { 95 | expect.assertions(1); 96 | const model = { ...modelBase, primaryKeys: { actorId: {}, filmId: {} } }; 97 | const record = { actorId: '1', filmId: '2' }; 98 | const keyManager = new PrimaryKeyManager(model, null, record); 99 | keyManager.annotateRecords([record]); 100 | expect(record.forestCompositePrimary).toStrictEqual('1|2'); 101 | }); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /test/services/query-builder.test.js: -------------------------------------------------------------------------------- 1 | import Sequelize from 'sequelize'; 2 | import Interface from 'forest-express'; 3 | import QueryBuilder from '../../src/services/query-builder'; 4 | 5 | describe('services > query-builder', () => { 6 | describe('getIncludes', () => { 7 | ['HasOne', 'BelongsTo'].forEach((associationType) => { 8 | describe(`with a ${associationType} relationship`, () => { 9 | function setup() { 10 | const target = { 11 | primaryKeyAttributes: ['id'], 12 | tableAttributes: { 13 | id: { field: 'Uid', fieldName: 'uid' }, 14 | name: { field: 'Name', fieldName: 'name' }, 15 | }, 16 | unscoped: () => ({ name: 'user' }), 17 | }; 18 | 19 | const association = { 20 | associationType, 21 | as: 'user', 22 | associationAccessor: 'userAccessor', 23 | target, 24 | sourceKey: 'id', 25 | }; 26 | 27 | const model = { 28 | name: 'address', 29 | associations: [association], 30 | }; 31 | 32 | const sequelizeOptions = { sequelize: Sequelize }; 33 | Interface.Schemas = { schemas: { actor: { idField: 'id' } } }; 34 | 35 | const queryBuilder = new QueryBuilder(model, sequelizeOptions, {}); 36 | 37 | return { 38 | association, model, target, queryBuilder, 39 | }; 40 | } 41 | 42 | it('should exclude field names that do not exist on the table', async () => { 43 | expect.assertions(1); 44 | const { model, queryBuilder } = setup(); 45 | 46 | const includes = queryBuilder.getIncludes(model, ['user.uid', 'user.name', 'user.id', 'user.badField']); 47 | 48 | expect(includes).toStrictEqual([{ 49 | as: 'userAccessor', 50 | attributes: ['uid', 'name'], 51 | model: { name: 'user' }, 52 | }]); 53 | }); 54 | 55 | it('should always include the primary key even if not specified', () => { 56 | expect.assertions(1); 57 | const { model, queryBuilder } = setup(); 58 | 59 | const includes = queryBuilder.getIncludes(model, ['user.name', 'user.id', 'user.badField']); 60 | 61 | expect(includes).toStrictEqual([{ 62 | as: 'userAccessor', 63 | attributes: ['uid', 'name'], 64 | model: { name: 'user' }, 65 | }]); 66 | }); 67 | }); 68 | }); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /test/services/query-options.test.js: -------------------------------------------------------------------------------- 1 | import Sequelize from 'sequelize'; 2 | import Interface from 'forest-express'; 3 | import QueryOptions from '../../src/services/query-options'; 4 | 5 | describe('services > query-options', () => { 6 | const resetSchema = () => { 7 | Interface.Schemas = { 8 | schemas: { 9 | actor: { 10 | idField: 'id', 11 | fields: [{ field: 'smartField' }, { field: 'secondSmartField' }], 12 | }, 13 | }, 14 | }; 15 | }; 16 | 17 | const buildModelMock = (dialect) => { 18 | // Sequelize is created here without connection to a database 19 | const sequelize = new Sequelize({ dialect }); 20 | 21 | const modelActor = sequelize.define('actor', {}); 22 | const modelMovie = sequelize.define('movie', {}); 23 | 24 | resetSchema(); 25 | 26 | modelActor.belongsTo(modelMovie); 27 | 28 | return modelActor; 29 | }; 30 | 31 | describe('order', () => { 32 | describe('with mssql', () => { 33 | const model = buildModelMock('mssql'); 34 | 35 | it('should return null if the sorting params is the primarykey', async () => { 36 | expect.assertions(1); 37 | const options = new QueryOptions(model); 38 | await options.sort('id'); 39 | expect(options.sequelizeOptions.order).toBeUndefined(); 40 | }); 41 | }); 42 | 43 | ['mysql', 'postgres'].forEach((dialect) => { 44 | describe(`with ${dialect}`, () => { 45 | const model = buildModelMock(dialect); 46 | 47 | it('should return null if there is no sort param', async () => { 48 | expect.assertions(1); 49 | const options = new QueryOptions(model); 50 | await options.sort(); 51 | expect(options.sequelizeOptions.order).toBeUndefined(); 52 | }); 53 | 54 | it('should set order ASC by default', async () => { 55 | expect.assertions(1); 56 | const options = new QueryOptions(model); 57 | await options.sort('id'); 58 | expect(options.sequelizeOptions.order).toStrictEqual([['id', 'ASC']]); 59 | }); 60 | 61 | it('should set order DESC if there is a minus sign', async () => { 62 | expect.assertions(1); 63 | const options = new QueryOptions(model); 64 | await options.sort('-id'); 65 | expect(options.sequelizeOptions.order).toStrictEqual([['id', 'DESC']]); 66 | }); 67 | }); 68 | }); 69 | }); 70 | 71 | describe('search', () => { 72 | const model = buildModelMock('postgres'); 73 | 74 | describe('when search on smart field is async', () => { 75 | describe('when promise reject', () => { 76 | it('should display an error message', async () => { 77 | expect.assertions(1); 78 | 79 | const loggerErrorSpy = jest.spyOn(Interface.logger, 'error'); 80 | 81 | const errorThrown = new Error('unexpected error'); 82 | Interface.Schemas.schemas.actor.fields[0].search = async () => 83 | Promise.reject(errorThrown); 84 | 85 | const options = new QueryOptions(model); 86 | await options.search('search string', null); 87 | expect(loggerErrorSpy).toHaveBeenCalledWith('Cannot search properly on Smart Field smartField: ', errorThrown); 88 | 89 | loggerErrorSpy.mockClear(); 90 | resetSchema(); 91 | }); 92 | }); 93 | 94 | it('should add the search includes', async () => { 95 | expect.assertions(1); 96 | 97 | Interface.Schemas.schemas.actor.fields[0].search = async (query) => { 98 | await Promise.resolve(); 99 | query.include.push('movie'); 100 | }; 101 | Interface.Schemas.schemas.actor.fields[1].search = async (query) => { 102 | await Promise.resolve(); 103 | query.include.push('toto'); 104 | }; 105 | 106 | const options = new QueryOptions(model); 107 | await options.search('search string', null); 108 | expect(options._customerIncludes).toStrictEqual(['movie', 'toto']); 109 | 110 | resetSchema(); 111 | }); 112 | }); 113 | 114 | describe('when search on smart field throw an error', () => { 115 | it('should display an error message', async () => { 116 | expect.assertions(1); 117 | 118 | const loggerErrorSpy = jest.spyOn(Interface.logger, 'error'); 119 | 120 | const errorThrown = new Error('unexpected error'); 121 | Interface.Schemas.schemas.actor.fields[0].search = () => { throw errorThrown; }; 122 | 123 | const options = new QueryOptions(model); 124 | await options.search('search string', null); 125 | expect(loggerErrorSpy).toHaveBeenCalledWith('Cannot search properly on Smart Field smartField: ', errorThrown); 126 | 127 | loggerErrorSpy.mockClear(); 128 | resetSchema(); 129 | }); 130 | }); 131 | 132 | describe('when smartField return none array include', () => { 133 | it('should transform include to array', async () => { 134 | expect.assertions(1); 135 | 136 | Interface.Schemas.schemas.actor.fields[0].search = (query) => { query.include = 'movie'; }; 137 | 138 | const options = new QueryOptions(model); 139 | await options.search('search string', null); 140 | expect(options._customerIncludes).toStrictEqual(['movie']); 141 | 142 | resetSchema(); 143 | }); 144 | }); 145 | }); 146 | }); 147 | -------------------------------------------------------------------------------- /test/services/requested-fields-extractor.test.js: -------------------------------------------------------------------------------- 1 | const extractRequestedFields = require('../../src/services/requested-fields-extractor'); 2 | 3 | describe('services > requested-fields-extractor', () => { 4 | it('should return null if fields is falsy', () => { 5 | expect.assertions(2); 6 | expect(extractRequestedFields(null, { name: 'user' })).toBeNull(); 7 | expect(extractRequestedFields(undefined, { name: 'user' })).toBeNull(); 8 | }); 9 | 10 | it('should return null if the fields do not contain the given model\'s name', () => { 11 | expect.assertions(1); 12 | expect(extractRequestedFields({}, { name: 'user' })).toBeNull(); 13 | }); 14 | 15 | it('should include the first primary key', () => { 16 | expect.assertions(1); 17 | 18 | const fields = { 19 | user: 'name', 20 | }; 21 | 22 | const model = { 23 | name: 'user', 24 | primaryKeys: { id: null, uid: null }, 25 | associations: {}, 26 | rawAttributes: { 27 | id: {}, 28 | uid: {}, 29 | name: {}, 30 | }, 31 | }; 32 | 33 | const schemas = { 34 | user: { 35 | name: 'user', 36 | fields: [{ 37 | field: 'name', 38 | isVirtual: false, 39 | }], 40 | }, 41 | }; 42 | 43 | const result = extractRequestedFields(fields, model, schemas); 44 | 45 | expect(result).toStrictEqual(['id', 'name']); 46 | }); 47 | 48 | it('should include fields only once', () => { 49 | expect.assertions(1); 50 | 51 | const fields = { 52 | user: 'id,name,name', 53 | }; 54 | 55 | const model = { 56 | name: 'user', 57 | primaryKeys: { id: null, uid: null }, 58 | associations: {}, 59 | rawAttributes: { 60 | id: {}, 61 | uid: {}, 62 | name: {}, 63 | }, 64 | }; 65 | 66 | const schemas = { 67 | user: { 68 | name: 'user', 69 | fields: [{ 70 | field: 'name', 71 | isVirtual: false, 72 | }], 73 | }, 74 | }; 75 | 76 | const result = extractRequestedFields(fields, model, schemas); 77 | 78 | expect(result).toStrictEqual(['id', 'name']); 79 | }); 80 | 81 | it('should include field with same name as the model', () => { 82 | expect.assertions(1); 83 | 84 | const fields = { 85 | user: 'id,user', 86 | }; 87 | 88 | const model = { 89 | name: 'user', 90 | primaryKeys: { id: null }, 91 | associations: {}, 92 | rawAttributes: { 93 | id: {}, 94 | user: {}, 95 | }, 96 | }; 97 | 98 | const schemas = { 99 | user: { 100 | name: 'user', 101 | fields: [{ 102 | field: 'user', 103 | isVirtual: false, 104 | }], 105 | }, 106 | }; 107 | 108 | const result = extractRequestedFields(fields, model, schemas); 109 | 110 | expect(result).toStrictEqual(['id', 'user']); 111 | }); 112 | 113 | it('should include all associations\' requested fields', () => { 114 | expect.assertions(1); 115 | 116 | const fields = { 117 | user: 'name', 118 | homeAddress: 'street', 119 | }; 120 | 121 | const model = { 122 | name: 'user', 123 | primaryKeys: { id: null, uid: null }, 124 | associations: { 125 | homeAddress: { 126 | name: 'homeAddress', 127 | target: { 128 | name: 'addresses', 129 | }, 130 | }, 131 | }, 132 | rawAttributes: { 133 | id: {}, 134 | uid: {}, 135 | name: {}, 136 | }, 137 | }; 138 | 139 | const schemas = { 140 | user: { 141 | name: 'user', 142 | fields: [{ 143 | field: 'name', 144 | isVirtual: false, 145 | }], 146 | }, 147 | addresses: { 148 | name: 'addresses', 149 | fields: [{ 150 | field: 'street', 151 | isVirtual: false, 152 | }], 153 | }, 154 | }; 155 | 156 | const result = extractRequestedFields(fields, model, schemas); 157 | 158 | expect(result).toStrictEqual(['id', 'name', 'homeAddress.street']); 159 | }); 160 | 161 | it('should remove associations from fields if there are explicit fields requested', () => { 162 | expect.assertions(1); 163 | 164 | const fields = { 165 | user: 'name,homeAddress,account', 166 | homeAddress: 'street', 167 | }; 168 | 169 | const model = { 170 | name: 'user', 171 | primaryKeys: { id: null, uid: null }, 172 | associations: { 173 | homeAddress: { 174 | name: 'homeAddress', 175 | target: { 176 | name: 'addresses', 177 | }, 178 | }, 179 | }, 180 | rawAttributes: { 181 | id: {}, 182 | uid: {}, 183 | name: {}, 184 | account: {}, 185 | }, 186 | }; 187 | 188 | const schemas = { 189 | user: { 190 | name: 'user', 191 | fields: [{ 192 | field: 'name', 193 | isVirtual: false, 194 | }], 195 | }, 196 | addresses: { 197 | name: 'addresses', 198 | fields: [ 199 | { 200 | field: 'street', 201 | isVirtual: false, 202 | }, 203 | ], 204 | }, 205 | }; 206 | 207 | const result = extractRequestedFields(fields, model, schemas); 208 | 209 | expect(result).toStrictEqual([ 210 | 'id', 211 | 'name', 212 | 'account', 213 | 'homeAddress.street', 214 | ]); 215 | }); 216 | 217 | it('should include all fields from an association for which a smart field is requested', () => { 218 | expect.assertions(1); 219 | 220 | const fields = { 221 | user: 'name,homeAddress,account', 222 | homeAddress: 'street', 223 | }; 224 | 225 | const model = { 226 | name: 'user', 227 | primaryKeys: { id: null, uid: null }, 228 | associations: { 229 | homeAddress: { 230 | name: 'homeAddress', 231 | target: { 232 | name: 'addresses', 233 | }, 234 | }, 235 | }, 236 | rawAttributes: { 237 | id: {}, 238 | uid: {}, 239 | name: {}, 240 | account: {}, 241 | }, 242 | }; 243 | 244 | const schemas = { 245 | user: { 246 | name: 'user', 247 | fields: [{ 248 | field: 'name', 249 | isVirtual: false, 250 | }], 251 | }, 252 | addresses: { 253 | name: 'addresses', 254 | fields: [ 255 | { 256 | field: 'street', 257 | isVirtual: true, 258 | }, 259 | ], 260 | }, 261 | }; 262 | 263 | const result = extractRequestedFields(fields, model, schemas); 264 | 265 | expect(result).toStrictEqual([ 266 | 'id', 267 | 'name', 268 | 'account', 269 | 'homeAddress', 270 | ]); 271 | }); 272 | 273 | it('should include requested smart field', () => { 274 | expect.assertions(1); 275 | 276 | const fields = { 277 | user: 'smartField', 278 | }; 279 | 280 | const model = { 281 | name: 'user', 282 | primaryKeys: { id: null, uid: null }, 283 | associations: {}, 284 | rawAttributes: { 285 | id: {}, 286 | uid: {}, 287 | }, 288 | }; 289 | 290 | const schemas = { 291 | user: { 292 | name: 'user', 293 | fields: [{ 294 | field: 'smartField', 295 | isVirtual: true, 296 | }, { 297 | field: 'anotherSmartField', 298 | isVirtual: true, 299 | }], 300 | }, 301 | }; 302 | 303 | const result = extractRequestedFields(fields, model, schemas); 304 | 305 | expect(result).toStrictEqual([ 306 | 'id', 307 | 'smartField', 308 | ]); 309 | }); 310 | }); 311 | -------------------------------------------------------------------------------- /test/services/resource-creator.test.js: -------------------------------------------------------------------------------- 1 | import Interface, { scopeManager } from 'forest-express'; 2 | import Sequelize from 'sequelize'; 3 | import associationRecord from '../../src/utils/association-record'; 4 | import ResourceCreator from '../../src/services/resource-creator'; 5 | import ResourceGetter from '../../src/services/resource-getter'; 6 | 7 | describe('services > resource-creator', () => { 8 | const user = { renderingId: 1 }; 9 | const params = { timezone: 'Europe/Paris' }; 10 | 11 | const buildModelMock = () => { 12 | // Sequelize is created here without connection to a database 13 | const sequelize = new Sequelize({ dialect: 'postgres' }); 14 | 15 | const Actor = sequelize.define('actor', { 16 | Id: { 17 | type: Sequelize.DataTypes.INTEGER, 18 | primaryKey: true, 19 | }, 20 | }); 21 | const Author = sequelize.define('author', { 22 | id: { 23 | type: Sequelize.DataTypes.INTEGER, 24 | primaryKey: true, 25 | }, 26 | name: { 27 | type: Sequelize.DataTypes.STRING, 28 | unique: true, 29 | }, 30 | }); 31 | const Film = sequelize.define('film', {}); 32 | 33 | Film.belongsTo(Actor); 34 | Film.belongsTo(Author, { 35 | targetKey: 'name', 36 | }); 37 | 38 | Interface.Schemas.schemas[Actor.name] = {}; 39 | Interface.Schemas.schemas[Film.name] = {}; 40 | 41 | return { Actor, Author, Film }; 42 | }; 43 | 44 | describe('perform', () => { 45 | describe('when the getter does not found the record', () => { 46 | it('should catch the 404 error and return the record', async () => { 47 | expect.assertions(1); 48 | 49 | const { Film } = buildModelMock(); 50 | const record = { dataValues: { id: 1, title: 'The Godfather' } }; 51 | 52 | const error = new Error('Record not found'); 53 | error.statusCode = 404; 54 | jest 55 | .spyOn(ResourceGetter.prototype, 'perform') 56 | .mockRejectedValue(error); 57 | 58 | jest.spyOn(Film.prototype, 'save').mockReturnValue(record); 59 | jest.spyOn(scopeManager, 'getScopeForUser').mockReturnValue({}); 60 | 61 | const body = { actor: 2 }; 62 | 63 | const resourceCreator = new ResourceCreator(Film, params, body, user); 64 | const result = await resourceCreator.perform(); 65 | expect(result).toStrictEqual(record); 66 | }); 67 | }); 68 | 69 | describe('when there is a scope and ResourcesGetter throw an error', () => { 70 | it('should throw the error', async () => { 71 | expect.assertions(1); 72 | 73 | const { Film } = buildModelMock(); 74 | const record = { dataValues: { id: 1, title: 'The Godfather' } }; 75 | 76 | const error = new Error('Unauthorized'); 77 | error.statusCode = 401; 78 | jest 79 | .spyOn(ResourceGetter.prototype, 'perform') 80 | .mockRejectedValue(error); 81 | 82 | jest.spyOn(Film.prototype, 'save').mockReturnValue(record); 83 | jest.spyOn(scopeManager, 'getScopeForUser').mockReturnValue({}); 84 | 85 | const body = { actor: 2 }; 86 | 87 | const resourceCreator = new ResourceCreator(Film, params, body, user); 88 | await expect(resourceCreator.perform()).rejects.toThrow(error); 89 | }); 90 | }); 91 | }); 92 | 93 | describe('_getTargetKey', () => { 94 | describe('when association does not have entry in body', () => { 95 | it('should return null', async () => { 96 | expect.assertions(2); 97 | 98 | const { Film, Actor } = buildModelMock(); 99 | 100 | const body = {}; 101 | 102 | const spy = jest.spyOn(associationRecord, 'get'); 103 | 104 | const resourceCreator = new ResourceCreator(Film, params, body, user); 105 | const targetKey = await resourceCreator._getTargetKey( 106 | Actor.name, 107 | Film.associations[Actor.name], 108 | ); 109 | 110 | expect(spy).not.toHaveBeenCalled(); 111 | expect(targetKey).toBeNil(); 112 | }); 113 | }); 114 | 115 | describe('when association does not have value in body', () => { 116 | it('should return null', async () => { 117 | expect.assertions(2); 118 | 119 | const { Film, Actor } = buildModelMock(); 120 | 121 | const body = { [Actor.name]: null }; 122 | 123 | const spy = jest.spyOn(associationRecord, 'get'); 124 | 125 | const resourceCreator = new ResourceCreator(Film, params, body, user); 126 | const targetKey = await resourceCreator._getTargetKey( 127 | Actor.name, 128 | Film.associations[Actor.name], 129 | ); 130 | 131 | expect(spy).not.toHaveBeenCalled(); 132 | expect(targetKey).toBeNil(); 133 | }); 134 | }); 135 | 136 | describe('when association target key is the primary key', () => { 137 | it('should return the body value', async () => { 138 | expect.assertions(2); 139 | 140 | const { Film, Actor } = buildModelMock(); 141 | 142 | const body = { [Actor.name]: 2 }; 143 | 144 | const spy = jest.spyOn(associationRecord, 'get'); 145 | 146 | const resourceCreator = new ResourceCreator(Film, params, body, user); 147 | const targetKey = await resourceCreator._getTargetKey( 148 | Actor.name, 149 | Film.associations[Actor.name], 150 | ); 151 | 152 | expect(spy).not.toHaveBeenCalled(); 153 | expect(targetKey).toStrictEqual(2); 154 | }); 155 | }); 156 | 157 | describe('when association target key is not the primary key', () => { 158 | it('should return the right value', async () => { 159 | expect.assertions(2); 160 | 161 | const { Film, Author } = buildModelMock(); 162 | 163 | const body = { [Author.name]: 2 }; 164 | 165 | const spy = jest.spyOn(associationRecord, 'get').mockResolvedValue({ id: 2, name: 'Scorsese' }); 166 | 167 | const resourceCreator = new ResourceCreator(Film, params, body, user); 168 | const targetKey = await resourceCreator._getTargetKey( 169 | Author.name, 170 | Film.associations[Author.name], 171 | ); 172 | 173 | expect(spy).toHaveBeenCalledWith(Author, 2); 174 | expect(targetKey).toStrictEqual('Scorsese'); 175 | }); 176 | }); 177 | }); 178 | }); 179 | -------------------------------------------------------------------------------- /test/services/resources-remover.test.js: -------------------------------------------------------------------------------- 1 | import { scopeManager } from 'forest-express'; 2 | import Sequelize, { Op } from 'sequelize'; 3 | import { InvalidParameterError } from '../../src/services/errors'; 4 | import ResourcesRemover from '../../src/services/resources-remover'; 5 | 6 | describe('services > resources-remover', () => { 7 | const user = { renderingId: 1 }; 8 | const params = { timezone: 'Europe/Paris' }; 9 | 10 | const buildModelMock = (dialect) => { 11 | // Sequelize is created here without connection to a database 12 | const sequelize = new Sequelize({ dialect }); 13 | 14 | const Actor = sequelize.define('actor', {}); 15 | const Film = sequelize.define('film', {}); 16 | const ActorFilm = sequelize.define('ActorFilem', { 17 | actorId: { 18 | type: Sequelize.DataTypes.INTEGER, 19 | primaryKey: true, 20 | }, 21 | filmId: { 22 | type: Sequelize.DataTypes.INTEGER, 23 | primaryKey: true, 24 | }, 25 | }); 26 | 27 | ActorFilm.belongsTo(Actor); 28 | ActorFilm.belongsTo(Film); 29 | 30 | return { Actor, Film, ActorFilm }; 31 | }; 32 | 33 | ['mysql', 'mssql', 'postgres'].forEach((dialect) => { 34 | describe(`perform with ${dialect}`, () => { 35 | it('should throw error if ids is not an array or empty', async () => { 36 | expect.assertions(3); 37 | 38 | const { Actor } = buildModelMock(dialect); 39 | const spy = jest.spyOn(scopeManager, 'getScopeForUser').mockReturnValue(null); 40 | 41 | await expect(new ResourcesRemover(Actor, params, [], user).perform()) 42 | .rejects 43 | .toBeInstanceOf(InvalidParameterError); 44 | 45 | await expect(new ResourcesRemover(Actor, params, 'foo', user).perform()) 46 | .rejects 47 | .toBeInstanceOf(InvalidParameterError); 48 | 49 | await expect(new ResourcesRemover(Actor, params, {}, user).perform()) 50 | .rejects 51 | .toBeInstanceOf(InvalidParameterError); 52 | 53 | spy.mockRestore(); 54 | }); 55 | 56 | it('should remove resources with a single primary key', async () => { 57 | expect.assertions(1); 58 | 59 | const { Actor } = buildModelMock(dialect); 60 | const spy = jest.spyOn(scopeManager, 'getScopeForUser').mockReturnValue(null); 61 | jest.spyOn(Actor, 'destroy').mockImplementation((condition) => { 62 | expect(condition).toStrictEqual({ where: { id: ['1', '2'] } }); 63 | }); 64 | 65 | await new ResourcesRemover(Actor, params, ['1', '2'], user).perform(); 66 | spy.mockRestore(); 67 | }); 68 | 69 | it('should remove resources with composite keys', async () => { 70 | expect.assertions(1); 71 | 72 | const { ActorFilm } = buildModelMock(dialect); 73 | const spy = jest.spyOn(scopeManager, 'getScopeForUser').mockReturnValue(null); 74 | jest.spyOn(ActorFilm, 'destroy').mockImplementation((condition) => { 75 | expect(condition.where).toStrictEqual({ 76 | [Op.or]: [ 77 | { actorId: '1', filmId: '2' }, 78 | { actorId: '3', filmId: '4' }, 79 | ], 80 | }); 81 | }); 82 | 83 | await new ResourcesRemover(ActorFilm, params, ['1|2', '3|4'], user).perform(); 84 | spy.mockRestore(); 85 | }); 86 | }); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /test/services/resources-updater.test.js: -------------------------------------------------------------------------------- 1 | import { scopeManager } from 'forest-express'; 2 | import Sequelize from 'sequelize'; 3 | import createError from 'http-errors'; 4 | import ResourceUpdater from '../../src/services/resource-updater'; 5 | import ResourceGetter from '../../src/services/resource-getter'; 6 | import QueryOptions from '../../src/services/query-options'; 7 | 8 | describe('services > resources-updater', () => { 9 | const user = { renderingId: 1 }; 10 | const params = { timezone: 'Europe/Paris' }; 11 | 12 | const buildModelMock = () => { 13 | // Sequelize is created here without connection to a database 14 | const sequelize = new Sequelize({ dialect: 'postgres' }); 15 | 16 | const Actor = sequelize.define('actor', {}); 17 | const Film = sequelize.define('film', {}); 18 | const ActorFilm = sequelize.define('ActorFilem', { 19 | actorId: { 20 | type: Sequelize.DataTypes.INTEGER, 21 | primaryKey: true, 22 | }, 23 | filmId: { 24 | type: Sequelize.DataTypes.INTEGER, 25 | primaryKey: true, 26 | }, 27 | }); 28 | 29 | ActorFilm.belongsTo(Actor); 30 | ActorFilm.belongsTo(Film); 31 | 32 | return { Actor, Film, ActorFilm }; 33 | }; 34 | 35 | describe('when it update with a scope and it is not in scope anymore', () => { 36 | it('should still return the record', async () => { 37 | expect.assertions(1); 38 | 39 | const { Film } = buildModelMock(); 40 | jest.spyOn(scopeManager, 'getScopeForUser').mockReturnValue({ aggregator: 'and', conditions: [{ field: 'name', operator: 'contains', value: 'Scope value' }] }); 41 | 42 | const record = { dataValues: { id: 1, title: 'The Godfather' }, validate: () => {}, save: () => {} }; 43 | 44 | jest.spyOn(record, 'validate'); 45 | jest.spyOn(record, 'save'); 46 | 47 | const error = new Error('Record not found'); 48 | error.statusCode = 404; 49 | jest 50 | .spyOn(ResourceGetter.prototype, 'perform') 51 | .mockRejectedValue(error); 52 | 53 | jest.spyOn(QueryOptions.prototype, 'filterByConditionTree').mockResolvedValue(); 54 | 55 | const resourceUpdater = new ResourceUpdater(Film, params, { name: 'new name' }, user); 56 | jest.spyOn(resourceUpdater._model, 'findOne').mockReturnValue(record); 57 | 58 | const result = await resourceUpdater.perform(); 59 | 60 | expect(result).toStrictEqual(record); 61 | }); 62 | }); 63 | 64 | describe('when it update with a scope but the record does not exist', () => { 65 | it('should throw 404', async () => { 66 | expect.assertions(1); 67 | 68 | const { Film } = buildModelMock(); 69 | jest.spyOn(scopeManager, 'getScopeForUser').mockReturnValue({ aggregator: 'and', conditions: [{ field: 'name', operator: 'contains', value: 'Scope value' }] }); 70 | 71 | const record = null; 72 | 73 | jest.spyOn(QueryOptions.prototype, 'filterByConditionTree').mockResolvedValue(); 74 | 75 | const resourceUpdater = new ResourceUpdater(Film, { ...params, recordId: 2 }, { name: 'new name' }, user); 76 | jest.spyOn(resourceUpdater._model, 'findOne').mockReturnValue(record); 77 | 78 | await expect(resourceUpdater.perform()).rejects.toThrow(createError(404, 'The film #2 does not exist.')); 79 | }); 80 | }); 81 | 82 | describe('when there is a scope and ResourcesGetter throw an error', () => { 83 | it('should throw the error', async () => { 84 | expect.assertions(1); 85 | 86 | const { Film } = buildModelMock(); 87 | jest.spyOn(scopeManager, 'getScopeForUser').mockReturnValue({ aggregator: 'and', conditions: [{ field: 'name', operator: 'contains', value: 'Scope value' }] }); 88 | 89 | const record = { dataValues: { id: 1, title: 'The Godfather' }, validate: () => {}, save: () => {} }; 90 | 91 | jest.spyOn(record, 'validate'); 92 | jest.spyOn(record, 'save'); 93 | 94 | const error = new Error('Unauthorized'); 95 | error.statusCode = 401; 96 | jest 97 | .spyOn(ResourceGetter.prototype, 'perform') 98 | .mockRejectedValue(error); 99 | 100 | jest.spyOn(QueryOptions.prototype, 'filterByConditionTree').mockResolvedValue(); 101 | 102 | const resourceUpdater = new ResourceUpdater(Film, { ...params, recordId: 2 }, { name: 'new name' }, user); 103 | jest.spyOn(resourceUpdater._model, 'findOne').mockReturnValue(record); 104 | 105 | await expect(resourceUpdater.perform()).rejects.toThrow(error); 106 | }); 107 | }); 108 | }); 109 | -------------------------------------------------------------------------------- /test/utils/operators.test.js: -------------------------------------------------------------------------------- 1 | import Sequelize from 'sequelize'; 2 | import Operators from '../../src/utils/operators'; 3 | 4 | describe('utils > operators', () => { 5 | describe('with an old sequelize', () => { 6 | it('should return a valid operator', () => { 7 | expect.assertions(13); 8 | 9 | const Op = new Operators(); 10 | expect(Op.AND).toStrictEqual('$and'); 11 | expect(Op.CONTAINS).toStrictEqual('$contains'); 12 | expect(Op.EQ).toStrictEqual('$eq'); 13 | expect(Op.GT).toStrictEqual('$gt'); 14 | expect(Op.GTE).toStrictEqual('$gte'); 15 | expect(Op.IN).toStrictEqual('$in'); 16 | expect(Op.LIKE).toStrictEqual('$like'); 17 | expect(Op.LT).toStrictEqual('$lt'); 18 | expect(Op.LTE).toStrictEqual('$lte'); 19 | expect(Op.NE).toStrictEqual('$ne'); 20 | expect(Op.NOT).toStrictEqual('$not'); 21 | expect(Op.NOT_LIKE).toStrictEqual('$notLike'); 22 | expect(Op.OR).toStrictEqual('$or'); 23 | }); 24 | }); 25 | 26 | describe('with an up to date sequelize', () => { 27 | it('should return a valid operator', () => { 28 | expect.assertions(13); 29 | 30 | const Op = new Operators({ Sequelize }); 31 | expect(Op.AND).toStrictEqual(Sequelize.Op.and); 32 | expect(Op.CONTAINS).toStrictEqual(Sequelize.Op.contains); 33 | expect(Op.EQ).toStrictEqual(Sequelize.Op.eq); 34 | expect(Op.GT).toStrictEqual(Sequelize.Op.gt); 35 | expect(Op.GTE).toStrictEqual(Sequelize.Op.gte); 36 | expect(Op.IN).toStrictEqual(Sequelize.Op.in); 37 | expect(Op.LIKE).toStrictEqual(Sequelize.Op.like); 38 | expect(Op.LT).toStrictEqual(Sequelize.Op.lt); 39 | expect(Op.LTE).toStrictEqual(Sequelize.Op.lte); 40 | expect(Op.NE).toStrictEqual(Sequelize.Op.ne); 41 | expect(Op.NOT).toStrictEqual(Sequelize.Op.not); 42 | expect(Op.NOT_LIKE).toStrictEqual(Sequelize.Op.notLike); 43 | expect(Op.OR).toStrictEqual(Sequelize.Op.or); 44 | }); 45 | }); 46 | 47 | describe('getInstance', () => { 48 | it('should return the same object', () => { 49 | expect.assertions(1); 50 | 51 | const emptyOptions = {}; 52 | const Op = Operators.getInstance(emptyOptions); 53 | 54 | expect(Op).toStrictEqual(Operators.getInstance(emptyOptions)); 55 | }); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /test/utils/query.test.js: -------------------------------------------------------------------------------- 1 | const { Sequelize } = require('sequelize'); 2 | const { getReferenceField, mergeWhere } = require('../../src/utils/query'); 3 | 4 | const operators = { AND: '$and' }; 5 | 6 | describe('utils > query', () => { 7 | describe('getReferenceField', () => { 8 | it('should return a valid reference when the field name is camelCased', () => { 9 | expect.assertions(1); 10 | 11 | const modelSchema = { 12 | fields: [{ field: 'car', reference: 'car.id' }], 13 | }; 14 | const schemas = { 15 | driver: modelSchema, 16 | car: { fields: [{ field: 'brandName', columnName: 'brand_name' }] }, 17 | }; 18 | 19 | const field = getReferenceField(schemas, modelSchema, 'car', 'brandName'); 20 | expect(field).toStrictEqual('car.brand_name'); 21 | }); 22 | }); 23 | 24 | describe('mergeWhere', () => { 25 | it('should work if only one simple condition is passed', () => { 26 | expect.assertions(1); 27 | 28 | const condition = mergeWhere(operators, { id: 1 }); 29 | expect(condition).toStrictEqual({ id: 1 }); 30 | }); 31 | 32 | it('should work if only one unmergeable condition is passed', () => { 33 | expect.assertions(1); 34 | 35 | const condition = mergeWhere(operators, Sequelize.literal('FALSE')); 36 | expect(condition).toStrictEqual(Sequelize.literal('FALSE')); 37 | }); 38 | 39 | it('should merge conditions where different keys are used', () => { 40 | expect.assertions(1); 41 | 42 | const condition = mergeWhere(operators, { id: 1 }, { name: 'John' }); 43 | expect(condition).toStrictEqual({ 44 | id: 1, 45 | name: 'John', 46 | }); 47 | }); 48 | 49 | it('should not merge conditions where the same keys are used', () => { 50 | expect.assertions(1); 51 | 52 | const condition = mergeWhere(operators, { id: 1 }, { id: 2 }); 53 | expect(condition).toStrictEqual({ 54 | $and: [ 55 | { id: 1 }, 56 | { id: 2 }, 57 | ], 58 | }); 59 | }); 60 | 61 | it('should not merge conditions which are not plain objects', () => { 62 | expect.assertions(1); 63 | 64 | const condition = mergeWhere(operators, { id: 1 }, Sequelize.literal('FALSE')); 65 | expect(condition).toStrictEqual({ 66 | $and: [ 67 | { id: 1 }, 68 | Sequelize.literal('FALSE'), 69 | ], 70 | }); 71 | }); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /test/utils/sequelize-compatibility.test.js: -------------------------------------------------------------------------------- 1 | const { postProcess } = require('../../src/utils/sequelize-compatibility'); 2 | 3 | const sequelize = { constructor: { version: '4.44.4' } }; 4 | const Model = { 5 | sequelize, 6 | name: 'model', 7 | associations: { 8 | submodelAlias: { 9 | target: { 10 | sequelize, 11 | name: 'submodel', 12 | associations: { 13 | subsubmodel1Alias: { 14 | target: { 15 | sequelize, 16 | name: 'subsubmodel2', 17 | associations: {}, 18 | }, 19 | }, 20 | subsubmodel2Alias: { 21 | target: { 22 | sequelize, 23 | name: 'subsubmodel1', 24 | associations: {}, 25 | }, 26 | }, 27 | }, 28 | }, 29 | }, 30 | }, 31 | }; 32 | 33 | const SubModel = Model.associations.submodelAlias.target; 34 | const SubSubModel1 = SubModel.associations.subsubmodel1Alias.target; 35 | const SubSubModel2 = SubModel.associations.subsubmodel2Alias.target; 36 | 37 | 38 | describe('utils > sequelize-compatibility', () => { 39 | describe('postProcess -> normalizeInclude', () => { 40 | it('should rewrite the include when using the {model, as} syntax', () => { 41 | expect.assertions(1); 42 | 43 | const options = postProcess(Model, { include: [{ model: SubModel, as: 'submodelAlias' }] }); 44 | expect(options).toStrictEqual({ 45 | include: [{ as: 'submodelAlias', model: SubModel }], 46 | }); 47 | }); 48 | 49 | it('should rewrite the include when using the {model} syntax', () => { 50 | expect.assertions(1); 51 | 52 | const options = postProcess(Model, { include: [{ model: SubModel }] }); 53 | expect(options).toStrictEqual({ 54 | include: [{ as: 'submodelAlias', model: SubModel }], 55 | }); 56 | }); 57 | 58 | it('should rewrite the include when using the {as} syntax', () => { 59 | expect.assertions(1); 60 | 61 | const options = postProcess(Model, { include: [{ as: 'submodelAlias' }] }); 62 | expect(options).toStrictEqual({ 63 | include: [{ as: 'submodelAlias', model: SubModel }], 64 | }); 65 | }); 66 | 67 | it('should rewrite the include when using the {association} syntax', () => { 68 | expect.assertions(1); 69 | 70 | const options = postProcess(Model, { include: [{ association: 'submodelAlias' }] }); 71 | expect(options).toStrictEqual({ 72 | include: [{ as: 'submodelAlias', model: SubModel }], 73 | }); 74 | }); 75 | 76 | it('should rewrite the include when using the string syntax', () => { 77 | expect.assertions(1); 78 | 79 | const options = postProcess(Model, { include: ['submodelAlias'] }); 80 | expect(options).toStrictEqual({ 81 | include: [{ as: 'submodelAlias', model: SubModel }], 82 | }); 83 | }); 84 | 85 | it('should rewrite the include when using the Model syntax', () => { 86 | expect.assertions(1); 87 | 88 | const options = postProcess(Model, { include: [SubModel] }); 89 | expect(options).toStrictEqual({ 90 | include: [{ as: 'submodelAlias', model: SubModel }], 91 | }); 92 | }); 93 | }); 94 | 95 | describe('postProcess', () => { 96 | it('bubble where conditions', () => { 97 | expect.assertions(1); 98 | 99 | const options = postProcess(Model, { 100 | include: [{ as: 'submodelAlias', where: { id: 1 } }], 101 | where: { id: 1 }, 102 | }); 103 | 104 | expect(options).toStrictEqual({ 105 | include: [{ as: 'submodelAlias', model: SubModel }], 106 | where: { id: 1, '$submodelAlias.id$': 1 }, 107 | }); 108 | }); 109 | 110 | it('should add attributes when both sides are defined', () => { 111 | expect.assertions(1); 112 | 113 | const options = postProcess(Model, { 114 | include: [ 115 | { as: 'submodelAlias', attributes: ['id'] }, 116 | { as: 'submodelAlias', attributes: ['name'] }, 117 | ], 118 | }); 119 | 120 | expect(options).toStrictEqual({ 121 | include: [{ as: 'submodelAlias', model: SubModel, attributes: ['id', 'name'] }], 122 | }); 123 | }); 124 | 125 | it('should drop attributes when either side is undefined', () => { 126 | expect.assertions(1); 127 | 128 | const options = postProcess(Model, { 129 | include: [ 130 | { as: 'submodelAlias', attributes: ['id'] }, 131 | { as: 'submodelAlias' }, 132 | ], 133 | }); 134 | 135 | expect(options).toStrictEqual({ 136 | include: [{ as: 'submodelAlias', model: SubModel }], 137 | }); 138 | }); 139 | 140 | it('should not crash if the root where conditions are undefined', () => { 141 | expect.assertions(1); 142 | 143 | const options = postProcess(Model, { 144 | include: [{ as: 'submodelAlias', where: { id: 1 } }], 145 | }); 146 | 147 | expect(options).toStrictEqual({ 148 | include: [{ as: 'submodelAlias', model: SubModel }], 149 | where: { '$submodelAlias.id$': 1 }, 150 | }); 151 | }); 152 | 153 | it('should merge includes when there are duplicates (for sequelize < 5)', () => { 154 | expect.assertions(1); 155 | 156 | const options = postProcess(Model, { 157 | include: [ 158 | SubModel, 159 | 'submodelAlias', 160 | { as: 'submodelAlias', where: { id: 1 } }, 161 | { association: 'submodelAlias', where: { title: 'Title' } }, 162 | { model: SubModel, where: { subTitle: 'subTitle' } }, 163 | ], 164 | where: { 165 | '$submodelAlias.rating$': 34, 166 | }, 167 | }); 168 | 169 | expect(options).toStrictEqual({ 170 | include: [{ as: 'submodelAlias', model: SubModel }], 171 | where: { 172 | '$submodelAlias.id$': 1, 173 | '$submodelAlias.title$': 'Title', 174 | '$submodelAlias.subTitle$': 'subTitle', 175 | '$submodelAlias.rating$': 34, 176 | }, 177 | }); 178 | }); 179 | 180 | it('should do all of the above recursively', () => { 181 | expect.assertions(1); 182 | 183 | const options = postProcess(Model, { 184 | include: [ 185 | SubModel, 186 | 'submodelAlias', 187 | { 188 | as: 'submodelAlias', 189 | include: [ 190 | { 191 | as: 'subsubmodel1Alias', 192 | model: SubSubModel1, 193 | where: { subsubTitle: 'subsubtitle1' }, 194 | }, 195 | ], 196 | where: { id: 1 }, 197 | }, 198 | { association: 'submodelAlias', where: { title: 'Title' } }, 199 | { 200 | model: SubModel, 201 | include: [ 202 | { 203 | model: SubSubModel2, 204 | where: { subsubTitle: 'subsubtitle2' }, 205 | }, 206 | ], 207 | where: { subTitle: 'subTitle' }, 208 | }, 209 | ], 210 | where: { 211 | '$submodelAlias.rating$': 34, 212 | }, 213 | }); 214 | 215 | expect(options).toStrictEqual({ 216 | include: [ 217 | { 218 | as: 'submodelAlias', 219 | model: SubModel, 220 | include: [ 221 | { as: 'subsubmodel1Alias', model: SubSubModel1 }, 222 | { as: 'subsubmodel2Alias', model: SubSubModel2 }, 223 | ], 224 | }, 225 | ], 226 | where: { 227 | '$submodelAlias.id$': 1, 228 | '$submodelAlias.title$': 'Title', 229 | '$submodelAlias.subTitle$': 'subTitle', 230 | '$submodelAlias.rating$': 34, 231 | '$submodelAlias.subsubmodel1Alias.subsubTitle$': 'subsubtitle1', 232 | '$submodelAlias.subsubmodel2Alias.subsubTitle$': 'subsubtitle2', 233 | }, 234 | }); 235 | }); 236 | 237 | 238 | it('should add attributes when sub include is not an array', () => { 239 | expect.assertions(1); 240 | 241 | const options = postProcess(Model, { 242 | include: [{ as: 'submodelAlias', attributes: ['id'], include: 'subsubmodel1Alias' }], 243 | }); 244 | 245 | expect(options).toStrictEqual({ 246 | include: [{ 247 | as: 'submodelAlias', 248 | model: SubModel, 249 | attributes: ['id'], 250 | include: [{ as: 'subsubmodel1Alias', model: SubSubModel1 }], 251 | }], 252 | }); 253 | }); 254 | }); 255 | 256 | describe('postProcess -> removeDuplicateAssociations', () => { 257 | describe('when include alias is not valid', () => { 258 | it('should not throw an error', () => { 259 | expect.assertions(1); 260 | 261 | const include = [{ model: SubModel, as: 'notAValidAlias', include: SubModel }]; 262 | 263 | expect(() => postProcess(Model, { include })).not.toThrow(); 264 | }); 265 | }); 266 | 267 | describe('when the main model is reincluded', () => { 268 | it('should not throw an error', () => { 269 | expect.assertions(1); 270 | 271 | const include = [{ model: Model, include: SubSubModel1 }]; 272 | 273 | expect(() => postProcess(Model, { include })).not.toThrow(); 274 | }); 275 | }); 276 | }); 277 | }); 278 | -------------------------------------------------------------------------------- /types/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": [ 5 | "@typescript-eslint" 6 | ], 7 | "rules": { 8 | "@typescript-eslint/no-explicit-any": "off", 9 | "@typescript-eslint/array-type": "error" 10 | }, 11 | "extends": [ 12 | "eslint:recommended", 13 | "plugin:@typescript-eslint/eslint-recommended", 14 | "plugin:@typescript-eslint/recommended" 15 | ] 16 | } 17 | --------------------------------------------------------------------------------