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