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.BaseRuntimeElementDefinition;
024import ca.uhn.fhir.context.ConfigurationException;
025import ca.uhn.fhir.context.RuntimeChildChoiceDefinition;
026import ca.uhn.fhir.context.RuntimeChildResourceDefinition;
027import ca.uhn.fhir.context.RuntimeResourceDefinition;
028import ca.uhn.fhir.context.RuntimeSearchParam;
029import ca.uhn.fhir.i18n.Msg;
030import ca.uhn.fhir.interceptor.api.HookParams;
031import ca.uhn.fhir.interceptor.api.IInterceptorBroadcaster;
032import ca.uhn.fhir.interceptor.api.Pointcut;
033import ca.uhn.fhir.interceptor.model.RequestPartitionId;
034import ca.uhn.fhir.jpa.api.config.JpaStorageSettings;
035import ca.uhn.fhir.jpa.api.dao.DaoRegistry;
036import ca.uhn.fhir.jpa.api.dao.IDao;
037import ca.uhn.fhir.jpa.api.svc.IIdHelperService;
038import ca.uhn.fhir.jpa.api.svc.ResolveIdentityMode;
039import ca.uhn.fhir.jpa.dao.BaseStorageDao;
040import ca.uhn.fhir.jpa.dao.predicate.SearchFilterParser;
041import ca.uhn.fhir.jpa.model.dao.JpaPid;
042import ca.uhn.fhir.jpa.model.search.StorageProcessingMessage;
043import ca.uhn.fhir.jpa.search.SearchCoordinatorSvcImpl;
044import ca.uhn.fhir.jpa.search.builder.QueryStack;
045import ca.uhn.fhir.jpa.search.builder.models.MissingQueryParameterPredicateParams;
046import ca.uhn.fhir.jpa.search.builder.sql.ColumnTupleObject;
047import ca.uhn.fhir.jpa.search.builder.sql.JpaPidValueTuples;
048import ca.uhn.fhir.jpa.search.builder.sql.SearchQueryBuilder;
049import ca.uhn.fhir.jpa.searchparam.MatchUrlService;
050import ca.uhn.fhir.jpa.searchparam.ResourceMetaParams;
051import ca.uhn.fhir.jpa.util.QueryParameterUtils;
052import ca.uhn.fhir.model.api.IQueryParameterType;
053import ca.uhn.fhir.model.primitive.IdDt;
054import ca.uhn.fhir.parser.DataFormatException;
055import ca.uhn.fhir.rest.api.RestSearchParameterTypeEnum;
056import ca.uhn.fhir.rest.api.server.RequestDetails;
057import ca.uhn.fhir.rest.param.ReferenceParam;
058import ca.uhn.fhir.rest.param.TokenParam;
059import ca.uhn.fhir.rest.param.TokenParamModifier;
060import ca.uhn.fhir.rest.server.exceptions.InternalErrorException;
061import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
062import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails;
063import ca.uhn.fhir.rest.server.util.CompositeInterceptorBroadcaster;
064import ca.uhn.fhir.rest.server.util.ISearchParamRegistry;
065import com.google.common.annotations.VisibleForTesting;
066import com.google.common.collect.Lists;
067import com.healthmarketscience.sqlbuilder.BinaryCondition;
068import com.healthmarketscience.sqlbuilder.ComboCondition;
069import com.healthmarketscience.sqlbuilder.Condition;
070import com.healthmarketscience.sqlbuilder.InCondition;
071import com.healthmarketscience.sqlbuilder.NotCondition;
072import com.healthmarketscience.sqlbuilder.SelectQuery;
073import com.healthmarketscience.sqlbuilder.UnaryCondition;
074import com.healthmarketscience.sqlbuilder.dbspec.basic.DbColumn;
075import jakarta.annotation.Nonnull;
076import jakarta.annotation.Nullable;
077import org.apache.commons.lang3.StringUtils;
078import org.hl7.fhir.instance.model.api.IBaseResource;
079import org.hl7.fhir.instance.model.api.IIdType;
080import org.slf4j.Logger;
081import org.slf4j.LoggerFactory;
082import org.springframework.beans.factory.annotation.Autowired;
083
084import java.util.ArrayList;
085import java.util.Collection;
086import java.util.Collections;
087import java.util.HashSet;
088import java.util.List;
089import java.util.ListIterator;
090import java.util.Optional;
091import java.util.Set;
092import java.util.regex.Pattern;
093import java.util.stream.Collectors;
094
095import static ca.uhn.fhir.jpa.search.builder.QueryStack.SearchForIdsParams.with;
096import static ca.uhn.fhir.rest.api.Constants.PARAM_TYPE;
097import static ca.uhn.fhir.rest.api.Constants.VALID_MODIFIERS;
098import static org.apache.commons.lang3.StringUtils.isBlank;
099import static org.apache.commons.lang3.StringUtils.trim;
100
101public class ResourceLinkPredicateBuilder extends BaseJoiningPredicateBuilder implements ICanMakeMissingParamPredicate {
102
103        private static final Logger ourLog = LoggerFactory.getLogger(ResourceLinkPredicateBuilder.class);
104        private static final Pattern MODIFIER_REPLACE_PATTERN = Pattern.compile(".*:");
105        private final DbColumn myColumnSrcType;
106        private final DbColumn myColumnSrcPath;
107        private final DbColumn myColumnTargetResourceId;
108        private final DbColumn myColumnTargetResourceUrl;
109        private final DbColumn myColumnSrcResourceId;
110        private final DbColumn myColumnTargetResourceType;
111        private final QueryStack myQueryStack;
112        private final boolean myReversed;
113
114        private final DbColumn myColumnTargetPartitionId;
115        private final DbColumn myColumnSrcPartitionId;
116
117        @Autowired
118        private JpaStorageSettings myStorageSettings;
119
120        @Autowired
121        private IInterceptorBroadcaster myInterceptorBroadcaster;
122
123        @Autowired
124        private ISearchParamRegistry mySearchParamRegistry;
125
126        @Autowired
127        private IIdHelperService<JpaPid> myIdHelperService;
128
129        @Autowired
130        private DaoRegistry myDaoRegistry;
131
132        @Autowired
133        private MatchUrlService myMatchUrlService;
134
135        /**
136         * Constructor
137         */
138        public ResourceLinkPredicateBuilder(
139                        QueryStack theQueryStack, SearchQueryBuilder theSearchSqlBuilder, boolean theReversed) {
140                super(theSearchSqlBuilder, theSearchSqlBuilder.addTable("HFJ_RES_LINK"));
141                myColumnSrcResourceId = getTable().addColumn("SRC_RESOURCE_ID");
142                myColumnSrcPartitionId = getTable().addColumn("PARTITION_ID");
143                myColumnSrcType = getTable().addColumn("SOURCE_RESOURCE_TYPE");
144                myColumnSrcPath = getTable().addColumn("SRC_PATH");
145                myColumnTargetResourceId = getTable().addColumn("TARGET_RESOURCE_ID");
146                myColumnTargetPartitionId = getTable().addColumn("TARGET_RES_PARTITION_ID");
147                myColumnTargetResourceUrl = getTable().addColumn("TARGET_RESOURCE_URL");
148                myColumnTargetResourceType = getTable().addColumn("TARGET_RESOURCE_TYPE");
149
150                myReversed = theReversed;
151                myQueryStack = theQueryStack;
152        }
153
154        @Override
155        public DbColumn getResourceTypeColumn() {
156                if (myReversed) {
157                        return myColumnTargetResourceType;
158                } else {
159                        return myColumnSrcType;
160                }
161        }
162
163        public DbColumn getColumnSourcePath() {
164                return myColumnSrcPath;
165        }
166
167        public DbColumn getColumnTargetResourceId() {
168                return myColumnTargetResourceId;
169        }
170
171        public DbColumn getColumnTargetPartitionId() {
172                return myColumnTargetPartitionId;
173        }
174
175        public DbColumn[] getJoinColumnsForTarget() {
176                return getSearchQueryBuilder().toJoinColumns(getColumnTargetPartitionId(), getColumnTargetResourceId());
177        }
178
179        public DbColumn[] getJoinColumnsForSource() {
180                return getSearchQueryBuilder().toJoinColumns(getPartitionIdColumn(), myColumnSrcResourceId);
181        }
182
183        /**
184         * Note that this may return the SRC_RESOURCE_ID or TGT_RESOURCE_ID depending
185         * on whether we're building a forward or reverse link. If you need a specific
186         * one of these, use {@link #getJoinColumnsForSource()} or {@link #getJoinColumnsForTarget()}.
187         */
188        @Override
189        public DbColumn[] getJoinColumns() {
190                return super.getJoinColumns();
191        }
192
193        public DbColumn getColumnSrcResourceId() {
194                return myColumnSrcResourceId;
195        }
196
197        public DbColumn getColumnSrcPartitionId() {
198                return myColumnSrcPartitionId;
199        }
200
201        public DbColumn getColumnTargetResourceType() {
202                return myColumnTargetResourceType;
203        }
204
205        @Override
206        public DbColumn getResourceIdColumn() {
207                if (myReversed) {
208                        return myColumnTargetResourceId;
209                } else {
210                        return myColumnSrcResourceId;
211                }
212        }
213
214        @Nullable
215        public Condition createPredicate(
216                        RequestDetails theRequest,
217                        String theResourceType,
218                        String theParamName,
219                        List<String> theQualifiers,
220                        List<? extends IQueryParameterType> theReferenceOrParamList,
221                        SearchFilterParser.CompareOperation theOperation,
222                        RequestPartitionId theRequestPartitionId) {
223
224                List<IIdType> targetIds = new ArrayList<>();
225                List<String> targetQualifiedUrls = new ArrayList<>();
226
227                for (int orIdx = 0; orIdx < theReferenceOrParamList.size(); orIdx++) {
228                        IQueryParameterType nextOr = theReferenceOrParamList.get(orIdx);
229
230                        if (nextOr instanceof ReferenceParam) {
231                                ReferenceParam ref = (ReferenceParam) nextOr;
232
233                                if (isBlank(ref.getChain())) {
234
235                                        /*
236                                         * Handle non-chained search, e.g. Patient?organization=Organization/123
237                                         */
238
239                                        IIdType dt = new IdDt(ref.getBaseUrl(), ref.getResourceType(), ref.getIdPart(), null);
240
241                                        if (dt.hasBaseUrl()) {
242                                                if (myStorageSettings.getTreatBaseUrlsAsLocal().contains(dt.getBaseUrl())) {
243                                                        dt = dt.toUnqualified();
244                                                        targetIds.add(dt);
245                                                } else {
246                                                        targetQualifiedUrls.add(dt.getValue());
247                                                }
248                                        } else {
249                                                validateModifierUse(theRequest, theResourceType, ref);
250                                                validateResourceTypeInReferenceParam(ref.getResourceType());
251                                                targetIds.add(dt);
252                                        }
253
254                                } else {
255
256                                        /*
257                                         * Handle chained search, e.g. Patient?organization.name=Kwik-e-mart
258                                         */
259
260                                        return addPredicateReferenceWithChain(
261                                                        theResourceType,
262                                                        theParamName,
263                                                        theQualifiers,
264                                                        theReferenceOrParamList,
265                                                        ref,
266                                                        theRequest,
267                                                        theRequestPartitionId);
268                                }
269
270                        } else {
271                                throw new IllegalArgumentException(
272                                                Msg.code(1241) + "Invalid token type (expecting ReferenceParam): " + nextOr.getClass());
273                        }
274                }
275
276                for (IIdType next : targetIds) {
277                        if (!next.hasResourceType()) {
278                                warnAboutPerformanceOnUnqualifiedResources(theParamName, theRequest, null);
279                        }
280                }
281
282                List<String> pathsToMatch = createResourceLinkPaths(theResourceType, theParamName, theQualifiers);
283                boolean inverse;
284                if ((theOperation == null) || (theOperation == SearchFilterParser.CompareOperation.eq)) {
285                        inverse = false;
286                } else {
287                        inverse = true;
288                }
289
290                List<JpaPid> pids = myIdHelperService.resolveResourcePids(
291                                theRequestPartitionId,
292                                targetIds,
293                                ResolveIdentityMode.includeDeleted().cacheOk());
294                List<Long> targetPidList = pids.stream().map(JpaPid::getId).collect(Collectors.toList());
295
296                if (targetPidList.isEmpty() && targetQualifiedUrls.isEmpty()) {
297                        setMatchNothing();
298                        return null;
299                } else {
300                        Condition retVal = createPredicateReference(inverse, pathsToMatch, targetPidList, targetQualifiedUrls);
301                        return combineWithRequestPartitionIdPredicate(getRequestPartitionId(), retVal);
302                }
303        }
304
305        private void validateModifierUse(RequestDetails theRequest, String theResourceType, ReferenceParam theRef) {
306                try {
307                        final String resourceTypeFromRef = theRef.getResourceType();
308                        if (StringUtils.isEmpty(resourceTypeFromRef)) {
309                                return;
310                        }
311                        // TODO: LD: unless we do this, ResourceProviderR4Test#testSearchWithSlashes will fail due to its
312                        // derived-from: syntax
313                        getFhirContext().getResourceDefinition(resourceTypeFromRef);
314                } catch (DataFormatException e) {
315                        final List<String> nonMatching = Optional.ofNullable(theRequest)
316                                        .map(RequestDetails::getParameters)
317                                        .map(params -> params.keySet().stream()
318                                                        .filter(mod -> mod.contains(":"))
319                                                        .map(MODIFIER_REPLACE_PATTERN::matcher)
320                                                        .map(pattern -> pattern.replaceAll(":"))
321                                                        .filter(mod -> !VALID_MODIFIERS.contains(mod))
322                                                        .distinct()
323                                                        .collect(Collectors.toUnmodifiableList()))
324                                        .orElse(Collections.emptyList());
325
326                        if (!nonMatching.isEmpty()) {
327                                final String msg = getFhirContext()
328                                                .getLocalizer()
329                                                .getMessageSanitized(
330                                                                SearchCoordinatorSvcImpl.class,
331                                                                "invalidUseOfSearchIdentifier",
332                                                                nonMatching,
333                                                                theResourceType,
334                                                                VALID_MODIFIERS);
335                                throw new InvalidRequestException(Msg.code(2498) + msg);
336                        }
337                }
338        }
339
340        private void validateResourceTypeInReferenceParam(final String theResourceType) {
341                if (StringUtils.isEmpty(theResourceType)) {
342                        return;
343                }
344
345                try {
346                        getFhirContext().getResourceDefinition(theResourceType);
347                } catch (DataFormatException e) {
348                        throw newInvalidResourceTypeException(theResourceType);
349                }
350        }
351
352        private Condition createPredicateReference(
353                        boolean theInverse,
354                        List<String> thePathsToMatch,
355                        List<Long> theTargetPidList,
356                        List<String> theTargetQualifiedUrls) {
357
358                Condition targetPidCondition = null;
359                if (!theTargetPidList.isEmpty()) {
360                        List<String> placeholders = generatePlaceholders(theTargetPidList);
361                        targetPidCondition =
362                                        QueryParameterUtils.toEqualToOrInPredicate(myColumnTargetResourceId, placeholders, theInverse);
363                }
364
365                Condition targetUrlsCondition = null;
366                if (!theTargetQualifiedUrls.isEmpty()) {
367                        List<String> placeholders = generatePlaceholders(theTargetQualifiedUrls);
368                        targetUrlsCondition =
369                                        QueryParameterUtils.toEqualToOrInPredicate(myColumnTargetResourceUrl, placeholders, theInverse);
370                }
371
372                Condition joinedCondition;
373                if (targetPidCondition != null && targetUrlsCondition != null) {
374                        joinedCondition = ComboCondition.or(targetPidCondition, targetUrlsCondition);
375                } else if (targetPidCondition != null) {
376                        joinedCondition = targetPidCondition;
377                } else {
378                        joinedCondition = targetUrlsCondition;
379                }
380
381                Condition pathPredicate = createPredicateSourcePaths(thePathsToMatch);
382                joinedCondition = ComboCondition.and(pathPredicate, joinedCondition);
383
384                return joinedCondition;
385        }
386
387        @Nonnull
388        public Condition createPredicateSourcePaths(List<String> thePathsToMatch) {
389                return QueryParameterUtils.toEqualToOrInPredicate(myColumnSrcPath, generatePlaceholders(thePathsToMatch));
390        }
391
392        public Condition createPredicateSourcePaths(String theResourceName, String theParamName) {
393                List<String> pathsToMatch = createResourceLinkPaths(theResourceName, theParamName, Collections.emptyList());
394                return createPredicateSourcePaths(pathsToMatch);
395        }
396
397        private void warnAboutPerformanceOnUnqualifiedResources(
398                        String theParamName, RequestDetails theRequest, @Nullable List<String> theCandidateTargetTypes) {
399                StringBuilder builder = new StringBuilder();
400                builder.append("This search uses an unqualified resource(a parameter in a chain without a resource type). ");
401                builder.append("This is less efficient than using a qualified type. ");
402                if (theCandidateTargetTypes != null) {
403                        builder.append("[" + theParamName + "] resolves to ["
404                                        + theCandidateTargetTypes.stream().collect(Collectors.joining(",")) + "].");
405                        builder.append("If you know what you're looking for, try qualifying it using the form ");
406                        builder.append(theCandidateTargetTypes.stream()
407                                        .map(cls -> "[" + cls + ":" + theParamName + "]")
408                                        .collect(Collectors.joining(" or ")));
409                } else {
410                        builder.append("If you know what you're looking for, try qualifying it using the form: '");
411                        builder.append(theParamName).append(":[resourceType]=[id] or ");
412                        builder.append(theParamName).append("=[resourceType]/[id]");
413                        builder.append("'");
414                }
415                String message = builder.toString();
416                StorageProcessingMessage msg = new StorageProcessingMessage().setMessage(message);
417
418                IInterceptorBroadcaster compositeBroadcaster =
419                                CompositeInterceptorBroadcaster.newCompositeBroadcaster(myInterceptorBroadcaster, theRequest);
420                if (compositeBroadcaster.hasHooks(Pointcut.JPA_PERFTRACE_WARNING)) {
421                        HookParams params = new HookParams()
422                                        .add(RequestDetails.class, theRequest)
423                                        .addIfMatchesType(ServletRequestDetails.class, theRequest)
424                                        .add(StorageProcessingMessage.class, msg);
425                        compositeBroadcaster.callHooks(Pointcut.JPA_PERFTRACE_WARNING, params);
426                }
427        }
428
429        /**
430         * This is for handling queries like the following: /Observation?device.identifier=urn:system|foo in which we use a chain
431         * on the device.
432         */
433        @Nullable
434        private Condition addPredicateReferenceWithChain(
435                        String theResourceName,
436                        String theParamName,
437                        List<String> theQualifiers,
438                        List<? extends IQueryParameterType> theList,
439                        ReferenceParam theReferenceParam,
440                        RequestDetails theRequest,
441                        RequestPartitionId theRequestPartitionId) {
442
443                /*
444                 * Which resource types can the given chained parameter actually link to? This might be a list
445                 * where the chain is unqualified, as in: Observation?subject.identifier=(...)
446                 * since subject can link to several possible target types.
447                 *
448                 * If the user has qualified the chain, as in: Observation?subject:Patient.identifier=(...)
449                 * this is just a simple 1-entry list.
450                 */
451                final List<String> resourceTypes =
452                                determineCandidateResourceTypesForChain(theResourceName, theParamName, theReferenceParam);
453
454                /*
455                 * Handle chain on _type
456                 */
457                if (PARAM_TYPE.equals(theReferenceParam.getChain())) {
458
459                        List<String> pathsToMatch = createResourceLinkPaths(theResourceName, theParamName, theQualifiers);
460                        Condition typeCondition = createPredicateSourcePaths(pathsToMatch);
461
462                        String typeValue = theReferenceParam.getValue();
463
464                        validateResourceTypeInReferenceParam(typeValue);
465                        if (!resourceTypes.contains(typeValue)) {
466                                throw newInvalidTargetTypeForChainException(theResourceName, theParamName, typeValue);
467                        }
468
469                        Condition condition = BinaryCondition.equalTo(
470                                        myColumnTargetResourceType, generatePlaceholder(theReferenceParam.getValue()));
471
472                        return QueryParameterUtils.toAndPredicate(typeCondition, condition);
473                }
474
475                boolean foundChainMatch = false;
476                List<String> candidateTargetTypes = new ArrayList<>();
477                List<Condition> orPredicates = new ArrayList<>();
478                boolean paramInverted = false;
479                QueryStack childQueryFactory = myQueryStack.newChildQueryFactoryWithFullBuilderReuse();
480
481                String chain = theReferenceParam.getChain();
482
483                String remainingChain = null;
484                int chainDotIndex = chain.indexOf('.');
485                if (chainDotIndex != -1) {
486                        remainingChain = chain.substring(chainDotIndex + 1);
487                        chain = chain.substring(0, chainDotIndex);
488                }
489
490                int qualifierIndex = chain.indexOf(':');
491                String qualifier = null;
492                if (qualifierIndex != -1) {
493                        qualifier = chain.substring(qualifierIndex);
494                        chain = chain.substring(0, qualifierIndex);
495                }
496
497                boolean isMeta = ResourceMetaParams.RESOURCE_META_PARAMS.containsKey(chain);
498
499                for (String nextType : resourceTypes) {
500
501                        RuntimeResourceDefinition typeDef = getFhirContext().getResourceDefinition(nextType);
502                        String subResourceName = typeDef.getName();
503
504                        IDao dao = myDaoRegistry.getResourceDao(nextType);
505                        if (dao == null) {
506                                ourLog.debug("Don't have a DAO for type {}", nextType);
507                                continue;
508                        }
509
510                        RuntimeSearchParam param = null;
511                        if (!isMeta) {
512                                param = mySearchParamRegistry.getActiveSearchParam(
513                                                nextType, chain, ISearchParamRegistry.SearchParamLookupContextEnum.SEARCH);
514                                if (param == null) {
515                                        ourLog.debug("Type {} doesn't have search param {}", nextType, param);
516                                        continue;
517                                }
518                        }
519
520                        ArrayList<IQueryParameterType> orValues = Lists.newArrayList();
521
522                        for (IQueryParameterType next : theList) {
523                                String nextValue = next.getValueAsQueryToken(getFhirContext());
524                                IQueryParameterType chainValue = mapReferenceChainToRawParamType(
525                                                remainingChain, param, theParamName, qualifier, nextType, chain, isMeta, nextValue);
526                                if (chainValue == null) {
527                                        continue;
528                                }
529
530                                // For the token param, if it's a :not modifier, need switch OR to AND
531                                if (!paramInverted && chainValue instanceof TokenParam) {
532                                        if (((TokenParam) chainValue).getModifier() == TokenParamModifier.NOT) {
533                                                paramInverted = true;
534                                        }
535                                }
536                                foundChainMatch = true;
537                                orValues.add(chainValue);
538                        }
539
540                        if (!foundChainMatch) {
541                                throw new InvalidRequestException(Msg.code(1242)
542                                                + getFhirContext()
543                                                                .getLocalizer()
544                                                                .getMessage(
545                                                                                BaseStorageDao.class,
546                                                                                "invalidParameterChain",
547                                                                                theParamName + '.' + theReferenceParam.getChain()));
548                        }
549
550                        candidateTargetTypes.add(nextType);
551
552                        List<Condition> andPredicates = new ArrayList<>();
553
554                        List<List<IQueryParameterType>> chainParamValues = Collections.singletonList(orValues);
555                        andPredicates.add(
556                                        childQueryFactory.searchForIdsWithAndOr(with().setSourceJoinColumn(getJoinColumnsForTarget())
557                                                        .setResourceName(subResourceName)
558                                                        .setParamName(chain)
559                                                        .setAndOrParams(chainParamValues)
560                                                        .setRequest(theRequest)
561                                                        .setRequestPartitionId(theRequestPartitionId)));
562
563                        orPredicates.add(QueryParameterUtils.toAndPredicate(andPredicates));
564                }
565
566                if (candidateTargetTypes.isEmpty()) {
567                        throw new InvalidRequestException(Msg.code(1243)
568                                        + getFhirContext()
569                                                        .getLocalizer()
570                                                        .getMessage(
571                                                                        BaseStorageDao.class,
572                                                                        "invalidParameterChain",
573                                                                        theParamName + '.' + theReferenceParam.getChain()));
574                }
575
576                if (candidateTargetTypes.size() > 1) {
577                        warnAboutPerformanceOnUnqualifiedResources(theParamName, theRequest, candidateTargetTypes);
578                }
579
580                // If :not modifier for a token, switch OR with AND in the multi-type case
581                Condition multiTypePredicate;
582                if (paramInverted) {
583                        multiTypePredicate = QueryParameterUtils.toAndPredicate(orPredicates);
584                } else {
585                        multiTypePredicate = QueryParameterUtils.toOrPredicate(orPredicates);
586                }
587
588                List<String> pathsToMatch = createResourceLinkPaths(theResourceName, theParamName, theQualifiers);
589                Condition pathPredicate = createPredicateSourcePaths(pathsToMatch);
590                return QueryParameterUtils.toAndPredicate(pathPredicate, multiTypePredicate);
591        }
592
593        @Nonnull
594        private List<String> determineCandidateResourceTypesForChain(
595                        String theResourceName, String theParamName, ReferenceParam theReferenceParam) {
596                final List<Class<? extends IBaseResource>> resourceTypes;
597                if (!theReferenceParam.hasResourceType()) {
598
599                        resourceTypes = determineResourceTypes(Collections.singleton(theResourceName), theParamName);
600
601                        if (resourceTypes.isEmpty()) {
602                                RuntimeSearchParam searchParamByName = mySearchParamRegistry.getActiveSearchParam(
603                                                theResourceName, theParamName, ISearchParamRegistry.SearchParamLookupContextEnum.SEARCH);
604                                if (searchParamByName == null) {
605                                        throw new InternalErrorException(Msg.code(1244) + "Could not find parameter " + theParamName);
606                                }
607                                String paramPath = searchParamByName.getPath();
608                                if (paramPath.endsWith(".as(Reference)")) {
609                                        paramPath = paramPath.substring(0, paramPath.length() - ".as(Reference)".length()) + "Reference";
610                                }
611
612                                if (paramPath.contains(".extension(")) {
613                                        int startIdx = paramPath.indexOf(".extension(");
614                                        int endIdx = paramPath.indexOf(')', startIdx);
615                                        if (startIdx != -1 && endIdx != -1) {
616                                                paramPath = paramPath.substring(0, startIdx + 10) + paramPath.substring(endIdx + 1);
617                                        }
618                                }
619
620                                Class<? extends IBaseResource> resourceType =
621                                                getFhirContext().getResourceDefinition(theResourceName).getImplementingClass();
622                                BaseRuntimeChildDefinition def = getFhirContext().newTerser().getDefinition(resourceType, paramPath);
623                                if (def instanceof RuntimeChildChoiceDefinition) {
624                                        RuntimeChildChoiceDefinition choiceDef = (RuntimeChildChoiceDefinition) def;
625                                        resourceTypes.addAll(choiceDef.getResourceTypes());
626                                } else if (def instanceof RuntimeChildResourceDefinition) {
627                                        RuntimeChildResourceDefinition resDef = (RuntimeChildResourceDefinition) def;
628                                        resourceTypes.addAll(resDef.getResourceTypes());
629                                        if (resourceTypes.size() == 1) {
630                                                if (resourceTypes.get(0).isInterface()) {
631                                                        throw new InvalidRequestException(
632                                                                        Msg.code(1245) + "Unable to perform search for unqualified chain '" + theParamName
633                                                                                        + "' as this SearchParameter does not declare any target types. Add a qualifier of the form '"
634                                                                                        + theParamName + ":[ResourceType]' to perform this search.");
635                                                }
636                                        }
637                                } else {
638                                        throw new ConfigurationException(Msg.code(1246) + "Property " + paramPath + " of type "
639                                                        + getResourceType() + " is not a resource: " + def.getClass());
640                                }
641                        }
642
643                        if (resourceTypes.isEmpty()) {
644                                for (BaseRuntimeElementDefinition<?> next : getFhirContext().getElementDefinitions()) {
645                                        if (next instanceof RuntimeResourceDefinition) {
646                                                RuntimeResourceDefinition nextResDef = (RuntimeResourceDefinition) next;
647                                                resourceTypes.add(nextResDef.getImplementingClass());
648                                        }
649                                }
650                        }
651
652                } else {
653
654                        try {
655                                RuntimeResourceDefinition resDef =
656                                                getFhirContext().getResourceDefinition(theReferenceParam.getResourceType());
657                                resourceTypes = new ArrayList<>(1);
658                                resourceTypes.add(resDef.getImplementingClass());
659                        } catch (DataFormatException e) {
660                                throw newInvalidResourceTypeException(theReferenceParam.getResourceType());
661                        }
662                }
663
664                return resourceTypes.stream()
665                                .map(t -> getFhirContext().getResourceType(t))
666                                .collect(Collectors.toList());
667        }
668
669        private List<Class<? extends IBaseResource>> determineResourceTypes(
670                        Set<String> theResourceNames, String theParamNameChain) {
671                int linkIndex = theParamNameChain.indexOf('.');
672                if (linkIndex == -1) {
673                        Set<Class<? extends IBaseResource>> resourceTypes = new HashSet<>();
674                        for (String resourceName : theResourceNames) {
675                                RuntimeSearchParam param = mySearchParamRegistry.getActiveSearchParam(
676                                                resourceName, theParamNameChain, ISearchParamRegistry.SearchParamLookupContextEnum.SEARCH);
677
678                                if (param != null && param.hasTargets()) {
679                                        Set<String> targetTypes = param.getTargets();
680                                        for (String next : targetTypes) {
681                                                resourceTypes.add(
682                                                                getFhirContext().getResourceDefinition(next).getImplementingClass());
683                                        }
684                                }
685                        }
686                        return new ArrayList<>(resourceTypes);
687                } else {
688                        String paramNameHead = theParamNameChain.substring(0, linkIndex);
689                        String paramNameTail = theParamNameChain.substring(linkIndex + 1);
690                        Set<String> targetResourceTypeNames = new HashSet<>();
691                        for (String resourceName : theResourceNames) {
692                                RuntimeSearchParam param = mySearchParamRegistry.getActiveSearchParam(
693                                                resourceName, paramNameHead, ISearchParamRegistry.SearchParamLookupContextEnum.SEARCH);
694
695                                if (param != null && param.hasTargets()) {
696                                        targetResourceTypeNames.addAll(param.getTargets());
697                                }
698                        }
699                        return determineResourceTypes(targetResourceTypeNames, paramNameTail);
700                }
701        }
702
703        public List<String> createResourceLinkPaths(
704                        String theResourceName, String theParamName, List<String> theParamQualifiers) {
705                RuntimeSearchParam param = mySearchParamRegistry.getActiveSearchParam(
706                                theResourceName, theParamName, ISearchParamRegistry.SearchParamLookupContextEnum.SEARCH);
707                if (param != null) {
708                        List<String> path = param.getPathsSplit();
709
710                        /*
711                         * SearchParameters can declare paths on multiple resource
712                         * types. Here we only want the ones that actually apply.
713                         */
714                        path = new ArrayList<>(path);
715
716                        ListIterator<String> iter = path.listIterator();
717                        while (iter.hasNext()) {
718                                String nextPath = trim(iter.next());
719                                if (!nextPath.contains(theResourceName + ".")) {
720                                        iter.remove();
721                                }
722                        }
723
724                        return path;
725                }
726
727                boolean containsChain = theParamName.contains(".");
728                if (containsChain) {
729                        int linkIndex = theParamName.indexOf('.');
730                        String paramNameHead = theParamName.substring(0, linkIndex);
731                        String paramNameTail = theParamName.substring(linkIndex + 1);
732                        String qualifier = !theParamQualifiers.isEmpty() ? theParamQualifiers.get(0) : null;
733                        List<String> nextQualifiersList = !theParamQualifiers.isEmpty()
734                                        ? theParamQualifiers.subList(1, theParamQualifiers.size())
735                                        : List.of();
736
737                        param = mySearchParamRegistry.getActiveSearchParam(
738                                        theResourceName, paramNameHead, ISearchParamRegistry.SearchParamLookupContextEnum.SEARCH);
739                        if (param != null) {
740                                Set<String> tailPaths = param.getTargets().stream()
741                                                .filter(t -> isBlank(qualifier) || qualifier.equals(t))
742                                                .map(t -> createResourceLinkPaths(t, paramNameTail, nextQualifiersList))
743                                                .flatMap(Collection::stream)
744                                                .map(t -> t.substring(t.indexOf('.') + 1))
745                                                .collect(Collectors.toSet());
746
747                                List<String> path = param.getPathsSplit();
748
749                                /*
750                                 * SearchParameters can declare paths on multiple resource
751                                 * types. Here we only want the ones that actually apply.
752                                 * Then append all the tail paths to each of the applicable head paths
753                                 */
754                                return path.stream()
755                                                .map(String::trim)
756                                                .filter(t -> t.startsWith(theResourceName + "."))
757                                                .map(head -> tailPaths.stream()
758                                                                .map(tail -> head + "." + tail)
759                                                                .collect(Collectors.toSet()))
760                                                .flatMap(Collection::stream)
761                                                .collect(Collectors.toList());
762                        }
763                }
764
765                // This can happen during recursion, if not all the possible target types of one link in the chain
766                // support the next link
767                return new ArrayList<>();
768        }
769
770        private IQueryParameterType mapReferenceChainToRawParamType(
771                        String remainingChain,
772                        RuntimeSearchParam param,
773                        String theParamName,
774                        String qualifier,
775                        String nextType,
776                        String chain,
777                        boolean isMeta,
778                        String resourceId) {
779                IQueryParameterType chainValue;
780                if (remainingChain != null) {
781                        if (param == null || param.getParamType() != RestSearchParameterTypeEnum.REFERENCE) {
782                                ourLog.debug(
783                                                "Type {} parameter {} is not a reference, can not chain {}", nextType, chain, remainingChain);
784                                return null;
785                        }
786
787                        chainValue = new ReferenceParam();
788                        chainValue.setValueAsQueryToken(getFhirContext(), theParamName, qualifier, resourceId);
789                        ((ReferenceParam) chainValue).setChain(remainingChain);
790                } else if (isMeta) {
791                        IQueryParameterType type = myMatchUrlService.newInstanceType(chain);
792                        type.setValueAsQueryToken(getFhirContext(), theParamName, qualifier, resourceId);
793                        chainValue = type;
794                } else {
795                        chainValue = myQueryStack.newParameterInstance(param, qualifier, resourceId);
796                }
797
798                return chainValue;
799        }
800
801        @Nonnull
802        private InvalidRequestException newInvalidTargetTypeForChainException(
803                        String theResourceName, String theParamName, String theTypeValue) {
804                String searchParamName = theResourceName + ":" + theParamName;
805                String msg = getFhirContext()
806                                .getLocalizer()
807                                .getMessage(
808                                                ResourceLinkPredicateBuilder.class, "invalidTargetTypeForChain", theTypeValue, searchParamName);
809                return new InvalidRequestException(Msg.code(2495) + msg);
810        }
811
812        @Nonnull
813        private InvalidRequestException newInvalidResourceTypeException(String theResourceType) {
814                String msg = getFhirContext()
815                                .getLocalizer()
816                                .getMessageSanitized(SearchCoordinatorSvcImpl.class, "invalidResourceType", theResourceType);
817                throw new InvalidRequestException(Msg.code(1250) + msg);
818        }
819
820        @Nonnull
821        public Condition createEverythingPredicate(
822                        String theResourceName, List<String> theSourceResourceNames, JpaPid... theTargetPids) {
823                Condition condition;
824
825                if (theTargetPids != null && theTargetPids.length >= 1) {
826                        // if resource ids are provided, we'll create the predicate
827                        // with ids in or equal to this value
828                        if (getSearchQueryBuilder().isIncludePartitionIdInJoins()) {
829                                Object left = ColumnTupleObject.from(getJoinColumnsForTarget());
830                                JpaPidValueTuples right = JpaPidValueTuples.from(getSearchQueryBuilder(), theTargetPids);
831                                condition = new InCondition(left, right);
832                        } else {
833                                condition = QueryParameterUtils.toEqualToOrInPredicate(
834                                                myColumnTargetResourceId, generatePlaceholders(JpaPid.toLongList(theTargetPids)));
835                        }
836                } else {
837                        // ... otherwise we look for resource types
838                        condition = BinaryCondition.equalTo(myColumnTargetResourceType, generatePlaceholder(theResourceName));
839                }
840
841                if (!theSourceResourceNames.isEmpty()) {
842                        // if source resources are provided, add on predicate for _type operation
843                        Condition typeCondition = QueryParameterUtils.toEqualToOrInPredicate(
844                                        myColumnSrcType, generatePlaceholders(theSourceResourceNames));
845                        condition = QueryParameterUtils.toAndPredicate(List.of(condition, typeCondition));
846                }
847
848                return condition;
849        }
850
851        @Override
852        public Condition createPredicateParamMissingValue(MissingQueryParameterPredicateParams theParams) {
853                SelectQuery subquery = new SelectQuery();
854                subquery.addCustomColumns(1);
855                subquery.addFromTable(getTable());
856
857                String resourceType = theParams.getResourceTablePredicateBuilder().getResourceType();
858                RuntimeSearchParam paramDefinition = mySearchParamRegistry.getRuntimeSearchParam(
859                                resourceType, theParams.getParamName(), ISearchParamRegistry.SearchParamLookupContextEnum.SEARCH);
860                List<String> pathList = paramDefinition.getPathsSplitForResourceType(resourceType);
861
862                Condition subQueryCondition = ComboCondition.and(
863                                BinaryCondition.equalTo(
864                                                getResourceIdColumn(),
865                                                theParams.getResourceTablePredicateBuilder().getResourceIdColumn()),
866                                BinaryCondition.equalTo(getResourceTypeColumn(), generatePlaceholder(resourceType)),
867                                ComboCondition.or(pathList.stream()
868                                                .map(path -> BinaryCondition.equalTo(getColumnSourcePath(), generatePlaceholder(path)))
869                                                .toArray(BinaryCondition[]::new)));
870
871                subquery.addCondition(subQueryCondition);
872
873                Condition unaryCondition = UnaryCondition.exists(subquery);
874                if (theParams.isMissing()) {
875                        unaryCondition = new NotCondition(unaryCondition);
876                }
877
878                return combineWithRequestPartitionIdPredicate(theParams.getRequestPartitionId(), unaryCondition);
879        }
880
881        @VisibleForTesting
882        void setSearchParamRegistryForUnitTest(ISearchParamRegistry theSearchParamRegistry) {
883                mySearchParamRegistry = theSearchParamRegistry;
884        }
885
886        @VisibleForTesting
887        void setIdHelperServiceForUnitTest(IIdHelperService theIdHelperService) {
888                myIdHelperService = theIdHelperService;
889        }
890}