Skip to content

Commit

Permalink
feat: deep decorator inheritance
Browse files Browse the repository at this point in the history
  • Loading branch information
tannerntannern committed Nov 18, 2020
1 parent 68dc1f3 commit 6daabc5
Show file tree
Hide file tree
Showing 13 changed files with 291 additions and 77 deletions.
84 changes: 79 additions & 5 deletions src/decorator.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { Class } from './types';
import { flatten, protoChain, unique } from './util';
import { getMixinsForClass } from './mixin-tracking';

type ObjectOfDecorators<T extends PropertyDecorator | MethodDecorator> = { [key: string]: T[] };

export type PropertyAndMethodDecorators = {
property?: { [key: string]: PropertyDecorator[] },
method?: { [key: string]: MethodDecorator[] },
property?: ObjectOfDecorators<PropertyDecorator>,
method?: ObjectOfDecorators<MethodDecorator>,
};

type Decorators = {
Expand All @@ -11,9 +15,79 @@ type Decorators = {
instance?: PropertyAndMethodDecorators,
};

export const decorators: Map<Class, Decorators> = new Map();
const mergeObjectsOfDecorators = <T extends PropertyDecorator | MethodDecorator>(
o1: ObjectOfDecorators<T>,
o2: ObjectOfDecorators<T>,
): ObjectOfDecorators<T> => {
const allKeys = unique([...Object.getOwnPropertyNames(o1), ...Object.getOwnPropertyNames(o2)]);
const mergedObject: ObjectOfDecorators<T> = {};
for (let key of allKeys)
mergedObject[key] = unique([...(o1?.[key] ?? []), ...(o2?.[key] ?? [])]);
return mergedObject;
};

const mergePropertyAndMethodDecorators = (d1: PropertyAndMethodDecorators, d2: PropertyAndMethodDecorators): PropertyAndMethodDecorators => ({
property: mergeObjectsOfDecorators(d1?.property ?? {}, d2?.property ?? {}),
method: mergeObjectsOfDecorators(d1?.method ?? {}, d2?.method ?? {}),
});

const mergeDecorators = (d1: Decorators, d2: Decorators): Decorators => ({
class: unique([...d1?.class ?? [], ...d2?.class ?? []]),
static: mergePropertyAndMethodDecorators(d1?.static ?? {}, d2?.static ?? {}),
instance: mergePropertyAndMethodDecorators(d1?.instance ?? {}, d2?.instance ?? {}),
});

const decorators: Map<Class, Decorators> = new Map();

const findAllConstituentClasses = (...classes: Class[]): Class[] => {
const allClasses = new Set<Class>();
const frontier = new Set<Class>([...classes]);

while (frontier.size > 0) {
for (let clazz of frontier) {
const protoChainClasses = protoChain(clazz.prototype).map(proto => proto.constructor);
const mixinClasses = getMixinsForClass(clazz) ?? [];
const potentiallyNewClasses = [...protoChainClasses, ...mixinClasses] as Class[];
const newClasses = potentiallyNewClasses.filter(c => !allClasses.has(c));
for (let newClass of newClasses)
frontier.add(newClass);

allClasses.add(clazz);
frontier.delete(clazz);
}
}

return [...allClasses];
};

export const deepDecoratorSearch = (...classes: Class[]): Decorators => {
const decoratorsForClassChain =
findAllConstituentClasses(...classes)
.map(clazz => decorators.get(clazz as Class))
.filter(decorators => !!decorators) as Decorators[];

if (decoratorsForClassChain.length == 0)
return {};

if (decoratorsForClassChain.length == 1)
return decoratorsForClassChain[0];

return decoratorsForClassChain.reduce((d1, d2) => mergeDecorators(d1, d2));
};

export const directDecoratorSearch = (...classes: Class[]): Decorators => {
const classDecorators = classes.map(clazz => getDecoratorsForClass(clazz));

if (classDecorators.length === 0)
return {};

if (classDecorators.length === 1)
return classDecorators[1];

return classDecorators.reduce((d1, d2) => mergeDecorators(d1, d2));
};

const getDecoratorsForClass = (clazz: Class) => {
export const getDecoratorsForClass = (clazz: Class) => {
let decoratorsForClass = decorators.get(clazz);
if (!decoratorsForClass) {
decoratorsForClass = {};
Expand Down Expand Up @@ -75,5 +149,5 @@ export const decorate = <T extends ClassDecorator | PropertyDecorator | MethodDe
if (args.length === 1)
return decorateClass(decorator as ClassDecorator)(args[0]);

return decorateMember(decorator as PropertyDecorator | MethodDecorator)(...args as [Object, string, PropertyDescriptor?]);
return decorateMember(decorator as PropertyDecorator | MethodDecorator)(...args as [Object, string, TypedPropertyDescriptor<any>]);
}) as T;
10 changes: 6 additions & 4 deletions src/mixin-tracking.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
/**
* Keeps track of constituent classes for every mixin class created by ts-mixer.
*/
import {protoChain} from './util';
import { protoChain } from './util';
import { Class } from './types';

// Keeps track of constituent classes for every mixin class created by ts-mixer.
const mixins = new Map<any, Function[]>();

export const getMixinsForClass = (clazz: Class) =>
mixins.get(clazz);

export const registerMixins = (mixedClass: any, constituents: Function[]) =>
mixins.set(mixedClass, constituents);

Expand Down
28 changes: 13 additions & 15 deletions src/mixins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { proxyMix } from './proxy';
import { Class, Longest } from './types'; // TODO: need something more than just Longest: also forces all to be subset of longest
import { settings } from './settings';
import { copyProps, hardMixProtos, softMixProtos } from './util';
import { decorators, PropertyAndMethodDecorators } from './decorator';
import { directDecoratorSearch, deepDecoratorSearch, PropertyAndMethodDecorators } from './decorator';
import { registerMixins } from './mixin-tracking';

function Mixin<
Expand Down Expand Up @@ -237,19 +237,17 @@ function Mixin(...constructors: Class[]) {
);

let DecoratedMixedClass: any = MixedClass;
for (let constructor of constructors) {
const classDecorators = decorators.get(constructor);
if (classDecorators) {
if (classDecorators.class)
for (let decorator of classDecorators.class)
DecoratedMixedClass = decorator(DecoratedMixedClass);

if (classDecorators.static)
applyPropAndMethodDecorators(classDecorators.static, DecoratedMixedClass);

if (classDecorators.instance)
applyPropAndMethodDecorators(classDecorators.instance, DecoratedMixedClass.prototype);
}

if (settings.decoratorInheritance !== 'none') {
const classDecorators = settings.decoratorInheritance === 'deep'
? deepDecoratorSearch(...constructors)
: directDecoratorSearch(...constructors);

for (let decorator of classDecorators?.class ?? [])
DecoratedMixedClass = decorator(DecoratedMixedClass);

applyPropAndMethodDecorators(classDecorators?.static ?? {}, DecoratedMixedClass);
applyPropAndMethodDecorators(classDecorators?.instance ?? {}, DecoratedMixedClass.prototype);
}

registerMixins(DecoratedMixedClass, constructors);
Expand All @@ -269,7 +267,7 @@ const applyPropAndMethodDecorators = (propAndMethodDecorators: PropertyAndMethod
if (methodDecorators)
for (let key in methodDecorators)
for (let decorator of methodDecorators[key])
decorator(target, key, Object.getOwnPropertyDescriptor(target, key));
decorator(target, key, Object.getOwnPropertyDescriptor(target, key)!);
};

/**
Expand Down
2 changes: 2 additions & 0 deletions src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ export type Settings = {
initFunction: string | null,
staticsStrategy: 'copy' | 'proxy',
prototypeStrategy: 'copy' | 'proxy',
decoratorInheritance: 'deep' | 'direct' | 'none',
};

export const settings: Settings = {
initFunction: null,
staticsStrategy: 'copy',
prototypeStrategy: 'copy',
decoratorInheritance: 'deep',
};
20 changes: 15 additions & 5 deletions src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,10 @@ export const protoChain = (obj: object, currentChain: object[] = [obj]): object[
* Identifies the nearest ancestor common to all the given objects in their prototype chains. For most unrelated
* objects, this function should return Object.prototype.
*/
export const nearestCommonProto = (...objs: object[]): Function => {
export const nearestCommonProto = (...objs: object[]): object | undefined => {
if (objs.length === 0) return undefined;

let commonProto = undefined;
let commonProto: object | undefined = undefined;
const protoChains = objs.map(obj => protoChain(obj));

while (protoChains.every(protoChain => protoChain.length > 0)) {
Expand All @@ -54,8 +54,8 @@ export const nearestCommonProto = (...objs: object[]): Function => {
* flexible as updates to the source prototypes aren't captured by the mixed result. See softMixProtos for why you may
* want to use that instead.
*/
export const hardMixProtos = (ingredients: any[], constructor: Function, exclude: string[] = []): object => {
const base = nearestCommonProto(...ingredients);
export const hardMixProtos = (ingredients: any[], constructor: Function | null, exclude: string[] = []): object => {
const base = nearestCommonProto(...ingredients) ?? Object.prototype;
const mixedProto = Object.create(base);

// Keeps track of prototypes we've already visited to avoid copying the same properties multiple times. We init the
Expand Down Expand Up @@ -88,5 +88,15 @@ export const hardMixProtos = (ingredients: any[], constructor: Function, exclude
* changes made to the source prototypes will be reflected in the proxy-prototype, which may be desirable.
*/
export const softMixProtos = (ingredients: any[], constructor: Function): object => {
return proxyMix([...ingredients, { constructor }], null);
return proxyMix([...ingredients, { constructor }]);
};

export const unique = <T>(arr: T[]): T[] =>
arr.filter((e, i) => arr.indexOf(e) == i);

export const flatten = <T>(arr: T[][]): T[] =>
arr.length === 0
? []
: arr.length === 1
? arr[0]
: arr.reduce((a1, a2) => [...a1, ...a2]);
File renamed without changes.
45 changes: 45 additions & 0 deletions test/gh-issues/28.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import 'mocha';
import { expect } from 'chai';
import { forEachSettings } from '../util';

import { IsBoolean, IsIn, validate } from 'class-validator';
import { decorate, Mixin } from '../../src';

describe('gh-issue #28', () => {
forEachSettings(() => {
it('should work', async () => {
class Disposable {
@decorate(IsBoolean()) // instead of @IsBoolean()
isDisposed: boolean = false;
}

class Statusable {
@decorate(IsIn(['red', 'green'])) // instead of @IsIn(['red', 'green'])
status: string = 'green';
}

class Statusable2 {
@decorate(IsIn(['red', 'green'])) // instead of @IsIn(['red', 'green'])
other: string = 'green';
}

class ExtendedObject extends Mixin(Disposable, Statusable) {
}

class ExtendedObject2 extends Mixin(Statusable2, ExtendedObject) {
}

const extendedObject = new ExtendedObject2();
extendedObject.status = 'blue';
extendedObject.other = 'blue';
// @ts-expect-error
extendedObject.isDisposed = undefined;

const errors = await validate(extendedObject);
const errorProps = errors.map(error => error.property);
expect(errorProps).to.contain('status');
expect(errorProps).to.contain('other');
expect(errorProps).to.contain('isDisposed');
});
});
});
35 changes: 0 additions & 35 deletions test/integration/decorator-decorator.test.ts

This file was deleted.

6 changes: 3 additions & 3 deletions test/integration/init-function.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,21 +87,21 @@ describe('Using an init function', () => {
settings.initFunction = 'init';

class ClassA {
public initContextA = null;
public initContextA: any = null;
protected init() {
this.initContextA = this;
}
}

class ClassB {
public initContextB = null;
public initContextB: any = null;
protected init() {
this.initContextB = this;
}
}

class ClassC {
public initContextC = null;
public initContextC: any = null;
protected init() {
this.initContextC = this;
}
Expand Down
7 changes: 7 additions & 0 deletions test/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"noEmit": true
},
"include": ["**/*.ts"]
}

0 comments on commit 6daabc5

Please sign in to comment.