Skip to content

Commit f342456

Browse files
lukedegruchyclaude
andauthored
Make opioidRec10PatientView resistant to wall-clock rot (#1018)
The test fixture's MedicationRequest.authoredOn was 2024-04-28, which slipped outside the CQL "2 years or less on or before Today()" window on 2026-04-29, breaking the test with no code change. A prior commit (395b8dd) had punted the same problem one year by bumping 2023->2024. Honor the CDC opioid-cds dataDateRoller extension already present on the fixtures: on read, compute today - dateLastUpdated and shift MedicationRequest.authoredOn plus dispenseRequest.validityPeriod start/end by that delta. Observation effective dates are intentionally not rolled - they need to stay outside the "last 12 months" window or the recommendation branch silently flips from "Annual Urine Screening Check" to "Positive Cocaine". Test-only changes; no production API touched. opioidRec10PatientView opts in via the new TestPlanDefinition.repositoryForWithDataDateRolling. Includes a unit test that walks today 2026 -> 2036 in yearly steps and asserts authoredOn stays inside the 2-year window. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ec2b6c5 commit f342456

5 files changed

Lines changed: 359 additions & 2 deletions

File tree

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
package org.opencds.cqf.fhir.cr.helpers;
2+
3+
import ca.uhn.fhir.context.FhirContext;
4+
import java.time.LocalDate;
5+
import java.time.temporal.ChronoUnit;
6+
import java.util.List;
7+
import java.util.Map;
8+
import org.hl7.fhir.instance.model.api.IBaseExtension;
9+
import org.hl7.fhir.instance.model.api.IBaseHasExtensions;
10+
import org.hl7.fhir.instance.model.api.IBaseResource;
11+
import org.hl7.fhir.instance.model.api.IPrimitiveType;
12+
13+
/**
14+
* Honors the CDC opioid-cds {@code dataDateRoller} extension on test fixtures
15+
* by shifting a resource's date fields forward so the data stays "current"
16+
* relative to today's wall clock. Without this, fixtures with hard-coded dates
17+
* eventually fall outside CQL windows like
18+
* {@code where date from Rx.authoredOn 2 years or less on or before Today()}
19+
* and tests rot.
20+
*
21+
* <p>The extension structure (see fixture JSON) is:
22+
* <pre>
23+
* extension: [{
24+
* url: "<a href="http://fhir.org/guides/cdc/opioid-cds/StructureDefinition/dataDateRoller">...</a>",
25+
* extension: [
26+
* { url: "dateLastUpdated", valueDateTime: "2023-04-28" },
27+
* { url: "frequency", valueDuration: { value: 30.0, unit: "days", ... } }
28+
* ]
29+
* }]
30+
* </pre>
31+
*
32+
* <p>This helper reads {@code dateLastUpdated}, computes
33+
* {@code offsetDays = today − dateLastUpdated}, and shifts known date fields
34+
* for each supported resource type by that many days. The {@code frequency}
35+
* sub-extension is informational and not currently used. After rolling, the
36+
* {@code dataDateRoller} extension is stripped so subsequent reads are no-ops
37+
* (idempotent).
38+
*/
39+
public final class DataDateRollerHelper {
40+
41+
public static final String EXT_URL = "http://fhir.org/guides/cdc/opioid-cds/StructureDefinition/dataDateRoller";
42+
public static final String EXT_DATE_LAST_UPDATED = "dateLastUpdated";
43+
44+
/**
45+
* Date fields we shift forward when a resource carries the
46+
* {@code dataDateRoller} extension. Intentionally narrow:
47+
* <ul>
48+
* <li>{@code MedicationRequest} dates are rolled because CQL "active
49+
* prescription" windows like
50+
* {@code where date from Rx.authoredOn 2 years or less on or before Today()}
51+
* require them to stay near "now".</li>
52+
* <li>{@code Observation} dates are NOT rolled. The opioid-cds tests
53+
* depend on their historical observations staying outside windows
54+
* like "in last 12 months" — rolling them forward would silently
55+
* flip the recommendation branch (e.g. trigger "Positive Cocaine"
56+
* instead of "Annual Urine Screening Check"). The fixtures keep
57+
* the {@code dataDateRoller} extension for documentation, but the
58+
* helper ignores it on Observations.</li>
59+
* <li>{@code Patient.birthDate} is not rolled either; patient age is
60+
* not gated by these tests.</li>
61+
* </ul>
62+
* Add a resource type here only when a test's CQL has a wall-clock
63+
* window that needs the data to stay current.
64+
*/
65+
private static final Map<String, List<String>> ROLLABLE_PATHS = Map.of(
66+
"MedicationRequest",
67+
List.of("authoredOn", "dispenseRequest.validityPeriod.start", "dispenseRequest.validityPeriod.end"));
68+
69+
private DataDateRollerHelper() {}
70+
71+
/**
72+
* If the resource carries a {@code dataDateRoller} extension, shift its date
73+
* fields forward by {@code today − dateLastUpdated} days and strip the
74+
* extension. No-op for resources without the extension or whose type is not
75+
* in {@link #ROLLABLE_PATHS}.
76+
*/
77+
public static void rollIfAnnotated(IBaseResource resource, LocalDate today, FhirContext ctx) {
78+
if (!(resource instanceof IBaseHasExtensions hasExtensions)) {
79+
return;
80+
}
81+
IBaseExtension<?, ?> rollerExt = findExtension(hasExtensions, EXT_URL);
82+
if (rollerExt == null) {
83+
return;
84+
}
85+
IBaseExtension<?, ?> anchorExt = findExtension(rollerExt, EXT_DATE_LAST_UPDATED);
86+
if (anchorExt == null || !(anchorExt.getValue() instanceof IPrimitiveType<?> anchorPrim)) {
87+
return;
88+
}
89+
LocalDate anchor = toLocalDate(anchorPrim);
90+
if (anchor == null) {
91+
return;
92+
}
93+
long offsetDays = ChronoUnit.DAYS.between(anchor, today);
94+
if (offsetDays != 0) {
95+
List<String> paths = ROLLABLE_PATHS.get(resource.fhirType());
96+
if (paths != null) {
97+
paths.forEach(path -> rollAtPath(resource, path, offsetDays, ctx));
98+
}
99+
// Resource types not in ROLLABLE_PATHS are intentionally left alone
100+
// — see ROLLABLE_PATHS Javadoc.
101+
}
102+
// Idempotency: subsequent reads of the same cached resource skip rolling.
103+
hasExtensions.getExtension().removeIf(e -> EXT_URL.equals(e.getUrl()));
104+
}
105+
106+
private static IBaseExtension<?, ?> findExtension(IBaseHasExtensions container, String url) {
107+
for (var ext : container.getExtension()) {
108+
if (url.equals(ext.getUrl())) {
109+
return ext;
110+
}
111+
}
112+
return null;
113+
}
114+
115+
@SuppressWarnings("rawtypes")
116+
private static IBaseExtension<?, ?> findExtension(IBaseExtension<?, ?> parent, String url) {
117+
for (Object raw : parent.getExtension()) {
118+
if (raw instanceof IBaseExtension ext && url.equals(ext.getUrl())) {
119+
return ext;
120+
}
121+
}
122+
return null;
123+
}
124+
125+
private static void rollAtPath(IBaseResource resource, String path, long offsetDays, FhirContext ctx) {
126+
List<IPrimitiveType> primitives;
127+
try {
128+
primitives = ctx.newTerser().getValues(resource, path, IPrimitiveType.class);
129+
} catch (RuntimeException e) {
130+
// Path not valid for this FHIR version (e.g. effectiveInstant on DSTU3). Skip.
131+
return;
132+
}
133+
for (IPrimitiveType<?> primitive : primitives) {
134+
rollPrimitive(primitive, offsetDays);
135+
}
136+
}
137+
138+
private static void rollPrimitive(IPrimitiveType<?> primitive, long offsetDays) {
139+
// Always go through the string form to avoid timezone/DST off-by-one errors
140+
// that arise when shifting a wall-clock Date through java.time.Instant.
141+
String stringValue = primitive.getValueAsString();
142+
if (stringValue == null || stringValue.length() < 10) {
143+
return;
144+
}
145+
try {
146+
LocalDate shiftedDate =
147+
LocalDate.parse(stringValue.substring(0, 10)).plusDays(offsetDays);
148+
String tail = stringValue.substring(10);
149+
primitive.setValueAsString(shiftedDate + tail);
150+
} catch (RuntimeException ignored) {
151+
// Non-date string (shouldn't happen for date fields), leave as-is.
152+
}
153+
}
154+
155+
private static LocalDate toLocalDate(IPrimitiveType<?> primitive) {
156+
String stringValue = primitive.getValueAsString();
157+
if (stringValue == null || stringValue.length() < 10) {
158+
return null;
159+
}
160+
try {
161+
return LocalDate.parse(stringValue.substring(0, 10));
162+
} catch (RuntimeException e) {
163+
return null;
164+
}
165+
}
166+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package org.opencds.cqf.fhir.cr.helpers;
2+
3+
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
import static org.junit.jupiter.api.Assertions.assertFalse;
5+
import static org.junit.jupiter.api.Assertions.assertTrue;
6+
7+
import ca.uhn.fhir.context.FhirContext;
8+
import java.time.LocalDate;
9+
import java.time.ZoneId;
10+
import java.util.Date;
11+
import org.hl7.fhir.r4.model.DateTimeType;
12+
import org.hl7.fhir.r4.model.Extension;
13+
import org.hl7.fhir.r4.model.MedicationRequest;
14+
import org.hl7.fhir.r4.model.Period;
15+
import org.junit.jupiter.api.Test;
16+
17+
class DataDateRollerHelperTest {
18+
19+
private static final FhirContext FHIR_CONTEXT_R4 = FhirContext.forR4Cached();
20+
21+
@Test
22+
void rollsMedicationRequestAuthoredOnByOffset() {
23+
var anchor = LocalDate.of(2023, 4, 28);
24+
var today = LocalDate.of(2030, 1, 1);
25+
var originalAuthoredOn = LocalDate.of(2023, 4, 28);
26+
27+
var medicationRequest = newMedicationRequestWithRoller(anchor, originalAuthoredOn, null, null);
28+
29+
DataDateRollerHelper.rollIfAnnotated(medicationRequest, today, FHIR_CONTEXT_R4);
30+
31+
assertEquals(today, dateOf(medicationRequest.getAuthoredOnElement()));
32+
}
33+
34+
@Test
35+
void rollsValidityPeriodStartAndEnd() {
36+
var anchor = LocalDate.of(2023, 4, 28);
37+
var today = LocalDate.of(2030, 1, 1);
38+
39+
var medicationRequest = newMedicationRequestWithRoller(
40+
anchor, LocalDate.of(2023, 4, 28), LocalDate.of(2023, 4, 28), LocalDate.of(2023, 7, 28));
41+
42+
DataDateRollerHelper.rollIfAnnotated(medicationRequest, today, FHIR_CONTEXT_R4);
43+
44+
var validityPeriod = medicationRequest.getDispenseRequest().getValidityPeriod();
45+
assertEquals(today, dateOf(validityPeriod.getStartElement()));
46+
// end was 91 days after anchor; should land 91 days after today.
47+
assertEquals(today.plusDays(91), dateOf(validityPeriod.getEndElement()));
48+
}
49+
50+
@Test
51+
void stripsRollerExtensionAfterRollingForIdempotency() {
52+
var anchor = LocalDate.of(2023, 4, 28);
53+
var medicationRequest = newMedicationRequestWithRoller(anchor, LocalDate.of(2024, 4, 28), null, null);
54+
55+
DataDateRollerHelper.rollIfAnnotated(medicationRequest, LocalDate.of(2030, 1, 1), FHIR_CONTEXT_R4);
56+
57+
var hasRollerExt = medicationRequest.getExtension().stream()
58+
.anyMatch(e -> DataDateRollerHelper.EXT_URL.equals(e.getUrl()));
59+
assertFalse(hasRollerExt, "dataDateRoller extension should be stripped after rolling");
60+
}
61+
62+
@Test
63+
void noOpForResourceWithoutRollerExtension() {
64+
var medicationRequest = new MedicationRequest();
65+
medicationRequest.setId("MedicationRequest/no-roller");
66+
medicationRequest.setAuthoredOnElement(new DateTimeType("2024-04-28"));
67+
68+
DataDateRollerHelper.rollIfAnnotated(medicationRequest, LocalDate.of(2030, 1, 1), FHIR_CONTEXT_R4);
69+
70+
assertEquals(LocalDate.of(2024, 4, 28), dateOf(medicationRequest.getAuthoredOnElement()));
71+
}
72+
73+
@Test
74+
void timeTravelSanityKeepsAuthoredOnInTwoYearWindow() {
75+
// Walk a few years into the future and confirm the rolled authoredOn
76+
// never falls outside the 2-year window CQL expects.
77+
var anchor = LocalDate.of(2023, 4, 28);
78+
var originalAuthoredOn = LocalDate.of(2023, 4, 28);
79+
80+
for (int yearOffset = 0; yearOffset <= 10; yearOffset++) {
81+
var today = LocalDate.of(2026 + yearOffset, 6, 15);
82+
var medicationRequest = newMedicationRequestWithRoller(anchor, originalAuthoredOn, null, null);
83+
84+
DataDateRollerHelper.rollIfAnnotated(medicationRequest, today, FHIR_CONTEXT_R4);
85+
86+
var rolled = dateOf(medicationRequest.getAuthoredOnElement());
87+
assertTrue(
88+
!rolled.isBefore(today.minusYears(2)) && !rolled.isAfter(today),
89+
"Rolled authoredOn (%s) must be within 2 years on or before today (%s)".formatted(rolled, today));
90+
}
91+
}
92+
93+
private static MedicationRequest newMedicationRequestWithRoller(
94+
LocalDate anchor, LocalDate authoredOn, LocalDate validityStart, LocalDate validityEnd) {
95+
var medicationRequest = new MedicationRequest();
96+
medicationRequest.setId("MedicationRequest/test-rolling");
97+
medicationRequest.setAuthoredOnElement(new DateTimeType(authoredOn.toString()));
98+
if (validityStart != null || validityEnd != null) {
99+
var validityPeriod = new Period();
100+
if (validityStart != null) {
101+
validityPeriod.setStartElement(new DateTimeType(validityStart.toString()));
102+
}
103+
if (validityEnd != null) {
104+
validityPeriod.setEndElement(new DateTimeType(validityEnd.toString()));
105+
}
106+
medicationRequest.getDispenseRequest().setValidityPeriod(validityPeriod);
107+
}
108+
var rollerExtension = new Extension(DataDateRollerHelper.EXT_URL);
109+
rollerExtension.addExtension(
110+
new Extension(DataDateRollerHelper.EXT_DATE_LAST_UPDATED, new DateTimeType(anchor.toString())));
111+
medicationRequest.addExtension(rollerExtension);
112+
return medicationRequest;
113+
}
114+
115+
private static LocalDate dateOf(DateTimeType primitive) {
116+
Date value = primitive.getValue();
117+
return value.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
118+
}
119+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package org.opencds.cqf.fhir.cr.helpers;
2+
3+
import ca.uhn.fhir.context.FhirContext;
4+
import ca.uhn.fhir.model.api.IQueryParameterType;
5+
import ca.uhn.fhir.util.BundleUtil;
6+
import com.google.common.collect.Multimap;
7+
import java.nio.file.Path;
8+
import java.time.LocalDate;
9+
import java.util.List;
10+
import java.util.Map;
11+
import org.hl7.fhir.instance.model.api.IBaseBundle;
12+
import org.hl7.fhir.instance.model.api.IBaseResource;
13+
import org.hl7.fhir.instance.model.api.IIdType;
14+
import org.opencds.cqf.fhir.utility.repository.ig.IgRepository;
15+
16+
/**
17+
* An {@link IgRepository} that applies the CDC opioid-cds {@code dataDateRoller}
18+
* extension to resources on read/search. See {@link DataDateRollerHelper}.
19+
*
20+
* <p>Test-only: lets opioid-cds fixtures with hard-coded anchor dates stay
21+
* "current" relative to today's wall clock, so that CQL windows like
22+
* {@code 2 years or less on or before Today()} remain satisfied without
23+
* yearly fixture date bumps.
24+
*/
25+
public class DataDateRollingIgRepository extends IgRepository {
26+
27+
private final LocalDate today;
28+
29+
public DataDateRollingIgRepository(FhirContext fhirContext, Path root, LocalDate today) {
30+
super(fhirContext, root);
31+
this.today = today;
32+
}
33+
34+
@Override
35+
public <T extends IBaseResource, I extends IIdType> T read(
36+
Class<T> resourceType, I id, Map<String, String> headers) {
37+
T resource = super.read(resourceType, id, headers);
38+
DataDateRollerHelper.rollIfAnnotated(resource, today, fhirContext());
39+
return resource;
40+
}
41+
42+
@Override
43+
public <B extends IBaseBundle, T extends IBaseResource> B search(
44+
Class<B> bundleType,
45+
Class<T> resourceType,
46+
Multimap<String, List<IQueryParameterType>> searchParameters,
47+
Map<String, String> headers) {
48+
B bundle = super.search(bundleType, resourceType, searchParameters, headers);
49+
for (var entry : BundleUtil.toListOfResources(fhirContext(), bundle)) {
50+
DataDateRollerHelper.rollIfAnnotated(entry, today, fhirContext());
51+
}
52+
return bundle;
53+
}
54+
}

cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/plandefinition/PlanDefinitionProcessorTests.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -260,14 +260,14 @@ void opioidRec10PatientView() {
260260
var planDefinitionID = "opioidcds-10-patient-view";
261261
var patientID = "example-rec-10-patient-view-POS-Cocaine-drugs";
262262
var encounterID = "example-rec-10-patient-view-POS-Cocaine-drugs-prefetch";
263-
given().repositoryFor(fhirContextR4, "r4/opioid-Rec10-patient-view")
263+
given().repositoryForWithDataDateRolling(fhirContextR4, "r4/opioid-Rec10-patient-view")
264264
.when()
265265
.planDefinitionId(planDefinitionID)
266266
.subjectId(patientID)
267267
.encounterId(encounterID)
268268
.thenApply()
269269
.isEqualsTo(new org.hl7.fhir.r4.model.IdType("CarePlan", planDefinitionID));
270-
given().repositoryFor(fhirContextR4, "r4/opioid-Rec10-patient-view")
270+
given().repositoryForWithDataDateRolling(fhirContextR4, "r4/opioid-Rec10-patient-view")
271271
.when()
272272
.planDefinitionId(planDefinitionID)
273273
.subjectId(patientID)

cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/plandefinition/TestPlanDefinition.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import java.io.InputStream;
2828
import java.nio.charset.StandardCharsets;
2929
import java.nio.file.Path;
30+
import java.time.LocalDate;
3031
import java.util.ArrayList;
3132
import java.util.HashMap;
3233
import java.util.List;
@@ -44,6 +45,7 @@
4445
import org.opencds.cqf.fhir.cr.CrSettings;
4546
import org.opencds.cqf.fhir.cr.TestOperationProvider;
4647
import org.opencds.cqf.fhir.cr.common.IOperationProcessor;
48+
import org.opencds.cqf.fhir.cr.helpers.DataDateRollingIgRepository;
4749
import org.opencds.cqf.fhir.cr.helpers.DataRequirementsLibrary;
4850
import org.opencds.cqf.fhir.cr.helpers.GeneratedPackage;
4951
import org.opencds.cqf.fhir.utility.Ids;
@@ -97,6 +99,22 @@ public Given repositoryFor(FhirContext fhirContext, String repositoryPath) {
9799
return this;
98100
}
99101

102+
/**
103+
* Like {@link #repositoryFor(FhirContext, String)} but applies the CDC
104+
* {@code dataDateRoller} extension to fixtures on read/search. Use this
105+
* for tests whose CQL has wall-clock-relative windows
106+
* (e.g. {@code Today() - 12 months}) so the data stays inside those
107+
* windows regardless of when the test runs. See
108+
* {@link org.opencds.cqf.fhir.cr.helpers.DataDateRollerHelper}.
109+
*/
110+
public Given repositoryForWithDataDateRolling(FhirContext fhirContext, String repositoryPath) {
111+
this.repository = new DataDateRollingIgRepository(
112+
fhirContext,
113+
Path.of(getResourcePath(this.getClass()) + "/" + CLASS_PATH + "/" + repositoryPath),
114+
LocalDate.now());
115+
return this;
116+
}
117+
100118
public Given evaluationSettings(EvaluationSettings evaluationSettings) {
101119
this.evaluationSettings = evaluationSettings;
102120
return this;

0 commit comments

Comments
 (0)