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/**/dictionaries
.idea/**/shelf
.idea/dataSources.xml
# Sensitive or high-churn files
.idea/**/dataSources/

View File

@ -18,18 +18,19 @@ import (
type SearchParameterType string
const (
//simple types
SearchParameterTypeNumber SearchParameterType = "number"
SearchParameterTypeDate SearchParameterType = "date"
SearchParameterTypeUri SearchParameterType = "uri"
SearchParameterTypeKeyword SearchParameterType = "keyword" //this is a literal/string primitive.
//complex types
SearchParameterTypeString SearchParameterType = "string"
SearchParameterTypeToken SearchParameterType = "token"
SearchParameterTypeReference SearchParameterType = "reference"
SearchParameterTypeUri SearchParameterType = "uri"
SearchParameterTypeQuantity SearchParameterType = "quantity"
SearchParameterTypeComposite SearchParameterType = "composite"
SearchParameterTypeSpecial SearchParameterType = "special"
SearchParameterTypeKeyword SearchParameterType = "keyword" //this is a literal/string primitive.
)
const TABLE_ALIAS = "fhir"
@ -39,14 +40,15 @@ const TABLE_ALIAS = "fhir"
//
// eg. Simple
//
//
// eg. Complex
// SELECT fhir.*
// FROM fhir_observation as fhir, json_each(fhir.code) as codeJson
// WHERE (
//
// (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' = "27113001" AND codeJson.value ->> '$.system' = "http://snomed.info/sct")
//
// )
// AND (user_id = "6efcd7c5-3f29-4f0d-926d-a66ff68bbfc2")
// GROUP BY `fhir`.`id`
@ -57,7 +59,7 @@ func (sr *SqliteRepository) QueryResources(ctx context.Context, query models.Que
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{}{}
clientResp := sqlQuery.Find(&results)
return results, clientResp.Error
@ -142,37 +144,45 @@ func (sr *SqliteRepository) sqlQueryResources(ctx context.Context, query models.
//defaults
selectClauses := []string{fmt.Sprintf("%s.*", 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 {
//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
query.Aggregations.OrderBy = "count(*) DESC"
query.Aggregations.OrderBy = &models.QueryResourceAggregation{
Field: "*",
Function: "count",
}
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
// `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
if len(query.Aggregations.OrderBy) > 0 {
orderAsc := true
if !strings.HasPrefix(query.Aggregations.OrderBy, "count(*)") {
orderAggregationParam, err := ProcessAggregationParameter(query.Aggregations.OrderBy, searchCodeToTypeLookup)
if query.Aggregations.OrderBy != nil {
orderAsc := true //default to ascending, switch to desc if parameter is a date type.
if !(query.Aggregations.OrderBy.Field == "*") {
orderAggregationParam, err := ProcessAggregationParameter(*query.Aggregations.OrderBy, searchCodeToTypeLookup)
if err != nil {
return nil, err
}
orderAggregationFromClause, err := SearchCodeToFromClause(orderAggregationParam)
orderAggregationFromClause, err := SearchCodeToFromClause(orderAggregationParam.SearchParameter)
if err != nil {
return nil, err
}
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)
if orderAsc {
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)
}
} else {
orderClause = query.Aggregations.OrderBy
orderClause = fmt.Sprintf("%s(%s) DESC", query.Aggregations.OrderBy.Function, query.Aggregations.OrderBy.Field)
}
}
//process group by clause
if len(query.Aggregations.GroupBy) > 0 {
groupAggregationParam, err := ProcessAggregationParameter(query.Aggregations.GroupBy, searchCodeToTypeLookup)
if query.Aggregations.GroupBy != nil {
groupAggregationParam, err := ProcessAggregationParameter(*query.Aggregations.GroupBy, searchCodeToTypeLookup)
if err != nil {
return nil, err
}
groupAggregationFromClause, err := SearchCodeToFromClause(groupAggregationParam)
groupAggregationFromClause, err := SearchCodeToFromClause(groupAggregationParam.SearchParameter)
if err != nil {
return nil, err
}
@ -199,8 +209,22 @@ func (sr *SqliteRepository) sqlQueryResources(ctx context.Context, query models.
groupClause = AggregationParameterToClause(groupAggregationParam)
selectClauses = []string{
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.Compact(fromClauses)
return sr.GormClient.WithContext(ctx).
sqlQuery := sr.GormClient.WithContext(ctx).
Select(strings.Join(selectClauses, ", ")).
Where(strings.Join(whereClauses, " AND "), whereNamedParameters).
Group(groupClause).
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
@ -227,13 +261,21 @@ type SearchParameter struct {
Modifier string
}
type AggregationParameter struct {
SearchParameter
Function string //count, sum, avg, min, max, etc
}
// Lists in the SearchParameterValueOperatorTree are AND'd together, and items within each SearchParameterValueOperatorTree list are OR'd together
// For example, the following would be AND'd together, and then OR'd with the next SearchParameterValueOperatorTree
//
// {
// {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
@ -284,6 +326,7 @@ func ProcessSearchParameter(searchCodeWithModifier string, searchParamTypeLookup
// top level is AND'd together, and each item within the lists are OR'd together
//
// For example, searchParamCodeValueOrValuesWithPrefix may be:
//
// "code": "29463-7,3141-9,27113001"
// "code": ["le29463-7", "gt3141-9", "27113001"]
func ProcessSearchParameterValueIntoOperatorTree(searchParameter SearchParameter, searchParamCodeValueOrValuesWithPrefix interface{}) (SearchParameterValueOperatorTree, error) {
@ -374,12 +417,8 @@ func ProcessSearchParameterValue(searchParameter SearchParameter, searchValueWit
}
} 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 len(searchParameterValueParts[0]) > 0 {
searchParameterValue.SecondaryValues[searchParameter.Name+"System"] = searchParameterValueParts[0]
}
if len(searchParameterValueParts[1]) > 0 {
searchParameterValue.Value = searchParameterValueParts[1]
}
if len(searchParameterValueParts[0]) == 0 && len(searchParameterValueParts[1]) == 0 {
return searchParameterValue, fmt.Errorf("invalid search parameter value: (%s=%s)", searchParameter.Name, searchParameterValue.Value)
}
@ -528,7 +567,10 @@ func SearchCodeToWhereClause(searchParam SearchParameter, searchParamValue Searc
//TODO: support ":text" modifier
//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)
//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 {
namedParameterKey := fmt.Sprintf("%s%s", searchParam.Name, strings.Title(k))
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:
//setup the clause
@ -565,14 +607,31 @@ func SearchCodeToFromClause(searchParam SearchParameter) (string, error) {
return "", nil
}
func AggregationParameterToClause(aggParameter SearchParameter) string {
func AggregationParameterToClause(aggParameter AggregationParameter) string {
var clause string
switch aggParameter.Type {
case SearchParameterTypeQuantity, SearchParameterTypeToken, SearchParameterTypeString:
case SearchParameterTypeQuantity, SearchParameterTypeString:
//setup the clause
return fmt.Sprintf("(%sJson.value ->> '$.%s')", aggParameter.Name, aggParameter.Modifier)
default:
return fmt.Sprintf("%s.%s", TABLE_ALIAS, aggParameter.Name)
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:
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:
@ -583,12 +642,15 @@ func AggregationParameterToClause(aggParameter SearchParameter) string {
// 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
func ProcessAggregationParameter(aggregationFieldWithProperty string, searchParamTypeLookup map[string]string) (SearchParameter, error) {
aggregationParameter := SearchParameter{}
func ProcessAggregationParameter(aggregationFieldWithFn models.QueryResourceAggregation, searchParamTypeLookup map[string]string) (AggregationParameter, error) {
aggregationParameter := AggregationParameter{
SearchParameter: SearchParameter{},
Function: aggregationFieldWithFn.Function,
}
//determine the searchCode searchCodeModifier
//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.Modifier = aggregationFieldParts[1]
} 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)
searchParamTypeStr, searchParamTypeOk := searchParamTypeLookup[aggregationParameter.Name]
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 {
aggregationParameter.Type = SearchParameterType(searchParamTypeStr)
}
//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 {
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 {
//complex types must have a modifier
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",
"WHERE ((codeJson.value ->> '$.code' = ?)) AND (user_id = ?)",
"GROUP BY `fhir`.`id`",
"ORDER BY fhir.sort_date ASC",
"ORDER BY fhir.sort_date DESC",
}, " "),
sqlString)
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",
"WHERE ((codeJson.value ->> '$.code' = ?)) AND ((categoryJson.value ->> '$.code' = ?)) AND (user_id = ?)",
"GROUP BY `fhir`.`id`",
"ORDER BY fhir.sort_date ASC",
"ORDER BY fhir.sort_date DESC",
}, " "),
sqlString)
require.Equal(suite.T(), sqlParams, []interface{}{
@ -158,7 +158,7 @@ func (suite *RepositorySqlTestSuite) TestQueryResources_SQL_WithPrimitiveOrderBy
"activityCode": "test_code",
},
From: "CarePlan",
Aggregations: &models.QueryResourceAggregations{OrderBy: "instantiatesUri"},
Aggregations: &models.QueryResourceAggregations{OrderBy: &models.QueryResourceAggregation{Field: "instantiatesUri"}},
})
require.NoError(suite.T(), err)
var results []map[string]interface{}
@ -193,7 +193,7 @@ func (suite *RepositorySqlTestSuite) TestQueryResources_SQL_WithKeywordOrderByAg
Select: []string{},
Where: map[string]interface{}{},
From: "CarePlan",
Aggregations: &models.QueryResourceAggregations{OrderBy: "id"},
Aggregations: &models.QueryResourceAggregations{OrderBy: &models.QueryResourceAggregation{Field: "id"}},
})
require.NoError(suite.T(), err)
var results []map[string]interface{}
@ -230,7 +230,7 @@ func (suite *RepositorySqlTestSuite) TestQueryResources_SQL_WithComplexOrderByAg
"code": "test_code",
},
From: "Observation",
Aggregations: &models.QueryResourceAggregations{OrderBy: "valueString:value"},
Aggregations: &models.QueryResourceAggregations{OrderBy: &models.QueryResourceAggregation{Field: "valueString:value"}},
})
require.NoError(suite.T(), err)
var results []map[string]interface{}
@ -267,7 +267,7 @@ func (suite *RepositorySqlTestSuite) TestQueryResources_SQL_WithPrimitiveCountBy
"activityCode": "test_code",
},
From: "CarePlan",
Aggregations: &models.QueryResourceAggregations{CountBy: "instantiatesUri"},
Aggregations: &models.QueryResourceAggregations{CountBy: &models.QueryResourceAggregation{Field: "instantiatesUri"}},
})
require.NoError(suite.T(), err)
var results []map[string]interface{}
@ -304,7 +304,7 @@ func (suite *RepositorySqlTestSuite) TestQueryResources_SQL_WithKeywordCountByAg
"activityCode": "test_code",
},
From: "CarePlan",
Aggregations: &models.QueryResourceAggregations{CountBy: "source_resource_type"},
Aggregations: &models.QueryResourceAggregations{CountBy: &models.QueryResourceAggregation{Field: "source_resource_type"}},
})
require.NoError(suite.T(), err)
var results []map[string]interface{}
@ -339,7 +339,7 @@ func (suite *RepositorySqlTestSuite) TestQueryResources_SQL_WithWildcardCountByA
Select: []string{},
Where: map[string]interface{}{},
From: "CarePlan",
Aggregations: &models.QueryResourceAggregations{CountBy: "*"},
Aggregations: &models.QueryResourceAggregations{CountBy: &models.QueryResourceAggregation{Field: "*"}},
})
require.NoError(suite.T(), err)
var results []map[string]interface{}
@ -376,7 +376,7 @@ func (suite *RepositorySqlTestSuite) TestQueryResources_SQL_WithComplexCountByAg
"code": "test_code",
},
From: "Observation",
Aggregations: &models.QueryResourceAggregations{CountBy: "code:code"},
Aggregations: &models.QueryResourceAggregations{CountBy: &models.QueryResourceAggregation{Field: "code:code"}},
})
require.NoError(suite.T(), err)
var results []map[string]interface{}
@ -398,3 +398,120 @@ func (suite *RepositorySqlTestSuite) TestQueryResources_SQL_WithComplexCountByAg
"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: "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.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() {
//setup
fakeConfig := mock_config.NewMockInterface(suite.MockCtrl)
@ -245,7 +296,7 @@ func (suite *RepositoryTestSuite) TestQueryResources_SQL() {
"SELECT fhir.*",
"FROM fhir_observation as fhir, json_each(fhir.code) as codeJson",
"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{}{
"test_code", "00000000-0000-0000-0000-000000000000",
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -107,6 +107,7 @@ func (s *FhirVisionPrescription) GetSearchParameters() map[string]string {
"lastUpdated": "date",
"prescriber": "reference",
"profile": "reference",
"sort_date": "date",
"source_id": "keyword",
"source_resource_id": "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_resource_id")] = 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"))

View File

@ -11,16 +11,23 @@ type QueryResource struct {
Select []string `json:"select"`
From string `json:"from"`
Where map[string]interface{} `json:"where"`
Limit *int `json:"limit,omitempty"`
Offset *int `json:"offset,omitempty"`
//aggregation fields
Aggregations *QueryResourceAggregations `json:"aggregations"`
}
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"`
OrderBy string `json:"order_by"`
GroupBy *QueryResourceAggregation `json:"group_by,omitempty"`
OrderBy *QueryResourceAggregation `json:"order_by,omitempty"`
}
type QueryResourceAggregation struct {
Field string `json:"field"`
Function string `json:"fn"`
}
func (q *QueryResource) Validate() error {
@ -36,27 +43,51 @@ func (q *QueryResource) Validate() error {
if len(q.Select) > 0 {
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")
}
if len(q.Aggregations.OrderBy) > 0 {
if q.Aggregations.OrderBy != nil {
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'")
}
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

View File

@ -13,15 +13,18 @@ func TestQueryResource_Validate(t *testing.T) {
}{
{QueryResource{Use: "test"}, "'use' is not supported yet", 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{From: "test", Aggregations: &QueryResourceAggregations{CountBy: "test", GroupBy: "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"}, GroupBy: &QueryResourceAggregation{Field: "test"}}}, "cannot use 'count_by' and 'group_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{CountBy: "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{GroupBy: "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{CountBy: &QueryResourceAggregation{Field: "test:property"}}}, "", false},
{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: &QueryResourceAggregation{Field: "test:property as HELLO"}}}, "group_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

View File

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

View File

@ -42,6 +42,7 @@ const routes: Routes = [
{ path: 'patient-profile', component: PatientProfileComponent, canActivate: [ IsAuthenticatedAuthGuard] },
{ path: 'medical-history', component: MedicalHistoryComponent, 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: 'ui-elements', loadChildren: () => import('./ui-elements/ui-elements.module').then(m => m.UiElementsModule) },

View File

@ -3,15 +3,20 @@ export class DashboardWidgetQuery {
select: string[]
from: string
where: {[key: string]: string | string[]}
// limit: number
// offset: number
limit?: number
offset?: number
//https://lodash.com/docs/4.17.15#unionBy
aggregations?: {
count_by?: string, //alias for groupBy and orderBy
group_by?: string,
order_by?: string,
count_by?: DashboardWidgetQueryAggregation, //alias for groupBy and orderBy
group_by?: DashboardWidgetQueryAggregation,
order_by?: DashboardWidgetQueryAggregation,
}
// aggregation_params?: string[]
// 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-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 -->
<div class="row mt-5 mb-3">
<div class="col-6">
<h1 class="az-dashboard-title">Observations</h1>
</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>
<!-- Observations List -->
@ -21,6 +64,18 @@
[observationCode]="observationGroup.key"
[observationTitle]="observationGroupTitles[observationGroup.key]"
></app-report-labs-observation>
<!-- Pagination -->
<ngb-pagination
class="mr-auto"
[collectionSize]="allObservationGroups.length"
[(page)]="currentPage"
[pageSize]="pageSize"
(pageChange)="populateObservationsForCurrentPage()"
>
</ngb-pagination>
</ng-template>

View File

@ -3,6 +3,24 @@ import {FastenApiService} from '../../services/fasten-api.service';
import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
import {ResourceFhir} from '../../models/fasten/resource_fhir';
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({
selector: 'app-report-labs',
@ -12,46 +30,201 @@ import * as fhirpath from 'fhirpath';
export class ReportLabsComponent implements OnInit {
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} = {}
isEmptyReport = false
diagnosticReports: ResourceFhir[] = []
constructor(
private fastenApi: FastenApiService,
private activatedRoute: ActivatedRoute,
) { }
ngOnInit(): void {
this.loading = true
this.fastenApi.getResources("Observation").subscribe(results => {
this.populateReports()
//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()
})
}
});
}
//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
results = results || []
console.log("ALL OBSERVATIONS", results)
this.observationGroups = data.observationGroups
this.observationGroupTitles = data.observationGroupTitles
//loop though all observations, group by "code.system": "http://loinc.org"
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]
}
}
this.isEmptyReport = !!!results.length
this.isEmptyReport = !!!Object.keys(this.observationGroups).length
}, error => {
this.loading = false
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) {
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": {},
"aggregations":{
"count_by": "source_resource_type"
"count_by": {"field": "source_resource_type"}
},
}
},
@ -138,7 +138,7 @@ describe('DashboardWidgetComponent', () => {
"where": {},
"aggregations":{
"count_by": "source_resource_type"
"count_by": {"field": "source_resource_type"}
},
}
}],