Files
openstapps/test/storage/elasticsearch/query.spec.ts
2023-04-28 12:43:31 +00:00

639 lines
18 KiB
TypeScript

/* 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 {buildFilter} from '../../../src/storage/elasticsearch/query/filter';
import {buildBooleanFilter} from '../../../src/storage/elasticsearch/query/filters/boolean';
import {buildQuery} from '../../../src/storage/elasticsearch/query/query';
import {buildSort} from '../../../src/storage/elasticsearch/query/sort';
import {ElasticsearchConfig} from '../../../src/storage/elasticsearch/types/elasticsearch-config';
import {QueryDslSpecificQueryContainer} from '../../../src/storage/elasticsearch/types/util';
import {configFile} from '../../../src/common';
import {SortCombinations} from '@elastic/elasticsearch/lib/api/types';
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<QueryDslSpecificQueryContainer<'term'>> = [
{
term: {
'type.raw': 'catalog',
},
},
{
term: {
'type.raw': 'building',
},
},
];
it('should create appropriate elasticsearch "and" filter argument', function () {
const {must} = buildBooleanFilter(booleanFilters.and).bool;
expect(must).to.be.eql(expectedEsFilters);
});
it('should create appropriate elasticsearch "or" filter argument', function () {
const {should, minimum_should_match} = buildBooleanFilter(booleanFilters.or).bool;
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).bool;
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');
});
it('should accept other search contexts', function () {
expect(buildQuery({context: 'place', ...parameters}, config, esConfig)).to.be.an('object');
});
});
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: QueryDslSpecificQueryContainer<'term'> = {
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: QueryDslSpecificQueryContainer<'range'> = {
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 QueryDslSpecificQueryContainer<'term'>;
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: QueryDslSpecificQueryContainer<'range'> = {
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 QueryDslSpecificQueryContainer<'range'>;
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: QueryDslSpecificQueryContainer<'range'> = {
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: QueryDslSpecificQueryContainer<'range'> = {
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: QueryDslSpecificQueryContainer<'range'> = {
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: QueryDslSpecificQueryContainer<'range'> = {
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: QueryDslSpecificQueryContainer<'geo_distance'> = {
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 = {
geo_shape: {
'geo.polygon': {
relation: undefined,
shape: {
type: 'envelope',
coordinates: [
[50.123, 8.123],
[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: QueryDslSpecificQueryContainer<'bool'> = {
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: SortCombinations[] = [];
const expectedSorts: {[key: string]: SortCombinations} = {
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) as SortCombinations[];
});
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] as any),
_script: {
...(sorts[3] as any)._script,
script: (expectedSorts.price as any)._script.script,
},
};
expect(priceSortNoScript).to.be.eql(expectedSorts.price);
});
});
});