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

Commit

Permalink
feat: support number and quantity type parameters
Browse files Browse the repository at this point in the history
  • Loading branch information
carvantes committed Apr 16, 2021
1 parent 0e8c4a2 commit 412518d
Show file tree
Hide file tree
Showing 9 changed files with 619 additions and 80 deletions.
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
48 changes: 48 additions & 0 deletions src/QueryBuilder/typeQueries/common/number.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*
* 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],
['10.0', 5e-2],
['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,
},
};
};
124 changes: 124 additions & 0 deletions src/QueryBuilder/typeQueries/common/prefixRange.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/*
* 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;

// 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: {
[path]: elasticSearchRange,
},
};
}

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

return neQuery;
};

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

0 comments on commit 412518d

Please sign in to comment.