diff --git a/src/glossary.ts b/src/glossary.ts index 63326ab..06d1c3b 100644 --- a/src/glossary.ts +++ b/src/glossary.ts @@ -19,7 +19,10 @@ export type KeyType = string | number | symbol export type AnyObject = Record 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 @@ -219,8 +222,14 @@ export type Value< [Key in keyof Target]: Target[Key] extends PrimaryKey ? ReturnType : // Extract underlying value type of nullable properties - Target[Key] extends NullableProperty + Target[Key] extends NullableProperty< + PrimitiveValueType | PrimitiveValueType[] + > ? ReturnType + : // Extract underlying value type of nullable object properties + // - retrieve values of properties from object returned by getter + Target[Key] extends NullableProperty + ? Partial, Dictionary>> : // Extract value type from OneOf relations. Target[Key] extends OneOf ? Nullable extends true diff --git a/src/model/createModel.ts b/src/model/createModel.ts index 67d8e67..a8bb5ca 100644 --- a/src/model/createModel.ts +++ b/src/model/createModel.ts @@ -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') @@ -51,7 +52,7 @@ export function createModel< const publicProperties = properties.reduce>( (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) { @@ -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 diff --git a/src/model/getDefinition.ts b/src/model/getDefinition.ts new file mode 100644 index 0000000..3a1f54e --- /dev/null +++ b/src/model/getDefinition.ts @@ -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) +} diff --git a/src/model/parseModelDefinition.ts b/src/model/parseModelDefinition.ts index 7060a34..7fae44a 100644 --- a/src/model/parseModelDefinition.ts +++ b/src/model/parseModelDefinition.ts @@ -71,6 +71,17 @@ function deepParseModelDefinition( } 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 diff --git a/src/model/updateEntity.ts b/src/model/updateEntity.ts index 7b28247..79f5e1e 100644 --- a/src/model/updateEntity.ts +++ b/src/model/updateEntity.ts @@ -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') @@ -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) { diff --git a/src/nullable.ts b/src/nullable.ts index 585b0d5..60532c0 100644 --- a/src/nullable.ts +++ b/src/nullable.ts @@ -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 | null export class NullableProperty { public getValue: NullableGetter + // Indicates if needs to generate nested object properties when getter returns object + public isGetterFunctionReturningObject: boolean constructor(getter: NullableGetter) { this.getValue = getter + this.isGetterFunctionReturningObject = isObject(getter()) } } diff --git a/src/query/queryTypes.ts b/src/query/queryTypes.ts index 86de35b..c67ed82 100644 --- a/src/query/queryTypes.ts +++ b/src/query/queryTypes.ts @@ -5,6 +5,7 @@ import { Value, ModelValueType, ModelDefinitionValue, + PrimitiveValueType, } from '../glossary' export interface QueryOptions { @@ -40,7 +41,7 @@ export interface WeakQuerySelectorWhere { export type SortDirection = 'asc' | 'desc' export type RecursiveOrderBy = - Value extends ModelValueType + Value extends PrimitiveValueType | PrimitiveValueType[] ? SortDirection : Value extends AnyObject ? DeepRequiredExactlyOne<{ diff --git a/src/utils/isModelValueType.ts b/src/utils/isModelValueType.ts index 705ff03..ed3cc4d 100644 --- a/src/utils/isModelValueType.ts +++ b/src/utils/isModelValueType.ts @@ -1,4 +1,5 @@ import { ModelValueType, PrimitiveValueType } from '../glossary' +import { isObject } from './isObject' function isPrimitiveValueType(value: any): value is PrimitiveValueType { return ( @@ -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) } diff --git a/test/model/create.test.ts b/test/model/create.test.ts index cc052c7..9709445 100644 --- a/test/model/create.test.ts +++ b/test/model/create.test.ts @@ -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(() => 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(() => 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(() => 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
(() => 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
(() => 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(() => 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: { diff --git a/test/model/update.test.ts b/test/model/update.test.ts index 9706a10..9c0046c 100644 --- a/test/model/update.test.ts +++ b/test/model/update.test.ts @@ -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(() => 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(() => 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) + }) +})