fasten-onprem/backend/pkg/database/gorm_repository_query.go

689 lines
33 KiB
Go
Raw Normal View History

package database
import (
"context"
"fmt"
2023-10-16 22:49:15 -06:00
"sort"
"strconv"
"strings"
"time"
"github.com/fastenhealth/fasten-onprem/backend/pkg/models"
databaseModel "github.com/fastenhealth/fasten-onprem/backend/pkg/models/database"
"github.com/iancoleman/strcase"
"github.com/samber/lo"
"golang.org/x/exp/maps"
"golang.org/x/exp/slices"
"gorm.io/gorm"
)
type SearchParameterType string
const (
//simple types
SearchParameterTypeNumber SearchParameterType = "number"
SearchParameterTypeDate SearchParameterType = "date"
SearchParameterTypeUri SearchParameterType = "uri"
SearchParameterTypeKeyword SearchParameterType = "keyword" //this is a literal/string primitive.
//complex types
SearchParameterTypeString SearchParameterType = "string"
SearchParameterTypeToken SearchParameterType = "token"
SearchParameterTypeReference SearchParameterType = "reference"
SearchParameterTypeQuantity SearchParameterType = "quantity"
SearchParameterTypeComposite SearchParameterType = "composite"
SearchParameterTypeSpecial SearchParameterType = "special"
)
const TABLE_ALIAS = "fhir"
// 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`
func (gr *GormRepository) QueryResources(ctx context.Context, query models.QueryResource) (interface{}, error) {
sqlQuery, err := gr.sqlQueryResources(ctx, query)
if err != nil {
return nil, err
}
if query.Aggregations != nil && (query.Aggregations.GroupBy != nil || query.Aggregations.CountBy != nil) {
results := []map[string]interface{}{}
clientResp := sqlQuery.Find(&results)
return results, clientResp.Error
} else {
results := []models.ResourceBase{}
clientResp := sqlQuery.Find(&results)
return results, clientResp.Error
}
}
// see QueryResources
// this function has all the logic, but should only be called directly for testing
func (gr *GormRepository) sqlQueryResources(ctx context.Context, query models.QueryResource) (*gorm.DB, error) {
//todo, until we actually parse the select statement, we will just return all resources based on "from"
//SECURITY: this is required to ensure that only valid resource types are queried (since it's controlled by the user)
if !slices.Contains(databaseModel.GetAllowedResourceTypes(), query.From) {
return nil, fmt.Errorf("invalid resource type %s", query.From)
}
if queryValidate := query.Validate(); queryValidate != nil {
return nil, queryValidate
}
//find the associated Gorm Model for this query
queryModel, err := databaseModel.NewFhirResourceModelByType(query.From)
if err != nil {
return nil, err
}
//SECURITY: this would be unsafe as the user controls the query.From value, however we've validated it is a valid resource type above
fromClauses := []string{fmt.Sprintf("%s as %s", strcase.ToSnake("Fhir"+query.From), TABLE_ALIAS)}
whereClauses := []string{}
whereNamedParameters := map[string]interface{}{}
//find the FHIR search types associated with each where clause. Any unknown parameters will be ignored.
searchCodeToTypeLookup := queryModel.GetSearchParameters()
for searchParamCodeWithModifier, searchParamCodeValueOrValuesWithPrefix := range query.Where {
searchParameter, err := ProcessSearchParameter(searchParamCodeWithModifier, searchCodeToTypeLookup)
if err != nil {
return nil, err
}
searchParameterValueOperatorTree, err := ProcessSearchParameterValueIntoOperatorTree(searchParameter, searchParamCodeValueOrValuesWithPrefix)
if err != nil {
return nil, err
}
for ndxANDlevel, searchParameterValueOperatorAND := range searchParameterValueOperatorTree {
whereORClauses := []string{}
for ndxORlevel, searchParameterValueOperatorOR := range searchParameterValueOperatorAND {
whereORClause, clauseNamedParameters, err := SearchCodeToWhereClause(searchParameter, searchParameterValueOperatorOR, fmt.Sprintf("%d_%d", ndxANDlevel, ndxORlevel))
if err != nil {
return nil, err
}
//add generated where clause to the list, and add the named parameters to the map of existing named parameters
whereORClauses = append(whereORClauses, whereORClause)
maps.Copy(whereNamedParameters, clauseNamedParameters)
}
whereClauses = append(whereClauses, fmt.Sprintf("(%s)", strings.Join(whereORClauses, " OR ")))
}
fromClause, err := SearchCodeToFromClause(searchParameter)
if err != nil {
return nil, err
}
if len(fromClause) > 0 {
fromClauses = append(fromClauses, fromClause)
}
}
//SECURITY: for safety, we will always add/override the current user_id to the where clause. This is to ensure that the user doesnt attempt to override this value in their own where clause
currentUser, currentUserErr := gr.GetCurrentUser(ctx)
if currentUserErr != nil {
return nil, currentUserErr
}
whereNamedParameters["user_id"] = currentUser.ID.String()
whereClauses = append(whereClauses, "(user_id = @user_id)")
//defaults
selectClauses := []string{fmt.Sprintf("%s.*", TABLE_ALIAS)}
groupClause := fmt.Sprintf("%s.id", TABLE_ALIAS)
orderClause := fmt.Sprintf("%s.sort_date DESC", TABLE_ALIAS)
if query.Aggregations != nil {
//Handle Aggregations
if query.Aggregations.CountBy != nil {
//populate the group by and order by clause with the count by values
query.Aggregations.OrderBy = &models.QueryResourceAggregation{
Field: "*",
Function: "count",
}
query.Aggregations.GroupBy = query.Aggregations.CountBy
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.Field = "source_resource_type"
}
}
//process order by clause
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.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)
} else {
orderClause = fmt.Sprintf("%s DESC", orderClause)
}
} else {
orderClause = fmt.Sprintf("%s(%s) DESC", query.Aggregations.OrderBy.Function, query.Aggregations.OrderBy.Field)
}
}
//process group by clause
if query.Aggregations.GroupBy != nil {
groupAggregationParam, err := ProcessAggregationParameter(*query.Aggregations.GroupBy, searchCodeToTypeLookup)
if err != nil {
return nil, err
}
groupAggregationFromClause, err := SearchCodeToFromClause(groupAggregationParam.SearchParameter)
if err != nil {
return nil, err
}
fromClauses = append(fromClauses, groupAggregationFromClause)
groupClause = AggregationParameterToClause(groupAggregationParam)
selectClauses = []string{
fmt.Sprintf("%s as %s", groupClause, "label"),
}
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"))
}
}
}
//ensure Where and From clauses are unique
whereClauses = lo.Uniq(whereClauses)
whereClauses = lo.Compact(whereClauses)
2023-10-16 22:49:15 -06:00
sort.Strings(whereClauses)
fromClauses = lo.Uniq(fromClauses)
fromClauses = lo.Compact(fromClauses)
2023-10-16 22:49:15 -06:00
sort.Strings(fromClauses)
sort.Strings(selectClauses)
sqlQuery := gr.GormClient.WithContext(ctx).
Select(strings.Join(selectClauses, ", ")).
Where(strings.Join(whereClauses, " AND "), whereNamedParameters).
Group(groupClause).
Order(orderClause).
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
//TODO: dont export these, instead use casting to convert the interface to the GormRepository struct, then call ehese functions directly
type SearchParameter struct {
Name string
Type SearchParameterType
Modifier string
}
type AggregationParameter struct {
SearchParameter
Function string //count, sum, avg, min, max, etc
}
// Lists in the SearchParameterValueOperatorTree are AND'd together, and items within each SearchParameterValueOperatorTree list are OR'd together
// For example, the following would be AND'd together, and then OR'd with the next SearchParameterValueOperatorTree
//
// {
// {SearchParameterValue{Value: "foo"}, SearchParameterValue{Value: "bar"}}
// {SearchParameterValue{Value: "baz"}},
// }
//
// This would result in the following SQL:
//
// (value = "foo" OR value = "bar") AND (value = "baz")
type SearchParameterValueOperatorTree [][]SearchParameterValue
type SearchParameterValue struct {
Prefix string
Value interface{}
SecondaryValues map[string]interface{}
}
// SearchParameters are made up of parameter names and modifiers. For example, "name" and "name:exact" are both valid search parameters
// This function will parse the searchCodeWithModifier and return the SearchParameter
func ProcessSearchParameter(searchCodeWithModifier string, searchParamTypeLookup map[string]string) (SearchParameter, error) {
searchParameter := SearchParameter{}
//determine the searchCode searchCodeModifier
//TODO: this is only applicable to string, token, reference and uri type (however unknown names & modifiers are ignored)
if searchCodeParts := strings.SplitN(searchCodeWithModifier, ":", 2); len(searchCodeParts) == 2 {
searchParameter.Name = searchCodeParts[0]
searchParameter.Modifier = searchCodeParts[1]
} else {
searchParameter.Name = searchCodeParts[0]
searchParameter.Modifier = ""
}
//next, determine the searchCodeType for this Resource (or throw an error if it is unknown)
searchParamTypeStr, searchParamTypeOk := searchParamTypeLookup[searchParameter.Name]
if !searchParamTypeOk {
return searchParameter, fmt.Errorf("unknown search parameter: %s", searchParameter.Name)
} else {
searchParameter.Type = SearchParameterType(searchParamTypeStr)
}
//if this is a token search parameter with a modifier, we need to throw an error
if searchParameter.Type == SearchParameterTypeToken && len(searchParameter.Modifier) > 0 {
return searchParameter, fmt.Errorf("token search parameter %s cannot have a modifier", searchParameter.Name)
}
return searchParameter, nil
}
// ProcessSearchParameterValueIntoOperatorTree searchParamCodeValueOrValuesWithPrefix may be a single string, or a list of strings
// each string, may itself be a concatenation of multiple values, seperated by a comma
// so we need to do three stages of processing:
// 1. split the searchParamCodeValueOrValuesWithPrefix into a list of strings
// 2. split each string into a list of values
// 3. use the ProcessSearchParameterValue function to split each value into a list of prefixes and values
// these are then stored in a multidimentional list of SearchParameterValueOperatorTree
// top level is AND'd together, and each item within the lists are OR'd together
//
// For example, searchParamCodeValueOrValuesWithPrefix may be:
//
// "code": "29463-7,3141-9,27113001"
// "code": ["le29463-7", "gt3141-9", "27113001"]
func ProcessSearchParameterValueIntoOperatorTree(searchParameter SearchParameter, searchParamCodeValueOrValuesWithPrefix interface{}) (SearchParameterValueOperatorTree, error) {
searchParamCodeValuesWithPrefix := []string{}
switch v := searchParamCodeValueOrValuesWithPrefix.(type) {
case string:
searchParamCodeValuesWithPrefix = append(searchParamCodeValuesWithPrefix, v)
break
case []string:
searchParamCodeValuesWithPrefix = v
break
default:
return nil, fmt.Errorf("invalid search parameter value type %T, must be a string or a list of strings (%s=%v)", v, searchParameter.Name, searchParamCodeValueOrValuesWithPrefix)
}
//generate a SearchParameterValueOperatorTree, because we may have multiple OR and AND operators for the same search parameter.
//ie, (code = "foo" OR code = "bar") AND (code = "baz")
searchParamCodeValueOperatorTree := SearchParameterValueOperatorTree{}
//loop through each searchParamCodeValueWithPrefix, and split it into a list of values (comma seperated)
for _, searchParamCodeValuesInANDClause := range searchParamCodeValuesWithPrefix {
searchParameterValuesOperatorOR := []SearchParameterValue{}
for _, searchParamCodeValueInORClause := range strings.Split(searchParamCodeValuesInANDClause, ",") {
searchParameterValue, err := ProcessSearchParameterValue(searchParameter, searchParamCodeValueInORClause)
if err != nil {
return nil, err
}
searchParameterValuesOperatorOR = append(searchParameterValuesOperatorOR, searchParameterValue)
}
searchParamCodeValueOperatorTree = append(searchParamCodeValueOperatorTree, searchParameterValuesOperatorOR)
}
return searchParamCodeValueOperatorTree, nil
}
// ProcessSearchParameterValue searchValueWithPrefix may or may not have a prefix which needs to be parsed
// this function will parse the searchValueWithPrefix and return the SearchParameterValue
// for example, "eq2018-01-01" would return a SearchParameterValue with a prefix of "eq" and a value of "2018-01-01"
// and "2018-01-01" would return a SearchParameterValue with a value of "2018-01-01"
//
// some query types, like token, quantity and reference, have secondary values that need to be parsed
// for example, code="http://loinc.org|29463-7" would return a SearchParameterValue with a value of "29463-7" and a secondary value of { "codeSystem": "http://loinc.org" }
func ProcessSearchParameterValue(searchParameter SearchParameter, searchValueWithPrefix string) (SearchParameterValue, error) {
searchParameterValue := SearchParameterValue{
SecondaryValues: map[string]interface{}{},
Value: searchValueWithPrefix,
}
if (searchParameter.Type == SearchParameterTypeString || searchParameter.Type == SearchParameterTypeUri || searchParameter.Type == SearchParameterTypeKeyword) && len(searchParameterValue.Value.(string)) == 0 {
return searchParameterValue, fmt.Errorf("invalid search parameter value: (%s=%s)", searchParameter.Name, searchParameterValue.Value)
}
//certain types (like number,date and quanitty have a prefix that needs to be parsed)
if searchParameter.Type == SearchParameterTypeNumber || searchParameter.Type == SearchParameterTypeDate || searchParameter.Type == SearchParameterTypeQuantity {
//loop though all known/allowed prefixes, and determine if the searchValueWithPrefix starts with one of them
allowedPrefixes := []string{"eq", "ne", "gt", "lt", "ge", "le", "sa", "eb", "ap"}
for _, allowedPrefix := range allowedPrefixes {
if strings.HasPrefix(searchValueWithPrefix, allowedPrefix) {
searchParameterValue.Prefix = allowedPrefix
searchParameterValue.Value = strings.TrimPrefix(searchValueWithPrefix, allowedPrefix)
break
}
}
}
//certain Types (like token, quantity, reference) have secondary query values that need to be parsed (delimited by "|") value
if searchParameter.Type == SearchParameterTypeQuantity {
if searchParameterValueParts := strings.SplitN(searchParameterValue.Value.(string), "|", 3); len(searchParameterValueParts) == 1 {
searchParameterValue.Value = searchParameterValueParts[0]
} else if len(searchParameterValueParts) == 2 {
searchParameterValue.Value = searchParameterValueParts[0]
if len(searchParameterValueParts[1]) > 0 {
searchParameterValue.SecondaryValues[searchParameter.Name+"System"] = searchParameterValueParts[1]
}
} else if len(searchParameterValueParts) == 3 {
searchParameterValue.Value = searchParameterValueParts[0]
if len(searchParameterValueParts[1]) > 0 {
searchParameterValue.SecondaryValues[searchParameter.Name+"System"] = searchParameterValueParts[1]
}
if len(searchParameterValueParts[2]) > 0 {
searchParameterValue.SecondaryValues[searchParameter.Name+"Code"] = searchParameterValueParts[2]
}
}
} else if searchParameter.Type == SearchParameterTypeToken {
if searchParameterValueParts := strings.SplitN(searchParameterValue.Value.(string), "|", 2); len(searchParameterValueParts) == 1 {
searchParameterValue.Value = searchParameterValueParts[0] //this is a code
if len(searchParameterValue.Value.(string)) == 0 {
return searchParameterValue, fmt.Errorf("invalid search parameter value: (%s=%s)", searchParameter.Name, searchParameterValue.Value)
}
} 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.
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)
}
}
} else if searchParameter.Type == SearchParameterTypeReference {
//todo
return searchParameterValue, fmt.Errorf("search parameter type not yet implemented: %s", searchParameter.Type)
}
//certain types (Quantity and Number) need to be converted to Float64
if searchParameter.Type == SearchParameterTypeQuantity || searchParameter.Type == SearchParameterTypeNumber {
if conv, err := strconv.ParseFloat(searchParameterValue.Value.(string), 64); err == nil {
searchParameterValue.Value = conv
} else {
return searchParameterValue, fmt.Errorf("invalid search parameter value (NaN): (%s=%s)", searchParameter.Name, searchParameterValue.Value)
}
} else if searchParameter.Type == SearchParameterTypeDate {
//other types (like date) need to be converted to a time.Time
if conv, err := time.Parse(time.RFC3339, searchParameterValue.Value.(string)); err == nil {
searchParameterValue.Value = conv
} else {
// fallback to parsing just a date (without time)
if conv, err := time.Parse("2006-01-02", searchParameterValue.Value.(string)); err == nil {
searchParameterValue.Value = conv
} else {
return searchParameterValue, fmt.Errorf("invalid search parameter value (invalid date): (%s=%s)", searchParameter.Name, searchParameterValue.Value)
}
}
}
return searchParameterValue, nil
}
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
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
searchClauseNamedParams := map[string]interface{}{
NamedParameterWithSuffix(searchParam.Name, namedParameterSuffix): searchParamValue.Value,
}
for k, v := range searchParamValue.SecondaryValues {
searchClauseNamedParams[NamedParameterWithSuffix(k, namedParameterSuffix)] = v
}
//parse the searchCode and searchCodeValue to determine the correct where clause
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//SIMPLE SEARCH PARAMETERS
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
switch searchParam.Type {
case SearchParameterTypeNumber, SearchParameterTypeDate:
if searchParamValue.Prefix == "" || searchParamValue.Prefix == "eq" {
return fmt.Sprintf("(%s = @%s)", searchParam.Name, NamedParameterWithSuffix(searchParam.Name, namedParameterSuffix)), searchClauseNamedParams, nil
} else if searchParamValue.Prefix == "lt" || searchParamValue.Prefix == "eb" {
return fmt.Sprintf("(%s < @%s)", searchParam.Name, NamedParameterWithSuffix(searchParam.Name, namedParameterSuffix)), searchClauseNamedParams, nil
} else if searchParamValue.Prefix == "le" {
return fmt.Sprintf("(%s <= @%s)", searchParam.Name, NamedParameterWithSuffix(searchParam.Name, namedParameterSuffix)), searchClauseNamedParams, nil
} else if searchParamValue.Prefix == "gt" || searchParamValue.Prefix == "sa" {
return fmt.Sprintf("(%s > @%s)", searchParam.Name, NamedParameterWithSuffix(searchParam.Name, namedParameterSuffix)), searchClauseNamedParams, nil
} else if searchParamValue.Prefix == "ge" {
return fmt.Sprintf("(%s >= @%s)", searchParam.Name, NamedParameterWithSuffix(searchParam.Name, namedParameterSuffix)), searchClauseNamedParams, nil
} else if searchParamValue.Prefix == "ne" {
return fmt.Sprintf("(%s <> @%s)", searchParam.Name, NamedParameterWithSuffix(searchParam.Name, namedParameterSuffix)), searchClauseNamedParams, nil
} else if searchParam.Modifier == "ap" {
return "", nil, fmt.Errorf("search modifier 'ap' not supported for search parameter type %s (%s=%s)", searchParam.Type, searchParam.Name, searchParamValue.Value)
}
case SearchParameterTypeUri:
if searchParam.Modifier == "" {
return fmt.Sprintf("(%s = @%s)", searchParam.Name, NamedParameterWithSuffix(searchParam.Name, namedParameterSuffix)), searchClauseNamedParams, nil
} else if searchParam.Modifier == "below" {
searchClauseNamedParams[NamedParameterWithSuffix(searchParam.Name, namedParameterSuffix)] = searchParamValue.Value.(string) + "%" // column starts with "http://example.com"
return fmt.Sprintf("(%s LIKE @%s)", searchParam.Name, NamedParameterWithSuffix(searchParam.Name, namedParameterSuffix)), searchClauseNamedParams, nil
} else if searchParam.Modifier == "above" {
return "", nil, fmt.Errorf("search modifier 'above' not supported for search parameter type %s (%s=%s)", searchParam.Type, searchParam.Name, searchParamValue.Value)
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//COMPLEX SEARCH PARAMETERS
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
case SearchParameterTypeString:
if searchParam.Modifier == "" {
searchClauseNamedParams[NamedParameterWithSuffix(searchParam.Name, namedParameterSuffix)] = searchParamValue.Value.(string) + "%" // "eve" matches "Eve" and "Evelyn"
return fmt.Sprintf("(%sJson.value LIKE @%s)", searchParam.Name, NamedParameterWithSuffix(searchParam.Name, namedParameterSuffix)), searchClauseNamedParams, nil
} else if searchParam.Modifier == "exact" {
// "eve" matches "eve" (not "Eve" or "EVE")
return fmt.Sprintf("(%sJson.value = @%s)", searchParam.Name, NamedParameterWithSuffix(searchParam.Name, namedParameterSuffix)), searchClauseNamedParams, nil
} else if searchParam.Modifier == "contains" {
searchClauseNamedParams[NamedParameterWithSuffix(searchParam.Name, namedParameterSuffix)] = "%" + searchParamValue.Value.(string) + "%" // "eve" matches "Eve", "Evelyn" and "Severine"
return fmt.Sprintf("(%sJson.value LIKE @%s)", searchParam.Name, NamedParameterWithSuffix(searchParam.Name, namedParameterSuffix)), searchClauseNamedParams, nil
}
case SearchParameterTypeQuantity:
//setup the clause
var clause string
if searchParamValue.Prefix == "" || searchParamValue.Prefix == "eq" {
//TODO: when no prefix is specified, we need to search using BETWEEN (+/- 0.05)
clause = fmt.Sprintf("%sJson.value ->> '$.value' = @%s", searchParam.Name, NamedParameterWithSuffix(searchParam.Name, namedParameterSuffix))
} else if searchParamValue.Prefix == "lt" || searchParamValue.Prefix == "eb" {
clause = fmt.Sprintf("%sJson.value ->> '$.value' < @%s", searchParam.Name, NamedParameterWithSuffix(searchParam.Name, namedParameterSuffix))
} else if searchParamValue.Prefix == "le" {
clause = fmt.Sprintf("%sJson.value ->> '$.value' <= @%s", searchParam.Name, NamedParameterWithSuffix(searchParam.Name, namedParameterSuffix))
} else if searchParamValue.Prefix == "gt" || searchParamValue.Prefix == "sa" {
clause = fmt.Sprintf("%sJson.value ->> '$.value' > @%s", searchParam.Name, NamedParameterWithSuffix(searchParam.Name, namedParameterSuffix))
} else if searchParamValue.Prefix == "ge" {
clause = fmt.Sprintf("%sJson.value ->> '$.value' >= @%s", searchParam.Name, NamedParameterWithSuffix(searchParam.Name, namedParameterSuffix))
} else if searchParamValue.Prefix == "ne" {
clause = fmt.Sprintf("%sJson.value ->> '$.value' <> @%s", searchParam.Name, NamedParameterWithSuffix(searchParam.Name, namedParameterSuffix))
} else if searchParamValue.Prefix == "ap" {
return "", nil, fmt.Errorf("search modifier 'ap' not supported for search parameter type %s (%s=%s)", searchParam.Type, searchParam.Name, searchParamValue.Value)
}
//append the code and/or system clauses (if required)
//this looks like unnecessary code, however its required to ensure consistent tests
allowedSecondaryKeys := []string{"code", "system"}
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))
}
}
return fmt.Sprintf("(%s)", clause), searchClauseNamedParams, nil
case SearchParameterTypeToken:
//unfortunately we don't know the datatype of this token, however, we're already preprocessed this field in backend/pkg/models/database/generate.go
// all of the following datatypes will be stored in a JSON object with the following structure:
// {
// "system": "http://example.com",
// "code": "example-code",
// "text": "example display"
// }
// primitive datatypes will not have a system or text, just a code (e.g. "code": true or "code": "http://www.example.com")
//
// - Coding - https://hl7.org/fhir/r4/datatypes.html#Coding
// - Identifier - https://hl7.org/fhir/r4/datatypes.html#Identifier
// - ContactPoint - https://hl7.org/fhir/r4/datatypes.html#ContactPoint
// - CodeableConcept - https://hl7.org/fhir/r4/datatypes.html#CodeableConcept
// - code - https://hl7.org/fhir/r4/datatypes.html#code
// - boolean - https://hl7.org/fhir/r4/datatypes.html#boolean
// - uri - https://hl7.org/fhir/r4/datatypes.html#uri
// - string - https://hl7.org/fhir/r4/datatypes.html#string
//TODO: support ":text" modifier
//setup the clause
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
allowedSecondaryKeys := []string{"system"}
for _, k := range allowedSecondaryKeys {
namedParameterKey := fmt.Sprintf("%s%s", searchParam.Name, strings.Title(k))
if _, ok := searchParamValue.SecondaryValues[namedParameterKey]; ok {
clause = append(clause, fmt.Sprintf(`%sJson.value ->> '$.%s' = @%s`, searchParam.Name, k, NamedParameterWithSuffix(namedParameterKey, namedParameterSuffix)))
}
}
return fmt.Sprintf("(%s)", strings.Join(clause, " AND ")), searchClauseNamedParams, nil
case SearchParameterTypeKeyword:
//setup the clause
return fmt.Sprintf("(%s = @%s)", searchParam.Name, NamedParameterWithSuffix(searchParam.Name, namedParameterSuffix)), searchClauseNamedParams, nil
case SearchParameterTypeReference:
return "", nil, fmt.Errorf("search parameter type %s not supported", searchParam.Type)
}
return "", searchClauseNamedParams, nil
}
func SearchCodeToFromClause(searchParam SearchParameter) (string, error) {
//complex search parameters (e.g. token, reference, quantities, special) require the use of `json_*` FROM clauses
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//COMPLEX SEARCH PARAMETERS
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
switch searchParam.Type {
case SearchParameterTypeQuantity, SearchParameterTypeToken, SearchParameterTypeString:
//setup the clause
return fmt.Sprintf("json_each(%s.%s) as %sJson", TABLE_ALIAS, searchParam.Name, searchParam.Name), nil
}
return "", nil
}
func AggregationParameterToClause(aggParameter AggregationParameter) string {
var clause string
switch aggParameter.Type {
case SearchParameterTypeQuantity, SearchParameterTypeString:
//setup the clause
clause = fmt.Sprintf("(%sJson.value ->> '$.%s')", aggParameter.Name, aggParameter.Modifier)
case SearchParameterTypeToken:
//modifier is optional for token types.
if aggParameter.Modifier != "" {
clause = fmt.Sprintf("(%sJson.value ->> '$.%s')", aggParameter.Name, aggParameter.Modifier)
} else {
//if no modifier is specified, use the system and code to generate the clause
//((codeJson.value ->> '$.system') || '|' || (codeJson.value ->> '$.code'))
clause = fmt.Sprintf("((%sJson.value ->> '$.system') || '|' || (%sJson.value ->> '$.code'))", aggParameter.Name, aggParameter.Name)
}
default:
clause = fmt.Sprintf("%s.%s", TABLE_ALIAS, aggParameter.Name)
}
if len(aggParameter.Function) > 0 {
clause = fmt.Sprintf("%s(%s)", aggParameter.Function, clause)
}
return clause
}
// ProcessAggregationParameter processes the aggregation parameters which are fields with optional properties:
// Fields that are primitive types (number, uri) must not have any property specified:
// eg. `probability`
//
// Fields that are complex types (token, quantity) must have a property specified:
// 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(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(aggregationFieldWithFn.Field, ":", 2); len(aggregationFieldParts) == 2 {
aggregationParameter.Name = aggregationFieldParts[0]
aggregationParameter.Modifier = aggregationFieldParts[1]
} else {
aggregationParameter.Name = aggregationFieldParts[0]
aggregationParameter.Modifier = ""
}
//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 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 || 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 {
return aggregationParameter, fmt.Errorf("complex aggregation parameter %s must have a property", aggregationParameter.Name)
}
}
return aggregationParameter, nil
}