001/*-
002 * #%L
003 * HAPI FHIR JPA Server
004 * %%
005 * Copyright (C) 2014 - 2024 Smile CDR, Inc.
006 * %%
007 * Licensed under the Apache License, Version 2.0 (the "License");
008 * you may not use this file except in compliance with the License.
009 * You may obtain a copy of the License at
010 *
011 *      http://www.apache.org/licenses/LICENSE-2.0
012 *
013 * Unless required by applicable law or agreed to in writing, software
014 * distributed under the License is distributed on an "AS IS" BASIS,
015 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
016 * See the License for the specific language governing permissions and
017 * limitations under the License.
018 * #L%
019 */
020package ca.uhn.fhir.jpa.search.builder.predicate;
021
022import ca.uhn.fhir.context.BaseRuntimeChildDefinition;
023import ca.uhn.fhir.context.BaseRuntimeDeclaredChildDefinition;
024import ca.uhn.fhir.context.BaseRuntimeElementCompositeDefinition;
025import ca.uhn.fhir.context.FhirContext;
026import ca.uhn.fhir.context.FhirVersionEnum;
027import ca.uhn.fhir.context.RuntimeResourceDefinition;
028import ca.uhn.fhir.context.RuntimeSearchParam;
029import ca.uhn.fhir.context.support.IValidationSupport;
030import ca.uhn.fhir.context.support.ValidationSupportContext;
031import ca.uhn.fhir.context.support.ValueSetExpansionOptions;
032import ca.uhn.fhir.i18n.Msg;
033import ca.uhn.fhir.interceptor.model.RequestPartitionId;
034import ca.uhn.fhir.jpa.api.config.JpaStorageSettings;
035import ca.uhn.fhir.jpa.dao.predicate.SearchFilterParser;
036import ca.uhn.fhir.jpa.model.entity.BaseResourceIndexedSearchParam;
037import ca.uhn.fhir.jpa.model.entity.ResourceIndexedSearchParamToken;
038import ca.uhn.fhir.jpa.search.builder.sql.SearchQueryBuilder;
039import ca.uhn.fhir.jpa.term.api.ITermReadSvc;
040import ca.uhn.fhir.jpa.util.QueryParameterUtils;
041import ca.uhn.fhir.model.api.IQueryParameterType;
042import ca.uhn.fhir.model.base.composite.BaseCodingDt;
043import ca.uhn.fhir.model.base.composite.BaseIdentifierDt;
044import ca.uhn.fhir.rest.api.Constants;
045import ca.uhn.fhir.rest.param.NumberParam;
046import ca.uhn.fhir.rest.param.TokenParam;
047import ca.uhn.fhir.rest.param.TokenParamModifier;
048import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
049import ca.uhn.fhir.rest.server.exceptions.MethodNotAllowedException;
050import ca.uhn.fhir.util.FhirVersionIndependentConcept;
051import com.google.common.annotations.VisibleForTesting;
052import com.google.common.collect.Sets;
053import com.healthmarketscience.sqlbuilder.BinaryCondition;
054import com.healthmarketscience.sqlbuilder.Condition;
055import com.healthmarketscience.sqlbuilder.dbspec.basic.DbColumn;
056import org.hl7.fhir.instance.model.api.IBase;
057import org.hl7.fhir.instance.model.api.IBaseResource;
058import org.hl7.fhir.instance.model.api.IPrimitiveType;
059import org.springframework.beans.factory.annotation.Autowired;
060
061import java.util.ArrayList;
062import java.util.Arrays;
063import java.util.Collection;
064import java.util.List;
065import java.util.Optional;
066import java.util.Set;
067import java.util.stream.Collectors;
068
069import static org.apache.commons.lang3.StringUtils.defaultIfBlank;
070import static org.apache.commons.lang3.StringUtils.isBlank;
071import static org.apache.commons.lang3.StringUtils.isNotBlank;
072
073public class TokenPredicateBuilder extends BaseSearchParamPredicateBuilder {
074        private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(TokenPredicateBuilder.class);
075
076        private final DbColumn myColumnResId;
077        private final DbColumn myColumnHashSystemAndValue;
078        private final DbColumn myColumnHashSystem;
079        private final DbColumn myColumnHashValue;
080        private final DbColumn myColumnSystem;
081        private final DbColumn myColumnValue;
082        private final DbColumn myColumnHashIdentity;
083
084        @Autowired
085        private IValidationSupport myValidationSupport;
086
087        @Autowired
088        private ITermReadSvc myTerminologySvc;
089
090        @Autowired
091        private FhirContext myContext;
092
093        @Autowired
094        private JpaStorageSettings myStorageSettings;
095
096        /**
097         * Constructor
098         */
099        public TokenPredicateBuilder(SearchQueryBuilder theSearchSqlBuilder) {
100                super(theSearchSqlBuilder, theSearchSqlBuilder.addTable("HFJ_SPIDX_TOKEN"));
101                myColumnResId = getTable().addColumn("RES_ID");
102                myColumnHashIdentity = getTable().addColumn("HASH_IDENTITY");
103                myColumnHashSystem = getTable().addColumn("HASH_SYS");
104                myColumnHashSystemAndValue = getTable().addColumn("HASH_SYS_AND_VALUE");
105                myColumnHashValue = getTable().addColumn("HASH_VALUE");
106                myColumnSystem = getTable().addColumn("SP_SYSTEM");
107                myColumnValue = getTable().addColumn("SP_VALUE");
108        }
109
110        @Override
111        public DbColumn getColumnHashIdentity() {
112                return myColumnHashIdentity;
113        }
114
115        @VisibleForTesting
116        public void setStorageSettingsForUnitTest(JpaStorageSettings theStorageSettings) {
117                myStorageSettings = theStorageSettings;
118        }
119
120        @Override
121        public DbColumn getResourceIdColumn() {
122                return myColumnResId;
123        }
124
125        public Condition createPredicateToken(
126                        Collection<IQueryParameterType> theParameters,
127                        String theResourceName,
128                        String theSpnamePrefix,
129                        RuntimeSearchParam theSearchParam,
130                        RequestPartitionId theRequestPartitionId) {
131                return createPredicateToken(
132                                theParameters, theResourceName, theSpnamePrefix, theSearchParam, null, theRequestPartitionId);
133        }
134
135        public Condition createPredicateToken(
136                        Collection<IQueryParameterType> theParameters,
137                        String theResourceName,
138                        String theSpnamePrefix,
139                        RuntimeSearchParam theSearchParam,
140                        SearchFilterParser.CompareOperation theOperation,
141                        RequestPartitionId theRequestPartitionId) {
142
143                final List<FhirVersionIndependentConcept> codes = new ArrayList<>();
144
145                String paramName = QueryParameterUtils.getParamNameWithPrefix(theSpnamePrefix, theSearchParam.getName());
146
147                SearchFilterParser.CompareOperation operation = theOperation;
148
149                TokenParamModifier modifier = null;
150                for (IQueryParameterType nextParameter : theParameters) {
151
152                        String code;
153                        String system;
154                        if (nextParameter instanceof TokenParam) {
155                                TokenParam id = (TokenParam) nextParameter;
156                                system = id.getSystem();
157                                code = id.getValue();
158                                modifier = id.getModifier();
159                        } else if (nextParameter instanceof BaseIdentifierDt) {
160                                BaseIdentifierDt id = (BaseIdentifierDt) nextParameter;
161                                system = id.getSystemElement().getValueAsString();
162                                code = (id.getValueElement().getValue());
163                        } else if (nextParameter instanceof BaseCodingDt) {
164                                BaseCodingDt id = (BaseCodingDt) nextParameter;
165                                system = id.getSystemElement().getValueAsString();
166                                code = (id.getCodeElement().getValue());
167                        } else if (nextParameter instanceof NumberParam) {
168                                NumberParam number = (NumberParam) nextParameter;
169                                system = null;
170                                code = number.getValueAsQueryToken(getFhirContext());
171                        } else {
172                                throw new IllegalArgumentException(Msg.code(1236) + "Invalid token type: " + nextParameter.getClass());
173                        }
174
175                        if (system != null && system.length() > ResourceIndexedSearchParamToken.MAX_LENGTH) {
176                                ourLog.info(
177                                                "Parameter[{}] has system ({}) that is longer than maximum ({}) so will truncate: {} ",
178                                                paramName,
179                                                system.length(),
180                                                ResourceIndexedSearchParamToken.MAX_LENGTH,
181                                                system);
182                        }
183
184                        if (code != null && code.length() > ResourceIndexedSearchParamToken.MAX_LENGTH) {
185                                ourLog.info(
186                                                "Parameter[{}] has code ({}) that is longer than maximum ({}) so will truncate: {} ",
187                                                paramName,
188                                                code.length(),
189                                                ResourceIndexedSearchParamToken.MAX_LENGTH,
190                                                code);
191                        }
192
193                        /*
194                         * Process token modifiers (:in, :below, :above)
195                         */
196
197                        if (modifier == TokenParamModifier.IN || modifier == TokenParamModifier.NOT_IN) {
198                                if (myContext.getVersion().getVersion().isNewerThan(FhirVersionEnum.DSTU2)) {
199                                        ValueSetExpansionOptions valueSetExpansionOptions = new ValueSetExpansionOptions();
200                                        valueSetExpansionOptions.setCount(myStorageSettings.getMaximumExpansionSize());
201                                        IValidationSupport.ValueSetExpansionOutcome expanded = myValidationSupport.expandValueSet(
202                                                        new ValidationSupportContext(myValidationSupport), valueSetExpansionOptions, code);
203
204                                        codes.addAll(extractValueSetCodes(expanded.getValueSet()));
205                                } else {
206                                        codes.addAll(myTerminologySvc.expandValueSetIntoConceptList(null, code));
207                                }
208                                if (modifier == TokenParamModifier.NOT_IN) {
209                                        operation = SearchFilterParser.CompareOperation.ne;
210                                }
211                        } else if (modifier == TokenParamModifier.ABOVE) {
212                                system = determineSystemIfMissing(theSearchParam, code, system);
213                                validateHaveSystemAndCodeForToken(paramName, code, system);
214                                codes.addAll(myTerminologySvc.findCodesAbove(system, code));
215                        } else if (modifier == TokenParamModifier.BELOW) {
216                                system = determineSystemIfMissing(theSearchParam, code, system);
217                                validateHaveSystemAndCodeForToken(paramName, code, system);
218                                codes.addAll(myTerminologySvc.findCodesBelow(system, code));
219                        } else if (modifier == TokenParamModifier.OF_TYPE) {
220                                if (!myStorageSettings.isIndexIdentifierOfType()) {
221                                        throw new MethodNotAllowedException(
222                                                        Msg.code(2012) + "The :of-type modifier is not enabled on this server");
223                                }
224                                if (isBlank(system) || isBlank(code)) {
225                                        throw new InvalidRequestException(Msg.code(2013) + "Invalid parameter value for :of-type query");
226                                }
227                                int pipeIdx = code.indexOf('|');
228                                if (pipeIdx < 1 || pipeIdx == code.length() - 1) {
229                                        throw new InvalidRequestException(Msg.code(2014) + "Invalid parameter value for :of-type query");
230                                }
231
232                                paramName = paramName + Constants.PARAMQUALIFIER_TOKEN_OF_TYPE;
233                                codes.add(new FhirVersionIndependentConcept(system, code));
234                        } else {
235                                if (modifier == TokenParamModifier.NOT && operation == null) {
236                                        operation = SearchFilterParser.CompareOperation.ne;
237                                }
238                                codes.add(new FhirVersionIndependentConcept(system, code));
239                        }
240                }
241
242                List<FhirVersionIndependentConcept> sortedCodesList = codes.stream()
243                                .filter(t -> t.getCode() != null || t.getSystem() != null)
244                                .sorted()
245                                .distinct()
246                                .collect(Collectors.toList());
247
248                if (codes.isEmpty()) {
249                        // This will never match anything
250                        setMatchNothing();
251                        return null;
252                }
253
254                Condition predicate;
255                if (operation == SearchFilterParser.CompareOperation.ne) {
256
257                        /*
258                         * For a token :not search, we look for index rows that have the right identity (i.e. it's the right resource and
259                         * param name) but not the actual provided token value.
260                         */
261
262                        long hashIdentity = BaseResourceIndexedSearchParam.calculateHashIdentity(
263                                        getPartitionSettings(), theRequestPartitionId, theResourceName, paramName);
264                        Condition hashIdentityPredicate =
265                                        BinaryCondition.equalTo(getColumnHashIdentity(), generatePlaceholder(hashIdentity));
266
267                        Condition hashValuePredicate = createPredicateOrList(theResourceName, paramName, sortedCodesList, false);
268                        predicate = QueryParameterUtils.toAndPredicate(hashIdentityPredicate, hashValuePredicate);
269
270                } else {
271
272                        predicate = createPredicateOrList(theResourceName, paramName, sortedCodesList, true);
273
274                        if (myStorageSettings.isIncludeHashIdentityForTokenSearches()) {
275                                long hashIdentity = BaseResourceIndexedSearchParam.calculateHashIdentity(
276                                                getPartitionSettings(), theRequestPartitionId, theResourceName, paramName);
277                                Condition hashIdentityPredicate =
278                                                BinaryCondition.equalTo(getColumnHashIdentity(), generatePlaceholder(hashIdentity));
279                                predicate = QueryParameterUtils.toAndPredicate(hashIdentityPredicate, predicate);
280                        }
281                }
282
283                return predicate;
284        }
285
286        private List<FhirVersionIndependentConcept> extractValueSetCodes(IBaseResource theValueSet) {
287                List<FhirVersionIndependentConcept> retVal = new ArrayList<>();
288
289                RuntimeResourceDefinition vsDef = myContext.getResourceDefinition("ValueSet");
290                BaseRuntimeChildDefinition expansionChild = vsDef.getChildByName("expansion");
291                Optional<IBase> expansionOpt = expansionChild.getAccessor().getFirstValueOrNull(theValueSet);
292                if (expansionOpt.isPresent()) {
293                        IBase expansion = expansionOpt.get();
294                        BaseRuntimeElementCompositeDefinition<?> expansionDef =
295                                        (BaseRuntimeElementCompositeDefinition<?>) myContext.getElementDefinition(expansion.getClass());
296                        BaseRuntimeChildDefinition containsChild = expansionDef.getChildByName("contains");
297                        List<IBase> contains = containsChild.getAccessor().getValues(expansion);
298
299                        BaseRuntimeChildDefinition.IAccessor systemAccessor = null;
300                        BaseRuntimeChildDefinition.IAccessor codeAccessor = null;
301                        for (IBase nextContains : contains) {
302                                if (systemAccessor == null) {
303                                        systemAccessor = myContext
304                                                        .getElementDefinition(nextContains.getClass())
305                                                        .getChildByName("system")
306                                                        .getAccessor();
307                                }
308                                if (codeAccessor == null) {
309                                        codeAccessor = myContext
310                                                        .getElementDefinition(nextContains.getClass())
311                                                        .getChildByName("code")
312                                                        .getAccessor();
313                                }
314                                String system = systemAccessor
315                                                .getFirstValueOrNull(nextContains)
316                                                .map(t -> (IPrimitiveType<?>) t)
317                                                .map(t -> t.getValueAsString())
318                                                .orElse(null);
319                                String code = codeAccessor
320                                                .getFirstValueOrNull(nextContains)
321                                                .map(t -> (IPrimitiveType<?>) t)
322                                                .map(t -> t.getValueAsString())
323                                                .orElse(null);
324                                if (isNotBlank(system) && isNotBlank(code)) {
325                                        retVal.add(new FhirVersionIndependentConcept(system, code));
326                                }
327                        }
328                }
329
330                return retVal;
331        }
332
333        private String determineSystemIfMissing(RuntimeSearchParam theSearchParam, String code, String theSystem) {
334                String retVal = theSystem;
335                if (retVal == null) {
336                        if (theSearchParam != null) {
337                                Set<String> valueSetUris = Sets.newHashSet();
338                                for (String nextPath : theSearchParam.getPathsSplitForResourceType(getResourceType())) {
339                                        Class<? extends IBaseResource> type = getFhirContext()
340                                                        .getResourceDefinition(getResourceType())
341                                                        .getImplementingClass();
342                                        BaseRuntimeChildDefinition def =
343                                                        getFhirContext().newTerser().getDefinition(type, nextPath);
344                                        if (def instanceof BaseRuntimeDeclaredChildDefinition) {
345                                                String valueSet = ((BaseRuntimeDeclaredChildDefinition) def).getBindingValueSet();
346                                                if (isNotBlank(valueSet)) {
347                                                        valueSetUris.add(valueSet);
348                                                }
349                                        }
350                                }
351                                if (valueSetUris.size() == 1) {
352                                        String valueSet = valueSetUris.iterator().next();
353                                        ValueSetExpansionOptions options = new ValueSetExpansionOptions().setFailOnMissingCodeSystem(false);
354                                        List<FhirVersionIndependentConcept> candidateCodes =
355                                                        myTerminologySvc.expandValueSetIntoConceptList(options, valueSet);
356                                        for (FhirVersionIndependentConcept nextCandidate : candidateCodes) {
357                                                if (nextCandidate.getCode().equals(code)) {
358                                                        retVal = nextCandidate.getSystem();
359                                                        break;
360                                                }
361                                        }
362                                }
363                        }
364                }
365                return retVal;
366        }
367
368        public DbColumn getColumnSystem() {
369                return myColumnSystem;
370        }
371
372        public DbColumn getColumnValue() {
373                return myColumnValue;
374        }
375
376        private void validateHaveSystemAndCodeForToken(String theParamName, String theCode, String theSystem) {
377                String systemDesc = defaultIfBlank(theSystem, "(missing)");
378                String codeDesc = defaultIfBlank(theCode, "(missing)");
379                if (isBlank(theCode)) {
380                        String msg = getFhirContext()
381                                        .getLocalizer()
382                                        .getMessage(
383                                                        TokenPredicateBuilder.class,
384                                                        "invalidCodeMissingSystem",
385                                                        theParamName,
386                                                        systemDesc,
387                                                        codeDesc);
388                        throw new InvalidRequestException(Msg.code(1239) + msg);
389                }
390                if (isBlank(theSystem)) {
391                        String msg = getFhirContext()
392                                        .getLocalizer()
393                                        .getMessage(
394                                                        TokenPredicateBuilder.class, "invalidCodeMissingCode", theParamName, systemDesc, codeDesc);
395                        throw new InvalidRequestException(Msg.code(1240) + msg);
396                }
397        }
398
399        private Condition createPredicateOrList(
400                        String theResourceType,
401                        String theSearchParamName,
402                        List<FhirVersionIndependentConcept> theCodes,
403                        boolean theWantEquals) {
404                Condition[] conditions = new Condition[theCodes.size()];
405
406                Long[] hashes = new Long[theCodes.size()];
407                DbColumn[] columns = new DbColumn[theCodes.size()];
408                boolean haveMultipleColumns = false;
409                for (int i = 0; i < conditions.length; i++) {
410
411                        FhirVersionIndependentConcept nextToken = theCodes.get(i);
412                        long hash;
413                        DbColumn column;
414                        if (nextToken.getSystem() == null) {
415                                hash = ResourceIndexedSearchParamToken.calculateHashValue(
416                                                getPartitionSettings(),
417                                                getRequestPartitionId(),
418                                                theResourceType,
419                                                theSearchParamName,
420                                                nextToken.getCode());
421                                column = myColumnHashValue;
422                        } else if (isBlank(nextToken.getCode())) {
423                                hash = ResourceIndexedSearchParamToken.calculateHashSystem(
424                                                getPartitionSettings(),
425                                                getRequestPartitionId(),
426                                                theResourceType,
427                                                theSearchParamName,
428                                                nextToken.getSystem());
429                                column = myColumnHashSystem;
430                        } else {
431                                hash = ResourceIndexedSearchParamToken.calculateHashSystemAndValue(
432                                                getPartitionSettings(),
433                                                getRequestPartitionId(),
434                                                theResourceType,
435                                                theSearchParamName,
436                                                nextToken.getSystem(),
437                                                nextToken.getCode());
438                                column = myColumnHashSystemAndValue;
439                        }
440                        hashes[i] = hash;
441                        columns[i] = column;
442                        if (i > 0 && columns[0] != columns[i]) {
443                                haveMultipleColumns = true;
444                        }
445                }
446
447                if (!haveMultipleColumns && conditions.length > 1) {
448                        List<Long> values = Arrays.asList(hashes);
449                        return QueryParameterUtils.toEqualToOrInPredicate(columns[0], generatePlaceholders(values), !theWantEquals);
450                }
451
452                for (int i = 0; i < conditions.length; i++) {
453                        String valuePlaceholder = generatePlaceholder(hashes[i]);
454                        if (theWantEquals) {
455                                conditions[i] = BinaryCondition.equalTo(columns[i], valuePlaceholder);
456                        } else {
457                                conditions[i] = BinaryCondition.notEqualTo(columns[i], valuePlaceholder);
458                        }
459                }
460                if (conditions.length > 1) {
461                        if (theWantEquals) {
462                                return QueryParameterUtils.toOrPredicate(conditions);
463                        } else {
464                                return QueryParameterUtils.toAndPredicate(conditions);
465                        }
466                } else {
467                        return conditions[0];
468                }
469        }
470}