Skip to content

Commit

Permalink
Add support for null property values (#143)
Browse files Browse the repository at this point in the history
* feat: `nullable` function for defining null fields

* chore(Relation): treat "nullable" as an attribute

* test(create): add null initial value for non-nullable properties

* docs: polish nullable docs

* fix: prevent creation if null used for non-nullable fields

* fix(primaryKey): do not match "NullableProperty" class

Co-authored-by: Artem Zakharchenko <[email protected]>
  • Loading branch information
olivierwilkinson and kettanaito authored Nov 17, 2021
1 parent 65cca1c commit cbf614c
Show file tree
Hide file tree
Showing 28 changed files with 1,930 additions and 105 deletions.
115 changes: 106 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ setupWorker(

- [Model methods](#model-methods)
- **Modeling:**
- [Nullable properties](#nullable-properties)
- [Nested structures](#nested-structures)
- [Model relationships](#model-relationships)
- **Querying:**
Expand Down Expand Up @@ -334,19 +335,73 @@ db.user.toHandlers('rest', 'https://example.com')
db.user.toHandlers('graphql', 'https://example.com/graphql')
```

### Nullable properties

By default, all model properties are non-nullable. You can use the `nullable` function to mark a property as nullable:

```js
import { factory, primaryKey, nullable } from '@mswjs/data'

const db = factory({
user: {
id: primaryKey(String),
firstName: String,
// "user.age" is a nullable property.
age: nullable(Number),
},
})

db.user.create({
id: 'user-1',
firstName: 'John',
// Nullable properties can be explicit null as the initial value.
age: null,
})

db.user.update({
where: {
id: {
equals: 'user-1',
},
},
data: {
// Nullable properties can be updated to null.
age: null,
},
})
```

> You can define [Nullable relationships](#nullable-relationships) in the same manner.
When using Typescript, you can manually set the type of the property when it is
not possible to infer it from the factory function, such as when you want the
property to default to null:

```typescript
import { factory, primaryKey, nullable } from '@mswjs/data'

const db = factory({
user: {
id: primaryKey(String),
age: nullable<number>(() => null),
},
})
```

### Nested structures

You may use nested objects to design a complex structure of your model:

```js
import { factory, primaryKey } from '@mswjs/data'
import { factory, primaryKey, nullable } from '@mswjs/data'

const db = factory({
user: {
id: primaryKey(String),
address: {
billing: {
street: String,
city: nullable(String),
},
},
},
Expand All @@ -360,6 +415,7 @@ db.user.create({
address: {
billing: {
street: 'Baker st.',
city: 'London',
},
},
})
Expand All @@ -374,6 +430,7 @@ db.user.update({
address: {
billing: {
street: 'Sunwell ave.',
city: null,
},
},
},
Expand Down Expand Up @@ -408,6 +465,7 @@ factory({
- [One-to-Many](#one-to-many)
- [Many-to-One](#many-to-one)
- [Unique relationships](#unique-relationships)
- [Nullable relationships](#nullable-relationships)

Relationship is a way for a model to reference another model.

Expand Down Expand Up @@ -523,6 +581,45 @@ const john = db.user.create({ invitation })
const karl = db.user.create({ invitation })
```

#### Nullable relationships

Both `oneOf` and `manyOf` relationships may be passed to `nullable` to allow
instantiating and updating that relation to null.

```js
import { factory, primaryKey, oneOf, nullable } from '@mswjs/data'

const db = factory({
user: {
id: primaryKey(String),
invitation: nullable(oneOf('invitation')),
friends: nullable(manyOf('user')),
},
invitation: {
id: primaryKey(String),
},
})

const invitation = db.invitation.create()

// Nullable relationships are instantiated with null.
const john = db.user.create({ invitation }) // john.friends === null
const kate = db.user.create({ friends: [john] }) // kate.invitation === null

db.user.updateMany({
where: {
id: {
in: [john.id, kate.id],
},
},
data: {
// Nullable relationships can be updated to null.
invitation: null,
friends: null,
},
})
```

### Querying data

This library supports querying of the seeded data similar to how one would query a SQL database. The data is queried based on its properties. A query you construct depends on the value type you are querying.
Expand Down Expand Up @@ -703,27 +800,27 @@ const db = factory({
post: {
id: primaryKey(String),
title: String,
author: oneOf('user')
author: oneOf('user'),
},
user: {
id: primaryKey(String),
firstName: String
}
firstName: String,
},
})

// Return all posts in the "Science" category
// sorted by the post author's first name.
db.post.findMany({
where: {
category: {
equals: 'Science'
}
equals: 'Science',
},
},
orderBy: {
author: {
firstName: 'asc'
}
}
firstName: 'asc',
},
},
})
```

Expand Down
35 changes: 23 additions & 12 deletions src/glossary.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { GraphQLSchema } from 'graphql'
import { GraphQLHandler, RestHandler } from 'msw'
import { NullableProperty } from './nullable'
import { PrimaryKey } from './primaryKey'
import {
BulkQueryOptions,
Expand All @@ -18,17 +19,19 @@ export type ModelValueTypeGetter = () => ModelValueType
export type ModelDefinition = Record<string, ModelDefinitionValue>

export type ModelDefinitionValue =
| ModelValueTypeGetter
| PrimaryKey<any>
| OneOf<any>
| ManyOf<any>
| ModelValueTypeGetter
| NullableProperty<any>
| OneOf<any, boolean>
| ManyOf<any, boolean>
| NestedModelDefinition

export type NestedModelDefinition = {
[propertyName: string]:
| ModelValueTypeGetter
| OneOf<any>
| ManyOf<any>
| NullableProperty<any>
| OneOf<any, boolean>
| ManyOf<any, boolean>
| NestedModelDefinition
}

Expand Down Expand Up @@ -168,9 +171,9 @@ export type UpdateManyValue<
| {
[Key in keyof Target]?: Target[Key] extends PrimaryKey
? (
prevValue: ReturnType<Target[Key]['getValue']>,
prevValue: ReturnType<Target[Key]['getPrimaryKeyValue']>,
entity: Value<Target, Dictionary>,
) => ReturnType<Target[Key]['getValue']>
) => ReturnType<Target[Key]['getPrimaryKeyValue']>
: Target[Key] extends ModelValueTypeGetter
? (
prevValue: ReturnType<Target[Key]>,
Expand All @@ -189,12 +192,20 @@ export type Value<
Dictionary extends ModelDictionary,
> = {
[Key in keyof Target]: Target[Key] extends PrimaryKey<any>
? ReturnType<Target[Key]['getPrimaryKeyValue']>
: // Extract underlying value type of nullable properties
Target[Key] extends NullableProperty<any>
? ReturnType<Target[Key]['getValue']>
: // Extract value type from relations.
Target[Key] extends OneOf<infer ModelName>
? PublicEntity<Dictionary, ModelName>
: Target[Key] extends ManyOf<infer ModelName>
? PublicEntity<Dictionary, ModelName>[]
: // Extract value type from OneOf relations.
Target[Key] extends OneOf<infer ModelName, infer Nullable>
? Nullable extends true
? PublicEntity<Dictionary, ModelName> | null
: PublicEntity<Dictionary, ModelName>
: // Extract value type from ManyOf relations.
Target[Key] extends ManyOf<infer ModelName, infer Nullable>
? Nullable extends true
? PublicEntity<Dictionary, ModelName>[] | null
: PublicEntity<Dictionary, ModelName>[]
: // Account for primitive value getters because
// native constructors (i.e. StringConstructor) satisfy
// the "AnyObject" predicate below.
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export { factory } from './factory'
export { primaryKey } from './primaryKey'
export { nullable } from './nullable';
export { oneOf } from './relations/oneOf'
export { manyOf } from './relations/manyOf'
export { drop } from './db/drop'
Expand Down
31 changes: 22 additions & 9 deletions src/model/createModel.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { debug } from 'debug'
import { invariant } from 'outvariant'
import get from 'lodash/get'
import set from 'lodash/set'
import isFunction from 'lodash/isFunction'
Expand All @@ -16,6 +17,8 @@ import { ParsedModelDefinition } from './parseModelDefinition'
import { defineRelationalProperties } from './defineRelationalProperties'
import { PrimaryKey } from '../primaryKey'
import { Relation } from '../relations/Relation'
import { NullableProperty } from '../nullable'
import { isModelValueType } from '../utils/isModelValueType'

const log = debug('createModel')

Expand Down Expand Up @@ -59,19 +62,29 @@ export function createModel<
set(
properties,
propertyName,
initialValue || propertyDefinition.getValue(),
initialValue || propertyDefinition.getPrimaryKeyValue(),
)
return properties
}

if (
typeof initialValue === 'string' ||
typeof initialValue === 'number' ||
typeof initialValue === 'boolean' ||
// @ts-ignore
initialValue?.constructor.name === 'Date' ||
Array.isArray(initialValue)
) {
if (propertyDefinition instanceof NullableProperty) {
const value =
initialValue === null || isModelValueType(initialValue)
? initialValue
: propertyDefinition.getValue()

set(properties, propertyName, value)
return properties
}

invariant(
initialValue !== null,
'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.',
modelName,
propertyName.join('.'),
)

if (isModelValueType(initialValue)) {
log(
'"%s" has a plain initial value:',
`${modelName}.${propertyName}`,
Expand Down
16 changes: 14 additions & 2 deletions src/model/defineRelationalProperties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,23 @@ export function defineRelationalProperties(
relation.target.modelName,
)

const references: Value<any, ModelDictionary> | undefined = get(
const references: Value<any, ModelDictionary> | null | undefined = get(
initialValues,
propertyPath,
)

invariant(
references !== null || relation.attributes.nullable,
'Failed to define a "%s" relational property to "%s" on "%s": a non-nullable relation cannot be instantiated with null. Use the "nullable" function when defining this relation to support nullable value.',
relation.kind,
propertyPath.join('.'),
entity[ENTITY_TYPE],
)

log(
`setting relational property "${entity.__type}.${propertyPath.join('.')}" with references: %j`,
`setting relational property "${entity.__type}.${propertyPath.join(
'.',
)}" with references: %j`,
relation,
references,
)
Expand All @@ -41,6 +51,8 @@ export function defineRelationalProperties(

if (references) {
relation.resolveWith(entity, references)
} else if (relation.attributes.nullable) {
relation.resolveWith(entity, null)
}
}
}
2 changes: 1 addition & 1 deletion src/model/generateRestHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ export function generateRestHandlers<
const primaryKey = findPrimaryKey(modelDefinition)!
const primaryKeyValue = (
modelDefinition[primaryKey] as PrimaryKey<PrimaryKeyType>
).getValue()
).getPrimaryKeyValue()
const modelPath = pluralize(modelName)
const buildUrl = createUrlBuilder(baseUrl)

Expand Down
7 changes: 7 additions & 0 deletions src/model/parseModelDefinition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import { PrimaryKey } from '../primaryKey'
import { isObject } from '../utils/isObject'
import { Relation, RelationsList } from '../relations/Relation'
import { NullableProperty } from '../nullable'

const log = debug('parseModelDefinition')

Expand Down Expand Up @@ -69,6 +70,12 @@ function deepParseModelDefinition<Dictionary extends ModelDictionary>(
continue
}

if (value instanceof NullableProperty) {
// Add nullable properties to the same list as regular properties
result.properties.push(propertyPath)
continue
}

// Relations.
if (value instanceof Relation) {
// Store the relations in a separate object.
Expand Down
Loading

0 comments on commit cbf614c

Please sign in to comment.