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