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