diff --git a/astra-db-java/src/main/java/com/datastax/astra/client/collections/Collection.java b/astra-db-java/src/main/java/com/datastax/astra/client/collections/Collection.java index d0ef2376..81153fde 100644 --- a/astra-db-java/src/main/java/com/datastax/astra/client/collections/Collection.java +++ b/astra-db-java/src/main/java/com/datastax/astra/client/collections/Collection.java @@ -2032,12 +2032,9 @@ public CollectionUpdateResult updateMany(Filter filter, Update update, Collectio .appendIfNotNull(OPTIONS_UPSERT, options.upsert()) .appendIfNotNull(OPTIONS_PAGE_STATE, nextPageState)); DataAPIResponse res = runCommand(cmd, options); - // Data - if (res.getData() != null) { - nextPageState = res.getData().getNextPageState(); - } // Status DataAPIStatus status = res.getStatus(); + nextPageState = (status != null) ? status.getString(RESULT_NEXT_PAGE_STATE) : null; if (status.containsKey(RESULT_MATCHED_COUNT)) { result.setMatchedCount(result.getMatchedCount() + status.getInteger(RESULT_MATCHED_COUNT)); } diff --git a/astra-db-java/src/main/java/com/datastax/astra/internal/command/AbstractCommandRunner.java b/astra-db-java/src/main/java/com/datastax/astra/internal/command/AbstractCommandRunner.java index 8b41ffb8..09da0fe2 100644 --- a/astra-db-java/src/main/java/com/datastax/astra/internal/command/AbstractCommandRunner.java +++ b/astra-db-java/src/main/java/com/datastax/astra/internal/command/AbstractCommandRunner.java @@ -123,6 +123,8 @@ public abstract class AbstractCommandRunner> impl /** parsing output json */ protected static final String RESULT_MORE_DATA = "moreData"; /** parsing output json */ + protected static final String RESULT_NEXT_PAGE_STATE = "nextPageState"; + /** parsing output json */ protected static final String RESULT_COUNT = "count"; // --- Build Requests -- diff --git a/astra-db-java/src/test/java/com/datastax/astra/test/integration/AbstractCollectionIT.java b/astra-db-java/src/test/java/com/datastax/astra/test/integration/AbstractCollectionIT.java index c48c36a6..5a2e29b6 100644 --- a/astra-db-java/src/test/java/com/datastax/astra/test/integration/AbstractCollectionIT.java +++ b/astra-db-java/src/test/java/com/datastax/astra/test/integration/AbstractCollectionIT.java @@ -1038,6 +1038,32 @@ void should_updateMany_returnZeroWhenNoMatch() { assertThat(res.getModifiedCount()).isEqualTo(0); } + @Test + @Order(50) + void should_updateMany_processAllPagesWhenMoreThanOnePage() throws TooManyDocumentsToCountException { + // Insert 60 documents — exceeds the 50-doc page limit so updateMany must follow nextPageState. + // Before the bug fix, only the first 50 would be updated. + collectionSimple.deleteAll(); + List docs = IntStream.range(0, 60) + .mapToObj(i -> new Document().id("page-test-" + i).append("status", "pending")) + .collect(Collectors.toList()); + collectionSimple.insertMany(docs); + + CollectionUpdateResult res = collectionSimple.updateMany( + Filters.eq("status", "pending"), + Updates.set("status", "done")); + + assertThat(res.getMatchedCount()).isEqualTo(60); + assertThat(res.getModifiedCount()).isEqualTo(60); + + // Verify every document was actually updated in the collection + long stillPending = collectionSimple.countDocuments(Filters.eq("status", "pending"), 100); + assertThat(stillPending).isZero(); + + long nowDone = collectionSimple.countDocuments(Filters.eq("status", "done"), 100); + assertThat(nowDone).isEqualTo(60); + } + // ========== findOneAndUpdate ========== @Test diff --git a/astra-db-java/src/test/java/com/datastax/astra/test/unit/collections/CollectionUpdateManyPaginationTest.java b/astra-db-java/src/test/java/com/datastax/astra/test/unit/collections/CollectionUpdateManyPaginationTest.java new file mode 100644 index 00000000..750bb73c --- /dev/null +++ b/astra-db-java/src/test/java/com/datastax/astra/test/unit/collections/CollectionUpdateManyPaginationTest.java @@ -0,0 +1,148 @@ +package com.datastax.astra.test.unit.collections; + +import com.datastax.astra.client.DataAPIDestination; +import com.datastax.astra.client.collections.Collection; +import com.datastax.astra.client.collections.CollectionOptions; +import com.datastax.astra.client.collections.commands.options.CollectionUpdateManyOptions; +import com.datastax.astra.client.collections.commands.results.CollectionUpdateResult; +import com.datastax.astra.client.collections.definition.documents.Document; +import com.datastax.astra.client.core.commands.Command; +import com.datastax.astra.client.core.query.Filter; +import com.datastax.astra.client.core.options.BaseOptions; +import com.datastax.astra.client.core.options.DataAPIClientOptions; +import com.datastax.astra.client.collections.commands.Update; +import com.datastax.astra.client.databases.Database; +import com.datastax.astra.client.databases.DatabaseOptions; +import com.datastax.astra.internal.api.DataAPIResponse; +import com.datastax.astra.internal.api.DataAPIStatus; +import org.junit.jupiter.api.Test; + +import java.util.ArrayDeque; +import java.util.Queue; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit test for issue #106 — updateMany does not proceed past first page. + * + *

The root cause was that {@code nextPageState} was read from {@code data} (always null + * for updateMany) instead of from {@code status} (where the API actually puts it). + * This test drives the fix without any real HTTP connection by subclassing {@link Collection} + * and overriding {@link Collection#runCommand} to return pre-built responses.

+ */ +class CollectionUpdateManyPaginationTest { + + // -------------------------------------------------- + // Helpers to build fake DataAPIResponse objects + // -------------------------------------------------- + + private static DataAPIResponse pageResponse(int matched, int modified, String nextPageState) { + DataAPIStatus status = new DataAPIStatus(); + status.setProperty("matchedCount", matched); + status.setProperty("modifiedCount", modified); + if (nextPageState != null) { + status.setProperty("nextPageState", nextPageState); + } + DataAPIResponse response = new DataAPIResponse(); + response.setStatus(status); + return response; + } + + // -------------------------------------------------- + // Controllable Collection subclass + // -------------------------------------------------- + + private static final DataAPIClientOptions CLIENT_OPTIONS = + new DataAPIClientOptions().destination(DataAPIDestination.HCD); + private static final Database STUB_DB = + new Database("http://localhost:8181/v1/default_keyspace", + new DatabaseOptions("test-token", CLIENT_OPTIONS)); + private static final CollectionOptions COL_OPTIONS = + new CollectionOptions("test-token", CLIENT_OPTIONS); + + /** + * Collection subclass that feeds responses from a queue instead of making HTTP calls. + * The {@code super()} call must be first, so we use static fields for the constructor args. + */ + static class StubCollection extends Collection { + + private final Queue responses; + + StubCollection(Queue responses) { + super(STUB_DB, "test_col", COL_OPTIONS, Document.class); + this.responses = responses; + } + + @Override + public DataAPIResponse runCommand(Command command, BaseOptions overridingOptions) { + DataAPIResponse next = responses.poll(); + if (next == null) { + throw new IllegalStateException("No more stubbed responses — unexpected runCommand call"); + } + return next; + } + } + + // -------------------------------------------------- + // Tests + // -------------------------------------------------- + + @Test + void updateMany_shouldProcessAllPagesWhenNextPageStateIsInStatus() { + // Two pages: first page signals more data via nextPageState in status, + // second page has no nextPageState (pagination ends). + Queue responses = new ArrayDeque<>(); + responses.add(pageResponse(50, 50, "PAGE_TOKEN_2")); + responses.add(pageResponse(30, 30, null)); + + StubCollection collection = new StubCollection(responses); + + CollectionUpdateResult result = collection.updateMany( + new Filter(), + new Update().set(new Document().append("status", "done")), + new CollectionUpdateManyOptions()); + + assertThat(result.getMatchedCount()).isEqualTo(80); + assertThat(result.getModifiedCount()).isEqualTo(80); + // All responses consumed — both pages were requested + assertThat(responses).isEmpty(); + } + + @Test + void updateMany_shouldStopAfterSinglePageWhenNoNextPageState() { + // Single page: no nextPageState in status → loop must terminate after one call. + Queue responses = new ArrayDeque<>(); + responses.add(pageResponse(20, 18, null)); + + StubCollection collection = new StubCollection(responses); + + CollectionUpdateResult result = collection.updateMany( + new Filter(), + new Update().set(new Document().append("active", true)), + new CollectionUpdateManyOptions()); + + assertThat(result.getMatchedCount()).isEqualTo(20); + assertThat(result.getModifiedCount()).isEqualTo(18); + assertThat(responses).isEmpty(); + } + + @Test + void updateMany_shouldHandleThreePagesCorrectly() { + // Three pages chained together. + Queue responses = new ArrayDeque<>(); + responses.add(pageResponse(50, 50, "TOKEN_2")); + responses.add(pageResponse(50, 50, "TOKEN_3")); + responses.add(pageResponse(10, 10, null)); + + StubCollection collection = new StubCollection(responses); + + CollectionUpdateResult result = collection.updateMany( + new Filter(), + new Update().set(new Document().append("processed", true)), + new CollectionUpdateManyOptions()); + + assertThat(result.getMatchedCount()).isEqualTo(110); + assertThat(result.getModifiedCount()).isEqualTo(110); + assertThat(responses).isEmpty(); + } +}