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