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

Add runtime type validation #195

Draft
wants to merge 14 commits into
base: master
Choose a base branch
from
Draft
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
2 changes: 2 additions & 0 deletions packages/lib/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,11 @@
"mobx": "^6.0.0 || ^5.0.0 || ^4.0.0"
},
"devDependencies": {
"@types/dedent": "^0.7.0",
"@types/jest": "^26.0.0",
"@typescript-eslint/eslint-plugin": "^4.5.0",
"@typescript-eslint/parser": "^4.5.0",
"dedent": "^0.7.0",
"eslint": "^7.12.1",
"eslint-plugin-flowtype": "^5.2.0",
"eslint-plugin-jest": "^24.1.0",
Expand Down
7 changes: 4 additions & 3 deletions packages/lib/src/fnModel/fnModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { isModelAutoTypeCheckingEnabled } from "../globalConfig/globalConfig"
import { toTreeNode } from "../tweaker/tweak"
import { AnyStandardType, TypeToData } from "../typeChecking/schemas"
import { typeCheck } from "../typeChecking/typeCheck"
import { throwTypeCheckErrors } from "../typeChecking/TypeCheckErrors"
import { assertIsString } from "../utils"
import { extendFnModelActions, FnModelActions, FnModelActionsDef } from "./actions"
import { extendFnModelFlowActions, FnModelFlowActions, FnModelFlowActionsDef } from "./flowActions"
Expand Down Expand Up @@ -130,9 +131,9 @@ function fnModelCreateWithoutType<Data extends object>(data: Data): Data {

function fnModelCreateWithType<Data extends object>(actualType: AnyStandardType, data: Data): Data {
if (isModelAutoTypeCheckingEnabled()) {
const errors = typeCheck(actualType, data)
if (errors) {
errors.throw(data)
const err = typeCheck(actualType, data)
if (err) {
throwTypeCheckErrors(err, data)
}
}
return toTreeNode(data)
Expand Down
6 changes: 6 additions & 0 deletions packages/lib/src/globalConfig/globalConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ export interface GlobalConfig {
*/
modelAutoTypeChecking: ModelAutoTypeCheckingMode

/**
* Model auto type-validation.
*/
modelAutoTypeValidation: boolean

/**
* ID generator function for model ids.
*/
Expand Down Expand Up @@ -59,6 +64,7 @@ function defaultModelIdGenerator(): string {
// defaults
let globalConfig: GlobalConfig = {
modelAutoTypeChecking: ModelAutoTypeCheckingMode.DevModeOnly,
modelAutoTypeValidation: false,
modelIdGenerator: defaultModelIdGenerator,
allowUndefinedArrayElements: false,
showDuplicateModelNameWarnings: true,
Expand Down
6 changes: 3 additions & 3 deletions packages/lib/src/model/BaseModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { getSnapshot } from "../snapshot/getSnapshot"
import { SnapshotInOfModel, SnapshotInOfObject, SnapshotOutOfModel } from "../snapshot/SnapshotOf"
import { typesModel } from "../typeChecking/model"
import { typeCheck } from "../typeChecking/typeCheck"
import { TypeCheckError } from "../typeChecking/TypeCheckError"
import { TypeCheckErrors } from "../typeChecking/TypeCheckErrors"
import { assertIsObject } from "../utils"
import { getModelIdPropertyName } from "./getModelMetadata"
import { modelIdKey, modelTypeKey } from "./metadata"
Expand Down Expand Up @@ -138,9 +138,9 @@ export abstract class BaseModel<
* Performs a type check over the model instance.
* For this to work a data type has to be declared in the model decorator.
*
* @returns A `TypeCheckError` or `null` if there is no error.
* @returns A `TypeCheckErrors` or `null` if there is no error.
*/
typeCheck(): TypeCheckError | null {
typeCheck(): TypeCheckErrors | null {
const type = typesModel<this>(this.constructor as any)
return typeCheck(type, this as any)
}
Expand Down
5 changes: 5 additions & 0 deletions packages/lib/src/model/getModelMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ export interface ModelMetadata {
*/
dataType?: AnyType

/**
* Associated validation type for runtime checking (if any).
*/
validationType?: AnyType

/**
* Property used as model id (usually `$modelId` unless overridden).
*/
Expand Down
30 changes: 26 additions & 4 deletions packages/lib/src/model/modelDecorator.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { HookAction } from "../action/hookActions"
import { wrapModelMethodInActionIfNeeded } from "../action/wrapInAction"
import { getGlobalConfig } from "../globalConfig"
import { AnyStandardType, TypeToData } from "../typeChecking/schemas"
import {
addHiddenProp,
failure,
Expand All @@ -12,21 +13,41 @@ import {
import { AnyModel, ModelClass, modelInitializedSymbol } from "./BaseModel"
import { modelTypeKey } from "./metadata"
import { modelInfoByClass, modelInfoByName } from "./modelInfo"
import { modelUnwrappedClassSymbol } from "./modelSymbols"
import { modelMetadataSymbol, modelUnwrappedClassSymbol } from "./modelSymbols"
import { assertIsModelClass } from "./utils"

type AllKeys<T> = T extends unknown ? keyof T : never

type AllValues<T, K extends keyof any> = T extends object
? K extends keyof T
? T[K]
: never
: never

type Unionize<T> = {
[K in AllKeys<T>]: AllValues<T, K>
}

/**
* Decorator that marks this class (which MUST inherit from the `Model` abstract class)
* as a model.
*
* @typeparam ValidationType Validation type.
* @param name Unique name for the model type. Note that this name must be unique for your whole
* application, so it is usually a good idea to use some prefix unique to your application domain.
* @param validationType Runtime validation type.
*/
export const model = (name: string) => <MC extends ModelClass<AnyModel>>(clazz: MC): MC => {
return internalModel(name)(clazz)
export const model = <ValidationType extends AnyStandardType>(
name: string,
validationType?: ValidationType
) => <MC extends ModelClass<AnyModel & Unionize<TypeToData<ValidationType>>>>(clazz: MC): MC => {
return internalModel(name, validationType)(clazz)
}

const internalModel = (name: string) => <MC extends ModelClass<AnyModel>>(clazz: MC): MC => {
const internalModel = <ValidationType extends AnyStandardType>(
name: string,
validationType?: ValidationType
) => <MC extends ModelClass<AnyModel & Unionize<TypeToData<ValidationType>>>>(clazz: MC): MC => {
assertIsModelClass(clazz, "a model class")

if (modelInfoByName[name]) {
Expand Down Expand Up @@ -91,6 +112,7 @@ const internalModel = (name: string) => <MC extends ModelClass<AnyModel>>(clazz:

clazz.toString = () => `class ${clazz.name}#${name}`
;(clazz as any)[modelTypeKey] = name
;(clazz as any)[modelMetadataSymbol].validationType = validationType

// this also gives access to modelInitializersSymbol, modelPropertiesSymbol, modelDataTypeCheckerSymbol
Object.setPrototypeOf(newClazz, clazz)
Expand Down
16 changes: 14 additions & 2 deletions packages/lib/src/model/newModel.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { action, set } from "mobx"
import { O } from "ts-toolbelt"
import { isModelAutoTypeCheckingEnabled } from "../globalConfig/globalConfig"
import { getGlobalConfig, isModelAutoTypeCheckingEnabled } from "../globalConfig/globalConfig"
import { getParent } from "../parent/path"
import { tweakModel } from "../tweaker/tweakModel"
import { tweakPlainObject } from "../tweaker/tweakPlainObject"
import { throwTypeCheckErrors } from "../typeChecking/TypeCheckErrors"
import { validationContext } from "../typeChecking/validation"
import { failure, inDevMode, makePropReadonly } from "../utils"
import { AnyModel, ModelPropsCreationData } from "./BaseModel"
import { getModelIdPropertyName, getModelMetadata } from "./getModelMetadata"
Expand Down Expand Up @@ -114,7 +117,7 @@ export const internalNewModel = action(
if (isModelAutoTypeCheckingEnabled() && getModelMetadata(modelClass).dataType) {
const err = modelObj.typeCheck()
if (err) {
err.throw(modelObj)
throwTypeCheckErrors(err, modelObj)
}
}

Expand All @@ -128,6 +131,15 @@ export const internalNewModel = action(
}
}

// validate model and provide the result via a context if needed
if (getGlobalConfig().modelAutoTypeValidation) {
validationContext.setComputed(modelObj, () => {
const parent = getParent(modelObj)
const result = parent ? validationContext.get(parent)! : modelObj.typeCheck()
return result
})
}

return modelObj as M
}
)
11 changes: 7 additions & 4 deletions packages/lib/src/parent/path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,11 +264,14 @@ const unresolved = { resolved: false } as const
* @typeparam T Returned value type.
* @param pathRootObject Object that serves as path root.
* @param path Path as an string or number array.
* @param includeModelDataObjects Pass `true` to include model interim data objects (`$`) explicitly
* in `path` or `false` to automatically traverse to `$` for all model nodes (defaults to `false`).
* @returns An object with `{ resolved: true, value: T }` or `{ resolved: false }`.
*/
export function resolvePath<T = any>(
pathRootObject: object,
path: Path
path: Path,
includeModelDataObjects: boolean = false
):
| {
resolved: true
Expand All @@ -281,7 +284,7 @@ export function resolvePath<T = any>(
// unit tests rely on this to work with any object
// assertTweakedObject(pathRootObject, "pathRootObject")

let current: any = modelToDataNode(pathRootObject)
let current: any = includeModelDataObjects ? pathRootObject : modelToDataNode(pathRootObject)

let len = path.length
for (let i = 0; i < len; i++) {
Expand All @@ -296,10 +299,10 @@ export function resolvePath<T = any>(
return unresolved
}

current = modelToDataNode(current[p])
current = includeModelDataObjects ? current[p] : modelToDataNode(current[p])
}

return { resolved: true, value: dataToModelNode(current) }
return { resolved: true, value: includeModelDataObjects ? current : dataToModelNode(current) }
}

/**
Expand Down
3 changes: 2 additions & 1 deletion packages/lib/src/tweaker/typeChecking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { findParent } from "../parent/findParent"
import { internalApplyPatches } from "../patch/applyPatches"
import { InternalPatchRecorder } from "../patch/emitPatch"
import { invalidateCachedTypeCheckerResult } from "../typeChecking/TypeChecker"
import { throwTypeCheckErrors } from "../typeChecking/TypeCheckErrors"
import { runWithoutSnapshotOrPatches } from "./core"

/**
Expand All @@ -27,7 +28,7 @@ export function runTypeCheckingAfterChange(obj: object, patchRecorder: InternalP
internalApplyPatches.call(obj, patchRecorder.invPatches, true)
})
// at the end of apply patches it will be type checked again and its result cached once more
err.throw(parentModelWithTypeChecker)
throwTypeCheckErrors(err, parentModelWithTypeChecker)
}
}
}
Expand Down
36 changes: 0 additions & 36 deletions packages/lib/src/typeChecking/TypeCheckError.ts

This file was deleted.

Loading