Skip to content

Commit 8f70a47

Browse files
committed
elastic criteria converter in backoffice
1 parent d5c33e6 commit 8f70a47

File tree

11 files changed

+183
-51
lines changed

11 files changed

+183
-51
lines changed

src/Contexts/Backoffice/Courses/infrastructure/persistence/ElasticBackofficeCourseRepository.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export class ElasticBackofficeCourseRepository
1818
return this.persist(course);
1919
}
2020

21-
matching(criteria: Criteria): Promise<BackofficeCourse[]> {
21+
async matching(criteria: Criteria): Promise<BackofficeCourse[]> {
2222
return this.searchByCriteria(criteria, BackofficeCourse.fromPrimitives);
2323
}
2424
}
Lines changed: 6 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { EnumValueObject } from '../value-object/EnumValueObject';
12
import { InvalidArgumentError } from '../value-object/InvalidArgumentError';
23

34
export enum FilterOperators {
@@ -9,12 +10,9 @@ export enum FilterOperators {
910
NOT_CONTAINS = 'NOT_CONTAINS'
1011
}
1112

12-
export class FilterOperator {
13-
readonly value: string;
14-
15-
constructor(value: string) {
16-
this.value = value;
17-
this.ensureIsBetweenAcceptedValues(value);
13+
export class FilterOperator extends EnumValueObject<FilterOperators> {
14+
constructor(value: FilterOperators) {
15+
super(value, Object.values(FilterOperators));
1816
}
1917

2018
static fromValue(value: string): FilterOperator {
@@ -32,24 +30,15 @@ export class FilterOperator {
3230
case FilterOperators.NOT_CONTAINS:
3331
return new FilterOperator(FilterOperators.NOT_CONTAINS);
3432
default:
35-
return new FilterOperator(value);
33+
throw new InvalidArgumentError(`The filter operator ${value} is invalid`);
3634
}
3735
}
3836

3937
public isPositive(): boolean {
4038
return this.value !== FilterOperators.NOT_EQUAL && this.value !== FilterOperators.NOT_CONTAINS;
4139
}
4240

43-
private ensureIsBetweenAcceptedValues(value: string): void {
44-
const allOperators: string[] = Object.values(FilterOperators);
45-
const operatorsIncludeValue = allOperators.includes(value);
46-
47-
if (!operatorsIncludeValue) {
48-
this.throwExceptionForInvalidValue(value);
49-
}
50-
}
51-
52-
private throwExceptionForInvalidValue(value: string): void {
41+
protected throwErrorForInvalidValue(value: FilterOperators): void {
5342
throw new InvalidArgumentError(`The filter operator ${value} is invalid`);
5443
}
5544
}

src/Contexts/Shared/domain/criteria/Order.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,6 @@ export class Order {
3131
}
3232

3333
public hasOrder() {
34-
return this.orderType.isNone();
34+
return !this.orderType.isNone();
3535
}
3636
}
Lines changed: 6 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { EnumValueObject } from '../value-object/EnumValueObject';
12
import { InvalidArgumentError } from '../value-object/InvalidArgumentError';
23

34
export enum OrderTypes {
@@ -6,12 +7,9 @@ export enum OrderTypes {
67
NONE = 'none'
78
}
89

9-
export class OrderType {
10-
readonly value: string;
11-
12-
constructor(type: string) {
13-
this.value = type;
14-
this.ensureIsBetweenAcceptedValues(type);
10+
export class OrderType extends EnumValueObject<OrderTypes> {
11+
constructor(value: OrderTypes) {
12+
super(value, Object.values(OrderTypes));
1513
}
1614

1715
static fromValue(value: string): OrderType {
@@ -21,7 +19,7 @@ export class OrderType {
2119
case OrderTypes.DESC:
2220
return new OrderType(OrderTypes.DESC);
2321
default:
24-
return new OrderType(value);
22+
throw new InvalidArgumentError(`The order type ${value} is invalid`);
2523
}
2624
}
2725

@@ -33,16 +31,7 @@ export class OrderType {
3331
return this.value === OrderTypes.ASC;
3432
}
3533

36-
private ensureIsBetweenAcceptedValues(value: string): void {
37-
const allOperators: string[] = Object.values(OrderTypes);
38-
const operatorsIncludeValue = allOperators.includes(value);
39-
40-
if (!operatorsIncludeValue) {
41-
this.throwExceptionForInvalidValue(value);
42-
}
43-
}
44-
45-
private throwExceptionForInvalidValue(value: string): void {
34+
protected throwErrorForInvalidValue(value: OrderTypes): void {
4635
throw new InvalidArgumentError(`The order type ${value} is invalid`);
4736
}
4837
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
export abstract class EnumValueObject<T> {
2+
readonly value: T;
3+
4+
constructor(value: T, public readonly validValues: T[]) {
5+
this.value = value;
6+
this.checkValueIsValid(value);
7+
}
8+
9+
public checkValueIsValid(value: T): void {
10+
if (!this.validValues.includes(value)) {
11+
this.throwErrorForInvalidValue(value);
12+
}
13+
}
14+
15+
protected abstract throwErrorForInvalidValue(value: T): void;
16+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { Bodybuilder } from 'bodybuilder';
2+
import { Criteria } from '../../../domain/criteria/Criteria';
3+
import { Filter } from '../../../domain/criteria/Filter';
4+
import { FilterOperators } from '../../../domain/criteria/FilterOperator';
5+
import { Filters } from '../../../domain/criteria/Filters';
6+
7+
export enum TypeQueryEnum {
8+
TERMS = 'terms',
9+
MATCH = 'match',
10+
MATCH_ALL = 'match_all',
11+
RANGE = 'range'
12+
}
13+
14+
type QueryObject = { type: TypeQueryEnum; field: string; value: string | object };
15+
16+
interface FunctionTransformer<T, K> {
17+
(value: T): K;
18+
}
19+
20+
export class ElasticCriteriaConverter {
21+
private queryTransformers: Map<FilterOperators, FunctionTransformer<Filter, QueryObject>>;
22+
23+
constructor() {
24+
this.queryTransformers = new Map<FilterOperators, FunctionTransformer<Filter, QueryObject>>([
25+
[FilterOperators.EQUAL, this.termsQuery],
26+
[FilterOperators.NOT_EQUAL, this.termsQuery],
27+
[FilterOperators.GT, this.greaterThanQuery],
28+
[FilterOperators.LT, this.lowerThanQuery],
29+
[FilterOperators.CONTAINS, this.matchQuery],
30+
[FilterOperators.NOT_CONTAINS, this.matchQuery]
31+
]);
32+
}
33+
34+
public convert(criteria: Criteria): Bodybuilder {
35+
let body = bodybuilder();
36+
37+
body.from(criteria.offset || 0);
38+
body.size(criteria.limit || 1000);
39+
40+
if (criteria.order.hasOrder()) {
41+
body.sort(criteria.order.orderBy.value, criteria.order.orderType.value);
42+
}
43+
44+
if (criteria.hasFilters()) {
45+
body = this.generateQuery(body, criteria.filters);
46+
}
47+
48+
return body;
49+
}
50+
51+
protected generateQuery(body: Bodybuilder, filters: Filters): Bodybuilder {
52+
filters.filters.map(filter => {
53+
const { type, value, field } = this.queryForFilter(filter);
54+
55+
if (filter.operator.isPositive()) {
56+
body.query(type, field, value);
57+
} else {
58+
body.notQuery(type, field, value);
59+
}
60+
});
61+
62+
return body;
63+
}
64+
65+
private queryForFilter(filter: Filter): QueryObject {
66+
const functionToApply = this.queryTransformers.get(filter.operator.value);
67+
68+
if (!functionToApply) {
69+
throw Error(`Unexpected operator value ${filter.operator.value}`);
70+
}
71+
72+
return functionToApply(filter);
73+
}
74+
75+
private termsQuery(filter: Filter): QueryObject {
76+
return { type: TypeQueryEnum.TERMS, field: filter.field.value, value: filter.value.value };
77+
}
78+
79+
private greaterThanQuery(filter: Filter): QueryObject {
80+
return { type: TypeQueryEnum.RANGE, field: filter.field.value, value: { gt: filter.value.value } };
81+
}
82+
83+
private lowerThanQuery(filter: Filter): QueryObject {
84+
return { type: TypeQueryEnum.RANGE, field: filter.field.value, value: { lt: filter.value.value } };
85+
}
86+
87+
private matchQuery(filter: Filter): QueryObject {
88+
return { type: TypeQueryEnum.MATCH, field: filter.field.value, value: filter.value.value };
89+
}
90+
}

src/Contexts/Shared/infrastructure/persistence/elasticsearch/ElasticRepository.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
import { Client as ElasticClient } from '@elastic/elasticsearch';
22
import { ResponseError } from '@elastic/elasticsearch/lib/errors';
3+
import bodybuilder, { Bodybuilder } from 'bodybuilder';
34
import httpStatus from 'http-status';
45
import { AggregateRoot } from '../../../domain/AggregateRoot';
56
import { Criteria } from '../../../domain/criteria/Criteria';
6-
import bodybuilder, { Bodybuilder } from 'bodybuilder';
7+
import { ElasticCriteriaConverter, TypeQueryEnum } from './ElasticCriteriaConverter';
78

89
type Hit = { _source: any };
910

1011
export abstract class ElasticRepository<T extends AggregateRoot> {
11-
constructor(private _client: Promise<ElasticClient>) {}
12+
private criteriaConverter: ElasticCriteriaConverter;
13+
14+
constructor(private _client: Promise<ElasticClient>) {
15+
this.criteriaConverter = new ElasticCriteriaConverter();
16+
}
1217

1318
protected abstract moduleName(): string;
1419

@@ -17,15 +22,14 @@ export abstract class ElasticRepository<T extends AggregateRoot> {
1722
}
1823

1924
protected async searchAllInElastic(unserializer: (data: any) => T): Promise<T[]> {
20-
const body = bodybuilder().query('match_all');
21-
body.build();
25+
const body = bodybuilder().query(TypeQueryEnum.MATCH_ALL);
26+
2227
return this.searchInElasticWithSourceBuilder(unserializer, body);
2328
}
2429

2530
protected async searchByCriteria(criteria: Criteria, unserializer: (data: any) => T): Promise<T[]> {
26-
// TODO elastic search converter from Criteria
27-
const body = bodybuilder().query('match_all');
28-
body.build();
31+
const body = this.criteriaConverter.convert(criteria);
32+
2933
return this.searchInElasticWithSourceBuilder(unserializer, body);
3034
}
3135

@@ -35,7 +39,7 @@ export abstract class ElasticRepository<T extends AggregateRoot> {
3539
try {
3640
const response = await client.search({
3741
index: this.moduleName(),
38-
body
42+
body: body.build()
3943
});
4044

4145
return response.body.hits.hits.map((hit: Hit) => unserializer({ ...hit._source }));

tests/Contexts/Backoffice/Courses/application/SearchByCriteria/SearchCoursesByCriteriaQueryHandler.test.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,10 @@ describe('SearchAllCourses QueryHandler', () => {
3333
const filters: Array<Map<string, string>> = new Array(filterName, filterDuration);
3434

3535
const query = new SearchCoursesByCriteriaQuery(filters);
36-
3736
const response = await handler.handle(query);
3837

39-
repository.assertMatchingHasBeenCalledWith();
40-
4138
const expected = SearchCoursesByCriteriaResponseMother.create(courses);
39+
repository.assertMatchingHasBeenCalledWith();
4240
expect(expected).toEqual(response);
4341
});
4442

@@ -60,17 +58,14 @@ describe('SearchAllCourses QueryHandler', () => {
6058
]);
6159

6260
const filters: Array<Map<string, string>> = new Array(filterName, filterDuration);
63-
6461
const orderBy = 'name';
6562
const orderType = OrderTypes.ASC;
6663

6764
const query = new SearchCoursesByCriteriaQuery(filters, orderBy, orderType, 10, 1);
68-
6965
const response = await handler.handle(query);
7066

71-
repository.assertMatchingHasBeenCalledWith();
72-
7367
const expected = SearchCoursesByCriteriaResponseMother.create(courses);
68+
repository.assertMatchingHasBeenCalledWith();
7469
expect(expected).toEqual(response);
7570
});
7671
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { Criteria } from '../../../../../src/Contexts/Shared/domain/criteria/Criteria';
2+
import { Filter } from '../../../../../src/Contexts/Shared/domain/criteria/Filter';
3+
import { FilterField } from '../../../../../src/Contexts/Shared/domain/criteria/FilterField';
4+
import { FilterOperator, FilterOperators } from '../../../../../src/Contexts/Shared/domain/criteria/FilterOperator';
5+
import { Filters } from '../../../../../src/Contexts/Shared/domain/criteria/Filters';
6+
import { FilterValue } from '../../../../../src/Contexts/Shared/domain/criteria/FilterValue';
7+
import { Order } from '../../../../../src/Contexts/Shared/domain/criteria/Order';
8+
9+
export class BackofficeCourseCriteriaMother {
10+
static nameAndDurationContains(name: string, duration: string): Criteria {
11+
const filterFieldName = new FilterField('name');
12+
const filterFieldDuration = new FilterField('duration');
13+
const filterOperator = new FilterOperator(FilterOperators.CONTAINS);
14+
const valueName = new FilterValue(name);
15+
const valueDuration = new FilterValue(duration);
16+
17+
const nameFilter = new Filter(filterFieldName, filterOperator, valueName);
18+
const durationFilter = new Filter(filterFieldDuration, filterOperator, valueDuration);
19+
20+
return new Criteria(new Filters([nameFilter, durationFilter]), Order.asc('name'));
21+
}
22+
}

tests/Contexts/Backoffice/Courses/domain/BackofficeCourseMother.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@ export class BackofficeCourseMother {
1515
return new BackofficeCourse(id, name, duration);
1616
}
1717

18+
static withNameAndDuration(name: string, duration: string): BackofficeCourse {
19+
return this.create(
20+
BackofficeCourseIdMother.random(),
21+
BackofficeCourseNameMother.create(name),
22+
BackofficeCourseDurationMother.create(duration)
23+
);
24+
}
25+
1826
static random(): BackofficeCourse {
1927
return this.create(
2028
BackofficeCourseIdMother.random(),

0 commit comments

Comments
 (0)