001/* 002 * #%L 003 * HAPI FHIR JPA Server 004 * %% 005 * Copyright (C) 2014 - 2024 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.dao; 021 022import ca.uhn.fhir.context.FhirContext; 023import ca.uhn.fhir.i18n.Msg; 024import ca.uhn.fhir.interceptor.api.HookParams; 025import ca.uhn.fhir.interceptor.api.IInterceptorBroadcaster; 026import ca.uhn.fhir.interceptor.api.Pointcut; 027import ca.uhn.fhir.jpa.api.config.JpaStorageSettings; 028import ca.uhn.fhir.jpa.api.svc.IIdHelperService; 029import ca.uhn.fhir.jpa.dao.search.ExtendedHSearchClauseBuilder; 030import ca.uhn.fhir.jpa.dao.search.ExtendedHSearchIndexExtractor; 031import ca.uhn.fhir.jpa.dao.search.ExtendedHSearchResourceProjection; 032import ca.uhn.fhir.jpa.dao.search.ExtendedHSearchSearchBuilder; 033import ca.uhn.fhir.jpa.dao.search.IHSearchSortHelper; 034import ca.uhn.fhir.jpa.dao.search.LastNOperation; 035import ca.uhn.fhir.jpa.dao.search.SearchScrollQueryExecutorAdaptor; 036import ca.uhn.fhir.jpa.model.dao.JpaPid; 037import ca.uhn.fhir.jpa.model.entity.ResourceTable; 038import ca.uhn.fhir.jpa.model.search.ExtendedHSearchBuilderConsumeAdvancedQueryClausesParams; 039import ca.uhn.fhir.jpa.model.search.ExtendedHSearchIndexData; 040import ca.uhn.fhir.jpa.model.search.StorageProcessingMessage; 041import ca.uhn.fhir.jpa.search.autocomplete.ValueSetAutocompleteOptions; 042import ca.uhn.fhir.jpa.search.autocomplete.ValueSetAutocompleteSearch; 043import ca.uhn.fhir.jpa.search.builder.ISearchQueryExecutor; 044import ca.uhn.fhir.jpa.search.builder.SearchBuilder; 045import ca.uhn.fhir.jpa.search.builder.SearchQueryExecutors; 046import ca.uhn.fhir.jpa.searchparam.SearchParameterMap; 047import ca.uhn.fhir.jpa.searchparam.extractor.ISearchParamExtractor; 048import ca.uhn.fhir.jpa.searchparam.extractor.ResourceIndexedSearchParams; 049import ca.uhn.fhir.model.api.IQueryParameterType; 050import ca.uhn.fhir.parser.IParser; 051import ca.uhn.fhir.rest.api.Constants; 052import ca.uhn.fhir.rest.api.server.RequestDetails; 053import ca.uhn.fhir.rest.api.server.storage.IResourcePersistentId; 054import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails; 055import ca.uhn.fhir.rest.server.util.CompositeInterceptorBroadcaster; 056import ca.uhn.fhir.rest.server.util.ISearchParamRegistry; 057import ca.uhn.fhir.rest.server.util.ResourceSearchParams; 058import com.google.common.collect.Ordering; 059import jakarta.annotation.Nonnull; 060import jakarta.persistence.EntityManager; 061import jakarta.persistence.PersistenceContext; 062import jakarta.persistence.PersistenceContextType; 063import org.hibernate.search.backend.elasticsearch.ElasticsearchExtension; 064import org.hibernate.search.engine.search.predicate.dsl.PredicateFinalStep; 065import org.hibernate.search.engine.search.predicate.dsl.SearchPredicateFactory; 066import org.hibernate.search.engine.search.projection.dsl.CompositeProjectionOptionsStep; 067import org.hibernate.search.engine.search.projection.dsl.SearchProjectionFactory; 068import org.hibernate.search.engine.search.query.dsl.SearchQueryOptionsStep; 069import org.hibernate.search.mapper.orm.Search; 070import org.hibernate.search.mapper.orm.common.EntityReference; 071import org.hibernate.search.mapper.orm.search.loading.dsl.SearchLoadingOptionsStep; 072import org.hibernate.search.mapper.orm.session.SearchSession; 073import org.hibernate.search.mapper.orm.work.SearchIndexingPlan; 074import org.hibernate.search.util.common.SearchException; 075import org.hl7.fhir.instance.model.api.IBaseResource; 076import org.springframework.beans.factory.annotation.Autowired; 077import org.springframework.transaction.PlatformTransactionManager; 078import org.springframework.transaction.annotation.Transactional; 079import org.springframework.transaction.support.TransactionTemplate; 080 081import java.util.ArrayList; 082import java.util.Collection; 083import java.util.Collections; 084import java.util.List; 085import java.util.Spliterators; 086import java.util.stream.Collectors; 087import java.util.stream.StreamSupport; 088 089import static ca.uhn.fhir.rest.server.BasePagingProvider.DEFAULT_MAX_PAGE_SIZE; 090import static org.apache.commons.lang3.StringUtils.isNotBlank; 091 092public class FulltextSearchSvcImpl implements IFulltextSearchSvc { 093 private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(FulltextSearchSvcImpl.class); 094 private static final int DEFAULT_MAX_NON_PAGED_SIZE = 500; 095 private final ExtendedHSearchSearchBuilder myAdvancedIndexQueryBuilder = new ExtendedHSearchSearchBuilder(); 096 097 @Autowired 098 ISearchParamExtractor mySearchParamExtractor; 099 100 @Autowired 101 IIdHelperService myIdHelperService; 102 103 @PersistenceContext(type = PersistenceContextType.TRANSACTION) 104 private EntityManager myEntityManager; 105 106 @Autowired 107 private PlatformTransactionManager myTxManager; 108 109 @Autowired 110 private FhirContext myFhirContext; 111 112 @Autowired 113 private ISearchParamRegistry mySearchParamRegistry; 114 115 @Autowired 116 private JpaStorageSettings myStorageSettings; 117 118 @Autowired 119 private IHSearchSortHelper myExtendedFulltextSortHelper; 120 121 @Autowired(required = false) 122 private IHSearchEventListener myHSearchEventListener; 123 124 @Autowired 125 private IInterceptorBroadcaster myInterceptorBroadcaster; 126 127 private Boolean ourDisabled; 128 129 /** 130 * Constructor 131 */ 132 public FulltextSearchSvcImpl() { 133 super(); 134 } 135 136 @Override 137 public ExtendedHSearchIndexData extractLuceneIndexData( 138 IBaseResource theResource, ResourceIndexedSearchParams theNewParams) { 139 String resourceType = myFhirContext.getResourceType(theResource); 140 ResourceSearchParams activeSearchParams = mySearchParamRegistry.getActiveSearchParams( 141 resourceType, ISearchParamRegistry.SearchParamLookupContextEnum.SEARCH); 142 ExtendedHSearchIndexExtractor extractor = new ExtendedHSearchIndexExtractor( 143 myStorageSettings, myFhirContext, activeSearchParams, mySearchParamExtractor); 144 return extractor.extract(theResource, theNewParams); 145 } 146 147 @Override 148 public boolean canUseHibernateSearch(String theResourceType, SearchParameterMap myParams) { 149 boolean requiresHibernateSearchAccess = myParams.containsKey(Constants.PARAM_CONTENT) 150 || myParams.containsKey(Constants.PARAM_TEXT) 151 || myParams.isLastN(); 152 // we have to use it - _text and _content searches only use hibernate 153 if (requiresHibernateSearchAccess) { 154 return true; 155 } 156 157 // if the registry has not been initialized 158 // we cannot use HibernateSearch because it 159 // will, internally, trigger a new search 160 // when it refreshes the search parameters 161 // (which will cause an infinite loop) 162 if (!mySearchParamRegistry.isInitialized()) { 163 return false; 164 } 165 166 return myStorageSettings.isAdvancedHSearchIndexing() 167 && myAdvancedIndexQueryBuilder.canUseHibernateSearch(theResourceType, myParams, mySearchParamRegistry); 168 } 169 170 @Override 171 public void reindex(ResourceTable theEntity) { 172 validateHibernateSearchIsEnabled(); 173 174 SearchIndexingPlan plan = getSearchSession().indexingPlan(); 175 plan.addOrUpdate(theEntity); 176 } 177 178 @Override 179 public ISearchQueryExecutor searchNotScrolled( 180 String theResourceName, 181 SearchParameterMap theParams, 182 Integer theMaxResultsToFetch, 183 RequestDetails theRequestDetails) { 184 validateHibernateSearchIsEnabled(); 185 186 return doSearch(theResourceName, theParams, null, theMaxResultsToFetch, theRequestDetails); 187 } 188 189 @Transactional 190 @Override 191 public ISearchQueryExecutor searchScrolled( 192 String theResourceType, SearchParameterMap theParams, RequestDetails theRequestDetails) { 193 validateHibernateSearchIsEnabled(); 194 195 SearchQueryOptionsStep<?, Long, SearchLoadingOptionsStep, ?, ?> searchQueryOptionsStep = 196 getSearchQueryOptionsStep(theResourceType, theParams, null); 197 logQuery(searchQueryOptionsStep, theRequestDetails); 198 199 return new SearchScrollQueryExecutorAdaptor(searchQueryOptionsStep.scroll(SearchBuilder.getMaximumPageSize())); 200 } 201 202 // keep this in sync with supportsSomeOf(); 203 @SuppressWarnings("rawtypes") 204 private ISearchQueryExecutor doSearch( 205 String theResourceType, 206 SearchParameterMap theParams, 207 IResourcePersistentId theReferencingPid, 208 Integer theMaxResultsToFetch, 209 RequestDetails theRequestDetails) { 210 211 int offset = theParams.getOffset() == null ? 0 : theParams.getOffset(); 212 int count = getMaxFetchSize(theParams, theMaxResultsToFetch); 213 214 // perform an offset search instead of a scroll one, which doesn't allow for offset 215 SearchQueryOptionsStep<?, Long, SearchLoadingOptionsStep, ?, ?> searchQueryOptionsStep = 216 getSearchQueryOptionsStep(theResourceType, theParams, theReferencingPid); 217 logQuery(searchQueryOptionsStep, theRequestDetails); 218 List<Long> longs = searchQueryOptionsStep.fetchHits(offset, count); 219 220 // indicate param was already processed, otherwise queries DB to process it 221 theParams.setOffset(null); 222 return SearchQueryExecutors.from(longs); 223 } 224 225 private int getMaxFetchSize(SearchParameterMap theParams, Integer theMax) { 226 if (theMax != null) { 227 return theMax; 228 } 229 230 // todo mb we should really pass this in. 231 if (theParams.getCount() != null) { 232 return theParams.getCount(); 233 } 234 235 return DEFAULT_MAX_NON_PAGED_SIZE; 236 } 237 238 @SuppressWarnings("rawtypes") 239 private SearchQueryOptionsStep<?, Long, SearchLoadingOptionsStep, ?, ?> getSearchQueryOptionsStep( 240 String theResourceType, SearchParameterMap theParams, IResourcePersistentId theReferencingPid) { 241 242 dispatchEvent(IHSearchEventListener.HSearchEventType.SEARCH); 243 var query = getSearchSession() 244 .search(ResourceTable.class) 245 // The document id is the PK which is pid. We use this instead of _myId to avoid fetching the doc body. 246 .select( 247 // adapt the String docRef.id() to the Long that it really is. 248 f -> f.composite(docRef -> Long.valueOf(docRef.id()), f.documentReference())) 249 .where(f -> buildWhereClause(f, theResourceType, theParams, theReferencingPid)); 250 251 if (theParams.getSort() != null) { 252 query.sort(f -> myExtendedFulltextSortHelper.getSortClauses(f, theParams.getSort(), theResourceType)); 253 254 // indicate parameter was processed 255 theParams.setSort(null); 256 } 257 258 return query; 259 } 260 261 @SuppressWarnings("rawtypes") 262 private PredicateFinalStep buildWhereClause( 263 SearchPredicateFactory f, 264 String theResourceType, 265 SearchParameterMap theParams, 266 IResourcePersistentId theReferencingPid) { 267 return f.bool(b -> { 268 ExtendedHSearchClauseBuilder builder = 269 new ExtendedHSearchClauseBuilder(myFhirContext, myStorageSettings, b, f); 270 271 /* 272 * Handle _content parameter (resource body content) 273 * 274 * Posterity: 275 * We do not want the HAPI-FHIR dao's to process the 276 * _content parameter, so we remove it from the map here 277 */ 278 List<List<IQueryParameterType>> contentAndTerms = theParams.remove(Constants.PARAM_CONTENT); 279 builder.addStringTextSearch(Constants.PARAM_CONTENT, contentAndTerms); 280 281 /* 282 * Handle _text parameter (resource narrative content) 283 * 284 * Posterity: 285 * We do not want the HAPI-FHIR dao's to process the 286 * _text parameter, so we remove it from the map here 287 */ 288 List<List<IQueryParameterType>> textAndTerms = theParams.remove(Constants.PARAM_TEXT); 289 builder.addStringTextSearch(Constants.PARAM_TEXT, textAndTerms); 290 291 if (theReferencingPid != null) { 292 b.must(f.match().field("myResourceLinksField").matching(theReferencingPid.toString())); 293 } 294 295 if (isNotBlank(theResourceType)) { 296 builder.addResourceTypeClause(theResourceType); 297 } 298 299 /* 300 * Handle other supported parameters 301 */ 302 if (myStorageSettings.isAdvancedHSearchIndexing() && theParams.getEverythingMode() == null) { 303 ExtendedHSearchBuilderConsumeAdvancedQueryClausesParams params = 304 new ExtendedHSearchBuilderConsumeAdvancedQueryClausesParams(); 305 params.setSearchParamRegistry(mySearchParamRegistry) 306 .setResourceType(theResourceType) 307 .setSearchParameterMap(theParams); 308 myAdvancedIndexQueryBuilder.addAndConsumeAdvancedQueryClauses(builder, params); 309 } 310 // DROP EARLY HERE IF BOOL IS EMPTY? 311 }); 312 } 313 314 @Nonnull 315 private SearchSession getSearchSession() { 316 return Search.session(myEntityManager); 317 } 318 319 @SuppressWarnings("rawtypes") 320 private List<IResourcePersistentId> convertLongsToResourcePersistentIds(List<Long> theLongPids) { 321 return theLongPids.stream().map(JpaPid::fromId).collect(Collectors.toList()); 322 } 323 324 @Override 325 @SuppressWarnings({"rawtypes", "unchecked"}) 326 public List<IResourcePersistentId> everything( 327 String theResourceName, 328 SearchParameterMap theParams, 329 IResourcePersistentId theReferencingPid, 330 RequestDetails theRequestDetails) { 331 validateHibernateSearchIsEnabled(); 332 333 // todo mb what about max results here? 334 List<IResourcePersistentId> retVal = 335 toList(doSearch(null, theParams, theReferencingPid, 10_000, theRequestDetails), 10_000); 336 if (theReferencingPid != null) { 337 retVal.add(theReferencingPid); 338 } 339 return retVal; 340 } 341 342 private void validateHibernateSearchIsEnabled() { 343 if (isDisabled()) { 344 throw new UnsupportedOperationException(Msg.code(2137) + "Hibernate search is not enabled!"); 345 } 346 } 347 348 @Override 349 public boolean isDisabled() { 350 Boolean retVal = ourDisabled; 351 352 if (retVal == null) { 353 retVal = new TransactionTemplate(myTxManager).execute(t -> { 354 try { 355 SearchSession searchSession = getSearchSession(); 356 searchSession.search(ResourceTable.class); 357 return Boolean.FALSE; 358 } catch (Exception e) { 359 ourLog.trace("FullText test failed", e); 360 ourLog.debug( 361 "Hibernate Search (Lucene) appears to be disabled on this server, fulltext will be disabled"); 362 return Boolean.TRUE; 363 } 364 }); 365 ourDisabled = retVal; 366 } 367 368 assert retVal != null; 369 return retVal; 370 } 371 372 @Transactional() 373 @Override 374 @SuppressWarnings("unchecked") 375 public List<IResourcePersistentId> search( 376 String theResourceName, SearchParameterMap theParams, RequestDetails theRequestDetails) { 377 validateHibernateSearchIsEnabled(); 378 return toList( 379 doSearch(theResourceName, theParams, null, DEFAULT_MAX_NON_PAGED_SIZE, theRequestDetails), 380 DEFAULT_MAX_NON_PAGED_SIZE); 381 } 382 383 /** 384 * Adapt our async interface to the legacy concrete List 385 */ 386 @SuppressWarnings("rawtypes") 387 private List<IResourcePersistentId> toList(ISearchQueryExecutor theSearchResultStream, long theMaxSize) { 388 return StreamSupport.stream(Spliterators.spliteratorUnknownSize(theSearchResultStream, 0), false) 389 .map(JpaPid::fromId) 390 .limit(theMaxSize) 391 .collect(Collectors.toList()); 392 } 393 394 @Transactional() 395 @Override 396 public IBaseResource tokenAutocompleteValueSetSearch(ValueSetAutocompleteOptions theOptions) { 397 validateHibernateSearchIsEnabled(); 398 ensureElastic(); 399 400 ValueSetAutocompleteSearch autocomplete = 401 new ValueSetAutocompleteSearch(myFhirContext, myStorageSettings, getSearchSession()); 402 403 dispatchEvent(IHSearchEventListener.HSearchEventType.SEARCH); 404 return autocomplete.search(theOptions); 405 } 406 407 /** 408 * Throws an error if configured with Lucene. 409 * <p> 410 * Some features only work with Elasticsearch. 411 * Lastn and the autocomplete search use nested aggregations which are Elasticsearch-only 412 */ 413 private void ensureElastic() { 414 try { 415 getSearchSession().scope(ResourceTable.class).aggregation().extension(ElasticsearchExtension.get()); 416 } catch (SearchException e) { 417 // unsupported. we are probably running Lucene. 418 throw new IllegalStateException( 419 Msg.code(2070) + "This operation requires Elasticsearch. Lucene is not supported."); 420 } 421 } 422 423 @Override 424 @SuppressWarnings("rawtypes") 425 public List<IResourcePersistentId> lastN(SearchParameterMap theParams, Integer theMaximumResults) { 426 ensureElastic(); 427 dispatchEvent(IHSearchEventListener.HSearchEventType.SEARCH); 428 List<Long> pidList = new LastNOperation( 429 getSearchSession(), myFhirContext, myStorageSettings, mySearchParamRegistry) 430 .executeLastN(theParams, theMaximumResults); 431 return convertLongsToResourcePersistentIds(pidList); 432 } 433 434 @Override 435 public List<IBaseResource> getResources(Collection<Long> thePids) { 436 if (thePids.isEmpty()) { 437 return Collections.emptyList(); 438 } 439 440 SearchSession session = getSearchSession(); 441 dispatchEvent(IHSearchEventListener.HSearchEventType.SEARCH); 442 List<ExtendedHSearchResourceProjection> rawResourceDataList = session.search(ResourceTable.class) 443 .select(this::buildResourceSelectClause) 444 .where( 445 f -> f.id().matchingAny(thePids) // matches '_id' from resource index 446 ) 447 .fetchAllHits(); 448 449 // order resource projections as per thePids 450 ArrayList<Long> pidList = new ArrayList<>(thePids); 451 List<ExtendedHSearchResourceProjection> orderedAsPidsResourceDataList = rawResourceDataList.stream() 452 .sorted(Ordering.explicit(pidList).onResultOf(ExtendedHSearchResourceProjection::getPid)) 453 .collect(Collectors.toList()); 454 455 return resourceProjectionsToResources(orderedAsPidsResourceDataList); 456 } 457 458 @Nonnull 459 private List<IBaseResource> resourceProjectionsToResources( 460 List<ExtendedHSearchResourceProjection> theResourceDataList) { 461 IParser parser = myFhirContext.newJsonParser(); 462 return theResourceDataList.stream().map(p -> p.toResource(parser)).collect(Collectors.toList()); 463 } 464 465 private CompositeProjectionOptionsStep<?, ExtendedHSearchResourceProjection> buildResourceSelectClause( 466 SearchProjectionFactory<EntityReference, ResourceTable> f) { 467 return f.composite( 468 ExtendedHSearchResourceProjection::new, 469 f.field("myId", Long.class), 470 f.field("myForcedId", String.class), 471 f.field("myRawResource", String.class)); 472 } 473 474 @Override 475 public long count(String theResourceName, SearchParameterMap theParams) { 476 SearchQueryOptionsStep<?, Long, SearchLoadingOptionsStep, ?, ?> queryOptionsStep = 477 getSearchQueryOptionsStep(theResourceName, theParams, null); 478 479 return queryOptionsStep.fetchTotalHitCount(); 480 } 481 482 @Override 483 @Transactional(readOnly = true) 484 public List<IBaseResource> searchForResources( 485 String theResourceType, SearchParameterMap theParams, RequestDetails theRequestDetails) { 486 int offset = 0; 487 int limit = theParams.getCount() == null ? DEFAULT_MAX_PAGE_SIZE : theParams.getCount(); 488 489 if (theParams.getOffset() != null && theParams.getOffset() != 0) { 490 offset = theParams.getOffset(); 491 // indicate param was already processed, otherwise queries DB to process it 492 theParams.setOffset(null); 493 } 494 495 dispatchEvent(IHSearchEventListener.HSearchEventType.SEARCH); 496 497 var query = getSearchSession() 498 .search(ResourceTable.class) 499 .select(this::buildResourceSelectClause) 500 .where(f -> buildWhereClause(f, theResourceType, theParams, null)); 501 502 if (theParams.getSort() != null) { 503 query.sort(f -> myExtendedFulltextSortHelper.getSortClauses(f, theParams.getSort(), theResourceType)); 504 } 505 506 logQuery(query, theRequestDetails); 507 List<ExtendedHSearchResourceProjection> extendedLuceneResourceProjections = query.fetchHits(offset, limit); 508 509 return resourceProjectionsToResources(extendedLuceneResourceProjections); 510 } 511 512 /** 513 * Fire the JPA_PERFTRACE_INFO hook if it is enabled 514 * @param theQuery the query to log 515 * @param theRequestDetails the request details 516 */ 517 @SuppressWarnings("rawtypes") 518 private void logQuery(SearchQueryOptionsStep theQuery, RequestDetails theRequestDetails) { 519 if (CompositeInterceptorBroadcaster.hasHooks( 520 Pointcut.JPA_PERFTRACE_INFO, myInterceptorBroadcaster, theRequestDetails)) { 521 StorageProcessingMessage storageProcessingMessage = new StorageProcessingMessage(); 522 String queryString = theQuery.toQuery().queryString(); 523 storageProcessingMessage.setMessage(queryString); 524 HookParams params = new HookParams() 525 .add(RequestDetails.class, theRequestDetails) 526 .addIfMatchesType(ServletRequestDetails.class, theRequestDetails) 527 .add(StorageProcessingMessage.class, storageProcessingMessage); 528 CompositeInterceptorBroadcaster.doCallHooks( 529 myInterceptorBroadcaster, theRequestDetails, Pointcut.JPA_PERFTRACE_INFO, params); 530 } 531 } 532 533 @Override 534 public boolean supportsAllOf(SearchParameterMap theParams) { 535 return myAdvancedIndexQueryBuilder.isSupportsAllOf(theParams); 536 } 537 538 @Override 539 public boolean supportsAllSortTerms(String theResourceType, SearchParameterMap theParams) { 540 return myExtendedFulltextSortHelper.supportsAllSortTerms(theResourceType, theParams); 541 } 542 543 private void dispatchEvent(IHSearchEventListener.HSearchEventType theEventType) { 544 if (myHSearchEventListener != null) { 545 myHSearchEventListener.hsearchEvent(theEventType); 546 } 547 } 548 549 @Override 550 public void deleteIndexedDocumentsByTypeAndId(Class theClazz, List<Object> theGivenIds) { 551 SearchSession session = Search.session(myEntityManager); 552 SearchIndexingPlan indexingPlan = session.indexingPlan(); 553 for (Object givenId : theGivenIds) { 554 indexingPlan.purge(theClazz, givenId, null); 555 } 556 indexingPlan.process(); 557 indexingPlan.execute(); 558 } 559}