Merge pull request #256 from fastenhealth/labwork_report_filtering
This commit is contained in:
commit
0b99549216
|
@ -8,6 +8,7 @@
|
|||
.idea/**/tasks.xml
|
||||
.idea/**/dictionaries
|
||||
.idea/**/shelf
|
||||
.idea/dataSources.xml
|
||||
|
||||
# Sensitive or high-churn files
|
||||
.idea/**/dataSources/
|
||||
|
|
|
@ -18,35 +18,37 @@ import (
|
|||
type SearchParameterType string
|
||||
|
||||
const (
|
||||
SearchParameterTypeNumber SearchParameterType = "number"
|
||||
SearchParameterTypeDate SearchParameterType = "date"
|
||||
//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"
|
||||
|
||||
//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:
|
||||
//
|
||||
// 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,14 +261,22 @@ type SearchParameter struct {
|
|||
Modifier string
|
||||
}
|
||||
|
||||
//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 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
|
||||
|
||||
type SearchParameterValue struct {
|
||||
|
@ -243,7 +285,7 @@ type SearchParameterValue struct {
|
|||
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
|
||||
func ProcessSearchParameter(searchCodeWithModifier string, searchParamTypeLookup map[string]string) (SearchParameter, error) {
|
||||
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
|
||||
//
|
||||
// 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) {
|
||||
|
||||
searchParamCodeValuesWithPrefix := []string{}
|
||||
|
@ -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]
|
||||
}
|
||||
searchParameterValue.SecondaryValues[searchParameter.Name+"System"] = searchParameterValueParts[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)
|
||||
}
|
||||
|
@ -416,7 +455,7 @@ func NamedParameterWithSuffix(parameterName string, suffix string) string {
|
|||
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) {
|
||||
|
||||
//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
|
||||
|
||||
//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,17 +607,34 @@ 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)
|
||||
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:
|
||||
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:
|
||||
// eg. `probability`
|
||||
//
|
||||
|
@ -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 {
|
||||
|
|
|
@ -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",
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
})
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"))
|
||||
|
@ -563,8 +564,8 @@ func main() {
|
|||
|
||||
}
|
||||
|
||||
//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.
|
||||
// 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.
|
||||
var AllowedResources = []string{
|
||||
"Account",
|
||||
"AdverseEvent",
|
||||
|
@ -623,7 +624,7 @@ var AllowedResources = []string{
|
|||
"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 {
|
||||
switch fieldType {
|
||||
case "number", "uri", "date":
|
||||
|
@ -636,8 +637,8 @@ func isSimpleFieldType(fieldType string) bool {
|
|||
return true
|
||||
}
|
||||
|
||||
//https://hl7.org/fhir/search.html#token
|
||||
//https://hl7.org/fhir/r4/valueset-search-param-type.html
|
||||
// https://hl7.org/fhir/search.html#token
|
||||
// https://hl7.org/fhir/r4/valueset-search-param-type.html
|
||||
func mapFieldType(fieldType string) string {
|
||||
switch fieldType {
|
||||
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 {
|
||||
// gorm:"type:text;serializer:json"
|
||||
|
||||
|
|
|
@ -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,28 +43,52 @@ 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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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": "*" }
|
||||
}
|
||||
}
|
||||
}],
|
||||
|
|
|
@ -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) },
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
||||
|
|
|
@ -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.loading = false
|
||||
results = results || []
|
||||
console.log("ALL OBSERVATIONS", results)
|
||||
|
||||
//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)
|
||||
this.populateReports()
|
||||
|
||||
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 => {
|
||||
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
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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"}
|
||||
},
|
||||
}
|
||||
}],
|
||||
|
|
Loading…
Reference in New Issue