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

openmrs / openmrs-core / 29955974657

22 Jul 2026 08:38PM UTC coverage: 66.238% (+0.02%) from 66.218%
29955974657

push

github

ibacher
TRUNK-6711: Batch-load search result pages (#6363)

99 of 105 new or added lines in 4 files covered. (94.29%)

8 existing lines in 5 files now uncovered.

24430 of 36882 relevant lines covered (66.24%)

0.66 hits per line

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

91.3
/api/src/main/java/org/openmrs/api/db/hibernate/HibernatePatientDAO.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.api.db.hibernate;
11

12
import java.util.ArrayList;
13
import java.util.Arrays;
14
import java.util.Collections;
15
import java.util.Comparator;
16
import java.util.Date;
17
import java.util.HashMap;
18
import java.util.LinkedList;
19
import java.util.List;
20
import java.util.Map;
21
import java.util.Objects;
22
import java.util.Set;
23
import java.util.function.Function;
24
import java.util.regex.Pattern;
25
import java.util.stream.Collectors;
26

27
import org.apache.commons.collections.CollectionUtils;
28
import org.apache.commons.lang3.StringUtils;
29
import org.hibernate.Criteria;
30
import org.hibernate.Query;
31
import org.hibernate.SQLQuery;
32
import org.hibernate.SessionFactory;
33
import org.hibernate.criterion.Order;
34
import org.hibernate.criterion.Restrictions;
35
import org.hibernate.persister.entity.AbstractEntityPersister;
36
import org.hibernate.search.engine.search.predicate.SearchPredicate;
37
import org.hibernate.search.engine.search.predicate.dsl.SearchPredicateFactory;
38
import org.openmrs.Allergies;
39
import org.openmrs.Allergy;
40
import org.openmrs.Location;
41
import org.openmrs.Patient;
42
import org.openmrs.PatientIdentifier;
43
import org.openmrs.PatientIdentifierType;
44
import org.openmrs.PatientIdentifierType.UniquenessBehavior;
45
import org.openmrs.PatientProgram;
46
import org.openmrs.Person;
47
import org.openmrs.PersonAttribute;
48
import org.openmrs.PersonName;
49
import org.openmrs.api.context.Context;
50
import org.openmrs.api.db.DAOException;
51
import org.openmrs.api.db.PatientDAO;
52
import org.openmrs.api.db.hibernate.search.SearchQueryUnique;
53
import org.openmrs.api.db.hibernate.search.session.SearchSessionFactory;
54
import org.openmrs.util.OpenmrsConstants;
55
import org.openmrs.util.OpenmrsUtil;
56
import org.slf4j.Logger;
57
import org.slf4j.LoggerFactory;
58

59
/**
60
 * Hibernate specific database methods for the PatientService
61
 *
62
 * @see org.openmrs.api.context.Context
63
 * @see org.openmrs.api.db.PatientDAO
64
 * @see org.openmrs.api.PatientService
65
 */
66
public class HibernatePatientDAO implements PatientDAO {
1✔
67
        
68
        private static final Logger log = LoggerFactory.getLogger(HibernatePatientDAO.class);
1✔
69
        
70
        /**
71
         * Hibernate session factory
72
         */
73
        private SessionFactory sessionFactory;
74
        
75
        private SearchSessionFactory searchSessionFactory;
76
        
77
        /**
78
         * Set session factory
79
         *
80
         * @param sessionFactory
81
         */
82
        public void setSessionFactory(SessionFactory sessionFactory) {
83
                this.sessionFactory = sessionFactory;
1✔
84
        }
1✔
85

86
        public void setSearchSessionFactory(SearchSessionFactory searchSessionFactory) {
87
                this.searchSessionFactory = searchSessionFactory;
1✔
88
        }
1✔
89

90
        /**
91
     * @param patientId  internal patient identifier
92
     * @return           patient with given internal identifier
93
         * @see org.openmrs.api.PatientService#getPatient(java.lang.Integer)
94
         */
95
        @Override
96
        public Patient getPatient(Integer patientId) {
97
                return (Patient) sessionFactory.getCurrentSession().get(Patient.class, patientId);
1✔
98
        }
99
        
100
        /**
101
     * @param patient  patient to be created or updated
102
     * @return         patient who was created or updated
103
         * @see org.openmrs.api.db.PatientDAO#savePatient(org.openmrs.Patient)
104
         */
105
        @Override
106
        public Patient savePatient(Patient patient) throws DAOException {
107
                if (patient.getPatientId() == null) {
1✔
108
                        // if we're saving a new patient, just do the normal thing
109
                        // and rows in the person and patient table will be created by
110
                        // hibernate
111
                        sessionFactory.getCurrentSession().saveOrUpdate(patient);
1✔
112
                        return patient;
1✔
113
                } else {
114
                        // if we're updating a patient, its possible that a person
115
                        // row exists but a patient row does not. hibernate does not deal
116
                        // with this correctly right now, so we must create a dummy row
117
                        // in the patient table before saving
118
                        
119
                        // Check to make sure we have a row in the patient table already.
120
                        // If we don't have a row, create it so Hibernate doesn't bung
121
                        // things up
122
                        insertPatientStubIfNeeded(patient);
1✔
123
                        
124
                        // Note: A merge might be necessary here because hibernate thinks that Patients
125
                        // and Persons are the same objects.  So it sees a Person object in the
126
                        // cache and claims it is a duplicate of this Patient object.
127
                        sessionFactory.getCurrentSession().saveOrUpdate(patient);
1✔
128
                        
129
                        return patient;
1✔
130
                }
131
        }
132
        
133
        /**
134
         * Inserts a row into the patient table This avoids hibernate's bunging of our
135
         * person/patient/user inheritance
136
         *
137
         * @param patient
138
         */
139
        private void insertPatientStubIfNeeded(Patient patient) {
140
                
141
                boolean stubInsertNeeded = false;
1✔
142
                
143
                if (patient.getPatientId() != null) {
1✔
144
                        // check if there is a row with a matching patient.patient_id
145
                        String sql = "SELECT 1 FROM patient WHERE patient_id = :patientId";
1✔
146
                        Query query = sessionFactory.getCurrentSession().createSQLQuery(sql);
1✔
147
                        query.setInteger("patientId", patient.getPatientId());
1✔
148
                        
149
                        stubInsertNeeded = (query.uniqueResult() == null);
1✔
150
                }
151
                
152
                if (stubInsertNeeded) {
1✔
153
                        //If not yet persisted
154
                        if (patient.getCreator() == null) {
1✔
155
                                patient.setCreator(Context.getAuthenticatedUser());
×
156
                        }
157
                        //If not yet persisted
158
                        if (patient.getDateCreated() == null) {
1✔
159
                                patient.setDateCreated(new Date());
×
160
                        }
161
                        
162
                        String insert = "INSERT INTO patient (patient_id, creator, voided, date_created) VALUES (:patientId, :creator, :voided, :dateCreated)";
1✔
163
                        Query query = sessionFactory.getCurrentSession().createSQLQuery(insert);
1✔
164
                        query.setInteger("patientId", patient.getPatientId());
1✔
165
                        query.setInteger("creator", patient.getCreator().getUserId());
1✔
166
                        query.setBoolean("voided", false);
1✔
167
                        query.setDate("dateCreated", patient.getDateCreated());
1✔
168
                        
169
                        query.executeUpdate();
1✔
170
                        
171
                        //Without evicting person, you will get this error when promoting person to patient
172
                        //org.hibernate.NonUniqueObjectException: a different object with the same identifier
173
                        //value was already associated with the session: [org.openmrs.Patient#]
174
                        //see TRUNK-3728
175
                        Person person = (Person) sessionFactory.getCurrentSession().get(Person.class, patient.getPersonId());
1✔
176
                        sessionFactory.getCurrentSession().evict(person);
1✔
177
                }
178
                
179
        }
1✔
180
        
181
        public List<Patient> getPatients(String query, List<PatientIdentifierType> identifierTypes,
182
                boolean matchIdentifierExactly, Integer start, Integer length) throws DAOException{
183
                
184
                if (StringUtils.isBlank(query) || (length != null && length < 1) || identifierTypes == null || identifierTypes.isEmpty())  {
1✔
185
                        return Collections.emptyList();
×
186
                }
187
                
188
                Integer tmpStart = start;
1✔
189
                if (tmpStart == null || tmpStart < 0) {
1✔
190
                        tmpStart = 0;
×
191
                }
192
                
193
                Integer tmpLength = length;
1✔
194
                if (tmpLength == null) {
1✔
195
                        tmpLength = HibernatePersonDAO.getMaximumSearchResults();
1✔
196
                }
197
                
198
                return findPatients(query, identifierTypes, matchIdentifierExactly, tmpStart, tmpLength);
1✔
199
        }
200
        
201
        /**
202
         * @see org.openmrs.api.db.PatientDAO#getPatients(String, boolean, Integer, Integer)
203
         * <strong>Should</strong> return exact match first
204
         */
205
        @Override
206
        public List<Patient> getPatients(String query, boolean includeVoided, Integer start, Integer length) throws DAOException {
207
                if (StringUtils.isBlank(query) || (length != null && length < 1)) {
1✔
208
                        return Collections.emptyList();
1✔
209
                }
210

211
                Integer tmpStart = start;
1✔
212
                if (tmpStart == null || tmpStart < 0) {
1✔
213
                        tmpStart = 0;
1✔
214
                }
215

216
                Integer tmpLength = length;
1✔
217
                if (tmpLength == null) {
1✔
218
                        tmpLength = HibernatePersonDAO.getMaximumSearchResults();
1✔
219
                }
220

221
                List<Patient> patients = findPatients(query, includeVoided, tmpStart, tmpLength);
1✔
222

223
                return new ArrayList<>(patients);
1✔
224
        }
225
        
226
        /**
227
         * @see org.openmrs.api.db.PatientDAO#getPatients(String, Integer, Integer)
228
         */
229
        @Override
230
        public List<Patient> getPatients(String query, Integer start, Integer length) throws DAOException {
231
                return getPatients(query, false, start, length);
1✔
232
        }
233
        
234
        private void setFirstAndMaxResult(Criteria criteria, Integer start, Integer length) {
235
                if (start != null) {
×
236
                        criteria.setFirstResult(start);
×
237
                }
238
                
239
                int maximumSearchResults = HibernatePersonDAO.getMaximumSearchResults();
×
240
                if (length != null && length < maximumSearchResults) {
×
241
                        criteria.setMaxResults(length);
×
242
                } else {
243
                        log.debug("Limiting the size of the number of matching patients to {}", maximumSearchResults);
×
244
                        criteria.setMaxResults(maximumSearchResults);
×
245
                }
246
        }
×
247
        
248
        /**
249
         * @see org.openmrs.api.db.PatientDAO#getAllPatients(boolean)
250
         */
251
        @SuppressWarnings("unchecked")
252
        @Override
253
        public List<Patient> getAllPatients(boolean includeVoided) throws DAOException {
254
                Criteria criteria = sessionFactory.getCurrentSession().createCriteria(Patient.class);
1✔
255
                
256
                if (!includeVoided) {
1✔
257
                        criteria.add(Restrictions.eq("voided", false));
1✔
258
                }
259
                
260
                return criteria.list();
1✔
261
        }
262
        
263
        /**
264
         * @see org.openmrs.api.PatientService#purgePatientIdentifierType(org.openmrs.PatientIdentifierType)
265
         * @see org.openmrs.api.db.PatientDAO#deletePatientIdentifierType(org.openmrs.PatientIdentifierType)
266
         */
267
        @Override
268
        public void deletePatientIdentifierType(PatientIdentifierType patientIdentifierType) throws DAOException {
269
                sessionFactory.getCurrentSession().delete(patientIdentifierType);
1✔
270
        }
1✔
271
        
272
        /**
273
         * @see org.openmrs.api.PatientService#getPatientIdentifiers(java.lang.String, java.util.List, java.util.List, java.util.List, java.lang.Boolean)
274
         */
275
        @SuppressWarnings("unchecked")
276
        @Override
277
        public List<PatientIdentifier> getPatientIdentifiers(String identifier,
278
                List<PatientIdentifierType> patientIdentifierTypes, List<Location> locations, List<Patient> patients,
279
                Boolean isPreferred) throws DAOException {
280
                Criteria criteria = sessionFactory.getCurrentSession().createCriteria(PatientIdentifier.class);
1✔
281
                
282
                // join with the patient table to prevent patient identifiers from patients
283
                // that already voided getting returned
284
                criteria.createAlias("patient", "patient");
1✔
285

286
                criteria.add(Restrictions.eq("patient.voided", false));
1✔
287
                
288
                criteria.add(Restrictions.eq("voided", false));
1✔
289
                
290
                if (identifier != null) {
1✔
291
                        criteria.add(Restrictions.eq("identifier", identifier));
1✔
292
                }
293
                
294
                if (!patientIdentifierTypes.isEmpty()) {
1✔
295
                        criteria.add(Restrictions.in("identifierType", patientIdentifierTypes));
1✔
296
                }
297
                
298
                if (!locations.isEmpty()) {
1✔
299
                        criteria.add(Restrictions.in("location", locations));
1✔
300
                }
301
                
302
                if (!patients.isEmpty()) {
1✔
303
                        criteria.add(Restrictions.in("patient", patients));
1✔
304
                }
305
                
306
                if (isPreferred != null) {
1✔
307
                        criteria.add(Restrictions.eq("preferred", isPreferred));
1✔
308
                }
309
                
310
                return criteria.list();
1✔
311
        }
312
        
313
        /**
314
         * @see org.openmrs.api.db.PatientDAO#savePatientIdentifierType(org.openmrs.PatientIdentifierType)
315
         */
316
        @Override
317
        public PatientIdentifierType savePatientIdentifierType(PatientIdentifierType patientIdentifierType) throws DAOException {
318
                sessionFactory.getCurrentSession().saveOrUpdate(patientIdentifierType);
1✔
319
                return patientIdentifierType;
1✔
320
        }
321
        
322
        /**
323
         * @see org.openmrs.api.PatientDAO#deletePatient(org.openmrs.Patient)
324
         */
325
        @Override
326
        public void deletePatient(Patient patient) throws DAOException {
327
                HibernatePersonDAO.deletePersonAndAttributes(sessionFactory, patient);
1✔
328
        }
1✔
329
        
330
        /**
331
         * @see org.openmrs.api.PatientService#getPatientIdentifierType(java.lang.Integer)
332
         */
333
        @Override
334
        public PatientIdentifierType getPatientIdentifierType(Integer patientIdentifierTypeId) throws DAOException {
335
                return (PatientIdentifierType) sessionFactory.getCurrentSession().get(PatientIdentifierType.class,
1✔
336
                    patientIdentifierTypeId);
337
        }
338
        
339
        /**
340
         * <strong>Should</strong> not return null when includeRetired is false
341
         * <strong>Should</strong> not return retired when includeRetired is false
342
         * <strong>Should</strong> not return null when includeRetired is true
343
         * <strong>Should</strong> return all when includeRetired is true
344
         * <strong>Should</strong> return ordered
345
         * @see org.openmrs.api.db.PatientDAO#getAllPatientIdentifierTypes(boolean)
346
         */
347
        @SuppressWarnings("unchecked")
348
        @Override
349
        public List<PatientIdentifierType> getAllPatientIdentifierTypes(boolean includeRetired) throws DAOException {
350
                Criteria criteria = sessionFactory.getCurrentSession().createCriteria(PatientIdentifierType.class);
1✔
351
                
352
                if (!includeRetired) {
1✔
353
                        criteria.add(Restrictions.eq("retired", false));
1✔
354
                } else {
355
                        //retired last
356
                        criteria.addOrder(Order.asc("retired"));
1✔
357
                }
358
                
359
                //required first
360
                criteria.addOrder(Order.desc("required"));
1✔
361
                criteria.addOrder(Order.asc("name"));
1✔
362
                criteria.addOrder(Order.asc("patientIdentifierTypeId"));
1✔
363
                
364
                return criteria.list();
1✔
365
        }
366
        
367
        /**
368
         * @see org.openmrs.api.db.PatientDAO#getPatientIdentifierTypes(java.lang.String,
369
         *      java.lang.String, java.lang.Boolean, java.lang.Boolean)
370
         *
371
         * <strong>Should</strong> return non retired patient identifier types with given name
372
         * <strong>Should</strong> return non retired patient identifier types with given format
373
         * <strong>Should</strong> return non retired patient identifier types that are not required
374
         * <strong>Should</strong> return non retired patient identifier types that are required
375
         * <strong>Should</strong> return non retired patient identifier types that has checkDigit
376
         * <strong>Should</strong> return non retired patient identifier types that has not CheckDigit
377
         * <strong>Should</strong> return only non retired patient identifier types
378
         * <strong>Should</strong> return non retired patient identifier types ordered by required first
379
         * <strong>Should</strong> return non retired patient identifier types ordered by required and name
380
         * <strong>Should</strong> return non retired patient identifier types ordered by required name and type id
381
         *
382
         */
383
        @SuppressWarnings("unchecked")
384
        @Override
385
        public List<PatientIdentifierType> getPatientIdentifierTypes(String name, String format, Boolean required,
386
                Boolean hasCheckDigit) throws DAOException {
387
                
388
                Criteria criteria = sessionFactory.getCurrentSession().createCriteria(PatientIdentifierType.class);
1✔
389
                
390
                if (name != null) {
1✔
391
                        criteria.add(Restrictions.eq("name", name));
1✔
392
                }
393
                
394
                if (format != null) {
1✔
395
                        criteria.add(Restrictions.eq("format", format));
1✔
396
                }
397
                
398
                if (required != null) {
1✔
399
                        criteria.add(Restrictions.eq("required", required));
1✔
400
                }
401
                
402
                if (hasCheckDigit != null) {
1✔
403
                        criteria.add(Restrictions.eq("checkDigit", hasCheckDigit));
×
404
                }
405
                
406
                criteria.add(Restrictions.eq("retired", false));
1✔
407
                
408
                //required first
409
                criteria.addOrder(Order.desc("required"));
1✔
410
                criteria.addOrder(Order.asc("name"));
1✔
411
                criteria.addOrder(Order.asc("patientIdentifierTypeId"));
1✔
412
                
413
                return criteria.list();
1✔
414
        }
415
        
416
        /**
417
     * @param attributes attributes on a Person or Patient object. similar to: [gender, givenName,
418
     *                   middleName, familyName]
419
     * @return           list of patients that match other patients
420
         * @see org.openmrs.api.db.PatientDAO#getDuplicatePatientsByAttributes(java.util.List)
421
         */
422
        @SuppressWarnings("unchecked")
423
        @Override
424
        public List<Patient> getDuplicatePatientsByAttributes(List<String> attributes) {
425
                List<Patient> patients = new ArrayList<>();
1✔
426
                List<Integer> patientIds = new ArrayList<>();
1✔
427

428
                if (!attributes.isEmpty()) {
1✔
429

430
                        String sqlString = getDuplicatePatientsSQLString(attributes);
1✔
431
                        if(sqlString != null) {
1✔
432

433
                                SQLQuery sqlquery = sessionFactory.getCurrentSession().createSQLQuery(sqlString);
1✔
434
                                patientIds = sqlquery.list();
1✔
435
                                if (!patientIds.isEmpty()) {
1✔
436
                                        Query query = sessionFactory.getCurrentSession().createQuery(
1✔
437
                                                        "from Patient p1 where p1.patientId in (:ids)");
438
                                        query.setParameterList("ids", patientIds);
1✔
439
                                        patients = query.list();
1✔
440
                                }
441
                        }
442
                
443
                }
444

445
                sortDuplicatePatients(patients, patientIds);
1✔
446
                return patients;
1✔
447
        }
448

449
        private String getDuplicatePatientsSQLString(List<String> attributes) {
450
                StringBuilder outerSelect = new StringBuilder("select distinct t1.patient_id from patient t1 ");
1✔
451
                final String t5 = " = t5.";
1✔
452
                Set<String> patientFieldNames = OpenmrsUtil.getDeclaredFields(Patient.class);
1✔
453
                Set<String> personFieldNames = OpenmrsUtil.getDeclaredFields(Person.class);
1✔
454
                Set<String> personNameFieldNames = OpenmrsUtil.getDeclaredFields(PersonName.class);
1✔
455
                Set<String> identifierFieldNames = OpenmrsUtil.getDeclaredFields(PatientIdentifier.class);
1✔
456

457
                List<String> whereConditions = new ArrayList<>();
1✔
458

459

460
                List<String> innerFields = new ArrayList<>();
1✔
461
                StringBuilder innerSelect = new StringBuilder(" from patient p1 ");
1✔
462

463
                for (String attribute : attributes) {
1✔
464
                        if (attribute != null) {
1✔
465
                                attribute = attribute.trim();
1✔
466
                        }
467
                        if (patientFieldNames.contains(attribute)) {
1✔
468

469
                                AbstractEntityPersister aep = (AbstractEntityPersister) sessionFactory.getClassMetadata(Patient.class);
×
470
                                String[] properties = aep.getPropertyColumnNames(attribute);
×
471
                                if (properties.length >= 1) {
×
472
                                        attribute = properties[0];
×
473
                                }
474
                                whereConditions.add(" t1." + attribute + t5 + attribute);
×
475
                                innerFields.add("p1." + attribute);
×
476
                        } else if (personFieldNames.contains(attribute)) {
1✔
477
                                // check if outerSelect contains 'person' word, surrounded by spaces.
478
                                // otherwise it will wrongly match for example: 'person_name' etc.
479
                                if (!Arrays.asList(outerSelect.toString().split("\\s+")).contains("person")) {
1✔
480
                                        outerSelect.append("inner join person t2 on t1.patient_id = t2.person_id ");
1✔
481
                                        innerSelect.append("inner join person person1 on p1.patient_id = person1.person_id ");
1✔
482
                                }
483

484
                                AbstractEntityPersister aep = (AbstractEntityPersister) sessionFactory.getClassMetadata(Person.class);
1✔
485
                                if (aep != null) {
1✔
486
                                        String[] properties = aep.getPropertyColumnNames(attribute);
1✔
487
                                        if (properties != null && properties.length >= 1) {
1✔
488
                                                attribute = properties[0];
1✔
489
                                        }
490
                                }
491

492
                                whereConditions.add(" t2." + attribute + t5 + attribute);
1✔
493
                                innerFields.add("person1." + attribute);
1✔
494
                        } else if (personNameFieldNames.contains(attribute)) {
1✔
495
                                if (!outerSelect.toString().contains("person_name")) {
1✔
496
                                        outerSelect.append("inner join person_name t3 on t1.patient_id = t3.person_id ");
1✔
497
                                        innerSelect.append("inner join person_name pn1 on p1.patient_id = pn1.person_id ");
1✔
498
                                }
499

500
                                //Since we are firing a native query get the actual table column name from the field name of the entity
501
                                AbstractEntityPersister aep = (AbstractEntityPersister) sessionFactory
1✔
502
                                                .getClassMetadata(PersonName.class);
1✔
503
                                if (aep != null) {
1✔
504
                                        String[] properties = aep.getPropertyColumnNames(attribute);
1✔
505

506
                                        if (properties != null && properties.length >= 1) {
1✔
507
                                                attribute = properties[0];
1✔
508
                                        }
509
                                }
510

511
                                whereConditions.add(" t3." + attribute + t5 + attribute);
1✔
512
                                innerFields.add("pn1." + attribute);
1✔
513
                        } else if (identifierFieldNames.contains(attribute)) {
1✔
514
                                if (!outerSelect.toString().contains("patient_identifier")) {
1✔
515
                                        outerSelect.append("inner join patient_identifier t4 on t1.patient_id = t4.patient_id ");
1✔
516
                                        innerSelect.append("inner join patient_identifier pi1 on p1.patient_id = pi1.patient_id ");
1✔
517
                                }
518

519
                                AbstractEntityPersister aep = (AbstractEntityPersister) sessionFactory
1✔
520
                                                .getClassMetadata(PatientIdentifier.class);
1✔
521
                                if (aep != null) {
1✔
522

523
                                        String[] properties = aep.getPropertyColumnNames(attribute);
1✔
524
                                        if (properties != null && properties.length >= 1) {
1✔
525
                                                attribute = properties[0];
1✔
526
                                        }
527
                                }
528

529
                                whereConditions.add(" t4." + attribute + t5 + attribute);
1✔
530
                                innerFields.add("pi1." + attribute);
1✔
531
                        } else {
1✔
532
                                log.warn("Unidentified attribute: " + attribute);
1✔
533
                        }
534
                }
1✔
535
                if(CollectionUtils.isNotEmpty(innerFields) || CollectionUtils.isNotEmpty(whereConditions)) {
1✔
536
                        String innerFieldsJoined = StringUtils.join(innerFields, ", ");
1✔
537
                        String whereFieldsJoined = StringUtils.join(whereConditions, " and ");
1✔
538
                        String innerWhereCondition = "";
1✔
539
                        if (!attributes.contains("includeVoided")) {
1✔
540
                                innerWhereCondition = " where p1.voided = false ";
1✔
541
                        }
542
                        String innerQuery = "(Select " + innerFieldsJoined + innerSelect + innerWhereCondition + " group by "
1✔
543
                                        + innerFieldsJoined + " having count(*) > 1" + " order by " + innerFieldsJoined + ") t5";
544
                        return outerSelect + ", " + innerQuery + " where " + whereFieldsJoined + ";";
1✔
545
                }
546
                return null;
1✔
547
        }
548

549
        private void sortDuplicatePatients(List<Patient> patients, List<Integer> patientIds) {
550

551
                Map<Integer, Integer> patientIdOrder = new HashMap<>();
1✔
552
                int startPos = 0;
1✔
553
                for (Integer id : patientIds) {
1✔
554
                        patientIdOrder.put(id, startPos++);
1✔
555
                }
1✔
556
                class PatientIdComparator implements Comparator<Patient> {
557

558
                        private Map<Integer, Integer> sortOrder;
559

560
                        public PatientIdComparator(Map<Integer, Integer> sortOrder) {
1✔
561
                                this.sortOrder = sortOrder;
1✔
562
                        }
1✔
563

564
                        @Override
565
                        public int compare(Patient patient1, Patient patient2) {
566
                                Integer patPos1 = sortOrder.get(patient1.getPatientId());
1✔
567
                                if (patPos1 == null) {
1✔
568
                                        throw new IllegalArgumentException("Bad patient encountered: " + patient1.getPatientId());
×
569
                                }
570
                                Integer patPos2 = sortOrder.get(patient2.getPatientId());
1✔
571
                                if (patPos2 == null) {
1✔
572
                                        throw new IllegalArgumentException("Bad patient encountered: " + patient2.getPatientId());
×
573
                                }
574
                                return patPos1.compareTo(patPos2);
1✔
575
                        }
576
                }
577
                patients.sort(new PatientIdComparator(patientIdOrder));
1✔
578
        }
1✔
579
        
580
        /**
581
         * @see org.openmrs.api.db.PatientDAO#getPatientByUuid(java.lang.String)
582
         */
583
        @Override
584
        public Patient getPatientByUuid(String uuid) {
585
                Patient p;
586
                
587
                p = (Patient) sessionFactory.getCurrentSession().createQuery("from Patient p where p.uuid = :uuid").setString(
1✔
588
                    "uuid", uuid).uniqueResult();
1✔
589
                
590
                return p;
1✔
591
        }
592
        
593
        @Override
594
        public PatientIdentifier getPatientIdentifierByUuid(String uuid) {
595
                return (PatientIdentifier) sessionFactory.getCurrentSession().createQuery(
1✔
596
                    "from PatientIdentifier p where p.uuid = :uuid").setString("uuid", uuid).uniqueResult();
1✔
597
        }
598
        
599
        /**
600
         * @see org.openmrs.api.db.PatientDAO#getPatientIdentifierTypeByUuid(java.lang.String)
601
         */
602
        @Override
603
        public PatientIdentifierType getPatientIdentifierTypeByUuid(String uuid) {
604
                return (PatientIdentifierType) sessionFactory.getCurrentSession().createQuery(
1✔
605
                    "from PatientIdentifierType pit where pit.uuid = :uuid").setString("uuid", uuid).uniqueResult();
1✔
606
        }
607
        
608
        /**
609
         * This method uses a SQL query and does not load anything into the hibernate session. It exists
610
         * because of ticket #1375.
611
         *
612
         * @see org.openmrs.api.db.PatientDAO#isIdentifierInUseByAnotherPatient(org.openmrs.PatientIdentifier)
613
         */
614
        @Override
615
        public boolean isIdentifierInUseByAnotherPatient(PatientIdentifier patientIdentifier) {
616
                boolean checkPatient = patientIdentifier.getPatient() != null
1✔
617
                        && patientIdentifier.getPatient().getPatientId() != null;
1✔
618
                boolean checkLocation = patientIdentifier.getLocation() != null
1✔
619
                        && patientIdentifier.getIdentifierType().getUniquenessBehavior() == UniquenessBehavior.LOCATION;
1✔
620
                
621
                // switched this to an hql query so the hibernate cache can be considered as well as the database
622
                String hql = "select count(*) from PatientIdentifier pi, Patient p where pi.patient.patientId = p.patientId "
1✔
623
                        + "and p.voided = false and pi.voided = false and pi.identifier = :identifier and pi.identifierType = :idType";
624
                
625
                if (checkPatient) {
1✔
626
                        hql += " and p.patientId != :ptId";
1✔
627
                }
628
                if (checkLocation) {
1✔
629
                        hql += " and pi.location = :locationId";
1✔
630
                }
631
                
632
                Query query = sessionFactory.getCurrentSession().createQuery(hql);
1✔
633
                query.setString("identifier", patientIdentifier.getIdentifier());
1✔
634
                query.setInteger("idType", patientIdentifier.getIdentifierType().getPatientIdentifierTypeId());
1✔
635
                if (checkPatient) {
1✔
636
                        query.setInteger("ptId", patientIdentifier.getPatient().getPatientId());
1✔
637
                }
638
                if (checkLocation) {
1✔
639
                        query.setInteger("locationId", patientIdentifier.getLocation().getLocationId());
1✔
640
                }
641
                return !"0".equals(query.uniqueResult().toString());
1✔
642
        }
643
        
644
        /**
645
     * @param patientIdentifierId  the patientIdentifier id
646
     * @return                     the patientIdentifier matching the Id
647
         * @see org.openmrs.api.db.PatientDAO#getPatientIdentifier(java.lang.Integer)
648
         */
649
        @Override
650
        public PatientIdentifier getPatientIdentifier(Integer patientIdentifierId) throws DAOException {
651
                
652
                return (PatientIdentifier) sessionFactory.getCurrentSession().get(PatientIdentifier.class, patientIdentifierId);
1✔
653
                
654
        }
655
        
656
        /**
657
     * @param patientIdentifier patientIndentifier to be created or updated
658
     * @return                  patientIndentifier that was created or updated
659
         * @see org.openmrs.api.db.PatientDAO#savePatientIdentifier(org.openmrs.PatientIdentifier)
660
         */
661
        @Override
662
        public PatientIdentifier savePatientIdentifier(PatientIdentifier patientIdentifier) {
663
                
664
                sessionFactory.getCurrentSession().saveOrUpdate(patientIdentifier);
1✔
665
                return patientIdentifier;
1✔
666
                
667
        }
668
        
669
        /**
670
         * @see org.openmrs.api.PatientService#purgePatientIdentifier(org.openmrs.PatientIdentifier)
671
         * @see org.openmrs.api.db.PatientDAO#deletePatientIdentifier(org.openmrs.PatientIdentifier)
672
         */
673
        @Override
674
        public void deletePatientIdentifier(PatientIdentifier patientIdentifier) throws DAOException {
675
                
676
                sessionFactory.getCurrentSession().delete(patientIdentifier);
1✔
677
                
678
        }
1✔
679
        
680
        /**
681
         * @param query  the string to search on
682
         * @return       the number of patients matching the given search phrase
683
         * @see org.openmrs.api.db.PatientDAO#getCountOfPatients(String)
684
         */
685
        @Override
686
        public Long getCountOfPatients(String query) {
687
                return getCountOfPatients(query, false);
1✔
688
        }
689
        
690
        /**
691
         * @param query          the string to search on
692
         * @param includeVoided  true/false whether or not to included voided patients
693
         * @return               the number of patients matching the given search phrase
694
         * @see org.openmrs.api.db.PatientDAO#getCountOfPatients(String, boolean)
695
         */
696
        @Override
697
        public Long getCountOfPatients(String query, boolean includeVoided) {
698
                if (StringUtils.isBlank(query)) {
1✔
699
                        return 0L;
1✔
700
                }
701

702
                PersonQuery personQuery = new PersonQuery();
1✔
703

704
                // Bound the count deduplication to the maximum number of results a patient search can return: an
705
                // exact count past that point costs a full hit-set scan for a total the caller cannot page to.
706
                // The count path only reads unique keys and never maps hits to entities, so these sub-queries are
707
                // declared without a mapper.
708
                return SearchQueryUnique.searchCount(searchSessionFactory,
1✔
709
                    SearchQueryUnique
710
                            .newQuery(PatientIdentifier.class,
1✔
711
                                f -> newPatientIdentifierSearchPredicate(f, query, includeVoided, false), "patient.personId")
1✔
712
                            .join(SearchQueryUnique
1✔
713
                                    .newQuery(PersonName.class, f -> personQuery.getPatientNameQuery(f, query, includeVoided),
1✔
714
                                        "person.personId")
715
                                    .join(SearchQueryUnique.newQuery(PersonAttribute.class,
1✔
716
                                        f -> personQuery.getPatientAttributeQuery(f, query, includeVoided), "person.personId"))),
1✔
717
                    HibernatePersonDAO.getMaximumSearchResults());
1✔
718
        }
719

720
    private List<Patient> findPatients(String query, boolean includeVoided) {
721
                return findPatients(query, includeVoided, null, null);
×
722
        }
723
        
724
        private List<Patient> findPatients(String query, List<PatientIdentifierType> identifierTypes, boolean matchExactly, 
725
                                                                           Integer start, Integer length) {
726
                Integer tmpStart = start;
1✔
727
                
728
                if (tmpStart == null) {
1✔
729
                        tmpStart = 0;
×
730
                }
731
                Integer maxLength = HibernatePersonDAO.getMaximumSearchResults();
1✔
732
                Integer tmpLength = length;
1✔
733
                if (tmpLength == null || tmpLength > maxLength) {
1✔
734
                        tmpLength = maxLength;
×
735
                }
736
                List<Patient> patients = new LinkedList<>();
1✔
737
                
738
                String minChars = Context.getAdministrationService().getGlobalProperty(OpenmrsConstants.GLOBAL_PROPERTY_MIN_SEARCH_CHARACTERS);
1✔
739
                
740
                if (!StringUtils.isNumeric(minChars)) {
1✔
741
                        minChars = "" + OpenmrsConstants.GLOBAL_PROPERTY_DEFAULT_MIN_SEARCH_CHARACTERS;
1✔
742
                }
743
                if (query.length() < Integer.parseInt(minChars)) {
1✔
744
                        return patients;
×
745
                }
746

747
                return SearchQueryUnique.search(searchSessionFactory,
1✔
748
                    SearchQueryUnique.newQuery(PatientIdentifier.class, f -> f.bool().with(b -> {
1✔
749
                            b.must(getPatientIdentifierSearchPredicate(f, query, matchExactly));
1✔
750
                            List<Integer> identifierTypeIds = new ArrayList<Integer>();
1✔
751
                            for (PatientIdentifierType identifierType : identifierTypes) {
1✔
752
                                    identifierTypeIds.add(identifierType.getId());
1✔
753
                            }
1✔
754
                            b.filter(f.terms().field("identifierType.patientIdentifierTypeId").matchingAny(identifierTypeIds));
1✔
755
                            b.filter(f.match().field("patient.isPatient").matching(true));
1✔
756
                            b.filter(f.match().field("voided").matching(false));
1✔
757
                    }).toPredicate(), "patient.personId",
1✔
758
                        (List<PatientIdentifier> pIs) -> multiLoadPatients(pIs, pI -> pI.getPatient().getPersonId())),
1✔
759
                    tmpStart, tmpLength);
760
        }
761
        
762
        public List<Patient> findPatients(String query, boolean includeVoided, Integer start, Integer length) {
763
                Integer tmpStart = start;
1✔
764
                if (tmpStart == null) {
1✔
765
                        tmpStart = 0;
×
766
                }
767
                Integer maxLength = HibernatePersonDAO.getMaximumSearchResults();
1✔
768
                Integer tmpLength = length;
1✔
769
                if (tmpLength == null || tmpLength > maxLength) {
1✔
770
                        tmpLength = maxLength;
1✔
771
                }
772

773
                List<Patient> patients = new LinkedList<>();
1✔
774

775
                String minChars = Context.getAdministrationService().getGlobalProperty(
1✔
776
                        OpenmrsConstants.GLOBAL_PROPERTY_MIN_SEARCH_CHARACTERS);
777

778
                if (!StringUtils.isNumeric(minChars)) {
1✔
779
                        minChars = "" + OpenmrsConstants.GLOBAL_PROPERTY_DEFAULT_MIN_SEARCH_CHARACTERS;
1✔
780
                }
781
                if (query.length() < Integer.parseInt(minChars)) {
1✔
782
                        return patients;
1✔
783
                }
784

785
                PersonQuery personQuery = new PersonQuery();
1✔
786

787
                patients = SearchQueryUnique.search(searchSessionFactory, SearchQueryUnique.newQuery(PatientIdentifier.class,
1✔
788
                    f -> newPatientIdentifierSearchPredicate(f, query, includeVoided, false), "patient.personId",
1✔
789
                    (List<PatientIdentifier> pIs) -> multiLoadPatients(pIs, pI -> pI.getPatient().getPersonId())).join(
1✔
790
                        SearchQueryUnique.newQuery(PersonName.class, f -> personQuery.getPatientNameQuery(f, query, includeVoided),
1✔
791
                            "person.personId", (List<PersonName> pNs) -> multiLoadPatients(pNs, pN -> pN.getPerson().getPersonId()))
1✔
792
                                .join(SearchQueryUnique.newQuery(PersonAttribute.class,
1✔
793
                                    f -> personQuery.getPatientAttributeQuery(f, query, includeVoided), "person.personId",
1✔
794
                                    (List<PersonAttribute> pAs) -> multiLoadPatients(pAs, pA -> pA.getPerson().getPersonId())))),
1✔
795
                    start, length);
796

797
                return patients;
1✔
798
        }
799
        
800
        private SearchPredicate getPatientIdentifierSearchPredicate(SearchPredicateFactory f, String paramQuery, boolean matchExactly) {
801
                List<String> tokens = tokenizeIdentifierQuery(removeIdentifierPadding(paramQuery));
1✔
802
                final String query = StringUtils.join(tokens, " | ");
1✔
803
                //TODO: hibernate search identifierType?
804
                //fields.add("identifierType");
805
                return f.bool().with(b -> {
1✔
806
                        b.minimumShouldMatchNumber(1);
1✔
807
                        b.should(f.simpleQueryString().field("identifierPhrase").matching(query).boost(8f));
1✔
808
                        String matchMode = Context.getAdministrationService()
1✔
809
                                .getGlobalProperty(OpenmrsConstants.GLOBAL_PROPERTY_PATIENT_IDENTIFIER_SEARCH_MATCH_MODE);
1✔
810
                        if (matchExactly) {
1✔
811
                                b.should(f.simpleQueryString().field("identifierExact").matching(query).boost(4f));
1✔
812
                        }
813
                        else if (OpenmrsConstants.GLOBAL_PROPERTY_PATIENT_SEARCH_MATCH_START.equals(matchMode)) {
1✔
814
                                b.should(f.simpleQueryString().field("identifierStart").matching(query).boost(2f));
1✔
815
                        }
816
                        else  {
817
                                b.should(f.simpleQueryString().field("identifierAnywhere").matching(query));
1✔
818
                        }
819
                }).toPredicate();
1✔
820
        
821
        }
822

823
        private SearchPredicate newPatientIdentifierSearchPredicate(SearchPredicateFactory predicateFactory, String query, boolean includeVoided, boolean matchExactly) {
824
                return predicateFactory.bool().with(b -> {
1✔
825
                        b.must(getPatientIdentifierSearchPredicate(predicateFactory, query, matchExactly));
1✔
826

827
                        if (!includeVoided) {
1✔
828
                                b.filter(predicateFactory.match().field("voided").matching(false));
1✔
829
                                b.filter(predicateFactory.match().field("patient.voided").matching(false));
1✔
830
                        }
831
                        b.filter(predicateFactory.match().field("patient.isPatient").matching(true));
1✔
832
                }).toPredicate();
1✔
833
        }
834

835
        private String removeIdentifierPadding(String query) {
836
                String regex = Context.getAdministrationService().getGlobalProperty(OpenmrsConstants.GLOBAL_PROPERTY_PATIENT_IDENTIFIER_REGEX, "");
1✔
837
                if (Pattern.matches("^\\^.{1}\\*.*$", regex)) {
1✔
838
                        String padding = regex.substring(regex.indexOf("^") + 1, regex.indexOf("*"));
1✔
839
                        Pattern pattern = Pattern.compile("^" + padding + "+");
1✔
840
                        query = pattern.matcher(query).replaceFirst("");
1✔
841
                }
842
                return query;
1✔
843
        }
844

845
        /**
846
         * Copied over from PatientSearchCriteria...
847
         *
848
         * I have no idea how it is supposed to work, but tests pass...
849
         *
850
         * @param query
851
         * @return
852
         * @see PatientSearchCriteria
853
         */
854
        private List<String> tokenizeIdentifierQuery(String query) {
855
                List<String> searchPatterns = new ArrayList<>();
1✔
856

857
                String patternSearch = Context.getAdministrationService().getGlobalProperty(
1✔
858
                                OpenmrsConstants.GLOBAL_PROPERTY_PATIENT_IDENTIFIER_SEARCH_PATTERN, "");
859

860
                if (StringUtils.isBlank(patternSearch)) {
1✔
861
                        searchPatterns.add(query);
1✔
862
                } else {
863
                        // split the pattern before replacing in case the user searched on a comma
864
                        // replace the @SEARCH@, etc in all elements
865
                        for (String pattern : patternSearch.split(",")) {
1✔
866
                                searchPatterns.add(replaceSearchString(pattern, query));
1✔
867
                        }
868
                }
869
                return searchPatterns;
1✔
870
        }
871

872
        /**
873
         * Copied over from PatientSearchCriteria...
874
         *
875
         * I have no idea how it is supposed to work, but tests pass...
876
         *
877
         * Puts @SEARCH@, @SEARCH-1@, and @CHECKDIGIT@ into the search string
878
         *
879
         * @param regex the admin-defined search string containing the @..@'s to be replaced
880
         * @param identifierSearched the user entered search string
881
         * @return substituted search strings.
882
         *
883
         * @see PatientSearchCriteria#replaceSearchString(String, String)
884
         */
885
        private String replaceSearchString(String regex, String identifierSearched) {
886
                String returnString = regex.replaceAll("@SEARCH@", identifierSearched);
1✔
887
                if (identifierSearched.length() > 1) {
1✔
888
                        // for 2 or more character searches, we allow regex to use last character as check digit
889
                        returnString = returnString.replaceAll("@SEARCH-1@", identifierSearched.substring(0,
1✔
890
                                        identifierSearched.length() - 1));
1✔
891
                        returnString = returnString.replaceAll("@CHECKDIGIT@", identifierSearched
1✔
892
                                        .substring(identifierSearched.length() - 1));
1✔
893
                } else {
894
                        returnString = returnString.replaceAll("@SEARCH-1@", "");
×
895
                        returnString = returnString.replaceAll("@CHECKDIGIT@", "");
×
896
                }
897
                return returnString;
1✔
898
        }
899

900
    /**
901
         * @see org.openmrs..api.db.PatientDAO#getAllergies(org.openmrs.Patient)
902
         */
903
        //@Override
904
        @Override
905
        public List<Allergy> getAllergies(Patient patient) {
906
                Criteria criteria = sessionFactory.getCurrentSession().createCriteria(Allergy.class);
1✔
907
                criteria.add(Restrictions.eq("patient", patient));
1✔
908
                criteria.add(Restrictions.eq("voided", false));
1✔
909
                return criteria.list();
1✔
910
        }
911
        
912
        /**
913
         * @see org.openmrs.api.db.PatientDAO#getAllergyStatus(org.openmrs.Patient)
914
         */
915
        //@Override
916
        @Override
917
        public String getAllergyStatus(Patient patient) {
918

919
                return (String) sessionFactory.getCurrentSession().createSQLQuery(
1✔
920
                            "select allergy_status from patient where patient_id = :patientId").setInteger("patientId", patient.getPatientId()).uniqueResult();
1✔
921
        }
922
        
923
        /**
924
         * @see org.openmrs.api.db.PatientDAO#saveAllergies(org.openmrs.Patient,
925
         *      org.openmrsallergyapi.Allergies)
926
         */
927
        @Override
928
        public Allergies saveAllergies(Patient patient, Allergies allergies) {
929

930
                sessionFactory.getCurrentSession().createSQLQuery(
1✔
931
                            "update patient set allergy_status = :allergyStatus where patient_id = :patientId")
932
                            .setInteger("patientId", patient.getPatientId())
1✔
933
                            .setString("allergyStatus", allergies.getAllergyStatus())
1✔
934
                            .executeUpdate();
1✔
935
                
936
                for (Allergy allergy : allergies) {
1✔
937
                        sessionFactory.getCurrentSession().save(allergy);
1✔
938
                }
1✔
939
                        
940
                return allergies;
1✔
941
        }
942
        
943
        /**
944
         * @see org.openmrs.PatientDAO#getAllergy(Integer)
945
         */
946
        @Override
947
        public Allergy getAllergy(Integer allergyId) {
948
                return (Allergy) sessionFactory.getCurrentSession().createQuery("from Allergy a where a.allergyId = :allergyId")
×
949
                                .setInteger("allergyId", allergyId).uniqueResult();
×
950
        }
951
        
952
        /**
953
         * @see org.openmrs.PatientDAO#getAllergyByUuid(String)
954
         */
955
        @Override
956
        public Allergy getAllergyByUuid(String uuid) {
957
                return (Allergy) sessionFactory.getCurrentSession().createQuery("from Allergy a where a.uuid = :uuid")
1✔
958
                                .setString("uuid", uuid).uniqueResult();
1✔
959
        }
960

961
        /**
962
     * @see org.openmrs.api.db.PatientDAO#saveAllergy(org.openmrs.Allergy)
963
     */
964
    @Override
965
    public Allergy saveAllergy(Allergy allergy) {
966
            sessionFactory.getCurrentSession().save(allergy);
1✔
967
            return allergy;
1✔
968
    }
969

970

971
    /**
972
     * @see org.openmrs.api.db.PatientDAO#getPatientIdentifierByProgram(org.openmrs.PatientProgram)
973
     */
974
    public List<PatientIdentifier> getPatientIdentifierByProgram(PatientProgram patientProgram) {
975
                Criteria criteria = sessionFactory.getCurrentSession().createCriteria(PatientIdentifier.class);
1✔
976
                criteria.add(Restrictions.eq("patientProgram", patientProgram));
1✔
977
                return criteria.list();
1✔
978
        }
979

980
        /**
981
         * Hydrates a page of full-text search hits into their patients in a single order-preserving
982
         * {@code multiLoad} instead of one {@code get} per hit, so a page issues a bounded number of
983
         * entity loads. Loading the whole page in one operation also lets the eagerly-fetched person names,
984
         * addresses, and attributes be read in a bounded number of batched selects across the page rather
985
         * than a separate set of selects for every patient. Hits whose patient is no longer present in the
986
         * database (a stale search-index entry) are dropped from the results.
987
         *
988
         * @param hits the page of search hits, in the order they should appear in the results
989
         * @param patientIdExtractor extracts the patient id from a hit
990
         * @param <S> the search hit type
991
         * @return the patients for the page, in hit order, with missing rows removed
992
         */
993
        private <S> List<Patient> multiLoadPatients(List<S> hits, Function<S, Integer> patientIdExtractor) {
994
                List<Integer> patientIds = hits.stream().map(patientIdExtractor).collect(Collectors.toList());
1✔
995
                List<Patient> patients = sessionFactory.getCurrentSession().byMultipleIds(Patient.class).multiLoad(patientIds).stream()
1✔
996
                        .filter(Objects::nonNull).collect(Collectors.toList());
1✔
997
                if (patients.size() < patientIds.size()) {
1✔
NEW
998
                        log.debug("Dropped {} patient search hit(s) with no matching row (stale search index?)",
×
NEW
999
                            patientIds.size() - patients.size());
×
1000
                }
1001
                return patients;
1✔
1002
        }
1003
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc