Skip to content
This repository has been archived by the owner on Apr 13, 2023. It is now read-only.

feat: support number and quantity type parameters #58

Merged
merged 3 commits into from
Apr 19, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/QueryBuilder/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { CompiledSearchParam, FHIRSearchParametersRegistry, SearchParam } from '
import { stringQuery } from './typeQueries/stringQuery';
import { dateQuery } from './typeQueries/dateQuery';
import { tokenQuery } from './typeQueries/tokenQuery';
import { numberQuery } from './typeQueries/numberQuery';
import { quantityQuery } from './typeQueries/quantityQuery';

function typeQueryWithConditions(
searchParam: SearchParam,
Expand All @@ -26,9 +28,13 @@ function typeQueryWithConditions(
case 'token':
typeQuery = tokenQuery(compiledSearchParam, searchValue);
break;
case 'composite':
case 'number':
typeQuery = numberQuery(compiledSearchParam, searchValue);
break;
case 'quantity':
typeQuery = quantityQuery(compiledSearchParam, searchValue);
break;
case 'composite':
case 'reference':
case 'special':
case 'uri':
Expand Down
53 changes: 53 additions & 0 deletions src/QueryBuilder/typeQueries/common/number.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

import each from 'jest-each';
import { InvalidSearchParameterError } from 'fhir-works-on-aws-interface';
import { parseNumber } from './number';

describe('parseNumber', () => {
describe('valid inputs', () => {
each([
['-100', 5e-1],
['100', 5e-1],
['+100', 5e-1],
['10.0', 5e-2],
['+10.0', 5e-2],
['-10.0', 5e-2],
['1e2', 5e1],
['+1e2', 5e1],
['-1e2', 5e1],
['1.0e2', 5],
['1.0E2', 5],
['5.4', 5e-2],
['5.40e-3', 5e-6],
['5.4e-3', 5e-5],
// Below cases are not truly scientific notation but we allow them since they are valid numbers.
['10e1', 5],
['100e0', 5e-1],
['54.0e-4', 5e-6],
]).test('%s', (string: string, delta: number) => {
const result = parseNumber(string);
expect(result.number).toBeCloseTo(Number(string), 7);
expect(result.implicitRange.end).toBeCloseTo(Number(string) + delta, 8);
expect(result.implicitRange.start).toBeCloseTo(Number(string) - delta, 8);
});
});

describe('invalid inputs', () => {
each([
['not a number at all'],
[' 100 '],
['1.2.3'],
['1,2'],
['+-1'],
['1+1'],
['1.3e12xx'],
['1.3a12'],
]).test('%s', (string: string) => {
expect(() => parseNumber(string)).toThrow(InvalidSearchParameterError);
});
});
});
46 changes: 46 additions & 0 deletions src/QueryBuilder/typeQueries/common/number.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*
*/

import { InvalidSearchParameterError } from 'fhir-works-on-aws-interface';

interface FhirNumber {
number: number;
implicitRange: {
start: number;
end: number;
};
}

const NUMBER_REGEX = /^(?<sign>[+-])?(?<whole>\d+)(\.(?<decimals>\d+))?([eE](?<exp>[+-]?\d+))?$/;

// eslint-disable-next-line import/prefer-default-export
export const parseNumber = (numberString: string): FhirNumber => {
const match = numberString.match(NUMBER_REGEX);
if (match === null) {
throw new InvalidSearchParameterError(`Invalid number in search parameter: ${numberString}`);
}

const { decimals = '', exp } = match.groups!;

let significantFiguresDeltaExp = 0;
// FHIR considers ALL written digits to be significant figures
// e.g. 100 has 3 significant figures (this is contrary to the more common definition where trailing zeroes are NOT significant figures)
// See: https://www.hl7.org/fhir/search.html#number
if (exp !== undefined) {
significantFiguresDeltaExp = Number(exp) - (decimals.length + 1);
} else {
significantFiguresDeltaExp = -(decimals.length + 1);
}

const numberValue = Number(numberString);
return {
number: numberValue,
implicitRange: {
start: numberValue - 5 * 10 ** significantFiguresDeltaExp,
end: numberValue + 5 * 10 ** significantFiguresDeltaExp,
},
};
};
120 changes: 120 additions & 0 deletions src/QueryBuilder/typeQueries/common/prefixRange.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

import { InvalidSearchParameterError } from 'fhir-works-on-aws-interface';

interface Range {
start: Date | number;
end: Date | number;
}

interface NumberRange {
start: number;
end: number;
}

interface DateRange {
start: Date;
end: Date;
}

const prefixRange = (prefix: string, range: Range, path: string): any => {
const { start, end } = range;

// not equal
if (prefix === 'ne') {
return {
bool: {
should: [
{
range: {
[path]: {
gt: end,
},
},
},
{
range: {
[path]: {
lt: start,
},
},
},
],
},
};
}

// See https://www.hl7.org/fhir/search.html#prefix
let elasticSearchRange;
switch (prefix) {
case 'eq': // equal
elasticSearchRange = {
gte: start,
lte: end,
};
break;
case 'lt': // less than
elasticSearchRange = {
lt: end,
};
break;
case 'le': // less or equal
elasticSearchRange = {
lte: end,
};
break;
case 'gt': // greater than
elasticSearchRange = {
gt: start,
};
break;
case 'ge': // greater or equal
elasticSearchRange = {
gte: start,
};
break;
case 'sa': // starts after
elasticSearchRange = {
gt: end,
};
break;
case 'eb': // ends before
elasticSearchRange = {
lt: start,
};
break;
case 'ap': // approximately
throw new InvalidSearchParameterError('Unsupported prefix: ap');
default:
// this should never happen
throw new Error(`unknown search prefix: ${prefix}`);
}
return {
range: {
[path]: elasticSearchRange,
},
};
};
export const prefixRangeNumber = (prefix: string, number: number, implicitRange: NumberRange, path: string): any => {
if (prefix === 'eq' || prefix === 'ne') {
return prefixRange(prefix, implicitRange, path);
}
// When a comparison prefix in the set lgt, lt, ge, le, sa & eb is provided, the implicit precision of the number is ignored,
// and they are treated as if they have arbitrarily high precision
// https://www.hl7.org/fhir/search.html#number
return prefixRange(
prefix,
{
start: number,
end: number,
},
path,
);
};

export const prefixRangeDate = (prefix: string, range: DateRange, path: string): any => {
return prefixRange(prefix, range, path);
};
81 changes: 2 additions & 79 deletions src/QueryBuilder/typeQueries/dateQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import lastDayOfMonth from 'date-fns/lastDayOfMonth';
import set from 'date-fns/set';
import { InvalidSearchParameterError } from 'fhir-works-on-aws-interface';
import { CompiledSearchParam } from '../../FHIRSearchParametersRegistry';
import { prefixRangeDate } from './common/prefixRange';

interface DateSearchParameter {
prefix: string;
Expand Down Expand Up @@ -69,83 +70,5 @@ export const parseDateSearchParam = (param: string): DateSearchParameter => {
// eslint-disable-next-line import/prefer-default-export
export const dateQuery = (compiledSearchParam: CompiledSearchParam, value: string): any => {
const { prefix, range } = parseDateSearchParam(value);
const { start, end } = range;

// See https://www.hl7.org/fhir/search.html#prefix
if (prefix !== 'ne') {
let elasticSearchRange;
switch (prefix) {
case 'eq': // equal
elasticSearchRange = {
gte: start,
lte: end,
};
break;
case 'lt': // less than
elasticSearchRange = {
lt: end,
};
break;
case 'le': // less or equal
elasticSearchRange = {
lte: end,
};
break;
case 'gt': // greater than
elasticSearchRange = {
gt: start,
};
break;
case 'ge': // greater or equal
elasticSearchRange = {
gte: start,
};
break;
case 'sa': // starts after
elasticSearchRange = {
gt: end,
};
break;
case 'eb': // ends before
elasticSearchRange = {
lt: start,
};
break;
case 'ap': // approximately
throw new InvalidSearchParameterError('Unsupported prefix: ap');
default:
// this should never happen
throw new Error(`unknown search prefix: ${prefix}`);
}

return {
range: {
[compiledSearchParam.path]: elasticSearchRange,
},
};
}

// ne prefix is the only case that requires a bool query;
const neQuery = {
bool: {
should: [
{
range: {
[compiledSearchParam.path]: {
gt: end,
},
},
},
{
range: {
[compiledSearchParam.path]: {
lt: start,
},
},
},
],
},
};

return neQuery;
return prefixRangeDate(prefix, range, compiledSearchParam.path);
};
Loading