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