diff --git a/CHANGELOG.md b/CHANGELOG.md index b9ca65d96..c9a27c9bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - Added `equals()` and `hashCode()` implementations to `FieldValue` ([#1998](https://github.com/opensearch-project/opensearch-java/pull/1998)) - Add document lifecycle guide and runnable sample ([#2017](https://github.com/opensearch-project/opensearch-java/pull/2017)) - Add transparent gRPC transport with HybridTransport (bulk over gRPC, REST fallback), translation layer, TLS, basic auth, AWS SigV4, and JWT support ([#2062](https://github.com/opensearch-project/opensearch-java/pull/2062)) +- Add search over gRPC with match_all query support, SearchRequestConverter, SearchResponseConverter, and _source deserialization ([#2071](https://github.com/opensearch-project/opensearch-java/pull/2071)) ### Fixed diff --git a/java-client-grpc/src/main/java/org/opensearch/client/transport/grpc/GrpcTransport.java b/java-client-grpc/src/main/java/org/opensearch/client/transport/grpc/GrpcTransport.java index 83f09cf54..23468e465 100644 --- a/java-client-grpc/src/main/java/org/opensearch/client/transport/grpc/GrpcTransport.java +++ b/java-client-grpc/src/main/java/org/opensearch/client/transport/grpc/GrpcTransport.java @@ -17,6 +17,8 @@ import org.opensearch.client.json.JsonpMapper; import org.opensearch.client.opensearch.core.BulkRequest; import org.opensearch.client.opensearch.core.BulkResponse; +import org.opensearch.client.opensearch.core.SearchRequest; +import org.opensearch.client.opensearch.core.SearchResponse; import org.opensearch.client.transport.Endpoint; import org.opensearch.client.transport.OpenSearchTransport; import org.opensearch.client.transport.TransportException; @@ -25,6 +27,7 @@ import org.opensearch.client.transport.grpc.translation.BulkResponseConverter; import org.opensearch.client.transport.grpc.translation.GrpcStatusConverter; import org.opensearch.protobufs.services.DocumentServiceGrpc; +import org.opensearch.protobufs.services.SearchServiceGrpc; /** * Pure gRPC transport for OpenSearch. Implements {@link OpenSearchTransport} and routes @@ -52,7 +55,8 @@ public class GrpcTransport implements OpenSearchTransport { static { java.util.Set> endpoints = new java.util.HashSet<>(); endpoints.add(BulkRequest._ENDPOINT); - // Future: SearchRequest._ENDPOINT, KnnSearchRequest._ENDPOINT + endpoints.add(SearchRequest._ENDPOINT); + // Future: KnnSearchRequest._ENDPOINT SUPPORTED_ENDPOINTS = java.util.Collections.unmodifiableSet(endpoints); } @@ -67,6 +71,7 @@ public static boolean isEndpointSupported(Endpoint endpoint) { private final ManagedChannel channel; private final DocumentServiceGrpc.DocumentServiceBlockingStub documentStub; + private final SearchServiceGrpc.SearchServiceBlockingStub searchStub; private final JsonpMapper jsonpMapper; private final GrpcTransportOptions grpcOptions; private final TransportOptions transportOptions; @@ -81,6 +86,7 @@ public static boolean isEndpointSupported(Endpoint endpoint) { ) { this.channel = channel; this.documentStub = channel != null ? DocumentServiceGrpc.newBlockingStub(channel) : null; + this.searchStub = channel != null ? SearchServiceGrpc.newBlockingStub(channel) : null; this.jsonpMapper = jsonpMapper; this.grpcOptions = grpcOptions; this.transportOptions = transportOptions; @@ -113,6 +119,9 @@ public ResponseT performRequest( if (endpoint == BulkRequest._ENDPOINT) { return (ResponseT) performBulk((BulkRequest) request); } + if (endpoint == SearchRequest._ENDPOINT) { + return (ResponseT) performSearch((SearchRequest) request); + } throw new UnsupportedOperationException("Endpoint registered but no handler: " + endpoint.requestUrl(request)); } @@ -238,6 +247,31 @@ private BulkResponse performBulk(BulkRequest request) throws TransportException } } + @SuppressWarnings("unchecked") + private SearchResponse performSearch(SearchRequest request) throws TransportException { + // Convert client request to protobuf + org.opensearch.protobufs.SearchRequest protoRequest = org.opensearch.client.transport.grpc.translation.SearchRequestConverter + .toProto(request, jsonpMapper); + + // Execute gRPC call + try { + org.opensearch.protobufs.SearchResponse protoResponse = searchStub.search(protoRequest); + + // Convert response — use Object.class as default; the actual deserialization + // is handled by the endpoint's response deserializer in the transport layer + return (SearchResponse) org.opensearch.client.transport.grpc.translation.SearchResponseConverter.fromProto( + protoResponse, + jsonpMapper, + (Class) Object.class + ); + } catch (StatusRuntimeException e) { + throw new TransportException( + "gRPC search request failed: " + e.getStatus().getDescription(), + new org.opensearch.client.transport.TransportException(e.getMessage(), e) + ); + } + } + // ─── Builder ───────────────────────────────────────────────────────────────── /** diff --git a/java-client-grpc/src/main/java/org/opensearch/client/transport/grpc/translation/SearchRequestConverter.java b/java-client-grpc/src/main/java/org/opensearch/client/transport/grpc/translation/SearchRequestConverter.java new file mode 100644 index 000000000..3ee553c99 --- /dev/null +++ b/java-client-grpc/src/main/java/org/opensearch/client/transport/grpc/translation/SearchRequestConverter.java @@ -0,0 +1,108 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.client.transport.grpc.translation; + +import org.opensearch.client.json.JsonpMapper; +import org.opensearch.client.opensearch._types.query_dsl.MatchAllQuery; +import org.opensearch.client.opensearch._types.query_dsl.Query; +import org.opensearch.client.opensearch.core.SearchRequest; + +/** + * Converts opensearch-java SearchRequest to protobuf SearchRequest. + * + * Currently supported query types: + * - match_all — matches all documents + * + * Query types not yet implemented (these can be executed over gRPC once + * the converter is extended — the server already supports them): + * - term, terms, terms_set + * - match, match_phrase, match_phrase_prefix, match_bool_prefix, multi_match + * - bool, constant_score, function_score + * - range, prefix, wildcard, regexp, fuzzy, exists, ids + * - nested, geo_distance, geo_bounding_box + * - knn, hybrid, script + * + * To add a new query type, implement a convertXxx() method and add a case + * to convertQuery(Query). + */ +public class SearchRequestConverter { + + /** + * Convert an opensearch-java SearchRequest to a protobuf SearchRequest. + * + * @param request the client SearchRequest + * @param jsonpMapper the JSON mapper (for future use with script queries) + * @return the protobuf SearchRequest + */ + public static org.opensearch.protobufs.SearchRequest toProto(SearchRequest request, JsonpMapper jsonpMapper) { + org.opensearch.protobufs.SearchRequest.Builder protoBuilder = org.opensearch.protobufs.SearchRequest.newBuilder(); + + // Set index(es) + if (request.index() != null && !request.index().isEmpty()) { + protoBuilder.addAllIndex(request.index()); + } + + // Build SearchRequestBody + org.opensearch.protobufs.SearchRequestBody.Builder bodyBuilder = org.opensearch.protobufs.SearchRequestBody.newBuilder(); + + // Set size + if (request.size() != null) { + bodyBuilder.setSize(request.size()); + } + + // Set from (pagination offset) + if (request.from() != null) { + bodyBuilder.setFrom(request.from()); + } + + // Convert query + if (request.query() != null) { + bodyBuilder.setQuery(convertQuery(request.query())); + } + + protoBuilder.setSearchRequestBody(bodyBuilder.build()); + return protoBuilder.build(); + } + + /** + * Convert an opensearch-java Query to a protobuf QueryContainer. + * + * @throws UnsupportedOperationException if the query type is not yet supported + */ + private static org.opensearch.protobufs.QueryContainer convertQuery(Query query) { + org.opensearch.protobufs.QueryContainer.Builder containerBuilder = org.opensearch.protobufs.QueryContainer.newBuilder(); + + if (query.isMatchAll()) { + containerBuilder.setMatchAll(convertMatchAll(query.matchAll())); + } else { + throw new UnsupportedOperationException( + "Query type '" + + query._kind() + + "' is not yet supported for gRPC transport in this version. " + + "Use REST transport for this query type." + ); + } + + return containerBuilder.build(); + } + + /** + * Convert MatchAllQuery to protobuf. + * MatchAllQuery has only optional boost and _name fields. + */ + private static org.opensearch.protobufs.MatchAllQuery convertMatchAll(MatchAllQuery matchAll) { + org.opensearch.protobufs.MatchAllQuery.Builder builder = org.opensearch.protobufs.MatchAllQuery.newBuilder(); + + if (matchAll.boost() != null) { + builder.setBoost(matchAll.boost().floatValue()); + } + + return builder.build(); + } +} diff --git a/java-client-grpc/src/main/java/org/opensearch/client/transport/grpc/translation/SearchResponseConverter.java b/java-client-grpc/src/main/java/org/opensearch/client/transport/grpc/translation/SearchResponseConverter.java new file mode 100644 index 000000000..6de681d5c --- /dev/null +++ b/java-client-grpc/src/main/java/org/opensearch/client/transport/grpc/translation/SearchResponseConverter.java @@ -0,0 +1,174 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.client.transport.grpc.translation; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import org.opensearch.client.json.JsonpMapper; +import org.opensearch.client.opensearch._types.ShardStatistics; +import org.opensearch.client.opensearch.core.SearchResponse; +import org.opensearch.client.opensearch.core.search.Hit; +import org.opensearch.client.opensearch.core.search.HitsMetadata; +import org.opensearch.client.opensearch.core.search.TotalHits; +import org.opensearch.client.opensearch.core.search.TotalHitsRelation; + +/** + * Converts protobuf SearchResponse to opensearch-java SearchResponse format. + *

+ * Currently supports match_all queries. Other query types (term, match, bool, etc.) + * are not yet implemented in the client-side converter but can be executed over gRPC + * once the request converter supports them — the response format is the same regardless + * of query type. + *

+ * Note: Unsupported query types will throw UnsupportedOperationException at the request + * conversion stage. The response converter handles all response formats since the + * SearchResponse structure is query-agnostic. + */ +public class SearchResponseConverter { + + /** + * Convert a protobuf SearchResponse to an opensearch-java SearchResponse. + * + * @param protoResponse the protobuf SearchResponse from the server + * @param jsonpMapper the JSON mapper for _source deserialization + * @param tDocumentClass the target class for hit _source deserialization + * @param the document type + * @return the opensearch-java SearchResponse + */ + public static SearchResponse fromProto( + org.opensearch.protobufs.SearchResponse protoResponse, + JsonpMapper jsonpMapper, + Class tDocumentClass + ) { + SearchResponse.Builder builder = new SearchResponse.Builder(); + + // took + builder.took(protoResponse.getTook()); + + // timed_out + builder.timedOut(protoResponse.getTimedOut()); + + // _shards + if (protoResponse.hasXShards()) { + org.opensearch.protobufs.ShardStatistics protoShards = protoResponse.getXShards(); + builder.shards( + new ShardStatistics.Builder().total(protoShards.getTotal()) + .successful(protoShards.getSuccessful()) + .failed(protoShards.getFailed()) + .build() + ); + } else { + builder.shards(new ShardStatistics.Builder().total(0).successful(0).failed(0).build()); + } + + // hits + if (protoResponse.hasHits()) { + builder.hits(convertHitsMetadata(protoResponse.getHits(), jsonpMapper, tDocumentClass)); + } else { + builder.hits(new HitsMetadata.Builder().hits(new ArrayList<>()).build()); + } + + return builder.build(); + } + + private static HitsMetadata convertHitsMetadata( + org.opensearch.protobufs.HitsMetadata protoHits, + JsonpMapper jsonpMapper, + Class tDocumentClass + ) { + HitsMetadata.Builder builder = new HitsMetadata.Builder(); + + // total hits + if (protoHits.hasTotal()) { + org.opensearch.protobufs.HitsMetadataTotal protoTotal = protoHits.getTotal(); + if (protoTotal.hasTotalHits()) { + org.opensearch.protobufs.TotalHits totalHits = protoTotal.getTotalHits(); + TotalHitsRelation relation = totalHits.getRelation() == org.opensearch.protobufs.TotalHitsRelation.TOTAL_HITS_RELATION_EQ + ? TotalHitsRelation.Eq + : TotalHitsRelation.Gte; + builder.total(new TotalHits.Builder().value(totalHits.getValue()).relation(relation).build()); + } + } + + // max_score + if (protoHits.hasMaxScore()) { + org.opensearch.protobufs.HitsMetadataMaxScore maxScore = protoHits.getMaxScore(); + if (maxScore.hasFloat()) { + builder.maxScore(maxScore.getFloat()); + } + } + + // individual hits + List> hits = new ArrayList<>(); + for (org.opensearch.protobufs.HitsMetadataHitsInner protoHit : protoHits.getHitsList()) { + hits.add(convertHit(protoHit, jsonpMapper, tDocumentClass)); + } + builder.hits(hits); + + return builder.build(); + } + + private static Hit convertHit( + org.opensearch.protobufs.HitsMetadataHitsInner protoHit, + JsonpMapper jsonpMapper, + Class tDocumentClass + ) { + Hit.Builder builder = new Hit.Builder(); + + // _index + builder.index(protoHit.getXIndex()); + + // _id + builder.id(protoHit.getXId()); + + // _score + if (protoHit.hasXScore()) { + org.opensearch.protobufs.HitXScore score = protoHit.getXScore(); + if (score.hasDouble()) { + builder.score(score.getDouble()); + } + } + + // _version + if (protoHit.hasXVersion()) { + builder.version(protoHit.getXVersion()); + } + + // _seq_no + if (protoHit.hasXSeqNo()) { + builder.seqNo(protoHit.getXSeqNo()); + } + + // _primary_term + if (protoHit.hasXPrimaryTerm()) { + builder.primaryTerm(protoHit.getXPrimaryTerm()); + } + + // _source — decode bytes and deserialize to TDocument + if (!protoHit.getXSource().isEmpty()) { + TDocument source = deserializeSource(protoHit.getXSource().toByteArray(), jsonpMapper, tDocumentClass); + builder.source(source); + } + + return builder.build(); + } + + /** + * Decode _source bytes from protobuf hit to a Java object. + * The server returns _source as UTF-8 JSON bytes. + */ + static TDocument deserializeSource(byte[] sourceBytes, JsonpMapper jsonpMapper, Class tDocumentClass) { + InputStream stream = new ByteArrayInputStream(sourceBytes); + jakarta.json.stream.JsonParser parser = jsonpMapper.jsonProvider().createParser(stream); + parser.next(); // advance to first token + return jsonpMapper.deserialize(parser, tDocumentClass); + } +} diff --git a/java-client-grpc/src/test/java/org/opensearch/client/transport/grpc/translation/SearchRequestConverterTest.java b/java-client-grpc/src/test/java/org/opensearch/client/transport/grpc/translation/SearchRequestConverterTest.java new file mode 100644 index 000000000..0de757a9e --- /dev/null +++ b/java-client-grpc/src/test/java/org/opensearch/client/transport/grpc/translation/SearchRequestConverterTest.java @@ -0,0 +1,101 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.client.transport.grpc.translation; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; +import org.opensearch.client.json.jackson3.JacksonJsonpMapper; +import org.opensearch.client.opensearch.core.SearchRequest; + +/** + * Unit tests for SearchRequestConverter. + */ +public class SearchRequestConverterTest { + + private final JacksonJsonpMapper mapper = new JacksonJsonpMapper(); + + @Test + public void testMatchAllQueryBasic() { + SearchRequest request = new SearchRequest.Builder().index("test-index").query(q -> q.matchAll(m -> m)).build(); + + org.opensearch.protobufs.SearchRequest proto = SearchRequestConverter.toProto(request, mapper); + + assertNotNull(proto); + assertEquals(1, proto.getIndexCount()); + assertEquals("test-index", proto.getIndex(0)); + assertTrue(proto.hasSearchRequestBody()); + assertTrue(proto.getSearchRequestBody().hasQuery()); + assertTrue(proto.getSearchRequestBody().getQuery().hasMatchAll()); + } + + @Test + public void testMatchAllQueryWithBoost() { + SearchRequest request = new SearchRequest.Builder().index("test-index").query(q -> q.matchAll(m -> m.boost(2.0f))).build(); + + org.opensearch.protobufs.SearchRequest proto = SearchRequestConverter.toProto(request, mapper); + + assertTrue(proto.getSearchRequestBody().getQuery().hasMatchAll()); + assertEquals(2.0f, proto.getSearchRequestBody().getQuery().getMatchAll().getBoost(), 0.001f); + } + + @Test + public void testMatchAllWithSize() { + SearchRequest request = new SearchRequest.Builder().index("test-index").query(q -> q.matchAll(m -> m)).size(50).build(); + + org.opensearch.protobufs.SearchRequest proto = SearchRequestConverter.toProto(request, mapper); + + assertEquals(50, proto.getSearchRequestBody().getSize()); + } + + @Test + public void testMatchAllWithFrom() { + SearchRequest request = new SearchRequest.Builder().index("test-index").query(q -> q.matchAll(m -> m)).from(10).size(20).build(); + + org.opensearch.protobufs.SearchRequest proto = SearchRequestConverter.toProto(request, mapper); + + assertEquals(10, proto.getSearchRequestBody().getFrom()); + assertEquals(20, proto.getSearchRequestBody().getSize()); + } + + @Test + public void testMultipleIndexes() { + SearchRequest request = new SearchRequest.Builder().index("index-1", "index-2", "index-3").query(q -> q.matchAll(m -> m)).build(); + + org.opensearch.protobufs.SearchRequest proto = SearchRequestConverter.toProto(request, mapper); + + assertEquals(3, proto.getIndexCount()); + assertEquals("index-1", proto.getIndex(0)); + assertEquals("index-2", proto.getIndex(1)); + assertEquals("index-3", proto.getIndex(2)); + } + + @Test + public void testNoQuery() { + SearchRequest request = new SearchRequest.Builder().index("test-index").size(10).build(); + + org.opensearch.protobufs.SearchRequest proto = SearchRequestConverter.toProto(request, mapper); + + assertNotNull(proto); + assertTrue(proto.hasSearchRequestBody()); + // No query set — server returns all docs by default + } + + @Test(expected = UnsupportedOperationException.class) + public void testUnsupportedQueryTypeThrows() { + SearchRequest request = new SearchRequest.Builder().index("test-index") + .query(q -> q.term(t -> t.field("status").value(v -> v.stringValue("active")))) + .build(); + + // Should throw UnsupportedOperationException for term query + SearchRequestConverter.toProto(request, mapper); + } +} diff --git a/java-client-grpc/src/test/java11/org/opensearch/client/opensearch/integTest/grpc/GrpcSearchIT.java b/java-client-grpc/src/test/java11/org/opensearch/client/opensearch/integTest/grpc/GrpcSearchIT.java new file mode 100644 index 000000000..a0ac4a6bf --- /dev/null +++ b/java-client-grpc/src/test/java11/org/opensearch/client/opensearch/integTest/grpc/GrpcSearchIT.java @@ -0,0 +1,196 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.client.opensearch.integTest.grpc; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import org.junit.Test; +import org.opensearch.client.opensearch._types.Refresh; +import org.opensearch.client.opensearch.core.BulkRequest; +import org.opensearch.client.opensearch.core.BulkResponse; +import org.opensearch.client.opensearch.core.SearchResponse; +import org.opensearch.client.opensearch.core.bulk.BulkOperation; +import org.opensearch.client.opensearch.core.bulk.IndexOperation; +import org.opensearch.client.opensearch.core.search.Hit; + +/** + * Integration tests for Search over gRPC transport. + * + * Verifies the complete search pipeline: + * 1. Index documents via gRPC bulk + * 2. Search with match_all via gRPC + * 3. Verify response structure and _source deserialization + * + * Skips automatically on OpenSearch versions below 3.5.0. + * + * Run: + * ./gradlew integrationTest --tests "org.opensearch.client.opensearch.integTest.grpc.GrpcSearchIT" \ + * -Dtests.opensearch.version=3.5.0 + */ +public class GrpcSearchIT extends AbstractGrpcIT { + + private static final String INDEX = "grpc-search-it"; + + // ─── Test Document ─────────────────────────────────────────────────────────── + + public static class Movie { + public String title; + public int year; + public String director; + + public Movie() {} + + public Movie(String title, int year, String director) { + this.title = title; + this.year = year; + this.director = director; + } + } + + // ─── Tests ─────────────────────────────────────────────────────────────────── + + @Test + public void testMatchAllSearch() throws IOException { + assumeGrpcSupported(); + + try { + // Setup: create index and index documents via gRPC bulk + grpcClient().indices().create(c -> c.index(INDEX)); + + List ops = new ArrayList<>(); + ops.add( + new BulkOperation.Builder().index( + new IndexOperation.Builder().index(INDEX) + .id("1") + .document(new Movie("The Dark Knight", 2008, "Christopher Nolan")) + .build() + ).build() + ); + ops.add( + new BulkOperation.Builder().index( + new IndexOperation.Builder().index(INDEX) + .id("2") + .document(new Movie("Inception", 2010, "Christopher Nolan")) + .build() + ).build() + ); + ops.add( + new BulkOperation.Builder().index( + new IndexOperation.Builder().index(INDEX) + .id("3") + .document(new Movie("Interstellar", 2014, "Christopher Nolan")) + .build() + ).build() + ); + + BulkResponse bulkResponse = grpcClient().bulk( + new BulkRequest.Builder().index(INDEX).operations(ops).refresh(Refresh.True).build() + ); + assertFalse("Bulk should have no errors", bulkResponse.errors()); + + // Search: match_all via gRPC + SearchResponse searchResponse = grpcClient().search(s -> s.index(INDEX).query(q -> q.matchAll(m -> m)), Movie.class); + + // Verify response structure + assertNotNull("Search response should not be null", searchResponse); + assertNotNull("Hits should not be null", searchResponse.hits()); + assertNotNull("Total should not be null", searchResponse.hits().total()); + assertEquals("Should find 3 documents", 3L, searchResponse.hits().total().value()); + assertTrue("Took should be >= 0", searchResponse.took() >= 0); + assertFalse("Should not time out", searchResponse.timedOut()); + + // Verify hits + List> hits = searchResponse.hits().hits(); + assertEquals("Should have 3 hits", 3, hits.size()); + + // Verify _source deserialization + for (Hit hit : hits) { + assertNotNull("Hit should have _index", hit.index()); + assertNotNull("Hit should have _id", hit.id()); + assertNotNull("Hit should have _source", hit.source()); + assertNotNull("Movie should have title", hit.source().title); + assertTrue("Movie year should be > 0", hit.source().year > 0); + assertNotNull("Movie should have director", hit.source().director); + } + + } finally { + // Cleanup + grpcClient().indices().delete(d -> d.index(INDEX).ignoreUnavailable(true)); + } + } + + @Test + public void testMatchAllWithSizeAndFrom() throws IOException { + assumeGrpcSupported(); + + try { + // Setup: index 5 documents + grpcClient().indices().create(c -> c.index(INDEX)); + + List ops = new ArrayList<>(); + for (int i = 1; i <= 5; i++) { + final int id = i; + ops.add( + new BulkOperation.Builder().index( + new IndexOperation.Builder().index(INDEX) + .id(String.valueOf(id)) + .document(new Movie("Movie " + id, 2000 + id, "Director " + id)) + .build() + ).build() + ); + } + + grpcClient().bulk(new BulkRequest.Builder().index(INDEX).operations(ops).refresh(Refresh.True).build()); + + // Search with size=2 + SearchResponse response = grpcClient().search(s -> s.index(INDEX).query(q -> q.matchAll(m -> m)).size(2), Movie.class); + + assertEquals("Total should be 5", 5L, response.hits().total().value()); + assertEquals("Should return 2 hits (size=2)", 2, response.hits().hits().size()); + + // Search with from=2, size=2 (pagination) + SearchResponse page2 = grpcClient().search( + s -> s.index(INDEX).query(q -> q.matchAll(m -> m)).from(2).size(2), + Movie.class + ); + + assertEquals("Total should still be 5", 5L, page2.hits().total().value()); + assertEquals("Should return 2 hits (page 2)", 2, page2.hits().hits().size()); + + } finally { + grpcClient().indices().delete(d -> d.index(INDEX).ignoreUnavailable(true)); + } + } + + @Test + public void testSearchEmptyIndex() throws IOException { + assumeGrpcSupported(); + + try { + // Create empty index + grpcClient().indices().create(c -> c.index(INDEX)); + + // Search empty index + SearchResponse response = grpcClient().search(s -> s.index(INDEX).query(q -> q.matchAll(m -> m)), Movie.class); + + assertNotNull("Response should not be null", response); + assertEquals("Should find 0 documents", 0L, response.hits().total().value()); + assertTrue("Hits list should be empty", response.hits().hits().isEmpty()); + + } finally { + grpcClient().indices().delete(d -> d.index(INDEX).ignoreUnavailable(true)); + } + } +}