Skip to content

Commit

Permalink
Initial _count functionality
Browse files Browse the repository at this point in the history
needs tests fix
  • Loading branch information
lmd59 committed Jun 10, 2024
1 parent 070de5f commit ba82f48
Show file tree
Hide file tree
Showing 7 changed files with 205 additions and 22 deletions.
2 changes: 2 additions & 0 deletions service/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ The `_summary` parameter is specified in the [HL7 FHIR Search Docs](https://www.

The `_elements` parameter is specified in the [HL7 FHIR Search Docs](https://www.hl7.org/fhir/search.html#_elements) and supports a comma-separated list of base element names.

The `_count` parameter is specified in the [HL7 FHIR Search Docs](https://www.hl7.org/fhir/search.html#_count) and supports a whole number indicating the number of results per page.

### Package

The Measure Repository Service server supports the `Library` and `Measure` `$cqfm-package` operation with the `id` and `identifier` parameters and SHALL parameters specified in the shareable measure repository section of the [HL7 Measure Repository Docs](http://hl7.org/fhir/us/cqfmeasures/measure-repository-service.html#publishable-measure-repository). Accepted parameters are:
Expand Down
41 changes: 37 additions & 4 deletions service/src/db/dbOperations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,37 @@ export async function findResourceById<T extends fhir4.FhirResource>(id: string,
}

/**
* searches the database and returns an array of all resources of the given type that match the given query
* searches the database and returns an array of objects that contain metadata and each respective resource of the given type that match the given query
*/
export async function findResourcesWithQuery<T extends fhir4.FhirResource>(
query: Filter<any>,
resourceType: FhirResourceType
) {
const collection = Connection.db.collection(resourceType);
const projection = { _id: 0, _dataRequirements: 0 };
const pagination: any = query.skip ? [{ $skip: query.skip }, { $limit: query.limit }] : [];
query._dataRequirements = { $exists: false };
query._summary = { $exists: false };
query._elements = { $exists: false };
return collection.find<T>(query, { projection: { _id: 0, _dataRequirements: 0 } }).toArray();
query._count = { $exists: false };
query.limit = { $exists: false };
query.skip = { $exists: false };
return collection
.aggregate<{ metadata: { total: number }[]; data: T }>([
{ $match: query },
{
$facet: {
metadata: [{ $count: 'total' }],
data: pagination
}
},
{ $project: projection }
])
.toArray();
}

/**
* searches the database and returns an array of all resources of the given type that match the given query
* searches the database and returns an array objects that contain metadata and each respective resource of the given type that match the given query
* but the resources only include the elements specified by the _elements parameter
*/
export async function findResourceElementsWithQuery<T extends fhir4.FhirResource>(
Expand All @@ -44,10 +60,27 @@ export async function findResourceElementsWithQuery<T extends fhir4.FhirResource
projection[elem] = 1;
});
projection['_id'] = 0;

// create pagination
const pagination: any = [{ $skip: query.skip }, { $limit: query.limit }];

query._dataRequirements = { $exists: false };
query._summary = { $exists: false };
query._elements = { $exists: false };
return collection.find<T>(query, { projection: projection }).toArray();
query.limit = { $exists: false };
query.skip = { $exists: false };
return collection
.aggregate<{ metadata: { total: number }[]; data: T }>([
{ $match: query },
{
$facet: {
metadata: [{ $count: 'total' }],
data: pagination
}
},
{ $project: projection }
])
.toArray();
}

/**
Expand Down
3 changes: 2 additions & 1 deletion service/src/requestSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,8 @@ export const CoreSearchArgs = z
_summary: z.literal('count'),
// adding _elements for a comma separated string (https://www.hl7.org/fhir/search.html#_elements)
_elements: z.string(),
_count: z.string()
_count: z.string(),
page: z.string()
})
.partial()
.strict();
Expand Down
49 changes: 43 additions & 6 deletions service/src/services/LibraryService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ import {
} from '../db/dbOperations';
import { LibrarySearchArgs, LibraryDataRequirementsArgs, PackageArgs, parseRequestSchema } from '../requestSchemas';
import { Service } from '../types/service';
import { createLibraryPackageBundle, createSearchsetBundle, createSummarySearchsetBundle } from '../util/bundleUtils';
import {
createLibraryPackageBundle,
createPaginationLinks,
createSearchsetBundle,
createSummarySearchsetBundle
} from '../util/bundleUtils';
import { BadRequestError, ResourceNotFoundError } from '../util/errorUtils';
import { getMongoQueryFromRequest } from '../util/queryUtils';
import {
Expand Down Expand Up @@ -44,15 +49,20 @@ export class LibraryService implements Service<fhir4.Library> {

// if the _summary parameter with a value of count is included, then
// return a searchset bundle that excludes the entries
if (parsedQuery._summary && parsedQuery._summary === 'count') {
// if _count has the value 0, this shall be treated the same as _summary=count
if (
(parsedQuery._summary && parsedQuery._summary === 'count') ||
(parsedQuery._count && parsedQuery._count === '0')
) {
const count = await findResourceCountWithQuery(mongoQuery, 'Library');
return createSummarySearchsetBundle<fhir4.Library>(count);
}
// if the _elements parameter with a comma-separated string is included
// then return a searchset bundle that includes only those elements
// on those resource entries
else if (parsedQuery._elements) {
const entries = await findResourceElementsWithQuery<fhir4.Library>(mongoQuery, 'Library');
const result = await findResourceElementsWithQuery<fhir4.Library>(mongoQuery, 'Library');
const entries = result.map(r => r.data);
// add the SUBSETTED tag to the resources returned by the _elements parameter
entries.map(e => {
if (e.meta) {
Expand All @@ -67,10 +77,37 @@ export class LibraryService implements Service<fhir4.Library> {
};
}
});
return createSearchsetBundle(entries);
const bundle = createSearchsetBundle(entries);
if (parsedQuery._count) {
bundle.link = createPaginationLinks(
`http://${req.headers.host}/${req.params.base_version}/`,
'Library',
new URLSearchParams(req.query),
{
numberOfPages: Math.ceil(result[0].metadata[0].total / parseInt(parsedQuery._count)),
page: parseInt(parsedQuery.page || '1')
}
);
}
return bundle;
} else {
const entries = await findResourcesWithQuery<fhir4.Library>(mongoQuery, 'Library');
return createSearchsetBundle(entries);
const result = await findResourcesWithQuery<fhir4.Library>(mongoQuery, 'Library');
const entries = result.map(r => r.data);
const bundle = createSearchsetBundle(entries);
if (parsedQuery._count) {
if (parsedQuery._count) {
bundle.link = createPaginationLinks(
`http://${req.headers.host}/${req.params.base_version}/`,
'Library',
new URLSearchParams(req.query),
{
numberOfPages: Math.ceil(result[0].metadata[0].total / parseInt(parsedQuery._count)),
page: parseInt(parsedQuery.page || '1')
}
);
}
}
return bundle;
}
}

Expand Down
51 changes: 45 additions & 6 deletions service/src/services/MeasureService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ import {
updateResource
} from '../db/dbOperations';
import { Service } from '../types/service';
import { createMeasurePackageBundle, createSearchsetBundle, createSummarySearchsetBundle } from '../util/bundleUtils';
import {
createPaginationLinks,
createMeasurePackageBundle,
createSearchsetBundle,
createSummarySearchsetBundle
} from '../util/bundleUtils';
import { BadRequestError, ResourceNotFoundError } from '../util/errorUtils';
import { getMongoQueryFromRequest } from '../util/queryUtils';
import {
Expand Down Expand Up @@ -45,15 +50,20 @@ export class MeasureService implements Service<fhir4.Measure> {

// if the _summary parameter with a value of count is included, then
// return a searchset bundle that excludes the entries
if (parsedQuery._summary && parsedQuery._summary === 'count') {
// if _count has the value 0, this shall be treated the same as _summary=count
if (
(parsedQuery._summary && parsedQuery._summary === 'count') ||
(parsedQuery._count && parsedQuery._count === '0')
) {
const count = await findResourceCountWithQuery(mongoQuery, 'Measure');
return createSummarySearchsetBundle<fhir4.Measure>(count);
}
// if the _elements parameter with a comma-separated string is included
// then return a searchset bundle that includes only those elements
// on those resource entries
else if (parsedQuery._elements) {
const entries = await findResourceElementsWithQuery<fhir4.Measure>(mongoQuery, 'Measure');
const result = await findResourceElementsWithQuery<fhir4.Measure>(mongoQuery, 'Measure');
const entries = result.map(r => r.data);
// add the SUBSETTED tag to the resources returned by the _elements parameter
entries.map(e => {
if (e.meta) {
Expand All @@ -68,10 +78,39 @@ export class MeasureService implements Service<fhir4.Measure> {
};
}
});
return createSearchsetBundle(entries);
const bundle = createSearchsetBundle(entries);
if (parsedQuery._count) {
if (parsedQuery._count) {
bundle.link = createPaginationLinks(
`http://${req.headers.host}/${req.params.base_version}/`,
'Measure',
new URLSearchParams(req.query),
{
numberOfPages: Math.ceil(result[0].metadata[0].total / parseInt(parsedQuery._count)),
page: parseInt(parsedQuery.page || '1')
}
);
}
}
return bundle;
} else {
const entries = await findResourcesWithQuery<fhir4.Measure>(mongoQuery, 'Measure');
return createSearchsetBundle(entries);
const result = await findResourcesWithQuery<fhir4.Measure>(mongoQuery, 'Measure');
const entries = result.map(r => r.data);
const bundle = createSearchsetBundle(entries);
if (parsedQuery._count) {
if (parsedQuery._count) {
bundle.link = createPaginationLinks(
`http://${req.headers.host}/${req.params.base_version}/`,
'Measure',
new URLSearchParams(req.query),
{
numberOfPages: Math.ceil(result[0].metadata[0].total / parseInt(parsedQuery._count)),
page: parseInt(parsedQuery.page || '1')
}
);
}
}
return bundle;
}
}

Expand Down
74 changes: 70 additions & 4 deletions service/src/util/bundleUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,72 @@ export function createSummarySearchsetBundle<T extends fhir4.FhirResource>(count
};
}

/**
* Creates pagination links for the search results bundle.
* Logic pulled from implementation in deqm-test-server
* https://github.com/projecttacoma/deqm-test-server/blob/dc89180bf4b355e97db38cfa84471dd16db82e93/src/util/baseUtils.js#L61
*
* @param {string} baseUrl Base URL of the server and FHIR base path. Should be pulled from request.
* @param {string} resourceType The resource type these results are for.
* @param {url.URLSearchParams} searchParams The search parameter object used for the initial query pulled from the request.
* @param {{numberOfPages: number, page: number}} resultsMetadata The results metadata object from the mongo results.
* @returns {fhir4.BundleLink[]} The links that should be added to the search st results bundle.
*/
export function createPaginationLinks(
baseUrl: string,
resourceType: string,
searchParams: URLSearchParams,
resultsMetadata: { numberOfPages: number; page: number }
): fhir4.BundleLink[] {
const { numberOfPages, page } = resultsMetadata;
const links = [];

// create self link, including query params only if there were any
if (searchParams.toString() !== '') {
links.push({
relation: 'self',
url: new URL(`${resourceType}?${searchParams}`, baseUrl).toString()
});
} else {
links.push({
relation: 'self',
url: new URL(`${resourceType}`, baseUrl).toString()
});
}

// first page
searchParams.set('page', '1');
links.push({
relation: 'first',
url: new URL(`${resourceType}?${searchParams}`, baseUrl).toString()
});

// only add previous and next if appropriate
if (page > 1) {
searchParams.set('page', `${page - 1}`);
links.push({
relation: 'previous',
url: new URL(`${resourceType}?${searchParams}`, baseUrl).toString()
});
}
if (page < numberOfPages) {
searchParams.set('page', `${page + 1}`);
links.push({
relation: 'next',
url: new URL(`${resourceType}?${searchParams}`, baseUrl).toString()
});
}

// last page
searchParams.set('page', `${numberOfPages}`);
links.push({
relation: 'last',
url: new URL(`${resourceType}?${searchParams}`, baseUrl).toString()
});

return links;
}

/**
* Takes in a mongo query, finds a Measure based on the query and all dependent
* Library resources and bundles them together with the Measure in a collection bundle
Expand All @@ -49,7 +115,7 @@ export async function createMeasurePackageBundle(
params: z.infer<typeof PackageArgs>
): Promise<fhir4.Bundle<fhir4.FhirResource>> {
const mongoQuery = getMongoQueryFromRequest(query);
const measure = await findResourcesWithQuery<fhir4.Measure>(mongoQuery, 'Measure');
const measure = (await findResourcesWithQuery<fhir4.Measure>(mongoQuery, 'Measure')).map(r => r.data);
if (!measure || !(measure.length > 0)) {
throw new ResourceNotFoundError(
`No resource found in collection: Measure, with ${Object.keys(query)
Expand All @@ -71,7 +137,7 @@ export async function createMeasurePackageBundle(
if (measureForPackaging.library && measureForPackaging.library.length > 0) {
const [mainLibraryRef] = measureForPackaging.library;
const mainLibQuery = getQueryFromReference(mainLibraryRef);
const libs = await findResourcesWithQuery<fhir4.Library>(mainLibQuery, 'Library');
const libs = (await findResourcesWithQuery<fhir4.Library>(mainLibQuery, 'Library')).map(r => r.data);
if (!libs || libs.length < 1) {
throw new ResourceNotFoundError(
`Could not find Library ${mainLibraryRef} referenced by Measure ${measureForPackaging.id}`
Expand All @@ -97,7 +163,7 @@ export async function createLibraryPackageBundle(
params: z.infer<typeof PackageArgs>
): Promise<{ libraryBundle: fhir4.Bundle<fhir4.FhirResource>; rootLibRef?: string }> {
const mongoQuery = getMongoQueryFromRequest(query);
const library = await findResourcesWithQuery<fhir4.Library>(mongoQuery, 'Library');
const library = (await findResourcesWithQuery<fhir4.Library>(mongoQuery, 'Library')).map(r => r.data);
if (!library || !(library.length > 0)) {
throw new ResourceNotFoundError(
`No resource found in collection: Library, with ${Object.keys(query)
Expand Down Expand Up @@ -281,7 +347,7 @@ export async function getAllDependentLibraries(lib: fhir4.Library): Promise<fhir
// Obtain all libraries referenced in the related artifact, and recurse on their dependencies
const libraryGets = depLibUrls.map(async url => {
const libQuery = getQueryFromReference(url);
const libs = await findResourcesWithQuery(libQuery, 'Library');
const libs = (await findResourcesWithQuery(libQuery, 'Library')).map(r => r.data);
if (!libs || libs.length < 1) {
throw new ResourceNotFoundError(
`Failed to find dependent library with ${
Expand Down
7 changes: 6 additions & 1 deletion service/src/util/queryUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ const STRING_TYPE_PARAMS = ['name', 'title', 'description', 'version'];
* a usable mongo query
*/
export function getMongoQueryFromRequest(query: RequestQuery): Filter<any> {
const pageSize = (query['_count'] && parseInt(query['_count'] as string)) || 100; // set a base limit of 100
if (!query['page']) query['page'] = '1'; // default to first page

//TODO: Handle potential for query value to be array
return Object.keys(query).reduce((mf: Filter<any>, key: string) => {
if (!query[key] || Array.isArray(query[key])) {
Expand Down Expand Up @@ -35,7 +38,9 @@ export function getMongoQueryFromRequest(query: RequestQuery): Filter<any> {
const elements = query[key] as string;
mf[key] = elements.split(',');
} else if (key === '_count') {
//blackhole _count for now
mf['limit'] = pageSize;
} else if (key === 'page') {
mf['skip'] = (parseInt((query[key] || '1') as string) - 1) * pageSize; //default to first page
} else {
// Otherwise no parsing necessary
mf[key] = query[key];
Expand Down

0 comments on commit ba82f48

Please sign in to comment.