Skip to content

Commit

Permalink
associations: handling circular, multiple same, undefined class, and …
Browse files Browse the repository at this point in the history
…not json class;

adding tests
  • Loading branch information
Richard Haddad committed Aug 16, 2019
1 parent 673e5f5 commit a992d0d
Show file tree
Hide file tree
Showing 17 changed files with 259 additions and 71 deletions.
12 changes: 5 additions & 7 deletions src/decorators/JSONObject.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import { JSONEntityNumber, JSONEntityObject } from '../types/JSONTypes';
import { JSONEntityObject } from '../types/JSONTypes';
import { Omit } from 'lodash';
import { ClassLike, DecoratorClassProps } from '../types/ClassTypes';
import { DecoratorEngine } from '../engine/DecoratorEngine';
import PropertyEngine from '../engine/PropertyEngine';
import SchemaEngine from '../engine/SchemaEngine';
import { NotAJsonSchemaError } from '../exception/NotAJsonSchemaError';
import AssociationEngine from '../engine/AssociationEngine';
import { ClassFn } from '../types/AssociationTypes';

type JSONObjectValue = Omit<Partial<JSONEntityObject>, 'type'> | (() => ClassLike);
type JSONObjectValue = Omit<Partial<JSONEntityObject>, 'type'> | ClassFn;

/**
* Decorator for JSON object attribute.
Expand All @@ -22,9 +20,9 @@ export function JSONObject(...args: [JSONObjectValue] | DecoratorClassProps): Fu
key: keyof ClassLike['prototype'] & string,
descriptor?: PropertyDescriptor
): void => {
const Class = (args as [Function])[0]();
const [classFn] = args as [ClassFn];

AssociationEngine.addAssociation(prototype, key, null, Class);
AssociationEngine.addAssociation(prototype, key, null, classFn);
};
}

Expand Down
30 changes: 23 additions & 7 deletions src/engine/AssociationEngine.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,39 @@
import { ClassLike } from '../types/ClassTypes';
import { JSONSchema7 } from 'json-schema';
import { REFLECT_KEY } from '../decorators/ReflectKeys';
import { Association, AssociationMap } from '../types/AssociationTypes';
import { Association, AssociationMap, ClassFn } from '../types/AssociationTypes';
import PropertyEngine from './PropertyEngine';
import SchemaEngine from './SchemaEngine';
import { NotAJsonSchemaError } from '../exception/NotAJsonSchemaError';
import { CircularDependencyError } from '../exception/CircularDependencyError';
import { Util } from './Util';

export default class AssociationEngine {
static computeJSONAssociations(target: ClassLike): void {
static computeJSONAssociations(target: ClassLike, sourceStack: ClassLike[] = []): void {
if (!Util.isClass(target)) {
throw new NotAJsonSchemaError(target);
}

if (sourceStack.some(s => s.name === target.name)) {
throw new CircularDependencyError(...sourceStack, target);
}

sourceStack.push(target);

const assocMapClass = AssociationEngine.getAssociations(target.name, target.prototype);

assocMapClass.forEach(a => {
const valueSchema = SchemaEngine.getReflectSchema(a.target);
const assocTarget: ClassLike = a.targetFn();

AssociationEngine.computeJSONAssociations(assocTarget, sourceStack);

const valueSchema = SchemaEngine.getReflectSchema(assocTarget);

if (!valueSchema) {
throw new NotAJsonSchemaError(a.target);
throw new NotAJsonSchemaError(assocTarget);
}

valueSchema.properties = PropertyEngine.getReflectProperties(a.target.prototype);
valueSchema.properties = PropertyEngine.getReflectProperties(assocTarget.prototype);

const value = a.jsonPropertyKey
? {
Expand All @@ -33,15 +49,15 @@ export default class AssociationEngine {
prototypeSource: C['prototype'],
propertyKey: keyof C['prototype'] & string,
jsonProperty: keyof JSONSchema7 | null,
classTarget: ClassLike
classTargetFn: ClassFn
): void {
const className = prototypeSource.constructor.name;

const association: Association = {
className,
key: propertyKey,
jsonPropertyKey: jsonProperty,
target: classTarget
targetFn: classTargetFn
};

const associationMap = AssociationEngine.getReflectAssociation(prototypeSource) || {};
Expand Down
7 changes: 7 additions & 0 deletions src/engine/Util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { ClassLike } from '../types/ClassTypes';

export const Util = {
isClass(o: any): o is ClassLike {
return o && o['name'] && o['prototype'];
}
};
20 changes: 20 additions & 0 deletions src/exception/CircularDependencyError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { ClassLike } from '../types/ClassTypes';

const msg = (classStack: ClassLike[]) =>
`Circular dependency detected in the JSON Schema associations, it is not yet supported by Tabbouleh.
This is the path followed: ${classStack.map(c => c.name).join(' => ')}`;

/**
* To throw when a circular dependency is detected.
*/
export class CircularDependencyError extends Error {
/**
* @param classStack the stack of classes followed
*/
constructor(...classStack: ClassLike[]) {
super(msg(classStack));
Error.call(this);
Error.captureStackTrace(this, this.constructor);
Object.setPrototypeOf(this, CircularDependencyError.prototype);
}
}
12 changes: 9 additions & 3 deletions src/exception/NotAJsonSchemaError.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import { ClassLike } from '../types/ClassTypes';

const msg = (name: string) =>
`Class called by Tabbouleh but not decorate with @JSONSchema: ${name}`;
`Class called by Tabbouleh but not decorated with @JSONSchema: ${name}`;

/**
* To throw when an expected JSON Schema class is not (or undefined).
*/
export class NotAJsonSchemaError extends Error {
constructor(target: ClassLike) {
super(msg(target.name));
/**
* @param target the failing target
*/
constructor(target: ClassLike | unknown) {
super(msg(target && (target as any).name ? (target as any).name : target));
Error.call(this);
Error.captureStackTrace(this, this.constructor);
Object.setPrototypeOf(this, NotAJsonSchemaError.prototype);
Expand Down
16 changes: 12 additions & 4 deletions src/types/AssociationTypes.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,27 @@
import { ClassLike } from './ClassTypes';
import { JSONSchema7 } from 'json-schema';

export type ClassFn<C extends ClassLike = ClassLike> = () => C;

export type Association<C extends ClassLike = ClassLike> = {
className: C['name'];

// class property key
/**
* class property key
*/
key: keyof C['prototype'] & string;

jsonPropertyKey: keyof JSONSchema7 | null;

// class targeted
target: ClassLike;
/**
* class targeted
*/
targetFn: ClassFn;
};

export type AssociationMap = {
// class name
/**
* [className]: list Association
*/
[key: string]: Association[];
};
20 changes: 20 additions & 0 deletions test/JSONObject.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import Tabbouleh from '../src/engine/Tabbouleh';
import { OBJECT_SAMPLE_USER, JSONObjectSample } from './samples/JSONObject.sample';
import { JSONSchema7 } from 'json-schema';

const schemaObjectSample: JSONSchema7 = {
type: 'object',

properties: {
user: {
type: 'object',
...(OBJECT_SAMPLE_USER as any)
}
}
};

describe('check JSONObject', () => {
it('should handle JSONObject with JSONEntity given', () => {
expect(Tabbouleh.generateJSONSchema(JSONObjectSample)).toEqual(schemaObjectSample);
});
});
85 changes: 85 additions & 0 deletions test/association.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { JSONSchema7 } from 'json-schema';
import { CSample5, CS5_SCHEMA_PROPS } from './samples/CSample5';
import Tabbouleh from '../src/engine/Tabbouleh';
import { FOOD_SCHEMA_PROPS } from './genericSample/Food.sample';
import { CircularAssociationSample } from './associationSamples/CircularAssociation.sample';
import { CircularDependencyError } from '../src/exception/CircularDependencyError';
import { UndefinedAssociationSample } from './associationSamples/UndefinedAssociation.sample';
import { NotAJsonSchemaError } from '../src/exception/NotAJsonSchemaError';
import { NotJsonSchemaAssociationSample } from './associationSamples/NotJsonSchemaAssociation.sample';
import { MultipleSameAssociationSample } from './associationSamples/MultipleSameAssociation.sample';

const schemaCSample5: JSONSchema7 = {
type: 'object',

...CS5_SCHEMA_PROPS,

properties: {
type: {
type: 'string'
},

price: {
type: 'number'
},

food: {
type: 'object',

...FOOD_SCHEMA_PROPS,

properties: {
parsley: {
type: 'string'
}
}
}
}
};

const schemaMultipleSameAssociation: JSONSchema7 = {
type: 'object',
properties: {
prop: {
type: 'object',

...FOOD_SCHEMA_PROPS,

properties: {
parsley: {
type: 'string'
}
}
}
}
};

describe('Check if related schemas work', () => {
it('should handle nested object', () => {
expect(Tabbouleh.generateJSONSchema(CSample5)).toEqual(schemaCSample5);
});

it('should throw a CircularDependencyError on circular association', () => {
expect(() => Tabbouleh.generateJSONSchema(CircularAssociationSample)).toThrow(
CircularDependencyError
);
});

it('should throw a NotAJsonSchemaError on undefined association', () => {
expect(() => Tabbouleh.generateJSONSchema(UndefinedAssociationSample)).toThrow(
NotAJsonSchemaError
);
});

it('should throw a NotAJsonSchemaError on no-json-schema association', () => {
expect(() => Tabbouleh.generateJSONSchema(NotJsonSchemaAssociationSample)).toThrow(
NotAJsonSchemaError
);
});

it('should handle multiple same association', () => {
expect(Tabbouleh.generateJSONSchema(MultipleSameAssociationSample)).toEqual(
schemaMultipleSameAssociation
);
});
});
8 changes: 8 additions & 0 deletions test/associationSamples/CircularAssociation.sample.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { JSONSchema } from '../../src/decorators/JSONSchema';
import { JSONObject } from '../../src/decorators/JSONObject';

@JSONSchema
export class CircularAssociationSample {
@JSONObject(() => CircularAssociationSample)
inception: CircularAssociationSample;
}
10 changes: 10 additions & 0 deletions test/associationSamples/MultipleSameAssociation.sample.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { JSONSchema } from '../../src/decorators/JSONSchema';
import { FoodSample } from '../genericSample/Food.sample';
import { JSONObject } from '../../src/decorators/JSONObject';

@JSONSchema
export class MultipleSameAssociationSample {
@JSONObject(() => FoodSample)
@JSONObject(() => FoodSample)
prop: FoodSample;
}
9 changes: 9 additions & 0 deletions test/associationSamples/NotJsonSchemaAssociation.sample.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { JSONSchema } from '../../src/decorators/JSONSchema';
import { JSONObject } from '../../src/decorators/JSONObject';
import { NotJsonSchemaSample } from '../genericSample/NotJsonSchema.sample';

@JSONSchema
export class NotJsonSchemaAssociationSample {
@JSONObject(() => NotJsonSchemaSample)
fake: unknown;
}
8 changes: 8 additions & 0 deletions test/associationSamples/UndefinedAssociation.sample.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { JSONSchema } from '../../src/decorators/JSONSchema';
import { JSONObject } from '../../src/decorators/JSONObject';

@JSONSchema
export class UndefinedAssociationSample {
@JSONObject(() => undefined as any)
fake: unknown;
}
12 changes: 12 additions & 0 deletions test/genericSample/Food.sample.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { JSONSchema } from '../../src/decorators/JSONSchema';
import { JSONString } from '../../src/decorators/JSONString';

export const FOOD_SCHEMA_PROPS = {
title: 'Tabbouleh'
};

@JSONSchema<FoodSample>(FOOD_SCHEMA_PROPS)
export class FoodSample {
@JSONString
parsley: string;
}
5 changes: 5 additions & 0 deletions test/genericSample/NotJsonSchema.sample.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export class NotJsonSchemaSample {
toto: number;

tata: string;
}
37 changes: 0 additions & 37 deletions test/related-schema.test.ts

This file was deleted.

0 comments on commit a992d0d

Please sign in to comment.