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

openmrs / openmrs-core / 24192180227

09 Apr 2026 01:11PM UTC coverage: 65.242% (+0.06%) from 65.184%
24192180227

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%)

5 existing lines in 5 files now uncovered.

23615 of 36196 relevant lines covered (65.24%)

0.65 hits per line

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

92.31
/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.Obs;
28
import org.openmrs.Patient;
29
import org.openmrs.PatientProgram;
30
import org.openmrs.PatientState;
31
import org.openmrs.Person;
32
import org.openmrs.api.APIException;
33
import org.openmrs.api.context.Context;
34
import org.openmrs.api.db.hibernate.HibernateUtil;
35
import org.springframework.expression.Expression;
36
import org.springframework.expression.ExpressionParser;
37
import org.springframework.expression.spel.SpelEvaluationException;
38
import org.springframework.expression.spel.SpelMessage;
39
import org.springframework.expression.spel.standard.SpelExpressionParser;
40
import org.springframework.expression.spel.support.DataBindingMethodResolver;
41
import org.springframework.expression.spel.support.DataBindingPropertyAccessor;
42
import org.springframework.context.expression.MapAccessor;
43
import org.springframework.expression.spel.support.SimpleEvaluationContext;
44

45
/**
46
 * A utility class that evaluates the concept ranges
47
 *
48
 * @since 2.7.0
49
 */
50
public class ConceptReferenceRangeUtility {
51
        
52
        /**
53
         * {@link ExpressionParser} instance used by the {@link ConceptReferenceRangeUtility} to parse
54
         * expressions
55
         */
56
        private static final ExpressionParser PARSER = new SpelExpressionParser();
1✔
57

58
        /**
59
         * Static {@link org.springframework.expression.EvaluationContext} which is used to run evaluations.
60
         * This class is thread-safe, so shareable.
61
         */
62
        private static final SimpleEvaluationContext EVAL_CONTEXT = SimpleEvaluationContext
1✔
63
                .forPropertyAccessors(new MapAccessor(), DataBindingPropertyAccessor.forReadOnlyAccess())
1✔
64
                .withMethodResolvers(DataBindingMethodResolver.forInstanceMethodInvocation()).build();
1✔
65

66
        private final CriteriaFunctions functions = new CriteriaFunctions();
1✔
67

68
        public ConceptReferenceRangeUtility() {
1✔
69
        }
1✔
70

71
        /**
72
         * This method evaluates the given criteria against the provided {@link Obs}.
73
         *
74
         * @param criteria the criteria string to evaluate e.g. "$patient.getAge() > 1"
75
         * @param obs The observation (Obs) object containing the values to be used in the criteria
76
         *            evaluation.
77
         * @return true if the criteria evaluates to true, false otherwise
78
         */
79
        public boolean evaluateCriteria(String criteria, Obs obs) {
80
                if (obs == null) {
1✔
81
                        throw new IllegalArgumentException("Failed to evaluate criteria with reason: Obs is null");
1✔
82
                }
83
                
84
                if (obs.getPerson() == null) {
1✔
85
                        throw new IllegalArgumentException("Failed to evaluate criteria with reason: patient is null");
×
86
                }
87

88
                if (StringUtils.isBlank(criteria)) {
1✔
89
                        throw new IllegalArgumentException("Failed to evaluate criteria with reason: criteria is empty");
1✔
90
                }
91

92
                Map<String, Object> root = new HashMap<>();
1✔
93
                root.put("$fn", functions);
1✔
94
                root.put("$patient", HibernateUtil.getRealObjectFromProxy(obs.getPerson()));
1✔
95
                root.put("$obs", obs);
1✔
96
                root.put("$encounter", obs.getEncounter());
1✔
97
                root.put("$date", obs.getObsDatetime());
1✔
98

99
                try {
100
                        Expression expression = PARSER.parseExpression(criteria);
1✔
101
                        Boolean result = expression.getValue(EVAL_CONTEXT, root, Boolean.class);
1✔
102
                        return result != null && result;
1✔
103
                } catch (SpelEvaluationException e) {
1✔
104
                        SpelMessage msg = e.getMessageCode();
1✔
105
                        if (msg == SpelMessage.METHOD_CALL_ON_NULL_OBJECT_NOT_ALLOWED
1✔
106
                                || msg == SpelMessage.PROPERTY_OR_FIELD_NOT_READABLE_ON_NULL) {
107
                                return false;
1✔
108
                        }
109
                        throw new APIException("An error occurred while evaluating criteria: " + criteria, e);
1✔
NEW
110
                } catch (Exception e) {
×
NEW
111
                        throw new APIException("An error occurred while evaluating criteria: " + criteria, e);
×
112
                }
113
        }
114
        
115
        /**
116
         * Helper functions available as {@code $fn} in concept reference range criteria expressions.
117
         * <p>
118
         * This class is intentionally separate from the outer class so that {@code evaluateCriteria} is not
119
         * callable from within expressions.
120
         *
121
         * @since 2.7.9, 2.8.6, 2.9.0, 3.0.0
122
         */
123
        static class CriteriaFunctions {
1✔
124

125
                private final long NULL_DATE_RETURN_VALUE = -1;
1✔
126

127
                /**
128
                 * Gets the latest Obs by concept.
129
                 *
130
                 * @param conceptRef can be either concept uuid or conceptMap's code and sourceName e.g
131
                 *            "bac25fd5-c143-4e43-bffe-4eb1e7efb6ce" or "CIEL:1434"
132
                 * @param person person to get obs for
133
                 * @return Obs latest Obs
134
                 */
135
                public Obs getLatestObs(String conceptRef, Person person) {
136
                        if (person == null) {
1✔
NEW
137
                                return null;
×
138
                        }
139
                        Concept concept = Context.getConceptService().getConceptByReference(conceptRef);
1✔
140

141
                        if (concept != null) {
1✔
142
                                List<Obs> observations = Context.getObsService().getObservations(Collections.singletonList(person), null,
1✔
143
                                    Collections.singletonList(concept), null, null, null, Collections.singletonList("dateCreated"), 1, null,
1✔
144
                                    null, null, false);
145

146
                                return observations.isEmpty() ? null : observations.get(0);
1✔
147
                        }
148

149
                        return null;
1✔
150
                }
151

152
                /**
153
                 * Gets the time of the day in hours.
154
                 *
155
                 * @return the hour of the day in 24hr format (e.g. 14 to mean 2pm)
156
                 */
157
                public int getCurrentHour() {
158
                        return LocalTime.now().getHourOfDay();
1✔
159
                }
160

161
                /**
162
                 * Retrieves the most relevant Obs for the given current Obs and conceptRef. If the current Obs
163
                 * contains a valid value (coded, numeric, date, text etc.) and the concept in Obs is the same as
164
                 * the supplied concept, the method returns the current Obs. Otherwise, it fetches the latest Obs
165
                 * for the supplied concept and patient.
166
                 *
167
                 * @param conceptRef can be either concept uuid or conceptMap's code and sourceName
168
                 * @param currentObs the current Obs being evaluated
169
                 * @return the most relevant Obs based on the current Obs, or the latest Obs if the current one has
170
                 *         no valid value
171
                 */
172
                public Obs getCurrentObs(String conceptRef, Obs currentObs) {
173
                        Concept concept = Context.getConceptService().getConceptByReference(conceptRef);
1✔
174

175
                        if (concept != null && concept.equals(currentObs.getConcept())
1✔
176
                                && !currentObs.getValueAsString(Locale.ENGLISH).isEmpty()) {
1✔
177
                                return currentObs;
1✔
178
                        } else {
179
                                return getLatestObs(conceptRef, currentObs.getPerson());
1✔
180
                        }
181
                }
182

183
                /**
184
                 * Gets the person's latest observation date for a given concept
185
                 *
186
                 * @param conceptRef can be either concept uuid or conceptMap's code and sourceName e.g
187
                 *            "bac25fd5-c143-4e43-bffe-4eb1e7efb6ce" or "CIEL:1434"
188
                 * @param person the person
189
                 * @return the observation date
190
                 * @since 2.7.0
191
                 */
192
                public Date getLatestObsDate(String conceptRef, Person person) {
193
                        Obs obs = getLatestObs(conceptRef, person);
1✔
194
                        if (obs == null) {
1✔
195
                                return null;
1✔
196
                        }
197

198
                        Date date = obs.getValueDate();
1✔
199
                        if (date == null) {
1✔
200
                                date = obs.getValueDatetime();
1✔
201
                        }
202

203
                        return date;
1✔
204
                }
205

206
                /**
207
                 * Checks if an observation's value coded answer is equal to a given concept
208
                 *
209
                 * @param conceptRef can be either concept uuid or conceptMap's code and sourceName e.g
210
                 *            "bac25fd5-c143-4e43-bffe-4eb1e7efb6ce" or "CIEL:1434" for the observation's question
211
                 * @param person the person
212
                 * @param answerConceptRef can be either concept uuid or conceptMap's code and sourceName for the
213
                 *            observation's coded answer
214
                 * @return true if the given concept is equal to the observation's value coded answer
215
                 * @since 2.7.0
216
                 */
217
                public boolean isObsValueCodedAnswer(String conceptRef, Person person, String answerConceptRef) {
218
                        Obs obs = getLatestObs(conceptRef, person);
1✔
219
                        if (obs == null) {
1✔
220
                                return false;
1✔
221
                        }
222

223
                        Concept valueCoded = obs.getValueCoded();
1✔
224
                        if (valueCoded == null) {
1✔
NEW
225
                                return false;
×
226
                        }
227

228
                        Concept answerConcept = Context.getConceptService().getConceptByReference(answerConceptRef);
1✔
229
                        if (answerConcept == null) {
1✔
NEW
230
                                return false;
×
231
                        }
232

233
                        return valueCoded.equals(answerConcept);
1✔
234
                }
235

236
                /**
237
                 * Gets the number of days from the person's latest observation date value for a given concept to
238
                 * the current date
239
                 *
240
                 * @param conceptRef concept uuid or conceptMap code and sourceName
241
                 * @param person the person
242
                 * @return the number of days
243
                 * @since 2.7.0
244
                 */
245
                public long getObsDays(String conceptRef, Person person) {
246
                        Date date = getLatestObsDate(conceptRef, person);
1✔
247
                        if (date == null) {
1✔
248
                                return NULL_DATE_RETURN_VALUE;
1✔
249
                        }
250
                        return getDays(date);
1✔
251
                }
252

253
                /**
254
                 * Gets the number of weeks from the person's latest observation date value for a given concept to
255
                 * the current date
256
                 *
257
                 * @param conceptRef concept uuid or conceptMap code and sourceName
258
                 * @param person the person
259
                 * @return the number of weeks
260
                 * @since 2.7.0
261
                 */
262
                public long getObsWeeks(String conceptRef, Person person) {
263
                        Date date = getLatestObsDate(conceptRef, person);
1✔
264
                        if (date == null) {
1✔
265
                                return NULL_DATE_RETURN_VALUE;
1✔
266
                        }
267
                        return getWeeks(date);
1✔
268
                }
269

270
                /**
271
                 * Gets the number of months from the person's latest observation date value for a given concept to
272
                 * the current date
273
                 *
274
                 * @param conceptRef concept uuid or conceptMap code and sourceName
275
                 * @param person the person
276
                 * @return the number of months
277
                 * @since 2.7.0
278
                 */
279
                public long getObsMonths(String conceptRef, Person person) {
280
                        Date date = getLatestObsDate(conceptRef, person);
1✔
281
                        if (date == null) {
1✔
282
                                return NULL_DATE_RETURN_VALUE;
1✔
283
                        }
284
                        return getMonths(date);
1✔
285
                }
286

287
                /**
288
                 * Gets the number of years from the person's latest observation date value for a given concept to
289
                 * the current date
290
                 *
291
                 * @param conceptRef concept uuid or conceptMap code and sourceName
292
                 * @param person the person
293
                 * @return the number of years
294
                 * @since 2.7.0
295
                 */
296
                public long getObsYears(String conceptRef, Person person) {
297
                        Date date = getLatestObsDate(conceptRef, person);
1✔
298
                        if (date == null) {
1✔
299
                                return NULL_DATE_RETURN_VALUE;
1✔
300
                        }
301
                        return getYears(date);
1✔
302
                }
303

304
                /**
305
                 * Gets the number of days between two given dates
306
                 *
307
                 * @param fromDate the date from which to start counting
308
                 * @param toDate the date up to which to stop counting
309
                 * @return the number of days between
310
                 * @since 2.7.0
311
                 */
312
                public long getDaysBetween(Date fromDate, Date toDate) {
313
                        if (fromDate == null || toDate == null) {
1✔
314
                                return NULL_DATE_RETURN_VALUE;
1✔
315
                        }
316
                        return ChronoUnit.DAYS.between(toLocalDate(fromDate), toLocalDate(toDate));
1✔
317
                }
318

319
                /**
320
                 * Gets the number of weeks between two given dates
321
                 *
322
                 * @param fromDate the date from which to start counting
323
                 * @param toDate the date up to which to stop counting
324
                 * @return the number of weeks between
325
                 * @since 2.7.0
326
                 */
327
                public long getWeeksBetween(Date fromDate, Date toDate) {
328
                        if (fromDate == null || toDate == null) {
1✔
329
                                return NULL_DATE_RETURN_VALUE;
1✔
330
                        }
331
                        return ChronoUnit.WEEKS.between(toLocalDate(fromDate), toLocalDate(toDate));
1✔
332
                }
333

334
                /**
335
                 * Gets the number of months between two given dates
336
                 *
337
                 * @param fromDate the date from which to start counting
338
                 * @param toDate the date up to which to stop counting
339
                 * @return the number of months between
340
                 * @since 2.7.0
341
                 */
342
                public long getMonthsBetween(Date fromDate, Date toDate) {
343
                        if (fromDate == null || toDate == null) {
1✔
344
                                return NULL_DATE_RETURN_VALUE;
1✔
345
                        }
346
                        return ChronoUnit.MONTHS.between(toLocalDate(fromDate), toLocalDate(toDate));
1✔
347
                }
348

349
                /**
350
                 * Gets the number of years between two given dates
351
                 *
352
                 * @param fromDate the date from which to start counting
353
                 * @param toDate the date up to which to stop counting
354
                 * @return the number of years between
355
                 * @since 2.7.0
356
                 */
357
                public long getYearsBetween(Date fromDate, Date toDate) {
358
                        if (fromDate == null || toDate == null) {
1✔
359
                                return NULL_DATE_RETURN_VALUE;
1✔
360
                        }
361
                        return ChronoUnit.YEARS.between(toLocalDate(fromDate), toLocalDate(toDate));
1✔
362
                }
363

364
                /**
365
                 * Gets the number of days from a given date up to the current date.
366
                 *
367
                 * @param fromDate the date from which to start counting
368
                 * @return the number of days
369
                 * @since 2.7.0
370
                 */
371
                public long getDays(Date fromDate) {
372
                        return getDaysBetween(fromDate, new Date());
1✔
373
                }
374

375
                /**
376
                 * Gets the number of weeks from a given date up to the current date.
377
                 *
378
                 * @param fromDate the date from which to start counting
379
                 * @return the number of weeks
380
                 * @since 2.7.0
381
                 */
382
                public long getWeeks(Date fromDate) {
383
                        return getWeeksBetween(fromDate, new Date());
1✔
384
                }
385

386
                /**
387
                 * Gets the number of months from a given date up to the current date.
388
                 *
389
                 * @param fromDate the date from which to start counting
390
                 * @return the number of months
391
                 * @since 2.7.0
392
                 */
393
                public long getMonths(Date fromDate) {
394
                        return getMonthsBetween(fromDate, new Date());
1✔
395
                }
396

397
                /**
398
                 * Gets the number of years from a given date up to the current date.
399
                 *
400
                 * @param fromDate the date from which to start counting
401
                 * @return the number of years
402
                 * @since 2.7.0
403
                 */
404
                public long getYears(Date fromDate) {
405
                        return getYearsBetween(fromDate, new Date());
1✔
406
                }
407

408
                /**
409
                 * Returns whether the patient is the specified program on the specified date
410
                 *
411
                 * @param uuid of program
412
                 * @param person the patient to test
413
                 * @param onDate the date to test whether the patient is in the program
414
                 * @return true if the patient is in the program on the specified date, false otherwise
415
                 * @since 2.7.0
416
                 */
417
                public boolean isEnrolledInProgram(String uuid, Person person, Date onDate) {
418
                        if (person == null) {
1✔
NEW
419
                                return false;
×
420
                        }
421
                        if (!(person.getIsPatient())) {
1✔
422
                                return false;
1✔
423
                        }
424
                        return getPatientPrograms((Patient) person, onDate).stream()
1✔
425
                                .anyMatch(pp -> pp.getProgram().getUuid().equals(uuid));
1✔
426
                }
427

428
                /**
429
                 * Returns whether the patient is the specified program state on the specified date
430
                 *
431
                 * @param uuid of program state
432
                 * @param person the patient to test
433
                 * @param onDate the date to test whether the patient is in the program state
434
                 * @return true if the patient is in the program state on the specified date, false otherwise
435
                 * @since 2.7.0
436
                 */
437
                public boolean isInProgramState(String uuid, Person person, Date onDate) {
438
                        if (person == null) {
1✔
NEW
439
                                return false;
×
440
                        }
441
                        if (!(person.getIsPatient())) {
1✔
442
                                return false;
1✔
443
                        }
444

445
                        List<PatientProgram> patientPrograms = getPatientPrograms((Patient) person, onDate);
1✔
446
                        List<PatientState> patientStates = new ArrayList<>();
1✔
447

448
                        for (PatientProgram pp : patientPrograms) {
1✔
449
                                for (PatientState state : pp.getStates()) {
1✔
450
                                        if (state.getActive(onDate)) {
1✔
451
                                                patientStates.add(state);
1✔
452
                                        }
453
                                }
1✔
454
                        }
1✔
455

456
                        return patientStates.stream().anyMatch(ps -> ps.getState().getUuid().equals(uuid));
1✔
457
                }
458

459
                private LocalDate toLocalDate(Date date) {
460
                        return date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
1✔
461
                }
462

463
                private List<PatientProgram> getPatientPrograms(Patient patient, Date onDate) {
464
                        if (onDate == null) {
1✔
NEW
465
                                onDate = new Date();
×
466
                        }
467
                        return Context.getProgramWorkflowService().getPatientPrograms(patient, null, null, onDate, onDate, null, false);
1✔
468
                }
469
        }
470
}
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