Skip to content

Commit

Permalink
feat(api-shared): retain entity order for queries (#944)
Browse files Browse the repository at this point in the history
Co-authored-by: Timon Masberg <contact@timonmasberg.com>
  • Loading branch information
JSPRH and timonmasberg authored Jun 24, 2024
1 parent ce78439 commit 000c434
Show file tree
Hide file tree
Showing 10 changed files with 185 additions and 30 deletions.
19 changes: 10 additions & 9 deletions libs/api/shared/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
export * from './lib/models/request.model';
export * from './lib/models/base-entity.model';
export * from './lib/models/base-document.model';
export * from './lib/models/validatable.model';
export * from './lib/models/coordinate.model';
export * from './lib/models/base.mapper-profile';
export * from './lib/exceptions';
export * from './lib/kernel/graphql';
export * from './lib/kernel/mongodb';
export * from './lib/kernel/shared-kernel.module';
export * from './lib/exceptions';
export * from './lib/kernel/service/retain-order.service';
export {
UnitOfWorkService,
DbSessionProvider,
UNIT_OF_WORK_SERVICE,
UnitOfWork,
UnitOfWorkService,
runDbOperation,
} from './lib/kernel/service/unit-of-work.service';
export * from './lib/kernel/shared-kernel.module';
export * from './lib/models/base-document.model';
export * from './lib/models/base-entity.model';
export { BaseModelProfile } from './lib/models/base-model.mapper-profile';
export * from './lib/models/base.mapper-profile';
export * from './lib/models/coordinate.model';
export * from './lib/models/request.model';
export * from './lib/models/validatable.model';
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { RetainOrderService } from './retain-order.service';

describe('RetainOrderMutator', () => {
const mutator = new RetainOrderService();

it('should retain id order if enabled in options', () => {
const result = mutator.retainOrderIfEnabled(
{ retainOrder: true },
['1', '2'],
[{ id: '2' }, { id: '1' }],
);

expect(result).toEqual([{ id: '1' }, { id: '2' }]);
});

it('should not retain id order if disabled in options', () => {
const result = mutator.retainOrderIfEnabled(
{ retainOrder: false },
['1', '2'],
[{ id: '2' }, { id: '1' }],
);

expect(result).toEqual([{ id: '2' }, { id: '1' }]);
});

it('should sort by id order', () => {
const result = mutator.sortByIdOrder(
['1', '2'],
[{ id: '2' }, { id: '1' }],
);

expect(result).toEqual([{ id: '1' }, { id: '2' }]);
});

it('should throw error on missing entity', () => {
expect(() => mutator.sortByIdOrder(['1', '2'], [{ id: '1' }])).toThrow(
'Missing entities for ids: 2',
);
});
});
39 changes: 39 additions & 0 deletions libs/api/shared/src/lib/kernel/service/retain-order.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
export interface RetainOrderOptions {
retainOrder: boolean;
}

export class RetainOrderService {
public retainOrderIfEnabled<T extends { id: string }>(
{ retainOrder }: RetainOrderOptions,
ids: string[],
entities: T[],
): T[] {
if (retainOrder) {
return this.sortByIdOrder(ids, entities);
}
return entities;
}

public sortByIdOrder<T extends { id: string }>(
ids: string[],
entities: T[],
): T[] {
const orderedUnits = ids.map((id) =>
entities.find((unit) => unit.id === id),
);
this.assertNoMissingEntities(ids, orderedUnits);
return orderedUnits;
}

private assertNoMissingEntities<T extends { id: string }>(
ids: string[],
entities: (T | undefined)[],
): asserts entities is T[] {
if (entities.includes(undefined)) {
const missingIds = ids.filter(
(id) => entities.find((entity) => entity?.id === id) === undefined,
);
throw new Error(`Missing entities for ids: ${missingIds.join(',')}`);
}
}
}
7 changes: 5 additions & 2 deletions libs/api/shared/src/lib/kernel/shared-kernel.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { BaseModelProfile } from '../models/base-model.mapper-profile';
import { DataLoaderContainer, GraphQLSubscriptionService } from './graphql';
import { MongoEncryptionClientProvider } from './mongodb';
import { MongoEncryptionService } from './mongodb/mongo-encryption.service';
import { RetainOrderService } from './service/retain-order.service';
import {
UNIT_OF_WORK_SERVICE,
UnitOfWorkServiceImpl,
Expand All @@ -19,15 +20,17 @@ import {
GraphQLSubscriptionService,
MongoEncryptionClientProvider,
MongoEncryptionService,
RetainOrderService,
{ provide: UNIT_OF_WORK_SERVICE, useClass: UnitOfWorkServiceImpl },
],
exports: [
BaseModelProfile,
CqrsModule,
DataLoaderContainer,
GraphQLSubscriptionService,
MongoEncryptionClientProvider,
MongoEncryptionService,
CqrsModule,
DataLoaderContainer,
RetainOrderService,
UNIT_OF_WORK_SERVICE,
],
})
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import { Test } from '@nestjs/testing';
import { plainToInstance } from 'class-transformer';

import { RetainOrderService } from '@kordis/api/shared';

import { AlertGroupEntity } from '../entity/alert-group.entity';
import {
ALERT_GROUP_REPOSITORY,
AlertGroupRepository,
} from '../repository/alert-group.repository';
import { GetAlertGroupsByIdsHandler } from './get-alert-groups-by-ids.query';
import {
GetAlertGroupsByIdsHandler,
GetAlertGroupsByIdsQuery,
} from './get-alert-groups-by-ids.query';

describe('GetAlertGroupsByIdsHandler', () => {
let getAlertGroupsByIdsHandler: GetAlertGroupsByIdsHandler;
Expand All @@ -21,6 +27,7 @@ describe('GetAlertGroupsByIdsHandler', () => {
findByIds: jest.fn(),
},
},
RetainOrderService,
],
}).compile();

Expand All @@ -33,15 +40,13 @@ describe('GetAlertGroupsByIdsHandler', () => {
});

it('should return alert groups by ids', async () => {
const entity1 = new AlertGroupEntity();
entity1.name = 'Alert Group 1';
const entity2 = new AlertGroupEntity();
entity2.name = 'Alert Group 2';
mockAlertGroupRepository.findByIds.mockResolvedValue([entity1, entity2]);

const result = await getAlertGroupsByIdsHandler.execute({
ids: ['1', '2'],
});
const entity1 = plainToInstance(AlertGroupEntity, { id: '1' });
const entity2 = plainToInstance(AlertGroupEntity, { id: '2' });
mockAlertGroupRepository.findByIds.mockResolvedValue([entity2, entity1]);

const result = await getAlertGroupsByIdsHandler.execute(
new GetAlertGroupsByIdsQuery(['1', '2'], { retainOrder: true }),
);

expect(result).toEqual([entity1, entity2]);
expect(mockAlertGroupRepository.findByIds).toHaveBeenCalledWith(['1', '2']);
Expand Down
15 changes: 13 additions & 2 deletions libs/api/unit/src/lib/core/query/get-alert-groups-by-ids.query.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import { Inject } from '@nestjs/common';
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';

import { RetainOrderOptions, RetainOrderService } from '@kordis/api/shared';

import { AlertGroupEntity } from '../entity/alert-group.entity';
import {
ALERT_GROUP_REPOSITORY,
AlertGroupRepository,
} from '../repository/alert-group.repository';

export class GetAlertGroupsByIdsQuery {
constructor(readonly ids: string[]) {}
constructor(
readonly ids: string[],
readonly options: RetainOrderOptions = { retainOrder: false },
) {}
}

@QueryHandler(GetAlertGroupsByIdsQuery)
Expand All @@ -18,11 +23,17 @@ export class GetAlertGroupsByIdsHandler
constructor(
@Inject(ALERT_GROUP_REPOSITORY)
private readonly repository: AlertGroupRepository,
private readonly mutator: RetainOrderService,
) {}

async execute({
ids,
options,
}: GetAlertGroupsByIdsQuery): Promise<AlertGroupEntity[]> {
return this.repository.findByIds(ids);
let alertGroups = await this.repository.findByIds(ids);

alertGroups = this.mutator.retainOrderIfEnabled(options, ids, alertGroups);

return alertGroups;
}
}
48 changes: 46 additions & 2 deletions libs/api/unit/src/lib/core/query/get-units-by-ids.query.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { Test } from '@nestjs/testing';
import { plainToInstance } from 'class-transformer';

import { RetainOrderService } from '@kordis/api/shared';

import { UnitEntity } from '../entity/unit.entity';
import { UNIT_REPOSITORY, UnitRepository } from '../repository/unit.repository';
import { GetUnitsByIdsHandler } from './get-units-by-ids.query';
import {
GetUnitsByIdsHandler,
GetUnitsByIdsQuery,
} from './get-units-by-ids.query';

describe('GetUnitsByIdsHandler', () => {
let getUnitsByIdsHandler: GetUnitsByIdsHandler;
Expand All @@ -18,6 +24,7 @@ describe('GetUnitsByIdsHandler', () => {
findByIds: jest.fn(),
},
},
RetainOrderService,
],
}).compile();

Expand All @@ -36,9 +43,46 @@ describe('GetUnitsByIdsHandler', () => {

mockUnitRepository.findByIds.mockResolvedValue([entity1, entity2]);

const result = await getUnitsByIdsHandler.execute({ ids: ['1', '2'] });
const result = await getUnitsByIdsHandler.execute(
new GetUnitsByIdsQuery(['1', '2']),
);

expect(result).toEqual([entity1, entity2]);
expect(mockUnitRepository.findByIds).toHaveBeenCalledWith(['1', '2']);
});

it('should keep units in order if retainOrder is true', async () => {
const entity1 = plainToInstance(UnitEntity, {
id: '1',
});

const entity2 = plainToInstance(UnitEntity, {
id: '2',
});

mockUnitRepository.findByIds.mockResolvedValue([entity2, entity1]);

const result = await getUnitsByIdsHandler.execute(
new GetUnitsByIdsQuery(['1', '2'], { retainOrder: true }),
);

expect(result).toEqual([entity1, entity2]);
expect(mockUnitRepository.findByIds).toHaveBeenCalledWith(['1', '2']);
});

it('should throw an error for non-existing units', async () => {
const entity1 = new UnitEntity();
entity1.name = 'unit1';

const entity2 = new UnitEntity();
entity2.name = 'unit2';

mockUnitRepository.findByIds.mockResolvedValue([entity2, entity1]);

await expect(async () => {
await getUnitsByIdsHandler.execute(
new GetUnitsByIdsQuery(['1', '2', '3'], { retainOrder: true }),
);
}).rejects.toThrow();
});
});
16 changes: 13 additions & 3 deletions libs/api/unit/src/lib/core/query/get-units-by-ids.query.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,31 @@
import { Inject } from '@nestjs/common';
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';

import { RetainOrderOptions, RetainOrderService } from '@kordis/api/shared';

import { UnitEntity } from '../entity/unit.entity';
import { UNIT_REPOSITORY, UnitRepository } from '../repository/unit.repository';

export class GetUnitsByIdsQuery {
constructor(readonly ids: string[]) {}
constructor(
readonly ids: string[],
readonly options: RetainOrderOptions = { retainOrder: false },
) {}
}

@QueryHandler(GetUnitsByIdsQuery)
export class GetUnitsByIdsHandler implements IQueryHandler<GetUnitsByIdsQuery> {
constructor(
@Inject(UNIT_REPOSITORY)
private readonly repository: UnitRepository,
private readonly mutator: RetainOrderService,
) {}

async execute({ ids }: GetUnitsByIdsQuery): Promise<UnitEntity[]> {
return this.repository.findByIds(ids);
async execute({ ids, options }: GetUnitsByIdsQuery): Promise<UnitEntity[]> {
let units = await this.repository.findByIds(ids);

units = this.mutator.retainOrderIfEnabled(options, ids, units);

return units;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ describe('UnitsDataLoader', () => {
await loader.loadMany(unitIds);

expect(queryBusMock.execute).toHaveBeenCalledWith(
new GetUnitsByIdsQuery(unitIds),
new GetUnitsByIdsQuery(unitIds, { retainOrder: true }),
);
});
});
4 changes: 3 additions & 1 deletion libs/api/unit/src/lib/data-loader/units.data-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ export class UnitsDataLoader {
loaderContainer.registerLoadingFunction(
UNITS_DATA_LOADER,
async (unitIds: readonly string[]) =>
bus.execute(new GetUnitsByIdsQuery(unitIds as string[])),
bus.execute(
new GetUnitsByIdsQuery(unitIds as string[], { retainOrder: true }),
),
);
}
}

0 comments on commit 000c434

Please sign in to comment.