UNPKG

@player-ui/player

Version:

240 lines (190 loc) 6.38 kB
import { SyncWaterfallHook } from "tapable-ts"; import type { Schema as SchemaType, Formatting } from "@player-ui/types"; import type { BindingInstance } from "../binding"; import type { ValidationProvider, ValidationObject } from "../validator"; import type { FormatDefinition, FormatOptions, FormatType } from "./types"; /** A function that returns itself */ const identify = (val: any) => val; /** Expand the authored schema into a set of paths -> DataTypes */ export function parse( schema: SchemaType.Schema, ): Map<string, SchemaType.DataTypes> { const expandedPaths = new Map<string, SchemaType.DataTypes>(); if (!schema.ROOT) { return expandedPaths; } const parseQueue: Array<{ /** The node to process */ node: SchemaType.Node; /** The path in the data-model this node represents */ path: Array<string>; /** A set of visited DataTypes to prevent loops */ visited: Set<string>; }> = [{ node: schema.ROOT, path: [], visited: new Set() }]; while (parseQueue.length > 0) { const next = parseQueue.shift(); if (!next) { break; } const { node, path, visited } = next; Object.entries(node).forEach(([prop, type]) => { const nestedPath = [...path, prop]; const nestedPathStr = nestedPath.join("."); if (expandedPaths.has(nestedPathStr)) { // We've gone in a loop. Panic throw new Error( "Path has already been processed. There's either a loop somewhere or a bug", ); } if (visited.has(type.type)) { throw new Error( `Path already contained type: ${type.type}. This likely indicates a loop in the schema`, ); } expandedPaths.set(nestedPathStr, type); if (type.isArray) { nestedPath.push("[]"); } if (type.isRecord) { nestedPath.push("{}"); } if (type.type && schema[type.type]) { parseQueue.push({ path: nestedPath, node: schema[type.type], visited: new Set([...visited, type.type]), }); } }); } return expandedPaths; } /** * The Schema is the central hub for all data invariants, and metaData associated with the data-model itself * Outside of the types defined in the JSON payload, it doesn't manage or keep any state. * It simply servers as an orchestrator for other modules to interface w/ the schema. */ export class SchemaController implements ValidationProvider { private formatters: Map<string, FormatType<any, any, FormatOptions>> = new Map(); private types: Map<string, SchemaType.DataType<any>> = new Map(); public readonly schema: Map<string, SchemaType.DataTypes> = new Map(); private bindingSchemaNormalizedCache: Map<BindingInstance, string> = new Map(); public readonly hooks = { resolveTypeForBinding: new SyncWaterfallHook< [SchemaType.DataTypes | undefined, BindingInstance] >(), }; constructor(schema?: SchemaType.Schema) { this.schema = schema ? parse(schema) : new Map(); } public addFormatters(fns: Array<FormatType<any, any, FormatOptions>>) { fns.forEach((def) => { this.formatters.set(def.name, def); }); } public addDataTypes(types: Array<SchemaType.DataType<any>>) { types.forEach((t) => { this.types.set(t.type, t); }); } getValidationsForBinding( binding: BindingInstance, ): Array<ValidationObject> | undefined { const typeDef = this.getApparentType(binding); if (!typeDef?.validation?.length) { return undefined; } // Set the defaults for schema-level validations return typeDef.validation.map((vRef) => ({ severity: "error", trigger: "change", ...vRef, })); } private normalizeBinding(binding: BindingInstance): string { const cached = this.bindingSchemaNormalizedCache.get(binding); if (cached) { return cached; } let bindingArray = binding.asArray(); let normalized = bindingArray .map((p) => (typeof p === "number" ? "[]" : p)) .join("."); if (normalized) { this.bindingSchemaNormalizedCache.set(binding, normalized); bindingArray = normalized.split("."); } bindingArray.forEach((item) => { const recordBinding = bindingArray .map((p) => (p === item ? "{}" : p)) .join("."); if (this.schema.get(recordBinding)) { this.bindingSchemaNormalizedCache.set(binding, recordBinding); bindingArray = recordBinding.split("."); normalized = recordBinding; } }); return normalized; } public getType(binding: BindingInstance): SchemaType.DataTypes | undefined { return this.hooks.resolveTypeForBinding.call( this.schema.get(this.normalizeBinding(binding)), binding, ); } public getApparentType( binding: BindingInstance, ): SchemaType.DataTypes | undefined { const schemaType = this.getType(binding); if (schemaType === undefined) { return undefined; } const baseType = this.getTypeDefinition(schemaType?.type); if (baseType === undefined) { return schemaType; } return { ...baseType, ...schemaType, validation: [ ...(schemaType.validation ?? []), ...(baseType.validation ?? []), ], }; } public getTypeDefinition(dataType: string) { return this.types.get(dataType); } public getFormatterForType( formatReference: Formatting.Reference, ): FormatDefinition<unknown, unknown> | undefined { const { type: formatType, ...options } = formatReference; const formatter = this.formatters.get(formatType); if (!formatter) { return; } return { format: formatter.format ? (val) => formatter.format?.(val, options) : identify, deformat: formatter.deformat ? (val) => formatter.deformat?.(val, options) : identify, }; } /** * Given a binding, fetch a function that's responsible for formatting, and/or de-formatting the data * If no formatter is registered, it will return undefined */ public getFormatter( binding: BindingInstance, ): FormatDefinition<unknown, unknown> | undefined { const type = this.getApparentType(binding); if (!type?.format) { return undefined; } return this.getFormatterForType(type.format); } }