UNPKG

@travetto/schema

Version:

Data type registry for runtime validation, reflection and binding.

498 lines (438 loc) 15.6 kB
import { Class, AppError, describeFunction, castTo, classConstruct, asFull, castKey } from '@travetto/runtime'; import { MetadataRegistry, RootRegistry, ChangeEvent } from '@travetto/registry'; import { ClassList, FieldConfig, ClassConfig, SchemaConfig, ViewFieldsConfig, ViewConfig, SchemaMethodConfig } from './types'; import { SchemaChangeListener } from './changes'; import { AllViewSymbol } from '../internal/types'; import { MethodValidatorFn } from '../validate/types'; const classToSubTypeName = (cls: Class): string => cls.name .replace(/([A-Z])([A-Z][a-z])/g, (all, l, r) => `${l}_${r.toLowerCase()}`) .replace(/([a-z]|\b)([A-Z])/g, (all, l, r) => l ? `${l}_${r.toLowerCase()}` : r.toLowerCase()) .toLowerCase(); /** * Schema registry for listening to changes */ class $SchemaRegistry extends MetadataRegistry<ClassConfig, FieldConfig> { #accessorDescriptors = new Map<Class, Map<string, PropertyDescriptor>>(); #subTypes = new Map<Class, Map<string, Class>>(); #pendingViews = new Map<Class, Map<string, ViewFieldsConfig<unknown>>>(); #baseSchema = new Map<Class, Class>(); constructor() { super(RootRegistry); } /** * Find base schema class for a given class */ getBaseSchema(cls: Class): Class { if (!this.#baseSchema.has(cls)) { let conf = this.get(cls) ?? this.getOrCreatePending(cls); let parent = cls; while (conf && !conf.baseType) { parent = this.getParentClass(parent)!; conf = this.get(parent) ?? this.pending.get(MetadataRegistry.id(parent)); } this.#baseSchema.set(cls, conf ? parent : cls); } return this.#baseSchema.get(cls)!; } /** * Retrieve class level metadata * @param cls * @param prop * @param key * @returns */ getMetadata<K>(cls: Class, key: symbol): K | undefined { const cfg = this.get(cls); return castTo(cfg.metadata?.[key]); } /** * Retrieve pending class level metadata, or create if needed * @param cls * @param prop * @param key * @returns */ getOrCreatePendingMetadata<K>(cls: Class, key: symbol, value: K): K { const cfg = this.getOrCreatePending(cls); return castTo((cfg.metadata ??= {})[key] ??= value); } /** * Ensure type is set properly */ ensureInstanceTypeField<T>(cls: Class, o: T): void { const schema = this.get(cls); const typeField = castKey<T>(schema.subTypeField); if (schema.subTypeName && typeField in schema.views[AllViewSymbol].schema && !o[typeField]) { // Do we have a type field defined o[typeField] = castTo(schema.subTypeName); // Assign if missing } } /** * Provides the prototype-derived descriptor for a property */ getAccessorDescriptor(cls: Class, field: string): PropertyDescriptor { if (!this.#accessorDescriptors.has(cls)) { this.#accessorDescriptors.set(cls, new Map()); } const map = this.#accessorDescriptors.get(cls)!; if (!map.has(field)) { let proto = cls.prototype; while (proto && !Object.hasOwn(proto, field)) { proto = proto.prototype; } map.set(field, Object.getOwnPropertyDescriptor(proto, field)!); } return map.get(field)!; } /** * Find the resolved type for a given instance * @param cls Class for instance * @param o Actual instance */ resolveInstanceType<T>(cls: Class<T>, o: T): Class { cls = this.get(cls.Ⲑid).class; // Resolve by id to handle any stale references const base = this.getBaseSchema(cls); const clsSchema = this.get(cls); const baseSchema = this.get(base); if (clsSchema.subTypeName || baseSchema.baseType) { // We have a sub type const type = castTo<string>(o[castKey<T>(baseSchema.subTypeField)]) ?? clsSchema.subTypeName ?? baseSchema.subTypeName; const ret = this.#subTypes.get(base)!.get(type)!; if (ret && !(classConstruct(ret) instanceof cls)) { throw new AppError(`Resolved class ${ret.name} is not assignable to ${cls.name}`); } return ret; } else { return cls; } } /** * Return all subtypes by discriminator for a given class * @param cls The base class to resolve from */ getSubTypesForClass(cls: Class): Class[] | undefined { const res = this.#subTypes.get(cls)?.values(); return res ? [...res] : undefined; } /** * Register sub types for a class * @param cls The class to register against * @param name The subtype name */ registerSubTypes(cls: Class, name?: string): void { // Mark as subtype const config = (this.get(cls) ?? this.getOrCreatePending(cls)); let base: Class | undefined = this.getBaseSchema(cls); if (!this.#subTypes.has(base)) { this.#subTypes.set(base, new Map()); } if (base !== cls || config.baseType) { config.subTypeField = (this.get(base) ?? this.getOrCreatePending(base)).subTypeField; config.subTypeName = name ?? config.subTypeName ?? classToSubTypeName(cls); this.#subTypes.get(base)!.set(config.subTypeName!, cls); } if (base !== cls) { while (base && base.Ⲑid) { this.#subTypes.get(base)!.set(config.subTypeName!, cls); const parent = this.getParentClass(base); base = parent ? this.getBaseSchema(parent) : undefined; } } } /** * Track changes to schemas, and track the dependent changes * @param cls The root class of the hierarchy * @param curr The new class * @param path The path within the object hierarchy */ trackSchemaDependencies(cls: Class, curr: Class = cls, path: FieldConfig[] = []): void { const config = this.get(curr); SchemaChangeListener.trackSchemaDependency(curr, cls, path, this.get(cls)); // Read children const view = config.views[AllViewSymbol]; for (const k of view.fields) { if (this.has(view.schema[k].type) && view.schema[k].type !== cls) { this.trackSchemaDependencies(cls, view.schema[k].type, [...path, view.schema[k]]); } } } createPending(cls: Class): ClassConfig { return { class: cls, validators: [], subTypeField: 'type', baseType: describeFunction(cls)?.abstract, metadata: {}, methods: {}, views: { [AllViewSymbol]: { schema: {}, fields: [] } } }; } /** * Get schema for a given view * @param cls The class to retrieve the schema for * @param view The view name */ getViewSchema<T>(cls: Class<T>, view?: string | typeof AllViewSymbol): ViewConfig { view = view ?? AllViewSymbol; const schema = this.get(cls)!; if (!schema) { throw new Error(`Unknown schema class ${cls.name}`); } const res = schema.views[view]; if (!res) { throw new Error(`Unknown view ${view.toString()} for ${cls.name}`); } return res; } /** * Get schema for a method invocation * @param cls * @param method */ getMethodSchema<T>(cls: Class<T>, method: string): FieldConfig[] { return (this.get(cls)?.methods?.[method] ?? {}).fields?.filter(x => !!x).sort((a, b) => a.index! - b.index!) ?? []; } /** * Get method validators * @param cls * @param method */ getMethodValidators<T>(cls: Class<T>, method: string): MethodValidatorFn<unknown[]>[] { return (this.get(cls)?.methods?.[method] ?? {}).validators ?? []; } /** * Register a view * @param target The target class * @param view View name * @param fields Fields to register */ registerPendingView<T>(target: Class<T>, view: string, fields: ViewFieldsConfig<T>): void { if (!this.#pendingViews.has(target)) { this.#pendingViews.set(target, new Map()); } const generalConfig: ViewFieldsConfig<unknown> = castTo(fields); this.#pendingViews.get(target)!.set(view, generalConfig); } /** * Register pending method, and establish a method config * @param target * @param method */ registerPendingMethod(target: Class, method: string): SchemaMethodConfig { const methods = this.getOrCreatePending(target)!.methods!; return (methods[method] ??= { fields: [], validators: [] }); } /** * Register a partial config for a pending method param * @param target The class to target * @param prop The method name * @param idx The param index * @param config The config to register */ registerPendingParamFacet(target: Class, method: string, idx: number, config: Partial<FieldConfig>): Class { const params = this.registerPendingMethod(target, method).fields; if (config.name === '') { delete config.name; } if (config.aliases) { config.aliases = [...params[idx]?.aliases ?? [], ...config.aliases]; } if (config.specifiers) { config.specifiers = [...params[idx]?.specifiers ?? [], ...config.specifiers]; } if (config.enum?.values) { config.enum.values = config.enum.values.slice().sort(); } params[idx] = { // @ts-expect-error name: `${method}.${idx}`, ...params[idx] ?? {}, owner: target, index: idx, ...config, }; return target; } /** * Register a partial config for a pending field * @param target The class to target * @param prop The property name * @param config The config to register */ registerPendingFieldFacet(target: Class, prop: string, config: Partial<FieldConfig>): Class { const allViewConf = this.getOrCreatePending(target).views![AllViewSymbol]; if (!allViewConf.schema[prop]) { allViewConf.fields.push(prop); // Partial config while building allViewConf.schema[prop] = asFull<FieldConfig>({}); } if (config.aliases) { config.aliases = [...allViewConf.schema[prop].aliases ?? [], ...config.aliases]; } if (config.specifiers) { config.specifiers = [...allViewConf.schema[prop].specifiers ?? [], ...config.specifiers]; } if (config.enum?.values) { config.enum.values = config.enum.values.slice().sort(); } Object.assign(allViewConf.schema[prop], config); return target; } /** * Register pending field configuration * @param target Target class * @param method Method name * @param idx Param index * @param type List of types * @param conf Extra config */ registerPendingParamConfig(target: Class, method: string, idx: number, type: ClassList, conf?: Partial<FieldConfig>): Class { return this.registerPendingParamFacet(target, method, idx, { ...conf, array: Array.isArray(type), type: Array.isArray(type) ? type[0] : type, }); } /** * Register pending field configuration * @param target Target class * @param prop Property name * @param type List of types * @param conf Extra config */ registerPendingFieldConfig(target: Class, prop: string, type: ClassList, conf?: Partial<FieldConfig>): Class { const fieldConf: FieldConfig = { owner: target, name: prop, array: Array.isArray(type), type: Array.isArray(type) ? type[0] : type, ...(conf ?? {}) }; return this.registerPendingFieldFacet(target, prop, fieldConf); } /** * Merge two class configs * @param dest Target config * @param src Source config */ mergeConfigs(dest: ClassConfig, src: Partial<ClassConfig>, inherited = false): ClassConfig { dest.views[AllViewSymbol] = { schema: { ...dest.views[AllViewSymbol].schema, ...src.views?.[AllViewSymbol].schema }, fields: [...dest.views[AllViewSymbol].fields, ...src.views?.[AllViewSymbol].fields ?? []] }; if (!inherited) { dest.baseType = src.baseType ?? dest.baseType; dest.subTypeName = src.subTypeName ?? dest.subTypeName; } dest.methods = { ...src.methods ?? {}, ...dest.methods ?? {} }; dest.metadata = { ...src.metadata ?? {}, ...dest.metadata ?? {} }; dest.subTypeField = src.subTypeField ?? dest.subTypeField; dest.title = src.title || dest.title; dest.validators = [...src.validators ?? [], ...dest.validators]; return dest; } /** * Project all pending views into a final state * @param target The target class * @param conf The class config */ finalizeViews<T>(target: Class<T>, conf: ClassConfig): ClassConfig { const allViewConf = conf.views![AllViewSymbol]; const pending = this.#pendingViews.get(target) ?? new Map<string, ViewFieldsConfig<string>>(); this.#pendingViews.delete(target); for (const [view, fields] of pending.entries()) { const withoutSet = 'without' in fields ? new Set<string>(fields.without) : undefined; const fieldList = withoutSet ? allViewConf.fields.filter(x => !withoutSet.has(x)) : ('with' in fields ? fields.with : []); conf.views![view] = { fields: fieldList, schema: fieldList.reduce<SchemaConfig>((acc, v) => { acc[v] = allViewConf.schema[v]; return acc; }, {}) }; } return conf; } onInstallFinalize(cls: Class): ClassConfig { let config: ClassConfig = this.createPending(cls); // Merge parent const parent = this.getParentClass(cls); if (parent) { const parentConfig = this.get(parent); if (parentConfig) { config = this.mergeConfigs(config, parentConfig, true); } } this.registerSubTypes(cls); // Merge pending, back on top, to allow child to have higher precedence const pending = this.getOrCreatePending(cls); if (pending) { config = this.mergeConfigs(config, pending); } // Write views out config = this.finalizeViews(cls, config); if (config.subTypeName && config.subTypeField in config.views[AllViewSymbol].schema) { const field = config.views[AllViewSymbol].schema[config.subTypeField]; config.views[AllViewSymbol].schema[config.subTypeField] = { ...field, enum: { values: [config.subTypeName], message: `${config.subTypeField} can only be '${config.subTypeName}'`, } }; } return config; } override onInstall(cls: Class, e: ChangeEvent<Class>): void { super.onInstall(cls, e); if (this.has(cls)) { // Track dependencies of schemas this.trackSchemaDependencies(cls); } } override onUninstall<T>(cls: Class<T>, e: ChangeEvent<Class>): void { super.onUninstall(cls, e); if (e.type === 'removing' && this.hasExpired(cls)) { // Recompute subtypes this.#subTypes.clear(); this.#baseSchema.delete(cls); this.#accessorDescriptors.delete(cls); // Recompute subtype mappings for (const el of this.entries.keys()) { const clz = this.entries.get(el)!.class; this.registerSubTypes(clz); } SchemaChangeListener.clearSchemaDependency(cls); } } override emit(ev: ChangeEvent<Class>): void { super.emit(ev); if (ev.type === 'changed') { SchemaChangeListener.emitFieldChanges({ type: 'changed', curr: this.get(ev.curr!), prev: this.getExpired(ev.curr!) }); } } /** * Visit fields recursively */ visitFields<T>(cls: Class<T>, onField: (field: FieldConfig, path: FieldConfig[]) => void, _path: FieldConfig[] = [], root = cls): void { const fields = this.has(cls) ? Object.values(this.getViewSchema(cls).schema) : []; for (const field of fields) { if (this.has(field.type)) { this.visitFields(field.type, onField, [..._path, field], root); } else { onField(field, _path); } } } } export const SchemaRegistry = new $SchemaRegistry();