001/*-
002 * #%L
003 * HAPI FHIR JPA Server
004 * %%
005 * Copyright (C) 2014 - 2024 Smile CDR, Inc.
006 * %%
007 * Licensed under the Apache License, Version 2.0 (the "License");
008 * you may not use this file except in compliance with the License.
009 * You may obtain a copy of the License at
010 *
011 *      http://www.apache.org/licenses/LICENSE-2.0
012 *
013 * Unless required by applicable law or agreed to in writing, software
014 * distributed under the License is distributed on an "AS IS" BASIS,
015 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
016 * See the License for the specific language governing permissions and
017 * limitations under the License.
018 * #L%
019 */
020package ca.uhn.fhir.jpa.dao.search;
021
022import ca.uhn.fhir.context.FhirContext;
023import ca.uhn.fhir.context.RuntimeSearchParam;
024import ca.uhn.fhir.i18n.Msg;
025import ca.uhn.fhir.jpa.model.entity.NormalizedQuantitySearchLevel;
026import ca.uhn.fhir.jpa.model.entity.StorageSettings;
027import ca.uhn.fhir.jpa.model.util.UcumServiceUtil;
028import ca.uhn.fhir.jpa.search.HapiHSearchAnalysisConfigurers;
029import ca.uhn.fhir.model.api.IQueryParameterType;
030import ca.uhn.fhir.model.api.TemporalPrecisionEnum;
031import ca.uhn.fhir.rest.api.Constants;
032import ca.uhn.fhir.rest.param.CompositeParam;
033import ca.uhn.fhir.rest.param.DateParam;
034import ca.uhn.fhir.rest.param.DateRangeParam;
035import ca.uhn.fhir.rest.param.NumberParam;
036import ca.uhn.fhir.rest.param.ParamPrefixEnum;
037import ca.uhn.fhir.rest.param.QuantityParam;
038import ca.uhn.fhir.rest.param.ReferenceParam;
039import ca.uhn.fhir.rest.param.StringParam;
040import ca.uhn.fhir.rest.param.TokenParam;
041import ca.uhn.fhir.rest.param.UriParam;
042import ca.uhn.fhir.util.DateUtils;
043import ca.uhn.fhir.util.NumericParamRangeUtil;
044import ca.uhn.fhir.util.StringUtil;
045import jakarta.annotation.Nonnull;
046import org.apache.commons.collections4.CollectionUtils;
047import org.apache.commons.lang3.ObjectUtils;
048import org.apache.commons.lang3.StringUtils;
049import org.apache.commons.lang3.Validate;
050import org.apache.commons.lang3.tuple.Pair;
051import org.hibernate.search.engine.search.common.BooleanOperator;
052import org.hibernate.search.engine.search.predicate.dsl.BooleanPredicateClausesStep;
053import org.hibernate.search.engine.search.predicate.dsl.PredicateFinalStep;
054import org.hibernate.search.engine.search.predicate.dsl.RangePredicateOptionsStep;
055import org.hibernate.search.engine.search.predicate.dsl.SearchPredicateFactory;
056import org.hibernate.search.engine.search.predicate.dsl.WildcardPredicateOptionsStep;
057import org.slf4j.Logger;
058import org.slf4j.LoggerFactory;
059
060import java.math.BigDecimal;
061import java.time.Instant;
062import java.util.Arrays;
063import java.util.HashSet;
064import java.util.List;
065import java.util.Locale;
066import java.util.Objects;
067import java.util.Optional;
068import java.util.Set;
069import java.util.stream.Collectors;
070
071import static ca.uhn.fhir.jpa.dao.search.PathContext.joinPath;
072import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.IDX_STRING_EXACT;
073import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.IDX_STRING_NORMALIZED;
074import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.IDX_STRING_TEXT;
075import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.INDEX_TYPE_QUANTITY;
076import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.INDEX_TYPE_STRING;
077import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.INDEX_TYPE_TOKEN;
078import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.NUMBER_VALUE;
079import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.QTY_CODE;
080import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.QTY_CODE_NORM;
081import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.QTY_SYSTEM;
082import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.QTY_VALUE;
083import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.QTY_VALUE_NORM;
084import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.SEARCH_PARAM_ROOT;
085import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.TOKEN_CODE;
086import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.TOKEN_SYSTEM;
087import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.TOKEN_SYSTEM_CODE;
088import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.URI_VALUE;
089import static org.apache.commons.lang3.StringUtils.isNotBlank;
090
091public class ExtendedHSearchClauseBuilder {
092        private static final Logger ourLog = LoggerFactory.getLogger(ExtendedHSearchClauseBuilder.class);
093
094        private static final double QTY_APPROX_TOLERANCE_PERCENT = .10;
095        public static final String PATH_JOINER = ".";
096
097        final FhirContext myFhirContext;
098        public final BooleanPredicateClausesStep<?> myRootClause;
099        public final StorageSettings myStorageSettings;
100        final PathContext myRootContext;
101
102        final List<TemporalPrecisionEnum> ordinalSearchPrecisions =
103                        Arrays.asList(TemporalPrecisionEnum.YEAR, TemporalPrecisionEnum.MONTH, TemporalPrecisionEnum.DAY);
104
105        public ExtendedHSearchClauseBuilder(
106                        FhirContext myFhirContext,
107                        StorageSettings theStorageSettings,
108                        BooleanPredicateClausesStep<?> theRootClause,
109                        SearchPredicateFactory thePredicateFactory) {
110                this.myFhirContext = myFhirContext;
111                this.myStorageSettings = theStorageSettings;
112                this.myRootClause = theRootClause;
113                myRootContext = PathContext.buildRootContext(theRootClause, thePredicateFactory);
114        }
115
116        /**
117         * Restrict search to resources of a type
118         * @param theResourceType the type to match.  e.g. "Observation"
119         */
120        public void addResourceTypeClause(String theResourceType) {
121                myRootClause.must(myRootContext.match().field("myResourceType").matching(theResourceType));
122        }
123
124        @Nonnull
125        private Set<String> extractOrStringParams(List<? extends IQueryParameterType> nextAnd) {
126                Set<String> terms = new HashSet<>();
127                for (IQueryParameterType nextOr : nextAnd) {
128                        String nextValueTrimmed;
129                        if (nextOr instanceof StringParam) {
130                                StringParam nextOrString = (StringParam) nextOr;
131                                nextValueTrimmed =
132                                                StringUtils.defaultString(nextOrString.getValue()).trim();
133                        } else if (nextOr instanceof TokenParam) {
134                                TokenParam nextOrToken = (TokenParam) nextOr;
135                                nextValueTrimmed = nextOrToken.getValue();
136                        } else if (nextOr instanceof ReferenceParam) {
137                                ReferenceParam referenceParam = (ReferenceParam) nextOr;
138                                nextValueTrimmed = referenceParam.getValue();
139                                if (nextValueTrimmed.contains("/_history")) {
140                                        nextValueTrimmed = nextValueTrimmed.substring(0, nextValueTrimmed.indexOf("/_history"));
141                                }
142                        } else {
143                                throw new IllegalArgumentException(
144                                                Msg.code(1088) + "Unsupported full-text param type: " + nextOr.getClass());
145                        }
146                        if (isNotBlank(nextValueTrimmed)) {
147                                terms.add(nextValueTrimmed);
148                        }
149                }
150                return terms;
151        }
152
153        public void addTokenUnmodifiedSearch(String theSearchParamName, List<List<IQueryParameterType>> theAndOrTerms) {
154                if (CollectionUtils.isEmpty(theAndOrTerms)) {
155                        return;
156                }
157                PathContext spContext = contextForFlatSP(theSearchParamName);
158                for (List<? extends IQueryParameterType> nextAnd : theAndOrTerms) {
159
160                        ourLog.debug("addTokenUnmodifiedSearch {} {}", theSearchParamName, nextAnd);
161                        List<? extends PredicateFinalStep> clauses = nextAnd.stream()
162                                        .map(orTerm -> buildTokenUnmodifiedMatchOn(orTerm, spContext))
163                                        .collect(Collectors.toList());
164                        PredicateFinalStep finalClause = spContext.orPredicateOrSingle(clauses);
165
166                        myRootClause.must(finalClause);
167                }
168        }
169
170        private PathContext contextForFlatSP(String theSearchParamName) {
171                String path = joinPath(SEARCH_PARAM_ROOT, theSearchParamName);
172                return myRootContext.forAbsolutePath(path);
173        }
174
175        private PredicateFinalStep buildTokenUnmodifiedMatchOn(IQueryParameterType orTerm, PathContext thePathContext) {
176                String pathPrefix = thePathContext.getContextPath();
177                if (orTerm instanceof TokenParam) {
178                        TokenParam token = (TokenParam) orTerm;
179                        if (StringUtils.isBlank(token.getSystem())) {
180                                // bare value
181                                return thePathContext
182                                                .match()
183                                                .field(joinPath(pathPrefix, INDEX_TYPE_TOKEN, TOKEN_CODE))
184                                                .matching(token.getValue());
185                        } else if (StringUtils.isBlank(token.getValue())) {
186                                // system without value
187                                return thePathContext
188                                                .match()
189                                                .field(joinPath(pathPrefix, INDEX_TYPE_TOKEN, TOKEN_SYSTEM))
190                                                .matching(token.getSystem());
191                        } else {
192                                // system + value
193                                return thePathContext
194                                                .match()
195                                                .field(joinPath(pathPrefix, INDEX_TYPE_TOKEN, TOKEN_SYSTEM_CODE))
196                                                .matching(token.getValueAsQueryToken(this.myFhirContext));
197                        }
198                } else if (orTerm instanceof StringParam) {
199                        // MB I don't quite understand why FhirResourceDaoR4SearchNoFtTest.testSearchByIdParamWrongType() uses
200                        // String but here we are
201                        StringParam string = (StringParam) orTerm;
202                        // treat a string as a code with no system (like _id)
203                        return thePathContext
204                                        .match()
205                                        .field(joinPath(pathPrefix, INDEX_TYPE_TOKEN, TOKEN_CODE))
206                                        .matching(string.getValue());
207                } else {
208                        throw new IllegalArgumentException(Msg.code(1089) + "Unexpected param type for token search-param: "
209                                        + orTerm.getClass().getName());
210                }
211        }
212
213        public void addStringTextSearch(String theSearchParamName, List<List<IQueryParameterType>> stringAndOrTerms) {
214                if (CollectionUtils.isEmpty(stringAndOrTerms)) {
215                        return;
216                }
217                String fieldName;
218                switch (theSearchParamName) {
219                                // _content and _text were here first, and don't obey our mapping.
220                                // Leave them as-is for backwards compatibility.
221                        case Constants.PARAM_CONTENT:
222                                fieldName = "myContentText";
223                                break;
224                        case Constants.PARAM_TEXT:
225                                fieldName = "myNarrativeText";
226                                break;
227                        default:
228                                fieldName = joinPath(SEARCH_PARAM_ROOT, theSearchParamName, INDEX_TYPE_STRING, IDX_STRING_TEXT);
229                                break;
230                }
231
232                for (List<? extends IQueryParameterType> nextOrList : stringAndOrTerms) {
233                        Set<String> orTerms = TermHelper.makePrefixSearchTerm(extractOrStringParams(nextOrList));
234                        ourLog.debug("addStringTextSearch {}, {}", theSearchParamName, orTerms);
235                        if (!orTerms.isEmpty()) {
236                                String query = orTerms.stream().map(s -> "( " + s + " )").collect(Collectors.joining(" | "));
237                                myRootClause.must(myRootContext
238                                                .simpleQueryString()
239                                                .field(fieldName)
240                                                .matching(query)
241                                                .defaultOperator(
242                                                                BooleanOperator
243                                                                                .AND)); // term value may contain multiple tokens.  Require all of them to be
244                                // present.
245                        } else {
246                                ourLog.warn("No Terms found in query parameter {}", nextOrList);
247                        }
248                }
249        }
250
251        public void addStringExactSearch(String theSearchParamName, List<List<IQueryParameterType>> theStringAndOrTerms) {
252                String fieldPath = joinPath(SEARCH_PARAM_ROOT, theSearchParamName, INDEX_TYPE_STRING, IDX_STRING_EXACT);
253
254                for (List<? extends IQueryParameterType> nextAnd : theStringAndOrTerms) {
255                        Set<String> terms = extractOrStringParams(nextAnd);
256                        ourLog.debug("addStringExactSearch {} {}", theSearchParamName, terms);
257                        List<? extends PredicateFinalStep> orTerms = terms.stream()
258                                        .map(s -> myRootContext.match().field(fieldPath).matching(s))
259                                        .collect(Collectors.toList());
260
261                        myRootClause.must(myRootContext.orPredicateOrSingle(orTerms));
262                }
263        }
264
265        public void addStringContainsSearch(
266                        String theSearchParamName, List<List<IQueryParameterType>> theStringAndOrTerms) {
267                String fieldPath = joinPath(SEARCH_PARAM_ROOT, theSearchParamName, INDEX_TYPE_STRING, IDX_STRING_NORMALIZED);
268                for (List<? extends IQueryParameterType> nextAnd : theStringAndOrTerms) {
269                        Set<String> terms = extractOrStringParams(nextAnd);
270                        ourLog.debug("addStringContainsSearch {} {}", theSearchParamName, terms);
271                        List<? extends PredicateFinalStep> orTerms = terms.stream()
272                                        // wildcard is a term-level query, so queries aren't analyzed.  Do our own normalization first.
273                                        .map(this::normalize)
274                                        .map(s -> myRootContext.wildcard().field(fieldPath).matching("*" + s + "*"))
275                                        .collect(Collectors.toList());
276
277                        myRootClause.must(myRootContext.orPredicateOrSingle(orTerms));
278                }
279        }
280
281        /**
282         * Normalize the string to match our standardAnalyzer.
283         * @see HapiHSearchAnalysisConfigurers.HapiLuceneAnalysisConfigurer#STANDARD_ANALYZER
284         *
285         * @param theString the raw string
286         * @return a case and accent normalized version of the input
287         */
288        @Nonnull
289        private String normalize(String theString) {
290                return StringUtil.normalizeStringForSearchIndexing(theString).toLowerCase(Locale.ROOT);
291        }
292
293        public void addStringUnmodifiedSearch(
294                        String theSearchParamName, List<List<IQueryParameterType>> theStringAndOrTerms) {
295                PathContext context = contextForFlatSP(theSearchParamName);
296                for (List<? extends IQueryParameterType> nextOrList : theStringAndOrTerms) {
297                        Set<String> terms = extractOrStringParams(nextOrList);
298                        ourLog.debug("addStringUnmodifiedSearch {} {}", theSearchParamName, terms);
299                        List<PredicateFinalStep> orTerms = terms.stream()
300                                        .map(s -> buildStringUnmodifiedClause(s, context))
301                                        .collect(Collectors.toList());
302
303                        myRootClause.must(context.orPredicateOrSingle(orTerms));
304                }
305        }
306
307        private WildcardPredicateOptionsStep<?> buildStringUnmodifiedClause(String theString, PathContext theContext) {
308                return theContext
309                                .wildcard()
310                                .field(joinPath(theContext.getContextPath(), INDEX_TYPE_STRING, IDX_STRING_NORMALIZED))
311                                // wildcard is a term-level query, so it isn't analyzed.  Do our own case-folding to match the
312                                // normStringAnalyzer
313                                .matching(normalize(theString) + "*");
314        }
315
316        public void addReferenceUnchainedSearch(
317                        String theSearchParamName, List<List<IQueryParameterType>> theReferenceAndOrTerms) {
318                String fieldPath = joinPath(SEARCH_PARAM_ROOT, theSearchParamName, "reference", "value");
319                for (List<? extends IQueryParameterType> nextAnd : theReferenceAndOrTerms) {
320                        Set<String> terms = extractOrStringParams(nextAnd);
321                        ourLog.trace("reference unchained search {}", terms);
322
323                        List<? extends PredicateFinalStep> orTerms = terms.stream()
324                                        .map(s -> myRootContext.match().field(fieldPath).matching(s))
325                                        .collect(Collectors.toList());
326
327                        myRootClause.must(myRootContext.orPredicateOrSingle(orTerms));
328                }
329        }
330
331        /**
332         * Create date clause from date params. The date lower and upper bounds are taken
333         * into considertion when generating date query ranges
334         *
335         * <p>Example 1 ('eq' prefix/empty): <code>http://fhirserver/Observation?date=eq2020</code>
336         * would generate the following search clause
337         * <pre>
338         * {@code
339         * {
340         *  "bool": {
341         *    "must": [{
342         *      "range": {
343         *        "sp.date.dt.lower-ord": { "gte": "20200101" }
344         *      }
345         *    }, {
346         *      "range": {
347         *        "sp.date.dt.upper-ord": { "lte": "20201231" }
348         *      }
349         *    }]
350         *  }
351         * }
352         * }
353         * </pre>
354         *
355         * <p>Example 2 ('gt' prefix): <code>http://fhirserver/Observation?date=gt2020-01-01T08:00:00.000</code>
356         * <p>No timezone in the query will be taken as localdatetime(for e.g MST/UTC-07:00 in this case) converted to UTC before comparison</p>
357         * <pre>
358         * {@code
359         * {
360         *   "range":{
361         *     "sp.date.dt.upper":{ "gt": "2020-01-01T15:00:00.000000000Z" }
362         *   }
363         * }
364         * }
365         * </pre>
366         *
367         * <p>Example 3 between dates: <code>http://fhirserver/Observation?date=ge2010-01-01&date=le2020-01</code></p>
368         * <pre>
369         * {@code
370         * {
371         *   "range":{
372         *     "sp.date.dt.upper-ord":{ "gte":"20100101" }
373         *   },
374         *   "range":{
375         *     "sp.date.dt.lower-ord":{ "lte":"20200101" }
376         *   }
377         * }
378         * }
379         * </pre>
380         *
381         * <p>Example 4 not equal: <code>http://fhirserver/Observation?date=ne2021</code></p>
382         * <pre>
383         * {@code
384         * {
385         *    "bool": {
386         *       "should": [{
387         *          "range": {
388         *             "sp.date.dt.upper-ord": { "lt": "20210101" }
389         *          }
390         *       }, {
391         *          "range": {
392         *             "sp.date.dt.lower-ord": { "gt": "20211231" }
393         *          }
394         *       }],
395         *       "minimum_should_match": "1"
396         *    }
397         * }
398         * }
399         * </pre>
400         *
401         * @param theSearchParamName e.g code
402         * @param theDateAndOrTerms The and/or list of DateParam values
403         *
404         * buildDateTermClause(subComponentPath, value);
405         */
406        public void addDateUnmodifiedSearch(String theSearchParamName, List<List<IQueryParameterType>> theDateAndOrTerms) {
407                for (List<? extends IQueryParameterType> nextOrList : theDateAndOrTerms) {
408
409                        PathContext spContext = contextForFlatSP(theSearchParamName);
410
411                        List<PredicateFinalStep> clauses = nextOrList.stream()
412                                        .map(d -> buildDateTermClause(d, spContext))
413                                        .collect(Collectors.toList());
414
415                        myRootClause.must(myRootContext.orPredicateOrSingle(clauses));
416                }
417        }
418
419        private PredicateFinalStep buildDateTermClause(IQueryParameterType theQueryParameter, PathContext theSpContext) {
420                DateParam dateParam = (DateParam) theQueryParameter;
421                boolean isOrdinalSearch = ordinalSearchPrecisions.contains(dateParam.getPrecision());
422                return isOrdinalSearch
423                                ? generateDateOrdinalSearchTerms(dateParam, theSpContext)
424                                : generateDateInstantSearchTerms(dateParam, theSpContext);
425        }
426
427        private PredicateFinalStep generateDateOrdinalSearchTerms(DateParam theDateParam, PathContext theSpContext) {
428
429                String lowerOrdinalField = joinPath(theSpContext.getContextPath(), "dt", "lower-ord");
430                String upperOrdinalField = joinPath(theSpContext.getContextPath(), "dt", "upper-ord");
431                int lowerBoundAsOrdinal;
432                int upperBoundAsOrdinal;
433                ParamPrefixEnum prefix = theDateParam.getPrefix();
434
435                // default when handling 'Day' temporal types
436                lowerBoundAsOrdinal = upperBoundAsOrdinal = DateUtils.convertDateToDayInteger(theDateParam.getValue());
437                TemporalPrecisionEnum precision = theDateParam.getPrecision();
438                // complete the date from 'YYYY' and 'YYYY-MM' temporal types
439                if (precision == TemporalPrecisionEnum.YEAR || precision == TemporalPrecisionEnum.MONTH) {
440                        Pair<String, String> completedDate = DateUtils.getCompletedDate(theDateParam.getValueAsString());
441                        lowerBoundAsOrdinal = Integer.parseInt(completedDate.getLeft().replace("-", ""));
442                        upperBoundAsOrdinal = Integer.parseInt(completedDate.getRight().replace("-", ""));
443                }
444
445                if (Objects.isNull(prefix) || prefix == ParamPrefixEnum.EQUAL) {
446                        // For equality prefix we would like the date to fall between the lower and upper bound
447                        List<? extends PredicateFinalStep> predicateSteps = Arrays.asList(
448                                        theSpContext.range().field(lowerOrdinalField).atLeast(lowerBoundAsOrdinal),
449                                        theSpContext.range().field(upperOrdinalField).atMost(upperBoundAsOrdinal));
450                        BooleanPredicateClausesStep<?> booleanStep = theSpContext.bool();
451                        predicateSteps.forEach(booleanStep::must);
452                        return booleanStep;
453                } else if (ParamPrefixEnum.GREATERTHAN == prefix || ParamPrefixEnum.STARTS_AFTER == prefix) {
454                        // TODO JB: more fine tuning needed for STARTS_AFTER
455                        return theSpContext.range().field(upperOrdinalField).greaterThan(upperBoundAsOrdinal);
456                } else if (ParamPrefixEnum.GREATERTHAN_OR_EQUALS == prefix) {
457                        return theSpContext.range().field(upperOrdinalField).atLeast(upperBoundAsOrdinal);
458                } else if (ParamPrefixEnum.LESSTHAN == prefix || ParamPrefixEnum.ENDS_BEFORE == prefix) {
459                        // TODO JB: more fine tuning needed for END_BEFORE
460                        return theSpContext.range().field(lowerOrdinalField).lessThan(lowerBoundAsOrdinal);
461                } else if (ParamPrefixEnum.LESSTHAN_OR_EQUALS == prefix) {
462                        return theSpContext.range().field(lowerOrdinalField).atMost(lowerBoundAsOrdinal);
463                } else if (ParamPrefixEnum.NOT_EQUAL == prefix) {
464                        List<? extends PredicateFinalStep> predicateSteps = Arrays.asList(
465                                        theSpContext.range().field(upperOrdinalField).lessThan(lowerBoundAsOrdinal),
466                                        theSpContext.range().field(lowerOrdinalField).greaterThan(upperBoundAsOrdinal));
467                        BooleanPredicateClausesStep<?> booleanStep = theSpContext.bool();
468                        predicateSteps.forEach(booleanStep::should);
469                        booleanStep.minimumShouldMatchNumber(1);
470                        return booleanStep;
471                }
472                throw new IllegalArgumentException(
473                                Msg.code(2255) + "Date search param does not support prefix of type: " + prefix);
474        }
475
476        private PredicateFinalStep generateDateInstantSearchTerms(DateParam theDateParam, PathContext theSpContext) {
477                String lowerInstantField = joinPath(theSpContext.getContextPath(), "dt", "lower");
478                String upperInstantField = joinPath(theSpContext.getContextPath(), "dt", "upper");
479                final ParamPrefixEnum prefix = ObjectUtils.defaultIfNull(theDateParam.getPrefix(), ParamPrefixEnum.EQUAL);
480
481                if (ParamPrefixEnum.NOT_EQUAL == prefix) {
482                        Instant dateInstant = theDateParam.getValue().toInstant();
483                        List<? extends PredicateFinalStep> predicateSteps = Arrays.asList(
484                                        theSpContext.range().field(upperInstantField).lessThan(dateInstant),
485                                        theSpContext.range().field(lowerInstantField).greaterThan(dateInstant));
486                        BooleanPredicateClausesStep<?> booleanStep = theSpContext.bool();
487                        predicateSteps.forEach(booleanStep::should);
488                        booleanStep.minimumShouldMatchNumber(1);
489                        return booleanStep;
490                }
491
492                // Consider lower and upper bounds for building range predicates
493                DateRangeParam dateRange = new DateRangeParam(theDateParam);
494                Instant lowerBoundAsInstant = Optional.ofNullable(dateRange.getLowerBound())
495                                .map(param -> param.getValue().toInstant())
496                                .orElse(null);
497                Instant upperBoundAsInstant = Optional.ofNullable(dateRange.getUpperBound())
498                                .map(param -> param.getValue().toInstant())
499                                .orElse(null);
500
501                if (prefix == ParamPrefixEnum.EQUAL) {
502                        // For equality prefix we would like the date to fall between the lower and upper bound
503                        List<? extends PredicateFinalStep> predicateSteps = Arrays.asList(
504                                        ((SearchPredicateFactory) theSpContext)
505                                                        .range()
506                                                        .field(lowerInstantField)
507                                                        .atLeast(lowerBoundAsInstant),
508                                        ((SearchPredicateFactory) theSpContext)
509                                                        .range()
510                                                        .field(upperInstantField)
511                                                        .atMost(upperBoundAsInstant));
512                        BooleanPredicateClausesStep<?> booleanStep = ((SearchPredicateFactory) theSpContext).bool();
513                        predicateSteps.forEach(booleanStep::must);
514                        return booleanStep;
515                } else if (ParamPrefixEnum.GREATERTHAN == prefix || ParamPrefixEnum.STARTS_AFTER == prefix) {
516                        return ((SearchPredicateFactory) theSpContext)
517                                        .range()
518                                        .field(upperInstantField)
519                                        .greaterThan(lowerBoundAsInstant);
520                } else if (ParamPrefixEnum.GREATERTHAN_OR_EQUALS == prefix) {
521                        return ((SearchPredicateFactory) theSpContext)
522                                        .range()
523                                        .field(upperInstantField)
524                                        .atLeast(lowerBoundAsInstant);
525                } else if (ParamPrefixEnum.LESSTHAN == prefix || ParamPrefixEnum.ENDS_BEFORE == prefix) {
526                        return ((SearchPredicateFactory) theSpContext)
527                                        .range()
528                                        .field(lowerInstantField)
529                                        .lessThan(upperBoundAsInstant);
530                } else if (ParamPrefixEnum.LESSTHAN_OR_EQUALS == prefix) {
531                        return ((SearchPredicateFactory) theSpContext)
532                                        .range()
533                                        .field(lowerInstantField)
534                                        .atMost(upperBoundAsInstant);
535                }
536
537                throw new IllegalArgumentException(
538                                Msg.code(2256) + "Date search param does not support prefix of type: " + prefix);
539        }
540
541        /**
542         * Differences with DB search:
543         *  _ is not all-normalized-or-all-not. Each parameter is applied on quantity or normalized quantity depending on UCUM fitness
544         *  _ respects ranges for equal and approximate qualifiers
545         *
546         * Strategy: For each parameter, if it can be canonicalized, it is, and used against 'normalized-value-quantity' index
547         *      otherwise it is applied as-is to 'value-quantity'
548         */
549        public void addQuantityUnmodifiedSearch(
550                        String theSearchParamName, List<List<IQueryParameterType>> theQuantityAndOrTerms) {
551
552                for (List<IQueryParameterType> nextOrList : theQuantityAndOrTerms) {
553                        // we build quantity predicates in a nested context so we can match units and systems with values.
554                        PredicateFinalStep nestedClause =
555                                        myRootContext.buildPredicateInNestedContext(theSearchParamName, nextedContext -> {
556                                                List<PredicateFinalStep> orClauses = nextOrList.stream()
557                                                                .map(quantityTerm -> buildQuantityTermClause(quantityTerm, nextedContext))
558                                                                .collect(Collectors.toList());
559
560                                                return nextedContext.orPredicateOrSingle(orClauses);
561                                        });
562
563                        myRootClause.must(nestedClause);
564                }
565        }
566
567        private BooleanPredicateClausesStep<?> buildQuantityTermClause(
568                        IQueryParameterType theQueryParameter, PathContext thePathContext) {
569
570                BooleanPredicateClausesStep<?> quantityClause = ((SearchPredicateFactory) thePathContext).bool();
571
572                QuantityParam qtyParam = QuantityParam.toQuantityParam(theQueryParameter);
573                ParamPrefixEnum activePrefix = qtyParam.getPrefix() == null ? ParamPrefixEnum.EQUAL : qtyParam.getPrefix();
574                String quantityElement = joinPath(thePathContext.getContextPath(), INDEX_TYPE_QUANTITY);
575
576                if (myStorageSettings.getNormalizedQuantitySearchLevel()
577                                == NormalizedQuantitySearchLevel.NORMALIZED_QUANTITY_SEARCH_SUPPORTED) {
578                        QuantityParam canonicalQty = UcumServiceUtil.toCanonicalQuantityOrNull(qtyParam);
579                        if (canonicalQty != null) {
580                                String valueFieldPath = joinPath(quantityElement, QTY_VALUE_NORM);
581
582                                quantityClause.must(
583                                                buildNumericClause(valueFieldPath, activePrefix, canonicalQty.getValue(), thePathContext));
584                                quantityClause.must(((SearchPredicateFactory) thePathContext)
585                                                .match()
586                                                .field(joinPath(quantityElement, QTY_CODE_NORM))
587                                                .matching(canonicalQty.getUnits()));
588                                return quantityClause;
589                        }
590                }
591
592                String valueFieldPath = joinPath(quantityElement, QTY_VALUE);
593
594                quantityClause.must(buildNumericClause(valueFieldPath, activePrefix, qtyParam.getValue(), thePathContext));
595
596                if (isNotBlank(qtyParam.getSystem())) {
597                        quantityClause.must(((SearchPredicateFactory) thePathContext)
598                                        .match()
599                                        .field(joinPath(quantityElement, QTY_SYSTEM))
600                                        .matching(qtyParam.getSystem()));
601                }
602
603                if (isNotBlank(qtyParam.getUnits())) {
604                        quantityClause.must(((SearchPredicateFactory) thePathContext)
605                                        .match()
606                                        .field(joinPath(quantityElement, QTY_CODE))
607                                        .matching(qtyParam.getUnits()));
608                }
609
610                return quantityClause;
611        }
612
613        /**
614         * Shared helper between quantity and number
615         * @param valueFieldPath The path leading to index node
616         * @param thePrefix the query prefix (e.g. lt).  Null means eq
617         * @param theNumberValue the query value
618         * @param thePathContext HSearch builder
619         * @return a query predicate applying the prefix to the value
620         */
621        @Nonnull
622        private PredicateFinalStep buildNumericClause(
623                        String valueFieldPath, ParamPrefixEnum thePrefix, BigDecimal theNumberValue, PathContext thePathContext) {
624                PredicateFinalStep predicate = null;
625
626                double value = theNumberValue.doubleValue();
627                Pair<BigDecimal, BigDecimal> range = NumericParamRangeUtil.getRange(theNumberValue);
628                double approxTolerance = value * QTY_APPROX_TOLERANCE_PERCENT;
629
630                ParamPrefixEnum activePrefix = thePrefix == null ? ParamPrefixEnum.EQUAL : thePrefix;
631                switch (activePrefix) {
632                                //      searches for resource quantity between passed param value +/- 10%
633                        case APPROXIMATE:
634                                predicate = ((SearchPredicateFactory) thePathContext)
635                                                .range()
636                                                .field(valueFieldPath)
637                                                .between(value - approxTolerance, value + approxTolerance);
638                                break;
639
640                                // searches for resource quantity between passed param value +/- 5%
641                        case EQUAL:
642                                predicate = ((SearchPredicateFactory) thePathContext)
643                                                .range()
644                                                .field(valueFieldPath)
645                                                .between(range.getLeft().doubleValue(), range.getRight().doubleValue());
646                                break;
647
648                                // searches for resource quantity > param value
649                        case GREATERTHAN:
650                        case STARTS_AFTER: // treated as GREATERTHAN because search doesn't handle ranges
651                                predicate = ((SearchPredicateFactory) thePathContext)
652                                                .range()
653                                                .field(valueFieldPath)
654                                                .greaterThan(value);
655                                break;
656
657                                // searches for resource quantity not < param value
658                        case GREATERTHAN_OR_EQUALS:
659                                predicate = ((SearchPredicateFactory) thePathContext)
660                                                .range()
661                                                .field(valueFieldPath)
662                                                .atLeast(value);
663                                break;
664
665                                // searches for resource quantity < param value
666                        case LESSTHAN:
667                        case ENDS_BEFORE: // treated as LESSTHAN because search doesn't handle ranges
668                                predicate = ((SearchPredicateFactory) thePathContext)
669                                                .range()
670                                                .field(valueFieldPath)
671                                                .lessThan(value);
672                                break;
673
674                                // searches for resource quantity not > param value
675                        case LESSTHAN_OR_EQUALS:
676                                predicate = ((SearchPredicateFactory) thePathContext)
677                                                .range()
678                                                .field(valueFieldPath)
679                                                .atMost(value);
680                                break;
681
682                                // NOT_EQUAL: searches for resource quantity not between passed param value +/- 5%
683                        case NOT_EQUAL:
684                                RangePredicateOptionsStep<?> negRange = ((SearchPredicateFactory) thePathContext)
685                                                .range()
686                                                .field(valueFieldPath)
687                                                .between(range.getLeft().doubleValue(), range.getRight().doubleValue());
688                                predicate = ((SearchPredicateFactory) thePathContext).bool().mustNot(negRange);
689                                break;
690                }
691                Validate.notNull(predicate, "Unsupported prefix: %s", thePrefix);
692                return predicate;
693        }
694
695        public void addUriUnmodifiedSearch(
696                        String theParamName, List<List<IQueryParameterType>> theUriUnmodifiedAndOrTerms) {
697                PathContext spContext = this.contextForFlatSP(theParamName);
698                for (List<IQueryParameterType> nextOrList : theUriUnmodifiedAndOrTerms) {
699
700                        PredicateFinalStep orListPredicate = buildURIClause(nextOrList, spContext);
701
702                        myRootClause.must(orListPredicate);
703                }
704        }
705
706        private PredicateFinalStep buildURIClause(List<IQueryParameterType> theOrList, PathContext thePathContext) {
707                List<String> orTerms =
708                                theOrList.stream().map(p -> ((UriParam) p).getValue()).collect(Collectors.toList());
709
710                return ((SearchPredicateFactory) thePathContext)
711                                .terms()
712                                .field(joinPath(thePathContext.getContextPath(), URI_VALUE))
713                                .matchingAny(orTerms);
714        }
715
716        public void addNumberUnmodifiedSearch(
717                        String theParamName, List<List<IQueryParameterType>> theNumberUnmodifiedAndOrTerms) {
718                PathContext pathContext = contextForFlatSP(theParamName);
719                String fieldPath = joinPath(SEARCH_PARAM_ROOT, theParamName, NUMBER_VALUE);
720
721                for (List<IQueryParameterType> nextOrList : theNumberUnmodifiedAndOrTerms) {
722                        List<PredicateFinalStep> orTerms = nextOrList.stream()
723                                        .map(NumberParam.class::cast)
724                                        .map(orTerm -> buildNumericClause(fieldPath, orTerm.getPrefix(), orTerm.getValue(), pathContext))
725                                        .collect(Collectors.toList());
726
727                        myRootClause.must(pathContext.orPredicateOrSingle(orTerms));
728                }
729        }
730
731        private PredicateFinalStep buildNumericClause(IQueryParameterType theValue, PathContext thePathContext) {
732                NumberParam p = (NumberParam) theValue;
733
734                return buildNumericClause(
735                                joinPath(thePathContext.getContextPath(), NUMBER_VALUE), p.getPrefix(), p.getValue(), thePathContext);
736        }
737
738        public void addCompositeUnmodifiedSearch(
739                        RuntimeSearchParam theSearchParam,
740                        List<RuntimeSearchParam> theSubSearchParams,
741                        List<List<IQueryParameterType>> theCompositeAndOrTerms) {
742                for (List<IQueryParameterType> nextOrList : theCompositeAndOrTerms) {
743
744                        // The index data for each extracted element is stored in a separate nested HSearch document.
745                        // Create a nested parent node for all component predicates.
746                        // Each can share this nested beacuse all nested docs share a parent id.
747
748                        PredicateFinalStep nestedClause =
749                                        myRootContext.buildPredicateInNestedContext(theSearchParam.getName(), nestedContext -> {
750                                                List<PredicateFinalStep> orClauses = nextOrList.stream()
751                                                                .map(term -> computeCompositeTermClause(
752                                                                                theSearchParam, theSubSearchParams, (CompositeParam<?, ?>) term, nestedContext))
753                                                                .collect(Collectors.toList());
754
755                                                return nestedContext.orPredicateOrSingle(orClauses);
756                                        });
757                        myRootClause.must(nestedClause);
758                }
759        }
760
761        /**
762         * Compute the match clause for all the components of theCompositeQueryParam.
763         *
764         * @param theSearchParam The composite SP
765         * @param theSubSearchParams the composite component SPs
766         * @param theCompositeQueryParam the query param values
767         * @param theCompositeContext the root of the nested SP query.
768         */
769        private PredicateFinalStep computeCompositeTermClause(
770                        RuntimeSearchParam theSearchParam,
771                        List<RuntimeSearchParam> theSubSearchParams,
772                        CompositeParam<?, ?> theCompositeQueryParam,
773                        PathContext theCompositeContext) {
774                Validate.notNull(theSearchParam);
775                Validate.notNull(theSubSearchParams);
776                Validate.notNull(theCompositeQueryParam);
777                Validate.isTrue(
778                                theSubSearchParams.size() == 2,
779                                "Hapi only supports composite search parameters with 2 components. %s %d",
780                                theSearchParam.getName(),
781                                theSubSearchParams.size());
782                List<IQueryParameterType> values = theCompositeQueryParam.getValues();
783                Validate.isTrue(
784                                theSubSearchParams.size() == values.size(),
785                                "Different number of query components than defined. %s %d %d",
786                                theSearchParam.getName(),
787                                theSubSearchParams.size(),
788                                values.size());
789
790                // The index data for each extracted element is stored in a separate nested HSearch document.
791
792                // Create a nested parent node for all component predicates.
793                BooleanPredicateClausesStep<?> compositeClause = ((SearchPredicateFactory) theCompositeContext).bool();
794                for (int i = 0; i < theSubSearchParams.size(); i += 1) {
795                        RuntimeSearchParam component = theSubSearchParams.get(i);
796                        IQueryParameterType value = values.get(i);
797                        PredicateFinalStep subMatch = null;
798                        PathContext componentContext = theCompositeContext.getSubComponentContext(component.getName());
799                        switch (component.getParamType()) {
800                                case DATE:
801                                        subMatch = buildDateTermClause(value, componentContext);
802                                        break;
803                                case STRING:
804                                        subMatch = buildStringUnmodifiedClause(value.getValueAsQueryToken(myFhirContext), componentContext);
805                                        break;
806                                case TOKEN:
807                                        subMatch = buildTokenUnmodifiedMatchOn(value, componentContext);
808                                        break;
809                                case QUANTITY:
810                                        subMatch = buildQuantityTermClause(value, componentContext);
811                                        break;
812                                case URI:
813                                        subMatch = buildURIClause(List.of(value), componentContext);
814                                        break;
815                                case NUMBER:
816                                        subMatch = buildNumericClause(value, componentContext);
817                                        break;
818                                case REFERENCE:
819
820                                default:
821                                        break;
822                        }
823
824                        Validate.notNull(
825                                        subMatch,
826                                        "Unsupported composite type in %s: %s %s",
827                                        theSearchParam.getName(),
828                                        component.getName(),
829                                        component.getParamType());
830                        compositeClause.must(subMatch);
831                }
832
833                return compositeClause;
834        }
835}