From ad1a633f41e00c3c8571a3bcbcfaad0967b676a6 Mon Sep 17 00:00:00 2001 From: Copybara Date: Tue, 15 Jul 2025 10:22:33 -0400 Subject: [PATCH] Project import generated by Copybara. GitOrigin-RevId: 25701006c81810ee439e15793fb746596114c406 --- .github/workflows/pr-check.yaml | 7 +- README.md | 4 +- fhirpath/compopts/compopts.go | 4 +- fhirpath/evalopts/evalopts.go | 9 + fhirpath/fhirpath_test.go | 131 ++++- .../fhirpathtest/fhirpathtest_example_test.go | 99 +++- fhirpath/internal/expr/arithmetic.go | 36 +- fhirpath/internal/expr/context.go | 6 + fhirpath/internal/funcs/impl/combine.go | 34 ++ fhirpath/internal/funcs/impl/combine_test.go | 93 ++++ fhirpath/internal/funcs/impl/filtering.go | 36 ++ .../internal/funcs/impl/filtering_test.go | 77 +++ fhirpath/internal/funcs/impl/resolve.go | 394 +++++++++++++++ fhirpath/internal/funcs/impl/resolve_test.go | 128 +++++ fhirpath/internal/funcs/impl/strings.go | 4 +- fhirpath/internal/funcs/table.go | 24 +- fhirpath/internal/grammar/fhirpath.g4 | 4 +- fhirpath/internal/parser/visitor.go | 42 +- .../internal/reflection/type_specifier.go | 14 + fhirpath/patch/patch.go | 2 +- fhirpath/resolver/bundleresolver.go | 275 ++++++++++ fhirpath/resolver/bundleresolver_test.go | 477 ++++++++++++++++++ fhirpath/resolver/resolver.go | 12 + .../resolver/resolvertest/resolvertest.go | 27 + fhirpath/system/date_test.go | 3 +- fhirpath/system/primitives.go | 2 +- fhirpath/system/types.go | 4 +- go.mod | 8 +- go.sum | 20 +- internal/element/extension/extension.go | 14 + internal/element/extension/extension_test.go | 68 +++ internal/element/extract.go | 2 +- internal/element/reference/identity.go | 2 +- internal/fhir/elements_general.go | 2 +- internal/fhir/elements_metadata.go | 19 - internal/fhir/elements_special.go | 26 - internal/fhir/elements_special_test.go | 28 - internal/fhir/protofields.go | 13 - internal/fhirconv/integer.go | 112 ---- internal/fhirconv/integer_test.go | 182 ------- internal/resource/contactable/contactable.go | 30 -- .../resource/contactable/contactable_test.go | 31 -- internal/resource/patient/patient.go | 199 -------- internal/resource/patient/patient_test.go | 184 ------- internal/resource/resource.go | 51 +- internal/resource/resource_test.go | 169 +++++++ 46 files changed, 2212 insertions(+), 896 deletions(-) create mode 100644 fhirpath/internal/funcs/impl/combine.go create mode 100644 fhirpath/internal/funcs/impl/combine_test.go create mode 100644 fhirpath/internal/funcs/impl/resolve.go create mode 100644 fhirpath/internal/funcs/impl/resolve_test.go create mode 100644 fhirpath/resolver/bundleresolver.go create mode 100644 fhirpath/resolver/bundleresolver_test.go create mode 100644 fhirpath/resolver/resolver.go create mode 100644 fhirpath/resolver/resolvertest/resolvertest.go delete mode 100644 internal/fhir/elements_metadata.go delete mode 100644 internal/fhir/elements_special.go delete mode 100644 internal/fhir/elements_special_test.go delete mode 100644 internal/fhir/protofields.go delete mode 100644 internal/fhirconv/integer.go delete mode 100644 internal/fhirconv/integer_test.go delete mode 100644 internal/resource/contactable/contactable.go delete mode 100644 internal/resource/contactable/contactable_test.go delete mode 100644 internal/resource/patient/patient.go delete mode 100644 internal/resource/patient/patient_test.go diff --git a/.github/workflows/pr-check.yaml b/.github/workflows/pr-check.yaml index 5b50d6f..602dcc8 100644 --- a/.github/workflows/pr-check.yaml +++ b/.github/workflows/pr-check.yaml @@ -15,8 +15,8 @@ env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} MESSAGE: > Thank you for your interest in this project! At this moment, we are not currently accepting community contributions in the form of PRs. - If you would like to make a proposal, - we will do our best to review it, implement it ourselves, and include it in the next release. + If you would like to make a proposal, + we will do our best to review it, implement it ourselves, and include it in the next release. If enough proposals come through, we will certainly revisit this policy to make the package as useful as possible. [Contribution Guidelines](https://github.com/verily-src/fhirpath-go/CONTRIBUTING.md). @@ -33,7 +33,7 @@ jobs: PR_AUTHOR: ${{ github.event.pull_request.user.login }} run: | body="${PR_AUTHOR}" - body=$(echo "${body}" | sed -E -e '/OliverCardoza|bitwizeshift|VickSuresh|assefamaru|jonhayesverily|alexlaurinmath|Copybara/!d') + body=$(echo "${body}" | sed -E -e '/OliverCardoza|bitwizeshift|VickSuresh|assefamaru|jonhayesverily|alexlaurinmath|AlexJSully|Copybara/!d') if [ -z "$body"]; then echo "status=failure" >> "${GITHUB_OUTPUT}" @@ -68,4 +68,3 @@ jobs: continue-on-error: true run: | gh pr comment "${{ env.PR_URL }}" -b "${{ env.AUTHOR }} ${{ env.MESSAGE }}" - diff --git a/README.md b/README.md index b6482e7..7e65a24 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ if err != nil { The compilation result can then be run against a resource: ```go -inputResources := []fhir.Resource{somePatient, someMedication} +inputResources := []fhirpath.Resource{somePatient, someMedication} result, err := expression.Evaluate(inputResources) if err != nil { @@ -74,7 +74,7 @@ The constraints on external constants are as follows: ```go customVar := system.String("custom variable") -result, err := expression.Evaluate([]fhir.Resource{someResource}, evalopts.EnvVariable("var", customVar)) +result, err := expression.Evaluate([]fhirpath.Resource{someResource}, evalopts.EnvVariable("var", customVar)) ``` ### System Types diff --git a/fhirpath/compopts/compopts.go b/fhirpath/compopts/compopts.go index a68c045..3e171bc 100644 --- a/fhirpath/compopts/compopts.go +++ b/fhirpath/compopts/compopts.go @@ -14,7 +14,9 @@ import ( "github.com/verily-src/fhirpath-go/fhirpath/internal/parser" ) -var ErrMultipleTransforms = errors.New("multiple transforms provided") +var ( + ErrMultipleTransforms = errors.New("multiple transforms provided") +) // AddFunction creates a CompileOption that will register a custom FHIRPath // function that can be called during evaluation with the given name. diff --git a/fhirpath/evalopts/evalopts.go b/fhirpath/evalopts/evalopts.go index 02e02d4..51cabb3 100644 --- a/fhirpath/evalopts/evalopts.go +++ b/fhirpath/evalopts/evalopts.go @@ -12,6 +12,7 @@ import ( "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/internal/fhir" ) @@ -72,3 +73,11 @@ func validateType(input any) error { } return err } + +// WithResolver returns an EvaluateOption that sets the FHIR reference resolver. +func WithResolver(resolver resolver.Resolver) opts.EvaluateOption { + return opts.Transform(func(cfg *opts.EvaluateConfig) error { + cfg.Context.Resolver = resolver + return nil + }) +} diff --git a/fhirpath/fhirpath_test.go b/fhirpath/fhirpath_test.go index 9b29099..9e6ae1f 100644 --- a/fhirpath/fhirpath_test.go +++ b/fhirpath/fhirpath_test.go @@ -21,6 +21,8 @@ import ( "github.com/verily-src/fhirpath-go/fhirpath" "github.com/verily-src/fhirpath-go/fhirpath/compopts" "github.com/verily-src/fhirpath-go/fhirpath/evalopts" + "github.com/verily-src/fhirpath-go/fhirpath/internal/funcs/impl" + "github.com/verily-src/fhirpath-go/fhirpath/resolver/resolvertest" "github.com/verily-src/fhirpath-go/fhirpath/system" "github.com/verily-src/fhirpath-go/internal/element/extension" "github.com/verily-src/fhirpath-go/internal/element/reference" @@ -34,6 +36,7 @@ type evaluateTestCase struct { inputPath string inputCollection []fhirpath.Resource wantCollection system.Collection + wantErr error compileOptions []fhirpath.CompileOption evaluateOptions []fhirpath.EvaluateOption } @@ -160,11 +163,12 @@ func testEvaluate(t *testing.T, testCases []evaluateTestCase) { t.Fatalf("Compiling \"%s\" returned unexpected error: %v", tc.inputPath, err) } - got, err := compiledExpression.Evaluate(tc.inputCollection, tc.evaluateOptions...) + got, gotErr := compiledExpression.Evaluate(tc.inputCollection, tc.evaluateOptions...) - if err != nil { - t.Fatalf("Evaluating \"%s\" returned unexpected error: %v", tc.inputPath, err) + if !cmp.Equal(gotErr, tc.wantErr, cmpopts.EquateErrors()) { + t.Errorf("Resolve() gotErr = %v, wantErr = %v", gotErr, tc.wantErr) } + if diff := cmp.Diff(tc.wantCollection, got, protocmp.Transform()); diff != "" { t.Errorf("Evaluating \"%s\" returned unexpected diff (-want, +got)\n%s", tc.inputPath, diff) } @@ -172,6 +176,78 @@ func testEvaluate(t *testing.T, testCases []evaluateTestCase) { } } +func TestResolve(t *testing.T) { + var patientChuRef = &dtpb.Reference{ + Type: fhir.URI("Patient"), + Id: fhir.String("123"), + } + + var obsWithPatientChuRef = &opb.Observation{ + Subject: patientChuRef, + } + var obsWithPatientTsuRef = &opb.Observation{ + Subject: reference.Weak("Patient", "123456"), + } + + resolveErr := errors.New("some resolve() error") + + testCases := []evaluateTestCase{ + { + name: "successful resolution", + inputPath: "Observation.subject.resolve()", + inputCollection: []fhirpath.Resource{ + obsWithPatientChuRef, + }, + evaluateOptions: []fhirpath.EvaluateOption{ + evalopts.WithResolver(resolvertest.HappyResolver(patientChu))}, + wantCollection: system.Collection{patientChu}, + }, + { + name: "Resolver not configured", + inputPath: "Observation.subject.resolve()", + inputCollection: []fhirpath.Resource{ + obsWithPatientChuRef, + }, + evaluateOptions: []fhirpath.EvaluateOption{}, + wantErr: impl.ErrUnconfiguredResolver, + }, + { + name: "resolve() returns an error", + inputPath: "Observation.subject.resolve()", + inputCollection: []fhirpath.Resource{ + obsWithPatientChuRef, + }, + evaluateOptions: []fhirpath.EvaluateOption{ + evalopts.WithResolver( + resolvertest.ErroringResolver(resolveErr)), + }, + wantErr: resolveErr, + }, + { + name: "empty input", + inputPath: "Observation.subject.resolve()", + inputCollection: []fhirpath.Resource{}, + evaluateOptions: []fhirpath.EvaluateOption{ + evalopts.WithResolver(resolvertest.HappyResolver(patientChu)), + }, + wantCollection: system.Collection{}, + }, + { + name: "multiple inputs", + inputPath: "Observation.subject.resolve()", + inputCollection: []fhirpath.Resource{ + obsWithPatientChuRef, + obsWithPatientTsuRef, + }, + evaluateOptions: []fhirpath.EvaluateOption{ + evalopts.WithResolver(resolvertest.HappyResolver(patientChu)), + }, + wantCollection: system.Collection{patientChu}, + }, + } + testEvaluate(t, testCases) +} + func TestEvaluate_PathSelection_ReturnsError(t *testing.T) { end := system.MustParseDateTime("@2016-01-01T12:22:33Z") task := makeTaskWithEndTime(end) @@ -854,6 +930,7 @@ func TestFunctionInvocation_Evaluates(t *testing.T) { wantCollection: system.Collection{testDateTime}, evaluateOptions: []fhirpath.EvaluateOption{evalopts.OverrideTime(testTime)}, }, + { name: "evaluate with custom function 'patient()'", inputPath: "patient() = Patient", @@ -951,6 +1028,54 @@ func TestFunctionInvocation_Evaluates(t *testing.T) { inputCollection: []fhirpath.Resource{patientChu}, wantCollection: system.Collection{system.Boolean(false), system.Boolean(true)}, }, + { + name: "filters child fields with ofType()", + inputPath: "children().ofType(string)", + inputCollection: []fhirpath.Resource{patientChu}, + wantCollection: system.Collection{fhir.ID("123"), &ppb.Patient_GenderCode{Value: cpb.AdministrativeGenderCode_FEMALE}}, + }, + { + name: "return fhir resource with ofType()", + inputPath: "Patient.ofType(Patient)", + inputCollection: []fhirpath.Resource{patientChu}, + wantCollection: system.Collection{patientChu}, + }, + { + name: "return fhir resource with ofType() type and namespace", + inputPath: "Patient.ofType(FHIR.Patient)", + inputCollection: []fhirpath.Resource{patientChu}, + wantCollection: system.Collection{patientChu}, + }, + { + name: "return fhir resource with ofType() using base type", + inputPath: "Patient.ofType(FHIR.Resource)", + inputCollection: []fhirpath.Resource{patientChu}, + wantCollection: system.Collection{patientChu}, + }, + { + name: "returns empty with ofType()", + inputPath: "Patient.ofType(FHIR.Observation)", + inputCollection: []fhirpath.Resource{patientChu}, + wantCollection: system.Collection{}, + }, + { + name: "ofType() returns gender field", + inputPath: "Patient.gender.ofType(code)", + inputCollection: []fhirpath.Resource{patientChu}, + wantCollection: system.Collection{&ppb.Patient_GenderCode{Value: cpb.AdministrativeGenderCode_FEMALE}}, + }, + { + name: "ofType() returns name.use fields", + inputPath: "Patient.name.ofType(HumanName).use", + inputCollection: []fhirpath.Resource{patientChu}, + wantCollection: system.Collection{&dtpb.HumanName_UseCode{Value: cpb.NameUseCode_NICKNAME}, &dtpb.HumanName_UseCode{Value: cpb.NameUseCode_OFFICIAL}}, + }, + { + name: "ofType() returns name.use fields using a base type", + inputPath: "Patient.name.ofType(FHIR.Element).use.exists()", + inputCollection: []fhirpath.Resource{patientChu}, + wantCollection: system.Collection{system.Boolean(true)}, + }, { name: "returns concatenated family name value with join()", inputPath: "name.family.value.join('-')", diff --git a/fhirpath/fhirpathtest/fhirpathtest_example_test.go b/fhirpath/fhirpathtest/fhirpathtest_example_test.go index 50cffd7..4a02d3e 100644 --- a/fhirpath/fhirpathtest/fhirpathtest_example_test.go +++ b/fhirpath/fhirpathtest/fhirpathtest_example_test.go @@ -9,8 +9,10 @@ import ( "github.com/verily-src/fhirpath-go/fhirpath/fhirpathtest" "github.com/verily-src/fhirpath-go/fhirpath/system" + cpb "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" pb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/patient_go_proto" + qrpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/questionnaire_response_go_proto" ) func ExampleError() { @@ -50,32 +52,59 @@ func ExampleReturnCollection() { // Output: got = true } -// TestFHIRPathResource is based on issue can't call Evaluate() because fhir.Resource is internal? #18 -// https://github.com/verily-src/fhirpath-go/issues/18 -// -// Testing that by using only the fhirpath package, we can still evaluate -// a FHIRPath expression that returns a resource. -func TestFHIRPathResource(t *testing.T) { - want := system.Collection{ - &dtpb.String{Value: "John"}, +// Create a FHIR Patient resource +var ( + patientOfficialName = &dtpb.HumanName{ + Given: []*dtpb.String{ + {Value: "John"}, + }, + Family: &dtpb.String{Value: "Doe"}, + Use: &dtpb.HumanName_UseCode{Value: *cpb.NameUseCode_OFFICIAL.Enum()}, } - // Create a FHIR Patient resource - patient := &pb.Patient{ + + testPatient = &pb.Patient{ Name: []*dtpb.HumanName{ + patientOfficialName, { Given: []*dtpb.String{ - {Value: "John"}, + {Value: "Johnny"}, }, Family: &dtpb.String{Value: "Doe"}, + Use: &dtpb.HumanName_UseCode{Value: *cpb.NameUseCode_NICKNAME.Enum()}, + }, + }, + } + + testQuestionnaireResponse = &qrpb.QuestionnaireResponse{ + Item: []*qrpb.QuestionnaireResponse_Item{ + { + Answer: []*qrpb.QuestionnaireResponse_Item_Answer{ + {Value: &qrpb.QuestionnaireResponse_Item_Answer_ValueX{ + Choice: &qrpb.QuestionnaireResponse_Item_Answer_ValueX_Coding{ + Coding: &dtpb.Coding{Code: &dtpb.Code{Value: "foo"}}, + }, + }}, + }, }, }, } +) + +// TestFHIRPathResource is based on issue can't call Evaluate() because fhir.Resource is internal? #18 +// https://github.com/verily-src/fhirpath-go/issues/18 +// +// Testing that by using only the fhirpath package, we can still evaluate +// a FHIRPath expression that returns a resource. +func TestFHIRPathResource(t *testing.T) { + want := system.Collection{ + &dtpb.String{Value: "John"}, + } // Compile the FHIRPath expression expr := fhirpath.MustCompile("name.given") // Wrap the Patient resource in a FHIRPath Resource - resource := []fhirpath.Resource{patient} + resource := []fhirpath.Resource{testPatient} // Evaluate the expression against the Patient resource got, err := expr.Evaluate(resource) @@ -84,8 +113,8 @@ func TestFHIRPathResource(t *testing.T) { } // Check if the results match the expected output - if len(got) != 1 { - t.Errorf("Expected 1 result, got %d", len(got)) + if len(got) != 2 { + t.Errorf("Expected 2 results, got %d", len(got)) } gotValue := got[0].(*dtpb.String).GetValue() wantValue := want[0].(*dtpb.String).GetValue() @@ -93,3 +122,45 @@ func TestFHIRPathResource(t *testing.T) { t.Errorf("Expected %s, got %s", wantValue, gotValue) } } + +func TestFHIRPathWhere(t *testing.T) { + resource := []fhirpath.Resource{testPatient} + + // Test where: name is official (cpb.NameUseCode_OFFICIAL) + exprWhere := fhirpath.MustCompile("name.where(use = 'official')") + gotWhere, err := exprWhere.Evaluate(resource) + if err != nil { + t.Errorf("Error evaluating where: %v", err) + } + wantWhere := patientOfficialName + + if len(gotWhere) != 1 { + t.Errorf("Expected 1 result from where, got %d", len(gotWhere)) + } + if len(gotWhere) == 1 { + gotValue := gotWhere[0].(*dtpb.HumanName).GetGiven()[0].GetValue() + wantValue := wantWhere.GetGiven()[0].GetValue() + if gotValue != wantValue { + t.Errorf("Expected %s from where, got %q", wantValue, gotValue) + } + } +} + +func TestFHIRPathCombine(t *testing.T) { + // Use the testQuestionnaireResponse defined above (empty for now, but can be extended) + resource := []fhirpath.Resource{testQuestionnaireResponse} + + // Evaluate the FHIRPath expression: QuestionnaireResponse.item.answer.combine(today()) + expr := fhirpath.MustCompile("item.answer.combine(today())") + got, err := expr.Evaluate(resource) + if err != nil { + t.Fatalf("Error evaluating combine: %v", err) + } + + if got == nil { + t.Errorf("Expected non-nil result from combine, got nil") + } + if len(got) != 2 { + t.Errorf("Expected 2 results from combine, got %d", len(got)) + } +} diff --git a/fhirpath/internal/expr/arithmetic.go b/fhirpath/internal/expr/arithmetic.go index a753f89..fe62526 100644 --- a/fhirpath/internal/expr/arithmetic.go +++ b/fhirpath/internal/expr/arithmetic.go @@ -95,7 +95,8 @@ func EvaluateMul(lhs, rhs system.Any) (system.Any, error) { return left.Mul(right) } if _, ok := rhs.(system.Quantity); ok { - return nil, fmt.Errorf("%w: PHP-7340", ErrToBeImplemented) + // TODO: Implement multiplication with Quantity. + return nil, ErrToBeImplemented } return nil, typeMismatch(Mul, lhs, rhs) case system.Decimal: @@ -103,11 +104,13 @@ func EvaluateMul(lhs, rhs system.Any) (system.Any, error) { return left.Mul(right), nil } if _, ok := rhs.(system.Quantity); ok { - return nil, fmt.Errorf("%w: PHP-7340", ErrToBeImplemented) + // TODO: Implement multiplication with Quantity. + return nil, ErrToBeImplemented } return nil, typeMismatch(Mul, lhs, rhs) case system.Quantity: - return nil, fmt.Errorf("%w: PHP-7171", ErrToBeImplemented) + // TODO: Implement support for multiplication with Quantity. + return nil, ErrToBeImplemented default: return nil, typeMismatch(Mul, lhs, rhs) } @@ -121,7 +124,8 @@ func EvaluateDiv(lhs, rhs system.Any) (system.Any, error) { return left.Div(right), nil } if _, ok := rhs.(system.Quantity); ok { - return nil, fmt.Errorf("%w: PHP-7340", ErrToBeImplemented) + // TODO: Implement division with Quantity. + return nil, ErrToBeImplemented } return nil, typeMismatch(Div, lhs, rhs) case system.Decimal: @@ -129,11 +133,13 @@ func EvaluateDiv(lhs, rhs system.Any) (system.Any, error) { return left.Div(right), nil } if _, ok := rhs.(system.Quantity); ok { - return nil, fmt.Errorf("%w: PHP-7340", ErrToBeImplemented) + // TODO: Implement division with Quantity. + return nil, ErrToBeImplemented } return nil, typeMismatch(Div, lhs, rhs) case system.Quantity: - return nil, fmt.Errorf("%w: PHP-7171", ErrToBeImplemented) + // TODO: Implement support for division with Quantity. + return nil, ErrToBeImplemented default: return nil, typeMismatch(Div, lhs, rhs) } @@ -147,7 +153,8 @@ func EvaluateFloorDiv(lhs, rhs system.Any) (system.Any, error) { return left.FloorDiv(right), nil } if _, ok := rhs.(system.Quantity); ok { - return nil, fmt.Errorf("%w: PHP-7340", ErrToBeImplemented) + // TODO: Implement floor division with Quantity. + return nil, ErrToBeImplemented } return nil, typeMismatch(FloorDiv, lhs, rhs) case system.Decimal: @@ -155,11 +162,13 @@ func EvaluateFloorDiv(lhs, rhs system.Any) (system.Any, error) { return left.FloorDiv(right) } if _, ok := rhs.(system.Quantity); ok { - return nil, fmt.Errorf("%w: PHP-7340", ErrToBeImplemented) + // TODO: Implement floor division with Quantity. + return nil, ErrToBeImplemented } return nil, typeMismatch(FloorDiv, lhs, rhs) case system.Quantity: - return nil, fmt.Errorf("%w: PHP-7171", ErrToBeImplemented) + // TODO: Implement support for floor division with Quantity. + return nil, ErrToBeImplemented default: return nil, typeMismatch(FloorDiv, lhs, rhs) } @@ -173,7 +182,8 @@ func EvaluateMod(lhs, rhs system.Any) (system.Any, error) { return left.Mod(right), nil } if _, ok := rhs.(system.Quantity); ok { - return nil, fmt.Errorf("%w: PHP-7340", ErrToBeImplemented) + // TODO: Implement modulus with Quantity. + return nil, ErrToBeImplemented } return nil, typeMismatch(Mod, lhs, rhs) case system.Decimal: @@ -181,11 +191,13 @@ func EvaluateMod(lhs, rhs system.Any) (system.Any, error) { return left.Mod(right), nil } if _, ok := rhs.(system.Quantity); ok { - return nil, fmt.Errorf("%w: PHP-7340", ErrToBeImplemented) + // TODO: Implement modulus with Quantity. + return nil, ErrToBeImplemented } return nil, typeMismatch(Mod, lhs, rhs) case system.Quantity: - return nil, fmt.Errorf("%w: PHP-7171", ErrToBeImplemented) + // TODO: Implement support for modulus with Quantity. + return nil, ErrToBeImplemented default: return nil, typeMismatch(Mod, lhs, rhs) } diff --git a/fhirpath/internal/expr/context.go b/fhirpath/internal/expr/context.go index b65d9da..6f8b788 100644 --- a/fhirpath/internal/expr/context.go +++ b/fhirpath/internal/expr/context.go @@ -3,6 +3,7 @@ package expr import ( "time" + "github.com/verily-src/fhirpath-go/fhirpath/resolver" "github.com/verily-src/fhirpath-go/fhirpath/system" ) @@ -23,6 +24,10 @@ type Context struct { // the 'LastResult' will be the unwrapped list from 'given', but we need the // 'name' element that contains the 'given' list in order to alter the list. BeforeLastResult system.Collection + + // Resolver is an optional mechanism for resolving FHIR Resources that + // is used in the 'resolve()' FHIRPath function. + Resolver resolver.Resolver } // Clone copies this Context object to produce a new instance. @@ -31,6 +36,7 @@ func (c *Context) Clone() *Context { Now: c.Now, ExternalConstants: c.ExternalConstants, LastResult: c.LastResult, + Resolver: c.Resolver, } } diff --git a/fhirpath/internal/funcs/impl/combine.go b/fhirpath/internal/funcs/impl/combine.go new file mode 100644 index 0000000..c94aed8 --- /dev/null +++ b/fhirpath/internal/funcs/impl/combine.go @@ -0,0 +1,34 @@ +// Package impl provides implementations of FHIRPath functions. +package impl + +import ( + "github.com/verily-src/fhirpath-go/fhirpath/internal/expr" + "github.com/verily-src/fhirpath-go/fhirpath/system" +) + +// Combine merges input and other collections into a single collection without eliminating duplicate values. +// Combining an empty collection with a non-empty collection will return the non-empty collection. +// There is no expectation of order in the resulting collection. +// FHIRPath docs here: https://hl7.org/fhirpath/N1/#combineother-collection-collection +func Combine(ctx *expr.Context, input system.Collection, args ...expr.Expression) (system.Collection, error) { + // Create a new collection with the size of the input collection + var result system.Collection + + // If the input collection is nil, we don't need to append it + if input != nil { + result = append(result, input...) + } + + // Iterate over the args and append each collection to the result + for _, arg := range args { + coll, err := arg.Evaluate(ctx, input) + if err != nil { + return nil, err + } + if coll != nil { + result = append(result, coll...) + } + } + + return result, nil +} diff --git a/fhirpath/internal/funcs/impl/combine_test.go b/fhirpath/internal/funcs/impl/combine_test.go new file mode 100644 index 0000000..af315f6 --- /dev/null +++ b/fhirpath/internal/funcs/impl/combine_test.go @@ -0,0 +1,93 @@ +package impl + +import ( + "math" + "testing" + + cpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/codes_go_proto" + ppb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/patient_go_proto" + "github.com/google/go-cmp/cmp" + "github.com/verily-src/fhirpath-go/fhirpath/internal/expr" + "github.com/verily-src/fhirpath-go/fhirpath/internal/expr/exprtest" + "github.com/verily-src/fhirpath-go/fhirpath/system" + "github.com/verily-src/fhirpath-go/internal/fhir" + "google.golang.org/protobuf/testing/protocmp" +) + +func TestCombine(t *testing.T) { + ctx := &expr.Context{} + + testCases := []struct { + name string + input system.Collection + args []expr.Expression + expected system.Collection + }{ + { + name: "Combine two non-empty collections", + input: system.Collection{1, 2, 3}, + args: []expr.Expression{exprtest.Return(4, 5), exprtest.Return(6)}, + expected: system.Collection{1, 2, 3, 4, 5, 6}, + }, + { + name: "Combine empty input with non-empty collection", + input: system.Collection{}, + args: []expr.Expression{exprtest.Return(7, 8, 9)}, + expected: system.Collection{7, 8, 9}, + }, + { + name: "Combine non-empty input with empty collection", + input: system.Collection{10, 11}, + args: []expr.Expression{exprtest.Return()}, + expected: system.Collection{10, 11}, + }, + { + name: "Combine multiple empty collections", + input: system.Collection{}, + args: []expr.Expression{exprtest.Return(), exprtest.Return()}, + expected: nil, + }, + { + name: "Combine with no other collections", + input: system.Collection{12, 13}, + args: []expr.Expression{}, + expected: system.Collection{12, 13}, + }, + { + name: "Combine with duplicate values", + input: system.Collection{14, 15}, + args: []expr.Expression{exprtest.Return(15, 16), exprtest.Return(14)}, + expected: system.Collection{14, 15, 15, 16, 14}, + }, + { + name: "Combine with nil collections", + input: system.Collection{17, 18}, + args: []expr.Expression{exprtest.Return(), exprtest.Return(19)}, + expected: system.Collection{17, 18, 19}, + }, + { + name: "Combine with different types", + input: system.Collection{20, 21, fhir.UnsignedInt(math.MaxUint32)}, + args: []expr.Expression{exprtest.Return(22.0, "23"), exprtest.Return(24)}, + expected: system.Collection{20, 21, fhir.UnsignedInt(math.MaxUint32), 22.0, "23", 24}, + }, + { + name: "Combine FHIR elements", + input: system.Collection{fhir.ID("123"), &ppb.Patient_GenderCode{Value: cpb.AdministrativeGenderCode_FEMALE}}, + args: []expr.Expression{exprtest.Return(fhir.String("456"), fhir.UnsignedInt(789))}, + expected: system.Collection{fhir.ID("123"), &ppb.Patient_GenderCode{Value: cpb.AdministrativeGenderCode_FEMALE}, fhir.String("456"), fhir.UnsignedInt(789)}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result, err := Combine(ctx, tc.input, tc.args...) + if err != nil { + t.Fatalf("Combine() returned an error: %v", err) + } + if !cmp.Equal(result, tc.expected, protocmp.Transform()) { + t.Errorf("Combine() result diff (-got, +want):\n%s", cmp.Diff(tc.expected, result, protocmp.Transform())) + } + }) + } +} diff --git a/fhirpath/internal/funcs/impl/filtering.go b/fhirpath/internal/funcs/impl/filtering.go index 7a293d4..d4cd989 100644 --- a/fhirpath/internal/funcs/impl/filtering.go +++ b/fhirpath/internal/funcs/impl/filtering.go @@ -2,8 +2,10 @@ package impl import ( "fmt" + "strings" "github.com/verily-src/fhirpath-go/fhirpath/internal/expr" + "github.com/verily-src/fhirpath-go/fhirpath/internal/reflection" "github.com/verily-src/fhirpath-go/fhirpath/system" ) @@ -33,3 +35,37 @@ func Where(ctx *expr.Context, input system.Collection, args ...expr.Expression) } return result, nil } + +// OfType filters the input collection to include only items that are of the specified type +// or a subclass thereof. The type is specified as an argument in the form of an identifier. +// If the input collection is empty, the result will also be empty. +func OfType(ctx *expr.Context, input system.Collection, args ...expr.Expression) (system.Collection, error) { + if len(args) != 1 { + return nil, fmt.Errorf("%w: received %v arguments, expected 1", ErrWrongArity, len(args)) + } + + var typeSpecifier reflection.TypeSpecifier + typeExpr, ok := args[0].(*expr.TypeExpression) + if !ok { + return nil, fmt.Errorf("received invalid argument, expected a type") + } + var err error + if parts := strings.Split(typeExpr.Type, "."); len(parts) == 2 { + if typeSpecifier, err = reflection.NewQualifiedTypeSpecifier(parts[0], parts[1]); err != nil { + return nil, err + } + } else if typeSpecifier, err = reflection.NewTypeSpecifier(typeExpr.Type); err != nil { + return nil, err + } + result := system.Collection{} + for _, item := range input { + typ, err := reflection.TypeOf(item) + if err != nil { + return nil, err + } + if typ.Is(typeSpecifier) { + result = append(result, item) + } + } + return result, nil +} diff --git a/fhirpath/internal/funcs/impl/filtering_test.go b/fhirpath/internal/funcs/impl/filtering_test.go index 400ce08..ffb700b 100644 --- a/fhirpath/internal/funcs/impl/filtering_test.go +++ b/fhirpath/internal/funcs/impl/filtering_test.go @@ -112,3 +112,80 @@ func TestWhere_RaisesError(t *testing.T) { }) } } + +func TestOfType_Evaluates(t *testing.T) { + testCases := []struct { + name string + inputCollection system.Collection + inputArgs expr.Expression + wantCollection system.Collection + }{ + { + name: "filter on base node", + inputCollection: slices.MustConvert[any](contact), + inputArgs: &expr.TypeExpression{Type: "FHIR.ContactDetail"}, + wantCollection: slices.MustConvert[any](contact), + }, + { + name: "filter on base node without namespace", + inputCollection: slices.MustConvert[any](contact[0:3]), + inputArgs: &expr.TypeExpression{Type: "ContactDetail"}, + wantCollection: slices.MustConvert[any](contact), + }, + { + name: "returns empty collection", + inputCollection: slices.MustConvert[any](contact[0:2]), + inputArgs: &expr.TypeExpression{Type: "string"}, + wantCollection: system.Collection{}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got, err := impl.OfType(&expr.Context{}, tc.inputCollection, tc.inputArgs) + if err != nil { + t.Fatalf("OfType function returned unexpected error: %v", err) + } + if diff := cmp.Diff(tc.wantCollection, got, protocmp.Transform()); diff != "" { + t.Errorf("OfType function returned unexpected diff (-want, +got):\n%s", diff) + } + }) + } +} + +func TestOfType_RaisesError(t *testing.T) { + testCases := []struct { + name string + inputArgs []expr.Expression + inputCollection system.Collection + }{ + { + name: "multiple arguments", + inputArgs: []expr.Expression{exprtest.Return(1), exprtest.Return(1)}, + inputCollection: slices.MustConvert[any](contact), + }, + { + name: "argument expression raises error", + inputArgs: []expr.Expression{exprtest.Error(errors.New("some error"))}, + inputCollection: slices.MustConvert[any](contact), + }, + { + name: "invalid argument expression", + inputArgs: []expr.Expression{exprtest.Return(1, 2)}, + inputCollection: slices.MustConvert[any](contact), + }, + { + name: "invalid type", + inputArgs: []expr.Expression{&expr.TypeExpression{Type: "foo"}}, + inputCollection: slices.MustConvert[any](contact), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if _, err := impl.OfType(&expr.Context{}, tc.inputCollection, tc.inputArgs...); err == nil { + t.Fatalf("evaluating OfType function didn't return error when expected") + } + }) + } +} diff --git a/fhirpath/internal/funcs/impl/resolve.go b/fhirpath/internal/funcs/impl/resolve.go new file mode 100644 index 0000000..70be07b --- /dev/null +++ b/fhirpath/internal/funcs/impl/resolve.go @@ -0,0 +1,394 @@ +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" +) + +var ( + ErrInvalidReference = errors.New("invalid reference") + ErrUnconfiguredResolver = errors.New("resolve() function requires a Resolver to be configured in the evaluation context") +) + +func isValidResolveInput(item any) bool { + switch item.(type) { + case *dtpb.String, *dtpb.Uri, *dtpb.Url, *dtpb.Canonical, *dtpb.Reference, system.String, string: + return true + default: + return false + } +} + +func stringifyReference(ref *dtpb.Reference) string { + + if ref == nil || ref.GetType() == nil || ref.GetReference() == nil { + return "" + } + refType := ref.GetType().GetValue() + + switch ref.GetReference().(type) { + case *dtpb.Reference_AccountId: + return fmt.Sprintf("%s/%s", refType, ref.GetAccountId().GetValue()) + case *dtpb.Reference_ActivityDefinitionId: + return fmt.Sprintf("%s/%s", refType, ref.GetActivityDefinitionId().GetValue()) + case *dtpb.Reference_AdverseEventId: + return fmt.Sprintf("%s/%s", refType, ref.GetAdverseEventId().GetValue()) + case *dtpb.Reference_AllergyIntoleranceId: + return fmt.Sprintf("%s/%s", refType, ref.GetAllergyIntoleranceId().GetValue()) + case *dtpb.Reference_AppointmentId: + return fmt.Sprintf("%s/%s", refType, ref.GetAppointmentId().GetValue()) + case *dtpb.Reference_AppointmentResponseId: + return fmt.Sprintf("%s/%s", refType, ref.GetAppointmentResponseId().GetValue()) + case *dtpb.Reference_AuditEventId: + return fmt.Sprintf("%s/%s", refType, ref.GetAuditEventId().GetValue()) + case *dtpb.Reference_BasicId: + return fmt.Sprintf("%s/%s", refType, ref.GetBasicId().GetValue()) + case *dtpb.Reference_BinaryId: + return fmt.Sprintf("%s/%s", refType, ref.GetBinaryId().GetValue()) + case *dtpb.Reference_BiologicallyDerivedProductId: + return fmt.Sprintf("%s/%s", refType, ref.GetBiologicallyDerivedProductId().GetValue()) + case *dtpb.Reference_BodyStructureId: + return fmt.Sprintf("%s/%s", refType, ref.GetBodyStructureId().GetValue()) + case *dtpb.Reference_BundleId: + return fmt.Sprintf("%s/%s", refType, ref.GetBundleId().GetValue()) + case *dtpb.Reference_CapabilityStatementId: + return fmt.Sprintf("%s/%s", refType, ref.GetCapabilityStatementId().GetValue()) + case *dtpb.Reference_CarePlanId: + return fmt.Sprintf("%s/%s", refType, ref.GetCarePlanId().GetValue()) + case *dtpb.Reference_CareTeamId: + return fmt.Sprintf("%s/%s", refType, ref.GetCareTeamId().GetValue()) + case *dtpb.Reference_CatalogEntryId: + return fmt.Sprintf("%s/%s", refType, ref.GetCatalogEntryId().GetValue()) + case *dtpb.Reference_ChargeItemId: + return fmt.Sprintf("%s/%s", refType, ref.GetChargeItemId().GetValue()) + case *dtpb.Reference_ChargeItemDefinitionId: + return fmt.Sprintf("%s/%s", refType, ref.GetChargeItemDefinitionId().GetValue()) + case *dtpb.Reference_ClaimId: + return fmt.Sprintf("%s/%s", refType, ref.GetClaimId().GetValue()) + case *dtpb.Reference_ClaimResponseId: + return fmt.Sprintf("%s/%s", refType, ref.GetClaimResponseId().GetValue()) + case *dtpb.Reference_ClinicalImpressionId: + return fmt.Sprintf("%s/%s", refType, ref.GetClinicalImpressionId().GetValue()) + case *dtpb.Reference_CodeSystemId: + return fmt.Sprintf("%s/%s", refType, ref.GetCodeSystemId().GetValue()) + case *dtpb.Reference_CommunicationId: + return fmt.Sprintf("%s/%s", refType, ref.GetCommunicationId().GetValue()) + case *dtpb.Reference_CommunicationRequestId: + return fmt.Sprintf("%s/%s", refType, ref.GetCommunicationRequestId().GetValue()) + case *dtpb.Reference_CompartmentDefinitionId: + return fmt.Sprintf("%s/%s", refType, ref.GetCompartmentDefinitionId().GetValue()) + case *dtpb.Reference_CompositionId: + return fmt.Sprintf("%s/%s", refType, ref.GetCompositionId().GetValue()) + case *dtpb.Reference_ConceptMapId: + return fmt.Sprintf("%s/%s", refType, ref.GetConceptMapId().GetValue()) + case *dtpb.Reference_ConditionId: + return fmt.Sprintf("%s/%s", refType, ref.GetConditionId().GetValue()) + case *dtpb.Reference_ConsentId: + return fmt.Sprintf("%s/%s", refType, ref.GetConsentId().GetValue()) + case *dtpb.Reference_ContractId: + return fmt.Sprintf("%s/%s", refType, ref.GetContractId().GetValue()) + case *dtpb.Reference_CoverageId: + return fmt.Sprintf("%s/%s", refType, ref.GetCoverageId().GetValue()) + case *dtpb.Reference_CoverageEligibilityRequestId: + return fmt.Sprintf("%s/%s", refType, ref.GetCoverageEligibilityRequestId().GetValue()) + case *dtpb.Reference_CoverageEligibilityResponseId: + return fmt.Sprintf("%s/%s", refType, ref.GetCoverageEligibilityResponseId().GetValue()) + case *dtpb.Reference_DetectedIssueId: + return fmt.Sprintf("%s/%s", refType, ref.GetDetectedIssueId().GetValue()) + case *dtpb.Reference_DeviceDefinitionId: + return fmt.Sprintf("%s/%s", refType, ref.GetDeviceDefinitionId().GetValue()) + case *dtpb.Reference_DeviceId: + return fmt.Sprintf("%s/%s", refType, ref.GetDeviceId().GetValue()) + case *dtpb.Reference_DeviceMetricId: + return fmt.Sprintf("%s/%s", refType, ref.GetDeviceMetricId().GetValue()) + case *dtpb.Reference_DeviceRequestId: + return fmt.Sprintf("%s/%s", refType, ref.GetDeviceRequestId().GetValue()) + case *dtpb.Reference_DeviceUseStatementId: + return fmt.Sprintf("%s/%s", refType, ref.GetDeviceUseStatementId().GetValue()) + case *dtpb.Reference_DiagnosticReportId: + return fmt.Sprintf("%s/%s", refType, ref.GetDiagnosticReportId().GetValue()) + case *dtpb.Reference_DocumentManifestId: + return fmt.Sprintf("%s/%s", refType, ref.GetDocumentManifestId().GetValue()) + case *dtpb.Reference_DocumentReferenceId: + return fmt.Sprintf("%s/%s", refType, ref.GetDocumentReferenceId().GetValue()) + case *dtpb.Reference_DomainResourceId: + return fmt.Sprintf("%s/%s", refType, ref.GetDomainResourceId().GetValue()) + case *dtpb.Reference_EffectEvidenceSynthesisId: + return fmt.Sprintf("%s/%s", refType, ref.GetEffectEvidenceSynthesisId().GetValue()) + case *dtpb.Reference_EncounterId: + return fmt.Sprintf("%s/%s", refType, ref.GetEncounterId().GetValue()) + case *dtpb.Reference_EndpointId: + return fmt.Sprintf("%s/%s", refType, ref.GetEndpointId().GetValue()) + case *dtpb.Reference_EnrollmentRequestId: + return fmt.Sprintf("%s/%s", refType, ref.GetEnrollmentRequestId().GetValue()) + case *dtpb.Reference_EnrollmentResponseId: + return fmt.Sprintf("%s/%s", refType, ref.GetEnrollmentResponseId().GetValue()) + case *dtpb.Reference_EpisodeOfCareId: + return fmt.Sprintf("%s/%s", refType, ref.GetEpisodeOfCareId().GetValue()) + case *dtpb.Reference_EventDefinitionId: + return fmt.Sprintf("%s/%s", refType, ref.GetEventDefinitionId().GetValue()) + case *dtpb.Reference_EvidenceId: + return fmt.Sprintf("%s/%s", refType, ref.GetEvidenceId().GetValue()) + case *dtpb.Reference_EvidenceVariableId: + return fmt.Sprintf("%s/%s", refType, ref.GetEvidenceVariableId().GetValue()) + case *dtpb.Reference_ExampleScenarioId: + return fmt.Sprintf("%s/%s", refType, ref.GetExampleScenarioId().GetValue()) + case *dtpb.Reference_ExplanationOfBenefitId: + return fmt.Sprintf("%s/%s", refType, ref.GetExplanationOfBenefitId().GetValue()) + case *dtpb.Reference_FamilyMemberHistoryId: + return fmt.Sprintf("%s/%s", refType, ref.GetFamilyMemberHistoryId().GetValue()) + case *dtpb.Reference_FlagId: + return fmt.Sprintf("%s/%s", refType, ref.GetFlagId().GetValue()) + case *dtpb.Reference_Fragment: + return fmt.Sprintf("%s/%s", refType, ref.GetFragment()) + case *dtpb.Reference_GoalId: + return fmt.Sprintf("%s/%s", refType, ref.GetGoalId().GetValue()) + case *dtpb.Reference_GraphDefinitionId: + return fmt.Sprintf("%s/%s", refType, ref.GetGraphDefinitionId().GetValue()) + case *dtpb.Reference_GroupId: + return fmt.Sprintf("%s/%s", refType, ref.GetGroupId().GetValue()) + case *dtpb.Reference_GuidanceResponseId: + return fmt.Sprintf("%s/%s", refType, ref.GetGuidanceResponseId().GetValue()) + case *dtpb.Reference_HealthcareServiceId: + return fmt.Sprintf("%s/%s", refType, ref.GetHealthcareServiceId().GetValue()) + case *dtpb.Reference_ImagingStudyId: + return fmt.Sprintf("%s/%s", refType, ref.GetImagingStudyId().GetValue()) + case *dtpb.Reference_ImmunizationEvaluationId: + return fmt.Sprintf("%s/%s", refType, ref.GetImmunizationEvaluationId().GetValue()) + case *dtpb.Reference_ImmunizationId: + return fmt.Sprintf("%s/%s", refType, ref.GetImmunizationId().GetValue()) + case *dtpb.Reference_ImmunizationRecommendationId: + return fmt.Sprintf("%s/%s", refType, ref.GetImmunizationRecommendationId().GetValue()) + case *dtpb.Reference_ImplementationGuideId: + return fmt.Sprintf("%s/%s", refType, ref.GetImplementationGuideId().GetValue()) + case *dtpb.Reference_InsurancePlanId: + return fmt.Sprintf("%s/%s", refType, ref.GetInsurancePlanId().GetValue()) + case *dtpb.Reference_InvoiceId: + return fmt.Sprintf("%s/%s", refType, ref.GetInvoiceId().GetValue()) + case *dtpb.Reference_LibraryId: + return fmt.Sprintf("%s/%s", refType, ref.GetLibraryId().GetValue()) + case *dtpb.Reference_LinkageId: + return fmt.Sprintf("%s/%s", refType, ref.GetLinkageId().GetValue()) + case *dtpb.Reference_ListId: + return fmt.Sprintf("%s/%s", refType, ref.GetListId().GetValue()) + case *dtpb.Reference_LocationId: + return fmt.Sprintf("%s/%s", refType, ref.GetLocationId().GetValue()) + case *dtpb.Reference_MeasureId: + return fmt.Sprintf("%s/%s", refType, ref.GetMeasureId().GetValue()) + case *dtpb.Reference_MeasureReportId: + return fmt.Sprintf("%s/%s", refType, ref.GetMeasureReportId().GetValue()) + case *dtpb.Reference_MediaId: + return fmt.Sprintf("%s/%s", refType, ref.GetMediaId().GetValue()) + case *dtpb.Reference_MedicationAdministrationId: + return fmt.Sprintf("%s/%s", refType, ref.GetMedicationAdministrationId().GetValue()) + case *dtpb.Reference_MedicationDispenseId: + return fmt.Sprintf("%s/%s", refType, ref.GetMedicationDispenseId().GetValue()) + case *dtpb.Reference_MedicationId: + return fmt.Sprintf("%s/%s", refType, ref.GetMedicationId().GetValue()) + case *dtpb.Reference_MedicationKnowledgeId: + return fmt.Sprintf("%s/%s", refType, ref.GetMedicationKnowledgeId().GetValue()) + case *dtpb.Reference_MedicationRequestId: + return fmt.Sprintf("%s/%s", refType, ref.GetMedicationRequestId().GetValue()) + case *dtpb.Reference_MedicationStatementId: + return fmt.Sprintf("%s/%s", refType, ref.GetMedicationStatementId().GetValue()) + case *dtpb.Reference_MedicinalProductAuthorizationId: + return fmt.Sprintf("%s/%s", refType, ref.GetMedicinalProductAuthorizationId().GetValue()) + case *dtpb.Reference_MedicinalProductContraindicationId: + return fmt.Sprintf("%s/%s", refType, ref.GetMedicinalProductContraindicationId().GetValue()) + case *dtpb.Reference_MedicinalProductId: + return fmt.Sprintf("%s/%s", refType, ref.GetMedicinalProductId().GetValue()) + case *dtpb.Reference_MedicinalProductIndicationId: + return fmt.Sprintf("%s/%s", refType, ref.GetMedicinalProductIndicationId().GetValue()) + case *dtpb.Reference_MedicinalProductIngredientId: + return fmt.Sprintf("%s/%s", refType, ref.GetMedicinalProductIngredientId().GetValue()) + case *dtpb.Reference_MedicinalProductInteractionId: + return fmt.Sprintf("%s/%s", refType, ref.GetMedicinalProductInteractionId().GetValue()) + case *dtpb.Reference_MedicinalProductManufacturedId: + return fmt.Sprintf("%s/%s", refType, ref.GetMedicinalProductManufacturedId().GetValue()) + case *dtpb.Reference_MedicinalProductPackagedId: + return fmt.Sprintf("%s/%s", refType, ref.GetMedicinalProductPackagedId().GetValue()) + case *dtpb.Reference_MedicinalProductPharmaceuticalId: + return fmt.Sprintf("%s/%s", refType, ref.GetMedicinalProductPharmaceuticalId().GetValue()) + case *dtpb.Reference_MedicinalProductUndesirableEffectId: + return fmt.Sprintf("%s/%s", refType, ref.GetMedicinalProductUndesirableEffectId().GetValue()) + case *dtpb.Reference_MessageDefinitionId: + return fmt.Sprintf("%s/%s", refType, ref.GetMessageDefinitionId().GetValue()) + case *dtpb.Reference_MessageHeaderId: + return fmt.Sprintf("%s/%s", refType, ref.GetMessageHeaderId().GetValue()) + case *dtpb.Reference_MetadataResourceId: + return fmt.Sprintf("%s/%s", refType, ref.GetMetadataResourceId().GetValue()) + case *dtpb.Reference_MolecularSequenceId: + return fmt.Sprintf("%s/%s", refType, ref.GetMolecularSequenceId().GetValue()) + case *dtpb.Reference_NamingSystemId: + return fmt.Sprintf("%s/%s", refType, ref.GetNamingSystemId().GetValue()) + case *dtpb.Reference_NutritionOrderId: + return fmt.Sprintf("%s/%s", refType, ref.GetNutritionOrderId().GetValue()) + case *dtpb.Reference_ObservationDefinitionId: + return fmt.Sprintf("%s/%s", refType, ref.GetObservationDefinitionId().GetValue()) + case *dtpb.Reference_ObservationId: + return fmt.Sprintf("%s/%s", refType, ref.GetObservationId().GetValue()) + case *dtpb.Reference_OperationDefinitionId: + return fmt.Sprintf("%s/%s", refType, ref.GetOperationDefinitionId().GetValue()) + case *dtpb.Reference_OperationOutcomeId: + return fmt.Sprintf("%s/%s", refType, ref.GetOperationOutcomeId().GetValue()) + case *dtpb.Reference_OrganizationAffiliationId: + return fmt.Sprintf("%s/%s", refType, ref.GetOrganizationAffiliationId().GetValue()) + case *dtpb.Reference_OrganizationId: + return fmt.Sprintf("%s/%s", refType, ref.GetOrganizationId().GetValue()) + case *dtpb.Reference_ParametersId: + return fmt.Sprintf("%s/%s", refType, ref.GetParametersId().GetValue()) + case *dtpb.Reference_PatientId: + return fmt.Sprintf("%s/%s", refType, ref.GetPatientId().GetValue()) + case *dtpb.Reference_PaymentNoticeId: + return fmt.Sprintf("%s/%s", refType, ref.GetPaymentNoticeId().GetValue()) + case *dtpb.Reference_PaymentReconciliationId: + return fmt.Sprintf("%s/%s", refType, ref.GetPaymentReconciliationId().GetValue()) + case *dtpb.Reference_PersonId: + return fmt.Sprintf("%s/%s", refType, ref.GetPersonId().GetValue()) + case *dtpb.Reference_PlanDefinitionId: + return fmt.Sprintf("%s/%s", refType, ref.GetPlanDefinitionId().GetValue()) + case *dtpb.Reference_PractitionerId: + return fmt.Sprintf("%s/%s", refType, ref.GetPractitionerId().GetValue()) + case *dtpb.Reference_PractitionerRoleId: + return fmt.Sprintf("%s/%s", refType, ref.GetPractitionerRoleId().GetValue()) + case *dtpb.Reference_ProcedureId: + return fmt.Sprintf("%s/%s", refType, ref.GetProcedureId().GetValue()) + case *dtpb.Reference_ProvenanceId: + return fmt.Sprintf("%s/%s", refType, ref.GetProvenanceId().GetValue()) + case *dtpb.Reference_QuestionnaireId: + return fmt.Sprintf("%s/%s", refType, ref.GetQuestionnaireId().GetValue()) + case *dtpb.Reference_QuestionnaireResponseId: + return fmt.Sprintf("%s/%s", refType, ref.GetQuestionnaireResponseId().GetValue()) + case *dtpb.Reference_RelatedPersonId: + return fmt.Sprintf("%s/%s", refType, ref.GetRelatedPersonId().GetValue()) + case *dtpb.Reference_RequestGroupId: + return fmt.Sprintf("%s/%s", refType, ref.GetRequestGroupId().GetValue()) + case *dtpb.Reference_ResearchDefinitionId: + return fmt.Sprintf("%s/%s", refType, ref.GetResearchDefinitionId().GetValue()) + case *dtpb.Reference_ResearchElementDefinitionId: + return fmt.Sprintf("%s/%s", refType, ref.GetResearchElementDefinitionId().GetValue()) + case *dtpb.Reference_ResearchStudyId: + return fmt.Sprintf("%s/%s", refType, ref.GetResearchStudyId().GetValue()) + case *dtpb.Reference_ResearchSubjectId: + return fmt.Sprintf("%s/%s", refType, ref.GetResearchSubjectId().GetValue()) + case *dtpb.Reference_ResourceId: + return fmt.Sprintf("%s/%s", refType, ref.GetResourceId()) + case *dtpb.Reference_RiskAssessmentId: + return fmt.Sprintf("%s/%s", refType, ref.GetRiskAssessmentId().GetValue()) + case *dtpb.Reference_RiskEvidenceSynthesisId: + return fmt.Sprintf("%s/%s", refType, ref.GetRiskEvidenceSynthesisId().GetValue()) + case *dtpb.Reference_ScheduleId: + return fmt.Sprintf("%s/%s", refType, ref.GetScheduleId().GetValue()) + case *dtpb.Reference_SearchParameterId: + return fmt.Sprintf("%s/%s", refType, ref.GetSearchParameterId().GetValue()) + case *dtpb.Reference_ServiceRequestId: + return fmt.Sprintf("%s/%s", refType, ref.GetServiceRequestId().GetValue()) + case *dtpb.Reference_SlotId: + return fmt.Sprintf("%s/%s", refType, ref.GetSlotId().GetValue()) + case *dtpb.Reference_SpecimenDefinitionId: + return fmt.Sprintf("%s/%s", refType, ref.GetSpecimenDefinitionId().GetValue()) + case *dtpb.Reference_SpecimenId: + return fmt.Sprintf("%s/%s", refType, ref.GetSpecimenId().GetValue()) + case *dtpb.Reference_StructureDefinitionId: + return fmt.Sprintf("%s/%s", refType, ref.GetStructureDefinitionId().GetValue()) + case *dtpb.Reference_StructureMapId: + return fmt.Sprintf("%s/%s", refType, ref.GetStructureMapId().GetValue()) + case *dtpb.Reference_SubscriptionId: + return fmt.Sprintf("%s/%s", refType, ref.GetSubscriptionId().GetValue()) + case *dtpb.Reference_SubstanceId: + return fmt.Sprintf("%s/%s", refType, ref.GetSubstanceId().GetValue()) + case *dtpb.Reference_SubstanceNucleicAcidId: + return fmt.Sprintf("%s/%s", refType, ref.GetSubstanceNucleicAcidId().GetValue()) + case *dtpb.Reference_SubstancePolymerId: + return fmt.Sprintf("%s/%s", refType, ref.GetSubstancePolymerId().GetValue()) + case *dtpb.Reference_SubstanceProteinId: + return fmt.Sprintf("%s/%s", refType, ref.GetSubstanceProteinId().GetValue()) + case *dtpb.Reference_SubstanceReferenceInformationId: + return fmt.Sprintf("%s/%s", refType, ref.GetSubstanceReferenceInformationId().GetValue()) + case *dtpb.Reference_SubstanceSourceMaterialId: + return fmt.Sprintf("%s/%s", refType, ref.GetSubstanceSourceMaterialId().GetValue()) + case *dtpb.Reference_SubstanceSpecificationId: + return fmt.Sprintf("%s/%s", refType, ref.GetSubstanceSpecificationId().GetValue()) + case *dtpb.Reference_SupplyDeliveryId: + return fmt.Sprintf("%s/%s", refType, ref.GetSupplyDeliveryId().GetValue()) + case *dtpb.Reference_SupplyRequestId: + return fmt.Sprintf("%s/%s", refType, ref.GetSupplyRequestId().GetValue()) + case *dtpb.Reference_TaskId: + return fmt.Sprintf("%s/%s", refType, ref.GetTaskId().GetValue()) + case *dtpb.Reference_TerminologyCapabilitiesId: + return fmt.Sprintf("%s/%s", refType, ref.GetTerminologyCapabilitiesId().GetValue()) + case *dtpb.Reference_TestReportId: + return fmt.Sprintf("%s/%s", refType, ref.GetTestReportId().GetValue()) + case *dtpb.Reference_TestScriptId: + return fmt.Sprintf("%s/%s", refType, ref.GetTestScriptId().GetValue()) + case *dtpb.Reference_Uri: + return fmt.Sprintf("%s/%s", refType, ref.GetUri()) + case *dtpb.Reference_ValueSetId: + return fmt.Sprintf("%s/%s", refType, ref.GetValueSetId().GetValue()) + case *dtpb.Reference_VerificationResultId: + return fmt.Sprintf("%s/%s", refType, ref.GetVerificationResultId().GetValue()) + case *dtpb.Reference_VisionPrescriptionId: + return fmt.Sprintf("%s/%s", refType, ref.GetVisionPrescriptionId().GetValue()) + default: + return "" + } +} + +func toString(item any) string { + switch item := item.(type) { + case *dtpb.String: + return item.GetValue() + case *dtpb.Uri: + return item.GetValue() + case *dtpb.Url: + return item.GetValue() + case *dtpb.Canonical: + return item.GetValue() + case *dtpb.Reference: + return stringifyReference(item) + case system.String: + return string(item) + default: + return "" + } +} + +// Resolve locates the target of each reference in the input collection. For each item that +// is a string representing a URI (or canonical or URL), or a Reference, Resolve locates +// the target resource and adds it to the resulting collection. If an item does not resolve +// to a resource, it is ignored. If the input is empty, the output will be empty. +func Resolve(ctx *expr.Context, input system.Collection, args ...expr.Expression) (system.Collection, error) { + if argLen := len(args); argLen != 0 { + return nil, fmt.Errorf("%w: received %v arguments, expected 0", ErrWrongArity, argLen) + } + + toResolve := []string{} + for _, item := range input { + if isValidResolveInput(item) { + toResolve = append(toResolve, toString(item)) + } + } + + if len(toResolve) == 0 { + return system.Collection{}, nil + } + + resolverImpl := ctx.Resolver + if resolverImpl == nil { + return nil, ErrUnconfiguredResolver + } + resources, err := resolverImpl.Resolve(toResolve) + if err != nil { + return nil, err + } + + resolved := system.Collection{} + for _, res := range resources { + resolved = append(resolved, res) + } + return resolved, nil +} diff --git a/fhirpath/internal/funcs/impl/resolve_test.go b/fhirpath/internal/funcs/impl/resolve_test.go new file mode 100644 index 0000000..f13576f --- /dev/null +++ b/fhirpath/internal/funcs/impl/resolve_test.go @@ -0,0 +1,128 @@ +package impl_test + +import ( + "errors" + "testing" + + dtpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/datatypes_go_proto" + ppb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/patient_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/resolver" + "github.com/verily-src/fhirpath-go/fhirpath/resolver/resolvertest" + "github.com/verily-src/fhirpath-go/fhirpath/system" + "github.com/verily-src/fhirpath-go/internal/fhir" + "google.golang.org/protobuf/testing/protocmp" +) + +func TestResolve(t *testing.T) { + var patientChu = &ppb.Patient{ + Id: fhir.ID("123"), + } + + var patientChuRef = &dtpb.Reference{ + Type: fhir.URI("Patient"), + Reference: &dtpb.Reference_PatientId{ + PatientId: &dtpb.ReferenceId{Value: "123"}}, + } + + var patientChuUri = &dtpb.Uri{ + Value: "Patient/123", + } + + var patientChuUrl = &dtpb.Url{ + Value: "http://example.com/Patient/123", + } + + var patientChuCanonical = &dtpb.Canonical{ + Value: "Patient/123", + } + + var patientChuString = &dtpb.String{ + Value: "Patient/123", + } + + resolveErr := errors.New("some resolve() error") + + testCases := []struct { + name string + inputCollection system.Collection + resolverImpl resolver.Resolver + args []expr.Expression + wantCollection system.Collection + wantErr error + }{ + { + name: "happy path; successful reference resolution", + inputCollection: system.Collection{patientChuRef}, + resolverImpl: resolvertest.HappyResolver(patientChu), + wantCollection: system.Collection{patientChu}, + }, + { + name: "happy path; successful uri resolution", + inputCollection: system.Collection{patientChuUri}, + resolverImpl: resolvertest.HappyResolver(patientChu), + wantCollection: system.Collection{patientChu}, + }, + { + name: "happy path; successful string resolution", + inputCollection: system.Collection{patientChuString}, + resolverImpl: resolvertest.HappyResolver(patientChu), + wantCollection: system.Collection{patientChu}, + }, + { + name: "happy path; successful url resolution", + inputCollection: system.Collection{patientChuUrl}, + resolverImpl: resolvertest.HappyResolver(patientChu), + wantCollection: system.Collection{patientChu}, + }, + { + name: "happy path; successful canonical resolution", + inputCollection: system.Collection{patientChuCanonical}, + resolverImpl: resolvertest.HappyResolver(patientChu), + wantCollection: system.Collection{patientChu}, + }, + { + name: "happy path; empty input", + inputCollection: system.Collection{}, + resolverImpl: resolvertest.HappyResolver(patientChu), + wantCollection: system.Collection{}, + }, + { + name: "too many arguments; throws error", + inputCollection: system.Collection{patientChuRef}, + resolverImpl: resolvertest.HappyResolver(patientChu), + args: []expr.Expression{ + &expr.LiteralExpression{Literal: system.String("")}, + }, + wantErr: impl.ErrWrongArity, + }, + { + name: "happy path; input doesn't have a string item - returns empty collection", + inputCollection: system.Collection{system.Integer(900)}, + resolverImpl: resolvertest.HappyResolver(patientChu), + wantCollection: system.Collection{}, + }, + { + name: "resolverImpl resolve() returns an error", + inputCollection: system.Collection{patientChuRef}, + resolverImpl: resolvertest.ErroringResolver(resolveErr), + wantErr: resolveErr, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + gotCollection, gotErr := impl.Resolve(&expr.Context{Resolver: tc.resolverImpl}, tc.inputCollection, tc.args...) + + if !cmp.Equal(gotErr, tc.wantErr, cmpopts.EquateErrors()) { + t.Errorf("Resolve() gotErr = %v, wantErr = %v", gotErr, tc.wantErr) + } + if diff := cmp.Diff(tc.wantCollection, gotCollection, protocmp.Transform()); diff != "" { + t.Errorf("Resolve() mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/fhirpath/internal/funcs/impl/strings.go b/fhirpath/internal/funcs/impl/strings.go index e9eb171..6feacc8 100644 --- a/fhirpath/internal/funcs/impl/strings.go +++ b/fhirpath/internal/funcs/impl/strings.go @@ -10,7 +10,9 @@ import ( "github.com/verily-src/fhirpath-go/fhirpath/system" ) -var ErrInvalidRegex = errors.New("invalid regex") +var ( + ErrInvalidRegex = errors.New("invalid regex") +) // StartsWith returns true if the input string starts with the given prefix. func StartsWith(ctx *expr.Context, input system.Collection, args ...expr.Expression) (system.Collection, error) { diff --git a/fhirpath/internal/funcs/table.go b/fhirpath/internal/funcs/table.go index 91a255d..347b8fa 100644 --- a/fhirpath/internal/funcs/table.go +++ b/fhirpath/internal/funcs/table.go @@ -89,7 +89,12 @@ var baseTable = FunctionTable{ false, }, "repeat": notImplemented, - "ofType": notImplemented, + "ofType": Function{ + impl.OfType, + 1, + 1, + true, + }, "single": notImplemented, "first": Function{ impl.First, @@ -133,8 +138,13 @@ var baseTable = FunctionTable{ 1, false, }, - "union": notImplemented, - "combine": notImplemented, + "union": notImplemented, + "combine": Function{ + impl.Combine, + 0, + 1, + false, + }, "iif": Function{ impl.Iif, 2, @@ -406,6 +416,12 @@ var baseTable = FunctionTable{ 0, false, }, + "resolve": Function{ + impl.Resolve, + 0, + 0, + false, + }, } // ExperimentalTable holds the mapping of all @@ -424,7 +440,7 @@ var experimentalTable = FunctionTable{ // Clone returns a deep copy of the base // function table. func Clone() FunctionTable { - // TODO(PHP-6173): Optimize + // TODO: Optimize table := make(FunctionTable) for k, v := range baseTable { table[k] = v diff --git a/fhirpath/internal/grammar/fhirpath.g4 b/fhirpath/internal/grammar/fhirpath.g4 index b6c993d..9a65ee6 100644 --- a/fhirpath/internal/grammar/fhirpath.g4 +++ b/fhirpath/internal/grammar/fhirpath.g4 @@ -59,8 +59,8 @@ invocation // Terms that can be used after the function ; function - : identifier '(' paramList? ')' - ; + : identifier '(' paramList? ')' + ; paramList : expression (',' expression)* diff --git a/fhirpath/internal/parser/visitor.go b/fhirpath/internal/parser/visitor.go index c4bf27f..e0fc156 100644 --- a/fhirpath/internal/parser/visitor.go +++ b/fhirpath/internal/parser/visitor.go @@ -250,9 +250,9 @@ func (v *FHIRPathVisitor) VisitEqualityExpression(ctx *grammar.EqualityExpressio case expr.NotEquals: expression = &expr.EqualityExpression{Left: leftResult.Result, Right: rightResult.Result, Not: true} case expr.Equivalence: - // TODO (PHP-5889): Implement equivalence expressions + // TODO : Implement equivalence expressions case expr.Inequivalence: - // TODO (PHP-5889): Implement non-equivalence expressions + // TODO : Implement non-equivalence expressions } return v.transformedVisitResult(expression) } @@ -450,10 +450,40 @@ func (v *FHIRPathVisitor) VisitTotalInvocation(ctx *grammar.TotalInvocationConte } func (v *FHIRPathVisitor) VisitFunction(ctx *grammar.FunctionContext) interface{} { - ident := ctx.Identifier().GetText() - fn, ok := v.Functions[ident] - if !ok { - return &VisitResult{nil, fmt.Errorf("%w: %s", errUnresolvedFunction, ident)} + var fn funcs.Function + if ctx.Identifier() == nil { + fn = v.Functions["ofType"] + } else { + var ok bool + fn, ok = v.Functions[ctx.Identifier().GetText()] + if !ok { + return &VisitResult{nil, fmt.Errorf("%w: %s", errUnresolvedFunction, ctx.Identifier().GetText())} + } + } + + // Handling for type functions + if fn.IsTypeFunction { + paramList := ctx.ParamList() + if paramList == nil || len(paramList.AllExpression()) != 1 { + return &VisitResult{nil, fmt.Errorf("type function expects exactly one argument")} + } + + // Visit the first argument as a type specifier + typeExpr := paramList.Expression(0) + // This gets the string, e.g. "string" or "FHIR.Patient" + typeName := typeExpr.GetText() + + typeSpecifier, err := reflection.NewTypeSpecifier(typeName) + if err != nil { + return &VisitResult{nil, err} + } + + return v.transformedVisitResult( + &expr.FunctionExpression{ + Fn: fn.Func, + Args: []expr.Expression{&expr.TypeExpression{Type: typeSpecifier.String()}}, + }, + ) } results := []*VisitResult{} diff --git a/fhirpath/internal/reflection/type_specifier.go b/fhirpath/internal/reflection/type_specifier.go index 330fe62..6a944a7 100644 --- a/fhirpath/internal/reflection/type_specifier.go +++ b/fhirpath/internal/reflection/type_specifier.go @@ -3,6 +3,7 @@ package reflection import ( "errors" "fmt" + "strings" "github.com/verily-src/fhirpath-go/fhirpath/system" "github.com/verily-src/fhirpath-go/internal/fhir" @@ -45,6 +46,10 @@ func NewQualifiedTypeSpecifier(namespace string, typeName string) (TypeSpecifier // is inferred with the priority rules of FHIRPath. Returns an error if the typeName cannot // be resolved. func NewTypeSpecifier(typeName string) (TypeSpecifier, error) { + if parts := strings.Split(typeName, "."); len(parts) == 2 { + return NewQualifiedTypeSpecifier(parts[0], parts[1]) + } + if IsValidFHIRPathElement(typeName) || protofields.IsValidResourceType(typeName) || isBaseType(typeName) { return TypeSpecifier{FHIR, typeName}, nil } @@ -74,6 +79,15 @@ func TypeOf(input any) (TypeSpecifier, error) { return TypeSpecifier{FHIR, primitiveToLowercase(name)}, nil } +// String returns a string representation of the type specifier in the format: +// . +func (ts TypeSpecifier) String() string { + if ts.namespace != "" { + return ts.namespace + "." + ts.typeName + } + return ts.typeName +} + // Is returns a boolean representing whether or not the receiver type is equivalent to the // input type, or if it's a valid subtype. func (ts TypeSpecifier) Is(input TypeSpecifier) system.Boolean { diff --git a/fhirpath/patch/patch.go b/fhirpath/patch/patch.go index 7849077..eaca0f0 100644 --- a/fhirpath/patch/patch.go +++ b/fhirpath/patch/patch.go @@ -576,7 +576,7 @@ func (e *Expression) getRefAndFieldForCollection(collection system.Collection, t return nil, nil, -1, fmt.Errorf("%w: field cannot be replaced", ErrNotPatchable) } -// TODO(PHP-29745): make this a common function +// TODO: make this a common function func (e *Expression) unwrapOneof(obj proto.Message) proto.Message { message := obj.ProtoReflect() descriptor := message.Descriptor() diff --git a/fhirpath/resolver/bundleresolver.go b/fhirpath/resolver/bundleresolver.go new file mode 100644 index 0000000..30abb3d --- /dev/null +++ b/fhirpath/resolver/bundleresolver.go @@ -0,0 +1,275 @@ +package resolver + +import ( + "fmt" + "regexp" + + cpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/codes_go_proto" + r4pb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/bundle_and_contained_resource_go_proto" + "github.com/verily-src/fhirpath-go/internal/containedresource" + "github.com/verily-src/fhirpath-go/internal/element/reference" + "github.com/verily-src/fhirpath-go/internal/fhir" +) + +var ( + ErrMultipleResourcesWithSameIDAndVersion = fmt.Errorf("multiple bundle entries with same id and version found for the same resource type") + ErrUnsupportedBundleType = fmt.Errorf("only bundles of type SEARCHSET or COLLECTION are supported by BundleResolver, another found") + ErrNilBundleInit = fmt.Errorf("cannot initialize BundleResolver with a nil bundle") + ErrMissingMetaOrLastUpdated = fmt.Errorf("undefined behavior, multiple versionless absolute reference matches found, with missing meta or meta.lastUpdated fields") +) + +// BundleResolver implements the `Resolver` interface and +// resolves FHIR references (URIs or plain URLs) to resources within a FHIR Bundle. +type BundleResolver struct { + bundle *r4pb.Bundle +} + +// NewBundleResolver initializes a BundleResolver for SEARCHSET and COLLECTION type bundles +func NewBundleResolver(bundle *r4pb.Bundle) (*BundleResolver, error) { + if bundle == nil { + return nil, ErrNilBundleInit + } + bundleType := bundle.GetType().GetValue() + if bundleType != cpb.BundleTypeCode_SEARCHSET && bundleType != cpb.BundleTypeCode_COLLECTION { + return nil, ErrUnsupportedBundleType + } + return &BundleResolver{ + bundle: bundle, + }, nil +} + +// resolvableResources holds maps and slices of resources extracted from a FHIR Bundle +// to facilitate efficient lookup and resolution of FHIR references. +type resolvableResources struct { + // urnResourceMap stores resources accessible by their URN (e.g., "urn:uuid:..." or "urn:oid:..."). + // The key is the URN string, and the value is the corresponding fhir.Resource. + urnResourceMap map[string]fhir.Resource + // versionlessUrlResourcesMap stores resources accessible by their absolute URL without a version ID. + // The key is a concatenation of the base URL and the resource's identity string (e.g., "http://example.com/Patient/123"). + // The value is a slice of fhir.Resource. + versionlessUrlResourcesMap map[string][]fhir.Resource + // versionedURLResourcesMap stores resources accessible by their absolute URL including a version ID. + // The key is a concatenation of the base URL, resource's identity string, and version ID (e.g., "http://example.com/Patient/123/_history/4"). + // The value is a slice of fhir.Resource. + versionedURLResourcesMap map[string][]fhir.Resource + // rootURLs stores a list of base URLs found in the bundle's entries that are absolute RESTful URIs. + // These are used to resolve relative FHIR references by constructing full URLs. + rootURLs []string +} + +// fromBundle populates a resolvableResources struct from a FHIR Bundle. +// It populates maps for resolving resources by their full URL and versioned URL, +// and extracts base URLs from absolute RESTful URIs within the bundle entries. +// Invalid entries or entries without a full URL are skipped. +func (rr *resolvableResources) fromBundle(bundle *r4pb.Bundle) { + for _, entry := range bundle.Entry { + if entry == nil || entry.Resource == nil { + continue + } + entryResource := containedresource.Unwrap(entry.Resource) + + // if the FullUrl is missing, this resource cannot be resolved + fullURL := entry.GetFullUrl().GetValue() + if fullURL == "" { + continue + } + + if isURN(fullURL) { + rr.urnResourceMap[fullURL] = entryResource + continue + } + + _, _, isVersioned, isAbsoluteURL := absoluteURLInfo(fullURL) + if isAbsoluteURL { + key := fullURL + if isVersioned { + rr.versionedURLResourcesMap[key] = append(rr.versionedURLResourcesMap[key], entryResource) + } else { + rr.versionlessUrlResourcesMap[key] = append(rr.versionlessUrlResourcesMap[key], entryResource) + } + } + + isRestful := restfulURLRegex.MatchString(fullURL) + if isRestful { + litInfo, err := reference.LiteralInfoFromURI(fullURL) + if err != nil { + continue + } + + rootURL := litInfo.ServiceBaseURL() + if rootURL == "" { + continue + } + rr.rootURLs = append(rr.rootURLs, rootURL) + } + } +} + +func (rr *resolvableResources) isEmpty() bool { + return len(rr.versionlessUrlResourcesMap) == 0 && len(rr.versionedURLResourcesMap) == 0 && len(rr.rootURLs) == 0 && len(rr.urnResourceMap) == 0 +} + +// Regex Sources - +// - https://hl7.org/fhir/datatypes.html#primitive +// - https://hl7.org/fhir/r4/references.html#literal +var ( + oidRegex = regexp.MustCompile(`^urn:oid:[0-2](\.(0|[1-9][0-9]*))+$`) + uuidRegex = regexp.MustCompile(`^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) + restfulURLRegex = regexp.MustCompile(`^((http|https)://([A-Za-z0-9\-\.:%\\$]*/)+)?(Account|ActivityDefinition|ActorDefinition|AdministrableProductDefinition|AdverseEvent|AllergyIntolerance|Appointment|AppointmentResponse|ArtifactAssessment|AuditEvent|Basic|Binary|BiologicallyDerivedProduct|BiologicallyDerivedProductDispense|BodyStructure|Bundle|CapabilityStatement|CarePlan|CareTeam|ChargeItem|ChargeItemDefinition|Citation|Claim|ClaimResponse|ClinicalAssessment|ClinicalUseDefinition|CodeSystem|Communication|CommunicationRequest|CompartmentDefinition|Composition|ConceptMap|Condition|ConditionDefinition|Consent|Contract|Coverage|CoverageEligibilityRequest|CoverageEligibilityResponse|DetectedIssue|Device|DeviceAlert|DeviceAssociation|DeviceDefinition|DeviceDispense|DeviceMetric|DeviceRequest|DeviceUsage|DiagnosticReport|DocumentReference|Encounter|EncounterHistory|Endpoint|EnrollmentRequest|EnrollmentResponse|EpisodeOfCare|EventDefinition|Evidence|EvidenceVariable|ExampleScenario|ExplanationOfBenefit|FamilyMemberHistory|Flag|FormularyItem|GenomicStudy|Goal|GraphDefinition|Group|GuidanceResponse|HealthcareService|ImagingSelection|ImagingStudy|Immunization|ImmunizationEvaluation|ImmunizationRecommendation|ImplementationGuide|Ingredient|InsurancePlan|InsuranceProduct|InventoryItem|InventoryReport|Invoice|Library|Linkage|List|Location|ManufacturedItemDefinition|Measure|MeasureReport|Medication|MedicationAdministration|MedicationDispense|MedicationKnowledge|MedicationRequest|MedicationStatement|MedicinalProductDefinition|MessageDefinition|MessageHeader|MolecularDefinition|MolecularSequence|NamingSystem|NutritionIntake|NutritionOrder|NutritionProduct|Observation|ObservationDefinition|OperationDefinition|OperationOutcome|Organization|OrganizationAffiliation|PackagedProductDefinition|Patient|PaymentNotice|PaymentReconciliation|Permission|Person|PersonalRelationship|PlanDefinition|Practitioner|PractitionerRole|Procedure|Provenance|Questionnaire|QuestionnaireResponse|RegulatedAuthorization|RelatedPerson|RequestOrchestration|Requirements|ResearchStudy|ResearchSubject|RiskAssessment|Schedule|SearchParameter|ServiceRequest|Slot|Specimen|SpecimenDefinition|StructureDefinition|StructureMap|Subscription|SubscriptionStatus|SubscriptionTopic|Substance|SubstanceDefinition|SubstanceNucleicAcid|SubstancePolymer|SubstanceProtein|SubstanceReferenceInformation|SubstanceSourceMaterial|SupplyDelivery|SupplyRequest|Task|TerminologyCapabilities|TestPlan|TestReport|TestScript|Transport|ValueSet|VerificationResult|VisionPrescription)/[A-Za-z0-9\-\.]{1,64}(/_history/[A-Za-z0-9\-\.]{1,64})?(#[A-Za-z0-9\-\.]{1,64})?$`) +) + +// isURN checks if a reference value is a URN (e.g., "urn:uuid:..." or "urn:oid:..."). +func isURN(refValue string) bool { + return oidRegex.MatchString(refValue) || uuidRegex.MatchString(refValue) +} + +// absoluteURLInfo is a helper function that returns base URL, identity string, isVersioned boolean, and a boolean indicating if the given reference is an absolute URL, for a given reference string +func absoluteURLInfo(refValue string) (baseURL string, identityStr string, isVersioned bool, isAbsoluteURL bool) { + identity, err := reference.IdentityFromAbsoluteURL(refValue) + if err != nil { + return "", "", false, false + } + litInfo, err := reference.LiteralInfoFromURI(refValue) + if err != nil { + return "", "", false, false + } + baseURL = litInfo.ServiceBaseURL() + _, isVersioned = identity.VersionID() + return baseURL, identity.String(), isVersioned, true +} + +func isRelativeURL(refValue string) bool { + _, err := reference.IdentityFromRelativeURI(refValue) + return err == nil +} + +// toAbsolute is a helper function that returns an absolute URL, given a base URL, and a relative identity +func toAbsolute(baseURL string, identity string) string { + return fmt.Sprintf("%s/%s", baseURL, identity) +} + +func (rr *resolvableResources) resolveURN(ref string) (fhir.Resource, error) { + resource, ok := rr.urnResourceMap[ref] + if !ok { + return nil, nil + } + return resource, nil +} + +// getLatestResource returns the resource with the latest meta.LastUpdated field, from a slice of Resources. +// If any of the given resources has a missing meta.LastUpdated field, an Undefined behavior error is thrown. +func getLatestResource(resources []fhir.Resource) (fhir.Resource, error) { + if resources[0].GetMeta().GetLastUpdated() == nil { + return nil, ErrMissingMetaOrLastUpdated + } + resolvedResource := resources[0] + resolvedLastUpdated := resolvedResource.GetMeta().GetLastUpdated().GetValueUs() + + for _, res := range resources[1:] { + if res.GetMeta().GetLastUpdated() == nil { + return nil, ErrMissingMetaOrLastUpdated + } + resLastUpdated := res.GetMeta().GetLastUpdated().GetValueUs() + if resolvedLastUpdated > resLastUpdated { + resolvedResource = res + resolvedLastUpdated = resLastUpdated + } + } + return resolvedResource, nil +} + +func (rr *resolvableResources) resolveAbsolute(baseURL string, identityStr string, isVersioned bool) (fhir.Resource, error) { + key := toAbsolute(baseURL, identityStr) + if isVersioned { + // Versioned absolute URL + resources, found := rr.versionedURLResourcesMap[key] + if !found { + return nil, nil + } + if len(resources) > 1 { + return nil, ErrMultipleResourcesWithSameIDAndVersion + } + return resources[0], nil + } + // Versionless absolute URL + resources, found := rr.versionlessUrlResourcesMap[key] + if !found { + return nil, nil + } + + if len(resources) == 1 { + return resources[0], nil + } + // If more than one versionless matches are found, return the one with the latest meta.LastUpdated field. + // If any of the matched resources has a missing meta.LastUpdated field, an Undefined behavior error is thrown. + return getLatestResource(resources) +} + +func (rr *resolvableResources) resolveRelative(ref string) (fhir.Resource, error) { + if len(rr.rootURLs) == 0 { + return nil, nil + } + for _, rootURL := range rr.rootURLs { + constructedRef := toAbsolute(rootURL, ref) + baseURL, identityStr, isVersioned, isAbsoluteURL := absoluteURLInfo(constructedRef) + if !isAbsoluteURL { + continue + } + resolvedRes, err := rr.resolveAbsolute(baseURL, identityStr, isVersioned) + if resolvedRes != nil && err == nil { + return resolvedRes, nil + } + } + return nil, nil +} + +func (rr *resolvableResources) resolveReference(ref string) (fhir.Resource, error) { + if isURN(ref) { + return rr.resolveURN(ref) + } + baseURL, identityStr, isVersioned, isAbsoluteURL := absoluteURLInfo(ref) + if isAbsoluteURL { + return rr.resolveAbsolute(baseURL, identityStr, isVersioned) + } + if isRelativeURL(ref) { + return rr.resolveRelative(ref) + } + return nil, nil +} + +// Resolve implements the FHIRPath resolve() function in the context of a bundle of type SEARCHSET or COLLECTION. +// It identifies and returns FHIR resources from the resolver's bundle that match the provided list of reference strings. +// The BundleResolver strictly supports references of type URN and URL (both absolute and relative), but not canonical references. +// It returns an empty slice if the bundle is empty, or if no matching resources are found for the given references. +// Invalid references and invalid bundle resource entries are ignored. +// Source for the algorithm implementation - +// https://build.fhir.org/bundle.html#references +func (br *BundleResolver) Resolve(toResolveRefs []string) ([]fhir.Resource, error) { + if br.bundle == nil || len(br.bundle.Entry) == 0 || len(toResolveRefs) == 0 { + return []fhir.Resource{}, nil + } + + rr := &resolvableResources{ + urnResourceMap: map[string]fhir.Resource{}, + versionlessUrlResourcesMap: map[string][]fhir.Resource{}, + versionedURLResourcesMap: map[string][]fhir.Resource{}, + rootURLs: []string{}, + } + rr.fromBundle(br.bundle) + + if rr.isEmpty() { + return []fhir.Resource{}, nil + } + + resolvedResources := []fhir.Resource{} + for _, ref := range toResolveRefs { + resolvedResource, err := rr.resolveReference(ref) + if err != nil { + return nil, err + } + if resolvedResource != nil { + resolvedResources = append(resolvedResources, resolvedResource) + } + } + return resolvedResources, nil +} diff --git a/fhirpath/resolver/bundleresolver_test.go b/fhirpath/resolver/bundleresolver_test.go new file mode 100644 index 0000000..942f9ce --- /dev/null +++ b/fhirpath/resolver/bundleresolver_test.go @@ -0,0 +1,477 @@ +package resolver_test + +import ( + "testing" + + cpb "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" + r4pb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/bundle_and_contained_resource_go_proto" + "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/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/verily-src/fhirpath-go/fhirpath/resolver" + "github.com/verily-src/fhirpath-go/internal/containedresource" + "github.com/verily-src/fhirpath-go/internal/fhir" + "google.golang.org/protobuf/testing/protocmp" +) + +// Helper to create a Bundle for testing +func createBundle(entries []*r4pb.Bundle_Entry) *r4pb.Bundle { + return &r4pb.Bundle{ + Type: &r4pb.Bundle_TypeCode{Value: cpb.BundleTypeCode_SEARCHSET}, + Entry: entries, + } +} + +func TestBundleResolver_Resolve(t *testing.T) { + uuidRef := fhir.UUID("c757873d-ec9a-4326-a141-556f43239520") + invalidUuidRef := fhir.UUID("invalid-uuid") + + oidRef := fhir.OID("1.2.3.4.5") + + patAbsVersionlessUrl := "https://healthcare.googleapis.com/v1/projects/123/locations/abc/datasets/def/fhirStores/ghi/fhir/Patient/123" + obsAbsVersionlessUrl := "https://healthcare.googleapis.com/v1/projects/123/locations/abc/datasets/def/fhirStores/ghi/fhir/Observation/123" + invalidAbsVersionlessUrl := "https://healthcare.googleapis.com/v1/projects/123/locations/abc/datasets/def/fhirStores/ghi/fhir/Whale/123" + + patAbsVersionedUrl := "https://healthcare.googleapis.com/v1/projects/123/locations/abc/datasets/def/fhirStores/ghi/fhir/Patient/123/_history/v1" + obsAbsVersionedUrl := "https://healthcare.googleapis.com/v1/projects/123/locations/abc/datasets/def/fhirStores/ghi/fhir/Observation/123/_history/v1" + invalidVersionedAbsUrl := "https://healthcare.googleapis.com/v1/projects/123/locations/abc/datasets/def/fhirStores/ghi/fhir/Patient/123/_history/%#@^%$#" + + obs123 := &observation_go_proto.Observation{ + Id: &dtpb.Id{Value: "123"}, + Subject: &dtpb.Reference{ + Reference: &dtpb.Reference_PatientId{PatientId: &dtpb.ReferenceId{Value: "123"}}, + }, + } + + patient123 := &ppb.Patient{ + Id: fhir.ID("123"), + Meta: &dtpb.Meta{ + LastUpdated: &dtpb.Instant{ValueUs: 1000}, + }, + } + crPatient123 := containedresource.Wrap(patient123) + + patient123Latest := &ppb.Patient{ + Id: fhir.ID("123"), + Meta: &dtpb.Meta{ + LastUpdated: &dtpb.Instant{ValueUs: 500}, + }, + } + + patientMissingMeta := &ppb.Patient{ + Id: fhir.ID("789"), + } + crPatientMissingMeta := containedresource.Wrap(patientMissingMeta) + + patientMissingLastUpdated := &ppb.Patient{ + Id: fhir.ID("456"), + Meta: &dtpb.Meta{}, + } + crPatientMissingLastUpdated := containedresource.Wrap(patientMissingLastUpdated) + + patient123VersionedV1 := &ppb.Patient{ + Id: fhir.ID("123"), + Meta: &dtpb.Meta{ + VersionId: fhir.ID("v1"), + }, + } + + patient123VersionedV1Copy := &ppb.Patient{ + Id: fhir.ID("123"), + Meta: &dtpb.Meta{ + VersionId: fhir.ID("v1"), + }, + } + + tests := []struct { + name string + bundle *r4pb.Bundle + toResolveRefs []string + wantResources []fhir.Resource + wantErr error + }{ + { + name: "Empty Bundle", + bundle: createBundle([]*r4pb.Bundle_Entry{}), + toResolveRefs: []string{"Patient/123"}, + wantResources: []fhir.Resource{}, + }, + { + name: "Empty toResolve References", + bundle: createBundle([]*r4pb.Bundle_Entry{ + {Resource: crPatient123, + FullUrl: fhir.URIFromOID(oidRef)}, + }), + toResolveRefs: []string{}, + wantResources: []fhir.Resource{}, + }, + { + name: "Resource not found - returns empty", + bundle: createBundle([]*r4pb.Bundle_Entry{ + {Resource: crPatient123, + FullUrl: fhir.URIFromOID(oidRef)}, + }), + toResolveRefs: []string{uuidRef.GetValue()}, + wantResources: []fhir.Resource{}, + }, + { + name: "Resolve URN - UUID Reference", + bundle: createBundle([]*r4pb.Bundle_Entry{ + {Resource: crPatient123, + FullUrl: fhir.URIFromUUID(uuidRef)}, + }), + toResolveRefs: []string{uuidRef.GetValue()}, + wantResources: []fhir.Resource{patient123}, + }, + { + name: "Resolve URN - Invalid UUID Reference; returns empty", + bundle: createBundle([]*r4pb.Bundle_Entry{ + {Resource: crPatient123, + FullUrl: fhir.URIFromUUID(invalidUuidRef)}, + }), + toResolveRefs: []string{invalidUuidRef.GetValue()}, + wantResources: []fhir.Resource{}, + }, + { + name: "Resolve URN - OID Reference", + bundle: createBundle([]*r4pb.Bundle_Entry{ + {Resource: crPatient123, + FullUrl: fhir.URIFromOID(oidRef)}, + }), + toResolveRefs: []string{oidRef.GetValue()}, + wantResources: []fhir.Resource{patient123}, + }, + { + name: "Resolve Absolute URL - Versionless - 1 match", + bundle: createBundle([]*r4pb.Bundle_Entry{ + {Resource: crPatient123, + FullUrl: fhir.URI(patAbsVersionlessUrl)}, + }), + toResolveRefs: []string{patAbsVersionlessUrl}, + wantResources: []fhir.Resource{patient123}, + }, + { + name: "Resolve Absolute URL - Versionless - multiple matches", + bundle: createBundle([]*r4pb.Bundle_Entry{ + {Resource: crPatient123, + FullUrl: fhir.URI(patAbsVersionlessUrl)}, + {Resource: containedresource.Wrap(patient123Latest), + FullUrl: fhir.URI(patAbsVersionlessUrl)}, + }), + toResolveRefs: []string{patAbsVersionlessUrl}, + wantResources: []fhir.Resource{patient123Latest}, + }, + { + name: "Resolve Absolute URL - Versionless - multiple matches without Meta; throws", + bundle: createBundle([]*r4pb.Bundle_Entry{ + {Resource: crPatient123, + FullUrl: fhir.URI(patAbsVersionlessUrl)}, + {Resource: crPatientMissingMeta, + FullUrl: fhir.URI(patAbsVersionlessUrl)}, + }), + toResolveRefs: []string{patAbsVersionlessUrl}, + wantErr: resolver.ErrMissingMetaOrLastUpdated, + }, + { + name: "Resolve Absolute URL - Versionless - multiple matches without Meta.LastUpdated; throws", + bundle: createBundle([]*r4pb.Bundle_Entry{ + {Resource: crPatient123, + FullUrl: fhir.URI(patAbsVersionlessUrl)}, + {Resource: crPatientMissingLastUpdated, + FullUrl: fhir.URI(patAbsVersionlessUrl)}, + }), + toResolveRefs: []string{patAbsVersionlessUrl}, + wantErr: resolver.ErrMissingMetaOrLastUpdated, + }, + { + name: "Resolve Absolute URL - Versionless - single match without Meta; resolves", + bundle: createBundle([]*r4pb.Bundle_Entry{ + {Resource: crPatientMissingMeta, + FullUrl: fhir.URI(patAbsVersionlessUrl)}, + }), + toResolveRefs: []string{patAbsVersionlessUrl}, + wantResources: []fhir.Resource{patientMissingMeta}, + }, + { + name: "Resolve Absolute URL - Versionless - single match without Meta.LastUpdated; resolves", + bundle: createBundle([]*r4pb.Bundle_Entry{ + {Resource: crPatientMissingLastUpdated, + FullUrl: fhir.URI(patAbsVersionlessUrl)}, + }), + toResolveRefs: []string{patAbsVersionlessUrl}, + wantResources: []fhir.Resource{patientMissingLastUpdated}, + }, + { + name: "Resolve Absolute URL - Versionless - no match found", + bundle: createBundle([]*r4pb.Bundle_Entry{ + {Resource: containedresource.Wrap(obs123), + FullUrl: fhir.URI(obsAbsVersionlessUrl)}, + }), + toResolveRefs: []string{patAbsVersionlessUrl}, + wantResources: []fhir.Resource{}, + }, + { + name: "Resolve Absolute URL - Invalid Versionless URL; returns empty", + bundle: createBundle([]*r4pb.Bundle_Entry{ + {Resource: crPatient123, + FullUrl: fhir.URI(invalidAbsVersionlessUrl)}, + }), + toResolveRefs: []string{invalidAbsVersionlessUrl}, + wantResources: []fhir.Resource{}, + }, + { + name: "Resolve Absolute URL - Versioned - 1 match", + bundle: createBundle([]*r4pb.Bundle_Entry{ + {Resource: containedresource.Wrap(patient123VersionedV1), + FullUrl: fhir.URI(patAbsVersionedUrl)}, + }), + toResolveRefs: []string{patAbsVersionedUrl}, + wantResources: []fhir.Resource{patient123VersionedV1}, + }, + { + name: "Resolve Absolute URL - Versioned - multiple matches with same ID and version - throws", + bundle: createBundle([]*r4pb.Bundle_Entry{ + {Resource: containedresource.Wrap(patient123VersionedV1), + FullUrl: fhir.URI(patAbsVersionedUrl)}, + {Resource: containedresource.Wrap(patient123VersionedV1Copy), + FullUrl: fhir.URI(patAbsVersionedUrl)}, + }), + toResolveRefs: []string{patAbsVersionedUrl}, + wantErr: resolver.ErrMultipleResourcesWithSameIDAndVersion, + }, + { + name: "Resolve Absolute URL - Versioned - no match found", + bundle: createBundle([]*r4pb.Bundle_Entry{ + {Resource: containedresource.Wrap(obs123), + FullUrl: fhir.URI(obsAbsVersionedUrl)}, + }), + toResolveRefs: []string{patAbsVersionedUrl}, + wantResources: []fhir.Resource{}, + }, + { + name: "Resolve Absolute URL - Versioned - invalid versioned URL; returns empty", + bundle: createBundle([]*r4pb.Bundle_Entry{ + {Resource: containedresource.Wrap(patient123VersionedV1), + FullUrl: fhir.URI(invalidVersionedAbsUrl)}, + }), + toResolveRefs: []string{invalidVersionedAbsUrl}, + wantResources: []fhir.Resource{}, + }, + { + name: "Resolve Relative URL Reference - Versionless - match found", + bundle: createBundle([]*r4pb.Bundle_Entry{ + {Resource: crPatient123, + FullUrl: fhir.URI(patAbsVersionlessUrl)}, + }), + toResolveRefs: []string{"Patient/123"}, + wantResources: []fhir.Resource{patient123}, + }, + { + name: "Resolve Relative URL Reference - Versionless - no match found", + bundle: createBundle([]*r4pb.Bundle_Entry{ + {Resource: crPatient123, + FullUrl: fhir.URI(patAbsVersionlessUrl)}, + }), + toResolveRefs: []string{"Observation/123"}, + wantResources: []fhir.Resource{}, + }, + { + name: "Resolve Relative URL Reference - Invalid Versionless URL - returns empty", + bundle: createBundle([]*r4pb.Bundle_Entry{ + {Resource: crPatient123, + FullUrl: fhir.URI(invalidAbsVersionlessUrl)}, + }), + toResolveRefs: []string{"Whale/123"}, + wantResources: []fhir.Resource{}, + }, + { + name: "Resolve Relative URL Reference - invalid relative reference; returns empty", + bundle: createBundle([]*r4pb.Bundle_Entry{ + {Resource: crPatient123, + FullUrl: fhir.URI(patAbsVersionlessUrl)}, + }), + toResolveRefs: []string{"Shark/Patient/Whale/123"}, + wantResources: []fhir.Resource{}, + }, + { + name: "Resolve Relative URL Reference - empty rootURLs in resolveeResources; returns empty", + bundle: createBundle([]*r4pb.Bundle_Entry{ + {Resource: crPatient123, + FullUrl: fhir.URIFromOID(oidRef)}, + }), + toResolveRefs: []string{"Patient/123"}, + wantResources: []fhir.Resource{}, + }, + { + name: "Resolve Relative URL Reference - Versioned - match found", + bundle: createBundle([]*r4pb.Bundle_Entry{ + {Resource: containedresource.Wrap(patient123VersionedV1), + FullUrl: fhir.URI(patAbsVersionedUrl)}, + }), + toResolveRefs: []string{"Patient/123/_history/v1"}, + wantResources: []fhir.Resource{patient123VersionedV1}, + }, + { + name: "Resolve Relative URL Reference - Versioned - no match found", + bundle: createBundle([]*r4pb.Bundle_Entry{ + {Resource: containedresource.Wrap(patient123VersionedV1), + FullUrl: fhir.URI(patAbsVersionedUrl)}, + }), + toResolveRefs: []string{"Observation/123/_history/v1"}, + wantResources: []fhir.Resource{}, + }, + { + name: "Resolve Relative URL Reference - Invalid Versioned; returns empty", + bundle: createBundle([]*r4pb.Bundle_Entry{ + {Resource: containedresource.Wrap(patient123VersionedV1), + FullUrl: fhir.URI(invalidVersionedAbsUrl)}, + }), + toResolveRefs: []string{"Patient/123/_history/%#@^%$#"}, + wantResources: []fhir.Resource{}, + }, + { + name: "Repeated bundle entries, resolves to a single resource for URNs", + bundle: createBundle([]*r4pb.Bundle_Entry{ + {Resource: crPatient123, + FullUrl: fhir.URIFromOID(oidRef)}, + {Resource: crPatient123, + FullUrl: fhir.URIFromOID(oidRef)}, + }), + toResolveRefs: []string{oidRef.GetValue()}, + wantResources: []fhir.Resource{patient123}, + }, + { + name: "Repeated toResolve reference inputs, resolves to one resource match for each reference, if found", + bundle: createBundle([]*r4pb.Bundle_Entry{ + {Resource: crPatient123, + FullUrl: fhir.URIFromOID(oidRef)}, + }), + toResolveRefs: []string{oidRef.GetValue(), oidRef.GetValue()}, + wantResources: []fhir.Resource{patient123, patient123}, + }, + { + name: "Bundle entries with same ID for different resource type returns the right match, if found", + bundle: createBundle([]*r4pb.Bundle_Entry{ + {Resource: crPatient123, + FullUrl: fhir.URI(patAbsVersionlessUrl)}, + {Resource: containedresource.Wrap(obs123), + FullUrl: fhir.URI(obsAbsVersionlessUrl)}, + }), + toResolveRefs: []string{patAbsVersionlessUrl}, + wantResources: []fhir.Resource{patient123}, + }, + { + name: "Nil entry in bundle; returns empty", + bundle: createBundle([]*r4pb.Bundle_Entry{ + {Resource: nil, + FullUrl: fhir.URIFromOID(oidRef)}, + }), + toResolveRefs: []string{oidRef.GetValue()}, + wantResources: []fhir.Resource{}, + }, + { + name: "Nil resource in bundle entry; returns empty", + bundle: createBundle([]*r4pb.Bundle_Entry{ + {Resource: containedresource.Wrap(nil), + FullUrl: fhir.URIFromOID(oidRef)}, + }), + toResolveRefs: []string{oidRef.GetValue()}, + wantResources: []fhir.Resource{}, + }, + { + name: "Empty bundle entries; returns empty", + bundle: createBundle([]*r4pb.Bundle_Entry{}), + toResolveRefs: []string{oidRef.GetValue()}, + wantResources: []fhir.Resource{}, + }, + { + name: "No bundle entries; returns empty", + bundle: &r4pb.Bundle{ + Type: &r4pb.Bundle_TypeCode{Value: cpb.BundleTypeCode_SEARCHSET}, + }, + toResolveRefs: []string{oidRef.GetValue()}, + wantResources: []fhir.Resource{}, + }, + { + name: "Nil FullUrl in bundle entry; returns empty", + bundle: createBundle([]*r4pb.Bundle_Entry{ + {Resource: crPatient123, + FullUrl: nil}, + }), + toResolveRefs: []string{patAbsVersionlessUrl}, + wantResources: []fhir.Resource{}, + }, + { + name: "Empty FullUrl in bundle entry; returns empty", + bundle: createBundle([]*r4pb.Bundle_Entry{ + {Resource: crPatient123, + FullUrl: fhir.URI("")}, + }), + toResolveRefs: []string{patAbsVersionlessUrl}, + wantResources: []fhir.Resource{}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + br, err := resolver.NewBundleResolver(tc.bundle) + if err != nil { + t.Fatalf("NewBundleResolver() error = %v, expected none", err) + } + gotResources, gotErr := br.Resolve(tc.toResolveRefs) + + if !cmp.Equal(gotErr, tc.wantErr, cmpopts.EquateErrors()) { + t.Errorf("Resolve() error = %v, wantErr %v", err, tc.wantErr) + } + + if diff := cmp.Diff(tc.wantResources, gotResources, protocmp.Transform()); diff != "" { + t.Errorf("Resolve(%s): mismatch (-want, +got):\n%s", tc.name, diff) + } + }) + } +} + +func TestNewBundleResolver(t *testing.T) { + tests := []struct { + name string + bundle *r4pb.Bundle + wantErr error + }{ + { + name: "Supported Bundle Type - COLLECTION", + bundle: &r4pb.Bundle{ + Type: &r4pb.Bundle_TypeCode{Value: cpb.BundleTypeCode_COLLECTION}, + }, + }, + { + name: "Supported Bundle Type - SEARCHSET", + bundle: &r4pb.Bundle{ + Type: &r4pb.Bundle_TypeCode{Value: cpb.BundleTypeCode_SEARCHSET}, + }, + }, + { + name: "Unsupported Bundle Type - HISTORY", + bundle: &r4pb.Bundle{ + Type: &r4pb.Bundle_TypeCode{Value: cpb.BundleTypeCode_HISTORY}, + }, + wantErr: resolver.ErrUnsupportedBundleType, + }, + { + name: "Nil Bundle - throws", + wantErr: resolver.ErrNilBundleInit, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + br, gotErr := resolver.NewBundleResolver(tc.bundle) + if gotErr != tc.wantErr { + t.Errorf("NewBundleResolver() error = %v, wantErr %v", gotErr, tc.wantErr) + } + if br == nil && tc.wantErr == nil { + t.Errorf("NewBundleResolver() got nil resolver, want non-nil") + } + }) + } +} diff --git a/fhirpath/resolver/resolver.go b/fhirpath/resolver/resolver.go new file mode 100644 index 0000000..b54b910 --- /dev/null +++ b/fhirpath/resolver/resolver.go @@ -0,0 +1,12 @@ +// Package resolver defines the Resolver interface that implements Resolve FHIRPath functionality. +package resolver + +import ( + "github.com/verily-src/fhirpath-go/internal/fhir" +) + +// Resolver interface defines the Resolve() method for resolving a collection of string-based references +// (URIs, canonical URLs, or plain URLs) into a collection of FHIR resources. +type Resolver interface { + Resolve(input []string) ([]fhir.Resource, error) +} diff --git a/fhirpath/resolver/resolvertest/resolvertest.go b/fhirpath/resolver/resolvertest/resolvertest.go new file mode 100644 index 0000000..b42c075 --- /dev/null +++ b/fhirpath/resolver/resolvertest/resolvertest.go @@ -0,0 +1,27 @@ +// Package resolvertest provides test utilities for the resolver package. +package resolvertest + +import ( + "github.com/verily-src/fhirpath-go/fhirpath/resolver" + + "github.com/verily-src/fhirpath-go/fhirpath" + "github.com/verily-src/fhirpath-go/internal/fhir" +) + +type resolverFunc func(input []string) ([]fhir.Resource, error) + +func (rf resolverFunc) Resolve(input []string) ([]fhir.Resource, error) { + return rf(input) +} + +func HappyResolver(resources ...fhirpath.Resource) resolver.Resolver { + return resolverFunc(func(input []string) ([]fhir.Resource, error) { + return resources, nil + }) +} + +func ErroringResolver(err error) resolver.Resolver { + return resolverFunc(func(input []string) ([]fhir.Resource, error) { + return nil, err + }) +} diff --git a/fhirpath/system/date_test.go b/fhirpath/system/date_test.go index 2e40db5..4d80364 100644 --- a/fhirpath/system/date_test.go +++ b/fhirpath/system/date_test.go @@ -2,13 +2,12 @@ package system_test import ( "errors" - "testing" - dtpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/datatypes_go_proto" "github.com/google/go-cmp/cmp" "github.com/verily-src/fhirpath-go/fhirpath/system" "github.com/verily-src/fhirpath-go/internal/fhir" "google.golang.org/protobuf/testing/protocmp" + "testing" ) func TestParseDate_ReturnsDate(t *testing.T) { diff --git a/fhirpath/system/primitives.go b/fhirpath/system/primitives.go index 24a8297..94b51d5 100644 --- a/fhirpath/system/primitives.go +++ b/fhirpath/system/primitives.go @@ -63,7 +63,7 @@ func ParseString(input string) (String, error) { "\\f", "\f", "\\\\", "\\", "\\", "", - // TODO PHP-5581 + // TODO: Add support for unicode escape sequences. } input = strings.TrimPrefix(input, "'") input = strings.TrimSuffix(input, "'") diff --git a/fhirpath/system/types.go b/fhirpath/system/types.go index 8ab5c37..f615999 100644 --- a/fhirpath/system/types.go +++ b/fhirpath/system/types.go @@ -12,7 +12,9 @@ import ( "github.com/verily-src/fhirpath-go/internal/protofields" ) -var ErrCantBeCast = errors.New("value can't be cast to system type") +var ( + ErrCantBeCast = errors.New("value can't be cast to system type") +) // Any is the root abstraction for all FHIRPath system types. type Any interface { diff --git a/go.mod b/go.mod index be497cc..780c113 100644 --- a/go.mod +++ b/go.mod @@ -12,8 +12,8 @@ require ( github.com/iancoleman/strcase v0.3.0 github.com/mattbaird/jsonpatch v0.0.0-20230413205102-771768614e91 github.com/shopspring/decimal v1.3.1 - golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 - google.golang.org/protobuf v1.36.3 + golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 + google.golang.org/protobuf v1.36.6 ) require ( @@ -25,6 +25,6 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/serenize/snaker v0.0.0-20201027110005-a7ad2135616e // indirect github.com/stretchr/testify v1.10.0 // indirect - golang.org/x/net v0.37.0 // indirect - golang.org/x/sys v0.31.0 // indirect + golang.org/x/net v0.39.0 // indirect + golang.org/x/sys v0.32.0 // indirect ) diff --git a/go.sum b/go.sum index 0ca2e1d..3ac9ba2 100644 --- a/go.sum +++ b/go.sum @@ -492,8 +492,8 @@ golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190312203227-4b39c73a6495/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= -golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= -golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA= +golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= @@ -529,8 +529,8 @@ golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191004110552-13f9640d40b9/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= -golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -572,14 +572,14 @@ golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -647,8 +647,8 @@ google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2 google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU= -google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/DataDog/dd-trace-go.v1 v1.17.0/go.mod h1:DVp8HmDh8PuTu2Z0fVVlBsyWaC++fzwVCaGWylTe3tg= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d/go.mod h1:cuepJuh7vyXfUyUwEgHQXw849cJrilpS5NeIjOWESAw= diff --git a/internal/element/extension/extension.go b/internal/element/extension/extension.go index 62863a5..2a74c9d 100644 --- a/internal/element/extension/extension.go +++ b/internal/element/extension/extension.go @@ -230,3 +230,17 @@ func updateExtensionsIn(ext fhir.Extendable, get protofields.FieldToValueFunc, e } message.Set(field, protoreflect.ValueOfList(extensionsList)) } + +// FindByURL returns all extensions whose URL matches the specified system. +// If no matching extensions are found, this returns an empty slice. +func FindByURL(extensions []*dtpb.Extension, url string) []*dtpb.Extension { + var matches []*dtpb.Extension + + for _, ext := range extensions { + if ext.GetUrl().GetValue() == url { + matches = append(matches, ext) + } + } + + return matches +} diff --git a/internal/element/extension/extension_test.go b/internal/element/extension/extension_test.go index 6c0bb4b..e3dfa53 100644 --- a/internal/element/extension/extension_test.go +++ b/internal/element/extension/extension_test.go @@ -444,3 +444,71 @@ func TestClear_ResourceHasExtensions_RemovesExtensions(t *testing.T) { }) } } + +func TestFindByURL(t *testing.T) { + const ( + urlA = "http://example.com/extA" + urlB = "http://example.com/extB" + ) + extA := extension.New(urlA, fhir.String("valueA")) + extB := extension.New(urlB, fhir.String("valueB")) + + testCases := []struct { + name string + extensions []*dtpb.Extension + system string + want []*dtpb.Extension + }{ + { + name: "nil slice", + extensions: nil, + system: urlA, + want: [](*dtpb.Extension)(nil), + }, + { + name: "empty slice", + extensions: []*dtpb.Extension{}, + system: urlA, + want: [](*dtpb.Extension)(nil), + }, + { + name: "no match", + extensions: []*dtpb.Extension{extA}, + system: urlB, + want: [](*dtpb.Extension)(nil), + }, + { + name: "single match", + extensions: []*dtpb.Extension{extA}, + system: urlA, + want: []*dtpb.Extension{extA}, + }, + { + name: "multiple extensions, match is first", + extensions: []*dtpb.Extension{extA, extB}, + system: urlA, + want: []*dtpb.Extension{extA}, + }, + { + name: "multiple extensions, contains match", + extensions: []*dtpb.Extension{extB, extA}, + system: urlA, + want: []*dtpb.Extension{extA}, + }, + { + name: "multiple matches", + extensions: []*dtpb.Extension{extA, extB, extA}, + system: urlA, + want: []*dtpb.Extension{extA, extA}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got := extension.FindByURL(tc.extensions, tc.system) + if diff := cmp.Diff(tc.want, got, protocmp.Transform()); diff != "" { + t.Errorf("FindByURL(%s): diff (-want,+got):\n%s", tc.name, diff) + } + }) + } +} diff --git a/internal/element/extract.go b/internal/element/extract.go index 7540f7c..4928da4 100644 --- a/internal/element/extract.go +++ b/internal/element/extract.go @@ -181,7 +181,7 @@ func computeFHIRPathOfProtoPath(p protopath.Path) (string, error) { cfn := string(fd.ContainingMessage().FullName()) if cfn == "google.fhir.r4.core.ContainedResource" { // Only elements of Resource have well defined FHIRpath within - // Bundle.entry.resource. See PHP-8862. Punt on this for now. + // Bundle.entry.resource. See ticket. Punt on this for now. return "", fmt.Errorf("%w: for %s", ErrFhirPathNotImplemented, cfn) } elementName := fd.JSONName() diff --git a/internal/element/reference/identity.go b/internal/element/reference/identity.go index ef4043a..c442cf8 100644 --- a/internal/element/reference/identity.go +++ b/internal/element/reference/identity.go @@ -13,7 +13,7 @@ import ( ) // Source: https://hl7.org/fhir/r4/references.html#literal -// WATCHOUT: See PHP-9300 about anchored matches. +// WATCHOUT: See ticket about anchored matches. var restFHIRServiceBaseURLRegex = regexp.MustCompile(`^(http|https):\/\/([A-Za-z0-9\-\\\.\:\%\$]*\/)+$`) var restFHIRServiceResourceURLRegex = regexp.MustCompile(`^((http|https):\/\/([A-Za-z0-9\-\\\.\:\%\$\_]*\/)+)?(Account|ActivityDefinition|AdverseEvent|AllergyIntolerance|Appointment|AppointmentResponse|AuditEvent|Basic|Binary|BiologicallyDerivedProduct|BodyStructure|Bundle|CapabilityStatement|CarePlan|CareTeam|CatalogEntry|ChargeItem|ChargeItemDefinition|Claim|ClaimResponse|ClinicalImpression|CodeSystem|Communication|CommunicationRequest|CompartmentDefinition|Composition|ConceptMap|Condition|Consent|Contract|Coverage|CoverageEligibilityRequest|CoverageEligibilityResponse|DetectedIssue|Device|DeviceDefinition|DeviceMetric|DeviceRequest|DeviceUseStatement|DiagnosticReport|DocumentManifest|DocumentReference|EffectEvidenceSynthesis|Encounter|Endpoint|EnrollmentRequest|EnrollmentResponse|EpisodeOfCare|EventDefinition|Evidence|EvidenceVariable|ExampleScenario|ExplanationOfBenefit|FamilyMemberHistory|Flag|Goal|GraphDefinition|Group|GuidanceResponse|HealthcareService|ImagingStudy|Immunization|ImmunizationEvaluation|ImmunizationRecommendation|ImplementationGuide|InsurancePlan|Invoice|Library|Linkage|List|Location|Measure|MeasureReport|Media|Medication|MedicationAdministration|MedicationDispense|MedicationKnowledge|MedicationRequest|MedicationStatement|MedicinalProduct|MedicinalProductAuthorization|MedicinalProductContraindication|MedicinalProductIndication|MedicinalProductIngredient|MedicinalProductInteraction|MedicinalProductManufactured|MedicinalProductPackaged|MedicinalProductPharmaceutical|MedicinalProductUndesirableEffect|MessageDefinition|MessageHeader|MolecularSequence|NamingSystem|NutritionOrder|Observation|ObservationDefinition|OperationDefinition|OperationOutcome|Organization|OrganizationAffiliation|Patient|PaymentNotice|PaymentReconciliation|Person|PlanDefinition|Practitioner|PractitionerRole|Procedure|Provenance|Questionnaire|QuestionnaireResponse|RelatedPerson|RequestGroup|ResearchDefinition|ResearchElementDefinition|ResearchStudy|ResearchSubject|RiskAssessment|RiskEvidenceSynthesis|Schedule|SearchParameter|ServiceRequest|Slot|Specimen|SpecimenDefinition|StructureDefinition|StructureMap|Subscription|Substance|SubstanceNucleicAcid|SubstancePolymer|SubstanceProtein|SubstanceReferenceInformation|SubstanceSourceMaterial|SubstanceSpecification|SupplyDelivery|SupplyRequest|Task|TerminologyCapabilities|TestReport|TestScript|ValueSet|VerificationResult|VisionPrescription)\/[A-Za-z0-9\-\.]{1,64}(\/_history\/[A-Za-z0-9\-\.]{1,64})?$`) diff --git a/internal/fhir/elements_general.go b/internal/fhir/elements_general.go index b7e9290..9f70a46 100644 --- a/internal/fhir/elements_general.go +++ b/internal/fhir/elements_general.go @@ -198,7 +198,7 @@ func Quantity(value float64, unit string) *dtpb.Quantity { // value and UCUM unit. // // See: http://hl7.org/fhir/R4/datatypes.html#quantity -// TODO(PHP-9521): Add a unit package to validate against UCUM units. +// TODO: Add a unit package to validate against UCUM units. func UCUMQuantity(value float64, unit string) *dtpb.Quantity { return &dtpb.Quantity{ Value: Decimal(value), diff --git a/internal/fhir/elements_metadata.go b/internal/fhir/elements_metadata.go deleted file mode 100644 index bb02072..0000000 --- a/internal/fhir/elements_metadata.go +++ /dev/null @@ -1,19 +0,0 @@ -package fhir - -import dtpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/datatypes_go_proto" - -// Metadata Types: -// -// The section below defines types from the "MetaDataTypes" heading in -// http://hl7.org/fhir/R4/datatypes.html#open - -// ContactDetail creates an R4 FHIR ContactDetail element from a string value -// and the specified contact-points. -// -// See: http://hl7.org/fhir/R4/metadatatypes.html#ContactDetail -func ContactDetail(name string, telecom ...*dtpb.ContactPoint) *dtpb.ContactDetail { - return &dtpb.ContactDetail{ - Name: String(name), - Telecom: telecom, - } -} diff --git a/internal/fhir/elements_special.go b/internal/fhir/elements_special.go deleted file mode 100644 index 629148f..0000000 --- a/internal/fhir/elements_special.go +++ /dev/null @@ -1,26 +0,0 @@ -package fhir - -import dtpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/datatypes_go_proto" - -// Special Types: -// -// The section below defines types from the "Special Types" heading in -// http://hl7.org/fhir/R4/datatypes.html#open - -// Narrative creates a R4 FHIR Narrative element from a string value. -// -// See: http://hl7.org/fhir/R4/narrative.html -func Narrative(value string) *dtpb.Narrative { - return &dtpb.Narrative{ - Div: XHTML(value), - } -} - -// XHTML creates an R4 FHIR XHTML element from a string value. -// -// See: http://hl7.org/fhir/R4/narrative.html#xhtml -func XHTML(value string) *dtpb.Xhtml { - return &dtpb.Xhtml{ - Value: value, - } -} diff --git a/internal/fhir/elements_special_test.go b/internal/fhir/elements_special_test.go deleted file mode 100644 index 5c30b6e..0000000 --- a/internal/fhir/elements_special_test.go +++ /dev/null @@ -1,28 +0,0 @@ -package fhir_test - -import ( - "testing" - - "github.com/google/go-cmp/cmp" - "github.com/verily-src/fhirpath-go/internal/fhir" -) - -func TestNarrative(t *testing.T) { - want := "" - - sut := fhir.Narrative(want) - - if got := sut.GetDiv().GetValue(); !cmp.Equal(got, want) { - t.Errorf("Narrative: got %v, want %v", got, want) - } -} - -func TestXHTML(t *testing.T) { - want := "" - - sut := fhir.XHTML(want) - - if got := sut.GetValue(); !cmp.Equal(got, want) { - t.Errorf("XHTML: got %v, want %v", got, want) - } -} diff --git a/internal/fhir/protofields.go b/internal/fhir/protofields.go deleted file mode 100644 index c594c51..0000000 --- a/internal/fhir/protofields.go +++ /dev/null @@ -1,13 +0,0 @@ -package fhir - -import ( - "github.com/verily-src/fhirpath-go/internal/protofields" -) - -// UnwrapValueX obtains the underlying Message for oneof ValueX -// elements, which use a nested Choice field. Returns nil if the input message -// doesn't have a Choice field, or if the Oneof descriptor is unpopulated. -// See wrapped implementation for more information. -func UnwrapValueX(element Base) Base { - return protofields.UnwrapOneofField(element, "choice") -} diff --git a/internal/fhirconv/integer.go b/internal/fhirconv/integer.go deleted file mode 100644 index 5d09906..0000000 --- a/internal/fhirconv/integer.go +++ /dev/null @@ -1,112 +0,0 @@ -package fhirconv - -import ( - "errors" - "fmt" - - dtpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/datatypes_go_proto" - "github.com/verily-src/fhirpath-go/internal/narrow" - "golang.org/x/exp/constraints" -) - -var ( - // ErrIntegerTruncated is an error raised when an integer truncation occurs - // during integer conversion - ErrIntegerTruncated = errors.New("integer truncation") -) - -// integerType is a constraint for FHIR integer types. -type integerType interface { - *dtpb.Integer | *dtpb.UnsignedInt | *dtpb.PositiveInt -} - -// ToInt8 converts a FHIR Integer type into a Go native int8. -func ToInt8[From integerType](v From) (int8, error) { - return ToInteger[int8](v) -} - -// ToInt16 converts a FHIR Integer type into a Go native int16. -func ToInt16[From integerType](v From) (int16, error) { - return ToInteger[int16](v) -} - -// ToInt32 converts a FHIR Integer type into a Go native int32. -func ToInt32[From integerType](v From) (int32, error) { - return ToInteger[int32](v) -} - -// ToInt64 converts a FHIR Integer type into a Go native int64. -func ToInt64[From integerType](v From) (int64, error) { - return ToInteger[int64](v) -} - -// ToInt converts a FHIR Integer type into a Go native int. -func ToInt[From integerType](v From) (int, error) { - return ToInteger[int](v) -} - -// ToUint8 converts a FHIR Integer type into a Go native uint8. -func ToUint8[From integerType](v From) (uint8, error) { - return ToInteger[uint8](v) -} - -// ToUint16 converts a FHIR Integer type into a Go native uint16. -func ToUint16[From integerType](v From) (uint16, error) { - return ToInteger[uint16](v) -} - -// ToUint32 converts a FHIR Integer type into a Go native uint32. -func ToUint32[From integerType](v From) (uint32, error) { - return ToInteger[uint32](v) -} - -// ToUint64 converts a FHIR Integer type into a Go native uint64. -func ToUint64[From integerType](v From) (uint64, error) { - return ToInteger[uint64](v) -} - -// ToUint converts a FHIR Integer type into a Go native uint. -func ToUint[From integerType](v From) (uint, error) { - return ToInteger[uint](v) -} - -// ToInteger converts a FHIR Integer type into a Go native integer type. -// -// If the value of the integer does not fit into the receiver integer type, -// this function will return an ErrIntegerTruncated. -func ToInteger[To constraints.Integer, From integerType](v From) (To, error) { - var result To - if val, ok := any(v).(interface{ GetValue() uint32 }); ok { - if result, ok := narrow.ToInteger[To](uint64(val.GetValue())); ok { - return result, nil - } - return 0, truncationError[To](val.GetValue()) - } else if val, ok := any(v).(interface{ GetValue() int32 }); ok { - - if result, ok := narrow.ToInteger[To](int64(val.GetValue())); ok { - return result, nil - } - return 0, truncationError[To](val.GetValue()) - } - // This cannot be reached because this function is constrained to only - // take FHIR Elements that fit one of the above two types. - return result, ErrIntegerTruncated -} - -// MustConvertToInteger converts a FHIR Integer type into a Go native integer type. -// -// If the value stored in the integer type cannot fit into the receiver type, -// this function will panic. -func MustConvertToInteger[To constraints.Integer, From integerType](v From) To { - result, err := ToInteger[To](v) - if err != nil { - panic(err) - } - return result -} - -// truncationError forms an Error type for truncation errors. -func truncationError[To constraints.Integer, From constraints.Integer](value From) error { - var result To - return fmt.Errorf("%w: type %T with value %v does not fit into receiver %T", ErrIntegerTruncated, value, value, result) -} diff --git a/internal/fhirconv/integer_test.go b/internal/fhirconv/integer_test.go deleted file mode 100644 index 95bcc07..0000000 --- a/internal/fhirconv/integer_test.go +++ /dev/null @@ -1,182 +0,0 @@ -package fhirconv_test - -import ( - "errors" - "math" - "testing" - - dtpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/datatypes_go_proto" - "github.com/verily-src/fhirpath-go/internal/fhir" - "github.com/verily-src/fhirpath-go/internal/fhirconv" - "golang.org/x/exp/constraints" -) - -type integerType interface { - *dtpb.Integer | *dtpb.UnsignedInt | *dtpb.PositiveInt -} - -func wantTruncation[To constraints.Integer, From integerType](value From) func(*testing.T) { - return func(t *testing.T) { - t.Helper() - var to To - - _, err := fhirconv.ToInteger[To](value) - - if got, want := err, fhirconv.ErrIntegerTruncated; !errors.Is(got, want) { - t.Errorf("ToInteger[%T]: got err %v, want %v", to, got, want) - } - } -} - -func wantConversion[To constraints.Integer, From integerType, Input constraints.Integer](conv func(Input) From, input Input) func(*testing.T) { - return func(t *testing.T) { - t.Helper() - var to To - value := conv(input) - - got, err := fhirconv.ToInteger[To](value) - if err != nil { - t.Fatalf("ToInteger[%T]: unexpected error '%v'", to, err) - } - - if got, want := got, To(input); got != want { - t.Errorf("ToInteger[%T]: got err %v, want %v", to, got, want) - } - } -} - -func TestToInteger_ValueExceedsReceiver_ReturnsError(t *testing.T) { - // Note: nested sub-tests are being done both for organization and test naming. - // We also can't use table-driven tests here, since the input is a type -- so - // this forces duplication. - t.Run("FromPositiveInt", func(t *testing.T) { - t.Run("ToInt8", wantTruncation[int8](fhir.PositiveInt(math.MaxUint32))) - t.Run("ToInt16", wantTruncation[int16](fhir.PositiveInt(math.MaxUint32))) - t.Run("ToInt32", wantTruncation[int32](fhir.PositiveInt(math.MaxUint32))) - t.Run("ToUint8", wantTruncation[uint8](fhir.PositiveInt(math.MaxUint32))) - t.Run("ToUint16", wantTruncation[uint16](fhir.PositiveInt(math.MaxUint32))) - }) - - t.Run("FromUnsignedInt", func(t *testing.T) { - t.Run("ToInt8", wantTruncation[int8](fhir.UnsignedInt(math.MaxUint32))) - t.Run("ToInt16", wantTruncation[int16](fhir.UnsignedInt(math.MaxUint32))) - t.Run("ToInt32", wantTruncation[int32](fhir.UnsignedInt(math.MaxUint32))) - t.Run("ToUint8", wantTruncation[uint8](fhir.UnsignedInt(math.MaxUint32))) - t.Run("ToUint16", wantTruncation[uint16](fhir.UnsignedInt(math.MaxUint32))) - }) - - t.Run("FromInteger", func(t *testing.T) { - t.Run("ToInt8", wantTruncation[int8](fhir.Integer(math.MaxInt32))) - t.Run("ToInt16", wantTruncation[int16](fhir.Integer(math.MaxInt32))) - // int32, int64, and int can't truncate - t.Run("ToUint8", wantTruncation[uint8](fhir.Integer(-1))) - t.Run("ToUint16", wantTruncation[uint16](fhir.Integer(-1))) - t.Run("ToUint32", wantTruncation[uint32](fhir.Integer(-1))) - t.Run("ToUint32", wantTruncation[uint64](fhir.Integer(-1))) - t.Run("ToUintptr", wantTruncation[uintptr](fhir.Integer(-1))) - t.Run("ToUint", wantTruncation[uint](fhir.Integer(-1))) - }) -} - -func TestToInteger_ValueWithinRange_ReturnsValue(t *testing.T) { - // Note: nested sub-tests are being done both for organization and test naming. - // We also can't use table-driven tests here, since the input is a type -- so - // this forces duplication. - t.Run("FromPositiveInt", func(t *testing.T) { - t.Run("ToInt8", wantConversion[int8](fhir.PositiveInt, 0)) - t.Run("ToInt8Max", wantConversion[int8](fhir.PositiveInt, math.MaxInt8)) - t.Run("ToInt16", wantConversion[int16](fhir.PositiveInt, 0)) - t.Run("ToInt16Max", wantConversion[int16](fhir.PositiveInt, math.MaxInt16)) - t.Run("ToInt32", wantConversion[int32](fhir.PositiveInt, 0)) - t.Run("ToInt32Max", wantConversion[int32](fhir.PositiveInt, math.MaxInt32)) - t.Run("ToInt64", wantConversion[int64](fhir.PositiveInt, 0)) - t.Run("ToInt64MaxUint32", wantConversion[int64](fhir.PositiveInt, math.MaxUint32)) - t.Run("ToInt", wantConversion[int](fhir.PositiveInt, 0)) - t.Run("ToIntMaxInt32", wantConversion[int](fhir.PositiveInt, math.MaxInt32)) - t.Run("ToUint8", wantConversion[uint8](fhir.PositiveInt, 0)) - t.Run("ToUint8Max", wantConversion[uint8](fhir.PositiveInt, math.MaxUint8)) - t.Run("ToUint16", wantConversion[uint16](fhir.PositiveInt, 0)) - t.Run("ToUint16Max", wantConversion[uint16](fhir.PositiveInt, math.MaxUint8)) - t.Run("ToUint32", wantConversion[uint32](fhir.PositiveInt, 0)) - t.Run("ToUint32Max", wantConversion[uint32](fhir.PositiveInt, math.MaxUint32)) - t.Run("ToUint64", wantConversion[uint64](fhir.PositiveInt, 0)) - t.Run("ToUint64MaxUint32", wantConversion[uint64](fhir.PositiveInt, math.MaxUint32)) - t.Run("ToUint", wantConversion[uint](fhir.PositiveInt, 0)) - t.Run("ToUintMaxUint32", wantConversion[uint](fhir.PositiveInt, math.MaxUint32)) - t.Run("ToUintptr", wantConversion[uintptr](fhir.PositiveInt, 0)) - }) - - t.Run("FromUnsignedInt", func(t *testing.T) { - t.Run("ToInt8", wantConversion[int8](fhir.UnsignedInt, 0)) - t.Run("ToInt8Max", wantConversion[int8](fhir.UnsignedInt, math.MaxInt8)) - t.Run("ToInt16", wantConversion[int16](fhir.UnsignedInt, 0)) - t.Run("ToInt16Max", wantConversion[int16](fhir.UnsignedInt, math.MaxInt16)) - t.Run("ToInt32", wantConversion[int32](fhir.UnsignedInt, 0)) - t.Run("ToInt32Max", wantConversion[int32](fhir.UnsignedInt, math.MaxInt32)) - t.Run("ToInt64", wantConversion[int64](fhir.UnsignedInt, 0)) - t.Run("ToInt64MaxUint32", wantConversion[int64](fhir.UnsignedInt, math.MaxUint32)) - t.Run("ToInt", wantConversion[int](fhir.UnsignedInt, 0)) - t.Run("ToIntMaxInt32", wantConversion[int](fhir.UnsignedInt, math.MaxInt32)) - t.Run("ToUint8", wantConversion[uint8](fhir.UnsignedInt, 0)) - t.Run("ToUint8Max", wantConversion[uint8](fhir.UnsignedInt, math.MaxUint8)) - t.Run("ToUint16", wantConversion[uint16](fhir.UnsignedInt, 0)) - t.Run("ToUint16Max", wantConversion[uint16](fhir.UnsignedInt, math.MaxUint8)) - t.Run("ToUint32", wantConversion[uint32](fhir.UnsignedInt, 0)) - t.Run("ToUint32Max", wantConversion[uint32](fhir.UnsignedInt, math.MaxUint32)) - t.Run("ToUint64", wantConversion[uint64](fhir.UnsignedInt, 0)) - t.Run("ToUint64MaxUint32", wantConversion[uint64](fhir.UnsignedInt, math.MaxUint32)) - t.Run("ToUint", wantConversion[uint](fhir.UnsignedInt, 0)) - t.Run("ToUintMaxUint32", wantConversion[uint](fhir.UnsignedInt, math.MaxUint32)) - t.Run("ToUintptr", wantConversion[uintptr](fhir.UnsignedInt, 0)) - }) - - t.Run("FromInteger", func(t *testing.T) { - t.Run("ToInt8", wantConversion[int8](fhir.Integer, 0)) - t.Run("ToInt8Max", wantConversion[int8](fhir.Integer, math.MaxInt8)) - t.Run("ToInt8Min", wantConversion[int8](fhir.Integer, math.MinInt8)) - t.Run("ToInt16", wantConversion[int16](fhir.Integer, 0)) - t.Run("ToInt16Max", wantConversion[int16](fhir.Integer, math.MaxInt16)) - t.Run("ToInt16Min", wantConversion[int16](fhir.Integer, math.MinInt16)) - t.Run("ToInt32", wantConversion[int32](fhir.Integer, 0)) - t.Run("ToInt32Max", wantConversion[int32](fhir.Integer, math.MaxInt32)) - t.Run("ToInt32Min", wantConversion[int32](fhir.Integer, math.MinInt32)) - t.Run("ToInt64", wantConversion[int64](fhir.Integer, 0)) - t.Run("ToInt64MaxInt32", wantConversion[int64](fhir.Integer, math.MaxInt32)) - t.Run("ToInt64MinInt32", wantConversion[int64](fhir.Integer, math.MinInt32)) - t.Run("ToInt", wantConversion[int](fhir.Integer, 0)) - t.Run("ToIntMaxInt32", wantConversion[int](fhir.Integer, math.MaxInt32)) - t.Run("ToIntMinInt32", wantConversion[int](fhir.Integer, math.MinInt32)) - t.Run("ToUint8", wantConversion[uint8](fhir.Integer, 0)) - t.Run("ToUint8Max", wantConversion[uint8](fhir.Integer, math.MaxUint8)) - t.Run("ToUint16", wantConversion[uint16](fhir.Integer, 0)) - t.Run("ToUint16Max", wantConversion[uint16](fhir.Integer, math.MaxUint16)) - t.Run("ToUint32", wantConversion[uint32](fhir.Integer, 0)) - t.Run("ToUint32MaxInt32", wantConversion[uint32](fhir.Integer, math.MaxInt32)) - t.Run("ToUint64", wantConversion[uint64](fhir.Integer, 0)) - t.Run("ToUint64MaxInt32", wantConversion[uint64](fhir.Integer, math.MaxInt32)) - t.Run("ToUint", wantConversion[uint](fhir.Integer, 0)) - t.Run("ToUintMaxInt32", wantConversion[uint](fhir.Integer, math.MaxInt32)) - t.Run("ToUintptr", wantConversion[uintptr](fhir.Integer, 0)) - }) -} - -func TestMustConvertToInteger_ValueWithinRange_ReturnsValue(t *testing.T) { - input := int32(42) - value := fhir.Integer(input) - - result := fhirconv.MustConvertToInteger[int32](value) - - if got, want := result, input; got != want { - t.Fatalf("MustConvertToInteger[int32]: got %v, want %v", got, want) - } -} - -func TestMustConvertToInteger_ValueOutsideRange_Panics(t *testing.T) { - defer func() { _ = recover() }() - value := fhir.Integer(-1) - - fhirconv.MustConvertToInteger[uint8](value) - - // This can't be reached if a panic occurs - t.Fatalf("MustConvertToInt: expected panic") -} diff --git a/internal/resource/contactable/contactable.go b/internal/resource/contactable/contactable.go deleted file mode 100644 index 976267e..0000000 --- a/internal/resource/contactable/contactable.go +++ /dev/null @@ -1,30 +0,0 @@ -// Package contactable contains utilities for working with FHIR Resource objects that -// include a contact field -package contactable - -import ( - dtpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/datatypes_go_proto" - "github.com/verily-src/fhirpath-go/internal/fhir" - "github.com/verily-src/fhirpath-go/internal/resource" - "github.com/verily-src/fhirpath-go/internal/resourceopt" -) - -// Option is an option that may be supplied to updates of ContactableResource types -type Option = resourceopt.Option - -// ContactableResource is the interface for FHIR resources that include a contact field -type ContactableResource interface { - GetContact() []*dtpb.ContactDetail - fhir.DomainResource -} - -// WithContacts returns a resource Option for setting the ContactableResource -// Contact with the specified contact entry. -func WithContacts(contact ...*dtpb.ContactDetail) Option { - return resourceopt.WithProtoField("contact", contact...) -} - -// Update modifies the input resource in-place with the specified options. -func Update(cr ContactableResource, opts ...Option) { - resource.Update(cr.(fhir.Resource), opts...) -} diff --git a/internal/resource/contactable/contactable_test.go b/internal/resource/contactable/contactable_test.go deleted file mode 100644 index a782101..0000000 --- a/internal/resource/contactable/contactable_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package contactable_test - -import ( - "testing" - - dtpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/datatypes_go_proto" - "github.com/verily-src/fhirpath-go/internal/fhir" - "github.com/verily-src/fhirpath-go/internal/fhirtest" - "github.com/verily-src/fhirpath-go/internal/resource/contactable" - "google.golang.org/protobuf/proto" -) - -func TestWithContact(t *testing.T) { - want := &dtpb.ContactDetail{ - Name: fhir.String("deadbeef"), - } - - for name, res := range fhirtest.CanonicalResources { - t.Run(name, func(t *testing.T) { - got := proto.Clone(res).(contactable.ContactableResource) - - contactable.Update(got, contactable.WithContacts(want)) - - for _, got := range got.(fhir.CanonicalResource).GetContact() { - if !proto.Equal(got, want) { - t.Errorf("WithContact(%v): got %v, want %v", name, got, want) - } - } - }) - } -} diff --git a/internal/resource/patient/patient.go b/internal/resource/patient/patient.go deleted file mode 100644 index 570476f..0000000 --- a/internal/resource/patient/patient.go +++ /dev/null @@ -1,199 +0,0 @@ -package patient - -import ( - "errors" - "fmt" - - dtpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/datatypes_go_proto" - appb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/appointment_go_proto" - arpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/appointment_response_go_proto" - bapb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/basic_go_proto" - cppb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/care_plan_go_proto" - clpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/claim_go_proto" - commpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/communication_go_proto" - crpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/communication_request_go_proto" - cpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/condition_go_proto" - conpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/consent_go_proto" - cerpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/coverage_eligibility_request_go_proto" - dpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/device_go_proto" - drpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/device_request_go_proto" - dorpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/document_reference_go_proto" - epb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/encounter_go_proto" - erpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/enrollment_request_go_proto" - eocpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/episode_of_care_go_proto" - eobpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/explanation_of_benefit_go_proto" - gpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/goal_go_proto" - irpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/immunization_recommendation_go_proto" - lpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/list_go_proto" - mrpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/medication_request_go_proto" - nopb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/nutrition_order_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" - procpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/procedure_go_proto" - qrpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/questionnaire_response_go_proto" - rppb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/related_person_go_proto" - rgpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/request_group_go_proto" - rspb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/research_subject_go_proto" - rapb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/risk_assessment_go_proto" - srpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/service_request_go_proto" - surpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/supply_request_go_proto" - tpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/task_go_proto" - vrpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/verification_result_go_proto" - vppb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/vision_prescription_go_proto" - "github.com/verily-src/fhirpath-go/internal/fhir" - "github.com/verily-src/fhirpath-go/internal/resource" -) - -var ( - ErrExtractingPatientID = errors.New("extracting patient id") - ErrUnsupportedType = errors.New("extraction unsupported for type") -) - -// IDFromResource returns the patient's ID from a FHIR Resource. -// This function provides a mapping from an input resource to the patient ID it references. -func IDFromResource(res fhir.Resource) (string, error) { - idOrError := func(patientRef *dtpb.Reference) (string, error) { - if id := patientRef.GetPatientId().GetValue(); id != "" { - return id, nil - } - return "", fmt.Errorf("%w from %T", ErrExtractingPatientID, res) - } - - switch res := res.(type) { - - //--------------------------------------------------------------------------- - // Unpatterned Resources - //--------------------------------------------------------------------------- - - case *ppb.Patient: - if id := res.GetId().GetValue(); id != "" { - return id, nil - } - return "", fmt.Errorf("%w from %T", ErrExtractingPatientID, res) - case *bapb.Basic: - return idOrError(res.GetSubject()) - case *conpb.Consent: - return idOrError(res.GetPatient()) - case *epb.Encounter: - return idOrError(res.GetSubject()) - case *eocpb.EpisodeOfCare: - return idOrError(res.GetPatient()) - case *dpb.Device: - return idOrError(res.GetPatient()) - case *eobpb.ExplanationOfBenefit: - return idOrError(res.GetPatient()) - case *gpb.Goal: - return idOrError(res.GetSubject()) - case *rspb.ResearchSubject: - return idOrError(res.GetIndividual()) - case *rppb.RelatedPerson: - return idOrError(res.GetPatient()) - case *lpb.List: - return idOrError(res.GetSubject()) - case *dorpb.DocumentReference: - return idOrError(res.GetSubject()) - case *vrpb.VerificationResult: - // VerificationResult may contain patientID in the target field, which - // supports multiple references of any type. - // This means there may be multiple patient IDs. We assume only 1 patient - // by searching for and returning the first patient we discover. - for _, target := range res.GetTarget() { - if id := target.GetPatientId().GetValue(); id != "" { - return id, nil - } - } - return "", fmt.Errorf("%w from %T", ErrExtractingPatientID, res) - - //--------------------------------------------------------------------------- - // Event Pattern Resources - //--------------------------------------------------------------------------- - - case *qrpb.QuestionnaireResponse: - return idOrError(res.GetSubject()) - case *rapb.RiskAssessment: - return idOrError(res.GetSubject()) - case *cpb.Condition: - return idOrError(res.GetSubject()) - case *procpb.Procedure: - return idOrError(res.GetSubject()) - case *opb.Observation: - return idOrError(res.GetSubject()) - case *tpb.Task: - // TODO(b/254654059): Remove usage of Task.for once decision engine supports event-patient extraction override. - if id := res.GetFocus().GetPatientId().GetValue(); id != "" { - return id, nil - } else if id := res.GetForValue().GetPatientId().GetValue(); id != "" { - return id, nil - } - return "", fmt.Errorf("%w from %T", ErrExtractingPatientID, res) - case *commpb.Communication: - return idOrError(res.GetSubject()) - - //--------------------------------------------------------------------------- - // Request Pattern Resources - //--------------------------------------------------------------------------- - - case *appb.Appointment: - // TODO(b/240690479): Appointments are a request-pattern type that supports - // multiple participants, which may be of type Practitioner, Patient, etc. - // This means we may have to support multiple patient IDs. Currently we - // assume only 1 patient by searching for and returning the first patient we - // discover. - for _, participant := range res.GetParticipant() { - if id := participant.GetActor().GetPatientId().GetValue(); id != "" { - return id, nil - } - } - return "", fmt.Errorf("%w from %T", ErrExtractingPatientID, res) - case *arpb.AppointmentResponse: - return idOrError(res.GetActor()) - case *cppb.CarePlan: - return idOrError(res.GetSubject()) - case *clpb.Claim: - return idOrError(res.GetPatient()) - case *crpb.CommunicationRequest: - return idOrError(res.GetSubject()) - case *cerpb.CoverageEligibilityRequest: - return idOrError(res.GetPatient()) - case *drpb.DeviceRequest: - return idOrError(res.GetSubject()) - case *erpb.EnrollmentRequest: - return idOrError(res.GetCandidate()) - case *irpb.ImmunizationRecommendation: - return idOrError(res.GetPatient()) - case *mrpb.MedicationRequest: - return idOrError(res.GetSubject()) - case *nopb.NutritionOrder: - return idOrError(res.GetPatient()) - case *rgpb.RequestGroup: - return idOrError(res.GetSubject()) - case *srpb.ServiceRequest: - return idOrError(res.GetSubject()) - case *surpb.SupplyRequest: - // Supply requests may contain PatientID in two possible locations: either as - // the source of the request, or as the destination for the request - if id := res.GetDeliverTo().GetPatientId().GetValue(); id != "" { - return id, nil - } else if id := res.GetRequester().GetPatientId().GetValue(); id != "" { - return id, nil - } - return "", fmt.Errorf("%w from %T", ErrExtractingPatientID, res) - case *vppb.VisionPrescription: - return idOrError(res.GetPatient()) - default: - return "", fmt.Errorf("%w: %T", ErrUnsupportedType, res) - } -} - -// Reference creates a literal Patient reference. -// This replaces verily-go-fhir/protohelpers.PatientReference. -func Reference(patientID string) *dtpb.Reference { - return &dtpb.Reference{ - Type: fhir.URI(resource.Patient.String()), - Reference: &dtpb.Reference_PatientId{ - PatientId: &dtpb.ReferenceId{ - Value: patientID, - }, - }, - } -} diff --git a/internal/resource/patient/patient_test.go b/internal/resource/patient/patient_test.go deleted file mode 100644 index c09de89..0000000 --- a/internal/resource/patient/patient_test.go +++ /dev/null @@ -1,184 +0,0 @@ -package patient_test - -import ( - "fmt" - "testing" - - dtpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/datatypes_go_proto" - apb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/account_go_proto" - appb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/appointment_go_proto" - arpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/appointment_response_go_proto" - bapb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/basic_go_proto" - cppb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/care_plan_go_proto" - clpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/claim_go_proto" - crpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/communication_request_go_proto" - cpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/condition_go_proto" - conpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/consent_go_proto" - cerpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/coverage_eligibility_request_go_proto" - dpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/device_go_proto" - drpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/device_request_go_proto" - epb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/encounter_go_proto" - erpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/enrollment_request_go_proto" - eobpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/explanation_of_benefit_go_proto" - gpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/goal_go_proto" - irpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/immunization_recommendation_go_proto" - mrpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/medication_request_go_proto" - nopb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/nutrition_order_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" - procpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/procedure_go_proto" - qrpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/questionnaire_response_go_proto" - rppb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/related_person_go_proto" - rgpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/request_group_go_proto" - rspb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/research_subject_go_proto" - rapb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/risk_assessment_go_proto" - srpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/service_request_go_proto" - surpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/supply_request_go_proto" - tpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/task_go_proto" - vrpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/verification_result_go_proto" - vppb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/vision_prescription_go_proto" - "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" - "github.com/verily-src/fhirpath-go/internal/fhir" - "github.com/verily-src/fhirpath-go/internal/resource/patient" - "google.golang.org/protobuf/testing/protocmp" -) - -// TODO(PHP-7652): Update testing -func TestIDFromResource(t *testing.T) { - mockPatientID := "patient-id" - mockPatientRef := patient.Reference(mockPatientID) - mockAccount := &apb.Account{} - - type TestCase struct { - name string - resource fhir.Resource - expected string - expectedError error - } - - // Helpers to make pass and fail cases consistent - pass := func(name string, res fhir.Resource) TestCase { - return TestCase{ - name: fmt.Sprintf("%v as input, contains patient id, returns id", name), - resource: res, - expected: mockPatientID, - expectedError: nil, - } - } - fail := func(name string, res fhir.Resource) TestCase { - return TestCase{ - name: fmt.Sprintf("%v as input, does not contain id, returns err", name), - resource: res, - expected: "", - expectedError: patient.ErrExtractingPatientID, - } - } - - testCases := []TestCase{ - - // General Error Conditions - {"unsupported resource type, returns err", mockAccount, "", patient.ErrUnsupportedType}, - - // Resources - pass("patient", &ppb.Patient{Id: fhir.ID(mockPatientID)}), - fail("patient", &ppb.Patient{}), - pass("basic", &bapb.Basic{Subject: mockPatientRef}), - fail("basic", &bapb.Basic{}), - pass("consent", &conpb.Consent{Patient: mockPatientRef}), - fail("consent", &conpb.Consent{}), - pass("encounter", &epb.Encounter{Subject: mockPatientRef}), - fail("encounter", &epb.Encounter{}), - pass("device", &dpb.Device{Patient: mockPatientRef}), - fail("device", &dpb.Device{}), - pass("explanation of benefit", &eobpb.ExplanationOfBenefit{Patient: mockPatientRef}), - fail("explanation of benefit", &eobpb.ExplanationOfBenefit{}), - pass("goal", &gpb.Goal{Subject: mockPatientRef}), - fail("goal", &gpb.Goal{}), - pass("research subject", &rspb.ResearchSubject{Individual: mockPatientRef}), - fail("research subject", &rspb.ResearchSubject{}), - pass("related person", &rppb.RelatedPerson{Patient: mockPatientRef}), - fail("related person", &rppb.RelatedPerson{}), - pass("verification result", &vrpb.VerificationResult{Target: []*dtpb.Reference{mockPatientRef}}), - fail("verification result", &vrpb.VerificationResult{}), - - // Event Patterns - pass("questionnaire response", &qrpb.QuestionnaireResponse{Subject: mockPatientRef}), - fail("questionnaire response", &qrpb.QuestionnaireResponse{}), - pass("risk assessment", &rapb.RiskAssessment{Subject: mockPatientRef}), - fail("risk assessment", &rapb.RiskAssessment{}), - pass("condition", &cpb.Condition{Subject: mockPatientRef}), - fail("condition", &cpb.Condition{}), - pass("procedure", &procpb.Procedure{Subject: mockPatientRef}), - fail("procedure", &procpb.Procedure{}), - pass("observation", &opb.Observation{Subject: mockPatientRef}), - fail("observation", &opb.Observation{}), - pass("task", &tpb.Task{ForValue: mockPatientRef}), - pass("task", &tpb.Task{Focus: mockPatientRef}), - fail("task", &tpb.Task{}), - - // Request Patterns - pass("appointment response", &arpb.AppointmentResponse{Actor: mockPatientRef}), - fail("appointment response", &arpb.AppointmentResponse{}), - pass("appointment", &appb.Appointment{Participant: []*appb.Appointment_Participant{ - {Actor: mockPatientRef}, - }}), - fail("appointment", &appb.Appointment{}), - pass("care plan", &cppb.CarePlan{Subject: mockPatientRef}), - fail("care plan", &cppb.CarePlan{}), - pass("claim", &clpb.Claim{Patient: mockPatientRef}), - fail("claim", &clpb.Claim{}), - pass("communication request", &crpb.CommunicationRequest{Subject: mockPatientRef}), - fail("communication reuqest", &crpb.CommunicationRequest{}), - pass("coverage eligibility request", &cerpb.CoverageEligibilityRequest{Patient: mockPatientRef}), - fail("coverage eligibility request", &cerpb.CoverageEligibilityRequest{}), - pass("device request", &drpb.DeviceRequest{Subject: mockPatientRef}), - fail("device request", &drpb.DeviceRequest{}), - pass("enrollment request", &erpb.EnrollmentRequest{Candidate: mockPatientRef}), - fail("enrollment request", &erpb.EnrollmentRequest{}), - pass("immunization recommendation", &irpb.ImmunizationRecommendation{Patient: mockPatientRef}), - fail("immunization recommendation", &irpb.ImmunizationRecommendation{}), - pass("medication request", &mrpb.MedicationRequest{Subject: mockPatientRef}), - fail("medication request", &mrpb.MedicationRequest{}), - pass("nutrition order", &nopb.NutritionOrder{Patient: mockPatientRef}), - fail("nutrition order", &nopb.NutritionOrder{}), - pass("request group", &rgpb.RequestGroup{Subject: mockPatientRef}), - fail("request group", &rgpb.RequestGroup{}), - pass("service request", &srpb.ServiceRequest{Subject: mockPatientRef}), - fail("service request", &srpb.ServiceRequest{}), - pass("supply request", &surpb.SupplyRequest{DeliverTo: mockPatientRef}), - pass("supply request", &surpb.SupplyRequest{Requester: mockPatientRef}), - fail("supply request", &surpb.SupplyRequest{}), - pass("vision prescription", &vppb.VisionPrescription{Patient: mockPatientRef}), - fail("vision prescription", &vppb.VisionPrescription{}), - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - patientID, err := patient.IDFromResource(tc.resource) - if got, want := err, tc.expectedError; !cmp.Equal(got, want, cmpopts.EquateErrors()) { - t.Fatalf("IDFromResource(%s) error got = %v, want = %v", tc.name, got, want) - } - if got, want := patientID, tc.expected; got != want { - t.Errorf("IDFromResource(%s) got = %v, want = %v", tc.name, got, want) - } - }) - } -} - -func TestReference(t *testing.T) { - want := &dtpb.Reference{ - Type: fhir.URI("Patient"), - Reference: &dtpb.Reference_PatientId{ - PatientId: &dtpb.ReferenceId{ - Value: "123", - }, - }, - } - - got := patient.Reference("123") - - if diff := cmp.Diff(got, want, protocmp.Transform()); diff != "" { - t.Errorf("Reference mismatch (-want, +got)\n%s", diff) - } -} diff --git a/internal/resource/resource.go b/internal/resource/resource.go index bb3754c..18ad410 100644 --- a/internal/resource/resource.go +++ b/internal/resource/resource.go @@ -7,6 +7,7 @@ package resource 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/fhir" @@ -145,6 +146,29 @@ func ProfileStrings(resource fhir.Resource) []string { return profiles } +// UnversionedProfileStrings is a helper for getting the unversioned fhir profiles of a resource in +// string form. The order of the profiles are preserved and duplicate profile base urls are removed. +// +// If the resource is nil, this will return nil. +func UnversionedProfileStrings(resource fhir.Resource) []string { + profiles := ProfileStrings(resource) + if profiles == nil { + return nil + } + + var unversionedProfiles []string + seen := map[string]bool{} + + for _, profile := range profiles { + unversionedProfile := strings.Split(profile, "|")[0] + if !seen[unversionedProfile] { + seen[unversionedProfile] = true + unversionedProfiles = append(unversionedProfiles, unversionedProfile) + } + } + return unversionedProfiles +} + // VersionedURI is a helper for getting the URI of a resource as a URI object. // The URI is returned in the format Type/ID/_history/VERSION. // @@ -251,7 +275,6 @@ type HasGetIdentifierSingle interface { // The list may be nil or empty if no identifiers are present. // See interfaces: fhir.HasGetIdentifierList, fhir.HasGetIdentifierSingle func GetIdentifierList(res fhir.Resource) ([]*dtpb.Identifier, error) { - if cast, ok := res.(HasGetIdentifierList); ok { // resource implements GetIdentifier() as a list return cast.GetIdentifier(), nil @@ -269,3 +292,29 @@ 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) } + +// GetExtensions returns the list of extensions from a resource, if available. +func GetExtensions(res fhir.Resource) (extensions []*dtpb.Extension, ok bool) { + type extensionList interface { + GetExtension() []*dtpb.Extension + } + type extensionSingle interface { + GetExtension() *dtpb.Extension + } + + // Check if the resource contains an Extension, either as a list or a single item. + if cast, ok := res.(extensionList); ok { + return cast.GetExtension(), true + } + + if cast, ok := res.(extensionSingle); ok { + ext := cast.GetExtension() + if ext == nil { + return nil, true + } + + return []*dtpb.Extension{ext}, true + } + + return nil, false +} diff --git a/internal/resource/resource_test.go b/internal/resource/resource_test.go index 2fe00be..4fd631d 100644 --- a/internal/resource/resource_test.go +++ b/internal/resource/resource_test.go @@ -184,6 +184,71 @@ func TestProfileStrings(t *testing.T) { } } +func TestUnversionedProfileStrings(t *testing.T) { + testCases := []struct { + name string + res fhir.Resource + want []string + }{ + { + name: "nil resource has no profiles", + res: nil, + want: nil, + }, + { + name: "empty resource has no profiles", + res: &ppb.Patient{}, + want: nil, + }, + { + name: "resource with no profile has no profiles", + res: &ppb.Patient{Meta: &dtpb.Meta{Profile: []*dtpb.Canonical{}}}, + want: nil, + }, + { + name: "resource with single unversioned profile returns profile", + res: &ppb.Patient{Meta: &dtpb.Meta{Profile: []*dtpb.Canonical{{Value: "Profile1"}}}}, + want: []string{"Profile1"}, + }, + { + name: "resource with single versioned profile returns unversioned profile", + res: &ppb.Patient{Meta: &dtpb.Meta{Profile: []*dtpb.Canonical{{Value: "Profile1|1.2.3"}}}}, + want: []string{"Profile1"}, + }, + { + name: "resource with multiple unversioned profiles returns profiles", + res: &ppb.Patient{Meta: &dtpb.Meta{Profile: []*dtpb.Canonical{ + {Value: "Profile1"}, {Value: "Profile2"}, {Value: "Profile3"}, {Value: "Profile4"}, {Value: "Profile5"}, + }}}, + want: []string{"Profile1", "Profile2", "Profile3", "Profile4", "Profile5"}, + }, + { + name: "resource with multiple versioned profiles returns unversioned profiles", + res: &ppb.Patient{Meta: &dtpb.Meta{Profile: []*dtpb.Canonical{ + {Value: "Profile1|1.2.3"}, {Value: "Profile2|4.5.6"}, {Value: "Profile3|7.8.9"}, + }}}, + want: []string{"Profile1", "Profile2", "Profile3"}, + }, + { + name: "resource with duplicate profiles returns unique profiles", + res: &ppb.Patient{Meta: &dtpb.Meta{Profile: []*dtpb.Canonical{ + {Value: "Profile1"}, {Value: "Profile1"}, {Value: "Profile2"}, {Value: "Profile2|1.2.3"}, {Value: "Profile2|4.5.6"}, {Value: "Profile2|7.8.9"}, + }}}, + want: []string{"Profile1", "Profile2"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got := resource.UnversionedProfileStrings(tc.res) + + if diff := cmp.Diff(got, tc.want, protocmp.Transform()); diff != "" { + t.Fatalf("UnversionedProfileStrings(%s): (-got, +want):\n%s", tc.name, diff) + } + }) + } +} + func TestProfiles(t *testing.T) { testCases := []struct { name string @@ -494,3 +559,107 @@ var _ resource.HasGetIdentifierList = (*document_reference_go_proto.DocumentRefe // Sanity check that a few resources have GetIdentifier() as a single ID. This is not a complete list. var _ resource.HasGetIdentifierSingle = (*bundle_and_contained_resource_go_proto.Bundle)(nil) var _ resource.HasGetIdentifierSingle = (*questionnaire_response_go_proto.QuestionnaireResponse)(nil) + +func TestGetExtension_List(t *testing.T) { + ext1 := &dtpb.Extension{Url: &dtpb.Uri{Value: "http://example.com/ext1"}} + ext2 := &dtpb.Extension{Url: &dtpb.Uri{Value: "http://example.com/ext2"}} + + testCases := []struct { + name string + res fhir.Resource + ok bool + want []*dtpb.Extension + }{ + { + name: "Patient with multiple extensions", + res: &ppb.Patient{ + Extension: []*dtpb.Extension{ext1, ext2}, + }, + ok: true, + want: []*dtpb.Extension{ext1, ext2}, + }, + { + name: "Patient with no extensions", + res: &ppb.Patient{}, + want: nil, + ok: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got, ok := resource.GetExtensions(tc.res) + if ok != tc.ok { + t.Errorf("GetExtensions(%s) got ok = %v, want %v", tc.name, ok, tc.ok) + return + } + if diff := cmp.Diff(tc.want, got, protocmp.Transform()); diff != "" { + t.Errorf("GetExtensions(%s) mismatch (-want, +got):\n%s", tc.name, diff) + } + }) + } +} + +func TestGetExtension_Single(t *testing.T) { + ext := &dtpb.Extension{Url: &dtpb.Uri{Value: "http://example.com/ext-single"}} + + testCases := []struct { + name string + res fhir.Resource + ok bool + want []*dtpb.Extension + }{ + { + name: "QuestionnaireResponse with single extension", + res: &questionnaire_response_go_proto.QuestionnaireResponse{ + Extension: []*dtpb.Extension{ext}, + }, + ok: true, + want: []*dtpb.Extension{ext}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got, ok := resource.GetExtensions(tc.res) + if ok != tc.ok { + t.Errorf("GetExtensions(%s) got ok = %v, want %v", tc.name, ok, tc.ok) + return + } + if diff := cmp.Diff(tc.want, got, protocmp.Transform()); diff != "" { + t.Errorf("GetExtensions(%s) mismatch (-want, +got):\n%s", tc.name, diff) + } + }) + } +} + +func TestGetExtension_nil(t *testing.T) { + testCases := []struct { + name string + res fhir.Resource + ok bool + }{ + { + name: "Resource does not implement GetExtension", + res: &bundle_and_contained_resource_go_proto.Bundle{}, + ok: false, + }, + { + name: "Nil resource", + res: nil, + ok: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got, ok := resource.GetExtensions(tc.res) + if ok != tc.ok { + t.Errorf("GetExtensions(%s): got ok = %v, want %v", tc.name, ok, tc.ok) + } + if got != nil { + t.Errorf("GetExtensions(%s): got %v, want nil", tc.name, got) + } + }) + } +}