Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Nullable object properties #231

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions src/glossary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ export type KeyType = string | number | symbol
export type AnyObject = Record<KeyType, any>
export type PrimaryKeyType = string | number
export type PrimitiveValueType = string | number | boolean | Date
export type ModelValueType = PrimitiveValueType | PrimitiveValueType[]
export type ModelValueType =
| PrimitiveValueType
| PrimitiveValueType[]
| AnyObject
export type ModelValueTypeGetter = () => ModelValueType

export type ModelDefinition = Record<string, ModelDefinitionValue>
Expand Down Expand Up @@ -219,8 +222,14 @@ export type Value<
[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>
Target[Key] extends NullableProperty<
PrimitiveValueType | PrimitiveValueType[]
>
? ReturnType<Target[Key]['getValue']>
: // Extract underlying value type of nullable object properties
// - retrieve values of properties from object returned by getter
Target[Key] extends NullableProperty<AnyObject>
? Partial<Value<ReturnType<Target[Key]['getValue']>, Dictionary>>
: // Extract value type from OneOf relations.
Target[Key] extends OneOf<infer ModelName, infer Nullable>
? Nullable extends true
Expand Down
12 changes: 11 additions & 1 deletion src/model/createModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { PrimaryKey } from '../primaryKey'
import { Relation } from '../relations/Relation'
import { NullableProperty } from '../nullable'
import { isModelValueType } from '../utils/isModelValueType'
import { getDefinition } from './getDefinition'

const log = debug('createModel')

Expand Down Expand Up @@ -51,7 +52,7 @@ export function createModel<
const publicProperties = properties.reduce<Record<string, unknown>>(
(properties, propertyName) => {
const initialValue = get(initialValues, propertyName)
const propertyDefinition = get(definition, propertyName)
const propertyDefinition = getDefinition(definition, propertyName)

// Ignore relational properties at this stage.
if (propertyDefinition instanceof Relation) {
Expand All @@ -68,6 +69,15 @@ export function createModel<
}

if (propertyDefinition instanceof NullableProperty) {
if (propertyDefinition.isGetterFunctionReturningObject) {
// Set the property to null to override default nested values returned from factory definition
if (initialValue === null) {
set(properties, propertyName, null)
}

return properties
}

const value =
initialValue === null || isModelValueType(initialValue)
? initialValue
Expand Down
22 changes: 22 additions & 0 deletions src/model/getDefinition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { NullableProperty } from '../nullable'
import { ModelDefinition } from '../glossary'

export function getDefinition(
definition: ModelDefinition,
propertyName: string[],
) {
return propertyName.reduce((acc, property) => {
const value = acc[property]

// Return the value of getter to generate values for nested properties
if (
value instanceof NullableProperty &&
value.isGetterFunctionReturningObject &&
property !== propertyName.at(-1)
) {
return value.getValue()
}

return value
}, definition)
}
11 changes: 11 additions & 0 deletions src/model/parseModelDefinition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,17 @@ function deepParseModelDefinition<Dictionary extends ModelDictionary>(
}

if (value instanceof NullableProperty) {
// Generate nested properties for nullable property returning object
if (value.isGetterFunctionReturningObject) {
deepParseModelDefinition(
dictionary,
modelName,
value.getValue(),
propertyPath,
result,
)
}

// Add nullable properties to the same list as regular properties
result.properties.push(propertyPath)
continue
Expand Down
3 changes: 2 additions & 1 deletion src/model/updateEntity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { isObject } from '../utils/isObject'
import { inheritInternalProperties } from '../utils/inheritInternalProperties'
import { NullableProperty } from '../nullable'
import { spread } from '../utils/spread'
import { getDefinition } from './getDefinition'

const log = debug('updateEntity')

Expand Down Expand Up @@ -38,7 +39,7 @@ export function updateEntity(
typeof value === 'function' ? value(prevValue, entity) : value
log('next value for "%s":', propertyPath, nextValue)

const propertyDefinition = get(definition, propertyPath)
const propertyDefinition = getDefinition(definition, propertyPath)
log('property definition for "%s":', propertyPath, propertyDefinition)

if (propertyDefinition == null) {
Expand Down
4 changes: 4 additions & 0 deletions src/nullable.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import { ModelValueType } from './glossary'
import { ManyOf, OneOf, Relation, RelationKind } from './relations/Relation'
import { isObject } from './utils/isObject'

export type NullableGetter<ValueType extends ModelValueType> =
() => ValueType | null

export class NullableProperty<ValueType extends ModelValueType> {
public getValue: NullableGetter<ValueType>
// Indicates if needs to generate nested object properties when getter returns object
public isGetterFunctionReturningObject: boolean

constructor(getter: NullableGetter<ValueType>) {
this.getValue = getter
this.isGetterFunctionReturningObject = isObject(getter())
}
}

Expand Down
3 changes: 2 additions & 1 deletion src/query/queryTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
Value,
ModelValueType,
ModelDefinitionValue,
PrimitiveValueType,
} from '../glossary'

export interface QueryOptions {
Expand Down Expand Up @@ -40,7 +41,7 @@ export interface WeakQuerySelectorWhere<KeyType extends PrimaryKeyType> {
export type SortDirection = 'asc' | 'desc'

export type RecursiveOrderBy<Value extends ModelDefinitionValue> =
Value extends ModelValueType
Value extends PrimitiveValueType | PrimitiveValueType[]
? SortDirection
: Value extends AnyObject
? DeepRequiredExactlyOne<{
Expand Down
3 changes: 2 additions & 1 deletion src/utils/isModelValueType.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ModelValueType, PrimitiveValueType } from '../glossary'
import { isObject } from './isObject'

function isPrimitiveValueType(value: any): value is PrimitiveValueType {
return (
Expand All @@ -10,5 +11,5 @@ function isPrimitiveValueType(value: any): value is PrimitiveValueType {
}

export function isModelValueType(value: any): value is ModelValueType {
return isPrimitiveValueType(value) || Array.isArray(value)
return isPrimitiveValueType(value) || Array.isArray(value) || isObject(value)
}
125 changes: 125 additions & 0 deletions test/model/create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,131 @@ test('creates a new entity with nullable properties', () => {
expect(user.address.number).toEqual(null)
})

describe('nullable object property', () => {
describe('when object getter is provided in factory definition', () => {
it('defaults to value provided in factory when not set during model creation', () => {
const db = factory({
user: {
id: primaryKey(faker.datatype.uuid),
address: nullable(() => ({
street: () => 'Wall Street',
number: nullable<number>(() => null),
})),
},
})

const user = db.user.create()

expect(user.address).toEqual({
street: 'Wall Street',
number: null,
})
})

it('equals to null when explicitly provided during model creation', () => {
const db = factory({
user: {
id: primaryKey(faker.datatype.uuid),
address: nullable(() => ({
street: () => 'Wall Street',
number: nullable<number>(() => null),
})),
},
})

const user = db.user.create({ address: null })

expect(user.address).toEqual(null)
})

it('equals to value provided during model creation', () => {
const db = factory({
user: {
id: primaryKey(faker.datatype.uuid),
address: nullable(() => ({
street: String,
number: nullable<number>(() => null),
})),
},
})

const user = db.user.create({
address: { street: 'Baker Street', number: 123 },
})

expect(user.address).toEqual({ street: 'Baker Street', number: 123 })
})
})

describe('when getter returning null is provided in factory definition', () => {
it('defaults to null when value is not provided during model creation', () => {
type Address = {
street: string
number: number | null
}

const db = factory({
user: {
id: primaryKey(faker.datatype.uuid),
address: nullable<Address>(() => null),
},
})

const user = db.user.create()

expect(user.address).toEqual(null)
})

it('equals to value provided during model creation', () => {
type Address = {
street: string
number: number | null
}

const db = factory({
user: {
id: primaryKey(faker.datatype.uuid),
address: nullable<Address>(() => null),
},
})

const user = db.user.create({
address: { street: 'Baker Street', number: 123 },
})

expect(user.address).toEqual({ street: 'Baker Street', number: 123 })
})
})

it('support nested nullable objects recursively in model definition', () => {
const db = factory({
user: {
id: primaryKey(faker.datatype.uuid),
address: nullable(() => ({
street: () => 'Wall Street',
number: nullable(() => ({
house: () => 123,
flat: nullable<number>(() => null),
})),
})),
},
})

const user = db.user.create({
address: {
number: {
flat: 456,
},
},
})

expect(user.address).toEqual({
street: 'Wall Street',
number: { house: 123, flat: 456 },
})
})
})

test('supports nested objects in the model definition', () => {
const db = factory({
user: {
Expand Down
49 changes: 49 additions & 0 deletions test/model/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -593,3 +593,52 @@ test('throws when setting a non-nullable property to null', () => {
'Failed to update "firstName" on "user": cannot set a non-nullable property to null.',
)
})

describe('nullable object properties', () => {
it('updates property initially set to null with some value', () => {
const db = factory({
user: {
id: primaryKey(faker.datatype.uuid),
address: nullable(() => ({
street: String,
number: nullable<number>(() => null),
})),
},
})

const user = db.user.create({ address: null })

const updatedUser = db.user.update({
where: { id: { equals: user.id } },
data: { address: { street: 'Wall Street', number: 123 } },
})

expect(updatedUser?.address).toEqual({ street: 'Wall Street', number: 123 })
})

it('updates property initially set to some value with null', () => {
const db = factory({
user: {
id: primaryKey(faker.datatype.uuid),
address: nullable(() => ({
street: String,
number: nullable<number>(() => null),
})),
},
})

const user = db.user.create({
address: {
street: 'Wall Street',
number: 123,
},
})

const updatedUser = db.user.update({
where: { id: { equals: user.id } },
data: { address: null },
})

expect(updatedUser?.address).toEqual(null)
})
})