From fb8bca5e7c315917d6d8d9f83926a3f2a07363a4 Mon Sep 17 00:00:00 2001 From: Uwe Ilgenstein Date: Sun, 14 Jan 2024 21:52:28 +0100 Subject: [PATCH] add domain object and persistence --- .../modules/board/poc/board-node.do.spec.ts | 199 ++++++++++++++++++ .../src/modules/board/poc/board-node.do.ts | 86 ++++++++ .../board/poc/board-node.entity.spec.ts | 40 ++++ .../modules/board/poc/board-node.entity.ts | 26 +++ .../modules/board/poc/board-node.factory.ts | 18 ++ .../modules/board/poc/board-node.repo.spec.ts | 130 ++++++++++++ .../src/modules/board/poc/board-node.repo.ts | 81 +++++++ .../src/modules/board/poc/path-utils.ts | 7 + .../src/modules/board/poc/tree-builder.ts | 28 +++ 9 files changed, 615 insertions(+) create mode 100644 apps/server/src/modules/board/poc/board-node.do.spec.ts create mode 100644 apps/server/src/modules/board/poc/board-node.do.ts create mode 100644 apps/server/src/modules/board/poc/board-node.entity.spec.ts create mode 100644 apps/server/src/modules/board/poc/board-node.entity.ts create mode 100644 apps/server/src/modules/board/poc/board-node.factory.ts create mode 100644 apps/server/src/modules/board/poc/board-node.repo.spec.ts create mode 100644 apps/server/src/modules/board/poc/board-node.repo.ts create mode 100644 apps/server/src/modules/board/poc/path-utils.ts create mode 100644 apps/server/src/modules/board/poc/tree-builder.ts diff --git a/apps/server/src/modules/board/poc/board-node.do.spec.ts b/apps/server/src/modules/board/poc/board-node.do.spec.ts new file mode 100644 index 00000000000..ede78c07362 --- /dev/null +++ b/apps/server/src/modules/board/poc/board-node.do.spec.ts @@ -0,0 +1,199 @@ +import { boardNodeFactory } from './board-node.factory'; +import { INITIAL_PATH, joinPath } from './path-utils'; + +describe('BoardNode', () => { + describe('a new instance', () => { + const setup = () => { + const boardNode = boardNodeFactory.build(); + + return { boardNode }; + }; + + it('should have no ancestors', () => { + const { boardNode } = setup(); + expect(boardNode.ancestorIds).toHaveLength(0); + }); + + it('should have no parent', () => { + const { boardNode } = setup(); + expect(boardNode.hasParent()).toBe(false); + expect(boardNode.parentId).not.toBeDefined(); + }); + + it('should have level = 0', () => { + const { boardNode } = setup(); + expect(boardNode.level).toBe(0); + }); + + it('should have initial path', () => { + const { boardNode } = setup(); + expect(boardNode.path).toBe(INITIAL_PATH); + }); + }); + + describe('when adding to a parent', () => { + const setup = () => { + const parent = boardNodeFactory.build(); + const child = boardNodeFactory.build(); + + return { parent, child }; + }; + + it('should update the ancestor list', () => { + const { parent, child } = setup(); + + child.addToParent(parent); + + expect(child.ancestorIds).toEqual([parent.id]); + }); + + it('should update the children of the parent', () => { + const { parent, child } = setup(); + + child.addToParent(parent); + + expect(parent.children).toEqual([child]); + }); + + it('should update the level of the child', () => { + const { parent, child } = setup(); + + child.addToParent(parent); + + expect(child.level).toEqual(1); + }); + + it('should update the path', () => { + const { parent, child } = setup(); + + child.addToParent(parent); + + expect(child.path).toEqual(joinPath(parent.path, parent.id)); + }); + }); + + describe('when adding a child', () => { + const setup = () => { + const parent = boardNodeFactory.build(); + const child = boardNodeFactory.build(); + + return { parent, child }; + }; + + it('should update the ancestor list', () => { + const { parent, child } = setup(); + + parent.addChild(child); + + expect(child.ancestorIds).toEqual([parent.id]); + }); + + it('should update the children of the parent', () => { + const { parent, child } = setup(); + + parent.addChild(child); + + expect(parent.children).toEqual([child]); + }); + + it('should update the level of the child', () => { + const { parent, child } = setup(); + + parent.addChild(child); + + expect(child.level).toEqual(1); + }); + + it('should update the path of the child', () => { + const { parent, child } = setup(); + + parent.addChild(child); + + expect(child.path).toEqual(joinPath(parent.path, parent.id)); + }); + }); + + describe('when removing from a parent', () => { + const setup = () => { + const parent = boardNodeFactory.build(); + const child = boardNodeFactory.build(); + child.addToParent(parent); + + return { parent, child }; + }; + + it('should update the ancestor list', () => { + const { parent, child } = setup(); + + child.removeFromParent(parent); + + expect(child.ancestorIds).toEqual([]); + }); + + it('should update the children of the parent', () => { + const { parent, child } = setup(); + + child.removeFromParent(parent); + + expect(parent.children).toEqual([]); + }); + + it('should update the level of the child', () => { + const { parent, child } = setup(); + + child.removeFromParent(parent); + + expect(child.level).toEqual(0); + }); + + it('should update the path', () => { + const { parent, child } = setup(); + + child.removeFromParent(parent); + + expect(child.path).toEqual(INITIAL_PATH); + }); + }); + + describe('when removing a child', () => { + const setup = () => { + const parent = boardNodeFactory.build(); + const child = boardNodeFactory.build(); + parent.addChild(child); + + return { parent, child }; + }; + + it('should update the ancestor list', () => { + const { parent, child } = setup(); + + parent.removeChild(child); + + expect(child.ancestorIds).toEqual([]); + }); + + it('should update the children of the parent', () => { + const { parent, child } = setup(); + + parent.removeChild(child); + + expect(parent.children).toEqual([]); + }); + + it('should update the level of the child', () => { + const { parent, child } = setup(); + + parent.removeChild(child); + + expect(child.level).toEqual(0); + }); + + it('should update the path of the child', () => { + const { parent, child } = setup(); + + parent.removeChild(child); + + expect(child.path).toEqual(INITIAL_PATH); + }); + }); +}); diff --git a/apps/server/src/modules/board/poc/board-node.do.ts b/apps/server/src/modules/board/poc/board-node.do.ts new file mode 100644 index 00000000000..9d35be5bd14 --- /dev/null +++ b/apps/server/src/modules/board/poc/board-node.do.ts @@ -0,0 +1,86 @@ +import { DomainObject } from '@shared/domain/domain-object'; +import { EntityId } from '@shared/domain/types'; +import { INITIAL_PATH, PATH_SEPARATOR, joinPath } from './path-utils'; + +export interface BoardNodeProps { + id: EntityId; + path: string; + level: number; + position: number; + // type!: BoardNodeType; + title?: string; + children: BoardNode[]; + createdAt: Date; + updatedAt: Date; +} + +export class BoardNode extends DomainObject { + get level(): number { + return this.ancestorIds.length; + } + + get children(): readonly BoardNode[] { + // return this.props.children; // should we clone the array? + return [...this.props.children] as const; + } + + get parentId(): EntityId | undefined { + const parentId = this.hasParent() ? this.ancestorIds[this.ancestorIds.length - 1] : undefined; + return parentId; + } + + hasParent() { + return this.ancestorIds.length > 0; + } + + get ancestorIds(): EntityId[] { + const parentIds = this.props.path.split(PATH_SEPARATOR).filter((id) => id !== ''); + return parentIds; + } + + get path(): string { + return this.props.path; + } + + addToParent(parent: BoardNode, position?: { index1: string; index2: string }): void { + if (this.parentId !== parent.id) { + this.props.path = joinPath(parent.path, parent.id); + // TODO set index (position) + this.props.level = parent.level + 1; + } + if (!parent.children.includes(this)) { + parent.addChild(this, position); + } + } + + removeFromParent(parent: BoardNode): void { + if (this.parentId === parent.id) { + this.props.path = INITIAL_PATH; + // TODO set index (position) + this.props.level = 0; + } + if (parent.children.includes(this)) { + parent.removeChild(this); + } + } + + addChild(child: BoardNode, position?: { index1: string; index2: string }): void { + if (!this.children.includes(child)) { + this.props.children.push(child); + // TODO sort children + } + if (child.parentId !== this.id) { + child.addToParent(this, position); + } + } + + removeChild(child: BoardNode): void { + if (this.children.includes(child)) { + const index = this.children.indexOf(child); + this.props.children.splice(index, 1); + } + if (child.parentId === this.id) { + child.removeFromParent(this); + } + } +} diff --git a/apps/server/src/modules/board/poc/board-node.entity.spec.ts b/apps/server/src/modules/board/poc/board-node.entity.spec.ts new file mode 100644 index 00000000000..910a98b8124 --- /dev/null +++ b/apps/server/src/modules/board/poc/board-node.entity.spec.ts @@ -0,0 +1,40 @@ +import { MikroORM } from '@mikro-orm/mongodb'; +import { BaseEntityWithTimestamps } from '@shared/domain/entity'; +import { BoardNodeEntity } from './board-node.entity'; + +describe('entity', () => { + let orm: MikroORM; + + beforeAll(async () => { + orm = await MikroORM.init({ + entities: [BaseEntityWithTimestamps, BoardNodeEntity], + clientUrl: 'mongodb://localhost:27017/boardtest', + type: 'mongo', + validate: true, + allowGlobalContext: true, + }); + }); + + beforeEach(async () => { + await orm.schema.clearDatabase(); + }); + + afterAll(async () => { + await orm.schema.dropSchema(); + await orm.close(true); + }); + + it('persists', async () => { + const entity = new BoardNodeEntity(); + + await orm.em.persistAndFlush(entity); + orm.em.clear(); + + expect(entity.id).toBeDefined(); + + const result = await orm.em.findOneOrFail(BoardNodeEntity, { id: entity.id }); + + expect(result).toBeDefined(); + expect(result.id).toEqual(entity.id); + }); +}); diff --git a/apps/server/src/modules/board/poc/board-node.entity.ts b/apps/server/src/modules/board/poc/board-node.entity.ts new file mode 100644 index 00000000000..e718f5b9700 --- /dev/null +++ b/apps/server/src/modules/board/poc/board-node.entity.ts @@ -0,0 +1,26 @@ +import { BaseEntityWithTimestamps } from '@shared/domain/entity'; +import { Entity, Index, Property } from '@mikro-orm/core'; +import { BoardNode, BoardNodeProps } from './board-node.do'; + +@Entity({ tableName: 'boardnodes' }) +export class BoardNodeEntity extends BaseEntityWithTimestamps implements BoardNodeProps { + @Index() + @Property({ nullable: false }) + path = ','; // TODO find better way to provide defaults! + + @Property({ nullable: false }) + level = 0; + + @Property({ nullable: false }) + position = 0; + + // @Index() + // @Enum(() => BoardNodeType) + // type!: BoardNodeType; + + @Property({ nullable: true }) + title?: string; + + @Property({ persist: false }) + children: BoardNode[] = []; +} diff --git a/apps/server/src/modules/board/poc/board-node.factory.ts b/apps/server/src/modules/board/poc/board-node.factory.ts new file mode 100644 index 00000000000..755fb2f72f4 --- /dev/null +++ b/apps/server/src/modules/board/poc/board-node.factory.ts @@ -0,0 +1,18 @@ +/* istanbul ignore file */ +import { BaseFactory } from '@shared/testing'; +import { ObjectId } from 'bson'; +import { BoardNode, BoardNodeProps } from './board-node.do'; +import { INITIAL_PATH } from './path-utils'; + +export const boardNodeFactory = BaseFactory.define(BoardNode, ({ sequence }) => { + return { + id: new ObjectId().toHexString(), + path: INITIAL_PATH, + level: 0, + title: `column board #${sequence}`, + position: 0, + children: [], + createdAt: new Date(), + updatedAt: new Date(), + }; +}); diff --git a/apps/server/src/modules/board/poc/board-node.repo.spec.ts b/apps/server/src/modules/board/poc/board-node.repo.spec.ts new file mode 100644 index 00000000000..6d4e96a1d6f --- /dev/null +++ b/apps/server/src/modules/board/poc/board-node.repo.spec.ts @@ -0,0 +1,130 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { Test, TestingModule } from '@nestjs/testing'; +import { BaseEntityWithTimestamps } from '@shared/domain/entity'; +import { cleanupCollections } from '@shared/testing'; +import { MongoMemoryDatabaseModule } from '@src/infra/database'; +import { ObjectId } from 'bson'; +import { Factory } from 'fishery'; +import { BoardNodeProps } from './board-node.do'; +import { BoardNodeEntity } from './board-node.entity'; +import { boardNodeFactory } from './board-node.factory'; +import { BoardNodeRepo } from './board-node.repo'; + +const propsFactory = Factory.define(({ sequence }) => { + return { + id: new ObjectId().toHexString(), + path: '', + level: 0, + title: `column board #${sequence}`, + position: 0, + children: [], + createdAt: new Date(), + updatedAt: new Date(), + }; +}); + +describe('BoardNodeRepo', () => { + let module: TestingModule; + let repo: BoardNodeRepo; + let em: EntityManager; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [MongoMemoryDatabaseModule.forRoot({ entities: [BaseEntityWithTimestamps, BoardNodeEntity] })], + providers: [BoardNodeRepo], + }).compile(); + repo = module.get(BoardNodeRepo); + em = module.get(EntityManager); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(async () => { + await cleanupCollections(em); + }); + + describe('when persisting', () => { + it('should work', async () => { + const boardNode = boardNodeFactory.build(); + + repo.persist(boardNode); + await repo.flush(); + }); + }); + + describe('when finding a single node by known id', () => { + const setup = async () => { + const props = em.create(BoardNodeEntity, propsFactory.build()); + await em.persistAndFlush(props); + em.clear(); + + return { props }; + }; + + it('should find the node', async () => { + const { props } = await setup(); + + const boardNode = await repo.findById(props.id); + + expect(boardNode.id).toBeDefined(); + }); + }); + + describe('after persisting a single node', () => { + const setup = () => { + const boardNode = boardNodeFactory.build(); + em.clear(); + + return { boardNode }; + }; + + it('should exist in the database', async () => { + const { boardNode } = setup(); + + await repo.persistAndFlush(boardNode); + + const result = await em.findOneOrFail(BoardNodeEntity, { id: boardNode.id }); + expect(result.id).toEqual(boardNode.id); + }); + }); + + describe('after persisting multiple nodes', () => { + const setup = () => { + const boardNodes = boardNodeFactory.buildList(2); + em.clear(); + + return { boardNodes }; + }; + + it('should exist in the database', async () => { + const { boardNodes } = setup(); + + await repo.persistAndFlush(boardNodes); + + const result = await em.find(BoardNodeEntity, { id: boardNodes.map((bn) => bn.id) }); + expect(result.length).toEqual(2); + }); + }); + + describe('after a tree was peristed', () => { + const setup = async () => { + const parent = boardNodeFactory.build(); + const children = boardNodeFactory.buildList(2); + children.forEach((child) => parent.addChild(child)); + await repo.persistAndFlush([parent, ...children]); + em.clear(); + + return { parent, children }; + }; + + it('can be found using the repo', async () => { + const { parent, children } = await setup(); + + const result = await repo.findById(parent.id); + + expect(result.children.length).toEqual(children.length); + }); + }); +}); diff --git a/apps/server/src/modules/board/poc/board-node.repo.ts b/apps/server/src/modules/board/poc/board-node.repo.ts new file mode 100644 index 00000000000..831107cb733 --- /dev/null +++ b/apps/server/src/modules/board/poc/board-node.repo.ts @@ -0,0 +1,81 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { Injectable } from '@nestjs/common'; +import { EntityId } from '@shared/domain/types'; +import { Utils } from '@mikro-orm/core'; +import { BoardNode, BoardNodeProps } from './board-node.do'; +import { BoardNodeEntity } from './board-node.entity'; +import { TreeBuilder } from './tree-builder'; +import { joinPath } from './path-utils'; + +@Injectable() +export class BoardNodeRepo { + constructor(private readonly em: EntityManager) {} + + async findById(id: EntityId): Promise { + const props = await this.em.findOneOrFail(BoardNodeEntity, { id }); + const descendants = await this.findDescendants(props); + + const builder = new TreeBuilder(descendants); + const boardNode = builder.build(props); + + return boardNode; + } + + persist(boardNode: BoardNode | BoardNode[]): BoardNodeRepo { + const boardNodes = Utils.asArray(boardNode); + + boardNodes.forEach((bn) => { + let props = this.getProps(bn); + + if (!(props instanceof BoardNodeEntity)) { + props = this.em.create(BoardNodeEntity, props); + this.setProps(bn, props); + } + + this.em.persist(props); + }); + + return this; + } + + async persistAndFlush(boardNode: BoardNode | BoardNode[]): Promise { + return this.persist(boardNode).flush(); + } + + remove(boardNode: BoardNode): BoardNodeRepo { + this.em.remove(this.getProps(boardNode)); + + return this; + } + + async removeAndFlush(boardNode: BoardNode): Promise { + await this.em.removeAndFlush(this.getProps(boardNode)); + } + + async flush(): Promise { + return this.em.flush(); + } + + private async findDescendants(props: BoardNodeProps, depth?: number): Promise { + const levelQuery = depth !== undefined ? { $gt: props.level, $lte: props.level + depth } : { $gt: props.level }; + const pathOfChildren = joinPath(props.path, props.id); + + const descendants = await this.em.find(BoardNodeEntity, { + path: { $re: `^${pathOfChildren}` }, + level: levelQuery, + }); + + return descendants; + } + + private getProps(boardNode: BoardNode): BoardNodeProps { + // @ts-ignore + const { props } = boardNode; + return props; + } + + private setProps(boardNode: BoardNode, props: BoardNodeProps): void { + // @ts-ignore + boardNode.props = props; + } +} diff --git a/apps/server/src/modules/board/poc/path-utils.ts b/apps/server/src/modules/board/poc/path-utils.ts new file mode 100644 index 00000000000..9bd835da8a6 --- /dev/null +++ b/apps/server/src/modules/board/poc/path-utils.ts @@ -0,0 +1,7 @@ +import { EntityId } from '@shared/domain/types'; + +export const PATH_SEPARATOR = ','; + +export const INITIAL_PATH = PATH_SEPARATOR; + +export const joinPath = (path: string, id: EntityId): string => `${path}${id}${PATH_SEPARATOR}`; diff --git a/apps/server/src/modules/board/poc/tree-builder.ts b/apps/server/src/modules/board/poc/tree-builder.ts new file mode 100644 index 00000000000..fc83fcffda3 --- /dev/null +++ b/apps/server/src/modules/board/poc/tree-builder.ts @@ -0,0 +1,28 @@ +import { BoardNode, BoardNodeProps } from './board-node.do'; +import { joinPath } from './path-utils'; + +export class TreeBuilder { + private childrenMap: Record = {}; + + constructor(descendants: BoardNodeProps[] = []) { + for (const props of descendants) { + this.childrenMap[props.path] ||= []; + this.childrenMap[props.path].push(props); + } + } + + build(props: BoardNodeProps): BoardNode { + props.children = this.getChildren(props).map((childProps) => this.build(childProps)); + + const boardNode = new BoardNode(props); + + return boardNode; + } + + private getChildren(props: BoardNodeProps): BoardNodeProps[] { + const pathOfChildren = joinPath(props.path, props.id); + const children = this.childrenMap[pathOfChildren] || []; + const sortedChildren = children.sort((a, b) => a.position - b.position); + return sortedChildren; + } +}