Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.couchbase.fhir.resources.search;

public class HasParam {
String targetResource;
String referenceField;
String criteriaParam;
String criteriaValue;

public HasParam(String targetResource, String referenceField, String criteriaParam, String criteriaValue) {
this.targetResource = targetResource;
this.referenceField = referenceField;
this.criteriaParam = criteriaParam;
this.criteriaValue = criteriaValue;
}

public static HasParam parse(String paramKey, String paramValue) {
if (!paramKey.startsWith("_has:")) {
return null;
}

try {
String raw = paramKey.substring(5);
String[] parts = raw.split(":");
if (parts.length != 3) return null;
return new HasParam(parts[0] , parts[1] , parts[2] , paramValue);
} catch (Exception ex) {
return null;
}
}

@Override
public String toString() {
return "_has(" + targetResource + "->" + referenceField + " where "
+ criteriaParam + "=" + criteriaValue + ")";
}

public String getTargetResource() {
return targetResource;
}

public void setTargetResource(String targetResource) {
this.targetResource = targetResource;
}

public String getReferenceField() {
return referenceField;
}

public void setReferenceField(String referenceField) {
this.referenceField = referenceField;
}

public String getCriteriaParam() {
return criteriaParam;
}

public void setCriteriaParam(String criteriaParam) {
this.criteriaParam = criteriaParam;
}

public String getCriteriaValue() {
return criteriaValue;
}

public void setCriteriaValue(String criteriaValue) {
this.criteriaValue = criteriaValue;
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.couchbase.fhir.resources.service;

import ca.uhn.fhir.context.BaseRuntimeChildDefinition;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.context.RuntimeResourceDefinition;
import ca.uhn.fhir.context.RuntimeSearchParam;
import ca.uhn.fhir.rest.api.SummaryEnum;
import ca.uhn.fhir.rest.api.server.RequestDetails;
Expand All @@ -10,16 +12,15 @@
import com.couchbase.client.java.search.SearchQuery;
import com.couchbase.client.java.search.sort.SearchSort;
import com.couchbase.fhir.resources.config.TenantContextHolder;
import com.couchbase.fhir.resources.search.*;
import com.couchbase.fhir.resources.search.validation.FhirSearchParameterPreprocessor;
import com.couchbase.fhir.resources.search.validation.FhirSearchValidationException;
import com.couchbase.fhir.resources.util.*;
import com.couchbase.fhir.resources.validation.FhirBucketValidator;
import com.couchbase.fhir.resources.validation.FhirBucketValidationException;
import com.couchbase.fhir.resources.search.SearchQueryResult;
import com.couchbase.fhir.resources.search.SearchStateManager;
import com.couchbase.fhir.resources.search.ChainParam;
import com.couchbase.fhir.resources.search.PaginationState;
import ca.uhn.fhir.model.api.Include;
import org.hl7.fhir.instance.model.api.IBase;
import org.hl7.fhir.instance.model.api.IBaseResource;
import org.hl7.fhir.r4.model.Bundle;
import org.hl7.fhir.r4.model.Resource;
import org.slf4j.Logger;
Expand Down Expand Up @@ -231,15 +232,23 @@ public Bundle search(String resourceType, RequestDetails requestDetails) {

// Check for chained parameters FIRST (before validation)
ChainParam chainParam = detectChainParameter(searchParams, resourceType);


//Check for Has Parameters FIRST
HasParam hasParam = detectHasParameter(searchParams);

// Validate search parameters (excluding chain, _include, _revinclude - they have special syntax)
Map<String, List<String>> paramsToValidate = new java.util.HashMap<>(allParams);

// Remove chain parameter from validation since validator doesn't understand chain syntax
if (chainParam != null) {
paramsToValidate.remove(chainParam.getOriginalParameter());
paramsToValidate.remove(chainParam.getOriginalParameter());
logger.debug("🔍 Detected chain parameter '{}', excluding from validation", chainParam.getOriginalParameter());
}

if(hasParam != null) {
paramsToValidate.remove("_has");
logger.debug("🔍 Detected HAS parameter, excluding from validation");
}

// Remove _include/_revinclude from validation since validator doesn't parse their syntax correctly
// (e.g., Encounter:subject:Patient causes validator to see "subject:Patient" as a parameter name)
Expand Down Expand Up @@ -292,6 +301,8 @@ public Bundle search(String resourceType, RequestDetails requestDetails) {
}
logger.info("🔍 Total _include parameters: {}", includes.size());
}



// Remove control parameters from search criteria (we already captured userExplicitCount)
searchParams.remove("_summary");
Expand All @@ -308,6 +319,12 @@ public Bundle search(String resourceType, RequestDetails requestDetails) {
allParams.remove(chainParam.getOriginalParameter());
logger.debug("🔍 Removed chain parameter '{}' from query building", chainParam.getOriginalParameter());
}

if(hasParam != null) {
searchParams.remove("_has");
allParams.remove("_has");
logger.debug("🔍 Removed has parameter from query building");
}

// Build search queries - use allParams to handle multiple values for the same parameter
SearchQueryResult searchQueryResult = buildSearchQueries(resourceType, allParams);
Expand All @@ -331,6 +348,13 @@ public Bundle search(String resourceType, RequestDetails requestDetails) {
RequestPerfBagUtils.addCount(requestDetails, "search_results", result.getTotal());
return result;
}

if(hasParam != null){
Bundle result = handleHasSearch(resourceType , hasParam , ftsQueries , bucketName , sortFields , count , requestDetails);
RequestPerfBagUtils.addTiming(requestDetails, "search_service", System.currentTimeMillis() - searchStartMs);
RequestPerfBagUtils.addCount(requestDetails, "search_results", result.getTotal());
return result;
}

// Check if this is a _revinclude search (can have multiple _revinclude parameters)
if (!revIncludes.isEmpty()) {
Expand Down Expand Up @@ -455,6 +479,28 @@ private ChainParam detectChainParameter(Map<String, String> searchParams, String

return null;
}

/**
* Detect if any parameter is a Has parameter
*/
private HasParam detectHasParameter(Map<String, String> searchParams) {
for (Map.Entry<String, String> e : searchParams.entrySet()) {
String key = e.getKey();
String value = e.getValue();

if (!key.startsWith("_has:")) {
continue;
}

HasParam hp = HasParam.parse(key, value);
if (hp != null) {
logger.info("🔁 Detected HAS parameter: {}", hp);
return hp;
}
}

return null;
}

/**
* Build search queries from criteria parameters
Expand Down Expand Up @@ -554,7 +600,6 @@ private SearchQueryResult buildSearchQueries(String resourceType, Map<String, Li
ftsQueries.add(quantitySearch);
logger.debug("🔍 Added Quantity query for {}: {}", paramName, quantitySearch.export());
break;
case HAS:
case SPECIAL:
case NUMBER:
logger.warn("Unsupported search parameter type: {} for parameter: {}", searchParam.getParamType(), paramName);
Expand Down Expand Up @@ -2618,7 +2663,8 @@ private Bundle handleChainSearch(String primaryResourceType, List<SearchQuery> f
bundle.getEntry().size(), totalCount, needsPagination ? "YES" : "NO");
return bundle;
}



/**
* FASTPATH: Handle chained search with pure JSON assembly (10x memory savings)
* Bypasses HAPI parsing/serialization for both primary and included resources, returns UTF-8 bytes (2x savings vs String)
Expand Down Expand Up @@ -2757,7 +2803,112 @@ private byte[] handleChainSearchFastpath(String primaryResourceType, List<Search

return bundleBytes;
}




/**
* Handle Has search with two-query strategy
*/
private Bundle handleHasSearch(String resourceType , HasParam hasParam, List<SearchQuery> ftsQueries , String bucketName , List<SearchSort> sortFields , int count , RequestDetails requestDetails){

String baseUrl = extractBaseUrl(requestDetails, bucketName);
Map<String, List<String>> chainCriteria = Map.of(hasParam.getCriteriaParam(), List.of(hasParam.getCriteriaValue()));
SearchQueryResult hasQueryResult = buildSearchQueries(hasParam.getTargetResource(), chainCriteria);
List<SearchQuery> hasQueries = hasQueryResult.getFtsQueries();
hasQueries.addAll(ftsQueries);
FtsSearchService.FtsSearchResult ftsResult = ftsKvSearchService.searchForAllKeys(hasQueries, hasParam.getTargetResource(), sortFields);
List<String> docKeys = new ArrayList<>(ftsResult.getDocumentKeys());
List<Resource> referencedDocs = ftsKvSearchService.getDocumentsFromKeys(docKeys , hasParam.getTargetResource());


if (referencedDocs.isEmpty()) {
logger.warn("🔗 No referenced resources found for has query, returning empty bundle");
return createEmptyBundle();
}

long totalCount = referencedDocs.size(); // Accurate total from FTS metadata
boolean needsPagination = referencedDocs.size() > count;

// Step 3: Get keys for first page
List<Resource> firstPageKeys = needsPagination ? referencedDocs.subList(0, count) : referencedDocs;

Set<String> results = new HashSet<>();

for (IBaseResource res : firstPageKeys) {
String ref = extractSubjectReference(fhirContext, res);
if (ref != null) {
results.add(ref);
}
}

List<Resource> primaryResources = ftsKvSearchService.getDocumentsFromKeys(results.stream().toList(),resourceType);


String continuationToken = null;

if (needsPagination) {
// Serialize queries and chain metadata for re-execution
List<String> serializedQueries = serializeFtsQueries(hasQueries);
List<String> serializedSortFields = serializeSortFields(sortFields);

PaginationState paginationState = PaginationState.builder()
.searchType("has")
.resourceType(resourceType)
.primaryResourceCount((int) totalCount) // Store for Bundle.total
.primaryFtsQueriesJson(serializedQueries)
.primaryOffset(count) // Next page starts at offset=count
.primaryPageSize(count)
.sortFieldsJson(serializedSortFields)
.bucketName(bucketName)
.baseUrl(baseUrl)
.useLegacyKeyList(false) // Query-based approach
.build();

continuationToken = searchStateManager.storePaginationState(paginationState);
logger.info("✅ Created HAS PaginationState: token={}, strategy=query-based, offset={}, total={}",
continuationToken, count, totalCount);
}

Bundle bundle = new Bundle();
bundle.setType(Bundle.BundleType.SEARCHSET);
bundle.setTotal( primaryResources.size());
for (Resource resource : primaryResources) {
Resource filteredResource = applyResourceFiltering(resource, null, null);
bundle.addEntry()
.setResource(filteredResource)
.getSearch()
.setMode(Bundle.SearchEntryMode.MATCH);
}

bundle.addLink()
.setRelation("self")
.setUrl(baseUrl + "/" + resourceType + "?_has" +":"+hasParam.getTargetResource()+":"+ hasParam.getReferenceField()+":"+hasParam.getCriteriaParam() + "=" + hasParam.getCriteriaValue());

// Add next link if there are more results
if (continuationToken != null && needsPagination) {
bundle.addLink()
.setRelation("next")
.setUrl(baseUrl + "/" + resourceType + "?_page=" + continuationToken + "&_offset=" + count + "&_count=" + count);
}

return bundle;
}

public String extractSubjectReference(FhirContext ctx, IBaseResource resource) {
RuntimeResourceDefinition def = ctx.getResourceDefinition(resource);
BaseRuntimeChildDefinition subjectChild = def.getChildByName("subject");
if (subjectChild == null) return null;

List<IBase> values = subjectChild.getAccessor().getValues(resource);
if (values == null || values.isEmpty()) return null;

if (values.get(0) instanceof org.hl7.fhir.r4.model.Reference ref) {
return ref.getReference();
}

return null;
}

/**
* Handle pagination requests for both legacy and new pagination strategies
* Supports both HAPI and fastpath rendering
Expand Down