octonom
Version:
Object Document Mapper for any database
311 lines (260 loc) • 11 kB
text/typescript
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;
}
}