From e7c275d6247b7a0867f275ea9e566e7fde4eb308 Mon Sep 17 00:00:00 2001 From: psachmann Date: Thu, 12 Oct 2023 16:33:18 +0200 Subject: [PATCH] EW-561 introducing search scopes --- playground.http | 29 +++++++++ src/global.d.ts | 2 + src/modules/person/api/person.controller.ts | 5 +- .../person/api/personen-query.param.ts | 22 +++++-- src/modules/person/domain/person.service.ts | 23 +++++++- src/modules/person/persistence/person.repo.ts | 47 ++++----------- .../person/persistence/person.scope.ts | 48 +++++++-------- src/shared/interface/find-options.ts | 16 ----- src/shared/interface/page.ts | 10 ---- src/shared/interface/person-search-query.ts | 7 --- src/shared/paging/paged.ts | 21 +++++++ src/shared/persistence/index.ts | 3 + src/shared/persistence/repo-base.ts | 16 +++++ src/shared/persistence/scope-base.ts | 59 +++++++++++++++++++ src/shared/persistence/scope.enums.ts | 9 +++ src/shared/query/empty-result.query.ts | 5 -- src/shared/repo/scope.ts | 43 -------------- tsconfig.json | 4 +- 18 files changed, 214 insertions(+), 155 deletions(-) create mode 100644 playground.http delete mode 100644 src/shared/interface/find-options.ts delete mode 100644 src/shared/interface/page.ts delete mode 100644 src/shared/interface/person-search-query.ts create mode 100644 src/shared/paging/paged.ts create mode 100644 src/shared/persistence/index.ts create mode 100644 src/shared/persistence/repo-base.ts create mode 100644 src/shared/persistence/scope-base.ts create mode 100644 src/shared/persistence/scope.enums.ts delete mode 100644 src/shared/query/empty-result.query.ts delete mode 100644 src/shared/repo/scope.ts diff --git a/playground.http b/playground.http new file mode 100644 index 000000000..43be5f00b --- /dev/null +++ b/playground.http @@ -0,0 +1,29 @@ +@host = 127.0.0.1 +@port = 9090 + +### + +GET http://{{host}}:{{port}}/docs HTTP/1.1 + +### + +POST http://{{host}}:{{port}}/api/organisation HTTP/1.1 +Content-Type: application/json + +{ + "kennung": "NI-HK-KGS", + "name": "Kooperative Gesamtschule Schwarmstedt", + "namensergaenzung": "string", + "kuerzel": "KGS", + "typ": "SCHULE" +} + +### + +@orgId = 78d9ae41-a101-4947-82cc-b0545d2b2e85 + +GET http://{{host}}:{{port}}/api/organisation/{{orgId}} HTTP/1.1 + +### + +GET http://localhost:8080/metrics HTTP/1.1 diff --git a/src/global.d.ts b/src/global.d.ts index d419e20ae..eafe2804e 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -3,3 +3,5 @@ declare type Option = T | null | undefined; declare type Result = { ok: true; value: T } | { ok: false; error: E }; declare type Persisted = WasPersisted extends true ? T : Option; + +declare type Counted = [T[], number]; diff --git a/src/modules/person/api/person.controller.ts b/src/modules/person/api/person.controller.ts index e4335a488..390be6956 100644 --- a/src/modules/person/api/person.controller.ts +++ b/src/modules/person/api/person.controller.ts @@ -7,6 +7,7 @@ import { ApiForbiddenResponse, ApiInternalServerErrorResponse, ApiNotFoundResponse, + ApiOkResponse, ApiTags, ApiUnauthorizedResponse, } from '@nestjs/swagger'; @@ -35,7 +36,7 @@ export class PersonController { } @Get(':personId') - @ApiCreatedResponse({ description: 'The person was successfully pulled.' }) + @ApiOkResponse({ description: 'The person was successfully returned.' }) @ApiBadRequestResponse({ description: 'Person ID is required' }) @ApiUnauthorizedResponse({ description: 'Not authorized to get the person.' }) @ApiNotFoundResponse({ description: 'The person does not exist.' }) @@ -51,7 +52,7 @@ export class PersonController { } @Get() - @ApiCreatedResponse({ description: 'The persons were successfully pulled.' }) + @ApiOkResponse({ description: 'The persons were successfully returned.' }) @ApiUnauthorizedResponse({ description: 'Not authorized to get persons.' }) @ApiForbiddenResponse({ description: 'Insufficient permissions to get persons.' }) @ApiInternalServerErrorResponse({ description: 'Internal server error while getting all persons.' }) diff --git a/src/modules/person/api/personen-query.param.ts b/src/modules/person/api/personen-query.param.ts index 7071025c9..0f4ed45ee 100644 --- a/src/modules/person/api/personen-query.param.ts +++ b/src/modules/person/api/personen-query.param.ts @@ -1,5 +1,4 @@ import { ApiProperty } from '@nestjs/swagger'; -import { Expose } from 'class-transformer'; import { IsOptional, IsString } from 'class-validator'; import { AutoMap } from '@automapper/classes'; @@ -21,7 +20,6 @@ export class PersonenQueryParam { @AutoMap() @IsOptional() @IsString() - @Expose({ name: 'familienname' }) @ApiProperty({ name: 'familienname', required: false, @@ -32,7 +30,6 @@ export class PersonenQueryParam { @AutoMap() @IsOptional() @IsString() - @Expose({ name: 'vorname' }) @ApiProperty({ name: 'vorname', required: false, @@ -44,7 +41,6 @@ export class PersonenQueryParam { /* @AutoMap() @IsOptional() @IsEnum(SichtfreigabeType) - @Expose({ name: 'sichtfreigabe' }) @ApiProperty({ name: 'sichtfreigabe', enum: SichtfreigabeType, @@ -53,4 +49,22 @@ export class PersonenQueryParam { nullable: true, }) public readonly sichtfreigabe: SichtfreigabeType = SichtfreigabeType.NEIN;*/ + + @AutoMap() + @IsOptional({}) + @ApiProperty({ + default: 0, + required: false, + nullable: false, + }) + public readonly offset: number = 0; + + @AutoMap() + @IsOptional({}) + @ApiProperty({ + default: 0, + required: false, + nullable: false, + }) + public readonly limit: number = 100; } diff --git a/src/modules/person/domain/person.service.ts b/src/modules/person/domain/person.service.ts index 10d2d01c0..ca7c77b86 100644 --- a/src/modules/person/domain/person.service.ts +++ b/src/modules/person/domain/person.service.ts @@ -2,6 +2,9 @@ import { Injectable } from '@nestjs/common'; import { DomainError, EntityNotFoundError, PersonAlreadyExistsError } from '../../../shared/error/index.js'; import { PersonDo } from '../domain/person.do.js'; import { PersonRepo } from '../persistence/person.repo.js'; +import { PersonScope } from '../persistence/person.scope.js'; +import { Paged } from '../../../shared/paging/paged.js'; +import { ScopeOrder } from '../../../shared/persistence/scope.enums.js'; @Injectable() export class PersonService { @@ -26,8 +29,22 @@ export class PersonService { return { ok: false, error: new EntityNotFoundError(`Person with the following ID ${id} does not exist`) }; } - public async findAllPersons(personDo: PersonDo): Promise[]> { - const persons: PersonDo[] = await this.personRepo.findAll(personDo); - return persons; + public async findAllPersons(personDto: PersonDo): Promise>> { + const scope: PersonScope = new PersonScope() + .searchBy({ + firstName: personDto.firstName, + lastName: personDto.lastName, + birthDate: personDto.birthDate, + }) + .sortBy('firstName', ScopeOrder.ASC) + .paged(0, 100); + const [persons, total]: Counted> = await this.personRepo.findBy(scope); + + return { + total, + offset: 0, + limit: 100, + items: persons, + }; } } diff --git a/src/modules/person/persistence/person.repo.ts b/src/modules/person/persistence/person.repo.ts index 415b261de..66ab91876 100644 --- a/src/modules/person/persistence/person.repo.ts +++ b/src/modules/person/persistence/person.repo.ts @@ -1,18 +1,12 @@ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -/* eslint-disable @typescript-eslint/no-unsafe-argument */ import { Mapper } from '@automapper/core'; import { getMapperToken } from '@automapper/nestjs'; import { EntityManager } from '@mikro-orm/postgresql'; import { Inject, Injectable } from '@nestjs/common'; import { PersonDo } from '../domain/person.do.js'; import { PersonEntity } from './person.entity.js'; -import { EntityName, Loaded, QueryOrderMap } from '@mikro-orm/core'; -import { IFindOptions, IPagination, SortOrder } from '../../../shared/interface/find-options.js'; -import { Page } from '../../../shared/interface/page.js'; -import { Scope } from '../../../shared/repo/scope.js'; +import { EntityName, Loaded } from '@mikro-orm/core'; import { PersonScope } from './person.scope.js'; -import { PersonSortingMapper } from './person-sorting.mapper.js'; -import { PersonSearchQuery } from '../../../shared/interface/person-search-query.js'; + @Injectable() export class PersonRepo { public constructor(private readonly em: EntityManager, @Inject(getMapperToken()) private readonly mapper: Mapper) {} @@ -21,6 +15,15 @@ export class PersonRepo { return PersonEntity; } + public async findBy(scope: PersonScope): Promise>> { + const [entities, total]: Counted = await scope.executeQuery(this.em); + const dos: PersonDo[] = entities.map((entity: PersonEntity) => + this.mapper.map(entity, PersonEntity, PersonDo), + ); + + return [dos, total]; + } + public async findById(id: string): Promise>> { const person: Option = await this.em.findOne(PersonEntity, { id }); if (person) { @@ -69,32 +72,4 @@ export class PersonRepo { await this.em.persistAndFlush(person); return this.mapper.map(person, PersonEntity, PersonDo); } - - public async findAll( - query: PersonSearchQuery, - options?: IFindOptions>, - ): Promise>> { - const pagination: IPagination = options?.pagination || {}; - const order: QueryOrderMap = PersonSortingMapper.mapDOSortOrderToQueryOrder(options?.order || {}); - const scope: Scope = new PersonScope() - .byFirstName(query.firstName) - .byLastName(query.lastName) - .byBirthDate(query.birthDate) - .allowEmptyQuery(true); - - if (order.firstName == null) { - order.firstName = SortOrder.asc; - } - - const [entities, total]: [PersonEntity[], number] = await this.em.findAndCount(PersonEntity, scope.query, { - ...pagination, - orderBy: order, - }); - - const entityDos: PersonDo[] = entities.map((person: PersonEntity) => - this.mapper.map(person, PersonEntity, PersonDo), - ); - const page: Page> = new Page>(entityDos, total); - return page; - } } diff --git a/src/modules/person/persistence/person.scope.ts b/src/modules/person/persistence/person.scope.ts index 59b0071f7..c5a376d7a 100644 --- a/src/modules/person/persistence/person.scope.ts +++ b/src/modules/person/persistence/person.scope.ts @@ -1,34 +1,28 @@ -/* eslint-disable @typescript-eslint/explicit-member-accessibility */ - -// TODO should Refactor with german -import { Scope } from '../../../shared/repo/scope.js'; +import { EntityName } from '@mikro-orm/core'; +import { ScopeBase, ScopeOperator } from '../../../shared/persistence/index.js'; import { PersonEntity } from './person.entity.js'; -export class PersonScope extends Scope { - byFirstName(firstName: string | undefined): this { - if (firstName) { - this.addQuery({ firstName: { $re: firstName } }); - } - return this; - } - byLastName(lastName: string | undefined): this { - if (lastName) { - this.addQuery({ lastName: { $re: lastName } }); - } - return this; - } +type SearchProps = { + firstName?: string; + lastName?: string; + birthDate?: Date; +}; - byBirthDate(birthDate: Date | undefined): this { - if (birthDate) { - this.addQuery({ birthDate }); - } - return this; +export class PersonScope extends ScopeBase { + public override get entityName(): EntityName { + return PersonEntity; } - /* byBirthPlace(birthPlace: string | undefined): this { - if (birthPlace) { - this.addQuery({ birthPlace }); - } + public searchBy(searchProps: SearchProps): this { + this.findBy( + { + firstName: searchProps.firstName, + lastName: searchProps.lastName, + birthDate: searchProps.birthDate, + }, + ScopeOperator.AND, + ); + return this; - } */ + } } diff --git a/src/shared/interface/find-options.ts b/src/shared/interface/find-options.ts deleted file mode 100644 index 915624503..000000000 --- a/src/shared/interface/find-options.ts +++ /dev/null @@ -1,16 +0,0 @@ -export interface IPagination { - skip?: number; - limit?: number; -} - -export enum SortOrder { - asc = 'asc', - desc = 'desc', -} - -export type SortOrderMap = Partial>; - -export interface IFindOptions { - pagination?: IPagination; - order?: SortOrderMap; -} diff --git a/src/shared/interface/page.ts b/src/shared/interface/page.ts deleted file mode 100644 index 0fba6d047..000000000 --- a/src/shared/interface/page.ts +++ /dev/null @@ -1,10 +0,0 @@ -export class Page { - public data: T[]; - - public total: number; - - public constructor(data: T[], total: number) { - this.data = data; - this.total = total; - } -} diff --git a/src/shared/interface/person-search-query.ts b/src/shared/interface/person-search-query.ts deleted file mode 100644 index 85072048d..000000000 --- a/src/shared/interface/person-search-query.ts +++ /dev/null @@ -1,7 +0,0 @@ -export interface PersonSearchQuery { - firstName?: string; - - lastName?: string; - - birthDate?: Date; -} diff --git a/src/shared/paging/paged.ts b/src/shared/paging/paged.ts new file mode 100644 index 000000000..190a7430d --- /dev/null +++ b/src/shared/paging/paged.ts @@ -0,0 +1,21 @@ +export interface Paged { + /** + * The number off items skipped from the start. + */ + offset: number; + + /** + * The maximal amount of items that can be fetched with one request. + */ + limit: number; + + /** + * The total amount of items tha can be fetched. + */ + total: number; + + /** + * The requested items. + */ + items: T[]; +} diff --git a/src/shared/persistence/index.ts b/src/shared/persistence/index.ts new file mode 100644 index 000000000..03de5e705 --- /dev/null +++ b/src/shared/persistence/index.ts @@ -0,0 +1,3 @@ +export * from './repo-base.js'; +export * from './scope-base.js'; +export * from './scope.enums.js'; diff --git a/src/shared/persistence/repo-base.ts b/src/shared/persistence/repo-base.ts new file mode 100644 index 000000000..bf2be9442 --- /dev/null +++ b/src/shared/persistence/repo-base.ts @@ -0,0 +1,16 @@ +import { AnyEntity, EntityName } from '@mikro-orm/core'; +import { EntityManager } from '@mikro-orm/postgresql'; + +export abstract class RepoBase { + protected constructor(protected readonly em: EntityManager) {} + + public abstract get entityName(): EntityName; + + public async findById(id: string): Promise>> { + const person: Option = await this.em.findOne(this.entityName, { id }); + if (person) { + return this.mapper.map(person, PersonEntity, PersonDo); + } + return null; + } +} diff --git a/src/shared/persistence/scope-base.ts b/src/shared/persistence/scope-base.ts new file mode 100644 index 000000000..8735a101f --- /dev/null +++ b/src/shared/persistence/scope-base.ts @@ -0,0 +1,59 @@ +import { AnyEntity, EntityName, QBFilterQuery, QBQueryOrderMap } from '@mikro-orm/core'; +import { EntityManager, QueryBuilder } from '@mikro-orm/postgresql'; +import { ScopeOrder, ScopeOperator } from './scope.enums.js'; + +export abstract class ScopeBase { + private readonly queryFilters: QBFilterQuery[] = []; + + private readonly queryOrderMaps: QBQueryOrderMap[] = []; + + private offset: number | undefined; + + private limit: number | undefined; + + public abstract get entityName(): EntityName; + + public async executeQuery(em: EntityManager): Promise<[T[], number]> { + const qb: QueryBuilder = em.createQueryBuilder(this.entityName); + const result: [T[], number] = await qb + .select('*') + .where(this.queryFilters) + .orderBy(this.queryOrderMaps) + .offset(this.offset) + .limit(this.limit) + .getResultAndCount(); + + return result; + } + + public sortBy(prop: keyof T, order: ScopeOrder): this { + const queryOrderMap: QBQueryOrderMap = { [prop]: order }; + + this.queryOrderMaps.push(queryOrderMap); + + return this; + } + + public paged(offset: number, limit: number): this { + this.offset = offset; + this.limit = limit; + + return this; + } + + protected findBy(props: Partial, operator: ScopeOperator): this { + const query: QBFilterQuery = { + [operator]: Object.keys(props) + .filter((key: string) => props[key] !== undefined) + .map((key: string) => { + return { + [key]: props[key], + }; + }), + }; + + this.queryFilters.push(query); + + return this; + } +} diff --git a/src/shared/persistence/scope.enums.ts b/src/shared/persistence/scope.enums.ts new file mode 100644 index 000000000..39a8c3598 --- /dev/null +++ b/src/shared/persistence/scope.enums.ts @@ -0,0 +1,9 @@ +export enum ScopeOperator { + AND = '$and', + OR = '$or', +} + +export enum ScopeOrder { + ASC = 'asc', + DESC = 'desc', +} diff --git a/src/shared/query/empty-result.query.ts b/src/shared/query/empty-result.query.ts deleted file mode 100644 index 777a57e9d..000000000 --- a/src/shared/query/empty-result.query.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * When this query is added ($and) to an existing query, - * it should ensure an empty result - */ -export const EmptyResultQuery = { $and: [{ id: false }] }; diff --git a/src/shared/repo/scope.ts b/src/shared/repo/scope.ts deleted file mode 100644 index 3add96247..000000000 --- a/src/shared/repo/scope.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* eslint-disable @typescript-eslint/explicit-member-accessibility */ -/* eslint-disable no-underscore-dangle */ - -import { FilterQuery } from '@mikro-orm/core'; -import { EmptyResultQuery } from '../query/empty-result.query.js'; - -type EmptyResultQueryType = typeof EmptyResultQuery; - -type ScopeOperator = '$and' | '$or'; - -export class Scope { - private _queries: FilterQuery[] = []; - - private _operator: ScopeOperator; - - private _allowEmptyQuery: boolean; - - constructor(operator: ScopeOperator = '$and') { - this._operator = operator; - this._allowEmptyQuery = false; - } - - get query(): FilterQuery { - if (this._queries.length === 0) { - if (this._allowEmptyQuery) { - return {} as FilterQuery; - } - return EmptyResultQuery as FilterQuery; - } - // eslint-disable-next-line @typescript-eslint/typedef - const query = this._queries.length > 1 ? { [this._operator]: this._queries } : this._queries[0]; - return query as FilterQuery; - } - - addQuery(query: FilterQuery): void { - this._queries.push(query); - } - - allowEmptyQuery(isEmptyQueryAllowed: boolean): Scope { - this._allowEmptyQuery = isEmptyQueryAllowed; - return this; - } -} diff --git a/tsconfig.json b/tsconfig.json index 5454d9578..9d0aafe85 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -80,7 +80,7 @@ "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ - "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "exactOptionalPropertyTypes": false, /* Interpret optional property types as written, rather than adding 'undefined'. */ "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ @@ -92,4 +92,4 @@ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ "skipLibCheck": true /* Skip type checking all .d.ts files. */ } -} +} \ No newline at end of file