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

openmrs / openmrs-core / 29592249542

17 Jul 2026 02:16PM UTC coverage: 66.061% (+0.09%) from 65.967%
29592249542

push

github

ibacher
TRUNK-6688: Follow-up: run Mockito's inline MockMaker on older JVMs

24319 of 36813 relevant lines covered (66.06%)

0.66 hits per line

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

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

12
import java.util.ArrayList;
13
import java.util.Collection;
14
import java.util.HashSet;
15
import java.util.LinkedHashSet;
16
import java.util.List;
17
import java.util.Set;
18
import java.util.function.Function;
19
import java.util.stream.Collectors;
20

21
import org.apache.lucene.search.BooleanQuery;
22
import org.hibernate.search.engine.search.predicate.SearchPredicate;
23
import org.hibernate.search.engine.search.predicate.dsl.SearchPredicateFactory;
24
import org.hibernate.search.engine.search.query.SearchQuery;
25
import org.hibernate.search.engine.search.query.SearchScroll;
26
import org.hibernate.search.engine.search.query.SearchScrollResult;
27
import org.hibernate.search.mapper.orm.scope.SearchScope;
28
import org.hibernate.search.mapper.orm.session.SearchSession;
29
import org.openmrs.api.db.hibernate.search.session.SearchSessionFactory;
30

31
/**
32
 * Provides methods for removing duplicate search results based on the given uniqueKey and combining
33
 * search results from multiple queries.
34
 * <p>
35
 * When fetching results, deduplication is bounded to the requested page: the follow-up filtering
36
 * query removes at most <code>max = {@link BooleanQuery#getMaxClauseCount()} / 2.5</code> duplicate
37
 * keys, and up to <code>max</code> unique keys from previous queries are removed from results in
38
 * the following queries.
39
 * <p>
40
 * When calculating a total hit count, deduplication is exact by default but can be bounded by the
41
 * caller via {@link #searchCount(SearchSessionFactory, SearchQueryUnique, int)}: the count is exact
42
 * while the number of distinct hits stays within the given cap, and degrades to a raw upper bound
43
 * (which counts duplicates) above it. This lets an expensive search (e.g. patient search over a
44
 * huge hit set) bound the count cost while other callers keep exact counts.
45
 *
46
 * @param <T> query scope
47
 * @param <R> query return type
48
 * @since 2.8.0
49
 */
50
public class SearchQueryUnique<T, R> {
51

52
        /**
53
         * Cap value for {@link #searchCount(SearchSessionFactory, SearchQueryUnique, int)} that keeps the
54
         * total hit count exact by never falling back to the raw upper bound.
55
         */
56
        public static final int UNBOUNDED_DEDUPLICATION = Integer.MAX_VALUE;
57

58
        /**
59
         * The number of hits to pull from the index per scroll iteration when deduplicating.
60
         */
61
        static final int DEFAULT_SCROLL_CHUNK_SIZE = 500;
62

63
        Class<? extends T> scope;
64

65
        Function<SearchPredicateFactory, SearchPredicate> search;
66

67
        Function<T, R> mapper;
68

69
        String uniqueKey;
70

71
        SearchQueryUnique<?, R> joinedQuery;
72

73
        public SearchQueryUnique(Class<? extends T> scope, Function<SearchPredicateFactory, SearchPredicate> search,
74
            String uniqueKey, Function<T, R> mapper, SearchQueryUnique<?, R> joinedQuery) {
1✔
75
                this.scope = scope;
1✔
76
                this.search = search;
1✔
77
                this.mapper = mapper;
1✔
78
                this.uniqueKey = uniqueKey;
1✔
79
                this.joinedQuery = joinedQuery;
1✔
80
        }
1✔
81

82
        public Class<? extends T> getScope() {
83
                return scope;
1✔
84
        }
85

86
        public Function<SearchPredicateFactory, SearchPredicate> getSearch() {
87
                return search;
1✔
88
        }
89

90
        public Function<T, R> getMapper() {
91
                return mapper;
1✔
92
        }
93

94
        public String getUniqueKey() {
95
                return uniqueKey;
1✔
96
        }
97

98
        public SearchQueryUnique<?, R> getJoinedQuery() {
99
                return joinedQuery;
1✔
100
        }
101

102
        /**
103
         * When joining a query, the algorithm will use the unique key values from the previous query (if
104
         * any) to filter out items using its unique key.
105
         *
106
         * @param query the query to join
107
         * @return joined queries
108
         */
109
        public SearchQueryUnique<?, R> join(SearchQueryUnique<?, R> query) {
110
                joinedQuery = query;
1✔
111
                return this;
1✔
112
        }
113

114
        /**
115
         * Creates a new query for {@link #search(SearchSessionFactory, SearchQueryUnique)}
116
         *
117
         * @param scope the index type to be searched
118
         * @param search the search predicate
119
         * @param uniqueKey the field to use as unique key, or <code>null</code> if none
120
         * @param mapper the mapper from index type to result type, or <code>null</code> if none
121
         * @return the search
122
         * @param <T> the index type
123
         * @param <R> the result type
124
         */
125
        public static <T, R> SearchQueryUnique<T, R> newQuery(Class<? extends T> scope,
126
                Function<SearchPredicateFactory, SearchPredicate> search, String uniqueKey, Function<T, R> mapper) {
127
                return new SearchQueryUnique<>(scope, search, uniqueKey, mapper, null);
1✔
128
        }
129

130
        /**
131
         * See {@link #newQuery(Class, Function, String, Function)}.
132
         *
133
         * @param scope thh index type to be searched
134
         * @param search the search predicate
135
         * @param uniqueKey the field to use as unique key, or <code>null</code> if none
136
         * @return the search
137
         * @param <T> the index type
138
         * @param <R> the result type
139
         */
140
        public static <T, R> SearchQueryUnique<T, R> newQuery(Class<? extends T> scope,
141
                Function<SearchPredicateFactory, SearchPredicate> search, String uniqueKey) {
142
                return new SearchQueryUnique<>(scope, search, uniqueKey, null, null);
1✔
143
        }
144

145
        public static class SearchUniqueResults<T> {
146

147
                List<T> results;
148

149
                Integer offset;
150

151
                Integer limit;
152

153
                Long totalHitCount;
154

155
                public SearchUniqueResults(List<T> results, Integer offset, Integer limit, Long totalHitCount) {
1✔
156
                        this.results = results;
1✔
157
                        this.offset = offset;
1✔
158
                        this.limit = limit;
1✔
159
                        this.totalHitCount = totalHitCount;
1✔
160
                }
1✔
161

162
                public List<T> getResults() {
163
                        return results;
1✔
164
                }
165

166
                public Integer getOffset() {
167
                        return offset;
×
168
                }
169

170
                public Integer getLimit() {
171
                        return limit;
×
172
                }
173

174
                public Long getTotalHitCount() {
175
                        return totalHitCount;
×
176
                }
177
        }
178

179
        /**
180
         * Runs the search calculating the exact total hit count only.
181
         *
182
         * @param searchSessionFactory SearchSessionFactory
183
         * @param uniqueQuery unique query {@link #newQuery(Class, Function, String, Function)}
184
         * @return the total hit count
185
         */
186
        public static Long searchCount(SearchSessionFactory searchSessionFactory, SearchQueryUnique<?, ?> uniqueQuery) {
187
                return searchCount(searchSessionFactory, uniqueQuery, UNBOUNDED_DEDUPLICATION);
1✔
188
        }
189

190
        /**
191
         * Runs the search calculating the total hit count only, bounding the exact deduplication to
192
         * <code>deduplicationCap</code> distinct hits. The count is exact while the number of distinct hits
193
         * stays within the cap; above it the raw hit count (which counts duplicates, so an upper bound) is
194
         * returned instead of scanning the whole hit set. Pass {@link #UNBOUNDED_DEDUPLICATION} for an
195
         * always exact count.
196
         *
197
         * @param searchSessionFactory SearchSessionFactory
198
         * @param uniqueQuery unique query {@link #newQuery(Class, Function, String, Function)}
199
         * @param deduplicationCap the number of distinct hits to deduplicate exactly before falling back to
200
         *            the raw upper bound
201
         * @return the total hit count
202
         */
203
        public static Long searchCount(SearchSessionFactory searchSessionFactory, SearchQueryUnique<?, ?> uniqueQuery,
204
                int deduplicationCap) {
205
                return searchTotalHitCount(searchSessionFactory.getSearchSession(), uniqueQuery, deduplicationCap);
1✔
206
        }
207

208
        /**
209
         * Runs the search without calculating the total hit count. See
210
         * {@link #search(SearchSessionFactory, SearchQueryUnique, Integer, Integer, Boolean)}.
211
         *
212
         * @param searchSessionFactory SearchSessionFactory
213
         * @param uniqueQuery unique query {@link #newQuery(Class, Function, String, Function)}
214
         * @return the results
215
         * @param <T> the type of results
216
         */
217
        public static <T> List<T> search(SearchSessionFactory searchSessionFactory, SearchQueryUnique<?, T> uniqueQuery) {
218
                return search(searchSessionFactory, uniqueQuery, null, null, false).getResults();
1✔
219
        }
220

221
        /**
222
         * Runs the search without calculating the total hit count. See
223
         * {@link #search(SearchSessionFactory, SearchQueryUnique, Integer, Integer, Boolean)}.
224
         *
225
         * @param searchSessionFactory SearchSessionFactory
226
         * @param uniqueQuery unique query {@link #newQuery(Class, Function, String, Function)}
227
         * @param offset offset of results
228
         * @param limit limit of results
229
         * @return the results
230
         * @param <T> the type of results
231
         */
232
        public static <T> List<T> search(SearchSessionFactory searchSessionFactory, SearchQueryUnique<?, T> uniqueQuery,
233
                Integer offset, Integer limit) {
234
                return search(searchSessionFactory, uniqueQuery, offset, limit, false).getResults();
1✔
235
        }
236

237
        /**
238
         * Executes unique queries applying joins and mapping to the result type.
239
         * <p>
240
         * When <code>includeTotalHitCount</code> is <code>true</code> only the exact total hit count is
241
         * calculated and the returned results are empty; use
242
         * {@link #searchCount(SearchSessionFactory, SearchQueryUnique, int)} to bound the count cost.
243
         * Otherwise the requested page of results is returned and the total hit count is <code>null</code>.
244
         *
245
         * @param searchSessionFactory search session factory
246
         * @param uniqueQuery unique query {@link #newQuery(Class, Function, String, Function)}
247
         * @param offset offset of results
248
         * @param limit limit of results
249
         * @param includeTotalHitCount calculate the total hit count instead of fetching results
250
         * @return the results
251
         * @param <T> the type of results
252
         */
253
        public static <T> SearchUniqueResults<T> search(SearchSessionFactory searchSessionFactory,
254
                SearchQueryUnique<?, T> uniqueQuery, final Integer offset, final Integer limit, Boolean includeTotalHitCount) {
255
                SearchSession searchSession = searchSessionFactory.getSearchSession();
1✔
256

257
                if (Boolean.TRUE.equals(includeTotalHitCount)) {
1✔
258
                        // The count path only needs the total number of distinct hits, so it skips fetching and
259
                        // hydrating a page of results entirely.
260
                        long totalHitCount = searchTotalHitCount(searchSession, uniqueQuery, UNBOUNDED_DEDUPLICATION);
×
261
                        return new SearchUniqueResults<>(new ArrayList<>(), offset, limit, totalHitCount);
×
262
                }
263

264
                return searchWithResults(searchSession, uniqueQuery, offset, limit);
1✔
265
        }
266

267
        /**
268
         * Fetches the requested page of deduplicated results, walking the joined queries in order.
269
         * <p>
270
         * The deduplication scroll for each query is bounded to the requested page: it stops once
271
         * <code>offset + limit</code> unique keys have been found, because any hit beyond that point sorts
272
         * after the page and therefore cannot appear in it. This keeps the work proportional to the page
273
         * size rather than to the full hit set.
274
         * <p>
275
         * This bound relies on the deduplication scroll and the page fetch producing hits in the same
276
         * order. Both queries are left unsorted so they default to descending score, and the fetch's filter
277
         * clauses do not affect scoring; do not add an explicit sort to one without the other, or the bound
278
         * no longer holds.
279
         */
280
        private static <T> SearchUniqueResults<T> searchWithResults(SearchSession searchSession,
281
                SearchQueryUnique<?, T> uniqueQuery, final Integer offset, final Integer limit) {
282
                List<T> results = new ArrayList<>();
1✔
283
                Set<Object> uniqueKeys = new LinkedHashSet<>(); // Preserve the order
1✔
284
                final int maxClauseCount = Math.round(BooleanQuery.getMaxClauseCount() / 2.5f);
1✔
285
                SearchQueryUnique<?, T> nextQuery = uniqueQuery;
1✔
286
                Integer currentOffset = offset;
1✔
287
                Integer currentLimit = limit;
1✔
288
                while (nextQuery != null) {
1✔
289
                        // A sub-query that is followed by another must define a unique key: the join deduplicates
290
                        // across sub-queries by unique key, and the offset carried to the next sub-query is derived
291
                        // from the number of unique keys this one contributed. A null key would leave both unsupported,
292
                        // so fail loudly rather than silently mis-paginate. All current callers satisfy this.
293
                        if (nextQuery.getJoinedQuery() != null && nextQuery.getUniqueKey() == null) {
1✔
294
                                throw new IllegalStateException("A joined SearchQueryUnique sub-query must define a unique key");
1✔
295
                        }
296

297
                        SearchScope<?> scope = searchSession.scope(nextQuery.getScope());
1✔
298
                        SearchPredicateFactory predicateFactory = scope.predicate();
1✔
299
                        SearchPredicate searchPredicate = nextQuery.getSearch().apply(predicateFactory);
1✔
300
                        SearchQuery<?> query;
301

302
                        final Collection<Object> previousQueryUniqueKeys = new ArrayList<>(uniqueKeys);
1✔
303
                        DeduplicationResult dedup = null;
1✔
304

305
                        if (nextQuery.getUniqueKey() != null) {
1✔
306
                                final String uniqueKey = nextQuery.getUniqueKey();
1✔
307
                                // Find unique keys and duplicate ids
308
                                SearchQuery<List<?>> uniqueKeyQuery = searchSession.search(scope)
1✔
309
                                        .select(f -> f.composite(f.field(uniqueKey), f.id())).where(searchPredicate).toQuery();
1✔
310

311
                                // A null limit means "fetch everything", so scan the whole hit set; otherwise only scan
312
                                // far enough to fill the requested page.
313
                                final Integer maxNewUniqueKeys;
314
                                if (currentLimit == null) {
1✔
315
                                        maxNewUniqueKeys = null;
1✔
316
                                } else {
317
                                        maxNewUniqueKeys = (currentOffset == null ? 0 : currentOffset) + currentLimit;
1✔
318
                                }
319
                                dedup = collectDuplicateIds(uniqueKeyQuery, uniqueKeys, maxClauseCount, maxNewUniqueKeys,
1✔
320
                                    DEFAULT_SCROLL_CHUNK_SIZE);
321
                                final List<Object> duplicateIds = dedup.duplicateIds;
1✔
322

323
                                if (uniqueKeys.size() > maxClauseCount) {
1✔
324
                                        // Keep it a Set so later queries can still detect duplicates; a plain List here would
325
                                        // make add() always return true and defeat deduplication for subsequent joined queries.
326
                                        uniqueKeys = new LinkedHashSet<>(new ArrayList<>(uniqueKeys).subList(0, maxClauseCount));
1✔
327
                                }
328

329
                                query = searchSession.search(scope).where(f -> f.bool().with(b -> {
1✔
330
                                        b.must(searchPredicate);
1✔
331
                                        if (!duplicateIds.isEmpty()) {
1✔
332
                                                b.filter(f.not(f.id().matchingAny(duplicateIds)));
1✔
333
                                        }
334
                                        // Get rid of unique keys that were added to results in a previous query
335
                                        if (!previousQueryUniqueKeys.isEmpty()) {
1✔
336
                                                b.filter(f.not(f.terms().field(uniqueKey).matchingAny(previousQueryUniqueKeys)));
1✔
337
                                        }
338
                                })).toQuery();
1✔
339
                        } else {
1✔
340
                                query = searchSession.search(scope).where(searchPredicate).toQuery();
×
341
                        }
342

343
                        List<?> partialResults;
344
                        if (currentOffset != null) {
1✔
345
                                partialResults = query.fetchHits(currentOffset, currentLimit);
1✔
346
                        } else if (currentLimit != null) {
1✔
347
                                partialResults = query.fetchHits(currentLimit);
1✔
348
                        } else {
349
                                partialResults = query.fetchAllHits();
1✔
350
                        }
351

352
                        if (!partialResults.isEmpty()) {
1✔
353
                                if (nextQuery.getMapper() != null) {
1✔
354
                                        //noinspection unchecked
355
                                        results.addAll(partialResults.stream().map((Function<Object, T>) nextQuery.getMapper())
1✔
356
                                                .collect(Collectors.toList()));
1✔
357
                                } else {
358
                                        //noinspection unchecked
359
                                        results.addAll((Collection<? extends T>) partialResults);
×
360
                                }
361
                        }
362

363
                        if (limit != null && results.size() == limit) {
1✔
364
                                // The requested page is full, so there is no need to visit the remaining queries.
365
                                return new SearchUniqueResults<>(results, offset, limit, null);
1✔
366
                        }
367

368
                        // The page was not filled by this query, so carry the remaining offset/limit to the next
369
                        // query. Because the page is not full, this query's whole deduplicated hit set was consumed,
370
                        // which is exactly the number of new unique keys the scroll found (barring the pathological
371
                        // maxClauseCount duplicate-id cap, which can stop the scroll early and understate the count for
372
                        // queries with more than maxClauseCount duplicates). The guard above guarantees dedup != null
373
                        // whenever there is a joined query to carry to.
374
                        if (nextQuery.getJoinedQuery() != null) {
1✔
375
                                int consumed = dedup.newUniqueKeyCount;
1✔
376
                                if (currentLimit != null) {
1✔
377
                                        currentLimit = limit - results.size();
1✔
378
                                }
379
                                if (currentOffset != null) {
1✔
380
                                        currentOffset = Math.max(0, currentOffset - consumed);
1✔
381
                                }
382
                        }
383

384
                        nextQuery = nextQuery.getJoinedQuery();
1✔
385
                }
1✔
386

387
                return new SearchUniqueResults<>(results, offset, limit, null);
1✔
388
        }
389

390
        /**
391
         * Calculates the total number of distinct hits across the joined queries.
392
         * <p>
393
         * Deduplication is done by collecting distinct unique-key values from an index-level projection,
394
         * which avoids hydrating hits and avoids the follow-up filtering query used on the results path.
395
         * <p>
396
         * The exact deduplication is bounded by <code>cap</code>: once more than <code>cap</code> distinct
397
         * hits have been seen, the scroll stops and the raw hit count (which counts duplicates and so is an
398
         * upper bound on the distinct count) is returned instead of scanning the whole hit set. This lets a
399
         * caller keep the count exact for the result sizes users actually paginate through while bounding
400
         * the cost for very large hit sets; {@link #UNBOUNDED_DEDUPLICATION} keeps it always exact.
401
         */
402
        private static long searchTotalHitCount(SearchSession searchSession, SearchQueryUnique<?, ?> uniqueQuery, int cap) {
403
                final Set<Object> uniqueKeys = new HashSet<>();
1✔
404
                long nonUniqueHitCount = 0;
1✔
405
                boolean exceededCap = false;
1✔
406
                SearchQueryUnique<?, ?> nextQuery = uniqueQuery;
1✔
407
                while (nextQuery != null && !exceededCap) {
1✔
408
                        if (nextQuery.getUniqueKey() != null) {
1✔
409
                                SearchScope<?> scope = searchSession.scope(nextQuery.getScope());
1✔
410
                                SearchPredicate searchPredicate = nextQuery.getSearch().apply(scope.predicate());
1✔
411
                                final String uniqueKey = nextQuery.getUniqueKey();
1✔
412
                                SearchQuery<?> keyQuery = searchSession.search(scope).select(f -> f.field(uniqueKey)).where(searchPredicate)
1✔
413
                                        .toQuery();
1✔
414
                                collectUniqueKeys(keyQuery, uniqueKeys, cap, DEFAULT_SCROLL_CHUNK_SIZE);
1✔
415
                                exceededCap = uniqueKeys.size() > cap;
1✔
416
                        } else {
1✔
417
                                // Without a unique key we cannot deduplicate, so fall back to the raw hit count.
418
                                nonUniqueHitCount += fetchTotalHitCount(searchSession, nextQuery);
×
419
                        }
420
                        nextQuery = nextQuery.getJoinedQuery();
1✔
421
                }
422

423
                if (!exceededCap) {
1✔
424
                        return uniqueKeys.size() + nonUniqueHitCount;
1✔
425
                }
426

427
                // There are more than `cap` distinct hits, so return the raw hit count as an upper bound rather
428
                // than scanning the entire hit set to determine the exact distinct count.
429
                return rawHitCount(searchSession, uniqueQuery);
1✔
430
        }
431

432
        /**
433
         * Returns the raw (non-deduplicated) total hit count across the joined queries. This counts
434
         * documents that share a unique key more than once, so it is an upper bound on the distinct count.
435
         */
436
        private static long rawHitCount(SearchSession searchSession, SearchQueryUnique<?, ?> uniqueQuery) {
437
                long totalHitCount = 0;
1✔
438
                SearchQueryUnique<?, ?> nextQuery = uniqueQuery;
1✔
439
                while (nextQuery != null) {
1✔
440
                        totalHitCount += fetchTotalHitCount(searchSession, nextQuery);
1✔
441
                        nextQuery = nextQuery.getJoinedQuery();
1✔
442
                }
443
                return totalHitCount;
1✔
444
        }
445

446
        /**
447
         * Returns the raw (non-deduplicated) hit count for a single query.
448
         */
449
        private static long fetchTotalHitCount(SearchSession searchSession, SearchQueryUnique<?, ?> query) {
450
                SearchScope<?> scope = searchSession.scope(query.getScope());
1✔
451
                SearchPredicate searchPredicate = query.getSearch().apply(scope.predicate());
1✔
452
                return searchSession.search(scope).where(searchPredicate).fetchTotalHitCount();
1✔
453
        }
454

455
        /**
456
         * Scrolls the unique-key projection of the given query, adding each key to <code>uniqueKeys</code>
457
         * until more than <code>cap</code> distinct keys have been collected.
458
         *
459
         * @param keyQuery the unique-key projection query to scroll
460
         * @param uniqueKeys the running set of unique keys, mutated in place
461
         * @param cap the number of distinct keys to collect before stopping
462
         * @param chunkSize the scroll chunk size
463
         * @return the number of hits scanned before stopping
464
         */
465
        static int collectUniqueKeys(SearchQuery<?> keyQuery, Set<Object> uniqueKeys, int cap, int chunkSize) {
466
                int scannedHitCount = 0;
1✔
467
                try (SearchScroll<?> scroll = keyQuery.scroll(chunkSize)) {
1✔
468
                        SearchScrollResult<?> chunk = scroll.next();
1✔
469
                        scan: while (chunk.hasHits()) {
1✔
470
                                for (Object key : chunk.hits()) {
1✔
471
                                        scannedHitCount++;
1✔
472
                                        uniqueKeys.add(key);
1✔
473
                                        if (uniqueKeys.size() > cap) {
1✔
474
                                                break scan;
1✔
475
                                        }
476
                                }
1✔
477
                                chunk = scroll.next();
1✔
478
                        }
479
                }
480
                return scannedHitCount;
1✔
481
        }
482

483
        /**
484
         * Scrolls the (uniqueKey, id) projection of the given query, adding newly seen unique keys to
485
         * <code>uniqueKeys</code> and collecting the ids of documents whose unique key was already seen.
486
         * <p>
487
         * The scroll stops early once <code>maxNewUniqueKeys</code> new unique keys have been collected
488
         * (when non-null), bounding the work to the requested page. It also stops once more than
489
         * <code>maxClauseCount</code> duplicate ids have been collected, since those ids become clauses of
490
         * the follow-up filtering query and are subject to the Lucene clause limit.
491
         *
492
         * @param uniqueKeyQuery the (uniqueKey, id) projection query to scroll
493
         * @param uniqueKeys the running set of unique keys, mutated in place
494
         * @param maxClauseCount the maximum number of duplicate ids to collect
495
         * @param maxNewUniqueKeys the number of new unique keys to collect before stopping, or
496
         *            <code>null</code> to scan the whole hit set
497
         * @param chunkSize the scroll chunk size
498
         * @return the collected duplicate ids together with counts describing the scan
499
         */
500
        static DeduplicationResult collectDuplicateIds(SearchQuery<List<?>> uniqueKeyQuery, Set<Object> uniqueKeys,
501
                int maxClauseCount, Integer maxNewUniqueKeys, int chunkSize) {
502
                final List<Object> duplicateIds = new ArrayList<>();
1✔
503
                int newUniqueKeyCount = 0;
1✔
504
                int scannedHitCount = 0;
1✔
505
                try (SearchScroll<List<?>> scroll = uniqueKeyQuery.scroll(chunkSize)) {
1✔
506
                        SearchScrollResult<List<?>> chunk = scroll.next();
1✔
507
                        scan: while (chunk.hasHits()) {
1✔
508
                                for (List<?> match : chunk.hits()) {
1✔
509
                                        scannedHitCount++;
1✔
510
                                        if (uniqueKeys.add(match.get(0))) {
1✔
511
                                                newUniqueKeyCount++;
1✔
512
                                                if (maxNewUniqueKeys != null && newUniqueKeyCount >= maxNewUniqueKeys) {
1✔
513
                                                        // We have enough unique keys to satisfy the requested page.
514
                                                        break scan;
1✔
515
                                                }
516
                                        } else {
517
                                                duplicateIds.add(match.get(1));
1✔
518
                                                if (duplicateIds.size() > maxClauseCount) {
1✔
519
                                                        // stop at max clause count
520
                                                        break scan;
1✔
521
                                                }
522
                                        }
523
                                }
1✔
524
                                chunk = scroll.next();
1✔
525
                        }
526
                }
527
                return new DeduplicationResult(duplicateIds, newUniqueKeyCount, scannedHitCount);
1✔
528
        }
529

530
        /**
531
         * Holds the outcome of {@link #collectDuplicateIds}: the duplicate ids to filter out, the number of
532
         * new unique keys found, and the number of hits actually scanned (which is bounded when the page
533
         * limit is reached).
534
         */
535
        static final class DeduplicationResult {
536

537
                final List<Object> duplicateIds;
538

539
                final int newUniqueKeyCount;
540

541
                /**
542
                 * Total hits scanned before the scroll stopped; exposed only for test assertions on boundedness.
543
                 */
544
                final int scannedHitCount;
545

546
                DeduplicationResult(List<Object> duplicateIds, int newUniqueKeyCount, int scannedHitCount) {
1✔
547
                        this.duplicateIds = duplicateIds;
1✔
548
                        this.newUniqueKeyCount = newUniqueKeyCount;
1✔
549
                        this.scannedHitCount = scannedHitCount;
1✔
550
                }
1✔
551
        }
552

553
        /**
554
         * Finds unique keys for the specified search.
555
         * <p>
556
         * It returns up to <code>max = {@link BooleanQuery#getMaxClauseCount()} / 2</code> unique keys.
557
         *
558
         * @param searchSession searchSession
559
         * @param scope scope
560
         * @param searchPredicate searchPredicate
561
         * @param uniqueKey uniqueKey
562
         * @return unique keys
563
         */
564
        public static List<Object> findUniqueKeys(SearchSession searchSession, SearchScope<?> scope,
565
                SearchPredicate searchPredicate, String uniqueKey) {
566
                // The default Lucene clauses limit is 1024. We arbitrarily choose to use half here as it does
567
                // not make sense to return more hits by concept name anyway.
568
                int maxClauseCount = BooleanQuery.getMaxClauseCount() / 2;
1✔
569
                LinkedHashSet<Object> uniqueKeys = new LinkedHashSet<>(); // Preserve the order
1✔
570

571
                try (SearchScroll<?> scroll = searchSession.search(scope).select(f -> f.field(uniqueKey)).where(searchPredicate)
1✔
572
                        .scroll(500)) {
1✔
573

574
                        SearchScrollResult<?> chunk = scroll.next();
1✔
575
                        while (chunk.hasHits()) {
1✔
576
                                uniqueKeys.addAll(chunk.hits());
1✔
577
                                if (uniqueKeys.size() > maxClauseCount) {
1✔
578
                                        break;
×
579
                                }
580
                                chunk = scroll.next();
1✔
581
                        }
582
                }
583

584
                if (uniqueKeys.size() > maxClauseCount) {
1✔
585
                        return new ArrayList<>(uniqueKeys).subList(0, maxClauseCount);
×
586
                } else {
587
                        return new ArrayList<>(uniqueKeys);
1✔
588
                }
589
        }
590
}
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