685 lines
33 KiB
Go
685 lines
33 KiB
Go
package database
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"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)
|
|
fromClauses = lo.Uniq(fromClauses)
|
|
fromClauses = lo.Compact(fromClauses)
|
|
|
|
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
|
|
}
|