mirror of
https://gitlab.com/openstapps/openstapps.git
synced 2026-01-05 13:02:54 +00:00
639 lines
18 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|