diff --git a/.gitignore b/.gitignore index 28121a83..c5d80bfe 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ .idea/**/tasks.xml .idea/**/dictionaries .idea/**/shelf +.idea/dataSources.xml # Sensitive or high-churn files .idea/**/dataSources/ diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml index 5b887de8..d542e1cb 100644 --- a/.idea/dataSources.xml +++ b/.idea/dataSources.xml @@ -14,4 +14,4 @@ - \ No newline at end of file + diff --git a/backend/pkg/database/sqlite_repository_query.go b/backend/pkg/database/sqlite_repository_query.go index a2480e72..e3a6e234 100644 --- a/backend/pkg/database/sqlite_repository_query.go +++ b/backend/pkg/database/sqlite_repository_query.go @@ -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 { diff --git a/backend/pkg/database/sqlite_repository_query_sql_test.go b/backend/pkg/database/sqlite_repository_query_sql_test.go index 51e61b6a..c872f97d 100644 --- a/backend/pkg/database/sqlite_repository_query_sql_test.go +++ b/backend/pkg/database/sqlite_repository_query_sql_test.go @@ -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", + }) +} diff --git a/backend/pkg/database/sqlite_repository_query_test.go b/backend/pkg/database/sqlite_repository_query_test.go index 6bbb1d00..b6a9168c 100644 --- a/backend/pkg/database/sqlite_repository_query_test.go +++ b/backend/pkg/database/sqlite_repository_query_test.go @@ -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", }) diff --git a/backend/pkg/models/database/fhir_account.go b/backend/pkg/models/database/fhir_account.go index dcee7494..4671e287 100644 --- a/backend/pkg/models/database/fhir_account.go +++ b/backend/pkg/models/database/fhir_account.go @@ -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", diff --git a/backend/pkg/models/database/fhir_adverse_event.go b/backend/pkg/models/database/fhir_adverse_event.go index 74d1eaf6..d9e74ffc 100644 --- a/backend/pkg/models/database/fhir_adverse_event.go +++ b/backend/pkg/models/database/fhir_adverse_event.go @@ -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", diff --git a/backend/pkg/models/database/fhir_allergy_intolerance.go b/backend/pkg/models/database/fhir_allergy_intolerance.go index 505db41a..0835409a 100644 --- a/backend/pkg/models/database/fhir_allergy_intolerance.go +++ b/backend/pkg/models/database/fhir_allergy_intolerance.go @@ -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", diff --git a/backend/pkg/models/database/fhir_appointment.go b/backend/pkg/models/database/fhir_appointment.go index ff1a6953..ecfc61ed 100644 --- a/backend/pkg/models/database/fhir_appointment.go +++ b/backend/pkg/models/database/fhir_appointment.go @@ -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", diff --git a/backend/pkg/models/database/fhir_binary.go b/backend/pkg/models/database/fhir_binary.go index 0822a213..1ad1f8fe 100644 --- a/backend/pkg/models/database/fhir_binary.go +++ b/backend/pkg/models/database/fhir_binary.go @@ -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", diff --git a/backend/pkg/models/database/fhir_care_plan.go b/backend/pkg/models/database/fhir_care_plan.go index 1652fb73..a70720b9 100644 --- a/backend/pkg/models/database/fhir_care_plan.go +++ b/backend/pkg/models/database/fhir_care_plan.go @@ -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", diff --git a/backend/pkg/models/database/fhir_care_team.go b/backend/pkg/models/database/fhir_care_team.go index b158e16f..ed31a644 100644 --- a/backend/pkg/models/database/fhir_care_team.go +++ b/backend/pkg/models/database/fhir_care_team.go @@ -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", diff --git a/backend/pkg/models/database/fhir_claim.go b/backend/pkg/models/database/fhir_claim.go index 4913a654..36fe8a12 100644 --- a/backend/pkg/models/database/fhir_claim.go +++ b/backend/pkg/models/database/fhir_claim.go @@ -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", diff --git a/backend/pkg/models/database/fhir_claim_response.go b/backend/pkg/models/database/fhir_claim_response.go index 4328773c..130b09ee 100644 --- a/backend/pkg/models/database/fhir_claim_response.go +++ b/backend/pkg/models/database/fhir_claim_response.go @@ -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", diff --git a/backend/pkg/models/database/fhir_composition.go b/backend/pkg/models/database/fhir_composition.go index 382dc706..8ba2f4b7 100644 --- a/backend/pkg/models/database/fhir_composition.go +++ b/backend/pkg/models/database/fhir_composition.go @@ -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", diff --git a/backend/pkg/models/database/fhir_condition.go b/backend/pkg/models/database/fhir_condition.go index 678f948b..bbd8eb22 100644 --- a/backend/pkg/models/database/fhir_condition.go +++ b/backend/pkg/models/database/fhir_condition.go @@ -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", diff --git a/backend/pkg/models/database/fhir_consent.go b/backend/pkg/models/database/fhir_consent.go index c4de0055..d9173f45 100644 --- a/backend/pkg/models/database/fhir_consent.go +++ b/backend/pkg/models/database/fhir_consent.go @@ -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", diff --git a/backend/pkg/models/database/fhir_coverage.go b/backend/pkg/models/database/fhir_coverage.go index 99c1e15f..b74c6b1e 100644 --- a/backend/pkg/models/database/fhir_coverage.go +++ b/backend/pkg/models/database/fhir_coverage.go @@ -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", diff --git a/backend/pkg/models/database/fhir_coverage_eligibility_request.go b/backend/pkg/models/database/fhir_coverage_eligibility_request.go index 1f49ea14..df671954 100644 --- a/backend/pkg/models/database/fhir_coverage_eligibility_request.go +++ b/backend/pkg/models/database/fhir_coverage_eligibility_request.go @@ -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", diff --git a/backend/pkg/models/database/fhir_coverage_eligibility_response.go b/backend/pkg/models/database/fhir_coverage_eligibility_response.go index a99e7df6..894b879e 100644 --- a/backend/pkg/models/database/fhir_coverage_eligibility_response.go +++ b/backend/pkg/models/database/fhir_coverage_eligibility_response.go @@ -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", diff --git a/backend/pkg/models/database/fhir_device.go b/backend/pkg/models/database/fhir_device.go index 63403ae5..e89cc32c 100644 --- a/backend/pkg/models/database/fhir_device.go +++ b/backend/pkg/models/database/fhir_device.go @@ -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", diff --git a/backend/pkg/models/database/fhir_device_request.go b/backend/pkg/models/database/fhir_device_request.go index 02411eb5..e08015a6 100644 --- a/backend/pkg/models/database/fhir_device_request.go +++ b/backend/pkg/models/database/fhir_device_request.go @@ -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", diff --git a/backend/pkg/models/database/fhir_diagnostic_report.go b/backend/pkg/models/database/fhir_diagnostic_report.go index 9757362a..2f9bf83d 100644 --- a/backend/pkg/models/database/fhir_diagnostic_report.go +++ b/backend/pkg/models/database/fhir_diagnostic_report.go @@ -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", diff --git a/backend/pkg/models/database/fhir_document_manifest.go b/backend/pkg/models/database/fhir_document_manifest.go index 6d76cbbb..260cb34c 100644 --- a/backend/pkg/models/database/fhir_document_manifest.go +++ b/backend/pkg/models/database/fhir_document_manifest.go @@ -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", diff --git a/backend/pkg/models/database/fhir_document_reference.go b/backend/pkg/models/database/fhir_document_reference.go index 07fe9c7d..4a2b7709 100644 --- a/backend/pkg/models/database/fhir_document_reference.go +++ b/backend/pkg/models/database/fhir_document_reference.go @@ -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", diff --git a/backend/pkg/models/database/fhir_encounter.go b/backend/pkg/models/database/fhir_encounter.go index 7a16bcd6..f779aa7b 100644 --- a/backend/pkg/models/database/fhir_encounter.go +++ b/backend/pkg/models/database/fhir_encounter.go @@ -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", diff --git a/backend/pkg/models/database/fhir_endpoint.go b/backend/pkg/models/database/fhir_endpoint.go index 0cc33955..189f80bc 100644 --- a/backend/pkg/models/database/fhir_endpoint.go +++ b/backend/pkg/models/database/fhir_endpoint.go @@ -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", diff --git a/backend/pkg/models/database/fhir_enrollment_request.go b/backend/pkg/models/database/fhir_enrollment_request.go index 5128441f..ea43a209 100644 --- a/backend/pkg/models/database/fhir_enrollment_request.go +++ b/backend/pkg/models/database/fhir_enrollment_request.go @@ -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", diff --git a/backend/pkg/models/database/fhir_enrollment_response.go b/backend/pkg/models/database/fhir_enrollment_response.go index e5532421..72630aec 100644 --- a/backend/pkg/models/database/fhir_enrollment_response.go +++ b/backend/pkg/models/database/fhir_enrollment_response.go @@ -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", diff --git a/backend/pkg/models/database/fhir_explanation_of_benefit.go b/backend/pkg/models/database/fhir_explanation_of_benefit.go index 9f74ba19..5ffaceed 100644 --- a/backend/pkg/models/database/fhir_explanation_of_benefit.go +++ b/backend/pkg/models/database/fhir_explanation_of_benefit.go @@ -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", diff --git a/backend/pkg/models/database/fhir_family_member_history.go b/backend/pkg/models/database/fhir_family_member_history.go index e9d2708c..4235b248 100644 --- a/backend/pkg/models/database/fhir_family_member_history.go +++ b/backend/pkg/models/database/fhir_family_member_history.go @@ -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", diff --git a/backend/pkg/models/database/fhir_goal.go b/backend/pkg/models/database/fhir_goal.go index 661447d6..0bd30d31 100644 --- a/backend/pkg/models/database/fhir_goal.go +++ b/backend/pkg/models/database/fhir_goal.go @@ -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", diff --git a/backend/pkg/models/database/fhir_imaging_study.go b/backend/pkg/models/database/fhir_imaging_study.go index fa34de7e..b4dab0b1 100644 --- a/backend/pkg/models/database/fhir_imaging_study.go +++ b/backend/pkg/models/database/fhir_imaging_study.go @@ -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", diff --git a/backend/pkg/models/database/fhir_immunization.go b/backend/pkg/models/database/fhir_immunization.go index 67ad23d1..e79a91f7 100644 --- a/backend/pkg/models/database/fhir_immunization.go +++ b/backend/pkg/models/database/fhir_immunization.go @@ -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", diff --git a/backend/pkg/models/database/fhir_insurance_plan.go b/backend/pkg/models/database/fhir_insurance_plan.go index 15160896..36b6355f 100644 --- a/backend/pkg/models/database/fhir_insurance_plan.go +++ b/backend/pkg/models/database/fhir_insurance_plan.go @@ -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", diff --git a/backend/pkg/models/database/fhir_location.go b/backend/pkg/models/database/fhir_location.go index 5967d568..61c5f78a 100644 --- a/backend/pkg/models/database/fhir_location.go +++ b/backend/pkg/models/database/fhir_location.go @@ -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", diff --git a/backend/pkg/models/database/fhir_media.go b/backend/pkg/models/database/fhir_media.go index 255d4967..a5d16c62 100644 --- a/backend/pkg/models/database/fhir_media.go +++ b/backend/pkg/models/database/fhir_media.go @@ -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", diff --git a/backend/pkg/models/database/fhir_medication.go b/backend/pkg/models/database/fhir_medication.go index f4a0b5c4..cdb6c2a2 100644 --- a/backend/pkg/models/database/fhir_medication.go +++ b/backend/pkg/models/database/fhir_medication.go @@ -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", diff --git a/backend/pkg/models/database/fhir_medication_administration.go b/backend/pkg/models/database/fhir_medication_administration.go index 74dc39a7..19aa216c 100644 --- a/backend/pkg/models/database/fhir_medication_administration.go +++ b/backend/pkg/models/database/fhir_medication_administration.go @@ -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", diff --git a/backend/pkg/models/database/fhir_medication_dispense.go b/backend/pkg/models/database/fhir_medication_dispense.go index 0c67965d..70f8446a 100644 --- a/backend/pkg/models/database/fhir_medication_dispense.go +++ b/backend/pkg/models/database/fhir_medication_dispense.go @@ -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", diff --git a/backend/pkg/models/database/fhir_medication_request.go b/backend/pkg/models/database/fhir_medication_request.go index b872cbcd..ef0581a9 100644 --- a/backend/pkg/models/database/fhir_medication_request.go +++ b/backend/pkg/models/database/fhir_medication_request.go @@ -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", diff --git a/backend/pkg/models/database/fhir_medication_statement.go b/backend/pkg/models/database/fhir_medication_statement.go index 0caadf81..f4ffb143 100644 --- a/backend/pkg/models/database/fhir_medication_statement.go +++ b/backend/pkg/models/database/fhir_medication_statement.go @@ -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", diff --git a/backend/pkg/models/database/fhir_nutrition_order.go b/backend/pkg/models/database/fhir_nutrition_order.go index a4d8d870..6e658ef6 100644 --- a/backend/pkg/models/database/fhir_nutrition_order.go +++ b/backend/pkg/models/database/fhir_nutrition_order.go @@ -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", diff --git a/backend/pkg/models/database/fhir_observation.go b/backend/pkg/models/database/fhir_observation.go index 3ca5ddfc..aeb73b46 100644 --- a/backend/pkg/models/database/fhir_observation.go +++ b/backend/pkg/models/database/fhir_observation.go @@ -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", diff --git a/backend/pkg/models/database/fhir_organization.go b/backend/pkg/models/database/fhir_organization.go index 1ea42fa2..c003a546 100644 --- a/backend/pkg/models/database/fhir_organization.go +++ b/backend/pkg/models/database/fhir_organization.go @@ -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", diff --git a/backend/pkg/models/database/fhir_organization_affiliation.go b/backend/pkg/models/database/fhir_organization_affiliation.go index 140389ca..7c02c028 100644 --- a/backend/pkg/models/database/fhir_organization_affiliation.go +++ b/backend/pkg/models/database/fhir_organization_affiliation.go @@ -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", diff --git a/backend/pkg/models/database/fhir_patient.go b/backend/pkg/models/database/fhir_patient.go index d31332ef..8c3b21fc 100644 --- a/backend/pkg/models/database/fhir_patient.go +++ b/backend/pkg/models/database/fhir_patient.go @@ -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", diff --git a/backend/pkg/models/database/fhir_person.go b/backend/pkg/models/database/fhir_person.go index fe68cf26..bbb4cf36 100644 --- a/backend/pkg/models/database/fhir_person.go +++ b/backend/pkg/models/database/fhir_person.go @@ -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", diff --git a/backend/pkg/models/database/fhir_practitioner.go b/backend/pkg/models/database/fhir_practitioner.go index 53c7d47b..f0df4947 100644 --- a/backend/pkg/models/database/fhir_practitioner.go +++ b/backend/pkg/models/database/fhir_practitioner.go @@ -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", diff --git a/backend/pkg/models/database/fhir_practitioner_role.go b/backend/pkg/models/database/fhir_practitioner_role.go index aaa4a8e6..4c41f691 100644 --- a/backend/pkg/models/database/fhir_practitioner_role.go +++ b/backend/pkg/models/database/fhir_practitioner_role.go @@ -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", diff --git a/backend/pkg/models/database/fhir_procedure.go b/backend/pkg/models/database/fhir_procedure.go index 01de05e7..1f7237d2 100644 --- a/backend/pkg/models/database/fhir_procedure.go +++ b/backend/pkg/models/database/fhir_procedure.go @@ -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", diff --git a/backend/pkg/models/database/fhir_provenance.go b/backend/pkg/models/database/fhir_provenance.go index 7e456ba5..38ec9ebd 100644 --- a/backend/pkg/models/database/fhir_provenance.go +++ b/backend/pkg/models/database/fhir_provenance.go @@ -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", diff --git a/backend/pkg/models/database/fhir_questionnaire.go b/backend/pkg/models/database/fhir_questionnaire.go index 95184e91..4e6bf6d9 100644 --- a/backend/pkg/models/database/fhir_questionnaire.go +++ b/backend/pkg/models/database/fhir_questionnaire.go @@ -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", diff --git a/backend/pkg/models/database/fhir_questionnaire_response.go b/backend/pkg/models/database/fhir_questionnaire_response.go index dafa8cea..fc1daab1 100644 --- a/backend/pkg/models/database/fhir_questionnaire_response.go +++ b/backend/pkg/models/database/fhir_questionnaire_response.go @@ -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", diff --git a/backend/pkg/models/database/fhir_related_person.go b/backend/pkg/models/database/fhir_related_person.go index 8e8573eb..2f87ae3d 100644 --- a/backend/pkg/models/database/fhir_related_person.go +++ b/backend/pkg/models/database/fhir_related_person.go @@ -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", diff --git a/backend/pkg/models/database/fhir_schedule.go b/backend/pkg/models/database/fhir_schedule.go index 41e56589..2651d0e2 100644 --- a/backend/pkg/models/database/fhir_schedule.go +++ b/backend/pkg/models/database/fhir_schedule.go @@ -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", diff --git a/backend/pkg/models/database/fhir_service_request.go b/backend/pkg/models/database/fhir_service_request.go index 2860bedb..54d9c994 100644 --- a/backend/pkg/models/database/fhir_service_request.go +++ b/backend/pkg/models/database/fhir_service_request.go @@ -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", diff --git a/backend/pkg/models/database/fhir_slot.go b/backend/pkg/models/database/fhir_slot.go index abdf0e7d..5820d5ec 100644 --- a/backend/pkg/models/database/fhir_slot.go +++ b/backend/pkg/models/database/fhir_slot.go @@ -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", diff --git a/backend/pkg/models/database/fhir_specimen.go b/backend/pkg/models/database/fhir_specimen.go index 55eb9b27..a63a2480 100644 --- a/backend/pkg/models/database/fhir_specimen.go +++ b/backend/pkg/models/database/fhir_specimen.go @@ -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", diff --git a/backend/pkg/models/database/fhir_vision_prescription.go b/backend/pkg/models/database/fhir_vision_prescription.go index 4bced74f..282c50a9 100644 --- a/backend/pkg/models/database/fhir_vision_prescription.go +++ b/backend/pkg/models/database/fhir_vision_prescription.go @@ -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", diff --git a/backend/pkg/models/database/generate.go b/backend/pkg/models/database/generate.go index f629eb27..54b553c3 100644 --- a/backend/pkg/models/database/generate.go +++ b/backend/pkg/models/database/generate.go @@ -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" diff --git a/backend/pkg/models/query_resource.go b/backend/pkg/models/query_resource.go index acad5118..88c3f584 100644 --- a/backend/pkg/models/query_resource.go +++ b/backend/pkg/models/query_resource.go @@ -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 } diff --git a/backend/pkg/models/query_resource_test.go b/backend/pkg/models/query_resource_test.go index 90a1d90e..a75806e1 100644 --- a/backend/pkg/models/query_resource_test.go +++ b/backend/pkg/models/query_resource_test.go @@ -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 diff --git a/backend/pkg/web/handler/dashboard/default.json b/backend/pkg/web/handler/dashboard/default.json index da885131..0f4df93c 100644 --- a/backend/pkg/web/handler/dashboard/default.json +++ b/backend/pkg/web/handler/dashboard/default.json @@ -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": "*" } } } }], diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 440ac052..aec8b3d0 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -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) }, diff --git a/frontend/src/app/models/widget/dashboard-widget-query.ts b/frontend/src/app/models/widget/dashboard-widget-query.ts index e573e851..d9d0a11f 100644 --- a/frontend/src/app/models/widget/dashboard-widget-query.ts +++ b/frontend/src/app/models/widget/dashboard-widget-query.ts @@ -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 +} diff --git a/frontend/src/app/pages/report-labs/report-labs.component.html b/frontend/src/app/pages/report-labs/report-labs.component.html index 8b926f3c..9990d171 100644 --- a/frontend/src/app/pages/report-labs/report-labs.component.html +++ b/frontend/src/app/pages/report-labs/report-labs.component.html @@ -8,11 +8,54 @@ + + + + + Report Info + + + + + + + + Observations + + + + + Included Reports + + + All + ----- + {{diagnosticReport?.sort_title}} [{{diagnosticReport?.sort_date | amDateFormat: 'LL'}}] + + + + + Sort By + + + Date + Name + Status + + + @@ -21,6 +64,18 @@ [observationCode]="observationGroup.key" [observationTitle]="observationGroupTitles[observationGroup.key]" > + + + + + + diff --git a/frontend/src/app/pages/report-labs/report-labs.component.ts b/frontend/src/app/pages/report-labs/report-labs.component.ts index 0c89ed7d..1e307ad8 100644 --- a/frontend/src/app/pages/report-labs/report-labs.component.ts +++ b/frontend/src/app/pages/report-labs/report-labs.component.ts @@ -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 { + 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 { + 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{ + 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 + } + }) + ); + } } diff --git a/frontend/src/app/widgets/dashboard-widget/dashboard-widget.component.spec.ts b/frontend/src/app/widgets/dashboard-widget/dashboard-widget.component.spec.ts index 473a4a30..b7928fdc 100644 --- a/frontend/src/app/widgets/dashboard-widget/dashboard-widget.component.spec.ts +++ b/frontend/src/app/widgets/dashboard-widget/dashboard-widget.component.spec.ts @@ -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"} }, } }],