Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

BC-6870 - Create unified board persistence entity #4919

Closed
wants to merge 27 commits into from
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
fb8bca5
add domain object and persistence
uidp Jan 14, 2024
7fab721
add minor changes and comments
uidp Jan 15, 2024
6a49333
Merge branch 'main' of github.com:hpi-schul-cloud/schulcloud-server i…
uidp Apr 11, 2024
dccffe3
add entity, repo and example domain object
uidp Apr 11, 2024
6860b73
add TODO item
uidp Apr 12, 2024
8f19c72
fix orm entity and test
uidp Apr 12, 2024
40d3767
BC-6870 - fixes and add findByIds
virgilchiriac Apr 15, 2024
d12bf40
BC-6870 - add a couple of functions
virgilchiriac Apr 15, 2024
ca33b28
add todo
uidp Apr 16, 2024
8969652
Revert "BC-6870 - add a couple of functions"
uidp Apr 16, 2024
9b106ea
Revert "BC-6870 - fixes and add findByIds"
uidp Apr 16, 2024
7d8e9d6
implement typings
uidp Apr 17, 2024
b880cd4
isAllowedAsChild added
wolfganggreschus Apr 18, 2024
d1c7bfc
add simple column domain object
uidp Apr 19, 2024
024fb6d
improve board node domain object
uidp Apr 19, 2024
4521db9
improve board node repo
uidp Apr 19, 2024
5d9400f
add comments and todos
uidp Apr 19, 2024
a03088b
Merge branch 'BC-6870-board-persistence' of github.com:hpi-schul-clou…
uidp Apr 19, 2024
a40c5c1
use spcific board node type on properties types
uidp Apr 22, 2024
435d3c5
add context embeddable
uidp Apr 22, 2024
059fce1
fix test factories
uidp Apr 22, 2024
713d419
add findByIdAndType to board node repo
uidp Apr 22, 2024
3d444f6
add simple board node service
uidp Apr 22, 2024
70a3b29
implement child position update
uidp Apr 23, 2024
157e269
refactor types and implement repo.findByIds
uidp Apr 24, 2024
d19d187
add repo methods
uidp Apr 25, 2024
7164d18
Merge branch 'main' of github.com:hpi-schul-cloud/schulcloud-server i…
uidp Apr 25, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/server/src/modules/board/poc/domain/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './board-node.do';
export * from './card.do';
export * from './richTextElement.do';
export * from './path-utils';
export * from './types';
21 changes: 21 additions & 0 deletions apps/server/src/modules/board/poc/domain/richTextElement.do.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { InputFormat } from '@shared/domain/types';
import { BoardNode } from './board-node.do';
import { RichTextElementProps } from './types';

export class RichTextElement extends BoardNode<RichTextElementProps> {
get text(): string {
return this.props.text;
}

set text(text: string) {
this.props.text = text;
}

get inputFormat(): InputFormat {
return this.props.inputFormat;
}

set inputFormat(inputFormat: InputFormat) {
this.props.inputFormat = inputFormat;
}
}
14 changes: 10 additions & 4 deletions apps/server/src/modules/board/poc/domain/types.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { BoardNodeType } from '@shared/domain/entity';
import { EntityId } from '@shared/domain/types';
import type { Card } from './card.do';
import { EntityId, InputFormat } from '@shared/domain/types';
import type { Card, RichTextElement } from '.';

export type AnyBoardNode = Card; // union type. add more types
export type AnyBoardNode = Card | RichTextElement; // union type. add more types

export interface BoardNodeProps {
id: EntityId;
Expand All @@ -20,4 +20,10 @@ export interface CardProps extends BoardNodeProps {
height: number;
}

export type AnyBoardNodeProps = CardProps; // union (or intersection?) type. add more types
export interface RichTextElementProps extends BoardNodeProps {
text: string;
inputFormat: InputFormat;
}

export type AnyBoardNodeProps = CardProps | RichTextElementProps;
export type AllBoardNodeProps = CardProps & RichTextElementProps;
82 changes: 82 additions & 0 deletions apps/server/src/modules/board/poc/repo/board-node.repo.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { CardProps } from '../domain';
import { BoardNodeEntity } from './entity/board-node.entity';
import { BoardNodeRepo } from './board-node.repo';
import { cardFactory } from '../testing';
// import { Card } from '../domain/card.do';

const propsFactory = Factory.define<CardProps>(({ sequence }) => {
return {
Expand Down Expand Up @@ -73,6 +74,87 @@ describe('BoardNodeRepo', () => {
expect(boardNode.id).toBeDefined();
});
});
/*
describe('when finding by class and id', () => {
describe('when the node is not of the given class', () => {
const setup = async () => {
const props = em.create(BoardNodeEntity, propsFactory.build());
await em.persistAndFlush(props);
em.clear();

return { props };
};

it('should throw an error', async () => {
const { props } = await setup();

await expect(repo.findByClassAndId(Card, props.id)).rejects.toThrowError();
});
});
});
*/

describe('when finding multiple nodes by known ids', () => {
const setup = async () => {
const props = em.create(BoardNodeEntity, propsFactory.build());

const child = cardFactory.build();

const propsWithChildren = em.create(BoardNodeEntity, propsFactory.build());

// em.find.mockResolvedValueOnce([child]);

await em.persistAndFlush([props, propsWithChildren]);
em.clear();
propsWithChildren.children = [child];

return { props, propsWithChildren, child };
};

it('should find the node', async () => {
const { props, propsWithChildren } = await setup();

const boardNodes = await repo.findByIds([props.id, propsWithChildren.id]);

expect(boardNodes[0].id).toBeDefined();
expect(boardNodes[1].id).toBeDefined();
});
});

describe('when finding titles by ids', () => {
const setup = async () => {
const propWithTitle = em.create(BoardNodeEntity, propsFactory.build());
const propWithoutTitle = em.create(BoardNodeEntity, propsFactory.build({ title: undefined }));
await em.persistAndFlush([propWithTitle, propWithoutTitle]);
em.clear();

return { propWithTitle, propWithoutTitle };
};

it('should find the titles', async () => {
const { propWithTitle, propWithoutTitle } = await setup();

const titles = await repo.getTitlesByIds([propWithTitle.id, propWithoutTitle.id]);

expect(titles[propWithTitle.id]).toEqual(propWithTitle.title);
});

it('should return for single id', async () => {
const { propWithTitle } = await setup();

const titles = await repo.getTitlesByIds(propWithTitle.id);

expect(titles[propWithTitle.id]).toEqual(propWithTitle.title);
});

it('should return empty string for nodes without title', async () => {
const { propWithoutTitle } = await setup();

const titles = await repo.getTitlesByIds(propWithoutTitle.id);

expect(titles[propWithoutTitle.id]).toEqual('');
});
});

describe('after persisting a single node', () => {
const setup = () => {
Expand Down
152 changes: 132 additions & 20 deletions apps/server/src/modules/board/poc/repo/board-node.repo.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
import { EntityManager } from '@mikro-orm/mongodb';
import { Injectable } from '@nestjs/common';
import { EntityManager, ObjectId } from '@mikro-orm/mongodb';
import { Injectable, NotFoundException } from '@nestjs/common';
import { EntityId } from '@shared/domain/types';
import { Utils } from '@mikro-orm/core';
import { type FilterQuery, Utils } from '@mikro-orm/core';
import { BoardNodeType } from '@shared/domain/entity';
import type { BoardExternalReference } from '@shared/domain/domainobject';
import { BoardNodeEntity } from './entity/board-node.entity';
import { TreeBuilder } from './tree-builder';
import { joinPath } from '../domain/path-utils';
import { AnyBoardNode, AnyBoardNodeProps } from '../domain';
import type { ContextExternalTool } from '../../../tool/context-external-tool/domain';

@Injectable()
export class BoardNodeRepo {
constructor(private readonly em: EntityManager) {}

async findById(id: EntityId, depth?: number): Promise<AnyBoardNode> {
const props = await this.em.findOneOrFail(BoardNodeEntity, { id });
public async findById(id: EntityId, depth?: number): Promise<AnyBoardNode> {
const props: AnyBoardNodeProps = await this.findOneOrFail(id);
const descendants = await this.findDescendants(props, depth);

const builder = new TreeBuilder(descendants);
Expand All @@ -21,16 +24,83 @@ export class BoardNodeRepo {
return boardNode;
}

// TODO findByIds()
/* TODO replace class with type instead */
public async findByClassAndId<S, T extends AnyBoardNode>(
doClass: { new (props: S): T },
id: EntityId,
depth?: number
): Promise<T> {
const domainObject = await this.findById(id, depth);
if (!(domainObject instanceof doClass)) {
throw new NotFoundException(`There is no '${doClass.name}' with this id`);
}

return domainObject;
}

// TODO: handle depth?
public async findByIds(ids: EntityId[]): Promise<AnyBoardNode[]> {
const props: AnyBoardNodeProps[] = await this.findMany(ids);

const childrenMap = await this.findDescendantsOfMany(props);

const domainObjects = props.map((prop) => {
const children = childrenMap[joinPath(prop.path, prop.id)];
const builder = new TreeBuilder(children);
return builder.build(prop);
});

return domainObjects;
}

public async getTitlesByIds(id: EntityId[] | EntityId): Promise<Record<EntityId, string>> {
const ids = Utils.asArray(id);
const boardNodeEntities = await this.em.find(BoardNodeEntity, { id: { $in: ids } });

const titlesMap = boardNodeEntities.reduce((map, node) => {
map[node.id] = node.title || '';
return map;
}, {} as Record<EntityId, string>);

return titlesMap;
}

persist(boardNode: AnyBoardNode | AnyBoardNode[]): BoardNodeRepo {
public async findByExternalReference(reference: BoardExternalReference): Promise<EntityId[]> {
// TODO Use an abstract base class for root nodes that have a contextId and a contextType. Multiple STI abstract base classes are blocked by MikroORM 6.1.2 (issue #3745)
const boardNodeEntities: BoardNodeEntity[] = await this.em.find(BoardNodeEntity, {
_contextId: new ObjectId(reference.id),
_contextType: reference.type,
} as FilterQuery<BoardNodeEntity>);

const ids: EntityId[] = boardNodeEntities.map((node) => node.id);

return ids;
}

public async countBoardUsageForExternalTools(contextExternalTools: ContextExternalTool[]) {
const toolIds: EntityId[] = contextExternalTools
.map((tool: ContextExternalTool): EntityId | undefined => tool.id)
.filter((id: EntityId | undefined): id is EntityId => !!id);

const boardNodeEntities = await this.em.find(BoardNodeEntity, {
type: BoardNodeType.EXTERNAL_TOOL,
contextExternalToolId: { $in: toolIds },
} as unknown as FilterQuery<BoardNodeEntity>);

const boardIds: EntityId[] = boardNodeEntities.map((node): EntityId => node.ancestorIds[0]);
const boardCount: number = new Set(boardIds).size;

return boardCount;
}

public persist(boardNode: AnyBoardNode | AnyBoardNode[]): BoardNodeRepo {
const boardNodes = Utils.asArray(boardNode);

boardNodes.forEach((bn) => {
let props = this.getProps(bn);

if (!(props instanceof BoardNodeEntity)) {
props = this.em.create(BoardNodeEntity, props);
props = this.em.create(BoardNodeEntity, props) as AnyBoardNodeProps;
this.setProps(bn, props);
}

Expand All @@ -40,24 +110,37 @@ export class BoardNodeRepo {
return this;
}

async persistAndFlush(boardNode: AnyBoardNode | AnyBoardNode[]): Promise<void> {
public async persistAndFlush(boardNode: AnyBoardNode | AnyBoardNode[]): Promise<void> {
return this.persist(boardNode).flush();
}

remove(boardNode: AnyBoardNode): BoardNodeRepo {
public remove(boardNode: AnyBoardNode): BoardNodeRepo {
this.em.remove(this.getProps(boardNode));

return this;
}

async removeAndFlush(boardNode: AnyBoardNode): Promise<void> {
public async removeAndFlush(boardNode: AnyBoardNode): Promise<void> {
await this.em.removeAndFlush(this.getProps(boardNode));
}

async flush(): Promise<void> {
public async flush(): Promise<void> {
return this.em.flush();
}

private getProps(boardNode: AnyBoardNode): AnyBoardNodeProps {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const { props } = boardNode;
return props;
}

private setProps(boardNode: AnyBoardNode, props: AnyBoardNodeProps): void {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
boardNode.props = props;
}

private async findDescendants(props: AnyBoardNodeProps, depth?: number): Promise<AnyBoardNodeProps[]> {
const levelQuery = depth !== undefined ? { $gt: props.level, $lte: props.level + depth } : { $gt: props.level };
const pathOfChildren = joinPath(props.path, props.id);
Expand All @@ -67,17 +150,46 @@ export class BoardNodeRepo {
level: levelQuery,
});

return descendants;
return descendants as AnyBoardNodeProps[];
}

private getProps(boardNode: AnyBoardNode): AnyBoardNodeProps {
// @ts-ignore
const { props } = boardNode;
return props;
private async findDescendantsOfMany(props: AnyBoardNodeProps[]): Promise<Record<string, AnyBoardNodeProps[]>> {
const pathQueries = props.map((prop) => {
return { path: { $re: `^${joinPath(prop.path, prop.id)}` } };
});

const map: Record<string, AnyBoardNodeProps[]> = {};
if (pathQueries.length === 0) {
return map;
}

const descendants = (await this.em.find(BoardNodeEntity, {
$or: pathQueries,
})) as AnyBoardNodeProps[];

// this is for finding tha ancestors of a descendant
// we use this to group the descendants by ancestor
// TODO we probably need a more efficient way to do the grouping
const matchAncestors = (descendant: AnyBoardNodeProps): AnyBoardNodeProps[] => {
const result = props.filter((n) => descendant.path.match(`^${n.path}`));
return result;
};

for (const desc of descendants) {
const ancestorNodes = matchAncestors(desc);
ancestorNodes.forEach((node) => {
map[node.path] ||= [];
map[node.path].push(desc);
});
}
return map;
}

private setProps(boardNode: AnyBoardNode, props: AnyBoardNodeProps): void {
// @ts-ignore
boardNode.props = props;
private findOneOrFail(id: EntityId): Promise<AnyBoardNodeProps> {
return this.em.findOneOrFail(BoardNodeEntity, id) as Promise<AnyBoardNodeProps>;
}

private findMany(ids: EntityId[]): Promise<AnyBoardNodeProps[]> {
return this.em.find(BoardNodeEntity, { id: { $in: ids } }) as Promise<AnyBoardNodeProps[]>;
}
}
Loading
Loading