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