From 38fa9404b65aa526973918ec7b3950b8204e7f18 Mon Sep 17 00:00:00 2001 From: Ken Stevens Date: Sun, 6 Jul 2025 12:00:49 -0400 Subject: [PATCH 1/2] Claude solution for comparison --- .../ca/uhn/fhir/jpa/term/TermReadSvcImpl.java | 63 ++++++++++++++----- .../jpa/term/ValueSetExpansionR4Test.java | 61 ++++++++++++++++++ 2 files changed, 107 insertions(+), 17 deletions(-) diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/term/TermReadSvcImpl.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/term/TermReadSvcImpl.java index 54bf78c3b73d..4a81fae468a3 100644 --- a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/term/TermReadSvcImpl.java +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/term/TermReadSvcImpl.java @@ -1648,25 +1648,35 @@ private void handleFilterConceptAndCode( SearchPredicateFactory f, BooleanPredicateClausesStep b, ValueSet.ConceptSetFilterComponent theFilter) { - TermConcept code = findCodeForFilterCriteriaCodeOrConcept(theSystem, theFilter); - if (theFilter.getOp() == ValueSet.FilterOperator.ISA) { - ourLog.debug( - " * Filtering on specific code and codes with a parent of {}/{}/{}", - code.getId(), - code.getCode(), - code.getDisplay()); - - b.must(f.bool() - .should(f.match().field("myParentPids").matching("" + code.getId())) - .should(f.match().field("myId").matching(code.getPid()))); - } else if (theFilter.getOp() == ValueSet.FilterOperator.DESCENDENTOF) { - ourLog.debug( - " * Filtering on codes with a parent of {}/{}/{}", code.getId(), code.getCode(), code.getDisplay()); - - b.must(f.match().field("myParentPids").matching("" + code.getId())); + if (theFilter.getOp() == ValueSet.FilterOperator.EQUAL) { + // For EQUAL filters on code/concept property, match the exact code value + b.must(f.match().field("myCode").matching(theFilter.getValue())); } else { - throwInvalidFilter(theFilter, ""); + // For other operators (ISA, DESCENDENTOF), we need to find the actual TermConcept + TermConcept code = findCodeForFilterCriteriaCodeOrConcept(theSystem, theFilter); + + if (theFilter.getOp() == ValueSet.FilterOperator.ISA) { + ourLog.debug( + " * Filtering on specific code and codes with a parent of {}/{}/{}", + code.getId(), + code.getCode(), + code.getDisplay()); + + b.must(f.bool() + .should(f.match().field("myParentPids").matching("" + code.getId())) + .should(f.match().field("myId").matching(code.getPid()))); + } else if (theFilter.getOp() == ValueSet.FilterOperator.DESCENDENTOF) { + ourLog.debug( + " * Filtering on codes with a parent of {}/{}/{}", + code.getId(), + code.getCode(), + code.getDisplay()); + + b.must(f.match().field("myParentPids").matching("" + code.getId())); + } else { + throwInvalidFilter(theFilter, ""); + } } } @@ -1932,6 +1942,25 @@ private void expandWithoutHibernateSearch( addConceptAndChildren( theValueSetCodeAccumulator, theAddedCodes, theInclude, theSystem, theAdd, code); handled = true; + } else if (nextFilter.getOp() == ValueSet.FilterOperator.EQUAL) { + // Handle EQUAL filter on code/concept property + String filterValue = nextFilter.getValue(); + Optional optionalConcept = findCode(theSystem, filterValue); + if (optionalConcept.isPresent()) { + TermConcept concept = optionalConcept.get(); + addCodeIfNotAlreadyAdded( + theValueSetCodeAccumulator, + theAddedCodes, + theAdd, + theSystem, + theInclude.getVersion(), + concept.getCode(), + concept.getDisplay(), + concept.getId(), + concept.getParentPidsAsString(), + concept.getDesignations()); + } + handled = true; } break; default: diff --git a/hapi-fhir-jpaserver-test-r4/src/test/java/ca/uhn/fhir/jpa/term/ValueSetExpansionR4Test.java b/hapi-fhir-jpaserver-test-r4/src/test/java/ca/uhn/fhir/jpa/term/ValueSetExpansionR4Test.java index 661c13cd473c..9c573092bf51 100644 --- a/hapi-fhir-jpaserver-test-r4/src/test/java/ca/uhn/fhir/jpa/term/ValueSetExpansionR4Test.java +++ b/hapi-fhir-jpaserver-test-r4/src/test/java/ca/uhn/fhir/jpa/term/ValueSetExpansionR4Test.java @@ -208,6 +208,67 @@ public void testExpandInline_IncludeCodeSystem_FilterOnDisplay_LeftMatchFilter() assertThat(expandedValueSet.getExpansion().getContains().stream().map(t -> t.getDisplay()).collect(Collectors.toList())).containsExactlyInAnyOrder("Systolic blood pressure 1 hour minimum", "Systolic blood pressure 1 hour mean", "Systolic blood pressure 1 hour maximum"); } + @Test + public void testExpandInline_IncludeCodeSystem_FilterOnCode_Equal() throws Exception { + loadAndPersistCodeSystemWithDesignations(HttpVerb.PUT); + + ValueSet input = new ValueSet(); + input.getCompose() + .addInclude() + .setSystem("http://acme.org") + .addFilter() + .setProperty("code") + .setOp(ValueSet.FilterOperator.EQUAL) + .setValue("8450-9"); + + ValueSet expandedValueSet = myTermSvc.expandValueSet(new ValueSetExpansionOptions(), input); + ourLog.debug("Expanded ValueSet:\n" + myFhirContext.newJsonParser().setPrettyPrint(true).encodeResourceToString(expandedValueSet)); + + assertEquals(1, expandedValueSet.getExpansion().getTotal()); + assertThat(expandedValueSet.getExpansion().getContains().stream().map(t -> t.getCode()).collect(Collectors.toList())).containsExactlyInAnyOrder("8450-9"); + } + + @Test + public void testExpandInline_IncludeCodeSystem_FilterOnCustomProperty_Equal() throws Exception { + // Create CodeSystem with custom properties + CodeSystem cs = new CodeSystem(); + cs.setUrl("http://example.com/cs"); + cs.setContent(CodeSystem.CodeSystemContentMode.COMPLETE); + + // Add concepts with custom properties + CodeSystem.ConceptDefinitionComponent concept1 = cs.addConcept() + .setCode("code1") + .setDisplay("Display 1"); + concept1.addProperty() + .setCode("status") + .setValue(new StringType("active")); + + CodeSystem.ConceptDefinitionComponent concept2 = cs.addConcept() + .setCode("code2") + .setDisplay("Display 2"); + concept2.addProperty() + .setCode("status") + .setValue(new StringType("inactive")); + + myCodeSystemDao.create(cs); + + // Create ValueSet with EQUAL filter on custom property + ValueSet input = new ValueSet(); + input.getCompose() + .addInclude() + .setSystem("http://example.com/cs") + .addFilter() + .setProperty("status") + .setOp(ValueSet.FilterOperator.EQUAL) + .setValue("active"); + + ValueSet expandedValueSet = myTermSvc.expandValueSet(new ValueSetExpansionOptions(), input); + ourLog.debug("Expanded ValueSet:\n" + myFhirContext.newJsonParser().setPrettyPrint(true).encodeResourceToString(expandedValueSet)); + + assertEquals(1, expandedValueSet.getExpansion().getTotal()); + assertThat(expandedValueSet.getExpansion().getContains().stream().map(t -> t.getCode()).collect(Collectors.toList())).containsExactlyInAnyOrder("code1"); + } + @Test public void testExpandInline_IncludePreExpandedValueSetByUri_FilterOnDisplay_LeftMatch_SelectAll() { myStorageSettings.setPreExpandValueSets(true); From e64bbcb25edc82fae95bb43e9fc2d89909a121ac Mon Sep 17 00:00:00 2001 From: Ken Stevens Date: Sun, 6 Jul 2025 20:42:14 -0400 Subject: [PATCH 2/2] changelog and docs --- ...does-not-support-equl-filter-operator.yaml | 7 +++ .../hapi/fhir/docs/server_jpa/terminology.md | 63 +++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/8_4_0/7031-valueset-expand-operation-does-not-support-equl-filter-operator.yaml diff --git a/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/8_4_0/7031-valueset-expand-operation-does-not-support-equl-filter-operator.yaml b/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/8_4_0/7031-valueset-expand-operation-does-not-support-equl-filter-operator.yaml new file mode 100644 index 000000000000..20da8c51bd05 --- /dev/null +++ b/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/8_4_0/7031-valueset-expand-operation-does-not-support-equl-filter-operator.yaml @@ -0,0 +1,7 @@ +--- +type: add +issue: 7031 +title: "ValueSet expansion operations now support the EQUAL filter operator. When using filters + in ValueSet.compose.include elements, the EQUAL operator can be used to filter concepts + based on exact matches for the 'code' property or custom properties defined in the CodeSystem. + This allows for more precise ValueSet expansions when working with large terminology systems." \ No newline at end of file diff --git a/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/docs/server_jpa/terminology.md b/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/docs/server_jpa/terminology.md index cac4de58471b..5c69bbc92808 100644 --- a/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/docs/server_jpa/terminology.md +++ b/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/docs/server_jpa/terminology.md @@ -284,3 +284,66 @@ The following are the main key columns in the TRM_CONCEPTMAP table that are used The TRM_CONCEPTMAP table will have exactly one row for each unique combination of URL and VER. + +# ValueSet Expansion Operations + +HAPI FHIR supports the `$expand` operation on ValueSet resources to expand the set of concepts that are defined by a ValueSet. This operation can be used to retrieve all concepts that are included in a ValueSet, optionally filtered by various criteria. + +## Filtering During Expansion + +ValueSet expansion supports filtering using the `filter` parameter in the ValueSet.compose.include elements. The following filter operators are supported: + +### EQUAL Filter Operator + +The `EQUAL` filter operator can be used to filter concepts based on exact matches for concept properties: + +#### Filtering by Code +When filtering by the `code` property, the expansion will include only concepts whose code exactly matches the filter value: + +```json +{ + "resourceType": "ValueSet", + "compose": { + "include": [ + { + "system": "http://example.org/codes", + "filter": [ + { + "property": "code", + "op": "=", + "value": "example-code" + } + ] + } + ] + } +} +``` + +#### Filtering by Custom Properties +The `EQUAL` filter can also be used with custom properties defined in the CodeSystem: + +```json +{ + "resourceType": "ValueSet", + "compose": { + "include": [ + { + "system": "http://example.org/codes", + "filter": [ + { + "property": "status", + "op": "=", + "value": "active" + } + ] + } + ] + } +} +``` + +### Other Supported Filter Operators + +- **ISA**: Includes the specified concept and all its descendants +- **DESCENDENTOF**: Includes all descendant concepts of the specified concept