├── src ├── hooks │ ├── index.ts │ ├── dehydrate.ts │ └── hydrate.ts ├── internal.types.ts ├── declarations.ts ├── utils.ts ├── index.ts └── adapter.ts ├── tsconfig.eslint.json ├── eslint.config.mjs ├── tsconfig.json ├── .editorconfig ├── .mocharc.cjs ├── .vscode └── launch.json ├── .gitignore ├── .nycrc ├── .github ├── issue_template.md ├── workflows │ ├── update-dependencies.yml │ └── nodejs.yml ├── pull_request_template.md └── contributing.md ├── LICENSE ├── test ├── connection.ts ├── hooks.hydrate.test.ts ├── hooks.dehydrate.test.ts ├── utils.test.ts └── index.test.ts ├── package.json ├── dist ├── index.d.cts ├── index.d.mts ├── index.d.ts ├── index.mjs └── index.cjs ├── tsconfig.test.json └── README.md /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './dehydrate.js' 2 | export * from './hydrate.js' 3 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["**/*.ts"], 4 | "exclude": ["node_modules", "lib", "coverage", ".mocharc.cjs"] 5 | } -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import config from '@feathers-community/eslint-config' 2 | 3 | export default config({ 4 | tsconfig: { 5 | path: './tsconfig.eslint.json', 6 | }, 7 | }) 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node22/tsconfig.json", 3 | "compilerOptions": { 4 | "esModuleInterop": true 5 | }, 6 | "include": ["src/**/*.ts"], 7 | "exclude": ["node_modules"], 8 | } 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /src/internal.types.ts: -------------------------------------------------------------------------------- 1 | import type { Paginated, PaginationOptions } from '@feathersjs/feathers' 2 | 3 | export type PaginatedOrArray = P extends { paginate: false } 4 | ? R[] 5 | : P extends { paginate: PaginationOptions } 6 | ? Paginated 7 | : Paginated | R[] 8 | -------------------------------------------------------------------------------- /.mocharc.cjs: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const path = require('node:path') 3 | 4 | /** 5 | * @type {import('mocha').MochaOptions} 6 | */ 7 | module.exports = { 8 | extension: ['ts', 'js'], 9 | package: path.join(__dirname, './package.json'), 10 | ui: 'bdd', 11 | require: ['tsx'], 12 | spec: ['./test/**/*.test.*'], 13 | exit: true, 14 | } 15 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "resolveSourceMapLocations": [ 7 | "${workspaceFolder}/**", 8 | "!**/node_modules/**" 9 | ], 10 | "request": "launch", 11 | "name": "Mocha Tests", 12 | "program": "${workspaceFolder}/node_modules/mocha/bin/mocha", 13 | "args": [ 14 | "--timeout", 15 | "60000", 16 | "--colors", 17 | "--recursive" 18 | ], 19 | "internalConsoleOptions": "openOnSessionStart", 20 | "outputCapture": "std", 21 | "skipFiles": [ 22 | "/**" 23 | ] 24 | }, 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /src/declarations.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AdapterParams, 3 | AdapterQuery, 4 | AdapterServiceOptions, 5 | } from '@feathersjs/adapter-commons' 6 | import type { Includeable, ModelStatic } from 'sequelize' 7 | 8 | export interface SequelizeAdapterOptions extends AdapterServiceOptions { 9 | Model: ModelStatic 10 | raw?: boolean 11 | operatorMap?: Record 12 | } 13 | 14 | export interface SequelizeAdapterParams 15 | extends AdapterParams> { 16 | sequelize?: any // FindOptions | CreateOptions | BulkCreateOptions 17 | } 18 | 19 | export type HydrateOptions = { 20 | include?: Includeable | Includeable[] 21 | } 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | # Logs 4 | logs 5 | *.log 6 | 7 | # Runtime data 8 | pids 9 | *.pid 10 | *.seed 11 | 12 | # Directory for instrumented libs generated by jscoverage/JSCover 13 | lib-cov 14 | 15 | # Coverage directory used by tools like istanbul 16 | coverage 17 | 18 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 19 | .grunt 20 | 21 | # Compiled binary addons (http://nodejs.org/api/addons.html) 22 | build/Release 23 | 24 | # Dependency directory 25 | # Commenting this out is preferred by some people, see 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 27 | node_modules 28 | 29 | # Users Environment Variables 30 | .lock-wscript 31 | 32 | lib/ 33 | db.sqlite 34 | -------------------------------------------------------------------------------- /.nycrc: -------------------------------------------------------------------------------- 1 | { 2 | "verbose": false, 3 | "tempDirectory": "./coverage/.tmp", 4 | "extension": [ 5 | ".ts", 6 | ".tsx", 7 | ".js" 8 | ], 9 | "include": [ 10 | "src/**/*.js", 11 | "src/**/*.ts" 12 | ], 13 | "exclude": [ 14 | "coverage/**", 15 | "node_modules/**", 16 | "**/*.d.ts", 17 | "**/*.test.ts" 18 | ], 19 | "print": "detail", 20 | "reporter": [ 21 | "html", 22 | "text", 23 | "text-summary", 24 | "lcov" 25 | ], 26 | "watermarks": { 27 | "statements": [ 28 | 70, 29 | 90 30 | ], 31 | "lines": [ 32 | 70, 33 | 90 34 | ], 35 | "functions": [ 36 | 70, 37 | 90 38 | ], 39 | "branches": [ 40 | 70, 41 | 90 42 | ] 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /.github/issue_template.md: -------------------------------------------------------------------------------- 1 | ### Steps to reproduce 2 | 3 | (First please check that this issue is not already solved as [described 4 | here](https://github.com/feathersjs/feathers/blob/master/.github/contributing.md#report-a-bug)) 5 | 6 | - [ ] Tell us what broke. The more detailed the better. 7 | - [ ] If you can, please create a simple example that reproduces the issue and link to a gist, jsbin, repo, etc. 8 | 9 | ### Expected behavior 10 | Tell us what should happen 11 | 12 | ### Actual behavior 13 | Tell us what happens instead 14 | 15 | ### System configuration 16 | 17 | Tell us about the applicable parts of your setup. 18 | 19 | **Module versions** (especially the part that's not working): 20 | 21 | **NodeJS version**: 22 | 23 | **Operating System**: 24 | 25 | **Browser Version**: 26 | 27 | **React Native Version**: 28 | 29 | **Module Loader**: -------------------------------------------------------------------------------- /.github/workflows/update-dependencies.yml: -------------------------------------------------------------------------------- 1 | name: Update dependencies 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 1 * *' 6 | workflow_dispatch: 7 | jobs: 8 | update-dependencies: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - name: Use Node.js 13 | uses: actions/setup-node@v1 14 | with: 15 | node-version: '15.x' 16 | - run: npm ci 17 | - run: | 18 | git config user.name "GitHub Actions Bot" 19 | git config user.email "hello@feathersjs.com" 20 | git checkout -b update-dependencies-$GITHUB_RUN_ID 21 | - run: | 22 | npm run update-dependencies 23 | npm install 24 | - run: | 25 | git commit -am "chore(dependencies): Update dependencies" 26 | git push origin update-dependencies-$GITHUB_RUN_ID 27 | - run: | 28 | gh pr create --title "chore(dependencies): Update all dependencies" --body "" 29 | env: 30 | GITHUB_TOKEN: ${{secrets.CI_ACCESS_TOKEN}} 31 | -------------------------------------------------------------------------------- /src/hooks/dehydrate.ts: -------------------------------------------------------------------------------- 1 | import type { HookContext } from '@feathersjs/feathers' 2 | 3 | const serialize = (item: any) => { 4 | if (typeof item.toJSON === 'function') { 5 | return item.toJSON() 6 | } 7 | return item 8 | } 9 | 10 | export const dehydrate = () => { 11 | return function (context: H) { 12 | switch (context.method) { 13 | case 'find': 14 | if (context.result.data) { 15 | context.result.data = context.result.data.map(serialize) 16 | } else { 17 | context.result = context.result.map(serialize) 18 | } 19 | break 20 | 21 | case 'get': 22 | case 'update': 23 | context.result = serialize(context.result) 24 | break 25 | 26 | case 'create': 27 | case 'patch': 28 | if (Array.isArray(context.result)) { 29 | context.result = context.result.map(serialize) 30 | } else { 31 | context.result = serialize(context.result) 32 | } 33 | break 34 | } 35 | return Promise.resolve(context) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ### Summary 2 | 3 | (If you have not already please refer to the contributing guideline as [described 4 | here](https://github.com/feathersjs/feathers/blob/master/.github/contributing.md#pull-requests)) 5 | 6 | - [ ] Tell us about the problem your pull request is solving. 7 | - [ ] Are there any open issues that are related to this? 8 | - [ ] Is this PR dependent on PRs in other repos? 9 | 10 | If so, please mention them to keep the conversations linked together. 11 | 12 | ### Other Information 13 | 14 | If there's anything else that's important and relevant to your pull 15 | request, mention that information here. This could include 16 | benchmarks, or other information. 17 | 18 | Your PR will be reviewed by a core team member and they will work with you to get your changes merged in a timely manner. If merged your PR will automatically be added to the changelog in the next release. 19 | 20 | If your changes involve documentation updates please mention that and link the appropriate PR in [feathers-docs](https://github.com/feathersjs/feathers-docs). 21 | 22 | Thanks for contributing to Feathers! :heart: -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2025 Feathers 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/connection.ts: -------------------------------------------------------------------------------- 1 | import type { Options } from 'sequelize' 2 | import { Sequelize } from 'sequelize' 3 | 4 | export default (DB?: 'postgres' | 'mysql' | 'mariadb' | (string & {})) => { 5 | const logging: Options['logging'] = false 6 | 7 | if (DB === 'postgres') { 8 | return new Sequelize( 9 | process.env.POSTGRES_DB ?? 'sequelize', 10 | process.env.POSTGRES_USER ?? 'postgres', 11 | process.env.POSTGRES_PASSWORD ?? 'password', 12 | { 13 | host: 'localhost', 14 | dialect: 'postgres', 15 | logging, 16 | }, 17 | ) 18 | } else if (DB === 'mysql') { 19 | return new Sequelize( 20 | process.env.MYSQl_DATABASE ?? 'sequelize', 21 | process.env.MYSQL_USER ?? 'root', 22 | process.env.MYSQL_PASSWORD ?? '', 23 | { 24 | host: '127.0.0.1', 25 | dialect: 'mysql', 26 | logging, 27 | }, 28 | ) 29 | } else if (DB === 'mariadb') { 30 | return new Sequelize( 31 | process.env.MARIADB_DATABASE ?? 'sequelize', 32 | process.env.MARIADB_USER ?? 'sequelize', 33 | process.env.MARIADB_PASSWORD ?? 'password', 34 | { 35 | host: 'localhost', 36 | port: 3306, 37 | dialect: 'mariadb', 38 | logging, 39 | }, 40 | ) 41 | } 42 | 43 | return new Sequelize('sequelize', '', '', { 44 | dialect: 'sqlite', 45 | storage: './db.sqlite', 46 | logging, 47 | }) 48 | } 49 | -------------------------------------------------------------------------------- /src/hooks/hydrate.ts: -------------------------------------------------------------------------------- 1 | import type { HookContext } from '@feathersjs/feathers' 2 | import type { ModelStatic, Model, Includeable } from 'sequelize' 3 | import type { HydrateOptions } from '../declarations.js' 4 | 5 | const factory = ( 6 | Model: ModelStatic, 7 | include?: Includeable | Includeable[], 8 | ) => { 9 | return (item: any) => { 10 | // (Darren): We have to check that the Model.Instance static property exists 11 | // first since it's been deprecated in Sequelize 4.x. 12 | // See: http://docs.sequelizejs.com/manual/tutorial/upgrade-to-v4.html 13 | const shouldBuild = !(item instanceof Model) 14 | 15 | if (shouldBuild) { 16 | return Model.build(item, { isNewRecord: false, include }) 17 | } 18 | 19 | return item 20 | } 21 | } 22 | 23 | export const hydrate = ( 24 | options?: HydrateOptions, 25 | ) => { 26 | options = options || {} 27 | 28 | return (context: H) => { 29 | if (context.type !== 'after') { 30 | throw new Error( 31 | 'feathers-sequelize hydrate() - should only be used as an "after" hook', 32 | ) 33 | } 34 | 35 | const makeInstance = factory(context.service.Model, options.include) 36 | switch (context.method) { 37 | case 'find': 38 | if (context.result.data) { 39 | context.result.data = context.result.data.map(makeInstance) 40 | } else { 41 | context.result = context.result.map(makeInstance) 42 | } 43 | break 44 | 45 | case 'get': 46 | case 'update': 47 | context.result = makeInstance(context.result) 48 | break 49 | 50 | case 'create': 51 | case 'patch': 52 | if (Array.isArray(context.result)) { 53 | context.result = context.result.map(makeInstance) 54 | } else { 55 | context.result = makeInstance(context.result) 56 | } 57 | break 58 | } 59 | return Promise.resolve(context) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import type { FeathersError } from '@feathersjs/errors' 2 | import { 3 | BadRequest, 4 | Forbidden, 5 | GeneralError, 6 | NotFound, 7 | Timeout, 8 | Unavailable, 9 | } from '@feathersjs/errors' 10 | import type { BaseError } from 'sequelize' 11 | export const ERROR = Symbol('feathers-sequelize/error') 12 | 13 | const wrap = (error: FeathersError, original: BaseError) => 14 | Object.assign(error, { [ERROR]: original }) 15 | 16 | export const errorHandler = (error: any) => { 17 | const { name, message } = error 18 | 19 | if (name.startsWith('Sequelize')) { 20 | switch (name) { 21 | case 'SequelizeValidationError': 22 | case 'SequelizeUniqueConstraintError': 23 | case 'SequelizeExclusionConstraintError': 24 | case 'SequelizeForeignKeyConstraintError': 25 | case 'SequelizeInvalidConnectionError': 26 | throw wrap(new BadRequest(message, { errors: error.errors }), error) 27 | case 'SequelizeTimeoutError': 28 | case 'SequelizeConnectionTimedOutError': 29 | throw wrap(new Timeout(message), error) 30 | case 'SequelizeConnectionRefusedError': 31 | case 'SequelizeAccessDeniedError': 32 | throw wrap(new Forbidden(message), error) 33 | case 'SequelizeHostNotReachableError': 34 | throw wrap(new Unavailable(message), error) 35 | case 'SequelizeHostNotFoundError': 36 | throw wrap(new NotFound(message), error) 37 | default: 38 | throw wrap(new GeneralError(message), error) 39 | } 40 | } 41 | 42 | throw error 43 | } 44 | 45 | export const getOrder = (sort: Record = {}): [string, string][] => 46 | Object.keys(sort).reduce( 47 | (order, name) => { 48 | let direction: 'ASC' | 'DESC' | 'ASC NULLS FIRST' | 'DESC NULLS LAST' 49 | if (Array.isArray(sort[name])) { 50 | direction = parseInt(sort[name][0], 10) === 1 ? 'ASC' : 'DESC' 51 | direction += 52 | parseInt(sort[name][1], 10) === 1 ? ' NULLS FIRST' : ' NULLS LAST' 53 | } else { 54 | direction = parseInt(sort[name], 10) === 1 ? 'ASC' : 'DESC' 55 | } 56 | order.push([name, direction]) 57 | 58 | return order 59 | }, 60 | [] as [string, string][], 61 | ) 62 | 63 | export const isPlainObject = (obj: any): boolean => { 64 | return !!obj && obj.constructor === {}.constructor 65 | } 66 | 67 | export const isPresent = (obj: any): boolean => { 68 | if (Array.isArray(obj)) { 69 | return obj.length > 0 70 | } 71 | if (isPlainObject(obj)) { 72 | return Object.keys(obj).length > 0 73 | } 74 | return !!obj 75 | } 76 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { SequelizeAdapter } from './adapter.js' 2 | import type { SequelizeAdapterParams } from './declarations.js' 3 | import type { 4 | Id, 5 | Paginated, 6 | Params, 7 | ServiceMethods, 8 | } from '@feathersjs/feathers' 9 | import type { PaginationOptions } from '@feathersjs/adapter-commons' 10 | import type { PaginatedOrArray } from './internal.types.js' 11 | 12 | export * from './declarations.js' 13 | export * from './adapter.js' 14 | export * from './hooks/index.js' 15 | export { ERROR, errorHandler } from './utils.js' 16 | 17 | export class SequelizeService< 18 | Result = any, 19 | Data = Partial, 20 | ServiceParams extends Params = SequelizeAdapterParams, 21 | PatchData = Partial, 22 | > 23 | extends SequelizeAdapter 24 | implements 25 | ServiceMethods, Data, ServiceParams, PatchData> 26 | { 27 | async find< 28 | P extends ServiceParams & { paginate?: PaginationOptions | false }, 29 | >(params?: P): Promise> { 30 | return this._find(params) as any 31 | } 32 | 33 | async get(id: Id, params?: ServiceParams): Promise { 34 | return this._get(id, params) 35 | } 36 | 37 | async create(data: Data, params?: ServiceParams): Promise 38 | async create(data: Data[], params?: ServiceParams): Promise 39 | async create( 40 | data: Data | Data[], 41 | params?: ServiceParams, 42 | ): Promise 43 | async create( 44 | data: Data | Data[], 45 | params?: ServiceParams, 46 | ): Promise { 47 | return this._create(data, params) 48 | } 49 | 50 | async update(id: Id, data: Data, params?: ServiceParams): Promise { 51 | return this._update(id, data, params) 52 | } 53 | 54 | async patch(id: Id, data: PatchData, params?: ServiceParams): Promise 55 | async patch( 56 | id: null, 57 | data: PatchData, 58 | params?: ServiceParams, 59 | ): Promise 60 | async patch( 61 | id: Id | null, 62 | data: PatchData, 63 | params?: ServiceParams, 64 | ): Promise { 65 | return this._patch(id as any /** fighting overloads */, data, params) 66 | } 67 | 68 | async remove(id: Id, params?: ServiceParams): Promise 69 | async remove(id: null, params?: ServiceParams): Promise 70 | async remove( 71 | id: Id | null, 72 | params?: ServiceParams, 73 | ): Promise { 74 | return this._remove(id as any /** fighting overloads */, params) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "feathers-sequelize", 3 | "description": "A service adapter for Sequelize an SQL ORM", 4 | "version": "8.0.0-pre.3", 5 | "homepage": "https://github.com/feathersjs-ecosystem/feathers-sequelize", 6 | "keywords": [ 7 | "feathers", 8 | "feathers-plugin", 9 | "sequel", 10 | "sequelize", 11 | "mysql", 12 | "sqlite", 13 | "mariadb", 14 | "postgres", 15 | "pg", 16 | "mssql", 17 | "database" 18 | ], 19 | "license": "MIT", 20 | "repository": { 21 | "type": "git", 22 | "url": "git://github.com/feathersjs-ecosystem/feathers-sequelize.git" 23 | }, 24 | "author": { 25 | "name": "Feathers contributors", 26 | "email": "hello@feathersjs.com", 27 | "url": "https://feathersjs.com" 28 | }, 29 | "contributors": [], 30 | "bugs": { 31 | "url": "https://github.com/feathersjs-ecosystem/feathers-sequelize/issues" 32 | }, 33 | "exports": { 34 | ".": { 35 | "types": "./dist/index.d.ts", 36 | "import": "./dist/index.mjs", 37 | "require": "./dist/index.cjs" 38 | } 39 | }, 40 | "main": "./dist/index.cjs", 41 | "module": "./dist/index.mjs", 42 | "types": "./dist/index.d.ts", 43 | "type": "module", 44 | "engines": { 45 | "node": ">= 20" 46 | }, 47 | "files": [ 48 | "CHANGELOG.md", 49 | "LICENSE", 50 | "README.md", 51 | "src/**", 52 | "lib/**" 53 | ], 54 | "scripts": { 55 | "prepublishOnly": "npm run compile", 56 | "compile": "unbuild", 57 | "publish": "git push origin --tags && npm run changelog && git push origin", 58 | "changelog": "github_changelog_generator -u feathersjs-ecosystem -p feathers-sequelize && git add CHANGELOG.md && git commit -am \"Updating changelog\"", 59 | "update-dependencies": "ncu -u", 60 | "release:prerelease": "npm version prerelease --preid pre && npm publish --tag pre", 61 | "release:patch": "npm version patch && npm publish", 62 | "release:minor": "npm version minor && npm publish", 63 | "release:major": "npm version major && npm publish", 64 | "lint": "eslint", 65 | "test": "mocha", 66 | "coverage": "c8 npm run mocha" 67 | }, 68 | "dependencies": { 69 | "@feathersjs/adapter-commons": "^5.0.34", 70 | "@feathersjs/commons": "^5.0.34", 71 | "@feathersjs/errors": "^5.0.34" 72 | }, 73 | "devDependencies": { 74 | "@feathers-community/eslint-config": "^0.0.6", 75 | "@feathersjs/adapter-tests": "^5.0.34", 76 | "@feathersjs/feathers": "^5.0.34", 77 | "@tsconfig/node22": "^22.0.1", 78 | "@types/chai": "^5.2.1", 79 | "@types/mocha": "^10.0.10", 80 | "@types/node": "^22.15.3", 81 | "@types/pg": "^8.11.14", 82 | "c8": "^10.1.3", 83 | "chai": "^5.2.0", 84 | "eslint": "^9.26.0", 85 | "mariadb": "^3.4.2", 86 | "mocha": "^11.2.2", 87 | "mysql2": "^3.14.1", 88 | "npm-check-updates": "^18.0.1", 89 | "pg": "^8.15.6", 90 | "pg-hstore": "^2.3.4", 91 | "sequelize": "^6.37.7", 92 | "shx": "^0.4.0", 93 | "sqlite3": "^5.1.7", 94 | "tsx": "^4.19.4", 95 | "typescript": "^5.8.3", 96 | "unbuild": "^3.5.0" 97 | }, 98 | "peerDependencies": { 99 | "@feathersjs/feathers": "^5.0.0", 100 | "sequelize": "^6.0.0" 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /test/hooks.hydrate.test.ts: -------------------------------------------------------------------------------- 1 | import type { HookContext } from '@feathersjs/feathers' 2 | import { expect } from 'chai' 3 | import Sequelize from 'sequelize' 4 | 5 | import { hydrate } from '../src/hooks/hydrate' 6 | import makeConnection from './connection' 7 | const sequelize = makeConnection(process.env.DB) 8 | 9 | type MethodName = 'find' | 'get' | 'create' | 'update' | 'patch' | 'remove' 10 | 11 | const BlogPost = sequelize.define( 12 | 'blogpost', 13 | { 14 | title: { 15 | type: Sequelize.STRING, 16 | allowNull: false, 17 | }, 18 | }, 19 | { 20 | freezeTableName: true, 21 | }, 22 | ) 23 | const Comment = sequelize.define( 24 | 'comment', 25 | { 26 | text: { 27 | type: Sequelize.STRING, 28 | allowNull: false, 29 | }, 30 | }, 31 | { 32 | freezeTableName: true, 33 | }, 34 | ) 35 | BlogPost.hasMany(Comment) 36 | Comment.belongsTo(BlogPost) 37 | 38 | const callHook = ( 39 | Model: any, 40 | method: MethodName, 41 | result: any, 42 | options?: any, 43 | ) => { 44 | return hydrate(options)({ 45 | service: { Model } as any, 46 | type: 'after', 47 | method, 48 | result, 49 | } as HookContext) 50 | } 51 | 52 | describe('Feathers Sequelize Hydrate Hook', () => { 53 | before(() => sequelize.sync()) 54 | 55 | it('throws if used as a "before" hook', () => { 56 | const hook = hydrate().bind(null, { type: 'before' } as HookContext) 57 | expect(hook).to.throw(Error) 58 | }) 59 | 60 | it('hydrates results for find()', async () => { 61 | const hook = await callHook(BlogPost, 'find', [{ title: 'David' }]) 62 | 63 | expect(hook.result[0] instanceof BlogPost) 64 | }) 65 | 66 | it('hydrates results for paginated find()', async () => { 67 | const hook = await callHook(BlogPost, 'find', { 68 | data: [{ title: 'David' }], 69 | }) 70 | 71 | expect(hook.result.data[0] instanceof BlogPost) 72 | }) 73 | 74 | it('hydrates results for get()', async () => { 75 | const hook = await callHook(BlogPost, 'get', { title: 'David' }) 76 | 77 | expect(hook.result instanceof BlogPost) 78 | }) 79 | ;(['create', 'update', 'patch'] as MethodName[]).forEach((method) => { 80 | it(`hydrates results for single ${method}()`, async () => { 81 | const hook = await callHook(BlogPost, method, { title: 'David' }) 82 | 83 | expect(hook.result instanceof BlogPost) 84 | }) 85 | }) 86 | ;(['create', 'patch'] as MethodName[]).forEach((method) => { 87 | it(`hydrates results for bulk ${method}()`, async () => { 88 | const hook = await callHook(BlogPost, method, [{ title: 'David' }]) 89 | 90 | expect(hook.result[0] instanceof BlogPost) 91 | }) 92 | }) 93 | 94 | it('hydrates included (associated) models', async () => { 95 | const hook = await callHook( 96 | BlogPost, 97 | 'get', 98 | { 99 | title: 'David', 100 | comments: [{ text: 'Comment text' }], 101 | }, 102 | { 103 | include: [Comment], 104 | }, 105 | ) 106 | 107 | expect(hook.result.comments[0] instanceof Comment) 108 | }) 109 | 110 | it('does not hydrate if data is a Model instance', async () => { 111 | const instance = BlogPost.build({ title: 'David' }) 112 | const hook = await callHook(BlogPost, 'get', instance) 113 | 114 | expect(hook.result).to.equal(instance) 115 | }) 116 | }) 117 | -------------------------------------------------------------------------------- /test/hooks.dehydrate.test.ts: -------------------------------------------------------------------------------- 1 | import type { HookContext } from '@feathersjs/feathers' 2 | import { expect } from 'chai' 3 | import Sequelize from 'sequelize' 4 | 5 | import { dehydrate } from '../src/hooks/dehydrate' 6 | import makeConnection from './connection' 7 | const sequelize = makeConnection(process.env.DB) 8 | 9 | type MethodName = 'find' | 'get' | 'create' | 'update' | 'patch' | 'remove' 10 | 11 | const BlogPost = sequelize.define( 12 | 'blogpost', 13 | { 14 | title: { 15 | type: Sequelize.STRING, 16 | allowNull: false, 17 | }, 18 | }, 19 | { 20 | freezeTableName: true, 21 | }, 22 | ) 23 | const Comment = sequelize.define( 24 | 'comment', 25 | { 26 | text: { 27 | type: Sequelize.STRING, 28 | allowNull: false, 29 | }, 30 | }, 31 | { 32 | freezeTableName: true, 33 | }, 34 | ) 35 | BlogPost.hasMany(Comment) 36 | Comment.belongsTo(BlogPost) 37 | 38 | const callHook = ( 39 | Model: any, 40 | method: MethodName, 41 | result: any, 42 | options?: any, 43 | ) => { 44 | if (result.data) { 45 | result.data = result.data.map((item: any) => Model.build(item, options)) 46 | } else if (Array.isArray(result)) { 47 | result = result.map((item) => Model.build(item, options)) 48 | } else { 49 | result = Model.build(result, options) 50 | } 51 | return dehydrate()({ 52 | service: { Model } as any, 53 | type: 'after', 54 | method, 55 | result, 56 | } as HookContext) 57 | } 58 | 59 | describe('Feathers Sequelize Dehydrate Hook', () => { 60 | before(() => sequelize.sync()) 61 | 62 | it('serializes results for find()', async () => { 63 | const hook = await callHook(BlogPost, 'find', [{ title: 'David' }]) 64 | 65 | expect(Object.getPrototypeOf(hook.result[0])).to.equal(Object.prototype) 66 | }) 67 | 68 | it('serializes results for paginated find()', async () => { 69 | const hook = await callHook(BlogPost, 'find', { 70 | data: [{ title: 'David' }], 71 | }) 72 | 73 | expect(Object.getPrototypeOf(hook.result.data[0])).to.equal( 74 | Object.prototype, 75 | ) 76 | }) 77 | 78 | it('serializes results for get()', async () => { 79 | const hook = await callHook(BlogPost, 'get', { title: 'David' }) 80 | 81 | expect(Object.getPrototypeOf(hook.result)).to.equal(Object.prototype) 82 | }) 83 | ;(['create', 'update', 'patch'] as MethodName[]).forEach((method) => { 84 | it(`serializes results for single ${method}()`, async () => { 85 | const hook = await callHook(BlogPost, method, { title: 'David' }) 86 | 87 | expect(Object.getPrototypeOf(hook.result)).to.equal(Object.prototype) 88 | }) 89 | }) 90 | ;(['create', 'patch'] as MethodName[]).forEach((method) => { 91 | it(`serializes results for bulk ${method}()`, async () => { 92 | const hook = await callHook(BlogPost, method, [{ title: 'David' }]) 93 | 94 | expect(Object.getPrototypeOf(hook.result[0])).to.equal(Object.prototype) 95 | }) 96 | }) 97 | 98 | it('serializes included (associated) models', async () => { 99 | const hook = await callHook( 100 | BlogPost, 101 | 'get', 102 | { 103 | title: 'David', 104 | comments: [{ text: 'Comment text' }], 105 | }, 106 | { 107 | include: [Comment], 108 | }, 109 | ) 110 | 111 | expect(Object.getPrototypeOf(hook.result.comments[0])).to.equal( 112 | Object.prototype, 113 | ) 114 | }) 115 | 116 | it('does not serialize if data is not a Model instance (with a toJSON method)', async () => { 117 | const result = { title: 'David' } 118 | 119 | const hook = await dehydrate().call({ Model: BlogPost }, { 120 | type: 'after', 121 | method: 'get', 122 | result, 123 | } as HookContext) 124 | 125 | expect(hook.result).to.equal(result) 126 | }) 127 | }) 128 | -------------------------------------------------------------------------------- /dist/index.d.cts: -------------------------------------------------------------------------------- 1 | import * as _feathersjs_adapter_commons from '@feathersjs/adapter-commons'; 2 | import { AdapterQuery, AdapterParams, AdapterServiceOptions, AdapterBase, PaginationOptions } from '@feathersjs/adapter-commons'; 3 | import * as sequelize from 'sequelize'; 4 | import { ModelStatic, Includeable, Op } from 'sequelize'; 5 | import { Query, NullableId, Paginated, Id, PaginationOptions as PaginationOptions$1, HookContext, Params, ServiceMethods } from '@feathersjs/feathers'; 6 | 7 | interface SequelizeAdapterOptions extends AdapterServiceOptions { 8 | Model: ModelStatic; 9 | raw?: boolean; 10 | operatorMap?: Record; 11 | } 12 | interface SequelizeAdapterParams extends AdapterParams> { 13 | sequelize?: any; 14 | } 15 | type HydrateOptions = { 16 | include?: Includeable | Includeable[]; 17 | }; 18 | 19 | declare class SequelizeAdapter, ServiceParams extends SequelizeAdapterParams = SequelizeAdapterParams, PatchData = Partial> extends AdapterBase { 20 | constructor(options: SequelizeAdapterOptions); 21 | get raw(): boolean; 22 | /** 23 | * 24 | * @deprecated Use `Op` from `sequelize` directly 25 | */ 26 | get Op(): typeof Op; 27 | get Model(): sequelize.ModelStatic; 28 | getModel(_params?: ServiceParams): sequelize.ModelStatic; 29 | convertOperators(q: any): Query; 30 | filterQuery(params: ServiceParams): { 31 | filters: { 32 | [key: string]: any; 33 | }; 34 | query: Query; 35 | paginate: _feathersjs_adapter_commons.PaginationParams | undefined; 36 | }; 37 | paramsToAdapter(id: NullableId, _params?: ServiceParams): any; 38 | handleError(feathersError: any, _sequelizeError: any): void; 39 | _find(params?: ServiceParams & { 40 | paginate?: PaginationOptions; 41 | }): Promise>; 42 | _find(params?: ServiceParams & { 43 | paginate: false; 44 | }): Promise; 45 | _find(params?: ServiceParams): Promise | Result[]>; 46 | _get(id: Id, params?: ServiceParams): Promise; 47 | _create(data: Data, params?: ServiceParams): Promise; 48 | _create(data: Data[], params?: ServiceParams): Promise; 49 | _create(data: Data | Data[], params?: ServiceParams): Promise; 50 | _patch(id: null, data: PatchData, params?: ServiceParams): Promise; 51 | _patch(id: Id, data: PatchData, params?: ServiceParams): Promise; 52 | _update(id: Id, data: Data, params?: ServiceParams): Promise; 53 | _remove(id: null, params?: ServiceParams): Promise; 54 | _remove(id: Id, params?: ServiceParams): Promise; 55 | } 56 | 57 | type PaginatedOrArray = P extends { 58 | paginate: false; 59 | } ? R[] : P extends { 60 | paginate: PaginationOptions$1; 61 | } ? Paginated : Paginated | R[]; 62 | 63 | declare const dehydrate: () => (context: H) => Promise>; 64 | 65 | declare const hydrate: (options?: HydrateOptions) => (context: H) => Promise>; 66 | 67 | declare const ERROR: unique symbol; 68 | declare const errorHandler: (error: any) => never; 69 | 70 | declare class SequelizeService, ServiceParams extends Params = SequelizeAdapterParams, PatchData = Partial> extends SequelizeAdapter implements ServiceMethods, Data, ServiceParams, PatchData> { 71 | find

(params?: P): Promise>; 74 | get(id: Id, params?: ServiceParams): Promise; 75 | create(data: Data, params?: ServiceParams): Promise; 76 | create(data: Data[], params?: ServiceParams): Promise; 77 | create(data: Data | Data[], params?: ServiceParams): Promise; 78 | update(id: Id, data: Data, params?: ServiceParams): Promise; 79 | patch(id: Id, data: PatchData, params?: ServiceParams): Promise; 80 | patch(id: null, data: PatchData, params?: ServiceParams): Promise; 81 | remove(id: Id, params?: ServiceParams): Promise; 82 | remove(id: null, params?: ServiceParams): Promise; 83 | } 84 | 85 | export { ERROR, type HydrateOptions, SequelizeAdapter, type SequelizeAdapterOptions, type SequelizeAdapterParams, SequelizeService, dehydrate, errorHandler, hydrate }; 86 | -------------------------------------------------------------------------------- /dist/index.d.mts: -------------------------------------------------------------------------------- 1 | import * as _feathersjs_adapter_commons from '@feathersjs/adapter-commons'; 2 | import { AdapterQuery, AdapterParams, AdapterServiceOptions, AdapterBase, PaginationOptions } from '@feathersjs/adapter-commons'; 3 | import * as sequelize from 'sequelize'; 4 | import { ModelStatic, Includeable, Op } from 'sequelize'; 5 | import { Query, NullableId, Paginated, Id, PaginationOptions as PaginationOptions$1, HookContext, Params, ServiceMethods } from '@feathersjs/feathers'; 6 | 7 | interface SequelizeAdapterOptions extends AdapterServiceOptions { 8 | Model: ModelStatic; 9 | raw?: boolean; 10 | operatorMap?: Record; 11 | } 12 | interface SequelizeAdapterParams extends AdapterParams> { 13 | sequelize?: any; 14 | } 15 | type HydrateOptions = { 16 | include?: Includeable | Includeable[]; 17 | }; 18 | 19 | declare class SequelizeAdapter, ServiceParams extends SequelizeAdapterParams = SequelizeAdapterParams, PatchData = Partial> extends AdapterBase { 20 | constructor(options: SequelizeAdapterOptions); 21 | get raw(): boolean; 22 | /** 23 | * 24 | * @deprecated Use `Op` from `sequelize` directly 25 | */ 26 | get Op(): typeof Op; 27 | get Model(): sequelize.ModelStatic; 28 | getModel(_params?: ServiceParams): sequelize.ModelStatic; 29 | convertOperators(q: any): Query; 30 | filterQuery(params: ServiceParams): { 31 | filters: { 32 | [key: string]: any; 33 | }; 34 | query: Query; 35 | paginate: _feathersjs_adapter_commons.PaginationParams | undefined; 36 | }; 37 | paramsToAdapter(id: NullableId, _params?: ServiceParams): any; 38 | handleError(feathersError: any, _sequelizeError: any): void; 39 | _find(params?: ServiceParams & { 40 | paginate?: PaginationOptions; 41 | }): Promise>; 42 | _find(params?: ServiceParams & { 43 | paginate: false; 44 | }): Promise; 45 | _find(params?: ServiceParams): Promise | Result[]>; 46 | _get(id: Id, params?: ServiceParams): Promise; 47 | _create(data: Data, params?: ServiceParams): Promise; 48 | _create(data: Data[], params?: ServiceParams): Promise; 49 | _create(data: Data | Data[], params?: ServiceParams): Promise; 50 | _patch(id: null, data: PatchData, params?: ServiceParams): Promise; 51 | _patch(id: Id, data: PatchData, params?: ServiceParams): Promise; 52 | _update(id: Id, data: Data, params?: ServiceParams): Promise; 53 | _remove(id: null, params?: ServiceParams): Promise; 54 | _remove(id: Id, params?: ServiceParams): Promise; 55 | } 56 | 57 | type PaginatedOrArray = P extends { 58 | paginate: false; 59 | } ? R[] : P extends { 60 | paginate: PaginationOptions$1; 61 | } ? Paginated : Paginated | R[]; 62 | 63 | declare const dehydrate: () => (context: H) => Promise>; 64 | 65 | declare const hydrate: (options?: HydrateOptions) => (context: H) => Promise>; 66 | 67 | declare const ERROR: unique symbol; 68 | declare const errorHandler: (error: any) => never; 69 | 70 | declare class SequelizeService, ServiceParams extends Params = SequelizeAdapterParams, PatchData = Partial> extends SequelizeAdapter implements ServiceMethods, Data, ServiceParams, PatchData> { 71 | find

(params?: P): Promise>; 74 | get(id: Id, params?: ServiceParams): Promise; 75 | create(data: Data, params?: ServiceParams): Promise; 76 | create(data: Data[], params?: ServiceParams): Promise; 77 | create(data: Data | Data[], params?: ServiceParams): Promise; 78 | update(id: Id, data: Data, params?: ServiceParams): Promise; 79 | patch(id: Id, data: PatchData, params?: ServiceParams): Promise; 80 | patch(id: null, data: PatchData, params?: ServiceParams): Promise; 81 | remove(id: Id, params?: ServiceParams): Promise; 82 | remove(id: null, params?: ServiceParams): Promise; 83 | } 84 | 85 | export { ERROR, type HydrateOptions, SequelizeAdapter, type SequelizeAdapterOptions, type SequelizeAdapterParams, SequelizeService, dehydrate, errorHandler, hydrate }; 86 | -------------------------------------------------------------------------------- /dist/index.d.ts: -------------------------------------------------------------------------------- 1 | import * as _feathersjs_adapter_commons from '@feathersjs/adapter-commons'; 2 | import { AdapterQuery, AdapterParams, AdapterServiceOptions, AdapterBase, PaginationOptions } from '@feathersjs/adapter-commons'; 3 | import * as sequelize from 'sequelize'; 4 | import { ModelStatic, Includeable, Op } from 'sequelize'; 5 | import { Query, NullableId, Paginated, Id, PaginationOptions as PaginationOptions$1, HookContext, Params, ServiceMethods } from '@feathersjs/feathers'; 6 | 7 | interface SequelizeAdapterOptions extends AdapterServiceOptions { 8 | Model: ModelStatic; 9 | raw?: boolean; 10 | operatorMap?: Record; 11 | } 12 | interface SequelizeAdapterParams extends AdapterParams> { 13 | sequelize?: any; 14 | } 15 | type HydrateOptions = { 16 | include?: Includeable | Includeable[]; 17 | }; 18 | 19 | declare class SequelizeAdapter, ServiceParams extends SequelizeAdapterParams = SequelizeAdapterParams, PatchData = Partial> extends AdapterBase { 20 | constructor(options: SequelizeAdapterOptions); 21 | get raw(): boolean; 22 | /** 23 | * 24 | * @deprecated Use `Op` from `sequelize` directly 25 | */ 26 | get Op(): typeof Op; 27 | get Model(): sequelize.ModelStatic; 28 | getModel(_params?: ServiceParams): sequelize.ModelStatic; 29 | convertOperators(q: any): Query; 30 | filterQuery(params: ServiceParams): { 31 | filters: { 32 | [key: string]: any; 33 | }; 34 | query: Query; 35 | paginate: _feathersjs_adapter_commons.PaginationParams | undefined; 36 | }; 37 | paramsToAdapter(id: NullableId, _params?: ServiceParams): any; 38 | handleError(feathersError: any, _sequelizeError: any): void; 39 | _find(params?: ServiceParams & { 40 | paginate?: PaginationOptions; 41 | }): Promise>; 42 | _find(params?: ServiceParams & { 43 | paginate: false; 44 | }): Promise; 45 | _find(params?: ServiceParams): Promise | Result[]>; 46 | _get(id: Id, params?: ServiceParams): Promise; 47 | _create(data: Data, params?: ServiceParams): Promise; 48 | _create(data: Data[], params?: ServiceParams): Promise; 49 | _create(data: Data | Data[], params?: ServiceParams): Promise; 50 | _patch(id: null, data: PatchData, params?: ServiceParams): Promise; 51 | _patch(id: Id, data: PatchData, params?: ServiceParams): Promise; 52 | _update(id: Id, data: Data, params?: ServiceParams): Promise; 53 | _remove(id: null, params?: ServiceParams): Promise; 54 | _remove(id: Id, params?: ServiceParams): Promise; 55 | } 56 | 57 | type PaginatedOrArray = P extends { 58 | paginate: false; 59 | } ? R[] : P extends { 60 | paginate: PaginationOptions$1; 61 | } ? Paginated : Paginated | R[]; 62 | 63 | declare const dehydrate: () => (context: H) => Promise>; 64 | 65 | declare const hydrate: (options?: HydrateOptions) => (context: H) => Promise>; 66 | 67 | declare const ERROR: unique symbol; 68 | declare const errorHandler: (error: any) => never; 69 | 70 | declare class SequelizeService, ServiceParams extends Params = SequelizeAdapterParams, PatchData = Partial> extends SequelizeAdapter implements ServiceMethods, Data, ServiceParams, PatchData> { 71 | find

(params?: P): Promise>; 74 | get(id: Id, params?: ServiceParams): Promise; 75 | create(data: Data, params?: ServiceParams): Promise; 76 | create(data: Data[], params?: ServiceParams): Promise; 77 | create(data: Data | Data[], params?: ServiceParams): Promise; 78 | update(id: Id, data: Data, params?: ServiceParams): Promise; 79 | patch(id: Id, data: PatchData, params?: ServiceParams): Promise; 80 | patch(id: null, data: PatchData, params?: ServiceParams): Promise; 81 | remove(id: Id, params?: ServiceParams): Promise; 82 | remove(id: null, params?: ServiceParams): Promise; 83 | } 84 | 85 | export { ERROR, type HydrateOptions, SequelizeAdapter, type SequelizeAdapterOptions, type SequelizeAdapterParams, SequelizeService, dehydrate, errorHandler, hydrate }; 86 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | env: 6 | POSTGRES_USER: postgres 7 | POSTGRES_PASSWORD: password 8 | POSTGRES_DB: sequelize 9 | 10 | MYSQL_USER: sequelize 11 | MYSQL_DATABASE: sequelize 12 | MYSQL_PASSWORD: password 13 | 14 | MARIADB_USER: sequelize 15 | MARIADB_DATABASE: sequelize 16 | MARIADB_PASSWORD: password 17 | 18 | jobs: 19 | lint: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Use Node.js 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: 22.x 27 | - run: npm install 28 | - run: npm run lint 29 | env: 30 | CI: true 31 | 32 | test-sqlite: 33 | runs-on: ubuntu-latest 34 | strategy: 35 | matrix: 36 | node-version: [20.x, 22.x] 37 | 38 | steps: 39 | - uses: actions/checkout@v4 40 | - name: Use Node.js ${{ matrix.node-version }} 41 | uses: actions/setup-node@v4 42 | with: 43 | node-version: ${{ matrix.node-version }} 44 | - run: npm install 45 | - run: DB=sqlite npm test 46 | env: 47 | CI: true 48 | 49 | test-postgres: 50 | runs-on: ubuntu-latest 51 | 52 | strategy: 53 | matrix: 54 | node-version: [20.x, 22.x] 55 | postgres-version: [14, 15, 16, 17, latest] # see https://hub.docker.com/_/postgres 56 | 57 | services: 58 | postgres: 59 | image: postgres:${{ matrix.postgres-version }} 60 | env: 61 | POSTGRES_USER: ${{ env.POSTGRES_USER }} 62 | POSTGRES_PASSWORD: ${{ env.POSTGRES_PASSWORD }} 63 | POSTGRES_DB: ${{ env.POSTGRES_DB }} 64 | ports: 65 | - 5432:5432 66 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 67 | 68 | steps: 69 | - uses: actions/checkout@v4 70 | - name: Use Node.js ${{ matrix.node-version }} 71 | uses: actions/setup-node@v4 72 | with: 73 | node-version: ${{ matrix.node-version }} 74 | - run: npm install 75 | - run: DB=postgres npm test 76 | env: 77 | CI: true 78 | 79 | test-mysql: 80 | runs-on: ubuntu-latest 81 | 82 | strategy: 83 | matrix: 84 | node-version: [20.x, 22.x] 85 | mysql-version: [lts, latest] # see https://hub.docker.com/_/mysql/ 86 | 87 | services: 88 | mysql: 89 | image: mysql:${{ matrix.mysql-version }} 90 | env: 91 | MYSQL_DATABASE: ${{ env.MYSQL_DATABASE }} 92 | MYSQL_ROOT_PASSWORD: ${{ env.MYSQL_PASSWORD }} 93 | MYSQL_USER: ${{ env.MYSQL_USER }} 94 | MYSQL_PASSWORD: ${{ env.MYSQL_PASSWORD }} 95 | ports: 96 | - 3306:3306 97 | options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries 5 98 | 99 | steps: 100 | - uses: actions/checkout@v4 101 | - name: Use Node.js ${{ matrix.node-version }} 102 | uses: actions/setup-node@v4 103 | with: 104 | node-version: ${{ matrix.node-version }} 105 | - run: npm install 106 | - run: DB=mysql npm test 107 | env: 108 | CI: true 109 | 110 | test-mariadb: 111 | runs-on: ubuntu-latest 112 | 113 | strategy: 114 | matrix: 115 | node-version: [20.x, 22.x] 116 | mariadb-version: [10, 11, lts, latest] # see https://hub.docker.com/_/mariadb 117 | 118 | services: 119 | mariadb: 120 | image: mariadb:${{ matrix.mariadb-version }} 121 | env: 122 | MARIADB_DATABASE: ${{ env.MARIADB_DATABASE }} 123 | MARIADB_ROOT_PASSWORD: ${{ env.MARIADB_PASSWORD }} 124 | MARIADB_USER: ${{ env.MARIADB_USER }} 125 | MARIADB_PASSWORD: ${{ env.MARIADB_PASSWORD }} 126 | ports: 127 | - 3306:3306 128 | options: --health-cmd="healthcheck.sh --connect --innodb_initialized" --health-interval=10s --health-timeout=5s --health-retries=3 129 | 130 | steps: 131 | - uses: actions/checkout@v3 132 | - name: Use Node.js ${{ matrix.node-version }} 133 | uses: actions/setup-node@v3 134 | with: 135 | node-version: ${{ matrix.node-version }} 136 | - run: npm install 137 | - run: DB=mariadb npm test 138 | env: 139 | CI: true 140 | 141 | build: 142 | runs-on: ubuntu-latest 143 | steps: 144 | - uses: actions/checkout@v4 145 | - name: Use Node.js 146 | uses: actions/setup-node@v4 147 | with: 148 | node-version: 22.x 149 | - run: npm install 150 | - run: npm run compile 151 | env: 152 | CI: true 153 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "src/**/*.ts", 4 | "test/**/*.ts" 5 | ], 6 | "exclude": [ 7 | "lib/**/*" 8 | ], 9 | "compilerOptions": { 10 | "outDir": "lib", 11 | /* Basic Options */ 12 | "target": "es2018", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */ 13 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 14 | // "lib": [], /* Specify library files to be included in the compilation. */ 15 | // "allowJs": true, /* Allow javascript files to be compiled. */ 16 | // "checkJs": true, /* Report errors in .js files. */ 17 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 18 | "declaration": true, /* Generates corresponding '.d.ts' file. */ 19 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 20 | "sourceMap": true, /* Generates corresponding '.map' file. */ 21 | // "outFile": "./", /* Concatenate and emit output to single file. */ 22 | // "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 23 | // "composite": true, /* Enable project compilation */ 24 | // "removeComments": true, /* Do not emit comments to output. */ 25 | // "noEmit": true, /* Do not emit outputs. */ 26 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 27 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 28 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 29 | 30 | /* Strict Type-Checking Options */ 31 | "strict": true, /* Enable all strict type-checking options. */ 32 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 33 | "strictNullChecks": false, /* Enable strict null checks. */ 34 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 35 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 36 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 37 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 38 | 39 | /* Additional Checks */ 40 | "noUnusedLocals": true, /* Report errors on unused locals. */ 41 | "noUnusedParameters": true, /* Report errors on unused parameters. */ 42 | "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 43 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 44 | 45 | /* Module Resolution Options */ 46 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 47 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 48 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 49 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 50 | // "typeRoots": [], /* List of folders to include type definitions from. */ 51 | // "types": [], /* Type declaration files to be included in compilation. */ 52 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 53 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 54 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 55 | 56 | /* Source Map Options */ 57 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 58 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 59 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 60 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 61 | 62 | /* Experimental Options */ 63 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 64 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /.github/contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing to Feathers 2 | 3 | Thank you for contributing to Feathers! :heart: :tada: 4 | 5 | This repo is the main core and where most issues are reported. Feathers embraces modularity and is broken up across many repos. To make this easier to manage we currently use [Zenhub](https://www.zenhub.com/) for issue triage and visibility. They have a free browser plugin you can install so that you can see what is in flight at any time, but of course you also always see current issues in Github. 6 | 7 | ## Report a bug 8 | 9 | Before creating an issue please make sure you have checked out the docs, specifically the [FAQ](https://docs.feathersjs.com/help/faq.html) section. You might want to also try searching Github. It's pretty likely someone has already asked a similar question. 10 | 11 | If you haven't found your answer please feel free to join our [slack channel](http://slack.feathersjs.com), create an issue on Github, or post on [Stackoverflow](http://stackoverflow.com) using the `feathers` or `feathersjs` tag. We try our best to monitor Stackoverflow but you're likely to get more immediate responses in Slack and Github. 12 | 13 | Issues can be reported in the [issue tracker](https://github.com/feathersjs/feathers/issues). Since feathers combines many modules it can be hard for us to assess the root cause without knowing which modules are being used and what your configuration looks like, so **it helps us immensely if you can link to a simple example that reproduces your issue**. 14 | 15 | ## Report a Security Concern 16 | 17 | We take security very seriously at Feathers. We welcome any peer review of our 100% open source code to ensure nobody's Feathers app is ever compromised or hacked. As a web application developer you are responsible for any security breaches. We do our very best to make sure Feathers is as secure as possible by default. 18 | 19 | In order to give the community time to respond and upgrade we strongly urge you report all security issues to us. Send one of the core team members a PM in [Slack](http://slack.feathersjs.com) or email us at hello@feathersjs.com with details and we will respond ASAP. 20 | 21 | For full details refer to our [Security docs](https://docs.feathersjs.com/SECURITY.html). 22 | 23 | ## Pull Requests 24 | 25 | We :heart: pull requests and we're continually working to make it as easy as possible for people to contribute, including a [Plugin Generator](https://github.com/feathersjs/generator-feathers-plugin) and a [common test suite](https://github.com/feathersjs/feathers-service-tests) for database adapters. 26 | 27 | We prefer small pull requests with minimal code changes. The smaller they are the easier they are to review and merge. A core team member will pick up your PR and review it as soon as they can. They may ask for changes or reject your pull request. This is not a reflection of you as an engineer or a person. Please accept feedback graciously as we will also try to be sensitive when providing it. 28 | 29 | Although we generally accept many PRs they can be rejected for many reasons. We will be as transparent as possible but it may simply be that you do not have the same context or information regarding the roadmap that the core team members have. We value the time you take to put together any contributions so we pledge to always be respectful of that time and will try to be as open as possible so that you don't waste it. :smile: 30 | 31 | **All PRs (except documentation) should be accompanied with tests and pass the linting rules.** 32 | 33 | ### Code style 34 | 35 | Before running the tests from the `test/` folder `npm test` will run ESlint. You can check your code changes individually by running `npm run lint`. 36 | 37 | ### ES6 compilation 38 | 39 | Feathers uses [Babel](https://babeljs.io/) to leverage the latest developments of the JavaScript language. All code and samples are currently written in ES2015. To transpile the code in this repository run 40 | 41 | > npm run compile 42 | 43 | __Note:__ `npm test` will run the compilation automatically before the tests. 44 | 45 | ### Tests 46 | 47 | [Mocha](http://mochajs.org/) tests are located in the `test/` folder and can be run using the `npm run mocha` or `npm test` (with ESLint and code coverage) command. 48 | 49 | ### Documentation 50 | 51 | Feathers documentation is contained in Markdown files in the [feathers-docs](https://github.com/feathersjs/feathers-docs) repository. To change the documentation submit a pull request to that repo, referencing any other PR if applicable, and the docs will be updated with the next release. 52 | 53 | ## External Modules 54 | 55 | If you're written something awesome for Feathers, the Feathers ecosystem, or using Feathers please add it to the [showcase](https://docs.feathersjs.com/why/showcase.html). You also might want to check out the [Plugin Generator](https://github.com/feathersjs/generator-feathers-plugin) that can be used to scaffold plugins to be Feathers compliant from the start. 56 | 57 | If you think it would be a good core module then please contact one of the Feathers core team members in [Slack](http://slack.feathersjs.com) and we can discuss whether it belongs in core and how to get it there. :beers: 58 | 59 | ## Contributor Code of Conduct 60 | 61 | As contributors and maintainers of this project, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. 62 | 63 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, or religion. 64 | 65 | Examples of unacceptable behavior by participants include the use of sexual language or imagery, derogatory comments or personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct. 66 | 67 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. Project maintainers who do not follow the Code of Conduct may be removed from the project team. 68 | 69 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. 70 | 71 | This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.0.0, available at [http://contributor-covenant.org/version/1/0/0/](http://contributor-covenant.org/version/1/0/0/) 72 | -------------------------------------------------------------------------------- /test/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | 3 | import * as errors from '@feathersjs/errors' 4 | import Sequelize from 'sequelize' 5 | 6 | import * as utils from '../src/utils' 7 | 8 | describe('Feathers Sequelize Utils', () => { 9 | describe('errorHandler', () => { 10 | it('throws a feathers error', () => { 11 | const e = new errors.GeneralError() 12 | expect(utils.errorHandler.bind(null, e)).to.throw(errors.GeneralError) 13 | }) 14 | 15 | it('throws a regular error', () => { 16 | const e = new Error('Regular Error') 17 | expect(utils.errorHandler.bind(null, e)).to.throw(e) 18 | }) 19 | 20 | it('wraps a ValidationError as a BadRequest', () => { 21 | // @ts-expect-error test-case 22 | const e = new Sequelize.ValidationError() 23 | expect(utils.errorHandler.bind(null, e)).to.throw(errors.BadRequest) 24 | }) 25 | 26 | it('preserves a validation error message', () => { 27 | // @ts-expect-error test-case 28 | const e = new Sequelize.ValidationError('Invalid Email') 29 | try { 30 | utils.errorHandler(e) 31 | } catch (error: any) { 32 | expect(error.message).to.equal('Invalid Email') 33 | } 34 | }) 35 | 36 | it('preserves a validation errors', () => { 37 | const emailError: any = { 38 | message: 'email cannot be null', 39 | type: 'notNull Violation', 40 | path: 'email', 41 | value: null, 42 | } 43 | 44 | const e = new Sequelize.ValidationError('Invalid Email', [emailError]) 45 | try { 46 | utils.errorHandler(e) 47 | } catch (error: any) { 48 | expect(error.errors).to.deep.equal([emailError]) 49 | } 50 | }) 51 | 52 | it('wraps a UniqueConstraintError as a BadRequest', () => { 53 | // @ts-expect-error test-case 54 | const e = new Sequelize.UniqueConstraintError() 55 | expect(utils.errorHandler.bind(null, e)).to.throw(errors.BadRequest) 56 | }) 57 | 58 | it('wraps a ExclusionConstraintError as a BadRequest', () => { 59 | // @ts-expect-error test-case 60 | const e = new Sequelize.ExclusionConstraintError() 61 | expect(utils.errorHandler.bind(null, e)).to.throw(errors.BadRequest) 62 | }) 63 | 64 | it('wraps a ForeignKeyConstraintError as a BadRequest', () => { 65 | // @ts-expect-error test-case 66 | const e = new Sequelize.ForeignKeyConstraintError() 67 | expect(utils.errorHandler.bind(null, e)).to.throw(errors.BadRequest) 68 | }) 69 | 70 | it('wraps a InvalidConnectionError as a BadRequest', () => { 71 | // @ts-expect-error test-case 72 | const e = new Sequelize.InvalidConnectionError() 73 | expect(utils.errorHandler.bind(null, e)).to.throw(errors.BadRequest) 74 | }) 75 | 76 | it('wraps a TimeoutError as a Timeout', () => { 77 | // NOTE (EK): We need to pass something to a time error otherwise 78 | // Sequelize blows up. 79 | // @ts-expect-error test-case 80 | const e = new Sequelize.TimeoutError('') 81 | expect(utils.errorHandler.bind(null, e)).to.throw(errors.Timeout) 82 | }) 83 | 84 | it('wraps a ConnectionTimedOutError as a Timeout', () => { 85 | // @ts-expect-error test-case 86 | const e = new Sequelize.ConnectionTimedOutError() 87 | expect(utils.errorHandler.bind(null, e)).to.throw(errors.Timeout) 88 | }) 89 | 90 | it('wraps a ConnectionRefusedError as a Forbidden', () => { 91 | // @ts-expect-error test-case 92 | const e = new Sequelize.ConnectionRefusedError() 93 | expect(utils.errorHandler.bind(null, e)).to.throw(errors.Forbidden) 94 | }) 95 | 96 | it('wraps a AccessDeniedError as a Forbidden', () => { 97 | // @ts-expect-error test-case 98 | const e = new Sequelize.AccessDeniedError() 99 | expect(utils.errorHandler.bind(null, e)).to.throw(errors.Forbidden) 100 | }) 101 | 102 | it('wraps a HostNotReachableError as a Unavailable', () => { 103 | // @ts-expect-error test-case 104 | const e = new Sequelize.HostNotReachableError() 105 | expect(utils.errorHandler.bind(null, e)).to.throw(errors.Unavailable) 106 | }) 107 | 108 | it('wraps a HostNotFoundError as a NotFound', () => { 109 | // @ts-expect-error test-case 110 | const e = new Sequelize.HostNotFoundError() 111 | expect(utils.errorHandler.bind(null, e)).to.throw(errors.NotFound) 112 | }) 113 | 114 | it('wraps a DatabaseError as a GeneralError', () => { 115 | // @ts-expect-error test-case 116 | const e = new Sequelize.DatabaseError('') 117 | expect(utils.errorHandler.bind(null, e)).to.throw(errors.GeneralError) 118 | }) 119 | }) 120 | 121 | describe('getOrder', () => { 122 | it('returns empty array when nothing is passed in', () => { 123 | const order = utils.getOrder() 124 | 125 | expect(order).to.deep.equal([]) 126 | }) 127 | 128 | it('returns order properly converted', () => { 129 | const order = utils.getOrder({ name: 1, age: -1 }) 130 | 131 | expect(order).to.deep.equal([ 132 | ['name', 'ASC'], 133 | ['age', 'DESC'], 134 | ]) 135 | }) 136 | 137 | it('returns order properly converted with the position of the nulls', () => { 138 | const order = utils.getOrder({ 139 | name: [1, 1], 140 | lastName: [1, -1], 141 | age: [-1, 1], 142 | phone: [-1, -1], 143 | }) 144 | 145 | expect(order).to.deep.equal([ 146 | ['name', 'ASC NULLS FIRST'], 147 | ['lastName', 'ASC NULLS LAST'], 148 | ['age', 'DESC NULLS FIRST'], 149 | ['phone', 'DESC NULLS LAST'], 150 | ]) 151 | }) 152 | }) 153 | 154 | describe('isPlainObject', () => { 155 | it('returns true for plain objects', () => { 156 | expect(utils.isPlainObject({})).to.equal(true) 157 | expect(utils.isPlainObject({ a: 1 })).to.equal(true) 158 | }) 159 | 160 | it('returns false for non-plain objects', () => { 161 | expect(utils.isPlainObject([])).to.equal(false) 162 | expect(utils.isPlainObject(new Date())).to.equal(false) 163 | expect(utils.isPlainObject(null)).to.equal(false) 164 | expect(utils.isPlainObject(undefined)).to.equal(false) 165 | expect(utils.isPlainObject('')).to.equal(false) 166 | expect(utils.isPlainObject(1)).to.equal(false) 167 | expect(utils.isPlainObject(true)).to.equal(false) 168 | }) 169 | }) 170 | 171 | describe('isPresent', () => { 172 | it('returns true for present objects', () => { 173 | expect(utils.isPresent({ a: 1 })).to.equal(true) 174 | expect(utils.isPresent([1])).to.equal(true) 175 | expect(utils.isPresent('a')).to.equal(true) 176 | expect(utils.isPresent(1)).to.equal(true) 177 | expect(utils.isPresent(true)).to.equal(true) 178 | }) 179 | 180 | it('returns false for non-present objects', () => { 181 | expect(utils.isPresent({})).to.equal(false) 182 | expect(utils.isPresent([])).to.equal(false) 183 | expect(utils.isPresent('')).to.equal(false) 184 | expect(utils.isPresent(null)).to.equal(false) 185 | expect(utils.isPresent(undefined)).to.equal(false) 186 | }) 187 | }) 188 | }) 189 | -------------------------------------------------------------------------------- /src/adapter.ts: -------------------------------------------------------------------------------- 1 | import { MethodNotAllowed, GeneralError, NotFound } from '@feathersjs/errors' 2 | import { _ } from '@feathersjs/commons' 3 | import { 4 | select as selector, 5 | AdapterBase, 6 | filterQuery, 7 | } from '@feathersjs/adapter-commons' 8 | import type { PaginationOptions } from '@feathersjs/adapter-commons' 9 | import type { 10 | SequelizeAdapterOptions, 11 | SequelizeAdapterParams, 12 | } from './declarations.js' 13 | import type { Id, NullableId, Paginated, Query } from '@feathersjs/feathers' 14 | import { errorHandler, getOrder, isPlainObject, isPresent } from './utils.js' 15 | import { Op } from 'sequelize' 16 | import type { 17 | CreateOptions, 18 | UpdateOptions, 19 | FindOptions, 20 | Model, 21 | } from 'sequelize' 22 | 23 | const defaultOperatorMap = { 24 | $eq: Op.eq, 25 | $ne: Op.ne, 26 | $gte: Op.gte, 27 | $gt: Op.gt, 28 | $lte: Op.lte, 29 | $lt: Op.lt, 30 | $in: Op.in, 31 | $nin: Op.notIn, 32 | $like: Op.like, 33 | $notLike: Op.notLike, 34 | $iLike: Op.iLike, 35 | $notILike: Op.notILike, 36 | $or: Op.or, 37 | $and: Op.and, 38 | } 39 | 40 | const defaultFilters = { 41 | $and: true as const, 42 | } 43 | 44 | const catchHandler = (handler: any) => { 45 | return (sequelizeError: any) => { 46 | try { 47 | errorHandler(sequelizeError) 48 | } catch (feathersError) { 49 | handler(feathersError, sequelizeError) 50 | } 51 | throw new GeneralError('`handleError` method must throw an error') 52 | } 53 | } 54 | 55 | export class SequelizeAdapter< 56 | Result, 57 | Data = Partial, 58 | ServiceParams extends SequelizeAdapterParams = SequelizeAdapterParams, 59 | PatchData = Partial, 60 | > extends AdapterBase< 61 | Result, 62 | Data, 63 | PatchData, 64 | ServiceParams, 65 | SequelizeAdapterOptions 66 | > { 67 | constructor(options: SequelizeAdapterOptions) { 68 | if (!options.Model) { 69 | throw new GeneralError('You must provide a Sequelize Model') 70 | } 71 | 72 | if (options.operators && !Array.isArray(options.operators)) { 73 | throw new GeneralError( 74 | "The 'operators' option must be an array. For migration from feathers.js v4 see: https://github.com/feathersjs-ecosystem/feathers-sequelize/tree/dove#migrate-to-feathers-v5-dove", 75 | ) 76 | } 77 | 78 | const operatorMap = { 79 | ...defaultOperatorMap, 80 | ...options.operatorMap, 81 | } 82 | const operators = Object.keys(operatorMap) 83 | if (options.operators) { 84 | options.operators.forEach((op) => { 85 | if (!operators.includes(op)) { 86 | operators.push(op) 87 | } 88 | }) 89 | } 90 | 91 | const { primaryKeyAttributes } = options.Model 92 | const id = 93 | typeof primaryKeyAttributes === 'object' && 94 | primaryKeyAttributes[0] !== undefined 95 | ? primaryKeyAttributes[0] 96 | : 'id' 97 | 98 | const filters = { 99 | ...defaultFilters, 100 | ...options.filters, 101 | } 102 | 103 | super({ 104 | id, 105 | ...options, 106 | operatorMap, 107 | filters, 108 | operators, 109 | }) 110 | } 111 | 112 | get raw(): boolean { 113 | return this.options.raw !== false 114 | } 115 | 116 | /** 117 | * 118 | * @deprecated Use `Op` from `sequelize` directly 119 | */ 120 | get Op(): typeof Op { 121 | return Op 122 | } 123 | 124 | get Model() { 125 | if (!this.options.Model) { 126 | throw new GeneralError( 127 | 'The Model getter was called with no Model provided in options!', 128 | ) 129 | } 130 | 131 | return this.options.Model 132 | } 133 | 134 | getModel(_params?: ServiceParams) { 135 | if (!this.options.Model) { 136 | throw new GeneralError( 137 | 'getModel was called without a Model present in the constructor options and without overriding getModel! Perhaps you intended to override getModel in a child class?', 138 | ) 139 | } 140 | 141 | return this.options.Model 142 | } 143 | 144 | convertOperators(q: any): Query { 145 | if (Array.isArray(q)) { 146 | return q.map((subQuery) => this.convertOperators(subQuery)) 147 | } 148 | 149 | if (!isPlainObject(q)) { 150 | return q 151 | } 152 | 153 | const { operatorMap = {} } = this.options 154 | 155 | const converted: Record = Object.keys(q).reduce( 156 | (result: Record, prop) => { 157 | const value = q[prop] 158 | const key = (operatorMap[prop] ? operatorMap[prop] : prop) as string 159 | 160 | result[key] = this.convertOperators(value) 161 | 162 | return result 163 | }, 164 | {}, 165 | ) 166 | 167 | Object.getOwnPropertySymbols(q).forEach((symbol) => { 168 | converted[symbol] = q[symbol] 169 | }) 170 | 171 | return converted 172 | } 173 | 174 | filterQuery(params: ServiceParams) { 175 | const options = this.getOptions(params) 176 | const { filters, query: _query } = filterQuery(params.query || {}, options) 177 | 178 | const query = this.convertOperators({ 179 | ..._query, 180 | ..._.omit(filters, '$select', '$skip', '$limit', '$sort'), 181 | }) 182 | 183 | if (filters.$select) { 184 | if (!filters.$select.includes(this.id)) { 185 | filters.$select.push(this.id) 186 | } 187 | filters.$select = filters.$select.map((select: any) => `${select}`) 188 | } 189 | 190 | return { 191 | filters, 192 | query, 193 | paginate: options.paginate, 194 | } 195 | } 196 | 197 | // paramsToAdapter (id: NullableId, _params?: ServiceParams): FindOptions { 198 | paramsToAdapter(id: NullableId, _params?: ServiceParams): any { 199 | const params = _params || ({} as ServiceParams) 200 | const { filters, query: where } = this.filterQuery(params) 201 | 202 | // Until Sequelize fix all the findAndCount issues, a few 'hacks' are needed to get the total count correct 203 | 204 | // Adding an empty include changes the way the count is done 205 | // See: https://github.com/sequelize/sequelize/blob/7e441a6a5ca44749acd3567b59b1d6ceb06ae64b/lib/model.js#L1780-L1782 206 | // sequelize.include = sequelize.include || []; 207 | 208 | const defaults = { 209 | where, 210 | attributes: filters.$select, 211 | distinct: true, 212 | returning: true, 213 | raw: this.raw, 214 | ...params.sequelize, 215 | } 216 | 217 | if (id === null) { 218 | return { 219 | order: getOrder(filters.$sort), 220 | limit: filters.$limit, 221 | offset: filters.$skip, 222 | ...defaults, 223 | } as FindOptions 224 | } 225 | 226 | const sequelize: FindOptions = { 227 | limit: 1, 228 | ...defaults, 229 | } 230 | 231 | if (where[this.id] === id) { 232 | return sequelize 233 | } 234 | 235 | if (this.id in where) { 236 | const { and } = Op 237 | where[and as any] = where[and as any] 238 | ? [...where[and as any], { [this.id]: id }] 239 | : { [this.id]: id } 240 | } else { 241 | where[this.id] = id 242 | } 243 | 244 | return sequelize 245 | } 246 | 247 | handleError(feathersError: any, _sequelizeError: any) { 248 | throw feathersError 249 | } 250 | 251 | async _find( 252 | params?: ServiceParams & { paginate?: PaginationOptions }, 253 | ): Promise> 254 | async _find(params?: ServiceParams & { paginate: false }): Promise 255 | async _find(params?: ServiceParams): Promise | Result[]> 256 | async _find( 257 | params: ServiceParams = {} as ServiceParams, 258 | ): Promise | Result[]> { 259 | const Model = this.getModel(params) 260 | const { paginate } = this.filterQuery(params) 261 | const sequelizeOptions = this.paramsToAdapter(null, params) 262 | 263 | if (!paginate || !paginate.default) { 264 | const result = await Model.findAll(sequelizeOptions).catch( 265 | catchHandler(this.handleError), 266 | ) 267 | return result 268 | } 269 | 270 | if (sequelizeOptions.limit === 0) { 271 | const total = (await Model.count({ 272 | ...sequelizeOptions, 273 | attributes: undefined, 274 | }).catch(catchHandler(this.handleError))) as any as number 275 | 276 | return { 277 | total, 278 | limit: sequelizeOptions.limit, 279 | skip: sequelizeOptions.offset || 0, 280 | data: [], 281 | } 282 | } 283 | 284 | const result = await Model.findAndCountAll(sequelizeOptions).catch( 285 | catchHandler(this.handleError), 286 | ) 287 | 288 | return { 289 | total: result.count, 290 | limit: sequelizeOptions.limit, 291 | skip: sequelizeOptions.offset || 0, 292 | data: result.rows, 293 | } 294 | } 295 | 296 | async _get( 297 | id: Id, 298 | params: ServiceParams = {} as ServiceParams, 299 | ): Promise { 300 | const Model = this.getModel(params) 301 | const sequelizeOptions = this.paramsToAdapter(id, params) 302 | const result = await Model.findAll(sequelizeOptions).catch( 303 | catchHandler(this.handleError), 304 | ) 305 | if (result.length === 0) { 306 | throw new NotFound(`No record found for id '${id}'`) 307 | } 308 | return result[0] 309 | } 310 | 311 | async _create(data: Data, params?: ServiceParams): Promise 312 | async _create(data: Data[], params?: ServiceParams): Promise 313 | async _create( 314 | data: Data | Data[], 315 | params?: ServiceParams, 316 | ): Promise 317 | async _create( 318 | data: Data | Data[], 319 | params: ServiceParams = {} as ServiceParams, 320 | ): Promise { 321 | const isArray = Array.isArray(data) 322 | const select = selector(params, this.id) 323 | 324 | if (isArray && !this.allowsMulti('create', params)) { 325 | throw new MethodNotAllowed('Can not create multiple entries') 326 | } 327 | 328 | if (isArray && data.length === 0) { 329 | return [] 330 | } 331 | 332 | const Model = this.getModel(params) 333 | const sequelizeOptions = this.paramsToAdapter(null, params) 334 | 335 | if (isArray) { 336 | const instances = await Model.bulkCreate( 337 | data as any[], 338 | sequelizeOptions, 339 | ).catch(catchHandler(this.handleError)) 340 | 341 | if (sequelizeOptions.returning === false) { 342 | return [] 343 | } 344 | 345 | if (sequelizeOptions.raw) { 346 | const result = instances.map((instance) => { 347 | if (isPresent(sequelizeOptions.attributes)) { 348 | return select(instance.toJSON()) 349 | } 350 | return instance.toJSON() 351 | }) 352 | return result 353 | } 354 | 355 | if (isPresent(sequelizeOptions.attributes)) { 356 | const result = instances.map((instance) => { 357 | const result = select(instance.toJSON()) 358 | return Model.build(result, { isNewRecord: false }) 359 | }) 360 | return result 361 | } 362 | 363 | return instances 364 | } 365 | 366 | const result = await Model.create( 367 | data as any, 368 | sequelizeOptions as CreateOptions, 369 | ).catch(catchHandler(this.handleError)) 370 | 371 | if (sequelizeOptions.raw) { 372 | return select((result as Model).toJSON()) 373 | } 374 | 375 | return result 376 | } 377 | 378 | async _patch( 379 | id: null, 380 | data: PatchData, 381 | params?: ServiceParams, 382 | ): Promise 383 | async _patch(id: Id, data: PatchData, params?: ServiceParams): Promise 384 | async _patch( 385 | id: NullableId, 386 | data: PatchData, 387 | params: ServiceParams = {} as ServiceParams, 388 | ): Promise { 389 | if (id === null && !this.allowsMulti('patch', params)) { 390 | throw new MethodNotAllowed('Can not patch multiple entries') 391 | } 392 | 393 | const Model = this.getModel(params) 394 | const sequelizeOptions = this.paramsToAdapter(id, params) 395 | const select = selector(params, this.id) 396 | const values = _.omit(data, this.id) 397 | 398 | if (id === null) { 399 | const current = await this._find({ 400 | ...params, 401 | paginate: false, 402 | query: { 403 | ...params?.query, 404 | $select: [this.id], 405 | }, 406 | }) 407 | 408 | if (!current.length) { 409 | return [] 410 | } 411 | 412 | const ids = current.map((item: any) => item[this.id]) 413 | 414 | let [, instances] = (await Model.update(values, { 415 | ...sequelizeOptions, 416 | raw: false, 417 | where: { [this.id]: ids.length === 1 ? ids[0] : { [Op.in]: ids } }, 418 | } as UpdateOptions).catch(catchHandler(this.handleError))) as [ 419 | number, 420 | Model[]?, 421 | ] 422 | 423 | if (sequelizeOptions.returning === false) { 424 | return [] 425 | } 426 | 427 | // Returning is only supported in postgres and mssql, and 428 | // its a little goofy array order how Sequelize handles it. 429 | // https://github.com/sequelize/sequelize/blob/abca55ee52d959f95c98dc7ae8b8162005536d05/packages/core/src/model.js#L3110 430 | if (!instances || typeof instances === 'number') { 431 | instances = undefined 432 | } 433 | 434 | const hasAttributes = isPresent(sequelizeOptions.attributes) 435 | 436 | if (instances) { 437 | if (isPresent(params.query?.$sort)) { 438 | const sortedInstances: Model[] = [] 439 | const unsortedInstances: Model[] = [] 440 | 441 | current.forEach((item: any) => { 442 | const id = item[this.id] 443 | const instance = instances!.find( 444 | (instance) => (instance as any)[this.id] === id, 445 | ) 446 | if (instance) { 447 | sortedInstances.push(instance) 448 | } else { 449 | unsortedInstances.push(item) 450 | } 451 | }) 452 | 453 | instances = [...sortedInstances, ...unsortedInstances] 454 | } 455 | 456 | if (sequelizeOptions.raw) { 457 | const result = instances.map((instance) => { 458 | if (hasAttributes) { 459 | return select(instance.toJSON()) 460 | } 461 | return instance.toJSON() 462 | }) 463 | return result 464 | } 465 | 466 | if (hasAttributes) { 467 | const result = instances.map((instance) => { 468 | const result = select(instance.toJSON()) 469 | return Model.build(result, { isNewRecord: false }) 470 | }) 471 | return result 472 | } 473 | 474 | return instances as unknown as Result[] 475 | } 476 | 477 | const result = await this._find({ 478 | ...params, 479 | paginate: false, 480 | query: { 481 | [this.id]: ids.length === 1 ? ids[0] : { $in: ids }, 482 | $select: params?.query?.$select, 483 | $sort: params?.query?.$sort, 484 | }, 485 | }) 486 | 487 | return result 488 | } 489 | 490 | const instance = (await this._get(id, { 491 | ...params, 492 | sequelize: { ...params.sequelize, raw: false }, 493 | })) as unknown as Model 494 | 495 | await instance 496 | .set(values) 497 | .update(values, sequelizeOptions) 498 | .catch(catchHandler(this.handleError)) 499 | 500 | if (isPresent(sequelizeOptions.include)) { 501 | return this._get(id, { 502 | ...params, 503 | query: { $select: params.query?.$select }, 504 | }) 505 | } 506 | 507 | if (sequelizeOptions.raw) { 508 | const result = instance.toJSON() 509 | if (isPresent(sequelizeOptions.attributes)) { 510 | return select(result) 511 | } 512 | return result 513 | } 514 | 515 | if (isPresent(sequelizeOptions.attributes)) { 516 | const result = select(instance.toJSON()) 517 | return Model.build(result, { isNewRecord: false }) 518 | } 519 | 520 | return instance as unknown as Result 521 | } 522 | 523 | async _update( 524 | id: Id, 525 | data: Data, 526 | params: ServiceParams = {} as ServiceParams, 527 | ): Promise { 528 | const Model = this.getModel(params) 529 | const sequelizeOptions = this.paramsToAdapter(id, params) 530 | const select = selector(params, this.id) 531 | 532 | const instance = (await this._get(id, { 533 | ...params, 534 | sequelize: { ...params.sequelize, raw: false }, 535 | })) as unknown as Model 536 | 537 | const values = Object.values(Model.getAttributes()).reduce( 538 | (values, attribute: any) => { 539 | const key = attribute.fieldName as string 540 | if (key === this.id) { 541 | return values 542 | } 543 | values[key] = key in (data as any) ? (data as any)[key] : null 544 | return values 545 | }, 546 | {} as Record, 547 | ) 548 | 549 | await instance 550 | .set(values) 551 | .update(values, sequelizeOptions) 552 | .catch(catchHandler(this.handleError)) 553 | 554 | if (isPresent(sequelizeOptions.include)) { 555 | return this._get(id, { 556 | ...params, 557 | query: { $select: params.query?.$select }, 558 | }) 559 | } 560 | 561 | if (sequelizeOptions.raw) { 562 | const result = instance.toJSON() 563 | if (isPresent(sequelizeOptions.attributes)) { 564 | return select(result) 565 | } 566 | return result 567 | } 568 | 569 | if (isPresent(sequelizeOptions.attributes)) { 570 | const result = select(instance.toJSON()) 571 | return Model.build(result, { isNewRecord: false }) 572 | } 573 | 574 | return instance as unknown as Result 575 | } 576 | 577 | async _remove(id: null, params?: ServiceParams): Promise 578 | async _remove(id: Id, params?: ServiceParams): Promise 579 | async _remove( 580 | id: NullableId, 581 | params: ServiceParams = {} as ServiceParams, 582 | ): Promise { 583 | if (id === null && !this.allowsMulti('remove', params)) { 584 | throw new MethodNotAllowed('Can not remove multiple entries') 585 | } 586 | 587 | const Model = this.getModel(params) 588 | const sequelizeOptions = this.paramsToAdapter(id, params) 589 | 590 | if (id === null) { 591 | const $select = 592 | sequelizeOptions.returning === false 593 | ? [this.id] 594 | : params?.query?.$select 595 | 596 | const current = await this._find({ 597 | ...params, 598 | paginate: false, 599 | query: { ...params.query, $select }, 600 | }) 601 | 602 | if (!current.length) { 603 | return [] 604 | } 605 | 606 | const ids: Id[] = current.map((item: any) => item[this.id]) 607 | 608 | await Model.destroy({ 609 | ...params.sequelize, 610 | where: { [this.id]: ids.length === 1 ? ids[0] : { [Op.in]: ids } }, 611 | }).catch(catchHandler(this.handleError)) 612 | 613 | if (sequelizeOptions.returning === false) { 614 | return [] 615 | } 616 | 617 | return current 618 | } 619 | 620 | const result = await this._get(id, params) 621 | 622 | const instance = 623 | result instanceof Model 624 | ? result 625 | : Model.build(result as any, { isNewRecord: false }) 626 | 627 | await instance.destroy(sequelizeOptions) 628 | 629 | return result 630 | } 631 | } 632 | -------------------------------------------------------------------------------- /dist/index.mjs: -------------------------------------------------------------------------------- 1 | import { GeneralError, NotFound, Unavailable, Forbidden, Timeout, BadRequest, MethodNotAllowed } from '@feathersjs/errors'; 2 | import { _ } from '@feathersjs/commons'; 3 | import { AdapterBase, filterQuery, select } from '@feathersjs/adapter-commons'; 4 | import { Op } from 'sequelize'; 5 | 6 | const ERROR = Symbol("feathers-sequelize/error"); 7 | const wrap = (error, original) => Object.assign(error, { [ERROR]: original }); 8 | const errorHandler = (error) => { 9 | const { name, message } = error; 10 | if (name.startsWith("Sequelize")) { 11 | switch (name) { 12 | case "SequelizeValidationError": 13 | case "SequelizeUniqueConstraintError": 14 | case "SequelizeExclusionConstraintError": 15 | case "SequelizeForeignKeyConstraintError": 16 | case "SequelizeInvalidConnectionError": 17 | throw wrap(new BadRequest(message, { errors: error.errors }), error); 18 | case "SequelizeTimeoutError": 19 | case "SequelizeConnectionTimedOutError": 20 | throw wrap(new Timeout(message), error); 21 | case "SequelizeConnectionRefusedError": 22 | case "SequelizeAccessDeniedError": 23 | throw wrap(new Forbidden(message), error); 24 | case "SequelizeHostNotReachableError": 25 | throw wrap(new Unavailable(message), error); 26 | case "SequelizeHostNotFoundError": 27 | throw wrap(new NotFound(message), error); 28 | default: 29 | throw wrap(new GeneralError(message), error); 30 | } 31 | } 32 | throw error; 33 | }; 34 | const getOrder = (sort = {}) => Object.keys(sort).reduce( 35 | (order, name) => { 36 | let direction; 37 | if (Array.isArray(sort[name])) { 38 | direction = parseInt(sort[name][0], 10) === 1 ? "ASC" : "DESC"; 39 | direction += parseInt(sort[name][1], 10) === 1 ? " NULLS FIRST" : " NULLS LAST"; 40 | } else { 41 | direction = parseInt(sort[name], 10) === 1 ? "ASC" : "DESC"; 42 | } 43 | order.push([name, direction]); 44 | return order; 45 | }, 46 | [] 47 | ); 48 | const isPlainObject = (obj) => { 49 | return !!obj && obj.constructor === {}.constructor; 50 | }; 51 | const isPresent = (obj) => { 52 | if (Array.isArray(obj)) { 53 | return obj.length > 0; 54 | } 55 | if (isPlainObject(obj)) { 56 | return Object.keys(obj).length > 0; 57 | } 58 | return !!obj; 59 | }; 60 | 61 | const defaultOperatorMap = { 62 | $eq: Op.eq, 63 | $ne: Op.ne, 64 | $gte: Op.gte, 65 | $gt: Op.gt, 66 | $lte: Op.lte, 67 | $lt: Op.lt, 68 | $in: Op.in, 69 | $nin: Op.notIn, 70 | $like: Op.like, 71 | $notLike: Op.notLike, 72 | $iLike: Op.iLike, 73 | $notILike: Op.notILike, 74 | $or: Op.or, 75 | $and: Op.and 76 | }; 77 | const defaultFilters = { 78 | $and: true 79 | }; 80 | const catchHandler = (handler) => { 81 | return (sequelizeError) => { 82 | try { 83 | errorHandler(sequelizeError); 84 | } catch (feathersError) { 85 | handler(feathersError, sequelizeError); 86 | } 87 | throw new GeneralError("`handleError` method must throw an error"); 88 | }; 89 | }; 90 | class SequelizeAdapter extends AdapterBase { 91 | constructor(options) { 92 | if (!options.Model) { 93 | throw new GeneralError("You must provide a Sequelize Model"); 94 | } 95 | if (options.operators && !Array.isArray(options.operators)) { 96 | throw new GeneralError( 97 | "The 'operators' option must be an array. For migration from feathers.js v4 see: https://github.com/feathersjs-ecosystem/feathers-sequelize/tree/dove#migrate-to-feathers-v5-dove" 98 | ); 99 | } 100 | const operatorMap = { 101 | ...defaultOperatorMap, 102 | ...options.operatorMap 103 | }; 104 | const operators = Object.keys(operatorMap); 105 | if (options.operators) { 106 | options.operators.forEach((op) => { 107 | if (!operators.includes(op)) { 108 | operators.push(op); 109 | } 110 | }); 111 | } 112 | const { primaryKeyAttributes } = options.Model; 113 | const id = typeof primaryKeyAttributes === "object" && primaryKeyAttributes[0] !== void 0 ? primaryKeyAttributes[0] : "id"; 114 | const filters = { 115 | ...defaultFilters, 116 | ...options.filters 117 | }; 118 | super({ 119 | id, 120 | ...options, 121 | operatorMap, 122 | filters, 123 | operators 124 | }); 125 | } 126 | get raw() { 127 | return this.options.raw !== false; 128 | } 129 | /** 130 | * 131 | * @deprecated Use `Op` from `sequelize` directly 132 | */ 133 | get Op() { 134 | return Op; 135 | } 136 | get Model() { 137 | if (!this.options.Model) { 138 | throw new GeneralError( 139 | "The Model getter was called with no Model provided in options!" 140 | ); 141 | } 142 | return this.options.Model; 143 | } 144 | getModel(_params) { 145 | if (!this.options.Model) { 146 | throw new GeneralError( 147 | "getModel was called without a Model present in the constructor options and without overriding getModel! Perhaps you intended to override getModel in a child class?" 148 | ); 149 | } 150 | return this.options.Model; 151 | } 152 | convertOperators(q) { 153 | if (Array.isArray(q)) { 154 | return q.map((subQuery) => this.convertOperators(subQuery)); 155 | } 156 | if (!isPlainObject(q)) { 157 | return q; 158 | } 159 | const { operatorMap = {} } = this.options; 160 | const converted = Object.keys(q).reduce( 161 | (result, prop) => { 162 | const value = q[prop]; 163 | const key = operatorMap[prop] ? operatorMap[prop] : prop; 164 | result[key] = this.convertOperators(value); 165 | return result; 166 | }, 167 | {} 168 | ); 169 | Object.getOwnPropertySymbols(q).forEach((symbol) => { 170 | converted[symbol] = q[symbol]; 171 | }); 172 | return converted; 173 | } 174 | filterQuery(params) { 175 | const options = this.getOptions(params); 176 | const { filters, query: _query } = filterQuery(params.query || {}, options); 177 | const query = this.convertOperators({ 178 | ..._query, 179 | ..._.omit(filters, "$select", "$skip", "$limit", "$sort") 180 | }); 181 | if (filters.$select) { 182 | if (!filters.$select.includes(this.id)) { 183 | filters.$select.push(this.id); 184 | } 185 | filters.$select = filters.$select.map((select) => `${select}`); 186 | } 187 | return { 188 | filters, 189 | query, 190 | paginate: options.paginate 191 | }; 192 | } 193 | // paramsToAdapter (id: NullableId, _params?: ServiceParams): FindOptions { 194 | paramsToAdapter(id, _params) { 195 | const params = _params || {}; 196 | const { filters, query: where } = this.filterQuery(params); 197 | const defaults = { 198 | where, 199 | attributes: filters.$select, 200 | distinct: true, 201 | returning: true, 202 | raw: this.raw, 203 | ...params.sequelize 204 | }; 205 | if (id === null) { 206 | return { 207 | order: getOrder(filters.$sort), 208 | limit: filters.$limit, 209 | offset: filters.$skip, 210 | ...defaults 211 | }; 212 | } 213 | const sequelize = { 214 | limit: 1, 215 | ...defaults 216 | }; 217 | if (where[this.id] === id) { 218 | return sequelize; 219 | } 220 | if (this.id in where) { 221 | const { and } = Op; 222 | where[and] = where[and] ? [...where[and], { [this.id]: id }] : { [this.id]: id }; 223 | } else { 224 | where[this.id] = id; 225 | } 226 | return sequelize; 227 | } 228 | handleError(feathersError, _sequelizeError) { 229 | throw feathersError; 230 | } 231 | async _find(params = {}) { 232 | const Model = this.getModel(params); 233 | const { paginate } = this.filterQuery(params); 234 | const sequelizeOptions = this.paramsToAdapter(null, params); 235 | if (!paginate || !paginate.default) { 236 | const result2 = await Model.findAll(sequelizeOptions).catch( 237 | catchHandler(this.handleError) 238 | ); 239 | return result2; 240 | } 241 | if (sequelizeOptions.limit === 0) { 242 | const total = await Model.count({ 243 | ...sequelizeOptions, 244 | attributes: void 0 245 | }).catch(catchHandler(this.handleError)); 246 | return { 247 | total, 248 | limit: sequelizeOptions.limit, 249 | skip: sequelizeOptions.offset || 0, 250 | data: [] 251 | }; 252 | } 253 | const result = await Model.findAndCountAll(sequelizeOptions).catch( 254 | catchHandler(this.handleError) 255 | ); 256 | return { 257 | total: result.count, 258 | limit: sequelizeOptions.limit, 259 | skip: sequelizeOptions.offset || 0, 260 | data: result.rows 261 | }; 262 | } 263 | async _get(id, params = {}) { 264 | const Model = this.getModel(params); 265 | const sequelizeOptions = this.paramsToAdapter(id, params); 266 | const result = await Model.findAll(sequelizeOptions).catch( 267 | catchHandler(this.handleError) 268 | ); 269 | if (result.length === 0) { 270 | throw new NotFound(`No record found for id '${id}'`); 271 | } 272 | return result[0]; 273 | } 274 | async _create(data, params = {}) { 275 | const isArray = Array.isArray(data); 276 | const select$1 = select(params, this.id); 277 | if (isArray && !this.allowsMulti("create", params)) { 278 | throw new MethodNotAllowed("Can not create multiple entries"); 279 | } 280 | if (isArray && data.length === 0) { 281 | return []; 282 | } 283 | const Model = this.getModel(params); 284 | const sequelizeOptions = this.paramsToAdapter(null, params); 285 | if (isArray) { 286 | const instances = await Model.bulkCreate( 287 | data, 288 | sequelizeOptions 289 | ).catch(catchHandler(this.handleError)); 290 | if (sequelizeOptions.returning === false) { 291 | return []; 292 | } 293 | if (sequelizeOptions.raw) { 294 | const result2 = instances.map((instance) => { 295 | if (isPresent(sequelizeOptions.attributes)) { 296 | return select$1(instance.toJSON()); 297 | } 298 | return instance.toJSON(); 299 | }); 300 | return result2; 301 | } 302 | if (isPresent(sequelizeOptions.attributes)) { 303 | const result2 = instances.map((instance) => { 304 | const result3 = select$1(instance.toJSON()); 305 | return Model.build(result3, { isNewRecord: false }); 306 | }); 307 | return result2; 308 | } 309 | return instances; 310 | } 311 | const result = await Model.create( 312 | data, 313 | sequelizeOptions 314 | ).catch(catchHandler(this.handleError)); 315 | if (sequelizeOptions.raw) { 316 | return select$1(result.toJSON()); 317 | } 318 | return result; 319 | } 320 | async _patch(id, data, params = {}) { 321 | if (id === null && !this.allowsMulti("patch", params)) { 322 | throw new MethodNotAllowed("Can not patch multiple entries"); 323 | } 324 | const Model = this.getModel(params); 325 | const sequelizeOptions = this.paramsToAdapter(id, params); 326 | const select$1 = select(params, this.id); 327 | const values = _.omit(data, this.id); 328 | if (id === null) { 329 | const current = await this._find({ 330 | ...params, 331 | paginate: false, 332 | query: { 333 | ...params?.query, 334 | $select: [this.id] 335 | } 336 | }); 337 | if (!current.length) { 338 | return []; 339 | } 340 | const ids = current.map((item) => item[this.id]); 341 | let [, instances] = await Model.update(values, { 342 | ...sequelizeOptions, 343 | raw: false, 344 | where: { [this.id]: ids.length === 1 ? ids[0] : { [Op.in]: ids } } 345 | }).catch(catchHandler(this.handleError)); 346 | if (sequelizeOptions.returning === false) { 347 | return []; 348 | } 349 | if (!instances || typeof instances === "number") { 350 | instances = void 0; 351 | } 352 | const hasAttributes = isPresent(sequelizeOptions.attributes); 353 | if (instances) { 354 | if (isPresent(params.query?.$sort)) { 355 | const sortedInstances = []; 356 | const unsortedInstances = []; 357 | current.forEach((item) => { 358 | const id2 = item[this.id]; 359 | const instance2 = instances.find( 360 | (instance3) => instance3[this.id] === id2 361 | ); 362 | if (instance2) { 363 | sortedInstances.push(instance2); 364 | } else { 365 | unsortedInstances.push(item); 366 | } 367 | }); 368 | instances = [...sortedInstances, ...unsortedInstances]; 369 | } 370 | if (sequelizeOptions.raw) { 371 | const result2 = instances.map((instance2) => { 372 | if (hasAttributes) { 373 | return select$1(instance2.toJSON()); 374 | } 375 | return instance2.toJSON(); 376 | }); 377 | return result2; 378 | } 379 | if (hasAttributes) { 380 | const result2 = instances.map((instance2) => { 381 | const result3 = select$1(instance2.toJSON()); 382 | return Model.build(result3, { isNewRecord: false }); 383 | }); 384 | return result2; 385 | } 386 | return instances; 387 | } 388 | const result = await this._find({ 389 | ...params, 390 | paginate: false, 391 | query: { 392 | [this.id]: ids.length === 1 ? ids[0] : { $in: ids }, 393 | $select: params?.query?.$select, 394 | $sort: params?.query?.$sort 395 | } 396 | }); 397 | return result; 398 | } 399 | const instance = await this._get(id, { 400 | ...params, 401 | sequelize: { ...params.sequelize, raw: false } 402 | }); 403 | await instance.set(values).update(values, sequelizeOptions).catch(catchHandler(this.handleError)); 404 | if (isPresent(sequelizeOptions.include)) { 405 | return this._get(id, { 406 | ...params, 407 | query: { $select: params.query?.$select } 408 | }); 409 | } 410 | if (sequelizeOptions.raw) { 411 | const result = instance.toJSON(); 412 | if (isPresent(sequelizeOptions.attributes)) { 413 | return select$1(result); 414 | } 415 | return result; 416 | } 417 | if (isPresent(sequelizeOptions.attributes)) { 418 | const result = select$1(instance.toJSON()); 419 | return Model.build(result, { isNewRecord: false }); 420 | } 421 | return instance; 422 | } 423 | async _update(id, data, params = {}) { 424 | const Model = this.getModel(params); 425 | const sequelizeOptions = this.paramsToAdapter(id, params); 426 | const select$1 = select(params, this.id); 427 | const instance = await this._get(id, { 428 | ...params, 429 | sequelize: { ...params.sequelize, raw: false } 430 | }); 431 | const values = Object.values(Model.getAttributes()).reduce( 432 | (values2, attribute) => { 433 | const key = attribute.fieldName; 434 | if (key === this.id) { 435 | return values2; 436 | } 437 | values2[key] = key in data ? data[key] : null; 438 | return values2; 439 | }, 440 | {} 441 | ); 442 | await instance.set(values).update(values, sequelizeOptions).catch(catchHandler(this.handleError)); 443 | if (isPresent(sequelizeOptions.include)) { 444 | return this._get(id, { 445 | ...params, 446 | query: { $select: params.query?.$select } 447 | }); 448 | } 449 | if (sequelizeOptions.raw) { 450 | const result = instance.toJSON(); 451 | if (isPresent(sequelizeOptions.attributes)) { 452 | return select$1(result); 453 | } 454 | return result; 455 | } 456 | if (isPresent(sequelizeOptions.attributes)) { 457 | const result = select$1(instance.toJSON()); 458 | return Model.build(result, { isNewRecord: false }); 459 | } 460 | return instance; 461 | } 462 | async _remove(id, params = {}) { 463 | if (id === null && !this.allowsMulti("remove", params)) { 464 | throw new MethodNotAllowed("Can not remove multiple entries"); 465 | } 466 | const Model = this.getModel(params); 467 | const sequelizeOptions = this.paramsToAdapter(id, params); 468 | if (id === null) { 469 | const $select = sequelizeOptions.returning === false ? [this.id] : params?.query?.$select; 470 | const current = await this._find({ 471 | ...params, 472 | paginate: false, 473 | query: { ...params.query, $select } 474 | }); 475 | if (!current.length) { 476 | return []; 477 | } 478 | const ids = current.map((item) => item[this.id]); 479 | await Model.destroy({ 480 | ...params.sequelize, 481 | where: { [this.id]: ids.length === 1 ? ids[0] : { [Op.in]: ids } } 482 | }).catch(catchHandler(this.handleError)); 483 | if (sequelizeOptions.returning === false) { 484 | return []; 485 | } 486 | return current; 487 | } 488 | const result = await this._get(id, params); 489 | const instance = result instanceof Model ? result : Model.build(result, { isNewRecord: false }); 490 | await instance.destroy(sequelizeOptions); 491 | return result; 492 | } 493 | } 494 | 495 | const serialize = (item) => { 496 | if (typeof item.toJSON === "function") { 497 | return item.toJSON(); 498 | } 499 | return item; 500 | }; 501 | const dehydrate = () => { 502 | return function(context) { 503 | switch (context.method) { 504 | case "find": 505 | if (context.result.data) { 506 | context.result.data = context.result.data.map(serialize); 507 | } else { 508 | context.result = context.result.map(serialize); 509 | } 510 | break; 511 | case "get": 512 | case "update": 513 | context.result = serialize(context.result); 514 | break; 515 | case "create": 516 | case "patch": 517 | if (Array.isArray(context.result)) { 518 | context.result = context.result.map(serialize); 519 | } else { 520 | context.result = serialize(context.result); 521 | } 522 | break; 523 | } 524 | return Promise.resolve(context); 525 | }; 526 | }; 527 | 528 | const factory = (Model, include) => { 529 | return (item) => { 530 | const shouldBuild = !(item instanceof Model); 531 | if (shouldBuild) { 532 | return Model.build(item, { isNewRecord: false, include }); 533 | } 534 | return item; 535 | }; 536 | }; 537 | const hydrate = (options) => { 538 | options = options || {}; 539 | return (context) => { 540 | if (context.type !== "after") { 541 | throw new Error( 542 | 'feathers-sequelize hydrate() - should only be used as an "after" hook' 543 | ); 544 | } 545 | const makeInstance = factory(context.service.Model, options.include); 546 | switch (context.method) { 547 | case "find": 548 | if (context.result.data) { 549 | context.result.data = context.result.data.map(makeInstance); 550 | } else { 551 | context.result = context.result.map(makeInstance); 552 | } 553 | break; 554 | case "get": 555 | case "update": 556 | context.result = makeInstance(context.result); 557 | break; 558 | case "create": 559 | case "patch": 560 | if (Array.isArray(context.result)) { 561 | context.result = context.result.map(makeInstance); 562 | } else { 563 | context.result = makeInstance(context.result); 564 | } 565 | break; 566 | } 567 | return Promise.resolve(context); 568 | }; 569 | }; 570 | 571 | class SequelizeService extends SequelizeAdapter { 572 | async find(params) { 573 | return this._find(params); 574 | } 575 | async get(id, params) { 576 | return this._get(id, params); 577 | } 578 | async create(data, params) { 579 | return this._create(data, params); 580 | } 581 | async update(id, data, params) { 582 | return this._update(id, data, params); 583 | } 584 | async patch(id, data, params) { 585 | return this._patch(id, data, params); 586 | } 587 | async remove(id, params) { 588 | return this._remove(id, params); 589 | } 590 | } 591 | 592 | export { ERROR, SequelizeAdapter, SequelizeService, dehydrate, errorHandler, hydrate }; 593 | -------------------------------------------------------------------------------- /dist/index.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const errors = require('@feathersjs/errors'); 4 | const commons = require('@feathersjs/commons'); 5 | const adapterCommons = require('@feathersjs/adapter-commons'); 6 | const sequelize = require('sequelize'); 7 | 8 | const ERROR = Symbol("feathers-sequelize/error"); 9 | const wrap = (error, original) => Object.assign(error, { [ERROR]: original }); 10 | const errorHandler = (error) => { 11 | const { name, message } = error; 12 | if (name.startsWith("Sequelize")) { 13 | switch (name) { 14 | case "SequelizeValidationError": 15 | case "SequelizeUniqueConstraintError": 16 | case "SequelizeExclusionConstraintError": 17 | case "SequelizeForeignKeyConstraintError": 18 | case "SequelizeInvalidConnectionError": 19 | throw wrap(new errors.BadRequest(message, { errors: error.errors }), error); 20 | case "SequelizeTimeoutError": 21 | case "SequelizeConnectionTimedOutError": 22 | throw wrap(new errors.Timeout(message), error); 23 | case "SequelizeConnectionRefusedError": 24 | case "SequelizeAccessDeniedError": 25 | throw wrap(new errors.Forbidden(message), error); 26 | case "SequelizeHostNotReachableError": 27 | throw wrap(new errors.Unavailable(message), error); 28 | case "SequelizeHostNotFoundError": 29 | throw wrap(new errors.NotFound(message), error); 30 | default: 31 | throw wrap(new errors.GeneralError(message), error); 32 | } 33 | } 34 | throw error; 35 | }; 36 | const getOrder = (sort = {}) => Object.keys(sort).reduce( 37 | (order, name) => { 38 | let direction; 39 | if (Array.isArray(sort[name])) { 40 | direction = parseInt(sort[name][0], 10) === 1 ? "ASC" : "DESC"; 41 | direction += parseInt(sort[name][1], 10) === 1 ? " NULLS FIRST" : " NULLS LAST"; 42 | } else { 43 | direction = parseInt(sort[name], 10) === 1 ? "ASC" : "DESC"; 44 | } 45 | order.push([name, direction]); 46 | return order; 47 | }, 48 | [] 49 | ); 50 | const isPlainObject = (obj) => { 51 | return !!obj && obj.constructor === {}.constructor; 52 | }; 53 | const isPresent = (obj) => { 54 | if (Array.isArray(obj)) { 55 | return obj.length > 0; 56 | } 57 | if (isPlainObject(obj)) { 58 | return Object.keys(obj).length > 0; 59 | } 60 | return !!obj; 61 | }; 62 | 63 | const defaultOperatorMap = { 64 | $eq: sequelize.Op.eq, 65 | $ne: sequelize.Op.ne, 66 | $gte: sequelize.Op.gte, 67 | $gt: sequelize.Op.gt, 68 | $lte: sequelize.Op.lte, 69 | $lt: sequelize.Op.lt, 70 | $in: sequelize.Op.in, 71 | $nin: sequelize.Op.notIn, 72 | $like: sequelize.Op.like, 73 | $notLike: sequelize.Op.notLike, 74 | $iLike: sequelize.Op.iLike, 75 | $notILike: sequelize.Op.notILike, 76 | $or: sequelize.Op.or, 77 | $and: sequelize.Op.and 78 | }; 79 | const defaultFilters = { 80 | $and: true 81 | }; 82 | const catchHandler = (handler) => { 83 | return (sequelizeError) => { 84 | try { 85 | errorHandler(sequelizeError); 86 | } catch (feathersError) { 87 | handler(feathersError, sequelizeError); 88 | } 89 | throw new errors.GeneralError("`handleError` method must throw an error"); 90 | }; 91 | }; 92 | class SequelizeAdapter extends adapterCommons.AdapterBase { 93 | constructor(options) { 94 | if (!options.Model) { 95 | throw new errors.GeneralError("You must provide a Sequelize Model"); 96 | } 97 | if (options.operators && !Array.isArray(options.operators)) { 98 | throw new errors.GeneralError( 99 | "The 'operators' option must be an array. For migration from feathers.js v4 see: https://github.com/feathersjs-ecosystem/feathers-sequelize/tree/dove#migrate-to-feathers-v5-dove" 100 | ); 101 | } 102 | const operatorMap = { 103 | ...defaultOperatorMap, 104 | ...options.operatorMap 105 | }; 106 | const operators = Object.keys(operatorMap); 107 | if (options.operators) { 108 | options.operators.forEach((op) => { 109 | if (!operators.includes(op)) { 110 | operators.push(op); 111 | } 112 | }); 113 | } 114 | const { primaryKeyAttributes } = options.Model; 115 | const id = typeof primaryKeyAttributes === "object" && primaryKeyAttributes[0] !== void 0 ? primaryKeyAttributes[0] : "id"; 116 | const filters = { 117 | ...defaultFilters, 118 | ...options.filters 119 | }; 120 | super({ 121 | id, 122 | ...options, 123 | operatorMap, 124 | filters, 125 | operators 126 | }); 127 | } 128 | get raw() { 129 | return this.options.raw !== false; 130 | } 131 | /** 132 | * 133 | * @deprecated Use `Op` from `sequelize` directly 134 | */ 135 | get Op() { 136 | return sequelize.Op; 137 | } 138 | get Model() { 139 | if (!this.options.Model) { 140 | throw new errors.GeneralError( 141 | "The Model getter was called with no Model provided in options!" 142 | ); 143 | } 144 | return this.options.Model; 145 | } 146 | getModel(_params) { 147 | if (!this.options.Model) { 148 | throw new errors.GeneralError( 149 | "getModel was called without a Model present in the constructor options and without overriding getModel! Perhaps you intended to override getModel in a child class?" 150 | ); 151 | } 152 | return this.options.Model; 153 | } 154 | convertOperators(q) { 155 | if (Array.isArray(q)) { 156 | return q.map((subQuery) => this.convertOperators(subQuery)); 157 | } 158 | if (!isPlainObject(q)) { 159 | return q; 160 | } 161 | const { operatorMap = {} } = this.options; 162 | const converted = Object.keys(q).reduce( 163 | (result, prop) => { 164 | const value = q[prop]; 165 | const key = operatorMap[prop] ? operatorMap[prop] : prop; 166 | result[key] = this.convertOperators(value); 167 | return result; 168 | }, 169 | {} 170 | ); 171 | Object.getOwnPropertySymbols(q).forEach((symbol) => { 172 | converted[symbol] = q[symbol]; 173 | }); 174 | return converted; 175 | } 176 | filterQuery(params) { 177 | const options = this.getOptions(params); 178 | const { filters, query: _query } = adapterCommons.filterQuery(params.query || {}, options); 179 | const query = this.convertOperators({ 180 | ..._query, 181 | ...commons._.omit(filters, "$select", "$skip", "$limit", "$sort") 182 | }); 183 | if (filters.$select) { 184 | if (!filters.$select.includes(this.id)) { 185 | filters.$select.push(this.id); 186 | } 187 | filters.$select = filters.$select.map((select) => `${select}`); 188 | } 189 | return { 190 | filters, 191 | query, 192 | paginate: options.paginate 193 | }; 194 | } 195 | // paramsToAdapter (id: NullableId, _params?: ServiceParams): FindOptions { 196 | paramsToAdapter(id, _params) { 197 | const params = _params || {}; 198 | const { filters, query: where } = this.filterQuery(params); 199 | const defaults = { 200 | where, 201 | attributes: filters.$select, 202 | distinct: true, 203 | returning: true, 204 | raw: this.raw, 205 | ...params.sequelize 206 | }; 207 | if (id === null) { 208 | return { 209 | order: getOrder(filters.$sort), 210 | limit: filters.$limit, 211 | offset: filters.$skip, 212 | ...defaults 213 | }; 214 | } 215 | const sequelize$1 = { 216 | limit: 1, 217 | ...defaults 218 | }; 219 | if (where[this.id] === id) { 220 | return sequelize$1; 221 | } 222 | if (this.id in where) { 223 | const { and } = sequelize.Op; 224 | where[and] = where[and] ? [...where[and], { [this.id]: id }] : { [this.id]: id }; 225 | } else { 226 | where[this.id] = id; 227 | } 228 | return sequelize$1; 229 | } 230 | handleError(feathersError, _sequelizeError) { 231 | throw feathersError; 232 | } 233 | async _find(params = {}) { 234 | const Model = this.getModel(params); 235 | const { paginate } = this.filterQuery(params); 236 | const sequelizeOptions = this.paramsToAdapter(null, params); 237 | if (!paginate || !paginate.default) { 238 | const result2 = await Model.findAll(sequelizeOptions).catch( 239 | catchHandler(this.handleError) 240 | ); 241 | return result2; 242 | } 243 | if (sequelizeOptions.limit === 0) { 244 | const total = await Model.count({ 245 | ...sequelizeOptions, 246 | attributes: void 0 247 | }).catch(catchHandler(this.handleError)); 248 | return { 249 | total, 250 | limit: sequelizeOptions.limit, 251 | skip: sequelizeOptions.offset || 0, 252 | data: [] 253 | }; 254 | } 255 | const result = await Model.findAndCountAll(sequelizeOptions).catch( 256 | catchHandler(this.handleError) 257 | ); 258 | return { 259 | total: result.count, 260 | limit: sequelizeOptions.limit, 261 | skip: sequelizeOptions.offset || 0, 262 | data: result.rows 263 | }; 264 | } 265 | async _get(id, params = {}) { 266 | const Model = this.getModel(params); 267 | const sequelizeOptions = this.paramsToAdapter(id, params); 268 | const result = await Model.findAll(sequelizeOptions).catch( 269 | catchHandler(this.handleError) 270 | ); 271 | if (result.length === 0) { 272 | throw new errors.NotFound(`No record found for id '${id}'`); 273 | } 274 | return result[0]; 275 | } 276 | async _create(data, params = {}) { 277 | const isArray = Array.isArray(data); 278 | const select = adapterCommons.select(params, this.id); 279 | if (isArray && !this.allowsMulti("create", params)) { 280 | throw new errors.MethodNotAllowed("Can not create multiple entries"); 281 | } 282 | if (isArray && data.length === 0) { 283 | return []; 284 | } 285 | const Model = this.getModel(params); 286 | const sequelizeOptions = this.paramsToAdapter(null, params); 287 | if (isArray) { 288 | const instances = await Model.bulkCreate( 289 | data, 290 | sequelizeOptions 291 | ).catch(catchHandler(this.handleError)); 292 | if (sequelizeOptions.returning === false) { 293 | return []; 294 | } 295 | if (sequelizeOptions.raw) { 296 | const result2 = instances.map((instance) => { 297 | if (isPresent(sequelizeOptions.attributes)) { 298 | return select(instance.toJSON()); 299 | } 300 | return instance.toJSON(); 301 | }); 302 | return result2; 303 | } 304 | if (isPresent(sequelizeOptions.attributes)) { 305 | const result2 = instances.map((instance) => { 306 | const result3 = select(instance.toJSON()); 307 | return Model.build(result3, { isNewRecord: false }); 308 | }); 309 | return result2; 310 | } 311 | return instances; 312 | } 313 | const result = await Model.create( 314 | data, 315 | sequelizeOptions 316 | ).catch(catchHandler(this.handleError)); 317 | if (sequelizeOptions.raw) { 318 | return select(result.toJSON()); 319 | } 320 | return result; 321 | } 322 | async _patch(id, data, params = {}) { 323 | if (id === null && !this.allowsMulti("patch", params)) { 324 | throw new errors.MethodNotAllowed("Can not patch multiple entries"); 325 | } 326 | const Model = this.getModel(params); 327 | const sequelizeOptions = this.paramsToAdapter(id, params); 328 | const select = adapterCommons.select(params, this.id); 329 | const values = commons._.omit(data, this.id); 330 | if (id === null) { 331 | const current = await this._find({ 332 | ...params, 333 | paginate: false, 334 | query: { 335 | ...params?.query, 336 | $select: [this.id] 337 | } 338 | }); 339 | if (!current.length) { 340 | return []; 341 | } 342 | const ids = current.map((item) => item[this.id]); 343 | let [, instances] = await Model.update(values, { 344 | ...sequelizeOptions, 345 | raw: false, 346 | where: { [this.id]: ids.length === 1 ? ids[0] : { [sequelize.Op.in]: ids } } 347 | }).catch(catchHandler(this.handleError)); 348 | if (sequelizeOptions.returning === false) { 349 | return []; 350 | } 351 | if (!instances || typeof instances === "number") { 352 | instances = void 0; 353 | } 354 | const hasAttributes = isPresent(sequelizeOptions.attributes); 355 | if (instances) { 356 | if (isPresent(params.query?.$sort)) { 357 | const sortedInstances = []; 358 | const unsortedInstances = []; 359 | current.forEach((item) => { 360 | const id2 = item[this.id]; 361 | const instance2 = instances.find( 362 | (instance3) => instance3[this.id] === id2 363 | ); 364 | if (instance2) { 365 | sortedInstances.push(instance2); 366 | } else { 367 | unsortedInstances.push(item); 368 | } 369 | }); 370 | instances = [...sortedInstances, ...unsortedInstances]; 371 | } 372 | if (sequelizeOptions.raw) { 373 | const result2 = instances.map((instance2) => { 374 | if (hasAttributes) { 375 | return select(instance2.toJSON()); 376 | } 377 | return instance2.toJSON(); 378 | }); 379 | return result2; 380 | } 381 | if (hasAttributes) { 382 | const result2 = instances.map((instance2) => { 383 | const result3 = select(instance2.toJSON()); 384 | return Model.build(result3, { isNewRecord: false }); 385 | }); 386 | return result2; 387 | } 388 | return instances; 389 | } 390 | const result = await this._find({ 391 | ...params, 392 | paginate: false, 393 | query: { 394 | [this.id]: ids.length === 1 ? ids[0] : { $in: ids }, 395 | $select: params?.query?.$select, 396 | $sort: params?.query?.$sort 397 | } 398 | }); 399 | return result; 400 | } 401 | const instance = await this._get(id, { 402 | ...params, 403 | sequelize: { ...params.sequelize, raw: false } 404 | }); 405 | await instance.set(values).update(values, sequelizeOptions).catch(catchHandler(this.handleError)); 406 | if (isPresent(sequelizeOptions.include)) { 407 | return this._get(id, { 408 | ...params, 409 | query: { $select: params.query?.$select } 410 | }); 411 | } 412 | if (sequelizeOptions.raw) { 413 | const result = instance.toJSON(); 414 | if (isPresent(sequelizeOptions.attributes)) { 415 | return select(result); 416 | } 417 | return result; 418 | } 419 | if (isPresent(sequelizeOptions.attributes)) { 420 | const result = select(instance.toJSON()); 421 | return Model.build(result, { isNewRecord: false }); 422 | } 423 | return instance; 424 | } 425 | async _update(id, data, params = {}) { 426 | const Model = this.getModel(params); 427 | const sequelizeOptions = this.paramsToAdapter(id, params); 428 | const select = adapterCommons.select(params, this.id); 429 | const instance = await this._get(id, { 430 | ...params, 431 | sequelize: { ...params.sequelize, raw: false } 432 | }); 433 | const values = Object.values(Model.getAttributes()).reduce( 434 | (values2, attribute) => { 435 | const key = attribute.fieldName; 436 | if (key === this.id) { 437 | return values2; 438 | } 439 | values2[key] = key in data ? data[key] : null; 440 | return values2; 441 | }, 442 | {} 443 | ); 444 | await instance.set(values).update(values, sequelizeOptions).catch(catchHandler(this.handleError)); 445 | if (isPresent(sequelizeOptions.include)) { 446 | return this._get(id, { 447 | ...params, 448 | query: { $select: params.query?.$select } 449 | }); 450 | } 451 | if (sequelizeOptions.raw) { 452 | const result = instance.toJSON(); 453 | if (isPresent(sequelizeOptions.attributes)) { 454 | return select(result); 455 | } 456 | return result; 457 | } 458 | if (isPresent(sequelizeOptions.attributes)) { 459 | const result = select(instance.toJSON()); 460 | return Model.build(result, { isNewRecord: false }); 461 | } 462 | return instance; 463 | } 464 | async _remove(id, params = {}) { 465 | if (id === null && !this.allowsMulti("remove", params)) { 466 | throw new errors.MethodNotAllowed("Can not remove multiple entries"); 467 | } 468 | const Model = this.getModel(params); 469 | const sequelizeOptions = this.paramsToAdapter(id, params); 470 | if (id === null) { 471 | const $select = sequelizeOptions.returning === false ? [this.id] : params?.query?.$select; 472 | const current = await this._find({ 473 | ...params, 474 | paginate: false, 475 | query: { ...params.query, $select } 476 | }); 477 | if (!current.length) { 478 | return []; 479 | } 480 | const ids = current.map((item) => item[this.id]); 481 | await Model.destroy({ 482 | ...params.sequelize, 483 | where: { [this.id]: ids.length === 1 ? ids[0] : { [sequelize.Op.in]: ids } } 484 | }).catch(catchHandler(this.handleError)); 485 | if (sequelizeOptions.returning === false) { 486 | return []; 487 | } 488 | return current; 489 | } 490 | const result = await this._get(id, params); 491 | const instance = result instanceof Model ? result : Model.build(result, { isNewRecord: false }); 492 | await instance.destroy(sequelizeOptions); 493 | return result; 494 | } 495 | } 496 | 497 | const serialize = (item) => { 498 | if (typeof item.toJSON === "function") { 499 | return item.toJSON(); 500 | } 501 | return item; 502 | }; 503 | const dehydrate = () => { 504 | return function(context) { 505 | switch (context.method) { 506 | case "find": 507 | if (context.result.data) { 508 | context.result.data = context.result.data.map(serialize); 509 | } else { 510 | context.result = context.result.map(serialize); 511 | } 512 | break; 513 | case "get": 514 | case "update": 515 | context.result = serialize(context.result); 516 | break; 517 | case "create": 518 | case "patch": 519 | if (Array.isArray(context.result)) { 520 | context.result = context.result.map(serialize); 521 | } else { 522 | context.result = serialize(context.result); 523 | } 524 | break; 525 | } 526 | return Promise.resolve(context); 527 | }; 528 | }; 529 | 530 | const factory = (Model, include) => { 531 | return (item) => { 532 | const shouldBuild = !(item instanceof Model); 533 | if (shouldBuild) { 534 | return Model.build(item, { isNewRecord: false, include }); 535 | } 536 | return item; 537 | }; 538 | }; 539 | const hydrate = (options) => { 540 | options = options || {}; 541 | return (context) => { 542 | if (context.type !== "after") { 543 | throw new Error( 544 | 'feathers-sequelize hydrate() - should only be used as an "after" hook' 545 | ); 546 | } 547 | const makeInstance = factory(context.service.Model, options.include); 548 | switch (context.method) { 549 | case "find": 550 | if (context.result.data) { 551 | context.result.data = context.result.data.map(makeInstance); 552 | } else { 553 | context.result = context.result.map(makeInstance); 554 | } 555 | break; 556 | case "get": 557 | case "update": 558 | context.result = makeInstance(context.result); 559 | break; 560 | case "create": 561 | case "patch": 562 | if (Array.isArray(context.result)) { 563 | context.result = context.result.map(makeInstance); 564 | } else { 565 | context.result = makeInstance(context.result); 566 | } 567 | break; 568 | } 569 | return Promise.resolve(context); 570 | }; 571 | }; 572 | 573 | class SequelizeService extends SequelizeAdapter { 574 | async find(params) { 575 | return this._find(params); 576 | } 577 | async get(id, params) { 578 | return this._get(id, params); 579 | } 580 | async create(data, params) { 581 | return this._create(data, params); 582 | } 583 | async update(id, data, params) { 584 | return this._update(id, data, params); 585 | } 586 | async patch(id, data, params) { 587 | return this._patch(id, data, params); 588 | } 589 | async remove(id, params) { 590 | return this._remove(id, params); 591 | } 592 | } 593 | 594 | exports.ERROR = ERROR; 595 | exports.SequelizeAdapter = SequelizeAdapter; 596 | exports.SequelizeService = SequelizeService; 597 | exports.dehydrate = dehydrate; 598 | exports.errorHandler = errorHandler; 599 | exports.hydrate = hydrate; 600 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # feathers-sequelize 2 | 3 | [![CI](https://github.com/feathersjs-ecosystem/feathers-sequelize/workflows/CI/badge.svg)](https://github.com/feathersjs-ecosystem/feathers-sequelize/actions?query=workflow%3ACI) 4 | [![Download Status](https://img.shields.io/npm/dm/feathers-sequelize.svg)](https://www.npmjs.com/package/feathers-sequelize) 5 | [![Discord](https://badgen.net/badge/icon/discord?icon=discord&label)](https://discord.gg/qa8kez8QBx) 6 | 7 | > **Caution:** When you're using feathers v4 and want to upgrade to feathers v5, please make sure to read the [migration guide](#migrate-to-feathers-v5-dove). 8 | 9 | > NOTE: This is the version for Feathers v5. For Feathers v4 use [feathers-sequelize v6](https://github.com/feathersjs-ecosystem/feathers-sequelize/tree/crow) 10 | 11 | A [Feathers](https://feathersjs.com) database adapter for [Sequelize](https://sequelize.org/), an ORM for Node.js. It supports PostgreSQL, MySQL, MariaDB, SQLite and MSSQL and features transaction support, relations, read replication and more. 12 | 13 | 14 | 15 | - [API](#api) 16 | - [`service(options)`](#serviceoptions) 17 | - [params.sequelize](#paramssequelize) 18 | - [operators](#operatormap) 19 | - [Modifying the model](#modifyModel) 20 | - [Caveats](#caveats) 21 | - [Sequelize `raw` queries](#sequelize-raw-queries) 22 | - [Working with MSSQL](#working-with-mssql) 23 | - [Example](#example) 24 | - [Associations](#associations) 25 | - [Embrace the ORM](#embrace-the-orm) 26 | - [Setting `params.sequelize.include`](#setting-paramssequelizeinclude) 27 | - [Querying](#querying) 28 | - [Querying a nested column](#querying-a-nested-column) 29 | - [Working with Sequelize Model instances](#working-with-sequelize-model-instances) 30 | - [Validation](#validation) 31 | - [Errors](#errors) 32 | - [Testing sequelize queries in isolation](#testing-sequelize-queries-in-isolation) 33 | - [1. Build a test file](#1-build-a-test-file) 34 | - [2. Integrate the query using a "before" hook](#2-integrate-the-query-using-a-before-hook) 35 | - [Migrations](#migrations) 36 | - [Initial Setup: one-time tasks](#initial-setup-one-time-tasks) 37 | - [Migrations workflow](#migrations-workflow) 38 | - [Create a new migration](#create-a-new-migration) 39 | - [Add the up/down scripts:](#add-the-updown-scripts) 40 | - [Keeping your app code in sync with migrations](#keeping-your-app-code-in-sync-with-migrations) 41 | - [Apply a migration](#apply-a-migration) 42 | - [Undo the previous migration](#undo-the-previous-migration) 43 | - [Reverting your app to a previous state](#reverting-your-app-to-a-previous-state) 44 | - [Migrating](#migrating) 45 | - [License](#license) 46 | - [Migrating to feathers v5](#migrate-to-feathers-v5-dove) 47 | 48 | 49 | 50 | > __Very Important:__ Before using this adapter you have to be familiar with both, the [Feathers Basics](https://docs.feathersjs.com/guides/basics/setup.html) and general use of [Sequelize](https://sequelize.org/docs/v6/). For associations and relations see the [associations](#associations) section. This adapter may not cover all use cases but they can still be implemented using Sequelize models directly in a [Custom Feathers service](https://docs.feathersjs.com/guides/basics/services.html). 51 | 52 | ```bash 53 | npm install --save feathers-sequelize@pre 54 | ``` 55 | 56 | And [one of the following](https://sequelize.org/docs/v6/getting-started/): 57 | 58 | ```bash 59 | npm install --save pg pg-hstore 60 | npm install --save mysql2 // For both mysql and mariadb dialects 61 | npm install --save sqlite3 62 | npm install --save tedious // MSSQL 63 | ``` 64 | 65 | > __Important:__ `feathers-sequelize` implements the [Feathers Common database adapter API](https://docs.feathersjs.com/api/databases/common.html) and [querying syntax](https://docs.feathersjs.com/api/databases/querying.html). 66 | > For more information about models and general Sequelize usage, follow up in the [Sequelize documentation](https://sequelize.org/docs/v6/). 67 | 68 | ## API 69 | 70 | ### `new SequelizeService(options)` 71 | 72 | Returns a new service instance initialized with the given options. 73 | 74 | ```js 75 | const Model = require('./models/mymodel'); 76 | const { SequelizeService } = require('feathers-sequelize'); 77 | 78 | app.use('/messages', new SequelizeService({ Model })); 79 | app.use('/messages', new SequelizeService({ Model, id, events, paginate })); 80 | ``` 81 | 82 | __Options:__ 83 | 84 | - `Model` (**required**) - The Sequelize model definition 85 | - `id` (*optional*, default: primary key of the model) - The name of the id field property. Will use the first property with `primaryKey: true` by default. 86 | - `raw` (*optional*, default: `true`) - Runs queries faster by returning plain objects instead of Sequelize models. 87 | - `Sequelize` (*optional*, default: `Model.sequelize.Sequelize`) - The Sequelize instance 88 | - `events` (*optional*) - A list of [custom service events](https://docs.feathersjs.com/api/events.html#custom-events) sent by this service 89 | - `paginate` (*optional*) - A [pagination object](https://docs.feathersjs.com/api/databases/common.html#pagination) containing a `default` and `max` page size 90 | - `multi` (*optional*) - Allow `create` with arrays and `update` and `remove` with `id` `null` to change multiple items. Can be `true` for all methods or an array of allowed methods (e.g. `[ 'remove', 'create' ]`) 91 | - `operatorMap` (*optional*) - A mapping from query syntax property names to to [Sequelize secure operators](https://sequelize.org/docs/v6/core-concepts/model-querying-basics/#operators) 92 | - `operators` (*optional*) - An array of additional query operators to allow (e..g `[ '$regex', '$geoNear' ]`). Default is the supported `operators` 93 | - `filters` (*optional*) - An object of additional query parameters to allow (e..g `{ '$post.id$': true }`).` 94 | 95 | ### params.sequelize 96 | 97 | When making a [service method](https://docs.feathersjs.com/api/services.html) call, `params` can contain an `sequelize` property which allows to pass additional Sequelize options. This can e.g. be used to **retrieve associations**. Normally this wil be set in a before [hook](https://docs.feathersjs.com/api/hooks.html): 98 | 99 | ```js 100 | app.service('messages').hooks({ 101 | before: { 102 | find(context) { 103 | // Get the Sequelize instance. In the generated application via: 104 | const sequelize = context.app.get('sequelizeClient'); 105 | const { User } = sequelize.models; 106 | 107 | context.params.sequelize = { 108 | include: [ User ] 109 | } 110 | 111 | return context; 112 | } 113 | } 114 | }); 115 | ``` 116 | 117 | Other options that `params.sequelize` allows you to pass can be found in [Sequelize querying docs](https://sequelize.org/master/manual/model-querying-basics.html). 118 | Beware that when setting a [top-level `where` property](https://sequelize.org/master/manual/eager-loading.html#complex-where-clauses-at-the-top-level) (usually for querying based on a column on an associated model), the `where` in `params.sequelize` will overwrite your `query`. 119 | 120 | This library offers some additional functionality when using `sequelize.returning` in services that support `multi`. The `multi` option allows you to create, patch, and remove multiple records at once. When using `sequelize.returning` with `multi`, the `sequelize.returning` is used to indicate if the method should return any results. This is helpful when updating large numbers of records and you do not need the API (or events) to be bogged down with results. 121 | 122 | ### operatorMap 123 | 124 | Sequelize deprecated string based operators a while ago for security reasons. Starting at version 4.0.0 `feathers-sequelize` converts queries securely, so you can still use string based operators listed below. If you want to support additional Sequelize operators, the `operatorMap` service option can contain a mapping from query parameter name to Sequelize operator. By default supported are: 125 | 126 | ``` 127 | '$eq', 128 | '$ne', 129 | '$gte', 130 | '$gt', 131 | '$lte', 132 | '$lt', 133 | '$in', 134 | '$nin', 135 | '$like', 136 | '$notLike', 137 | '$iLike', 138 | '$notILike', 139 | '$or', 140 | '$and' 141 | ``` 142 | 143 | ```js 144 | // Find all users with name similar to Dav 145 | app.service('users').find({ 146 | query: { 147 | name: { 148 | $like: 'Dav%' 149 | } 150 | } 151 | }); 152 | ``` 153 | 154 | ``` 155 | GET /users?name[$like]=Dav% 156 | ``` 157 | 158 | ## Modifying the Model 159 | 160 | Sequelize allows you to call methods like `Model.scope()`, `Model.schema()`, and others. To use these methods, extend the class to overwrite the `getModel` method. 161 | 162 | ```js 163 | const { SequelizeService } = require('feathers-sequelize'); 164 | 165 | class Service extends SequelizeService { 166 | getModel(params) { 167 | let Model = this.options.Model; 168 | if (params?.sequelize?.scope) { 169 | Model = Model.scope(params.sequelize.scope); 170 | } 171 | if (params?.sequelize?.schema) { 172 | Model = Model.schema(params.sequelize.schema); 173 | } 174 | return Model; 175 | } 176 | } 177 | ``` 178 | 179 | ## Caveats 180 | 181 | ### Sequelize `raw` queries 182 | 183 | By default, all `feathers-sequelize` operations will return `raw` data (using `raw: true` when querying the database). This results in faster execution and allows feathers-sequelize to interoperate with feathers-common hooks and other 3rd party integrations. However, this will bypass some of the "goodness" you get when using Sequelize as an ORM: 184 | 185 | - custom getters/setters will be bypassed 186 | - model-level validations are bypassed 187 | - associated data loads a bit differently 188 | - ...and several other issues that one might not expect 189 | 190 | Don't worry! The solution is easy. Please read the guides about [working with model instances](#working-with-sequelize-model-instances). You can also pass `{ raw: true/false}` in `params.sequelize` to change the behavior per service call. 191 | 192 | ### Working with MSSQL 193 | 194 | When using MSSQL as the database, a default sort order always has to be applied, otherwise the adapter will throw an `Invalid usage of the option NEXT in the FETCH statement.` error. This can be done in your model with: 195 | 196 | ```js 197 | model.beforeFind(model => model.order.push(['id', 'ASC'])) 198 | ``` 199 | 200 | Or in a hook like this: 201 | 202 | ```js 203 | export default function (options = {}) { 204 | return async context => { 205 | const { query = {} } = context.params; 206 | // Sort by id field ascending (or any other property you want) 207 | // See https://docs.feathersjs.com/api/databases/querying.html#sort 208 | const $sort = { id: 1 }; 209 | 210 | context.params.query = { 211 | $sort: { 212 | 213 | }, 214 | ...query 215 | } 216 | 217 | return context; 218 | } 219 | } 220 | ``` 221 | 222 | ### Primary keys 223 | All tables used by a feathers-sequelize service require a primary key. Although it is common practice for many-to-many tables to not have a primary key, this service will break if the table does not have a primary key. This is because most service methods require an ID and because of how feathers maps services to URLs. 224 | 225 | ## Example 226 | 227 | Here is an example of a Feathers server with a `messages` SQLite Sequelize Model: 228 | 229 | ``` 230 | $ npm install @feathersjs/feathers @feathersjs/errors @feathersjs/express @feathersjs/socketio sequelize feathers-sequelize sqlite3 231 | ``` 232 | 233 | In `app.js`: 234 | 235 | ```ts 236 | import path from 'path'; 237 | import { feathers } from '@feathersjs/feathers'; 238 | import express from '@feathersjs/express'; 239 | import socketio from '@feathersjs/socketio'; 240 | 241 | import Sequelize from 'sequelize'; 242 | import SequelizeService from 'feathers-sequelize'; 243 | 244 | const sequelize = new Sequelize('sequelize', '', '', { 245 | dialect: 'sqlite', 246 | storage: path.join(__dirname, 'db.sqlite'), 247 | logging: false 248 | }); 249 | 250 | const Message = sequelize.define('message', { 251 | text: { 252 | type: Sequelize.STRING, 253 | allowNull: false 254 | } 255 | }, { 256 | freezeTableName: true 257 | }); 258 | 259 | // Create an Express compatible Feathers application instance. 260 | const app = express(feathers()); 261 | 262 | // Turn on JSON parser for REST services 263 | app.use(express.json()); 264 | // Turn on URL-encoded parser for REST services 265 | app.use(express.urlencoded({ extended: true })); 266 | // Enable REST services 267 | app.configure(express.rest()); 268 | // Enable Socket.io services 269 | app.configure(socketio()); 270 | // Create an in-memory Feathers service with a default page size of 2 items 271 | // and a maximum size of 4 272 | app.use('/messages', new SequelizeService({ 273 | Model: Message, 274 | paginate: { 275 | default: 2, 276 | max: 4 277 | } 278 | })); 279 | app.use(express.errorHandler()); 280 | 281 | Message.sync({ force: true }).then(() => { 282 | // Create a dummy Message 283 | app.service('messages').create({ 284 | text: 'Message created on server' 285 | }).then(message => console.log('Created message', message)); 286 | }); 287 | 288 | // Start the server 289 | const port = 3030; 290 | 291 | app.listen(port, () => { 292 | console.log(`Feathers server listening on port ${port}`); 293 | }); 294 | ``` 295 | 296 | Run the example with `node app` and go to [localhost:3030/messages](http://localhost:3030/messages). 297 | 298 | 299 | ## Associations 300 | 301 | ### Embrace the ORM 302 | 303 | The documentation on [Sequelize associations and relations](https://sequelize.org/docs/v6/core-concepts/assocs/) is essential to implementing associations with this adapter and one of the steepest parts of the Sequelize learning curve. If you have never used an ORM, let it do a lot of the heavy lifting for you! 304 | 305 | ### Setting `params.sequelize.include` 306 | 307 | Once you understand how the `include` option works with Sequelize, you will want to set that option from a [before hook](https://docs.feathersjs.com/guides/basics/hooks.html) in Feathers. Feathers will pass the value of `context.params.sequelize` as the options parameter for all Sequelize method calls. This is what your hook might look like: 308 | 309 | ```js 310 | // GET /my-service?name=John&include=1 311 | function (context) { 312 | const { include, ...query } = context.params.query; 313 | 314 | if (include) { 315 | const AssociatedModel = context.app.services.fooservice.Model; 316 | context.params.sequelize = { 317 | include: [{ model: AssociatedModel }] 318 | }; 319 | // Update the query to not include `include` 320 | context.params.query = query; 321 | } 322 | 323 | return context; 324 | } 325 | ``` 326 | 327 | Underneath the hood, feathers will call your models find method sort of like this: 328 | 329 | ```js 330 | // YourModel is a sequelize model 331 | const options = Object.assign({ where: { name: 'John' }}, context.params.sequelize); 332 | YourModel.findAndCount(options); 333 | ``` 334 | 335 | For more information, follow up up in the [Sequelize documentation for associations](https://sequelize.org/docs/v6/core-concepts/assocs/) and [this issue](https://github.com/feathersjs-ecosystem/feathers-sequelize/issues/20). 336 | 337 | ## Querying 338 | 339 | Additionally to the [common querying mechanism](https://docs.feathersjs.com/api/databases/querying.html) this adapter also supports all [Sequelize query operators](https://sequelize.org/docs/v6/core-concepts/model-querying-basics/#operators). 340 | 341 | ### Querying a nested column 342 | 343 | To query based on a column in an associated model, you can use Sequelize's [nested column syntax](https://sequelize.org/master/manual/eager-loading.html#complex-where-clauses-at-the-top-level) in a query. The nested column syntax is considered a `filter` by Feathers, and so each such usage has to be [whitelisted](#whitelist). 344 | 345 | Example: 346 | ```js 347 | // Find a user with post.id == 120 348 | app.service('users').find({ 349 | query: { 350 | '$post.id$': 120, 351 | include: { 352 | model: posts 353 | } 354 | } 355 | }); 356 | ``` 357 | 358 | For this case to work, you'll need to add '$post.id$' to the service options' ['filters' property](#whitelist). 359 | 360 | ## Working with Sequelize Model instances 361 | 362 | It is highly recommended to use `raw` queries, which is the default. However, there are times when you will want to take advantage of [Sequelize Instance](https://sequelize.org/docs/v6/core-concepts/model-instances/) methods. There are two ways to tell feathers to return Sequelize instances: 363 | 364 | 1. Set `{ raw: false }` in a "before" hook: 365 | ```js 366 | const rawFalse = () => (context) => { 367 | if (!context.params.sequelize) context.params.sequelize = {}; 368 | Object.assign(context.params.sequelize, { raw: false }); 369 | return context; 370 | } 371 | 372 | export default { 373 | after: { 374 | // ... 375 | find: [rawFalse()] 376 | // ... 377 | }, 378 | // ... 379 | }; 380 | 381 | ``` 382 | 1. Use the `hydrate` hook in the "after" phase: 383 | 384 | ```js 385 | import { hydrate } from 'feathers-sequelize'; 386 | 387 | export default { 388 | after: { 389 | // ... 390 | find: [hydrate()] 391 | // ... 392 | }, 393 | // ... 394 | }; 395 | 396 | // Or, if you need to include associated models, you can do the following: 397 | const includeAssociated = () => (context) => hydrate({ 398 | include: [{ model: context.app.services.fooservice.Model }] 399 | }); 400 | 401 | export default { 402 | after: { 403 | // ... 404 | find: [includeAssociated()] 405 | // ... 406 | }, 407 | // ... 408 | }; 409 | ``` 410 | 411 | For a more complete example see this [gist](https://gist.github.com/sicruse/bfaa17008990bab2fd1d76a670c3923f). 412 | 413 | > **Important:** When working with Sequelize Instances, most of the feathers-hooks-common will no longer work. If you need to use a common hook or other 3rd party hooks, you should use the "dehydrate" hook to convert data back to a plain object: 414 | > ```js 415 | > import { dehydrate, hydrate } from 'feathers-sequelize'; 416 | > import { populate } = from 'feathers-hooks-common'; 417 | > 418 | > export default { 419 | > after: { 420 | > // ... 421 | > find: [hydrate(), doSomethingCustom(), dehydrate(), populate()] 422 | > // ... 423 | > }, 424 | > // ... 425 | > }; 426 | > ``` 427 | 428 | ## Validation 429 | 430 | Sequelize by default gives you the ability to [add validations at the model level](https://sequelize.org/docs/v6/core-concepts/validations-and-constraints/). Using an error handler like the one that [comes with Feathers](https://github.com/feathersjs/feathers-errors/blob/master/src/error-handler.js) your validation errors will be formatted nicely right out of the box! 431 | 432 | ## Errors 433 | 434 | Errors do not contain Sequelize specific information. The original Sequelize error can be retrieved on the server via: 435 | 436 | ```js 437 | import { ERROR } = from 'feathers-sequelize'; 438 | 439 | try { 440 | await sequelizeService.doSomething(); 441 | } catch(error) { 442 | // error is a FeathersError 443 | // Safely retrieve the Sequelize error 444 | const sequelizeError = error[ERROR]; 445 | } 446 | ``` 447 | 448 | ## Testing sequelize queries in isolation 449 | 450 | If you wish to use some of the more advanced features of sequelize, you should first test your queries in isolation (without feathers). Once your query is working, you can integrate it into your feathers app. 451 | 452 | ### 1. Build a test file 453 | 454 | Create a temporary file in your project root like this: 455 | 456 | ```js 457 | // test.js 458 | import app from from './src/app'; 459 | // run setup to initialize relations 460 | app.setup(); 461 | 462 | const seqClient = app.get('sequelizeClient'); 463 | const SomeModel = seqClient.models['some-model']; 464 | const log = console.log.bind(console); 465 | 466 | SomeModel.findAll({ 467 | /* 468 | * Build your custom query here. We will use this object later. 469 | */ 470 | }).then(log).catch(log); 471 | ``` 472 | 473 | And then run this file like this: 474 | 475 | ``` 476 | node test.js 477 | ``` 478 | Continue updating the file and running it until you are satisfied with the results. 479 | 480 | ### 2. Integrate the query using a "before" hook 481 | 482 | Once your have your custom query working to your satisfaction, you will want to integrate it into your feathers app. Take the guts of the `findAll` operation above and create a "before" hook: 483 | 484 | ```js 485 | function buildCustomQuery(context) { 486 | context.params.sequelize = { 487 | /* 488 | * This is the same object you passed to "findAll" above. 489 | * This object is *shallow merged* onto the underlying query object 490 | * generated by feathers-sequelize (it is *not* a deep merge!). 491 | * The underlying data will already contain the following: 492 | * - "where" condition based on query paramters 493 | * - "limit" and "offset" based on pagination settings 494 | * - "order" based $sort query parameter 495 | * You can override any/all of the underlying data by setting it here. 496 | * This gives you full control over the query object passed to sequelize! 497 | */ 498 | }; 499 | } 500 | 501 | someService.hooks({ 502 | before: { 503 | find: [buildCustomQuery] 504 | } 505 | }); 506 | ``` 507 | 508 | 509 | ## Migrations 510 | 511 | Migrations with feathers and sequelize are quite simple. This guide will walk you through creating the recommended file structure, but you are free to rearrange things as you see fit. The following assumes you have a `migrations` folder in the root of your app. 512 | 513 | ### Initial Setup: one-time tasks 514 | 515 | - Install the [sequelize CLI](https://github.com/sequelize/cli): 516 | 517 | ``` 518 | npm install sequelize-cli --save -g 519 | ``` 520 | 521 | - Create a `.sequelizerc` file in your project root with the following content: 522 | 523 | ```js 524 | const path = require('path'); 525 | 526 | module.exports = { 527 | 'config': path.resolve('migrations/config.js'), 528 | 'migrations-path': path.resolve('migrations/scripts'), 529 | 'seeders-path': path.resolve('migrations/seeders'), 530 | 'models-path': path.resolve('migrations/models.js') 531 | }; 532 | ``` 533 | 534 | - Create the migrations config in `migrations/config.js`: 535 | 536 | ```js 537 | const app = require('../src/app'); 538 | const env = process.env.NODE_ENV || 'development'; 539 | const dialect = 'postgres'; // Or your dialect name 540 | 541 | module.exports = { 542 | [env]: { 543 | dialect, 544 | url: app.get(dialect), 545 | migrationStorageTableName: '_migrations' 546 | } 547 | }; 548 | ``` 549 | 550 | - Define your models config in `migrations/models.js`: 551 | 552 | ```js 553 | const Sequelize = require('sequelize'); 554 | const app = require('../src/app'); 555 | const sequelize = app.get('sequelizeClient'); 556 | const models = sequelize.models; 557 | 558 | // The export object must be a dictionary of model names -> models 559 | // It must also include sequelize (instance) and Sequelize (constructor) properties 560 | module.exports = Object.assign({ 561 | Sequelize, 562 | sequelize 563 | }, models); 564 | ``` 565 | 566 | ### Migrations workflow 567 | 568 | The migration commands will load your application and it is therefore required that you define the same environment variables as when running your application. For example, many applications will define the database connection string in the startup command: 569 | 570 | ``` 571 | DATABASE_URL=postgres://user:pass@host:port/dbname npm start 572 | ``` 573 | All of the following commands assume that you have defined the same environment variables used by your application. 574 | 575 | > **ProTip:** To save typing, you can export environment variables for your current bash/terminal session: 576 | 577 | ``` 578 | export DATABASE_URL=postgres://user:pass@host:port/db 579 | ``` 580 | 581 | ### Create a new migration 582 | 583 | To create a new migration file, run the following command and provide a meaningful name: 584 | 585 | ``` 586 | sequelize migration:create --name="meaningful-name" 587 | ``` 588 | 589 | This will create a new file in the `migrations/scripts` folder. All migration file names will be prefixed with a sortable data/time string: `20160421135254-meaningful-name.js`. This prefix is crucial for making sure your migrations are executed in the proper order. 590 | 591 | > **NOTE:** The order of your migrations is determined by the alphabetical order of the migration scripts in the file system. The file names generated by the CLI tools will always ensure that the most recent migration comes last. 592 | 593 | #### Add the up/down scripts: 594 | 595 | Open the newly created migration file and write the code to both apply and undo the migration. Please refer to the [sequelize migration functions](https://sequelize.org/docs/v6/other-topics/migrations/) for available operations. **Do not be lazy - write the down script too and test!** Here is an example of converting a `NOT NULL` column accept null values: 596 | 597 | ```js 598 | 'use strict'; 599 | 600 | module.exports = { 601 | up: function (queryInterface, Sequelize) { 602 | return queryInterface.changeColumn('tableName', 'columnName', { 603 | type: Sequelize.STRING, 604 | allowNull: true 605 | }); 606 | }, 607 | 608 | down: function (queryInterface, Sequelize) { 609 | return queryInterface.changeColumn('tableName', 'columnName', { 610 | type: Sequelize.STRING, 611 | allowNull: false 612 | }); 613 | } 614 | }; 615 | ``` 616 | 617 | > **ProTip:** As of this writing, if you use the `changeColumn` method you must **always** specify the `type`, even if the type is not changing. 618 | 619 | > **ProTip:** Down scripts are typically easy to create and should be nearly identical to the up script except with inverted logic and inverse method calls. 620 | 621 | #### Keeping your app code in sync with migrations 622 | 623 | The application code should always be up to date with the migrations. This allows the app to be freshly installed with everything up-to-date without running the migration scripts. Your migrations should also never break a freshly installed app. This often times requires that you perform any necessary checks before executing a task. For example, if you update a model to include a new field, your migration should first check to make sure that new field does not exist: 624 | 625 | ```js 626 | 'use strict'; 627 | 628 | module.exports = { 629 | up: function (queryInterface, Sequelize) { 630 | return queryInterface.describeTable('tableName').then(attributes => { 631 | if ( !attributes.columnName ) { 632 | return queryInterface.addColumn('tableName', 'columnName', { 633 | type: Sequelize.INTEGER, 634 | defaultValue: 0 635 | }); 636 | } 637 | }) 638 | }, 639 | 640 | down: function (queryInterface, Sequelize) { 641 | return queryInterface.describeTable('tableName').then(attributes => { 642 | if ( attributes.columnName ) { 643 | return queryInterface.removeColumn('tableName', 'columnName'); 644 | } 645 | }); 646 | } 647 | }; 648 | ``` 649 | 650 | ### Apply a migration 651 | 652 | The CLI tools will always run your migrations in the correct order and will keep track of which migrations have been applied and which have not. This data is stored in the database under the `_migrations` table. To ensure you are up to date, simply run the following: 653 | 654 | ``` 655 | sequelize db:migrate 656 | ``` 657 | 658 | > **ProTip:** You can add the migrations script to your application startup command to ensure that all migrations have run every time your app is started. Try updating your package.json `scripts` attribute and run `npm start`: 659 | 660 | ``` 661 | scripts: { 662 | start: "sequelize db:migrate && node src/" 663 | } 664 | ``` 665 | 666 | ### Undo the previous migration 667 | 668 | To undo the last migration, run the following command: 669 | 670 | ``` 671 | sequelize db:migrate:undo 672 | ``` 673 | 674 | Continue running the command to undo each migration one at a time - the migrations will be undone in the proper order. 675 | 676 | > **Note:** - You shouldn't really have to undo a migration unless you are the one developing a new migration and you want to test that it works. Applications rarely have to revert to a previous state, but when they do you will be glad you took the time to write and test your `down` scripts! 677 | 678 | ### Reverting your app to a previous state 679 | 680 | In the unfortunate case where you must revert your app to a previous state, it is important to take your time and plan your method of attack. Every application is different and there is no one-size-fits-all strategy for rewinding an application. However, most applications should be able to follow these steps (order is important): 681 | 682 | 1. Stop your application (kill the process) 683 | 1. Find the last stable version of your app 684 | 1. Count the number of migrations which have been added since that version 685 | 1. Undo your migrations one at a time until the db is in the correct state 686 | 1. Revert your code back to the previous state 687 | 1. Start your app 688 | 689 | ## License 690 | 691 | Copyright (c) 2024 692 | 693 | Licensed under the [MIT license](LICENSE). 694 | 695 | ### whitelist 696 | 697 | The `whitelist` property is no longer, you should use `filters` instead. Checkout the migration guide below. 698 | 699 | > Feathers v5 introduces a convention for `options.operators` and `options.filters`. The way feathers-sequelize worked in previous version is not compatible with these conventions. Please read https://dove.feathersjs.com/guides/migrating.html#custom-filters-operators. 700 | 701 | ## Migrate to Feathers v5 (dove) 702 | 703 | There are several breaking changes for feathers-sequelize in Feathers v5. This guide will help you to migrate your existing Feathers v4 application to Feathers v5. 704 | 705 | ### Named export 706 | 707 | The default export of `feathers-sequelize` has been removed. You now have to import the `SequelizeService` class directly: 708 | ```js 709 | import { SequelizeService } from 'feathers-sequelize'; 710 | 711 | app.use('/messages', new SequelizeService({ ... })); 712 | ``` 713 | This follows conventions from feathers v5. 714 | 715 | ### operators / operatorMap 716 | 717 | > Feathers v5 introduces a convention for `options.operators` and `options.filters`. The way feathers-sequelize worked in previous version is not compatible with these conventions. Please read https://dove.feathersjs.com/guides/migrating.html#custom-filters-operators first. 718 | 719 | The old `options.operators` object is renamed to `options.operatorMap`: 720 | 721 | ```js 722 | import { SequelizeService } from 'feathers-sequelize'; 723 | import { Op } from 'sequelize'; 724 | 725 | app.use('/messages', new SequelizeService({ 726 | Model, 727 | // operators is now operatorMap: 728 | operatorMap: { 729 | $between: Op.between 730 | } 731 | })); 732 | ``` 733 | 734 | ### filters 735 | 736 | > Feathers v5 introduces a convention for `options.operators` and `options.filters`. The way feathers-sequelize worked in previous version is not compatible with these conventions. Please read https://dove.feathersjs.com/guides/migrating.html#custom-filters-operators first. 737 | 738 | Feathers v5 introduces a new `filters` option. It is an object to verify filters. Here you need to add `$dollar.notation$` operators, if you have some. 739 | 740 | ```js 741 | import { SequelizeService } from 'feathers-sequelize'; 742 | 743 | app.use('/messages', new SequelizeService({ 744 | Model, 745 | filters: { 746 | '$and': true, 747 | '$person.name$': true 748 | } 749 | })); 750 | ``` 751 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | import pg from 'pg' 2 | import assert from 'node:assert' 3 | import { expect } from 'chai' 4 | 5 | import Sequelize, { Op } from 'sequelize' 6 | import { errors } from '@feathersjs/errors' 7 | import type { Paginated } from '@feathersjs/feathers' 8 | import { feathers } from '@feathersjs/feathers' 9 | import adaptertests from '@feathersjs/adapter-tests' 10 | 11 | import type { SequelizeAdapterOptions } from '../src' 12 | import { SequelizeService, hydrate, dehydrate, ERROR } from '../src' 13 | import makeConnection from './connection' 14 | const testSuite = adaptertests([ 15 | '.options', 16 | '.events', 17 | '._create', 18 | '._find', 19 | '._get', 20 | '._patch', 21 | '._remove', 22 | '._update', 23 | 'params.adapter + paginate', 24 | 'params.adapter + multi', 25 | '.get', 26 | '.get + $select', 27 | '.get + id + query', 28 | // '.get + NotFound', // add '.get + NotFound (integer)' once https://github.com/feathersjs/feathers/pull/3486 is merged and published 29 | '.find', 30 | '.remove', 31 | '.remove + $select', 32 | '.remove + id + query', 33 | // add '.remove + NotFound (integer)' once https://github.com/feathersjs/feathers/pull/3486 is merged and published 34 | '.remove + multi', 35 | '.remove + multi no pagination', 36 | '.update', 37 | '.update + $select', 38 | '.update + id + query', 39 | '.update + query + NotFound', 40 | // '.update + NotFound', // add '.update + NotFound (integer)' once https://github.com/feathersjs/feathers/pull/3486 is merged and published 41 | '.patch', 42 | '.patch + $select', 43 | '.patch + id + query', 44 | '.patch multiple', 45 | '.patch multiple no pagination', 46 | '.patch multi query same', 47 | '.patch multi query changed', 48 | '.patch + query + NotFound', 49 | // '.patch + NotFound', // add '.patch + NotFound (integer)' once https://github.com/feathersjs/feathers/pull/3486 is merged and published 50 | '.create', 51 | '.create + $select', 52 | '.create multi', 53 | 'internal .find', 54 | 'internal .get', 55 | 'internal .create', 56 | 'internal .update', 57 | 'internal .patch', 58 | 'internal .remove', 59 | '.find + equal', 60 | '.find + equal multiple', 61 | '.find + $sort', 62 | '.find + $sort + string', 63 | '.find + $limit', 64 | '.find + $limit 0', 65 | '.find + $skip', 66 | '.find + $select', 67 | '.find + $or', 68 | '.find + $in', 69 | '.find + $nin', 70 | '.find + $lt', 71 | '.find + $lte', 72 | '.find + $gt', 73 | '.find + $gte', 74 | '.find + $ne', 75 | '.find + $gt + $lt + $sort', 76 | '.find + $or nested + $sort', 77 | '.find + paginate', 78 | '.find + paginate + query', 79 | '.find + paginate + $limit + $skip', 80 | '.find + paginate + $limit 0', 81 | '.find + paginate + params', 82 | '.remove + id + query id', 83 | '.update + id + query id', 84 | '.patch + id + query id', 85 | '.get + id + query id', 86 | ]) 87 | 88 | // The base tests require the use of Sequelize.BIGINT to avoid 'out of range errors' 89 | // Unfortunately BIGINT's are serialized as Strings: 90 | // https://github.com/sequelize/sequelize/issues/1774 91 | // eslint-disable-next-line import-x/no-named-as-default-member 92 | pg.defaults.parseInt8 = true 93 | 94 | const sequelize = makeConnection(process.env.DB) 95 | 96 | const Model = sequelize.define( 97 | 'people', 98 | { 99 | name: { 100 | type: Sequelize.STRING, 101 | allowNull: false, 102 | }, 103 | age: { 104 | type: Sequelize.INTEGER, 105 | }, 106 | created: { 107 | type: Sequelize.BOOLEAN, 108 | }, 109 | time: { 110 | type: Sequelize.BIGINT, 111 | }, 112 | status: { 113 | type: Sequelize.STRING, 114 | defaultValue: 'pending', 115 | }, 116 | }, 117 | { 118 | freezeTableName: true, 119 | }, 120 | ) 121 | const Order = sequelize.define( 122 | 'orders', 123 | { 124 | name: { 125 | type: Sequelize.STRING, 126 | allowNull: false, 127 | }, 128 | }, 129 | { 130 | freezeTableName: true, 131 | }, 132 | ) 133 | const CustomId = sequelize.define( 134 | 'people-customid', 135 | { 136 | customid: { 137 | type: Sequelize.INTEGER, 138 | autoIncrement: true, 139 | primaryKey: true, 140 | }, 141 | name: { 142 | type: Sequelize.STRING, 143 | allowNull: false, 144 | }, 145 | age: { 146 | type: Sequelize.INTEGER, 147 | }, 148 | created: { 149 | type: Sequelize.BOOLEAN, 150 | }, 151 | time: { 152 | type: Sequelize.BIGINT, 153 | }, 154 | }, 155 | { 156 | freezeTableName: true, 157 | }, 158 | ) 159 | const CustomGetterSetter = sequelize.define( 160 | 'custom-getter-setter', 161 | { 162 | addsOneOnSet: { 163 | type: Sequelize.INTEGER, 164 | set(val: any) { 165 | this.setDataValue('addsOneOnSet', val + 1) 166 | }, 167 | }, 168 | addsOneOnGet: { 169 | type: Sequelize.INTEGER, 170 | get() { 171 | return this.getDataValue('addsOneOnGet') + 1 172 | }, 173 | }, 174 | }, 175 | { 176 | freezeTableName: true, 177 | }, 178 | ) 179 | Model.hasMany(Order) 180 | Order.belongsTo(Model) 181 | 182 | describe('Feathers Sequelize Service', () => { 183 | before(async () => { 184 | await Model.sync({ force: true }) 185 | await CustomId.sync({ force: true }) 186 | await CustomGetterSetter.sync({ force: true }) 187 | await Order.sync({ force: true }) 188 | }) 189 | 190 | describe('Initialization', () => { 191 | it('throws an error when missing a Model', () => { 192 | // @ts-expect-error Model is missing 193 | expect(() => new SequelizeService({ name: 'Test' })).to.throw( 194 | 'You must provide a Sequelize Model', 195 | ) 196 | }) 197 | 198 | it('throws an error if options.operators is not an array', () => { 199 | expect( 200 | () => 201 | new SequelizeService({ 202 | Model, 203 | operators: { 204 | // @ts-expect-error operators is not an array 205 | $like: Op.like, 206 | }, 207 | }), 208 | ).to.throw(/The 'operators' option must be an array./) 209 | 210 | expect( 211 | () => 212 | new SequelizeService({ 213 | Model, 214 | operators: [], 215 | }), 216 | ).to.not.throw() 217 | }) 218 | 219 | it('re-exports hooks', () => { 220 | assert.ok(hydrate) 221 | assert.ok(dehydrate) 222 | }) 223 | }) 224 | 225 | describe('Common Tests', () => { 226 | const app = feathers<{ 227 | people: SequelizeService 228 | 'people-customid': SequelizeService 229 | }>() 230 | .use( 231 | 'people', 232 | new SequelizeService({ 233 | Model, 234 | events: ['testing'], 235 | }), 236 | ) 237 | .use( 238 | 'people-customid', 239 | new SequelizeService({ 240 | Model: CustomId, 241 | events: ['testing'], 242 | }), 243 | ) 244 | 245 | it('has .Model', () => { 246 | assert.ok(app.service('people').Model) 247 | }) 248 | 249 | testSuite(app, errors, 'people', 'id') 250 | testSuite(app, errors, 'people-customid', 'customid') 251 | 252 | describe('remove this when https://github.com/feathersjs/feathers/pull/3486 is merged and published', () => { 253 | it('.get + NotFound (integer)', async () => { 254 | try { 255 | await app.service('people').get(123456789) 256 | throw new Error('Should never get here') 257 | } catch (error: any) { 258 | assert.strictEqual( 259 | error.name, 260 | 'NotFound', 261 | 'Error is a NotFound Feathers error', 262 | ) 263 | } 264 | }) 265 | 266 | it('.get + NotFound (integer)', async () => { 267 | try { 268 | await app.service('people-customid').get(123456789) 269 | throw new Error('Should never get here') 270 | } catch (error: any) { 271 | assert.strictEqual( 272 | error.name, 273 | 'NotFound', 274 | 'Error is a NotFound Feathers error', 275 | ) 276 | } 277 | }) 278 | 279 | it('.remove + NotFound (integer)', async () => { 280 | try { 281 | await app.service('people').remove(123456789) 282 | throw new Error('Should never get here') 283 | } catch (error: any) { 284 | assert.strictEqual( 285 | error.name, 286 | 'NotFound', 287 | 'Error is a NotFound Feathers error', 288 | ) 289 | } 290 | }) 291 | 292 | it('.remove + NotFound (integer)', async () => { 293 | try { 294 | await app.service('people-customid').remove(123456789) 295 | throw new Error('Should never get here') 296 | } catch (error: any) { 297 | assert.strictEqual( 298 | error.name, 299 | 'NotFound', 300 | 'Error is a NotFound Feathers error', 301 | ) 302 | } 303 | }) 304 | 305 | it('.update + NotFound (integer)', async () => { 306 | try { 307 | await app.service('people').update(123456789, { 308 | name: 'NotFound', 309 | }) 310 | throw new Error('Should never get here') 311 | } catch (error: any) { 312 | assert.strictEqual( 313 | error.name, 314 | 'NotFound', 315 | 'Error is a NotFound Feathers error', 316 | ) 317 | } 318 | }) 319 | 320 | it('.update + NotFound (integer)', async () => { 321 | try { 322 | await app.service('people-customid').update(123456789, { 323 | name: 'NotFound', 324 | }) 325 | throw new Error('Should never get here') 326 | } catch (error: any) { 327 | assert.strictEqual( 328 | error.name, 329 | 'NotFound', 330 | 'Error is a NotFound Feathers error', 331 | ) 332 | } 333 | }) 334 | 335 | it('.patch + NotFound (integer)', async () => { 336 | try { 337 | await app.service('people').patch(123456789, { 338 | name: 'PatchDoug', 339 | }) 340 | throw new Error('Should never get here') 341 | } catch (error: any) { 342 | assert.strictEqual( 343 | error.name, 344 | 'NotFound', 345 | 'Error is a NotFound Feathers error', 346 | ) 347 | } 348 | }) 349 | 350 | it('.patch + NotFound (integer)', async () => { 351 | try { 352 | await app.service('people-customid').patch(123456789, { 353 | name: 'PatchDoug', 354 | }) 355 | throw new Error('Should never get here') 356 | } catch (error: any) { 357 | assert.strictEqual( 358 | error.name, 359 | 'NotFound', 360 | 'Error is a NotFound Feathers error', 361 | ) 362 | } 363 | }) 364 | }) 365 | }) 366 | 367 | describe('Feathers-Sequelize Specific Tests', () => { 368 | const app = feathers<{ 369 | people: SequelizeService 370 | orders: SequelizeService 371 | 'custom-getter-setter': SequelizeService 372 | }>() 373 | .use( 374 | 'people', 375 | new SequelizeService({ 376 | Model, 377 | paginate: { 378 | default: 10, 379 | }, 380 | events: ['testing'], 381 | multi: true, 382 | }), 383 | ) 384 | .use( 385 | 'orders', 386 | new SequelizeService({ 387 | Model: Order, 388 | multi: true, 389 | filters: { 390 | '$person.name$': true, 391 | }, 392 | }), 393 | ) 394 | .use( 395 | 'custom-getter-setter', 396 | new SequelizeService({ 397 | Model: CustomGetterSetter, 398 | events: ['testing'], 399 | raw: false, 400 | multi: true, 401 | }), 402 | ) 403 | 404 | afterEach(() => app.service('people').remove(null, { query: {} })) 405 | 406 | describe('Common functionality', () => { 407 | const people = app.service('people') 408 | let kirsten: any 409 | 410 | beforeEach(async () => { 411 | kirsten = await people.create({ name: 'Kirsten', age: 30 }) 412 | }) 413 | 414 | it('allows querying for null values (#45)', async () => { 415 | const name = 'Null test' 416 | await people.create({ name }) 417 | const { data } = (await people.find({ 418 | query: { age: null }, 419 | })) as Paginated 420 | 421 | assert.strictEqual(data.length, 1) 422 | assert.strictEqual(data[0].name, name) 423 | assert.strictEqual(data[0].age, null) 424 | }) 425 | 426 | it('cleans up the query prototype', async () => { 427 | const page = (await people.find({ 428 | query: { 429 | name: 'Dave', 430 | __proto__: [], 431 | }, 432 | })) as Paginated 433 | 434 | assert.strictEqual(page.data.length, 0) 435 | }) 436 | 437 | it('still allows querying with Sequelize operators', async () => { 438 | const name = 'Age test' 439 | await people.create({ name, age: 10 }) 440 | const { data } = (await people.find({ 441 | query: { age: { [Op.eq]: 10 } }, 442 | })) as Paginated 443 | 444 | assert.strictEqual(data.length, 1) 445 | assert.strictEqual(data[0].name, name) 446 | assert.strictEqual(data[0].age, 10) 447 | }) 448 | 449 | it('$like works', async () => { 450 | const name = 'Like test' 451 | await people.create({ name, age: 10 }) 452 | const { data } = (await people.find({ 453 | query: { name: { $like: '%ike%' } }, 454 | })) as Paginated 455 | 456 | assert.strictEqual(data.length, 1) 457 | assert.strictEqual(data[0].name, name) 458 | assert.strictEqual(data[0].age, 10) 459 | }) 460 | 461 | it('does not allow raw attribute $select', async () => { 462 | await assert.rejects(() => 463 | people.find({ 464 | // @ts-expect-error test-case 465 | query: { $select: [['(sqlite_version())', 'x']] }, 466 | }), 467 | ) 468 | }) 469 | 470 | it('hides the Sequelize error in ERROR symbol', async () => { 471 | try { 472 | await people.create({ 473 | age: 10, 474 | }) 475 | assert.ok(false, 'Should never get here') 476 | } catch (error: any) { 477 | assert.ok(error[ERROR]) 478 | assert.strictEqual(error.name, 'BadRequest') 479 | } 480 | }) 481 | 482 | it('correctly persists updates (#125)', async () => { 483 | const updateName = 'Ryan' 484 | 485 | await people.update(kirsten.id, { name: updateName }) 486 | 487 | const updatedPerson = await people.get(kirsten.id) 488 | 489 | assert.strictEqual(updatedPerson.name, updateName) 490 | }) 491 | 492 | it('correctly updates records using optional query param', async () => { 493 | const updateAge = 40 494 | const updateName = 'Kirtsten' 495 | 496 | await people.update( 497 | kirsten.id, 498 | { name: updateName, age: updateAge }, 499 | { 500 | query: { name: 'Kirsten' }, 501 | }, 502 | ) 503 | 504 | const updatedPerson = await people.get(kirsten.id) 505 | 506 | assert.strictEqual(updatedPerson.age, updateAge) 507 | }) 508 | 509 | it('fails update when query prevents result in no record match for id', async () => { 510 | const updateAge = 50 511 | const updateName = 'Kirtsten' 512 | 513 | try { 514 | await people.update( 515 | kirsten.id, 516 | { name: updateName, age: updateAge }, 517 | { 518 | query: { name: 'John' }, 519 | }, 520 | ) 521 | assert.ok(false, 'Should never get here') 522 | } catch (error: any) { 523 | assert.ok(error.message.indexOf('No record found') >= 0) 524 | } 525 | }) 526 | 527 | it('multi patch with correct sort', async () => { 528 | const people = app.service('people') 529 | const john = await people.create({ name: 'John', age: 30 }) 530 | const jane = await people.create({ name: 'Jane', age: 30 }) 531 | const updated = await people.patch( 532 | null, 533 | { age: 31 }, 534 | { 535 | query: { 536 | id: { $in: [john.id, jane.id] }, 537 | $sort: { name: 1 }, 538 | }, 539 | }, 540 | ) 541 | 542 | assert.strictEqual(updated.length, 2) 543 | assert.strictEqual(updated[0].id, jane.id) 544 | assert.strictEqual(updated[1].id, john.id) 545 | }) 546 | 547 | it('single patch with $select', async () => { 548 | const updateName = 'Ryan' 549 | 550 | const result = await people.patch( 551 | kirsten.id, 552 | { name: updateName }, 553 | { query: { $select: ['id'] } }, 554 | ) 555 | 556 | assert.deepStrictEqual(result, { id: kirsten.id }) 557 | 558 | const updatedPerson = await people.get(kirsten.id) 559 | 560 | assert.strictEqual(updatedPerson.name, updateName) 561 | }) 562 | 563 | it('does not use $skip in get()', async () => { 564 | const result = await people.get(kirsten.id, { query: { $skip: 10 } }) 565 | 566 | assert.strictEqual(result.id, kirsten.id) 567 | }) 568 | 569 | it('filterQuery does not convert dates and symbols', () => { 570 | const mySymbol = Symbol('test') 571 | const date = new Date() 572 | const query = { 573 | test: { sub: date }, 574 | [mySymbol]: 'hello', 575 | } 576 | const filtered = app.service('people').filterQuery({ query }) 577 | 578 | assert.deepStrictEqual(filtered.query, query) 579 | }) 580 | 581 | it('get works with custom $and', async () => { 582 | const john = await people.create({ name: 'John', age: 30 }) 583 | const foundJohn = await people.get(john.id, { 584 | query: { $and: [{ age: 30 }, { status: 'pending' }] }, 585 | }) 586 | assert.strictEqual(foundJohn.id, john.id) 587 | }) 588 | }) 589 | 590 | describe('Association Tests', () => { 591 | const people = app.service('people') 592 | const orders = app.service('orders') 593 | let kirsten: any 594 | let ryan: any 595 | 596 | beforeEach(async () => { 597 | kirsten = await people.create({ name: 'Kirsten', age: 30 }) 598 | 599 | await orders.create([ 600 | { name: 'Order 1', personId: kirsten.id }, 601 | { name: 'Order 2', personId: kirsten.id }, 602 | { name: 'Order 3', personId: kirsten.id }, 603 | ]) 604 | 605 | ryan = await people.create({ name: 'Ryan', age: 30 }) 606 | await orders.create([ 607 | { name: 'Order 4', personId: ryan.id }, 608 | { name: 'Order 5', personId: ryan.id }, 609 | { name: 'Order 6', personId: ryan.id }, 610 | ]) 611 | }) 612 | 613 | afterEach( 614 | async () => 615 | await orders 616 | .remove(null, { query: {} }) 617 | .then(() => people.remove(null, { query: {} })), 618 | ) 619 | 620 | it('find() returns correct total when using includes for non-raw requests #137', async () => { 621 | const options = { sequelize: { raw: false, include: Order } } 622 | 623 | const result = (await people.find(options)) as Paginated 624 | 625 | assert.strictEqual(result.total, 2) 626 | }) 627 | 628 | it('find() returns correct total when using includes for raw requests', async () => { 629 | const options = { sequelize: { include: Order } } 630 | 631 | const result = (await people.find(options)) as Paginated 632 | 633 | assert.strictEqual(result.total, 2) 634 | }) 635 | 636 | it('patch() includes associations', async () => { 637 | const params = { sequelize: { include: Order } } 638 | const data = { name: 'Patched' } 639 | 640 | const result = await people.patch(kirsten.id, data, params) 641 | 642 | expect(result).to.have.property('orders.id') 643 | }) 644 | 645 | it('patch() includes associations and query', async () => { 646 | const params = { sequelize: { include: Order } } 647 | const data = { name: 'Patched' } 648 | 649 | const current = await people.get(kirsten.id, params) 650 | 651 | const result = await people.patch(kirsten.id, data, { 652 | query: { name: current.name }, 653 | ...params, 654 | }) 655 | 656 | delete current.updatedAt 657 | // @ts-expect-error test-case 658 | delete result.updatedAt 659 | 660 | assert.deepStrictEqual(result, { ...current, ...data }) 661 | }) 662 | 663 | it('update() includes associations', async () => { 664 | const params = { sequelize: { include: Order } } 665 | const data = { name: 'Updated' } 666 | 667 | const result = await people.update(kirsten.id, data, params) 668 | 669 | expect(result).to.have.property('orders.id') 670 | }) 671 | 672 | it('update() includes associations and query', async () => { 673 | const params = { sequelize: { include: Order } } 674 | const data = { name: 'Updated' } 675 | 676 | const current = await people.get(kirsten.id, params) 677 | 678 | const result = await people.update( 679 | kirsten.id, 680 | { 681 | ...current, 682 | ...data, 683 | }, 684 | { 685 | query: { name: current.name }, 686 | ...params, 687 | }, 688 | ) 689 | 690 | delete current.updatedAt 691 | delete result.updatedAt 692 | 693 | assert.deepStrictEqual(result, { ...current, ...data }) 694 | }) 695 | 696 | it('remove() includes associations', async () => { 697 | const params = { sequelize: { include: Order } } 698 | 699 | const result = await people.remove(kirsten.id, params) 700 | 701 | expect(result).to.have.property('orders.id') 702 | }) 703 | 704 | it('can use $dollar.notation$', async () => { 705 | const result = (await orders.find({ 706 | query: { 707 | '$person.name$': 'Kirsten', 708 | }, 709 | sequelize: { 710 | include: [ 711 | { 712 | model: Model, 713 | as: 'person', 714 | }, 715 | ], 716 | raw: true, 717 | }, 718 | })) as any 719 | 720 | expect( 721 | result.map((x: any) => ({ name: x.name, personId: x.personId })), 722 | ).to.deep.equal([ 723 | { name: 'Order 1', personId: kirsten.id }, 724 | { name: 'Order 2', personId: kirsten.id }, 725 | { name: 'Order 3', personId: kirsten.id }, 726 | ]) 727 | }) 728 | }) 729 | 730 | describe('Custom getters and setters', () => { 731 | const service = app.service('custom-getter-setter') 732 | const value = 0 733 | const updatedValue = value + 1 734 | const data = { addsOneOnGet: value, addsOneOnSet: value } 735 | 736 | it('calls custom getters and setters (#113)', async () => { 737 | const created = await service.create(data) 738 | const createdArray = (await service.create([data])) as any 739 | const updated = await service.update(created.id, data) 740 | const patched = (await service.patch(created.id, data)) as any 741 | const patchedArray = (await service.patch(null, data)) as any 742 | const removed = (await service.remove(created.id)) as any 743 | await service.create(data) 744 | const removedArray = await service.remove(null) 745 | 746 | assert.strictEqual(created.addsOneOnGet, updatedValue) 747 | assert.strictEqual(createdArray[0].addsOneOnGet, updatedValue) 748 | assert.strictEqual(updated.addsOneOnGet, updatedValue) 749 | assert.strictEqual(patched.addsOneOnGet, updatedValue) 750 | assert.strictEqual(patchedArray[0].addsOneOnGet, updatedValue) 751 | assert.strictEqual(removed.addsOneOnGet, updatedValue) 752 | assert.strictEqual(removedArray[0].addsOneOnGet, updatedValue) 753 | 754 | assert.strictEqual(created.addsOneOnSet, updatedValue) 755 | assert.strictEqual(createdArray[0].addsOneOnSet, updatedValue) 756 | assert.strictEqual(updated.addsOneOnSet, updatedValue) 757 | assert.strictEqual(patched.addsOneOnSet, updatedValue) 758 | assert.strictEqual(patchedArray[0].addsOneOnSet, updatedValue) 759 | }) 760 | 761 | it('can ignore custom getters and setters (#113)', async () => { 762 | // Getters cannot be ignored 763 | // https://sequelize.org/api/v7/interfaces/_sequelize_core.index.createoptions#raw 764 | // Note it says that it ignores "field and virtual setters" 765 | // bulkCreate/bulkUpdate also do not support raw for getters/setters 766 | const IGNORE_SETTERS = { sequelize: { raw: true } } 767 | const created = await service.create(data, IGNORE_SETTERS) 768 | const updated = await service.update(created.id, data, IGNORE_SETTERS) 769 | const patched = (await service.patch( 770 | created.id, 771 | data, 772 | IGNORE_SETTERS, 773 | )) as any 774 | 775 | // This code demonstrates that the raw option does not ignore getters 776 | // assert.strictEqual(created.addsOneOnGet, updatedValue); 777 | // assert.strictEqual(updated.addsOneOnGet, updatedValue); 778 | // assert.strictEqual(patched.addsOneOnGet, updatedValue); 779 | 780 | assert.strictEqual(created.addsOneOnSet, value) 781 | assert.strictEqual(updated.addsOneOnSet, value) 782 | assert.strictEqual(patched.addsOneOnSet, value) 783 | }) 784 | }) 785 | 786 | describe('Operators and Whitelist', () => { 787 | it('merges whitelist and default operators', async () => { 788 | const app = feathers<{ 789 | 'ops-and-whitelist': SequelizeService 790 | }>() 791 | const operators = ['$something'] 792 | app.use( 793 | 'ops-and-whitelist', 794 | new SequelizeService({ 795 | Model, 796 | operators, 797 | }), 798 | ) 799 | const ops = app.service('ops-and-whitelist') 800 | expect(ops.options.operators).to.deep.equal([ 801 | '$eq', 802 | '$ne', 803 | '$gte', 804 | '$gt', 805 | '$lte', 806 | '$lt', 807 | '$in', 808 | '$nin', 809 | '$like', 810 | '$notLike', 811 | '$iLike', 812 | '$notILike', 813 | '$or', 814 | '$and', 815 | '$something', 816 | ]) 817 | }) 818 | 819 | it('fails using operator that IS NOT whitelisted OR default', async () => { 820 | const app = feathers<{ 821 | 'ops-and-whitelist': SequelizeService 822 | }>() 823 | app.use( 824 | 'ops-and-whitelist', 825 | new SequelizeService({ 826 | Model, 827 | }), 828 | ) 829 | const ops = app.service('ops-and-whitelist') 830 | try { 831 | await ops.find({ query: { name: { $notWhitelisted: 'Beau' } } }) 832 | assert.ok(false, 'Should never get here') 833 | } catch (error: any) { 834 | assert.strictEqual(error.name, 'BadRequest') 835 | assert.strictEqual( 836 | error.message, 837 | 'Invalid query parameter $notWhitelisted', 838 | ) 839 | } 840 | }) 841 | 842 | it('succeeds using operator that IS whitelisted OR default', async () => { 843 | const app = feathers<{ 844 | 'ops-and-whitelist': SequelizeService 845 | }>() 846 | app.use( 847 | 'ops-and-whitelist', 848 | new SequelizeService({ 849 | Model, 850 | whitelist: ['$between'], 851 | operatorMap: { $between: Op.between }, 852 | }), 853 | ) 854 | const ops = app.service('ops-and-whitelist') 855 | await ops.find({ query: { name: { $like: 'Beau' } } }) 856 | }) 857 | 858 | it('succeeds using operator that IS whitelisted AND default', async () => { 859 | const app = feathers<{ 860 | 'ops-and-whitelist': SequelizeService 861 | }>() 862 | app.use( 863 | 'ops-and-whitelist', 864 | new SequelizeService({ 865 | Model, 866 | whitelist: ['$like'], 867 | }), 868 | ) 869 | const ops = app.service('ops-and-whitelist') 870 | await ops.find({ query: { name: { $like: 'Beau' } } }) 871 | }) 872 | 873 | it('fails using an invalid operator in the whitelist', async () => { 874 | const app = feathers<{ 875 | 'ops-and-whitelist': SequelizeService 876 | }>() 877 | app.use( 878 | 'ops-and-whitelist', 879 | new SequelizeService({ 880 | Model, 881 | whitelist: ['$invalidOp'], 882 | }), 883 | ) 884 | const ops = app.service('ops-and-whitelist') 885 | try { 886 | await ops.find({ query: { name: { $invalidOp: 'Beau' } } }) 887 | assert.ok(false, 'Should never get here') 888 | } catch (error: any) { 889 | assert.strictEqual( 890 | error.message, 891 | 'Invalid query parameter $invalidOp', 892 | ) 893 | } 894 | }) 895 | }) 896 | }) 897 | 898 | describe('ORM functionality', () => { 899 | const app = feathers<{ 900 | 'raw-people': SequelizeService 901 | people: SequelizeService 902 | }>() 903 | app.use( 904 | 'raw-people', 905 | new SequelizeService({ 906 | Model, 907 | events: ['testing'], 908 | multi: true, 909 | }), 910 | ) 911 | const rawPeople = app.service('raw-people') 912 | 913 | describe('Non-raw Service Config', () => { 914 | app.use( 915 | 'people', 916 | new SequelizeService({ 917 | Model, 918 | events: ['testing'], 919 | multi: true, 920 | raw: false, // -> this is what we are testing 921 | }), 922 | ) 923 | const people = app.service('people') 924 | let david: any 925 | 926 | beforeEach(async () => { 927 | david = await people.create({ name: 'David' }) 928 | }) 929 | 930 | afterEach(() => people.remove(null, { query: {} }).catch(() => {})) 931 | 932 | it('find() returns model instances', async () => { 933 | const results = (await people.find()) as any as any[] 934 | 935 | expect(results[0]).to.be.an.instanceof(Model) 936 | }) 937 | 938 | it('find() with `params.sequelize.raw: true` returns raw data', async () => { 939 | const results = (await people.find({ 940 | sequelize: { 941 | raw: true, 942 | }, 943 | })) as any as any[] 944 | 945 | expect(results[0]).to.be.an('object') 946 | expect(results[0]).to.not.be.instanceOf(Model) 947 | }) 948 | 949 | it('get() returns a model instance', async () => { 950 | const instance = await people.get(david.id) 951 | 952 | expect(instance).to.be.an.instanceOf(Model) 953 | }) 954 | 955 | it('get() with `params.sequelize.raw: true` returns raw data', async () => { 956 | const instance = await people.get(david.id, { 957 | sequelize: { raw: true }, 958 | }) 959 | 960 | expect(instance).to.be.an('object') 961 | expect(instance).to.not.be.instanceOf(Model) 962 | }) 963 | 964 | it('create() returns a model instance', async () => { 965 | const instance = await people.create({ name: 'Sarah' }) 966 | 967 | expect(instance).to.be.an.instanceOf(Model) 968 | }) 969 | 970 | it('create() with `params.sequelize.raw: true` returns raw data', async () => { 971 | const instance = await people.create( 972 | { name: 'Sarah' }, 973 | { sequelize: { raw: true } }, 974 | ) 975 | 976 | expect(instance).to.be.an('object') 977 | expect(instance).to.not.be.instanceOf(Model) 978 | }) 979 | 980 | it('bulk create() returns model instances', async () => { 981 | const results = await people.create([ 982 | { name: 'Sarah' }, 983 | { name: 'Connor' }, 984 | ]) 985 | 986 | expect(results.length).to.equal(2) 987 | expect(results[0]).to.be.an.instanceOf(Model) 988 | assert.ok(results[0].id) 989 | assert.ok(results[1].id) 990 | }) 991 | 992 | it('bulk create() with `params.sequelize.raw: true` returns raw data', async () => { 993 | const results = await people.create( 994 | [{ name: 'Sarah' }, { name: 'Connor' }], 995 | { sequelize: { raw: true } }, 996 | ) 997 | 998 | expect(results.length).to.equal(2) 999 | expect(results[0]).to.be.an('object') 1000 | expect(results[0]).to.not.be.instanceOf(Model) 1001 | assert.ok(results[0].id) 1002 | assert.ok(results[1].id) 1003 | }) 1004 | 1005 | it('create() with returning=false returns empty array', async () => { 1006 | // does not work for single requests 1007 | const responseSingle = await people.create( 1008 | { name: 'delete' }, 1009 | { 1010 | sequelize: { returning: false }, 1011 | }, 1012 | ) 1013 | expect(responseSingle).to.be.an.instanceOf(Model) 1014 | 1015 | // works for bulk requests 1016 | const responseMulti = await people.create([{ name: 'delete' }], { 1017 | sequelize: { returning: false }, 1018 | }) 1019 | expect(responseMulti).to.deep.equal([]) 1020 | }) 1021 | 1022 | it('patch() returns a model instance', async () => { 1023 | const instance = await people.patch(david.id, { name: 'Sarah' }) 1024 | 1025 | expect(instance).to.be.an.instanceOf(Model) 1026 | }) 1027 | 1028 | it('bulk patch() returns model instances', async () => { 1029 | const results = await people.patch(null, { name: 'Sarah' }) 1030 | 1031 | expect(results).to.be.an('array').with.lengthOf(1) 1032 | expect(results[0]).to.be.an.instanceOf(Model) 1033 | }) 1034 | 1035 | it('bulk patch() with `params.sequelize.raw: true` returns raw data', async () => { 1036 | const results = await people.patch( 1037 | null, 1038 | { name: 'Sarah' }, 1039 | { sequelize: { raw: true } }, 1040 | ) 1041 | 1042 | expect(results).to.be.an('array').with.lengthOf(1) 1043 | expect(results[0]).to.be.an('object') 1044 | expect(results[0]).to.not.be.instanceOf(Model) 1045 | }) 1046 | 1047 | it('patch() with returning=false returns empty array', async () => { 1048 | // does not work for single requests 1049 | const responseSingle = await people.patch( 1050 | david.id, 1051 | { name: 'Sarah' }, 1052 | { 1053 | sequelize: { returning: false }, 1054 | }, 1055 | ) 1056 | expect(responseSingle).to.be.an.instanceOf(Model) 1057 | 1058 | // works for bulk requests 1059 | const responseMulti = await people.patch( 1060 | null, 1061 | { name: 'Sarah' }, 1062 | { 1063 | query: { name: 'Sarah' }, 1064 | sequelize: { returning: false }, 1065 | }, 1066 | ) 1067 | 1068 | expect(responseMulti).to.deep.equal([]) 1069 | }) 1070 | 1071 | it('update() returns a model instance', async () => { 1072 | const instance = await people.update(david.id, david) 1073 | 1074 | expect(instance).to.be.an.instanceOf(Model) 1075 | }) 1076 | 1077 | it('update() with `params.sequelize.raw: true` returns raw data', async () => { 1078 | const instance = await people.update(david.id, david, { 1079 | sequelize: { raw: true }, 1080 | }) 1081 | 1082 | expect(instance).to.be.an('object') 1083 | expect(instance).to.not.be.instanceOf(Model) 1084 | }) 1085 | 1086 | it('remove() returns a model instance', async () => { 1087 | const instance = await people.remove(david.id) 1088 | 1089 | expect(instance).to.be.an.instanceOf(Model) 1090 | }) 1091 | 1092 | it('remove() with `params.sequelize.raw: true` returns raw data', async () => { 1093 | const instance = await people.remove(david.id, { 1094 | sequelize: { raw: true }, 1095 | }) 1096 | 1097 | expect(instance).to.be.an('object') 1098 | expect(instance).to.not.be.instanceOf(Model) 1099 | }) 1100 | 1101 | it('bulk remove() returns model instances', async () => { 1102 | const results = await people.remove(null, { query: {} }) 1103 | 1104 | expect(results).to.be.an('array').with.lengthOf(1) 1105 | expect(results[0]).to.be.an.instanceOf(Model) 1106 | }) 1107 | 1108 | it('bulk remove() with `params.sequelize.raw: true` returns raw data', async () => { 1109 | const results = await people.remove(null, { 1110 | query: {}, 1111 | sequelize: { raw: true }, 1112 | }) 1113 | 1114 | expect(results).to.be.an('array').with.lengthOf(1) 1115 | expect(results[0]).to.be.an('object') 1116 | expect(results[0]).to.not.be.instanceOf(Model) 1117 | }) 1118 | 1119 | it('remove() with returning=false returns empty array', async () => { 1120 | // does not work for single requests 1121 | const responseSingle = await people.remove(david.id, { 1122 | sequelize: { returning: false }, 1123 | }) 1124 | expect(responseSingle).to.be.an.instanceOf(Model) 1125 | 1126 | david = await people.create({ name: 'David' }) 1127 | const responseMulti = await people.remove(null, { 1128 | query: { name: 'David' }, 1129 | sequelize: { returning: false }, 1130 | }) 1131 | expect(responseMulti).to.deep.equal([]) 1132 | }) 1133 | }) 1134 | 1135 | describe('raw Service', () => { 1136 | let david: any 1137 | 1138 | beforeEach(async () => { 1139 | david = await rawPeople.create({ name: 'David' }) 1140 | }) 1141 | 1142 | afterEach(() => rawPeople.remove(null, { query: {} }).catch(() => {})) 1143 | 1144 | it('find() returns raw', async () => { 1145 | const results = (await rawPeople.find()) as any as any[] 1146 | 1147 | expect(results).to.be.an('array').with.lengthOf(1) 1148 | expect(results[0]).to.not.be.an.instanceof(Model) 1149 | }) 1150 | 1151 | it('get() returns raw', async () => { 1152 | const instance = await rawPeople.get(david.id) 1153 | 1154 | expect(instance).to.not.be.an.instanceof(Model) 1155 | }) 1156 | 1157 | it('create() returns raw', async () => { 1158 | const instance = await rawPeople.create({ name: 'Sarah' }) 1159 | 1160 | expect(instance).to.not.be.an.instanceof(Model) 1161 | }) 1162 | 1163 | it('bulk create() returns raw', async () => { 1164 | const results = await rawPeople.create([{ name: 'Sarah' }]) 1165 | 1166 | expect(results).to.be.an('array').with.lengthOf(1) 1167 | expect(results[0]).to.not.be.an.instanceof(Model) 1168 | }) 1169 | 1170 | it('patch() returns raw', async () => { 1171 | const instance = await rawPeople.patch(david.id, { name: 'Sarah' }) 1172 | 1173 | expect(instance).to.not.be.an.instanceof(Model) 1174 | }) 1175 | 1176 | it('bulk patch() returns raw', async () => { 1177 | const results = await rawPeople.patch( 1178 | null, 1179 | { name: 'Sarah' }, 1180 | { query: { id: { $in: [david.id] } } }, 1181 | ) 1182 | 1183 | expect(results).to.be.an('array').with.lengthOf(1) 1184 | expect(results[0]).to.not.be.an.instanceof(Model) 1185 | }) 1186 | 1187 | it('update() returns raw', async () => { 1188 | const instance = await rawPeople.update(david.id, david) 1189 | 1190 | expect(instance).to.not.be.an.instanceof(Model) 1191 | }) 1192 | 1193 | it('remove() returns raw', async () => { 1194 | const instance = await rawPeople.remove(david.id) 1195 | 1196 | expect(instance).to.not.be.an.instanceof(Model) 1197 | }) 1198 | 1199 | it('bulk remove() returns raw', async () => { 1200 | const results = await rawPeople.remove(null) 1201 | 1202 | expect(results).to.be.an('array').with.lengthOf(1) 1203 | expect(results[0]).to.not.be.an.instanceof(Model) 1204 | }) 1205 | }) 1206 | 1207 | describe('Non-raw Service Method Calls', () => { 1208 | const NOT_RAW = { sequelize: { raw: false } } 1209 | let david: any 1210 | 1211 | beforeEach(async () => { 1212 | david = await rawPeople.create({ name: 'David' }) 1213 | }) 1214 | 1215 | afterEach(() => rawPeople.remove(null, { query: {} }).catch(() => {})) 1216 | 1217 | it('find() returns raw', async () => { 1218 | const results = (await rawPeople.find()) as any as any[] 1219 | 1220 | expect(results).to.be.an('array').with.lengthOf(1) 1221 | expect(results[0]).to.be.not.an.instanceof(Model) 1222 | }) 1223 | 1224 | it('`raw: false` works for find()', async () => { 1225 | const results = (await rawPeople.find(NOT_RAW)) as any as any[] 1226 | 1227 | expect(results[0]).to.be.an.instanceof(Model) 1228 | }) 1229 | 1230 | it('`raw: false` works for get()', async () => { 1231 | const instance = await rawPeople.get(david.id, NOT_RAW) 1232 | 1233 | expect(instance).to.be.an.instanceof(Model) 1234 | }) 1235 | 1236 | it('`raw: false` works for create()', async () => { 1237 | const instance = await rawPeople.create({ name: 'Sarah' }, NOT_RAW) 1238 | 1239 | expect(instance).to.be.an.instanceof(Model) 1240 | }) 1241 | 1242 | it('`raw: false` works for bulk create()', async () => { 1243 | const results = await rawPeople.create([{ name: 'Sarah' }], NOT_RAW) 1244 | 1245 | expect(results).to.be.an('array').with.lengthOf(1) 1246 | expect(results[0]).to.be.an.instanceof(Model) 1247 | }) 1248 | 1249 | it('`raw: false` works for patch()', async () => { 1250 | const instance = await rawPeople.patch( 1251 | david.id, 1252 | { name: 'Sarah' }, 1253 | NOT_RAW, 1254 | ) 1255 | 1256 | expect(instance).to.be.an.instanceof(Model) 1257 | }) 1258 | 1259 | it('`raw: false` works for bulk patch()', async () => { 1260 | const results = await rawPeople.patch(null, { name: 'Sarah' }, NOT_RAW) 1261 | 1262 | expect(results).to.be.an('array').with.lengthOf(1) 1263 | expect(results[0]).to.be.an.instanceof(Model) 1264 | }) 1265 | 1266 | it('`raw: false` works for update()', async () => { 1267 | const instance = await rawPeople.update(david.id, david, NOT_RAW) 1268 | 1269 | expect(instance).to.be.an.instanceof(Model) 1270 | }) 1271 | 1272 | it('`raw: false` works for remove()', async () => { 1273 | const instance = await rawPeople.remove(david.id, NOT_RAW) 1274 | 1275 | expect(instance).to.be.an.instanceof(Model) 1276 | }) 1277 | 1278 | it('`raw: false` works for bulk remove()', async () => { 1279 | const results = await rawPeople.remove(null, NOT_RAW) 1280 | 1281 | expect(results).to.be.an('array').with.lengthOf(1) 1282 | expect(results[0]).to.be.an.instanceof(Model) 1283 | }) 1284 | }) 1285 | }) 1286 | 1287 | describe('ORM functionality with overridden getModel method', () => { 1288 | const EXPECTED_ATTRIBUTE_VALUE = 42 1289 | 1290 | function getExtraParams(params?: Record) { 1291 | return { 1292 | ...params, 1293 | sequelize: { 1294 | expectedAttribute: EXPECTED_ATTRIBUTE_VALUE, 1295 | getModelCalls: { count: 0 }, 1296 | ...params?.sequelize, 1297 | }, 1298 | } 1299 | } 1300 | 1301 | class ExtendedService extends SequelizeService { 1302 | getModel(params: any) { 1303 | if ( 1304 | !params.sequelize || 1305 | params.sequelize.expectedAttribute !== EXPECTED_ATTRIBUTE_VALUE 1306 | ) { 1307 | throw new Error( 1308 | 'Expected custom attribute not found in overridden getModel()!', 1309 | ) 1310 | } 1311 | 1312 | if (params.sequelize.getModelCalls === undefined) { 1313 | throw new Error('getModelCalls not defined on params.sequelize!') 1314 | } 1315 | 1316 | params.sequelize.getModelCalls.count++ 1317 | 1318 | return this.options.Model 1319 | } 1320 | 1321 | get Model() { 1322 | // Extended service classes that override getModel will often 1323 | // depend upon having certain params provided from further up 1324 | // the call stack (e.g. part of the request object to make a decision 1325 | // on which model/db to return based on the hostname being accessed). 1326 | // If feathers-sequelize wants access to the model, it should always 1327 | // call getModel(params). 1328 | // Returning null here is a way to ensure that a regression isn't 1329 | // introduced later whereby feathers-sequelize attempts to access a 1330 | // model obtained via the Model getter rather than via getModel(params). 1331 | return null as any 1332 | } 1333 | } 1334 | 1335 | function extendedService(options: SequelizeAdapterOptions) { 1336 | return new ExtendedService(options) 1337 | } 1338 | 1339 | const app = feathers<{ 1340 | 'raw-people': ExtendedService 1341 | people: ExtendedService 1342 | }>() 1343 | app.use( 1344 | 'raw-people', 1345 | extendedService({ 1346 | Model, 1347 | events: ['testing'], 1348 | multi: true, 1349 | }), 1350 | ) 1351 | const rawPeople = app.service('raw-people') 1352 | 1353 | describe('Non-raw Service Config', () => { 1354 | app.use( 1355 | 'people', 1356 | extendedService({ 1357 | Model, 1358 | events: ['testing'], 1359 | multi: true, 1360 | raw: false, // -> this is what we are testing 1361 | }), 1362 | ) 1363 | const people = app.service('people') 1364 | let david: any 1365 | 1366 | beforeEach(async () => { 1367 | david = await people.create({ name: 'David' }, getExtraParams()) 1368 | }) 1369 | 1370 | afterEach(() => people.remove(david.id, getExtraParams()).catch(() => {})) 1371 | 1372 | it('find() returns model instances', async () => { 1373 | const params = getExtraParams() 1374 | const results = (await people.find(params)) as any as any[] 1375 | expect(params.sequelize.getModelCalls.count).to.gte(1) 1376 | 1377 | expect(results[0]).to.be.an.instanceof(Model) 1378 | }) 1379 | 1380 | it('get() returns a model instance', async () => { 1381 | const params = getExtraParams() 1382 | const instance = await people.get(david.id, params) 1383 | expect(params.sequelize.getModelCalls.count).to.gte(1) 1384 | expect(instance).to.be.an.instanceof(Model) 1385 | }) 1386 | 1387 | it('create() returns a model instance', async () => { 1388 | const params = getExtraParams() 1389 | const instance = await people.create({ name: 'Sarah' }, params) 1390 | expect(params.sequelize.getModelCalls.count).to.gte(1) 1391 | 1392 | expect(instance).to.be.an.instanceof(Model) 1393 | 1394 | const removeParams = getExtraParams() 1395 | await people.remove(instance.id, removeParams) 1396 | expect(removeParams.sequelize.getModelCalls.count).to.gte(1) 1397 | }) 1398 | 1399 | it('bulk create() returns model instances', async () => { 1400 | const params = getExtraParams() 1401 | const results = await people.create([{ name: 'Sarah' }], params) 1402 | expect(params.sequelize.getModelCalls.count).to.gte(1) 1403 | 1404 | expect(results).to.be.an('array').with.lengthOf(1) 1405 | expect(results[0]).to.be.an.instanceof(Model) 1406 | 1407 | const removeParams = getExtraParams() 1408 | await people.remove(results[0].id, removeParams) 1409 | expect(removeParams.sequelize.getModelCalls.count).to.gte(1) 1410 | }) 1411 | 1412 | it('patch() returns a model instance', async () => { 1413 | const params = getExtraParams() 1414 | const instance = await people.patch(david.id, { name: 'Sarah' }, params) 1415 | expect(params.sequelize.getModelCalls.count).to.gte(1) 1416 | expect(instance).to.be.an.instanceof(Model) 1417 | }) 1418 | 1419 | it('create() with returning=false returns empty array', async () => { 1420 | const params = getExtraParams({ sequelize: { returning: false } }) 1421 | const responseSingle = await people.create({ name: 'delete' }, params) 1422 | const responseMulti = await people.create([{ name: 'delete' }], params) 1423 | 1424 | expect(responseSingle).to.be.an('object').that.has.property('id') 1425 | expect(responseMulti).to.deep.equal([]) 1426 | 1427 | await people.remove(null, { ...params, query: { name: 'delete' } }) 1428 | }) 1429 | 1430 | it('patch() with returning=false returns empty array', async () => { 1431 | const params = getExtraParams({ sequelize: { returning: false } }) 1432 | const responseSingle = await people.patch( 1433 | david.id, 1434 | { name: 'Sarah' }, 1435 | params, 1436 | ) 1437 | const responseMulti = await people.patch( 1438 | null, 1439 | { name: 'Sarah' }, 1440 | { 1441 | ...params, 1442 | query: { name: 'Sarah' }, 1443 | sequelize: { ...params.sequelize }, 1444 | }, 1445 | ) 1446 | 1447 | expect(responseSingle) 1448 | .to.be.an('object') 1449 | .with.property('id') 1450 | .that.equals(david.id) 1451 | expect(responseMulti).to.deep.equal([]) 1452 | }) 1453 | 1454 | it('update() returns a model instance', async () => { 1455 | const params = getExtraParams() 1456 | const instance = await people.update(david.id, david, params) 1457 | expect(params.sequelize.getModelCalls.count).to.gte(1) 1458 | expect(instance).to.be.an.instanceof(Model) 1459 | }) 1460 | 1461 | it('remove() returns a model instance', async () => { 1462 | const params = getExtraParams() 1463 | const instance = await people.remove(david.id, params) 1464 | expect(params.sequelize.getModelCalls.count).to.gte(1) 1465 | 1466 | expect(instance).to.be.an.instanceof(Model) 1467 | }) 1468 | 1469 | it('remove() with returning=false returns empty array', async () => { 1470 | const params = getExtraParams({ sequelize: { returning: false } }) 1471 | const responseSingle = await people.remove(david.id, params) 1472 | david = await people.create({ name: 'David' }, params) 1473 | const responseMulti = await people.remove(null, { 1474 | ...params, 1475 | query: { name: 'David' }, 1476 | }) 1477 | 1478 | expect(responseSingle).to.be.an('object').that.has.property('id') 1479 | expect(responseMulti).to.deep.equal([]) 1480 | }) 1481 | }) 1482 | 1483 | describe('Non-raw Service Method Calls', () => { 1484 | const NOT_RAW = { raw: false } 1485 | 1486 | let david: any 1487 | 1488 | beforeEach(async () => { 1489 | david = await rawPeople.create({ name: 'David' }, getExtraParams()) 1490 | }) 1491 | 1492 | afterEach(() => 1493 | rawPeople.remove(david.id, getExtraParams()).catch(() => {}), 1494 | ) 1495 | 1496 | it('`raw: false` works for find()', async () => { 1497 | const params = getExtraParams({ sequelize: NOT_RAW }) 1498 | const results = (await rawPeople.find(params)) as any as any[] 1499 | expect(params.sequelize.getModelCalls.count).to.gte(1) 1500 | 1501 | expect(results[0]).to.be.an.instanceof(Model) 1502 | }) 1503 | 1504 | it('`raw: false` works for get()', async () => { 1505 | const params = getExtraParams({ sequelize: NOT_RAW }) 1506 | const instance = await rawPeople.get(david.id, params) 1507 | expect(params.sequelize.getModelCalls.count).to.gte(1) 1508 | 1509 | expect(instance).to.be.an.instanceof(Model) 1510 | }) 1511 | 1512 | it('`raw: false` works for create()', async () => { 1513 | const params = getExtraParams({ sequelize: NOT_RAW }) 1514 | const instance = await rawPeople.create({ name: 'Sarah' }, params) 1515 | expect(params.sequelize.getModelCalls.count).to.gte(1) 1516 | 1517 | expect(instance).to.be.an.instanceof(Model) 1518 | 1519 | const removeParams = getExtraParams() 1520 | await rawPeople.remove(instance.id, removeParams) 1521 | expect(removeParams.sequelize.getModelCalls.count).to.gte(1) 1522 | }) 1523 | 1524 | it('`raw: false` works for bulk create()', async () => { 1525 | const params = getExtraParams({ sequelize: NOT_RAW }) 1526 | const results = await rawPeople.create([{ name: 'Sarah' }], params) 1527 | expect(params.sequelize.getModelCalls.count).to.gte(1) 1528 | 1529 | expect(results).to.be.an('array').with.lengthOf(1) 1530 | expect(results[0]).to.be.an.instanceof(Model) 1531 | 1532 | const removeParams = getExtraParams() 1533 | await rawPeople.remove(results[0].id, removeParams) 1534 | expect(removeParams.sequelize.getModelCalls.count).to.gte(1) 1535 | }) 1536 | 1537 | it('`raw: false` works for patch()', async () => { 1538 | const params = getExtraParams({ sequelize: NOT_RAW }) 1539 | const instance = await rawPeople.patch( 1540 | david.id, 1541 | { name: 'Sarah' }, 1542 | params, 1543 | ) 1544 | expect(params.sequelize.getModelCalls.count).to.gte(1) 1545 | 1546 | expect(instance).to.be.an.instanceof(Model) 1547 | }) 1548 | 1549 | it('`raw: false` works for update()', async () => { 1550 | const params = getExtraParams({ sequelize: NOT_RAW }) 1551 | const instance = await rawPeople.update(david.id, david, params) 1552 | expect(params.sequelize.getModelCalls.count).to.gte(1) 1553 | 1554 | expect(instance).to.be.an.instanceof(Model) 1555 | }) 1556 | 1557 | it('`raw: false` works for remove()', async () => { 1558 | const params = getExtraParams({ sequelize: NOT_RAW }) 1559 | const instance = await rawPeople.remove(david.id, params) 1560 | expect(params.sequelize.getModelCalls.count).to.gte(1) 1561 | 1562 | expect(instance).to.be.an.instanceof(Model) 1563 | }) 1564 | }) 1565 | }) 1566 | }) 1567 | --------------------------------------------------------------------------------