├── .github ├── FUNDING.yml └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .prettierrc ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── commitlint.config.js ├── logo.svg ├── package.json ├── release.config.json ├── simple-git-hooks.js ├── src ├── comparators │ ├── boolean.ts │ ├── date.ts │ ├── number.ts │ └── string.ts ├── db │ ├── Database.ts │ └── drop.ts ├── errors │ └── OperationError.ts ├── extensions │ └── sync.ts ├── factory.ts ├── glossary.ts ├── index.ts ├── model │ ├── createModel.ts │ ├── defineRelationalProperties.ts │ ├── generateGraphQLHandlers.ts │ ├── generateRestHandlers.ts │ ├── getDefinition.ts │ ├── parseModelDefinition.ts │ └── updateEntity.ts ├── nullable.ts ├── primaryKey.ts ├── query │ ├── compileQuery.ts │ ├── executeQuery.ts │ ├── getComparatorsForValue.ts │ ├── paginateResults.ts │ ├── queryTypes.ts │ └── sortResults.ts ├── relations │ ├── Relation.ts │ ├── manyOf.ts │ └── oneOf.ts └── utils │ ├── capitalize.ts │ ├── definePropertyAtPath.ts │ ├── findPrimaryKey.ts │ ├── first.ts │ ├── identity.ts │ ├── inheritInternalProperties.ts │ ├── isModelValueType.ts │ ├── isObject.ts │ ├── iteratorUtils.ts │ ├── numberInRange.ts │ ├── safeStringify.ts │ └── spread.ts ├── test ├── comparators │ ├── boolean-comparators.test.ts │ ├── date-comparators.test.ts │ ├── number-comparators.test.ts │ └── string-comparators.test.ts ├── db │ ├── drop.test.ts │ └── events.test.ts ├── extensions │ ├── sync.multiple.runtime.js │ ├── sync.runtime.js │ └── sync.test.ts ├── jest.d.ts ├── model │ ├── count.test.ts │ ├── create.test-d.ts │ ├── create.test.ts │ ├── delete.test-d.ts │ ├── delete.test.ts │ ├── deleteMany.test-d.ts │ ├── deleteMany.test.ts │ ├── findFirst.test-d.ts │ ├── findFirst.test.ts │ ├── findMany.test-d.ts │ ├── findMany.test.ts │ ├── getAll.test-d.ts │ ├── getAll.test.ts │ ├── relationalProperties.test-d.ts │ ├── relationalProperties.test.ts │ ├── toGraphQLHandlers.test.ts │ ├── toGraphQLSchema.test.ts │ ├── toRestHandlers │ │ ├── basic.test.ts │ │ └── primary-key-number.test.ts │ ├── update.test-d.ts │ ├── update.test.ts │ ├── update │ │ └── collocated-update.test.ts │ └── updateMany.test.ts ├── performance │ └── performance.test.ts ├── primaryKey.test.ts ├── query │ ├── boolean.test.ts │ ├── date.test.ts │ ├── number.test.ts │ ├── pagination.test.ts │ └── string.test.ts ├── regressions │ ├── 02-handlers-many-of.test.ts │ └── 112-event-emitter-leak │ │ ├── 112-event-emitter-leak.runtime.js │ │ └── 112-event-emitter-leak.test.ts ├── relations │ ├── Relation.test.ts │ ├── bi-directional.test.ts │ ├── many-to-one.test.ts │ ├── one-to-many.test.ts │ ├── one-to-one.create.test.ts │ ├── one-to-one.operations.test.ts │ └── one-to-one.update.test.ts ├── testUtils.ts ├── tsconfig.json ├── typings │ ├── DeepRequiredExactlyOne.test-d.ts │ ├── nested-objects.test-d.ts │ ├── relations.test-d.ts │ ├── strict-queries.test-d.ts │ └── tsconfig.json ├── utils │ ├── capitalize.test.ts │ ├── definePropertyAtPath.test.ts │ ├── findPrimaryKey.test.ts │ ├── first.test.ts │ ├── generateGraphQLHandlers.test.ts │ ├── generateRestHandlers.test.ts │ ├── identity.test.ts │ ├── inheritInternalProperties.test.ts │ ├── isModelValueType.test.ts │ ├── isObject.test.ts │ ├── parseModelDefinition.test.ts │ ├── spread.test.ts │ └── updateEntity.test.ts ├── vitest.config.ts └── vitest.setup.ts ├── tsconfig.json └── yarn.lock /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: mswjs 2 | open_collective: mswjs 3 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | workflow_dispatch: 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v2 17 | 18 | - name: Install dependencies 19 | run: yarn install --frozen-lockfile 20 | 21 | - name: Install browsers 22 | run: yarn playwright install 23 | 24 | - name: Environment (versions) 25 | run: | 26 | echo "node: $(node -v)" 27 | echo "npm: $(npm -v)" 28 | echo "typescript: $(npm ls typescript)" 29 | echo "tsc: $(tsc -v)" 30 | 31 | - name: Build 32 | run: yarn build 33 | 34 | - name: Tests 35 | run: yarn test 36 | 37 | - name: Tests (typings) 38 | run: yarn test:ts 39 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | schedule: 5 | - cron: '0 23 * * *' 6 | 7 | workflow_dispatch: 8 | 9 | jobs: 10 | release: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v2 15 | with: 16 | fetch-depth: 0 17 | token: ${{ secrets.GH_ADMIN_TOKEN }} 18 | 19 | - name: Setup Git 20 | run: | 21 | git config --local user.name "kettanaito" 22 | git config --local user.email "kettanaito@gmail.com" 23 | 24 | - name: Install dependencies 25 | run: yarn install --frozen-lockfile 26 | 27 | - name: Install browsers 28 | run: yarn playwright install 29 | 30 | - name: Release 31 | run: yarn release 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GH_ADMIN_TOKEN }} 34 | NPM_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | *.log -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "arrowParens": "always", 5 | "trailingComma": "all" 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020–present Artem Zakharchenko 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | } 4 | -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | data-logo 4 | 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mswjs/data", 3 | "description": "Data modeling and relation library for testing JavaScript applications.", 4 | "version": "0.16.2", 5 | "main": "lib/index.js", 6 | "typings": "lib/index.d.ts", 7 | "author": "Artem Zakharchenko", 8 | "license": "MIT", 9 | "scripts": { 10 | "start": "tsc -w", 11 | "format": "prettier src/**/*.ts --write", 12 | "test": "vitest -c test/vitest.config.ts", 13 | "test:ts": "tsc -p test/typings/tsconfig.json", 14 | "clean": "rimraf ./lib", 15 | "build": "yarn clean && tsc", 16 | "release": "release publish", 17 | "prepare": "yarn simple-git-hooks init", 18 | "prepublishOnly": "yarn build && yarn test:ts && yarn test" 19 | }, 20 | "files": [ 21 | "lib", 22 | "README.md" 23 | ], 24 | "devDependencies": { 25 | "@commitlint/cli": "^16.0.1", 26 | "@commitlint/config-conventional": "^16.0.0", 27 | "@faker-js/faker": "^6.3.1", 28 | "@ossjs/release": "^0.8.0", 29 | "@types/debug": "^4.1.7", 30 | "@types/node-fetch": "^2.5.10", 31 | "commitizen": "^4.2.4", 32 | "cz-conventional-changelog": "3.3.0", 33 | "jsdom": "^22.1.0", 34 | "msw": "^2.0.8", 35 | "node-fetch": "^2.6.1", 36 | "page-with": "^0.6.0", 37 | "prettier": "^2.2.1", 38 | "rimraf": "^3.0.2", 39 | "simple-git-hooks": "^2.7.0", 40 | "ts-node": "^9.1.1", 41 | "typescript": "4.3.5", 42 | "vitest": "^0.34.6" 43 | }, 44 | "dependencies": { 45 | "@types/lodash": "^4.14.172", 46 | "@types/md5": "^2.3.0", 47 | "@types/pluralize": "^0.0.29", 48 | "@types/uuid": "^8.3.0", 49 | "date-fns": "^2.21.1", 50 | "debug": "^4.3.1", 51 | "graphql": "^16.8.1", 52 | "lodash": "^4.17.21", 53 | "md5": "^2.3.0", 54 | "outvariant": "^1.2.1", 55 | "pluralize": "^8.0.0", 56 | "strict-event-emitter": "^0.5.0", 57 | "uuid": "^8.3.1" 58 | }, 59 | "optionalDependencies": { 60 | "msw": "^2.0.8" 61 | }, 62 | "config": { 63 | "commitizen": { 64 | "path": "./node_modules/cz-conventional-changelog" 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /release.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": [ 3 | { 4 | "name": "latest", 5 | "use": "yarn publish --new-version $RELEASE_VERSION", 6 | "prerelease": true 7 | } 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /simple-git-hooks.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'prepare-commit-msg': `grep -qE '^[^#]' .git/COMMIT_EDITMSG || (exec < /dev/tty && yarn cz --hook || true)`, 3 | 'commit-msg': 'yarn commitlint --edit $1', 4 | } 5 | -------------------------------------------------------------------------------- /src/comparators/boolean.ts: -------------------------------------------------------------------------------- 1 | import { BooleanQuery, QueryToComparator } from '../query/queryTypes' 2 | 3 | export const booleanComparators: QueryToComparator = { 4 | equals(expected, actual) { 5 | return actual === expected 6 | }, 7 | notEquals(expected, actual) { 8 | return expected !== actual 9 | }, 10 | } 11 | -------------------------------------------------------------------------------- /src/comparators/date.ts: -------------------------------------------------------------------------------- 1 | import { compareAsc as compareDates } from 'date-fns' 2 | import { DateQuery, QueryToComparator } from '../query/queryTypes' 3 | 4 | export const dateComparators: QueryToComparator = { 5 | equals(expected, actual) { 6 | return compareDates(expected, actual) === 0 7 | }, 8 | notEquals(expected, actual) { 9 | return compareDates(expected, actual) !== 0 10 | }, 11 | gt(expected, actual) { 12 | return compareDates(actual, expected) === 1 13 | }, 14 | gte(expected, actual) { 15 | return [0, 1].includes(compareDates(actual, expected)) 16 | }, 17 | lt(expected, actual) { 18 | return compareDates(actual, expected) === -1 19 | }, 20 | lte(expected, actual) { 21 | return [-1, 0].includes(compareDates(actual, expected)) 22 | }, 23 | } 24 | -------------------------------------------------------------------------------- /src/comparators/number.ts: -------------------------------------------------------------------------------- 1 | import { NumberQuery, QueryToComparator } from '../query/queryTypes' 2 | import { numberInRange } from '../utils/numberInRange' 3 | 4 | export const numberComparators: QueryToComparator = { 5 | equals(expected, actual) { 6 | return actual === expected 7 | }, 8 | notEquals(expected, actual) { 9 | return !numberComparators.equals(expected, actual) 10 | }, 11 | between(expected, actual) { 12 | return numberInRange(expected[0], expected[1], actual) 13 | }, 14 | notBetween(expected, actual) { 15 | return !numberComparators.between(expected, actual) 16 | }, 17 | gt(expected, actual) { 18 | return actual > expected 19 | }, 20 | gte(expected, actual) { 21 | return actual >= expected 22 | }, 23 | lt(expected, actual) { 24 | return actual < expected 25 | }, 26 | lte(expected, actual) { 27 | return actual <= expected 28 | }, 29 | in(expected, actual) { 30 | return expected.includes(actual) 31 | }, 32 | notIn(expected, actual) { 33 | return !numberComparators.in(expected, actual) 34 | }, 35 | } 36 | -------------------------------------------------------------------------------- /src/comparators/string.ts: -------------------------------------------------------------------------------- 1 | import { QueryToComparator, StringQuery } from '../query/queryTypes' 2 | 3 | export const stringComparators: QueryToComparator = { 4 | equals(expected, actual) { 5 | return expected === actual 6 | }, 7 | notEquals(expected, actual) { 8 | return !stringComparators.equals(expected, actual) 9 | }, 10 | contains(expected, actual) { 11 | return actual.includes(expected) 12 | }, 13 | notContains(expected, actual) { 14 | return !stringComparators.contains(expected, actual) 15 | }, 16 | gt(expected, actual) { 17 | return actual > expected 18 | }, 19 | gte(expected, actual) { 20 | return actual >= expected 21 | }, 22 | lt(expected, actual) { 23 | return actual < expected 24 | }, 25 | lte(expected, actual) { 26 | return actual <= expected 27 | }, 28 | in(expected, actual) { 29 | return expected.includes(actual) 30 | }, 31 | notIn(expected, actual) { 32 | return !stringComparators.in(expected, actual) 33 | }, 34 | } 35 | -------------------------------------------------------------------------------- /src/db/Database.ts: -------------------------------------------------------------------------------- 1 | import md5 from 'md5' 2 | import { invariant } from 'outvariant' 3 | import { Emitter } from 'strict-event-emitter' 4 | import { 5 | Entity, 6 | ENTITY_TYPE, 7 | KeyType, 8 | ModelDictionary, 9 | PrimaryKeyType, 10 | PRIMARY_KEY, 11 | } from '../glossary' 12 | 13 | export const SERIALIZED_INTERNAL_PROPERTIES_KEY = 14 | 'SERIALIZED_INTERNAL_PROPERTIES' 15 | 16 | type Models = Record< 17 | keyof Dictionary, 18 | Map> 19 | > 20 | 21 | export interface SerializedInternalEntityProperties { 22 | entityType: string 23 | primaryKey: PrimaryKeyType 24 | } 25 | 26 | export interface SerializedEntity extends Entity { 27 | [SERIALIZED_INTERNAL_PROPERTIES_KEY]: SerializedInternalEntityProperties 28 | } 29 | 30 | export type DatabaseEventsMap = { 31 | create: [ 32 | sourceId: string, 33 | modelName: KeyType, 34 | entity: SerializedEntity, 35 | customPrimaryKey?: PrimaryKeyType, 36 | ] 37 | update: [ 38 | sourceId: string, 39 | modelName: KeyType, 40 | prevEntity: SerializedEntity, 41 | nextEntity: SerializedEntity, 42 | ] 43 | delete: [sourceId: string, modelName: KeyType, primaryKey: PrimaryKeyType] 44 | } 45 | 46 | let callOrder = 0 47 | 48 | export class Database { 49 | public id: string 50 | public events: Emitter 51 | private models: Models 52 | 53 | constructor(dictionary: Dictionary) { 54 | this.events = new Emitter() 55 | this.models = Object.keys(dictionary).reduce>( 56 | (acc, modelName: keyof Dictionary) => { 57 | acc[modelName] = new Map>() 58 | return acc 59 | }, 60 | {} as Models, 61 | ) 62 | 63 | callOrder++ 64 | this.id = this.generateId() 65 | } 66 | 67 | /** 68 | * Generates a unique MD5 hash based on the database 69 | * module location and invocation order. Used to reproducibly 70 | * identify a database instance among sibling instances. 71 | */ 72 | private generateId() { 73 | const { stack } = new Error() 74 | const callFrame = stack?.split('\n')[4] 75 | const salt = `${callOrder}-${callFrame?.trim()}` 76 | return md5(salt) 77 | } 78 | 79 | private serializeEntity(entity: Entity): SerializedEntity { 80 | return { 81 | ...entity, 82 | [SERIALIZED_INTERNAL_PROPERTIES_KEY]: { 83 | entityType: entity[ENTITY_TYPE], 84 | primaryKey: entity[PRIMARY_KEY], 85 | }, 86 | } 87 | } 88 | 89 | getModel(name: ModelName) { 90 | return this.models[name] 91 | } 92 | 93 | create( 94 | modelName: ModelName, 95 | entity: Entity, 96 | customPrimaryKey?: PrimaryKeyType, 97 | ): Map> { 98 | invariant( 99 | entity[ENTITY_TYPE], 100 | 'Failed to create a new "%s" record: provided entity has no type. %j', 101 | modelName, 102 | entity, 103 | ) 104 | invariant( 105 | entity[PRIMARY_KEY], 106 | 'Failed to create a new "%s" record: provided entity has no primary key. %j', 107 | modelName, 108 | entity, 109 | ) 110 | 111 | const primaryKey = 112 | customPrimaryKey || (entity[entity[PRIMARY_KEY]] as string) 113 | 114 | this.events.emit( 115 | 'create', 116 | this.id, 117 | modelName, 118 | this.serializeEntity(entity), 119 | customPrimaryKey, 120 | ) 121 | return this.getModel(modelName).set(primaryKey, entity) 122 | } 123 | 124 | update( 125 | modelName: ModelName, 126 | prevEntity: Entity, 127 | nextEntity: Entity, 128 | ): void { 129 | const prevPrimaryKey = prevEntity[prevEntity[PRIMARY_KEY]] as PrimaryKeyType 130 | const nextPrimaryKey = nextEntity[prevEntity[PRIMARY_KEY]] as PrimaryKeyType 131 | 132 | if (nextPrimaryKey !== prevPrimaryKey) { 133 | this.delete(modelName, prevPrimaryKey) 134 | } 135 | 136 | this.getModel(modelName).set(nextPrimaryKey, nextEntity) 137 | 138 | // this.create(modelName, nextEntity, nextPrimaryKey) 139 | this.events.emit( 140 | 'update', 141 | this.id, 142 | modelName, 143 | this.serializeEntity(prevEntity), 144 | this.serializeEntity(nextEntity), 145 | ) 146 | } 147 | 148 | delete( 149 | modelName: ModelName, 150 | primaryKey: PrimaryKeyType, 151 | ): void { 152 | this.getModel(modelName).delete(primaryKey) 153 | this.events.emit('delete', this.id, modelName, primaryKey) 154 | } 155 | 156 | has( 157 | modelName: ModelName, 158 | primaryKey: PrimaryKeyType, 159 | ): boolean { 160 | return this.getModel(modelName).has(primaryKey) 161 | } 162 | 163 | count(modelName: ModelName) { 164 | return this.getModel(modelName).size 165 | } 166 | 167 | listEntities( 168 | modelName: ModelName, 169 | ): Entity[] { 170 | return Array.from(this.getModel(modelName).values()) 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/db/drop.ts: -------------------------------------------------------------------------------- 1 | import { FactoryAPI } from '../glossary' 2 | 3 | export function drop(factoryApi: FactoryAPI): void { 4 | Object.values(factoryApi).forEach((model) => { 5 | model.deleteMany({ where: {} }) 6 | }) 7 | } 8 | -------------------------------------------------------------------------------- /src/errors/OperationError.ts: -------------------------------------------------------------------------------- 1 | export enum OperationErrorType { 2 | MissingPrimaryKey = 'MissingPrimaryKey', 3 | DuplicatePrimaryKey = 'DuplicatePrimaryKey', 4 | EntityNotFound = 'EntityNotFound', 5 | } 6 | 7 | export class OperationError extends Error { 8 | public type: ErrorType 9 | 10 | constructor(type: ErrorType, message?: string) { 11 | super(message) 12 | this.name = 'OperationError' 13 | this.type = type 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/extensions/sync.ts: -------------------------------------------------------------------------------- 1 | import { ENTITY_TYPE, PRIMARY_KEY, Entity } from '../glossary' 2 | import { 3 | Database, 4 | DatabaseEventsMap, 5 | SerializedEntity, 6 | SERIALIZED_INTERNAL_PROPERTIES_KEY, 7 | } from '../db/Database' 8 | import { inheritInternalProperties } from '../utils/inheritInternalProperties' 9 | import { Listener } from 'strict-event-emitter' 10 | 11 | export type DatabaseMessageEventData = 12 | | { 13 | operationType: 'create' 14 | payload: DatabaseEventsMap['create'] 15 | } 16 | | { 17 | operationType: 'update' 18 | payload: DatabaseEventsMap['update'] 19 | } 20 | | { 21 | operationType: 'delete' 22 | payload: DatabaseEventsMap['delete'] 23 | } 24 | 25 | function removeListeners( 26 | event: Event, 27 | db: Database, 28 | ) { 29 | const listeners = db.events.listeners(event) as Listener< 30 | DatabaseEventsMap[Event] 31 | >[] 32 | 33 | listeners.forEach((listener) => { 34 | db.events.removeListener(event, listener) 35 | }) 36 | 37 | return () => { 38 | listeners.forEach((listener) => { 39 | db.events.addListener(event, listener) 40 | }) 41 | } 42 | } 43 | 44 | /** 45 | * Sets the serialized internal properties as symbols 46 | * on the given entity. 47 | * @note `Symbol` properties are stripped off when sending 48 | * an object over an event emitter. 49 | */ 50 | function deserializeEntity(entity: SerializedEntity): Entity { 51 | const { 52 | [SERIALIZED_INTERNAL_PROPERTIES_KEY]: internalProperties, 53 | ...publicProperties 54 | } = entity 55 | 56 | inheritInternalProperties(publicProperties, { 57 | [ENTITY_TYPE]: internalProperties.entityType, 58 | [PRIMARY_KEY]: internalProperties.primaryKey, 59 | }) 60 | 61 | return publicProperties 62 | } 63 | 64 | /** 65 | * Synchronizes database operations across multiple clients. 66 | */ 67 | export function sync(db: Database) { 68 | const IS_BROWSER = typeof window !== 'undefined' 69 | const SUPPORTS_BROADCAST_CHANNEL = typeof BroadcastChannel !== 'undefined' 70 | 71 | if (!IS_BROWSER || !SUPPORTS_BROADCAST_CHANNEL) { 72 | return 73 | } 74 | 75 | const channel = new BroadcastChannel('mswjs/data/sync') 76 | 77 | channel.addEventListener( 78 | 'message', 79 | (event: MessageEvent) => { 80 | const [sourceId] = event.data.payload 81 | 82 | // Ignore messages originating from unrelated databases. 83 | // Useful in case of multiple databases on the same page. 84 | if (db.id !== sourceId) { 85 | return 86 | } 87 | 88 | // Remove database event listener for the signaled operation 89 | // to prevent an infinite loop when applying this operation. 90 | const restoreListeners = removeListeners(event.data.operationType, db) 91 | 92 | // Apply the database operation signaled from another client 93 | // to the current database instance. 94 | switch (event.data.operationType) { 95 | case 'create': { 96 | const [_, modelName, entity, customPrimaryKey] = event.data.payload 97 | db.create(modelName, deserializeEntity(entity), customPrimaryKey) 98 | break 99 | } 100 | 101 | case 'update': { 102 | const [_, modelName, prevEntity, nextEntity] = event.data.payload 103 | db.update( 104 | modelName, 105 | deserializeEntity(prevEntity), 106 | deserializeEntity(nextEntity), 107 | ) 108 | break 109 | } 110 | 111 | case 'delete': { 112 | const [_, modelName, primaryKey] = event.data.payload 113 | db.delete(modelName, primaryKey) 114 | break 115 | } 116 | } 117 | 118 | // Re-attach database event listeners. 119 | restoreListeners() 120 | }, 121 | ) 122 | 123 | // Broadcast the emitted event from this client 124 | // to all the other connected clients. 125 | function broadcastDatabaseEvent( 126 | operationType: Event, 127 | ) { 128 | return (...payload: DatabaseEventsMap[Event]) => { 129 | channel.postMessage({ 130 | operationType, 131 | payload, 132 | } as DatabaseMessageEventData) 133 | } 134 | } 135 | 136 | db.events.on('create', broadcastDatabaseEvent('create')) 137 | db.events.on('update', broadcastDatabaseEvent('update')) 138 | db.events.on('delete', broadcastDatabaseEvent('delete')) 139 | } 140 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { factory } from './factory' 2 | export { primaryKey } from './primaryKey' 3 | export { nullable } from './nullable' 4 | export { oneOf } from './relations/oneOf' 5 | export { manyOf } from './relations/manyOf' 6 | export { drop } from './db/drop' 7 | export { identity } from './utils/identity' 8 | 9 | /* Types */ 10 | export { PRIMARY_KEY, ENTITY_TYPE } from './glossary' 11 | -------------------------------------------------------------------------------- /src/model/createModel.ts: -------------------------------------------------------------------------------- 1 | import { debug } from 'debug' 2 | import { invariant } from 'outvariant' 3 | import get from 'lodash/get' 4 | import set from 'lodash/set' 5 | import isFunction from 'lodash/isFunction' 6 | import { Database } from '../db/Database' 7 | import { 8 | ENTITY_TYPE, 9 | Entity, 10 | InternalEntityProperties, 11 | ModelDefinition, 12 | ModelDictionary, 13 | PRIMARY_KEY, 14 | Value, 15 | } from '../glossary' 16 | import { ParsedModelDefinition } from './parseModelDefinition' 17 | import { defineRelationalProperties } from './defineRelationalProperties' 18 | import { PrimaryKey } from '../primaryKey' 19 | import { Relation } from '../relations/Relation' 20 | import { NullableObject, NullableProperty } from '../nullable' 21 | import { isModelValueType } from '../utils/isModelValueType' 22 | import { getDefinition } from './getDefinition' 23 | 24 | const log = debug('createModel') 25 | 26 | export function createModel< 27 | Dictionary extends ModelDictionary, 28 | ModelName extends string, 29 | >( 30 | modelName: ModelName, 31 | definition: ModelDefinition, 32 | dictionary: Dictionary, 33 | parsedModel: ParsedModelDefinition, 34 | initialValues: Partial>, 35 | db: Database, 36 | ): Entity { 37 | const { primaryKey, properties, relations } = parsedModel 38 | 39 | log( 40 | `creating a "${modelName}" entity (primary key: "${primaryKey}")`, 41 | definition, 42 | parsedModel, 43 | relations, 44 | initialValues, 45 | ) 46 | 47 | const internalProperties: InternalEntityProperties = { 48 | [ENTITY_TYPE]: modelName, 49 | [PRIMARY_KEY]: primaryKey, 50 | } 51 | 52 | const publicProperties = properties.reduce>( 53 | (properties, propertyName) => { 54 | const initialValue = get(initialValues, propertyName) 55 | const propertyDefinition = getDefinition(definition, propertyName) 56 | 57 | // Ignore relational properties at this stage. 58 | if (propertyDefinition instanceof Relation) { 59 | return properties 60 | } 61 | 62 | if (propertyDefinition instanceof PrimaryKey) { 63 | set( 64 | properties, 65 | propertyName, 66 | initialValue || propertyDefinition.getPrimaryKeyValue(), 67 | ) 68 | return properties 69 | } 70 | 71 | if (propertyDefinition instanceof NullableProperty) { 72 | const value = 73 | initialValue === null || isModelValueType(initialValue) 74 | ? initialValue 75 | : propertyDefinition.getValue() 76 | 77 | set(properties, propertyName, value) 78 | return properties 79 | } 80 | 81 | if (propertyDefinition instanceof NullableObject) { 82 | if ( 83 | initialValue === null || 84 | (propertyDefinition.defaultsToNull && initialValue === undefined) 85 | ) { 86 | // this is for all the cases we want to override the inner values of 87 | // the nullable object and just set it to be null. it happens when: 88 | // 1. the initial value of the nullable object is null 89 | // 2. the initial value of the nullable object is not defined and the definition defaults to null 90 | set(properties, propertyName, null) 91 | } 92 | return properties 93 | } 94 | 95 | invariant( 96 | initialValue !== null, 97 | 'Failed to create a "%s" entity: a non-nullable property "%s" cannot be instantiated with null. Use the "nullable" function when defining this property to support nullable value.', 98 | modelName, 99 | propertyName.join('.'), 100 | ) 101 | 102 | if (isModelValueType(initialValue)) { 103 | log( 104 | '"%s" has a plain initial value:', 105 | `${modelName}.${propertyName}`, 106 | initialValue, 107 | ) 108 | set(properties, propertyName, initialValue) 109 | return properties 110 | } 111 | 112 | if (isFunction(propertyDefinition)) { 113 | set(properties, propertyName, propertyDefinition()) 114 | return properties 115 | } 116 | 117 | return properties 118 | }, 119 | {}, 120 | ) 121 | 122 | const entity = Object.assign( 123 | {}, 124 | publicProperties, 125 | internalProperties, 126 | ) as Entity 127 | 128 | defineRelationalProperties(entity, initialValues, relations, dictionary, db) 129 | 130 | log('created "%s" entity:', modelName, entity) 131 | 132 | return entity 133 | } 134 | -------------------------------------------------------------------------------- /src/model/defineRelationalProperties.ts: -------------------------------------------------------------------------------- 1 | import { debug } from 'debug' 2 | import get from 'lodash/get' 3 | import { invariant } from 'outvariant' 4 | import { Database } from '../db/Database' 5 | import { 6 | Entity, 7 | ENTITY_TYPE, 8 | ModelDictionary, 9 | PRIMARY_KEY, 10 | Value, 11 | } from '../glossary' 12 | import { RelationKind, RelationsList } from '../relations/Relation' 13 | 14 | const log = debug('defineRelationalProperties') 15 | 16 | export function defineRelationalProperties( 17 | entity: Entity, 18 | initialValues: Partial>, 19 | relations: RelationsList, 20 | dictionary: ModelDictionary, 21 | db: Database, 22 | ): void { 23 | log('defining relational properties...', { entity, initialValues, relations }) 24 | 25 | for (const { propertyPath, relation } of relations) { 26 | invariant( 27 | dictionary[relation.target.modelName], 28 | 'Failed to define a "%s" relational property to "%s" on "%s": cannot find a model by the name "%s".', 29 | relation.kind, 30 | propertyPath.join('.'), 31 | entity[ENTITY_TYPE], 32 | relation.target.modelName, 33 | ) 34 | 35 | const references: Value | null | undefined = get( 36 | initialValues, 37 | propertyPath, 38 | ) 39 | 40 | invariant( 41 | references !== null || relation.attributes.nullable, 42 | 'Failed to define a "%s" relationship to "%s" at "%s.%s" (%s: "%s"): cannot set a non-nullable relationship to null.', 43 | 44 | relation.kind, 45 | relation.target.modelName, 46 | entity[ENTITY_TYPE], 47 | propertyPath.join('.'), 48 | entity[PRIMARY_KEY], 49 | entity[entity[PRIMARY_KEY]], 50 | ) 51 | 52 | log( 53 | `setting relational property "${entity[ENTITY_TYPE]}.${propertyPath.join( 54 | '.', 55 | )}" with references: %j`, 56 | references, 57 | relation, 58 | ) 59 | 60 | relation.apply(entity, propertyPath, dictionary, db) 61 | 62 | if (references) { 63 | log('has references, applying a getter...') 64 | relation.resolveWith(entity, references) 65 | continue 66 | } 67 | 68 | if (relation.attributes.nullable) { 69 | log('has no references but is nullable, applying a getter to null...') 70 | relation.resolveWith(entity, null) 71 | continue 72 | } 73 | 74 | if (relation.kind === RelationKind.ManyOf) { 75 | log( 76 | 'has no references but is a non-nullable "manyOf" relationship, applying a getter to []...', 77 | ) 78 | relation.resolveWith(entity, []) 79 | continue 80 | } 81 | 82 | log('has no relations, skipping the getter...') 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/model/generateRestHandlers.ts: -------------------------------------------------------------------------------- 1 | import pluralize from 'pluralize' 2 | import { 3 | ResponseResolver, 4 | http, 5 | DefaultBodyType, 6 | PathParams, 7 | HttpResponse, 8 | } from 'msw' 9 | import { 10 | Entity, 11 | ModelDictionary, 12 | ModelAPI, 13 | PrimaryKeyType, 14 | ModelDefinition, 15 | } from '../glossary' 16 | import { QuerySelectorWhere, WeakQuerySelectorWhere } from '../query/queryTypes' 17 | import { OperationErrorType, OperationError } from '../errors/OperationError' 18 | import { findPrimaryKey } from '../utils/findPrimaryKey' 19 | import { PrimaryKey } from '../primaryKey' 20 | 21 | enum HTTPErrorType { 22 | BadRequest, 23 | } 24 | 25 | const ErrorType = { ...HTTPErrorType, ...OperationErrorType } 26 | 27 | class HTTPError extends OperationError { 28 | constructor(type: HTTPErrorType, message?: string) { 29 | super(type, message) 30 | this.name = 'HTTPError' 31 | } 32 | } 33 | 34 | type RequestParams = { 35 | [K in Key]: string 36 | } 37 | 38 | export function createUrlBuilder(baseUrl?: string) { 39 | return (path: string) => { 40 | // For the previous implementation trailing slash didn't matter, we must keep it this way for backward compatibility 41 | const normalizedBaseUrl = 42 | baseUrl && baseUrl.slice(-1) === '/' 43 | ? baseUrl.slice(0, -1) 44 | : baseUrl || '' 45 | return `${normalizedBaseUrl}/${path}` 46 | } 47 | } 48 | 49 | export function getResponseStatusByErrorType( 50 | error: OperationError | HTTPError, 51 | ): number { 52 | switch (error.type) { 53 | case ErrorType.EntityNotFound: 54 | return 404 55 | case ErrorType.DuplicatePrimaryKey: 56 | return 409 57 | case ErrorType.BadRequest: 58 | return 400 59 | default: 60 | return 500 61 | } 62 | } 63 | 64 | export function withErrors< 65 | RequestBodyType extends DefaultBodyType = any, 66 | RequestParamsType extends PathParams = any, 67 | >(resolver: ResponseResolver) { 68 | return async (...args: Parameters): Promise => { 69 | try { 70 | const response = await resolver(...args) 71 | return response 72 | } catch (error) { 73 | if (error instanceof Error) { 74 | return HttpResponse.json( 75 | { message: error.message }, 76 | { 77 | status: getResponseStatusByErrorType(error as HTTPError), 78 | }, 79 | ) 80 | } 81 | } 82 | } 83 | } 84 | 85 | export function parseQueryParams( 86 | modelName: ModelName, 87 | definition: ModelDefinition, 88 | searchParams: URLSearchParams, 89 | ) { 90 | const paginationKeys = ['cursor', 'skip', 'take'] 91 | const cursor = searchParams.get('cursor') 92 | const rawSkip = searchParams.get('skip') 93 | const rawTake = searchParams.get('take') 94 | 95 | const filters: QuerySelectorWhere = {} 96 | const skip = rawSkip == null ? rawSkip : parseInt(rawSkip, 10) 97 | const take = rawTake == null ? rawTake : parseInt(rawTake, 10) 98 | 99 | searchParams.forEach((value, key) => { 100 | if (paginationKeys.includes(key)) { 101 | return 102 | } 103 | 104 | if (definition[key]) { 105 | filters[key] = { 106 | equals: value, 107 | } 108 | } else { 109 | throw new HTTPError( 110 | HTTPErrorType.BadRequest, 111 | `Failed to query the "${modelName}" model: unknown property "${key}".`, 112 | ) 113 | } 114 | }) 115 | 116 | return { 117 | cursor, 118 | skip, 119 | take, 120 | filters, 121 | } 122 | } 123 | 124 | export function generateRestHandlers< 125 | Dictionary extends ModelDictionary, 126 | ModelName extends string, 127 | >( 128 | modelName: ModelName, 129 | modelDefinition: ModelDefinition, 130 | model: ModelAPI, 131 | baseUrl: string = '', 132 | ) { 133 | const primaryKey = findPrimaryKey(modelDefinition)! 134 | const primaryKeyValue = ( 135 | modelDefinition[primaryKey] as PrimaryKey 136 | ).getPrimaryKeyValue() 137 | const modelPath = pluralize(modelName) 138 | const buildUrl = createUrlBuilder(baseUrl) 139 | 140 | function extractPrimaryKey(params: Record): PrimaryKeyType { 141 | const parameterValue = params[primaryKey] 142 | return typeof primaryKeyValue === 'number' 143 | ? Number(parameterValue) 144 | : parameterValue 145 | } 146 | 147 | return [ 148 | http.get( 149 | buildUrl(modelPath), 150 | withErrors>(({ request }) => { 151 | const url = new URL(request.url) 152 | const { skip, take, cursor, filters } = parseQueryParams( 153 | modelName, 154 | modelDefinition, 155 | url.searchParams, 156 | ) 157 | 158 | let options = { where: filters } 159 | if (take || skip) { 160 | options = Object.assign(options, { take, skip }) 161 | } 162 | if (take || cursor) { 163 | options = Object.assign(options, { take, cursor }) 164 | } 165 | 166 | const records = model.findMany(options) 167 | 168 | return HttpResponse.json(records) 169 | }), 170 | ), 171 | http.get( 172 | buildUrl(`${modelPath}/:${primaryKey}`), 173 | withErrors, RequestParams>( 174 | ({ params }) => { 175 | const id = extractPrimaryKey(params) 176 | const where: WeakQuerySelectorWhere = { 177 | [primaryKey]: { 178 | equals: id as string, 179 | }, 180 | } 181 | const entity = model.findFirst({ 182 | strict: true, 183 | where: where as any, 184 | }) 185 | 186 | return HttpResponse.json(entity) 187 | }, 188 | ), 189 | ), 190 | http.post( 191 | buildUrl(modelPath), 192 | withErrors>(async ({ request }) => { 193 | const definition = await request.json() 194 | const createdEntity = model.create(definition) 195 | 196 | return HttpResponse.json(createdEntity, { status: 201 }) 197 | }), 198 | ), 199 | http.put( 200 | buildUrl(`${modelPath}/:${primaryKey}`), 201 | withErrors, RequestParams>( 202 | async ({ request, params }) => { 203 | const id = extractPrimaryKey(params) 204 | const where: WeakQuerySelectorWhere = { 205 | [primaryKey]: { 206 | equals: id as string, 207 | }, 208 | } 209 | const updatedEntity = model.update({ 210 | strict: true, 211 | where: where as any, 212 | data: await request.json(), 213 | })! 214 | 215 | return HttpResponse.json(updatedEntity) 216 | }, 217 | ), 218 | ), 219 | http.delete( 220 | buildUrl(`${modelPath}/:${primaryKey}`), 221 | withErrors, RequestParams>( 222 | ({ params }) => { 223 | const id = extractPrimaryKey(params) 224 | const where: WeakQuerySelectorWhere = { 225 | [primaryKey]: { 226 | equals: id as string, 227 | }, 228 | } 229 | const deletedEntity = model.delete({ 230 | strict: true, 231 | where: where as any, 232 | })! 233 | 234 | return HttpResponse.json(deletedEntity) 235 | }, 236 | ), 237 | ), 238 | ] 239 | } 240 | -------------------------------------------------------------------------------- /src/model/getDefinition.ts: -------------------------------------------------------------------------------- 1 | import { NullableObject, NullableProperty } from '../nullable' 2 | import { ModelDefinition } from '../glossary' 3 | import { isObject } from '../utils/isObject' 4 | import { isFunction } from 'lodash' 5 | 6 | export function getDefinition( 7 | definition: ModelDefinition, 8 | propertyName: string[], 9 | ) { 10 | return propertyName.reduce((reducedDefinition, property) => { 11 | const value = reducedDefinition[property] 12 | 13 | if (value instanceof NullableProperty) { 14 | return value 15 | } 16 | 17 | if (value instanceof NullableObject) { 18 | // in case the propertyName array includes NullableObject, we get 19 | // the NullableObject definition and continue the reduce loop 20 | if (property !== propertyName.at(-1)) { 21 | return value.objectDefinition 22 | } 23 | // in case the propertyName array ends with NullableObject, we just return it and if 24 | // it should get the value of null, it will override its inner properties 25 | return value 26 | } 27 | 28 | // getter functions and nested objects 29 | if (isFunction(value) || isObject(value)) { 30 | return value 31 | } 32 | 33 | return 34 | }, definition) 35 | } 36 | -------------------------------------------------------------------------------- /src/model/parseModelDefinition.ts: -------------------------------------------------------------------------------- 1 | import { debug } from 'debug' 2 | import { invariant } from 'outvariant' 3 | import { 4 | ModelDefinition, 5 | PrimaryKeyType, 6 | ModelDictionary, 7 | NestedModelDefinition, 8 | } from '../glossary' 9 | import { PrimaryKey } from '../primaryKey' 10 | import { isObject } from '../utils/isObject' 11 | import { Relation, RelationsList } from '../relations/Relation' 12 | import { NullableObject, NullableProperty } from '../nullable' 13 | 14 | const log = debug('parseModelDefinition') 15 | 16 | export interface ParsedModelDefinition { 17 | primaryKey: PrimaryKeyType 18 | properties: Array 19 | relations: RelationsList 20 | } 21 | 22 | /** 23 | * Recursively parses a given model definition into properties and relations. 24 | */ 25 | function deepParseModelDefinition( 26 | dictionary: Dictionary, 27 | modelName: string, 28 | definition: ModelDefinition, 29 | parentPath?: string[], 30 | result: ParsedModelDefinition = { 31 | primaryKey: undefined!, 32 | properties: [], 33 | relations: [], 34 | }, 35 | ) { 36 | if (parentPath) { 37 | log( 38 | 'parsing a nested model definition for "%s" property at "%s"', 39 | parentPath, 40 | modelName, 41 | definition, 42 | ) 43 | } 44 | 45 | for (const [propertyName, value] of Object.entries(definition)) { 46 | const propertyPath = parentPath 47 | ? [...parentPath, propertyName] 48 | : [propertyName] 49 | 50 | // Primary key. 51 | if (value instanceof PrimaryKey) { 52 | invariant( 53 | !result.primaryKey, 54 | 'Failed to parse a model definition for "%s": cannot have both properties "%s" and "%s" as a primary key.', 55 | modelName, 56 | result.primaryKey, 57 | propertyName, 58 | ) 59 | 60 | invariant( 61 | !parentPath, 62 | 'Failed to parse a model definition for "%s" property of "%s": cannot have a primary key in a nested object.', 63 | parentPath?.join('.'), 64 | modelName, 65 | ) 66 | 67 | result.primaryKey = propertyName 68 | result.properties.push([propertyName]) 69 | 70 | continue 71 | } 72 | 73 | if (value instanceof NullableProperty) { 74 | // Add nullable properties to the same list as regular properties 75 | result.properties.push(propertyPath) 76 | continue 77 | } 78 | 79 | if (value instanceof NullableObject) { 80 | deepParseModelDefinition( 81 | dictionary, 82 | modelName, 83 | value.objectDefinition, 84 | propertyPath, 85 | result, 86 | ) 87 | 88 | // after the recursion calls we want to set the nullable object itself to be part of the properties 89 | // because in case it will get the value of null we want to override its inner values 90 | result.properties.push(propertyPath) 91 | continue 92 | } 93 | 94 | // Relations. 95 | if (value instanceof Relation) { 96 | // Store the relations in a separate object. 97 | result.relations.push({ propertyPath, relation: value }) 98 | continue 99 | } 100 | 101 | // Nested objects. 102 | if (isObject(value)) { 103 | deepParseModelDefinition( 104 | dictionary, 105 | modelName, 106 | value, 107 | propertyPath, 108 | result, 109 | ) 110 | 111 | continue 112 | } 113 | 114 | // Regular properties. 115 | result.properties.push(propertyPath) 116 | } 117 | 118 | return result 119 | } 120 | 121 | export function parseModelDefinition( 122 | dictionary: Dictionary, 123 | modelName: string, 124 | definition: ModelDefinition, 125 | ): ParsedModelDefinition { 126 | log('parsing model definition for "%s" entity', modelName, definition) 127 | const result = deepParseModelDefinition(dictionary, modelName, definition) 128 | 129 | invariant( 130 | result.primaryKey, 131 | 'Failed to parse a model definition for "%s": model is missing a primary key. Did you forget to mark one of its properties using the "primaryKey" function?', 132 | modelName, 133 | ) 134 | 135 | return result 136 | } 137 | -------------------------------------------------------------------------------- /src/nullable.ts: -------------------------------------------------------------------------------- 1 | import { ModelValueType, NestedModelDefinition } from './glossary' 2 | import { ManyOf, OneOf, Relation, RelationKind } from './relations/Relation' 3 | 4 | export class NullableObject { 5 | public objectDefinition: ValueType 6 | public defaultsToNull: boolean 7 | 8 | constructor(definition: ValueType, defaultsToNull: boolean) { 9 | this.objectDefinition = definition 10 | this.defaultsToNull = defaultsToNull 11 | } 12 | } 13 | 14 | export type NullableGetter = 15 | () => ValueType | null 16 | 17 | export class NullableProperty { 18 | public getValue: NullableGetter 19 | 20 | constructor(getter: NullableGetter) { 21 | this.getValue = getter 22 | } 23 | } 24 | 25 | export function nullable( 26 | value: ValueType, 27 | options?: { defaultsToNull?: boolean }, 28 | ): NullableObject 29 | 30 | export function nullable( 31 | value: NullableGetter, 32 | options?: { defaultsToNull?: boolean }, 33 | ): NullableProperty 34 | 35 | export function nullable< 36 | ValueType extends Relation, 37 | >( 38 | value: ValueType, 39 | options?: { defaultsToNull?: boolean }, 40 | ): ValueType extends Relation 41 | ? Kind extends RelationKind.ManyOf 42 | ? ManyOf 43 | : OneOf 44 | : never 45 | 46 | export function nullable( 47 | value: 48 | | NullableGetter 49 | | Relation 50 | | NestedModelDefinition, 51 | options?: { defaultsToNull?: boolean }, 52 | ) { 53 | if (value instanceof Relation) { 54 | return new Relation({ 55 | kind: value.kind, 56 | to: value.target.modelName, 57 | attributes: { 58 | ...value.attributes, 59 | nullable: true, 60 | }, 61 | }) 62 | } 63 | 64 | if (typeof value === 'object') { 65 | return new NullableObject(value, !!options?.defaultsToNull) 66 | } 67 | 68 | if (typeof value === 'function') { 69 | return new NullableProperty(value) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/primaryKey.ts: -------------------------------------------------------------------------------- 1 | import { PrimaryKeyType } from './glossary' 2 | 3 | export type PrimaryKeyGetter = () => ValueType 4 | 5 | export class PrimaryKey { 6 | public getPrimaryKeyValue: PrimaryKeyGetter 7 | 8 | constructor(getter: PrimaryKeyGetter) { 9 | this.getPrimaryKeyValue = getter 10 | } 11 | } 12 | 13 | export function primaryKey( 14 | getter: PrimaryKeyGetter, 15 | ): PrimaryKey { 16 | return new PrimaryKey(getter) 17 | } 18 | -------------------------------------------------------------------------------- /src/query/compileQuery.ts: -------------------------------------------------------------------------------- 1 | import { debug } from 'debug' 2 | import { invariant } from 'outvariant' 3 | import { ComparatorFn, QuerySelector } from './queryTypes' 4 | import { getComparatorsForValue } from './getComparatorsForValue' 5 | import { isObject } from '../utils/isObject' 6 | 7 | const log = debug('compileQuery') 8 | 9 | /** 10 | * Compile a query expression into a function that accepts an actual entity 11 | * and returns a query execution result (whether the entity satisfies the query). 12 | */ 13 | export function compileQuery>( 14 | query: QuerySelector, 15 | ) { 16 | log('%j', query) 17 | 18 | return (data: Data): boolean => { 19 | return Object.entries(query.where) 20 | .map(([property, queryChunk]) => { 21 | const actualValue = data[property] 22 | 23 | log( 24 | 'executing query chunk on "%s":\n\n%j\n\non data:\n\n%j\n', 25 | property, 26 | queryChunk, 27 | data, 28 | ) 29 | log('actual value for "%s":', property, actualValue) 30 | 31 | if (!queryChunk) { 32 | return true 33 | } 34 | 35 | // If an entity doesn't have any value for the property 36 | // is being queried for, treat it as non-matching. 37 | if (actualValue == null) { 38 | return false 39 | } 40 | 41 | return Object.entries(queryChunk).reduce( 42 | (acc, [comparatorName, expectedValue]) => { 43 | if (!acc) { 44 | return acc 45 | } 46 | 47 | if (Array.isArray(actualValue)) { 48 | log( 49 | 'actual value is array, checking if at least one item matches...', 50 | { 51 | comparatorName, 52 | expectedValue, 53 | }, 54 | ) 55 | 56 | /** 57 | * @fixme Can assume `some`? Why not `every`? 58 | */ 59 | return actualValue.some((value) => { 60 | return compileQuery({ where: queryChunk })(value) 61 | }) 62 | } 63 | 64 | // When the actual value is a resolved relational property reference, 65 | // execute the current query chunk on the referenced entity. 66 | if (actualValue.__type || isObject(actualValue)) { 67 | return compileQuery({ where: queryChunk })(actualValue) 68 | } 69 | 70 | const comparatorSet = getComparatorsForValue(actualValue) 71 | log('comparators', comparatorSet) 72 | 73 | const comparatorFn = (comparatorSet as any)[ 74 | comparatorName 75 | ] as ComparatorFn 76 | 77 | log( 78 | 'using comparator function for "%s":', 79 | comparatorName, 80 | comparatorFn, 81 | ) 82 | 83 | invariant( 84 | comparatorFn, 85 | 'Failed to compile the query "%j": no comparator found for the chunk "%s". Please check the validity of the query.', 86 | query, 87 | comparatorName, 88 | ) 89 | 90 | return comparatorFn(expectedValue, actualValue) 91 | }, 92 | true, 93 | ) 94 | }) 95 | .every(Boolean) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/query/executeQuery.ts: -------------------------------------------------------------------------------- 1 | import { debug } from 'debug' 2 | import { Entity, PrimaryKeyType, PRIMARY_KEY } from '../glossary' 3 | import { compileQuery } from './compileQuery' 4 | import { 5 | BulkQueryOptions, 6 | QuerySelector, 7 | WeakQuerySelector, 8 | } from './queryTypes' 9 | import * as iteratorUtils from '../utils/iteratorUtils' 10 | import { paginateResults } from './paginateResults' 11 | import { Database } from '../db/Database' 12 | import { sortResults } from './sortResults' 13 | import { invariant } from 'outvariant' 14 | import { safeStringify } from '../utils/safeStringify' 15 | 16 | const log = debug('executeQuery') 17 | 18 | function queryByPrimaryKey( 19 | records: Map>, 20 | query: QuerySelector, 21 | ) { 22 | log('querying by primary key') 23 | log('query by primary key', { query, records }) 24 | 25 | const matchPrimaryKey = compileQuery(query) 26 | 27 | const result = iteratorUtils.filter((id, value) => { 28 | const primaryKey = value[PRIMARY_KEY] 29 | 30 | invariant( 31 | primaryKey, 32 | 'Failed to query by primary key using "%j": record (%j) has no primary key set.', 33 | query, 34 | value, 35 | ) 36 | 37 | return matchPrimaryKey({ [primaryKey]: id }) 38 | }, records) 39 | 40 | log('result of querying by primary key:', result) 41 | return result 42 | } 43 | 44 | /** 45 | * Execute a given query against a model in the database. 46 | * Returns the list of records that satisfy the query. 47 | */ 48 | export function executeQuery( 49 | modelName: string, 50 | primaryKey: PrimaryKeyType, 51 | query: WeakQuerySelector & BulkQueryOptions, 52 | db: Database, 53 | ): Entity[] { 54 | log(`${safeStringify(query)} on "${modelName}"`) 55 | log('using primary key "%s"', primaryKey) 56 | 57 | const records = db.getModel(modelName) 58 | 59 | // Reduce the query scope if there's a query by primary key of the model. 60 | const { [primaryKey]: primaryKeyComparator, ...restQueries } = 61 | query.where || {} 62 | log('primary key query', primaryKeyComparator) 63 | 64 | const scopedRecords = primaryKeyComparator 65 | ? queryByPrimaryKey(records, { 66 | where: { [primaryKey]: primaryKeyComparator }, 67 | }) 68 | : records 69 | 70 | const result = iteratorUtils.filter((_, record) => { 71 | const executeQuery = compileQuery({ where: restQueries }) 72 | return executeQuery(record) 73 | }, scopedRecords) 74 | 75 | const resultJson = Array.from(result.values()) 76 | 77 | log( 78 | `resolved query "${safeStringify(query)}" on "${modelName}" to`, 79 | resultJson, 80 | ) 81 | 82 | if (query.orderBy) { 83 | sortResults(query.orderBy, resultJson) 84 | } 85 | 86 | const paginatedResults = paginateResults(query, resultJson) 87 | log('paginated query results', paginatedResults) 88 | 89 | return paginatedResults 90 | } 91 | -------------------------------------------------------------------------------- /src/query/getComparatorsForValue.ts: -------------------------------------------------------------------------------- 1 | import { booleanComparators } from '../comparators/boolean' 2 | import { dateComparators } from '../comparators/date' 3 | import { numberComparators } from '../comparators/number' 4 | import { stringComparators } from '../comparators/string' 5 | import { 6 | DateQuery, 7 | NumberQuery, 8 | StringQuery, 9 | BooleanQuery, 10 | QueryToComparator, 11 | } from './queryTypes' 12 | 13 | export function getComparatorsForValue( 14 | value: string | number, 15 | ): QueryToComparator { 16 | switch (value.constructor.name) { 17 | case 'String': 18 | return stringComparators 19 | 20 | case 'Number': 21 | return numberComparators 22 | 23 | case 'Boolean': 24 | return booleanComparators 25 | 26 | case 'Date': 27 | return dateComparators 28 | 29 | default: 30 | throw new Error( 31 | `Failed to find a comparator for the value "${JSON.stringify( 32 | value, 33 | )}" of type "${value.constructor.name}".`, 34 | ) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/query/paginateResults.ts: -------------------------------------------------------------------------------- 1 | import { Entity, PRIMARY_KEY } from '../glossary' 2 | import { BulkQueryOptions, WeakQuerySelector } from './queryTypes' 3 | 4 | function getEndIndex(start: number, end?: number) { 5 | return end ? start + end : undefined 6 | } 7 | 8 | export function paginateResults( 9 | query: WeakQuerySelector & BulkQueryOptions, 10 | data: Entity[], 11 | ): Entity[] { 12 | if (query.cursor) { 13 | const cursorIndex = data.findIndex((entity) => { 14 | return entity[entity[PRIMARY_KEY]] === query.cursor 15 | }) 16 | 17 | if (cursorIndex === -1) { 18 | return [] 19 | } 20 | 21 | return data.slice(cursorIndex + 1, getEndIndex(cursorIndex + 1, query.take)) 22 | } 23 | 24 | const start = query.skip || 0 25 | return data.slice(start, getEndIndex(start, query.take)) 26 | } 27 | -------------------------------------------------------------------------------- /src/query/queryTypes.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AnyObject, 3 | DeepRequiredExactlyOne, 4 | PrimaryKeyType, 5 | Value, 6 | ModelValueType, 7 | ModelDefinitionValue, 8 | } from '../glossary' 9 | 10 | export interface QueryOptions { 11 | strict?: boolean 12 | } 13 | export interface QuerySelector { 14 | where: QuerySelectorWhere 15 | } 16 | 17 | export type WeakQuerySelector = Partial< 18 | QuerySelector 19 | > 20 | 21 | export type RecursiveQuerySelectorWhere = 22 | Value extends Array 23 | ? Partial> 24 | : Value extends ModelValueType 25 | ? Partial> 26 | : Value extends AnyObject 27 | ? { 28 | [K in keyof Value]?: RecursiveQuerySelectorWhere 29 | } 30 | : never 31 | 32 | export type QuerySelectorWhere = { 33 | [Key in keyof EntityType]?: RecursiveQuerySelectorWhere 34 | } 35 | 36 | export interface WeakQuerySelectorWhere { 37 | [key: string]: Partial> 38 | } 39 | 40 | export type SortDirection = 'asc' | 'desc' 41 | 42 | export type RecursiveOrderBy = 43 | Value extends ModelValueType 44 | ? SortDirection 45 | : Value extends AnyObject 46 | ? DeepRequiredExactlyOne<{ 47 | [K in keyof Value]?: RecursiveOrderBy 48 | }> 49 | : never 50 | 51 | export type OrderBy = DeepRequiredExactlyOne<{ 52 | [Key in keyof EntityType]?: RecursiveOrderBy 53 | }> 54 | 55 | export interface BulkQueryBaseOptions { 56 | take?: number 57 | orderBy?: OrderBy | OrderBy[] 58 | } 59 | 60 | interface BulkQueryOffsetOptions 61 | extends BulkQueryBaseOptions { 62 | skip?: number 63 | cursor?: never 64 | } 65 | 66 | interface BulkQueryCursorOptions 67 | extends BulkQueryBaseOptions { 68 | skip?: never 69 | cursor: PrimaryKeyType | null 70 | } 71 | 72 | export type BulkQueryOptions = 73 | | BulkQueryOffsetOptions 74 | | BulkQueryCursorOptions 75 | 76 | export type ComparatorFn = ( 77 | expected: ExpectedType, 78 | actual: ActualType, 79 | ) => boolean 80 | 81 | export type QueryToComparator< 82 | QueryType extends StringQuery | NumberQuery | BooleanQuery | DateQuery, 83 | > = { 84 | [Key in keyof QueryType]: ComparatorFn< 85 | QueryType[Key], 86 | QueryType[Key] extends Array ? ValueType : QueryType[Key] 87 | > 88 | } 89 | 90 | export type GetQueryFor = ValueType extends string 91 | ? StringQuery 92 | : ValueType extends number 93 | ? NumberQuery 94 | : ValueType extends Boolean 95 | ? BooleanQuery 96 | : ValueType extends Date 97 | ? DateQuery 98 | : ValueType extends Array 99 | ? QuerySelector['where'] 100 | : /** 101 | * Relational `oneOf`/`manyOf` invocation 102 | * resolves to the `Value` type. 103 | */ 104 | ValueType extends Value 105 | ? QuerySelector['where'] 106 | : never 107 | 108 | export interface StringQuery { 109 | equals: string 110 | notEquals: string 111 | contains: string 112 | notContains: string 113 | gt: string 114 | gte: string 115 | lt: string 116 | lte: string 117 | in: string[] 118 | notIn: string[] 119 | } 120 | 121 | export interface NumberQuery { 122 | equals: number 123 | notEquals: number 124 | between: [number, number] 125 | notBetween: [number, number] 126 | gt: number 127 | gte: number 128 | lt: number 129 | lte: number 130 | in: number[] 131 | notIn: number[] 132 | } 133 | 134 | export interface BooleanQuery { 135 | equals: boolean 136 | notEquals: boolean 137 | } 138 | 139 | export interface DateQuery { 140 | equals: Date 141 | notEquals: Date 142 | gt: Date 143 | gte: Date 144 | lt: Date 145 | lte: Date 146 | } 147 | -------------------------------------------------------------------------------- /src/query/sortResults.ts: -------------------------------------------------------------------------------- 1 | import { debug } from 'debug' 2 | import get from 'lodash/get' 3 | import { Entity } from 'src/glossary' 4 | import { OrderBy, SortDirection } from './queryTypes' 5 | 6 | const log = debug('sortResults') 7 | 8 | type FlatSortCriteria = [string[], SortDirection] 9 | 10 | function warnOnIneffectiveSortingKeys(sortCriteria: Record): void { 11 | const [mainCriteria, ...siblings] = Object.keys(sortCriteria) 12 | 13 | if (siblings.length > 0) { 14 | console.warn( 15 | 'Sorting by "%s" has no effect: already sorted by "%s".', 16 | siblings.join(','), 17 | mainCriteria, 18 | ) 19 | } 20 | } 21 | 22 | function flattenSortCriteria>( 23 | orderBy: OrderBy[], 24 | propertyPath: string[] = [], 25 | ): FlatSortCriteria[] { 26 | log('flattenSortCriteria:', orderBy, propertyPath) 27 | 28 | return orderBy.reduce((criteria, properties) => { 29 | warnOnIneffectiveSortingKeys(properties) 30 | 31 | // Multiple properties in a single criteria object are forbidden. 32 | // Use the list of criteria objects for multi-criteria sort. 33 | const property = Object.keys(properties)[0] as keyof OrderBy 34 | const sortDirection = properties[property]! 35 | const path = propertyPath.concat(property.toString()) 36 | log({ property, sortDirection, path }) 37 | 38 | // Recursively flatten order criteria when referencing 39 | // relational properties. 40 | const newCriteria = 41 | typeof sortDirection === 'object' 42 | ? flattenSortCriteria([sortDirection], path) 43 | : ([[path, sortDirection]] as FlatSortCriteria[]) 44 | 45 | log('pushing new criteria:', newCriteria) 46 | return criteria.concat(newCriteria) 47 | }, []) 48 | } 49 | 50 | /** 51 | * Sorts the given list of entities by a certain criteria. 52 | */ 53 | export function sortResults>( 54 | orderBy: OrderBy | OrderBy[], 55 | data: Entity[], 56 | ): void { 57 | log('sorting data:', data) 58 | log('order by:', orderBy) 59 | 60 | const criteriaList = ([] as OrderBy[]).concat(orderBy) 61 | log('criteria list:', criteriaList) 62 | 63 | const criteria = flattenSortCriteria(criteriaList) 64 | log('flattened criteria:', JSON.stringify(criteria)) 65 | 66 | data.sort((left, right) => { 67 | for (const [path, sortDirection] of criteria) { 68 | const leftValue = get(left, path) 69 | const rightValue = get(right, path) 70 | 71 | log( 72 | 'comparing value at "%s" (%s): "%s" / "%s"', 73 | path, 74 | sortDirection, 75 | leftValue, 76 | rightValue, 77 | ) 78 | 79 | if (leftValue > rightValue) { 80 | return sortDirection === 'asc' ? 1 : -1 81 | } 82 | 83 | if (leftValue < rightValue) { 84 | return sortDirection === 'asc' ? -1 : 1 85 | } 86 | } 87 | 88 | return 0 89 | }) 90 | 91 | log('sorted results:\n', data) 92 | } 93 | -------------------------------------------------------------------------------- /src/relations/manyOf.ts: -------------------------------------------------------------------------------- 1 | import { ManyOf, Relation, RelationAttributes, RelationKind } from './Relation' 2 | 3 | export function manyOf( 4 | to: ModelName, 5 | attributes?: Partial, 6 | ): ManyOf { 7 | return new Relation({ 8 | to, 9 | kind: RelationKind.ManyOf, 10 | attributes, 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /src/relations/oneOf.ts: -------------------------------------------------------------------------------- 1 | import { OneOf, Relation, RelationAttributes, RelationKind } from './Relation' 2 | 3 | export function oneOf( 4 | to: ModelName, 5 | attributes?: Partial, 6 | ): OneOf { 7 | return new Relation({ 8 | to, 9 | kind: RelationKind.OneOf, 10 | attributes, 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/capitalize.ts: -------------------------------------------------------------------------------- 1 | export function capitalize(str: string): string { 2 | const [firstLetter, ...rest] = str 3 | return firstLetter.toUpperCase() + rest.join('') 4 | } 5 | -------------------------------------------------------------------------------- /src/utils/definePropertyAtPath.ts: -------------------------------------------------------------------------------- 1 | import has from 'lodash/has' 2 | import set from 'lodash/set' 3 | import get from 'lodash/get' 4 | 5 | /** 6 | * Abstraction over `Object.defineProperty` that supports 7 | * property paths (nested properties). 8 | * 9 | * @example 10 | * const target = {} 11 | * definePropertyAtPath(target, 'a.b.c', { get(): { return 2 }}) 12 | * console.log(target.a.b.c) // 2 13 | */ 14 | export function definePropertyAtPath( 15 | target: Record, 16 | propertyPath: string[], 17 | attributes: AttributesType, 18 | ) { 19 | const propertyName = propertyPath[propertyPath.length - 1] 20 | const parentPath = propertyPath.slice(0, -1) 21 | 22 | if (parentPath.length && !has(target, parentPath)) { 23 | set(target, parentPath, {}) 24 | } 25 | 26 | const parent = parentPath.length ? get(target, parentPath) : target 27 | Object.defineProperty(parent, propertyName, attributes) 28 | } 29 | -------------------------------------------------------------------------------- /src/utils/findPrimaryKey.ts: -------------------------------------------------------------------------------- 1 | import { ModelDefinition, PrimaryKeyType } from '../glossary' 2 | import { PrimaryKey } from '../primaryKey' 3 | 4 | /** 5 | * Returns a primary key property name of the given model definition. 6 | */ 7 | export function findPrimaryKey( 8 | definition: ModelDefinition, 9 | ): PrimaryKeyType | undefined { 10 | for (const propertyName in definition) { 11 | const value = definition[propertyName] 12 | 13 | if (value instanceof PrimaryKey) { 14 | return propertyName 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/first.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Return the first element in the given array. 3 | */ 4 | export function first( 5 | arr: ArrayType, 6 | ): ArrayType extends Array ? ValueType | null : never { 7 | return arr != null && arr.length > 0 ? arr[0] : null 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/identity.ts: -------------------------------------------------------------------------------- 1 | export function identity(value: T): () => T { 2 | return () => value 3 | } 4 | -------------------------------------------------------------------------------- /src/utils/inheritInternalProperties.ts: -------------------------------------------------------------------------------- 1 | import { invariant } from 'outvariant' 2 | import { ENTITY_TYPE, PRIMARY_KEY, Entity } from '../glossary' 3 | 4 | export function inheritInternalProperties( 5 | target: Record, 6 | source: Entity, 7 | ): void { 8 | const entityType = source[ENTITY_TYPE] 9 | const primaryKey = source[PRIMARY_KEY] 10 | 11 | invariant( 12 | entityType, 13 | 'Failed to inherit internal properties from (%j) to (%j): provided source entity has no entity type specified.', 14 | source, 15 | target, 16 | ) 17 | invariant( 18 | primaryKey, 19 | 'Failed to inherit internal properties from (%j) to (%j): provided source entity has no primary key specified.', 20 | source, 21 | target, 22 | ) 23 | 24 | Object.defineProperties(target, { 25 | [ENTITY_TYPE]: { 26 | enumerable: true, 27 | value: entityType, 28 | }, 29 | [PRIMARY_KEY]: { 30 | enumerable: true, 31 | value: primaryKey, 32 | }, 33 | }) 34 | } 35 | -------------------------------------------------------------------------------- /src/utils/isModelValueType.ts: -------------------------------------------------------------------------------- 1 | import { ModelValueType, PrimitiveValueType } from '../glossary' 2 | import { isObject } from './isObject' 3 | 4 | function isPrimitiveValueType(value: any): value is PrimitiveValueType { 5 | return ( 6 | typeof value === 'string' || 7 | typeof value === 'number' || 8 | typeof value === 'boolean' || 9 | isObject(value) || 10 | value?.constructor?.name === 'Date' 11 | ) 12 | } 13 | 14 | export function isModelValueType(value: any): value is ModelValueType { 15 | return isPrimitiveValueType(value) || Array.isArray(value) 16 | } 17 | -------------------------------------------------------------------------------- /src/utils/isObject.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns true if the given value is a plain Object. 3 | */ 4 | export function isObject>( 5 | value: any, 6 | ): value is O { 7 | return ( 8 | value != null && 9 | typeof value === 'object' && 10 | !Array.isArray(value) && 11 | !(value instanceof Date) 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/iteratorUtils.ts: -------------------------------------------------------------------------------- 1 | import { PrimaryKeyType } from '../glossary' 2 | 3 | export function forEach( 4 | fn: (key: K, value: V) => any, 5 | map: Map, 6 | ): void { 7 | for (const [key, value] of map.entries()) { 8 | fn(key, value) 9 | } 10 | } 11 | 12 | export function filter( 13 | predicate: (key: K, value: V) => boolean, 14 | map: Map, 15 | ): Map { 16 | const nextMap = new Map() 17 | 18 | forEach((key, value) => { 19 | if (predicate(key, value)) { 20 | nextMap.set(key, value) 21 | } 22 | }, map) 23 | 24 | return nextMap 25 | } 26 | -------------------------------------------------------------------------------- /src/utils/numberInRange.ts: -------------------------------------------------------------------------------- 1 | export function numberInRange( 2 | min: number, 3 | max: number, 4 | actual: number, 5 | ): boolean { 6 | return actual >= min && actual <= max 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/safeStringify.ts: -------------------------------------------------------------------------------- 1 | import { ENTITY_TYPE, PRIMARY_KEY } from '../glossary' 2 | 3 | export function safeStringify(value: unknown): string { 4 | const seen = new WeakSet() 5 | 6 | return JSON.stringify(value, (_, value) => { 7 | if (typeof value !== 'object' || value === null) { 8 | return value 9 | } 10 | 11 | if (seen.has(value)) { 12 | const type = value[ENTITY_TYPE] 13 | const primaryKey = value[PRIMARY_KEY] 14 | 15 | return type && primaryKey 16 | ? `Entity(type: ${type}, ${primaryKey}: ${value[primaryKey]})` 17 | : '[Circular Reference]' 18 | } 19 | 20 | seen.add(value) 21 | return value 22 | }) 23 | } 24 | -------------------------------------------------------------------------------- /src/utils/spread.ts: -------------------------------------------------------------------------------- 1 | import { isObject } from './isObject' 2 | 3 | /** 4 | * Clones the given object, preserving its setters/getters. 5 | */ 6 | export function spread< 7 | ObjectType extends Record, 8 | >(source: ObjectType): ObjectType { 9 | const target = {} as ObjectType 10 | const descriptors = Object.getOwnPropertyDescriptors(source) 11 | 12 | for (const [propertyName, descriptor] of Object.entries(descriptors)) { 13 | // Spread nested objects, preserving their descriptors. 14 | if (isObject(descriptor.value)) { 15 | Object.defineProperty(target, propertyName, { 16 | ...descriptor, 17 | value: spread(descriptor.value), 18 | }) 19 | continue 20 | } 21 | 22 | Object.defineProperty(target, propertyName, descriptor) 23 | } 24 | 25 | return target 26 | } 27 | -------------------------------------------------------------------------------- /test/comparators/boolean-comparators.test.ts: -------------------------------------------------------------------------------- 1 | import { booleanComparators } from '../../src/comparators/boolean' 2 | 3 | test('equals', () => { 4 | expect(booleanComparators.equals(true, true)).toBe(true) 5 | expect(booleanComparators.equals(false, false)).toBe(true) 6 | expect(booleanComparators.equals(true, false)).toBe(false) 7 | expect(booleanComparators.equals(false, true)).toBe(false) 8 | }) 9 | 10 | test('notEquals', () => { 11 | expect(booleanComparators.notEquals(true, false)).toBe(true) 12 | expect(booleanComparators.notEquals(false, true)).toBe(true) 13 | expect(booleanComparators.notEquals(true, true)).toBe(false) 14 | expect(booleanComparators.notEquals(false, false)).toBe(false) 15 | }) 16 | -------------------------------------------------------------------------------- /test/comparators/date-comparators.test.ts: -------------------------------------------------------------------------------- 1 | import { dateComparators } from '../../src/comparators/date' 2 | 3 | test('equals', () => { 4 | expect( 5 | dateComparators.equals(new Date('1980-12-10'), new Date('1980-12-10')), 6 | ).toBe(true) 7 | 8 | expect( 9 | dateComparators.equals(new Date('1980-12-10'), new Date('1980-01-01')), 10 | ).toBe(false) 11 | }) 12 | 13 | test('notEquals', () => { 14 | expect( 15 | dateComparators.notEquals(new Date('1980-12-10'), new Date('1980-01-01')), 16 | ).toBe(true) 17 | 18 | expect( 19 | dateComparators.notEquals(new Date('1980-12-10'), new Date('1980-12-10')), 20 | ).toBe(false) 21 | }) 22 | 23 | test('gt', () => { 24 | expect( 25 | dateComparators.gt(new Date('1980-01-01'), new Date('1980-06-24')), 26 | ).toBe(true) 27 | 28 | expect( 29 | dateComparators.gt(new Date('1980-02-14'), new Date('1980-02-12')), 30 | ).toBe(false) 31 | }) 32 | 33 | test('gte', () => { 34 | expect( 35 | dateComparators.gte(new Date('1980-01-01'), new Date('1980-06-24')), 36 | ).toBe(true) 37 | expect( 38 | dateComparators.gte(new Date('1980-01-01'), new Date('1980-01-01')), 39 | ).toBe(true) 40 | 41 | expect( 42 | dateComparators.gte(new Date('1980-02-14'), new Date('1980-02-12')), 43 | ).toBe(false) 44 | }) 45 | 46 | test('lt', () => { 47 | expect( 48 | dateComparators.lt(new Date('1980-02-14'), new Date('1980-02-12')), 49 | ).toBe(true) 50 | 51 | expect( 52 | dateComparators.lt(new Date('1980-01-01'), new Date('1980-06-24')), 53 | ).toBe(false) 54 | }) 55 | 56 | test('lte', () => { 57 | expect( 58 | dateComparators.lte(new Date('1980-02-14'), new Date('1980-02-12')), 59 | ).toBe(true) 60 | expect( 61 | dateComparators.lte(new Date('1980-01-01'), new Date('1980-01-01')), 62 | ).toBe(true) 63 | 64 | expect( 65 | dateComparators.lte(new Date('1980-01-01'), new Date('1980-06-24')), 66 | ).toBe(false) 67 | }) 68 | -------------------------------------------------------------------------------- /test/comparators/number-comparators.test.ts: -------------------------------------------------------------------------------- 1 | import { numberComparators } from '../../src/comparators/number' 2 | 3 | test('equals', () => { 4 | expect(numberComparators.equals(1, 1)).toEqual(true) 5 | expect(numberComparators.equals(234, 234)).toEqual(true) 6 | expect(numberComparators.equals(2, 5)).toEqual(false) 7 | }) 8 | 9 | test('notEquals', () => { 10 | expect(numberComparators.notEquals(2, 5)).toEqual(true) 11 | expect(numberComparators.notEquals(0, 10)).toEqual(true) 12 | expect(numberComparators.notEquals(1, 1)).toEqual(false) 13 | }) 14 | 15 | test('between', () => { 16 | expect(numberComparators.between([5, 10], 7)).toEqual(true) 17 | expect(numberComparators.between([5, 10], 5)).toEqual(true) 18 | expect(numberComparators.between([5, 10], 7)).toEqual(true) 19 | expect(numberComparators.between([5, 10], 24)).toEqual(false) 20 | }) 21 | 22 | test('notBetween', () => { 23 | expect(numberComparators.notBetween([5, 10], 4)).toEqual(true) 24 | expect(numberComparators.notBetween([5, 10], 11)).toEqual(true) 25 | expect(numberComparators.notBetween([5, 10], 5)).toEqual(false) 26 | expect(numberComparators.notBetween([5, 10], 10)).toEqual(false) 27 | }) 28 | 29 | test('gt', () => { 30 | expect(numberComparators.gt(2, 5)).toEqual(true) 31 | expect(numberComparators.gt(9, 20)).toEqual(true) 32 | expect(numberComparators.gt(20, 20)).toEqual(false) 33 | }) 34 | 35 | test('gte', () => { 36 | expect(numberComparators.gte(2, 5)).toEqual(true) 37 | expect(numberComparators.gte(9, 20)).toEqual(true) 38 | expect(numberComparators.gte(20, 20)).toEqual(true) 39 | expect(numberComparators.gte(4, 2)).toEqual(false) 40 | }) 41 | 42 | test('lt', () => { 43 | expect(numberComparators.lt(5, 2)).toEqual(true) 44 | expect(numberComparators.lt(20, 9)).toEqual(true) 45 | expect(numberComparators.lt(20, 20)).toEqual(false) 46 | expect(numberComparators.lt(5, 20)).toEqual(false) 47 | }) 48 | 49 | test('lte', () => { 50 | expect(numberComparators.lte(5, 2)).toEqual(true) 51 | expect(numberComparators.lte(20, 9)).toEqual(true) 52 | expect(numberComparators.lte(20, 20)).toEqual(true) 53 | expect(numberComparators.lte(5, 20)).toEqual(false) 54 | }) 55 | 56 | test('in', () => { 57 | expect(numberComparators.in([5], 5)).toEqual(true) 58 | expect(numberComparators.in([5, 10], 5)).toEqual(true) 59 | expect(numberComparators.in([1, 3, 5], 3)).toEqual(true) 60 | 61 | expect(numberComparators.in([5], 3)).toEqual(false) 62 | expect(numberComparators.in([3, 5], 4)).toEqual(false) 63 | }) 64 | 65 | test('notIn', () => { 66 | expect(numberComparators.notIn([5], 2)).toEqual(true) 67 | expect(numberComparators.notIn([5, 10], 7)).toEqual(true) 68 | expect(numberComparators.notIn([1, 3, 5], 4)).toEqual(true) 69 | 70 | expect(numberComparators.notIn([5], 5)).toEqual(false) 71 | expect(numberComparators.notIn([3, 5], 3)).toEqual(false) 72 | }) 73 | -------------------------------------------------------------------------------- /test/comparators/string-comparators.test.ts: -------------------------------------------------------------------------------- 1 | import { stringComparators } from '../../src/comparators/string' 2 | 3 | test('equals', () => { 4 | expect(stringComparators.equals('foo', 'foo')).toBe(true) 5 | expect(stringComparators.equals('foo', 'bar')).toBe(false) 6 | }) 7 | 8 | test('notEquals', () => { 9 | expect(stringComparators.notEquals('foo', 'bar')).toBe(true) 10 | expect(stringComparators.notEquals('foo', 'foo')).toBe(false) 11 | }) 12 | 13 | test('contains', () => { 14 | expect(stringComparators.contains('foo', 'footer')).toBe(true) 15 | expect(stringComparators.contains('bar', 'abarthe')).toBe(true) 16 | expect(stringComparators.contains('foo', 'nope')).toBe(false) 17 | }) 18 | 19 | test('notContains', () => { 20 | expect(stringComparators.notContains('foo', 'nope')).toBe(true) 21 | expect(stringComparators.notContains('nope', 'foo')).toBe(true) 22 | expect(stringComparators.notContains('foo', 'footer')).toBe(false) 23 | }) 24 | 25 | test('gt', () => { 26 | expect(stringComparators.gt('bar', 'foo')).toEqual(true) 27 | expect(stringComparators.gt('001', '002')).toEqual(true) 28 | expect(stringComparators.gt('foo', 'footer')).toEqual(true) 29 | expect( 30 | stringComparators.gt( 31 | 'c971d070-9b87-5492-9df6-b53091ae3874', 32 | 'c9ac1210-e5e9-5422-acad-1839535989fe', 33 | ), 34 | ).toEqual(true) 35 | expect( 36 | stringComparators.gt( 37 | '2022-01-01T15:00:00.000Z', 38 | '2022-01-01T15:00:00.001Z', 39 | ), 40 | ).toEqual(true) 41 | expect(stringComparators.gt('foo', 'foo')).toEqual(false) 42 | expect( 43 | stringComparators.gt( 44 | 'c9ac1210-e5e9-5422-acad-1839535989fe', 45 | 'c971d070-9b87-5492-9df6-b53091ae3874', 46 | ), 47 | ).toEqual(false) 48 | expect( 49 | stringComparators.gt( 50 | '2022-01-01T15:00:00.000Z', 51 | '2022-01-01T14:00:00.000Z', 52 | ), 53 | ).toEqual(false) 54 | }) 55 | 56 | test('gte', () => { 57 | expect(stringComparators.gte('bar', 'foo')).toEqual(true) 58 | expect(stringComparators.gte('001', '002')).toEqual(true) 59 | expect(stringComparators.gte('foo', 'footer')).toEqual(true) 60 | expect( 61 | stringComparators.gte( 62 | '2022-01-01T15:00:00.000Z', 63 | '2022-01-01T15:00:00.000Z', 64 | ), 65 | ).toEqual(true) 66 | expect( 67 | stringComparators.gte( 68 | '2022-01-01T15:00:00.000Z', 69 | '2022-01-01T14:00:00.000Z', 70 | ), 71 | ).toEqual(false) 72 | expect(stringComparators.gte('footer', 'foot')).toEqual(false) 73 | }) 74 | 75 | test('lt', () => { 76 | expect(stringComparators.lt('foo', 'bar')).toEqual(true) 77 | expect(stringComparators.lt('002', '001')).toEqual(true) 78 | expect(stringComparators.lt('footer', 'foo')).toEqual(true) 79 | expect( 80 | stringComparators.lt( 81 | 'c9ac1210-e5e9-5422-acad-1839535989fe', 82 | 'c971d070-9b87-5492-9df6-b53091ae3874', 83 | ), 84 | ).toEqual(true) 85 | expect( 86 | stringComparators.lt( 87 | '2022-01-01T15:00:00.001Z', 88 | '2022-01-01T15:00:00.000Z', 89 | ), 90 | ).toEqual(true) 91 | expect(stringComparators.lt('abc', 'abc')).toEqual(false) 92 | expect( 93 | stringComparators.lt( 94 | 'c971d070-9b87-5492-9df6-b53091ae3874', 95 | 'c9ac1210-e5e9-5422-acad-1839535989fe', 96 | ), 97 | ).toEqual(false) 98 | expect( 99 | stringComparators.lt( 100 | '2022-01-01T14:00:00.000Z', 101 | '2022-01-01T15:00:00.000Z', 102 | ), 103 | ).toEqual(false) 104 | }) 105 | 106 | test('lte', () => { 107 | expect(stringComparators.lte('foo', 'bar')).toEqual(true) 108 | expect(stringComparators.lte('002', '001')).toEqual(true) 109 | expect(stringComparators.lte('footer', 'foot')).toEqual(true) 110 | expect( 111 | stringComparators.lte( 112 | '2022-01-01T14:00:00.000Z', 113 | '2022-01-01T14:00:00.000Z', 114 | ), 115 | ).toEqual(true) 116 | expect( 117 | stringComparators.lte( 118 | '2022-01-01T14:00:00.000Z', 119 | '2022-01-01T15:00:00.000Z', 120 | ), 121 | ).toEqual(false) 122 | expect(stringComparators.lte('foot', 'footer')).toEqual(false) 123 | }) 124 | 125 | test('in', () => { 126 | expect(stringComparators.in(['a', 'foo'], 'a')).toBe(true) 127 | expect(stringComparators.in(['a', 'foo'], 'foo')).toBe(true) 128 | expect(stringComparators.in(['a', 'foo'], 'antler')).toBe(false) 129 | expect(stringComparators.in(['a', 'foo'], 'footer')).toBe(false) 130 | }) 131 | 132 | test('notIn', () => { 133 | expect(stringComparators.notIn(['a', 'foo'], 'bar')).toBe(true) 134 | expect(stringComparators.notIn(['a', 'foo'], 'footer')).toBe(true) 135 | expect(stringComparators.notIn(['a', 'foo'], 'antler')).toBe(true) 136 | expect(stringComparators.notIn(['a', 'foo'], 'a')).toBe(false) 137 | expect(stringComparators.notIn(['a', 'foo'], 'foo')).toBe(false) 138 | }) 139 | -------------------------------------------------------------------------------- /test/db/drop.test.ts: -------------------------------------------------------------------------------- 1 | import { drop, factory, identity, primaryKey, oneOf } from '@mswjs/data' 2 | 3 | test('drops all records in the database', () => { 4 | const db = factory({ 5 | user: { 6 | id: primaryKey(identity('abc-123')), 7 | }, 8 | }) 9 | 10 | db.user.create() 11 | expect(db.user.getAll()).toHaveLength(1) 12 | 13 | drop(db) 14 | expect(db.user.getAll()).toHaveLength(0) 15 | }) 16 | 17 | test('does nothing when the database is already empty', () => { 18 | const db = factory({ 19 | user: { 20 | id: primaryKey(identity('abc-123')), 21 | }, 22 | }) 23 | 24 | db.user.create() 25 | db.user.delete({ 26 | where: { 27 | id: { 28 | equals: 'abc-123', 29 | }, 30 | }, 31 | }) 32 | 33 | expect(db.user.getAll()).toHaveLength(0) 34 | 35 | drop(db) 36 | expect(db.user.getAll()).toHaveLength(0) 37 | }) 38 | 39 | test('properly cleans up relational properties', () => { 40 | const db = factory({ 41 | user: { 42 | id: primaryKey(identity('abc-123')), 43 | }, 44 | group: { 45 | id: primaryKey(identity('def-456')), 46 | owner: oneOf('user'), 47 | }, 48 | }) 49 | 50 | const user = db.user.create() 51 | db.group.create({ owner: user }) 52 | 53 | expect(db.user.getAll()).toHaveLength(1) 54 | expect(db.group.getAll()).toHaveLength(1) 55 | 56 | drop(db) 57 | expect(db.user.getAll()).toHaveLength(0) 58 | expect(db.group.getAll()).toHaveLength(0) 59 | }) 60 | -------------------------------------------------------------------------------- /test/db/events.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Database, 3 | SerializedEntity, 4 | SERIALIZED_INTERNAL_PROPERTIES_KEY, 5 | DatabaseEventsMap, 6 | } from '../../src/db/Database' 7 | import { createModel } from '../../src/model/createModel' 8 | import { primaryKey } from '../../src/primaryKey' 9 | import { parseModelDefinition } from '../../src/model/parseModelDefinition' 10 | import { ENTITY_TYPE, PRIMARY_KEY } from '../../src/glossary' 11 | 12 | test('emits the "create" event when a new entity is created', async () => { 13 | const dictionary = { 14 | user: { 15 | id: primaryKey(String), 16 | }, 17 | } 18 | 19 | const db = new Database({ 20 | user: dictionary.user, 21 | }) 22 | 23 | const createCallbackPromise = new Promise( 24 | (resolve) => { 25 | db.events.on('create', (...args) => resolve(args)) 26 | }, 27 | ) 28 | 29 | db.create( 30 | 'user', 31 | createModel( 32 | 'user', 33 | dictionary.user, 34 | dictionary, 35 | parseModelDefinition(dictionary, 'user', dictionary.user), 36 | { 37 | id: 'abc-123', 38 | }, 39 | db, 40 | ), 41 | ) 42 | 43 | const [id, modelName, entity, customPrimaryKey] = await createCallbackPromise 44 | expect(id).toEqual(db.id) 45 | expect(modelName).toEqual('user') 46 | expect(entity).toEqual({ 47 | /** 48 | * @note Entity reference in the database event listener 49 | * contains its serialized internal properties. 50 | * This allows for this listener to re-create the entity 51 | * when the data is transferred over other channels 52 | * (i.e. via "BroadcastChannel" which strips object symbols). 53 | */ 54 | [SERIALIZED_INTERNAL_PROPERTIES_KEY]: { 55 | entityType: 'user', 56 | primaryKey: 'id', 57 | }, 58 | [ENTITY_TYPE]: 'user', 59 | [PRIMARY_KEY]: 'id', 60 | id: 'abc-123', 61 | } as SerializedEntity) 62 | expect(customPrimaryKey).toBeUndefined() 63 | }) 64 | 65 | test('emits the "update" event when an existing entity is updated', async () => { 66 | const dictionary = { 67 | user: { 68 | id: primaryKey(String), 69 | firstName: String, 70 | }, 71 | } 72 | 73 | const db = new Database({ 74 | user: dictionary.user, 75 | }) 76 | 77 | const updateCallbackPromise = new Promise( 78 | (resolve) => { 79 | db.events.on('update', (...args) => resolve(args)) 80 | }, 81 | ) 82 | 83 | db.create( 84 | 'user', 85 | createModel( 86 | 'user', 87 | dictionary.user, 88 | dictionary, 89 | parseModelDefinition(dictionary, 'user', dictionary.user), 90 | { id: 'abc-123', firstName: 'John' }, 91 | db, 92 | ), 93 | ) 94 | db.update( 95 | 'user', 96 | db.getModel('user').get('abc-123')!, 97 | createModel( 98 | 'user', 99 | dictionary.user, 100 | dictionary, 101 | parseModelDefinition(dictionary, 'user', dictionary.user), 102 | { id: 'def-456', firstName: 'Kate' }, 103 | db, 104 | ), 105 | ) 106 | 107 | const [id, modelName, prevEntity, nextEntity] = await updateCallbackPromise 108 | expect(id).toEqual(db.id) 109 | expect(modelName).toEqual('user') 110 | expect(prevEntity).toEqual({ 111 | [SERIALIZED_INTERNAL_PROPERTIES_KEY]: { 112 | entityType: 'user', 113 | primaryKey: 'id', 114 | }, 115 | [ENTITY_TYPE]: 'user', 116 | [PRIMARY_KEY]: 'id', 117 | id: 'abc-123', 118 | firstName: 'John', 119 | } as SerializedEntity) 120 | 121 | expect(nextEntity).toEqual({ 122 | [SERIALIZED_INTERNAL_PROPERTIES_KEY]: { 123 | entityType: 'user', 124 | primaryKey: 'id', 125 | }, 126 | [ENTITY_TYPE]: 'user', 127 | [PRIMARY_KEY]: 'id', 128 | id: 'def-456', 129 | firstName: 'Kate', 130 | } as SerializedEntity) 131 | }) 132 | 133 | test('emits the "delete" event when an existing entity is deleted', async () => { 134 | const dictionary = { 135 | user: { 136 | id: primaryKey(String), 137 | firstName: String, 138 | }, 139 | } 140 | 141 | const db = new Database({ 142 | user: dictionary.user, 143 | }) 144 | 145 | const deleteCallbackPromise = new Promise( 146 | (resolve) => { 147 | db.events.on('delete', (...args) => { 148 | resolve(args) 149 | }) 150 | }, 151 | ) 152 | 153 | db.create( 154 | 'user', 155 | createModel( 156 | 'user', 157 | dictionary.user, 158 | dictionary, 159 | parseModelDefinition(dictionary, 'user', dictionary.user), 160 | { id: 'abc-123', firstName: 'John' }, 161 | db, 162 | ), 163 | ) 164 | db.delete('user', 'abc-123') 165 | 166 | const [id, modelName, deletedPrimaryKey] = await deleteCallbackPromise 167 | expect(id).toEqual(db.id) 168 | expect(modelName).toEqual('user') 169 | expect(deletedPrimaryKey).toEqual('abc-123') 170 | }) 171 | -------------------------------------------------------------------------------- /test/extensions/sync.multiple.runtime.js: -------------------------------------------------------------------------------- 1 | import { factory, primaryKey } from '@mswjs/data' 2 | 3 | window.db = factory({ 4 | user: { 5 | id: primaryKey(String), 6 | firstName: String, 7 | }, 8 | }) 9 | 10 | window.secondDb = factory({ 11 | user: { 12 | id: primaryKey(String), 13 | firstName: String, 14 | }, 15 | }) 16 | -------------------------------------------------------------------------------- /test/extensions/sync.runtime.js: -------------------------------------------------------------------------------- 1 | import { factory, primaryKey } from '@mswjs/data' 2 | 3 | const db = factory({ 4 | user: { 5 | id: primaryKey(String), 6 | firstName: String, 7 | }, 8 | }) 9 | 10 | window.db = db 11 | -------------------------------------------------------------------------------- /test/extensions/sync.test.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path' 2 | import { createBrowser, CreateBrowserApi, pageWith } from 'page-with' 3 | import { FactoryAPI } from '../../src/glossary' 4 | 5 | interface User { 6 | id: string 7 | firstName: string 8 | } 9 | 10 | declare namespace window { 11 | export const db: FactoryAPI<{ user: User }> 12 | export const secondDb: FactoryAPI<{ user: User }> 13 | } 14 | 15 | let browser: CreateBrowserApi 16 | 17 | beforeAll(async () => { 18 | browser = await createBrowser({ 19 | serverOptions: { 20 | webpackConfig: { 21 | resolve: { 22 | alias: { 23 | '@mswjs/data': path.resolve(__dirname, '../..'), 24 | }, 25 | }, 26 | }, 27 | }, 28 | }) 29 | }) 30 | 31 | afterAll(async () => { 32 | await browser.cleanup() 33 | }) 34 | 35 | test('synchornizes entity create across multiple clients', async () => { 36 | const runtime = await pageWith({ 37 | example: path.resolve(__dirname, 'sync.runtime.js'), 38 | }) 39 | const secondPage = await runtime.context.newPage() 40 | await secondPage.goto(runtime.origin) 41 | await runtime.page.bringToFront() 42 | 43 | await runtime.page.evaluate(() => { 44 | window.db.user.create({ 45 | id: 'abc-123', 46 | firstName: 'John', 47 | }) 48 | }) 49 | 50 | expect(await secondPage.evaluate(() => window.db.user.getAll())).toEqual([ 51 | { 52 | id: 'abc-123', 53 | firstName: 'John', 54 | }, 55 | ]) 56 | }) 57 | 58 | test('synchornizes entity update across multiple clients', async () => { 59 | const runtime = await pageWith({ 60 | example: path.resolve(__dirname, 'sync.runtime.js'), 61 | }) 62 | const secondPage = await runtime.context.newPage() 63 | await secondPage.goto(runtime.origin) 64 | await runtime.page.bringToFront() 65 | 66 | await runtime.page.evaluate(() => { 67 | window.db.user.create({ 68 | id: 'abc-123', 69 | firstName: 'John', 70 | }) 71 | }) 72 | 73 | await secondPage.evaluate(() => { 74 | return window.db.user.update({ 75 | where: { 76 | id: { 77 | equals: 'abc-123', 78 | }, 79 | }, 80 | data: { 81 | firstName: 'Kate', 82 | }, 83 | }) 84 | }) 85 | 86 | const expectedUsers = [ 87 | { 88 | id: 'abc-123', 89 | firstName: 'Kate', 90 | }, 91 | ] 92 | expect(await secondPage.evaluate(() => window.db.user.getAll())).toEqual( 93 | expectedUsers, 94 | ) 95 | expect(await runtime.page.evaluate(() => window.db.user.getAll())).toEqual( 96 | expectedUsers, 97 | ) 98 | }) 99 | 100 | test('synchronizes entity delete across multiple clients', async () => { 101 | const runtime = await pageWith({ 102 | example: path.resolve(__dirname, 'sync.runtime.js'), 103 | }) 104 | const secondPage = await runtime.context.newPage() 105 | await secondPage.goto(runtime.origin) 106 | await runtime.page.bringToFront() 107 | 108 | await runtime.page.evaluate(() => { 109 | window.db.user.create({ 110 | id: 'abc-123', 111 | firstName: 'John', 112 | }) 113 | }) 114 | 115 | await secondPage.evaluate(() => { 116 | window.db.user.delete({ 117 | where: { 118 | id: { 119 | equals: 'abc-123', 120 | }, 121 | }, 122 | }) 123 | }) 124 | 125 | expect(await secondPage.evaluate(() => window.db.user.getAll())).toEqual([]) 126 | expect(await runtime.page.evaluate(() => window.db.user.getAll())).toEqual([]) 127 | }) 128 | 129 | test('handles events from multiple database instances separately', async () => { 130 | const runtime = await pageWith({ 131 | example: path.resolve(__dirname, 'sync.multiple.runtime.js'), 132 | }) 133 | const secondPage = await runtime.context.newPage() 134 | await secondPage.goto(runtime.origin) 135 | await runtime.page.bringToFront() 136 | 137 | const john = { 138 | id: 'abc-123', 139 | firstName: 'John', 140 | } 141 | 142 | const kate = { 143 | id: 'def-456', 144 | firstName: 'Kate', 145 | } 146 | 147 | // Create a new user in the first database. 148 | await runtime.page.evaluate(() => { 149 | window.db.user.create({ id: 'abc-123', firstName: 'John' }) 150 | }) 151 | expect(await runtime.page.evaluate(() => window.db.user.getAll())).toEqual([ 152 | john, 153 | ]) 154 | expect(await secondPage.evaluate(() => window.db.user.getAll())).toEqual([ 155 | john, 156 | ]) 157 | 158 | // No entities are created in the second, unrelated database. 159 | expect( 160 | await secondPage.evaluate(() => { 161 | return window.secondDb.user.getAll() 162 | }), 163 | ).toEqual([]) 164 | 165 | await secondPage.evaluate(() => { 166 | window.secondDb.user.create({ id: 'def-456', firstName: 'Kate' }) 167 | }) 168 | 169 | // A new entity created in a different database is synchronized in another client. 170 | expect( 171 | await runtime.page.evaluate(() => window.secondDb.user.getAll()), 172 | ).toEqual([kate]) 173 | 174 | // An unrelated database does not contain a newly created entity. 175 | expect(await runtime.page.evaluate(() => window.db.user.getAll())).toEqual([ 176 | john, 177 | ]) 178 | }) 179 | 180 | test('handles events from multiple databases on different hostnames', async () => { 181 | const firstRuntime = await pageWith({ 182 | example: path.resolve(__dirname, 'sync.runtime.js'), 183 | }) 184 | const secondRuntime = await pageWith({ 185 | example: path.resolve(__dirname, 'sync.multiple.runtime.js'), 186 | }) 187 | expect(firstRuntime.origin).not.toEqual(secondRuntime.origin) 188 | 189 | await firstRuntime.page.evaluate(() => { 190 | window.db.user.create({ id: 'abc-123', firstName: 'John' }) 191 | }) 192 | expect( 193 | await secondRuntime.page.evaluate(() => window.db.user.getAll()), 194 | ).toEqual([]) 195 | 196 | await secondRuntime.page.evaluate(() => { 197 | window.db.user.create({ id: 'def-456', firstName: 'Kate' }) 198 | }) 199 | expect( 200 | await firstRuntime.page.evaluate(() => window.db.user.getAll()), 201 | ).toEqual([ 202 | { 203 | id: 'abc-123', 204 | firstName: 'John', 205 | }, 206 | ]) 207 | }) 208 | -------------------------------------------------------------------------------- /test/jest.d.ts: -------------------------------------------------------------------------------- 1 | import type { Value } from 'lib/glossary' 2 | 3 | type OwnMatcherFn< 4 | Target extends unknown, 5 | Matcher extends (...args: any[]) => void, 6 | > = (...args: Parameters) => void 7 | 8 | export interface OwnMatchers extends Record> { 9 | toHaveRelationalProperty: OwnMatcherFn< 10 | Value, 11 | (propertyName: string, value?: Value | null) => void 12 | > 13 | } 14 | 15 | type CustomExtendMap = { 16 | [MatcherName in keyof OwnMatchers]: OwnMatchers[MatcherName] extends OwnMatcherFn< 17 | infer TargetType, 18 | any 19 | > 20 | ? ( 21 | this: jest.MatcherContext, 22 | received: TargetType, 23 | ...actual: Parameters 24 | ) => ReturnType 25 | : never 26 | } 27 | 28 | declare global { 29 | namespace jest { 30 | interface Matchers extends OwnMatchers {} 31 | interface ExpectExtendMap extends CustomExtendMap {} 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /test/model/count.test.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker' 2 | import { factory, primaryKey } from '../../src' 3 | import { repeat } from '../testUtils' 4 | 5 | test('counts the amount of records for the model', () => { 6 | const db = factory({ 7 | book: { 8 | id: primaryKey(faker.datatype.uuid), 9 | }, 10 | }) 11 | repeat(db.book.create, 12) 12 | 13 | const booksCount = db.book.count() 14 | expect(booksCount).toBe(12) 15 | }) 16 | 17 | test('returns 0 when no records are present', () => { 18 | const db = factory({ 19 | book: { 20 | id: primaryKey(faker.datatype.uuid), 21 | }, 22 | user: { 23 | id: primaryKey(faker.datatype.uuid), 24 | }, 25 | }) 26 | repeat(db.book.create, 5) 27 | 28 | const usersCount = db.user.count() 29 | expect(usersCount).toBe(0) 30 | }) 31 | 32 | test('counts the amount of records that match the query', () => { 33 | const db = factory({ 34 | book: { 35 | id: primaryKey(faker.datatype.uuid), 36 | pagesCount: Number, 37 | }, 38 | }) 39 | db.book.create({ pagesCount: 150 }) 40 | db.book.create({ pagesCount: 335 }) 41 | db.book.create({ pagesCount: 750 }) 42 | 43 | const longBooks = db.book.count({ 44 | where: { 45 | pagesCount: { 46 | gte: 300, 47 | }, 48 | }, 49 | }) 50 | expect(longBooks).toBe(2) 51 | }) 52 | 53 | test('returns 0 when no records match the query', () => { 54 | const db = factory({ 55 | book: { 56 | id: primaryKey(faker.datatype.uuid), 57 | pagesCount: Number, 58 | }, 59 | }) 60 | db.book.create({ pagesCount: 150 }) 61 | db.book.create({ pagesCount: 335 }) 62 | db.book.create({ pagesCount: 750 }) 63 | 64 | const longBooks = db.book.count({ 65 | where: { 66 | pagesCount: { 67 | gte: 1000, 68 | }, 69 | }, 70 | }) 71 | expect(longBooks).toBe(0) 72 | }) 73 | -------------------------------------------------------------------------------- /test/model/create.test-d.ts: -------------------------------------------------------------------------------- 1 | import { factory, primaryKey, nullable } from '../../src' 2 | import { faker } from '@faker-js/faker' 3 | 4 | const db = factory({ 5 | user: { 6 | id: primaryKey(String), 7 | firstName: String, 8 | lastName: nullable(faker.name.lastName), 9 | age: nullable(() => null), 10 | address: { 11 | billing: { 12 | country: String, 13 | }, 14 | }, 15 | }, 16 | company: { 17 | name: primaryKey(String), 18 | }, 19 | }) 20 | 21 | // @ts-expect-error Unknown model name. 22 | db.unknownModel.create() 23 | 24 | db.user.create({ 25 | id: 'abc-123', 26 | // @ts-expect-error Unknown model property. 27 | unknownProp: true, 28 | address: { 29 | billing: { 30 | country: 'us', 31 | }, 32 | }, 33 | }) 34 | 35 | db.user.create({ 36 | // @ts-expect-error Non-nullable properties cannot be instantiated with null. 37 | firstName: null, 38 | }) 39 | 40 | db.user.create({ 41 | address: { 42 | // @ts-expect-error Property "unknown" does not exist on "user.address". 43 | unknown: 'value', 44 | }, 45 | }) 46 | 47 | db.user.create({ 48 | address: { 49 | billing: { 50 | // @ts-expect-error Property "unknown" does not exist on "user.address.billing". 51 | unknown: 'value', 52 | }, 53 | }, 54 | }) 55 | 56 | db.user.create({ 57 | // @ts-expect-error Relational properties must reference 58 | // a valid entity of that model. 59 | country: 'Exact string', 60 | }) 61 | 62 | db.user.create({ 63 | // @ts-expect-error Relational property must reference 64 | // the exact model type ("country"). 65 | country: db.post.create(), 66 | }) 67 | 68 | db.user.create({ 69 | // Any property is optional. 70 | // When not provided, its value getter from the model 71 | // will be executed to get the initial value. 72 | firstName: 'John', 73 | }) 74 | 75 | const user = db.user.create({ 76 | // Nullable properties can have an initialValue of null or the property type 77 | lastName: null, 78 | age: 15, 79 | }) 80 | 81 | // @ts-expect-error lastName property is possibly null 82 | user.lastName.toUpperCase() 83 | 84 | // @ts-expect-error property 'toUpperCase' does not exist on type 'number' 85 | user.age?.toUpperCase() 86 | -------------------------------------------------------------------------------- /test/model/delete.test-d.ts: -------------------------------------------------------------------------------- 1 | import { factory, oneOf, primaryKey } from '../../src' 2 | 3 | const db = factory({ 4 | user: { 5 | id: primaryKey(String), 6 | firstName: String, 7 | age: Number, 8 | createdAt: () => new Date(), 9 | country: oneOf('country'), 10 | address: { 11 | billing: { 12 | country: String, 13 | }, 14 | }, 15 | }, 16 | country: { 17 | code: primaryKey(String), 18 | }, 19 | }) 20 | 21 | db.user.delete({ 22 | // Provide no query to match all entities. 23 | where: {}, 24 | }) 25 | 26 | db.user.delete({ 27 | where: { 28 | id: { 29 | equals: 'abc-123', 30 | // @ts-expect-error Only string comparators are allowed. 31 | gte: 2, 32 | }, 33 | firstName: { 34 | contains: 'John', 35 | }, 36 | age: { 37 | gte: 18, 38 | // @ts-expect-error Only number comparators are allowed. 39 | contains: 'value', 40 | }, 41 | createdAt: { 42 | gte: new Date('2004-01-01'), 43 | }, 44 | }, 45 | }) 46 | 47 | // Delete by a nested property value. 48 | db.user.delete({ 49 | where: { 50 | address: { 51 | billing: { 52 | country: { 53 | equals: 'us', 54 | }, 55 | }, 56 | }, 57 | }, 58 | }) 59 | 60 | db.user.delete({ 61 | where: { 62 | address: { 63 | // @ts-expect-error Property "unknown" does not exist on "user.address". 64 | unknown: 'value', 65 | }, 66 | }, 67 | }) 68 | 69 | db.user.delete({ 70 | where: { 71 | address: { 72 | billing: { 73 | // @ts-expect-error Property "unknown" does not exist on "user.address.billing". 74 | unknown: 'value', 75 | }, 76 | }, 77 | }, 78 | }) 79 | 80 | // Delete by a relational property value. 81 | db.user.delete({ 82 | where: { 83 | country: { 84 | code: { 85 | equals: 'us', 86 | }, 87 | }, 88 | }, 89 | }) 90 | 91 | db.user.delete({ 92 | where: { 93 | // @ts-expect-error Property "unknown" doesn't exist on "user". 94 | unknown: { 95 | equals: 'abc-123', 96 | }, 97 | }, 98 | }) 99 | 100 | db.user.delete({ 101 | where: { 102 | firstName: { 103 | // @ts-expect-error Unknown value comparator. 104 | unknownComparator: '123', 105 | }, 106 | }, 107 | }) 108 | -------------------------------------------------------------------------------- /test/model/delete.test.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker' 2 | import { factory, primaryKey } from '../../src' 3 | import { OperationErrorType } from '../../src/errors/OperationError' 4 | import { getThrownError } from '../testUtils' 5 | 6 | test('deletes a unique entity that matches the query', () => { 7 | const userId = faker.datatype.uuid() 8 | const db = factory({ 9 | user: { 10 | id: primaryKey(faker.datatype.uuid), 11 | firstName: faker.name.findName, 12 | }, 13 | }) 14 | 15 | db.user.create({ firstName: 'Kate' }) 16 | db.user.create({ id: userId, firstName: 'John' }) 17 | db.user.create({ firstName: 'Alice' }) 18 | 19 | const deletedUser = db.user.delete({ 20 | where: { 21 | id: { 22 | equals: userId, 23 | }, 24 | }, 25 | }) 26 | expect(deletedUser).toHaveProperty('id', userId) 27 | expect(deletedUser).toHaveProperty('firstName', 'John') 28 | 29 | const remainingUsers = db.user.getAll() 30 | const remainingUserNames = remainingUsers.map((user) => user.firstName) 31 | expect(remainingUserNames).toEqual(['Kate', 'Alice']) 32 | }) 33 | 34 | test('deletes the first entity that matches the query', () => { 35 | const db = factory({ 36 | user: { 37 | id: primaryKey(faker.datatype.uuid), 38 | firstName: faker.name.firstName, 39 | followersCount: Number, 40 | }, 41 | }) 42 | 43 | db.user.create({ firstName: 'John', followersCount: 10 }) 44 | db.user.create({ firstName: 'Kate', followersCount: 12 }) 45 | db.user.create({ firstName: 'Alice', followersCount: 15 }) 46 | 47 | const deletedUser = db.user.delete({ 48 | where: { 49 | followersCount: { 50 | gt: 10, 51 | }, 52 | }, 53 | }) 54 | expect(deletedUser).toHaveProperty('firstName', 'Kate') 55 | 56 | const deletedUserSearch = db.user.findFirst({ 57 | where: { 58 | firstName: { 59 | equals: 'Kate', 60 | }, 61 | }, 62 | }) 63 | expect(deletedUserSearch).toBeNull() 64 | 65 | const allUsers = db.user.getAll() 66 | const userNames = allUsers.map((user) => user.firstName) 67 | expect(userNames).toEqual(['John', 'Alice']) 68 | }) 69 | 70 | test('throws an exception when no entities matches the query in strict mode', () => { 71 | const db = factory({ 72 | user: { 73 | id: primaryKey(faker.datatype.uuid), 74 | }, 75 | }) 76 | db.user.create() 77 | db.user.create() 78 | 79 | const error = getThrownError(() => { 80 | db.user.delete({ 81 | where: { 82 | id: { 83 | equals: 'abc-123', 84 | }, 85 | }, 86 | strict: true, 87 | }) 88 | }) 89 | 90 | expect(error).toHaveProperty('name', 'OperationError') 91 | expect(error).toHaveProperty('type', OperationErrorType.EntityNotFound) 92 | expect(error).toHaveProperty( 93 | 'message', 94 | 'Failed to execute "delete" on the "user" model: no entity found matching the query "{"id":{"equals":"abc-123"}}".', 95 | ) 96 | }) 97 | 98 | test('does nothing when no entity matches the query', () => { 99 | const db = factory({ 100 | user: { 101 | id: primaryKey(faker.datatype.uuid), 102 | firstName: faker.name.firstName, 103 | }, 104 | }) 105 | db.user.create({ firstName: 'Kate' }) 106 | db.user.create({ firstName: 'Alice' }) 107 | db.user.create({ firstName: 'John' }) 108 | 109 | const deletedUser = db.user.delete({ 110 | where: { 111 | id: { 112 | equals: 'abc-123', 113 | }, 114 | }, 115 | }) 116 | expect(deletedUser).toBeNull() 117 | 118 | const allUsers = db.user.getAll() 119 | expect(allUsers).toHaveLength(3) 120 | 121 | const userNames = allUsers.map((user) => user.firstName) 122 | expect(userNames).toEqual(['Kate', 'Alice', 'John']) 123 | }) 124 | -------------------------------------------------------------------------------- /test/model/deleteMany.test-d.ts: -------------------------------------------------------------------------------- 1 | import { factory, primaryKey, oneOf } from '../../src' 2 | 3 | const db = factory({ 4 | user: { 5 | id: primaryKey(String), 6 | firstName: String, 7 | age: Number, 8 | createdAt: () => new Date(), 9 | country: oneOf('country'), 10 | address: { 11 | billing: { 12 | country: String, 13 | }, 14 | }, 15 | }, 16 | country: { 17 | code: primaryKey(String), 18 | }, 19 | }) 20 | 21 | db.user.deleteMany({ 22 | // Providing no query criteria matches all entities. 23 | where: {}, 24 | }) 25 | 26 | db.user.deleteMany({ 27 | where: { 28 | id: { 29 | equals: 'abc-123', 30 | }, 31 | firstName: { 32 | contains: 'John', 33 | }, 34 | age: { 35 | gte: 18, 36 | }, 37 | createdAt: { 38 | gte: new Date('2004-01-01'), 39 | }, 40 | }, 41 | }) 42 | 43 | // Delete multiple entities by a nested property value. 44 | db.user.deleteMany({ 45 | where: { 46 | address: { 47 | billing: { 48 | country: { 49 | equals: 'us', 50 | }, 51 | }, 52 | }, 53 | }, 54 | }) 55 | 56 | db.user.deleteMany({ 57 | where: { 58 | address: { 59 | // @ts-expect-error Property "unknown" does not exist on "user.address". 60 | unknown: 'value', 61 | }, 62 | }, 63 | }) 64 | 65 | db.user.deleteMany({ 66 | where: { 67 | address: { 68 | billing: { 69 | // @ts-expect-error Property "unknown" does not exist on "user.address.billing". 70 | unknown: 'value', 71 | }, 72 | }, 73 | }, 74 | }) 75 | 76 | // Delete multiple entities by their relational property value. 77 | db.user.deleteMany({ 78 | where: { 79 | country: { 80 | code: { 81 | equals: 'us', 82 | }, 83 | }, 84 | }, 85 | }) 86 | 87 | db.user.deleteMany({ 88 | where: { 89 | // @ts-expect-error Property "unknown" doesn't exist on "user". 90 | unknown: { 91 | equals: 'abc-123', 92 | }, 93 | }, 94 | }) 95 | 96 | db.user.deleteMany({ 97 | where: { 98 | firstName: { 99 | // @ts-expect-error Unknown value comparator. 100 | unknownComparator: '123', 101 | }, 102 | }, 103 | }) 104 | -------------------------------------------------------------------------------- /test/model/deleteMany.test.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker' 2 | import { factory, primaryKey } from '../../src' 3 | import { OperationErrorType } from '../../src/errors/OperationError' 4 | import { getThrownError } from '../testUtils' 5 | 6 | test('deletes all entites that match the query', () => { 7 | const db = factory({ 8 | user: { 9 | id: primaryKey(faker.datatype.uuid), 10 | firstName: faker.name.firstName, 11 | followersCount: faker.datatype.number, 12 | }, 13 | }) 14 | 15 | db.user.create({ 16 | firstName: 'John', 17 | followersCount: 10, 18 | }) 19 | db.user.create({ 20 | firstName: 'Kate', 21 | followersCount: 12, 22 | }) 23 | db.user.create({ 24 | firstName: 'Alice', 25 | followersCount: 18, 26 | }) 27 | db.user.create({ 28 | firstName: 'Joseph', 29 | followersCount: 24, 30 | }) 31 | 32 | const deletedUsers = db.user.deleteMany({ 33 | where: { 34 | followersCount: { 35 | between: [11, 20], 36 | }, 37 | }, 38 | })! 39 | expect(deletedUsers).toHaveLength(2) 40 | 41 | const deletedUserNames = deletedUsers.map((user) => user.firstName) 42 | expect(deletedUserNames).toEqual(['Kate', 'Alice']) 43 | 44 | const queriedDeletedUsers = db.user.findMany({ 45 | where: { 46 | followersCount: { 47 | between: [11, 20], 48 | }, 49 | }, 50 | }) 51 | expect(queriedDeletedUsers).toEqual([]) 52 | 53 | const restUsers = db.user.getAll() 54 | expect(restUsers).toHaveLength(2) 55 | 56 | const restUserNames = restUsers.map((user) => user.firstName) 57 | expect(restUserNames).toEqual(['John', 'Joseph']) 58 | }) 59 | 60 | test('throws an exception when no entities match the query in a strict mode', () => { 61 | const db = factory({ 62 | user: { 63 | id: primaryKey(faker.datatype.uuid), 64 | }, 65 | }) 66 | db.user.create() 67 | db.user.create() 68 | 69 | const error = getThrownError(() => { 70 | db.user.deleteMany({ 71 | where: { 72 | id: { 73 | in: ['abc-123', 'def-456'], 74 | }, 75 | }, 76 | strict: true, 77 | }) 78 | }) 79 | 80 | expect(error).toHaveProperty('name', 'OperationError') 81 | expect(error).toHaveProperty('type', OperationErrorType.EntityNotFound) 82 | expect(error).toHaveProperty( 83 | 'message', 84 | 'Failed to execute "deleteMany" on the "user" model: no entities found matching the query "{"id":{"in":["abc-123","def-456"]}}".', 85 | ) 86 | }) 87 | 88 | test('does nothing when no entities match the query', () => { 89 | const db = factory({ 90 | user: { 91 | id: primaryKey(faker.datatype.uuid), 92 | firstName: faker.name.firstName, 93 | followersCount: faker.datatype.number, 94 | }, 95 | }) 96 | 97 | db.user.create({ 98 | firstName: 'John', 99 | followersCount: 10, 100 | }) 101 | db.user.create({ 102 | firstName: 'Kate', 103 | followersCount: 12, 104 | }) 105 | 106 | const deletedUsers = db.user.deleteMany({ 107 | where: { 108 | followersCount: { 109 | gte: 1000, 110 | }, 111 | }, 112 | }) 113 | expect(deletedUsers).toBeNull() 114 | 115 | const restUsers = db.user.getAll() 116 | expect(restUsers).toHaveLength(2) 117 | 118 | const restUserNames = restUsers.map((user) => user.firstName) 119 | expect(restUserNames).toEqual(['John', 'Kate']) 120 | }) 121 | -------------------------------------------------------------------------------- /test/model/findFirst.test-d.ts: -------------------------------------------------------------------------------- 1 | import { factory, oneOf, primaryKey } from '../../src' 2 | 3 | const db = factory({ 4 | user: { 5 | id: primaryKey(String), 6 | firstName: String, 7 | createdAt: () => new Date(), 8 | country: oneOf('country'), 9 | }, 10 | country: { 11 | id: primaryKey(String), 12 | name: String, 13 | }, 14 | post: { 15 | id: primaryKey(String), 16 | title: String, 17 | }, 18 | }) 19 | 20 | db.user.findFirst({ 21 | where: { 22 | // @ts-expect-error Unknown model property. 23 | unknown: { 24 | equals: 2, 25 | }, 26 | }, 27 | }) 28 | 29 | db.user.findFirst({ 30 | where: { 31 | id: { 32 | equals: 'abc-123', 33 | // @ts-expect-error Only string comparators are allowed. 34 | gte: 2, 35 | }, 36 | }, 37 | }) 38 | -------------------------------------------------------------------------------- /test/model/findFirst.test.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker' 2 | import { factory, primaryKey } from '../../src' 3 | import { OperationErrorType } from '../../src/errors/OperationError' 4 | import { identity } from '../../src/utils/identity' 5 | import { getThrownError } from '../testUtils' 6 | 7 | test('returns the only matching entity', () => { 8 | const userId = faker.datatype.uuid() 9 | const db = factory({ 10 | user: { 11 | id: primaryKey(identity(userId)), 12 | }, 13 | }) 14 | 15 | db.user.create() 16 | 17 | const user = db.user.findFirst({ 18 | where: { 19 | id: { 20 | equals: userId, 21 | }, 22 | }, 23 | }) 24 | expect(user).toHaveProperty('id', userId) 25 | }) 26 | 27 | test('returns the first entity among multiple matching entities', () => { 28 | const db = factory({ 29 | user: { 30 | id: primaryKey(faker.datatype.uuid), 31 | followersCount: Number, 32 | }, 33 | }) 34 | 35 | db.user.create({ followersCount: 10 }) 36 | db.user.create({ followersCount: 12 }) 37 | db.user.create({ followersCount: 15 }) 38 | 39 | const user = db.user.findFirst({ 40 | where: { 41 | followersCount: { 42 | gt: 10, 43 | }, 44 | }, 45 | }) 46 | expect(user).toHaveProperty('followersCount', 12) 47 | }) 48 | 49 | test('throws an exception when no results in strict mode', () => { 50 | const db = factory({ 51 | user: { 52 | id: primaryKey(faker.datatype.uuid), 53 | }, 54 | }) 55 | db.user.create() 56 | 57 | const error = getThrownError(() => { 58 | db.user.findFirst({ 59 | where: { 60 | id: { 61 | equals: 'abc-123', 62 | }, 63 | }, 64 | strict: true, 65 | }) 66 | }) 67 | 68 | expect(error).toHaveProperty('name', 'OperationError') 69 | expect(error).toHaveProperty('type', OperationErrorType.EntityNotFound) 70 | expect(error).toHaveProperty( 71 | 'message', 72 | `Failed to execute "findFirst" on the "user" model: no entity found matching the query "{"id":{"equals":"abc-123"}}".`, 73 | ) 74 | }) 75 | 76 | test('returns null when found no matching entities', () => { 77 | const db = factory({ 78 | user: { 79 | id: primaryKey(faker.datatype.uuid), 80 | }, 81 | }) 82 | db.user.create() 83 | 84 | const user = db.user.findFirst({ 85 | where: { 86 | id: { 87 | equals: 'abc-123', 88 | }, 89 | }, 90 | }) 91 | expect(user).toBeNull() 92 | }) 93 | -------------------------------------------------------------------------------- /test/model/findMany.test-d.ts: -------------------------------------------------------------------------------- 1 | import { factory, oneOf, primaryKey } from '../../src' 2 | 3 | const db = factory({ 4 | user: { 5 | id: primaryKey(String), 6 | firstName: String, 7 | age: Number, 8 | createdAt: () => new Date(), 9 | country: oneOf('country'), 10 | address: { 11 | billing: { 12 | street: String, 13 | code: String, 14 | }, 15 | }, 16 | }, 17 | country: { 18 | code: primaryKey(String), 19 | }, 20 | }) 21 | 22 | db.user.findMany({ 23 | where: { 24 | id: { 25 | equals: 'abc-123', 26 | }, 27 | firstName: { 28 | contains: 'John', 29 | }, 30 | createdAt: { 31 | gte: new Date('2020-01-01'), 32 | }, 33 | country: { 34 | code: { 35 | equals: 'us', 36 | }, 37 | }, 38 | }, 39 | }) 40 | 41 | db.user.findMany({ 42 | where: { 43 | address: { 44 | billing: { 45 | code: { 46 | equals: 'us', 47 | }, 48 | }, 49 | }, 50 | }, 51 | }) 52 | 53 | db.user.findMany({ 54 | where: { 55 | address: { 56 | // @ts-expect-error Property "unknown" doesn't exist on "user.address". 57 | unknown: {}, 58 | }, 59 | }, 60 | }) 61 | 62 | db.user.findMany({ 63 | where: { 64 | address: { 65 | billing: { 66 | // @ts-expect-error Property "unknown" doesn't exist on "user.address". 67 | unknown: {}, 68 | }, 69 | }, 70 | }, 71 | }) 72 | 73 | db.user.findMany({ 74 | where: { 75 | // @ts-expect-error Unknown model property. 76 | unknown: { 77 | equals: 2, 78 | }, 79 | }, 80 | }) 81 | 82 | db.user.findMany({ 83 | where: { 84 | id: { 85 | equals: 'abc-123', 86 | // @ts-expect-error Only string comparators are allowed. 87 | gte: 2, 88 | }, 89 | }, 90 | }) 91 | 92 | /** 93 | * Sorting. 94 | */ 95 | // Single-criteria sort by a primitive value. 96 | db.user.findMany({ 97 | orderBy: { 98 | id: 'asc', 99 | }, 100 | }) 101 | 102 | db.user.findMany({ 103 | orderBy: { 104 | // @ts-expect-error Unknown property name. 105 | unknown: 'asc', 106 | }, 107 | }) 108 | 109 | db.user.findMany({ 110 | // @ts-expect-error Unknown sort direction. 111 | orderBy: { 112 | id: 'any', 113 | }, 114 | }) 115 | 116 | // Single-criteria sort by a nested value. 117 | db.user.findMany({ 118 | orderBy: { 119 | address: { 120 | billing: { 121 | code: 'desc', 122 | }, 123 | }, 124 | }, 125 | }) 126 | 127 | db.user.findMany({ 128 | orderBy: { 129 | address: { 130 | // @ts-expect-error Unknown property name. 131 | unknown: 'asc', 132 | }, 133 | }, 134 | }) 135 | 136 | db.user.findMany({ 137 | // @ts-expect-error Unknown property name "billing.unknown". 138 | orderBy: { 139 | address: { 140 | billing: { 141 | unknown: 'asc', 142 | }, 143 | }, 144 | }, 145 | }) 146 | 147 | // Single-criteria sort by a relational value. 148 | db.user.findMany({ 149 | orderBy: { 150 | country: { 151 | code: 'asc', 152 | }, 153 | }, 154 | }) 155 | 156 | // Multi-criteria sort by primitive values. 157 | db.user.findMany({ 158 | orderBy: [ 159 | { 160 | id: 'asc', 161 | }, 162 | { 163 | age: 'desc', 164 | }, 165 | ], 166 | }) 167 | 168 | // Multi-criteria sort by nested values. 169 | db.user.findMany({ 170 | orderBy: [ 171 | { 172 | address: { 173 | billing: { 174 | street: 'asc', 175 | }, 176 | }, 177 | }, 178 | { 179 | address: { 180 | billing: { 181 | code: 'desc', 182 | }, 183 | }, 184 | }, 185 | ], 186 | }) 187 | 188 | // One key restriction. 189 | db.user.findMany({ 190 | // @ts-expect-error Cannot specify multiple order keys. 191 | orderBy: { 192 | id: 'asc', 193 | age: 'desc', 194 | }, 195 | }) 196 | 197 | db.user.findMany({ 198 | // @ts-expect-error Cannot specify multiple order keys. 199 | orderBy: { 200 | address: { 201 | billing: { 202 | code: 'asc', 203 | street: 'desc', 204 | }, 205 | }, 206 | }, 207 | }) 208 | 209 | db.user.findMany({ 210 | orderBy: [ 211 | // @ts-expect-error Cannot specify multiple order keys. 212 | { 213 | id: 'asc', 214 | age: 'desc', 215 | }, 216 | ], 217 | }) 218 | -------------------------------------------------------------------------------- /test/model/findMany.test.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker' 2 | import { factory, primaryKey } from '../../src' 3 | import { OperationErrorType } from '../../src/errors/OperationError' 4 | import { getThrownError } from '../testUtils' 5 | 6 | test('returns all matching entities', () => { 7 | const db = factory({ 8 | user: { 9 | id: primaryKey(faker.datatype.uuid), 10 | followersCount: Number, 11 | }, 12 | }) 13 | 14 | db.user.create({ followersCount: 10 }) 15 | db.user.create({ followersCount: 12 }) 16 | db.user.create({ followersCount: 15 }) 17 | 18 | const users = db.user.findMany({ 19 | where: { 20 | followersCount: { 21 | gt: 10, 22 | }, 23 | }, 24 | }) 25 | expect(users).toHaveLength(2) 26 | const usersFollowersCount = users.map((user) => user.followersCount) 27 | expect(usersFollowersCount).toEqual([12, 15]) 28 | }) 29 | 30 | test('throws an exception when no results in strict mode', () => { 31 | const db = factory({ 32 | user: { 33 | id: primaryKey(faker.datatype.uuid), 34 | }, 35 | }) 36 | db.user.create() 37 | db.user.create() 38 | 39 | const error = getThrownError(() => { 40 | db.user.findMany({ 41 | where: { 42 | id: { 43 | in: ['abc-123', 'def-456'], 44 | }, 45 | }, 46 | strict: true, 47 | }) 48 | }) 49 | 50 | expect(error).toHaveProperty('name', 'OperationError') 51 | expect(error).toHaveProperty('type', OperationErrorType.EntityNotFound) 52 | expect(error).toHaveProperty( 53 | 'message', 54 | 'Failed to execute "findMany" on the "user" model: no entities found matching the query "{"id":{"in":["abc-123","def-456"]}}".', 55 | ) 56 | }) 57 | 58 | test('returns an empty array when not found matching entities', () => { 59 | const db = factory({ 60 | user: { 61 | id: primaryKey(faker.datatype.uuid), 62 | followersCount: Number, 63 | }, 64 | }) 65 | 66 | db.user.create({ followersCount: 10 }) 67 | db.user.create({ followersCount: 12 }) 68 | db.user.create({ followersCount: 15 }) 69 | 70 | const users = db.user.findMany({ 71 | where: { 72 | followersCount: { 73 | gte: 1000, 74 | }, 75 | }, 76 | }) 77 | expect(users).toEqual([]) 78 | }) 79 | -------------------------------------------------------------------------------- /test/model/getAll.test-d.ts: -------------------------------------------------------------------------------- 1 | import { factory, manyOf, primaryKey } from '../../src' 2 | 3 | const db = factory({ 4 | user: { 5 | id: primaryKey(String), 6 | firstName: String, 7 | address: { 8 | billing: { 9 | country: String, 10 | }, 11 | }, 12 | posts: manyOf('post'), 13 | }, 14 | post: { 15 | id: primaryKey(String), 16 | title: String, 17 | }, 18 | }) 19 | 20 | const allUsers = db.user.getAll() 21 | 22 | allUsers[0].id 23 | allUsers[0].firstName 24 | allUsers[0].address.billing?.country 25 | 26 | // Relational properties. 27 | const user = allUsers[0] 28 | const { posts = [] } = user 29 | posts[0].id 30 | posts[0].title 31 | 32 | // @ts-expect-error Property "unknown" doesn't exist on "post". 33 | posts[0].unknown 34 | 35 | // @ts-expect-error Property "unknown" doesn't exist on "user". 36 | allUsers[0].unknown 37 | -------------------------------------------------------------------------------- /test/model/getAll.test.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker' 2 | import { factory, primaryKey } from '../../src' 3 | 4 | test('returns all entities', () => { 5 | const db = factory({ 6 | user: { 7 | id: primaryKey(faker.datatype.uuid), 8 | firstName: String, 9 | }, 10 | }) 11 | 12 | db.user.create({ firstName: 'John' }) 13 | db.user.create({ firstName: 'Kate' }) 14 | db.user.create({ firstName: 'Alice' }) 15 | 16 | const allUsers = db.user.getAll() 17 | expect(allUsers).toHaveLength(3) 18 | 19 | const userNames = allUsers.map((user) => user.firstName) 20 | expect(userNames).toEqual(['John', 'Kate', 'Alice']) 21 | }) 22 | 23 | test('returns an empty list when found no entities', () => { 24 | const db = factory({ 25 | user: { 26 | id: primaryKey(faker.datatype.uuid), 27 | firstName: String, 28 | }, 29 | }) 30 | 31 | const allUsers = db.user.getAll() 32 | expect(allUsers).toEqual([]) 33 | }) 34 | -------------------------------------------------------------------------------- /test/model/relationalProperties.test-d.ts: -------------------------------------------------------------------------------- 1 | import { factory, manyOf, oneOf, primaryKey, nullable } from '@mswjs/data' 2 | 3 | const db = factory({ 4 | user: { 5 | id: primaryKey(String), 6 | posts: manyOf('post'), 7 | }, 8 | post: { 9 | id: primaryKey(String), 10 | text: String, 11 | author: oneOf('user'), 12 | reply: nullable(oneOf('post')), 13 | likedBy: nullable(manyOf('user')), 14 | }, 15 | }) 16 | 17 | const user = db.user.create() 18 | const post = db.post.create() 19 | 20 | // @ts-expect-error author is potentially undefined 21 | post.author.id 22 | 23 | // @ts-expect-error reply is potentially null 24 | post.reply.id 25 | 26 | // @ts-expect-error likedBy is potentially null 27 | post.likedBy.length 28 | 29 | // nullable oneOf relationships are not potentially undefined, only null 30 | if (post.reply !== null) { 31 | // we can call reply.text.toUpperCase after excluding null from types 32 | post.reply.text.toUpperCase() 33 | } 34 | 35 | // nullable manyOf relationships are not potentially undefined, only null 36 | if (post.likedBy !== null) { 37 | // we can call likedBy.pop after excluding null from types 38 | post.likedBy.pop() 39 | } 40 | -------------------------------------------------------------------------------- /test/model/relationalProperties.test.ts: -------------------------------------------------------------------------------- 1 | import { oneOf, primaryKey, nullable } from '../../src' 2 | import { 3 | Relation, 4 | RelationKind, 5 | RelationsList, 6 | } from '../../src/relations/Relation' 7 | import { defineRelationalProperties } from '../../src/model/defineRelationalProperties' 8 | import { testFactory } from '../../test/testUtils' 9 | 10 | it('marks relational properties as enumerable', () => { 11 | const { db, dictionary, databaseInstance } = testFactory({ 12 | user: { 13 | id: primaryKey(String), 14 | name: String, 15 | }, 16 | post: { 17 | id: primaryKey(String), 18 | title: String, 19 | author: oneOf('user'), 20 | }, 21 | }) 22 | 23 | const user = db.user.create({ 24 | id: 'user-1', 25 | name: 'John Maverick', 26 | }) 27 | const post = db.post.create({ 28 | id: 'post-1', 29 | title: 'Test Post', 30 | }) 31 | 32 | const relations: RelationsList = [ 33 | { 34 | propertyPath: ['author'], 35 | relation: new Relation({ 36 | to: 'user', 37 | kind: RelationKind.OneOf, 38 | }), 39 | }, 40 | ] 41 | 42 | defineRelationalProperties( 43 | post, 44 | { 45 | author: user, 46 | }, 47 | relations, 48 | dictionary, 49 | databaseInstance, 50 | ) 51 | 52 | expect(post.propertyIsEnumerable('author')).toEqual(true) 53 | }) 54 | 55 | it('marks nullable relational properties as enumerable', () => { 56 | const { db, dictionary, databaseInstance } = testFactory({ 57 | user: { 58 | id: primaryKey(String), 59 | name: String, 60 | }, 61 | post: { 62 | id: primaryKey(String), 63 | title: String, 64 | author: nullable(oneOf('user')), 65 | }, 66 | }) 67 | 68 | const user = db.user.create({ 69 | id: 'user-1', 70 | name: 'John Maverick', 71 | }) 72 | 73 | const post = db.post.create({ 74 | id: 'post-1', 75 | title: 'Test Post', 76 | }) 77 | 78 | const relations: RelationsList = [ 79 | { 80 | propertyPath: ['author'], 81 | relation: new Relation({ 82 | to: 'user', 83 | kind: RelationKind.OneOf, 84 | }), 85 | }, 86 | ] 87 | 88 | defineRelationalProperties( 89 | post, 90 | { 91 | author: user, 92 | }, 93 | relations, 94 | dictionary, 95 | databaseInstance, 96 | ) 97 | 98 | expect(post.propertyIsEnumerable('author')).toEqual(true) 99 | }) 100 | -------------------------------------------------------------------------------- /test/model/toGraphQLSchema.test.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker' 2 | import { printSchema } from 'graphql' 3 | import { factory, primaryKey } from '../../src' 4 | 5 | const db = factory({ 6 | user: { 7 | id: primaryKey(faker.datatype.uuid), 8 | firstName: String, 9 | age: Number, 10 | }, 11 | }) 12 | 13 | test('generates a graphql schema', () => { 14 | const schema = db.user.toGraphQLSchema() 15 | expect(printSchema(schema)).toMatchInlineSnapshot(` 16 | "type Query { 17 | user(where: UserQueryInput): User 18 | users(take: Int, skip: Int, cursor: ID, where: UserQueryInput): [User] 19 | } 20 | 21 | type User { 22 | id: ID 23 | firstName: String 24 | age: Int 25 | } 26 | 27 | input UserQueryInput { 28 | id: IdQueryType 29 | firstName: StringQueryType 30 | age: IntQueryType 31 | } 32 | 33 | input IdQueryType { 34 | equals: ID 35 | notEquals: ID 36 | contains: ID 37 | notContains: ID 38 | gt: ID 39 | gte: ID 40 | lt: ID 41 | lte: ID 42 | in: [ID] 43 | notIn: [ID] 44 | } 45 | 46 | input StringQueryType { 47 | equals: String 48 | notEquals: String 49 | contains: String 50 | notContains: String 51 | gt: String 52 | gte: String 53 | lt: String 54 | lte: String 55 | in: [String] 56 | notIn: [String] 57 | } 58 | 59 | input IntQueryType { 60 | equals: Int 61 | notEquals: Int 62 | between: [Int] 63 | notBetween: [Int] 64 | gt: Int 65 | gte: Int 66 | lt: Int 67 | lte: Int 68 | in: [Int] 69 | notIn: [Int] 70 | } 71 | 72 | type Mutation { 73 | createUser(data: UserInput): User 74 | updateUser(where: UserQueryInput, data: UserInput): User 75 | updateUsers(where: UserQueryInput, data: UserInput): [User] 76 | deleteUser(where: UserQueryInput): User 77 | deleteUsers(where: UserQueryInput): [User] 78 | } 79 | 80 | input UserInput { 81 | id: ID 82 | firstName: String 83 | age: Int 84 | }" 85 | `) 86 | }) 87 | -------------------------------------------------------------------------------- /test/model/toRestHandlers/primary-key-number.test.ts: -------------------------------------------------------------------------------- 1 | // @jest-environment jsdom 2 | import { setupServer } from 'msw/node' 3 | import { factory, drop, primaryKey } from '../../../src' 4 | 5 | const db = factory({ 6 | todo: { 7 | id: primaryKey(Number), 8 | title: String, 9 | }, 10 | }) 11 | 12 | const server = setupServer() 13 | 14 | beforeAll(() => { 15 | server.listen() 16 | }) 17 | 18 | afterEach(() => { 19 | drop(db) 20 | server.resetHandlers() 21 | }) 22 | 23 | afterAll(() => { 24 | server.close() 25 | }) 26 | 27 | it('generates CRUD request handlers for the model', () => { 28 | const userHandlers = db.todo.toHandlers('rest') 29 | const displayRoutes = userHandlers.map((handler) => handler.info.header) 30 | 31 | expect(displayRoutes).toEqual([ 32 | 'GET /todos', 33 | 'GET /todos/:id', 34 | 'POST /todos', 35 | 'PUT /todos/:id', 36 | 'DELETE /todos/:id', 37 | ]) 38 | }) 39 | 40 | describe('GET /todos/:id', () => { 41 | it('handles a GET request to get a single entity', async () => { 42 | server.use(...db.todo.toHandlers('rest', 'http://localhost')) 43 | 44 | db.todo.create({ 45 | id: 123, 46 | title: 'Todo 1', 47 | }) 48 | db.todo.create({ 49 | id: 456, 50 | title: 'Todo 2', 51 | }) 52 | 53 | const res = await fetch('http://localhost/todos/123') 54 | const todo = await res.json() 55 | 56 | expect(res.status).toEqual(200) 57 | expect(todo).toEqual({ 58 | id: 123, 59 | title: 'Todo 1', 60 | }) 61 | }) 62 | 63 | it('returns a 404 response when getting a non-existing entity', async () => { 64 | server.use(...db.todo.toHandlers('rest', 'http://localhost')) 65 | 66 | db.todo.create({ 67 | id: 123, 68 | title: 'Todo 1', 69 | }) 70 | 71 | const res = await fetch('http://localhost/todos/456') 72 | const json = await res.json() 73 | 74 | expect(res.status).toEqual(404) 75 | expect(json).toEqual({ 76 | message: 77 | 'Failed to execute "findFirst" on the "todo" model: no entity found matching the query "{"id":{"equals":456}}".', 78 | }) 79 | }) 80 | }) 81 | 82 | describe('PUT /todos/:id', () => { 83 | it('handles a PUT request to update an entity', async () => { 84 | server.use(...db.todo.toHandlers('rest', 'http://localhost')) 85 | 86 | db.todo.create({ 87 | id: 123, 88 | title: 'Todo 1', 89 | }) 90 | 91 | const res = await fetch('http://localhost/todos/123', { 92 | method: 'PUT', 93 | headers: { 94 | 'Content-Type': 'application/json', 95 | }, 96 | body: JSON.stringify({ 97 | title: 'Todo 1 updated', 98 | }), 99 | }) 100 | const todo = await res.json() 101 | 102 | expect(res.status).toEqual(200) 103 | expect(todo).toEqual({ 104 | id: 123, 105 | title: 'Todo 1 updated', 106 | }) 107 | }) 108 | 109 | it('returns a 404 response when updating a non-existing entity', async () => { 110 | server.use(...db.todo.toHandlers('rest', 'http://localhost')) 111 | 112 | const res = await fetch('http://localhost/todos/123', { 113 | method: 'PUT', 114 | headers: { 115 | 'Content-Type': 'application/json', 116 | }, 117 | body: JSON.stringify({ 118 | title: 'Todo 1 updated', 119 | }), 120 | }) 121 | const json = await res.json() 122 | 123 | expect(res.status).toEqual(404) 124 | expect(json).toEqual({ 125 | message: 126 | 'Failed to execute "update" on the "todo" model: no entity found matching the query "{"id":{"equals":123}}".', 127 | }) 128 | }) 129 | 130 | it('returns a 409 response when updating an entity with primary key of another entity', async () => { 131 | server.use(...db.todo.toHandlers('rest', 'http://localhost')) 132 | 133 | db.todo.create({ 134 | id: 123, 135 | title: 'Todo 1', 136 | }) 137 | db.todo.create({ 138 | id: 456, 139 | title: 'Todo 2', 140 | }) 141 | 142 | const res = await fetch('http://localhost/todos/123', { 143 | method: 'PUT', 144 | headers: { 145 | 'Content-Type': 'application/json', 146 | }, 147 | body: JSON.stringify({ 148 | id: 456, 149 | title: 'Todo 1 updated', 150 | }), 151 | }) 152 | const json = await res.json() 153 | 154 | expect(res.status).toEqual(409) 155 | expect(json).toEqual({ 156 | message: 157 | 'Failed to execute "update" on the "todo" model: the entity with a primary key "456" ("id") already exists.', 158 | }) 159 | }) 160 | }) 161 | 162 | describe('DELETE /todos/:id', () => { 163 | it('handles a DELETE request to delete an entity', async () => { 164 | server.use(...db.todo.toHandlers('rest', 'http://localhost')) 165 | 166 | db.todo.create({ 167 | id: 123, 168 | title: 'Todo 1', 169 | }) 170 | db.todo.create({ 171 | id: 456, 172 | title: 'Todo 2', 173 | }) 174 | 175 | const res = await fetch('http://localhost/todos/456', { 176 | method: 'DELETE', 177 | }) 178 | const todo = await res.json() 179 | expect(res.status).toEqual(200) 180 | expect(todo).toEqual({ 181 | id: 456, 182 | title: 'Todo 2', 183 | }) 184 | 185 | const alltodos = await fetch('http://localhost/todos').then((res) => 186 | res.json(), 187 | ) 188 | expect(alltodos).toEqual([ 189 | { 190 | id: 123, 191 | title: 'Todo 1', 192 | }, 193 | ]) 194 | }) 195 | 196 | it('returns a 404 response when deleting a non-existing entity', async () => { 197 | server.use(...db.todo.toHandlers('rest', 'http://localhost')) 198 | 199 | const res = await fetch('http://localhost/todos/456', { 200 | method: 'DELETE', 201 | }) 202 | const json = await res.json() 203 | 204 | expect(res.status).toEqual(404) 205 | expect(json).toEqual({ 206 | message: 207 | 'Failed to execute "delete" on the "todo" model: no entity found matching the query "{"id":{"equals":456}}".', 208 | }) 209 | }) 210 | }) 211 | -------------------------------------------------------------------------------- /test/model/update.test-d.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker' 2 | import { factory, oneOf, manyOf, primaryKey, nullable } from '@mswjs/data' 3 | 4 | const db = factory({ 5 | user: { 6 | id: primaryKey(String), 7 | firstName: String, 8 | lastName: nullable(faker.name.lastName), 9 | age: Number, 10 | createdAt: () => new Date(), 11 | country: oneOf('country'), 12 | company: nullable(oneOf('company')), 13 | address: { 14 | billing: { 15 | country: String, 16 | city: nullable(() => null), 17 | }, 18 | }, 19 | }, 20 | country: { 21 | code: primaryKey(String), 22 | }, 23 | company: { 24 | name: primaryKey(String), 25 | employees: manyOf('user'), 26 | countries: nullable(manyOf('country')), 27 | }, 28 | }) 29 | 30 | db.user.update({ 31 | where: { 32 | id: { 33 | equals: 'abc-123', 34 | // @ts-expect-error Only string comparators are allowed. 35 | gte: 2, 36 | }, 37 | firstName: { 38 | contains: 'John', 39 | }, 40 | age: { 41 | gte: 18, 42 | // @ts-expect-error Only number comparators are allowed. 43 | contains: 'value', 44 | }, 45 | createdAt: { 46 | gte: new Date('2004-01-01'), 47 | }, 48 | }, 49 | data: { 50 | id: 'next', 51 | firstName: 'next', 52 | age: 24, 53 | country: db.country.create({ code: 'de' }), 54 | company: db.company.create({ name: 'Umbrella' }), 55 | lastName: null, 56 | // @ts-expect-error Unable to update non-nullable values to null 57 | updatedAt: null, 58 | }, 59 | }) 60 | 61 | // Query and update through nested properties. 62 | db.user.update({ 63 | where: { 64 | address: { 65 | billing: { 66 | country: { 67 | equals: 'us', 68 | }, 69 | }, 70 | }, 71 | }, 72 | data: { 73 | address: { 74 | billing: { 75 | country: 'de', 76 | city: 'Berlin', 77 | }, 78 | }, 79 | }, 80 | }) 81 | 82 | // Update nullable hasOne relations to null 83 | db.user.update({ 84 | where: { 85 | id: { 86 | equals: 'abc-123', 87 | }, 88 | }, 89 | data: { 90 | company: null, 91 | // @ts-expect-error unable to update non-nullable relations to null 92 | country: null, 93 | }, 94 | }) 95 | 96 | // Update nullable hasMany relations to null 97 | db.company.update({ 98 | where: { 99 | name: { 100 | equals: 'Umbrella', 101 | }, 102 | }, 103 | data: { 104 | countries: null, 105 | // @ts-expect-error unable to update non-nullable hasMany relations to null 106 | employees: null, 107 | }, 108 | }) 109 | 110 | db.user.update({ 111 | where: {}, 112 | data: { 113 | id(id, user) { 114 | user.firstName 115 | // @ts-expect-error Unknown property. 116 | user.unknown 117 | 118 | return id.toUpperCase() 119 | }, 120 | age(age) { 121 | age.toExponential 122 | return age + 10 123 | }, 124 | }, 125 | }) 126 | 127 | // Update a nested property using value getter. 128 | db.user.update({ 129 | where: { 130 | address: { 131 | billing: { 132 | country: { 133 | equals: 'us', 134 | }, 135 | }, 136 | }, 137 | }, 138 | data: { 139 | address: { 140 | billing: { 141 | country(country, user) { 142 | user.firstName 143 | user.address.billing?.country 144 | 145 | // @ts-expect-error Property "unknown" doesn't exist on "user". 146 | user.unknown 147 | 148 | return country.toUpperCase() 149 | }, 150 | }, 151 | }, 152 | }, 153 | }) 154 | -------------------------------------------------------------------------------- /test/model/update/collocated-update.test.ts: -------------------------------------------------------------------------------- 1 | import { factory, primaryKey, oneOf } from '../../../src' 2 | 3 | it.skip('supports a collocated update of a parent its ONE_OF relationship', () => { 4 | const db = factory({ 5 | post: { 6 | id: primaryKey(String), 7 | title: String, 8 | revision: oneOf('revision'), 9 | }, 10 | revision: { 11 | id: primaryKey(String), 12 | isReviewed: Boolean, 13 | }, 14 | }) 15 | 16 | db.post.create({ 17 | id: 'post-1', 18 | title: 'Initial title', 19 | revision: db.revision.create({ 20 | id: 'revision-1', 21 | isReviewed: false, 22 | }), 23 | }) 24 | 25 | const nextPost = db.post.update({ 26 | where: { id: { equals: 'post-1' } }, 27 | // @ts-ignore 28 | data: { 29 | title: 'Next title', 30 | revision(revision) { 31 | // Update the "post.revision" from within the "post" update. 32 | return db.revision.update({ 33 | where: { id: { equals: revision.id } }, 34 | data: { 35 | isReviewed: true, 36 | }, 37 | })! 38 | }, 39 | }, 40 | })! 41 | 42 | // Revision on the updated "post" returns the updated entity. 43 | expect(nextPost.title).toEqual('Next title') 44 | expect(nextPost.revision?.isReviewed).toEqual(true) 45 | 46 | // Revision on a newly queried post returns the updated entity. 47 | const latestPost = db.post.findFirst({ where: { id: { equals: 'post-1' } } })! 48 | expect(latestPost.title).toEqual('Next title') 49 | expect(latestPost.revision?.isReviewed).toEqual(true) 50 | 51 | // Direct query on the revision (relational property) returns the updated entity. 52 | const revision = db.revision.findFirst({ 53 | where: { id: { equals: 'revision-1' } }, 54 | })! 55 | expect(revision.isReviewed).toEqual(true) 56 | }) 57 | -------------------------------------------------------------------------------- /test/model/updateMany.test.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker' 2 | import { factory, primaryKey, nullable } from '../../src' 3 | import { OperationErrorType } from '../../src/errors/OperationError' 4 | import { getThrownError } from '../testUtils' 5 | 6 | test('derives updated value from the existing value', () => { 7 | const db = factory({ 8 | user: { 9 | id: primaryKey(faker.datatype.uuid), 10 | firstName: faker.name.findName, 11 | role: String, 12 | }, 13 | }) 14 | db.user.create({ 15 | firstName: 'Joseph', 16 | role: 'Auditor', 17 | }) 18 | db.user.create({ 19 | firstName: 'Jack', 20 | role: 'Writer', 21 | }) 22 | db.user.create({ 23 | firstName: 'John', 24 | role: 'Auditor', 25 | }) 26 | 27 | const updateMultiUsers = db.user.updateMany({ 28 | where: { 29 | role: { 30 | equals: 'Auditor', 31 | }, 32 | }, 33 | data: { 34 | firstName(firstName) { 35 | return firstName.toUpperCase() 36 | }, 37 | role(role, user) { 38 | return user.firstName === 'John' ? 'Writer' : role 39 | }, 40 | }, 41 | })! 42 | 43 | expect(updateMultiUsers).toHaveLength(2) 44 | const names = updateMultiUsers.map((user) => user.firstName) 45 | const roles = updateMultiUsers.map((user) => user.role) 46 | expect(names).toEqual(['JOSEPH', 'JOHN']) 47 | expect(roles).toEqual(['Auditor', 'Writer']) 48 | 49 | const userResult = db.user.findMany({ 50 | where: { 51 | role: { 52 | equals: 'Auditor', 53 | }, 54 | }, 55 | }) 56 | const allFirstNames = userResult.map((user) => user.firstName) 57 | // "John" is no longer in the results because it's role changed to "Writer". 58 | expect(allFirstNames).toEqual(['JOSEPH']) 59 | }) 60 | 61 | test('moves entities when they update primary keys', () => { 62 | const db = factory({ 63 | user: { 64 | id: primaryKey(String), 65 | }, 66 | }) 67 | db.user.create({ id: 'a' }) 68 | db.user.create({ id: 'b' }) 69 | db.user.create({ id: 'c' }) 70 | 71 | db.user.updateMany({ 72 | where: { 73 | id: { 74 | in: ['a', 'b'], 75 | }, 76 | }, 77 | data: { 78 | id: (value) => value + 1, 79 | }, 80 | }) 81 | 82 | const updatedUsers = db.user.findMany({ 83 | where: { 84 | id: { 85 | in: ['a1', 'b1'], 86 | }, 87 | }, 88 | }) 89 | expect(updatedUsers).toHaveLength(2) 90 | const updatedUserIds = updatedUsers.map((user) => user.id) 91 | expect(updatedUserIds).toEqual(['a1', 'b1']) 92 | 93 | const oldUsers = db.user.findMany({ 94 | where: { 95 | id: { 96 | in: ['a', 'b'], 97 | }, 98 | }, 99 | }) 100 | expect(oldUsers).toHaveLength(0) 101 | 102 | const intactUser = db.user.findFirst({ 103 | where: { 104 | id: { equals: 'c' }, 105 | }, 106 | }) 107 | expect(intactUser).toHaveProperty('id', 'c') 108 | }) 109 | 110 | test('throws an exception when no entity matches the query in strict mode', () => { 111 | const db = factory({ 112 | user: { 113 | id: primaryKey(faker.datatype.uuid), 114 | firstName: faker.name.firstName, 115 | }, 116 | }) 117 | db.user.create() 118 | db.user.create() 119 | 120 | const error = getThrownError(() => { 121 | db.user.updateMany({ 122 | where: { 123 | id: { 124 | in: ['abc-123', 'def-456'], 125 | }, 126 | }, 127 | data: { 128 | firstName: (value) => value.toUpperCase(), 129 | }, 130 | strict: true, 131 | }) 132 | }) 133 | 134 | expect(error).toHaveProperty('name', 'OperationError') 135 | expect(error).toHaveProperty('type', OperationErrorType.EntityNotFound) 136 | expect(error).toHaveProperty( 137 | 'message', 138 | 'Failed to execute "updateMany" on the "user" model: no entities found matching the query "{"id":{"in":["abc-123","def-456"]}}".', 139 | ) 140 | }) 141 | 142 | test('should update many entities with primitive values', () => { 143 | const db = factory({ 144 | user: { 145 | id: primaryKey(faker.datatype.uuid), 146 | firstName: faker.name.findName, 147 | role: String, 148 | }, 149 | }) 150 | db.user.create({ 151 | firstName: 'Joseph', 152 | role: 'Auditor', 153 | }) 154 | 155 | db.user.create({ 156 | firstName: 'John', 157 | role: 'Auditor', 158 | }) 159 | 160 | db.user.create({ 161 | firstName: 'Jack', 162 | role: 'Writer', 163 | }) 164 | 165 | const updateMultiUsers = db.user.updateMany({ 166 | where: { 167 | role: { 168 | equals: 'Auditor', 169 | }, 170 | }, 171 | data: { 172 | role: 'Admin', 173 | }, 174 | })! 175 | 176 | expect(updateMultiUsers).toHaveLength(2) 177 | updateMultiUsers.forEach((user) => expect(user.role).toEqual('Admin')) 178 | }) 179 | 180 | test('supports updating a nullable property to a non-null value on many entities', () => { 181 | const db = factory({ 182 | user: { 183 | id: primaryKey(faker.datatype.uuid), 184 | firstName: faker.name.findName, 185 | role: nullable(() => null), 186 | }, 187 | }) 188 | db.user.create({ 189 | firstName: 'Joseph', 190 | role: null, 191 | }) 192 | 193 | db.user.create({ 194 | firstName: 'John', 195 | role: null, 196 | }) 197 | 198 | db.user.create({ 199 | firstName: 'Jack', 200 | role: 'Writer', 201 | }) 202 | 203 | const nextAdmins = db.user.updateMany({ 204 | where: { 205 | firstName: { 206 | contains: 'J', 207 | }, 208 | }, 209 | data: { 210 | role: 'Admin', 211 | }, 212 | })! 213 | 214 | expect(nextAdmins).toHaveLength(3) 215 | nextAdmins.forEach((user) => expect(user).toHaveProperty('role', 'Admin')) 216 | }) 217 | 218 | test('supports updating a nullable property with a value to null on many entities', () => { 219 | const db = factory({ 220 | user: { 221 | id: primaryKey(faker.datatype.uuid), 222 | firstName: faker.name.findName, 223 | role: nullable(() => null), 224 | }, 225 | }) 226 | db.user.create({ 227 | firstName: 'Joseph', 228 | role: 'Auditor', 229 | }) 230 | 231 | db.user.create({ 232 | firstName: 'John', 233 | role: 'Auditor', 234 | }) 235 | 236 | db.user.create({ 237 | firstName: 'Jack', 238 | role: 'Writer', 239 | }) 240 | 241 | const prevAuditors = db.user.updateMany({ 242 | where: { 243 | role: { 244 | equals: 'Auditor', 245 | }, 246 | }, 247 | data: { 248 | role: null, 249 | }, 250 | })! 251 | 252 | expect(prevAuditors).toHaveLength(2) 253 | prevAuditors.forEach((user) => expect(user.role).toBeNull()) 254 | }) 255 | 256 | test('throw an error when updating entities with an already existing primary key', () => { 257 | const db = factory({ 258 | user: { 259 | id: primaryKey(faker.datatype.uuid), 260 | role: String, 261 | }, 262 | }) 263 | 264 | db.user.create({ 265 | id: '123', 266 | role: 'Admin', 267 | }) 268 | db.user.create({ 269 | id: '456', 270 | role: 'Auditor', 271 | }) 272 | 273 | db.user.create({ 274 | id: '789', 275 | role: 'Auditor', 276 | }) 277 | 278 | const error = getThrownError(() => { 279 | db.user.updateMany({ 280 | where: { 281 | role: { 282 | equals: 'Auditor', 283 | }, 284 | }, 285 | data: { 286 | id: '123', 287 | }, 288 | }) 289 | }) 290 | 291 | expect(error).toHaveProperty('name', 'OperationError') 292 | expect(error).toHaveProperty('type', OperationErrorType.DuplicatePrimaryKey) 293 | expect(error).toHaveProperty( 294 | 'message', 295 | 'Failed to execute "updateMany" on the "user" model: the entity with a primary key "123" ("id") already exists.', 296 | ) 297 | }) 298 | -------------------------------------------------------------------------------- /test/performance/performance.test.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker' 2 | import { factory, primaryKey } from '@mswjs/data' 3 | import { measurePerformance, repeat } from '../testUtils' 4 | 5 | describe.skip('Performance testing', () => { 6 | test('creates a 1000 records in under 100ms', async () => { 7 | const db = factory({ 8 | user: { 9 | id: primaryKey(faker.datatype.uuid), 10 | firstName: faker.name.firstName, 11 | lastName: faker.name.lastName, 12 | age: faker.datatype.number, 13 | role: faker.random.word, 14 | }, 15 | }) 16 | 17 | const createPerformance = await measurePerformance('create', () => { 18 | repeat(db.user.create, 1000) 19 | }) 20 | 21 | expect(createPerformance.duration).toBeLessThanOrEqual(350) 22 | }) 23 | 24 | test('queries through a 1000 records in under 100ms', async () => { 25 | const db = factory({ 26 | user: { 27 | id: primaryKey(faker.datatype.uuid), 28 | firstName: faker.name.firstName, 29 | lastName: faker.name.lastName, 30 | age: faker.datatype.number, 31 | role: faker.random.word, 32 | }, 33 | }) 34 | repeat(db.user.create, 1000) 35 | 36 | const findManyPerformance = await measurePerformance('findMany', () => { 37 | db.user.findMany({ 38 | where: { 39 | age: { 40 | gte: 18, 41 | }, 42 | }, 43 | }) 44 | }) 45 | 46 | expect(findManyPerformance.duration).toBeLessThanOrEqual(350) 47 | }) 48 | 49 | test('updates a single record under 100ms', async () => { 50 | const db = factory({ 51 | user: { 52 | id: primaryKey(faker.datatype.uuid), 53 | firstName: faker.name.firstName, 54 | lastName: faker.name.lastName, 55 | age: faker.datatype.number, 56 | role: faker.random.word, 57 | }, 58 | }) 59 | repeat(db.user.create, 1000) 60 | 61 | const updatePerformance = await measurePerformance('update', () => { 62 | db.user.update({ 63 | where: { 64 | age: { 65 | lte: 20, 66 | }, 67 | }, 68 | data: { 69 | age: 21, 70 | }, 71 | }) 72 | }) 73 | 74 | expect(updatePerformance.duration).toBeLessThanOrEqual(350) 75 | }) 76 | 77 | test('deletes a single record in under 100ms', async () => { 78 | const db = factory({ 79 | user: { 80 | id: primaryKey(faker.datatype.uuid), 81 | firstName: faker.name.firstName, 82 | lastName: faker.name.lastName, 83 | age: faker.datatype.number, 84 | role: faker.random.word, 85 | }, 86 | }) 87 | repeat(db.user.create, 999) 88 | db.user.create({ id: 'abc-123' }) 89 | 90 | const deletePerformance = await measurePerformance('delete', () => { 91 | db.user.delete({ 92 | where: { 93 | id: { 94 | equals: 'abc-123', 95 | }, 96 | }, 97 | }) 98 | }) 99 | 100 | expect(deletePerformance.duration).toBeLessThanOrEqual(350) 101 | }) 102 | 103 | test('deletes multiple records in under 100ms', async () => { 104 | const db = factory({ 105 | user: { 106 | id: primaryKey(faker.datatype.uuid), 107 | firstName: faker.name.firstName, 108 | lastName: faker.name.lastName, 109 | age: faker.datatype.number, 110 | role: faker.random.word, 111 | }, 112 | }) 113 | repeat(db.user.create, 1000) 114 | 115 | const deleteManyPerformance = await measurePerformance('deleteMany', () => { 116 | db.user.deleteMany({ 117 | where: { 118 | age: { 119 | lte: 18, 120 | }, 121 | }, 122 | }) 123 | }) 124 | 125 | expect(deleteManyPerformance.duration).toBeLessThanOrEqual(350) 126 | }) 127 | }) 128 | -------------------------------------------------------------------------------- /test/primaryKey.test.ts: -------------------------------------------------------------------------------- 1 | import { v4 } from 'uuid' 2 | import { faker } from '@faker-js/faker' 3 | import { factory, primaryKey } from '../src' 4 | import { 5 | OperationError, 6 | OperationErrorType, 7 | } from '../src/errors/OperationError' 8 | import { getThrownError } from './testUtils' 9 | 10 | test('supports querying by the primary key', () => { 11 | const db = factory({ 12 | user: { 13 | id: primaryKey(v4), 14 | firstName: faker.random.word, 15 | }, 16 | }) 17 | 18 | db.user.create() 19 | db.user.create() 20 | const user = db.user.create({ 21 | firstName: 'John', 22 | }) 23 | db.user.create() 24 | 25 | const userResult = db.user.findFirst({ 26 | where: { 27 | id: { 28 | equals: user.id, 29 | }, 30 | }, 31 | }) 32 | 33 | expect(userResult).toHaveProperty('id', user.id) 34 | expect(userResult).toHaveProperty('firstName', 'John') 35 | }) 36 | 37 | test('supports querying by the range of primary keys', () => { 38 | const db = factory({ 39 | user: { 40 | id: primaryKey(faker.random.word), 41 | firstName: faker.random.word, 42 | }, 43 | }) 44 | 45 | db.user.create({ 46 | id: 'abc-123', 47 | firstName: 'John', 48 | }) 49 | db.user.create() 50 | db.user.create({ 51 | id: 'def-456', 52 | firstName: 'Kate', 53 | }) 54 | db.user.create() 55 | 56 | const results = db.user.findMany({ 57 | where: { 58 | id: { 59 | in: ['abc-123', 'def-456'], 60 | }, 61 | }, 62 | }) 63 | expect(results).toHaveLength(2) 64 | 65 | const userNames = results.map((user) => user.firstName) 66 | expect(userNames).toEqual(['John', 'Kate']) 67 | }) 68 | 69 | test('supports querying by the primary key and additional properties', () => { 70 | const db = factory({ 71 | user: { 72 | id: primaryKey(faker.datatype.uuid), 73 | firstName: String, 74 | age: Number, 75 | }, 76 | }) 77 | 78 | db.user.create({ 79 | id: 'abc-123', 80 | firstName: 'John', 81 | age: 32, 82 | }) 83 | db.user.create({ 84 | firstName: 'Alice', 85 | age: 23, 86 | }) 87 | db.user.create({ 88 | id: 'def-456', 89 | firstName: 'Kate', 90 | age: 14, 91 | }) 92 | db.user.create({ 93 | firstName: 'Sheldon', 94 | age: 42, 95 | }) 96 | 97 | const results = db.user.findMany({ 98 | where: { 99 | id: { 100 | in: ['abc-123', 'def-456'], 101 | }, 102 | age: { 103 | gte: 18, 104 | }, 105 | }, 106 | }) 107 | expect(results).toHaveLength(1) 108 | 109 | expect(results[0]).toHaveProperty('firstName', 'John') 110 | expect(results[0]).toHaveProperty('age', 32) 111 | }) 112 | 113 | test('throws an exception when creating entity with existing primary key', () => { 114 | const db = factory({ 115 | user: { 116 | id: primaryKey(v4), 117 | }, 118 | }) 119 | 120 | db.user.create({ id: 'abc-123' }) 121 | 122 | expect(() => { 123 | db.user.create({ id: 'abc-123' }) 124 | }).toThrowError( 125 | new OperationError( 126 | OperationErrorType.DuplicatePrimaryKey, 127 | 'Failed to create a "user" entity: an entity with the same primary key "abc-123" ("id") already exists.', 128 | ), 129 | ) 130 | }) 131 | 132 | test('throws an error when primary key is not set at root level', () => { 133 | const error = getThrownError(() => { 134 | factory({ 135 | user: { 136 | name: String, 137 | info: { 138 | // @ts-expect-error Primary key on nested properties are forbidden. 139 | id: primaryKey(faker.datatype.uuid), 140 | firstName: String, 141 | lastName: String, 142 | }, 143 | }, 144 | }) 145 | }) 146 | expect(error).toHaveProperty( 147 | 'message', 148 | 'Failed to parse a model definition for "info" property of "user": cannot have a primary key in a nested object.', 149 | ) 150 | }) 151 | -------------------------------------------------------------------------------- /test/query/boolean.test.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker' 2 | import { factory, primaryKey, nullable } from '../../src' 3 | 4 | const setup = () => { 5 | const db = factory({ 6 | book: { 7 | id: primaryKey(faker.datatype.uuid), 8 | title: String, 9 | published: Boolean, 10 | finished: nullable(() => null), 11 | }, 12 | }) 13 | 14 | db.book.create({ 15 | title: 'The Winds of Winter', 16 | published: false, 17 | finished: false, 18 | }) 19 | db.book.create({ 20 | title: 'New Spring', 21 | published: true, 22 | finished: true, 23 | }) 24 | db.book.create({ 25 | title: 'The Doors of Stone', 26 | published: false, 27 | finished: null, // Who knows with Patrick? 28 | }) 29 | db.book.create({ 30 | title: 'The Fellowship of the Ring', 31 | published: true, 32 | finished: true, 33 | }) 34 | 35 | return db 36 | } 37 | 38 | test('queries entities based on a boolean value', () => { 39 | const db = setup() 40 | 41 | const firstPublished = db.book.findFirst({ 42 | where: { 43 | published: { 44 | equals: true, 45 | }, 46 | }, 47 | }) 48 | expect(firstPublished).toHaveProperty('title', 'New Spring') 49 | 50 | const allUnpublished = db.book.findMany({ 51 | where: { 52 | published: { 53 | notEquals: true, 54 | }, 55 | }, 56 | }) 57 | expect(allUnpublished).toHaveLength(2) 58 | 59 | const unpublishedTitles = allUnpublished.map((book) => book.title) 60 | expect(unpublishedTitles).toEqual([ 61 | 'The Winds of Winter', 62 | 'The Doors of Stone', 63 | ]) 64 | }) 65 | 66 | test('ignores entities with missing values when querying using boolean', () => { 67 | const db = setup() 68 | 69 | const finishedBooks = db.book.findMany({ 70 | where: { finished: { equals: true } }, 71 | }) 72 | const unfinishedBooks = db.book.findMany({ 73 | where: { finished: { notEquals: true } }, 74 | }) 75 | const bookTitles = [...finishedBooks, ...unfinishedBooks].map( 76 | (book) => book.title, 77 | ) 78 | 79 | expect(bookTitles).toHaveLength(3) 80 | expect(bookTitles).not.toContain('The Doors of Stone') 81 | }) 82 | -------------------------------------------------------------------------------- /test/query/date.test.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker' 2 | import { factory, primaryKey, nullable } from '../../src' 3 | 4 | const setup = () => { 5 | const db = factory({ 6 | user: { 7 | id: primaryKey(faker.datatype.uuid), 8 | firstName: String, 9 | createdAt: () => new Date(), 10 | updatedAt: nullable(() => null), 11 | }, 12 | }) 13 | db.user.create({ 14 | firstName: 'John', 15 | createdAt: new Date('1980-04-12'), 16 | updatedAt: new Date('1980-04-12'), 17 | }) 18 | db.user.create({ 19 | firstName: 'Kate', 20 | createdAt: new Date('2013-08-09'), 21 | updatedAt: new Date('2014-01-01'), 22 | }) 23 | db.user.create({ 24 | firstName: 'Sedrik', 25 | createdAt: new Date('1980-04-12'), 26 | }) 27 | return db 28 | } 29 | 30 | test('queries entities that equal a date', () => { 31 | const db = setup() 32 | 33 | const userResults = db.user.findMany({ 34 | where: { 35 | createdAt: { 36 | equals: new Date('1980-04-12'), 37 | }, 38 | }, 39 | }) 40 | expect(userResults).toHaveLength(2) 41 | 42 | const userNames = userResults.map((user) => user.firstName) 43 | expect(userNames).toEqual(['John', 'Sedrik']) 44 | }) 45 | 46 | test('queries entities that do not equal a date', () => { 47 | const db = setup() 48 | 49 | const userResults = db.user.findMany({ 50 | where: { 51 | createdAt: { 52 | notEquals: new Date('1980-04-12'), 53 | }, 54 | }, 55 | }) 56 | expect(userResults).toHaveLength(1) 57 | 58 | const userNames = userResults.map((user) => user.firstName) 59 | expect(userNames).toEqual(['Kate']) 60 | }) 61 | 62 | test('queries entities that are older than a date', () => { 63 | const db = setup() 64 | 65 | const userResults = db.user.findMany({ 66 | where: { 67 | createdAt: { 68 | lt: new Date('1980-04-14'), 69 | }, 70 | }, 71 | }) 72 | expect(userResults).toHaveLength(2) 73 | 74 | const userNames = userResults.map((user) => user.firstName) 75 | expect(userNames).toEqual(['John', 'Sedrik']) 76 | }) 77 | 78 | test('queries entities that are older or equal a date', () => { 79 | const db = setup() 80 | 81 | const userResults = db.user.findMany({ 82 | where: { 83 | createdAt: { 84 | lte: new Date('1980-04-14'), 85 | }, 86 | }, 87 | }) 88 | expect(userResults).toHaveLength(2) 89 | 90 | const userNames = userResults.map((user) => user.firstName) 91 | expect(userNames).toEqual(['John', 'Sedrik']) 92 | }) 93 | 94 | test('queries entities that are newer than a date', () => { 95 | const db = setup() 96 | 97 | const userResults = db.user.findMany({ 98 | where: { 99 | createdAt: { 100 | gt: new Date('1980-04-14'), 101 | }, 102 | }, 103 | }) 104 | expect(userResults).toHaveLength(1) 105 | 106 | const userNames = userResults.map((user) => user.firstName) 107 | expect(userNames).toEqual(['Kate']) 108 | }) 109 | 110 | test('queries entities that are newer or equal to a date', () => { 111 | const db = setup() 112 | 113 | const userResults = db.user.findMany({ 114 | where: { 115 | createdAt: { 116 | gte: new Date('1980-04-14'), 117 | }, 118 | }, 119 | }) 120 | expect(userResults).toHaveLength(1) 121 | 122 | const userNames = userResults.map((user) => user.firstName) 123 | expect(userNames).toEqual(['Kate']) 124 | }) 125 | 126 | test('ignores entities with missing values when querying using date', () => { 127 | const db = setup() 128 | 129 | const date = new Date('2000-01-01') 130 | const updatedBefore = db.user.findMany({ 131 | where: { updatedAt: { lte: date } }, 132 | }) 133 | const updatedAfter = db.user.findMany({ 134 | where: { updatedAt: { gte: date } }, 135 | }) 136 | const updatedUserNames = [...updatedBefore, ...updatedAfter].map( 137 | (user) => user.firstName, 138 | ) 139 | 140 | expect(updatedUserNames).toHaveLength(2) 141 | expect(updatedUserNames).not.toContain('Sedrick') 142 | }) 143 | -------------------------------------------------------------------------------- /test/query/number.test.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker' 2 | import { factory, primaryKey, nullable } from '../../src' 3 | 4 | const setup = () => { 5 | const db = factory({ 6 | user: { 7 | id: primaryKey(faker.datatype.uuid), 8 | firstName: String, 9 | age: Number, 10 | height: nullable(() => null), 11 | }, 12 | }) 13 | db.user.create({ 14 | firstName: 'John', 15 | age: 16, 16 | height: 200, 17 | }) 18 | db.user.create({ 19 | firstName: 'Alice', 20 | age: 24, 21 | height: 165, 22 | }) 23 | db.user.create({ 24 | firstName: 'Kate', 25 | age: 41, 26 | }) 27 | 28 | return db 29 | } 30 | 31 | test('queries entities where property equals to a number', () => { 32 | const db = setup() 33 | 34 | const firstAdult = db.user.findFirst({ 35 | where: { 36 | age: { 37 | gte: 18, 38 | }, 39 | }, 40 | }) 41 | expect(firstAdult).toHaveProperty('firstName', 'Alice') 42 | 43 | const allAdults = db.user.findMany({ 44 | where: { 45 | age: { 46 | gte: 18, 47 | }, 48 | }, 49 | }) 50 | expect(allAdults).toHaveLength(2) 51 | const adultsNames = allAdults.map((user) => user.firstName) 52 | expect(adultsNames).toEqual(['Alice', 'Kate']) 53 | }) 54 | 55 | test('queries entities where property is not equals to a number', () => { 56 | const db = setup() 57 | 58 | const users = db.user.findMany({ 59 | where: { 60 | age: { 61 | notEquals: 24, 62 | }, 63 | }, 64 | }) 65 | expect(users).toHaveLength(2) 66 | const names = users.map((user) => user.firstName) 67 | expect(names).toEqual(['John', 'Kate']) 68 | }) 69 | 70 | test('queries entities where property is within a number range', () => { 71 | const db = setup() 72 | 73 | const john = db.user.findFirst({ 74 | where: { 75 | age: { 76 | between: [16, 34], 77 | }, 78 | }, 79 | }) 80 | expect(john).toHaveProperty('firstName', 'John') 81 | 82 | const usersInAge = db.user.findMany({ 83 | where: { 84 | age: { 85 | between: [16, 34], 86 | }, 87 | }, 88 | }) 89 | expect(usersInAge).toHaveLength(2) 90 | const names = usersInAge.map((user) => user.firstName) 91 | expect(names).toEqual(['John', 'Alice']) 92 | }) 93 | 94 | test('queries entities where property is not within a number range', () => { 95 | const db = setup() 96 | 97 | const users = db.user.findMany({ 98 | where: { 99 | age: { 100 | notBetween: [16, 34], 101 | }, 102 | }, 103 | }) 104 | expect(users).toHaveLength(1) 105 | const names = users.map((user) => user.firstName) 106 | expect(names).toEqual(['Kate']) 107 | }) 108 | 109 | test('queries entities that are older than a number', () => { 110 | const db = setup() 111 | 112 | const users = db.user.findMany({ 113 | where: { 114 | age: { 115 | gt: 23, 116 | }, 117 | }, 118 | }) 119 | expect(users).toHaveLength(2) 120 | const names = users.map((user) => user.firstName) 121 | expect(names).toEqual(['Alice', 'Kate']) 122 | }) 123 | 124 | test('queries entities that are older or equal a number', () => { 125 | const db = setup() 126 | 127 | const users = db.user.findMany({ 128 | where: { 129 | age: { 130 | gte: 24, 131 | }, 132 | }, 133 | }) 134 | expect(users).toHaveLength(2) 135 | const names = users.map((user) => user.firstName) 136 | expect(names).toEqual(['Alice', 'Kate']) 137 | }) 138 | 139 | test('queries entities that are younger then a number', () => { 140 | const db = setup() 141 | 142 | const users = db.user.findMany({ 143 | where: { 144 | age: { 145 | lt: 24, 146 | }, 147 | }, 148 | }) 149 | expect(users).toHaveLength(1) 150 | const names = users.map((user) => user.firstName) 151 | expect(names).toEqual(['John']) 152 | }) 153 | 154 | test('queries entities that are younger or equal a number', () => { 155 | const db = setup() 156 | 157 | const users = db.user.findMany({ 158 | where: { 159 | age: { 160 | lte: 24, 161 | }, 162 | }, 163 | }) 164 | expect(users).toHaveLength(2) 165 | const names = users.map((user) => user.firstName) 166 | expect(names).toEqual(['John', 'Alice']) 167 | }) 168 | 169 | test('queries entities where property is not contained into the array', () => { 170 | const db = setup() 171 | 172 | const users = db.user.findMany({ 173 | where: { 174 | age: { 175 | notIn: [16, 24], 176 | }, 177 | }, 178 | }) 179 | const names = users.map((user) => user.firstName) 180 | expect(names).toEqual(['Kate']) 181 | }) 182 | 183 | test('queries entities where property is contained into the array', () => { 184 | const db = setup() 185 | 186 | const users = db.user.findMany({ 187 | where: { 188 | age: { 189 | in: [16, 24], 190 | }, 191 | }, 192 | }) 193 | const names = users.map((user) => user.firstName) 194 | expect(names).toEqual(['John', 'Alice']) 195 | }) 196 | 197 | test('ignores entities with missing values when querying using number', () => { 198 | const db = setup() 199 | 200 | const height = 180 201 | const shorterUsers = db.user.findMany({ where: { height: { lt: height } } }) 202 | const tallerUsers = db.user.findMany({ where: { height: { gte: height } } }) 203 | const userNames = [...shorterUsers, ...tallerUsers].map( 204 | (user) => user.firstName, 205 | ) 206 | 207 | expect(userNames).toHaveLength(2) 208 | expect(userNames).toEqual(['Alice', 'John']) 209 | }) 210 | -------------------------------------------------------------------------------- /test/query/string.test.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker' 2 | import { factory, primaryKey, nullable } from '@mswjs/data' 3 | 4 | const setup = () => { 5 | const db = factory({ 6 | recipe: { 7 | id: primaryKey(faker.datatype.uuid), 8 | title: String, 9 | category: nullable(() => null), 10 | }, 11 | }) 12 | db.recipe.create({ 13 | title: 'New York Pizza', 14 | category: 'pizza', 15 | }) 16 | db.recipe.create({ 17 | title: 'Chocolate Cake', 18 | category: 'cake', 19 | }) 20 | db.recipe.create({ 21 | title: 'Pizza Mozzarrela', 22 | category: 'pizza', 23 | }) 24 | db.recipe.create({ 25 | title: 'Pizza Cake', 26 | }) 27 | return db 28 | } 29 | 30 | test('queries entity where property equals a string', () => { 31 | const db = setup() 32 | 33 | const firstPizza = db.recipe.findFirst({ 34 | where: { 35 | category: { 36 | equals: 'pizza', 37 | }, 38 | }, 39 | }) 40 | expect(firstPizza).toHaveProperty('title', 'New York Pizza') 41 | 42 | const allPizza = db.recipe.findMany({ 43 | where: { 44 | category: { 45 | equals: 'pizza', 46 | }, 47 | }, 48 | }) 49 | expect(allPizza).toHaveLength(2) 50 | const titles = allPizza.map((pizza) => pizza.title) 51 | expect(titles).toEqual(['New York Pizza', 'Pizza Mozzarrela']) 52 | }) 53 | 54 | test('queries entities where property contains a string', () => { 55 | const db = setup() 56 | 57 | const firstPizza = db.recipe.findFirst({ 58 | where: { 59 | title: { 60 | contains: 'Pizza', 61 | }, 62 | }, 63 | }) 64 | expect(firstPizza).toHaveProperty('title', 'New York Pizza') 65 | 66 | const allPizzas = db.recipe.findMany({ 67 | where: { 68 | title: { 69 | contains: 'Pizza', 70 | }, 71 | }, 72 | }) 73 | expect(allPizzas).toHaveLength(3) 74 | const pizzaTitles = allPizzas.map((pizza) => pizza.title) 75 | expect(pizzaTitles).toEqual([ 76 | 'New York Pizza', 77 | 'Pizza Mozzarrela', 78 | 'Pizza Cake', 79 | ]) 80 | }) 81 | 82 | test('queries entities where property not contains a string', () => { 83 | const db = setup() 84 | 85 | const chocolateCake = db.recipe.findFirst({ 86 | where: { 87 | title: { 88 | notContains: 'Pizza', 89 | }, 90 | }, 91 | }) 92 | expect(chocolateCake).toHaveProperty('title', 'Chocolate Cake') 93 | }) 94 | 95 | test('queries entities where property is not equals to a string', () => { 96 | const db = setup() 97 | 98 | const chocolateCake = db.recipe.findFirst({ 99 | where: { 100 | title: { 101 | notEquals: 'New York Pizza', 102 | }, 103 | }, 104 | }) 105 | expect(chocolateCake).toHaveProperty('title', 'Chocolate Cake') 106 | }) 107 | 108 | test('queries entities where property is not contained into the array', () => { 109 | const db = setup() 110 | 111 | const chocolateCake = db.recipe.findFirst({ 112 | where: { 113 | title: { 114 | notIn: ['New York Pizza'], 115 | }, 116 | }, 117 | }) 118 | expect(chocolateCake).toHaveProperty('title', 'Chocolate Cake') 119 | }) 120 | 121 | test('queries entities where property is contained into the array', () => { 122 | const db = setup() 123 | 124 | const chocolateCake = db.recipe.findFirst({ 125 | where: { 126 | title: { 127 | in: ['New York Pizza'], 128 | }, 129 | }, 130 | }) 131 | expect(chocolateCake).toHaveProperty('title', 'New York Pizza') 132 | }) 133 | 134 | test('ignores entities with missing values when querying using strings', () => { 135 | const db = setup() 136 | 137 | const pizzaOrCakeRecipes = db.recipe.findMany({ 138 | where: { category: { in: ['pizza', 'cake'] } }, 139 | }) 140 | const pizzaOrCakeRecipeTitles = pizzaOrCakeRecipes.map( 141 | (recipe) => recipe.title, 142 | ) 143 | 144 | expect(pizzaOrCakeRecipeTitles).toHaveLength(3) 145 | expect(pizzaOrCakeRecipeTitles).not.toContain('Pizza Cake') 146 | }) 147 | -------------------------------------------------------------------------------- /test/regressions/02-handlers-many-of.test.ts: -------------------------------------------------------------------------------- 1 | // @vitest-environment jsdom 2 | import fetch from 'node-fetch' 3 | import { HttpResponse, http } from 'msw' 4 | import { setupServer } from 'msw/node' 5 | import { factory, manyOf, primaryKey } from '../../src' 6 | import { ENTITY_TYPE, PRIMARY_KEY } from '../../src/glossary' 7 | 8 | const server = setupServer() 9 | 10 | beforeAll(() => { 11 | server.listen() 12 | }) 13 | 14 | afterAll(() => { 15 | server.close() 16 | }) 17 | 18 | it('updates database entity modified via a generated request handler', async () => { 19 | const db = factory({ 20 | user: { 21 | id: primaryKey(String), 22 | notes: manyOf('note'), 23 | }, 24 | note: { 25 | id: primaryKey(String), 26 | title: String, 27 | }, 28 | }) 29 | 30 | db.user.create({ 31 | id: 'user-1', 32 | notes: [ 33 | db.note.create({ id: 'note-1', title: 'First note' }), 34 | db.note.create({ id: 'note-2', title: 'Second note' }), 35 | ], 36 | }) 37 | 38 | server.use( 39 | http.get('/user', () => { 40 | const user = db.user.findFirst({ 41 | strict: true, 42 | where: { 43 | id: { 44 | equals: 'user-1', 45 | }, 46 | }, 47 | }) 48 | return HttpResponse.json(user) 49 | }), 50 | http.put<{ noteId: string }, { title: string }>( 51 | '/note/:noteId', 52 | async ({ request, params }) => { 53 | const note = await request.json() 54 | const updatedNote = db.note.update({ 55 | strict: true, 56 | where: { 57 | id: { 58 | equals: params.noteId, 59 | }, 60 | }, 61 | data: { 62 | title: note.title, 63 | }, 64 | }) 65 | 66 | return HttpResponse.json(updatedNote) 67 | }, 68 | ), 69 | ) 70 | 71 | // Update a referenced relational property via request handler. 72 | const noteUpdateResponse = await fetch('http://localhost/note/note-2', { 73 | method: 'PUT', 74 | headers: { 75 | 'Content-Type': 'application/json', 76 | }, 77 | body: JSON.stringify({ 78 | title: 'Updated title', 79 | }), 80 | }) 81 | expect(noteUpdateResponse.status).toEqual(200) 82 | 83 | // Updates persist when querying the updated entity directly. 84 | expect( 85 | db.note.findFirst({ 86 | where: { 87 | id: { 88 | equals: 'note-2', 89 | }, 90 | }, 91 | }), 92 | ).toEqual({ 93 | [ENTITY_TYPE]: 'note', 94 | [PRIMARY_KEY]: 'id', 95 | id: 'note-2', 96 | title: 'Updated title', 97 | }) 98 | 99 | // Updates persist when querying a parent entity that references 100 | // the updated relational entity. 101 | expect( 102 | db.user.findFirst({ 103 | where: { 104 | id: { 105 | equals: 'user-1', 106 | }, 107 | }, 108 | }), 109 | ).toEqual({ 110 | [ENTITY_TYPE]: 'user', 111 | [PRIMARY_KEY]: 'id', 112 | id: 'user-1', 113 | notes: [ 114 | { 115 | [ENTITY_TYPE]: 'note', 116 | [PRIMARY_KEY]: 'id', 117 | id: 'note-1', 118 | title: 'First note', 119 | }, 120 | { 121 | [ENTITY_TYPE]: 'note', 122 | [PRIMARY_KEY]: 'id', 123 | id: 'note-2', 124 | title: 'Updated title', 125 | }, 126 | ], 127 | }) 128 | 129 | // Updates persist in the request handler's mocked response. 130 | const refetchedUser = await fetch('http://localhost/user').then((res) => 131 | res.json(), 132 | ) 133 | expect(refetchedUser).toEqual({ 134 | id: 'user-1', 135 | notes: [ 136 | { 137 | id: 'note-1', 138 | title: 'First note', 139 | }, 140 | { 141 | id: 'note-2', 142 | title: 'Updated title', 143 | }, 144 | ], 145 | }) 146 | }) 147 | -------------------------------------------------------------------------------- /test/regressions/112-event-emitter-leak/112-event-emitter-leak.runtime.js: -------------------------------------------------------------------------------- 1 | import { factory, primaryKey } from '@mswjs/data' 2 | 3 | const models = {} 4 | 5 | for (let i = 0; i < 100; i++) { 6 | models[`model${i}`] = { 7 | id: primaryKey(String), 8 | } 9 | } 10 | 11 | const db = factory(models) 12 | 13 | window.db = db 14 | -------------------------------------------------------------------------------- /test/regressions/112-event-emitter-leak/112-event-emitter-leak.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @see https://github.com/mswjs/data/issues/112 3 | */ 4 | import * as path from 'path' 5 | import { CreateBrowserApi, createBrowser, pageWith } from 'page-with' 6 | 7 | let browser: CreateBrowserApi 8 | 9 | beforeAll(async () => { 10 | browser = await createBrowser({ 11 | serverOptions: { 12 | webpackConfig: { 13 | resolve: { 14 | alias: { 15 | '@mswjs/data': path.resolve(__dirname, '../../..'), 16 | }, 17 | }, 18 | }, 19 | }, 20 | }) 21 | }) 22 | 23 | afterAll(async () => { 24 | await browser.cleanup() 25 | }) 26 | 27 | it('creates numerous models in a browser without any memory leaks', async () => { 28 | const runtime = await pageWith({ 29 | example: path.resolve(__dirname, '112-event-emitter-leak.runtime.js'), 30 | }) 31 | 32 | expect(runtime.consoleSpy.get('warning')).toBeUndefined() 33 | }) 34 | -------------------------------------------------------------------------------- /test/relations/one-to-one.operations.test.ts: -------------------------------------------------------------------------------- 1 | import { nullable, oneOf, primaryKey } from '../../src' 2 | import { testFactory } from '../testUtils' 3 | 4 | /** 5 | * Non-nullable one-to-one relationship. 6 | */ 7 | it('supports querying through a non-nullable relationship with initial value', () => { 8 | const { db, entity } = testFactory({ 9 | country: { 10 | code: primaryKey(String), 11 | capital: oneOf('city'), 12 | }, 13 | city: { 14 | name: primaryKey(String), 15 | }, 16 | }) 17 | 18 | db.country.create({ 19 | code: 'uk', 20 | capital: db.city.create({ 21 | name: 'London', 22 | }), 23 | }) 24 | const expectedCountry = entity('country', { 25 | code: 'uk', 26 | capital: entity('city', { 27 | name: 'London', 28 | }), 29 | }) 30 | 31 | expect( 32 | db.country.findFirst({ 33 | where: { 34 | capital: { name: { equals: 'London' } }, 35 | }, 36 | }), 37 | ).toEqual(expectedCountry) 38 | expect( 39 | db.country.findMany({ 40 | where: { 41 | capital: { name: { equals: 'London' } }, 42 | }, 43 | }), 44 | ).toEqual([expectedCountry]) 45 | 46 | // Non-matching query yields no results. 47 | expect( 48 | db.country.findFirst({ 49 | where: { 50 | capital: { name: { equals: 'New Hampshire' } }, 51 | }, 52 | }), 53 | ).toEqual(null) 54 | }) 55 | 56 | it('supports querying through a non-nullable relationship without initial value', () => { 57 | const { db } = testFactory({ 58 | country: { 59 | code: primaryKey(String), 60 | capital: oneOf('city'), 61 | }, 62 | city: { 63 | name: primaryKey(String), 64 | }, 65 | }) 66 | 67 | db.country.create({ 68 | code: 'uk', 69 | }) 70 | 71 | // Querying through the relationship is permitted 72 | // but since it hasn't been set, no queries will match. 73 | expect( 74 | db.country.findFirst({ 75 | where: { 76 | capital: { name: { equals: 'London' } }, 77 | }, 78 | }), 79 | ).toEqual(null) 80 | }) 81 | 82 | it('supports querying through a deeply nested non-nullable relationship', () => { 83 | const { db, entity } = testFactory({ 84 | user: { 85 | id: primaryKey(String), 86 | address: { 87 | billing: { 88 | country: oneOf('country'), 89 | }, 90 | }, 91 | }, 92 | country: { 93 | code: primaryKey(String), 94 | }, 95 | }) 96 | 97 | db.user.create({ 98 | id: 'user-1', 99 | address: { 100 | billing: { 101 | country: db.country.create({ 102 | code: 'uk', 103 | }), 104 | }, 105 | }, 106 | }) 107 | 108 | expect( 109 | db.user.findFirst({ 110 | where: { 111 | address: { 112 | billing: { 113 | country: { 114 | code: { equals: 'uk' }, 115 | }, 116 | }, 117 | }, 118 | }, 119 | }), 120 | ).toEqual( 121 | entity('user', { 122 | id: 'user-1', 123 | address: { 124 | billing: { 125 | country: entity('country', { 126 | code: 'uk', 127 | }), 128 | }, 129 | }, 130 | }), 131 | ) 132 | }) 133 | 134 | it('supports querying through nested non-nullable relationships', () => { 135 | const { db, entity } = testFactory({ 136 | user: { 137 | id: primaryKey(String), 138 | location: oneOf('country'), 139 | }, 140 | country: { 141 | code: primaryKey(String), 142 | capital: oneOf('city'), 143 | }, 144 | city: { 145 | name: primaryKey(String), 146 | }, 147 | }) 148 | 149 | db.user.create({ 150 | id: 'user-1', 151 | location: db.country.create({ 152 | code: 'uk', 153 | capital: db.city.create({ 154 | name: 'London', 155 | }), 156 | }), 157 | }) 158 | 159 | expect( 160 | db.user.findFirst({ 161 | where: { 162 | location: { 163 | capital: { 164 | name: { equals: 'London' }, 165 | }, 166 | }, 167 | }, 168 | }), 169 | ).toEqual( 170 | entity('user', { 171 | id: 'user-1', 172 | location: entity('country', { 173 | code: 'uk', 174 | capital: entity('city', { 175 | name: 'London', 176 | }), 177 | }), 178 | }), 179 | ) 180 | }) 181 | 182 | /** 183 | * Nullable one-to-one relationship. 184 | */ 185 | it('supports querying through a nullable relationship with initial value', () => { 186 | const { db, entity } = testFactory({ 187 | country: { 188 | code: primaryKey(String), 189 | capital: nullable(oneOf('city')), 190 | }, 191 | city: { 192 | name: primaryKey(String), 193 | }, 194 | }) 195 | 196 | db.country.create({ 197 | code: 'uk', 198 | capital: db.city.create({ 199 | name: 'London', 200 | }), 201 | }) 202 | const expectedCountry = entity('country', { 203 | code: 'uk', 204 | capital: entity('city', { 205 | name: 'London', 206 | }), 207 | }) 208 | 209 | expect( 210 | db.country.findFirst({ 211 | where: { 212 | capital: { name: { equals: 'London' } }, 213 | }, 214 | }), 215 | ).toEqual(expectedCountry) 216 | expect( 217 | db.country.findMany({ 218 | where: { 219 | capital: { name: { equals: 'London' } }, 220 | }, 221 | }), 222 | ).toEqual([expectedCountry]) 223 | 224 | expect( 225 | db.country.findFirst({ 226 | where: { 227 | capital: { name: { equals: 'New Hampshire' } }, 228 | }, 229 | }), 230 | ).toEqual(null) 231 | }) 232 | 233 | it('supports querying through a nullable relationship with null as initial value', () => { 234 | const { db, entity } = testFactory({ 235 | country: { 236 | code: primaryKey(String), 237 | capital: nullable(oneOf('city')), 238 | }, 239 | city: { 240 | name: primaryKey(String), 241 | }, 242 | }) 243 | 244 | db.country.create({ 245 | code: 'uk', 246 | capital: null, 247 | }) 248 | 249 | // Querying through the relationship is permitted 250 | // but since it hasn't been set, no queries will match. 251 | expect( 252 | db.country.findFirst({ 253 | where: { 254 | capital: { name: { equals: 'London' } }, 255 | }, 256 | }), 257 | ).toEqual(null) 258 | }) 259 | 260 | it('supports querying through a nullable relationship without initial value', () => { 261 | const { db } = testFactory({ 262 | country: { 263 | code: primaryKey(String), 264 | capital: nullable(oneOf('city')), 265 | }, 266 | city: { 267 | name: primaryKey(String), 268 | }, 269 | }) 270 | 271 | db.country.create({ 272 | code: 'uk', 273 | }) 274 | 275 | // Querying through the relationship is permitted 276 | // but since it hasn't been set, no queries will match. 277 | expect( 278 | db.country.findFirst({ 279 | where: { 280 | capital: { name: { equals: 'London' } }, 281 | }, 282 | }), 283 | ).toEqual(null) 284 | }) 285 | 286 | it('supports querying through a deeply nested nullable relationship', () => { 287 | const { db, entity } = testFactory({ 288 | user: { 289 | id: primaryKey(String), 290 | address: { 291 | billing: { 292 | country: nullable(oneOf('country')), 293 | }, 294 | }, 295 | }, 296 | country: { 297 | code: primaryKey(String), 298 | }, 299 | }) 300 | 301 | db.user.create({ 302 | id: 'user-1', 303 | address: { 304 | billing: { 305 | country: db.country.create({ 306 | code: 'uk', 307 | }), 308 | }, 309 | }, 310 | }) 311 | 312 | expect( 313 | db.user.findFirst({ 314 | where: { 315 | address: { 316 | billing: { 317 | country: { 318 | code: { equals: 'uk' }, 319 | }, 320 | }, 321 | }, 322 | }, 323 | }), 324 | ).toEqual( 325 | entity('user', { 326 | id: 'user-1', 327 | address: { 328 | billing: { 329 | country: entity('country', { 330 | code: 'uk', 331 | }), 332 | }, 333 | }, 334 | }), 335 | ) 336 | }) 337 | -------------------------------------------------------------------------------- /test/testUtils.ts: -------------------------------------------------------------------------------- 1 | import { performance, PerformanceObserver, PerformanceEntry } from 'perf_hooks' 2 | import { factory } from '../src' 3 | import { 4 | ModelDictionary, 5 | ENTITY_TYPE, 6 | PRIMARY_KEY, 7 | DATABASE_INSTANCE, 8 | Value, 9 | } from '../src/glossary' 10 | 11 | export function repeat(action: () => void, times: number) { 12 | for (let i = 0; i < times; i++) { 13 | action() 14 | } 15 | } 16 | 17 | export async function measurePerformance( 18 | name: string, 19 | fn: () => void | Promise, 20 | ): Promise { 21 | const startEvent = `${name}Start` 22 | const endEvent = `${name}End` 23 | 24 | return new Promise(async (resolve) => { 25 | const observer = new PerformanceObserver((list) => { 26 | const entries = list.getEntriesByName(name) 27 | const lastEntry = entries[entries.length - 1] 28 | 29 | observer.disconnect() 30 | resolve(lastEntry) 31 | }) 32 | observer.observe({ entryTypes: ['measure'] }) 33 | 34 | performance.mark(startEvent) 35 | await fn() 36 | performance.mark(endEvent) 37 | performance.measure(name, startEvent, endEvent) 38 | }) 39 | } 40 | 41 | export function getThrownError(fn: () => void) { 42 | try { 43 | fn() 44 | } catch (error) { 45 | return error 46 | } 47 | } 48 | 49 | export function testFactory( 50 | dictionary: Dictionary, 51 | ) { 52 | const db = factory(dictionary) 53 | 54 | return { 55 | db, 56 | databaseInstance: db[DATABASE_INSTANCE], 57 | dictionary, 58 | entity( 59 | modelName: ModelName, 60 | properties: Value, 61 | ) { 62 | const entity = db[modelName].getAll()[0] 63 | return { 64 | [ENTITY_TYPE]: entity[ENTITY_TYPE], 65 | [PRIMARY_KEY]: entity[PRIMARY_KEY], 66 | ...properties, 67 | } 68 | }, 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": false, 5 | "noEmit": true, 6 | "baseUrl": "../", 7 | "types": ["vitest/globals"] 8 | }, 9 | "include": ["jest.d.ts", "**/*.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /test/typings/DeepRequiredExactlyOne.test-d.ts: -------------------------------------------------------------------------------- 1 | import { DeepRequiredExactlyOne } from '../../src/glossary' 2 | 3 | type Shallow = DeepRequiredExactlyOne<{ a: number; b: string }> 4 | 5 | let shallow: Shallow = { a: 1 } 6 | shallow = { b: '' } 7 | 8 | // @ts-expect-error Only one known property is allowed. 9 | shallow = { a: 1, b: '' } 10 | 11 | type Nested = DeepRequiredExactlyOne<{ a: number; b: { c: { d: string } } }> 12 | 13 | let nested: Nested = { a: 1 } 14 | nested = { b: { c: { d: '' } } } 15 | 16 | // @ts-expect-error Only one known property is allowed. 17 | nested = { a: 1, b: { c: { d: '' } } } 18 | -------------------------------------------------------------------------------- /test/typings/nested-objects.test-d.ts: -------------------------------------------------------------------------------- 1 | import { factory, primaryKey } from '@mswjs/data' 2 | 3 | const db = factory({ 4 | user: { 5 | id: primaryKey(String), 6 | name: () => 'John', 7 | address: { 8 | billing: { 9 | street: String, 10 | }, 11 | shipping: { 12 | street: String, 13 | }, 14 | }, 15 | }, 16 | }) 17 | 18 | /** 19 | * Create. 20 | */ 21 | db.user.create({ 22 | address: { 23 | billing: { 24 | // Providing a known nested property. 25 | street: 'Baker', 26 | }, 27 | }, 28 | }) 29 | 30 | db.user.create({ 31 | address: { 32 | billing: { 33 | // @ts-expect-error Property "foo" doesn't exist on "user.address.billing". 34 | foo: 'Unknown', 35 | }, 36 | }, 37 | }) 38 | 39 | db.user.create({ 40 | address: { 41 | // @ts-expect-error Property "unknown" doesn't exist on "user.address". 42 | unknown: {}, 43 | }, 44 | }) 45 | 46 | /** 47 | * Find first. 48 | */ 49 | db.user.findFirst({ 50 | where: { 51 | address: { 52 | billing: { 53 | street: { 54 | equals: 'Baker', 55 | }, 56 | }, 57 | }, 58 | }, 59 | }) 60 | 61 | db.user.findFirst({ 62 | where: { 63 | address: { 64 | // @ts-expect-error Property "unknown" doesn't exist on "user.address". 65 | unknown: {}, 66 | }, 67 | }, 68 | }) 69 | 70 | db.user.findFirst({ 71 | where: { 72 | address: { 73 | billing: { 74 | // @ts-expect-error Property "unknown" doesn't exist on "user.address.billing". 75 | unknown: { 76 | equals: 'Baker', 77 | }, 78 | }, 79 | }, 80 | }, 81 | }) 82 | 83 | /** 84 | * Find many. 85 | */ 86 | db.user.findMany({ 87 | where: { 88 | address: { 89 | billing: { 90 | street: { 91 | equals: 'Baker', 92 | }, 93 | }, 94 | }, 95 | }, 96 | }) 97 | 98 | db.user.findMany({ 99 | where: { 100 | address: { 101 | // @ts-expect-error Property "unknown" doesn't exist on "user.address". 102 | unknown: {}, 103 | }, 104 | }, 105 | }) 106 | 107 | db.user.findMany({ 108 | where: { 109 | address: { 110 | billing: { 111 | // @ts-expect-error Property "unknown" doesn't exist on "user.address.billing". 112 | unknown: { 113 | equals: 'Baker', 114 | }, 115 | }, 116 | }, 117 | }, 118 | }) 119 | 120 | /** 121 | * Update. 122 | */ 123 | db.user.update({ 124 | where: { 125 | id: { equals: 'abc-123' }, 126 | }, 127 | data: { 128 | address: { 129 | billing: { 130 | // Updating a known nested property. 131 | street: 'Sunwell Ave.', 132 | }, 133 | }, 134 | }, 135 | }) 136 | 137 | db.user.update({ 138 | where: { 139 | id: { equals: 'abc-123' }, 140 | }, 141 | data: { 142 | id(value) { 143 | return value.toUpperCase() 144 | }, 145 | address: { 146 | billing: { 147 | street(value) { 148 | return value.toUpperCase() 149 | }, 150 | }, 151 | }, 152 | }, 153 | }) 154 | 155 | db.user.update({ 156 | where: { 157 | id: { equals: 'abc-123' }, 158 | }, 159 | data: { 160 | address: { 161 | billing: { 162 | // @ts-expect-error Property "foo" doesn't exist on "user.address.billing" 163 | foo: 'Unknown', 164 | }, 165 | }, 166 | }, 167 | }) 168 | 169 | /** 170 | * Update many. 171 | */ 172 | db.user.updateMany({ 173 | where: { 174 | address: { 175 | billing: { 176 | street: { 177 | equals: 'Baker', 178 | }, 179 | }, 180 | }, 181 | }, 182 | data: { 183 | address: { 184 | billing: { 185 | street(value) { 186 | return value.toUpperCase() 187 | }, 188 | }, 189 | }, 190 | }, 191 | }) 192 | 193 | /** 194 | * Sorting. 195 | */ 196 | db.user.findMany({ 197 | where: {}, 198 | orderBy: { 199 | address: { 200 | billing: { 201 | street: 'asc', 202 | }, 203 | }, 204 | }, 205 | }) 206 | 207 | db.user.findMany({ 208 | where: {}, 209 | // @ts-expect-error Must use "asc"/"desc" as sort direction. 210 | orderBy: { 211 | address: { 212 | billing: { 213 | street: 'UNKNOWN VALUE', 214 | }, 215 | }, 216 | }, 217 | }) 218 | 219 | db.user.findMany({ 220 | where: {}, 221 | // @ts-expect-error Must sort by a single criteria 222 | // using object as the "orderBy" value. 223 | orderBy: { 224 | address: { 225 | billing: { 226 | street: 'asc', 227 | }, 228 | shipping: { 229 | street: 'desc', 230 | }, 231 | }, 232 | }, 233 | }) 234 | 235 | // Multi-criteria sorting. 236 | db.user.findMany({ 237 | where: {}, 238 | orderBy: [ 239 | { 240 | address: { 241 | billing: { 242 | street: 'asc', 243 | }, 244 | }, 245 | }, 246 | { 247 | address: { 248 | shipping: { 249 | street: 'desc', 250 | }, 251 | }, 252 | }, 253 | ], 254 | }) 255 | -------------------------------------------------------------------------------- /test/typings/relations.test-d.ts: -------------------------------------------------------------------------------- 1 | import { factory, oneOf, primaryKey } from '@mswjs/data' 2 | 3 | const db = factory({ 4 | user: { 5 | id: primaryKey(String), 6 | country: oneOf('country'), 7 | stats: { 8 | revision: oneOf('revision'), 9 | }, 10 | }, 11 | country: { 12 | code: primaryKey(String), 13 | }, 14 | revision: { 15 | id: primaryKey(String), 16 | updatedAt: Number, 17 | }, 18 | }) 19 | 20 | const user = db.user.create() 21 | user.country?.code.toUpperCase() 22 | 23 | // @ts-expect-error Unknown property "foo" on "country". 24 | user.country.foo 25 | 26 | user.stats.revision?.id 27 | user.stats.revision?.updatedAt.toFixed() 28 | 29 | // @ts-expect-error Unknown property "foo" on "revision". 30 | user.stats.revision?.foo 31 | -------------------------------------------------------------------------------- /test/typings/strict-queries.test-d.ts: -------------------------------------------------------------------------------- 1 | import { factory, primaryKey } from '@mswjs/data' 2 | 3 | const db = factory({ 4 | user: { 5 | id: primaryKey(String), 6 | }, 7 | }) 8 | 9 | // @ts-expect-error Value it potentially "null". 10 | db.user.findFirst({ 11 | where: { id: { equals: 'user-1' } }, 12 | }).id 13 | 14 | // Using "strict" the value is never null. 15 | db.user.findFirst({ 16 | where: { id: { equals: 'user-1' } }, 17 | strict: true, 18 | }).id 19 | 20 | // @ts-expect-error Value it potentially "null". 21 | db.user.update({ 22 | where: { id: { equals: 'user-1' } }, 23 | }).id 24 | 25 | // Using "strict" the value is never null. 26 | db.user.update({ 27 | where: { id: { equals: 'user-1' } }, 28 | data: {}, 29 | strict: true, 30 | }).id 31 | 32 | // @ts-expect-error Value it potentially "null". 33 | db.user.updateMany({ 34 | where: { id: { equals: 'user-1' } }, 35 | }).forEach 36 | 37 | // Using "strict" the value is never null. 38 | db.user.updateMany({ 39 | where: { id: { equals: 'user-1' } }, 40 | data: {}, 41 | strict: true, 42 | }).forEach 43 | 44 | // @ts-expect-error Value it potentially "null". 45 | db.user.delete({ 46 | where: { id: { equals: 'user-1' } }, 47 | }).id 48 | 49 | // Using "strict" the value is never null. 50 | db.user.delete({ 51 | where: { id: { equals: 'user-1' } }, 52 | strict: true, 53 | }).id 54 | 55 | // @ts-expect-error Value it potentially "null". 56 | db.user.deleteMany({ 57 | where: { id: { equals: 'user-1' } }, 58 | }).forEach 59 | 60 | // Using "strict" the value is never null. 61 | db.user.deleteMany({ 62 | where: { id: { equals: 'user-1' } }, 63 | strict: true, 64 | }).forEach 65 | -------------------------------------------------------------------------------- /test/typings/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": true, 5 | "esModuleInterop": true, 6 | "downlevelIteration": true 7 | }, 8 | "include": ["**/*.test-d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /test/utils/capitalize.test.ts: -------------------------------------------------------------------------------- 1 | import { capitalize } from '../../src/utils/capitalize' 2 | 3 | it('capitalizes a given string', () => { 4 | expect(capitalize('user')).toEqual('User') 5 | expect(capitalize('deliveryType')).toEqual('DeliveryType') 6 | expect(capitalize('AlreadyCapitalized')).toEqual('AlreadyCapitalized') 7 | }) 8 | -------------------------------------------------------------------------------- /test/utils/definePropertyAtPath.test.ts: -------------------------------------------------------------------------------- 1 | import { definePropertyAtPath } from '../../src/utils/definePropertyAtPath' 2 | 3 | type AnyObject = Record 4 | 5 | describe('definePropertyAtPath()', () => { 6 | it('defines a root property by give name', () => { 7 | const target: AnyObject = {} 8 | definePropertyAtPath(target, ['a'], { 9 | get() { 10 | return 'hello world' 11 | }, 12 | }) 13 | expect(target.a).toEqual('hello world') 14 | }) 15 | 16 | it('defines a nested property at a given path', () => { 17 | const target: AnyObject = {} 18 | definePropertyAtPath(target, ['a', 'b', 'c'], { 19 | get() { 20 | return 'hello world' 21 | }, 22 | }) 23 | expect(target.a.b.c).toEqual('hello world') 24 | }) 25 | 26 | it('defines properies with dots in them', () => { 27 | const target: AnyObject = {} 28 | definePropertyAtPath(target, ['a.b.c'], { 29 | get() { 30 | return 'hello world' 31 | }, 32 | }) 33 | expect(target['a.b.c']).toEqual('hello world') 34 | }) 35 | 36 | it('defines deep properies with dots in them', () => { 37 | const target: AnyObject = {} 38 | definePropertyAtPath(target, ['a.b.c', 'e.d.f'], { 39 | get() { 40 | return 'hello world' 41 | }, 42 | }) 43 | expect(target['a.b.c']['e.d.f']).toEqual('hello world') 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /test/utils/findPrimaryKey.test.ts: -------------------------------------------------------------------------------- 1 | import { primaryKey } from '../../src' 2 | import { findPrimaryKey } from '../../src/utils/findPrimaryKey' 3 | 4 | it('returns the primary key property name of the model definition', () => { 5 | const result = findPrimaryKey({ 6 | id: primaryKey(String), 7 | }) 8 | expect(result).toEqual('id') 9 | }) 10 | 11 | it('returns undefined if the model definition contains property-compatible object', () => { 12 | const result = findPrimaryKey({ 13 | id: { 14 | // This object is compatible with the "PrimaryKey" class 15 | // but is not an instance of that class. 16 | getValue() { 17 | return 'abc-123' 18 | }, 19 | }, 20 | }) 21 | expect(result).toBeUndefined() 22 | }) 23 | 24 | it('returns undefined if the model definition has no primary key', () => { 25 | const result = findPrimaryKey({}) 26 | expect(result).toBeUndefined() 27 | }) 28 | -------------------------------------------------------------------------------- /test/utils/first.test.ts: -------------------------------------------------------------------------------- 1 | import { first } from '../../src/utils/first' 2 | 3 | test('returns the first item of a one-item array', () => { 4 | expect(first([10])).toBe(10) 5 | }) 6 | 7 | test('returns the first item of a non-empty array', () => { 8 | expect(first([1, 2, 3])).toBe(1) 9 | }) 10 | 11 | test('returns null given an empty array', () => { 12 | expect(first([])).toBeNull() 13 | }) 14 | 15 | test('returns null given a falsy value', () => { 16 | expect( 17 | first( 18 | // @ts-expect-error Runtime null value. 19 | null, 20 | ), 21 | ).toBeNull() 22 | expect( 23 | first( 24 | // @ts-expect-error Runtime undefined value. 25 | undefined, 26 | ), 27 | ).toBeNull() 28 | }) 29 | -------------------------------------------------------------------------------- /test/utils/generateGraphQLHandlers.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLBoolean, 3 | GraphQLFloat, 4 | GraphQLID, 5 | GraphQLInt, 6 | GraphQLString, 7 | } from 'graphql' 8 | import { primaryKey } from '../../src' 9 | import { 10 | comparatorTypes, 11 | getGraphQLType, 12 | getQueryTypeByValueType, 13 | definitionToFields, 14 | } from '../../src/model/generateGraphQLHandlers' 15 | 16 | describe('getGraphQLType', () => { 17 | it('derives GraphQL type from a variable', () => { 18 | expect(getGraphQLType(String)).toEqual(GraphQLString) 19 | expect(getGraphQLType(Number)).toEqual(GraphQLInt) 20 | expect(getGraphQLType(Date)).toEqual(GraphQLString) 21 | }) 22 | }) 23 | 24 | describe('getQueryTypeByValueType', () => { 25 | it('returns ID query type given GraphQLID value type', () => { 26 | expect(getQueryTypeByValueType(GraphQLID)).toEqual( 27 | comparatorTypes.IdQueryType, 28 | ) 29 | }) 30 | 31 | it('returns Int query type given GraphQLInt value type', () => { 32 | expect(getQueryTypeByValueType(GraphQLInt)).toEqual( 33 | comparatorTypes.IntQueryType, 34 | ) 35 | }) 36 | 37 | it('returns Boolean query type given GraphQLBoolean value type', () => { 38 | expect(getQueryTypeByValueType(GraphQLBoolean)).toEqual( 39 | comparatorTypes.BooleanQueryType, 40 | ) 41 | }) 42 | 43 | it('returns String query type given GraphQLString value type', () => { 44 | expect(getQueryTypeByValueType(GraphQLString)).toEqual( 45 | comparatorTypes.StringQueryType, 46 | ) 47 | }) 48 | 49 | it('returns String query type given an unknown GraphQLScalar type', () => { 50 | expect(getQueryTypeByValueType(GraphQLFloat)).toEqual( 51 | comparatorTypes.StringQueryType, 52 | ) 53 | }) 54 | }) 55 | 56 | describe('definitionToFields', () => { 57 | it('derives fields, input fields, and query input fields from a model definition', () => { 58 | expect( 59 | definitionToFields({ 60 | id: primaryKey(String), 61 | firstName: String, 62 | age: Number, 63 | }), 64 | ).toEqual({ 65 | fields: { 66 | id: { type: GraphQLID }, 67 | firstName: { type: GraphQLString }, 68 | age: { type: GraphQLInt }, 69 | }, 70 | inputFields: { 71 | id: { type: GraphQLID }, 72 | firstName: { type: GraphQLString }, 73 | age: { type: GraphQLInt }, 74 | }, 75 | queryInputFields: { 76 | id: { type: comparatorTypes.IdQueryType }, 77 | firstName: { type: comparatorTypes.StringQueryType }, 78 | age: { type: comparatorTypes.IntQueryType }, 79 | }, 80 | }) 81 | }) 82 | }) 83 | -------------------------------------------------------------------------------- /test/utils/generateRestHandlers.test.ts: -------------------------------------------------------------------------------- 1 | import { HttpResponse } from 'msw' 2 | import { primaryKey } from '../../src' 3 | import { ModelDefinition } from '../../src/glossary' 4 | import { 5 | OperationError, 6 | OperationErrorType, 7 | } from '../../src/errors/OperationError' 8 | import { 9 | createUrlBuilder, 10 | getResponseStatusByErrorType, 11 | withErrors, 12 | parseQueryParams, 13 | } from '../../src/model/generateRestHandlers' 14 | 15 | describe('createUrlBuilder', () => { 16 | it('builds a relative URL given no base URL', () => { 17 | const buildUrl = createUrlBuilder() 18 | expect(buildUrl('users')).toEqual('/users') 19 | }) 20 | 21 | it('builds a relative URL given a prefix as a base URL', () => { 22 | const buildUrl = createUrlBuilder('/api/v1') 23 | expect(buildUrl('users')).toEqual('/api/v1/users') 24 | }) 25 | 26 | it('builds an absolute URL given a base URL', () => { 27 | const buildUrl = createUrlBuilder('https://example.com') 28 | expect(buildUrl('users')).toEqual('https://example.com/users') 29 | }) 30 | 31 | it('builds an absolute URL given a base URL with trailing slash', () => { 32 | const buildUrl = createUrlBuilder('https://example.com/') 33 | expect(buildUrl('users')).toEqual('https://example.com/users') 34 | }) 35 | }) 36 | 37 | describe('getResponseStatusByErrorType', () => { 38 | it('returns 505 for the not-found operation error', () => { 39 | const notFoundError = new OperationError(OperationErrorType.EntityNotFound) 40 | expect(getResponseStatusByErrorType(notFoundError)).toEqual(404) 41 | }) 42 | 43 | it('returns 409 for the duplicate key operation error', () => { 44 | const duplicateKeyError = new OperationError( 45 | OperationErrorType.DuplicatePrimaryKey, 46 | ) 47 | expect(getResponseStatusByErrorType(duplicateKeyError)).toEqual(409) 48 | }) 49 | 50 | it('returns 500 for any other operation error', () => { 51 | const unknownError = new OperationError('UNKNOWN') 52 | expect( 53 | getResponseStatusByErrorType( 54 | // @ts-expect-error Runtime unknown error instance. 55 | unknownError, 56 | ), 57 | ).toEqual(500) 58 | }) 59 | }) 60 | 61 | describe('withErrors', () => { 62 | it('executes a successful handler as-is', async () => { 63 | const resolver = withErrors(() => { 64 | return HttpResponse.text('ok') 65 | }) 66 | const response = (await resolver({})) as Response 67 | 68 | expect(response.status).toBe(200) 69 | expect(await response.text()).toBe('ok') 70 | }) 71 | 72 | it('handles a not-found error as a 404', async () => { 73 | const resolver = withErrors(() => { 74 | throw new OperationError(OperationErrorType.EntityNotFound, 'Not found') 75 | }) 76 | const response = (await resolver({})) as Response 77 | 78 | expect(response.status).toBe(404) 79 | expect(await response.json()).toEqual({ 80 | message: 'Not found', 81 | }) 82 | }) 83 | 84 | it('handles a duplicate key error as 409', async () => { 85 | const resolver = withErrors(() => { 86 | throw new OperationError( 87 | OperationErrorType.DuplicatePrimaryKey, 88 | 'Duplicate key', 89 | ) 90 | }) 91 | const response = (await resolver({})) as Response 92 | 93 | expect(response.status).toBe(409) 94 | expect(await response.json()).toEqual({ message: 'Duplicate key' }) 95 | }) 96 | 97 | it('handles internal errors as a 500', async () => { 98 | const resolver = withErrors(() => { 99 | throw new Error('Arbitrary error') 100 | }) 101 | const response = (await resolver({})) as Response 102 | 103 | expect(response.status).toBe(500) 104 | expect(await response.json()).toEqual({ 105 | message: 'Arbitrary error', 106 | }) 107 | }) 108 | }) 109 | 110 | describe('parseQueryParams', () => { 111 | const definition: ModelDefinition = { 112 | id: primaryKey(String), 113 | firstName: String, 114 | } 115 | 116 | it('parses search params into pagination and filters', () => { 117 | const result = parseQueryParams( 118 | 'user', 119 | definition, 120 | new URLSearchParams({ 121 | take: '10', 122 | skip: '5', 123 | firstName: 'John', 124 | }), 125 | ) 126 | expect(result).toEqual({ 127 | take: 10, 128 | skip: 5, 129 | cursor: null, 130 | filters: { 131 | firstName: { equals: 'John' }, 132 | }, 133 | }) 134 | }) 135 | 136 | it('returns null as the "take" when none is set', () => { 137 | const result = parseQueryParams( 138 | 'user', 139 | definition, 140 | new URLSearchParams({ 141 | skip: '5', 142 | }), 143 | ) 144 | expect(result).toHaveProperty('take', null) 145 | }) 146 | 147 | it('returns null as the "skip" when none is set', () => { 148 | const result = parseQueryParams( 149 | 'user', 150 | definition, 151 | new URLSearchParams({ 152 | take: '10', 153 | }), 154 | ) 155 | expect(result).toHaveProperty('skip', null) 156 | }) 157 | 158 | it('returns null as the "cursor" when none is set', () => { 159 | const result = parseQueryParams( 160 | 'user', 161 | definition, 162 | new URLSearchParams({ 163 | take: '10', 164 | skip: '5', 165 | }), 166 | ) 167 | expect(result).toHaveProperty('cursor', null) 168 | }) 169 | 170 | it('returns an empty object given no model definition-based params', () => { 171 | const result = parseQueryParams( 172 | 'user', 173 | definition, 174 | new URLSearchParams({ take: '10', skip: '5' }), 175 | ) 176 | expect(result).toHaveProperty('filters', {}) 177 | }) 178 | 179 | it('throws an error given an unknown model definition-based param', () => { 180 | const parse = () => { 181 | return parseQueryParams( 182 | 'user', 183 | definition, 184 | new URLSearchParams({ 185 | unknownProp: 'yes', 186 | }), 187 | ) 188 | } 189 | 190 | expect(parse).toThrow( 191 | 'Failed to query the "user" model: unknown property "unknownProp".', 192 | ) 193 | }) 194 | }) 195 | -------------------------------------------------------------------------------- /test/utils/identity.test.ts: -------------------------------------------------------------------------------- 1 | import { identity } from '../../src/utils/identity' 2 | 3 | test('returns a function that returns a given value', () => { 4 | const id = identity(5) 5 | expect(id).toBeInstanceOf(Function) 6 | expect(id()).toBe(5) 7 | }) 8 | -------------------------------------------------------------------------------- /test/utils/inheritInternalProperties.test.ts: -------------------------------------------------------------------------------- 1 | import { Entity, ENTITY_TYPE, PRIMARY_KEY } from '../../src/glossary' 2 | import { inheritInternalProperties } from '../../src/utils/inheritInternalProperties' 3 | 4 | it('inherits internal properties from the given entity', () => { 5 | const target = { 6 | id: 'abc-123', 7 | firstName: 'John', 8 | } 9 | const entity: Entity = { 10 | [ENTITY_TYPE]: 'user', 11 | [PRIMARY_KEY]: 'id', 12 | } 13 | 14 | inheritInternalProperties(target, entity) 15 | 16 | expect(Object.keys(target)).toEqual(['id', 'firstName']) 17 | expect(Object.getOwnPropertySymbols(target)).toEqual([ 18 | ENTITY_TYPE, 19 | PRIMARY_KEY, 20 | ]) 21 | expect(target).toEqual({ 22 | [ENTITY_TYPE]: 'user', 23 | [PRIMARY_KEY]: 'id', 24 | id: 'abc-123', 25 | firstName: 'John', 26 | }) 27 | }) 28 | 29 | it('throws an exception given a corrupted source entity', () => { 30 | expect(() => 31 | inheritInternalProperties( 32 | { firstName: 'John' }, 33 | // @ts-expect-error Intentionally corrupt entity. 34 | { id: 'abc-123' }, 35 | ), 36 | ).toThrow( 37 | 'Failed to inherit internal properties from ({"id":"abc-123"}) to ({"firstName":"John"}): provided source entity has no entity type specified.', 38 | ) 39 | 40 | expect(() => 41 | inheritInternalProperties( 42 | { 43 | firstName: 'John', 44 | }, 45 | // @ts-expect-error Intentionally corrupt entity. 46 | { 47 | [ENTITY_TYPE]: 'user', 48 | id: 'abc-123', 49 | }, 50 | ), 51 | ).toThrow( 52 | 'Failed to inherit internal properties from ({"id":"abc-123"}) to ({"firstName":"John"}): provided source entity has no primary key specified.', 53 | ) 54 | }) 55 | -------------------------------------------------------------------------------- /test/utils/isModelValueType.test.ts: -------------------------------------------------------------------------------- 1 | import { isModelValueType } from '../../src/utils/isModelValueType' 2 | 3 | it('returns true given a string', () => { 4 | expect(isModelValueType('I am a string')).toBe(true) 5 | }) 6 | 7 | it('returns true given a new string', () => { 8 | expect(isModelValueType(String())).toBe(true) 9 | }) 10 | 11 | it('returns true given a number', () => { 12 | expect(isModelValueType(100)).toBe(true) 13 | }) 14 | 15 | it('returns true given a new number', () => { 16 | expect(isModelValueType(Number())).toBe(true) 17 | }) 18 | 19 | it('returns true given a Date', () => { 20 | expect(isModelValueType(new Date())).toBe(true) 21 | }) 22 | 23 | it('returns true given a new array', () => { 24 | expect(isModelValueType(new Array())).toBe(true) 25 | }) 26 | 27 | it('returns true given an array with primitive values', () => { 28 | expect(isModelValueType(['I am a string', 100])).toBe(true) 29 | }) 30 | 31 | it('returns true when given an array with non-primitive values', () => { 32 | expect(isModelValueType(['I am a string', {}])).toBe(true) 33 | }) 34 | 35 | it('returns true when given nested primitive arrays', () => { 36 | expect(isModelValueType(['I am a string', [100]])).toBe(true) 37 | }) 38 | 39 | it('returns true when given a literal object', () => { 40 | expect(isModelValueType({ intensity: 1 })).toBe(true) 41 | }) 42 | 43 | it('returns false given an undefined', () => { 44 | expect(isModelValueType(undefined)).toBe(false) 45 | }) 46 | 47 | it('returns false given a null', () => { 48 | expect(isModelValueType(null)).toBe(false) 49 | }) 50 | -------------------------------------------------------------------------------- /test/utils/isObject.test.ts: -------------------------------------------------------------------------------- 1 | import { isObject } from '../../src/utils/isObject' 2 | 3 | it('returns true given an empty object', () => { 4 | expect(isObject({})).toEqual(true) 5 | }) 6 | 7 | it('returns true given an object with values', () => { 8 | expect(isObject({ a: 1, b: ['foo'] })).toEqual(true) 9 | }) 10 | 11 | it('returns false given falsy values', () => { 12 | expect(isObject(undefined)).toEqual(false) 13 | expect(isObject(null)).toEqual(false) 14 | expect(isObject(false)).toEqual(false) 15 | }) 16 | 17 | it('returns false given an array', () => { 18 | expect(isObject([])).toEqual(false) 19 | expect(isObject([{ a: 1 }])).toEqual(false) 20 | }) 21 | -------------------------------------------------------------------------------- /test/utils/parseModelDefinition.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ParsedModelDefinition, 3 | parseModelDefinition, 4 | } from '../../src/model/parseModelDefinition' 5 | import { manyOf, oneOf, primaryKey } from '../../src' 6 | import { ModelDictionary } from '../../src/glossary' 7 | import { Relation, RelationKind } from '../../src/relations/Relation' 8 | 9 | it('parses a plain model definition', () => { 10 | const dictionary = { 11 | user: { 12 | id: primaryKey(String), 13 | firstName: String, 14 | }, 15 | } 16 | const result = parseModelDefinition(dictionary, 'user', dictionary.user) 17 | 18 | expect(result).toEqual({ 19 | primaryKey: 'id', 20 | properties: [['id'], ['firstName']], 21 | relations: [], 22 | } as ParsedModelDefinition) 23 | }) 24 | 25 | it('parses a model definition with relations', () => { 26 | const dictionary = { 27 | user: { 28 | id: primaryKey(String), 29 | firstName: String, 30 | country: oneOf('country', { unique: true }), 31 | posts: manyOf('post'), 32 | }, 33 | country: { 34 | code: primaryKey(String), 35 | }, 36 | post: { 37 | id: primaryKey(String), 38 | }, 39 | } 40 | const result = parseModelDefinition(dictionary, 'user', dictionary['user']) 41 | 42 | expect(result).toEqual({ 43 | primaryKey: 'id', 44 | properties: [['id'], ['firstName']], 45 | relations: [ 46 | { 47 | propertyPath: ['country'], 48 | relation: new Relation({ 49 | to: 'country', 50 | kind: RelationKind.OneOf, 51 | attributes: { 52 | unique: true, 53 | }, 54 | }), 55 | }, 56 | { 57 | propertyPath: ['posts'], 58 | relation: new Relation({ 59 | to: 'post', 60 | kind: RelationKind.ManyOf, 61 | }), 62 | }, 63 | ], 64 | } as ParsedModelDefinition) 65 | }) 66 | 67 | it('parses a model definition with nested objects', () => { 68 | const dictionary: ModelDictionary = { 69 | user: { 70 | id: primaryKey(String), 71 | address: { 72 | billing: { 73 | street: String, 74 | houseNumber: String, 75 | country: oneOf('country'), 76 | }, 77 | }, 78 | activity: { 79 | posts: manyOf('post', { unique: true }), 80 | }, 81 | }, 82 | post: { 83 | id: primaryKey(String), 84 | }, 85 | country: { 86 | code: primaryKey(String), 87 | }, 88 | } 89 | 90 | const result = parseModelDefinition(dictionary, 'user', dictionary.user) 91 | 92 | expect(result).toEqual({ 93 | primaryKey: 'id', 94 | properties: [ 95 | ['id'], 96 | ['address', 'billing', 'street'], 97 | ['address', 'billing', 'houseNumber'], 98 | ], 99 | relations: [ 100 | { 101 | propertyPath: ['address', 'billing', 'country'], 102 | relation: new Relation({ 103 | to: 'country', 104 | kind: RelationKind.OneOf, 105 | }), 106 | }, 107 | { 108 | propertyPath: ['activity', 'posts'], 109 | relation: new Relation({ 110 | to: 'post', 111 | kind: RelationKind.ManyOf, 112 | attributes: { 113 | unique: true, 114 | }, 115 | }), 116 | }, 117 | ], 118 | } as ParsedModelDefinition) 119 | }) 120 | 121 | it('throws an error when provided a model definition with multiple primary keys', () => { 122 | const dictionary = { 123 | user: { 124 | id: primaryKey(String), 125 | role: primaryKey(String), 126 | }, 127 | } 128 | const parse = () => parseModelDefinition(dictionary, 'user', dictionary.user) 129 | 130 | expect(parse).toThrow( 131 | 'Failed to parse a model definition for "user": cannot have both properties "id" and "role" as a primary key.', 132 | ) 133 | }) 134 | 135 | it('throws an error when provided a model definition without a primary key', () => { 136 | const dictionary = { 137 | user: { 138 | firstName: String, 139 | }, 140 | } 141 | const parse = () => parseModelDefinition(dictionary, 'user', dictionary.user) 142 | 143 | expect(parse).toThrow( 144 | 'Failed to parse a model definition for "user": model is missing a primary key. Did you forget to mark one of its properties using the "primaryKey" function?', 145 | ) 146 | }) 147 | -------------------------------------------------------------------------------- /test/utils/spread.test.ts: -------------------------------------------------------------------------------- 1 | import { spread } from '../../src/utils/spread' 2 | 3 | it('spreads a plain object', () => { 4 | const source = { a: 1, b: { c: 2 } } 5 | const target = spread(source) 6 | 7 | expect(target).toEqual(source) 8 | 9 | source.a = 2 10 | source.b.c = 3 11 | 12 | expect(target.a).toEqual(1) 13 | expect(target.b.c).toEqual(2) 14 | }) 15 | 16 | it('preserves property getters', () => { 17 | const source = { a: 1, getCount: undefined } 18 | Object.defineProperty(source, 'getCount', { 19 | get() { 20 | return 123 21 | }, 22 | }) 23 | 24 | const target = spread(source) 25 | 26 | expect(target.a).toEqual(1) 27 | expect(Object.getOwnPropertyDescriptor(target, 'getCount')).toEqual( 28 | Object.getOwnPropertyDescriptor(source, 'getCount'), 29 | ) 30 | expect(target.getCount).toEqual(123) 31 | }) 32 | 33 | it('does not preserve symbols', () => { 34 | const symbol = Symbol('secret') 35 | const source = {} as { [symbol]: number } 36 | Object.defineProperty(source, symbol, { value: 123 }) 37 | 38 | const target = spread(source) 39 | 40 | expect(target[symbol]).toBeUndefined() 41 | expect(Object.getOwnPropertySymbols(target)).toEqual([]) 42 | }) 43 | -------------------------------------------------------------------------------- /test/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'node:path' 2 | import { defineConfig } from 'vitest/config' 3 | 4 | export default defineConfig({ 5 | test: { 6 | root: __dirname, 7 | globals: true, 8 | testTimeout: 60_000, 9 | setupFiles: ['./vitest.setup.ts'], 10 | alias: { 11 | '@mswjs/data': path.resolve(__dirname, '../src'), 12 | }, 13 | environmentOptions: { 14 | jsdom: { 15 | url: 'http://localhost', 16 | }, 17 | }, 18 | }, 19 | }) 20 | -------------------------------------------------------------------------------- /test/vitest.setup.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'vitest' 2 | 3 | expect.extend({ 4 | toHaveRelationalProperty(entity, propertyName, value) { 5 | expect(entity).toHaveProperty(propertyName) 6 | 7 | // Relational property must only have a getter. 8 | const descriptor = Object.getOwnPropertyDescriptor(entity, propertyName)! 9 | expect(descriptor.get).toBeInstanceOf(Function) 10 | expect(descriptor.value).not.toBeDefined() 11 | expect(descriptor.enumerable).toEqual(true) 12 | expect(descriptor.configurable).toEqual(true) 13 | 14 | if (value) { 15 | const actualValue = entity[propertyName] 16 | expect(actualValue).toEqual(value) 17 | } 18 | 19 | return { 20 | pass: true, 21 | message() { 22 | return '' 23 | }, 24 | } 25 | }, 26 | }) 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "strictNullChecks": true, 5 | "skipLibCheck": true, 6 | "outDir": "lib", 7 | "declaration": true, 8 | "moduleResolution": "node", 9 | "downlevelIteration": true, 10 | "esModuleInterop": true, 11 | "noImplicitAny": true, 12 | "baseUrl": ".", 13 | "paths": { 14 | "@mswjs/data": ["./src"] 15 | } 16 | }, 17 | "include": ["src/**/*.ts"], 18 | "exclude": ["node_modules"] 19 | } 20 | --------------------------------------------------------------------------------