mirror of
https://gitlab.com/openstapps/openstapps.git
synced 2026-01-23 01:53:00 +00:00
refactor: move backend to monorepo
This commit is contained in:
661
backend/test/storage/elasticsearch/query.spec.ts
Normal file
661
backend/test/storage/elasticsearch/query.spec.ts
Normal file
@@ -0,0 +1,661 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any,unicorn/no-null */
|
||||
/*
|
||||
* Copyright (C) 2020 StApps
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import {
|
||||
SCConfigFile,
|
||||
SCSearchBooleanFilter,
|
||||
SCSearchDateRangeFilter,
|
||||
SCSearchFilter,
|
||||
SCSearchNumericRangeFilter,
|
||||
SCSearchQuery,
|
||||
SCSearchSort,
|
||||
SCThingType,
|
||||
} from '@openstapps/core';
|
||||
import {expect} from 'chai';
|
||||
import {
|
||||
ESDateRangeFilter,
|
||||
ESRangeFilter,
|
||||
ESNumericRangeFilter,
|
||||
ElasticsearchConfig,
|
||||
ESBooleanFilter,
|
||||
ESGenericSort,
|
||||
ESGeoDistanceFilter,
|
||||
ESGeoDistanceSort,
|
||||
ESTermFilter,
|
||||
ScriptSort,
|
||||
} from '../../../src/storage/elasticsearch/types/elasticsearch';
|
||||
import {configFile} from '../../../src/common';
|
||||
import {
|
||||
buildBooleanFilter,
|
||||
buildFilter,
|
||||
buildQuery,
|
||||
buildSort,
|
||||
} from '../../../src/storage/elasticsearch/query';
|
||||
|
||||
describe('Query', function () {
|
||||
describe('buildBooleanFilter', function () {
|
||||
const booleanFilter: SCSearchBooleanFilter = {
|
||||
arguments: {
|
||||
operation: 'and',
|
||||
filters: [
|
||||
{
|
||||
type: 'value',
|
||||
arguments: {
|
||||
field: 'type',
|
||||
value: SCThingType.Catalog,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
arguments: {
|
||||
field: 'type',
|
||||
value: SCThingType.Building,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
type: 'boolean',
|
||||
};
|
||||
const booleanFilters: {[key: string]: SCSearchBooleanFilter} = {
|
||||
and: booleanFilter,
|
||||
or: {...booleanFilter, arguments: {...booleanFilter.arguments, operation: 'or'}},
|
||||
not: {...booleanFilter, arguments: {...booleanFilter.arguments, operation: 'not'}},
|
||||
};
|
||||
const expectedEsFilters: Array<ESTermFilter> = [
|
||||
{
|
||||
term: {
|
||||
'type.raw': 'catalog',
|
||||
},
|
||||
},
|
||||
{
|
||||
term: {
|
||||
'type.raw': 'building',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
it('should create appropriate elasticsearch "and" filter argument', function () {
|
||||
const {must} = buildBooleanFilter(booleanFilters.and);
|
||||
|
||||
expect(must).to.be.eql(expectedEsFilters);
|
||||
});
|
||||
|
||||
it('should create appropriate elasticsearch "or" filter argument', function () {
|
||||
const {should, minimum_should_match} = buildBooleanFilter(booleanFilters.or);
|
||||
|
||||
expect(should).to.be.eql(expectedEsFilters);
|
||||
expect(minimum_should_match).to.be.equal(1);
|
||||
});
|
||||
|
||||
it('should create appropriate elasticsearch "not" filter argument', function () {
|
||||
const {must_not} = buildBooleanFilter(booleanFilters.not);
|
||||
|
||||
expect(must_not).to.be.eql(expectedEsFilters);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildQuery', function () {
|
||||
const parameters: SCSearchQuery = {
|
||||
query: 'mathematics',
|
||||
from: 30,
|
||||
size: 5,
|
||||
sort: [
|
||||
{
|
||||
type: 'ducet',
|
||||
order: 'desc',
|
||||
arguments: {
|
||||
field: 'name',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'ducet',
|
||||
order: 'desc',
|
||||
arguments: {
|
||||
field: 'categories',
|
||||
},
|
||||
},
|
||||
],
|
||||
filter: {
|
||||
type: 'value',
|
||||
arguments: {
|
||||
field: 'type',
|
||||
value: SCThingType.AcademicEvent,
|
||||
},
|
||||
},
|
||||
};
|
||||
let esConfig: ElasticsearchConfig = {
|
||||
name: 'elasticsearch',
|
||||
version: '123',
|
||||
query: {
|
||||
minMatch: '75%',
|
||||
queryType: 'dis_max',
|
||||
matchBoosting: 1.3,
|
||||
fuzziness: 'AUTO',
|
||||
cutoffFrequency: 0,
|
||||
tieBreaker: 0,
|
||||
},
|
||||
};
|
||||
const query = {
|
||||
minMatch: '75%',
|
||||
queryType: 'dis_max',
|
||||
matchBoosting: 1.3,
|
||||
fuzziness: 'AUTO',
|
||||
cutoffFrequency: 0,
|
||||
tieBreaker: 0,
|
||||
};
|
||||
const config: SCConfigFile = {
|
||||
...configFile,
|
||||
};
|
||||
beforeEach(function () {
|
||||
esConfig = {
|
||||
name: 'elasticsearch',
|
||||
version: '123',
|
||||
};
|
||||
});
|
||||
|
||||
// TODO: check parts of received elasticsearch query for each test case
|
||||
|
||||
it('should build query that includes sorting when query is undefined', function () {
|
||||
expect(buildQuery(parameters, config, esConfig)).to.be.an('object');
|
||||
});
|
||||
|
||||
it('should build query that includes sorting when query type is query_string', function () {
|
||||
esConfig.query = {...query, queryType: 'query_string'};
|
||||
|
||||
expect(buildQuery(parameters, config, esConfig)).to.be.an('object');
|
||||
});
|
||||
|
||||
it('should build query that includes sorting when query type is dis_max', function () {
|
||||
esConfig.query = {...query, queryType: 'dis_max'};
|
||||
|
||||
expect(buildQuery(parameters, config, esConfig)).to.be.an('object');
|
||||
});
|
||||
|
||||
it('should build query that includes sorting when query type is dis_max', function () {
|
||||
esConfig.query = {...query, queryType: 'dis_max'};
|
||||
|
||||
expect(buildQuery(parameters, config, esConfig)).to.be.an('object');
|
||||
});
|
||||
|
||||
it('should reject (throw an error) if provided query type is not supported', function () {
|
||||
// @ts-expect-error not assignable
|
||||
esConfig.query = {...query, queryType: 'invalid_query_type'};
|
||||
|
||||
expect(() => buildQuery(parameters, config, esConfig)).to.throw('query type');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildFilter', function () {
|
||||
const searchFilters: {[key: string]: SCSearchFilter} = {
|
||||
value: {
|
||||
type: 'value',
|
||||
arguments: {
|
||||
field: 'type',
|
||||
value: SCThingType.Dish,
|
||||
},
|
||||
},
|
||||
distance: {
|
||||
type: 'distance',
|
||||
arguments: {
|
||||
distance: 1000,
|
||||
field: 'geo',
|
||||
position: [50.123, 8.123],
|
||||
},
|
||||
},
|
||||
geoPoint: {
|
||||
type: 'geo',
|
||||
arguments: {
|
||||
field: 'geo',
|
||||
shape: {
|
||||
type: 'envelope',
|
||||
coordinates: [
|
||||
[50.123, 8.123],
|
||||
[50.123, 8.123],
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
geoShape: {
|
||||
type: 'geo',
|
||||
arguments: {
|
||||
field: 'geo',
|
||||
spatialRelation: 'contains',
|
||||
shape: {
|
||||
type: 'envelope',
|
||||
coordinates: [
|
||||
[50.123, 8.123],
|
||||
[50.123, 8.123],
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
boolean: {
|
||||
type: 'boolean',
|
||||
arguments: {
|
||||
operation: 'and',
|
||||
filters: [
|
||||
{
|
||||
type: 'value',
|
||||
arguments: {
|
||||
field: 'type',
|
||||
value: SCThingType.Dish,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'availability',
|
||||
arguments: {
|
||||
field: 'offers.availabilityRange',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it('should build value filter', function () {
|
||||
const filter = buildFilter(searchFilters.value);
|
||||
const expectedFilter: ESTermFilter = {
|
||||
term: {
|
||||
'type.raw': SCThingType.Dish,
|
||||
},
|
||||
};
|
||||
|
||||
expect(filter).to.be.eql(expectedFilter);
|
||||
});
|
||||
|
||||
it('should build numeric range filters', function () {
|
||||
for (const upperMode of ['inclusive', 'exclusive', null]) {
|
||||
for (const lowerMode of ['inclusive', 'exclusive', null]) {
|
||||
const expectedFilter: ESNumericRangeFilter = {
|
||||
range: {
|
||||
price: {
|
||||
relation: undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const rawFilter: SCSearchNumericRangeFilter = {
|
||||
type: 'numeric range',
|
||||
arguments: {
|
||||
bounds: {},
|
||||
field: 'price',
|
||||
},
|
||||
};
|
||||
|
||||
// eslint-disable-next-line unicorn/consistent-function-scoping
|
||||
const setBound = (location: 'upperBound' | 'lowerBound', bound: string | null) => {
|
||||
let out: number | null = null;
|
||||
if (bound != undefined) {
|
||||
out = Math.random();
|
||||
rawFilter.arguments.bounds[location] = {
|
||||
mode: bound as 'inclusive' | 'exclusive',
|
||||
limit: out,
|
||||
};
|
||||
expectedFilter.range.price[
|
||||
`${location === 'lowerBound' ? 'g' : 'l'}${bound === 'inclusive' ? 'te' : 't'}`
|
||||
] = out;
|
||||
}
|
||||
};
|
||||
setBound('upperBound', upperMode);
|
||||
setBound('lowerBound', lowerMode);
|
||||
|
||||
const filter = buildFilter(rawFilter) as ESNumericRangeFilter;
|
||||
expect(filter).to.deep.equal(expectedFilter);
|
||||
for (const bound of ['g', 'l']) {
|
||||
// @ts-expect-error implicit any
|
||||
const inclusiveExists = typeof filter.range.price[`${bound}t`] !== 'undefined';
|
||||
// @ts-expect-error implicit any
|
||||
const exclusiveExists = typeof filter.range.price[`${bound}te`] !== 'undefined';
|
||||
|
||||
// only one should exist at the same time
|
||||
expect(inclusiveExists && exclusiveExists).to.be.false;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should build date range filters', function () {
|
||||
for (const upperMode of ['inclusive', 'exclusive', null]) {
|
||||
for (const lowerMode of ['inclusive', 'exclusive', null]) {
|
||||
const expectedFilter: ESDateRangeFilter = {
|
||||
range: {
|
||||
price: {
|
||||
format: 'thisIsADummyFormat',
|
||||
time_zone: 'thisIsADummyTimeZone',
|
||||
relation: 'testRelation' as any,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const rawFilter: SCSearchDateRangeFilter = {
|
||||
type: 'date range',
|
||||
arguments: {
|
||||
bounds: {},
|
||||
field: 'price',
|
||||
relation: 'testRelation' as any,
|
||||
format: 'thisIsADummyFormat',
|
||||
timeZone: 'thisIsADummyTimeZone',
|
||||
},
|
||||
};
|
||||
|
||||
const setBound = (location: 'upperBound' | 'lowerBound', bound: string | null) => {
|
||||
let out: string | null = null;
|
||||
if (bound != undefined) {
|
||||
out = `${location} ${bound} ${upperMode} ${lowerMode}`;
|
||||
rawFilter.arguments.bounds[location] = {
|
||||
mode: bound as 'inclusive' | 'exclusive',
|
||||
limit: out,
|
||||
};
|
||||
expectedFilter.range.price[
|
||||
`${location === 'lowerBound' ? 'g' : 'l'}${bound === 'inclusive' ? 'te' : 't'}`
|
||||
] = out;
|
||||
}
|
||||
};
|
||||
setBound('upperBound', upperMode);
|
||||
setBound('lowerBound', lowerMode);
|
||||
|
||||
const filter = buildFilter(rawFilter) as ESNumericRangeFilter;
|
||||
expect(filter).to.deep.equal(expectedFilter);
|
||||
for (const bound of ['g', 'l']) {
|
||||
// @ts-expect-error implicit any
|
||||
const inclusiveExists = typeof filter.range.price[`${bound}t`] !== 'undefined';
|
||||
// @ts-expect-error implicit any
|
||||
const exclusiveExists = typeof filter.range.price[`${bound}te`] !== 'undefined';
|
||||
|
||||
// only one should exist at the same time
|
||||
expect(inclusiveExists && exclusiveExists).to.be.false;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should build availability filters', function () {
|
||||
it('should copy scope', function () {
|
||||
for (const scope of ['a', 'b']) {
|
||||
const filter = buildFilter({
|
||||
type: 'availability',
|
||||
arguments: {
|
||||
time: 'test',
|
||||
scope: scope as any,
|
||||
field: 'offers.availabilityRange',
|
||||
},
|
||||
});
|
||||
|
||||
const expectedFilter: ESRangeFilter = {
|
||||
range: {
|
||||
'offers.availabilityRange': {
|
||||
gte: `test||/${scope}`,
|
||||
lt: `test||+1${scope}/${scope}`,
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(filter).to.be.eql(expectedFilter);
|
||||
}
|
||||
});
|
||||
|
||||
it('should default to second scope', function () {
|
||||
const filter = buildFilter({
|
||||
type: 'availability',
|
||||
arguments: {
|
||||
time: 'test',
|
||||
field: 'offers.availabilityRange',
|
||||
},
|
||||
});
|
||||
|
||||
const expectedFilter: ESRangeFilter = {
|
||||
range: {
|
||||
'offers.availabilityRange': {
|
||||
gte: 'test||/s',
|
||||
lt: 'test||+1s/s',
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(filter).to.be.eql(expectedFilter);
|
||||
});
|
||||
|
||||
it('should add || to dates', function () {
|
||||
const filter = buildFilter({
|
||||
type: 'availability',
|
||||
arguments: {
|
||||
time: 'test',
|
||||
scope: 'd',
|
||||
field: 'offers.availabilityRange',
|
||||
},
|
||||
});
|
||||
|
||||
const expectedFilter: ESRangeFilter = {
|
||||
range: {
|
||||
'offers.availabilityRange': {
|
||||
gte: `test||/d`,
|
||||
lt: `test||+1d/d`,
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(filter).to.be.eql(expectedFilter);
|
||||
});
|
||||
|
||||
it('should default to now and not add ||', function () {
|
||||
const filter = buildFilter({
|
||||
type: 'availability',
|
||||
arguments: {
|
||||
scope: 'd',
|
||||
field: 'offers.availabilityRange',
|
||||
},
|
||||
});
|
||||
|
||||
const expectedFilter: ESRangeFilter = {
|
||||
range: {
|
||||
'offers.availabilityRange': {
|
||||
gte: `now/d`,
|
||||
lt: `now+1d/d`,
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(filter).to.be.eql(expectedFilter);
|
||||
});
|
||||
});
|
||||
|
||||
it('should build distance filter', function () {
|
||||
const filter = buildFilter(searchFilters.distance);
|
||||
const expectedFilter: ESGeoDistanceFilter = {
|
||||
geo_distance: {
|
||||
'distance': '1000m',
|
||||
'geo.point.coordinates': {
|
||||
lat: 8.123,
|
||||
lon: 50.123,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(filter).to.be.eql(expectedFilter);
|
||||
});
|
||||
|
||||
it('should build geo filter for shapes and points', function () {
|
||||
const filter = buildFilter(searchFilters.geoPoint);
|
||||
const expectedFilter = {
|
||||
bool: {
|
||||
minimum_should_match: 1,
|
||||
should: [
|
||||
{
|
||||
geo_shape: {
|
||||
'geo.polygon': {
|
||||
relation: undefined,
|
||||
shape: {
|
||||
type: 'envelope',
|
||||
coordinates: [
|
||||
[50.123, 8.123],
|
||||
[50.123, 8.123],
|
||||
],
|
||||
},
|
||||
},
|
||||
'ignore_unmapped': true,
|
||||
},
|
||||
},
|
||||
{
|
||||
geo_bounding_box: {
|
||||
'geo.point.coordinates': {
|
||||
bottom_right: [50.123, 8.123],
|
||||
top_left: [50.123, 8.123],
|
||||
},
|
||||
'ignore_unmapped': true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
expect(filter).to.be.eql(expectedFilter);
|
||||
});
|
||||
|
||||
it('should build geo filter for shapes only', function () {
|
||||
const filter = buildFilter(searchFilters.geoShape);
|
||||
const expectedFilter = {
|
||||
geo_shape: {
|
||||
'geo.polygon': {
|
||||
relation: 'contains',
|
||||
shape: {
|
||||
type: 'envelope',
|
||||
coordinates: [
|
||||
[50.123, 8.123],
|
||||
[50.123, 8.123],
|
||||
],
|
||||
},
|
||||
},
|
||||
'ignore_unmapped': true,
|
||||
},
|
||||
};
|
||||
|
||||
expect(filter).to.be.eql(expectedFilter);
|
||||
});
|
||||
|
||||
it('should build boolean filter', function () {
|
||||
const filter = buildFilter(searchFilters.boolean);
|
||||
const expectedFilter: ESBooleanFilter<any> = {
|
||||
bool: {
|
||||
minimum_should_match: 0,
|
||||
must: [
|
||||
{
|
||||
term: {
|
||||
'type.raw': 'dish',
|
||||
},
|
||||
},
|
||||
{
|
||||
range: {
|
||||
'offers.availabilityRange': {
|
||||
gte: 'now/s',
|
||||
lt: 'now+1s/s',
|
||||
relation: 'intersects',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
must_not: [],
|
||||
should: [],
|
||||
},
|
||||
};
|
||||
|
||||
expect(filter).to.be.eql(expectedFilter);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSort', function () {
|
||||
const searchSCSearchSort: Array<SCSearchSort> = [
|
||||
{
|
||||
type: 'ducet',
|
||||
order: 'desc',
|
||||
arguments: {
|
||||
field: 'name',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'generic',
|
||||
order: 'desc',
|
||||
arguments: {
|
||||
field: 'name',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'distance',
|
||||
order: 'desc',
|
||||
arguments: {
|
||||
field: 'geo',
|
||||
position: [8.123, 50.123],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'price',
|
||||
order: 'asc',
|
||||
arguments: {
|
||||
universityRole: 'student',
|
||||
field: 'offers.prices',
|
||||
},
|
||||
},
|
||||
];
|
||||
let sorts: Array<ESGenericSort | ESGeoDistanceSort | ScriptSort> = [];
|
||||
const expectedSorts: {[key: string]: ESGenericSort | ESGeoDistanceSort | ScriptSort} = {
|
||||
ducet: {
|
||||
'name.sort': 'desc',
|
||||
},
|
||||
generic: {
|
||||
name: 'desc',
|
||||
},
|
||||
distance: {
|
||||
_geo_distance: {
|
||||
'mode': 'avg',
|
||||
'order': 'desc',
|
||||
'unit': 'm',
|
||||
'geo.point.coordinates': {
|
||||
lat: 50.123,
|
||||
lon: 8.123,
|
||||
},
|
||||
},
|
||||
},
|
||||
price: {
|
||||
_script: {
|
||||
order: 'asc',
|
||||
script: '\n // foo price sort script',
|
||||
type: 'number',
|
||||
},
|
||||
},
|
||||
};
|
||||
before(function () {
|
||||
sorts = buildSort(searchSCSearchSort);
|
||||
});
|
||||
|
||||
it('should build ducet sort', function () {
|
||||
expect(sorts[0]).to.be.eql(expectedSorts.ducet);
|
||||
});
|
||||
|
||||
it('should build generic sort', function () {
|
||||
expect(sorts[1]).to.be.eql(expectedSorts.generic);
|
||||
});
|
||||
|
||||
it('should build distance sort', function () {
|
||||
expect(sorts[2]).to.be.eql(expectedSorts.distance);
|
||||
});
|
||||
|
||||
it('should build price sort', function () {
|
||||
const priceSortNoScript = {
|
||||
...sorts[3],
|
||||
_script: {
|
||||
...(sorts[3] as ScriptSort)._script,
|
||||
script: (expectedSorts.price as ScriptSort)._script.script,
|
||||
},
|
||||
};
|
||||
expect(priceSortNoScript).to.be.eql(expectedSorts.price);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user