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