UNPKG

octonom

Version:

Object Document Mapper for any database

311 lines (260 loc) 11 kB
import { SanitizationError, ValidationError } from '../errors'; import { ISanitizeOptions, ISchema, ISchemaInstance, ISchemaOptions, ISchemaParentInstance, IToObjectOptions, Path, PopulateReference, validate, } from './schema'; export interface IArrayInstance<T> extends ISchemaParentInstance<T[]> { instanceArray: Array<ISchemaInstance<T>>; rawArray: T[]; } export type ArrayInstance<T> = IArrayInstance<T>; export interface IArrayOptions<T = any> extends ISchemaOptions<ArrayInstance<T>> { elementSchema: ISchema<T, ISchemaInstance<T>>; minLength?: number; maxLength?: number; callParentHooks?: boolean; } /** Creates a proxy array that a user can interact with. * All change operations on the array are intercepted for reflecting the changes * in instanceArray and calling the beforeChange/afterChange handlers. * Note: this is a bit lengthy since we need to cover the full js array API. */ export function proxifyArray<T>( array: T[], instanceArray: Array<ISchemaInstance<T>>, parentInstance: ISchemaParentInstance, elementSchema: ISchema<T, ISchemaInstance<T>>, ) { return new Proxy(array, { get(target, key, receiver) { switch (key) { /* istanbul ignore next */ case 'copyWithin': throw new Error('not yet implemented'); /* istanbul ignore next */ case 'fill': throw new Error('not yet implemented'); case 'pop': return () => { const clone = target.slice(); clone.pop(); parentInstance.beforeChange([], clone, parentInstance); const removedInstance = instanceArray.pop(); delete removedInstance.parent; const result = target.pop(); parentInstance.afterChange([], clone, parentInstance); return result; }; case 'push': return (...args: any[]) => { const clone = target.slice(); clone.push(...args); parentInstance.beforeChange([], clone, parentInstance); const newInstances = args.map((arg, index) => { return elementSchema.create( arg, {parent: {instance: parentInstance, path: target.length + index}}, ); }); instanceArray.push(...newInstances); const result = target.push(...newInstances.map(el => el.value)); parentInstance.afterChange([], clone, parentInstance); return result; }; case 'reverse': return () => { const clone = target.slice(); clone.reverse(); parentInstance.beforeChange([], clone, parentInstance); instanceArray.reverse(); instanceArray.forEach((instance, index) => instance.parent.path = index); target.reverse(); parentInstance.afterChange([], clone, parentInstance); return receiver; }; case 'sort': return (compare: (a, b) => number) => { const mapped = instanceArray.map((instance, index) => ({instance, index})); mapped.sort((a, b) => { if (compare) { return compare(a.instance.value, b.instance.value); } else { if (a.instance.value < b.instance.value) { return -1; } if (a.instance.value > b.instance.value) { return 1; } return 0; } }); const clone = mapped.map(el => el.instance.value); parentInstance.beforeChange([], clone, parentInstance); instanceArray.splice(0, instanceArray.length, ...mapped.map((el, index) => { el.instance.parent.path = index; return el.instance; })); target.splice(0, target.length, ...mapped.map(el => el.instance.value)); parentInstance.afterChange([], clone, parentInstance); return receiver; }; case 'splice': return (start, deleteCount, ...args: any[]) => { if (deleteCount === undefined || deleteCount > target.length - start) { deleteCount = target.length - start; } const clone = instanceArray.map(el => el.value); clone.splice(start, deleteCount, ...args); parentInstance.beforeChange([], clone, parentInstance); const newInstances = args.map((arg, index) => { return elementSchema.create( arg, {parent: {instance: parentInstance, path: start + index}}, ); }); const removedInstances = instanceArray.splice(start, deleteCount, ...newInstances); removedInstances.forEach(instance => delete instance.parent); instanceArray.slice(start + newInstances.length).forEach((instance, index) => { instance.parent.path = start + newInstances.length + index; }); const result = target.splice(start, deleteCount, ...newInstances.map(el => el.value)); parentInstance.afterChange([], clone, parentInstance); return result; }; // case 'toString': // return () => target.map(el => el.value).toString(); case 'unshift': return (...args: any[]) => { const clone = instanceArray.map(el => el.value); clone.unshift(...args); parentInstance.beforeChange([], clone, parentInstance); const newInstances = args.map((arg, index) => { return elementSchema.create( arg, {parent: {instance: parentInstance, path: index}}, ); }); instanceArray.unshift(...newInstances); instanceArray.slice(newInstances.length).forEach((instance, index) => { instance.parent.path = newInstances.length + index; }); const result = target.unshift(...newInstances.map(el => el.value)); parentInstance.afterChange([], clone, parentInstance); return result; }; } return target[key]; }, set(target, key, value, receiver) { if (typeof key === 'number' || typeof key === 'string' && /^\d$/.test(key)) { const numKey = typeof key === 'number' ? key : parseInt(key, 10); const oldInstance = instanceArray[numKey]; parentInstance.beforeChange([numKey], value, oldInstance); const newInstance = elementSchema.create( value, {parent: {instance: parentInstance, path: numKey}}, ); if (oldInstance) { delete oldInstance.parent; } instanceArray[numKey] = newInstance; target[numKey] = newInstance ? newInstance.value : undefined; parentInstance.afterChange([numKey], value, newInstance); } else { target[key] = value; } return true; }, deleteProperty: (target, key) => { if (typeof key === 'number' || typeof key === 'string' && /^\d$/.test(key)) { const numKey = typeof key === 'number' ? key : parseInt(key, 10); const oldInstance = instanceArray[numKey]; parentInstance.beforeChange([numKey], undefined, oldInstance); if (oldInstance) { delete oldInstance.parent; } delete instanceArray[numKey]; delete target[numKey]; parentInstance.afterChange([numKey], undefined, undefined); } else { delete target[key]; } return true; }, }) as T[]; } export class ArraySchema<T> implements ISchema<T[], ArrayInstance<T>, T[]> { constructor(public readonly options: IArrayOptions<T>) {} public create(value: any, sanitizeOptions: ISanitizeOptions = {}): ArrayInstance<T> { const sanitizedValue = this.sanitize(value, sanitizeOptions); if (sanitizedValue === undefined) { return undefined; } const instance: ArrayInstance<T> = { instanceArray: undefined, rawArray: undefined, parent: sanitizeOptions.parent, schema: this, value: undefined, beforeChange: (path: Path, newValue: any, oldInstance: ISchemaInstance) => { if (this.options.callParentHooks !== false && instance.parent) { instance.parent.instance.beforeChange([instance.parent.path].concat(path), newValue, oldInstance); } }, afterChange: (path: Path, newValue: any, newInstance: ISchemaInstance) => { if (this.options.callParentHooks !== false && instance.parent) { instance.parent.instance.afterChange([instance.parent.path].concat(path), newValue, newInstance); } }, }; instance.instanceArray = sanitizedValue.map( (element, index) => this.options.elementSchema.create( element, {...sanitizeOptions, parent: {instance, path: index}}, ), ); instance.rawArray = instance.instanceArray.map(element => element.value); instance.value = proxifyArray<T>(instance.rawArray, instance.instanceArray, instance, this.options.elementSchema); return instance; } public async populate(instance: ArrayInstance<T>, populateReference: PopulateReference) { await Promise.all(instance.instanceArray.map(async (element, index) => { instance.rawArray[index] = await this.options.elementSchema.populate(element, populateReference); })); return instance.value; } public toObject(instance: ArrayInstance<T>, options: IToObjectOptions = {}): T[] { return instance.instanceArray.map(element => element ? element.schema.toObject(element, options) : undefined); } public async validate(instance: ArrayInstance<T>): Promise<void> { if (this.options.minLength !== undefined && instance.value.length < this.options.minLength) { throw new ValidationError( `Array must have at least ${this.options.minLength} elements.`, 'array-min-length', instance.parent, ); } if (this.options.maxLength !== undefined && instance.value.length > this.options.maxLength) { throw new ValidationError( `Array must have at most ${this.options.maxLength} elements.`, 'array-max-length', instance.parent, ); } // validate all elements await Promise.all(instance.instanceArray.map( element => this.options.elementSchema.validate(element), )); await validate(this.options, instance); } protected sanitize(value: any, sanitizeOptions: ISanitizeOptions) { if (value === undefined) { if (this.options.required && sanitizeOptions.defaults) { return []; } return undefined; } if (!(value instanceof Array)) { throw new SanitizationError('Value is not an array.', 'no-array', sanitizeOptions.parent); } return value; } }