Skip to content

Commit

Permalink
Merge pull request #27 from Asymmetrik/feat/fhir-qb-sql
Browse files Browse the repository at this point in the history
feat: fhir-qb-sql module and unit tests for Sequelize/Postgres
  • Loading branch information
zeevo committed Sep 20, 2019
2 parents 0dc5b71 + 33ef463 commit a2849ee
Show file tree
Hide file tree
Showing 16 changed files with 10,624 additions and 53 deletions.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"eslint": "^5.7.0",
"jest": "^23.6.0",
"lerna": "^3.13.1",
"prettier": "^1.14.3"
"prettier": "^1.14.3",
"sequelize": "^5.18.4"
}
}
4 changes: 3 additions & 1 deletion packages/fhir-json-schema-validator/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@ class JSONSchemaValidator {
if (verbose) {
errors = this.validator.errors;
} else {
let resourceValidate = this.ajv.compile(this.getSubSchema(resourceType));
let resourceValidate = this.ajv.compile(
this.getSubSchema(resourceType),
);
resourceValidate(resource);
errors = resourceValidate.errors;
}
Expand Down
6 changes: 4 additions & 2 deletions packages/fhir-qb-mongo/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -208,9 +208,11 @@ let assembleSearchQuery = function({
let toSuppress = {};

// Check that the necessary implementation parameters were passed through
let {archivedParamPath} = implementationParameters;
let { archivedParamPath } = implementationParameters;
if (!archivedParamPath) {
throw new Error('Missing required implementation parameter \'archivedParamPath\'');
throw new Error(
"Missing required implementation parameter 'archivedParamPath'",
);
}

// Construct the necessary joins and add them to the aggregate pipeline. Also follow each $lookup with an $unwind
Expand Down
24 changes: 13 additions & 11 deletions packages/fhir-qb-mongo/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ describe('Mongo Query Builder Tests', () => {
joinsToPerform: [],
matchesToPerform: [],
searchResultTransformations: {},
implementationParameters: {archivedParamPath: 'meta._isArchived'},
implementationParameters: { archivedParamPath: 'meta._isArchived' },
includeArchived: false,
pageNumber: 1,
resultsPerPage: 10,
Expand Down Expand Up @@ -254,7 +254,7 @@ describe('Mongo Query Builder Tests', () => {
joinsToPerform: [{ from: 'foo', localKey: 'bar', foreignKey: 'baz' }],
matchesToPerform: [],
searchResultTransformations: {},
implementationParameters: {archivedParamPath: 'meta._isArchived'},
implementationParameters: { archivedParamPath: 'meta._isArchived' },
includeArchived: false,
pageNumber: 1,
resultsPerPage: 10,
Expand Down Expand Up @@ -284,7 +284,7 @@ describe('Mongo Query Builder Tests', () => {
joinsToPerform: [],
matchesToPerform: [[]],
searchResultTransformations: {},
implementationParameters: {archivedParamPath: 'meta._isArchived'},
implementationParameters: { archivedParamPath: 'meta._isArchived' },
includeArchived: false,
pageNumber: 1,
resultsPerPage: 10,
Expand Down Expand Up @@ -314,7 +314,7 @@ describe('Mongo Query Builder Tests', () => {
joinsToPerform: [],
matchesToPerform: [[{ foo: { $gte: 1, $lte: 10 } }]],
searchResultTransformations: {},
implementationParameters: {archivedParamPath: 'meta._isArchived'},
implementationParameters: { archivedParamPath: 'meta._isArchived' },
includeArchived: false,
pageNumber: 1,
resultsPerPage: 10,
Expand Down Expand Up @@ -346,7 +346,7 @@ describe('Mongo Query Builder Tests', () => {
joinsToPerform: [],
matchesToPerform: [],
searchResultTransformations: { _count: 3 },
implementationParameters: {archivedParamPath: 'meta._isArchived'},
implementationParameters: { archivedParamPath: 'meta._isArchived' },
includeArchived: false,
pageNumber: 1,
resultsPerPage: 10,
Expand Down Expand Up @@ -387,7 +387,7 @@ describe('Mongo Query Builder Tests', () => {
joinsToPerform: [],
matchesToPerform: [],
searchResultTransformations: {},
implementationParameters: {archivedParamPath: 'meta._isArchived'},
implementationParameters: { archivedParamPath: 'meta._isArchived' },
includeArchived: false,
pageNumber: 1,
});
Expand All @@ -409,20 +409,22 @@ describe('Mongo Query Builder Tests', () => {
} catch (err) {
error = err;
}
expect(error.message).toContain('Missing required implementation parameter \'archivedParamPath\'');
expect(error.message).toContain(
"Missing required implementation parameter 'archivedParamPath'",
);
});
test('Should return input query as is if we are not filtering out archived results', () => {
const expectedResult = [
{
$facet: {
data: [{ $skip: 0 }, {$limit: 10}],
data: [{ $skip: 0 }, { $limit: 10 }],
metadata: [
{
$count: 'total',
},
{
$addFields: {
numberOfPages: {$ceil: {$divide:['$total',10]}},
numberOfPages: { $ceil: { $divide: ['$total', 10] } },
},
},
{
Expand All @@ -438,10 +440,10 @@ describe('Mongo Query Builder Tests', () => {
joinsToPerform: [],
matchesToPerform: [],
searchResultTransformations: {},
implementationParameters: {archivedParamPath: 'meta._isArchived'},
implementationParameters: { archivedParamPath: 'meta._isArchived' },
includeArchived: true,
pageNumber: 1,
resultsPerPage: 10
resultsPerPage: 10,
});
expect(observedResult).toEqual(expectedResult);
});
Expand Down
4 changes: 4 additions & 0 deletions packages/fhir-qb-sql/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Change Log

All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
23 changes: 23 additions & 0 deletions packages/fhir-qb-sql/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# FHIR-Query-Builder-SQL
> Utility for building SQL queries based on incoming requests.
## Install
```shell
yarn add @asymmetrik/fhir-qb-sql
```

## Usage
This module is meant to be imported and used by the fhir-qb. Included are implementations of the following methods:
```
assembleSearchQuery,
buildAndQuery,
buildComparatorQuery,
buildContainsQuery,
buildEndsWithQuery,
buildEqualToQuery,
buildExistsQuery,
buildOrQuery,
buildInRangeQuery,
buildStartsWithQuery,
```
These are used by the fhir-qb to build a query that will work in the sql aggregation pipeline.
212 changes: 212 additions & 0 deletions packages/fhir-qb-sql/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
const Sequelize = require('sequelize');
const Op = Sequelize.Op;

let supportedSearchTransformations = {};

let formDateComparison = function(comparator, date, colName = 'value') {
return Sequelize.where(
Sequelize.fn('date', Sequelize.col(colName)),
comparator,
date,
);
};

/**
* Takes in a list of queries and wraps them in an $and block
*/
let buildAndQuery = function(queries) {
return { [Op.and]: queries };
};

/**
* Takes in a list of queries and wraps them in an $or block
*/
let buildOrQuery = function({ queries, invert = false }) {
if (invert) {
return { [Op.not]: { [Op.or]: queries } };
} else {
return { [Op.or]: queries };
}
};

/**
* Builds query to get records where the value of the field equal to the value.
* Setting invert to true will get records that are NOT equal instead.
*/
let buildEqualToQuery = function({
field,
value,
invert = false,
isDate = false,
}) {
if (isDate) {
const comparator = invert ? '!=' : '=';
return {
[Op.and]: [{ name: field }, formDateComparison(comparator, value)],
};
} else {
return { name: field, value: invert ? { [Op.ne]: value } : value };
}
};

/**
* Builds query to get records where the value of the field is [<,<=,>,>=,!=] to the value.
*/
let buildComparatorQuery = function({
field,
value,
comparator,
isDate = false,
}) {
const sqlComparators = {
gt: Op.gt,
ge: Op.gte,
lt: Op.lt,
le: Op.lte,
ne: Op.ne,
sa: Op.gt,
eb: Op.lt,
};
const sqlComparator = sqlComparators[comparator];
if (isDate) {
return {
[Op.and]: [{ name: field }, formDateComparison(sqlComparator, value)],
};
} else {
return { name: field, value: { [sqlComparator]: value } };
}
};

/**
* Builds query to get records where the value of the field is in the specified range
* Setting invert to true will get records that are NOT in the specified range.
*/
let buildInRangeQuery = function({
field,
lowerBound,
upperBound,
invert = false,
isDate = false,
}) {
if (invert) {
if (isDate) {
return {
[Op.and]: [
{ name: field },
formDateComparison('<=', lowerBound),
formDateComparison('>=', upperBound),
],
};
}
return {
name: field,
value: { [Op.notBetween]: [lowerBound, upperBound] },
};
} else {
if (isDate) {
return {
[Op.and]: [
{ name: field },
formDateComparison('>=', lowerBound),
formDateComparison('<=', upperBound),
],
};
}
return { name: field, value: { [Op.between]: [lowerBound, upperBound] } };
}
};

/**
* Builds query to retrieve records where the field exists (or not).
*/
// TODO: Need to figure out how to do exist check.
let buildExistsQuery = function({ field, exists }) {
return 'NOT IMPLEMENTED';
};

/**
* Builds query to get records where the value of the field contains the value.
* Setting caseSensitive to true will cause the regex to be case insensitive
*/
let buildContainsQuery = function({ field, value, caseSensitive = false }) {
// TODO: contains is not working as expected, like is for string matching - doublecheck this
if (caseSensitive) {
return { name: field, value: { [Op.like]: value } };
} else {
return { name: field, value: { [Op.iLike]: value } };
}
};

/**
* Builds query to get records where the value of the field starts with the value.
* Setting caseSensitive to true will cause the regex to be case insensitive
*/
let buildStartsWithQuery = function({ field, value, caseSensitive = false }) {
if (caseSensitive) {
return { name: field, value: { [Op.startsWith]: value } };
} else {
return { name: field, value: { [Op.iRegexp]: `^${value}` } };
}
};

/**
* Builds query to get records where the value of the field ends with the value.
* Setting caseSensitive to true will cause the regex to be case insensitive
*/
let buildEndsWithQuery = function({ field, value, caseSensitive = false }) {
if (caseSensitive) {
return { name: field, value: { [Op.endsWith]: value } };
} else {
return { name: field, value: { [Op.iRegexp]: `${value}$` } };
}
};

/**
* Assembles a mongo aggregation pipeline
* @param joinsToPerform - List of joins to perform first through lookups
* @param matchesToPerform - List of matches to perform
* @param implementationParameters
* @returns {Array}
*/
let assembleSearchQuery = function({
matchesToPerform,
implementationParameters,
}) {
let query = [];

// Check that the necessary implementation parameters were passed through
let { archivedParamPath } = implementationParameters;
if (!archivedParamPath) {
throw new Error(
"Missing required implementation parameter 'archivedParamPath'",
);
}

// Construct the necessary queries for each match and add them the pipeline.
if (matchesToPerform.length > 0) {
let listOfOrs = [];
for (let match of matchesToPerform) {
if (match.length === 0) {
match.push({});
}
listOfOrs.push(buildOrQuery({ queries: match }));
}
query.push({ where: buildAndQuery(listOfOrs) });
}
return query;
};

module.exports = {
assembleSearchQuery,
buildAndQuery,
buildComparatorQuery,
buildContainsQuery,
buildEndsWithQuery,
buildEqualToQuery,
buildExistsQuery,
buildOrQuery,
buildInRangeQuery,
buildStartsWithQuery,
supportedSearchTransformations,
formDateComparison,
};
Loading

0 comments on commit a2849ee

Please sign in to comment.