import { Maker } from './make'; import { assert, fail } from './assert'; import safe from '@smartlyio/safe-navigation'; import * as runtime from './runtime'; export type Type = | BinaryType | UnknownType | VoidType | BooleanType | IntegerType | NumberType | NullType | UnionType | IntersectionType | StringType | ArrayType | ObjectType | NamedType; export type NamedTypeDefinitionDeferred = () => NamedTypeDefinition; export interface NamedTypeDefinition { readonly name: string; readonly definition: Type; readonly maker: Maker; readonly isA: null | ((value: any) => value is A); } export interface BinaryType { readonly type: 'binary'; } export interface UnknownType { readonly type: 'unknown'; } export interface VoidType { readonly type: 'void'; } export interface NullType { readonly type: 'null'; } export interface UnionType { readonly type: 'union'; readonly options: Type[]; } export interface IntersectionType { readonly type: 'intersection'; readonly options: Type[]; } export interface StringType { readonly type: 'string'; readonly format?: string; readonly pattern?: string; readonly minLength?: number; readonly maxLength?: number; readonly enum?: string[]; } export interface BooleanType { readonly type: 'boolean'; readonly enum?: boolean[]; } export interface NumberType { readonly type: 'number'; readonly enum?: number[]; readonly minimum?: number; readonly maximum?: number; } export interface IntegerType { readonly type: 'integer'; readonly enum?: number[]; readonly minimum?: number; readonly maximum?: number; } export interface ArrayType { readonly type: 'array'; readonly items: Type; readonly minItems?: number; readonly maxItems?: number; } export interface NamedType { readonly type: 'named'; readonly reference: NamedTypeDefinitionDeferred; } export interface PropType { required: boolean; value: Type; networkName?: string; } export interface Props { [name: string]: PropType; } export type AdditionalProp = boolean | Type; export interface ObjectType { readonly type: 'object'; readonly additionalProperties: AdditionalProp; readonly properties: Props; } type Path = PathItem[]; type PathItem = | { type: 'path'; path: string } | { type: 'array' } | { type: 'additionalProperty'; definedProperties: string[] }; export class Traversal { static compile( root: NamedTypeDefinition, leaf: NamedTypeDefinition ) { return new Traversal(root, leaf); } private cache: Reaches = new Map(); private cachedAncestors: Map, Path[]>; private constructor( private readonly root: NamedTypeDefinition, private readonly leaf: NamedTypeDefinition ) { calculateReverseReach(new Set(), this.cache, this.root, this.leaf, false); this.cachedAncestors = this.ancestorNamedObjects(this.leaf); } private dedupePaths(ancestors: Map, Path[]>) { const deduped = new Map(); for (const [ancestor, paths] of ancestors.entries()) { const dedupedPaths: Set = paths.reduce( (memo, path) => memo.add(JSON.stringify(path)), new Set() ); deduped.set( ancestor, [...dedupedPaths.values()].map(path => JSON.parse(path)) ); } return deduped; } map(value: Root, fn: (leaf: Leaf) => Leaf) { this.validateRoot(value); const match = this.matcher(); return runtime.map(value, ((value: any) => !!match(value)) as any, (value: any) => { this.paths(value).forEach((path: Path) => { value = safeMapPath(value, path, fn); }); return value; }); } async pmap(value: Root, fn: (leaf: Leaf) => Promise) { this.validateRoot(value); const match = this.matcher(); return await runtime.pmap(value, ((value: any) => match(value)) as any, async (value: any) => { for (const path of this.paths(value)) { value = await safePmapPath(value, path, fn); } return value; }); } private paths(value: any): Path[] { const paths: Path[] = []; for (const [ancestor, pathsFromAncestor] of this.cachedAncestors.entries()) { if (ancestor.isA && ancestor.isA(value)) { paths.push(...pathsFromAncestor); } } return paths; } private matcher(): (a: any) => boolean { return (value: any) => { for (const ancestor of this.cachedAncestors.keys()) { if (ancestor.isA && ancestor.isA(value)) { return true; } } return false; }; } private validateRoot(value: any) { assert(this.root.isA && this.root.isA(value), 'Root value does not match expected root type'); } private addAncestor( ancestors: Map, Path[]>, ancestor: NamedTypeDefinition, path: Path ) { const pathsToAncestor = ancestors.get(ancestor); if (!pathsToAncestor) { return ancestors.set(ancestor, [path]); } pathsToAncestor.push(path); return ancestors; } private ancestorNamedObjects( target: NamedTypeDefinition ): Map, Path[]> { const found = this.cache.get(target); if (!found) { return fail('no path to target'); } const allAncestors: Map, Path[]> = new Map(); for (const [pathStr, parents] of found.entries()) { const path: Path = JSON.parse(pathStr); parents.forEach(ancestor => { if (ancestor.isA) { this.addAncestor(allAncestors, ancestor, path); } else if (['array', 'named'].indexOf(ancestor.definition.type) >= 0) { for (const [namedObjectAncestor, paths] of this.ancestorNamedObjects( ancestor ).entries()) { paths.forEach(pathFromAncestor => this.addAncestor(allAncestors, namedObjectAncestor, [...pathFromAncestor, ...path]) ); } } else { fail('nearest containing named thing is not an object: ' + ancestor.name); } }); } return this.dedupePaths(allAncestors); } } function safeMapPath(value: any, path: Path, fn: (value: any) => any) { let cursor = safe(value); // tslint:disable-next-line:prefer-for-of for (let ix = 0; ix < path.length; ix++) { const p = path[ix]; if (p.type === 'array') { return cursor.$map(arrayValue => arrayValue.map((item: any) => safeMapPath(item, path.slice(ix + 1), fn)) ); } if (p.type === 'additionalProperty') { const fields = Object.keys(cursor.$ || {}); const extraFields = fields.filter(field => p.definedProperties.indexOf(field) < 0); return cursor.$map((value: any) => { for (const prop of extraFields) { value = safeMapPath(value, [{ type: 'path', path: prop }, ...path.slice(ix + 1)], fn); } return value; }); } cursor = cursor[p.path]; } return cursor.$map(fn); } async function safePmapPath(value: any, path: Path, fn: (value: any) => Promise) { let cursor = safe(value); // tslint:disable-next-line:prefer-for-of for (let ix = 0; ix < path.length; ix++) { const p = path[ix]; if (p.type === 'array') { return cursor.$pmap(arrayValue => Promise.all(arrayValue.map((item: any) => safePmapPath(item, path.slice(ix + 1), fn))) ); } if (p.type === 'additionalProperty') { const fields = Object.keys(cursor.$ || {}); const extraFields = fields.filter(field => p.definedProperties.indexOf(field) < 0); return cursor.$pmap(async (value: any) => { for (const prop of extraFields) { value = await safePmapPath( value, [{ type: 'path', path: prop }, ...path.slice(ix + 1)], fn ); } return value; }); } cursor = cursor[p.path]; } return cursor.$pmap(fn); } type ReachTo = Array>; type Reaches = Map, Map>; function canReach( reaches: Reaches, from: NamedTypeDefinition, to: NamedTypeDefinition, byPath: Path ) { let existing = reaches.get(to); if (!existing) { existing = new Map(); reaches.set(to, existing); } const pathString = JSON.stringify(byPath); let froms = existing.get(pathString); if (!froms) { froms = []; existing.set(pathString, froms); } froms.push(from); } function calculateReverseReach( processed: Set>, reaches: Reaches, from: NamedTypeDefinition, to: NamedTypeDefinition, ambiguousPath: boolean ) { assert( from !== to || !ambiguousPath, 'Cannot calculate unambiguous type. There are union or intersection type between the nearest containing named object and the target leaf' ); if (processed.has(from)) { return; } if (from.definition.type === 'object') { ambiguousPath = false; processed.add(from); } calculateReachInType(processed, reaches, from, to, from.definition, [], ambiguousPath); } function ambiguousOptions(options: Type[]) { const ambiguous = options.filter(option => option.type !== 'null'); return ambiguous.length > 1; } function calculateReachInType( processed: Set>, reaches: Reaches, from: NamedTypeDefinition, to: NamedTypeDefinition, type: Type, path: Path, ambiguousPath: boolean ) { if (type.type === 'named') { canReach(reaches, from, type.reference(), path); calculateReverseReach(processed, reaches, type.reference(), to, ambiguousPath); } else if (type.type === 'array') { calculateReachInType( processed, reaches, from, to, type.items, [...path, { type: 'array' }], ambiguousPath ); } else if (type.type === 'union') { type.options.map(option => calculateReachInType( processed, reaches, from, to, option, path, ambiguousOptions(type.options) || ambiguousPath ) ); } else if (type.type === 'intersection') { type.options.map(option => calculateReachInType( processed, reaches, from, to, option, path, ambiguousOptions(type.options) || ambiguousPath ) ); } else if (type.type === 'object') { if (type.additionalProperties && type.additionalProperties !== true) { calculateReachInType( processed, reaches, from, to, type.additionalProperties, [...path, { type: 'additionalProperty', definedProperties: Object.keys(type.properties) }], ambiguousPath ); } Object.keys(type.properties).forEach(property => { calculateReachInType( processed, reaches, from, to, type.properties[property].value, [...path, { type: 'path', path: property }], ambiguousPath ); }); } }