From ebbaad559dcf685c6fbf3e71f5718e46a08c7fa9 Mon Sep 17 00:00:00 2001 From: Adepu Sri Charan Date: Mon, 9 Nov 2026 17:37:21 +0530 Subject: [PATCH 1/4] refactor: share vector helpers for the search API Signed-off-by: Adepu Sri Charan --- .../java/io/dapr/client/DaprVectorClient.java | 220 +++--------------- .../io/dapr/client/SearchVectorHelper.java | 200 ++++++++++++++++ .../domain/IndexingWaitTimeoutAction.java | 3 + .../io/dapr/client/domain/VectorQuery.java | 3 +- .../io/dapr/client/domain/VectorRecord.java | 3 +- 5 files changed, 242 insertions(+), 187 deletions(-) create mode 100644 sdk/src/main/java/io/dapr/client/SearchVectorHelper.java diff --git a/sdk/src/main/java/io/dapr/client/DaprVectorClient.java b/sdk/src/main/java/io/dapr/client/DaprVectorClient.java index 7bd129aafa..2da4672961 100644 --- a/sdk/src/main/java/io/dapr/client/DaprVectorClient.java +++ b/sdk/src/main/java/io/dapr/client/DaprVectorClient.java @@ -15,22 +15,14 @@ import com.google.protobuf.ByteString; import com.google.protobuf.Empty; -import com.google.protobuf.ListValue; -import com.google.protobuf.NullValue; -import com.google.protobuf.Struct; -import com.google.protobuf.Value; -import com.google.rpc.Status; import io.dapr.client.domain.BatchQueryVectorsRequestAlpha1; import io.dapr.client.domain.BatchQueryVectorsResultAlpha1; import io.dapr.client.domain.CreateVectorCollectionRequestAlpha1; import io.dapr.client.domain.DeleteVectorsRequestAlpha1; import io.dapr.client.domain.DistanceMetric; -import io.dapr.client.domain.FailedItem; import io.dapr.client.domain.GetVectorCollectionResponseAlpha1; import io.dapr.client.domain.GetVectorsRequestAlpha1; import io.dapr.client.domain.IndexAck; -import io.dapr.client.domain.IndexingOptions; -import io.dapr.client.domain.IndexingWaitTimeoutAction; import io.dapr.client.domain.QueryVectorsRequestAlpha1; import io.dapr.client.domain.QueryVectorsResponseAlpha1; import io.dapr.client.domain.UpsertVectorsRequestAlpha1; @@ -38,18 +30,13 @@ import io.dapr.client.domain.VectorMatch; import io.dapr.client.domain.VectorQuery; import io.dapr.client.domain.VectorRecord; -import io.dapr.exceptions.DaprErrorDetails; import io.dapr.exceptions.DaprException; -import io.dapr.v1.DaprSearchProtos; import io.dapr.v1.DaprVectorProtos; import reactor.core.publisher.Mono; -import java.time.Duration; import java.util.ArrayList; -import java.util.Collection; import java.util.List; import java.util.Map; -import java.util.function.Consumer; /** * Implements the vector building block (alpha) of {@link DaprPreviewClient} over gRPC. @@ -64,7 +51,7 @@ final class DaprVectorClient { Mono createVectorCollection(CreateVectorCollectionRequestAlpha1 request) { try { - requireRequest(request); + SearchVectorHelper.requireRequest(request); validateCollection(request.getStoreName(), request.getCollection()); if (request.getDimensions() <= 0) { throw new IllegalArgumentException("Dimensions must be greater than zero."); @@ -76,7 +63,7 @@ Mono createVectorCollection(CreateVectorCollectionRequestAlpha1 request) { .setCollection(request.getCollection()) .setDimensions(request.getDimensions()) .setMetric(toProto(request.getMetric())); - putMetadata(builder::putAllMetadata, request.getMetadata()); + SearchVectorHelper.putMetadata(builder::putAllMetadata, request.getMetadata()); DaprVectorProtos.CreateCollectionRequestAlpha1 protoRequest = builder.build(); return Mono.deferContextual( @@ -98,7 +85,7 @@ Mono getVectorCollection( DaprVectorProtos.GetCollectionRequestAlpha1.newBuilder() .setStoreName(storeName) .setCollection(collection); - putMetadata(builder::putAllMetadata, metadata); + SearchVectorHelper.putMetadata(builder::putAllMetadata, metadata); DaprVectorProtos.GetCollectionRequestAlpha1 protoRequest = builder.build(); return Mono.deferContextual( @@ -118,11 +105,11 @@ Mono getVectorCollection( Mono> listVectorCollections(String storeName, Map metadata) { try { - validateStoreName(storeName); + SearchVectorHelper.validateStoreName(storeName); DaprVectorProtos.ListCollectionsRequestAlpha1.Builder builder = DaprVectorProtos.ListCollectionsRequestAlpha1.newBuilder().setStoreName(storeName); - putMetadata(builder::putAllMetadata, metadata); + SearchVectorHelper.putMetadata(builder::putAllMetadata, metadata); DaprVectorProtos.ListCollectionsRequestAlpha1 protoRequest = builder.build(); return Mono.deferContextual( @@ -143,7 +130,7 @@ Mono deleteVectorCollection(String storeName, String collection, Map deleteVectorCollection(String storeName, String collection, Map upsertVectors(UpsertVectorsRequestAlpha1 request) { try { - requireRequest(request); + SearchVectorHelper.requireRequest(request); validateCollection(request.getStoreName(), request.getCollection()); if (request.getRecords() == null) { throw new IllegalArgumentException("Records cannot be null."); @@ -171,9 +158,9 @@ Mono upsertVectors(UpsertVectorsRequestAlpha1 reque for (VectorRecord record : request.getRecords()) { builder.addRecords(toProto(record)); } - putMetadata(builder::putAllMetadata, request.getMetadata()); + SearchVectorHelper.putMetadata(builder::putAllMetadata, request.getMetadata()); if (request.getOptions() != null) { - builder.setOptions(toProto(request.getOptions())); + builder.setOptions(SearchVectorHelper.toIndexingOptions(request.getOptions())); } DaprVectorProtos.UpsertVectorsRequestAlpha1 protoRequest = builder.build(); @@ -182,7 +169,8 @@ Mono upsertVectors(UpsertVectorsRequestAlpha1 reque it -> grpcCalls.intercept(context).upsertVectorsAlpha1(protoRequest, it) ) ).map(response -> new UpsertVectorsResponseAlpha1( - fromProto(response.getAck()), fromProto(response.getFailedItemsList()))); + SearchVectorHelper.toIndexAck(response.getAck()), + SearchVectorHelper.toFailedItems(response.getFailedItemsList()))); } catch (Exception ex) { return DaprException.wrapMono(ex); } @@ -190,9 +178,9 @@ Mono upsertVectors(UpsertVectorsRequestAlpha1 reque Mono> getVectors(GetVectorsRequestAlpha1 request) { try { - requireRequest(request); + SearchVectorHelper.requireRequest(request); validateCollection(request.getStoreName(), request.getCollection()); - validateIds(request.getIds()); + SearchVectorHelper.validateIds(request.getIds()); DaprVectorProtos.GetVectorsRequestAlpha1.Builder builder = DaprVectorProtos.GetVectorsRequestAlpha1.newBuilder() @@ -202,7 +190,7 @@ Mono> getVectors(GetVectorsRequestAlpha1 request) { if (request.getIncludeValues() != null) { builder.setIncludeValues(request.getIncludeValues()); } - putMetadata(builder::putAllMetadata, request.getMetadata()); + SearchVectorHelper.putMetadata(builder::putAllMetadata, request.getMetadata()); DaprVectorProtos.GetVectorsRequestAlpha1 protoRequest = builder.build(); return Mono.deferContextual( @@ -223,18 +211,18 @@ Mono> getVectors(GetVectorsRequestAlpha1 request) { Mono deleteVectors(DeleteVectorsRequestAlpha1 request) { try { - requireRequest(request); + SearchVectorHelper.requireRequest(request); validateCollection(request.getStoreName(), request.getCollection()); - validateIds(request.getIds()); + SearchVectorHelper.validateIds(request.getIds()); DaprVectorProtos.DeleteVectorsRequestAlpha1.Builder builder = DaprVectorProtos.DeleteVectorsRequestAlpha1.newBuilder() .setStoreName(request.getStoreName()) .setCollection(request.getCollection()) .addAllIds(request.getIds()); - putMetadata(builder::putAllMetadata, request.getMetadata()); + SearchVectorHelper.putMetadata(builder::putAllMetadata, request.getMetadata()); if (request.getOptions() != null) { - builder.setOptions(toProto(request.getOptions())); + builder.setOptions(SearchVectorHelper.toIndexingOptions(request.getOptions())); } DaprVectorProtos.DeleteVectorsRequestAlpha1 protoRequest = builder.build(); @@ -242,7 +230,7 @@ Mono deleteVectors(DeleteVectorsRequestAlpha1 request) { context -> grpcCalls.createMono( it -> grpcCalls.intercept(context).deleteVectorsAlpha1(protoRequest, it) ) - ).map(response -> fromProto(response.getAck())); + ).map(response -> SearchVectorHelper.toIndexAck(response.getAck())); } catch (Exception ex) { return DaprException.wrapMono(ex); } @@ -250,7 +238,7 @@ Mono deleteVectors(DeleteVectorsRequestAlpha1 request) { Mono queryVectors(QueryVectorsRequestAlpha1 request) { try { - requireRequest(request); + SearchVectorHelper.requireRequest(request); validateCollection(request.getStoreName(), request.getCollection()); DaprVectorProtos.QueryVectorsRequestAlpha1.Builder builder = @@ -258,7 +246,7 @@ Mono queryVectors(QueryVectorsRequestAlpha1 request) .setStoreName(request.getStoreName()) .setCollection(request.getCollection()) .setQuery(toProto(request.getQuery())); - putMetadata(builder::putAllMetadata, request.getMetadata()); + SearchVectorHelper.putMetadata(builder::putAllMetadata, request.getMetadata()); DaprVectorProtos.QueryVectorsRequestAlpha1 protoRequest = builder.build(); return Mono.deferContextual( @@ -273,7 +261,7 @@ Mono queryVectors(QueryVectorsRequestAlpha1 request) Mono> batchQueryVectors(BatchQueryVectorsRequestAlpha1 request) { try { - requireRequest(request); + SearchVectorHelper.requireRequest(request); validateCollection(request.getStoreName(), request.getCollection()); if (request.getQueries() == null) { throw new IllegalArgumentException("Queries cannot be null."); @@ -286,7 +274,7 @@ Mono> batchQueryVectors(BatchQueryVectorsReq for (VectorQuery query : request.getQueries()) { builder.addQueries(toProto(query)); } - putMetadata(builder::putAllMetadata, request.getMetadata()); + SearchVectorHelper.putMetadata(builder::putAllMetadata, request.getMetadata()); DaprVectorProtos.BatchQueryVectorsRequestAlpha1 protoRequest = builder.build(); return Mono.deferContextual( @@ -299,51 +287,18 @@ Mono> batchQueryVectors(BatchQueryVectorsReq } } - private static void requireRequest(Object request) { - if (request == null) { - throw new IllegalArgumentException("Request cannot be null."); - } - } - - private static void validateStoreName(String storeName) { - if (isBlank(storeName)) { - throw new IllegalArgumentException("Store name cannot be null or empty."); - } - } - private static void validateCollection(String storeName, String collection) { - validateStoreName(storeName); - if (isBlank(collection)) { + SearchVectorHelper.validateStoreName(storeName); + if (SearchVectorHelper.isBlank(collection)) { throw new IllegalArgumentException("Collection name cannot be null or empty."); } } - private static void validateIds(List ids) { - if (ids == null) { - throw new IllegalArgumentException("IDs cannot be null."); - } - for (String id : ids) { - if (isBlank(id)) { - throw new IllegalArgumentException("IDs cannot contain a null or empty ID."); - } - } - } - - private static boolean isBlank(String value) { - return value == null || value.trim().isEmpty(); - } - - private static void putMetadata(Consumer> target, Map metadata) { - if (metadata != null) { - target.accept(metadata); - } - } - private static DaprVectorProtos.VectorRecord toProto(VectorRecord record) { if (record == null) { throw new IllegalArgumentException("Records cannot contain a null record."); } - if (isBlank(record.getId())) { + if (SearchVectorHelper.isBlank(record.getId())) { throw new IllegalArgumentException("Record ID cannot be null or empty."); } if (record.getValues() == null) { @@ -357,7 +312,7 @@ private static DaprVectorProtos.VectorRecord toProto(VectorRecord record) { builder.setPayload(ByteString.copyFrom(record.getPayload())); } if (record.getMetadata() != null) { - builder.setMetadata(toStruct(record.getMetadata())); + builder.setMetadata(SearchVectorHelper.toStruct(record.getMetadata())); } return builder.build(); } @@ -382,7 +337,7 @@ private static DaprVectorProtos.VectorQuery toProto(VectorQuery query) { addValues(vector, query.getVector()); builder.setVector(vector); } else { - if (isBlank(query.getById())) { + if (SearchVectorHelper.isBlank(query.getById())) { throw new IllegalArgumentException("The query record ID cannot be empty."); } builder.setById(query.getById()); @@ -391,7 +346,7 @@ private static DaprVectorProtos.VectorQuery toProto(VectorQuery query) { builder.setTopK(query.getTopK()); } if (query.getFilter() != null) { - builder.setFilter(toStruct(query.getFilter())); + builder.setFilter(SearchVectorHelper.toStruct(query.getFilter())); } if (query.getIncludeValues() != null) { builder.setIncludeValues(query.getIncludeValues()); @@ -406,40 +361,6 @@ private static DaprVectorProtos.VectorQuery toProto(VectorQuery query) { return builder.build(); } - private static DaprSearchProtos.IndexingOptionsAlpha1 toProto(IndexingOptions options) { - if (!options.isWaitForCompletion()) { - return DaprSearchProtos.IndexingOptionsAlpha1.newBuilder() - .setMode(DaprSearchProtos.IndexingMode.INDEXING_MODE_RETURN_ON_ACCEPTANCE) - .build(); - } - - Duration waitTimeout = options.getWaitTimeout(); - if (waitTimeout == null || waitTimeout.isZero() || waitTimeout.isNegative()) { - throw new IllegalArgumentException("Waiting for completion needs a positive wait timeout."); - } - if (options.getOnWaitTimeout() == null) { - throw new IllegalArgumentException("Waiting for completion needs an action for when the wait times out."); - } - return DaprSearchProtos.IndexingOptionsAlpha1.newBuilder() - .setMode(DaprSearchProtos.IndexingMode.INDEXING_MODE_WAIT_FOR_COMPLETION) - .setWaitTimeout(com.google.protobuf.Duration.newBuilder() - .setSeconds(waitTimeout.getSeconds()) - .setNanos(waitTimeout.getNano())) - .setOnWaitTimeout(toProto(options.getOnWaitTimeout())) - .build(); - } - - private static DaprSearchProtos.IndexingWaitTimeoutAction toProto(IndexingWaitTimeoutAction action) { - switch (action) { - case CONTINUE_ASYNC: - return DaprSearchProtos.IndexingWaitTimeoutAction.INDEXING_WAIT_TIMEOUT_ACTION_CONTINUE_ASYNC; - case FAIL_REQUEST: - return DaprSearchProtos.IndexingWaitTimeoutAction.INDEXING_WAIT_TIMEOUT_ACTION_FAIL_REQUEST; - default: - throw new IllegalArgumentException("Unknown wait timeout action: " + action); - } - } - private static DaprVectorProtos.DistanceMetric toProto(DistanceMetric metric) { if (metric == null) { return DaprVectorProtos.DistanceMetric.DISTANCE_METRIC_UNSPECIFIED; @@ -465,29 +386,10 @@ private static DistanceMetric fromProto(DaprVectorProtos.DistanceMetric metric) case DISTANCE_METRIC_EUCLIDEAN: return DistanceMetric.EUCLIDEAN; default: - throw invalidResponse("Vector store returned an invalid distance metric: " + metric + "."); + throw SearchVectorHelper.invalidResponse("Vector store returned an invalid distance metric: " + metric + "."); } } - private static IndexAck fromProto(DaprSearchProtos.IndexAck ack) { - switch (ack) { - case INDEX_ACK_QUEUED: - return IndexAck.QUEUED; - case INDEX_ACK_COMPLETED: - return IndexAck.COMPLETED; - default: - throw invalidResponse("Vector store returned an invalid write acknowledgement: " + ack + "."); - } - } - - private static List fromProto(List failedItems) { - List items = new ArrayList<>(); - for (DaprSearchProtos.FailedItem failedItem : failedItems) { - items.add(new FailedItem(failedItem.getId(), toDaprException(failedItem.getError()))); - } - return items; - } - private static VectorRecord fromProto(DaprVectorProtos.VectorRecord record) { float[] values = new float[record.getValuesCount()]; for (int i = 0; i < values.length; i++) { @@ -507,7 +409,7 @@ private static QueryVectorsResponseAlpha1 fromProto(DaprVectorProtos.QueryVector List matches = new ArrayList<>(); for (DaprVectorProtos.VectorMatch match : response.getMatchesList()) { if (!match.hasRecord()) { - throw invalidResponse("Vector store returned a match without a record."); + throw SearchVectorHelper.invalidResponse("Vector store returned a match without a record."); } matches.add(new VectorMatch(fromProto(match.getRecord()), match.getScore())); } @@ -518,7 +420,7 @@ private static List fromProto( DaprVectorProtos.BatchQueryVectorsResponseAlpha1 response, int queryCount) { // Results are matched to queries by position, so a reply of the wrong shape is rejected. if (response.getResultsCount() != queryCount) { - throw invalidResponse( + throw SearchVectorHelper.invalidResponse( "Vector store returned " + response.getResultsCount() + " results for " + queryCount + " queries."); } @@ -529,71 +431,19 @@ private static List fromProto( results.add(BatchQueryVectorsResultAlpha1.ofResponse(fromProto(result.getResponse()))); break; case ERROR: - results.add(BatchQueryVectorsResultAlpha1.ofError(toDaprException(result.getError()))); + results.add(BatchQueryVectorsResultAlpha1.ofError(SearchVectorHelper.toDaprException(result.getError()))); break; default: - throw invalidResponse("Vector store returned a batch result with neither a response nor an error."); + throw SearchVectorHelper.invalidResponse( + "Vector store returned a batch result with neither a response nor an error."); } } return List.copyOf(results); } - private static DaprException toDaprException(Status status) { - io.grpc.Status.Code code = io.grpc.Status.fromCodeValue(status.getCode()).getCode(); - if (code == io.grpc.Status.Code.OK) { - throw invalidResponse("Vector store returned an error with an OK status code."); - } - return new DaprException(code.toString(), status.getMessage(), new DaprErrorDetails(status), status.toByteArray()); - } - - private static DaprException invalidResponse(String message) { - return new DaprException(io.grpc.Status.Code.INTERNAL.toString(), message, (Throwable) null); - } - private static void addValues(DaprVectorProtos.VectorRecord.Builder builder, float[] values) { for (float value : values) { builder.addValues(value); } } - - // Unlike ProtobufValueHelper.toProtobufValue, this rejects unsupported types instead of sending their toString(), - // which a filter would silently fail to match. - private static Struct toStruct(Map map) { - Struct.Builder builder = Struct.newBuilder(); - for (Map.Entry entry : map.entrySet()) { - if (!(entry.getKey() instanceof String)) { - throw new IllegalArgumentException("Filter and metadata keys must be strings."); - } - builder.putFields((String) entry.getKey(), toValue(entry.getValue())); - } - return builder.build(); - } - - private static Value toValue(Object value) { - Value.Builder builder = Value.newBuilder(); - if (value == null) { - return builder.setNullValue(NullValue.NULL_VALUE).build(); - } - if (value instanceof Boolean) { - return builder.setBoolValue((Boolean) value).build(); - } - if (value instanceof Number) { - return builder.setNumberValue(((Number) value).doubleValue()).build(); - } - if (value instanceof String) { - return builder.setStringValue((String) value).build(); - } - if (value instanceof Collection) { - ListValue.Builder list = ListValue.newBuilder(); - for (Object item : (Collection) value) { - list.addValues(toValue(item)); - } - return builder.setListValue(list).build(); - } - if (value instanceof Map) { - return builder.setStructValue(toStruct((Map) value)).build(); - } - throw new IllegalArgumentException("Filter and metadata values must be null, a Boolean, a Number, a String, " - + "a Collection or a Map, not " + value.getClass().getName() + "."); - } } diff --git a/sdk/src/main/java/io/dapr/client/SearchVectorHelper.java b/sdk/src/main/java/io/dapr/client/SearchVectorHelper.java new file mode 100644 index 0000000000..a7479c3f43 --- /dev/null +++ b/sdk/src/main/java/io/dapr/client/SearchVectorHelper.java @@ -0,0 +1,200 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.client; + +import com.google.protobuf.ListValue; +import com.google.protobuf.NullValue; +import com.google.protobuf.Struct; +import com.google.protobuf.Value; +import com.google.rpc.Status; +import io.dapr.client.domain.FailedItem; +import io.dapr.client.domain.IndexAck; +import io.dapr.client.domain.IndexingOptions; +import io.dapr.client.domain.IndexingWaitTimeoutAction; +import io.dapr.exceptions.DaprErrorDetails; +import io.dapr.exceptions.DaprException; +import io.dapr.v1.DaprSearchProtos; + +import java.math.BigDecimal; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +/** + * Validation and conversions shared by the search and vector building blocks (alpha). + */ +final class SearchVectorHelper { + + private SearchVectorHelper() { + } + + static void requireRequest(Object request) { + if (request == null) { + throw new IllegalArgumentException("Request cannot be null."); + } + } + + static void validateStoreName(String storeName) { + if (isBlank(storeName)) { + throw new IllegalArgumentException("Store name cannot be null or empty."); + } + } + + static void validateIds(List ids) { + if (ids == null) { + throw new IllegalArgumentException("IDs cannot be null."); + } + for (String id : ids) { + if (isBlank(id)) { + throw new IllegalArgumentException("IDs cannot contain a null or empty ID."); + } + } + } + + static boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); + } + + static void putMetadata(Consumer> target, Map metadata) { + if (metadata != null) { + target.accept(metadata); + } + } + + static DaprSearchProtos.IndexingOptionsAlpha1 toIndexingOptions(IndexingOptions options) { + if (!options.isWaitForCompletion()) { + return DaprSearchProtos.IndexingOptionsAlpha1.newBuilder() + .setMode(DaprSearchProtos.IndexingMode.INDEXING_MODE_RETURN_ON_ACCEPTANCE) + .build(); + } + + Duration waitTimeout = options.getWaitTimeout(); + if (waitTimeout == null || waitTimeout.isZero() || waitTimeout.isNegative()) { + throw new IllegalArgumentException("Waiting for completion needs a positive wait timeout."); + } + if (options.getOnWaitTimeout() == null) { + throw new IllegalArgumentException("Waiting for completion needs an action for when the wait times out."); + } + return DaprSearchProtos.IndexingOptionsAlpha1.newBuilder() + .setMode(DaprSearchProtos.IndexingMode.INDEXING_MODE_WAIT_FOR_COMPLETION) + .setWaitTimeout(com.google.protobuf.Duration.newBuilder() + .setSeconds(waitTimeout.getSeconds()) + .setNanos(waitTimeout.getNano())) + .setOnWaitTimeout(toWaitTimeoutAction(options.getOnWaitTimeout())) + .build(); + } + + private static DaprSearchProtos.IndexingWaitTimeoutAction toWaitTimeoutAction(IndexingWaitTimeoutAction action) { + switch (action) { + case CONTINUE_ASYNC: + return DaprSearchProtos.IndexingWaitTimeoutAction.INDEXING_WAIT_TIMEOUT_ACTION_CONTINUE_ASYNC; + case FAIL_REQUEST: + return DaprSearchProtos.IndexingWaitTimeoutAction.INDEXING_WAIT_TIMEOUT_ACTION_FAIL_REQUEST; + default: + throw new IllegalArgumentException("Unknown wait timeout action: " + action); + } + } + + static IndexAck toIndexAck(DaprSearchProtos.IndexAck ack) { + switch (ack) { + case INDEX_ACK_QUEUED: + return IndexAck.QUEUED; + case INDEX_ACK_COMPLETED: + return IndexAck.COMPLETED; + default: + throw invalidResponse("The store returned an invalid write acknowledgement: " + ack + "."); + } + } + + static List toFailedItems(List failedItems) { + List items = new ArrayList<>(); + for (DaprSearchProtos.FailedItem failedItem : failedItems) { + items.add(new FailedItem(failedItem.getId(), toDaprException(failedItem.getError()))); + } + return items; + } + + static DaprException toDaprException(Status status) { + io.grpc.Status.Code code = io.grpc.Status.fromCodeValue(status.getCode()).getCode(); + if (code == io.grpc.Status.Code.OK) { + throw invalidResponse("The store returned an error with an OK status code."); + } + return new DaprException(code.toString(), status.getMessage(), new DaprErrorDetails(status), status.toByteArray()); + } + + static DaprException invalidResponse(String message) { + return new DaprException(io.grpc.Status.Code.INTERNAL.toString(), message, (Throwable) null); + } + + // Unlike ProtobufValueHelper.toProtobufValue, this rejects unsupported types instead of sending their toString(), + // which a filter would silently fail to match. + static Struct toStruct(Map map) { + Struct.Builder builder = Struct.newBuilder(); + for (Map.Entry entry : map.entrySet()) { + if (!(entry.getKey() instanceof String)) { + throw new IllegalArgumentException("Query, filter and metadata keys must be strings."); + } + builder.putFields((String) entry.getKey(), toValue(entry.getValue())); + } + return builder.build(); + } + + private static Value toValue(Object value) { + Value.Builder builder = Value.newBuilder(); + if (value == null) { + return builder.setNullValue(NullValue.NULL_VALUE).build(); + } + if (value instanceof Boolean) { + return builder.setBoolValue((Boolean) value).build(); + } + if (value instanceof Number) { + return builder.setNumberValue(toDouble((Number) value)).build(); + } + if (value instanceof String) { + return builder.setStringValue((String) value).build(); + } + if (value instanceof Collection) { + ListValue.Builder list = ListValue.newBuilder(); + for (Object item : (Collection) value) { + list.addValues(toValue(item)); + } + return builder.setListValue(list).build(); + } + if (value instanceof Map) { + return builder.setStructValue(toStruct((Map) value)).build(); + } + throw new IllegalArgumentException("Query, filter and metadata values must be null, a Boolean, a Number, a String, " + + "a Collection or a Map, not " + value.getClass().getName() + "."); + } + + // A Struct keeps every number as a double. A number that would change on the way is rejected, because a filter + // holding it would match the wrong documents. + private static double toDouble(Number number) { + if (number instanceof Double) { + return number.doubleValue(); + } + if (number instanceof Float) { + // Through its decimal form, so that 1.1f stays 1.1 rather than becoming 1.100000023841858. + return Double.parseDouble(number.toString()); + } + double value = number.doubleValue(); + if (Double.isInfinite(value) || new BigDecimal(number.toString()).compareTo(BigDecimal.valueOf(value)) != 0) { + throw new IllegalArgumentException("The number " + number + " cannot be represented exactly as a double."); + } + return value; + } +} diff --git a/sdk/src/main/java/io/dapr/client/domain/IndexingWaitTimeoutAction.java b/sdk/src/main/java/io/dapr/client/domain/IndexingWaitTimeoutAction.java index e22a6afacf..0031a3f62e 100644 --- a/sdk/src/main/java/io/dapr/client/domain/IndexingWaitTimeoutAction.java +++ b/sdk/src/main/java/io/dapr/client/domain/IndexingWaitTimeoutAction.java @@ -28,6 +28,9 @@ public enum IndexingWaitTimeoutAction { /** * Fail the call with DEADLINE_EXCEEDED. The store may still finish the write. + * + *

The client's retry policy treats DEADLINE_EXCEEDED as retryable, so with retries enabled + * ({@code dapr.api.maxRetries}) the SDK sends the write again before the call fails. */ FAIL_REQUEST } diff --git a/sdk/src/main/java/io/dapr/client/domain/VectorQuery.java b/sdk/src/main/java/io/dapr/client/domain/VectorQuery.java index cde53814e3..d75787e13f 100644 --- a/sdk/src/main/java/io/dapr/client/domain/VectorQuery.java +++ b/sdk/src/main/java/io/dapr/client/domain/VectorQuery.java @@ -107,7 +107,8 @@ public Map getFilter() { * Sets a filter on record metadata, written in the Dapr filter format. * *

Values can be null, a Boolean, a Number, a String, a Collection or a Map with string keys. Other types - * are rejected with an {@link IllegalArgumentException} when the query is sent. + * are rejected with an {@link IllegalArgumentException} when the query is sent, and so are numbers that a double + * cannot hold exactly, such as a long above 2^53. * * @param filter the filter * @return this query for method chaining diff --git a/sdk/src/main/java/io/dapr/client/domain/VectorRecord.java b/sdk/src/main/java/io/dapr/client/domain/VectorRecord.java index 0a7d11dffa..e3255a3468 100644 --- a/sdk/src/main/java/io/dapr/client/domain/VectorRecord.java +++ b/sdk/src/main/java/io/dapr/client/domain/VectorRecord.java @@ -91,7 +91,8 @@ public Map getMetadata() { * Sets structured attributes that queries can filter on. * *

Values can be null, a Boolean, a Number, a String, a Collection or a Map with string keys. Other types - * are rejected with an {@link IllegalArgumentException} when the record is sent. + * are rejected with an {@link IllegalArgumentException} when the record is sent, and so are numbers that a double + * cannot hold exactly, such as a long above 2^53. * * @param metadata the metadata * @return this record for method chaining From d557fff7cc9e2a44f3fe07e360d9c4554b48e295 Mon Sep 17 00:00:00 2001 From: Adepu Sri Charan Date: Mon, 9 Nov 2026 17:38:21 +0530 Subject: [PATCH 2/4] feat: support the search API (alpha) from Dapr 1.19 Signed-off-by: Adepu Sri Charan --- .github/workflows/validate.yml | 1 + examples/components/search/inmemory.yaml | 7 + .../java/io/dapr/examples/search/README.md | 149 +++ .../dapr/examples/search/SearchExample.java | 132 +++ .../search/DaprPreviewClientSearchIT.java | 322 +++++++ .../java/io/dapr/client/DaprClientImpl.java | 108 +++ .../io/dapr/client/DaprPreviewClient.java | 159 ++++ .../java/io/dapr/client/DaprSearchClient.java | 390 ++++++++ .../DeleteSearchDocumentsRequestAlpha1.java | 111 +++ .../GetSearchDocumentsRequestAlpha1.java | 111 +++ .../domain/GetSearchIndexResponseAlpha1.java | 68 ++ .../IndexSearchDocumentsRequestAlpha1.java | 111 +++ .../IndexSearchDocumentsResponseAlpha1.java | 57 ++ .../io/dapr/client/domain/SearchDocument.java | 79 ++ .../java/io/dapr/client/domain/SearchHit.java | 68 ++ .../io/dapr/client/domain/SearchQuery.java | 270 ++++++ .../client/domain/SearchRequestAlpha1.java | 89 ++ .../client/domain/SearchResponseAlpha1.java | 82 ++ .../io/dapr/client/domain/SortClause.java | 68 ++ .../java/io/dapr/client/domain/SortOrder.java | 32 + .../dapr/client/domain/TotalHitsRelation.java | 42 + .../client/DaprPreviewClientSearchTest.java | 890 ++++++++++++++++++ 22 files changed, 3346 insertions(+) create mode 100644 examples/components/search/inmemory.yaml create mode 100644 examples/src/main/java/io/dapr/examples/search/README.md create mode 100644 examples/src/main/java/io/dapr/examples/search/SearchExample.java create mode 100644 sdk-tests/src/test/java/io/dapr/it/testcontainers/search/DaprPreviewClientSearchIT.java create mode 100644 sdk/src/main/java/io/dapr/client/DaprSearchClient.java create mode 100644 sdk/src/main/java/io/dapr/client/domain/DeleteSearchDocumentsRequestAlpha1.java create mode 100644 sdk/src/main/java/io/dapr/client/domain/GetSearchDocumentsRequestAlpha1.java create mode 100644 sdk/src/main/java/io/dapr/client/domain/GetSearchIndexResponseAlpha1.java create mode 100644 sdk/src/main/java/io/dapr/client/domain/IndexSearchDocumentsRequestAlpha1.java create mode 100644 sdk/src/main/java/io/dapr/client/domain/IndexSearchDocumentsResponseAlpha1.java create mode 100644 sdk/src/main/java/io/dapr/client/domain/SearchDocument.java create mode 100644 sdk/src/main/java/io/dapr/client/domain/SearchHit.java create mode 100644 sdk/src/main/java/io/dapr/client/domain/SearchQuery.java create mode 100644 sdk/src/main/java/io/dapr/client/domain/SearchRequestAlpha1.java create mode 100644 sdk/src/main/java/io/dapr/client/domain/SearchResponseAlpha1.java create mode 100644 sdk/src/main/java/io/dapr/client/domain/SortClause.java create mode 100644 sdk/src/main/java/io/dapr/client/domain/SortOrder.java create mode 100644 sdk/src/main/java/io/dapr/client/domain/TotalHitsRelation.java create mode 100644 sdk/src/test/java/io/dapr/client/DaprPreviewClientSearchTest.java diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index e2b32f544f..d087201474 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -124,6 +124,7 @@ jobs: (cd examples && mm.py ./src/main/java/io/dapr/examples/crypto/README.md) (cd examples && mm.py ./src/main/java/io/dapr/examples/binarystore/README.md) (cd examples && mm.py ./src/main/java/io/dapr/examples/vector/README.md) + (cd examples && mm.py ./src/main/java/io/dapr/examples/search/README.md) (cd examples && mm.py ./src/main/java/io/dapr/examples/workflows/README.md) (cd spring-boot-examples/pubsub && mm.py README.md) (cd spring-boot-examples/workflows/patterns && mm.py README.md) diff --git a/examples/components/search/inmemory.yaml b/examples/components/search/inmemory.yaml new file mode 100644 index 0000000000..f71d0305e6 --- /dev/null +++ b/examples/components/search/inmemory.yaml @@ -0,0 +1,7 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: searchstore +spec: + type: search.in-memory + version: v1 diff --git a/examples/src/main/java/io/dapr/examples/search/README.md b/examples/src/main/java/io/dapr/examples/search/README.md new file mode 100644 index 0000000000..a5fb95d376 --- /dev/null +++ b/examples/src/main/java/io/dapr/examples/search/README.md @@ -0,0 +1,149 @@ +## Dapr Search API Example (alpha) + +This example shows how to create a search index, store documents, search them, and delete them with the Dapr search API. The search API is an alpha API in Dapr 1.19. Its names and behavior can change in a later release. + +### Using the Search API + +The Java SDK has these methods on `DaprPreviewClient`: +* `client.createSearchIndex(...)`, `client.getSearchIndex(...)`, `client.listSearchIndexes(...)` and `client.deleteSearchIndex(...)` manage indexes. +* `client.indexSearchDocuments(...)` stores documents by ID, and `client.getSearchDocuments(...)` and `client.deleteSearchDocuments(...)` read and delete them. +* `client.search(...)` finds documents by full-text query, by a query in the store's own format, or by filter alone, and returns the hits one page at a time. + +## Pre-requisites + +* [Dapr CLI](https://docs.dapr.io/getting-started/install-dapr-cli/) and Dapr runtime 1.19 or later. +* Java JDK 17 (or greater): + * [Microsoft JDK 17](https://docs.microsoft.com/en-us/java/openjdk/download#openjdk-17) + * [Oracle JDK 17](https://www.oracle.com/technetwork/java/javase/downloads/index.html#JDK17) + * [OpenJDK 17](https://jdk.java.net/17/) +* [Apache Maven](https://maven.apache.org/install.html) version 3.x. + +### Checking out the code + +Clone this repository: + +```sh +git clone https://github.com/dapr/java-sdk.git +cd java-sdk +``` + +Then build the Maven project: + +```sh +# make sure you are in the `java-sdk` directory. +mvn install +``` + +Then get into the examples directory: + +```sh +cd examples +``` + +### Initialize Dapr + +Run `dapr init` to initialize Dapr in Self-Hosted Mode if it's not already initialized. + +### The search store component + +The example uses the in-memory search store component in `./components/search/inmemory.yaml`: + +```yaml +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: searchstore +spec: + type: search.in-memory + version: v1 +``` + +The in-memory store keeps documents only in the sidecar's memory. Use a different search store component, such as `search.meilisearch`, to keep them. + +### Running the Example + +`SearchExample.java` creates a `books` index, stores three books, and then searches them. A document's content is a UTF-8 encoded JSON object: + +```java +try (DaprPreviewClient client = new DaprClientBuilder().buildPreviewClient()) { + client.createSearchIndex("searchstore", "books").block(); + + List books = List.of( + new SearchDocument("dune", + "{\"title\":\"Dune\",\"genre\":\"scifi\",\"year\":1965}".getBytes(StandardCharsets.UTF_8)), + ...); + client.indexSearchDocuments(new IndexSearchDocumentsRequestAlpha1("searchstore", "books", books) + .setOptions(IndexingOptions.waitForCompletion(Duration.ofSeconds(5), IndexingWaitTimeoutAction.FAIL_REQUEST))) + .block(); + + SearchResponseAlpha1 text = client.search( + new SearchRequestAlpha1("searchstore", "books", SearchQuery.byText("hobbit"))).block(); + + SearchResponseAlpha1 scifi = client.search(new SearchRequestAlpha1("searchstore", "books", + SearchQuery.matchAll() + .setFilter(Map.of("genre", Map.of("$eq", "scifi"))) + .setSort(List.of(SortClause.asc("year"))))).block(); +} +``` + +The write waits until the store has indexed the documents, so the searches that follow see them. Without indexing options, a write returns as soon as the store accepts it. + +The example also reads every book in pages of two: each response has a continuation token until the last page, and the next request sends the same query with that token. + +Use the following command to run this example: + + + +```bash +dapr run --resources-path ./components/search --app-id search-app --dapr-http-port 3500 --dapr-grpc-port 50001 -- java -jar target/dapr-java-sdk-examples-exec.jar io.dapr.examples.search.SearchExample +``` + + + +### Sample Output + +``` +=== Dapr Search Example === +Created index: books +Stored books: 3, COMPLETED, failed: 0 +Index books: 3 documents +Full-text search: [hobbit] +Science fiction, oldest first: [foundation, dune] +Page 1 of 3 books: [dune, foundation] +Page 2 of 3 books: [hobbit] +Read dune: {"title":"Dune","genre":"scifi","year":1965} +Deleted document dune: COMPLETED +Deleted index: books +SUCCESS: The search example finished. +``` + +### Cleanup + +To stop the app, run (or press CTRL+C): + + + +```bash +dapr stop --app-id search-app +``` + + diff --git a/examples/src/main/java/io/dapr/examples/search/SearchExample.java b/examples/src/main/java/io/dapr/examples/search/SearchExample.java new file mode 100644 index 0000000000..2781781c05 --- /dev/null +++ b/examples/src/main/java/io/dapr/examples/search/SearchExample.java @@ -0,0 +1,132 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.examples.search; + +import io.dapr.client.DaprClientBuilder; +import io.dapr.client.DaprPreviewClient; +import io.dapr.client.domain.DeleteSearchDocumentsRequestAlpha1; +import io.dapr.client.domain.GetSearchDocumentsRequestAlpha1; +import io.dapr.client.domain.GetSearchIndexResponseAlpha1; +import io.dapr.client.domain.IndexAck; +import io.dapr.client.domain.IndexSearchDocumentsRequestAlpha1; +import io.dapr.client.domain.IndexSearchDocumentsResponseAlpha1; +import io.dapr.client.domain.IndexingOptions; +import io.dapr.client.domain.IndexingWaitTimeoutAction; +import io.dapr.client.domain.SearchDocument; +import io.dapr.client.domain.SearchHit; +import io.dapr.client.domain.SearchQuery; +import io.dapr.client.domain.SearchRequestAlpha1; +import io.dapr.client.domain.SearchResponseAlpha1; +import io.dapr.client.domain.SortClause; +import io.dapr.config.Properties; +import io.dapr.config.Property; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Stores, reads, searches, and deletes documents with the Dapr search API (alpha). + * + *

Run with the in-memory search store component in components/search. + */ +public class SearchExample { + + private static final String STORE_NAME = "searchstore"; + private static final String INDEX = "books"; + + /** + * The main method of the example. + * + * @param args Command line arguments (unused). + * @throws Exception if the client fails to close. + */ + public static void main(String[] args) throws Exception { + Map, String> overrides = Map.of( + Properties.HTTP_PORT, "3500", + Properties.GRPC_PORT, "50001" + ); + + try (DaprPreviewClient client = new DaprClientBuilder().withPropertyOverrides(overrides).buildPreviewClient()) { + System.out.println("=== Dapr Search Example ==="); + + client.createSearchIndex(STORE_NAME, INDEX).block(); + System.out.println("Created index: " + INDEX); + + List books = List.of( + book("dune", "{\"title\":\"Dune\",\"genre\":\"scifi\",\"year\":1965}"), + book("foundation", "{\"title\":\"Foundation\",\"genre\":\"scifi\",\"year\":1951}"), + book("hobbit", "{\"title\":\"The Hobbit\",\"genre\":\"fantasy\",\"year\":1937}")); + IndexSearchDocumentsResponseAlpha1 indexed = client.indexSearchDocuments( + new IndexSearchDocumentsRequestAlpha1(STORE_NAME, INDEX, books) + .setOptions(IndexingOptions.waitForCompletion( + Duration.ofSeconds(5), IndexingWaitTimeoutAction.FAIL_REQUEST))).block(); + System.out.println("Stored books: " + books.size() + ", " + indexed.getAck() + + ", failed: " + indexed.getFailedItems().size()); + + GetSearchIndexResponseAlpha1 index = client.getSearchIndex(STORE_NAME, INDEX).block(); + System.out.println("Index " + index.getIndex() + ": " + index.getDocumentCount() + " documents"); + + SearchResponseAlpha1 text = client.search( + new SearchRequestAlpha1(STORE_NAME, INDEX, SearchQuery.byText("hobbit"))).block(); + System.out.println("Full-text search: " + ids(text)); + + SearchResponseAlpha1 scifi = client.search(new SearchRequestAlpha1(STORE_NAME, INDEX, + SearchQuery.matchAll() + .setFilter(Map.of("genre", Map.of("$eq", "scifi"))) + .setSort(List.of(SortClause.asc("year"))))).block(); + System.out.println("Science fiction, oldest first: " + ids(scifi)); + + // A continuation token is bound to its query, so each page sends the same query with the new token. + String token = null; + int page = 1; + do { + SearchResponseAlpha1 response = client.search(new SearchRequestAlpha1(STORE_NAME, INDEX, + SearchQuery.matchAll().setTopK(2).setContinuationToken(token))).block(); + System.out.println("Page " + page + " of " + response.getTotalHits() + " books: " + ids(response)); + token = response.getContinuationToken(); + page++; + } while (token != null); + + List found = client.getSearchDocuments( + new GetSearchDocumentsRequestAlpha1(STORE_NAME, INDEX, List.of("dune", "missing")) + .setIncludeContent(true)).block(); + for (SearchDocument document : found) { + String content = new String(document.getContent(), StandardCharsets.UTF_8); + System.out.println("Read " + document.getId() + ": " + content); + } + + IndexAck deleted = client.deleteSearchDocuments( + new DeleteSearchDocumentsRequestAlpha1(STORE_NAME, INDEX, List.of("dune"))).block(); + System.out.println("Deleted document dune: " + deleted); + + client.deleteSearchIndex(STORE_NAME, INDEX).block(); + System.out.println("Deleted index: " + INDEX); + System.out.println("SUCCESS: The search example finished."); + } + } + + private static SearchDocument book(String id, String json) { + return new SearchDocument(id, json.getBytes(StandardCharsets.UTF_8)); + } + + private static List ids(SearchResponseAlpha1 response) { + return response.getHits().stream() + .map(SearchHit::getDocument) + .map(SearchDocument::getId) + .collect(Collectors.toList()); + } +} diff --git a/sdk-tests/src/test/java/io/dapr/it/testcontainers/search/DaprPreviewClientSearchIT.java b/sdk-tests/src/test/java/io/dapr/it/testcontainers/search/DaprPreviewClientSearchIT.java new file mode 100644 index 0000000000..74e2160fbc --- /dev/null +++ b/sdk-tests/src/test/java/io/dapr/it/testcontainers/search/DaprPreviewClientSearchIT.java @@ -0,0 +1,322 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.it.testcontainers.search; + +import io.dapr.client.DaprClientBuilder; +import io.dapr.client.DaprPreviewClient; +import io.dapr.client.domain.DeleteSearchDocumentsRequestAlpha1; +import io.dapr.client.domain.FailedItem; +import io.dapr.client.domain.GetSearchDocumentsRequestAlpha1; +import io.dapr.client.domain.GetSearchIndexResponseAlpha1; +import io.dapr.client.domain.IndexAck; +import io.dapr.client.domain.IndexSearchDocumentsRequestAlpha1; +import io.dapr.client.domain.IndexSearchDocumentsResponseAlpha1; +import io.dapr.client.domain.IndexingOptions; +import io.dapr.client.domain.IndexingWaitTimeoutAction; +import io.dapr.client.domain.SearchDocument; +import io.dapr.client.domain.SearchHit; +import io.dapr.client.domain.SearchQuery; +import io.dapr.client.domain.SearchRequestAlpha1; +import io.dapr.client.domain.SearchResponseAlpha1; +import io.dapr.client.domain.SortClause; +import io.dapr.client.domain.TotalHitsRelation; +import io.dapr.config.Properties; +import io.dapr.exceptions.DaprException; +import io.dapr.testcontainers.Component; +import io.dapr.testcontainers.DaprContainer; +import io.dapr.testcontainers.MetadataEntry; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +import static io.dapr.it.testcontainers.ContainerConstants.DAPR_RUNTIME_IMAGE_TAG; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Integration tests for the Dapr search Alpha1 API. + */ +@Testcontainers +@Tag("testcontainers") +public class DaprPreviewClientSearchIT { + + private static final String STORE_NAME = "searchstore"; + + // Applies every write a few seconds late, so that writes come back as queued and short waits time out. + private static final String DELAYED_STORE_NAME = "delayedsearchstore"; + + private static final IndexingOptions WAIT = IndexingOptions.waitForCompletion( + Duration.ofSeconds(30), IndexingWaitTimeoutAction.FAIL_REQUEST); + + private static final byte[] DUNE = json("{\"title\":\"Dune\",\"genre\":\"scifi\",\"year\":1965}"); + + private static final byte[] FOUNDATION = json("{\"title\":\"Foundation\",\"genre\":\"scifi\",\"year\":1951}"); + + private static final byte[] HOBBIT = json("{\"title\":\"The Hobbit\",\"genre\":\"fantasy\",\"year\":1937}"); + + @Container + private static final DaprContainer DAPR_CONTAINER = new DaprContainer(DAPR_RUNTIME_IMAGE_TAG) + .withAppName("search-test-app") + .withComponent(new Component(STORE_NAME, "search.in-memory", "v1", Collections.emptyList())) + .withComponent(new Component(DELAYED_STORE_NAME, "search.in-memory", "v1", + List.of(new MetadataEntry("indexingDelay", "3s")))); + + private static DaprPreviewClient daprPreviewClient; + + @BeforeAll + static void setUp() { + daprPreviewClient = new DaprClientBuilder() + .withPropertyOverride(Properties.HTTP_ENDPOINT, DAPR_CONTAINER.getHttpEndpoint()) + .withPropertyOverride(Properties.GRPC_ENDPOINT, DAPR_CONTAINER.getGrpcEndpoint()) + .buildPreviewClient(); + } + + @AfterAll + static void tearDown() throws Exception { + if (daprPreviewClient != null) { + daprPreviewClient.close(); + } + } + + @Test + public void shouldCreateGetListAndDeleteIndex() { + String index = createIndex(STORE_NAME); + + GetSearchIndexResponseAlpha1 created = daprPreviewClient.getSearchIndex(STORE_NAME, index).block(); + assertEquals(index, created.getIndex()); + assertEquals(0L, created.getDocumentCount()); + assertTrue(daprPreviewClient.listSearchIndexes(STORE_NAME).block().contains(index)); + assertErrorCode("ALREADY_EXISTS", () -> daprPreviewClient.createSearchIndex(STORE_NAME, index).block()); + + daprPreviewClient.deleteSearchIndex(STORE_NAME, index).block(); + + assertFalse(daprPreviewClient.listSearchIndexes(STORE_NAME).block().contains(index)); + assertErrorCode("NOT_FOUND", () -> daprPreviewClient.getSearchIndex(STORE_NAME, index).block()); + } + + @Test + public void shouldStoreAndReadDocumentsInRequestOrder() { + String index = createBooksIndex(); + + assertEquals(3L, daprPreviewClient.getSearchIndex(STORE_NAME, index).block().getDocumentCount()); + + List documents = daprPreviewClient.getSearchDocuments( + new GetSearchDocumentsRequestAlpha1(STORE_NAME, index, List.of("hobbit", "missing", "dune")) + .setIncludeContent(true)).block(); + assertEquals(List.of("hobbit", "dune"), documentIds(documents)); + assertArrayEquals(HOBBIT, documents.get(0).getContent()); + assertArrayEquals(DUNE, documents.get(1).getContent()); + assertEquals(Map.of("shelf", "b2"), documents.get(1).getMetadata()); + assertNull(documents.get(0).getMetadata()); + + List withoutContent = daprPreviewClient.getSearchDocuments( + new GetSearchDocumentsRequestAlpha1(STORE_NAME, index, List.of("dune"))).block(); + assertNull(withoutContent.get(0).getContent()); + assertEquals(Map.of("shelf", "b2"), withoutContent.get(0).getMetadata()); + } + + @Test + public void shouldReportContentThatIsNotJsonAsFailedItem() { + String index = createIndex(STORE_NAME); + + IndexSearchDocumentsResponseAlpha1 response = daprPreviewClient.indexSearchDocuments( + new IndexSearchDocumentsRequestAlpha1(STORE_NAME, index, List.of( + new SearchDocument("dune", DUNE), + new SearchDocument("notjson", json("not json")), + new SearchDocument("array", json("[1,2]")))) + .setOptions(WAIT)).block(); + + assertEquals(IndexAck.COMPLETED, response.getAck()); + assertEquals(List.of("notjson", "array"), + response.getFailedItems().stream().map(FailedItem::getId).collect(Collectors.toList())); + for (FailedItem failedItem : response.getFailedItems()) { + assertEquals("INVALID_ARGUMENT", failedItem.getError().getErrorCode()); + } + assertEquals(List.of("dune"), documentIds(daprPreviewClient.getSearchDocuments( + new GetSearchDocumentsRequestAlpha1(STORE_NAME, index, List.of("dune", "notjson", "array"))).block())); + } + + @Test + public void shouldRejectDuplicateIds() { + String index = createIndex(STORE_NAME); + + assertErrorCode("INVALID_ARGUMENT", () -> daprPreviewClient.indexSearchDocuments( + new IndexSearchDocumentsRequestAlpha1(STORE_NAME, index, List.of( + new SearchDocument("dune", DUNE), + new SearchDocument("dune", FOUNDATION)))).block()); + } + + @Test + public void shouldSearchByTextFilterAndSort() { + String index = createBooksIndex(); + + SearchResponseAlpha1 byText = search(index, SearchQuery.byText("hobbit")); + assertEquals(List.of("hobbit"), hitIds(byText)); + assertTrue(byText.getHits().get(0).getScore() > 0); + assertEquals(1L, byText.getTotalHits()); + assertEquals(TotalHitsRelation.EXACT, byText.getTotalHitsRelation()); + assertNull(byText.getContinuationToken()); + assertNull(byText.getHits().get(0).getDocument().getContent()); + + assertEquals(List.of(), hitIds(search(index, SearchQuery.byText("hobbit").setSearchFields(List.of("genre"))))); + + assertEquals(List.of("dune", "foundation"), hitIds(search(index, + SearchQuery.matchAll().setFilter(Map.of("genre", "scifi"))))); + assertEquals(List.of("foundation", "hobbit"), hitIds(search(index, + SearchQuery.matchAll().setFilter(Map.of("year", Map.of("$in", Set.of(1951, 1937))))))); + assertEquals(List.of("dune"), hitIds(search(index, + SearchQuery.matchAll().setFilter(Map.of("year", Map.of("$gt", 1951)))))); + + assertEquals(List.of("hobbit", "foundation", "dune"), hitIds(search(index, + SearchQuery.matchAll().setSort(List.of(SortClause.asc("year")))))); + assertEquals(List.of("dune", "foundation", "hobbit"), hitIds(search(index, + SearchQuery.matchAll().setSort(List.of(SortClause.desc("year")))))); + + assertEquals(List.of("dune", "foundation", "hobbit"), hitIds(daprPreviewClient.search( + new SearchRequestAlpha1(STORE_NAME, index, null)).block())); + } + + @Test + public void shouldReturnContentAndSelectedFields() { + String index = createBooksIndex(); + + SearchHit full = search(index, SearchQuery.byText("hobbit").setIncludeContent(true)).getHits().get(0); + assertArrayEquals(HOBBIT, full.getDocument().getContent()); + + SearchHit selected = search(index, SearchQuery.byText("hobbit") + .setIncludeContent(true) + .setReturnFields(List.of("title"))).getHits().get(0); + assertEquals("{\"title\":\"The Hobbit\"}", new String(selected.getDocument().getContent(), StandardCharsets.UTF_8)); + } + + @Test + public void shouldPageThroughHits() { + String index = createBooksIndex(); + + SearchResponseAlpha1 first = search(index, SearchQuery.matchAll().setTopK(2)); + assertEquals(List.of("dune", "foundation"), hitIds(first)); + assertEquals(3L, first.getTotalHits()); + assertNotNull(first.getContinuationToken()); + + SearchResponseAlpha1 second = search(index, + SearchQuery.matchAll().setTopK(2).setContinuationToken(first.getContinuationToken())); + assertEquals(List.of("hobbit"), hitIds(second)); + assertNull(second.getContinuationToken()); + + assertErrorCode("INVALID_ARGUMENT", () -> search(index, + SearchQuery.byText("dune").setContinuationToken(first.getContinuationToken()))); + } + + @Test + public void shouldSendFeaturesTheStoreDoesNotSupport() { + String index = createIndex(STORE_NAME); + + assertErrorCode("UNIMPLEMENTED", () -> search(index, SearchQuery.byNative(Map.of("match", "dune")))); + assertErrorCode("UNIMPLEMENTED", () -> search(index, SearchQuery.matchAll().setHighlightFields(List.of("title")))); + } + + @Test + public void shouldDeleteDocuments() { + String index = createBooksIndex(); + + IndexAck ack = daprPreviewClient.deleteSearchDocuments( + new DeleteSearchDocumentsRequestAlpha1(STORE_NAME, index, List.of("dune", "missing")).setOptions(WAIT)).block(); + + assertEquals(IndexAck.COMPLETED, ack); + assertEquals(List.of("foundation", "hobbit"), documentIds(daprPreviewClient.getSearchDocuments( + new GetSearchDocumentsRequestAlpha1(STORE_NAME, index, List.of("dune", "foundation", "hobbit"))).block())); + } + + @Test + public void shouldFollowIndexingOptionsOnQueuedWrites() { + String index = createIndex(DELAYED_STORE_NAME); + List documents = List.of(new SearchDocument("dune", DUNE)); + Duration shortWait = Duration.ofMillis(200); + + assertEquals(IndexAck.QUEUED, daprPreviewClient.indexSearchDocuments( + new IndexSearchDocumentsRequestAlpha1(DELAYED_STORE_NAME, index, documents)).block().getAck()); + assertEquals(IndexAck.QUEUED, daprPreviewClient.indexSearchDocuments( + new IndexSearchDocumentsRequestAlpha1(DELAYED_STORE_NAME, index, documents) + .setOptions(IndexingOptions.returnOnAcceptance())).block().getAck()); + assertEquals(IndexAck.QUEUED, daprPreviewClient.indexSearchDocuments( + new IndexSearchDocumentsRequestAlpha1(DELAYED_STORE_NAME, index, documents) + .setOptions(IndexingOptions.waitForCompletion(shortWait, IndexingWaitTimeoutAction.CONTINUE_ASYNC))) + .block().getAck()); + assertErrorCode("DEADLINE_EXCEEDED", () -> daprPreviewClient.indexSearchDocuments( + new IndexSearchDocumentsRequestAlpha1(DELAYED_STORE_NAME, index, documents) + .setOptions(IndexingOptions.waitForCompletion(shortWait, IndexingWaitTimeoutAction.FAIL_REQUEST))) + .block()); + assertEquals(IndexAck.COMPLETED, daprPreviewClient.indexSearchDocuments( + new IndexSearchDocumentsRequestAlpha1(DELAYED_STORE_NAME, index, documents).setOptions(WAIT)).block().getAck()); + } + + private static String createIndex(String storeName) { + String index = "books-" + UUID.randomUUID(); + daprPreviewClient.createSearchIndex(storeName, index).block(); + return index; + } + + private static String createBooksIndex() { + String index = createIndex(STORE_NAME); + IndexSearchDocumentsResponseAlpha1 response = daprPreviewClient.indexSearchDocuments( + new IndexSearchDocumentsRequestAlpha1(STORE_NAME, index, List.of( + new SearchDocument("dune", DUNE).setMetadata(Map.of("shelf", "b2")), + new SearchDocument("foundation", FOUNDATION), + new SearchDocument("hobbit", HOBBIT))) + .setOptions(WAIT)).block(); + assertEquals(IndexAck.COMPLETED, response.getAck()); + assertTrue(response.getFailedItems().isEmpty()); + return index; + } + + private static SearchResponseAlpha1 search(String index, SearchQuery query) { + return daprPreviewClient.search(new SearchRequestAlpha1(STORE_NAME, index, query)).block(); + } + + private static List hitIds(SearchResponseAlpha1 response) { + return response.getHits().stream() + .map(SearchHit::getDocument) + .map(SearchDocument::getId) + .collect(Collectors.toList()); + } + + private static List documentIds(List documents) { + return documents.stream().map(SearchDocument::getId).collect(Collectors.toList()); + } + + private static void assertErrorCode(String code, Runnable call) { + assertEquals(code, assertThrows(DaprException.class, call::run).getErrorCode()); + } + + private static byte[] json(String json) { + return json.getBytes(StandardCharsets.UTF_8); + } +} diff --git a/sdk/src/main/java/io/dapr/client/DaprClientImpl.java b/sdk/src/main/java/io/dapr/client/DaprClientImpl.java index 2a60cd72f8..882ab24ac2 100644 --- a/sdk/src/main/java/io/dapr/client/DaprClientImpl.java +++ b/sdk/src/main/java/io/dapr/client/DaprClientImpl.java @@ -38,6 +38,7 @@ import io.dapr.client.domain.DaprMetadata; import io.dapr.client.domain.DecryptRequestAlpha1; import io.dapr.client.domain.DeleteJobRequest; +import io.dapr.client.domain.DeleteSearchDocumentsRequestAlpha1; import io.dapr.client.domain.DeleteStateRequest; import io.dapr.client.domain.DeleteVectorsRequestAlpha1; import io.dapr.client.domain.DropFailurePolicy; @@ -49,6 +50,8 @@ import io.dapr.client.domain.GetConfigurationRequest; import io.dapr.client.domain.GetJobRequest; import io.dapr.client.domain.GetJobResponse; +import io.dapr.client.domain.GetSearchDocumentsRequestAlpha1; +import io.dapr.client.domain.GetSearchIndexResponseAlpha1; import io.dapr.client.domain.GetSecretRequest; import io.dapr.client.domain.GetStateRequest; import io.dapr.client.domain.GetVectorCollectionResponseAlpha1; @@ -56,6 +59,8 @@ import io.dapr.client.domain.HttpEndpointMetadata; import io.dapr.client.domain.HttpExtension; import io.dapr.client.domain.IndexAck; +import io.dapr.client.domain.IndexSearchDocumentsRequestAlpha1; +import io.dapr.client.domain.IndexSearchDocumentsResponseAlpha1; import io.dapr.client.domain.InvokeBindingRequest; import io.dapr.client.domain.InvokeMethodRequest; import io.dapr.client.domain.JobSchedule; @@ -69,6 +74,9 @@ import io.dapr.client.domain.RuleMetadata; import io.dapr.client.domain.SaveStateRequest; import io.dapr.client.domain.ScheduleJobRequest; +import io.dapr.client.domain.SearchDocument; +import io.dapr.client.domain.SearchRequestAlpha1; +import io.dapr.client.domain.SearchResponseAlpha1; import io.dapr.client.domain.SetBinaryFileRequestAlpha1; import io.dapr.client.domain.State; import io.dapr.client.domain.StateOptions; @@ -192,6 +200,8 @@ public class DaprClientImpl extends AbstractDaprClient { private final DaprVectorClient vectorClient; + private final DaprSearchClient searchClient; + /** * Default access level constructor, in order to create an instance of this class use io.dapr.client.DaprClientBuilder * @@ -291,6 +301,7 @@ private DaprClientImpl( this.grpcCalls = new DaprGrpcCalls(asyncStub, this.grpcInterceptors, retryPolicy); this.conversationClient = new DaprConversationClient(this.grpcCalls, objectSerializer); this.vectorClient = new DaprVectorClient(this.grpcCalls); + this.searchClient = new DaprSearchClient(this.grpcCalls); this.logger = LoggerFactory.getLogger(DaprClientImpl.class); } @@ -2408,4 +2419,101 @@ public Mono queryVectors(QueryVectorsRequestAlpha1 r public Mono> batchQueryVectors(BatchQueryVectorsRequestAlpha1 request) { return vectorClient.batchQueryVectors(request); } + + /** + * {@inheritDoc} + */ + @Override + public Mono createSearchIndex(String storeName, String index) { + return searchClient.createSearchIndex(storeName, index, null); + } + + /** + * {@inheritDoc} + */ + @Override + public Mono createSearchIndex(String storeName, String index, Map metadata) { + return searchClient.createSearchIndex(storeName, index, metadata); + } + + /** + * {@inheritDoc} + */ + @Override + public Mono getSearchIndex(String storeName, String index) { + return searchClient.getSearchIndex(storeName, index, null); + } + + /** + * {@inheritDoc} + */ + @Override + public Mono getSearchIndex( + String storeName, String index, Map metadata) { + return searchClient.getSearchIndex(storeName, index, metadata); + } + + /** + * {@inheritDoc} + */ + @Override + public Mono> listSearchIndexes(String storeName) { + return searchClient.listSearchIndexes(storeName, null); + } + + /** + * {@inheritDoc} + */ + @Override + public Mono> listSearchIndexes(String storeName, Map metadata) { + return searchClient.listSearchIndexes(storeName, metadata); + } + + /** + * {@inheritDoc} + */ + @Override + public Mono deleteSearchIndex(String storeName, String index) { + return searchClient.deleteSearchIndex(storeName, index, null); + } + + /** + * {@inheritDoc} + */ + @Override + public Mono deleteSearchIndex(String storeName, String index, Map metadata) { + return searchClient.deleteSearchIndex(storeName, index, metadata); + } + + /** + * {@inheritDoc} + */ + @Override + public Mono indexSearchDocuments(IndexSearchDocumentsRequestAlpha1 request) { + return searchClient.indexSearchDocuments(request); + } + + /** + * {@inheritDoc} + */ + @Override + public Mono> getSearchDocuments(GetSearchDocumentsRequestAlpha1 request) { + return searchClient.getSearchDocuments(request); + } + + /** + * {@inheritDoc} + */ + @Override + public Mono deleteSearchDocuments(DeleteSearchDocumentsRequestAlpha1 request) { + return searchClient.deleteSearchDocuments(request); + } + + /** + * {@inheritDoc} + */ + @Override + public Mono search(SearchRequestAlpha1 request) { + return searchClient.search(request); + } } diff --git a/sdk/src/main/java/io/dapr/client/DaprPreviewClient.java b/sdk/src/main/java/io/dapr/client/DaprPreviewClient.java index 4fefb65786..552ca7b3eb 100644 --- a/sdk/src/main/java/io/dapr/client/DaprPreviewClient.java +++ b/sdk/src/main/java/io/dapr/client/DaprPreviewClient.java @@ -25,16 +25,24 @@ import io.dapr.client.domain.ConversationResponseAlpha2; import io.dapr.client.domain.CreateVectorCollectionRequestAlpha1; import io.dapr.client.domain.DecryptRequestAlpha1; +import io.dapr.client.domain.DeleteSearchDocumentsRequestAlpha1; import io.dapr.client.domain.DeleteVectorsRequestAlpha1; import io.dapr.client.domain.EncryptRequestAlpha1; +import io.dapr.client.domain.GetSearchDocumentsRequestAlpha1; +import io.dapr.client.domain.GetSearchIndexResponseAlpha1; import io.dapr.client.domain.GetVectorCollectionResponseAlpha1; import io.dapr.client.domain.GetVectorsRequestAlpha1; import io.dapr.client.domain.IndexAck; +import io.dapr.client.domain.IndexSearchDocumentsRequestAlpha1; +import io.dapr.client.domain.IndexSearchDocumentsResponseAlpha1; import io.dapr.client.domain.LockRequest; import io.dapr.client.domain.QueryStateRequest; import io.dapr.client.domain.QueryStateResponse; import io.dapr.client.domain.QueryVectorsRequestAlpha1; import io.dapr.client.domain.QueryVectorsResponseAlpha1; +import io.dapr.client.domain.SearchDocument; +import io.dapr.client.domain.SearchRequestAlpha1; +import io.dapr.client.domain.SearchResponseAlpha1; import io.dapr.client.domain.SetBinaryFileRequestAlpha1; import io.dapr.client.domain.UnlockRequest; import io.dapr.client.domain.UnlockResponseStatus; @@ -677,4 +685,155 @@ Mono getVectorCollection( * query is not valid. */ Mono> batchQueryVectors(BatchQueryVectorsRequestAlpha1 request); + + /** + * Creates an index in a search store component. + * + *

If the index exists, the Mono fails with the ALREADY_EXISTS error code. + * This is an alpha API. Its name and behavior can change in a later release. + * + * @param storeName Name of the search store component. + * @param index Name of the index. + * @return An empty Mono that completes when the sidecar creates the index. + * @throws IllegalArgumentException if storeName or index is null or blank. + */ + Mono createSearchIndex(String storeName, String index); + + /** + * Creates an index in a search store component, passing metadata to the component. + * + *

This is an alpha API. Its name and behavior can change in a later release. + * + * @param storeName Name of the search store component. + * @param index Name of the index. + * @param metadata Metadata passed to the component, such as index settings. Can be null. + * @return An empty Mono that completes when the sidecar creates the index. + * @throws IllegalArgumentException if storeName or index is null or blank. + */ + Mono createSearchIndex(String storeName, String index, Map metadata); + + /** + * Gets an index of a search store component. + * + *

If the index does not exist, the Mono fails with the NOT_FOUND error code. + * This is an alpha API. Its name and behavior can change in a later release. + * + * @param storeName Name of the search store component. + * @param index Name of the index. + * @return A Mono with the index's approximate document count and properties. + * @throws IllegalArgumentException if storeName or index is null or blank. + */ + Mono getSearchIndex(String storeName, String index); + + /** + * Gets an index of a search store component, passing metadata to the component. + * + *

This is an alpha API. Its name and behavior can change in a later release. + * + * @param storeName Name of the search store component. + * @param index Name of the index. + * @param metadata Metadata passed to the component. Can be null. + * @return A Mono with the index's approximate document count and properties. + * @throws IllegalArgumentException if storeName or index is null or blank. + */ + Mono getSearchIndex(String storeName, String index, Map metadata); + + /** + * Lists the indexes of a search store component. + * + *

This is an alpha API. Its name and behavior can change in a later release. + * + * @param storeName Name of the search store component. + * @return A Mono with the index names. + * @throws IllegalArgumentException if storeName is null or blank. + */ + Mono> listSearchIndexes(String storeName); + + /** + * Lists the indexes of a search store component, passing metadata to the component. + * + *

This is an alpha API. Its name and behavior can change in a later release. + * + * @param storeName Name of the search store component. + * @param metadata Metadata passed to the component. Can be null. + * @return A Mono with the index names. + * @throws IllegalArgumentException if storeName is null or blank. + */ + Mono> listSearchIndexes(String storeName, Map metadata); + + /** + * Deletes an index and all its documents from a search store component. + * + *

This is an alpha API. Its name and behavior can change in a later release. + * + * @param storeName Name of the search store component. + * @param index Name of the index. + * @return An empty Mono that completes when the sidecar deletes the index. + * @throws IllegalArgumentException if storeName or index is null or blank. + */ + Mono deleteSearchIndex(String storeName, String index); + + /** + * Deletes an index and all its documents from a search store component, passing metadata to the component. + * + *

This is an alpha API. Its name and behavior can change in a later release. + * + * @param storeName Name of the search store component. + * @param index Name of the index. + * @param metadata Metadata passed to the component. Can be null. + * @return An empty Mono that completes when the sidecar deletes the index. + * @throws IllegalArgumentException if storeName or index is null or blank. + */ + Mono deleteSearchIndex(String storeName, String index, Map metadata); + + /** + * Inserts or replaces documents in a search index, by document ID. + * + *

Without indexing options, the call returns once the store accepts the write, and the documents may not be + * searchable yet. Documents the store rejects, including content that is not a JSON object, are listed in the + * response; the call itself still succeeds. + * This is an alpha API. Its name and behavior can change in a later release. + * + * @param request The request with the store name, index name, documents, metadata and indexing options. + * @return A Mono with the acknowledgement and the documents that failed. + * @throws IllegalArgumentException if the request, store name, index name or documents are missing, a document + * has no ID or content, or the indexing options are not valid. + */ + Mono indexSearchDocuments(IndexSearchDocumentsRequestAlpha1 request); + + /** + * Reads documents from a search index by ID. + * + *

The documents come back in request order. IDs that do not exist are left out. + * This is an alpha API. Its name and behavior can change in a later release. + * + * @param request The request with the store name, index name, IDs, include-content flag and metadata. + * @return A Mono with the documents that exist. + * @throws IllegalArgumentException if the request, store name, index name or IDs are missing, or an ID is blank. + */ + Mono> getSearchDocuments(GetSearchDocumentsRequestAlpha1 request); + + /** + * Deletes documents from a search index by ID. IDs that do not exist are not an error. + * + *

This is an alpha API. Its name and behavior can change in a later release. + * + * @param request The request with the store name, index name, IDs, metadata and indexing options. + * @return A Mono with the acknowledgement. + * @throws IllegalArgumentException if the request, store name, index name or IDs are missing, an ID is blank, or + * the indexing options are not valid. + */ + Mono deleteSearchDocuments(DeleteSearchDocumentsRequestAlpha1 request); + + /** + * Searches an index. + * + *

To read the next page, send the same query with the continuation token from the response. + * This is an alpha API. Its name and behavior can change in a later release. + * + * @param request The request with the store name, index name, query and metadata. + * @return A Mono with one page of hits, the total if the store reports one, and the token of the next page. + * @throws IllegalArgumentException if the request, store name or index name is missing, or the query is not valid. + */ + Mono search(SearchRequestAlpha1 request); } diff --git a/sdk/src/main/java/io/dapr/client/DaprSearchClient.java b/sdk/src/main/java/io/dapr/client/DaprSearchClient.java new file mode 100644 index 0000000000..eaa09e5f04 --- /dev/null +++ b/sdk/src/main/java/io/dapr/client/DaprSearchClient.java @@ -0,0 +1,390 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.client; + +import com.google.protobuf.ByteString; +import com.google.protobuf.Empty; +import io.dapr.client.domain.DeleteSearchDocumentsRequestAlpha1; +import io.dapr.client.domain.GetSearchDocumentsRequestAlpha1; +import io.dapr.client.domain.GetSearchIndexResponseAlpha1; +import io.dapr.client.domain.IndexAck; +import io.dapr.client.domain.IndexSearchDocumentsRequestAlpha1; +import io.dapr.client.domain.IndexSearchDocumentsResponseAlpha1; +import io.dapr.client.domain.SearchDocument; +import io.dapr.client.domain.SearchHit; +import io.dapr.client.domain.SearchQuery; +import io.dapr.client.domain.SearchRequestAlpha1; +import io.dapr.client.domain.SearchResponseAlpha1; +import io.dapr.client.domain.SortClause; +import io.dapr.client.domain.SortOrder; +import io.dapr.client.domain.TotalHitsRelation; +import io.dapr.exceptions.DaprException; +import io.dapr.v1.DaprSearchProtos; +import reactor.core.publisher.Mono; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Implements the search building block (alpha) of {@link DaprPreviewClient} over gRPC. + */ +final class DaprSearchClient { + + private final DaprGrpcCalls grpcCalls; + + DaprSearchClient(DaprGrpcCalls grpcCalls) { + this.grpcCalls = grpcCalls; + } + + Mono createSearchIndex(String storeName, String index, Map metadata) { + try { + validateIndex(storeName, index); + + DaprSearchProtos.CreateIndexRequestAlpha1.Builder builder = + DaprSearchProtos.CreateIndexRequestAlpha1.newBuilder() + .setStoreName(storeName) + .setIndex(index); + SearchVectorHelper.putMetadata(builder::putAllMetadata, metadata); + DaprSearchProtos.CreateIndexRequestAlpha1 protoRequest = builder.build(); + + return Mono.deferContextual( + context -> grpcCalls.createMono( + it -> grpcCalls.intercept(context).createIndexAlpha1(protoRequest, it) + ) + ).then(); + } catch (Exception ex) { + return DaprException.wrapMono(ex); + } + } + + Mono getSearchIndex(String storeName, String index, Map metadata) { + try { + validateIndex(storeName, index); + + DaprSearchProtos.GetIndexRequestAlpha1.Builder builder = + DaprSearchProtos.GetIndexRequestAlpha1.newBuilder() + .setStoreName(storeName) + .setIndex(index); + SearchVectorHelper.putMetadata(builder::putAllMetadata, metadata); + DaprSearchProtos.GetIndexRequestAlpha1 protoRequest = builder.build(); + + return Mono.deferContextual( + context -> grpcCalls.createMono( + it -> grpcCalls.intercept(context).getIndexAlpha1(protoRequest, it) + ) + ).map(response -> new GetSearchIndexResponseAlpha1( + response.getIndex(), + response.getDocumentCount(), + response.getPropertiesMap())); + } catch (Exception ex) { + return DaprException.wrapMono(ex); + } + } + + Mono> listSearchIndexes(String storeName, Map metadata) { + try { + SearchVectorHelper.validateStoreName(storeName); + + DaprSearchProtos.ListIndexesRequestAlpha1.Builder builder = + DaprSearchProtos.ListIndexesRequestAlpha1.newBuilder().setStoreName(storeName); + SearchVectorHelper.putMetadata(builder::putAllMetadata, metadata); + DaprSearchProtos.ListIndexesRequestAlpha1 protoRequest = builder.build(); + + return Mono.deferContextual( + context -> grpcCalls.createMono( + it -> grpcCalls.intercept(context).listIndexesAlpha1(protoRequest, it) + ) + ).map(response -> List.copyOf(response.getIndexesList())); + } catch (Exception ex) { + return DaprException.wrapMono(ex); + } + } + + Mono deleteSearchIndex(String storeName, String index, Map metadata) { + try { + validateIndex(storeName, index); + + DaprSearchProtos.DeleteIndexRequestAlpha1.Builder builder = + DaprSearchProtos.DeleteIndexRequestAlpha1.newBuilder() + .setStoreName(storeName) + .setIndex(index); + SearchVectorHelper.putMetadata(builder::putAllMetadata, metadata); + DaprSearchProtos.DeleteIndexRequestAlpha1 protoRequest = builder.build(); + + return Mono.deferContextual( + context -> grpcCalls.createMono( + it -> grpcCalls.intercept(context).deleteIndexAlpha1(protoRequest, it) + ) + ).then(); + } catch (Exception ex) { + return DaprException.wrapMono(ex); + } + } + + Mono indexSearchDocuments(IndexSearchDocumentsRequestAlpha1 request) { + try { + SearchVectorHelper.requireRequest(request); + validateIndex(request.getStoreName(), request.getIndex()); + if (request.getDocuments() == null) { + throw new IllegalArgumentException("Documents cannot be null."); + } + + DaprSearchProtos.IndexDocumentsRequestAlpha1.Builder builder = + DaprSearchProtos.IndexDocumentsRequestAlpha1.newBuilder() + .setStoreName(request.getStoreName()) + .setIndex(request.getIndex()); + for (SearchDocument document : request.getDocuments()) { + builder.addDocuments(toProto(document)); + } + SearchVectorHelper.putMetadata(builder::putAllMetadata, request.getMetadata()); + if (request.getOptions() != null) { + builder.setOptions(SearchVectorHelper.toIndexingOptions(request.getOptions())); + } + DaprSearchProtos.IndexDocumentsRequestAlpha1 protoRequest = builder.build(); + + return Mono.deferContextual( + context -> grpcCalls.createMono( + it -> grpcCalls.intercept(context).indexDocumentsAlpha1(protoRequest, it) + ) + ).map(response -> new IndexSearchDocumentsResponseAlpha1( + SearchVectorHelper.toIndexAck(response.getAck()), + SearchVectorHelper.toFailedItems(response.getFailedItemsList()))); + } catch (Exception ex) { + return DaprException.wrapMono(ex); + } + } + + Mono> getSearchDocuments(GetSearchDocumentsRequestAlpha1 request) { + try { + SearchVectorHelper.requireRequest(request); + validateIndex(request.getStoreName(), request.getIndex()); + SearchVectorHelper.validateIds(request.getIds()); + + DaprSearchProtos.GetDocumentsRequestAlpha1.Builder builder = + DaprSearchProtos.GetDocumentsRequestAlpha1.newBuilder() + .setStoreName(request.getStoreName()) + .setIndex(request.getIndex()) + .addAllIds(request.getIds()); + if (request.getIncludeContent() != null) { + builder.setIncludeContent(request.getIncludeContent()); + } + SearchVectorHelper.putMetadata(builder::putAllMetadata, request.getMetadata()); + DaprSearchProtos.GetDocumentsRequestAlpha1 protoRequest = builder.build(); + + return Mono.deferContextual( + context -> grpcCalls.createMono( + it -> grpcCalls.intercept(context).getDocumentsAlpha1(protoRequest, it) + ) + ).map(response -> { + List documents = new ArrayList<>(); + for (DaprSearchProtos.SearchDocument document : response.getDocumentsList()) { + documents.add(fromProto(document)); + } + return List.copyOf(documents); + }); + } catch (Exception ex) { + return DaprException.wrapMono(ex); + } + } + + Mono deleteSearchDocuments(DeleteSearchDocumentsRequestAlpha1 request) { + try { + SearchVectorHelper.requireRequest(request); + validateIndex(request.getStoreName(), request.getIndex()); + SearchVectorHelper.validateIds(request.getIds()); + + DaprSearchProtos.DeleteDocumentsRequestAlpha1.Builder builder = + DaprSearchProtos.DeleteDocumentsRequestAlpha1.newBuilder() + .setStoreName(request.getStoreName()) + .setIndex(request.getIndex()) + .addAllIds(request.getIds()); + SearchVectorHelper.putMetadata(builder::putAllMetadata, request.getMetadata()); + if (request.getOptions() != null) { + builder.setOptions(SearchVectorHelper.toIndexingOptions(request.getOptions())); + } + DaprSearchProtos.DeleteDocumentsRequestAlpha1 protoRequest = builder.build(); + + return Mono.deferContextual( + context -> grpcCalls.createMono( + it -> grpcCalls.intercept(context).deleteDocumentsAlpha1(protoRequest, it) + ) + ).map(response -> SearchVectorHelper.toIndexAck(response.getAck())); + } catch (Exception ex) { + return DaprException.wrapMono(ex); + } + } + + Mono search(SearchRequestAlpha1 request) { + try { + SearchVectorHelper.requireRequest(request); + validateIndex(request.getStoreName(), request.getIndex()); + + DaprSearchProtos.SearchRequestAlpha1.Builder builder = + DaprSearchProtos.SearchRequestAlpha1.newBuilder() + .setStoreName(request.getStoreName()) + .setIndex(request.getIndex()); + if (request.getQuery() != null) { + builder.setQuery(toProto(request.getQuery())); + } + SearchVectorHelper.putMetadata(builder::putAllMetadata, request.getMetadata()); + DaprSearchProtos.SearchRequestAlpha1 protoRequest = builder.build(); + + return Mono.deferContextual( + context -> grpcCalls.createMono( + it -> grpcCalls.intercept(context).searchAlpha1(protoRequest, it) + ) + ).map(DaprSearchClient::fromProto); + } catch (Exception ex) { + return DaprException.wrapMono(ex); + } + } + + private static void validateIndex(String storeName, String index) { + SearchVectorHelper.validateStoreName(storeName); + if (SearchVectorHelper.isBlank(index)) { + throw new IllegalArgumentException("Index name cannot be null or empty."); + } + } + + private static void validateFields(List fields, String description) { + for (String field : fields) { + if (SearchVectorHelper.isBlank(field)) { + throw new IllegalArgumentException(description + " cannot contain a null or empty field."); + } + } + } + + private static DaprSearchProtos.SearchDocument toProto(SearchDocument document) { + if (document == null) { + throw new IllegalArgumentException("Documents cannot contain a null document."); + } + if (SearchVectorHelper.isBlank(document.getId())) { + throw new IllegalArgumentException("Document ID cannot be null or empty."); + } + if (document.getContent() == null) { + throw new IllegalArgumentException("Document content cannot be null."); + } + + DaprSearchProtos.SearchDocument.Builder builder = DaprSearchProtos.SearchDocument.newBuilder() + .setId(document.getId()) + .setContent(ByteString.copyFrom(document.getContent())); + SearchVectorHelper.putMetadata(builder::putAllMetadata, document.getMetadata()); + return builder.build(); + } + + private static DaprSearchProtos.SearchQuery toProto(SearchQuery query) { + boolean hasText = !SearchVectorHelper.isBlank(query.getText()); + boolean hasNative = query.getNative() != null && !query.getNative().isEmpty(); + // The sidecar treats an empty query as one that matches every document, so that has to be asked for explicitly. + if (!query.isMatchAll() && !hasText && !hasNative) { + throw new IllegalArgumentException( + "A text or native query cannot be empty. Use SearchQuery.matchAll() to match every document."); + } + if (query.getTopK() != null && query.getTopK() < 0) { + throw new IllegalArgumentException("Top K cannot be negative."); + } + + DaprSearchProtos.SearchQuery.Builder builder = DaprSearchProtos.SearchQuery.newBuilder(); + if (hasText) { + builder.setText(query.getText()); + } else if (hasNative) { + builder.setNative(SearchVectorHelper.toStruct(query.getNative())); + } + if (query.getFilter() != null) { + builder.setFilter(SearchVectorHelper.toStruct(query.getFilter())); + } + if (query.getTopK() != null) { + builder.setTopK(query.getTopK()); + } + if (query.getContinuationToken() != null) { + builder.setContinuationToken(query.getContinuationToken()); + } + if (query.getReturnFields() != null) { + validateFields(query.getReturnFields(), "Return fields"); + builder.addAllReturnFields(query.getReturnFields()); + } + if (query.getIncludeContent() != null) { + builder.setIncludeContent(query.getIncludeContent()); + } + if (query.getSearchFields() != null) { + validateFields(query.getSearchFields(), "Search fields"); + builder.addAllSearchFields(query.getSearchFields()); + } + if (query.getSort() != null) { + for (SortClause clause : query.getSort()) { + builder.addSort(toProto(clause)); + } + } + if (query.getHighlightFields() != null) { + validateFields(query.getHighlightFields(), "Highlight fields"); + builder.addAllHighlightFields(query.getHighlightFields()); + } + return builder.build(); + } + + private static DaprSearchProtos.SortClause toProto(SortClause clause) { + if (clause == null) { + throw new IllegalArgumentException("Sort cannot contain a null clause."); + } + if (SearchVectorHelper.isBlank(clause.getField())) { + throw new IllegalArgumentException("Sort field cannot be null or empty."); + } + return DaprSearchProtos.SortClause.newBuilder() + .setField(clause.getField()) + .setOrder(clause.getOrder() == SortOrder.DESC + ? DaprSearchProtos.SortOrder.SORT_ORDER_DESC + : DaprSearchProtos.SortOrder.SORT_ORDER_ASC) + .build(); + } + + private static SearchDocument fromProto(DaprSearchProtos.SearchDocument document) { + // A stored document is a JSON object, so empty content means the read did not ask for it. + byte[] content = document.getContent().isEmpty() ? null : document.getContent().toByteArray(); + SearchDocument searchDocument = new SearchDocument(document.getId(), content); + if (document.getMetadataCount() > 0) { + searchDocument.setMetadata(Map.copyOf(document.getMetadataMap())); + } + return searchDocument; + } + + private static SearchResponseAlpha1 fromProto(DaprSearchProtos.SearchResponseAlpha1 response) { + List hits = new ArrayList<>(); + for (DaprSearchProtos.SearchHit hit : response.getHitsList()) { + if (!hit.hasDocument()) { + throw SearchVectorHelper.invalidResponse("Search store returned a hit without a document."); + } + hits.add(new SearchHit(fromProto(hit.getDocument()), hit.getScore(), hit.getHighlightsMap())); + } + return new SearchResponseAlpha1( + hits, + response.hasTotalHits() ? response.getTotalHits() : null, + response.hasContinuationToken() ? response.getContinuationToken() : null, + fromProto(response.getTotalHitsRelation())); + } + + private static TotalHitsRelation fromProto(DaprSearchProtos.TotalHitsRelation relation) { + switch (relation) { + case TOTAL_HITS_RELATION_EXACT: + return TotalHitsRelation.EXACT; + case TOTAL_HITS_RELATION_LOWER_BOUND: + return TotalHitsRelation.LOWER_BOUND; + case TOTAL_HITS_RELATION_ESTIMATE: + return TotalHitsRelation.ESTIMATE; + default: + // The relation only qualifies the total, so a value this SDK doesn't know shouldn't cost the caller the hits. + return TotalHitsRelation.UNSPECIFIED; + } + } +} diff --git a/sdk/src/main/java/io/dapr/client/domain/DeleteSearchDocumentsRequestAlpha1.java b/sdk/src/main/java/io/dapr/client/domain/DeleteSearchDocumentsRequestAlpha1.java new file mode 100644 index 0000000000..16b108ee7a --- /dev/null +++ b/sdk/src/main/java/io/dapr/client/domain/DeleteSearchDocumentsRequestAlpha1.java @@ -0,0 +1,111 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.client.domain; + +import java.util.List; +import java.util.Map; + +/** + * Request to delete documents from a search index by ID. IDs that do not exist are not an error. + * + *

This is an alpha API. Its name and behavior can change in a later release. + */ +public class DeleteSearchDocumentsRequestAlpha1 { + + private final String storeName; + private final String index; + private final List ids; + private Map metadata; + private IndexingOptions options; + + /** + * Constructor for DeleteSearchDocumentsRequestAlpha1. + * + * @param storeName Name of the search store component. Required. + * @param index Name of the index. Required. + * @param ids IDs of the documents to delete. Required. + */ + public DeleteSearchDocumentsRequestAlpha1(String storeName, String index, List ids) { + this.storeName = storeName; + this.index = index; + this.ids = ids; + } + + /** + * Gets the search store component name. + * + * @return the store name + */ + public String getStoreName() { + return storeName; + } + + /** + * Gets the index name. + * + * @return the index name + */ + public String getIndex() { + return index; + } + + /** + * Gets the IDs of the documents to delete. + * + * @return the document IDs + */ + public List getIds() { + return ids; + } + + /** + * Gets the metadata passed to the search store component. + * + * @return the metadata, or null when there is none + */ + public Map getMetadata() { + return metadata; + } + + /** + * Sets metadata passed to the search store component. + * + * @param metadata the metadata + * @return this request for method chaining + */ + public DeleteSearchDocumentsRequestAlpha1 setMetadata(Map metadata) { + this.metadata = metadata; + return this; + } + + /** + * Gets when the write returns. + * + * @return the indexing options, or null to return once the store accepts the write + */ + public IndexingOptions getOptions() { + return options; + } + + /** + * Sets when the write returns. Without options, it returns once the store accepts the write. + * + * @param options the indexing options + * @return this request for method chaining + */ + public DeleteSearchDocumentsRequestAlpha1 setOptions(IndexingOptions options) { + this.options = options; + return this; + } +} diff --git a/sdk/src/main/java/io/dapr/client/domain/GetSearchDocumentsRequestAlpha1.java b/sdk/src/main/java/io/dapr/client/domain/GetSearchDocumentsRequestAlpha1.java new file mode 100644 index 0000000000..51ac6dfbaf --- /dev/null +++ b/sdk/src/main/java/io/dapr/client/domain/GetSearchDocumentsRequestAlpha1.java @@ -0,0 +1,111 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.client.domain; + +import java.util.List; +import java.util.Map; + +/** + * Request to read documents from a search index by ID. + * + *

This is an alpha API. Its name and behavior can change in a later release. + */ +public class GetSearchDocumentsRequestAlpha1 { + + private final String storeName; + private final String index; + private final List ids; + private Boolean includeContent; + private Map metadata; + + /** + * Constructor for GetSearchDocumentsRequestAlpha1. + * + * @param storeName Name of the search store component. Required. + * @param index Name of the index. Required. + * @param ids IDs of the documents to read. Required. + */ + public GetSearchDocumentsRequestAlpha1(String storeName, String index, List ids) { + this.storeName = storeName; + this.index = index; + this.ids = ids; + } + + /** + * Gets the search store component name. + * + * @return the store name + */ + public String getStoreName() { + return storeName; + } + + /** + * Gets the index name. + * + * @return the index name + */ + public String getIndex() { + return index; + } + + /** + * Gets the IDs of the documents to read. + * + * @return the document IDs + */ + public List getIds() { + return ids; + } + + /** + * Gets whether the documents include their content. + * + * @return true to include the content, or null for the default (false) + */ + public Boolean getIncludeContent() { + return includeContent; + } + + /** + * Sets whether the documents include their content. The default is false. + * + * @param includeContent true to include the content + * @return this request for method chaining + */ + public GetSearchDocumentsRequestAlpha1 setIncludeContent(Boolean includeContent) { + this.includeContent = includeContent; + return this; + } + + /** + * Gets the metadata passed to the search store component. + * + * @return the metadata, or null when there is none + */ + public Map getMetadata() { + return metadata; + } + + /** + * Sets metadata passed to the search store component. + * + * @param metadata the metadata + * @return this request for method chaining + */ + public GetSearchDocumentsRequestAlpha1 setMetadata(Map metadata) { + this.metadata = metadata; + return this; + } +} diff --git a/sdk/src/main/java/io/dapr/client/domain/GetSearchIndexResponseAlpha1.java b/sdk/src/main/java/io/dapr/client/domain/GetSearchIndexResponseAlpha1.java new file mode 100644 index 0000000000..b83c80dde6 --- /dev/null +++ b/sdk/src/main/java/io/dapr/client/domain/GetSearchIndexResponseAlpha1.java @@ -0,0 +1,68 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.client.domain; + +import java.util.Map; + +/** + * An index in a search store component, as returned by the store. + * + *

This is an alpha API. Its name and behavior can change in a later release. + */ +public class GetSearchIndexResponseAlpha1 { + + private final String index; + private final long documentCount; + private final Map properties; + + /** + * Constructor for GetSearchIndexResponseAlpha1. + * + * @param index Name of the index. + * @param documentCount Approximate number of documents. + * @param properties Component-specific properties of the index. + */ + public GetSearchIndexResponseAlpha1(String index, long documentCount, Map properties) { + this.index = index; + this.documentCount = documentCount; + this.properties = Map.copyOf(properties); + } + + /** + * Gets the index name. + * + * @return the index name + */ + public String getIndex() { + return index; + } + + /** + * Gets the approximate number of documents. Stores that cannot count documents cheaply return 0. + * + * @return the document count + */ + public long getDocumentCount() { + return documentCount; + } + + /** + * Gets the component-specific properties of the index. + * + * @return the properties + */ + public Map getProperties() { + return properties; + } +} diff --git a/sdk/src/main/java/io/dapr/client/domain/IndexSearchDocumentsRequestAlpha1.java b/sdk/src/main/java/io/dapr/client/domain/IndexSearchDocumentsRequestAlpha1.java new file mode 100644 index 0000000000..e7d8e487de --- /dev/null +++ b/sdk/src/main/java/io/dapr/client/domain/IndexSearchDocumentsRequestAlpha1.java @@ -0,0 +1,111 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.client.domain; + +import java.util.List; +import java.util.Map; + +/** + * Request to insert or replace documents in a search index, by document ID. + * + *

This is an alpha API. Its name and behavior can change in a later release. + */ +public class IndexSearchDocumentsRequestAlpha1 { + + private final String storeName; + private final String index; + private final List documents; + private Map metadata; + private IndexingOptions options; + + /** + * Constructor for IndexSearchDocumentsRequestAlpha1. + * + * @param storeName Name of the search store component. Required. + * @param index Name of the index. Required. + * @param documents Documents to store. Required. Each needs an ID and content, and IDs must be unique in the request. + */ + public IndexSearchDocumentsRequestAlpha1(String storeName, String index, List documents) { + this.storeName = storeName; + this.index = index; + this.documents = documents; + } + + /** + * Gets the search store component name. + * + * @return the store name + */ + public String getStoreName() { + return storeName; + } + + /** + * Gets the index name. + * + * @return the index name + */ + public String getIndex() { + return index; + } + + /** + * Gets the documents to store. + * + * @return the documents + */ + public List getDocuments() { + return documents; + } + + /** + * Gets the metadata passed to the search store component. + * + * @return the metadata, or null when there is none + */ + public Map getMetadata() { + return metadata; + } + + /** + * Sets metadata passed to the search store component. + * + * @param metadata the metadata + * @return this request for method chaining + */ + public IndexSearchDocumentsRequestAlpha1 setMetadata(Map metadata) { + this.metadata = metadata; + return this; + } + + /** + * Gets when the write returns. + * + * @return the indexing options, or null to return once the store accepts the write + */ + public IndexingOptions getOptions() { + return options; + } + + /** + * Sets when the write returns. Without options, it returns once the store accepts the write. + * + * @param options the indexing options + * @return this request for method chaining + */ + public IndexSearchDocumentsRequestAlpha1 setOptions(IndexingOptions options) { + this.options = options; + return this; + } +} diff --git a/sdk/src/main/java/io/dapr/client/domain/IndexSearchDocumentsResponseAlpha1.java b/sdk/src/main/java/io/dapr/client/domain/IndexSearchDocumentsResponseAlpha1.java new file mode 100644 index 0000000000..353291e776 --- /dev/null +++ b/sdk/src/main/java/io/dapr/client/domain/IndexSearchDocumentsResponseAlpha1.java @@ -0,0 +1,57 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.client.domain; + +import java.util.List; + +/** + * Result of storing documents in a search index. + * + *

This is an alpha API. Its name and behavior can change in a later release. + */ +public class IndexSearchDocumentsResponseAlpha1 { + + private final IndexAck ack; + private final List failedItems; + + /** + * Constructor for IndexSearchDocumentsResponseAlpha1. + * + * @param ack How far the store got with the write. + * @param failedItems Documents the store could not write. + */ + public IndexSearchDocumentsResponseAlpha1(IndexAck ack, List failedItems) { + this.ack = ack; + this.failedItems = List.copyOf(failedItems); + } + + /** + * Gets how far the store got with the write. + * + * @return the acknowledgement + */ + public IndexAck getAck() { + return ack; + } + + /** + * Gets the documents the store could not write. After {@link IndexAck#QUEUED}, this lists only the + * failures known when the store accepted the write. + * + * @return the failed documents + */ + public List getFailedItems() { + return failedItems; + } +} diff --git a/sdk/src/main/java/io/dapr/client/domain/SearchDocument.java b/sdk/src/main/java/io/dapr/client/domain/SearchDocument.java new file mode 100644 index 0000000000..baf2dc8bda --- /dev/null +++ b/sdk/src/main/java/io/dapr/client/domain/SearchDocument.java @@ -0,0 +1,79 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.client.domain; + +import java.util.Map; + +/** + * A document in a search index: an ID, its content as a JSON object, and optional metadata. + * + *

This is an alpha API. Its name and behavior can change in a later release. + */ +public class SearchDocument { + + private final String id; + private final byte[] content; + private Map metadata; + + /** + * Constructor for SearchDocument. + * + * @param id ID of the document, unique within the index. Required. + * @param content UTF-8 encoded JSON object. Required. The sidecar reports content that is not a JSON object + * as a failed item of the write. + */ + public SearchDocument(String id, byte[] content) { + this.id = id; + this.content = content; + } + + /** + * Gets the ID of the document. + * + * @return the document ID + */ + public String getId() { + return id; + } + + /** + * Gets the content of the document. + * + * @return the UTF-8 encoded JSON object, or null when a read did not ask for it + */ + public byte[] getContent() { + return content; + } + + /** + * Gets the metadata stored with the document. + * + * @return the metadata, or null when there is none + */ + public Map getMetadata() { + return metadata; + } + + /** + * Sets opaque metadata to store with the document. The store returns it unchanged. It is not indexed, and + * queries cannot filter on it. + * + * @param metadata the metadata + * @return this document for method chaining + */ + public SearchDocument setMetadata(Map metadata) { + this.metadata = metadata; + return this; + } +} diff --git a/sdk/src/main/java/io/dapr/client/domain/SearchHit.java b/sdk/src/main/java/io/dapr/client/domain/SearchHit.java new file mode 100644 index 0000000000..7f133aceea --- /dev/null +++ b/sdk/src/main/java/io/dapr/client/domain/SearchHit.java @@ -0,0 +1,68 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.client.domain; + +import java.util.Map; + +/** + * A document that matched a search, with its score and highlights. + * + *

This is an alpha API. Its name and behavior can change in a later release. + */ +public class SearchHit { + + private final SearchDocument document; + private final double score; + private final Map highlights; + + /** + * Constructor for SearchHit. + * + * @param document The matching document. + * @param score The relevance score. + * @param highlights Highlighted fragments, by field name. + */ + public SearchHit(SearchDocument document, double score, Map highlights) { + this.document = document; + this.score = score; + this.highlights = Map.copyOf(highlights); + } + + /** + * Gets the matching document. + * + * @return the document + */ + public SearchDocument getDocument() { + return document; + } + + /** + * Gets the relevance score. Scores are specific to the store. Higher is more relevant. + * + * @return the score + */ + public double getScore() { + return score; + } + + /** + * Gets the highlighted fragments, by field name. + * + * @return the highlights, empty when the query did not ask for any + */ + public Map getHighlights() { + return highlights; + } +} diff --git a/sdk/src/main/java/io/dapr/client/domain/SearchQuery.java b/sdk/src/main/java/io/dapr/client/domain/SearchQuery.java new file mode 100644 index 0000000000..1aa33f9ee6 --- /dev/null +++ b/sdk/src/main/java/io/dapr/client/domain/SearchQuery.java @@ -0,0 +1,270 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.client.domain; + +import java.util.List; +import java.util.Map; + +/** + * Selects and shapes the hits of a search: every document, a full-text query or a query in the store's own format, + * narrowed by an optional filter. + * + *

This is an alpha API. Its name and behavior can change in a later release. + */ +public class SearchQuery { + + private final boolean matchAll; + private final String text; + private final Map nativeQuery; + private Map filter; + private Integer topK; + private String continuationToken; + private List returnFields; + private Boolean includeContent; + private List searchFields; + private List sort; + private List highlightFields; + + private SearchQuery(boolean matchAll, String text, Map nativeQuery) { + this.matchAll = matchAll; + this.text = text; + this.nativeQuery = nativeQuery; + } + + /** + * Creates a query that matches every document, subject to a filter if one is set. + * + * @return the query + */ + public static SearchQuery matchAll() { + return new SearchQuery(true, null, null); + } + + /** + * Creates a full-text query. + * + * @param text the text to search for. Must not be blank. + * @return the query + */ + public static SearchQuery byText(String text) { + return new SearchQuery(false, text, null); + } + + /** + * Creates a query in the store's own format, passed to the store as is. Not every store supports this. + * + *

Values can be null, a Boolean, a Number, a String, a Collection or a Map with string keys. Other types + * are rejected with an {@link IllegalArgumentException} when the query is sent, and so are numbers that a double + * cannot hold exactly, such as a long above 2^53. + * + * @param query the native query. Must not be empty. + * @return the query + */ + public static SearchQuery byNative(Map query) { + return new SearchQuery(false, null, query); + } + + /** + * Gets whether this query was created to match every document. + * + * @return true for {@link #matchAll()} + */ + public boolean isMatchAll() { + return matchAll; + } + + /** + * Gets the text to search for. + * + * @return the text, or null when this is not a full-text query + */ + public String getText() { + return text; + } + + /** + * Gets the query in the store's own format. + * + * @return the native query, or null when this is not a native query + */ + public Map getNative() { + return nativeQuery; + } + + /** + * Gets the filter on document content. + * + * @return the filter, or null when the query has none + */ + public Map getFilter() { + return filter; + } + + /** + * Sets a filter on document content, written in the Dapr filter format. + * + *

Values can be null, a Boolean, a Number, a String, a Collection or a Map with string keys. Other types + * are rejected with an {@link IllegalArgumentException} when the query is sent, and so are numbers that a double + * cannot hold exactly, such as a long above 2^53. + * + * @param filter the filter + * @return this query for method chaining + */ + public SearchQuery setFilter(Map filter) { + this.filter = filter; + return this; + } + + /** + * Gets the maximum number of hits in a page. + * + * @return the limit, or null for the component's default + */ + public Integer getTopK() { + return topK; + } + + /** + * Sets the maximum number of hits in a page. 0 or null uses the component's default. + * + * @param topK the limit. Must not be negative. + * @return this query for method chaining + */ + public SearchQuery setTopK(Integer topK) { + this.topK = topK; + return this; + } + + /** + * Gets the token of the page to read. + * + * @return the token, or null for the first page + */ + public String getContinuationToken() { + return continuationToken; + } + + /** + * Sets the token of the page to read, from {@link SearchResponseAlpha1#getContinuationToken()}. A token is bound + * to the query that produced it, so send the same query with only the token changed. + * + * @param continuationToken the token + * @return this query for method chaining + */ + public SearchQuery setContinuationToken(String continuationToken) { + this.continuationToken = continuationToken; + return this; + } + + /** + * Gets the content fields to return with each hit. + * + * @return the fields, or null when the query does not restrict them + */ + public List getReturnFields() { + return returnFields; + } + + /** + * Restricts the content returned with each hit to these fields, as top-level or dotted nested keys. Set + * {@link #setIncludeContent(Boolean)} as well, because stores differ on whether return fields alone return content. + * + * @param returnFields the fields + * @return this query for method chaining + */ + public SearchQuery setReturnFields(List returnFields) { + this.returnFields = returnFields; + return this; + } + + /** + * Gets whether hits include the document content. + * + * @return true to include the content, or null for the default (false) + */ + public Boolean getIncludeContent() { + return includeContent; + } + + /** + * Sets whether hits include the document content. The default is false. + * + * @param includeContent true to include the content + * @return this query for method chaining + */ + public SearchQuery setIncludeContent(Boolean includeContent) { + this.includeContent = includeContent; + return this; + } + + /** + * Gets the content fields that a full-text query searches. + * + * @return the fields, or null when the query does not restrict them + */ + public List getSearchFields() { + return searchFields; + } + + /** + * Restricts a full-text query to these content fields, as top-level or dotted nested keys. + * + * @param searchFields the fields + * @return this query for method chaining + */ + public SearchQuery setSearchFields(List searchFields) { + this.searchFields = searchFields; + return this; + } + + /** + * Gets the sort keys. + * + * @return the sort keys, or null to order hits by relevance + */ + public List getSort() { + return sort; + } + + /** + * Sets the sort keys, most significant first. Without sort keys, the store orders hits by relevance. + * + * @param sort the sort keys + * @return this query for method chaining + */ + public SearchQuery setSort(List sort) { + this.sort = sort; + return this; + } + + /** + * Gets the content fields to highlight. + * + * @return the fields, or null when the query asks for no highlights + */ + public List getHighlightFields() { + return highlightFields; + } + + /** + * Sets the content fields to highlight, as top-level or dotted nested keys. Not every store supports this. + * + * @param highlightFields the fields + * @return this query for method chaining + */ + public SearchQuery setHighlightFields(List highlightFields) { + this.highlightFields = highlightFields; + return this; + } +} diff --git a/sdk/src/main/java/io/dapr/client/domain/SearchRequestAlpha1.java b/sdk/src/main/java/io/dapr/client/domain/SearchRequestAlpha1.java new file mode 100644 index 0000000000..cec6ff9cc7 --- /dev/null +++ b/sdk/src/main/java/io/dapr/client/domain/SearchRequestAlpha1.java @@ -0,0 +1,89 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.client.domain; + +import java.util.Map; + +/** + * Request to search an index. + * + *

This is an alpha API. Its name and behavior can change in a later release. + */ +public class SearchRequestAlpha1 { + + private final String storeName; + private final String index; + private final SearchQuery query; + private Map metadata; + + /** + * Constructor for SearchRequestAlpha1. + * + * @param storeName Name of the search store component. Required. + * @param index Name of the index. Required. + * @param query The query. Null matches every document. + */ + public SearchRequestAlpha1(String storeName, String index, SearchQuery query) { + this.storeName = storeName; + this.index = index; + this.query = query; + } + + /** + * Gets the search store component name. + * + * @return the store name + */ + public String getStoreName() { + return storeName; + } + + /** + * Gets the index name. + * + * @return the index name + */ + public String getIndex() { + return index; + } + + /** + * Gets the query. + * + * @return the query, or null to match every document + */ + public SearchQuery getQuery() { + return query; + } + + /** + * Gets the metadata passed to the search store component. + * + * @return the metadata, or null when there is none + */ + public Map getMetadata() { + return metadata; + } + + /** + * Sets metadata passed to the search store component. + * + * @param metadata the metadata + * @return this request for method chaining + */ + public SearchRequestAlpha1 setMetadata(Map metadata) { + this.metadata = metadata; + return this; + } +} diff --git a/sdk/src/main/java/io/dapr/client/domain/SearchResponseAlpha1.java b/sdk/src/main/java/io/dapr/client/domain/SearchResponseAlpha1.java new file mode 100644 index 0000000000..a01fa3fe13 --- /dev/null +++ b/sdk/src/main/java/io/dapr/client/domain/SearchResponseAlpha1.java @@ -0,0 +1,82 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.client.domain; + +import java.util.List; + +/** + * One page of the hits of a search. + * + *

This is an alpha API. Its name and behavior can change in a later release. + */ +public class SearchResponseAlpha1 { + + private final List hits; + private final Long totalHits; + private final String continuationToken; + private final TotalHitsRelation totalHitsRelation; + + /** + * Constructor for SearchResponseAlpha1. + * + * @param hits The hits of this page. + * @param totalHits The total number of hits, or null when the store did not report one. + * @param continuationToken The token of the next page, or null when there are no more hits. + * @param totalHitsRelation How accurate the total is. + */ + public SearchResponseAlpha1( + List hits, Long totalHits, String continuationToken, TotalHitsRelation totalHitsRelation) { + this.hits = List.copyOf(hits); + this.totalHits = totalHits; + this.continuationToken = continuationToken; + this.totalHitsRelation = totalHitsRelation; + } + + /** + * Gets the hits of this page, in the order the store returned them. + * + * @return the hits + */ + public List getHits() { + return hits; + } + + /** + * Gets the total number of hits across all pages. This is best effort: see {@link #getTotalHitsRelation()}. + * + * @return the total, or null when the store did not report one + */ + public Long getTotalHits() { + return totalHits; + } + + /** + * Gets the token of the next page, for {@link SearchQuery#setContinuationToken(String)}. + * + * @return the token, or null when there are no more hits + */ + public String getContinuationToken() { + return continuationToken; + } + + /** + * Gets how accurate the total is. + * + * @return the relation, {@link TotalHitsRelation#UNSPECIFIED} when the store reported no total or did not say how + * accurate it is + */ + public TotalHitsRelation getTotalHitsRelation() { + return totalHitsRelation; + } +} diff --git a/sdk/src/main/java/io/dapr/client/domain/SortClause.java b/sdk/src/main/java/io/dapr/client/domain/SortClause.java new file mode 100644 index 0000000000..1181428033 --- /dev/null +++ b/sdk/src/main/java/io/dapr/client/domain/SortClause.java @@ -0,0 +1,68 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.client.domain; + +/** + * One sort key of a search query. + * + *

This is an alpha API. Its name and behavior can change in a later release. + */ +public class SortClause { + + private final String field; + private final SortOrder order; + + private SortClause(String field, SortOrder order) { + this.field = field; + this.order = order; + } + + /** + * Creates a clause that sorts by a field, lowest values first. + * + * @param field Top-level or dotted nested key of the document content. Must not be blank. + * @return the clause + */ + public static SortClause asc(String field) { + return new SortClause(field, SortOrder.ASC); + } + + /** + * Creates a clause that sorts by a field, highest values first. + * + * @param field Top-level or dotted nested key of the document content. Must not be blank. + * @return the clause + */ + public static SortClause desc(String field) { + return new SortClause(field, SortOrder.DESC); + } + + /** + * Gets the field to sort by. + * + * @return the field + */ + public String getField() { + return field; + } + + /** + * Gets the sort direction. + * + * @return the direction + */ + public SortOrder getOrder() { + return order; + } +} diff --git a/sdk/src/main/java/io/dapr/client/domain/SortOrder.java b/sdk/src/main/java/io/dapr/client/domain/SortOrder.java new file mode 100644 index 0000000000..73a5d2a681 --- /dev/null +++ b/sdk/src/main/java/io/dapr/client/domain/SortOrder.java @@ -0,0 +1,32 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.client.domain; + +/** + * Direction of a {@link SortClause}. + * + *

This is an alpha API. Its name and behavior can change in a later release. + */ +public enum SortOrder { + + /** + * Lowest values first. + */ + ASC, + + /** + * Highest values first. + */ + DESC +} diff --git a/sdk/src/main/java/io/dapr/client/domain/TotalHitsRelation.java b/sdk/src/main/java/io/dapr/client/domain/TotalHitsRelation.java new file mode 100644 index 0000000000..7a3096b376 --- /dev/null +++ b/sdk/src/main/java/io/dapr/client/domain/TotalHitsRelation.java @@ -0,0 +1,42 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.client.domain; + +/** + * How accurate the total number of hits of a search is. + * + *

This is an alpha API. Its name and behavior can change in a later release. + */ +public enum TotalHitsRelation { + + /** + * The store reported no total, or did not say how accurate it is. + */ + UNSPECIFIED, + + /** + * The total is exact. + */ + EXACT, + + /** + * At least this many documents match. + */ + LOWER_BOUND, + + /** + * The total is an estimate. + */ + ESTIMATE +} diff --git a/sdk/src/test/java/io/dapr/client/DaprPreviewClientSearchTest.java b/sdk/src/test/java/io/dapr/client/DaprPreviewClientSearchTest.java new file mode 100644 index 0000000000..835b7b59d0 --- /dev/null +++ b/sdk/src/test/java/io/dapr/client/DaprPreviewClientSearchTest.java @@ -0,0 +1,890 @@ +/* + * Copyright 2026 The Dapr Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and +limitations under the License. +*/ + +package io.dapr.client; + +import com.google.protobuf.Any; +import com.google.protobuf.ByteString; +import com.google.protobuf.Empty; +import com.google.protobuf.Value; +import com.google.rpc.Code; +import com.google.rpc.ErrorInfo; +import com.google.rpc.Status; +import io.dapr.client.domain.DeleteSearchDocumentsRequestAlpha1; +import io.dapr.client.domain.GetSearchDocumentsRequestAlpha1; +import io.dapr.client.domain.GetSearchIndexResponseAlpha1; +import io.dapr.client.domain.IndexAck; +import io.dapr.client.domain.IndexSearchDocumentsRequestAlpha1; +import io.dapr.client.domain.IndexSearchDocumentsResponseAlpha1; +import io.dapr.client.domain.IndexingOptions; +import io.dapr.client.domain.IndexingWaitTimeoutAction; +import io.dapr.client.domain.SearchDocument; +import io.dapr.client.domain.SearchHit; +import io.dapr.client.domain.SearchQuery; +import io.dapr.client.domain.SearchRequestAlpha1; +import io.dapr.client.domain.SearchResponseAlpha1; +import io.dapr.client.domain.SortClause; +import io.dapr.client.domain.SortOrder; +import io.dapr.client.domain.TotalHitsRelation; +import io.dapr.exceptions.DaprErrorDetails; +import io.dapr.exceptions.DaprException; +import io.dapr.serializer.DefaultObjectSerializer; +import io.dapr.utils.TypeRef; +import io.dapr.v1.DaprGrpc; +import io.dapr.v1.DaprSearchProtos; +import io.grpc.stub.StreamObserver; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.stubbing.Answer; +import reactor.core.publisher.Mono; + +import java.io.IOException; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +import static io.dapr.client.DaprClientGrpcTest.newStatusRuntimeException; +import static io.dapr.utils.TestUtils.assertThrowsDaprException; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class DaprPreviewClientSearchTest { + + private static final String STORE = "searchstore"; + + private static final String INDEX = "books"; + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + private static final byte[] DUNE = "{\"title\":\"Dune\"}".getBytes(StandardCharsets.UTF_8); + + private GrpcChannelFacade channel; + private DaprGrpc.DaprStub daprStub; + private DaprPreviewClient previewClient; + + @BeforeEach + public void setup() throws IOException { + channel = mock(GrpcChannelFacade.class); + daprStub = mock(DaprGrpc.DaprStub.class); + when(daprStub.withInterceptors(any())).thenReturn(daprStub); + previewClient = new DaprClientImpl( + channel, daprStub, mock(DaprHttp.class), new DefaultObjectSerializer(), new DefaultObjectSerializer()); + doNothing().when(channel).close(); + } + + @AfterEach + public void tearDown() throws Exception { + previewClient.close(); + } + + @SuppressWarnings("unchecked") + private static Answer respond(T response) { + return invocation -> { + StreamObserver observer = (StreamObserver) invocation.getArguments()[1]; + observer.onNext(response); + observer.onCompleted(); + return null; + }; + } + + private static void assertRejected(Supplier> call) { + assertThrows(IllegalArgumentException.class, () -> call.get().block(TIMEOUT)); + } + + private static Value string(String value) { + return Value.newBuilder().setStringValue(value).build(); + } + + private static DaprSearchProtos.IndexDocumentsResponseAlpha1 completed() { + return DaprSearchProtos.IndexDocumentsResponseAlpha1.newBuilder() + .setAck(DaprSearchProtos.IndexAck.INDEX_ACK_COMPLETED) + .build(); + } + + private DaprSearchProtos.SearchRequestAlpha1 sentBy(SearchQuery query) { + ArgumentCaptor captor = + ArgumentCaptor.forClass(DaprSearchProtos.SearchRequestAlpha1.class); + doAnswer(respond(DaprSearchProtos.SearchResponseAlpha1.getDefaultInstance())) + .when(daprStub).searchAlpha1(captor.capture(), any()); + previewClient.search(new SearchRequestAlpha1(STORE, INDEX, query)).block(TIMEOUT); + return captor.getValue(); + } + + @Test + @DisplayName("createSearchIndex should send the names and metadata") + public void createSearchIndexTest() { + ArgumentCaptor captor = + ArgumentCaptor.forClass(DaprSearchProtos.CreateIndexRequestAlpha1.class); + doAnswer(respond(Empty.getDefaultInstance())).when(daprStub).createIndexAlpha1(captor.capture(), any()); + + previewClient.createSearchIndex(STORE, INDEX, Map.of("primaryKey", "id")).block(TIMEOUT); + + DaprSearchProtos.CreateIndexRequestAlpha1 sent = captor.getValue(); + assertEquals(STORE, sent.getStoreName()); + assertEquals(INDEX, sent.getIndex()); + assertEquals(Map.of("primaryKey", "id"), sent.getMetadataMap()); + } + + @Test + @DisplayName("createSearchIndex without metadata should send the names only") + public void createSearchIndexWithoutMetadataTest() { + ArgumentCaptor captor = + ArgumentCaptor.forClass(DaprSearchProtos.CreateIndexRequestAlpha1.class); + doAnswer(respond(Empty.getDefaultInstance())).when(daprStub).createIndexAlpha1(captor.capture(), any()); + + previewClient.createSearchIndex(STORE, INDEX).block(TIMEOUT); + + assertEquals(STORE, captor.getValue().getStoreName()); + assertEquals(INDEX, captor.getValue().getIndex()); + assertTrue(captor.getValue().getMetadataMap().isEmpty()); + } + + @Test + @DisplayName("getSearchIndex should send the names and metadata and map the reply") + public void getSearchIndexTest() { + ArgumentCaptor captor = + ArgumentCaptor.forClass(DaprSearchProtos.GetIndexRequestAlpha1.class); + doAnswer(respond(DaprSearchProtos.GetIndexResponseAlpha1.newBuilder() + .setIndex(INDEX) + .setDocumentCount(42) + .putProperties("primaryKey", "id") + .build())).when(daprStub).getIndexAlpha1(captor.capture(), any()); + + GetSearchIndexResponseAlpha1 response = + previewClient.getSearchIndex(STORE, INDEX, Map.of("consistency", "strong")).block(TIMEOUT); + + assertEquals(STORE, captor.getValue().getStoreName()); + assertEquals(INDEX, captor.getValue().getIndex()); + assertEquals(Map.of("consistency", "strong"), captor.getValue().getMetadataMap()); + assertEquals(INDEX, response.getIndex()); + assertEquals(42L, response.getDocumentCount()); + assertEquals(Map.of("primaryKey", "id"), response.getProperties()); + } + + @Test + @DisplayName("getSearchIndex without metadata should send the names only") + public void getSearchIndexWithoutMetadataTest() { + ArgumentCaptor captor = + ArgumentCaptor.forClass(DaprSearchProtos.GetIndexRequestAlpha1.class); + doAnswer(respond(DaprSearchProtos.GetIndexResponseAlpha1.getDefaultInstance())) + .when(daprStub).getIndexAlpha1(captor.capture(), any()); + + previewClient.getSearchIndex(STORE, INDEX).block(TIMEOUT); + + assertEquals(STORE, captor.getValue().getStoreName()); + assertEquals(INDEX, captor.getValue().getIndex()); + assertTrue(captor.getValue().getMetadataMap().isEmpty()); + } + + @Test + @DisplayName("listSearchIndexes should send the store and metadata and return the names") + public void listSearchIndexesTest() { + ArgumentCaptor captor = + ArgumentCaptor.forClass(DaprSearchProtos.ListIndexesRequestAlpha1.class); + doAnswer(respond(DaprSearchProtos.ListIndexesResponseAlpha1.newBuilder() + .addIndexes("books") + .addIndexes("films") + .build())).when(daprStub).listIndexesAlpha1(captor.capture(), any()); + + List indexes = previewClient.listSearchIndexes(STORE, Map.of("prefix", "b")).block(TIMEOUT); + + assertEquals(STORE, captor.getValue().getStoreName()); + assertEquals(Map.of("prefix", "b"), captor.getValue().getMetadataMap()); + assertEquals(List.of("books", "films"), indexes); + } + + @Test + @DisplayName("listSearchIndexes without metadata should send the store only") + public void listSearchIndexesWithoutMetadataTest() { + ArgumentCaptor captor = + ArgumentCaptor.forClass(DaprSearchProtos.ListIndexesRequestAlpha1.class); + doAnswer(respond(DaprSearchProtos.ListIndexesResponseAlpha1.getDefaultInstance())) + .when(daprStub).listIndexesAlpha1(captor.capture(), any()); + + assertTrue(previewClient.listSearchIndexes(STORE).block(TIMEOUT).isEmpty()); + + assertEquals(STORE, captor.getValue().getStoreName()); + assertTrue(captor.getValue().getMetadataMap().isEmpty()); + } + + @Test + @DisplayName("deleteSearchIndex should send the names and metadata, with and without the overload") + public void deleteSearchIndexTest() { + ArgumentCaptor captor = + ArgumentCaptor.forClass(DaprSearchProtos.DeleteIndexRequestAlpha1.class); + doAnswer(respond(Empty.getDefaultInstance())).when(daprStub).deleteIndexAlpha1(captor.capture(), any()); + + previewClient.deleteSearchIndex(STORE, INDEX, Map.of("force", "true")).block(TIMEOUT); + assertEquals(STORE, captor.getValue().getStoreName()); + assertEquals(INDEX, captor.getValue().getIndex()); + assertEquals(Map.of("force", "true"), captor.getValue().getMetadataMap()); + + previewClient.deleteSearchIndex(STORE, INDEX).block(TIMEOUT); + assertEquals(STORE, captor.getValue().getStoreName()); + assertEquals(INDEX, captor.getValue().getIndex()); + assertTrue(captor.getValue().getMetadataMap().isEmpty()); + } + + @Test + @DisplayName("every call should reject a null or blank store or index name") + public void invalidNamesTest() { + List documents = List.of(new SearchDocument("a", DUNE)); + List ids = List.of("a"); + for (String blank : Arrays.asList(null, "", " ")) { + assertRejected(() -> previewClient.createSearchIndex(blank, INDEX)); + assertRejected(() -> previewClient.createSearchIndex(STORE, blank)); + assertRejected(() -> previewClient.getSearchIndex(blank, INDEX)); + assertRejected(() -> previewClient.getSearchIndex(STORE, blank)); + assertRejected(() -> previewClient.listSearchIndexes(blank)); + assertRejected(() -> previewClient.deleteSearchIndex(blank, INDEX)); + assertRejected(() -> previewClient.deleteSearchIndex(STORE, blank)); + assertRejected(() -> previewClient.indexSearchDocuments( + new IndexSearchDocumentsRequestAlpha1(blank, INDEX, documents))); + assertRejected(() -> previewClient.indexSearchDocuments( + new IndexSearchDocumentsRequestAlpha1(STORE, blank, documents))); + assertRejected(() -> previewClient.getSearchDocuments(new GetSearchDocumentsRequestAlpha1(blank, INDEX, ids))); + assertRejected(() -> previewClient.getSearchDocuments(new GetSearchDocumentsRequestAlpha1(STORE, blank, ids))); + assertRejected(() -> previewClient.deleteSearchDocuments( + new DeleteSearchDocumentsRequestAlpha1(blank, INDEX, ids))); + assertRejected(() -> previewClient.deleteSearchDocuments( + new DeleteSearchDocumentsRequestAlpha1(STORE, blank, ids))); + assertRejected(() -> previewClient.search(new SearchRequestAlpha1(blank, INDEX, SearchQuery.matchAll()))); + assertRejected(() -> previewClient.search(new SearchRequestAlpha1(STORE, blank, SearchQuery.matchAll()))); + } + verify(daprStub, never()).createIndexAlpha1(any(), any()); + verify(daprStub, never()).getIndexAlpha1(any(), any()); + verify(daprStub, never()).listIndexesAlpha1(any(), any()); + verify(daprStub, never()).deleteIndexAlpha1(any(), any()); + verify(daprStub, never()).indexDocumentsAlpha1(any(), any()); + verify(daprStub, never()).getDocumentsAlpha1(any(), any()); + verify(daprStub, never()).deleteDocumentsAlpha1(any(), any()); + verify(daprStub, never()).searchAlpha1(any(), any()); + } + + @Test + @DisplayName("every RPC should surface a sidecar error with its gRPC code") + public void sidecarErrorTest() { + Answer notFound = invocation -> { + StreamObserver observer = (StreamObserver) invocation.getArguments()[1]; + observer.onError(newStatusRuntimeException("NOT_FOUND", "index books not found")); + return null; + }; + doAnswer(notFound).when(daprStub).createIndexAlpha1(any(), any()); + doAnswer(notFound).when(daprStub).getIndexAlpha1(any(), any()); + doAnswer(notFound).when(daprStub).listIndexesAlpha1(any(), any()); + doAnswer(notFound).when(daprStub).deleteIndexAlpha1(any(), any()); + doAnswer(notFound).when(daprStub).indexDocumentsAlpha1(any(), any()); + doAnswer(notFound).when(daprStub).getDocumentsAlpha1(any(), any()); + doAnswer(notFound).when(daprStub).deleteDocumentsAlpha1(any(), any()); + doAnswer(notFound).when(daprStub).searchAlpha1(any(), any()); + List ids = List.of("a"); + List>> calls = List.of( + () -> previewClient.createSearchIndex(STORE, INDEX), + () -> previewClient.getSearchIndex(STORE, INDEX), + () -> previewClient.listSearchIndexes(STORE), + () -> previewClient.deleteSearchIndex(STORE, INDEX), + () -> previewClient.indexSearchDocuments( + new IndexSearchDocumentsRequestAlpha1(STORE, INDEX, List.of(new SearchDocument("a", DUNE)))), + () -> previewClient.getSearchDocuments(new GetSearchDocumentsRequestAlpha1(STORE, INDEX, ids)), + () -> previewClient.deleteSearchDocuments(new DeleteSearchDocumentsRequestAlpha1(STORE, INDEX, ids)), + () -> previewClient.search(new SearchRequestAlpha1(STORE, INDEX, null))); + + for (Supplier> call : calls) { + DaprException exception = assertThrows(DaprException.class, () -> call.get().block(TIMEOUT)); + assertEquals("NOT_FOUND", exception.getErrorCode()); + } + } + + @Test + @DisplayName("indexSearchDocuments should send every field and map the reply") + public void indexSearchDocumentsTest() { + ArgumentCaptor captor = + ArgumentCaptor.forClass(DaprSearchProtos.IndexDocumentsRequestAlpha1.class); + doAnswer(respond(DaprSearchProtos.IndexDocumentsResponseAlpha1.newBuilder() + .setAck(DaprSearchProtos.IndexAck.INDEX_ACK_QUEUED) + .build())).when(daprStub).indexDocumentsAlpha1(captor.capture(), any()); + + IndexSearchDocumentsResponseAlpha1 response = previewClient.indexSearchDocuments( + new IndexSearchDocumentsRequestAlpha1(STORE, INDEX, List.of( + new SearchDocument("dune", DUNE).setMetadata(Map.of("source", "library")), + new SearchDocument("hobbit", "{}".getBytes(StandardCharsets.UTF_8)))) + .setMetadata(Map.of("batch", "1")) + .setOptions(IndexingOptions.waitForCompletion( + Duration.ofSeconds(3, 500), IndexingWaitTimeoutAction.FAIL_REQUEST))).block(TIMEOUT); + + DaprSearchProtos.IndexDocumentsRequestAlpha1 sent = captor.getValue(); + assertEquals(STORE, sent.getStoreName()); + assertEquals(INDEX, sent.getIndex()); + assertEquals(Map.of("batch", "1"), sent.getMetadataMap()); + assertEquals(2, sent.getDocumentsCount()); + assertEquals("dune", sent.getDocuments(0).getId()); + assertEquals(ByteString.copyFrom(DUNE), sent.getDocuments(0).getContent()); + assertEquals(Map.of("source", "library"), sent.getDocuments(0).getMetadataMap()); + assertEquals("hobbit", sent.getDocuments(1).getId()); + assertTrue(sent.getDocuments(1).getMetadataMap().isEmpty()); + assertEquals(DaprSearchProtos.IndexingMode.INDEXING_MODE_WAIT_FOR_COMPLETION, sent.getOptions().getMode()); + assertEquals(3, sent.getOptions().getWaitTimeout().getSeconds()); + assertEquals(500, sent.getOptions().getWaitTimeout().getNanos()); + assertEquals(DaprSearchProtos.IndexingWaitTimeoutAction.INDEXING_WAIT_TIMEOUT_ACTION_FAIL_REQUEST, + sent.getOptions().getOnWaitTimeout()); + assertEquals(IndexAck.QUEUED, response.getAck()); + assertTrue(response.getFailedItems().isEmpty()); + } + + @Test + @DisplayName("indexSearchDocuments should omit the options and metadata when they are not set") + public void indexSearchDocumentsDefaultsTest() { + ArgumentCaptor captor = + ArgumentCaptor.forClass(DaprSearchProtos.IndexDocumentsRequestAlpha1.class); + doAnswer(respond(completed())).when(daprStub).indexDocumentsAlpha1(captor.capture(), any()); + + IndexSearchDocumentsResponseAlpha1 response = previewClient.indexSearchDocuments( + new IndexSearchDocumentsRequestAlpha1(STORE, INDEX, List.of(new SearchDocument("dune", DUNE)))).block(TIMEOUT); + + assertFalse(captor.getValue().hasOptions()); + assertTrue(captor.getValue().getMetadataMap().isEmpty()); + assertEquals(IndexAck.COMPLETED, response.getAck()); + } + + @Test + @DisplayName("indexSearchDocuments should leave an empty list to the sidecar") + public void indexSearchDocumentsEmptyListTest() { + ArgumentCaptor captor = + ArgumentCaptor.forClass(DaprSearchProtos.IndexDocumentsRequestAlpha1.class); + doAnswer(respond(completed())).when(daprStub).indexDocumentsAlpha1(captor.capture(), any()); + + previewClient.indexSearchDocuments(new IndexSearchDocumentsRequestAlpha1(STORE, INDEX, List.of())).block(TIMEOUT); + + assertEquals(0, captor.getValue().getDocumentsCount()); + } + + @Test + @DisplayName("indexSearchDocuments should send each indexing mode") + public void indexSearchDocumentsIndexingModesTest() { + ArgumentCaptor captor = + ArgumentCaptor.forClass(DaprSearchProtos.IndexDocumentsRequestAlpha1.class); + doAnswer(respond(completed())).when(daprStub).indexDocumentsAlpha1(captor.capture(), any()); + List documents = List.of(new SearchDocument("dune", DUNE)); + + previewClient.indexSearchDocuments(new IndexSearchDocumentsRequestAlpha1(STORE, INDEX, documents) + .setOptions(IndexingOptions.returnOnAcceptance())).block(TIMEOUT); + assertEquals(DaprSearchProtos.IndexingMode.INDEXING_MODE_RETURN_ON_ACCEPTANCE, + captor.getValue().getOptions().getMode()); + assertFalse(captor.getValue().getOptions().hasWaitTimeout()); + + previewClient.indexSearchDocuments(new IndexSearchDocumentsRequestAlpha1(STORE, INDEX, documents) + .setOptions(IndexingOptions.waitForCompletion( + Duration.ofSeconds(1), IndexingWaitTimeoutAction.CONTINUE_ASYNC))).block(TIMEOUT); + assertEquals(DaprSearchProtos.IndexingWaitTimeoutAction.INDEXING_WAIT_TIMEOUT_ACTION_CONTINUE_ASYNC, + captor.getValue().getOptions().getOnWaitTimeout()); + } + + @Test + @DisplayName("indexSearchDocuments should return per-item failures with their code and details") + public void indexSearchDocumentsFailedItemsTest() { + doAnswer(respond(DaprSearchProtos.IndexDocumentsResponseAlpha1.newBuilder() + .setAck(DaprSearchProtos.IndexAck.INDEX_ACK_COMPLETED) + .addFailedItems(DaprSearchProtos.FailedItem.newBuilder() + .setId("dune") + .setError(Status.newBuilder() + .setCode(Code.INVALID_ARGUMENT_VALUE) + .setMessage("content must be a JSON object") + .addDetails(Any.pack(ErrorInfo.newBuilder().setReason("INVALID_CONTENT").build())))) + .build())).when(daprStub).indexDocumentsAlpha1(any(), any()); + + IndexSearchDocumentsResponseAlpha1 response = previewClient.indexSearchDocuments( + new IndexSearchDocumentsRequestAlpha1(STORE, INDEX, List.of( + new SearchDocument("dune", "not json".getBytes(StandardCharsets.UTF_8))))).block(TIMEOUT); + + assertEquals(IndexAck.COMPLETED, response.getAck()); + assertEquals(1, response.getFailedItems().size()); + assertEquals("dune", response.getFailedItems().get(0).getId()); + DaprException error = response.getFailedItems().get(0).getError(); + assertEquals("INVALID_ARGUMENT", error.getErrorCode()); + assertEquals("INVALID_ARGUMENT: content must be a JSON object", error.getMessage()); + assertEquals("INVALID_CONTENT", + error.getErrorDetails().get(DaprErrorDetails.ErrorDetailType.ERROR_INFO, "reason", TypeRef.STRING)); + } + + @Test + @DisplayName("indexSearchDocuments should fail on a failed item with an OK status code") + public void indexSearchDocumentsOkFailedItemTest() { + doAnswer(respond(DaprSearchProtos.IndexDocumentsResponseAlpha1.newBuilder() + .setAck(DaprSearchProtos.IndexAck.INDEX_ACK_COMPLETED) + .addFailedItems(DaprSearchProtos.FailedItem.newBuilder() + .setId("dune") + .setError(Status.newBuilder().setCode(Code.OK_VALUE))) + .build())).when(daprStub).indexDocumentsAlpha1(any(), any()); + + assertThrowsDaprException("INTERNAL", () -> previewClient.indexSearchDocuments( + new IndexSearchDocumentsRequestAlpha1(STORE, INDEX, List.of(new SearchDocument("dune", DUNE)))).block(TIMEOUT)); + } + + @Test + @DisplayName("indexSearchDocuments should reject missing or invalid documents and options") + public void indexSearchDocumentsInvalidArgumentsTest() { + List valid = List.of(new SearchDocument("dune", DUNE)); + assertRejected(() -> previewClient.indexSearchDocuments(null)); + assertRejected(() -> previewClient.indexSearchDocuments(new IndexSearchDocumentsRequestAlpha1(STORE, INDEX, null))); + assertRejected(() -> previewClient.indexSearchDocuments(new IndexSearchDocumentsRequestAlpha1( + STORE, INDEX, Collections.singletonList(null)))); + for (String blank : Arrays.asList(null, "", " ")) { + assertRejected(() -> previewClient.indexSearchDocuments(new IndexSearchDocumentsRequestAlpha1( + STORE, INDEX, List.of(new SearchDocument(blank, DUNE))))); + } + assertRejected(() -> previewClient.indexSearchDocuments(new IndexSearchDocumentsRequestAlpha1( + STORE, INDEX, List.of(new SearchDocument("dune", null))))); + assertRejected(() -> previewClient.indexSearchDocuments(new IndexSearchDocumentsRequestAlpha1(STORE, INDEX, valid) + .setOptions(IndexingOptions.waitForCompletion(Duration.ZERO, IndexingWaitTimeoutAction.FAIL_REQUEST)))); + assertRejected(() -> previewClient.indexSearchDocuments(new IndexSearchDocumentsRequestAlpha1(STORE, INDEX, valid) + .setOptions(IndexingOptions.waitForCompletion(Duration.ofSeconds(1), null)))); + verify(daprStub, never()).indexDocumentsAlpha1(any(), any()); + } + + @Test + @DisplayName("getSearchDocuments should send every field and keep the reply's order") + public void getSearchDocumentsTest() { + ArgumentCaptor captor = + ArgumentCaptor.forClass(DaprSearchProtos.GetDocumentsRequestAlpha1.class); + doAnswer(respond(DaprSearchProtos.GetDocumentsResponseAlpha1.newBuilder() + .addDocuments(DaprSearchProtos.SearchDocument.newBuilder() + .setId("dune") + .setContent(ByteString.copyFrom(DUNE)) + .putMetadata("source", "library")) + .addDocuments(DaprSearchProtos.SearchDocument.newBuilder().setId("hobbit")) + .build())).when(daprStub).getDocumentsAlpha1(captor.capture(), any()); + + List documents = previewClient.getSearchDocuments( + new GetSearchDocumentsRequestAlpha1(STORE, INDEX, List.of("dune", "missing", "hobbit")) + .setIncludeContent(true) + .setMetadata(Map.of("consistency", "strong"))).block(TIMEOUT); + + DaprSearchProtos.GetDocumentsRequestAlpha1 sent = captor.getValue(); + assertEquals(STORE, sent.getStoreName()); + assertEquals(INDEX, sent.getIndex()); + assertEquals(List.of("dune", "missing", "hobbit"), sent.getIdsList()); + assertTrue(sent.hasIncludeContent()); + assertTrue(sent.getIncludeContent()); + assertEquals(Map.of("consistency", "strong"), sent.getMetadataMap()); + assertEquals(2, documents.size()); + assertEquals("dune", documents.get(0).getId()); + assertArrayEquals(DUNE, documents.get(0).getContent()); + assertEquals(Map.of("source", "library"), documents.get(0).getMetadata()); + assertEquals("hobbit", documents.get(1).getId()); + assertNull(documents.get(1).getContent()); + assertNull(documents.get(1).getMetadata()); + } + + @Test + @DisplayName("getSearchDocuments should omit the include flag when it is not set") + public void getSearchDocumentsIncludeContentUnsetTest() { + ArgumentCaptor captor = + ArgumentCaptor.forClass(DaprSearchProtos.GetDocumentsRequestAlpha1.class); + doAnswer(respond(DaprSearchProtos.GetDocumentsResponseAlpha1.getDefaultInstance())) + .when(daprStub).getDocumentsAlpha1(captor.capture(), any()); + + previewClient.getSearchDocuments(new GetSearchDocumentsRequestAlpha1(STORE, INDEX, List.of("dune"))).block(TIMEOUT); + + assertFalse(captor.getValue().hasIncludeContent()); + } + + @Test + @DisplayName("deleteSearchDocuments should send every field and return the acknowledgement") + public void deleteSearchDocumentsTest() { + ArgumentCaptor captor = + ArgumentCaptor.forClass(DaprSearchProtos.DeleteDocumentsRequestAlpha1.class); + doAnswer(respond(DaprSearchProtos.DeleteDocumentsResponseAlpha1.newBuilder() + .setAck(DaprSearchProtos.IndexAck.INDEX_ACK_QUEUED) + .build())).when(daprStub).deleteDocumentsAlpha1(captor.capture(), any()); + + IndexAck ack = previewClient.deleteSearchDocuments( + new DeleteSearchDocumentsRequestAlpha1(STORE, INDEX, List.of("dune", "hobbit")) + .setMetadata(Map.of("batch", "1")) + .setOptions(IndexingOptions.returnOnAcceptance())).block(TIMEOUT); + + DaprSearchProtos.DeleteDocumentsRequestAlpha1 sent = captor.getValue(); + assertEquals(STORE, sent.getStoreName()); + assertEquals(INDEX, sent.getIndex()); + assertEquals(List.of("dune", "hobbit"), sent.getIdsList()); + assertEquals(Map.of("batch", "1"), sent.getMetadataMap()); + assertEquals(DaprSearchProtos.IndexingMode.INDEXING_MODE_RETURN_ON_ACCEPTANCE, sent.getOptions().getMode()); + assertEquals(IndexAck.QUEUED, ack); + } + + @Test + @DisplayName("deleteSearchDocuments should omit the options when they are not set") + public void deleteSearchDocumentsDefaultsTest() { + ArgumentCaptor captor = + ArgumentCaptor.forClass(DaprSearchProtos.DeleteDocumentsRequestAlpha1.class); + doAnswer(respond(DaprSearchProtos.DeleteDocumentsResponseAlpha1.newBuilder() + .setAck(DaprSearchProtos.IndexAck.INDEX_ACK_COMPLETED) + .build())).when(daprStub).deleteDocumentsAlpha1(captor.capture(), any()); + + IndexAck ack = previewClient.deleteSearchDocuments( + new DeleteSearchDocumentsRequestAlpha1(STORE, INDEX, List.of("dune"))).block(TIMEOUT); + + assertFalse(captor.getValue().hasOptions()); + assertEquals(IndexAck.COMPLETED, ack); + } + + @Test + @DisplayName("document reads and deletes should reject missing or blank IDs") + public void documentIdsInvalidArgumentsTest() { + assertRejected(() -> previewClient.getSearchDocuments(null)); + assertRejected(() -> previewClient.deleteSearchDocuments(null)); + assertRejected(() -> previewClient.getSearchDocuments(new GetSearchDocumentsRequestAlpha1(STORE, INDEX, null))); + assertRejected(() -> previewClient.deleteSearchDocuments( + new DeleteSearchDocumentsRequestAlpha1(STORE, INDEX, null))); + for (String blank : Arrays.asList(null, "", " ")) { + List ids = Arrays.asList("dune", blank); + assertRejected(() -> previewClient.getSearchDocuments(new GetSearchDocumentsRequestAlpha1(STORE, INDEX, ids))); + assertRejected(() -> previewClient.deleteSearchDocuments( + new DeleteSearchDocumentsRequestAlpha1(STORE, INDEX, ids))); + } + assertRejected(() -> previewClient.deleteSearchDocuments( + new DeleteSearchDocumentsRequestAlpha1(STORE, INDEX, List.of("dune")) + .setOptions(IndexingOptions.waitForCompletion(null, IndexingWaitTimeoutAction.FAIL_REQUEST)))); + verify(daprStub, never()).getDocumentsAlpha1(any(), any()); + verify(daprStub, never()).deleteDocumentsAlpha1(any(), any()); + } + + @Test + @DisplayName("writes should fail on an invalid or unspecified acknowledgement") + public void invalidAckResponseTest() { + doAnswer(respond(DaprSearchProtos.IndexDocumentsResponseAlpha1.getDefaultInstance())) + .when(daprStub).indexDocumentsAlpha1(any(), any()); + doAnswer(respond(DaprSearchProtos.DeleteDocumentsResponseAlpha1.newBuilder().setAckValue(9).build())) + .when(daprStub).deleteDocumentsAlpha1(any(), any()); + + assertThrowsDaprException("INTERNAL", () -> previewClient.indexSearchDocuments( + new IndexSearchDocumentsRequestAlpha1(STORE, INDEX, List.of(new SearchDocument("a", DUNE)))).block(TIMEOUT)); + assertThrowsDaprException("INTERNAL", () -> previewClient.deleteSearchDocuments( + new DeleteSearchDocumentsRequestAlpha1(STORE, INDEX, List.of("a"))).block(TIMEOUT)); + } + + @Test + @DisplayName("search by text should send every query field and map the reply") + public void searchByTextTest() { + ArgumentCaptor captor = + ArgumentCaptor.forClass(DaprSearchProtos.SearchRequestAlpha1.class); + doAnswer(respond(DaprSearchProtos.SearchResponseAlpha1.newBuilder() + .addHits(DaprSearchProtos.SearchHit.newBuilder() + .setDocument(DaprSearchProtos.SearchDocument.newBuilder() + .setId("dune") + .setContent(ByteString.copyFrom(DUNE)) + .putMetadata("source", "library")) + .setScore(1.5) + .putHighlights("title", "Dune")) + .setTotalHits(7) + .setContinuationToken("next") + .setTotalHitsRelation(DaprSearchProtos.TotalHitsRelation.TOTAL_HITS_RELATION_EXACT) + .build())).when(daprStub).searchAlpha1(captor.capture(), any()); + + SearchResponseAlpha1 response = previewClient.search(new SearchRequestAlpha1(STORE, INDEX, + SearchQuery.byText("dune") + .setFilter(Map.of("genre", Map.of("$in", new LinkedHashSet<>(List.of("scifi", "fantasy"))))) + .setTopK(5) + .setContinuationToken("first") + .setReturnFields(List.of("title")) + .setIncludeContent(true) + .setSearchFields(List.of("title", "summary")) + .setSort(List.of(SortClause.desc("year"), SortClause.asc("title"))) + .setHighlightFields(List.of("summary"))) + .setMetadata(Map.of("consistency", "strong"))).block(TIMEOUT); + + DaprSearchProtos.SearchRequestAlpha1 sent = captor.getValue(); + assertEquals(STORE, sent.getStoreName()); + assertEquals(INDEX, sent.getIndex()); + assertEquals(Map.of("consistency", "strong"), sent.getMetadataMap()); + DaprSearchProtos.SearchQuery query = sent.getQuery(); + assertEquals(DaprSearchProtos.SearchQuery.KindCase.TEXT, query.getKindCase()); + assertEquals("dune", query.getText()); + assertEquals(List.of(string("scifi"), string("fantasy")), + query.getFilter().getFieldsOrThrow("genre").getStructValue().getFieldsOrThrow("$in") + .getListValue().getValuesList()); + assertEquals(5, query.getTopK()); + assertEquals("first", query.getContinuationToken()); + assertEquals(List.of("title"), query.getReturnFieldsList()); + assertTrue(query.getIncludeContent()); + assertEquals(List.of("title", "summary"), query.getSearchFieldsList()); + assertEquals(2, query.getSortCount()); + assertEquals("year", query.getSort(0).getField()); + assertEquals(DaprSearchProtos.SortOrder.SORT_ORDER_DESC, query.getSort(0).getOrder()); + assertEquals("title", query.getSort(1).getField()); + assertEquals(DaprSearchProtos.SortOrder.SORT_ORDER_ASC, query.getSort(1).getOrder()); + assertEquals(List.of("summary"), query.getHighlightFieldsList()); + + assertEquals(1, response.getHits().size()); + SearchHit hit = response.getHits().get(0); + assertEquals("dune", hit.getDocument().getId()); + assertArrayEquals(DUNE, hit.getDocument().getContent()); + assertEquals(Map.of("source", "library"), hit.getDocument().getMetadata()); + assertEquals(1.5, hit.getScore()); + assertEquals(Map.of("title", "Dune"), hit.getHighlights()); + assertEquals(7L, response.getTotalHits()); + assertEquals("next", response.getContinuationToken()); + assertEquals(TotalHitsRelation.EXACT, response.getTotalHitsRelation()); + } + + @Test + @DisplayName("search by native query should send the query and no text") + public void searchByNativeTest() { + DaprSearchProtos.SearchRequestAlpha1 sent = + sentBy(SearchQuery.byNative(Map.of("match", Map.of("title", "dune")))); + + assertEquals(DaprSearchProtos.SearchQuery.KindCase.NATIVE, sent.getQuery().getKindCase()); + assertEquals(string("dune"), + sent.getQuery().getNative().getFieldsOrThrow("match").getStructValue().getFieldsOrThrow("title")); + } + + @Test + @DisplayName("search matching everything should send neither text nor a native query, and keep the filter") + public void searchMatchAllTest() { + DaprSearchProtos.SearchRequestAlpha1 sent = sentBy(SearchQuery.matchAll().setFilter(Map.of("genre", "scifi"))); + + assertTrue(sent.hasQuery()); + assertEquals(DaprSearchProtos.SearchQuery.KindCase.KIND_NOT_SET, sent.getQuery().getKindCase()); + assertEquals(string("scifi"), sent.getQuery().getFilter().getFieldsOrThrow("genre")); + } + + @Test + @DisplayName("search without a query should omit it") + public void searchWithoutQueryTest() { + assertFalse(sentBy(null).hasQuery()); + } + + @Test + @DisplayName("search should omit the query fields that are not set") + public void searchQueryDefaultsTest() { + DaprSearchProtos.SearchQuery query = sentBy(SearchQuery.byText("dune")).getQuery(); + + assertFalse(query.hasFilter()); + assertFalse(query.hasTopK()); + assertFalse(query.hasContinuationToken()); + assertFalse(query.hasIncludeContent()); + assertEquals(0, query.getReturnFieldsCount()); + assertEquals(0, query.getSearchFieldsCount()); + assertEquals(0, query.getSortCount()); + assertEquals(0, query.getHighlightFieldsCount()); + } + + @Test + @DisplayName("search should send query fields set to their zero values") + public void searchExplicitZeroValuesTest() { + DaprSearchProtos.SearchQuery query = sentBy(SearchQuery.matchAll() + .setTopK(0) + .setContinuationToken("") + .setIncludeContent(false)).getQuery(); + + assertTrue(query.hasTopK()); + assertEquals(0, query.getTopK()); + assertTrue(query.hasContinuationToken()); + assertTrue(query.hasIncludeContent()); + assertFalse(query.getIncludeContent()); + } + + @Test + @DisplayName("search should map a reply with no total, token, content or highlights") + public void searchResponseDefaultsTest() { + doAnswer(respond(DaprSearchProtos.SearchResponseAlpha1.newBuilder() + .addHits(DaprSearchProtos.SearchHit.newBuilder() + .setDocument(DaprSearchProtos.SearchDocument.newBuilder().setId("dune"))) + .build())).when(daprStub).searchAlpha1(any(), any()); + + SearchResponseAlpha1 response = previewClient.search(new SearchRequestAlpha1(STORE, INDEX, null)).block(TIMEOUT); + + assertNull(response.getTotalHits()); + assertNull(response.getContinuationToken()); + assertEquals(TotalHitsRelation.UNSPECIFIED, response.getTotalHitsRelation()); + SearchHit hit = response.getHits().get(0); + assertNull(hit.getDocument().getContent()); + assertNull(hit.getDocument().getMetadata()); + assertTrue(hit.getHighlights().isEmpty()); + } + + @Test + @DisplayName("search should keep a total of zero apart from a missing total") + public void searchZeroTotalTest() { + doAnswer(respond(DaprSearchProtos.SearchResponseAlpha1.newBuilder() + .setTotalHits(0) + .setTotalHitsRelation(DaprSearchProtos.TotalHitsRelation.TOTAL_HITS_RELATION_EXACT) + .build())).when(daprStub).searchAlpha1(any(), any()); + + assertEquals(0L, previewClient.search(new SearchRequestAlpha1(STORE, INDEX, null)).block(TIMEOUT).getTotalHits()); + } + + @Test + @DisplayName("search should map every total hits relation") + public void searchTotalHitsRelationsTest() { + Map relations = Map.of( + DaprSearchProtos.TotalHitsRelation.TOTAL_HITS_RELATION_EXACT, TotalHitsRelation.EXACT, + DaprSearchProtos.TotalHitsRelation.TOTAL_HITS_RELATION_LOWER_BOUND, TotalHitsRelation.LOWER_BOUND, + DaprSearchProtos.TotalHitsRelation.TOTAL_HITS_RELATION_ESTIMATE, TotalHitsRelation.ESTIMATE); + relations.forEach((proto, expected) -> { + doAnswer(respond(DaprSearchProtos.SearchResponseAlpha1.newBuilder() + .setTotalHits(1) + .setTotalHitsRelation(proto) + .build())).when(daprStub).searchAlpha1(any(), any()); + + assertEquals(expected, previewClient.search(new SearchRequestAlpha1(STORE, INDEX, null)).block(TIMEOUT) + .getTotalHitsRelation()); + }); + } + + @Test + @DisplayName("search should keep the hits when the total hits relation is unknown") + public void searchUnknownTotalHitsRelationTest() { + doAnswer(respond(DaprSearchProtos.SearchResponseAlpha1.newBuilder() + .addHits(DaprSearchProtos.SearchHit.newBuilder() + .setDocument(DaprSearchProtos.SearchDocument.newBuilder().setId("dune"))) + .setTotalHits(1) + .setTotalHitsRelationValue(42) + .build())).when(daprStub).searchAlpha1(any(), any()); + + SearchResponseAlpha1 response = previewClient.search(new SearchRequestAlpha1(STORE, INDEX, null)).block(TIMEOUT); + + assertEquals("dune", response.getHits().get(0).getDocument().getId()); + assertEquals(1L, response.getTotalHits()); + assertEquals(TotalHitsRelation.UNSPECIFIED, response.getTotalHitsRelation()); + } + + @Test + @DisplayName("search should fail on a hit without a document") + public void searchHitWithoutDocumentTest() { + doAnswer(respond(DaprSearchProtos.SearchResponseAlpha1.newBuilder() + .addHits(DaprSearchProtos.SearchHit.newBuilder().setScore(1)) + .build())).when(daprStub).searchAlpha1(any(), any()); + + assertThrowsDaprException("INTERNAL", + () -> previewClient.search(new SearchRequestAlpha1(STORE, INDEX, null)).block(TIMEOUT)); + } + + @Test + @DisplayName("search should reject an empty text or native query rather than match every document") + public void searchEmptyQueryTest() { + assertRejected(() -> previewClient.search(null)); + for (String blank : Arrays.asList(null, "", " ")) { + assertRejected(() -> previewClient.search(new SearchRequestAlpha1(STORE, INDEX, SearchQuery.byText(blank)))); + } + assertRejected(() -> previewClient.search(new SearchRequestAlpha1(STORE, INDEX, SearchQuery.byNative(null)))); + assertRejected(() -> previewClient.search(new SearchRequestAlpha1(STORE, INDEX, SearchQuery.byNative(Map.of())))); + verify(daprStub, never()).searchAlpha1(any(), any()); + } + + @Test + @DisplayName("search should reject invalid query fields") + public void searchInvalidQueryFieldsTest() { + assertRejected(() -> previewClient.search(new SearchRequestAlpha1(STORE, INDEX, + SearchQuery.matchAll().setTopK(-1)))); + assertRejected(() -> previewClient.search(new SearchRequestAlpha1(STORE, INDEX, + SearchQuery.matchAll().setSort(Collections.singletonList(null))))); + for (String blank : Arrays.asList(null, "", " ")) { + List fields = Arrays.asList("title", blank); + assertRejected(() -> previewClient.search(new SearchRequestAlpha1(STORE, INDEX, + SearchQuery.matchAll().setSort(List.of(SortClause.asc(blank)))))); + assertRejected(() -> previewClient.search(new SearchRequestAlpha1(STORE, INDEX, + SearchQuery.matchAll().setReturnFields(fields)))); + assertRejected(() -> previewClient.search(new SearchRequestAlpha1(STORE, INDEX, + SearchQuery.matchAll().setSearchFields(fields)))); + assertRejected(() -> previewClient.search(new SearchRequestAlpha1(STORE, INDEX, + SearchQuery.matchAll().setHighlightFields(fields)))); + } + verify(daprStub, never()).searchAlpha1(any(), any()); + } + + @Test + @DisplayName("search should reject filter and native values it cannot send faithfully") + public void searchUnsupportedValuesTest() { + assertRejected(() -> previewClient.search(new SearchRequestAlpha1(STORE, INDEX, + SearchQuery.matchAll().setFilter(Map.of("since", Duration.ofDays(1)))))); + assertRejected(() -> previewClient.search(new SearchRequestAlpha1(STORE, INDEX, + SearchQuery.byNative(Map.of("terms", new String[] {"dune"}))))); + assertRejected(() -> previewClient.search(new SearchRequestAlpha1(STORE, INDEX, + SearchQuery.byNative(Map.of("nested", Map.of(1, "dune")))))); + verify(daprStub, never()).searchAlpha1(any(), any()); + } + + @Test + @DisplayName("search should send numbers as doubles, without changing them") + public void searchNumbersTest() { + DaprSearchProtos.SearchQuery query = sentBy(SearchQuery.matchAll().setFilter(Map.of( + "int", 5, + "long", 1L << 53, + "float", 1.1f, + "double", 2.5d, + "decimal", new BigDecimal("1.1"), + "big", BigInteger.valueOf(1965)))).getQuery(); + + Map sent = query.getFilter().getFieldsMap(); + assertEquals(5d, sent.get("int").getNumberValue()); + assertEquals(9007199254740992d, sent.get("long").getNumberValue()); + assertEquals(1.1d, sent.get("float").getNumberValue()); + assertEquals(2.5d, sent.get("double").getNumberValue()); + assertEquals(1.1d, sent.get("decimal").getNumberValue()); + assertEquals(1965d, sent.get("big").getNumberValue()); + } + + @Test + @DisplayName("search should reject numbers that a double cannot hold exactly") + public void searchInexactNumbersTest() { + List numbers = List.of( + (1L << 53) + 1, + Long.MAX_VALUE, + new BigDecimal("0.1000000000000000055"), + BigInteger.TEN.pow(400)); + for (Number number : numbers) { + assertRejected(() -> previewClient.search(new SearchRequestAlpha1(STORE, INDEX, + SearchQuery.matchAll().setFilter(Map.of("id", number))))); + } + verify(daprStub, never()).searchAlpha1(any(), any()); + } + + @Test + @DisplayName("invalid input should fail through the Mono, not when the method is called") + public void validationFailsThroughMonoTest() { + Mono search = previewClient.search(null); + Mono> list = previewClient.listSearchIndexes(null); + + assertThrows(IllegalArgumentException.class, () -> search.block(TIMEOUT)); + assertThrows(IllegalArgumentException.class, () -> list.block(TIMEOUT)); + } + + @Test + @DisplayName("sort clauses should expose their field and direction") + public void sortClauseFactoriesTest() { + assertEquals("title", SortClause.asc("title").getField()); + assertEquals(SortOrder.ASC, SortClause.asc("title").getOrder()); + assertEquals(SortOrder.DESC, SortClause.desc("title").getOrder()); + } +} From 1e0216ffb2cc95fb755afc248f852daae550309b Mon Sep 17 00:00:00 2001 From: Adepu Sri Charan Date: Tue, 10 Nov 2026 15:42:32 +0530 Subject: [PATCH 3/4] fix: accept large numbers that a double holds exactly The check compared a number only with the double's shortest decimal form, so exact values such as 2^60 and Long.MIN_VALUE were rejected. A number is now accepted when it equals either the double's binary value or its decimal form. Adds unit tests for the number conversion in vector and search. Signed-off-by: Adepu Sri Charan --- .../io/dapr/client/SearchVectorHelper.java | 9 ++- .../io/dapr/client/domain/SearchQuery.java | 4 +- .../io/dapr/client/domain/VectorQuery.java | 2 +- .../io/dapr/client/domain/VectorRecord.java | 2 +- .../client/DaprPreviewClientSearchTest.java | 17 ++++- .../client/DaprPreviewClientVectorTest.java | 76 +++++++++++++++++++ 6 files changed, 101 insertions(+), 9 deletions(-) diff --git a/sdk/src/main/java/io/dapr/client/SearchVectorHelper.java b/sdk/src/main/java/io/dapr/client/SearchVectorHelper.java index a7479c3f43..0303810ed6 100644 --- a/sdk/src/main/java/io/dapr/client/SearchVectorHelper.java +++ b/sdk/src/main/java/io/dapr/client/SearchVectorHelper.java @@ -182,7 +182,7 @@ private static Value toValue(Object value) { } // A Struct keeps every number as a double. A number that would change on the way is rejected, because a filter - // holding it would match the wrong documents. + // holding it would match the wrong documents or records. private static double toDouble(Number number) { if (number instanceof Double) { return number.doubleValue(); @@ -192,9 +192,14 @@ private static double toDouble(Number number) { return Double.parseDouble(number.toString()); } double value = number.doubleValue(); - if (Double.isInfinite(value) || new BigDecimal(number.toString()).compareTo(BigDecimal.valueOf(value)) != 0) { + if (!Double.isFinite(value) || !holdsExactly(value, new BigDecimal(number.toString()))) { throw new IllegalArgumentException("The number " + number + " cannot be represented exactly as a double."); } return value; } + + // 2^60 only equals the double's binary value, and BigDecimal("0.1") only equals its decimal form. + private static boolean holdsExactly(double value, BigDecimal number) { + return number.compareTo(new BigDecimal(value)) == 0 || number.compareTo(BigDecimal.valueOf(value)) == 0; + } } diff --git a/sdk/src/main/java/io/dapr/client/domain/SearchQuery.java b/sdk/src/main/java/io/dapr/client/domain/SearchQuery.java index 1aa33f9ee6..87bda9c2ad 100644 --- a/sdk/src/main/java/io/dapr/client/domain/SearchQuery.java +++ b/sdk/src/main/java/io/dapr/client/domain/SearchQuery.java @@ -66,7 +66,7 @@ public static SearchQuery byText(String text) { * *

Values can be null, a Boolean, a Number, a String, a Collection or a Map with string keys. Other types * are rejected with an {@link IllegalArgumentException} when the query is sent, and so are numbers that a double - * cannot hold exactly, such as a long above 2^53. + * cannot hold exactly, such as the long 9007199254740993. * * @param query the native query. Must not be empty. * @return the query @@ -116,7 +116,7 @@ public Map getFilter() { * *

Values can be null, a Boolean, a Number, a String, a Collection or a Map with string keys. Other types * are rejected with an {@link IllegalArgumentException} when the query is sent, and so are numbers that a double - * cannot hold exactly, such as a long above 2^53. + * cannot hold exactly, such as the long 9007199254740993. * * @param filter the filter * @return this query for method chaining diff --git a/sdk/src/main/java/io/dapr/client/domain/VectorQuery.java b/sdk/src/main/java/io/dapr/client/domain/VectorQuery.java index d75787e13f..a89476bf3e 100644 --- a/sdk/src/main/java/io/dapr/client/domain/VectorQuery.java +++ b/sdk/src/main/java/io/dapr/client/domain/VectorQuery.java @@ -108,7 +108,7 @@ public Map getFilter() { * *

Values can be null, a Boolean, a Number, a String, a Collection or a Map with string keys. Other types * are rejected with an {@link IllegalArgumentException} when the query is sent, and so are numbers that a double - * cannot hold exactly, such as a long above 2^53. + * cannot hold exactly, such as the long 9007199254740993. * * @param filter the filter * @return this query for method chaining diff --git a/sdk/src/main/java/io/dapr/client/domain/VectorRecord.java b/sdk/src/main/java/io/dapr/client/domain/VectorRecord.java index e3255a3468..3645ff6b94 100644 --- a/sdk/src/main/java/io/dapr/client/domain/VectorRecord.java +++ b/sdk/src/main/java/io/dapr/client/domain/VectorRecord.java @@ -92,7 +92,7 @@ public Map getMetadata() { * *

Values can be null, a Boolean, a Number, a String, a Collection or a Map with string keys. Other types * are rejected with an {@link IllegalArgumentException} when the record is sent, and so are numbers that a double - * cannot hold exactly, such as a long above 2^53. + * cannot hold exactly, such as the long 9007199254740993. * * @param metadata the metadata * @return this record for method chaining diff --git a/sdk/src/test/java/io/dapr/client/DaprPreviewClientSearchTest.java b/sdk/src/test/java/io/dapr/client/DaprPreviewClientSearchTest.java index 835b7b59d0..ebe8f69673 100644 --- a/sdk/src/test/java/io/dapr/client/DaprPreviewClientSearchTest.java +++ b/sdk/src/test/java/io/dapr/client/DaprPreviewClientSearchTest.java @@ -122,6 +122,11 @@ private static void assertRejected(Supplier> call) { assertThrows(IllegalArgumentException.class, () -> call.get().block(TIMEOUT)); } + private static void assertInexact(Supplier> call) { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> call.get().block(TIMEOUT)); + assertTrue(error.getMessage().contains("cannot be represented exactly"), error.getMessage()); + } + private static Value string(String value) { return Value.newBuilder().setStringValue(value).build(); } @@ -843,8 +848,11 @@ public void searchNumbersTest() { "long", 1L << 53, "float", 1.1f, "double", 2.5d, - "decimal", new BigDecimal("1.1"), - "big", BigInteger.valueOf(1965)))).getQuery(); + "decimal", new BigDecimal("1.10"), + "big", BigInteger.valueOf(1965), + "power", 1L << 60, + "min", Long.MIN_VALUE, + "bigPower", BigInteger.TWO.pow(70)))).getQuery(); Map sent = query.getFilter().getFieldsMap(); assertEquals(5d, sent.get("int").getNumberValue()); @@ -853,6 +861,9 @@ public void searchNumbersTest() { assertEquals(2.5d, sent.get("double").getNumberValue()); assertEquals(1.1d, sent.get("decimal").getNumberValue()); assertEquals(1965d, sent.get("big").getNumberValue()); + assertEquals(0x1p60, sent.get("power").getNumberValue()); + assertEquals(-0x1p63, sent.get("min").getNumberValue()); + assertEquals(0x1p70, sent.get("bigPower").getNumberValue()); } @Test @@ -864,7 +875,7 @@ public void searchInexactNumbersTest() { new BigDecimal("0.1000000000000000055"), BigInteger.TEN.pow(400)); for (Number number : numbers) { - assertRejected(() -> previewClient.search(new SearchRequestAlpha1(STORE, INDEX, + assertInexact(() -> previewClient.search(new SearchRequestAlpha1(STORE, INDEX, SearchQuery.matchAll().setFilter(Map.of("id", number))))); } verify(daprStub, never()).searchAlpha1(any(), any()); diff --git a/sdk/src/test/java/io/dapr/client/DaprPreviewClientVectorTest.java b/sdk/src/test/java/io/dapr/client/DaprPreviewClientVectorTest.java index 9bd579d0cb..77d17758fc 100644 --- a/sdk/src/test/java/io/dapr/client/DaprPreviewClientVectorTest.java +++ b/sdk/src/test/java/io/dapr/client/DaprPreviewClientVectorTest.java @@ -56,6 +56,8 @@ import reactor.core.publisher.Mono; import java.io.IOException; +import java.math.BigDecimal; +import java.math.BigInteger; import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; @@ -463,6 +465,80 @@ public void unsupportedStructValuesTest() { verify(daprStub, never()).queryVectorsAlpha1(any(), any()); } + @Test + @DisplayName("filters and metadata should send numbers as doubles without changing them") + public void structNumbersTest() { + ArgumentCaptor query = + ArgumentCaptor.forClass(DaprVectorProtos.QueryVectorsRequestAlpha1.class); + doAnswer(respond(DaprVectorProtos.QueryVectorsResponseAlpha1.newBuilder() + .setMetric(DaprVectorProtos.DistanceMetric.DISTANCE_METRIC_COSINE) + .build())).when(daprStub).queryVectorsAlpha1(query.capture(), any()); + ArgumentCaptor upsert = + ArgumentCaptor.forClass(DaprVectorProtos.UpsertVectorsRequestAlpha1.class); + doAnswer(respond(DaprVectorProtos.UpsertVectorsResponseAlpha1.newBuilder() + .setAck(DaprSearchProtos.IndexAck.INDEX_ACK_COMPLETED) + .build())).when(daprStub).upsertVectorsAlpha1(upsert.capture(), any()); + Map numbers = Map.of( + "int", 5, + "long", 1L << 53, + "float", 1.1f, + "double", 2.5d, + "decimal", new BigDecimal("1.10"), + "integer", BigInteger.valueOf(1965), + "power", 1L << 60, + "min", Long.MIN_VALUE, + "bigPower", BigInteger.TWO.pow(70), + "nested", Map.of("$in", List.of(1.1f))); + + previewClient.queryVectors(new QueryVectorsRequestAlpha1( + STORE, COLLECTION, VectorQuery.byId("a").setFilter(numbers))).block(); + previewClient.upsertVectors(new UpsertVectorsRequestAlpha1(STORE, COLLECTION, List.of( + new VectorRecord("a", new float[] {1f}).setMetadata(numbers)))).block(); + + assertSentNumbers(query.getValue().getQuery().getFilter()); + assertSentNumbers(upsert.getValue().getRecords(0).getMetadata()); + } + + private static void assertSentNumbers(Struct sent) { + Map fields = sent.getFieldsMap(); + assertEquals(5d, fields.get("int").getNumberValue()); + assertEquals(0x1p53, fields.get("long").getNumberValue()); + assertEquals(1.1d, fields.get("float").getNumberValue()); + assertEquals(2.5d, fields.get("double").getNumberValue()); + assertEquals(1.1d, fields.get("decimal").getNumberValue()); + assertEquals(1965d, fields.get("integer").getNumberValue()); + assertEquals(0x1p60, fields.get("power").getNumberValue()); + assertEquals(-0x1p63, fields.get("min").getNumberValue()); + assertEquals(0x1p70, fields.get("bigPower").getNumberValue()); + assertEquals(1.1d, fields.get("nested").getStructValue().getFieldsOrThrow("$in").getListValue() + .getValues(0).getNumberValue()); + } + + @Test + @DisplayName("filters and metadata should reject numbers that a double cannot hold exactly") + public void inexactStructNumbersTest() { + List numbers = List.of( + (1L << 53) + 1, + Long.MAX_VALUE, + new BigDecimal("0.1000000000000000055"), + BigInteger.TEN.pow(400)); + for (Number number : numbers) { + assertInexact(() -> previewClient.queryVectors(new QueryVectorsRequestAlpha1(STORE, COLLECTION, + VectorQuery.byId("a").setFilter(Map.of("id", number))))); + assertInexact(() -> previewClient.upsertVectors(new UpsertVectorsRequestAlpha1(STORE, COLLECTION, List.of( + new VectorRecord("a", new float[] {1f}).setMetadata(Map.of("id", number)))))); + } + assertInexact(() -> previewClient.queryVectors(new QueryVectorsRequestAlpha1(STORE, COLLECTION, + VectorQuery.byId("a").setFilter(Map.of("id", Map.of("$in", List.of((1L << 53) + 1))))))); + verify(daprStub, never()).upsertVectorsAlpha1(any(), any()); + verify(daprStub, never()).queryVectorsAlpha1(any(), any()); + } + + private static void assertInexact(Supplier> call) { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> call.get().block(TIMEOUT)); + assertTrue(error.getMessage().contains("cannot be represented exactly"), error.getMessage()); + } + @Test @DisplayName("upsertVectors should reject missing records, records without an ID or vector, and bad options") public void upsertVectorsInvalidArgumentsTest() { From af464502d5e9e6af02afbbf5255a91674b5e86c9 Mon Sep 17 00:00:00 2001 From: Adepu Sri Charan Date: Tue, 10 Nov 2026 22:16:24 +0530 Subject: [PATCH 4/4] fix: reject NaN and infinite numbers in filters and metadata The sidecar accepts NaN and the infinities without an error, and a filter holding one matches the wrong records: on the in-memory stores, n < Infinity matched nothing. Double and Float now get the same finite check as the other number types. Signed-off-by: Adepu Sri Charan --- .../io/dapr/client/SearchVectorHelper.java | 17 +++++++------ .../io/dapr/client/domain/SearchQuery.java | 8 +++--- .../io/dapr/client/domain/VectorQuery.java | 4 +-- .../io/dapr/client/domain/VectorRecord.java | 4 +-- .../client/DaprPreviewClientSearchTest.java | 24 ++++++++++++++++++ .../client/DaprPreviewClientVectorTest.java | 25 +++++++++++++++++++ 6 files changed, 66 insertions(+), 16 deletions(-) diff --git a/sdk/src/main/java/io/dapr/client/SearchVectorHelper.java b/sdk/src/main/java/io/dapr/client/SearchVectorHelper.java index 0303810ed6..f26c30426b 100644 --- a/sdk/src/main/java/io/dapr/client/SearchVectorHelper.java +++ b/sdk/src/main/java/io/dapr/client/SearchVectorHelper.java @@ -181,15 +181,16 @@ private static Value toValue(Object value) { + "a Collection or a Map, not " + value.getClass().getName() + "."); } - // A Struct keeps every number as a double. A number that would change on the way is rejected, because a filter - // holding it would match the wrong documents or records. + // A Struct keeps every number as a double. A number that would change on the way, NaN or an infinity is rejected, + // because a filter holding it would match the wrong documents or records. private static double toDouble(Number number) { - if (number instanceof Double) { - return number.doubleValue(); - } - if (number instanceof Float) { - // Through its decimal form, so that 1.1f stays 1.1 rather than becoming 1.100000023841858. - return Double.parseDouble(number.toString()); + if (number instanceof Double || number instanceof Float) { + // A float goes through its decimal form, so that 1.1f stays 1.1 rather than becoming 1.100000023841858. + double value = number instanceof Float ? Double.parseDouble(number.toString()) : number.doubleValue(); + if (!Double.isFinite(value)) { + throw new IllegalArgumentException("Numbers must be finite, not " + number + "."); + } + return value; } double value = number.doubleValue(); if (!Double.isFinite(value) || !holdsExactly(value, new BigDecimal(number.toString()))) { diff --git a/sdk/src/main/java/io/dapr/client/domain/SearchQuery.java b/sdk/src/main/java/io/dapr/client/domain/SearchQuery.java index 87bda9c2ad..6b2b118540 100644 --- a/sdk/src/main/java/io/dapr/client/domain/SearchQuery.java +++ b/sdk/src/main/java/io/dapr/client/domain/SearchQuery.java @@ -65,8 +65,8 @@ public static SearchQuery byText(String text) { * Creates a query in the store's own format, passed to the store as is. Not every store supports this. * *

Values can be null, a Boolean, a Number, a String, a Collection or a Map with string keys. Other types - * are rejected with an {@link IllegalArgumentException} when the query is sent, and so are numbers that a double - * cannot hold exactly, such as the long 9007199254740993. + * are rejected with an {@link IllegalArgumentException} when the query is sent, and so are NaN, + * infinities and numbers that a double cannot hold exactly, such as the long 9007199254740993. * * @param query the native query. Must not be empty. * @return the query @@ -115,8 +115,8 @@ public Map getFilter() { * Sets a filter on document content, written in the Dapr filter format. * *

Values can be null, a Boolean, a Number, a String, a Collection or a Map with string keys. Other types - * are rejected with an {@link IllegalArgumentException} when the query is sent, and so are numbers that a double - * cannot hold exactly, such as the long 9007199254740993. + * are rejected with an {@link IllegalArgumentException} when the query is sent, and so are NaN, + * infinities and numbers that a double cannot hold exactly, such as the long 9007199254740993. * * @param filter the filter * @return this query for method chaining diff --git a/sdk/src/main/java/io/dapr/client/domain/VectorQuery.java b/sdk/src/main/java/io/dapr/client/domain/VectorQuery.java index a89476bf3e..a2cefb1a62 100644 --- a/sdk/src/main/java/io/dapr/client/domain/VectorQuery.java +++ b/sdk/src/main/java/io/dapr/client/domain/VectorQuery.java @@ -107,8 +107,8 @@ public Map getFilter() { * Sets a filter on record metadata, written in the Dapr filter format. * *

Values can be null, a Boolean, a Number, a String, a Collection or a Map with string keys. Other types - * are rejected with an {@link IllegalArgumentException} when the query is sent, and so are numbers that a double - * cannot hold exactly, such as the long 9007199254740993. + * are rejected with an {@link IllegalArgumentException} when the query is sent, and so are NaN, + * infinities and numbers that a double cannot hold exactly, such as the long 9007199254740993. * * @param filter the filter * @return this query for method chaining diff --git a/sdk/src/main/java/io/dapr/client/domain/VectorRecord.java b/sdk/src/main/java/io/dapr/client/domain/VectorRecord.java index 3645ff6b94..15008cdec4 100644 --- a/sdk/src/main/java/io/dapr/client/domain/VectorRecord.java +++ b/sdk/src/main/java/io/dapr/client/domain/VectorRecord.java @@ -91,8 +91,8 @@ public Map getMetadata() { * Sets structured attributes that queries can filter on. * *

Values can be null, a Boolean, a Number, a String, a Collection or a Map with string keys. Other types - * are rejected with an {@link IllegalArgumentException} when the record is sent, and so are numbers that a double - * cannot hold exactly, such as the long 9007199254740993. + * are rejected with an {@link IllegalArgumentException} when the record is sent, and so are NaN, + * infinities and numbers that a double cannot hold exactly, such as the long 9007199254740993. * * @param metadata the metadata * @return this record for method chaining diff --git a/sdk/src/test/java/io/dapr/client/DaprPreviewClientSearchTest.java b/sdk/src/test/java/io/dapr/client/DaprPreviewClientSearchTest.java index ebe8f69673..1da71513ec 100644 --- a/sdk/src/test/java/io/dapr/client/DaprPreviewClientSearchTest.java +++ b/sdk/src/test/java/io/dapr/client/DaprPreviewClientSearchTest.java @@ -127,6 +127,11 @@ private static void assertInexact(Supplier> call) { assertTrue(error.getMessage().contains("cannot be represented exactly"), error.getMessage()); } + private static void assertNotFinite(Supplier> call) { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> call.get().block(TIMEOUT)); + assertTrue(error.getMessage().contains("must be finite"), error.getMessage()); + } + private static Value string(String value) { return Value.newBuilder().setStringValue(value).build(); } @@ -881,6 +886,25 @@ public void searchInexactNumbersTest() { verify(daprStub, never()).searchAlpha1(any(), any()); } + @Test + @DisplayName("search should reject NaN and infinite numbers") + public void searchNonFiniteNumbersTest() { + List numbers = List.of( + Double.NaN, + Double.POSITIVE_INFINITY, + Double.NEGATIVE_INFINITY, + Float.NaN, + Float.POSITIVE_INFINITY, + Float.NEGATIVE_INFINITY); + for (Number number : numbers) { + assertNotFinite(() -> previewClient.search(new SearchRequestAlpha1(STORE, INDEX, + SearchQuery.matchAll().setFilter(Map.of("year", Map.of("$lt", number)))))); + assertNotFinite(() -> previewClient.search(new SearchRequestAlpha1(STORE, INDEX, + SearchQuery.byNative(Map.of("year", number))))); + } + verify(daprStub, never()).searchAlpha1(any(), any()); + } + @Test @DisplayName("invalid input should fail through the Mono, not when the method is called") public void validationFailsThroughMonoTest() { diff --git a/sdk/src/test/java/io/dapr/client/DaprPreviewClientVectorTest.java b/sdk/src/test/java/io/dapr/client/DaprPreviewClientVectorTest.java index 77d17758fc..6dbbcc24b9 100644 --- a/sdk/src/test/java/io/dapr/client/DaprPreviewClientVectorTest.java +++ b/sdk/src/test/java/io/dapr/client/DaprPreviewClientVectorTest.java @@ -539,6 +539,31 @@ private static void assertInexact(Supplier> call) { assertTrue(error.getMessage().contains("cannot be represented exactly"), error.getMessage()); } + @Test + @DisplayName("filters and metadata should reject NaN and infinite numbers") + public void nonFiniteStructNumbersTest() { + List numbers = List.of( + Double.NaN, + Double.POSITIVE_INFINITY, + Double.NEGATIVE_INFINITY, + Float.NaN, + Float.POSITIVE_INFINITY, + Float.NEGATIVE_INFINITY); + for (Number number : numbers) { + assertNotFinite(() -> previewClient.queryVectors(new QueryVectorsRequestAlpha1(STORE, COLLECTION, + VectorQuery.byId("a").setFilter(Map.of("price", Map.of("$lt", number)))))); + assertNotFinite(() -> previewClient.upsertVectors(new UpsertVectorsRequestAlpha1(STORE, COLLECTION, List.of( + new VectorRecord("a", new float[] {1f}).setMetadata(Map.of("price", number)))))); + } + verify(daprStub, never()).upsertVectorsAlpha1(any(), any()); + verify(daprStub, never()).queryVectorsAlpha1(any(), any()); + } + + private static void assertNotFinite(Supplier> call) { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> call.get().block(TIMEOUT)); + assertTrue(error.getMessage().contains("must be finite"), error.getMessage()); + } + @Test @DisplayName("upsertVectors should reject missing records, records without an ID or vector, and bad options") public void upsertVectorsInvalidArgumentsTest() {