Skip to content

Commit 4ed96d4

Browse files
AlexJSullyCopybara
andauthored
Add support for SupersetOf, Split and MemberOf (#26)
Add support for [SupersetOf](https://hl7.org/fhirpath/2018Sep/index.html#supersetofother-collection-boolean), `Split` and `MemberOf` experimental functions as well as some minor code improvements. GitOrigin-RevId: 0db5687e39b59f005daa4112fc45ded9d853825e Co-authored-by: Copybara <copybara@example.com>
1 parent 2774b93 commit 4ed96d4

20 files changed

Lines changed: 1401 additions & 9 deletions

fhirpath/evalopts/evalopts.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@ since this will simplify discovery of evaluation-specific options.
77
package evalopts
88

99
import (
10+
"context"
1011
"errors"
1112
"fmt"
1213
"time"
1314

1415
"github.com/verily-src/fhirpath-go/fhirpath/internal/opts"
1516
"github.com/verily-src/fhirpath-go/fhirpath/resolver"
1617
"github.com/verily-src/fhirpath-go/fhirpath/system"
18+
"github.com/verily-src/fhirpath-go/fhirpath/terminology"
1719
"github.com/verily-src/fhirpath-go/internal/fhir"
1820
)
1921

@@ -81,3 +83,19 @@ func WithResolver(resolver resolver.Resolver) opts.EvaluateOption {
8183
return nil
8284
})
8385
}
86+
87+
// WithTerminologyService returns an EvaluateOption that sets a Terminology Service in the context
88+
func WithTerminologyService(termService terminology.Service) opts.EvaluateOption {
89+
return opts.Transform(func(cfg *opts.EvaluateConfig) error {
90+
cfg.Context.TermService = termService
91+
return nil
92+
})
93+
}
94+
95+
// WithContext returns an EvaluateOption that sets the Golang context.Context in the expr.Context
96+
func WithContext(ctx context.Context) opts.EvaluateOption {
97+
return opts.Transform(func(cfg *opts.EvaluateConfig) error {
98+
cfg.Context.GoContext = ctx
99+
return nil
100+
})
101+
}

fhirpath/fhirjson/decoder_test.go

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
package fhirjson_test
2+
3+
import (
4+
"bytes"
5+
"testing"
6+
"time"
7+
8+
codes_go_proto "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/codes_go_proto"
9+
dtpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/datatypes_go_proto"
10+
opb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/observation_go_proto"
11+
ppb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/patient_go_proto"
12+
13+
"github.com/verily-src/fhirpath-go/fhirpath/fhirjson"
14+
"github.com/verily-src/fhirpath-go/internal/fhir"
15+
16+
"github.com/google/go-cmp/cmp"
17+
"google.golang.org/protobuf/testing/protocmp"
18+
)
19+
20+
func TestDecodeNew_Success(t *testing.T) {
21+
testCases := []struct {
22+
name string
23+
input string
24+
want fhir.Resource
25+
}{
26+
{
27+
name: "Patient resource",
28+
input: `{"resourceType": "Patient", "id": "p1"}`,
29+
want: &ppb.Patient{
30+
Id: &dtpb.Id{Value: "p1"},
31+
},
32+
},
33+
{
34+
name: "Minimal Patient",
35+
input: `{"resourceType": "Patient"}`,
36+
want: &ppb.Patient{},
37+
},
38+
}
39+
40+
for _, tc := range testCases {
41+
t.Run(tc.name, func(t *testing.T) {
42+
dec := fhirjson.NewDecoder(bytes.NewReader([]byte(tc.input)))
43+
44+
res, err := dec.DecodeNew()
45+
if err != nil {
46+
t.Errorf("DecodeNew error: got %v, want nil", err)
47+
}
48+
49+
if got, want := res, tc.want; !cmp.Equal(got, want, protocmp.Transform()) {
50+
t.Errorf("DecodeNew resource: got %v, want %v", got, want)
51+
}
52+
})
53+
}
54+
}
55+
56+
func TestDecodeNew_Error(t *testing.T) {
57+
testCases := []struct {
58+
name string
59+
input string
60+
}{
61+
{
62+
name: "Invalid JSON",
63+
input: `{"resourceType": "Patient",`,
64+
},
65+
{
66+
name: "Empty input",
67+
input: ``,
68+
},
69+
}
70+
71+
for _, tc := range testCases {
72+
t.Run(tc.name, func(t *testing.T) {
73+
dec := fhirjson.NewDecoder(bytes.NewReader([]byte(tc.input)))
74+
75+
res, err := dec.DecodeNew()
76+
if err == nil {
77+
t.Errorf("DecodeNew: expected error, got nil")
78+
}
79+
80+
if res != nil {
81+
t.Errorf("DecodeNew: expected nil resource, got %v", res)
82+
}
83+
})
84+
}
85+
}
86+
87+
func TestDecoderTimeZone(t *testing.T) {
88+
testCases := []struct {
89+
name string
90+
json string
91+
zone *time.Location
92+
want fhir.Resource
93+
}{
94+
{
95+
name: "Not setting timezone implies Local",
96+
json: `{
97+
"resourceType": "Patient",
98+
"id": "example-patient",
99+
"birthDate": "1990-05-27"
100+
}`,
101+
zone: nil,
102+
want: &ppb.Patient{
103+
Id: &dtpb.Id{Value: "example-patient"},
104+
BirthDate: &dtpb.Date{
105+
ValueUs: time.Date(1990, 5, 27, 0, 0, 0, 0, time.UTC).UnixMicro(),
106+
Timezone: "Local",
107+
Precision: dtpb.Date_DAY,
108+
},
109+
},
110+
},
111+
{
112+
name: "Set timezone to UTC",
113+
json: `{
114+
"resourceType": "Patient",
115+
"id": "example-patient",
116+
"birthDate": "1990-05-27"
117+
}`,
118+
zone: time.UTC,
119+
want: &ppb.Patient{
120+
Id: &dtpb.Id{Value: "example-patient"},
121+
BirthDate: &dtpb.Date{
122+
ValueUs: time.Date(1990, 5, 27, 0, 0, 0, 0, time.UTC).UnixMicro(),
123+
Timezone: "UTC",
124+
Precision: dtpb.Date_DAY,
125+
},
126+
},
127+
},
128+
{
129+
name: "Timezone does not apply to DateTime",
130+
json: `{
131+
"resourceType": "Observation",
132+
"id": "example-observation",
133+
"status": "final",
134+
"code": { "coding": [ { "system": "http://loinc.org", "code": "1234-5" } ] },
135+
"effectiveDateTime": "2022-01-02T03:04:05Z"
136+
}`,
137+
zone: time.UTC,
138+
want: &opb.Observation{
139+
Id: &dtpb.Id{Value: "example-observation"},
140+
Status: &opb.Observation_StatusCode{Value: codes_go_proto.ObservationStatusCode_FINAL},
141+
Code: &dtpb.CodeableConcept{
142+
Coding: []*dtpb.Coding{
143+
{
144+
System: &dtpb.Uri{Value: "http://loinc.org"},
145+
Code: &dtpb.Code{Value: "1234-5"},
146+
},
147+
},
148+
},
149+
Effective: &opb.Observation_EffectiveX{
150+
Choice: &opb.Observation_EffectiveX_DateTime{
151+
DateTime: &dtpb.DateTime{
152+
ValueUs: time.Date(2022, 1, 2, 3, 4, 5, 0, time.UTC).UnixMicro(),
153+
Timezone: "Z",
154+
Precision: dtpb.DateTime_SECOND,
155+
},
156+
},
157+
},
158+
},
159+
},
160+
}
161+
162+
for _, tc := range testCases {
163+
t.Run(tc.name, func(t *testing.T) {
164+
dec := fhirjson.NewDecoder(bytes.NewReader([]byte(tc.json)))
165+
if tc.zone != nil {
166+
dec.TimeZone(tc.zone)
167+
}
168+
169+
var got fhir.Resource
170+
switch tc.want.(type) {
171+
case *ppb.Patient:
172+
got = &ppb.Patient{}
173+
case *opb.Observation:
174+
got = &opb.Observation{}
175+
default:
176+
t.Fatalf("unsupported resource type: %T", tc.want)
177+
}
178+
179+
err := dec.Decode(got)
180+
if err != nil {
181+
t.Errorf("Decode error: got %v, want nil", err)
182+
}
183+
184+
if got, want := got, tc.want; !cmp.Equal(got, want, protocmp.Transform()) {
185+
t.Errorf("Decode resource diff: %s", cmp.Diff(got, want, protocmp.Transform()))
186+
}
187+
})
188+
}
189+
}

fhirpath/internal/expr/context.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
package expr
22

33
import (
4+
"context"
45
"time"
56

67
"github.com/verily-src/fhirpath-go/fhirpath/resolver"
78
"github.com/verily-src/fhirpath-go/fhirpath/system"
9+
"github.com/verily-src/fhirpath-go/fhirpath/terminology"
810
)
911

1012
// Context holds the global time and external constant
@@ -28,6 +30,33 @@ type Context struct {
2830
// Resolver is an optional mechanism for resolving FHIR Resources that
2931
// is used in the 'resolve()' FHIRPath function.
3032
Resolver resolver.Resolver
33+
34+
// Service is an optional mechanism for providing a terminology service
35+
// which can be used to validate code in valueSet
36+
TermService terminology.Service
37+
38+
// GoContext is a context from the calling main function
39+
GoContext context.Context
40+
}
41+
42+
// Deadline wraps the Deadline() method of context.Context. More information available at https://pkg.go.dev/context
43+
func (c *Context) Deadline() (deadline time.Time, ok bool) {
44+
return c.GoContext.Deadline()
45+
}
46+
47+
// Done wraps the Done() method of context.Context. More information available at https://pkg.go.dev/context
48+
func (c *Context) Done() <-chan struct{} {
49+
return c.GoContext.Done()
50+
}
51+
52+
// Err wraps the Err() method of context.Context. More information available at https://pkg.go.dev/context
53+
func (c *Context) Err() error {
54+
return c.GoContext.Err()
55+
}
56+
57+
// Value wraps the Value() method of context.Context. More information available at https://pkg.go.dev/context
58+
func (c *Context) Value(key any) any {
59+
return c.GoContext.Value(key)
3160
}
3261

3362
// Clone copies this Context object to produce a new instance.
@@ -37,6 +66,8 @@ func (c *Context) Clone() *Context {
3766
ExternalConstants: c.ExternalConstants,
3867
LastResult: c.LastResult,
3968
Resolver: c.Resolver,
69+
TermService: c.TermService,
70+
GoContext: c.GoContext,
4071
}
4172
}
4273

fhirpath/internal/funcs/impl/errors.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ import "errors"
66
var (
77
ErrWrongArity = errors.New("incorrect function arity")
88
ErrInvalidReturnType = errors.New("invalid return type")
9+
ErrNotSingleton = errors.New("invalid cardinality: not a singleton")
910
)

fhirpath/internal/funcs/impl/existence.go

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -162,25 +162,56 @@ func SubsetOf(ctx *expr.Context, input system.Collection, args ...expr.Expressio
162162
return nil, err
163163
}
164164

165+
return subset(input, otherCollection)
166+
}
167+
168+
// SupersetOf returns true if all items in the other collection are members of the
169+
// input collection. Membership is determined using the equals (=) operation.
170+
//
171+
// If the input collection is empty, the result is false.
172+
// If the other collection is empty, the result is true.
173+
//
174+
// https://hl7.org/fhirpath/N1/index.html#supersetofother-collection-boolean
175+
func SupersetOf(ctx *expr.Context, input system.Collection, args ...expr.Expression) (system.Collection, error) {
176+
// Validate exactly one argument is provided
177+
if len(args) != 1 {
178+
return nil, fmt.Errorf("%w: received %v arguments, expected 1", ErrWrongArity, len(args))
179+
}
180+
181+
// Evaluate the other collection argument in the current context
182+
otherCollection, err := args[0].Evaluate(ctx, input)
183+
if err != nil {
184+
return nil, err
185+
}
186+
187+
// Check if otherCollection is a subset of input
188+
// i.e. input is a superset of otherCollection
189+
return subset(otherCollection, input)
190+
}
191+
192+
// subset checks if input collection is a subset of the other collection
193+
// using FHIRPath equality semantics.
194+
// It returns a Collection containing a single Boolean result.
195+
func subset(input system.Collection, other system.Collection) (system.Collection, error) {
165196
// Empty input collection is always a subset (conceptually true)
166197
if input.IsEmpty() {
167198
return system.Collection{system.Boolean(true)}, nil
168199
}
169200

170201
// Non-empty input with empty other collection means false
171-
if otherCollection.IsEmpty() {
202+
if other.IsEmpty() {
172203
return system.Collection{system.Boolean(false)}, nil
173204
}
174205

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

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

182213
// Search for an unused matching element in the other collection
183-
for i, otherItem := range otherCollection {
214+
for i, otherItem := range other {
184215
// Skip if this element has already been used (bag semantics)
185216
if used[i] {
186217
continue

0 commit comments

Comments
 (0)