• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

openmrs / openmrs-core / 24161934390

08 Apr 2026 10:26PM UTC coverage: 63.378% (+0.1%) from 63.244%
24161934390

push

github

ibacher
TRUNK-6610: Improve the Concept Reference Range code (#6024)

This is a backport slightly re-written to account for differences from
Platform 3 and Platform 2

101 of 109 new or added lines in 1 file covered. (92.66%)

1 existing line in 1 file now uncovered.

23252 of 36688 relevant lines covered (63.38%)

0.63 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

90.55
/api/src/main/java/org/openmrs/util/ConceptReferenceRangeUtility.java
1
/**
2
 * This Source Code Form is subject to the terms of the Mozilla Public License,
3
 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
4
 * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under
5
 * the terms of the Healthcare Disclaimer located at http://openmrs.org/license.
6
 *
7
 * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS
8
 * graphic logo is a trademark of OpenMRS Inc.
9
 */
10
package org.openmrs.util;
11

12
import java.time.LocalDate;
13
import java.time.ZoneId;
14
import java.time.temporal.ChronoUnit;
15
import java.util.ArrayList;
16
import java.util.Collections;
17
import java.util.Date;
18
import java.util.HashMap;
19
import java.util.List;
20
import java.util.Locale;
21
import java.util.Map;
22
import java.util.concurrent.TimeUnit;
23

24
import org.apache.commons.lang3.StringUtils;
25
import org.joda.time.LocalTime;
26
import org.openmrs.Concept;
27
import org.openmrs.ConceptReferenceRangeContext;
28
import org.openmrs.Obs;
29
import org.openmrs.Patient;
30
import org.openmrs.PatientProgram;
31
import org.openmrs.PatientState;
32
import org.openmrs.Person;
33
import org.openmrs.api.APIException;
34
import org.openmrs.api.context.Context;
35
import org.openmrs.api.db.hibernate.HibernateUtil;
36
import org.springframework.expression.Expression;
37
import org.springframework.expression.ExpressionParser;
38
import org.springframework.expression.spel.SpelEvaluationException;
39
import org.springframework.expression.spel.SpelMessage;
40
import org.springframework.expression.spel.standard.SpelExpressionParser;
41
import org.springframework.expression.spel.support.DataBindingMethodResolver;
42
import org.springframework.expression.spel.support.DataBindingPropertyAccessor;
43
import org.springframework.context.expression.MapAccessor;
44
import org.springframework.expression.spel.support.SimpleEvaluationContext;
45

46
import com.github.benmanes.caffeine.cache.Cache;
47
import com.github.benmanes.caffeine.cache.Caffeine;
48

49
/**
50
 * A utility class that evaluates the concept ranges
51
 *
52
 * @since 2.7.0
53
 */
54
public class ConceptReferenceRangeUtility {
55

56
        /**
57
         * A local-only cache for expressions, which should alleviate parsing overhead in hot loops, i.e.,
58
         * if the same expressions are evaluated multiple times within a relatively short succession.
59
         * Expires each element 5 minutes after its last access.
60
         */
61
        private static final Cache<String, Expression> EXPRESSION_CACHE = Caffeine.newBuilder().maximumSize(20000)
1✔
62
                .expireAfterAccess(5, TimeUnit.MINUTES).build();
1✔
63

64
        /**
65
         * {@link ExpressionParser} instance used by the {@link ConceptReferenceRangeUtility} to parse
66
         * expressions
67
         */
68
        private static final ExpressionParser PARSER = new SpelExpressionParser();
1✔
69

70
        /**
71
         * Static {@link org.springframework.expression.EvaluationContext} which is used to run evaluations.
72
         * This class is thread-safe, so shareable.
73
         */
74
        private static final SimpleEvaluationContext EVAL_CONTEXT = SimpleEvaluationContext
1✔
75
                .forPropertyAccessors(new MapAccessor(), DataBindingPropertyAccessor.forReadOnlyAccess())
1✔
76
                .withMethodResolvers(DataBindingMethodResolver.forInstanceMethodInvocation()).build();
1✔
77

78
        private final CriteriaFunctions functions = new CriteriaFunctions();
1✔
79

80
        public ConceptReferenceRangeUtility() {
1✔
81
        }
1✔
82

83
        /**
84
         * This method evaluates the given criteria against the provided {@link Obs}.
85
         *
86
         * @param criteria the criteria string to evaluate e.g. "$patient.getAge() > 1"
87
         * @param obs The observation (Obs) object containing the values to be used in the criteria
88
         *            evaluation.
89
         * @return true if the criteria evaluates to true, false otherwise
90
         */
91
        public boolean evaluateCriteria(String criteria, Obs obs) {
92
                if (obs == null) {
1✔
93
                        throw new IllegalArgumentException("Failed to evaluate criteria with reason: Obs is null");
1✔
94
                }
95

96
                if (obs.getPerson() == null) {
1✔
97
                        throw new IllegalArgumentException("Failed to evaluate criteria with reason: patient is null");
×
98
                }
99

100
                if (StringUtils.isBlank(criteria)) {
1✔
101
                        throw new IllegalArgumentException("Failed to evaluate criteria with reason: criteria is empty");
1✔
102
                }
103

104
                return evaluateCriteria(criteria, new ConceptReferenceRangeContext(obs));
1✔
105
        }
106

107
        /**
108
         * Evaluates criteria against a {@link ConceptReferenceRangeContext}. When the context was
109
         * constructed from an Obs, {@code $obs} is available in the expression; otherwise only
110
         * {@code $patient}, {@code $fn}, {@code $context}, {@code $date}, and {@code $encounter} are
111
         * available.
112
         *
113
         * @param criteria the criteria string to evaluate
114
         * @param context the evaluation context
115
         * @return true if the criteria evaluates to true, false otherwise
116
         * @since 3.0.0, 2.9.0, 2.8.5, 2.7.9
117
         */
118
        public boolean evaluateCriteria(String criteria, ConceptReferenceRangeContext context) {
119
                if (context == null) {
1✔
120
                        throw new IllegalArgumentException("Failed to evaluate criteria with reason: context is null");
×
121
                }
122

123
                if (context.getPerson() == null) {
1✔
124
                        throw new IllegalArgumentException("Failed to evaluate criteria with reason: patient is null");
×
125
                }
126

127
                if (StringUtils.isBlank(criteria)) {
1✔
128
                        throw new IllegalArgumentException("Failed to evaluate criteria with reason: criteria is empty");
×
129
                }
130

131
                Map<String, Object> root = new HashMap<>();
1✔
132
                root.put("$fn", functions);
1✔
133
                root.put("$patient", HibernateUtil.getRealObjectFromProxy(context.getPerson()));
1✔
134
                root.put("$context", context);
1✔
135
                root.put("$obs", context.getObs());
1✔
136
                root.put("$encounter", context.getEncounter());
1✔
137
                root.put("$date", context.getDate());
1✔
138

139
                try {
140
                        Expression expression = EXPRESSION_CACHE.get(criteria, PARSER::parseExpression);
1✔
141
                        Boolean result = expression.getValue(EVAL_CONTEXT, root, Boolean.class);
1✔
142
                        return result != null && result;
1✔
143
                } catch (SpelEvaluationException e) {
1✔
144
                        SpelMessage msg = e.getMessageCode();
1✔
145
                        if (msg == SpelMessage.METHOD_CALL_ON_NULL_OBJECT_NOT_ALLOWED
1✔
146
                                || msg == SpelMessage.PROPERTY_OR_FIELD_NOT_READABLE_ON_NULL) {
147
                                return false;
1✔
148
                        }
149
                        throw new APIException("An error occurred while evaluating criteria: " + criteria, e);
1✔
NEW
150
                } catch (Exception e) {
×
NEW
151
                        throw new APIException("An error occurred while evaluating criteria: " + criteria, e);
×
152
                }
153
        }
154

155
        /**
156
         * Helper functions available as {@code $fn} in concept reference range criteria expressions.
157
         * <p>
158
         * This class is intentionally separate from the outer class so that {@code evaluateCriteria} is not
159
         * callable from within expressions.
160
         *
161
         * @since 2.7.9, 2.8.6, 2.9.0, 3.0.0
162
         */
163
        static class CriteriaFunctions {
1✔
164

165
                private final long NULL_DATE_RETURN_VALUE = -1;
1✔
166

167
                /**
168
                 * Gets the latest Obs by concept.
169
                 *
170
                 * @param conceptRef can be either concept uuid or conceptMap's code and sourceName e.g
171
                 *            "bac25fd5-c143-4e43-bffe-4eb1e7efb6ce" or "CIEL:1434"
172
                 * @param person person to get obs for
173
                 * @return Obs latest Obs
174
                 */
175
                public Obs getLatestObs(String conceptRef, Person person) {
176
                        if (person == null) {
1✔
NEW
177
                                return null;
×
178
                        }
179
                        Concept concept = Context.getConceptService().getConceptByReference(conceptRef);
1✔
180

181
                        if (concept != null) {
1✔
182
                                List<Obs> observations = Context.getObsService().getObservations(Collections.singletonList(person), null,
1✔
183
                                    Collections.singletonList(concept), null, null, null, Collections.singletonList("dateCreated"), 1, null,
1✔
184
                                    null, null, false);
185

186
                                return observations.isEmpty() ? null : observations.get(0);
1✔
187
                        }
188

189
                        return null;
1✔
190
                }
191

192
                /**
193
                 * Gets the time of the day in hours.
194
                 *
195
                 * @return the hour of the day in 24hr format (e.g. 14 to mean 2pm)
196
                 */
197
                public int getCurrentHour() {
198
                        return LocalTime.now().getHourOfDay();
1✔
199
                }
200

201
                /**
202
                 * Retrieves the most relevant Obs for the given current Obs and conceptRef. If the current Obs
203
                 * contains a valid value (coded, numeric, date, text etc.) and the concept in Obs is the same as
204
                 * the supplied concept, the method returns the current Obs. Otherwise, it fetches the latest Obs
205
                 * for the supplied concept and patient.
206
                 *
207
                 * @param conceptRef can be either concept uuid or conceptMap's code and sourceName
208
                 * @param currentObs the current Obs being evaluated
209
                 * @return the most relevant Obs based on the current Obs, or the latest Obs if the current one has
210
                 *         no valid value
211
                 */
212
                public Obs getCurrentObs(String conceptRef, Obs currentObs) {
213
                        Concept concept = Context.getConceptService().getConceptByReference(conceptRef);
1✔
214

215
                        if (concept != null && concept.equals(currentObs.getConcept())
1✔
216
                                && !currentObs.getValueAsString(Locale.ENGLISH).isEmpty()) {
1✔
217
                                return currentObs;
1✔
218
                        } else {
219
                                return getLatestObs(conceptRef, currentObs.getPerson());
1✔
220
                        }
221
                }
222

223
                /**
224
                 * Gets the person's latest observation date for a given concept
225
                 *
226
                 * @param conceptRef can be either concept uuid or conceptMap's code and sourceName e.g
227
                 *            "bac25fd5-c143-4e43-bffe-4eb1e7efb6ce" or "CIEL:1434"
228
                 * @param person the person
229
                 * @return the observation date
230
                 * @since 2.7.0
231
                 */
232
                public Date getLatestObsDate(String conceptRef, Person person) {
233
                        Obs obs = getLatestObs(conceptRef, person);
1✔
234
                        if (obs == null) {
1✔
235
                                return null;
1✔
236
                        }
237

238
                        Date date = obs.getValueDate();
1✔
239
                        if (date == null) {
1✔
240
                                date = obs.getValueDatetime();
1✔
241
                        }
242

243
                        return date;
1✔
244
                }
245

246
                /**
247
                 * Checks if an observation's value coded answer is equal to a given concept
248
                 *
249
                 * @param conceptRef can be either concept uuid or conceptMap's code and sourceName e.g
250
                 *            "bac25fd5-c143-4e43-bffe-4eb1e7efb6ce" or "CIEL:1434" for the observation's question
251
                 * @param person the person
252
                 * @param answerConceptRef can be either concept uuid or conceptMap's code and sourceName for the
253
                 *            observation's coded answer
254
                 * @return true if the given concept is equal to the observation's value coded answer
255
                 * @since 2.7.0
256
                 */
257
                public boolean isObsValueCodedAnswer(String conceptRef, Person person, String answerConceptRef) {
258
                        Obs obs = getLatestObs(conceptRef, person);
1✔
259
                        if (obs == null) {
1✔
260
                                return false;
1✔
261
                        }
262

263
                        Concept valueCoded = obs.getValueCoded();
1✔
264
                        if (valueCoded == null) {
1✔
NEW
265
                                return false;
×
266
                        }
267

268
                        Concept answerConcept = Context.getConceptService().getConceptByReference(answerConceptRef);
1✔
269
                        if (answerConcept == null) {
1✔
NEW
270
                                return false;
×
271
                        }
272

273
                        return valueCoded.equals(answerConcept);
1✔
274
                }
275

276
                /**
277
                 * Gets the number of days from the person's latest observation date value for a given concept to
278
                 * the current date
279
                 *
280
                 * @param conceptRef concept uuid or conceptMap code and sourceName
281
                 * @param person the person
282
                 * @return the number of days
283
                 * @since 2.7.0
284
                 */
285
                public long getObsDays(String conceptRef, Person person) {
286
                        Date date = getLatestObsDate(conceptRef, person);
1✔
287
                        if (date == null) {
1✔
288
                                return NULL_DATE_RETURN_VALUE;
1✔
289
                        }
290
                        return getDays(date);
1✔
291
                }
292

293
                /**
294
                 * Gets the number of weeks from the person's latest observation date value for a given concept to
295
                 * the current date
296
                 *
297
                 * @param conceptRef concept uuid or conceptMap code and sourceName
298
                 * @param person the person
299
                 * @return the number of weeks
300
                 * @since 2.7.0
301
                 */
302
                public long getObsWeeks(String conceptRef, Person person) {
303
                        Date date = getLatestObsDate(conceptRef, person);
1✔
304
                        if (date == null) {
1✔
305
                                return NULL_DATE_RETURN_VALUE;
1✔
306
                        }
307
                        return getWeeks(date);
1✔
308
                }
309

310
                /**
311
                 * Gets the number of months from the person's latest observation date value for a given concept to
312
                 * the current date
313
                 *
314
                 * @param conceptRef concept uuid or conceptMap code and sourceName
315
                 * @param person the person
316
                 * @return the number of months
317
                 * @since 2.7.0
318
                 */
319
                public long getObsMonths(String conceptRef, Person person) {
320
                        Date date = getLatestObsDate(conceptRef, person);
1✔
321
                        if (date == null) {
1✔
322
                                return NULL_DATE_RETURN_VALUE;
1✔
323
                        }
324
                        return getMonths(date);
1✔
325
                }
326

327
                /**
328
                 * Gets the number of years from the person's latest observation date value for a given concept to
329
                 * the current date
330
                 *
331
                 * @param conceptRef concept uuid or conceptMap code and sourceName
332
                 * @param person the person
333
                 * @return the number of years
334
                 * @since 2.7.0
335
                 */
336
                public long getObsYears(String conceptRef, Person person) {
337
                        Date date = getLatestObsDate(conceptRef, person);
1✔
338
                        if (date == null) {
1✔
339
                                return NULL_DATE_RETURN_VALUE;
1✔
340
                        }
341
                        return getYears(date);
1✔
342
                }
343

344
                /**
345
                 * Gets the number of days between two given dates
346
                 *
347
                 * @param fromDate the date from which to start counting
348
                 * @param toDate the date up to which to stop counting
349
                 * @return the number of days between
350
                 * @since 2.7.0
351
                 */
352
                public long getDaysBetween(Date fromDate, Date toDate) {
353
                        if (fromDate == null || toDate == null) {
1✔
354
                                return NULL_DATE_RETURN_VALUE;
1✔
355
                        }
356
                        return ChronoUnit.DAYS.between(toLocalDate(fromDate), toLocalDate(toDate));
1✔
357
                }
358

359
                /**
360
                 * Gets the number of weeks between two given dates
361
                 *
362
                 * @param fromDate the date from which to start counting
363
                 * @param toDate the date up to which to stop counting
364
                 * @return the number of weeks between
365
                 * @since 2.7.0
366
                 */
367
                public long getWeeksBetween(Date fromDate, Date toDate) {
368
                        if (fromDate == null || toDate == null) {
1✔
369
                                return NULL_DATE_RETURN_VALUE;
1✔
370
                        }
371
                        return ChronoUnit.WEEKS.between(toLocalDate(fromDate), toLocalDate(toDate));
1✔
372
                }
373

374
                /**
375
                 * Gets the number of months between two given dates
376
                 *
377
                 * @param fromDate the date from which to start counting
378
                 * @param toDate the date up to which to stop counting
379
                 * @return the number of months between
380
                 * @since 2.7.0
381
                 */
382
                public long getMonthsBetween(Date fromDate, Date toDate) {
383
                        if (fromDate == null || toDate == null) {
1✔
384
                                return NULL_DATE_RETURN_VALUE;
1✔
385
                        }
386
                        return ChronoUnit.MONTHS.between(toLocalDate(fromDate), toLocalDate(toDate));
1✔
387
                }
388

389
                /**
390
                 * Gets the number of years between two given dates
391
                 *
392
                 * @param fromDate the date from which to start counting
393
                 * @param toDate the date up to which to stop counting
394
                 * @return the number of years between
395
                 * @since 2.7.0
396
                 */
397
                public long getYearsBetween(Date fromDate, Date toDate) {
398
                        if (fromDate == null || toDate == null) {
1✔
399
                                return NULL_DATE_RETURN_VALUE;
1✔
400
                        }
401
                        return ChronoUnit.YEARS.between(toLocalDate(fromDate), toLocalDate(toDate));
1✔
402
                }
403

404
                /**
405
                 * Gets the number of days from a given date up to the current date.
406
                 *
407
                 * @param fromDate the date from which to start counting
408
                 * @return the number of days
409
                 * @since 2.7.0
410
                 */
411
                public long getDays(Date fromDate) {
412
                        return getDaysBetween(fromDate, new Date());
1✔
413
                }
414

415
                /**
416
                 * Gets the number of weeks from a given date up to the current date.
417
                 *
418
                 * @param fromDate the date from which to start counting
419
                 * @return the number of weeks
420
                 * @since 2.7.0
421
                 */
422
                public long getWeeks(Date fromDate) {
423
                        return getWeeksBetween(fromDate, new Date());
1✔
424
                }
425

426
                /**
427
                 * Gets the number of months from a given date up to the current date.
428
                 *
429
                 * @param fromDate the date from which to start counting
430
                 * @return the number of months
431
                 * @since 2.7.0
432
                 */
433
                public long getMonths(Date fromDate) {
434
                        return getMonthsBetween(fromDate, new Date());
1✔
435
                }
436

437
                /**
438
                 * Gets the number of years from a given date up to the current date.
439
                 *
440
                 * @param fromDate the date from which to start counting
441
                 * @return the number of years
442
                 * @since 2.7.0
443
                 */
444
                public long getYears(Date fromDate) {
445
                        return getYearsBetween(fromDate, new Date());
1✔
446
                }
447

448
                /**
449
                 * Returns whether the patient is the specified program on the specified date
450
                 *
451
                 * @param uuid of program
452
                 * @param person the patient to test
453
                 * @param onDate the date to test whether the patient is in the program
454
                 * @return true if the patient is in the program on the specified date, false otherwise
455
                 * @since 2.7.0
456
                 */
457
                public boolean isEnrolledInProgram(String uuid, Person person, Date onDate) {
458
                        if (person == null) {
1✔
NEW
459
                                return false;
×
460
                        }
461
                        if (!(person.getIsPatient())) {
1✔
462
                                return false;
1✔
463
                        }
464
                        return getPatientPrograms((Patient) person, onDate).stream()
1✔
465
                                .anyMatch(pp -> pp.getProgram().getUuid().equals(uuid));
1✔
466
                }
467

468
                /**
469
                 * Returns whether the patient is the specified program state on the specified date
470
                 *
471
                 * @param uuid of program state
472
                 * @param person the patient to test
473
                 * @param onDate the date to test whether the patient is in the program state
474
                 * @return true if the patient is in the program state on the specified date, false otherwise
475
                 * @since 2.7.0
476
                 */
477
                public boolean isInProgramState(String uuid, Person person, Date onDate) {
478
                        if (person == null) {
1✔
NEW
479
                                return false;
×
480
                        }
481
                        if (!(person.getIsPatient())) {
1✔
482
                                return false;
1✔
483
                        }
484

485
                        List<PatientProgram> patientPrograms = getPatientPrograms((Patient) person, onDate);
1✔
486
                        List<PatientState> patientStates = new ArrayList<>();
1✔
487

488
                        for (PatientProgram pp : patientPrograms) {
1✔
489
                                for (PatientState state : pp.getStates()) {
1✔
490
                                        if (state.getActive(onDate)) {
1✔
491
                                                patientStates.add(state);
1✔
492
                                        }
493
                                }
1✔
494
                        }
1✔
495

496
                        return patientStates.stream().anyMatch(ps -> ps.getState().getUuid().equals(uuid));
1✔
497
                }
498

499
                private LocalDate toLocalDate(Date date) {
500
                        return date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
1✔
501
                }
502

503
                private List<PatientProgram> getPatientPrograms(Patient patient, Date onDate) {
504
                        if (onDate == null) {
1✔
NEW
505
                                onDate = new Date();
×
506
                        }
507
                        return Context.getProgramWorkflowService().getPatientPrograms(patient, null, null, onDate, onDate, null, false);
1✔
508
                }
509
        }
510
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc