diff --git a/packages/server/src/models/relation/path.test.ts b/packages/server/src/models/relation/path.test.ts new file mode 100644 index 000000000..e250419cd --- /dev/null +++ b/packages/server/src/models/relation/path.test.ts @@ -0,0 +1,56 @@ +import "ts-jest"; +import Superclass from "./superclass"; +import Path from "./path"; +import { RelationEnums } from "@shared/enums"; +import { IRelationModel } from "./relation"; +import Synonym from "./synonym"; + +describe("test Path for superclasses", () => { + let pathInstance = new Path(RelationEnums.Type.Superclass); + let entries: IRelationModel[] = []; + + beforeAll(async () => { + const ab = new Superclass({ entityIds: ["A", "B"] }) + const bc = new Superclass({ entityIds: ["B", "C"] }); + const cd = new Superclass({ entityIds: ["C", "D"] }); + entries = [ab, bc, cd]; + }) + + test("test Path.build", async () => { + pathInstance.build(entries); + expect(Object.keys(pathInstance.trees)).toHaveLength(entries.length); + }) + + test("test existing path from A->D", () => { + expect(pathInstance.pathExists("A", "D")).toBeTruthy(); + }) + + test("test existing path D -> A", () => { + expect(pathInstance.pathExists("D", "A")).toBeFalsy(); + }) +}); + +describe("test Path for synonyms & superclass", () => { + let pathInstance = new Path(RelationEnums.Type.Synonym); + let entries: IRelationModel[] = []; + + beforeAll(async () => { + const ab = new Synonym({ entityIds: ["A", "B"] }) + const bc = new Synonym({ entityIds: ["B", "C"] }); + const cd = new Superclass({ entityIds: ["C", "D"] }); + entries = [ab, bc, cd]; + }) + + test("test Path.build", async () => { + pathInstance.build(entries); + expect(Object.keys(pathInstance.trees)).toHaveLength(2); // only two synonyms + }) + + test("test existing path from A->D", () => { + expect(pathInstance.pathExists("A", "D")).toBeFalsy(); + }) + + test("test existing path D -> A", () => { + expect(pathInstance.pathExists("D", "A")).toBeFalsy(); + }) +}); \ No newline at end of file diff --git a/packages/server/src/models/relation/path.ts b/packages/server/src/models/relation/path.ts new file mode 100644 index 000000000..41603f827 --- /dev/null +++ b/packages/server/src/models/relation/path.ts @@ -0,0 +1,59 @@ +import { RelationEnums } from "@shared/enums"; +import { IRelationModel } from "./relation"; + +interface PathTree { + mainId: string; + ids: string[]; +} + +export default class Path { + type: RelationEnums.Type; + trees: Record + + constructor(type: RelationEnums.Type) { + this.type = type; + this.trees = {}; + } + + + async build(entries: IRelationModel[]) { + this.trees = {}; + for (const entry of entries.filter(e => e.type === this.type)) { + this.trees[entry.entityIds[0]] = { + mainId: entry.entityIds[0], + ids: entry.entityIds, + } + } + } + + /** + * Tests if there exists path from entity A -> B through relations + * @param a + * @param b + */ + pathExists(a: string, b: string): boolean { + if (!this.trees[a]) { + return false; + } + + let start = [this.trees[a]] + while (start.length) { + const nextStart = []; + for (const subtree of start) { + for (const entityId of subtree.ids) { + if (entityId === b) { + return true; + } + + // entry from 'entityId' -> X must exists to be added to nextStat + if (entityId !== subtree.mainId && this.trees[entityId]) { + nextStart.push(this.trees[entityId]); + } + } + } + start = nextStart; + } + + return false; + } +} \ No newline at end of file diff --git a/packages/server/src/models/relation/relation.ts b/packages/server/src/models/relation/relation.ts index 63e6a0431..03fc6441b 100644 --- a/packages/server/src/models/relation/relation.ts +++ b/packages/server/src/models/relation/relation.ts @@ -3,11 +3,12 @@ import { r as rethink, Connection, WriteResult } from "rethinkdb-ts"; import { IEntity, Relation as RelationTypes } from "@shared/types"; import { DbEnums, EntityEnums, RelationEnums, UserEnums } from "@shared/enums"; import { EnumValidators } from "@shared/enums"; -import { InternalServerError, ModelNotValidError } from "@shared/types/errors"; +import { InternalServerError, ModelNotValidError, RelationAsymetricalPathExist } from "@shared/types/errors"; import User from "@models/user/user"; import { IRequest } from "../../custom_typings/request"; import { nonenumerable } from "@common/decorators"; import Entity from "@models/entity/entity"; +import Path from "./path"; export interface IRelationModel extends RelationTypes.IRelation, IDbModel { beforeSave(request: IRequest): Promise; @@ -178,6 +179,15 @@ export default class Relation implements IRelationModel { * @param request */ async beforeSave(request: IRequest): Promise { + if (RelationTypes.RelationRules[this.type]?.asymmetrical) { + const pathHelper = new Path(this.type); + await pathHelper.build(await Relation.getByType(request.db.connection, this.type)) + + if (pathHelper.pathExists(this.entityIds[1], this.entityIds[0])) { + throw new RelationAsymetricalPathExist(); + } + } + if (!this.entities || this.entities.length !== this.entityIds.length) { this.entities = await Entity.findEntitiesByIds( request.db.connection, @@ -403,6 +413,15 @@ export default class Relation implements IRelationModel { return items; } + static async getByType(db: Connection, relType: RelationEnums.Type): Promise { + const items: T[] = await rethink + .table(Relation.table) + .filter({ type: relType }) + .run(db) + + return items; + } + /** * searches for relations with specific entity id and returns both relation ids and connected entity ids * @param request IRequest diff --git a/packages/shared/types/errors.ts b/packages/shared/types/errors.ts index d68b34c8f..0b2625277 100644 --- a/packages/shared/types/errors.ts +++ b/packages/shared/types/errors.ts @@ -339,6 +339,22 @@ class RelationDoesNotExist extends CustomError { } } + +/** + * RelationAsymetricalPathExist will be thrown when attempting to add asymetrical relation while there could already be path from A -> B + */ +class RelationAsymetricalPathExist extends CustomError { + public static code = 400; + public static title = "Asymetrical constraint check failed"; + public static message = "Relation cannot be created"; + + static forId(id: string): RelationAsymetricalPathExist { + return new RelationAsymetricalPathExist( + RelationAsymetricalPathExist.message.replace("$1", id) + ); + } +} + /** * DocumentDoesNotExist will be thrown when attempting to retrieve document by id, which does not exist */ @@ -461,6 +477,7 @@ const allErrors: Record = { StatementInvalidMove, EmailError, RelationDoesNotExist, + RelationAsymetricalPathExist, DocumentDoesNotExist, NetworkError, UnsafePasswordError, @@ -509,6 +526,7 @@ export { StatementInvalidMove, EmailError, RelationDoesNotExist, + RelationAsymetricalPathExist, DocumentDoesNotExist, NetworkError, UnsafePasswordError,