Merge pull request #256 from fastenhealth/labwork_report_filtering

This commit is contained in:
Jason Kulatunga 2023-10-03 13:24:06 -07:00 committed by GitHub
commit 0b99549216
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
69 changed files with 679 additions and 122 deletions

1
.gitignore vendored
View File

@ -8,6 +8,7 @@
.idea/**/tasks.xml .idea/**/tasks.xml
.idea/**/dictionaries .idea/**/dictionaries
.idea/**/shelf .idea/**/shelf
.idea/dataSources.xml
# Sensitive or high-churn files # Sensitive or high-churn files
.idea/**/dataSources/ .idea/**/dataSources/

View File

@ -18,35 +18,37 @@ import (
type SearchParameterType string type SearchParameterType string
const ( const (
SearchParameterTypeNumber SearchParameterType = "number" //simple types
SearchParameterTypeDate SearchParameterType = "date" SearchParameterTypeNumber SearchParameterType = "number"
SearchParameterTypeDate SearchParameterType = "date"
SearchParameterTypeUri SearchParameterType = "uri"
SearchParameterTypeKeyword SearchParameterType = "keyword" //this is a literal/string primitive.
//complex types
SearchParameterTypeString SearchParameterType = "string" SearchParameterTypeString SearchParameterType = "string"
SearchParameterTypeToken SearchParameterType = "token" SearchParameterTypeToken SearchParameterType = "token"
SearchParameterTypeReference SearchParameterType = "reference" SearchParameterTypeReference SearchParameterType = "reference"
SearchParameterTypeUri SearchParameterType = "uri"
SearchParameterTypeQuantity SearchParameterType = "quantity" SearchParameterTypeQuantity SearchParameterType = "quantity"
SearchParameterTypeComposite SearchParameterType = "composite" SearchParameterTypeComposite SearchParameterType = "composite"
SearchParameterTypeSpecial SearchParameterType = "special" SearchParameterTypeSpecial SearchParameterType = "special"
SearchParameterTypeKeyword SearchParameterType = "keyword" //this is a literal/string primitive.
) )
const TABLE_ALIAS = "fhir" const TABLE_ALIAS = "fhir"
//Allows users to use SearchParameters to query resources // Allows users to use SearchParameters to query resources
// Can generate simple or complex queries, depending on the SearchParameter type: // Can generate simple or complex queries, depending on the SearchParameter type:
// //
// eg. Simple // eg. Simple
// //
//
// eg. Complex // eg. Complex
// SELECT fhir.* // SELECT fhir.*
// FROM fhir_observation as fhir, json_each(fhir.code) as codeJson // FROM fhir_observation as fhir, json_each(fhir.code) as codeJson
// WHERE ( // WHERE (
//
// (codeJson.value ->> '$.code' = "29463-7" AND codeJson.value ->> '$.system' = "http://loinc.org") // (codeJson.value ->> '$.code' = "29463-7" AND codeJson.value ->> '$.system' = "http://loinc.org")
// OR (codeJson.value ->> '$.code' = "3141-9" AND codeJson.value ->> '$.system' = "http://loinc.org") // OR (codeJson.value ->> '$.code' = "3141-9" AND codeJson.value ->> '$.system' = "http://loinc.org")
// OR (codeJson.value ->> '$.code' = "27113001" AND codeJson.value ->> '$.system' = "http://snomed.info/sct") // OR (codeJson.value ->> '$.code' = "27113001" AND codeJson.value ->> '$.system' = "http://snomed.info/sct")
//
// ) // )
// AND (user_id = "6efcd7c5-3f29-4f0d-926d-a66ff68bbfc2") // AND (user_id = "6efcd7c5-3f29-4f0d-926d-a66ff68bbfc2")
// GROUP BY `fhir`.`id` // GROUP BY `fhir`.`id`
@ -57,7 +59,7 @@ func (sr *SqliteRepository) QueryResources(ctx context.Context, query models.Que
return nil, err return nil, err
} }
if query.Aggregations != nil && (len(query.Aggregations.GroupBy) > 0 || len(query.Aggregations.CountBy) > 0) { if query.Aggregations != nil && (query.Aggregations.GroupBy != nil || query.Aggregations.CountBy != nil) {
results := []map[string]interface{}{} results := []map[string]interface{}{}
clientResp := sqlQuery.Find(&results) clientResp := sqlQuery.Find(&results)
return results, clientResp.Error return results, clientResp.Error
@ -142,37 +144,45 @@ func (sr *SqliteRepository) sqlQueryResources(ctx context.Context, query models.
//defaults //defaults
selectClauses := []string{fmt.Sprintf("%s.*", TABLE_ALIAS)} selectClauses := []string{fmt.Sprintf("%s.*", TABLE_ALIAS)}
groupClause := fmt.Sprintf("%s.id", TABLE_ALIAS) groupClause := fmt.Sprintf("%s.id", TABLE_ALIAS)
orderClause := fmt.Sprintf("%s.sort_date ASC", TABLE_ALIAS) orderClause := fmt.Sprintf("%s.sort_date DESC", TABLE_ALIAS)
if query.Aggregations != nil { if query.Aggregations != nil {
//Handle Aggregations //Handle Aggregations
if len(query.Aggregations.CountBy) > 0 { if query.Aggregations.CountBy != nil {
//populate the group by and order by clause with the count by values //populate the group by and order by clause with the count by values
query.Aggregations.OrderBy = "count(*) DESC" query.Aggregations.OrderBy = &models.QueryResourceAggregation{
Field: "*",
Function: "count",
}
query.Aggregations.GroupBy = query.Aggregations.CountBy query.Aggregations.GroupBy = query.Aggregations.CountBy
if query.Aggregations.GroupBy == "*" { if query.Aggregations.GroupBy.Field == "*" {
//we need to get the count of all resources, so we need to remove the group by clause and replace it by //we need to get the count of all resources, so we need to remove the group by clause and replace it by
// `source_resource_type` which will be the same for all resources // `source_resource_type` which will be the same for all resources
query.Aggregations.GroupBy = "source_resource_type" query.Aggregations.GroupBy.Field = "source_resource_type"
} }
} }
//process order by clause //process order by clause
if len(query.Aggregations.OrderBy) > 0 { if query.Aggregations.OrderBy != nil {
orderAsc := true orderAsc := true //default to ascending, switch to desc if parameter is a date type.
if !strings.HasPrefix(query.Aggregations.OrderBy, "count(*)") { if !(query.Aggregations.OrderBy.Field == "*") {
orderAggregationParam, err := ProcessAggregationParameter(query.Aggregations.OrderBy, searchCodeToTypeLookup) orderAggregationParam, err := ProcessAggregationParameter(*query.Aggregations.OrderBy, searchCodeToTypeLookup)
if err != nil { if err != nil {
return nil, err return nil, err
} }
orderAggregationFromClause, err := SearchCodeToFromClause(orderAggregationParam) orderAggregationFromClause, err := SearchCodeToFromClause(orderAggregationParam.SearchParameter)
if err != nil { if err != nil {
return nil, err return nil, err
} }
fromClauses = append(fromClauses, orderAggregationFromClause) fromClauses = append(fromClauses, orderAggregationFromClause)
//if the order by is a date type, we need to order by DESC (most recent first)
if orderAggregationParam.Type == SearchParameterTypeDate {
orderAsc = false
}
orderClause = AggregationParameterToClause(orderAggregationParam) orderClause = AggregationParameterToClause(orderAggregationParam)
if orderAsc { if orderAsc {
orderClause = fmt.Sprintf("%s ASC", orderClause) orderClause = fmt.Sprintf("%s ASC", orderClause)
@ -180,17 +190,17 @@ func (sr *SqliteRepository) sqlQueryResources(ctx context.Context, query models.
orderClause = fmt.Sprintf("%s DESC", orderClause) orderClause = fmt.Sprintf("%s DESC", orderClause)
} }
} else { } else {
orderClause = query.Aggregations.OrderBy orderClause = fmt.Sprintf("%s(%s) DESC", query.Aggregations.OrderBy.Function, query.Aggregations.OrderBy.Field)
} }
} }
//process group by clause //process group by clause
if len(query.Aggregations.GroupBy) > 0 { if query.Aggregations.GroupBy != nil {
groupAggregationParam, err := ProcessAggregationParameter(query.Aggregations.GroupBy, searchCodeToTypeLookup) groupAggregationParam, err := ProcessAggregationParameter(*query.Aggregations.GroupBy, searchCodeToTypeLookup)
if err != nil { if err != nil {
return nil, err return nil, err
} }
groupAggregationFromClause, err := SearchCodeToFromClause(groupAggregationParam) groupAggregationFromClause, err := SearchCodeToFromClause(groupAggregationParam.SearchParameter)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -199,8 +209,22 @@ func (sr *SqliteRepository) sqlQueryResources(ctx context.Context, query models.
groupClause = AggregationParameterToClause(groupAggregationParam) groupClause = AggregationParameterToClause(groupAggregationParam)
selectClauses = []string{ selectClauses = []string{
fmt.Sprintf("%s as %s", groupClause, "label"), fmt.Sprintf("%s as %s", groupClause, "label"),
"count(*) as value",
} }
if query.Aggregations.OrderBy == nil || query.Aggregations.OrderBy.Field == "*" {
selectClauses = append(selectClauses, fmt.Sprintf("%s as %s", "count(*)", "value"))
orderClause = fmt.Sprintf("%s DESC", "count(*)")
} else {
//use the orderBy aggregation as the value
orderAggregationParam, err := ProcessAggregationParameter(*query.Aggregations.OrderBy, searchCodeToTypeLookup)
if err != nil {
return nil, err
}
orderSelectClause := AggregationParameterToClause(orderAggregationParam)
selectClauses = append(selectClauses, fmt.Sprintf("%s as %s", orderSelectClause, "value"))
}
} }
} }
@ -210,12 +234,22 @@ func (sr *SqliteRepository) sqlQueryResources(ctx context.Context, query models.
fromClauses = lo.Uniq(fromClauses) fromClauses = lo.Uniq(fromClauses)
fromClauses = lo.Compact(fromClauses) fromClauses = lo.Compact(fromClauses)
return sr.GormClient.WithContext(ctx). sqlQuery := sr.GormClient.WithContext(ctx).
Select(strings.Join(selectClauses, ", ")). Select(strings.Join(selectClauses, ", ")).
Where(strings.Join(whereClauses, " AND "), whereNamedParameters). Where(strings.Join(whereClauses, " AND "), whereNamedParameters).
Group(groupClause). Group(groupClause).
Order(orderClause). Order(orderClause).
Table(strings.Join(fromClauses, ", ")), nil Table(strings.Join(fromClauses, ", "))
//add limit and offset clauses if present
if query.Limit != nil {
sqlQuery = sqlQuery.Limit(*query.Limit)
}
if query.Offset != nil {
sqlQuery = sqlQuery.Offset(*query.Offset)
}
return sqlQuery, nil
} }
/// INTERNAL functionality. These functions are exported for testing, but are not available in the Interface /// INTERNAL functionality. These functions are exported for testing, but are not available in the Interface
@ -227,14 +261,22 @@ type SearchParameter struct {
Modifier string Modifier string
} }
//Lists in the SearchParameterValueOperatorTree are AND'd together, and items within each SearchParameterValueOperatorTree list are OR'd together type AggregationParameter struct {
//For example, the following would be AND'd together, and then OR'd with the next SearchParameterValueOperatorTree SearchParameter
// { Function string //count, sum, avg, min, max, etc
// {SearchParameterValue{Value: "foo"}, SearchParameterValue{Value: "bar"}} }
// {SearchParameterValue{Value: "baz"}},
// } // Lists in the SearchParameterValueOperatorTree are AND'd together, and items within each SearchParameterValueOperatorTree list are OR'd together
//This would result in the following SQL: // For example, the following would be AND'd together, and then OR'd with the next SearchParameterValueOperatorTree
// (value = "foo" OR value = "bar") AND (value = "baz") //
// {
// {SearchParameterValue{Value: "foo"}, SearchParameterValue{Value: "bar"}}
// {SearchParameterValue{Value: "baz"}},
// }
//
// This would result in the following SQL:
//
// (value = "foo" OR value = "bar") AND (value = "baz")
type SearchParameterValueOperatorTree [][]SearchParameterValue type SearchParameterValueOperatorTree [][]SearchParameterValue
type SearchParameterValue struct { type SearchParameterValue struct {
@ -243,7 +285,7 @@ type SearchParameterValue struct {
SecondaryValues map[string]interface{} SecondaryValues map[string]interface{}
} }
//SearchParameters are made up of parameter names and modifiers. For example, "name" and "name:exact" are both valid search parameters // SearchParameters are made up of parameter names and modifiers. For example, "name" and "name:exact" are both valid search parameters
// This function will parse the searchCodeWithModifier and return the SearchParameter // This function will parse the searchCodeWithModifier and return the SearchParameter
func ProcessSearchParameter(searchCodeWithModifier string, searchParamTypeLookup map[string]string) (SearchParameter, error) { func ProcessSearchParameter(searchCodeWithModifier string, searchParamTypeLookup map[string]string) (SearchParameter, error) {
searchParameter := SearchParameter{} searchParameter := SearchParameter{}
@ -284,8 +326,9 @@ func ProcessSearchParameter(searchCodeWithModifier string, searchParamTypeLookup
// top level is AND'd together, and each item within the lists are OR'd together // top level is AND'd together, and each item within the lists are OR'd together
// //
// For example, searchParamCodeValueOrValuesWithPrefix may be: // For example, searchParamCodeValueOrValuesWithPrefix may be:
// "code": "29463-7,3141-9,27113001" //
// "code": ["le29463-7", "gt3141-9", "27113001"] // "code": "29463-7,3141-9,27113001"
// "code": ["le29463-7", "gt3141-9", "27113001"]
func ProcessSearchParameterValueIntoOperatorTree(searchParameter SearchParameter, searchParamCodeValueOrValuesWithPrefix interface{}) (SearchParameterValueOperatorTree, error) { func ProcessSearchParameterValueIntoOperatorTree(searchParameter SearchParameter, searchParamCodeValueOrValuesWithPrefix interface{}) (SearchParameterValueOperatorTree, error) {
searchParamCodeValuesWithPrefix := []string{} searchParamCodeValuesWithPrefix := []string{}
@ -374,12 +417,8 @@ func ProcessSearchParameterValue(searchParameter SearchParameter, searchValueWit
} }
} else if len(searchParameterValueParts) == 2 { } else if len(searchParameterValueParts) == 2 {
//if theres 2 parts, first is always system, second is always the code. Either one may be emty. If both are emty this is invalid. //if theres 2 parts, first is always system, second is always the code. Either one may be emty. If both are emty this is invalid.
if len(searchParameterValueParts[0]) > 0 { searchParameterValue.SecondaryValues[searchParameter.Name+"System"] = searchParameterValueParts[0]
searchParameterValue.SecondaryValues[searchParameter.Name+"System"] = searchParameterValueParts[0] searchParameterValue.Value = searchParameterValueParts[1]
}
if len(searchParameterValueParts[1]) > 0 {
searchParameterValue.Value = searchParameterValueParts[1]
}
if len(searchParameterValueParts[0]) == 0 && len(searchParameterValueParts[1]) == 0 { if len(searchParameterValueParts[0]) == 0 && len(searchParameterValueParts[1]) == 0 {
return searchParameterValue, fmt.Errorf("invalid search parameter value: (%s=%s)", searchParameter.Name, searchParameterValue.Value) return searchParameterValue, fmt.Errorf("invalid search parameter value: (%s=%s)", searchParameter.Name, searchParameterValue.Value)
} }
@ -416,7 +455,7 @@ func NamedParameterWithSuffix(parameterName string, suffix string) string {
return fmt.Sprintf("%s_%s", parameterName, suffix) return fmt.Sprintf("%s_%s", parameterName, suffix)
} }
//SearchCodeToWhereClause converts a searchCode and searchCodeValue to a where clause and a map of named parameters // SearchCodeToWhereClause converts a searchCode and searchCodeValue to a where clause and a map of named parameters
func SearchCodeToWhereClause(searchParam SearchParameter, searchParamValue SearchParameterValue, namedParameterSuffix string) (string, map[string]interface{}, error) { func SearchCodeToWhereClause(searchParam SearchParameter, searchParamValue SearchParameterValue, namedParameterSuffix string) (string, map[string]interface{}, error) {
//add named parameters to the lookup map. Basically, this is a map of all the named parameters that will be used in the where clause we're generating //add named parameters to the lookup map. Basically, this is a map of all the named parameters that will be used in the where clause we're generating
@ -528,7 +567,10 @@ func SearchCodeToWhereClause(searchParam SearchParameter, searchParamValue Searc
//TODO: support ":text" modifier //TODO: support ":text" modifier
//setup the clause //setup the clause
clause := fmt.Sprintf("%sJson.value ->> '$.code' = @%s", searchParam.Name, NamedParameterWithSuffix(searchParam.Name, namedParameterSuffix)) clause := []string{}
if searchParamValue.Value.(string) != "" {
clause = append(clause, fmt.Sprintf("%sJson.value ->> '$.code' = @%s", searchParam.Name, NamedParameterWithSuffix(searchParam.Name, namedParameterSuffix)))
}
//append the code and/or system clauses (if required) //append the code and/or system clauses (if required)
//this looks like unnecessary code, however its required to ensure consistent tests //this looks like unnecessary code, however its required to ensure consistent tests
@ -537,10 +579,10 @@ func SearchCodeToWhereClause(searchParam SearchParameter, searchParamValue Searc
for _, k := range allowedSecondaryKeys { for _, k := range allowedSecondaryKeys {
namedParameterKey := fmt.Sprintf("%s%s", searchParam.Name, strings.Title(k)) namedParameterKey := fmt.Sprintf("%s%s", searchParam.Name, strings.Title(k))
if _, ok := searchParamValue.SecondaryValues[namedParameterKey]; ok { if _, ok := searchParamValue.SecondaryValues[namedParameterKey]; ok {
clause += fmt.Sprintf(` AND %sJson.value ->> '$.%s' = @%s`, searchParam.Name, k, NamedParameterWithSuffix(namedParameterKey, namedParameterSuffix)) clause = append(clause, fmt.Sprintf(`%sJson.value ->> '$.%s' = @%s`, searchParam.Name, k, NamedParameterWithSuffix(namedParameterKey, namedParameterSuffix)))
} }
} }
return fmt.Sprintf("(%s)", clause), searchClauseNamedParams, nil return fmt.Sprintf("(%s)", strings.Join(clause, " AND ")), searchClauseNamedParams, nil
case SearchParameterTypeKeyword: case SearchParameterTypeKeyword:
//setup the clause //setup the clause
@ -565,17 +607,34 @@ func SearchCodeToFromClause(searchParam SearchParameter) (string, error) {
return "", nil return "", nil
} }
func AggregationParameterToClause(aggParameter SearchParameter) string { func AggregationParameterToClause(aggParameter AggregationParameter) string {
var clause string
switch aggParameter.Type { switch aggParameter.Type {
case SearchParameterTypeQuantity, SearchParameterTypeToken, SearchParameterTypeString: case SearchParameterTypeQuantity, SearchParameterTypeString:
//setup the clause //setup the clause
return fmt.Sprintf("(%sJson.value ->> '$.%s')", aggParameter.Name, aggParameter.Modifier) clause = fmt.Sprintf("(%sJson.value ->> '$.%s')", aggParameter.Name, aggParameter.Modifier)
case SearchParameterTypeToken:
//modifier is optional for token types.
if aggParameter.Modifier != "" {
clause = fmt.Sprintf("(%sJson.value ->> '$.%s')", aggParameter.Name, aggParameter.Modifier)
} else {
//if no modifier is specified, use the system and code to generate the clause
//((codeJson.value ->> '$.system') || '|' || (codeJson.value ->> '$.code'))
clause = fmt.Sprintf("((%sJson.value ->> '$.system') || '|' || (%sJson.value ->> '$.code'))", aggParameter.Name, aggParameter.Name)
}
default: default:
return fmt.Sprintf("%s.%s", TABLE_ALIAS, aggParameter.Name) clause = fmt.Sprintf("%s.%s", TABLE_ALIAS, aggParameter.Name)
} }
if len(aggParameter.Function) > 0 {
clause = fmt.Sprintf("%s(%s)", aggParameter.Function, clause)
}
return clause
} }
//ProcessAggregationParameter processes the aggregation parameters which are fields with optional properties: // ProcessAggregationParameter processes the aggregation parameters which are fields with optional properties:
// Fields that are primitive types (number, uri) must not have any property specified: // Fields that are primitive types (number, uri) must not have any property specified:
// eg. `probability` // eg. `probability`
// //
@ -583,12 +642,15 @@ func AggregationParameterToClause(aggParameter SearchParameter) string {
// eg. `identifier:code` // eg. `identifier:code`
// //
// if the a property is specified, its set as the modifier, and used when generating the SQL query groupBy, orderBy, etc clause // if the a property is specified, its set as the modifier, and used when generating the SQL query groupBy, orderBy, etc clause
func ProcessAggregationParameter(aggregationFieldWithProperty string, searchParamTypeLookup map[string]string) (SearchParameter, error) { func ProcessAggregationParameter(aggregationFieldWithFn models.QueryResourceAggregation, searchParamTypeLookup map[string]string) (AggregationParameter, error) {
aggregationParameter := SearchParameter{} aggregationParameter := AggregationParameter{
SearchParameter: SearchParameter{},
Function: aggregationFieldWithFn.Function,
}
//determine the searchCode searchCodeModifier //determine the searchCode searchCodeModifier
//TODO: this is only applicable to string, token, reference and uri type (however unknown names & modifiers are ignored) //TODO: this is only applicable to string, token, reference and uri type (however unknown names & modifiers are ignored)
if aggregationFieldParts := strings.SplitN(aggregationFieldWithProperty, ":", 2); len(aggregationFieldParts) == 2 { if aggregationFieldParts := strings.SplitN(aggregationFieldWithFn.Field, ":", 2); len(aggregationFieldParts) == 2 {
aggregationParameter.Name = aggregationFieldParts[0] aggregationParameter.Name = aggregationFieldParts[0]
aggregationParameter.Modifier = aggregationFieldParts[1] aggregationParameter.Modifier = aggregationFieldParts[1]
} else { } else {
@ -599,16 +661,18 @@ func ProcessAggregationParameter(aggregationFieldWithProperty string, searchPara
//next, determine the searchCodeType for this Resource (or throw an error if it is unknown) //next, determine the searchCodeType for this Resource (or throw an error if it is unknown)
searchParamTypeStr, searchParamTypeOk := searchParamTypeLookup[aggregationParameter.Name] searchParamTypeStr, searchParamTypeOk := searchParamTypeLookup[aggregationParameter.Name]
if !searchParamTypeOk { if !searchParamTypeOk {
return aggregationParameter, fmt.Errorf("unknown search parameter: %s", aggregationParameter.Name) return aggregationParameter, fmt.Errorf("unknown search parameter in aggregation: %s", aggregationParameter.Name)
} else { } else {
aggregationParameter.Type = SearchParameterType(searchParamTypeStr) aggregationParameter.Type = SearchParameterType(searchParamTypeStr)
} }
//primitive types should not have a modifier, we need to throw an error //primitive types should not have a modifier, we need to throw an error
if aggregationParameter.Type == SearchParameterTypeNumber || aggregationParameter.Type == SearchParameterTypeUri || aggregationParameter.Type == SearchParameterTypeKeyword { if aggregationParameter.Type == SearchParameterTypeNumber || aggregationParameter.Type == SearchParameterTypeUri || aggregationParameter.Type == SearchParameterTypeKeyword || aggregationParameter.Type == SearchParameterTypeDate {
if len(aggregationParameter.Modifier) > 0 { if len(aggregationParameter.Modifier) > 0 {
return aggregationParameter, fmt.Errorf("primitive aggregation parameter %s cannot have a property (%s)", aggregationParameter.Name, aggregationParameter.Modifier) return aggregationParameter, fmt.Errorf("primitive aggregation parameter %s cannot have a property (%s)", aggregationParameter.Name, aggregationParameter.Modifier)
} }
} else if aggregationParameter.Type == SearchParameterTypeToken {
//modifier is optional for token types
} else { } else {
//complex types must have a modifier //complex types must have a modifier
if len(aggregationParameter.Modifier) == 0 { if len(aggregationParameter.Modifier) == 0 {

View File

@ -98,7 +98,7 @@ func (suite *RepositorySqlTestSuite) TestQueryResources_SQL() {
"FROM fhir_observation as fhir, json_each(fhir.code) as codeJson", "FROM fhir_observation as fhir, json_each(fhir.code) as codeJson",
"WHERE ((codeJson.value ->> '$.code' = ?)) AND (user_id = ?)", "WHERE ((codeJson.value ->> '$.code' = ?)) AND (user_id = ?)",
"GROUP BY `fhir`.`id`", "GROUP BY `fhir`.`id`",
"ORDER BY fhir.sort_date ASC", "ORDER BY fhir.sort_date DESC",
}, " "), }, " "),
sqlString) sqlString)
require.Equal(suite.T(), sqlParams, []interface{}{ require.Equal(suite.T(), sqlParams, []interface{}{
@ -136,7 +136,7 @@ func (suite *RepositorySqlTestSuite) TestQueryResources_SQL_WithMultipleWhereCon
"FROM fhir_observation as fhir, json_each(fhir.code) as codeJson, json_each(fhir.category) as categoryJson", "FROM fhir_observation as fhir, json_each(fhir.code) as codeJson, json_each(fhir.category) as categoryJson",
"WHERE ((codeJson.value ->> '$.code' = ?)) AND ((categoryJson.value ->> '$.code' = ?)) AND (user_id = ?)", "WHERE ((codeJson.value ->> '$.code' = ?)) AND ((categoryJson.value ->> '$.code' = ?)) AND (user_id = ?)",
"GROUP BY `fhir`.`id`", "GROUP BY `fhir`.`id`",
"ORDER BY fhir.sort_date ASC", "ORDER BY fhir.sort_date DESC",
}, " "), }, " "),
sqlString) sqlString)
require.Equal(suite.T(), sqlParams, []interface{}{ require.Equal(suite.T(), sqlParams, []interface{}{
@ -158,7 +158,7 @@ func (suite *RepositorySqlTestSuite) TestQueryResources_SQL_WithPrimitiveOrderBy
"activityCode": "test_code", "activityCode": "test_code",
}, },
From: "CarePlan", From: "CarePlan",
Aggregations: &models.QueryResourceAggregations{OrderBy: "instantiatesUri"}, Aggregations: &models.QueryResourceAggregations{OrderBy: &models.QueryResourceAggregation{Field: "instantiatesUri"}},
}) })
require.NoError(suite.T(), err) require.NoError(suite.T(), err)
var results []map[string]interface{} var results []map[string]interface{}
@ -193,7 +193,7 @@ func (suite *RepositorySqlTestSuite) TestQueryResources_SQL_WithKeywordOrderByAg
Select: []string{}, Select: []string{},
Where: map[string]interface{}{}, Where: map[string]interface{}{},
From: "CarePlan", From: "CarePlan",
Aggregations: &models.QueryResourceAggregations{OrderBy: "id"}, Aggregations: &models.QueryResourceAggregations{OrderBy: &models.QueryResourceAggregation{Field: "id"}},
}) })
require.NoError(suite.T(), err) require.NoError(suite.T(), err)
var results []map[string]interface{} var results []map[string]interface{}
@ -230,7 +230,7 @@ func (suite *RepositorySqlTestSuite) TestQueryResources_SQL_WithComplexOrderByAg
"code": "test_code", "code": "test_code",
}, },
From: "Observation", From: "Observation",
Aggregations: &models.QueryResourceAggregations{OrderBy: "valueString:value"}, Aggregations: &models.QueryResourceAggregations{OrderBy: &models.QueryResourceAggregation{Field: "valueString:value"}},
}) })
require.NoError(suite.T(), err) require.NoError(suite.T(), err)
var results []map[string]interface{} var results []map[string]interface{}
@ -267,7 +267,7 @@ func (suite *RepositorySqlTestSuite) TestQueryResources_SQL_WithPrimitiveCountBy
"activityCode": "test_code", "activityCode": "test_code",
}, },
From: "CarePlan", From: "CarePlan",
Aggregations: &models.QueryResourceAggregations{CountBy: "instantiatesUri"}, Aggregations: &models.QueryResourceAggregations{CountBy: &models.QueryResourceAggregation{Field: "instantiatesUri"}},
}) })
require.NoError(suite.T(), err) require.NoError(suite.T(), err)
var results []map[string]interface{} var results []map[string]interface{}
@ -304,7 +304,7 @@ func (suite *RepositorySqlTestSuite) TestQueryResources_SQL_WithKeywordCountByAg
"activityCode": "test_code", "activityCode": "test_code",
}, },
From: "CarePlan", From: "CarePlan",
Aggregations: &models.QueryResourceAggregations{CountBy: "source_resource_type"}, Aggregations: &models.QueryResourceAggregations{CountBy: &models.QueryResourceAggregation{Field: "source_resource_type"}},
}) })
require.NoError(suite.T(), err) require.NoError(suite.T(), err)
var results []map[string]interface{} var results []map[string]interface{}
@ -339,7 +339,7 @@ func (suite *RepositorySqlTestSuite) TestQueryResources_SQL_WithWildcardCountByA
Select: []string{}, Select: []string{},
Where: map[string]interface{}{}, Where: map[string]interface{}{},
From: "CarePlan", From: "CarePlan",
Aggregations: &models.QueryResourceAggregations{CountBy: "*"}, Aggregations: &models.QueryResourceAggregations{CountBy: &models.QueryResourceAggregation{Field: "*"}},
}) })
require.NoError(suite.T(), err) require.NoError(suite.T(), err)
var results []map[string]interface{} var results []map[string]interface{}
@ -376,7 +376,7 @@ func (suite *RepositorySqlTestSuite) TestQueryResources_SQL_WithComplexCountByAg
"code": "test_code", "code": "test_code",
}, },
From: "Observation", From: "Observation",
Aggregations: &models.QueryResourceAggregations{CountBy: "code:code"}, Aggregations: &models.QueryResourceAggregations{CountBy: &models.QueryResourceAggregation{Field: "code:code"}},
}) })
require.NoError(suite.T(), err) require.NoError(suite.T(), err)
var results []map[string]interface{} var results []map[string]interface{}
@ -398,3 +398,120 @@ func (suite *RepositorySqlTestSuite) TestQueryResources_SQL_WithComplexCountByAg
"test_code", "00000000-0000-0000-0000-000000000000", "test_code", "00000000-0000-0000-0000-000000000000",
}) })
} }
func (suite *RepositorySqlTestSuite) TestQueryResources_SQL_WithComplexGroupByWithOrderByMaxFnAggregation() {
//setup
sqliteRepo := suite.TestRepository.(*SqliteRepository)
sqliteRepo.GormClient = sqliteRepo.GormClient.Session(&gorm.Session{DryRun: true})
//test
authContext := context.WithValue(context.Background(), pkg.ContextKeyTypeAuthUsername, "test_username")
sqlQuery, err := sqliteRepo.sqlQueryResources(authContext, models.QueryResource{
Select: []string{},
Where: map[string]interface{}{
"code": "test_code",
},
From: "Observation",
Aggregations: &models.QueryResourceAggregations{
GroupBy: &models.QueryResourceAggregation{Field: "code:code"},
OrderBy: &models.QueryResourceAggregation{Field: "sort_date", Function: "max"},
},
})
require.NoError(suite.T(), err)
var results []map[string]interface{}
statement := sqlQuery.Find(&results).Statement
sqlString := statement.SQL.String()
sqlParams := statement.Vars
//assert
require.NoError(suite.T(), err)
require.Equal(suite.T(),
strings.Join([]string{
"SELECT (codeJson.value ->> '$.code') as label, max(fhir.sort_date) as value",
"FROM fhir_observation as fhir, json_each(fhir.code) as codeJson",
"WHERE ((codeJson.value ->> '$.code' = ?)) AND (user_id = ?)",
"GROUP BY (codeJson.value ->> '$.code')",
"ORDER BY max(fhir.sort_date) DESC",
}, " "), sqlString)
require.Equal(suite.T(), sqlParams, []interface{}{
"test_code", "00000000-0000-0000-0000-000000000000",
})
}
func (suite *RepositorySqlTestSuite) TestQueryResources_SQL_WithTokenGroupByNoModifier() {
//setup
sqliteRepo := suite.TestRepository.(*SqliteRepository)
sqliteRepo.GormClient = sqliteRepo.GormClient.Session(&gorm.Session{DryRun: true})
//test
authContext := context.WithValue(context.Background(), pkg.ContextKeyTypeAuthUsername, "test_username")
sqlQuery, err := sqliteRepo.sqlQueryResources(authContext, models.QueryResource{
Select: []string{},
Where: map[string]interface{}{},
From: "Observation",
Aggregations: &models.QueryResourceAggregations{
GroupBy: &models.QueryResourceAggregation{Field: "code"},
},
})
require.NoError(suite.T(), err)
var results []map[string]interface{}
statement := sqlQuery.Find(&results).Statement
sqlString := statement.SQL.String()
sqlParams := statement.Vars
//assert
require.NoError(suite.T(), err)
require.Equal(suite.T(),
strings.Join([]string{
"SELECT ((codeJson.value ->> '$.system') || '|' || (codeJson.value ->> '$.code')) as label, count(*) as value",
"FROM fhir_observation as fhir, json_each(fhir.code) as codeJson",
"WHERE (user_id = ?)",
"GROUP BY ((codeJson.value ->> '$.system') || '|' || (codeJson.value ->> '$.code'))",
"ORDER BY count(*) DESC",
}, " "), sqlString)
require.Equal(suite.T(), sqlParams, []interface{}{
"00000000-0000-0000-0000-000000000000",
})
}
func (suite *RepositorySqlTestSuite) TestQueryResources_SQL_WithTokenGroupByNoModifierWithLimit() {
//setup
sqliteRepo := suite.TestRepository.(*SqliteRepository)
sqliteRepo.GormClient = sqliteRepo.GormClient.Session(&gorm.Session{DryRun: true})
//test
authContext := context.WithValue(context.Background(), pkg.ContextKeyTypeAuthUsername, "test_username")
limit := 10
sqlQuery, err := sqliteRepo.sqlQueryResources(authContext, models.QueryResource{
Select: []string{},
Where: map[string]interface{}{},
From: "Observation",
Limit: &limit,
Aggregations: &models.QueryResourceAggregations{
GroupBy: &models.QueryResourceAggregation{Field: "code"},
},
})
require.NoError(suite.T(), err)
var results []map[string]interface{}
statement := sqlQuery.Find(&results).Statement
sqlString := statement.SQL.String()
sqlParams := statement.Vars
//assert
require.NoError(suite.T(), err)
require.Equal(suite.T(),
strings.Join([]string{
"SELECT ((codeJson.value ->> '$.system') || '|' || (codeJson.value ->> '$.code')) as label, count(*) as value",
"FROM fhir_observation as fhir, json_each(fhir.code) as codeJson",
"WHERE (user_id = ?)",
"GROUP BY ((codeJson.value ->> '$.system') || '|' || (codeJson.value ->> '$.code'))",
"ORDER BY count(*) DESC",
"LIMIT 10",
}, " "), sqlString)
require.Equal(suite.T(), sqlParams, []interface{}{
"00000000-0000-0000-0000-000000000000",
})
}

View File

@ -103,6 +103,8 @@ func TestProcessSearchParameterValue(t *testing.T) {
{SearchParameter{Type: "token", Name: "identifier", Modifier: "otype"}, "http://terminology.hl7.org/CodeSystem/v2-0203|MR|446053", SearchParameterValue{Value: "MR|446053", Prefix: "", SecondaryValues: map[string]interface{}{"identifierSystem": "http://terminology.hl7.org/CodeSystem/v2-0203"}}, false}, {SearchParameter{Type: "token", Name: "identifier", Modifier: "otype"}, "http://terminology.hl7.org/CodeSystem/v2-0203|MR|446053", SearchParameterValue{Value: "MR|446053", Prefix: "", SecondaryValues: map[string]interface{}{"identifierSystem": "http://terminology.hl7.org/CodeSystem/v2-0203"}}, false},
{SearchParameter{Type: "token", Name: "code", Modifier: ""}, "|", SearchParameterValue{}, true}, //empty value should throw an error {SearchParameter{Type: "token", Name: "code", Modifier: ""}, "|", SearchParameterValue{}, true}, //empty value should throw an error
{SearchParameter{Type: "token", Name: "code", Modifier: ""}, "", SearchParameterValue{}, true}, //empty value should throw an error {SearchParameter{Type: "token", Name: "code", Modifier: ""}, "", SearchParameterValue{}, true}, //empty value should throw an error
{SearchParameter{Type: "token", Name: "code", Modifier: ""}, "http://acme.org/conditions/codes|", SearchParameterValue{Value: "", Prefix: "", SecondaryValues: map[string]interface{}{"codeSystem": "http://acme.org/conditions/codes"}}, false},
{SearchParameter{Type: "token", Name: "code", Modifier: ""}, "|807-1", SearchParameterValue{Value: "807-1", Prefix: "", SecondaryValues: map[string]interface{}{"codeSystem": ""}}, false},
{SearchParameter{Type: "quantity", Name: "valueQuantity", Modifier: ""}, "5.4|http://unitsofmeasure.org|mg", SearchParameterValue{Value: float64(5.4), Prefix: "", SecondaryValues: map[string]interface{}{"valueQuantitySystem": "http://unitsofmeasure.org", "valueQuantityCode": "mg"}}, false}, {SearchParameter{Type: "quantity", Name: "valueQuantity", Modifier: ""}, "5.4|http://unitsofmeasure.org|mg", SearchParameterValue{Value: float64(5.4), Prefix: "", SecondaryValues: map[string]interface{}{"valueQuantitySystem": "http://unitsofmeasure.org", "valueQuantityCode": "mg"}}, false},
{SearchParameter{Type: "quantity", Name: "valueQuantity", Modifier: ""}, "5.40e-3|http://unitsofmeasure.org|g", SearchParameterValue{Value: float64(0.0054), Prefix: "", SecondaryValues: map[string]interface{}{"valueQuantitySystem": "http://unitsofmeasure.org", "valueQuantityCode": "g"}}, false}, {SearchParameter{Type: "quantity", Name: "valueQuantity", Modifier: ""}, "5.40e-3|http://unitsofmeasure.org|g", SearchParameterValue{Value: float64(0.0054), Prefix: "", SecondaryValues: map[string]interface{}{"valueQuantitySystem": "http://unitsofmeasure.org", "valueQuantityCode": "g"}}, false},
@ -204,6 +206,55 @@ func TestSearchCodeToFromClause(t *testing.T) {
} }
//Aggregation tests
// mimic tests from https://hl7.org/fhir/r4/search.html#token
func TestProcessAggregationParameter(t *testing.T) {
//setup
t.Parallel()
var processSearchParameterTests = []struct {
aggregationFieldWithFn models.QueryResourceAggregation // input
searchParameterLookup map[string]string // input (allowed search parameters)
expected AggregationParameter
expectedError bool // expected result
}{
//primitive types
{models.QueryResourceAggregation{Field: "test"}, map[string]string{"test": "keyword"}, AggregationParameter{SearchParameter: SearchParameter{Type: "keyword", Name: "test", Modifier: ""}}, false},
{models.QueryResourceAggregation{Field: "test"}, map[string]string{"test": "number"}, AggregationParameter{SearchParameter: SearchParameter{Type: "number", Name: "test", Modifier: ""}}, false},
{models.QueryResourceAggregation{Field: "test"}, map[string]string{"test": "uri"}, AggregationParameter{SearchParameter: SearchParameter{Type: "uri", Name: "test", Modifier: ""}}, false},
{models.QueryResourceAggregation{Field: "test"}, map[string]string{"test": "date"}, AggregationParameter{SearchParameter: SearchParameter{Type: "date", Name: "test", Modifier: ""}}, false},
{models.QueryResourceAggregation{Field: "test:hello"}, map[string]string{"test": "keyword"}, AggregationParameter{SearchParameter: SearchParameter{Type: "date", Name: "test", Modifier: ""}}, true}, //cannot have a modifier
{models.QueryResourceAggregation{Field: "test:hello"}, map[string]string{"test": "number"}, AggregationParameter{SearchParameter: SearchParameter{Type: "date", Name: "test", Modifier: ""}}, true}, //cannot have a modifier
{models.QueryResourceAggregation{Field: "test:hello"}, map[string]string{"test": "uri"}, AggregationParameter{SearchParameter: SearchParameter{Type: "date", Name: "test", Modifier: ""}}, true}, //cannot have a modifier
{models.QueryResourceAggregation{Field: "test:hello"}, map[string]string{"test": "date"}, AggregationParameter{SearchParameter: SearchParameter{Type: "date", Name: "test", Modifier: ""}}, true}, //cannot have a modifier
//complex types
{models.QueryResourceAggregation{Field: "test"}, map[string]string{"test": "reference"}, AggregationParameter{SearchParameter: SearchParameter{Type: "reference", Name: "test", Modifier: ""}}, true}, //complex types should throw an error when missing modifier
{models.QueryResourceAggregation{Field: "test"}, map[string]string{"test": "string"}, AggregationParameter{SearchParameter: SearchParameter{Type: "string", Name: "test", Modifier: ""}}, true}, //complex types should throw an error when missing modifier
{models.QueryResourceAggregation{Field: "test"}, map[string]string{"test": "quantity"}, AggregationParameter{SearchParameter: SearchParameter{Type: "quantity", Name: "test", Modifier: ""}}, true}, //complex types should throw an error when missing modifier
{models.QueryResourceAggregation{Field: "test:hello"}, map[string]string{"test": "reference"}, AggregationParameter{SearchParameter: SearchParameter{Type: "reference", Name: "test", Modifier: "hello"}}, false},
{models.QueryResourceAggregation{Field: "test:hello"}, map[string]string{"test": "string"}, AggregationParameter{SearchParameter: SearchParameter{Type: "string", Name: "test", Modifier: "hello"}}, false},
{models.QueryResourceAggregation{Field: "test:hello"}, map[string]string{"test": "quantity"}, AggregationParameter{SearchParameter: SearchParameter{Type: "quantity", Name: "test", Modifier: "hello"}}, false},
//token type
{models.QueryResourceAggregation{Field: "code"}, map[string]string{"code": "token"}, AggregationParameter{SearchParameter: SearchParameter{Type: "token", Name: "code", Modifier: ""}}, false},
{models.QueryResourceAggregation{Field: "code:code"}, map[string]string{"code": "token"}, AggregationParameter{SearchParameter: SearchParameter{Type: "token", Name: "code", Modifier: "code"}}, false},
}
//test && assert
for ndx, tt := range processSearchParameterTests {
actual, actualErr := ProcessAggregationParameter(tt.aggregationFieldWithFn, tt.searchParameterLookup)
if tt.expectedError {
require.Error(t, actualErr, "Expected error but got none for processAggregationParameterTests[%d] %s", ndx, tt.aggregationFieldWithFn)
} else {
require.NoError(t, actualErr, "Expected no error but got one for processAggregationParameterTests[%d] %s", ndx, tt.aggregationFieldWithFn)
require.Equal(t, tt.expected, actual)
}
}
}
func (suite *RepositoryTestSuite) TestQueryResources_SQL() { func (suite *RepositoryTestSuite) TestQueryResources_SQL() {
//setup //setup
fakeConfig := mock_config.NewMockInterface(suite.MockCtrl) fakeConfig := mock_config.NewMockInterface(suite.MockCtrl)
@ -245,7 +296,7 @@ func (suite *RepositoryTestSuite) TestQueryResources_SQL() {
"SELECT fhir.*", "SELECT fhir.*",
"FROM fhir_observation as fhir, json_each(fhir.code) as codeJson", "FROM fhir_observation as fhir, json_each(fhir.code) as codeJson",
"WHERE ((codeJson.value ->> '$.code' = ?)) AND (user_id = ?) GROUP BY `fhir`.`id`", "WHERE ((codeJson.value ->> '$.code' = ?)) AND (user_id = ?) GROUP BY `fhir`.`id`",
"ORDER BY fhir.sort_date ASC"}, " ")) "ORDER BY fhir.sort_date DESC"}, " "))
require.Equal(suite.T(), sqlParams, []interface{}{ require.Equal(suite.T(), sqlParams, []interface{}{
"test_code", "00000000-0000-0000-0000-000000000000", "test_code", "00000000-0000-0000-0000-000000000000",
}) })

View File

@ -62,6 +62,7 @@ func (s *FhirAccount) GetSearchParameters() map[string]string {
"owner": "reference", "owner": "reference",
"period": "date", "period": "date",
"profile": "reference", "profile": "reference",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -85,6 +85,7 @@ func (s *FhirAdverseEvent) GetSearchParameters() map[string]string {
"resultingcondition": "reference", "resultingcondition": "reference",
"seriousness": "token", "seriousness": "token",
"severity": "token", "severity": "token",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -165,6 +165,7 @@ func (s *FhirAllergyIntolerance) GetSearchParameters() map[string]string {
"recorder": "reference", "recorder": "reference",
"route": "token", "route": "token",
"severity": "token", "severity": "token",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -101,6 +101,7 @@ func (s *FhirAppointment) GetSearchParameters() map[string]string {
"serviceCategory": "token", "serviceCategory": "token",
"serviceType": "token", "serviceType": "token",
"slot": "reference", "slot": "reference",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -40,6 +40,7 @@ func (s *FhirBinary) GetSearchParameters() map[string]string {
"language": "token", "language": "token",
"lastUpdated": "date", "lastUpdated": "date",
"profile": "reference", "profile": "reference",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -167,6 +167,7 @@ func (s *FhirCarePlan) GetSearchParameters() map[string]string {
"performer": "reference", "performer": "reference",
"profile": "reference", "profile": "reference",
"replaces": "reference", "replaces": "reference",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -119,6 +119,7 @@ func (s *FhirCareTeam) GetSearchParameters() map[string]string {
"lastUpdated": "date", "lastUpdated": "date",
"participant": "reference", "participant": "reference",
"profile": "reference", "profile": "reference",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -101,6 +101,7 @@ func (s *FhirClaim) GetSearchParameters() map[string]string {
"procedureUdi": "reference", "procedureUdi": "reference",
"profile": "reference", "profile": "reference",
"provider": "reference", "provider": "reference",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -78,6 +78,7 @@ func (s *FhirClaimResponse) GetSearchParameters() map[string]string {
"profile": "reference", "profile": "reference",
"request": "reference", "request": "reference",
"requestor": "reference", "requestor": "reference",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -169,6 +169,7 @@ func (s *FhirComposition) GetSearchParameters() map[string]string {
"relatedId": "token", "relatedId": "token",
"relatedRef": "reference", "relatedRef": "reference",
"section": "token", "section": "token",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -167,6 +167,7 @@ func (s *FhirCondition) GetSearchParameters() map[string]string {
"profile": "reference", "profile": "reference",
"recordedDate": "date", "recordedDate": "date",
"severity": "token", "severity": "token",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -147,6 +147,7 @@ func (s *FhirConsent) GetSearchParameters() map[string]string {
"purpose": "token", "purpose": "token",
"scope": "token", "scope": "token",
"securityLabel": "token", "securityLabel": "token",
"sort_date": "date",
"sourceReference": "reference", "sourceReference": "reference",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",

View File

@ -74,6 +74,7 @@ func (s *FhirCoverage) GetSearchParameters() map[string]string {
"payor": "reference", "payor": "reference",
"policyHolder": "reference", "policyHolder": "reference",
"profile": "reference", "profile": "reference",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -63,6 +63,7 @@ func (s *FhirCoverageEligibilityRequest) GetSearchParameters() map[string]string
"lastUpdated": "date", "lastUpdated": "date",
"profile": "reference", "profile": "reference",
"provider": "reference", "provider": "reference",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -71,6 +71,7 @@ func (s *FhirCoverageEligibilityResponse) GetSearchParameters() map[string]strin
"profile": "reference", "profile": "reference",
"request": "reference", "request": "reference",
"requestor": "reference", "requestor": "reference",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -76,6 +76,7 @@ func (s *FhirDevice) GetSearchParameters() map[string]string {
"model": "string", "model": "string",
"organization": "reference", "organization": "reference",
"profile": "reference", "profile": "reference",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -171,6 +171,7 @@ func (s *FhirDeviceRequest) GetSearchParameters() map[string]string {
"priorRequest": "reference", "priorRequest": "reference",
"profile": "reference", "profile": "reference",
"requester": "reference", "requester": "reference",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -182,6 +182,7 @@ func (s *FhirDiagnosticReport) GetSearchParameters() map[string]string {
"profile": "reference", "profile": "reference",
"result": "reference", "result": "reference",
"resultsInterpreter": "reference", "resultsInterpreter": "reference",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -114,6 +114,7 @@ func (s *FhirDocumentManifest) GetSearchParameters() map[string]string {
"recipient": "reference", "recipient": "reference",
"relatedId": "token", "relatedId": "token",
"relatedRef": "reference", "relatedRef": "reference",
"sort_date": "date",
"source": "uri", "source": "uri",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",

View File

@ -170,6 +170,7 @@ func (s *FhirDocumentReference) GetSearchParameters() map[string]string {
"relation": "token", "relation": "token",
"securityLabel": "token", "securityLabel": "token",
"setting": "token", "setting": "token",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -174,6 +174,7 @@ func (s *FhirEncounter) GetSearchParameters() map[string]string {
"reasonCode": "token", "reasonCode": "token",
"reasonReference": "reference", "reasonReference": "reference",
"serviceProvider": "reference", "serviceProvider": "reference",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -63,6 +63,7 @@ func (s *FhirEndpoint) GetSearchParameters() map[string]string {
"organization": "reference", "organization": "reference",
"payloadType": "token", "payloadType": "token",
"profile": "reference", "profile": "reference",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -50,6 +50,7 @@ func (s *FhirEnrollmentRequest) GetSearchParameters() map[string]string {
"language": "token", "language": "token",
"lastUpdated": "date", "lastUpdated": "date",
"profile": "reference", "profile": "reference",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -51,6 +51,7 @@ func (s *FhirEnrollmentResponse) GetSearchParameters() map[string]string {
"lastUpdated": "date", "lastUpdated": "date",
"profile": "reference", "profile": "reference",
"request": "reference", "request": "reference",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -102,6 +102,7 @@ func (s *FhirExplanationOfBenefit) GetSearchParameters() map[string]string {
"procedureUdi": "reference", "procedureUdi": "reference",
"profile": "reference", "profile": "reference",
"provider": "reference", "provider": "reference",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -141,6 +141,7 @@ func (s *FhirFamilyMemberHistory) GetSearchParameters() map[string]string {
"profile": "reference", "profile": "reference",
"relationship": "token", "relationship": "token",
"sex": "token", "sex": "token",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -98,6 +98,7 @@ func (s *FhirGoal) GetSearchParameters() map[string]string {
"lastUpdated": "date", "lastUpdated": "date",
"lifecycleStatus": "token", "lifecycleStatus": "token",
"profile": "reference", "profile": "reference",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -134,6 +134,7 @@ func (s *FhirImagingStudy) GetSearchParameters() map[string]string {
"reason": "token", "reason": "token",
"referrer": "reference", "referrer": "reference",
"series": "token", "series": "token",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -149,6 +149,7 @@ func (s *FhirImmunization) GetSearchParameters() map[string]string {
"reasonCode": "token", "reasonCode": "token",
"reasonReference": "reference", "reasonReference": "reference",
"series": "string", "series": "string",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -91,6 +91,7 @@ func (s *FhirInsurancePlan) GetSearchParameters() map[string]string {
"ownedBy": "reference", "ownedBy": "reference",
"phonetic": "string", "phonetic": "string",
"profile": "reference", "profile": "reference",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -91,6 +91,7 @@ func (s *FhirLocation) GetSearchParameters() map[string]string {
"organization": "reference", "organization": "reference",
"partof": "reference", "partof": "reference",
"profile": "reference", "profile": "reference",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -81,6 +81,7 @@ func (s *FhirMedia) GetSearchParameters() map[string]string {
"operator": "reference", "operator": "reference",
"profile": "reference", "profile": "reference",
"site": "token", "site": "token",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -92,6 +92,7 @@ func (s *FhirMedication) GetSearchParameters() map[string]string {
"lotNumber": "token", "lotNumber": "token",
"manufacturer": "reference", "manufacturer": "reference",
"profile": "reference", "profile": "reference",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -150,6 +150,7 @@ func (s *FhirMedicationAdministration) GetSearchParameters() map[string]string {
"reasonGiven": "token", "reasonGiven": "token",
"reasonNotGiven": "token", "reasonNotGiven": "token",
"request": "reference", "request": "reference",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -156,6 +156,7 @@ func (s *FhirMedicationDispense) GetSearchParameters() map[string]string {
"profile": "reference", "profile": "reference",
"receiver": "reference", "receiver": "reference",
"responsibleparty": "reference", "responsibleparty": "reference",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -170,6 +170,7 @@ func (s *FhirMedicationRequest) GetSearchParameters() map[string]string {
"priority": "token", "priority": "token",
"profile": "reference", "profile": "reference",
"requester": "reference", "requester": "reference",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -141,6 +141,7 @@ func (s *FhirMedicationStatement) GetSearchParameters() map[string]string {
"medication": "reference", "medication": "reference",
"partOf": "reference", "partOf": "reference",
"profile": "reference", "profile": "reference",
"sort_date": "date",
"source": "reference", "source": "reference",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",

View File

@ -130,6 +130,7 @@ func (s *FhirNutritionOrder) GetSearchParameters() map[string]string {
"oraldiet": "token", "oraldiet": "token",
"profile": "reference", "profile": "reference",
"provider": "reference", "provider": "reference",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -234,6 +234,7 @@ func (s *FhirObservation) GetSearchParameters() map[string]string {
"partOf": "reference", "partOf": "reference",
"performer": "reference", "performer": "reference",
"profile": "reference", "profile": "reference",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -88,6 +88,7 @@ func (s *FhirOrganization) GetSearchParameters() map[string]string {
"partof": "reference", "partof": "reference",
"phonetic": "string", "phonetic": "string",
"profile": "reference", "profile": "reference",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -94,6 +94,7 @@ func (s *FhirOrganizationAffiliation) GetSearchParameters() map[string]string {
"profile": "reference", "profile": "reference",
"role": "token", "role": "token",
"service": "reference", "service": "reference",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -223,6 +223,7 @@ func (s *FhirPatient) GetSearchParameters() map[string]string {
"phone": "token", "phone": "token",
"phonetic": "string", "phonetic": "string",
"profile": "reference", "profile": "reference",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -197,6 +197,7 @@ func (s *FhirPerson) GetSearchParameters() map[string]string {
"practitioner": "reference", "practitioner": "reference",
"profile": "reference", "profile": "reference",
"relatedperson": "reference", "relatedperson": "reference",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -197,6 +197,7 @@ func (s *FhirPractitioner) GetSearchParameters() map[string]string {
"phone": "token", "phone": "token",
"phonetic": "string", "phonetic": "string",
"profile": "reference", "profile": "reference",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -114,6 +114,7 @@ func (s *FhirPractitionerRole) GetSearchParameters() map[string]string {
"profile": "reference", "profile": "reference",
"role": "token", "role": "token",
"service": "reference", "service": "reference",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -183,6 +183,7 @@ func (s *FhirProcedure) GetSearchParameters() map[string]string {
"profile": "reference", "profile": "reference",
"reasonCode": "token", "reasonCode": "token",
"reasonReference": "reference", "reasonReference": "reference",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -74,6 +74,7 @@ func (s *FhirProvenance) GetSearchParameters() map[string]string {
"profile": "reference", "profile": "reference",
"recorded": "date", "recorded": "date",
"signatureType": "token", "signatureType": "token",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -103,6 +103,7 @@ func (s *FhirQuestionnaire) GetSearchParameters() map[string]string {
"name": "string", "name": "string",
"profile": "reference", "profile": "reference",
"publisher": "string", "publisher": "string",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -77,6 +77,7 @@ func (s *FhirQuestionnaireResponse) GetSearchParameters() map[string]string {
"partOf": "reference", "partOf": "reference",
"profile": "reference", "profile": "reference",
"questionnaire": "reference", "questionnaire": "reference",
"sort_date": "date",
"source": "reference", "source": "reference",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",

View File

@ -189,6 +189,7 @@ func (s *FhirRelatedPerson) GetSearchParameters() map[string]string {
"phonetic": "string", "phonetic": "string",
"profile": "reference", "profile": "reference",
"relationship": "token", "relationship": "token",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -67,6 +67,7 @@ func (s *FhirSchedule) GetSearchParameters() map[string]string {
"profile": "reference", "profile": "reference",
"serviceCategory": "token", "serviceCategory": "token",
"serviceType": "token", "serviceType": "token",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -182,6 +182,7 @@ func (s *FhirServiceRequest) GetSearchParameters() map[string]string {
"replaces": "reference", "replaces": "reference",
"requester": "reference", "requester": "reference",
"requisition": "token", "requisition": "token",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -69,6 +69,7 @@ func (s *FhirSlot) GetSearchParameters() map[string]string {
"schedule": "reference", "schedule": "reference",
"serviceCategory": "token", "serviceCategory": "token",
"serviceType": "token", "serviceType": "token",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -78,6 +78,7 @@ func (s *FhirSpecimen) GetSearchParameters() map[string]string {
"lastUpdated": "date", "lastUpdated": "date",
"parent": "reference", "parent": "reference",
"profile": "reference", "profile": "reference",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -107,6 +107,7 @@ func (s *FhirVisionPrescription) GetSearchParameters() map[string]string {
"lastUpdated": "date", "lastUpdated": "date",
"prescriber": "reference", "prescriber": "reference",
"profile": "reference", "profile": "reference",
"sort_date": "date",
"source_id": "keyword", "source_id": "keyword",
"source_resource_id": "keyword", "source_resource_id": "keyword",
"source_resource_type": "keyword", "source_resource_type": "keyword",

View File

@ -209,6 +209,7 @@ func main() {
d[jen.Lit("source_uri")] = jen.Lit("keyword") d[jen.Lit("source_uri")] = jen.Lit("keyword")
d[jen.Lit("source_resource_id")] = jen.Lit("keyword") d[jen.Lit("source_resource_id")] = jen.Lit("keyword")
d[jen.Lit("source_resource_type")] = jen.Lit("keyword") d[jen.Lit("source_resource_type")] = jen.Lit("keyword")
d[jen.Lit("sort_date")] = jen.Lit("date")
})) }))
g.Return(jen.Id("searchParameters")) g.Return(jen.Id("searchParameters"))
@ -563,8 +564,8 @@ func main() {
} }
//TODO: should we do this, or allow all resources instead of just USCore? // TODO: should we do this, or allow all resources instead of just USCore?
//The dataabase would be full of empty data, but we'd be more flexible & future-proof.. supporting other countries, etc. // The dataabase would be full of empty data, but we'd be more flexible & future-proof.. supporting other countries, etc.
var AllowedResources = []string{ var AllowedResources = []string{
"Account", "Account",
"AdverseEvent", "AdverseEvent",
@ -623,7 +624,7 @@ var AllowedResources = []string{
"VisionPrescription", "VisionPrescription",
} }
//simple field types are not json encoded in the DB and are always single values (not arrays) // simple field types are not json encoded in the DB and are always single values (not arrays)
func isSimpleFieldType(fieldType string) bool { func isSimpleFieldType(fieldType string) bool {
switch fieldType { switch fieldType {
case "number", "uri", "date": case "number", "uri", "date":
@ -636,8 +637,8 @@ func isSimpleFieldType(fieldType string) bool {
return true return true
} }
//https://hl7.org/fhir/search.html#token // https://hl7.org/fhir/search.html#token
//https://hl7.org/fhir/r4/valueset-search-param-type.html // https://hl7.org/fhir/r4/valueset-search-param-type.html
func mapFieldType(fieldType string) string { func mapFieldType(fieldType string) string {
switch fieldType { switch fieldType {
case "number": case "number":
@ -661,7 +662,7 @@ func mapFieldType(fieldType string) string {
} }
} }
//https://www.sqlite.org/datatype3.html // https://www.sqlite.org/datatype3.html
func mapGormType(fieldType string) string { func mapGormType(fieldType string) string {
// gorm:"type:text;serializer:json" // gorm:"type:text;serializer:json"

View File

@ -11,16 +11,23 @@ type QueryResource struct {
Select []string `json:"select"` Select []string `json:"select"`
From string `json:"from"` From string `json:"from"`
Where map[string]interface{} `json:"where"` Where map[string]interface{} `json:"where"`
Limit *int `json:"limit,omitempty"`
Offset *int `json:"offset,omitempty"`
//aggregation fields //aggregation fields
Aggregations *QueryResourceAggregations `json:"aggregations"` Aggregations *QueryResourceAggregations `json:"aggregations"`
} }
type QueryResourceAggregations struct { type QueryResourceAggregations struct {
CountBy string `json:"count_by"` //alias for both groupby and orderby, cannot be used together CountBy *QueryResourceAggregation `json:"count_by,omitempty"` //alias for both groupby and orderby, cannot be used together
GroupBy string `json:"group_by"` GroupBy *QueryResourceAggregation `json:"group_by,omitempty"`
OrderBy string `json:"order_by"` OrderBy *QueryResourceAggregation `json:"order_by,omitempty"`
}
type QueryResourceAggregation struct {
Field string `json:"field"`
Function string `json:"fn"`
} }
func (q *QueryResource) Validate() error { func (q *QueryResource) Validate() error {
@ -36,28 +43,52 @@ func (q *QueryResource) Validate() error {
if len(q.Select) > 0 { if len(q.Select) > 0 {
return fmt.Errorf("cannot use 'select' and 'aggregations' together") return fmt.Errorf("cannot use 'select' and 'aggregations' together")
} }
if len(q.Aggregations.CountBy) > 0 {
if len(q.Aggregations.GroupBy) > 0 { if q.Aggregations.CountBy != nil {
if len(q.Aggregations.CountBy.Field) == 0 {
return fmt.Errorf("if 'count_by' is present, field must be populated")
}
if strings.Contains(q.Aggregations.CountBy.Field, " ") {
return fmt.Errorf("count_by cannot have spaces (or aliases)")
}
}
if q.Aggregations.GroupBy != nil {
if len(q.Aggregations.GroupBy.Field) == 0 {
return fmt.Errorf("if 'group_by' is present, field must be populated")
}
if strings.Contains(q.Aggregations.GroupBy.Field, " ") {
return fmt.Errorf("group_by cannot have spaces (or aliases)")
}
}
if q.Aggregations.OrderBy != nil {
if len(q.Aggregations.OrderBy.Field) == 0 {
return fmt.Errorf("if 'order_by' is present, field must be populated")
}
if strings.Contains(q.Aggregations.OrderBy.Field, " ") {
return fmt.Errorf("order_by cannot have spaces (or aliases)")
}
}
if q.Aggregations.CountBy != nil {
if q.Aggregations.GroupBy != nil {
return fmt.Errorf("cannot use 'count_by' and 'group_by' together") return fmt.Errorf("cannot use 'count_by' and 'group_by' together")
} }
if len(q.Aggregations.OrderBy) > 0 { if q.Aggregations.OrderBy != nil {
return fmt.Errorf("cannot use 'count_by' and 'order_by' together") return fmt.Errorf("cannot use 'count_by' and 'order_by' together")
} }
} }
if len(q.Aggregations.CountBy) == 0 && len(q.Aggregations.OrderBy) == 0 && len(q.Aggregations.GroupBy) == 0 { if q.Aggregations.CountBy == nil && q.Aggregations.OrderBy == nil && q.Aggregations.GroupBy == nil {
return fmt.Errorf("aggregations must have at least one of 'count_by', 'group_by', or 'order_by'") return fmt.Errorf("aggregations must have at least one of 'count_by', 'group_by', or 'order_by'")
} }
if strings.Contains(q.Aggregations.CountBy, " ") {
return fmt.Errorf("count_by cannot have spaces (or aliases)")
}
if strings.Contains(q.Aggregations.GroupBy, " ") {
return fmt.Errorf("group_by cannot have spaces (or aliases)")
}
if strings.Contains(q.Aggregations.OrderBy, " ") {
return fmt.Errorf("order_by cannot have spaces (or aliases)")
}
} }
if q.Limit != nil && *q.Limit < 0 {
return fmt.Errorf("'limit' must be greater than or equal to zero")
}
if q.Offset != nil && *q.Offset < 0 {
return fmt.Errorf("'offset' must be greater than or equal to zero")
}
return nil return nil
} }

View File

@ -13,15 +13,18 @@ func TestQueryResource_Validate(t *testing.T) {
}{ }{
{QueryResource{Use: "test"}, "'use' is not supported yet", true}, {QueryResource{Use: "test"}, "'use' is not supported yet", true},
{QueryResource{}, "'from' is required", true}, {QueryResource{}, "'from' is required", true},
{QueryResource{From: "test", Aggregations: &QueryResourceAggregations{CountBy: "test"}}, "", false}, {QueryResource{From: "test", Aggregations: &QueryResourceAggregations{CountBy: &QueryResourceAggregation{Field: ""}}}, "if 'count_by' is present, field must be populated", true},
{QueryResource{From: "test", Aggregations: &QueryResourceAggregations{GroupBy: &QueryResourceAggregation{Field: ""}}}, "if 'group_by' is present, field must be populated", true},
{QueryResource{From: "test", Aggregations: &QueryResourceAggregations{OrderBy: &QueryResourceAggregation{Field: ""}}}, "if 'order_by' is present, field must be populated", true},
{QueryResource{From: "test", Aggregations: &QueryResourceAggregations{CountBy: &QueryResourceAggregation{Field: "test"}}}, "", false},
{QueryResource{Select: []string{"test"}, From: "test", Aggregations: &QueryResourceAggregations{}}, "cannot use 'select' and 'aggregations' together", true}, {QueryResource{Select: []string{"test"}, From: "test", Aggregations: &QueryResourceAggregations{}}, "cannot use 'select' and 'aggregations' together", true},
{QueryResource{From: "test", Aggregations: &QueryResourceAggregations{CountBy: "test", GroupBy: "test"}}, "cannot use 'count_by' and 'group_by' together", true}, {QueryResource{From: "test", Aggregations: &QueryResourceAggregations{CountBy: &QueryResourceAggregation{Field: "test"}, GroupBy: &QueryResourceAggregation{Field: "test"}}}, "cannot use 'count_by' and 'group_by' together", true},
{QueryResource{From: "test", Aggregations: &QueryResourceAggregations{CountBy: "test", OrderBy: "test"}}, "cannot use 'count_by' and 'order_by' together", true}, {QueryResource{From: "test", Aggregations: &QueryResourceAggregations{CountBy: &QueryResourceAggregation{Field: "test"}, OrderBy: &QueryResourceAggregation{Field: "test"}}}, "cannot use 'count_by' and 'order_by' together", true},
{QueryResource{From: "test", Aggregations: &QueryResourceAggregations{}}, "aggregations must have at least one of 'count_by', 'group_by', or 'order_by'", true}, {QueryResource{From: "test", Aggregations: &QueryResourceAggregations{}}, "aggregations must have at least one of 'count_by', 'group_by', or 'order_by'", true},
{QueryResource{From: "test", Aggregations: &QueryResourceAggregations{CountBy: "test:property"}}, "", false}, {QueryResource{From: "test", Aggregations: &QueryResourceAggregations{CountBy: &QueryResourceAggregation{Field: "test:property"}}}, "", false},
{QueryResource{From: "test", Aggregations: &QueryResourceAggregations{CountBy: "test:property as HELLO"}}, "count_by cannot have spaces (or aliases)", true}, {QueryResource{From: "test", Aggregations: &QueryResourceAggregations{CountBy: &QueryResourceAggregation{Field: "test:property as HELLO"}}}, "count_by cannot have spaces (or aliases)", true},
{QueryResource{From: "test", Aggregations: &QueryResourceAggregations{GroupBy: "test:property as HELLO"}}, "group_by cannot have spaces (or aliases)", true}, {QueryResource{From: "test", Aggregations: &QueryResourceAggregations{GroupBy: &QueryResourceAggregation{Field: "test:property as HELLO"}}}, "group_by cannot have spaces (or aliases)", true},
{QueryResource{From: "test", Aggregations: &QueryResourceAggregations{OrderBy: "test:property as HELLO"}}, "order_by cannot have spaces (or aliases)", true}, {QueryResource{From: "test", Aggregations: &QueryResourceAggregations{OrderBy: &QueryResourceAggregation{Field: "test:property as HELLO"}}}, "order_by cannot have spaces (or aliases)", true},
} }
//test && assert //test && assert

View File

@ -132,7 +132,7 @@
"from": "Observation", "from": "Observation",
"where": {}, "where": {},
"aggregations":{ "aggregations":{
"count_by": "code:code" "count_by": {"field": "code:code" }
} }
} }
}], }],
@ -158,7 +158,7 @@
"from": "Immunization", "from": "Immunization",
"where": {}, "where": {},
"aggregations":{ "aggregations":{
"count_by": "*" "count_by": {"field": "*" }
} }
} }
}, },
@ -171,7 +171,7 @@
"from": "Claim", "from": "Claim",
"where": {}, "where": {},
"aggregations":{ "aggregations":{
"count_by": "*" "count_by": {"field": "*" }
} }
} }
}], }],

View File

@ -42,6 +42,7 @@ const routes: Routes = [
{ path: 'patient-profile', component: PatientProfileComponent, canActivate: [ IsAuthenticatedAuthGuard] }, { path: 'patient-profile', component: PatientProfileComponent, canActivate: [ IsAuthenticatedAuthGuard] },
{ path: 'medical-history', component: MedicalHistoryComponent, canActivate: [ IsAuthenticatedAuthGuard] }, { path: 'medical-history', component: MedicalHistoryComponent, canActivate: [ IsAuthenticatedAuthGuard] },
{ path: 'labs', component: ReportLabsComponent, canActivate: [ IsAuthenticatedAuthGuard] }, { path: 'labs', component: ReportLabsComponent, canActivate: [ IsAuthenticatedAuthGuard] },
{ path: 'labs/report/:source_id/:resource_type/:resource_id', component: ReportLabsComponent, canActivate: [ IsAuthenticatedAuthGuard] },
// { path: 'general-pages', loadChildren: () => import('./general-pages/general-pages.module').then(m => m.GeneralPagesModule) }, // { path: 'general-pages', loadChildren: () => import('./general-pages/general-pages.module').then(m => m.GeneralPagesModule) },
// { path: 'ui-elements', loadChildren: () => import('./ui-elements/ui-elements.module').then(m => m.UiElementsModule) }, // { path: 'ui-elements', loadChildren: () => import('./ui-elements/ui-elements.module').then(m => m.UiElementsModule) },

View File

@ -3,15 +3,20 @@ export class DashboardWidgetQuery {
select: string[] select: string[]
from: string from: string
where: {[key: string]: string | string[]} where: {[key: string]: string | string[]}
// limit: number limit?: number
// offset: number offset?: number
//https://lodash.com/docs/4.17.15#unionBy //https://lodash.com/docs/4.17.15#unionBy
aggregations?: { aggregations?: {
count_by?: string, //alias for groupBy and orderBy count_by?: DashboardWidgetQueryAggregation, //alias for groupBy and orderBy
group_by?: string, group_by?: DashboardWidgetQueryAggregation,
order_by?: string, order_by?: DashboardWidgetQueryAggregation,
} }
// aggregation_params?: string[] // aggregation_params?: string[]
// aggregation_type?: 'countBy' | 'groupBy' | 'orderBy' // | 'minBy' | 'maxBy' | 'sumBy' // 'orderBy' | 'sortBy' | // aggregation_type?: 'countBy' | 'groupBy' | 'orderBy' // | 'minBy' | 'maxBy' | 'sumBy' // 'orderBy' | 'sortBy' |
} }
export class DashboardWidgetQueryAggregation {
field: string
fn?: string
}

View File

@ -8,11 +8,54 @@
<ng-container [ngTemplateOutlet]="loading ? isLoadingTemplate : isEmptyReport ? emptyReport : report"></ng-container> <ng-container [ngTemplateOutlet]="loading ? isLoadingTemplate : isEmptyReport ? emptyReport : report"></ng-container>
<ng-template #report> <ng-template #report>
<!-- Report Details -->
<div class="row" *ngIf="reportDisplayModel">
<div class="col-12 mt-3 mb-3">
<h1 class="az-dashboard-title">Report Info</h1>
</div>
<div class="col-12">
<fhir-resource [displayModel]="reportDisplayModel" [showDetails]="true"></fhir-resource>
</div>
</div>
<!-- Observations Title --> <!-- Observations Title -->
<div class="row mt-5 mb-3"> <div class="row mt-5 mb-3">
<div class="col-6"> <div class="col-6">
<h1 class="az-dashboard-title">Observations</h1> <h1 class="az-dashboard-title">Observations</h1>
</div> </div>
<div class="col-6">
<div ngbDropdown class="d-inline-block float-right dropdown ml-3">
<button type="button" class="btn btn-outline-indigo" id="dropdownReports" ngbDropdownToggle>
Included Reports
</button>
<div ngbDropdownMenu aria-labelledby="dropdownReports">
<button ngbDropdownItem
[class.active]="!reportSourceId && !reportResourceType && !reportResourceId"
[routerLink]="'/labs'"
>All</button>
<button ngbDropdownItem [disabled]="true">-----</button>
<button
[class.active]="reportSourceId == diagnosticReport?.source_id && reportResourceType == diagnosticReport?.source_resource_type && reportResourceId == diagnosticReport?.source_resource_id"
*ngFor="let diagnosticReport of diagnosticReports" ngbDropdownItem
[routerLink]="'/labs/report/'+ diagnosticReport?.source_id + '/' + diagnosticReport?.source_resource_type + '/' + diagnosticReport?.source_resource_id"
>{{diagnosticReport?.sort_title}} [{{diagnosticReport?.sort_date | amDateFormat: 'LL'}}]</button>
</div>
</div>
<div ngbDropdown class="d-inline-block float-right">
<button ngbTooltip="not yet implemented" type="button" class="btn btn-outline-indigo" id="dropdownSort" ngbDropdownToggle>
Sort By
</button>
<div ngbDropdownMenu aria-labelledby="dropdownSort">
<button ngbDropdownItem class="active">Date</button>
<button ngbDropdownItem>Name</button>
<button ngbDropdownItem>Status</button>
</div>
</div>
</div>
</div> </div>
<!-- Observations List --> <!-- Observations List -->
@ -21,6 +64,18 @@
[observationCode]="observationGroup.key" [observationCode]="observationGroup.key"
[observationTitle]="observationGroupTitles[observationGroup.key]" [observationTitle]="observationGroupTitles[observationGroup.key]"
></app-report-labs-observation> ></app-report-labs-observation>
<!-- Pagination -->
<ngb-pagination
class="mr-auto"
[collectionSize]="allObservationGroups.length"
[(page)]="currentPage"
[pageSize]="pageSize"
(pageChange)="populateObservationsForCurrentPage()"
>
</ngb-pagination>
</ng-template> </ng-template>

View File

@ -3,6 +3,24 @@ import {FastenApiService} from '../../services/fasten-api.service';
import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
import {ResourceFhir} from '../../models/fasten/resource_fhir'; import {ResourceFhir} from '../../models/fasten/resource_fhir';
import * as fhirpath from 'fhirpath'; import * as fhirpath from 'fhirpath';
import {forkJoin, Observable} from 'rxjs';
import {flatMap, map, mergeMap} from 'rxjs/operators';
import {ResponseWrapper} from '../../models/response-wrapper';
import {ActivatedRoute, Params} from '@angular/router';
import {FastenDisplayModel} from '../../../lib/models/fasten/fasten-display-model';
import {fhirModelFactory} from '../../../lib/models/factory';
import {ResourceType} from '../../../lib/models/constants';
class ObservationGroup {[key: string]: ResourceFhir[]}
class ObservationGroupInfo {
observationGroups: ObservationGroup = {}
observationGroupTitles: {[key: string]: string} = {}
}
class LabResultCodeByDate {
label: string //lab result coding (system|code)
value: string //lab result date
}
@Component({ @Component({
selector: 'app-report-labs', selector: 'app-report-labs',
@ -12,46 +30,201 @@ import * as fhirpath from 'fhirpath';
export class ReportLabsComponent implements OnInit { export class ReportLabsComponent implements OnInit {
loading: boolean = false loading: boolean = false
observationGroups: {[key: string]: ResourceFhir[]} = {} currentPage: number = 1 //1-based index due to the way the pagination component works
pageSize: number = 10
allObservationGroups: string[] = []
//diagnostic report data
reportSourceId: string = ''
reportResourceType: string = ''
reportResourceId: string = ''
reportDisplayModel: FastenDisplayModel = null
//currentPage data
observationGroups: ObservationGroup = {}
observationGroupTitles: {[key: string]: string} = {} observationGroupTitles: {[key: string]: string} = {}
isEmptyReport = false isEmptyReport = false
diagnosticReports: ResourceFhir[] = []
constructor( constructor(
private fastenApi: FastenApiService, private fastenApi: FastenApiService,
private activatedRoute: ActivatedRoute,
) { } ) { }
ngOnInit(): void { ngOnInit(): void {
this.loading = true this.loading = true
this.fastenApi.getResources("Observation").subscribe(results => {
this.loading = false
results = results || []
console.log("ALL OBSERVATIONS", results)
//loop though all observations, group by "code.system": "http://loinc.org" this.populateReports()
for(let observation of results){
let observationGroup = fhirpath.evaluate(observation.resource_raw, "Observation.code.coding.where(system='http://loinc.org').first().code")[0]
this.observationGroups[observationGroup] = this.observationGroups[observationGroup] ? this.observationGroups[observationGroup] : []
this.observationGroups[observationGroup].push(observation)
if(!this.observationGroupTitles[observationGroup]){
this.observationGroupTitles[observationGroup] = fhirpath.evaluate(observation.resource_raw, "Observation.code.coding.where(system='http://loinc.org').first().display")[0] //determine if we're requesting all results or just a single report
} //source_id/:resource_type/:resource_id
this.activatedRoute.params.subscribe((routeParams: Params) => {
this.reportSourceId = routeParams['source_id']
this.reportResourceType = routeParams['resource_type']
this.reportResourceId = routeParams['resource_id']
console.log("Selected Report changed!", this.reportSourceId,this.reportResourceType, this.reportResourceId)
if(this.reportSourceId && this.reportResourceType && this.reportResourceId){
//we're requesting a single report
console.log("REQUSTING REPORT", this.reportSourceId, this.reportResourceType, this.reportResourceId)
this.findLabResultCodesFilteredToReport(this.reportSourceId, this.reportResourceType, this.reportResourceId).subscribe((data) => {
console.log("REPORT result codes", data)
this.allObservationGroups = data
this.currentPage = 1 //reset to first page when changing report
return this.populateObservationsForCurrentPage()
})
} else {
this.findLabResultCodesSortedByLatest().subscribe((data) => {
// this.loading = false
console.log("ALL lab result codes", data)
this.allObservationGroups = data.map((item) => item.label)
return this.populateObservationsForCurrentPage()
})
} }
});
this.isEmptyReport = !!!results.length }
//using the current list of allObservationGroups, retrieve a list of observations, group them by observationGroup, and set the observationGroupTitles
populateObservationsForCurrentPage(){
let observationGroups = this.allObservationGroups.slice((this.currentPage-1) * this.pageSize, this.currentPage * this.pageSize)
console.log("FILTERED OBSERVATION GROUPS", observationGroups, (this.currentPage -1) * this.pageSize, this.currentPage * this.pageSize)
this.loading = true
this.getObservationsByCodes(observationGroups).subscribe((data) => {
this.loading = false
this.observationGroups = data.observationGroups
this.observationGroupTitles = data.observationGroupTitles
this.isEmptyReport = !!!Object.keys(this.observationGroups).length
}, error => { }, error => {
this.loading = false this.loading = false
this.isEmptyReport = true this.isEmptyReport = true
}) })
}
//get a list of all lab codes associated with a diagnostic report
findLabResultCodesFilteredToReport(sourceId, resourceType, resourceId): Observable<any[]> {
return this.fastenApi.getResources(resourceType, sourceId, resourceId)
.pipe(
mergeMap((diagnosticReports) => {
let diagnosticReport = diagnosticReports?.[0]
console.log("diagnosticReport", diagnosticReport)
this.reportDisplayModel = fhirModelFactory(diagnosticReport.source_resource_type as ResourceType, diagnosticReport)
//get a list of all the observations associated with this report
let observationIds = fhirpath.evaluate(diagnosticReport.resource_raw, "DiagnosticReport.result.reference")
//request each observation, and find the lab codes associated with each
let requests = []
for(let observationId of observationIds){
let observationIdParts = observationId.split("/")
requests.push(this.fastenApi.getResources(observationIdParts[0], diagnosticReport.source_id, observationIdParts[1]))
}
return forkJoin(requests)
}),
map((results:ResourceFhir[][]) => {
let allObservationGroups = []
//for each result, loop through the observations and find the loinc code
for(let result of results){
for(let observation of result){
let observationGroup = fhirpath.evaluate(observation.resource_raw, "Observation.code.coding.where(system='http://loinc.org').first().code")[0]
allObservationGroups.push('http://loinc.org|' + observationGroup)
}
}
console.log("FOUND REPORT LAB CODES", allObservationGroups)
return allObservationGroups
})
)
}
//get a list of all unique lab codes ordered by latest date
findLabResultCodesSortedByLatest(): Observable<LabResultCodeByDate[]> {
return this.fastenApi.queryResources({
select: [],
from: "Observation",
where: {
"code": "http://loinc.org|,urn:oid:2.16.840.1.113883.6.1|",
},
aggregations: {
order_by: {
field: "sort_date",
fn: "max"
},
group_by: {
field: "code",
}
}
})
.pipe(
map((response: ResponseWrapper) => {
return response.data as LabResultCodeByDate[]
}),
)
} }
//get a list of the last 10 lab results
populateReports(){
return this.fastenApi.queryResources({
select: ["*"],
from: "DiagnosticReport",
where: {
"category": "http://terminology.hl7.org/CodeSystem/v2-0074|LAB",
},
limit: 10,
}).subscribe(results => {
this.diagnosticReports = results.data
})
}
isEmpty(obj: any) { isEmpty(obj: any) {
return Object.keys(obj).length === 0; return Object.keys(obj).length === 0;
} }
//private methods
//get a list of observations that have a matching code
private getObservationsByCodes(codes: string[]): Observable<ObservationGroupInfo>{
return this.fastenApi.queryResources({
select: [],
from: "Observation",
where: {
"code": codes.join(","),
}
}).pipe(
map((response: ResponseWrapper) => {
let observationGroups: ObservationGroup = {}
let observationGroupTitles: {[key: string]: string} = {}
//loop though all observations, group by "code.system": "http://loinc.org"
for(let observation of response.data){
let observationGroup = fhirpath.evaluate(observation.resource_raw, "Observation.code.coding.where(system='http://loinc.org').first().code")[0]
observationGroups[observationGroup] = observationGroups[observationGroup] ? observationGroups[observationGroup] : []
observationGroups[observationGroup].push(observation)
if(!observationGroupTitles[observationGroup]){
observationGroupTitles[observationGroup] = fhirpath.evaluate(observation.resource_raw, "Observation.code.coding.where(system='http://loinc.org').first().display")[0]
}
}
return {
observationGroups: observationGroups,
observationGroupTitles: observationGroupTitles
}
})
);
}
} }

View File

@ -124,7 +124,7 @@ describe('DashboardWidgetComponent', () => {
"where": {}, "where": {},
"aggregations":{ "aggregations":{
"count_by": "source_resource_type" "count_by": {"field": "source_resource_type"}
}, },
} }
}, },
@ -138,7 +138,7 @@ describe('DashboardWidgetComponent', () => {
"where": {}, "where": {},
"aggregations":{ "aggregations":{
"count_by": "source_resource_type" "count_by": {"field": "source_resource_type"}
}, },
} }
}], }],