Skip to content

Commit a8dc5c1

Browse files
authored
Fix IG/$data-requirements key ValueSet binding discovery (#1032)
* Fix IG/$data-requirements key ValueSet binding discovery Make IG/$data-requirements emit the same key ValueSet bindings the IG-publisher shows in its "Key Elements Table". For several IGs like hl7.fhir.uv.cql the operation previously surfaced zero ValueSets in the relatedArtifact list and zero entries with crmi-dependencyRole=key even though the IG-publisher's published key elements analysis identifies several per profile. * Preserve unversioned stub CodeSystem refs in $data-requirements
1 parent 1335b94 commit a8dc5c1

7 files changed

Lines changed: 266 additions & 7 deletions

File tree

cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/visitor/DataRequirementsVisitor.java

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,14 @@ public IBase visit(IKnowledgeArtifactAdapter adapter, IBaseParameters operationP
165165

166166
gatherDependenciesWithClassification(adapter, new ArrayList<>(), relatedArtifacts, ctx);
167167

168+
// Surface key bindings the dependency walk missed. Some bindings (e.g., on
169+
// sliced elements whose binding lives on a complex element type) aren't
170+
// reachable through differential + baseDefinition walking, but the snapshot-based
171+
// KeyElementAnalyzer correctly identifies them. Promote each transitiveKeyCanonical
172+
// not already in relatedArtifacts to a synthetic depends-on entry with role=key,
173+
// matching what the IG-publisher's Key Elements Table would show.
174+
synthesizeMissingKeyDependencies(relatedArtifacts, transitiveKeyCanonicals, conformanceResolver);
175+
168176
// Add root IG as a composed-of entry
169177
if (adapter.get() instanceof org.hl7.fhir.r4.model.ImplementationGuide
170178
|| adapter.get() instanceof org.hl7.fhir.r5.model.ImplementationGuide) {
@@ -281,6 +289,17 @@ private String enrichReference(
281289
IKnowledgeArtifactAdapter dependencyAdapter,
282290
ConformanceResourceResolver conformanceResolver) {
283291
if (dependencyAdapter != null) {
292+
var url = dependencyAdapter.getUrl();
293+
// External CodeSystems shipped as "stub" entries (CodeSystem.content = not-present)
294+
// carry a version field that reflects the publishing package, not the actual
295+
// terminology version. NpmRepository excludes these from the version index, so a
296+
// present URL with a missing indexed version identifies a stub. Skip the pin.
297+
if (conformanceResolver != null
298+
&& url != null
299+
&& conformanceResolver.getResourceType(url) != null
300+
&& conformanceResolver.getVersion(url) == null) {
301+
return url;
302+
}
284303
return dependencyAdapter.getCanonical();
285304
}
286305
var reference = dependency.getReference();
@@ -424,6 +443,53 @@ private <T extends ICompositeType & IBaseHasExtensions> void addCrmiExtensions(
424443
}
425444
}
426445

446+
@SuppressWarnings("unchecked")
447+
private <T extends ICompositeType & IBaseHasExtensions> void synthesizeMissingKeyDependencies(
448+
List<T> relatedArtifacts,
449+
Set<String> transitiveKeyCanonicals,
450+
ConformanceResourceResolver conformanceResolver) {
451+
if (transitiveKeyCanonicals == null || transitiveKeyCanonicals.isEmpty()) {
452+
return;
453+
}
454+
455+
var existingUrls = relatedArtifacts.stream()
456+
.map(IKnowledgeArtifactAdapter::getRelatedArtifactReference)
457+
.filter(StringUtils::isNotBlank)
458+
.map(Canonicals::getUrl)
459+
.collect(Collectors.toSet());
460+
461+
for (var keyCanonical : transitiveKeyCanonicals) {
462+
var url = Canonicals.getUrl(keyCanonical);
463+
if (url == null || existingUrls.contains(url)) {
464+
continue;
465+
}
466+
467+
var indexedVersion = conformanceResolver != null ? conformanceResolver.getVersion(url) : null;
468+
var enrichedRef = indexedVersion != null ? url + "|" + indexedVersion : url;
469+
470+
var newDep = (T) IKnowledgeArtifactAdapter.newRelatedArtifact(
471+
fhirVersion(), Constants.RELATEDARTIFACT_TYPE_DEPENDSON, enrichedRef, null);
472+
473+
var extensionList = (List<IBaseExtension<?, ?>>) newDep.getExtension();
474+
extensionList.add(ExtensionBuilders.buildDependencyRoleExt(fhirVersion(), "key"));
475+
476+
if (conformanceResolver != null) {
477+
var resourceType = conformanceResolver.getResourceType(url);
478+
if (resourceType != null) {
479+
addResourceTypeExtension(resourceType, newDep);
480+
}
481+
var pkgInfo = conformanceResolver.getPackageInfo(url);
482+
if (pkgInfo != null) {
483+
extensionList.add(ExtensionBuilders.buildComplexPackageSourceExt(
484+
fhirVersion(), pkgInfo.packageId(), pkgInfo.version(), pkgInfo.canonical()));
485+
}
486+
}
487+
488+
relatedArtifacts.add(newDep);
489+
existingUrls.add(url);
490+
}
491+
}
492+
427493
private void addResourceTypeExtension(String resourceType, ICompositeType relatedArtifact) {
428494
if (fhirVersion().equals(FhirVersionEnum.R4)) {
429495
((org.hl7.fhir.r4.model.RelatedArtifact) relatedArtifact)

cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/visitor/DependencyRoleClassifier.java

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,13 +90,32 @@ public static List<String> classifyDependencyRoles(
9090
/**
9191
* Checks if a dependency is in the set of transitive key canonicals discovered
9292
* via ValueSet compose chain walking.
93+
* <p>
94+
* Normalizes both sides on version: dependency references and key-set entries are
95+
* compared on URL only. The set may contain version-pinned URLs (FHIR core writes
96+
* bindings inconsistently — e.g. {@code publication-status|4.0.1} pinned but
97+
* {@code library-type} not), and dep references are version-stripped at lookup, so
98+
* symmetric stripping is required to avoid spurious misses.
9399
*/
94100
private static boolean isTransitiveKeyDependency(IDependencyInfo dependency, Set<String> transitiveKeyCanonicals) {
95101
if (transitiveKeyCanonicals == null || transitiveKeyCanonicals.isEmpty()) {
96102
return false;
97103
}
98104
var canonical = getDependencyCanonical(dependency.getReference());
99-
return canonical != null && transitiveKeyCanonicals.contains(canonical);
105+
if (canonical == null) {
106+
return false;
107+
}
108+
// Fast path: exact URL hit (covers entries that are already version-stripped).
109+
if (transitiveKeyCanonicals.contains(canonical)) {
110+
return true;
111+
}
112+
// Fallback: strip versions on the key-set side and compare URLs.
113+
for (var keyEntry : transitiveKeyCanonicals) {
114+
if (canonical.equals(getDependencyCanonical(keyEntry))) {
115+
return true;
116+
}
117+
}
118+
return false;
100119
}
101120

102121
/**

cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/visitor/DependencyRoleClassifierTest.java

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,4 +524,46 @@ void testTransitiveKeyCanonicals_emptySet_noEffect() {
524524
assertFalse(roles.contains("key"));
525525
assertTrue(roles.contains("default"));
526526
}
527+
528+
@Test
529+
void testTransitiveKeyMatchesWhenSetHasVersionPinAndDepDoesNot() {
530+
// FHIR core writes some bindings with version pins (e.g.
531+
// "http://hl7.org/fhir/ValueSet/publication-status|4.0.1") and others without.
532+
// When KeyElementAnalyzer collects these into the transitive key set, dep
533+
// references discovered through other paths may be version-stripped. The
534+
// classifier must normalize so set membership is URL-only.
535+
var resolver = createResolver();
536+
537+
var library = new Library();
538+
library.setUrl("http://example.org/Library/main");
539+
var libraryAdapter = (IKnowledgeArtifactAdapter) adapterFactory.createKnowledgeArtifactAdapter(library);
540+
541+
var transitiveKeySet = Set.of("http://hl7.org/fhir/ValueSet/publication-status|4.0.1");
542+
var dependency = createDependency("http://hl7.org/fhir/ValueSet/publication-status");
543+
544+
var roles = DependencyRoleClassifier.classifyDependencyRoles(
545+
dependency, libraryAdapter, null, resolver, transitiveKeySet);
546+
547+
assertTrue(
548+
roles.contains("key"), "Should match version-pinned set entry against version-stripped dep reference");
549+
}
550+
551+
@Test
552+
void testTransitiveKeyMatchesWhenDepHasVersionPinAndSetDoesNot() {
553+
// Symmetric case: set entry has no pin, dep reference does. Classifier
554+
// strips dep on lookup, so this branch already worked, but lock it down.
555+
var resolver = createResolver();
556+
557+
var library = new Library();
558+
library.setUrl("http://example.org/Library/main");
559+
var libraryAdapter = (IKnowledgeArtifactAdapter) adapterFactory.createKnowledgeArtifactAdapter(library);
560+
561+
var transitiveKeySet = Set.of("http://hl7.org/fhir/ValueSet/library-type");
562+
var dependency = createDependency("http://hl7.org/fhir/ValueSet/library-type|4.0.1");
563+
564+
var roles = DependencyRoleClassifier.classifyDependencyRoles(
565+
dependency, libraryAdapter, null, resolver, transitiveKeySet);
566+
567+
assertTrue(roles.contains("key"));
568+
}
527569
}

cqf-fhir-utility/src/main/java/org/opencds/cqf/fhir/utility/Canonicals.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,10 @@ public static String getResourceType(String canonical) {
3838
return null;
3939
}
4040

41-
canonical = canonical.replace(canonical.substring(canonical.lastIndexOf("/")), "");
41+
// Drop only the trailing /<id> segment. Using substring rather than replace,
42+
// since replace is global and would mangle self-referential URLs like
43+
// http://hl7.org/fhir/StructureDefinition/StructureDefinition.
44+
canonical = canonical.substring(0, canonical.lastIndexOf("/"));
4245
return canonical.contains("/") ? canonical.substring(canonical.lastIndexOf("/") + 1) : canonical;
4346
}
4447

cqf-fhir-utility/src/main/java/org/opencds/cqf/fhir/utility/repository/NpmRepository.java

Lines changed: 85 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package org.opencds.cqf.fhir.utility.repository;
22

33
import ca.uhn.fhir.context.FhirContext;
4+
import ca.uhn.fhir.model.api.IQueryParameterType;
5+
import ca.uhn.fhir.rest.param.UriParam;
46
import ca.uhn.fhir.rest.server.exceptions.ResourceNotFoundException;
7+
import com.google.common.collect.Multimap;
58
import java.io.File;
69
import java.io.InputStream;
710
import java.util.ArrayList;
@@ -10,13 +13,17 @@
1013
import java.util.LinkedList;
1114
import java.util.List;
1215
import java.util.Map;
16+
import java.util.Objects;
1317
import java.util.Queue;
1418
import java.util.Set;
19+
import org.hl7.fhir.instance.model.api.IBaseBundle;
1520
import org.hl7.fhir.instance.model.api.IBaseResource;
1621
import org.hl7.fhir.instance.model.api.IIdType;
1722
import org.hl7.fhir.utilities.npm.FilesystemPackageCacheManager;
1823
import org.hl7.fhir.utilities.npm.NpmPackage;
24+
import org.opencds.cqf.fhir.utility.BundleHelper;
1925
import org.opencds.cqf.fhir.utility.Canonicals;
26+
import org.opencds.cqf.fhir.utility.Ids;
2027
import org.slf4j.Logger;
2128
import org.slf4j.LoggerFactory;
2229

@@ -41,6 +48,7 @@ public class NpmRepository extends InMemoryFhirRepository {
4148
private Map<String, String> resourceTypeIndex; // canonical URL → FHIR resource type
4249
private Map<String, String> versionIndex; // canonical URL → version
4350
private Map<String, PackageInfo> packageIndex; // canonical URL → owning package info
51+
private Map<String, String> idIndex; // canonical URL → resource id (for canonical-by-URL search)
4452

4553
/** Package provenance information for a resource. */
4654
public record PackageInfo(String packageId, String version, String canonical) {}
@@ -90,6 +98,70 @@ public <T extends IBaseResource, I extends IIdType> T read(
9098
}
9199
}
92100

101+
/**
102+
* Canonical-by-URL search support: when an in-memory search comes up empty and the
103+
* search includes a {@code url} parameter, look up the URL in the package index and
104+
* trigger {@link #read} to lazy-load the resource. Then re-run the in-memory search.
105+
*
106+
* <p>Without this override, callers that resolve canonicals via search (e.g.
107+
* {@code SearchHelper.searchRepositoryByCanonicalWithPaging}) silently miss
108+
* NPM-only resources because lazy loading is only wired into {@code read()}.
109+
*/
110+
@Override
111+
public <B extends IBaseBundle, T extends IBaseResource> B search(
112+
Class<B> bundleType,
113+
Class<T> resourceType,
114+
Multimap<String, List<IQueryParameterType>> searchParameters,
115+
Map<String, String> headers) {
116+
117+
var result = super.search(bundleType, resourceType, searchParameters, headers);
118+
119+
if (!BundleHelper.getEntry(result).isEmpty() || dependsOnPackages.isEmpty()) {
120+
return result;
121+
}
122+
123+
var url = extractUrlSearchParam(searchParameters);
124+
if (url == null) {
125+
return result;
126+
}
127+
128+
ensurePackagesLoaded();
129+
130+
var indexedType = resourceTypeIndex.get(url);
131+
if (indexedType == null || !indexedType.equals(resourceType.getSimpleName())) {
132+
return result;
133+
}
134+
135+
var indexedId = idIndex.get(url);
136+
if (indexedId == null) {
137+
return result;
138+
}
139+
140+
try {
141+
IIdType id = Ids.newId(fhirContext(), indexedType, indexedId);
142+
read(resourceType, id, headers);
143+
} catch (Exception e) {
144+
logger.debug("NPM lazy-load by URL '{}' failed: {}", url, e.getMessage());
145+
return result;
146+
}
147+
148+
return super.search(bundleType, resourceType, searchParameters, headers);
149+
}
150+
151+
private static String extractUrlSearchParam(Multimap<String, List<IQueryParameterType>> searchParameters) {
152+
if (searchParameters == null || !searchParameters.containsKey("url")) {
153+
return null;
154+
}
155+
return searchParameters.get("url").stream()
156+
.flatMap(List::stream)
157+
.filter(UriParam.class::isInstance)
158+
.map(UriParam.class::cast)
159+
.map(UriParam::getValue)
160+
.filter(Objects::nonNull)
161+
.findFirst()
162+
.orElse(null);
163+
}
164+
93165
/**
94166
* Returns the loaded NPM packages, triggering lazy loading if needed.
95167
*/
@@ -142,17 +214,19 @@ private synchronized void ensurePackagesLoaded() {
142214
var typeIndex = new HashMap<String, String>();
143215
var verIndex = new HashMap<String, String>();
144216
var pkgIndex = new HashMap<String, PackageInfo>();
217+
var idMap = new HashMap<String, String>();
145218
logger.info("Loading NPM packages from {} seed(s)", dependsOnPackages.size());
146219
try {
147220
var mgr = createPackageCacheManager();
148-
loadPackagesTransitively(mgr, result, typeIndex, verIndex, pkgIndex);
221+
loadPackagesTransitively(mgr, result, typeIndex, verIndex, pkgIndex, idMap);
149222
logger.info("NPM package loading complete: {} package(s) loaded", result.size());
150223
} catch (Exception e) {
151224
logger.warn("Could not initialize NPM package cache manager: {}", e.getMessage(), e);
152225
}
153226
resourceTypeIndex = typeIndex;
154227
versionIndex = verIndex;
155228
packageIndex = pkgIndex;
229+
idIndex = idMap;
156230
loadedPackages = result;
157231
}
158232

@@ -170,7 +244,8 @@ private void loadPackagesTransitively(
170244
List<NpmPackage> result,
171245
Map<String, String> typeIndex,
172246
Map<String, String> verIndex,
173-
Map<String, PackageInfo> pkgIndex) {
247+
Map<String, PackageInfo> pkgIndex,
248+
Map<String, String> idMap) {
174249
Set<String> loaded = new HashSet<>();
175250
Queue<String[]> toLoad = new LinkedList<>(dependsOnPackages);
176251
while (!toLoad.isEmpty()) {
@@ -182,7 +257,7 @@ private void loadPackagesTransitively(
182257
if (!loaded.add(key)) {
183258
continue;
184259
}
185-
loadSinglePackage(mgr, pkgInfo, key, result, typeIndex, verIndex, pkgIndex, toLoad);
260+
loadSinglePackage(mgr, pkgInfo, key, result, typeIndex, verIndex, pkgIndex, idMap, toLoad);
186261
}
187262
}
188263

@@ -194,6 +269,7 @@ private void loadSinglePackage(
194269
Map<String, String> typeIndex,
195270
Map<String, String> verIndex,
196271
Map<String, PackageInfo> pkgIndex,
272+
Map<String, String> idMap,
197273
Queue<String[]> toLoad) {
198274
try {
199275
var npmPkg = mgr.loadPackage(pkgInfo[0], pkgInfo[1]);
@@ -204,7 +280,7 @@ private void loadSinglePackage(
204280
result.add(npmPkg);
205281
logger.info("Loaded NPM package: {}", key);
206282

207-
indexPackageResources(npmPkg, key, typeIndex, verIndex, pkgIndex);
283+
indexPackageResources(npmPkg, key, typeIndex, verIndex, pkgIndex, idMap);
208284

209285
for (var dep : npmPkg.dependencies()) {
210286
var parts = dep.split("#", 2);
@@ -222,7 +298,8 @@ private void indexPackageResources(
222298
String key,
223299
Map<String, String> typeIndex,
224300
Map<String, String> verIndex,
225-
Map<String, PackageInfo> pkgIndex) {
301+
Map<String, PackageInfo> pkgIndex,
302+
Map<String, String> idMap) {
226303
try {
227304
var owningPackage = new PackageInfo(npmPkg.id(), npmPkg.version(), npmPkg.canonical());
228305
for (var info : npmPkg.listIndexedResources()) {
@@ -232,6 +309,9 @@ private void indexPackageResources(
232309
if (info.getResourceType() != null) {
233310
typeIndex.putIfAbsent(info.getUrl(), info.getResourceType());
234311
}
312+
if (info.getId() != null) {
313+
idMap.putIfAbsent(info.getUrl(), info.getId());
314+
}
235315
// Stub CodeSystems (content: not-present) are placeholders —
236316
// their version and package provenance are not meaningful.
237317
var isStub = "CodeSystem".equals(info.getResourceType()) && "not-present".equals(info.getContent());

cqf-fhir-utility/src/test/java/org/opencds/cqf/fhir/utility/CanonicalsTest.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,17 @@ void partialCanonicalType() {
5858
assertNull(Canonicals.getFragment(testUrl));
5959
}
6060

61+
@Test
62+
void selfReferentialUrlResolvesResourceType() {
63+
// Regression: previously String.replace() stripped both occurrences of
64+
// "/StructureDefinition" and returned "fhir" for this self-referential URL.
65+
String selfRef = "http://hl7.org/fhir/StructureDefinition/StructureDefinition";
66+
67+
assertEquals("StructureDefinition", Canonicals.getResourceType(selfRef));
68+
assertEquals("StructureDefinition", Canonicals.getIdPart(selfRef));
69+
assertEquals(selfRef, Canonicals.getUrl(selfRef));
70+
}
71+
6172
@Test
6273
void canonicalParts() {
6374
CanonicalType testUrl = new CanonicalType("http://fhir.acme.com/Questionnaire/example|1.0#vs1");

0 commit comments

Comments
 (0)