001package ca.uhn.fhir.jpa.dao.search;
002
003/*-
004 * #%L
005 * HAPI FHIR JPA Server
006 * %%
007 * Copyright (C) 2014 - 2022 Smile CDR, Inc.
008 * %%
009 * Licensed under the Apache License, Version 2.0 (the "License");
010 * you may not use this file except in compliance with the License.
011 * You may obtain a copy of the License at
012 *
013 *      http://www.apache.org/licenses/LICENSE-2.0
014 *
015 * Unless required by applicable law or agreed to in writing, software
016 * distributed under the License is distributed on an "AS IS" BASIS,
017 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
018 * See the License for the specific language governing permissions and
019 * limitations under the License.
020 * #L%
021 */
022
023import ca.uhn.fhir.context.FhirContext;
024import ca.uhn.fhir.i18n.Msg;
025import ca.uhn.fhir.jpa.dao.predicate.SearchFilterParser;
026import ca.uhn.fhir.jpa.model.entity.ModelConfig;
027import ca.uhn.fhir.jpa.model.entity.NormalizedQuantitySearchLevel;
028import ca.uhn.fhir.jpa.model.util.UcumServiceUtil;
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.DateParam;
033import ca.uhn.fhir.rest.param.DateRangeParam;
034import ca.uhn.fhir.rest.param.NumberParam;
035import ca.uhn.fhir.rest.param.ParamPrefixEnum;
036import ca.uhn.fhir.rest.param.QuantityParam;
037import ca.uhn.fhir.rest.param.ReferenceParam;
038import ca.uhn.fhir.rest.param.StringParam;
039import ca.uhn.fhir.rest.param.TokenParam;
040import ca.uhn.fhir.rest.param.UriParam;
041import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
042import ca.uhn.fhir.util.DateUtils;
043import ca.uhn.fhir.util.StringUtil;
044import org.apache.commons.collections4.CollectionUtils;
045import org.apache.commons.lang3.StringUtils;
046import org.apache.commons.lang3.tuple.Pair;
047import org.hibernate.search.engine.search.common.BooleanOperator;
048import org.hibernate.search.engine.search.predicate.dsl.BooleanPredicateClausesStep;
049import org.hibernate.search.engine.search.predicate.dsl.PredicateFinalStep;
050import org.hibernate.search.engine.search.predicate.dsl.SearchPredicateFactory;
051import org.hibernate.search.util.common.data.RangeBoundInclusion;
052import org.slf4j.Logger;
053import org.slf4j.LoggerFactory;
054
055import javax.annotation.Nonnull;
056import java.time.Instant;
057import java.util.Arrays;
058import java.util.HashSet;
059import java.util.List;
060import java.util.Locale;
061import java.util.Objects;
062import java.util.Optional;
063import java.util.Set;
064import java.util.stream.Collectors;
065
066import static ca.uhn.fhir.jpa.model.search.HibernateSearchIndexWriter.IDX_STRING_EXACT;
067import static ca.uhn.fhir.jpa.model.search.HibernateSearchIndexWriter.IDX_STRING_NORMALIZED;
068import static ca.uhn.fhir.jpa.model.search.HibernateSearchIndexWriter.IDX_STRING_TEXT;
069import static ca.uhn.fhir.jpa.model.search.HibernateSearchIndexWriter.NESTED_SEARCH_PARAM_ROOT;
070import static ca.uhn.fhir.jpa.model.search.HibernateSearchIndexWriter.NUMBER_VALUE;
071import static ca.uhn.fhir.jpa.model.search.HibernateSearchIndexWriter.QTY_CODE;
072import static ca.uhn.fhir.jpa.model.search.HibernateSearchIndexWriter.QTY_CODE_NORM;
073import static ca.uhn.fhir.jpa.model.search.HibernateSearchIndexWriter.QTY_PARAM_NAME;
074import static ca.uhn.fhir.jpa.model.search.HibernateSearchIndexWriter.QTY_SYSTEM;
075import static ca.uhn.fhir.jpa.model.search.HibernateSearchIndexWriter.QTY_VALUE;
076import static ca.uhn.fhir.jpa.model.search.HibernateSearchIndexWriter.QTY_VALUE_NORM;
077import static ca.uhn.fhir.jpa.model.search.HibernateSearchIndexWriter.SEARCH_PARAM_ROOT;
078import static ca.uhn.fhir.jpa.model.search.HibernateSearchIndexWriter.URI_VALUE;
079import static org.apache.commons.lang3.ObjectUtils.defaultIfNull;
080import static org.apache.commons.lang3.StringUtils.isNotBlank;
081
082public class ExtendedLuceneClauseBuilder {
083        private static final Logger ourLog = LoggerFactory.getLogger(ExtendedLuceneClauseBuilder.class);
084
085        private static final double QTY_APPROX_TOLERANCE_PERCENT = .10;
086        private static final double QTY_TOLERANCE_PERCENT = .05;
087
088        final FhirContext myFhirContext;
089        public final SearchPredicateFactory myPredicateFactory;
090        public final BooleanPredicateClausesStep<?> myRootClause;
091        public final ModelConfig myModelConfig;
092
093        final List<TemporalPrecisionEnum> ordinalSearchPrecisions = Arrays.asList(TemporalPrecisionEnum.YEAR, TemporalPrecisionEnum.MONTH, TemporalPrecisionEnum.DAY);
094
095        public ExtendedLuceneClauseBuilder(FhirContext myFhirContext, ModelConfig theModelConfig,
096                        BooleanPredicateClausesStep<?> myRootClause, SearchPredicateFactory myPredicateFactory) {
097                this.myFhirContext = myFhirContext;
098                this.myModelConfig = theModelConfig;
099                this.myRootClause = myRootClause;
100                this.myPredicateFactory = myPredicateFactory;
101        }
102
103        /**
104         * Restrict search to resources of a type
105         * @param theResourceType the type to match.  e.g. "Observation"
106         */
107        public void addResourceTypeClause(String theResourceType) {
108                myRootClause.must(myPredicateFactory.match().field("myResourceType").matching(theResourceType));
109        }
110
111        @Nonnull
112        private Set<String> extractOrStringParams(List<? extends IQueryParameterType> nextAnd) {
113                Set<String> terms = new HashSet<>();
114                for (IQueryParameterType nextOr : nextAnd) {
115                        String nextValueTrimmed;
116                        if (nextOr instanceof StringParam) {
117                                StringParam nextOrString = (StringParam) nextOr;
118                                nextValueTrimmed = StringUtils.defaultString(nextOrString.getValue()).trim();
119                        } else if (nextOr instanceof TokenParam) {
120                                TokenParam nextOrToken = (TokenParam) nextOr;
121                                nextValueTrimmed = nextOrToken.getValue();
122                        } else if (nextOr instanceof ReferenceParam) {
123                                ReferenceParam referenceParam = (ReferenceParam) nextOr;
124                                nextValueTrimmed = referenceParam.getValue();
125                                if (nextValueTrimmed.contains("/_history")) {
126                                        nextValueTrimmed = nextValueTrimmed.substring(0, nextValueTrimmed.indexOf("/_history"));
127                                }
128                        } else {
129                                throw new IllegalArgumentException(Msg.code(1088) + "Unsupported full-text param type: " + nextOr.getClass());
130                        }
131                        if (isNotBlank(nextValueTrimmed)) {
132                                terms.add(nextValueTrimmed);
133                        }
134                }
135                return terms;
136        }
137
138
139        /**
140         * Provide an OR wrapper around a list of predicates.
141         * Returns the sole predicate if it solo, or wrap as a bool/should for OR semantics.
142         *
143         * @param theOrList a list containing at least 1 predicate
144         * @return a predicate providing or-sematics over the list.
145         */
146        private PredicateFinalStep orPredicateOrSingle(List<? extends PredicateFinalStep> theOrList) {
147                PredicateFinalStep finalClause;
148                if (theOrList.size() == 1) {
149                        finalClause = theOrList.get(0);
150                } else {
151                        BooleanPredicateClausesStep<?> orClause = myPredicateFactory.bool();
152                        theOrList.forEach(orClause::should);
153                        finalClause = orClause;
154                }
155                return finalClause;
156        }
157
158        public void addTokenUnmodifiedSearch(String theSearchParamName, List<List<IQueryParameterType>> theAndOrTerms) {
159                if (CollectionUtils.isEmpty(theAndOrTerms)) {
160                        return;
161                }
162                for (List<? extends IQueryParameterType> nextAnd : theAndOrTerms) {
163
164                        ourLog.debug("addTokenUnmodifiedSearch {} {}", theSearchParamName, nextAnd);
165                        List<? extends PredicateFinalStep> clauses = nextAnd.stream().map(orTerm -> {
166                                if (orTerm instanceof TokenParam) {
167                                        TokenParam token = (TokenParam) orTerm;
168                                        if (StringUtils.isBlank(token.getSystem())) {
169                                                // bare value
170                                                return myPredicateFactory.match().field(getTokenCodeFieldPath(theSearchParamName)).matching(token.getValue());
171                                        } else if (StringUtils.isBlank(token.getValue())) {
172                                                // system without value
173                                                return myPredicateFactory.match().field(SEARCH_PARAM_ROOT + "." + theSearchParamName + ".token" + ".system").matching(token.getSystem());
174                                        } else {
175                                                // system + value
176                                                return myPredicateFactory.match().field(getTokenSystemCodeFieldPath(theSearchParamName)).matching(token.getValueAsQueryToken(this.myFhirContext));
177                                        }
178                                } else if (orTerm instanceof StringParam) {
179                                        // MB I don't quite understand why FhirResourceDaoR4SearchNoFtTest.testSearchByIdParamWrongType() uses String but here we are
180                                        StringParam string = (StringParam) orTerm;
181                                        // treat a string as a code with no system (like _id)
182                                        return myPredicateFactory.match().field(getTokenCodeFieldPath(theSearchParamName)).matching(string.getValue());
183                                } else {
184                                        throw new IllegalArgumentException(Msg.code(1089) + "Unexpected param type for token search-param: " + orTerm.getClass().getName());
185                                }
186                        }).collect(Collectors.toList());
187
188                        PredicateFinalStep finalClause = orPredicateOrSingle(clauses);
189                        myRootClause.must(finalClause);
190                }
191
192        }
193
194        @Nonnull
195        public static String getTokenCodeFieldPath(String theSearchParamName) {
196                return SEARCH_PARAM_ROOT + "." + theSearchParamName + ".token" + ".code";
197        }
198
199        @Nonnull
200        public static String getTokenSystemCodeFieldPath(@Nonnull String theSearchParamName) {
201                return SEARCH_PARAM_ROOT + "." + theSearchParamName + ".token" + ".code-system";
202        }
203
204        public void addStringTextSearch(String theSearchParamName, List<List<IQueryParameterType>> stringAndOrTerms) {
205                if (CollectionUtils.isEmpty(stringAndOrTerms)) {
206                        return;
207                }
208                String fieldName;
209                switch (theSearchParamName) {
210                        // _content and _text were here first, and don't obey our mapping.
211                        // Leave them as-is for backwards compatibility.
212                        case Constants.PARAM_CONTENT:
213                                fieldName = "myContentText";
214                                break;
215                        case Constants.PARAM_TEXT:
216                                fieldName = "myNarrativeText";
217                                break;
218                        default:
219                                fieldName = SEARCH_PARAM_ROOT + "." + theSearchParamName + ".string." + IDX_STRING_TEXT;
220                                break;
221                }
222
223                for (List<? extends IQueryParameterType> nextAnd : stringAndOrTerms) {
224                        Set<String> terms = extractOrStringParams(nextAnd);
225                        ourLog.debug("addStringTextSearch {}, {}", theSearchParamName, terms);
226                        if (!terms.isEmpty()) {
227                                String query = terms.stream()
228                                        .map(s -> "( " + s + " )")
229                                        .collect(Collectors.joining(" | "));
230                                myRootClause.must(myPredicateFactory
231                                        .simpleQueryString()
232                                        .field(fieldName)
233                                        .matching(query)
234                                        .defaultOperator(BooleanOperator.AND)); // term value may contain multiple tokens.  Require all of them to be present.
235                        } else {
236                                ourLog.warn("No Terms found in query parameter {}", nextAnd);
237                        }
238                }
239        }
240
241        public void addStringExactSearch(String theSearchParamName, List<List<IQueryParameterType>> theStringAndOrTerms) {
242                String fieldPath = SEARCH_PARAM_ROOT + "." + theSearchParamName + ".string." + IDX_STRING_EXACT;
243
244                for (List<? extends IQueryParameterType> nextAnd : theStringAndOrTerms) {
245                        Set<String> terms = extractOrStringParams(nextAnd);
246                        ourLog.debug("addStringExactSearch {} {}", theSearchParamName, terms);
247                        List<? extends PredicateFinalStep> orTerms = terms.stream()
248                                .map(s -> myPredicateFactory.match().field(fieldPath).matching(s))
249                                .collect(Collectors.toList());
250
251                        myRootClause.must(orPredicateOrSingle(orTerms));
252                }
253        }
254
255        public void addStringContainsSearch(String theSearchParamName, List<List<IQueryParameterType>> theStringAndOrTerms) {
256                String fieldPath = SEARCH_PARAM_ROOT + "." + theSearchParamName + ".string." + IDX_STRING_NORMALIZED;
257                for (List<? extends IQueryParameterType> nextAnd : theStringAndOrTerms) {
258                        Set<String> terms = extractOrStringParams(nextAnd);
259                        ourLog.debug("addStringContainsSearch {} {}", theSearchParamName, terms);
260                        List<? extends PredicateFinalStep> orTerms = terms.stream()
261                                // wildcard is a term-level query, so queries aren't analyzed.  Do our own normalization first.
262                                .map(s-> normalize(s))
263                                .map(s -> myPredicateFactory
264                                        .wildcard().field(fieldPath)
265                                        .matching("*" + s + "*"))
266                                .collect(Collectors.toList());
267
268                        myRootClause.must(orPredicateOrSingle(orTerms));
269                }
270        }
271
272        /**
273         * Normalize the string to match our standardAnalyzer.
274         * @see ca.uhn.fhir.jpa.search.HapiLuceneAnalysisConfigurer#STANDARD_ANALYZER
275         *
276         * @param theString the raw string
277         * @return a case and accent normalized version of the input
278         */
279        @Nonnull
280        private String normalize(String theString) {
281                return StringUtil.normalizeStringForSearchIndexing(theString).toLowerCase(Locale.ROOT);
282        }
283
284        public void addStringUnmodifiedSearch(String theSearchParamName, List<List<IQueryParameterType>> theStringAndOrTerms) {
285                String fieldPath = SEARCH_PARAM_ROOT + "." + theSearchParamName + ".string." + IDX_STRING_NORMALIZED;
286                for (List<? extends IQueryParameterType> nextAnd : theStringAndOrTerms) {
287                        Set<String> terms = extractOrStringParams(nextAnd);
288                        ourLog.debug("addStringUnmodifiedSearch {} {}", theSearchParamName, terms);
289                        List<? extends PredicateFinalStep> orTerms = terms.stream()
290                                .map(s ->
291                                        myPredicateFactory.wildcard()
292                                                .field(fieldPath)
293                                                // wildcard is a term-level query, so it isn't analyzed.  Do our own case-folding to match the normStringAnalyzer
294                                                .matching(normalize(s) + "*"))
295                                .collect(Collectors.toList());
296
297                        myRootClause.must(orPredicateOrSingle(orTerms));
298                }
299        }
300
301        public void addReferenceUnchainedSearch(String theSearchParamName, List<List<IQueryParameterType>> theReferenceAndOrTerms) {
302                String fieldPath = SEARCH_PARAM_ROOT + "." + theSearchParamName + ".reference.value";
303                for (List<? extends IQueryParameterType> nextAnd : theReferenceAndOrTerms) {
304                        Set<String> terms = extractOrStringParams(nextAnd);
305                        ourLog.trace("reference unchained search {}", terms);
306
307                        List<? extends PredicateFinalStep> orTerms = terms.stream()
308                                .map(s -> myPredicateFactory.match().field(fieldPath).matching(s))
309                                .collect(Collectors.toList());
310
311                        myRootClause.must(orPredicateOrSingle(orTerms));
312                }
313        }
314
315        /**
316         * Create date clause from date params. The date lower and upper bounds are taken
317         * into considertion when generating date query ranges
318         *
319         * <p>Example 1 ('eq' prefix/empty): <code>http://fhirserver/Observation?date=eq2020</code>
320         * would generate the following search clause
321         * <pre>
322         * {@code
323         * {
324         *  "bool": {
325         *    "must": [{
326         *      "range": {
327         *        "sp.date.dt.lower-ord": { "gte": "20200101" }
328         *      }
329         *    }, {
330         *      "range": {
331         *        "sp.date.dt.upper-ord": { "lte": "20201231" }
332         *      }
333         *    }]
334         *  }
335         * }
336         * }
337         * </pre>
338         *
339         * <p>Example 2 ('gt' prefix): <code>http://fhirserver/Observation?date=gt2020-01-01T08:00:00.000</code>
340         * <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>
341         * <pre>
342         * {@code
343         * {
344         *   "range":{
345         *     "sp.date.dt.upper":{ "gt": "2020-01-01T15:00:00.000000000Z" }
346         *   }
347         * }
348         * }
349         * </pre>
350         *
351         * <p>Example 3 between dates: <code>http://fhirserver/Observation?date=ge2010-01-01&date=le2020-01</code></p>
352         * <pre>
353         * {@code
354         * {
355         *   "range":{
356         *     "sp.date.dt.upper-ord":{ "gte":"20100101" }
357         *   },
358         *   "range":{
359         *     "sp.date.dt.lower-ord":{ "lte":"20200101" }
360         *   }
361         * }
362         * }
363         * </pre>
364         *
365         * <p>Example 4 not equal: <code>http://fhirserver/Observation?date=ne2021</code></p>
366         * <pre>
367         * {@code
368         * {
369         *    "bool": {
370         *       "should": [{
371         *          "range": {
372         *             "sp.date.dt.upper-ord": { "lt": "20210101" }
373         *          }
374         *       }, {
375         *          "range": {
376         *             "sp.date.dt.lower-ord": { "gt": "20211231" }
377         *          }
378         *       }],
379         *       "minimum_should_match": "1"
380         *    }
381         * }
382         * }
383         * </pre>
384         *
385         * @param theSearchParamName e.g code
386         * @param theDateAndOrTerms The and/or list of DateParam values
387         */
388        public void addDateUnmodifiedSearch(String theSearchParamName, List<List<IQueryParameterType>> theDateAndOrTerms) {
389                for (List<? extends IQueryParameterType> nextAnd : theDateAndOrTerms) {
390                        // comma separated list of dates(OR list) on a date param is not applicable so grab
391                        // first from default list
392                        if (nextAnd.size() > 1) {
393                                throw new IllegalArgumentException(Msg.code(2032) + "OR (,) searches on DATE search parameters are not supported for ElasticSearch/Lucene");
394                        }
395                        DateParam dateParam = (DateParam) nextAnd.stream().findFirst()
396                                .orElseThrow(() -> new InvalidRequestException("Date param is missing value"));
397
398                        boolean isOrdinalSearch = ordinalSearchPrecisions.contains(dateParam.getPrecision());
399
400                        PredicateFinalStep searchPredicate = isOrdinalSearch
401                                ? generateDateOrdinalSearchTerms(theSearchParamName, dateParam)
402                                : generateDateInstantSearchTerms(theSearchParamName, dateParam);
403
404                        myRootClause.must(searchPredicate);
405                }
406        }
407
408        private PredicateFinalStep generateDateOrdinalSearchTerms(String theSearchParamName, DateParam theDateParam) {
409                String lowerOrdinalField = SEARCH_PARAM_ROOT + "." + theSearchParamName + ".dt.lower-ord";
410                String upperOrdinalField = SEARCH_PARAM_ROOT + "." + theSearchParamName + ".dt.upper-ord";
411                int lowerBoundAsOrdinal;
412                int upperBoundAsOrdinal;
413                ParamPrefixEnum prefix = theDateParam.getPrefix();
414
415                // default when handling 'Day' temporal types
416                lowerBoundAsOrdinal = upperBoundAsOrdinal = DateUtils.convertDateToDayInteger(theDateParam.getValue());
417                TemporalPrecisionEnum precision = theDateParam.getPrecision();
418                // complete the date from 'YYYY' and 'YYYY-MM' temporal types
419                if (precision == TemporalPrecisionEnum.YEAR || precision == TemporalPrecisionEnum.MONTH) {
420                        Pair<String, String> completedDate = DateUtils.getCompletedDate(theDateParam.getValueAsString());
421                        lowerBoundAsOrdinal = Integer.parseInt(completedDate.getLeft().replace("-", ""));
422                        upperBoundAsOrdinal = Integer.parseInt(completedDate.getRight().replace("-", ""));
423                }
424
425                if (Objects.isNull(prefix) || prefix == ParamPrefixEnum.EQUAL) {
426                        // For equality prefix we would like the date to fall between the lower and upper bound
427                        List<? extends PredicateFinalStep> predicateSteps = Arrays.asList(
428                                myPredicateFactory.range().field(lowerOrdinalField).atLeast(lowerBoundAsOrdinal),
429                                myPredicateFactory.range().field(upperOrdinalField).atMost(upperBoundAsOrdinal)
430                        );
431                        BooleanPredicateClausesStep<?> booleanStep = myPredicateFactory.bool();
432                        predicateSteps.forEach(booleanStep::must);
433                        return booleanStep;
434                } else if (ParamPrefixEnum.GREATERTHAN == prefix || ParamPrefixEnum.STARTS_AFTER == prefix) {
435                        // TODO JB: more fine tuning needed for STARTS_AFTER
436                        return myPredicateFactory.range().field(upperOrdinalField).greaterThan(upperBoundAsOrdinal);
437                } else if (ParamPrefixEnum.GREATERTHAN_OR_EQUALS == prefix) {
438                        return myPredicateFactory.range().field(upperOrdinalField).atLeast(upperBoundAsOrdinal);
439                } else if (ParamPrefixEnum.LESSTHAN == prefix || ParamPrefixEnum.ENDS_BEFORE == prefix) {
440                        // TODO JB: more fine tuning needed for END_BEFORE
441                        return myPredicateFactory.range().field(lowerOrdinalField).lessThan(lowerBoundAsOrdinal);
442                } else if (ParamPrefixEnum.LESSTHAN_OR_EQUALS == prefix) {
443                        return myPredicateFactory.range().field(lowerOrdinalField).atMost(lowerBoundAsOrdinal);
444                } else if (ParamPrefixEnum.NOT_EQUAL == prefix) {
445                        List<? extends PredicateFinalStep> predicateSteps = Arrays.asList(
446                                myPredicateFactory.range().field(upperOrdinalField).lessThan(lowerBoundAsOrdinal),
447                                myPredicateFactory.range().field(lowerOrdinalField).greaterThan(upperBoundAsOrdinal)
448                        );
449                        BooleanPredicateClausesStep<?> booleanStep = myPredicateFactory.bool();
450                        predicateSteps.forEach(booleanStep::should);
451                        booleanStep.minimumShouldMatchNumber(1);
452                        return booleanStep;
453                }
454                throw new IllegalArgumentException(Msg.code(2025) + "Date search param does not support prefix of type: " + prefix);
455        }
456
457        private PredicateFinalStep generateDateInstantSearchTerms(String theSearchParamName, DateParam theDateParam) {
458                String lowerInstantField = SEARCH_PARAM_ROOT + "." + theSearchParamName + ".dt.lower";
459                String upperInstantField = SEARCH_PARAM_ROOT + "." + theSearchParamName + ".dt.upper";
460                ParamPrefixEnum prefix = theDateParam.getPrefix();
461
462                if (ParamPrefixEnum.NOT_EQUAL == prefix) {
463                        Instant dateInstant = theDateParam.getValue().toInstant();
464                        List<? extends PredicateFinalStep> predicateSteps = Arrays.asList(
465                                myPredicateFactory.range().field(upperInstantField).lessThan(dateInstant),
466                                myPredicateFactory.range().field(lowerInstantField).greaterThan(dateInstant)
467                        );
468                        BooleanPredicateClausesStep<?> booleanStep = myPredicateFactory.bool();
469                        predicateSteps.forEach(booleanStep::should);
470                        booleanStep.minimumShouldMatchNumber(1);
471                        return booleanStep;
472                }
473
474                // Consider lower and upper bounds for building range predicates
475                DateRangeParam dateRange = new DateRangeParam(theDateParam);
476                Instant lowerBoundAsInstant = Optional.ofNullable(dateRange.getLowerBound()).map(param -> param.getValue().toInstant()).orElse(null);
477                Instant upperBoundAsInstant = Optional.ofNullable(dateRange.getUpperBound()).map(param -> param.getValue().toInstant()).orElse(null);
478
479                if (Objects.isNull(prefix) || prefix == ParamPrefixEnum.EQUAL) {
480                        // For equality prefix we would like the date to fall between the lower and upper bound
481                        List<? extends PredicateFinalStep> predicateSteps = Arrays.asList(
482                                myPredicateFactory.range().field(lowerInstantField).atLeast(lowerBoundAsInstant),
483                                myPredicateFactory.range().field(upperInstantField).atMost(upperBoundAsInstant)
484                        );
485                        BooleanPredicateClausesStep<?> booleanStep = myPredicateFactory.bool();
486                        predicateSteps.forEach(booleanStep::must);
487                        return booleanStep;
488                } else if (ParamPrefixEnum.GREATERTHAN == prefix || ParamPrefixEnum.STARTS_AFTER == prefix) {
489                        return myPredicateFactory.range().field(upperInstantField).greaterThan(lowerBoundAsInstant);
490                } else if (ParamPrefixEnum.GREATERTHAN_OR_EQUALS == prefix) {
491                        return myPredicateFactory.range().field(upperInstantField).atLeast(lowerBoundAsInstant);
492                } else if (ParamPrefixEnum.LESSTHAN == prefix || ParamPrefixEnum.ENDS_BEFORE == prefix) {
493                        return myPredicateFactory.range().field(lowerInstantField).lessThan(upperBoundAsInstant);
494                } else if (ParamPrefixEnum.LESSTHAN_OR_EQUALS == prefix) {
495                        return myPredicateFactory.range().field(lowerInstantField).atMost(upperBoundAsInstant);
496                }
497
498                throw new IllegalArgumentException(Msg.code(2026) + "Date search param does not support prefix of type: " + prefix);
499        }
500
501
502        /**
503         * Differences with DB search:
504         *  _ is not all-normalized-or-all-not. Each parameter is applied on quantity or normalized quantity depending on UCUM fitness
505         *  _ respects ranges for equal and approximate qualifiers
506         *
507         * Strategy: For each parameter, if it can be canonicalized, it is, and used against 'normalized-value-quantity' index
508         *      otherwise it is applied as-is to 'value-quantity'
509         */
510        public void addQuantityUnmodifiedSearch(String theSearchParamName, List<List<IQueryParameterType>> theQuantityAndOrTerms) {
511
512                for (List<IQueryParameterType> nextAnd : theQuantityAndOrTerms) {
513                        BooleanPredicateClausesStep<?> quantityTerms = myPredicateFactory.bool();
514                        quantityTerms.minimumShouldMatchNumber(1);
515
516                        for (IQueryParameterType paramType : nextAnd) {
517                                BooleanPredicateClausesStep<?> orQuantityTerms = myPredicateFactory.bool();
518                                addQuantityOrClauses(theSearchParamName, paramType, orQuantityTerms);
519                                quantityTerms.should(orQuantityTerms);
520                        }
521
522                        myRootClause.must(quantityTerms);
523                }
524        }
525
526
527        private void addQuantityOrClauses(String theSearchParamName,
528                        IQueryParameterType theParamType, BooleanPredicateClausesStep<?> theQuantityTerms) {
529
530                QuantityParam qtyParam = QuantityParam.toQuantityParam(theParamType);
531                ParamPrefixEnum activePrefix = qtyParam.getPrefix() == null ? ParamPrefixEnum.EQUAL : qtyParam.getPrefix();
532                String fieldPath = NESTED_SEARCH_PARAM_ROOT + "." + theSearchParamName + "." + QTY_PARAM_NAME;
533
534                if (myModelConfig.getNormalizedQuantitySearchLevel() == NormalizedQuantitySearchLevel.NORMALIZED_QUANTITY_SEARCH_SUPPORTED) {
535                        QuantityParam canonicalQty = UcumServiceUtil.toCanonicalQuantityOrNull(qtyParam);
536                        if (canonicalQty != null) {
537                                String valueFieldPath = fieldPath + "." + QTY_VALUE_NORM;
538                                setPrefixedQuantityPredicate(theQuantityTerms, activePrefix, canonicalQty, valueFieldPath);
539                                theQuantityTerms.must(myPredicateFactory.match()
540                                        .field(fieldPath + "." + QTY_CODE_NORM)
541                                        .matching(canonicalQty.getUnits()));
542                                return;
543                        }
544                }
545
546                // not NORMALIZED_QUANTITY_SEARCH_SUPPORTED or non-canonicalizable parameter
547                String valueFieldPath = fieldPath + "." + QTY_VALUE;
548                setPrefixedQuantityPredicate(theQuantityTerms, activePrefix, qtyParam, valueFieldPath);
549
550                if ( isNotBlank(qtyParam.getSystem()) ) {
551                        theQuantityTerms.must(
552                                myPredicateFactory.match()
553                                        .field(fieldPath + "." + QTY_SYSTEM).matching(qtyParam.getSystem()) );
554                }
555
556                if ( isNotBlank(qtyParam.getUnits()) ) {
557                        theQuantityTerms.must(
558                                myPredicateFactory.match()
559                                        .field(fieldPath + "." + QTY_CODE).matching(qtyParam.getUnits()) );
560                }
561        }
562
563
564        private void setPrefixedQuantityPredicate(BooleanPredicateClausesStep<?> theQuantityTerms,
565                        ParamPrefixEnum thePrefix, QuantityParam theQuantity, String valueFieldPath) {
566
567                double value = theQuantity.getValue().doubleValue();
568                double approxTolerance = value * QTY_APPROX_TOLERANCE_PERCENT;
569                double defaultTolerance = value * QTY_TOLERANCE_PERCENT;
570
571                switch (thePrefix) {
572                        //      searches for resource quantity between passed param value +/- 10%
573                        case APPROXIMATE:
574                                theQuantityTerms.must(
575                                        myPredicateFactory.range()
576                                                .field(valueFieldPath)
577                                                .between(value-approxTolerance, value+approxTolerance));
578                                break;
579
580                        // searches for resource quantity between passed param value +/- 5%
581                        case EQUAL:
582                                theQuantityTerms.must(
583                                        myPredicateFactory.range()
584                                                .field(valueFieldPath)
585                                                .between(value-defaultTolerance, value+defaultTolerance));
586                                break;
587
588                        // searches for resource quantity > param value
589                        case GREATERTHAN:
590                        case STARTS_AFTER:  // treated as GREATERTHAN because search doesn't handle ranges
591                                theQuantityTerms.must(
592                                        myPredicateFactory.range()
593                                                .field(valueFieldPath)
594                                                .greaterThan(value));
595                                break;
596
597                        // searches for resource quantity not < param value
598                        case GREATERTHAN_OR_EQUALS:
599                                theQuantityTerms.must(
600                                        myPredicateFactory.range()
601                                                .field(valueFieldPath)
602                                                .atLeast(value));
603                                break;
604
605                        // searches for resource quantity < param value
606                        case LESSTHAN:
607                        case ENDS_BEFORE:  // treated as LESSTHAN because search doesn't handle ranges
608                                theQuantityTerms.must(
609                                        myPredicateFactory.range()
610                                                .field(valueFieldPath)
611                                                .lessThan(value));
612                                break;
613
614                                // searches for resource quantity not > param value
615                        case LESSTHAN_OR_EQUALS:
616                                theQuantityTerms.must(
617                                        myPredicateFactory.range()
618                                                .field(valueFieldPath)
619                                                .atMost(value));
620                                break;
621
622                                // NOT_EQUAL: searches for resource quantity not between passed param value +/- 5%
623                        case NOT_EQUAL:
624                                theQuantityTerms.mustNot(
625                                        myPredicateFactory.range()
626                                                .field(valueFieldPath)
627                                                .between(value-defaultTolerance, value+defaultTolerance));
628                                break;
629                }
630        }
631
632
633        public void addUriUnmodifiedSearch(String theParamName, List<List<IQueryParameterType>> theUriUnmodifiedAndOrTerms) {
634                for (List<IQueryParameterType> nextAnd : theUriUnmodifiedAndOrTerms) {
635
636                        List<String> orTerms = nextAnd.stream().map(p -> ((UriParam) p).getValue()).collect(Collectors.toList());
637                        PredicateFinalStep orTermPredicate = myPredicateFactory.terms()
638                                .field(String.join(".", SEARCH_PARAM_ROOT, theParamName, URI_VALUE))
639                                .matchingAny(orTerms);
640
641                        myRootClause.must(orTermPredicate);
642                }
643        }
644
645
646        public void addNumberUnmodifiedSearch(String theParamName, List<List<IQueryParameterType>> theNumberUnmodifiedAndOrTerms) {
647                String fieldPath = String.join(".", SEARCH_PARAM_ROOT, theParamName, NUMBER_VALUE);
648
649                for (List<IQueryParameterType> nextAnd : theNumberUnmodifiedAndOrTerms) {
650                        List<NumberParam> orTerms = nextAnd.stream().map(NumberParam.class::cast).collect(Collectors.toList());
651
652                        BooleanPredicateClausesStep<?> numberPredicateStep = myPredicateFactory.bool();
653                        numberPredicateStep.minimumShouldMatchNumber(1);
654
655                        for (NumberParam orTerm : orTerms) {
656                                double value = orTerm.getValue().doubleValue();
657                                double approxTolerance = value * QTY_APPROX_TOLERANCE_PERCENT;
658                                double defaultTolerance = value * QTY_TOLERANCE_PERCENT;
659
660                                ParamPrefixEnum activePrefix = orTerm.getPrefix() == null ? ParamPrefixEnum.EQUAL : orTerm.getPrefix();
661                                switch (activePrefix) {
662                                        case APPROXIMATE:
663                                                numberPredicateStep.should(myPredicateFactory.range()
664                                                        .field(fieldPath).between(value - approxTolerance, value + approxTolerance));
665                                                break;
666
667                                        case EQUAL:
668                                                numberPredicateStep.should(myPredicateFactory.range()
669                                                        .field(fieldPath).between(value - defaultTolerance, value + defaultTolerance));
670                                                break;
671
672                                        case STARTS_AFTER:
673                                        case GREATERTHAN:
674                                                numberPredicateStep.should(myPredicateFactory.range().field(fieldPath).greaterThan(value));
675                                                break;
676
677                                        case GREATERTHAN_OR_EQUALS:
678                                                numberPredicateStep.should(myPredicateFactory.range().field(fieldPath).atLeast(value));
679                                                break;
680
681                                        case ENDS_BEFORE:
682                                        case LESSTHAN:
683                                                numberPredicateStep.should(myPredicateFactory.range().field(fieldPath).lessThan(value));
684                                                break;
685
686                                        case LESSTHAN_OR_EQUALS:
687                                                numberPredicateStep.should(myPredicateFactory.range().field(fieldPath).atMost(value));
688                                                break;
689
690                                        case NOT_EQUAL:
691                                                numberPredicateStep.mustNot(myPredicateFactory.match().field(fieldPath).matching(value));
692                                                break;
693                                }
694                        }
695
696                        myRootClause.must(numberPredicateStep);
697                }
698        }
699
700
701}