Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions fhirpath/evalopts/evalopts.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ since this will simplify discovery of evaluation-specific options.
package evalopts

import (
"context"
"errors"
"fmt"
"time"

"github.com/verily-src/fhirpath-go/fhirpath/internal/opts"
"github.com/verily-src/fhirpath-go/fhirpath/resolver"
"github.com/verily-src/fhirpath-go/fhirpath/system"
"github.com/verily-src/fhirpath-go/fhirpath/terminology"
"github.com/verily-src/fhirpath-go/internal/fhir"
)

Expand Down Expand Up @@ -81,3 +83,19 @@ func WithResolver(resolver resolver.Resolver) opts.EvaluateOption {
return nil
})
}

// WithTerminologyService returns an EvaluateOption that sets a Terminology Service in the context
func WithTerminologyService(termService terminology.Service) opts.EvaluateOption {
return opts.Transform(func(cfg *opts.EvaluateConfig) error {
cfg.Context.TermService = termService
return nil
})
}

// WithContext returns an EvaluateOption that sets the Golang context.Context in the expr.Context
func WithContext(ctx context.Context) opts.EvaluateOption {
return opts.Transform(func(cfg *opts.EvaluateConfig) error {
cfg.Context.GoContext = ctx
return nil
})
}
189 changes: 189 additions & 0 deletions fhirpath/fhirjson/decoder_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
package fhirjson_test

import (
"bytes"
"testing"
"time"

codes_go_proto "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/codes_go_proto"
dtpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/datatypes_go_proto"
opb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/observation_go_proto"
ppb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/patient_go_proto"

"github.com/verily-src/fhirpath-go/fhirpath/fhirjson"
"github.com/verily-src/fhirpath-go/internal/fhir"

"github.com/google/go-cmp/cmp"
"google.golang.org/protobuf/testing/protocmp"
)

func TestDecodeNew_Success(t *testing.T) {
testCases := []struct {
name string
input string
want fhir.Resource
}{
{
name: "Patient resource",
input: `{"resourceType": "Patient", "id": "p1"}`,
want: &ppb.Patient{
Id: &dtpb.Id{Value: "p1"},
},
},
{
name: "Minimal Patient",
input: `{"resourceType": "Patient"}`,
want: &ppb.Patient{},
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
dec := fhirjson.NewDecoder(bytes.NewReader([]byte(tc.input)))

res, err := dec.DecodeNew()
if err != nil {
t.Errorf("DecodeNew error: got %v, want nil", err)
}

if got, want := res, tc.want; !cmp.Equal(got, want, protocmp.Transform()) {
t.Errorf("DecodeNew resource: got %v, want %v", got, want)
}
})
}
}

func TestDecodeNew_Error(t *testing.T) {
testCases := []struct {
name string
input string
}{
{
name: "Invalid JSON",
input: `{"resourceType": "Patient",`,
},
{
name: "Empty input",
input: ``,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
dec := fhirjson.NewDecoder(bytes.NewReader([]byte(tc.input)))

res, err := dec.DecodeNew()
if err == nil {
t.Errorf("DecodeNew: expected error, got nil")
}

if res != nil {
t.Errorf("DecodeNew: expected nil resource, got %v", res)
}
})
}
}

func TestDecoderTimeZone(t *testing.T) {
testCases := []struct {
name string
json string
zone *time.Location
want fhir.Resource
}{
{
name: "Not setting timezone implies Local",
json: `{
"resourceType": "Patient",
"id": "example-patient",
"birthDate": "1990-05-27"
}`,
zone: nil,
want: &ppb.Patient{
Id: &dtpb.Id{Value: "example-patient"},
BirthDate: &dtpb.Date{
ValueUs: time.Date(1990, 5, 27, 0, 0, 0, 0, time.UTC).UnixMicro(),
Timezone: "Local",
Precision: dtpb.Date_DAY,
},
},
},
{
name: "Set timezone to UTC",
json: `{
"resourceType": "Patient",
"id": "example-patient",
"birthDate": "1990-05-27"
}`,
zone: time.UTC,
want: &ppb.Patient{
Id: &dtpb.Id{Value: "example-patient"},
BirthDate: &dtpb.Date{
ValueUs: time.Date(1990, 5, 27, 0, 0, 0, 0, time.UTC).UnixMicro(),
Timezone: "UTC",
Precision: dtpb.Date_DAY,
},
},
},
{
name: "Timezone does not apply to DateTime",
json: `{
"resourceType": "Observation",
"id": "example-observation",
"status": "final",
"code": { "coding": [ { "system": "http://loinc.org", "code": "1234-5" } ] },
"effectiveDateTime": "2022-01-02T03:04:05Z"
}`,
zone: time.UTC,
want: &opb.Observation{
Id: &dtpb.Id{Value: "example-observation"},
Status: &opb.Observation_StatusCode{Value: codes_go_proto.ObservationStatusCode_FINAL},
Code: &dtpb.CodeableConcept{
Coding: []*dtpb.Coding{
{
System: &dtpb.Uri{Value: "http://loinc.org"},
Code: &dtpb.Code{Value: "1234-5"},
},
},
},
Effective: &opb.Observation_EffectiveX{
Choice: &opb.Observation_EffectiveX_DateTime{
DateTime: &dtpb.DateTime{
ValueUs: time.Date(2022, 1, 2, 3, 4, 5, 0, time.UTC).UnixMicro(),
Timezone: "Z",
Precision: dtpb.DateTime_SECOND,
},
},
},
},
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
dec := fhirjson.NewDecoder(bytes.NewReader([]byte(tc.json)))
if tc.zone != nil {
dec.TimeZone(tc.zone)
}

var got fhir.Resource
switch tc.want.(type) {
case *ppb.Patient:
got = &ppb.Patient{}
case *opb.Observation:
got = &opb.Observation{}
default:
t.Fatalf("unsupported resource type: %T", tc.want)
}

err := dec.Decode(got)
if err != nil {
t.Errorf("Decode error: got %v, want nil", err)
}

if got, want := got, tc.want; !cmp.Equal(got, want, protocmp.Transform()) {
t.Errorf("Decode resource diff: %s", cmp.Diff(got, want, protocmp.Transform()))
}
})
}
}
31 changes: 31 additions & 0 deletions fhirpath/internal/expr/context.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package expr

import (
"context"
"time"

"github.com/verily-src/fhirpath-go/fhirpath/resolver"
"github.com/verily-src/fhirpath-go/fhirpath/system"
"github.com/verily-src/fhirpath-go/fhirpath/terminology"
)

// Context holds the global time and external constant
Expand All @@ -28,6 +30,33 @@ type Context struct {
// Resolver is an optional mechanism for resolving FHIR Resources that
// is used in the 'resolve()' FHIRPath function.
Resolver resolver.Resolver

// Service is an optional mechanism for providing a terminology service
// which can be used to validate code in valueSet
TermService terminology.Service

// GoContext is a context from the calling main function
GoContext context.Context
}

// Deadline wraps the Deadline() method of context.Context. More information available at https://pkg.go.dev/context
func (c *Context) Deadline() (deadline time.Time, ok bool) {
return c.GoContext.Deadline()
}

// Done wraps the Done() method of context.Context. More information available at https://pkg.go.dev/context
func (c *Context) Done() <-chan struct{} {
return c.GoContext.Done()
}

// Err wraps the Err() method of context.Context. More information available at https://pkg.go.dev/context
func (c *Context) Err() error {
return c.GoContext.Err()
}

// Value wraps the Value() method of context.Context. More information available at https://pkg.go.dev/context
func (c *Context) Value(key any) any {
return c.GoContext.Value(key)
}

// Clone copies this Context object to produce a new instance.
Expand All @@ -37,6 +66,8 @@ func (c *Context) Clone() *Context {
ExternalConstants: c.ExternalConstants,
LastResult: c.LastResult,
Resolver: c.Resolver,
TermService: c.TermService,
GoContext: c.GoContext,
}
}

Expand Down
1 change: 1 addition & 0 deletions fhirpath/internal/funcs/impl/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ import "errors"
var (
ErrWrongArity = errors.New("incorrect function arity")
ErrInvalidReturnType = errors.New("invalid return type")
ErrNotSingleton = errors.New("invalid cardinality: not a singleton")
)
37 changes: 34 additions & 3 deletions fhirpath/internal/funcs/impl/existence.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,25 +162,56 @@ func SubsetOf(ctx *expr.Context, input system.Collection, args ...expr.Expressio
return nil, err
}

return subset(input, otherCollection)
}

// SupersetOf returns true if all items in the other collection are members of the
// input collection. Membership is determined using the equals (=) operation.
//
// If the input collection is empty, the result is false.
// If the other collection is empty, the result is true.
//
// https://hl7.org/fhirpath/N1/index.html#supersetofother-collection-boolean
func SupersetOf(ctx *expr.Context, input system.Collection, args ...expr.Expression) (system.Collection, error) {
// Validate exactly one argument is provided
if len(args) != 1 {
return nil, fmt.Errorf("%w: received %v arguments, expected 1", ErrWrongArity, len(args))
}

// Evaluate the other collection argument in the current context
otherCollection, err := args[0].Evaluate(ctx, input)
if err != nil {
return nil, err
}

// Check if otherCollection is a subset of input
// i.e. input is a superset of otherCollection
return subset(otherCollection, input)
}

// subset checks if input collection is a subset of the other collection
// using FHIRPath equality semantics.
// It returns a Collection containing a single Boolean result.
func subset(input system.Collection, other system.Collection) (system.Collection, error) {
// Empty input collection is always a subset (conceptually true)
if input.IsEmpty() {
return system.Collection{system.Boolean(true)}, nil
}

// Non-empty input with empty other collection means false
if otherCollection.IsEmpty() {
if other.IsEmpty() {
return system.Collection{system.Boolean(false)}, nil
}

// Implement bag semantics using a boolean slice to track used elements.
used := make([]bool, len(otherCollection))
used := make([]bool, len(other))

// Check each input element for membership in the other collection
for _, inputItem := range input {
found := false

// Search for an unused matching element in the other collection
for i, otherItem := range otherCollection {
for i, otherItem := range other {
// Skip if this element has already been used (bag semantics)
if used[i] {
continue
Expand Down
Loading