diff --git a/fhirpath/evalopts/evalopts.go b/fhirpath/evalopts/evalopts.go index 51cabb3..b1e80e1 100644 --- a/fhirpath/evalopts/evalopts.go +++ b/fhirpath/evalopts/evalopts.go @@ -7,6 +7,7 @@ since this will simplify discovery of evaluation-specific options. package evalopts import ( + "context" "errors" "fmt" "time" @@ -14,6 +15,7 @@ import ( "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" ) @@ -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 + }) +} diff --git a/fhirpath/fhirjson/decoder_test.go b/fhirpath/fhirjson/decoder_test.go new file mode 100644 index 0000000..d289d8b --- /dev/null +++ b/fhirpath/fhirjson/decoder_test.go @@ -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())) + } + }) + } +} diff --git a/fhirpath/internal/expr/context.go b/fhirpath/internal/expr/context.go index 6f8b788..a480fd4 100644 --- a/fhirpath/internal/expr/context.go +++ b/fhirpath/internal/expr/context.go @@ -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 @@ -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. @@ -37,6 +66,8 @@ func (c *Context) Clone() *Context { ExternalConstants: c.ExternalConstants, LastResult: c.LastResult, Resolver: c.Resolver, + TermService: c.TermService, + GoContext: c.GoContext, } } diff --git a/fhirpath/internal/funcs/impl/errors.go b/fhirpath/internal/funcs/impl/errors.go index f4089b4..433239e 100644 --- a/fhirpath/internal/funcs/impl/errors.go +++ b/fhirpath/internal/funcs/impl/errors.go @@ -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") ) diff --git a/fhirpath/internal/funcs/impl/existence.go b/fhirpath/internal/funcs/impl/existence.go index 6ee2dd1..c418c2b 100644 --- a/fhirpath/internal/funcs/impl/existence.go +++ b/fhirpath/internal/funcs/impl/existence.go @@ -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 diff --git a/fhirpath/internal/funcs/impl/existence_test.go b/fhirpath/internal/funcs/impl/existence_test.go index 0b71e8a..6bc3717 100644 --- a/fhirpath/internal/funcs/impl/existence_test.go +++ b/fhirpath/internal/funcs/impl/existence_test.go @@ -983,3 +983,313 @@ func TestSubsetOf_Errors(t *testing.T) { }) } } + +func TestSupersetOf_Success(t *testing.T) { + testCases := []struct { + name string + input system.Collection + args []expr.Expression + want system.Collection + }{ + { + name: "returns false if input is empty", + input: system.Collection{}, + args: []expr.Expression{exprtest.Return(system.Integer(1), system.Integer(2))}, + want: system.Collection{system.Boolean(false)}, + }, + { + name: "returns true if other collection is empty but input is not", + input: system.Collection{ + system.Integer(1), + system.Integer(2), + }, + args: []expr.Expression{exprtest.Return()}, + want: system.Collection{system.Boolean(true)}, + }, + { + name: "returns true if input is superset of argument collection", + input: system.Collection{ + system.Integer(1), + system.Integer(2), + system.Integer(3), + system.Integer(4), + }, + args: []expr.Expression{exprtest.Return( + system.Integer(1), + system.Integer(2), + )}, + want: system.Collection{system.Boolean(true)}, + }, + { + name: "returns false if input is not superset of argument collection", + input: system.Collection{ + system.Integer(5), + }, + args: []expr.Expression{exprtest.Return( + system.Integer(1), + system.Integer(2), + system.Integer(3), + system.Integer(4), + )}, + want: system.Collection{system.Boolean(false)}, + }, + { + name: "returns true if input equals argument collection", + input: system.Collection{ + system.Integer(1), + system.Integer(2), + system.Integer(3), + }, + args: []expr.Expression{exprtest.Return( + system.Integer(1), + system.Integer(2), + system.Integer(3), + )}, + want: system.Collection{system.Boolean(true)}, + }, + { + name: "returns true with fhir.Integer types - superset case", + input: system.Collection{ + fhir.Integer(1), + fhir.Integer(2), + fhir.Integer(3), + }, + args: []expr.Expression{exprtest.Return( + fhir.Integer(1), + fhir.Integer(2), + )}, + want: system.Collection{system.Boolean(true)}, + }, + { + name: "returns false with fhir.Integer types - not superset case", + input: system.Collection{ + fhir.Integer(1), + fhir.Integer(4), + }, + args: []expr.Expression{exprtest.Return( + fhir.Integer(1), + fhir.Integer(2), + fhir.Integer(3), + )}, + want: system.Collection{system.Boolean(false)}, + }, + { + name: "returns true with mixed system and fhir types", + input: system.Collection{ + system.Integer(1), + fhir.Integer(2), + system.Integer(3), + }, + args: []expr.Expression{exprtest.Return( + fhir.Integer(1), + system.Integer(2), + )}, + want: system.Collection{system.Boolean(true)}, + }, + { + name: "returns true with string collections - superset case", + input: system.Collection{ + system.String("a"), + system.String("b"), + system.String("c"), + }, + args: []expr.Expression{exprtest.Return( + system.String("a"), + system.String("b"), + )}, + want: system.Collection{system.Boolean(true)}, + }, + { + name: "returns false with string collections - not superset case", + input: system.Collection{ + system.String("a"), + system.String("d"), + }, + args: []expr.Expression{exprtest.Return( + system.String("a"), + system.String("b"), + system.String("c"), + )}, + want: system.Collection{system.Boolean(false)}, + }, + { + name: "returns true with coding collections - superset case", + input: system.Collection{ + fhir.Coding("loinc-system", "loinc-code"), + fhir.Coding("snomed-system", "snomed-code"), + fhir.Coding("icd10-system", "icd10-code"), + }, + args: []expr.Expression{exprtest.Return( + fhir.Coding("loinc-system", "loinc-code"), + fhir.Coding("snomed-system", "snomed-code"), + )}, + want: system.Collection{system.Boolean(true)}, + }, + { + name: "returns false with coding collections - not superset case", + input: system.Collection{ + fhir.Coding("loinc-system", "loinc-code"), + fhir.Coding("missing-system", "missing-code"), + }, + args: []expr.Expression{exprtest.Return( + fhir.Coding("loinc-system", "loinc-code"), + fhir.Coding("snomed-system", "snomed-code"), + )}, + want: system.Collection{system.Boolean(false)}, + }, + { + name: "treats FHIR messages with different field orders as equivalent - superset case", + input: system.Collection{ + &dtpb.HumanName{ + Given: []*dtpb.String{fhir.String("John")}, + Family: fhir.String("Doe"), + }, + &dtpb.HumanName{ + Given: []*dtpb.String{fhir.String("Jane")}, + Family: fhir.String("Smith"), + }, + }, + args: []expr.Expression{exprtest.Return( + &dtpb.HumanName{ + Family: fhir.String("Doe"), + Given: []*dtpb.String{fhir.String("John")}, + }, + )}, + want: system.Collection{system.Boolean(true)}, + }, + { + name: "returns false for large collections - element not in superset", + input: system.Collection{ + system.Integer(1), + system.Integer(2), + system.Integer(3), + system.Integer(4), + system.Integer(5), + }, + args: []expr.Expression{exprtest.Return( + system.Integer(1), + system.Integer(2), + system.Integer(3), + system.Integer(4), + system.Integer(6), + system.Integer(7), + )}, + want: system.Collection{system.Boolean(false)}, + }, + { + name: "returns true for large collections - proper superset", + input: system.Collection{ + system.Integer(1), + system.Integer(2), + system.Integer(3), + system.Integer(4), + system.Integer(5), + system.Integer(6), + }, + args: []expr.Expression{exprtest.Return( + system.Integer(1), + system.Integer(2), + system.Integer(3), + system.Integer(4), + system.Integer(5), + )}, + want: system.Collection{system.Boolean(true)}, + }, + { + name: "returns false for multiset - duplicate elements in other collection", + input: system.Collection{ + system.Integer(1), + system.Integer(2), + }, + args: []expr.Expression{exprtest.Return( + system.Integer(1), + system.Integer(1), + )}, + want: system.Collection{system.Boolean(false)}, + }, + { + name: "returns true for multiset - input has more duplicates than other collection", + input: system.Collection{ + system.Integer(1), + system.Integer(1), + system.Integer(1), + system.Integer(2), + system.Integer(3), + }, + args: []expr.Expression{exprtest.Return( + system.Integer(1), + )}, + want: system.Collection{system.Boolean(true)}, + }, + { + name: "returns false for multiset - input has fewer duplicates than other collection", + input: system.Collection{ + system.Integer(1), + system.Integer(2), + }, + args: []expr.Expression{exprtest.Return( + system.Integer(1), + system.Integer(1), + system.Integer(2), + system.Integer(2), + )}, + want: system.Collection{system.Boolean(false)}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got, err := impl.SupersetOf(&expr.Context{}, tc.input, tc.args...) + if err != nil { + t.Errorf("SupersetOf() error = %v", err) + } + if diff := cmp.Diff(tc.want, got, protocmp.Transform()); diff != "" { + t.Errorf("SupersetOf() returned unexpected diff (-want, +got)\n%s", diff) + } + }) + } +} + +func TestSupersetOf_Errors(t *testing.T) { + testCases := []struct { + name string + inputArgs []expr.Expression + inputCollection system.Collection + wantErrMsg string + }{ + { + name: "no arguments provided", + inputArgs: []expr.Expression{}, + inputCollection: system.Collection{system.Integer(1), system.Integer(2)}, + wantErrMsg: "incorrect function arity: received 0 arguments, expected 1", + }, + { + name: "multiple arguments provided", + inputArgs: []expr.Expression{ + exprtest.Return(system.Integer(1)), + exprtest.Return(system.Integer(2)), + }, + inputCollection: system.Collection{system.Integer(1)}, + wantErrMsg: "incorrect function arity: received 2 arguments, expected 1", + }, + { + name: "argument expression raises error", + inputArgs: []expr.Expression{exprtest.Error(errors.New("some error"))}, + inputCollection: system.Collection{system.Integer(1)}, + wantErrMsg: "some error", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + _, err := impl.SupersetOf(&expr.Context{}, tc.inputCollection, tc.inputArgs...) + if err == nil { + t.Fatalf("evaluating SupersetOf function didn't return error when expected") + } + + if err.Error() != tc.wantErrMsg { + t.Errorf("SupersetOf() error = %v, wantErrMsg %v", err, tc.wantErrMsg) + } + }) + } +} diff --git a/fhirpath/internal/funcs/impl/strings.go b/fhirpath/internal/funcs/impl/strings.go index 6feacc8..9f15e6e 100644 --- a/fhirpath/internal/funcs/impl/strings.go +++ b/fhirpath/internal/funcs/impl/strings.go @@ -471,3 +471,63 @@ func Join(ctx *expr.Context, input system.Collection, args ...expr.Expression) ( } return system.Collection{system.String(strings.Join(strs, delimiter))}, nil } + +// Split splits the input string by the specified separator and returns a collection of strings. +// +// Syntax: input.split(separator) +// +// Examples: +// +// empty.split(',') -> [] (empty input produces empty collection) +// 'a,b,c'.split(empty) -> [] (empty separator argument produces empty collection) +// ''.split(',') -> [''] (empty string produces single empty element) +// 'a,b,c'.split(',') -> ['a', 'b', 'c'] (single-char separator) +// 'a::b::c'.split('::') -> ['a', 'b', 'c'] (multi-char separator) +// patient.reference.split('/') -> ['Patient', '12345'] (e.g. 'Patient/12345') +// 'abc'.split('') -> ['a', 'b', 'c'] (empty separator splits to chars) +// 'abc'.split(',') -> ['abc'] (no split occurs) +func Split(ctx *expr.Context, input system.Collection, args ...expr.Expression) (system.Collection, error) { + // Validate single argument (separator) + if len(args) != 1 { + return nil, fmt.Errorf("%w: received %v arguments, expected 1", ErrWrongArity, len(args)) + } + + if input.IsEmpty() { + // Empty input returns empty collection + return system.Collection{}, nil + } + + // Validate single string input + if !input.IsSingleton() { + return nil, fmt.Errorf("%w: input has %d elements", ErrNotSingleton, len(input)) + } + fullString, err := input.ToString() + if err != nil { + return nil, err + } + + // Evaluate the separator argument + argValues, err := args[0].Evaluate(ctx, input) + if err != nil { + return nil, err + } + if argValues.IsEmpty() { + // Empty separator argument returns empty collection + return system.Collection{}, nil + } + if !argValues.IsSingleton() { + return nil, fmt.Errorf("%w: separator has %d elements", ErrNotSingleton, len(argValues)) + } + separator, err := argValues.ToString() + if err != nil { + return nil, err + } + + // Split the string and return collection of strings + parts := strings.Split(fullString, separator) + result := make(system.Collection, len(parts)) + for i, part := range parts { + result[i] = system.String(part) + } + return result, nil +} diff --git a/fhirpath/internal/funcs/impl/strings_test.go b/fhirpath/internal/funcs/impl/strings_test.go index 8099200..c87e067 100644 --- a/fhirpath/internal/funcs/impl/strings_test.go +++ b/fhirpath/internal/funcs/impl/strings_test.go @@ -4,6 +4,8 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/verily-src/fhirpath-go/fhirpath/internal/expr" "github.com/verily-src/fhirpath-go/fhirpath/internal/funcs/impl" "github.com/verily-src/fhirpath-go/fhirpath/system" @@ -1247,3 +1249,190 @@ func TestJoin(t *testing.T) { }) } } + +func TestSplit(t *testing.T) { + testCases := []struct { + name string + input system.Collection + args []expr.Expression + want system.Collection + wantErr error + }{ + { + name: "returns empty for empty input", + input: system.Collection{}, + args: []expr.Expression{ + &expr.LiteralExpression{Literal: system.String(",")}, + }, + want: system.Collection{}, + wantErr: nil, + }, + { + name: "returns empty for empty separator arg", + input: system.Collection{system.String("apple,banana,cherry")}, + args: []expr.Expression{ + &expr.LiteralExpression{}, + }, + want: system.Collection{}, + wantErr: nil, + }, + { + name: "splits string by comma separator", + input: system.Collection{system.String("apple,banana,cherry")}, + args: []expr.Expression{ + &expr.LiteralExpression{Literal: system.String(",")}, + }, + want: system.Collection{ + system.String("apple"), + system.String("banana"), + system.String("cherry"), + }, + wantErr: nil, + }, + { + name: "splits string by space separator", + input: system.Collection{system.String("hello world test")}, + args: []expr.Expression{ + &expr.LiteralExpression{Literal: system.String(" ")}, + }, + want: system.Collection{ + system.String("hello"), + system.String("world"), + system.String("test"), + }, + wantErr: nil, + }, + { + name: "returns single string if separator not found", + input: system.Collection{system.String("nospaces")}, + args: []expr.Expression{ + &expr.LiteralExpression{Literal: system.String(" ")}, + }, + want: system.Collection{ + system.String("nospaces"), + }, + wantErr: nil, + }, + { + name: "handles empty string input", + input: system.Collection{system.String("")}, + args: []expr.Expression{ + &expr.LiteralExpression{Literal: system.String(",")}, + }, + want: system.Collection{ + system.String(""), + }, + wantErr: nil, + }, + { + name: "handles consecutive separators", + input: system.Collection{system.String("a,,b")}, + args: []expr.Expression{ + &expr.LiteralExpression{Literal: system.String(",")}, + }, + want: system.Collection{ + system.String("a"), + system.String(""), + system.String("b"), + }, + wantErr: nil, + }, + { + name: "splits by multi-character separator", + input: system.Collection{system.String("one::two::three")}, + args: []expr.Expression{ + &expr.LiteralExpression{Literal: system.String("::")}, + }, + want: system.Collection{ + system.String("one"), + system.String("two"), + system.String("three"), + }, + wantErr: nil, + }, + { + name: "handles separator at beginning and end", + input: system.Collection{system.String(",start,end,")}, + args: []expr.Expression{ + &expr.LiteralExpression{Literal: system.String(",")}, + }, + want: system.Collection{ + system.String(""), + system.String("start"), + system.String("end"), + system.String(""), + }, + wantErr: nil, + }, + { + name: "splits by empty string separator yields characters", + input: system.Collection{system.String("abc")}, + args: []expr.Expression{ + &expr.LiteralExpression{Literal: system.String("")}, + }, + want: system.Collection{ + system.String("a"), + system.String("b"), + system.String("c"), + }, + wantErr: nil, + }, + { + name: "errors if input length is more than 1", + input: system.Collection{system.String("apple,banana,cherry"), system.String("apple,banana,cherry")}, + args: []expr.Expression{ + &expr.LiteralExpression{Literal: system.String(",")}, + }, + want: nil, + wantErr: impl.ErrNotSingleton, + }, + { + name: "errors if input is not a string", + input: system.Collection{system.Integer(123)}, + args: []expr.Expression{ + &expr.LiteralExpression{Literal: system.String(",")}, + }, + want: nil, + wantErr: system.ErrNotConvertible, + }, + { + name: "errors if args length is not 1", + input: system.Collection{system.String("apple,banana,cherry")}, + args: []expr.Expression{}, + want: nil, + wantErr: impl.ErrWrongArity, + }, + { + name: "errors if args length is more than 1", + input: system.Collection{system.String("apple,banana,cherry")}, + args: []expr.Expression{ + &expr.LiteralExpression{Literal: system.String(",")}, + &expr.LiteralExpression{Literal: system.String(";")}, + }, + want: nil, + wantErr: impl.ErrWrongArity, + }, + { + name: "errors if arg is not a string", + input: system.Collection{system.String("apple,banana,cherry")}, + args: []expr.Expression{ + &expr.LiteralExpression{Literal: system.Integer(123)}, + }, + want: nil, + wantErr: system.ErrNotConvertible, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got, err := impl.Split(&expr.Context{}, tc.input, tc.args...) + + if !cmp.Equal(err, tc.wantErr, cmpopts.EquateErrors()) { + t.Fatalf("Split got unexpected error: got %v, want %v", err, tc.wantErr) + } + if !cmp.Equal(tc.want, got) { + t.Errorf("Split returned unexpected result: got %v, want %v", got, tc.want) + } + }) + } +} diff --git a/fhirpath/internal/funcs/impl/terminology.go b/fhirpath/internal/funcs/impl/terminology.go new file mode 100644 index 0000000..c66dcf3 --- /dev/null +++ b/fhirpath/internal/funcs/impl/terminology.go @@ -0,0 +1,110 @@ +package impl + +import ( + "errors" + "fmt" + + dtpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/datatypes_go_proto" + "github.com/verily-src/fhirpath-go/fhirpath/internal/expr" + "github.com/verily-src/fhirpath-go/fhirpath/system" + "github.com/verily-src/fhirpath-go/fhirpath/terminology" +) + +var ( + ErrUnconfiguredClient = errors.New("memberOf() function requires a Terminology Service client to be configured in the evaluation context") + ErrNotSupported = errors.New("Not Supported, memberOf must be called on a single Coding or a CodeableConcept") +) + +// MemberOf takes a Coding or a Codeable concept and a ValueSet internal id +// and determines if the coding of any of the coding is inside the ValueSet +func MemberOf(ctx *expr.Context, input system.Collection, args ...expr.Expression) (system.Collection, error) { + if length := len(args); length != 1 { + return nil, fmt.Errorf("%w: received %d arguments, expected 1", ErrWrongArity, length) + } + + if len(input) != 1 { + return system.Collection{}, ErrNotSupported + } + + content, err := args[0].Evaluate(ctx, input) + if err != nil { + return nil, fmt.Errorf("argument dereference error: %s", err) + } + + if len(content) == 0 { + return nil, fmt.Errorf("empty argument content") + } + + var valueSetId string + switch content[0].(type) { + case system.String: + valueSetId = string(content[0].(system.String)) + case *dtpb.String: + valueSetId = content[0].(*dtpb.String).GetValue() + default: + return nil, fmt.Errorf("unsupported argument type") + } + + validateResult := false + for _, item := range input { + switch res := item.(type) { + case *dtpb.Coding: + result, err := validateCoding(ctx, res.GetCode().GetValue(), res.GetSystem().GetValue(), valueSetId) + if err != nil { + return system.Collection{system.Boolean(false)}, nil + } + validateResult = result + + case *dtpb.CodeableConcept: + // If it's a Codeable Concept, we will checking the coding inside one by one + for _, coding := range res.GetCoding() { + result, err := validateCoding(ctx, coding.GetCode().GetValue(), coding.GetSystem().GetValue(), valueSetId) + if err != nil { + continue + } + if result { + validateResult = result + break + } + } + default: + return nil, ErrNotSupported + } + } + return system.Collection{system.Boolean(validateResult)}, nil +} + +func validateCoding(ctx *expr.Context, code string, system string, valueSet string) (bool, error) { + // We will not evaluate code without system + if system == "" { + return false, nil + } + + ts := ctx.TermService + if ts == nil { + return false, ErrUnconfiguredClient + } + + opt := &terminology.ValueSetValidateCodeOptions{ + Code: code, + System: system, + ID: valueSet, + } + + response, err := ts.ValueSetValidateCode(ctx, opt) + if err != nil { + return false, fmt.Errorf("validating valueSet code: %s", err) + } + + result := false + for _, item := range response.Parameter { + switch item.GetName().GetValue() { + case "result": + result = item.GetValue().GetBoolean().GetValue() + default: + continue + } + } + + return result, nil +} diff --git a/fhirpath/internal/funcs/impl/terminology_test.go b/fhirpath/internal/funcs/impl/terminology_test.go new file mode 100644 index 0000000..d535cc9 --- /dev/null +++ b/fhirpath/internal/funcs/impl/terminology_test.go @@ -0,0 +1,184 @@ +package impl_test + +import ( + "context" + "testing" + + dtpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/datatypes_go_proto" + pgp "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/parameters_go_proto" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/verily-src/fhirpath-go/fhirpath/internal/expr" + "github.com/verily-src/fhirpath-go/fhirpath/internal/funcs/impl" + "github.com/verily-src/fhirpath-go/fhirpath/system" + "github.com/verily-src/fhirpath-go/fhirpath/terminology" + "github.com/verily-src/fhirpath-go/internal/fhir" + "google.golang.org/protobuf/testing/protocmp" +) + +type fakeValueSet struct { + valueSetId string + system string + codes []string +} + +type fakeTerminologyService struct { + dbItems []*fakeValueSet +} + +func buildParameters(name string, result bool) *pgp.Parameters { + return &pgp.Parameters{ + Parameter: []*pgp.Parameters_Parameter{ + { + Name: &dtpb.String{Value: name}, + Value: &pgp.Parameters_Parameter_ValueX{ + Choice: &pgp.Parameters_Parameter_ValueX_Boolean{ + Boolean: &dtpb.Boolean{ + Value: result, + }, + }, + }, + }, + }, + } +} + +func (fts *fakeTerminologyService) ValueSetValidateCode(ctx context.Context, opts *terminology.ValueSetValidateCodeOptions) (*pgp.Parameters, error) { + + targetValueSet := opts.ID + targetCode := opts.Code + targetSystem := opts.System + + if targetValueSet == "" || targetSystem == "" { + return buildParameters("result", false), nil + } + + for _, item := range fts.dbItems { + if item.valueSetId == targetValueSet { + for _, code := range item.codes { + if targetCode == code && targetSystem == item.system { + return buildParameters("result", true), nil + } + } + } + } + + return buildParameters("result", false), nil +} + +func TestMemberOf(t *testing.T) { + + var fakeTerminology = []*fakeValueSet{ + { + valueSetId: "testValueSet", + system: "http://terminology.hl7.org/CodeSystem/v3-MaritalStatus", + codes: []string{"M", "D", "S", "W", "A", "L", "C", "P", "T", "U", "I"}, + // https://terminology.hl7.org/6.1.0/CodeSystem-v3-MaritalStatus.html + }, + } + + fakeTerminologyService := &fakeTerminologyService{dbItems: fakeTerminology} + + var includedCoding = &dtpb.Coding{ + Code: &dtpb.Code{ + Value: "M", + }, + System: fhir.URI("http://terminology.hl7.org/CodeSystem/v3-MaritalStatus"), + } + + var notIncludedCoding = &dtpb.Coding{ + Code: &dtpb.Code{ + Value: "not included", + }, + System: fhir.URI("http://terminology.hl7.org/CodeSystem/v3-MaritalStatus"), + } + + var noneIncludedCodeableConcept = &dtpb.CodeableConcept{ + Coding: []*dtpb.Coding{ + { + Code: &dtpb.Code{ + Value: "not included", + }, + System: fhir.URI("http://terminology.hl7.org/CodeSystem/v3-MaritalStatus"), + }, + { + Code: &dtpb.Code{ + Value: "not included either", + }, + System: fhir.URI("http://terminology.hl7.org/CodeSystem/v3-MaritalStatus"), + }, + }, + } + + var includedCodeableConcept = &dtpb.CodeableConcept{ + Coding: []*dtpb.Coding{ + { + Code: &dtpb.Code{ + Value: "not included", + }, + System: fhir.URI("http://terminology.hl7.org/CodeSystem/v3-MaritalStatus"), + }, + { + Code: &dtpb.Code{ + Value: "M", + }, + System: fhir.URI("http://terminology.hl7.org/CodeSystem/v3-MaritalStatus"), + }, + }, + } + + valueSetExpr := &expr.LiteralExpression{ + Literal: system.String("testValueSet"), + } + + testCases := []struct { + name string + inputCollection system.Collection + termServiceImpl terminology.Service + args []expr.Expression + wantCollection system.Collection + wantErr error + }{ + { + name: "Coding is in the value set", + inputCollection: system.Collection{includedCoding}, + termServiceImpl: fakeTerminologyService, + args: []expr.Expression{valueSetExpr}, + wantCollection: system.Collection{system.Boolean(true)}, + }, + { + name: "Coding is not in the value set", + inputCollection: system.Collection{notIncludedCoding}, + termServiceImpl: fakeTerminologyService, + args: []expr.Expression{valueSetExpr}, + wantCollection: system.Collection{system.Boolean(false)}, + }, + { + name: "CodeableConcept is not in the value set", + inputCollection: system.Collection{noneIncludedCodeableConcept}, + termServiceImpl: fakeTerminologyService, + args: []expr.Expression{valueSetExpr}, + wantCollection: system.Collection{system.Boolean(false)}, + }, + { + name: "CodeableConcept is in the value set", + inputCollection: system.Collection{includedCodeableConcept}, + termServiceImpl: fakeTerminologyService, + args: []expr.Expression{valueSetExpr}, + wantCollection: system.Collection{system.Boolean(true)}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + gotCollection, gotErr := impl.MemberOf(&expr.Context{TermService: tc.termServiceImpl}, tc.inputCollection, tc.args...) + + if !cmp.Equal(gotErr, tc.wantErr, cmpopts.EquateErrors()) { + t.Errorf("MemberOf() gotErr = %v, wantErr = %v", gotErr, tc.wantErr) + } + if diff := cmp.Diff(tc.wantCollection, gotCollection, protocmp.Transform()); diff != "" { + t.Errorf("MemberOf() mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/fhirpath/internal/funcs/table.go b/fhirpath/internal/funcs/table.go index 20745dc..f12d08c 100644 --- a/fhirpath/internal/funcs/table.go +++ b/fhirpath/internal/funcs/table.go @@ -440,6 +440,18 @@ var experimentalTable = FunctionTable{ 1, false, }, + "memberOf": Function{ + impl.MemberOf, + 1, + 1, + false, + }, + "split": Function{ + impl.Split, + 1, + 1, + false, + }, } // Clone returns a deep copy of the base diff --git a/fhirpath/terminology/service.go b/fhirpath/terminology/service.go new file mode 100644 index 0000000..e1a4e0b --- /dev/null +++ b/fhirpath/terminology/service.go @@ -0,0 +1,26 @@ +package terminology + +import ( + "context" + + pgp "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/parameters_go_proto" +) + +type ValueSetValidateCodeOptions struct { + // The value set OID or UUID. + ID string + // The code system ID, OID, or URI. + System string + // The code to be checked for validity. + Code string + // The effective date for determining validity, format should be + // YYYY-MM-DD. If empty, the service will return a result based + // on the latest dated system. + Date string + // The value set revision to validate against. + ValueSetVersion string +} + +type Service interface { + ValueSetValidateCode(ctx context.Context, opts *ValueSetValidateCodeOptions) (*pgp.Parameters, error) +} diff --git a/go.mod b/go.mod index 780c113..4187cda 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ require ( ) require ( - bitbucket.org/creachadair/stringset v0.0.11 // indirect + bitbucket.org/creachadair/stringset v0.0.14 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect diff --git a/go.sum b/go.sum index 3ac9ba2..9b37c91 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,6 @@ bitbucket.org/creachadair/stringset v0.0.9/go.mod h1:t+4WcQ4+PXTa8aQdNKe40ZP6iwesoMFWAxPGd3UGjyY= -bitbucket.org/creachadair/stringset v0.0.11 h1:6Sv4CCv14Wm+OipW4f3tWOb0SQVpBDLW0knnJqUnmZ8= -bitbucket.org/creachadair/stringset v0.0.11/go.mod h1:wh0BHewFe+j0HrzWz7KcGbSNpFzWwnpmgPRlB57U5jU= +bitbucket.org/creachadair/stringset v0.0.14 h1:t1ejQyf8utS4GZV/4fM+1gvYucggZkfhb+tMobDxYOE= +bitbucket.org/creachadair/stringset v0.0.14/go.mod h1:Ej8fsr6rQvmeMDf6CCWMWGb14H9mz8kmDgPPTdiVT0w= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= diff --git a/internal/fhir/time_test.go b/internal/fhir/time_test.go index a91858b..43aa40f 100644 --- a/internal/fhir/time_test.go +++ b/internal/fhir/time_test.go @@ -350,3 +350,92 @@ func TestMustParseInstant_BadInput_Panics(t *testing.T) { // If code reaches here, it means we didn't panic t.Errorf("MustParseInstant: expected panic") } + +func TestDate(t *testing.T) { + testCases := []struct { + name string + input time.Time + wantValue int64 + wantPrecision dtpb.Date_Precision + wantTimezone string + }{ + { + name: "UTC", + input: time.Date(2022, 1, 2, 0, 0, 0, 0, time.UTC), + wantValue: time.Date(2022, 1, 2, 0, 0, 0, 0, time.UTC).UnixMicro(), + }, + { + name: "PositiveOffset", + input: time.Date(2022, 1, 2, 0, 0, 0, 0, time.FixedZone("+0530", 5*3600+30*60)), + wantValue: time.Date(2022, 1, 2, 0, 0, 0, 0, time.FixedZone("+0530", 5*3600+30*60)).UnixMicro(), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got := fhir.Date(tc.input) + + if got.ValueUs != tc.wantValue { + t.Errorf("Date.ValueUs: got %v, want %v", got.ValueUs, tc.wantValue) + } + }) + } +} + +func TestDateTime(t *testing.T) { + testCases := []struct { + name string + input time.Time + wantValue int64 + }{ + { + name: "UTC", + input: time.Date(2022, 1, 2, 3, 4, 5, 123456, time.UTC), + wantValue: time.Date(2022, 1, 2, 3, 4, 5, 123456, time.UTC).UnixMicro(), + }, + { + name: "NegativeOffset", + input: time.Date(2022, 1, 2, 3, 4, 5, 654321, time.FixedZone("-0800", -8*3600)), + wantValue: time.Date(2022, 1, 2, 3, 4, 5, 654321, time.FixedZone("-0800", -8*3600)).UnixMicro(), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got := fhir.DateTime(tc.input) + + if got.ValueUs != tc.wantValue { + t.Errorf("DateTime.ValueUs: got %v, want %v", got.ValueUs, tc.wantValue) + } + }) + } +} + +func TestInstant(t *testing.T) { + testCases := []struct { + name string + input time.Time + wantValue int64 + }{ + { + name: "UTC", + input: time.Date(2022, 1, 2, 3, 4, 5, 789000, time.UTC), + wantValue: time.Date(2022, 1, 2, 3, 4, 5, 789000, time.UTC).UnixMicro(), + }, + { + name: "HalfHourOffset", + input: time.Date(2022, 1, 2, 3, 4, 5, 789000, time.FixedZone("+0330", 3*3600+30*60)), + wantValue: time.Date(2022, 1, 2, 3, 4, 5, 789000, time.FixedZone("+0330", 3*3600+30*60)).UnixMicro(), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got := fhir.Instant(tc.input) + + if got.ValueUs != tc.wantValue { + t.Errorf("Instant.ValueUs: got %v, want %v", got.ValueUs, tc.wantValue) + } + }) + } +} diff --git a/internal/protofields/fields.go b/internal/protofields/fields.go index 5d2a097..0521abb 100644 --- a/internal/protofields/fields.go +++ b/internal/protofields/fields.go @@ -35,6 +35,8 @@ type ResourceFieldRefs struct { Resource protoreflect.FieldDescriptor } + DummyResource proto.Message + // New is a function that will create a new instance of this FHIR Resource. New func() proto.Message } @@ -200,6 +202,7 @@ func init() { fields := &ResourceFieldRefs{} fields.ContainedResource.Resource = getContainedResourceOneOf(msg) fields.New = newProto(msg) + fields.DummyResource = msg Resources[name] = fields } for _, msg := range dummyElements { diff --git a/internal/resource/canonical_identity.go b/internal/resource/canonical_identity.go index 0f6a039..05e5262 100644 --- a/internal/resource/canonical_identity.go +++ b/internal/resource/canonical_identity.go @@ -4,6 +4,9 @@ import ( "errors" "fmt" "strings" + + dtpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/datatypes_go_proto" + "github.com/verily-src/fhirpath-go/internal/protofields" ) var ( @@ -11,6 +14,8 @@ var ( ErrMissingCanonicalURL = errors.New("missing canonical url") delimiter = "/" + + canonicalResourceTypes map[string]bool ) // CanonicalIdentity is a canonical representation of a FHIR Resource. @@ -22,6 +27,13 @@ type CanonicalIdentity struct { Fragment string // only used if a fragment of resource is targetted } +// canonicalTypeMatcher is used to identify if a resource is a canonical resource. +// This +type canonicalTypeMatcher interface { + GetUrl() *dtpb.Uri + GetVersion() *dtpb.String +} + // Type attempts to identify the resource type associated with the identity. func (c *CanonicalIdentity) Type() (Type, bool) { for _, r := range strings.Split(c.Url, delimiter) { @@ -56,3 +68,22 @@ func NewCanonicalIdentity(url, version, fragment string) (*CanonicalIdentity, er Fragment: fragment, }, nil } + +// IsCanonicalType checks if a resource type is a canonical resource. +// https://hl7.org/fhir/R4/references.html#canonical does not list contract as a canonical resource. +// But contract has a url and version, making it look like a canonical resource. +// NamingSystem is on the list, but does not have a url and version, so it is not a canonical resource. +func IsCanonicalType(resourceType string) bool { + return canonicalResourceTypes[resourceType] +} + +func init() { + canonicalResourceTypes = make(map[string]bool) + + for name, res := range protofields.Resources { + d := res.DummyResource + if _, ok := d.(canonicalTypeMatcher); ok { + canonicalResourceTypes[name] = true + } + } +} diff --git a/internal/resource/canonical_identity_test.go b/internal/resource/canonical_identity_test.go index 57aaf3d..21d36da 100644 --- a/internal/resource/canonical_identity_test.go +++ b/internal/resource/canonical_identity_test.go @@ -1,9 +1,12 @@ package resource_test import ( + "strings" "testing" "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/verily-src/fhirpath-go/internal/fhirtest" "github.com/verily-src/fhirpath-go/internal/resource" "google.golang.org/protobuf/testing/protocmp" ) @@ -86,3 +89,46 @@ func TestCanonicalIdentity(t *testing.T) { }) } } + +func TestIsCanonicalResourceType(t *testing.T) { + wantTypes := []string{ + "ActivityDefinition", + "CapabilityStatement", + "ChargeItemDefinition", + "CodeSystem", + "CompartmentDefinition", + "ConceptMap", + "Contract", + "EffectEvidenceSynthesis", + "EventDefinition", + "Evidence", + "EvidenceVariable", + "ExampleScenario", + "GraphDefinition", + "ImplementationGuide", + "Library", + "Measure", + "MessageDefinition", + "OperationDefinition", + "PlanDefinition", + "Questionnaire", + "ResearchDefinition", + "ResearchElementDefinition", + "RiskEvidenceSynthesis", + "SearchParameter", + "StructureDefinition", + "StructureMap", + "TerminologyCapabilities", + "TestScript", + "ValueSet", + } + gotTypes := []string{} + for name := range fhirtest.Resources { + if resource.IsCanonicalType(name) { + gotTypes = append(gotTypes, name) + } + } + if diff := cmp.Diff(wantTypes, gotTypes, cmpopts.SortSlices(strings.Compare)); diff != "" { + t.Errorf("IsCanonicalType: %v", diff) + } +} diff --git a/internal/resource/resource.go b/internal/resource/resource.go index 18ad410..5d81029 100644 --- a/internal/resource/resource.go +++ b/internal/resource/resource.go @@ -197,6 +197,30 @@ func VersionedURIString(resource fhir.Resource) (string, bool) { return fmt.Sprintf("%v/%v/_history/%v", TypeOf(resource), id, vID), true } +// MaybeVersionedURI returns the versioned URI if the resource has a version ID, +// otherwise it returns the unversioned URI. +// +// If the resource is nil, this will return a nil URI. +func MaybeVersionedURI(resource fhir.Resource) *dtpb.Uri { + uri := MaybeVersionedURIString(resource) + if uri == "" { + return nil + } + return fhir.URI(uri) +} + +// MaybeVersionedURIString returns the versioned URI string if the resource has +// a version ID, otherwise it returns the unversioned URI string. +// +// If the resource is nil, this will return an empty string. +func MaybeVersionedURIString(resource fhir.Resource) string { + uri, ok := VersionedURIString(resource) + if ok { + return uri + } + return URIString(resource) +} + // RemoveDuplicates finds all duplicates of resources -- determined by the // same // -- and removes them, returning an // updated list of resources. @@ -290,7 +314,7 @@ func GetIdentifierList(res fhir.Resource) ([]*dtpb.Identifier, error) { } // This is likely a bug / results from passing an unexpected type of resource - return nil, fmt.Errorf("%w: Resource does not implement GetIdentifier(): %v", ErrGetIdentifierList, res) + return nil, fmt.Errorf("%w: ResourceType does not implement GetIdentifier(): %s", ErrGetIdentifierList, TypeOf(res)) } // GetExtensions returns the list of extensions from a resource, if available. diff --git a/internal/resource/resource_test.go b/internal/resource/resource_test.go index 4fd631d..78fc75d 100644 --- a/internal/resource/resource_test.go +++ b/internal/resource/resource_test.go @@ -70,7 +70,7 @@ func TestVersionedURI(t *testing.T) { nil, }, { - "nil resource", + "empty string VersionID", &ppb.Patient{Meta: &dtpb.Meta{VersionId: fhir.ID("")}}, nil, }, @@ -96,6 +96,44 @@ func TestVersionedURI(t *testing.T) { } } +func TestMaybeVersionedURI(t *testing.T) { + testCases := []struct { + name string + res fhir.Resource + want *dtpb.Uri + }{ + { + "nil resource", + nil, + nil, + }, + { + "empty string ID", + &ppb.Patient{Id: fhir.ID("")}, + &dtpb.Uri{Value: "Patient/"}, + }, + { + "no version", + &ppb.Patient{Id: fhir.ID("abc")}, + &dtpb.Uri{Value: "Patient/abc"}, + }, + { + "versioned resource", + &dpb.Device{Id: fhir.ID("123"), Meta: &dtpb.Meta{VersionId: fhir.ID("abc")}}, + &dtpb.Uri{Value: "Device/123/_history/abc"}, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got := resource.MaybeVersionedURI(tc.res) + + if diff := cmp.Diff(got, tc.want, protocmp.Transform()); diff != "" { + t.Fatalf("MaybeVersionedURI(%s): (-got, +want):\n%s", tc.name, diff) + } + }) + } +} + func TestVersionedURIString(t *testing.T) { testCases := []struct { name string @@ -541,7 +579,7 @@ func TestGetIdentifier_nil(t *testing.T) { t.Errorf("got nil, want error") } - wanterr := "Resource does not implement GetIdentifier()" + wanterr := "ResourceType does not implement GetIdentifier()" if !strings.Contains(err.Error(), wanterr) { t.Errorf("got %#v, want %#v", err.Error(), wanterr) }