mobx-keystone
Version:
A MobX powered state management solution based on data trees with first class support for TypeScript, snapshots, patches and much more
377 lines (326 loc) • 12.9 kB
text/typescript
import { applySet } from "../action/applySet"
import { getCurrentActionContext } from "../action/context"
import { modelAction } from "../action/modelAction"
import { AnyDataModel, BaseDataModel, baseDataModelPropNames } from "../dataModel/BaseDataModel"
import type { DataModelConstructorOptions } from "../dataModel/DataModelConstructorOptions"
import type { DataModelMetadata } from "../dataModel/getDataModelMetadata"
import { getGlobalConfig } from "../globalConfig/globalConfig"
import { AnyModel, BaseModel, baseModelPropNames } from "../model/BaseModel"
import type { ModelMetadata } from "../model/getModelMetadata"
import { modelTypeKey } from "../model/metadata"
import type { ModelConstructorOptions } from "../model/ModelConstructorOptions"
import { typesObject } from "../types/objectBased/typesObject"
import { typesString } from "../types/primitiveBased/typesPrimitive"
import type { AnyType } from "../types/schemas"
import { tProp } from "../types/tProp"
import type { LateTypeChecker } from "../types/TypeChecker"
import { typesUnchecked } from "../types/utility/typesUnchecked"
import { addHiddenProp, assertIsObject, failure, propNameToSetterName } from "../utils"
import { chainFns } from "../utils/chainFns"
import { ModelClass, modelInitializedSymbol } from "./BaseModelShared"
import { ModelClassInitializer, modelInitializersSymbol } from "./modelClassInitializer"
import { getInternalModelClassPropsInfo, setInternalModelClassPropsInfo } from "./modelPropsInfo"
import { modelMetadataSymbol, modelUnwrappedClassSymbol } from "./modelSymbols"
import { AnyModelProp, getModelPropDefaultValue, ModelProps, noDefaultValue, prop } from "./prop"
import { assertIsClassOrDataModelClass } from "./utils"
function createGetModelInstanceDataField<M extends AnyModel | AnyDataModel>(
modelProp: AnyModelProp,
modelPropName: string
): (this: M) => unknown {
const transformFn = modelProp._transform?.transform
if (!transformFn) {
// no need to use get since these vars always get on the initial $
return function (this) {
return this.$[modelPropName]
}
}
const transformValue = (model: M, value: unknown) =>
transformFn(value, model, modelPropName, (newValue) => {
// use apply set instead to wrap it in an action
// set the $ object to set the original value directly
applySet(model.$, modelPropName, newValue)
})
return function (this) {
// no need to use get since these vars always get on the initial $
const value = this.$[modelPropName]
return transformValue(this, value)
}
}
type SetModelInstanceDataFieldFn = <M extends AnyModel | AnyDataModel>(
modelProp: AnyModelProp,
modelPropName: string,
model: M,
value: unknown
) => boolean | void
const setModelInstanceDataField: SetModelInstanceDataFieldFn = (
modelProp,
modelPropName,
model,
value
): void => {
if (modelProp._setter === "assign" && !getCurrentActionContext()) {
// use apply set instead to wrap it in an action
applySet(model, modelPropName as any, value)
return
}
let untransformedValue = modelProp._transform
? modelProp._transform.untransform(value, model, modelPropName)
: value
// apply default value if applicable
if (untransformedValue == null) {
const defaultValue = getModelPropDefaultValue(modelProp)
if (defaultValue !== noDefaultValue) {
untransformedValue = defaultValue
}
}
// no need to use set since these vars always get on the initial $
model.$[modelPropName] = untransformedValue
}
const setModelInstanceDataFieldWithPrecheck: SetModelInstanceDataFieldFn = (
modelProp,
modelPropName,
model,
value
): boolean => {
// hack to only permit setting these values once fully constructed
// this is to ignore abstract properties being set by babel
// see https://github.com/xaviergonz/mobx-keystone/issues/18
if (!(model as any)[modelInitializedSymbol]) {
return false
}
setModelInstanceDataField(modelProp, modelPropName, model, value)
return true
}
const idGenerator = () => getGlobalConfig().modelIdGenerator()
const tPropForId = tProp(typesString, idGenerator)
tPropForId._isId = true
const propForId = prop(idGenerator)
propForId._isId = true
type FromSnapshotProcessorFn = (sn: any) => any
type ToSnapshotProcessorFn = (sn: any, instance: any) => any
export function sharedInternalModel<
TProps extends ModelProps,
TBaseModel extends AnyModel | AnyDataModel,
>({
modelProps,
baseModel,
type,
valueType,
fromSnapshotProcessor,
toSnapshotProcessor,
}: {
modelProps: TProps
baseModel: ModelClass<TBaseModel> | undefined
type: "class" | "data"
valueType: boolean
fromSnapshotProcessor: FromSnapshotProcessorFn | undefined
toSnapshotProcessor: ToSnapshotProcessorFn | undefined
}): any {
assertIsObject(modelProps, "modelProps")
// make sure we avoid prototype pollution
modelProps = Object.assign(Object.create(null), modelProps)
if (baseModel) {
assertIsClassOrDataModelClass(baseModel, "baseModel")
// if the baseModel is wrapped with the model decorator get the original one
const unwrappedClass = (baseModel as any)[modelUnwrappedClassSymbol]
if (unwrappedClass) {
baseModel = unwrappedClass
assertIsClassOrDataModelClass(baseModel, "baseModel")
}
}
const composedModelProps: ModelProps = modelProps
if (baseModel) {
const oldModelProps = getInternalModelClassPropsInfo(baseModel)
for (const oldModelPropKey of Object.keys(oldModelProps)) {
if (!modelProps[oldModelPropKey]) {
composedModelProps[oldModelPropKey] = oldModelProps[oldModelPropKey]
}
}
}
// look for id keys
const idKeys = Object.keys(composedModelProps).filter((k) => {
const p = composedModelProps[k]
return p._isId
})
if (type === "class") {
if (idKeys.length > 1) {
throw failure(`expected at most one idProp but got many: ${JSON.stringify(idKeys)}`)
}
} else if (idKeys.length > 0) {
throw failure(`expected no idProp but got some: ${JSON.stringify(idKeys)}`)
}
const needsTypeChecker = Object.values(composedModelProps).some((mp) => !!mp._typeChecker)
// transform id keys (only one really)
let idKey: string | undefined
if (idKeys.length > 0) {
idKey = idKeys[0]
const idProp = composedModelProps[idKey]
let baseProp: AnyModelProp = needsTypeChecker ? tPropForId : propForId
switch (idProp._setter) {
case true:
baseProp = baseProp.withSetter()
break
case "assign":
baseProp = baseProp.withSetter("assign")
break
default:
break
}
composedModelProps[idKey] = baseProp
}
// create type checker if needed
let dataTypeChecker: LateTypeChecker | undefined
if (needsTypeChecker) {
const typeCheckerObj: {
[k: string]: any
} = {}
for (const [k, mp] of Object.entries(composedModelProps)) {
typeCheckerObj[k] = mp._typeChecker ? mp._typeChecker : typesUnchecked()
}
dataTypeChecker = typesObject(() => typeCheckerObj) as any
}
const base: any = baseModel ?? (type === "class" ? BaseModel : BaseDataModel)
const basePropNames = type === "class" ? baseModelPropNames : baseDataModelPropNames
let propsToDeleteFromBase: string[] | undefined
// we use this weird hack rather than just class CustomBaseModel extends base {}
// in order to work around problems with ES5 classes extending ES6 classes
// see https://github.com/xaviergonz/mobx-keystone/issues/15
function ThisModel(
this: any,
initialData: any,
constructorOptions?: ModelConstructorOptions | DataModelConstructorOptions
) {
const modelClass = constructorOptions?.modelClass ?? this.constructor
const baseModel = new base(initialData, {
...constructorOptions,
modelClass,
} as ModelConstructorOptions & DataModelConstructorOptions)
// add prop, it is faster than having to go to the root of the prototype to not find it
addHiddenProp(baseModel, modelInitializedSymbol, false, true)
// make sure abstract classes do not override prototype props
if (!propsToDeleteFromBase) {
propsToDeleteFromBase = Object.keys(modelProps).filter(
(p) => !basePropNames.has(p as any) && Object.hasOwn(baseModel, p)
)
}
propsToDeleteFromBase.forEach((prop) => {
delete baseModel[prop]
})
return baseModel
}
// copy static props from base
Object.assign(ThisModel, base)
const initializers: ModelClassInitializer[] = base[modelInitializersSymbol]
if (initializers) {
ThisModel[modelInitializersSymbol] = initializers.slice()
}
setInternalModelClassPropsInfo(ThisModel as any, composedModelProps)
if (type === "class") {
const metadata: ModelMetadata = {
dataType: dataTypeChecker as unknown as AnyType | undefined,
modelIdProperty: idKey,
valueType,
}
ThisModel[modelMetadataSymbol] = metadata
} else {
const metadata: DataModelMetadata = {
dataType: dataTypeChecker as unknown as AnyType | undefined,
}
ThisModel[modelMetadataSymbol] = metadata
}
ThisModel.prototype = Object.create(base.prototype)
ThisModel.prototype.constructor = ThisModel
let setFn: SetModelInstanceDataFieldFn = (modelProp, modelPropName, model, value) => {
if (setModelInstanceDataFieldWithPrecheck(modelProp, modelPropName, model, value)) {
setFn = setModelInstanceDataField
}
}
for (const [propName, propData] of Object.entries(modelProps)) {
if (!(basePropNames as Set<string>).has(propName)) {
const get = createGetModelInstanceDataField(propData, propName)
Object.defineProperty(ThisModel.prototype, propName, {
get,
set(value: unknown) {
setFn(propData, propName, this, value)
},
enumerable: true,
configurable: false,
})
}
if (propData._setter === true) {
const setterName = propNameToSetterName(propName)
if (!(basePropNames as Set<string>).has(setterName)) {
const newPropDescriptor: any = modelAction(ThisModel.prototype, setterName, {
value: function (this: any, value: any) {
this[propName] = value
},
writable: true,
enumerable: false,
configurable: false,
})
Object.defineProperty(ThisModel.prototype, setterName, newPropDescriptor)
}
}
}
const modelPropsFromSnapshotProcessor = getModelPropsFromSnapshotProcessor(composedModelProps)
const modelPropsToSnapshotProcessor = getModelPropsToSnapshotProcessor(composedModelProps)
if (fromSnapshotProcessor) {
const fn = fromSnapshotProcessor
fromSnapshotProcessor = (sn) => {
return {
...fn(sn),
[modelTypeKey]: sn[modelTypeKey],
}
}
}
if (toSnapshotProcessor) {
const fn = toSnapshotProcessor
toSnapshotProcessor = (sn, modelInstance) => {
return {
...fn(sn, modelInstance),
[modelTypeKey]: sn[modelTypeKey],
}
}
}
ThisModel.fromSnapshotProcessor = chainFns(fromSnapshotProcessor, modelPropsFromSnapshotProcessor)
ThisModel.toSnapshotProcessor = chainFns(modelPropsToSnapshotProcessor, toSnapshotProcessor)
return ThisModel
}
function getModelPropsFromSnapshotProcessor(
composedModelProps: ModelProps
): FromSnapshotProcessorFn | undefined {
const propsWithFromSnapshotProcessor = Object.entries(composedModelProps).filter(
([_propName, propData]) => propData._fromSnapshotProcessor
)
if (propsWithFromSnapshotProcessor.length <= 0) {
return undefined
}
return (sn) => {
const newSn = { ...sn }
for (const [propName, propData] of propsWithFromSnapshotProcessor) {
if (propData._fromSnapshotProcessor) {
newSn[propName] = propData._fromSnapshotProcessor(sn[propName])
}
}
return newSn
}
}
function getModelPropsToSnapshotProcessor(
composedModelProps: ModelProps
): ToSnapshotProcessorFn | undefined {
const propsWithToSnapshotProcessor = Object.entries(composedModelProps).filter(
([_propName, propData]) => propData._toSnapshotProcessor
)
if (propsWithToSnapshotProcessor.length <= 0) {
return undefined
}
return (sn) => {
const newSn = { ...sn }
for (const [propName, propData] of propsWithToSnapshotProcessor) {
if (propData._toSnapshotProcessor) {
newSn[propName] = propData._toSnapshotProcessor(sn[propName])
}
}
return newSn
}
}