diff --git a/package.json b/package.json index 0a952b8..216ea20 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "functional-models", - "version": "2.1.2", + "version": "2.1.3", "description": "A library for creating JavaScript function based models.", "main": "index.js", "types": "index.d.ts", diff --git a/src/interfaces.ts b/src/interfaces.ts index 813022d..2ca68a8 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -161,17 +161,19 @@ interface ModelReferencePropertyInstance< T extends FunctionalModel, TProperty extends Arrayable, TModel extends Model = Model, + TModelInstance extends ModelInstance = ModelInstance, > extends PropertyInstance { readonly getReferencedId: ( - instanceValues: ModelReference + instanceValues: ModelReference ) => Maybe readonly getReferencedModel: () => TModel } -type ModelReference = - | T - | TypedJsonObj - | PrimaryKeyType +type ModelReference< + T extends FunctionalModel, + TModel extends Model = Model, + TModelInstance extends ModelInstance = ModelInstance, +> = T | TModelInstance | TypedJsonObj | PrimaryKeyType type DefaultPropertyValidators = Readonly<{ required?: boolean @@ -204,10 +206,14 @@ type PropertyConfigContents> = Readonly<{ type ModelFetcher = < T extends FunctionalModel, TModel extends Model = Model, + TModelInstance extends ModelInstance = ModelInstance, >( model: TModel, primaryKey: PrimaryKeyType -) => Promise> | Promise | Promise +) => + | Promise> + | Promise + | Promise type PropertyConfig> = | (PropertyConfigContents & DefaultPropertyValidators) diff --git a/src/lib.ts b/src/lib.ts index 94e074d..a3cd6ea 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -29,6 +29,14 @@ const getValueForReferencedModel = async ( return modelInstance.references[head]() } const modelReference = await modelInstance.get[head]() + if (modelReference.toObj) { + const [nestedHead, nestedTail] = createHeadAndTail(tail.split('.'), '.') + const value = await modelReference.get[nestedHead]() + if (nestedTail) { + return get(value, nestedTail) + } + return value + } return get(modelReference, tail) } @@ -91,6 +99,11 @@ const mergeValidators = >( return [...validators, ...(config?.validators ? config.validators : [])] } +const isModelInstance = (obj: any): obj is ModelInstance => { + // @ts-ignore + return Boolean(obj && obj.getPrimaryKeyName) +} + export { isReferencedProperty, getValueForModelInstance, @@ -99,4 +112,5 @@ export { getValidatorFromConfigElseEmpty, getCommonNumberValidators, mergeValidators, + isModelInstance, } diff --git a/src/properties.ts b/src/properties.ts index af05d22..72bae6a 100644 --- a/src/properties.ts +++ b/src/properties.ts @@ -1,5 +1,10 @@ import merge from 'lodash/merge' -import { createPropertyValidator, isType, meetsRegex } from './validation' +import { + createPropertyValidator, + isType, + meetsRegex, + referenceTypeMatch, +} from './validation' import { PROPERTY_TYPES } from './constants' import { lazyValue } from './lazy' import { createHeadAndTail, createUuid } from './utils' @@ -20,6 +25,7 @@ import { FunctionalModel, JsonAble, PropertyModifier, + TypedJsonObj, } from './interfaces' import { getValueForModelInstance, @@ -28,6 +34,7 @@ import { getCommonTextValidators, getCommonNumberValidators, mergeValidators, + isModelInstance, } from './lib' const EMAIL_REGEX = @@ -279,15 +286,17 @@ const ModelReferenceProperty = < config: PropertyConfig = {}, additionalMetadata = {} ) => - AdvancedModelReferenceProperty, TModifier>( - model, - config, - additionalMetadata - ) + AdvancedModelReferenceProperty< + T, + Model, + ModelInstance>, + TModifier + >(model, config, additionalMetadata) const AdvancedModelReferenceProperty = < T extends FunctionalModel, TModel extends Model = Model, + TModelInstance extends ModelInstance = ModelInstance, TModifier extends PropertyModifier> = PropertyModifier< ModelReference >, @@ -307,10 +316,15 @@ const AdvancedModelReferenceProperty = < return model } - const validators = mergeValidators(config, []) + const validator = referenceTypeMatch(model) + const validators = mergeValidators(config, [ + // @ts-ignore + validator, + ]) const _getId = - (instanceValues: ModelReference) => (): Maybe => { + (instanceValues: ModelReference | TModifier) => + (): Maybe => { if (!instanceValues) { return null } @@ -320,29 +334,53 @@ const AdvancedModelReferenceProperty = < if (typeof instanceValues === 'string') { return instanceValues } + if ((instanceValues as TModelInstance).getPrimaryKey) { + return (instanceValues as TModelInstance).getPrimaryKey() + } const theModel = _getModel() const primaryKey = theModel.getPrimaryKeyName() - return (instanceValues as T)[primaryKey] as PrimaryKeyType + return (instanceValues as TypedJsonObj)[primaryKey] as PrimaryKeyType } - const lazyLoadMethod = async (instanceValues: T) => { - // Path for returning a TypedJsonObj / T + const lazyLoadMethod = async (instanceValues: TModifier) => { + const valueIsModelInstance = isModelInstance(instanceValues) + const _getInstanceReturn = (objToUse: TModifier) => { + // We need to determine if the object we just got is an actual model instance to determine if we need to make one. + const objIsModelInstance = isModelInstance(objToUse) + // @ts-ignore + const instance = objIsModelInstance + ? objToUse + : _getModel().create(objToUse as TypedJsonObj) + // We are replacing the toObj function, because the reference type in the end should be the primary key when serialized. + return merge({}, instance, { + toObj: _getId(instanceValues), + }) + } + + // @ts-ignore + if (valueIsModelInstance) { + return _getInstanceReturn(instanceValues) + } if (config?.fetcher) { const id = await _getId(instanceValues)() const model = _getModel() if (id !== null && id !== undefined) { - return config.fetcher(model, id) + const obj = await config.fetcher(model, id) + return _getInstanceReturn(obj as TModifier) } return null } - - // This is just an id. return _getId(instanceValues)() } - const p: ModelReferencePropertyInstance = merge( + const p: ModelReferencePropertyInstance< + T, + TModifier, + TModel, + TModelInstance + > = merge( Property( PROPERTY_TYPES.ReferenceProperty, merge({}, config, { @@ -352,7 +390,7 @@ const AdvancedModelReferenceProperty = < additionalMetadata ), { - getReferencedId: (instanceValues: ModelReference) => + getReferencedId: (instanceValues: ModelReference) => _getId(instanceValues)(), getReferencedModel: _getModel, } diff --git a/src/serialization.ts b/src/serialization.ts index 8e60e72..97204f3 100644 --- a/src/serialization.ts +++ b/src/serialization.ts @@ -5,8 +5,13 @@ import { toObj, FunctionalModel, TypedJsonObj, + ModelInstance, } from './interfaces' +const isModelInstance = (obj: any): obj is ModelInstance => { + return Boolean(obj.toObj) +} + const _getValue = async (value: any): Promise => { if (value === undefined) { return null @@ -20,9 +25,8 @@ const _getValue = async (value: any): Promise => { return _getValue(await asFunction()) } // Nested Object - const asModel = value.toObj - if (asModel) { - return _getValue(await asModel()) + if (isModelInstance(value)) { + return _getValue(await value.toObj()) } // Dates const asDate = value as Date diff --git a/src/validation.ts b/src/validation.ts index de7c479..2571501 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -19,6 +19,8 @@ import { ValidatorConfiguration, ValuePropertyValidatorComponent, ValidationErrors, + MaybeFunction, + PropertyValidatorComponentTypeAdvanced, } from './interfaces' const TYPE_PRIMITIVES = { @@ -393,6 +395,38 @@ const isValid = (errors: ModelErrors) => { return Object.keys(errors).length < 1 } +const referenceTypeMatch = < + T extends FunctionalModel, + TModel extends Model = Model, + TModelInstance extends ModelInstance = ModelInstance, +>( + referencedModel: MaybeFunction +): PropertyValidatorComponentTypeAdvanced< + ModelInstance, + T, + TModel, + TModelInstance +> => { + return (value?: ModelInstance) => { + if (!value) { + return 'Must include a value' + } + // This needs to stay here, as it delays the creation long enough for + // self referencing types. + const model = + typeof referencedModel === 'function' + ? referencedModel() + : referencedModel + // Assumption: By the time this is received, value === a model instance. + const eModel = model.getName() + const aModel = value.getModel().getName() + if (eModel !== aModel) { + return `Model should be ${eModel} instead, received ${aModel}` + } + return undefined + } +} + export { isNumber, isBoolean, @@ -415,4 +449,5 @@ export { arrayType, isValid, TYPE_PRIMITIVES, + referenceTypeMatch, } diff --git a/test/src/properties.test.ts b/test/src/properties.test.ts index 77fc114..e010f58 100644 --- a/test/src/properties.test.ts +++ b/test/src/properties.test.ts @@ -142,16 +142,16 @@ describe('/src/properties.ts', () => { primaryKey: PrimaryKeyType ) => { if (model.getName() === 'Model1') { - return { + return Model1.create({ id: 5, name: 'fake-model-data', - } + }) } if (model.getName() === 'Model2') { - return { + return Model2.create({ id: 10, name: 'fake-model-data-2', - } + }) } throw new Error(`Not gonna happen`) } @@ -244,16 +244,16 @@ describe('/src/properties.ts', () => { primaryKey: PrimaryKeyType ) => { if (model.getName() === 'Model1') { - return { + return Model1.create({ id: 5, name: 'fake-model-data', - } + }) } if (model.getName() === 'Model2') { - return { + return Model2.create({ id: 10, name: 'fake-model-data-2', - } + }) } throw new Error(`Not gonna happen`) } @@ -309,16 +309,16 @@ describe('/src/properties.ts', () => { primaryKey: PrimaryKeyType ) => { if (model.getName() === 'Model1') { - return { + return Model1.create({ id: 5, name: 'fake-model-data', - } + }) } if (model.getName() === 'Model2') { - return { + return Model2.create({ id: 10, name: 'fake-model-data-2', - } + }) } throw new Error(`Not gonna happen`) } @@ -1228,6 +1228,7 @@ describe('/src/properties.ts', () => { AdvancedModelReferenceProperty< MyType, MyModel, + MyModelInstance>, CustomReferenceType >(model) }) @@ -1305,6 +1306,25 @@ describe('/src/properties.ts', () => { const expected = 123 assert.equal(actual, expected) }) + it('should return a shallow object with a replaced toObj() function that returns 123 from when a ModelInstance is used and no fetcher is used', async () => { + const fakeModel = { + getPrimaryKeyName: () => 'not-real', + getPrimaryKey: () => 123, + } + const modelInstance = await ModelReferenceProperty( + TestModel1, + {} + ).createGetter( + // @ts-ignore + fakeModel, + {}, + {} as unknown as ModelInstance + )() + // @ts-ignore + const actual = await modelInstance?.toObj() + const expected = 123 + assert.equal(actual, expected) + }) it('should return name:"switch-a-roo" when switch-a-roo fetcher is used', async () => { const model = BaseModel('Test', { properties: { @@ -1313,7 +1333,7 @@ describe('/src/properties.ts', () => { }) const modelFetcher: ModelFetcher = (theirModel: any, key) => { - const m = { id: 123, name: 'switch-a-roo' } + const m = model.create({ id: 123, name: 'switch-a-roo' }) return Promise.resolve(m as any) } @@ -1326,10 +1346,10 @@ describe('/src/properties.ts', () => { 123, {}, {} as unknown as ModelInstance - )()) as TypedJsonObj + )()) as ModelInstance const expected = 'switch-a-roo' - assert.deepEqual(actual.name, expected) + assert.deepEqual(actual.get.name(), expected) }) it('should return "obj-id" if no config passed', async () => { // @ts-ignore diff --git a/test/src/validation.test.ts b/test/src/validation.test.ts index 40ac337..1a5bbfb 100644 --- a/test/src/validation.test.ts +++ b/test/src/validation.test.ts @@ -25,6 +25,7 @@ import { createModelValidator, createPropertyValidator, TYPE_PRIMITIVES, + referenceTypeMatch, } from '../../src/validation' import { ModelValidatorComponent, @@ -540,6 +541,63 @@ describe('/src/validation.ts', () => { assert.deepEqual(actual, expected) }) }) + describe('#referenceTypeMatch()', () => { + it('should return an error if undefined is passed as a value', () => { + const myModel = TestModel1.create({}) + const actual = referenceTypeMatch(TestModel1)( + // @ts-ignore + undefined, + EMPTY_MODEL_INSTANCE, + {}, + {} + ) + assert.isOk(actual) + }) + it('should return an error if null is passed as a value', () => { + const myModel = TestModel1.create({}) + const actual = referenceTypeMatch(TestModel1)( + // @ts-ignore + null, + EMPTY_MODEL_INSTANCE, + {}, + {} + ) + assert.isOk(actual) + }) + it('should allow a function for a model', async () => { + const myModel = EMPTY_MODEL.create({}) + const actual = referenceTypeMatch(() => EMPTY_MODEL)( + myModel, + myModel, + await myModel.toObj(), + {} + ) + const expected = undefined + assert.equal(actual, expected) + }) + it('should validate when the correct object matches the model', async () => { + const myModel = EMPTY_MODEL.create({}) + const actual = referenceTypeMatch(EMPTY_MODEL)( + myModel, + myModel, + await myModel.toObj(), + {} + ) + const expected = undefined + assert.equal(actual, expected) + }) + it('should return an error when the input does not match the model', () => { + const myModel = EMPTY_MODEL.create({}) + const actual = referenceTypeMatch(TestModel1)( + // @ts-ignore + myModel, + myModel, + {}, + {} + ) + assert.isOk(actual) + }) + }) describe('#createPropertyValidator()', () => { it('should accept an undefined configuration', async () => { // @ts-ignore