001/*
002 * #%L
003 * HAPI FHIR JPA - Search Parameters
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.searchparam.registry;
021
022import ca.uhn.fhir.context.ComboSearchParamType;
023import ca.uhn.fhir.context.FhirContext;
024import ca.uhn.fhir.context.RuntimeSearchParam;
025import ca.uhn.fhir.context.phonetic.IPhoneticEncoder;
026import ca.uhn.fhir.i18n.Msg;
027import ca.uhn.fhir.interceptor.api.IInterceptorService;
028import ca.uhn.fhir.jpa.cache.IResourceChangeEvent;
029import ca.uhn.fhir.jpa.cache.IResourceChangeListener;
030import ca.uhn.fhir.jpa.cache.IResourceChangeListenerCache;
031import ca.uhn.fhir.jpa.cache.IResourceChangeListenerRegistry;
032import ca.uhn.fhir.jpa.cache.ISearchParamIdentityCacheSvc;
033import ca.uhn.fhir.jpa.cache.ResourceChangeResult;
034import ca.uhn.fhir.jpa.model.config.PartitionSettings;
035import ca.uhn.fhir.jpa.model.entity.StorageSettings;
036import ca.uhn.fhir.jpa.searchparam.SearchParameterMap;
037import ca.uhn.fhir.rest.api.Constants;
038import ca.uhn.fhir.rest.api.RestSearchParameterTypeEnum;
039import ca.uhn.fhir.rest.api.server.IBundleProvider;
040import ca.uhn.fhir.rest.server.exceptions.ResourceNotFoundException;
041import ca.uhn.fhir.rest.server.util.ISearchParamRegistry;
042import ca.uhn.fhir.rest.server.util.IndexedSearchParam;
043import ca.uhn.fhir.rest.server.util.ResourceSearchParams;
044import ca.uhn.fhir.util.SearchParameterUtil;
045import ca.uhn.fhir.util.StopWatch;
046import com.google.common.annotations.VisibleForTesting;
047import com.google.common.collect.Sets;
048import jakarta.annotation.Nonnull;
049import jakarta.annotation.Nullable;
050import jakarta.annotation.PostConstruct;
051import jakarta.annotation.PreDestroy;
052import org.apache.commons.lang3.StringUtils;
053import org.apache.commons.lang3.time.DateUtils;
054import org.hl7.fhir.instance.model.api.IBaseResource;
055import org.hl7.fhir.instance.model.api.IIdType;
056import org.slf4j.Logger;
057import org.slf4j.LoggerFactory;
058import org.springframework.beans.factory.ObjectProvider;
059import org.springframework.beans.factory.annotation.Autowired;
060
061import java.util.ArrayList;
062import java.util.Collection;
063import java.util.Collections;
064import java.util.Iterator;
065import java.util.List;
066import java.util.Map;
067import java.util.Objects;
068import java.util.Optional;
069import java.util.Set;
070import java.util.stream.Collectors;
071
072import static ca.uhn.fhir.rest.server.util.ISearchParamRegistry.isAllowedForContext;
073import static org.apache.commons.lang3.StringUtils.isBlank;
074
075public class SearchParamRegistryImpl
076                implements ISearchParamRegistry, IResourceChangeListener, ISearchParamRegistryController {
077
078        // Basic is needed by the R4 SubscriptionTopic registry
079        public static final Set<String> NON_DISABLEABLE_SEARCH_PARAMS =
080                        Collections.unmodifiableSet(Sets.newHashSet("*:url", "Subscription:*", "SearchParameter:*", "Basic:*"));
081
082        private static final Logger ourLog = LoggerFactory.getLogger(SearchParamRegistryImpl.class);
083        public static final int MAX_MANAGED_PARAM_COUNT = 10000;
084        private static final long REFRESH_INTERVAL = DateUtils.MILLIS_PER_MINUTE;
085
086        private JpaSearchParamCache myJpaSearchParamCache;
087
088        @Autowired
089        private StorageSettings myStorageSettings;
090
091        @Autowired
092        private ISearchParamProvider mySearchParamProvider;
093
094        @Autowired
095        private FhirContext myFhirContext;
096
097        @Autowired
098        private SearchParameterCanonicalizer mySearchParameterCanonicalizer;
099
100        @Autowired
101        private IInterceptorService myInterceptorBroadcaster;
102
103        @Autowired
104        private IResourceChangeListenerRegistry myResourceChangeListenerRegistry;
105
106        @Autowired
107        private PartitionSettings myPartitionSettings;
108
109        @Autowired
110        private ObjectProvider<ISearchParamIdentityCacheSvc> mySearchParamIdentityCacheSvcProvider;
111
112        private IResourceChangeListenerCache myResourceChangeListenerCache;
113        private volatile ReadOnlySearchParamCache myBuiltInSearchParams;
114        private volatile IPhoneticEncoder myPhoneticEncoder;
115        private volatile RuntimeSearchParamCache myActiveSearchParams;
116        private boolean myPrePopulateSearchParamIdentities = true;
117
118        @VisibleForTesting
119        public void setPopulateSearchParamIdentities(boolean myPrePopulateSearchParamIdentities) {
120                this.myPrePopulateSearchParamIdentities = myPrePopulateSearchParamIdentities;
121        }
122
123        /**
124         * Constructor
125         */
126        public SearchParamRegistryImpl() {
127                super();
128        }
129
130        @PostConstruct
131        public void start() {
132                myJpaSearchParamCache = new JpaSearchParamCache(myPartitionSettings);
133        }
134
135        @Override
136        public RuntimeSearchParam getActiveSearchParam(
137                        @Nonnull String theResourceName,
138                        @Nonnull String theParamName,
139                        @Nonnull SearchParamLookupContextEnum theContext) {
140                requiresActiveSearchParams();
141
142                // Can still be null in unit test scenarios
143                if (myActiveSearchParams != null) {
144                        RuntimeSearchParam param = myActiveSearchParams.get(theResourceName, theParamName);
145                        if (param != null) {
146                                if (isAllowedForContext(param, theContext)) {
147                                        return param;
148                                }
149                        }
150                }
151
152                return null;
153        }
154
155        @Nonnull
156        @Override
157        public ResourceSearchParams getActiveSearchParams(
158                        @Nonnull String theResourceName, @Nonnull SearchParamLookupContextEnum theContext) {
159                requiresActiveSearchParams();
160                return getActiveSearchParams().getSearchParamMap(theResourceName).toFilteredForContext(theContext);
161        }
162
163        private void requiresActiveSearchParams() {
164                if (myActiveSearchParams == null) {
165                        // forced refreshes should not use a cache - we're forcibly refreshing it, after all
166                        myResourceChangeListenerCache.forceRefresh();
167                }
168        }
169
170        @Override
171        public List<RuntimeSearchParam> getActiveComboSearchParams(
172                        @Nonnull String theResourceName, @Nonnull SearchParamLookupContextEnum theContext) {
173                return filteredForContext(myJpaSearchParamCache.getActiveComboSearchParams(theResourceName), theContext);
174        }
175
176        @Override
177        public List<RuntimeSearchParam> getActiveComboSearchParams(
178                        @Nonnull String theResourceName,
179                        @Nonnull ComboSearchParamType theParamType,
180                        @Nonnull SearchParamLookupContextEnum theContext) {
181                return filteredForContext(
182                                myJpaSearchParamCache.getActiveComboSearchParams(theResourceName, theParamType), theContext);
183        }
184
185        @Override
186        public List<RuntimeSearchParam> getActiveComboSearchParams(
187                        @Nonnull String theResourceName,
188                        @Nonnull Set<String> theParamNames,
189                        @Nonnull SearchParamLookupContextEnum theContext) {
190                return filteredForContext(
191                                myJpaSearchParamCache.getActiveComboSearchParams(theResourceName, theParamNames), theContext);
192        }
193
194        @Nullable
195        @Override
196        public RuntimeSearchParam getActiveSearchParamByUrl(
197                        @Nonnull String theUrl, @Nonnull SearchParamLookupContextEnum theContext) {
198                if (myActiveSearchParams != null) {
199                        RuntimeSearchParam param = myActiveSearchParams.getByUrl(theUrl);
200                        if (isAllowedForContext(param, theContext)) {
201                                return param;
202                        }
203                }
204                return null;
205        }
206
207        @Override
208        public Optional<RuntimeSearchParam> getActiveComboSearchParamById(
209                        @Nonnull String theResourceName, @Nonnull IIdType theId) {
210                return myJpaSearchParamCache.getActiveComboSearchParamById(theResourceName, theId);
211        }
212
213        private void rebuildActiveSearchParams() {
214                ourLog.info("Rebuilding SearchParamRegistry");
215                SearchParameterMap params = new SearchParameterMap();
216                params.setLoadSynchronousUpTo(MAX_MANAGED_PARAM_COUNT);
217                params.setCount(MAX_MANAGED_PARAM_COUNT);
218
219                IBundleProvider allSearchParamsBp = mySearchParamProvider.search(params);
220
221                List<IBaseResource> allSearchParams = allSearchParamsBp.getResources(0, MAX_MANAGED_PARAM_COUNT);
222                Integer size = allSearchParamsBp.size();
223
224                ourLog.trace("Loaded {} search params from the DB", allSearchParams.size());
225
226                if (size == null) {
227                        ourLog.error(
228                                        "Only {} search parameters have been loaded, but there are more than that in the repository.  Is offset search configured on this server?",
229                                        allSearchParams.size());
230                } else if (size >= MAX_MANAGED_PARAM_COUNT) {
231                        ourLog.warn("Unable to support >" + MAX_MANAGED_PARAM_COUNT + " search params!");
232                }
233
234                initializeActiveSearchParams(allSearchParams);
235        }
236
237        private void initializeActiveSearchParams(Collection<IBaseResource> theJpaSearchParams) {
238                StopWatch sw = new StopWatch();
239
240                ReadOnlySearchParamCache builtInSearchParams = getBuiltInSearchParams();
241                RuntimeSearchParamCache searchParams =
242                                RuntimeSearchParamCache.fromReadOnlySearchParamCache(builtInSearchParams);
243                long overriddenCount = overrideBuiltinSearchParamsWithActiveJpaSearchParams(searchParams, theJpaSearchParams);
244                ourLog.trace("Have overridden {} built-in search parameters", overriddenCount);
245                removeInactiveSearchParams(searchParams);
246
247                /*
248                 * The _language SearchParameter is a weird exception - It is actually just a normal
249                 * token SP, but we explcitly ban SPs from registering themselves with a prefix
250                 * of "_" since that's system reserved so we put this one behind a settings toggle
251                 */
252                if (myStorageSettings.isLanguageSearchParameterEnabled()) {
253                        IIdType id = myFhirContext.getVersion().newIdType();
254                        id.setValue("SearchParameter/Resource-language");
255                        RuntimeSearchParam sp = new RuntimeSearchParam(
256                                        id,
257                                        "http://hl7.org/fhir/SearchParameter/Resource-language",
258                                        Constants.PARAM_LANGUAGE,
259                                        "Language of the resource content",
260                                        "language",
261                                        RestSearchParameterTypeEnum.TOKEN,
262                                        Collections.emptySet(),
263                                        Collections.emptySet(),
264                                        RuntimeSearchParam.RuntimeSearchParamStatusEnum.ACTIVE,
265                                        myFhirContext.getResourceTypes());
266                        for (String baseResourceType : sp.getBase()) {
267                                searchParams.add(baseResourceType, sp.getName(), sp);
268                        }
269                }
270
271                setActiveSearchParams(searchParams);
272
273                myJpaSearchParamCache.populateActiveSearchParams(
274                                myInterceptorBroadcaster, myPhoneticEncoder, myActiveSearchParams);
275                updateSearchParameterIdentityCache();
276                ourLog.debug("Refreshed search parameter cache in {}ms", sw.getMillis());
277        }
278
279        private void updateSearchParameterIdentityCache() {
280                if (!myPrePopulateSearchParamIdentities) {
281                        return;
282                }
283
284                ISearchParamIdentityCacheSvc spIdentityCacheSvc = mySearchParamIdentityCacheSvcProvider.getIfAvailable();
285                if (spIdentityCacheSvc == null) {
286                        return;
287                }
288
289                myJpaSearchParamCache
290                                .getHashIdentityToIndexedSearchParamMap()
291                                .forEach((hash, param) -> spIdentityCacheSvc.findOrCreateSearchParamIdentity(
292                                                hash, param.getResourceType(), param.getParameterName()));
293        }
294
295        @VisibleForTesting
296        public Map<Long, IndexedSearchParam> getHashIdentityToIndexedSearchParamMap() {
297                return myJpaSearchParamCache.getHashIdentityToIndexedSearchParamMap();
298        }
299
300        @VisibleForTesting
301        public void setFhirContext(FhirContext theFhirContext) {
302                myFhirContext = theFhirContext;
303        }
304
305        private ReadOnlySearchParamCache getBuiltInSearchParams() {
306                if (myBuiltInSearchParams == null) {
307                        if (myStorageSettings.isAutoSupportDefaultSearchParams()) {
308                                myBuiltInSearchParams =
309                                                ReadOnlySearchParamCache.fromFhirContext(myFhirContext, mySearchParameterCanonicalizer);
310                        } else {
311                                // Only the built-in search params that can not be disabled will be supported automatically
312                                myBuiltInSearchParams = ReadOnlySearchParamCache.fromFhirContext(
313                                                myFhirContext, mySearchParameterCanonicalizer, NON_DISABLEABLE_SEARCH_PARAMS);
314                        }
315                }
316                return myBuiltInSearchParams;
317        }
318
319        private void removeInactiveSearchParams(RuntimeSearchParamCache theSearchParams) {
320                for (String resourceName : theSearchParams.getResourceNameKeys()) {
321                        ResourceSearchParams resourceSearchParams = theSearchParams.getSearchParamMap(resourceName);
322                        resourceSearchParams.removeInactive();
323                }
324        }
325
326        @VisibleForTesting
327        public void setStorageSettings(StorageSettings theStorageSettings) {
328                myStorageSettings = theStorageSettings;
329        }
330
331        private long overrideBuiltinSearchParamsWithActiveJpaSearchParams(
332                        RuntimeSearchParamCache theSearchParamCache, Collection<IBaseResource> theSearchParams) {
333                if (!myStorageSettings.isDefaultSearchParamsCanBeOverridden() || theSearchParams == null) {
334                        return 0;
335                }
336
337                long retval = 0;
338                for (IBaseResource searchParam : theSearchParams) {
339                        retval += overrideSearchParam(theSearchParamCache, searchParam);
340                }
341                return retval;
342        }
343
344        private long overrideSearchParam(RuntimeSearchParamCache theSearchParams, IBaseResource theSearchParameter) {
345                if (theSearchParameter == null) {
346                        return 0;
347                }
348
349                RuntimeSearchParam runtimeSp = mySearchParameterCanonicalizer.canonicalizeSearchParameter(theSearchParameter);
350                if (runtimeSp == null) {
351                        return 0;
352                }
353                if (runtimeSp.getStatus() == RuntimeSearchParam.RuntimeSearchParamStatusEnum.DRAFT) {
354                        return 0;
355                }
356
357                long retval = 0;
358                for (String nextBaseName : SearchParameterUtil.getBaseAsStrings(myFhirContext, theSearchParameter)) {
359                        if (isBlank(nextBaseName)) {
360                                continue;
361                        }
362
363                        String name = runtimeSp.getName();
364
365                        theSearchParams.add(nextBaseName, name, runtimeSp);
366                        ourLog.debug(
367                                        "Adding search parameter {}.{} to SearchParamRegistry",
368                                        nextBaseName,
369                                        Objects.toString(name, "[composite]"));
370                        retval++;
371                }
372                return retval;
373        }
374
375        @Override
376        public void requestRefresh() {
377                myResourceChangeListenerCache.requestRefresh();
378        }
379
380        @Override
381        public void forceRefresh() {
382                RuntimeSearchParamCache activeSearchParams = myActiveSearchParams;
383                myResourceChangeListenerCache.forceRefresh();
384
385                // If the refresh didn't trigger a change, proceed with one anyway
386                if (myActiveSearchParams == activeSearchParams) {
387                        rebuildActiveSearchParams();
388                }
389        }
390
391        @Override
392        public ResourceChangeResult refreshCacheIfNecessary() {
393                return myResourceChangeListenerCache.refreshCacheIfNecessary();
394        }
395
396        @VisibleForTesting
397        public void setResourceChangeListenerRegistry(IResourceChangeListenerRegistry theResourceChangeListenerRegistry) {
398                myResourceChangeListenerRegistry = theResourceChangeListenerRegistry;
399        }
400
401        /**
402         * There is a circular reference between this class and the ResourceChangeListenerRegistry:
403         * SearchParamRegistryImpl -> ResourceChangeListenerRegistry -> InMemoryResourceMatcher -> SearchParamRegistryImpl. Since we only need this once on boot-up, we delay
404         * until ContextRefreshedEvent.
405         */
406        @PostConstruct
407        public void registerListener() {
408                SearchParameterMap spMap = SearchParameterMap.newSynchronous();
409                spMap.setLoadSynchronousUpTo(MAX_MANAGED_PARAM_COUNT);
410                myResourceChangeListenerCache = myResourceChangeListenerRegistry.registerResourceResourceChangeListener(
411                                "SearchParameter", spMap, this, REFRESH_INTERVAL);
412        }
413
414        @PreDestroy
415        public void unregisterListener() {
416                myResourceChangeListenerRegistry.unregisterResourceResourceChangeListener(this);
417        }
418
419        public ReadOnlySearchParamCache getActiveSearchParams() {
420                requiresActiveSearchParams();
421                if (myActiveSearchParams == null) {
422                        throw new IllegalStateException(Msg.code(511) + "SearchParamRegistry has not been initialized");
423                }
424                return ReadOnlySearchParamCache.fromRuntimeSearchParamCache(myActiveSearchParams);
425        }
426
427        @VisibleForTesting
428        public void setActiveSearchParams(RuntimeSearchParamCache theSearchParams) {
429                myActiveSearchParams = theSearchParams;
430        }
431
432        /**
433         * All SearchParameters with the name "phonetic" encode the normalized index value using this phonetic encoder.
434         *
435         * @since 5.1.0
436         */
437        @Override
438        public void setPhoneticEncoder(IPhoneticEncoder thePhoneticEncoder) {
439                myPhoneticEncoder = thePhoneticEncoder;
440
441                if (myActiveSearchParams == null) {
442                        return;
443                }
444                myActiveSearchParams
445                                .getSearchParamStream()
446                                .forEach(searchParam -> myJpaSearchParamCache.setPhoneticEncoder(myPhoneticEncoder, searchParam));
447        }
448
449        @Override
450        public void handleChange(IResourceChangeEvent theResourceChangeEvent) {
451                if (theResourceChangeEvent.isEmpty()) {
452                        return;
453                }
454
455                ResourceChangeResult result = ResourceChangeResult.fromResourceChangeEvent(theResourceChangeEvent);
456                if (result.created > 0) {
457                        ourLog.info(
458                                        "Adding {} search parameters to SearchParamRegistry: {}",
459                                        result.created,
460                                        unqualified(theResourceChangeEvent.getCreatedResourceIds()));
461                }
462                if (result.updated > 0) {
463                        ourLog.info(
464                                        "Updating {} search parameters in SearchParamRegistry: {}",
465                                        result.updated,
466                                        unqualified(theResourceChangeEvent.getUpdatedResourceIds()));
467                }
468                if (result.deleted > 0) {
469                        ourLog.info(
470                                        "Deleting {} search parameters from SearchParamRegistry: {}",
471                                        result.deleted,
472                                        unqualified(theResourceChangeEvent.getDeletedResourceIds()));
473                }
474                rebuildActiveSearchParams();
475        }
476
477        private String unqualified(List<IIdType> theIds) {
478                Iterator<String> unqualifiedIds = theIds.stream()
479                                .map(IIdType::toUnqualifiedVersionless)
480                                .map(IIdType::getValue)
481                                .iterator();
482
483                return StringUtils.join(unqualifiedIds, ", ");
484        }
485
486        @Override
487        public void handleInit(Collection<IIdType> theResourceIds) {
488                List<IBaseResource> searchParams = new ArrayList<>();
489                for (IIdType id : theResourceIds) {
490                        try {
491                                IBaseResource searchParam = mySearchParamProvider.read(id);
492                                searchParams.add(searchParam);
493                        } catch (ResourceNotFoundException e) {
494                                ourLog.warn("SearchParameter {} not found.  Excluding from list of active search params.", id);
495                        }
496                }
497                initializeActiveSearchParams(searchParams);
498        }
499
500        @Override
501        public boolean isInitialized() {
502                return myActiveSearchParams != null;
503        }
504
505        @VisibleForTesting
506        public void resetForUnitTest() {
507                myBuiltInSearchParams = null;
508                setActiveSearchParams(null);
509                handleInit(Collections.emptyList());
510        }
511
512        @VisibleForTesting
513        public void setSearchParameterCanonicalizerForUnitTest(
514                        SearchParameterCanonicalizer theSearchParameterCanonicalizerForUnitTest) {
515                mySearchParameterCanonicalizer = theSearchParameterCanonicalizerForUnitTest;
516        }
517
518        private static List<RuntimeSearchParam> filteredForContext(
519                        List<RuntimeSearchParam> theActiveComboSearchParams, SearchParamLookupContextEnum theContext) {
520                return theActiveComboSearchParams.stream()
521                                .filter(t -> isAllowedForContext(t, theContext))
522                                .collect(Collectors.toList());
523        }
524}