From 11f82819a44a08236f7acaa6910092602906ae5f Mon Sep 17 00:00:00 2001 From: Kanat Date: Tue, 8 Sep 2026 14:23:06 -0400 Subject: [PATCH 1/4] feat(channel): support custom_set and custom_unset in batch channel update (CHA-3618) Add customSet and customUnset to ChannelsBatchOptions, serialized as the root-level custom_set and custom_unset of PUT /channels/batch and omitted when unset. They patch individual keys of a channel's custom object instead of replacing it, which is what data.custom does today. ChannelBatchUpdater.updateData gains an overload carrying both, with a nullable data so a custom patch can be sent on its own. Validation stays on the server: it owns the rules for which combinations are rejected. Co-Authored-By: Claude Opus 5 --- .../channel_management/batch-updates.md | 31 +++++- .../getstream/chat/java/models/Channel.java | 22 ++++ .../chat/java/models/ChannelBatchUpdater.java | 30 ++++++ .../java/ChannelBatchCustomPatchTest.java | 100 ++++++++++++++++++ 4 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 src/test/java/io/getstream/chat/java/ChannelBatchCustomPatchTest.java diff --git a/docs/channels/channel_management/batch-updates.md b/docs/channels/channel_management/batch-updates.md index 1ac25870a..69dc021b2 100644 --- a/docs/channels/channel_management/batch-updates.md +++ b/docs/channels/channel_management/batch-updates.md @@ -55,7 +55,7 @@ You can perform different operations on the channels but only once at a time. Th | show | Show the channels for members. | members | | archive | Archive the channels for members. | members | | unarchive | Unarchive the channels for members. | members | -| updateData | Update the channel data for the channels. | channelData | +| updateData | Update the channel data for the channels. | channelData, custom_set, custom_unset | | assignRoles | Assign roles to members in the channels. | members | | inviteMembers | Send invites to users to join the channels. | members | @@ -91,6 +91,35 @@ The `config_overrides` object allows you to override the default channel type co | `grants` | object | Permission grants modifiers | | `commands` | array | List of enabled command names | +### Partial custom updates + +The `custom` property above replaces the whole custom object: every key that is not in the request is deleted. For channels the display `name` lives inside `custom`, so a payload that omits it deletes the channel name. + +To change individual keys instead, use `custom_set` and `custom_unset`. Unlike `custom`, these two are sent at the **root** of the request, next to `operation` and `filter`, not inside `data`. + +| Property | Type | Description | +| -------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| `custom_set` | object | Merges these keys into each channel's existing custom object, leaving every other custom key untouched. | +| `custom_unset` | array of strings | Deletes these keys from each channel's existing custom object, leaving every other custom key untouched. | + +Keys in both are dot-paths, so `a.b` addresses key `b` inside object `a`, and the parent object must already exist. Deleting a key that does not exist is a no-op. + +Both are only supported for the `updateData` operation, and neither can be combined with `custom` in the same request. The backend validates these rules and returns a `400` before the task is created. + +```java +// Set one custom key and delete another, leaving the rest of the custom object alone +var updater = Channel.channelBatchUpdater(); +var filter = new ChannelsBatchFilters(); +filter.setCids(Map.of("$in", List.of("messaging:a", "messaging:b"))); + +var resp = + updater + .updateData(filter, null, Map.of("group", "old"), List.of("location_id")) + .request(); +``` + +The same fields are available on `ChannelsBatchOptions` directly, via `setCustomSet` and `setCustomUnset`, and can be combined with the other channel data properties in the same `updateData` request as long as `custom` is not set. + Most of the operations require additional parameters to be specified, such as the _members_ to add or remove, or the _channelData_ to update. We've prepared convenience methods for all operations, some examples are shown below: diff --git a/src/main/java/io/getstream/chat/java/models/Channel.java b/src/main/java/io/getstream/chat/java/models/Channel.java index cf2873226..4909ca8d9 100644 --- a/src/main/java/io/getstream/chat/java/models/Channel.java +++ b/src/main/java/io/getstream/chat/java/models/Channel.java @@ -2106,6 +2106,28 @@ public static class ChannelsBatchOptions { @Nullable @JsonProperty("data") private ChannelDataUpdate data; + + /** + * Custom keys to merge into each matched channel's existing custom object, leaving every other + * custom key untouched. Keys are dot-paths, so {@code a.b} sets key {@code b} inside object + * {@code a}. Only valid with {@link ChannelBatchOperation#UPDATE_DATA} and cannot be combined + * with {@code data.custom}, which replaces the whole object; the backend validates both. + */ + @Nullable + @JsonProperty("custom_set") + @JsonInclude(JsonInclude.Include.NON_NULL) + private Map customSet; + + /** + * Custom keys to delete from each matched channel's existing custom object, leaving every other + * custom key untouched. Keys are dot-paths; deleting a key that does not exist is a no-op. Only + * valid with {@link ChannelBatchOperation#UPDATE_DATA} and cannot be combined with {@code + * data.custom}, which replaces the whole object; the backend validates both. + */ + @Nullable + @JsonProperty("custom_unset") + @JsonInclude(JsonInclude.Include.NON_NULL) + private List customUnset; } @Getter diff --git a/src/main/java/io/getstream/chat/java/models/ChannelBatchUpdater.java b/src/main/java/io/getstream/chat/java/models/ChannelBatchUpdater.java index 529ec7303..d9edef731 100644 --- a/src/main/java/io/getstream/chat/java/models/ChannelBatchUpdater.java +++ b/src/main/java/io/getstream/chat/java/models/ChannelBatchUpdater.java @@ -2,8 +2,11 @@ import io.getstream.chat.java.models.Channel.*; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; /** Provides convenience methods for batch channel operations. */ public class ChannelBatchUpdater { @@ -188,10 +191,37 @@ public ChannelsBatchUpdateRequest unarchive( @NotNull public ChannelsBatchUpdateRequest updateData( @NotNull ChannelsBatchFilters filter, @NotNull ChannelDataUpdate data) { + return updateData(filter, data, null, null); + } + + /** + * Updates data on channels matching the filter, patching individual custom keys instead of + * replacing the whole custom object. + * + *

{@code customSet} merges its keys into each channel's existing custom object and {@code + * customUnset} deletes its keys, both leaving every other custom key untouched. Keys are + * dot-paths. They cannot be combined with {@code data.custom}, which replaces the whole object; + * the backend validates that and the other combinations it rejects. Pass a null {@code data} to + * send a custom patch on its own. + * + * @param filter the filter to match channels + * @param data channel data to update, or null to only patch custom keys + * @param customSet custom keys to merge in, or null + * @param customUnset custom keys to delete, or null + * @return the batch update request + */ + @NotNull + public ChannelsBatchUpdateRequest updateData( + @NotNull ChannelsBatchFilters filter, + @Nullable ChannelDataUpdate data, + @Nullable Map customSet, + @Nullable List customUnset) { ChannelsBatchOptions options = new ChannelsBatchOptions(); options.setOperation(ChannelBatchOperation.UPDATE_DATA); options.setFilter(filter); options.setData(data); + options.setCustomSet(customSet != null ? new HashMap<>(customSet) : null); + options.setCustomUnset(customUnset != null ? new ArrayList<>(customUnset) : null); return Channel.updateBatch(options); } } diff --git a/src/test/java/io/getstream/chat/java/ChannelBatchCustomPatchTest.java b/src/test/java/io/getstream/chat/java/ChannelBatchCustomPatchTest.java new file mode 100644 index 000000000..50354924c --- /dev/null +++ b/src/test/java/io/getstream/chat/java/ChannelBatchCustomPatchTest.java @@ -0,0 +1,100 @@ +package io.getstream.chat.java; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.PropertyAccessor; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.getstream.chat.java.models.Channel; +import io.getstream.chat.java.models.Channel.ChannelBatchOperation; +import io.getstream.chat.java.models.Channel.ChannelDataUpdate; +import io.getstream.chat.java.models.Channel.ChannelsBatchFilters; +import io.getstream.chat.java.models.Channel.ChannelsBatchOptions; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +public class ChannelBatchCustomPatchTest { + + // Mirrors the visibility configuration of DefaultClient's mapper. + private static final ObjectMapper MAPPER = + new ObjectMapper() + .setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE) + .setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY); + + private static ChannelsBatchFilters filterByCids() { + var filter = new ChannelsBatchFilters(); + Map cids = new HashMap<>(); + cids.put("$in", List.of("messaging:a", "messaging:b")); + filter.setCids(cids); + return filter; + } + + @DisplayName("The custom patch serializes at the request root, not inside data") + @Test + void whenSettingCustomPatch_thenSerializedAtRequestRoot() throws Exception { + var options = new ChannelsBatchOptions(); + options.setOperation(ChannelBatchOperation.UPDATE_DATA); + options.setFilter(filterByCids()); + Map customSet = new HashMap<>(); + customSet.put("group", "old"); + options.setCustomSet(customSet); + options.setCustomUnset(List.of("location_id")); + + JsonNode root = MAPPER.readTree(MAPPER.writeValueAsString(options)); + + Assertions.assertEquals("old", root.path("custom_set").path("group").asText()); + Assertions.assertEquals(1, root.path("custom_unset").size()); + Assertions.assertEquals("location_id", root.path("custom_unset").get(0).asText()); + // The fields are siblings of operation and filter. Inside data they would be + // collected into custom by the v1 extra-fields sink instead. + Assertions.assertEquals("updateData", root.path("operation").asText()); + Assertions.assertTrue(root.hasNonNull("filter")); + Assertions.assertFalse(root.path("data").has("custom_set")); + Assertions.assertFalse(root.path("data").has("custom_unset")); + } + + @DisplayName("The custom patch fields are omitted when not set") + @Test + void whenCustomPatchNotSet_thenOmittedFromTheRequest() throws Exception { + var options = new ChannelsBatchOptions(); + options.setOperation(ChannelBatchOperation.UPDATE_DATA); + options.setFilter(filterByCids()); + var data = new ChannelDataUpdate(); + data.setFrozen(true); + options.setData(data); + + JsonNode root = MAPPER.readTree(MAPPER.writeValueAsString(options)); + + Assertions.assertFalse(root.has("custom_set")); + Assertions.assertFalse(root.has("custom_unset")); + } + + @DisplayName("ChannelBatchUpdater carries the custom patch without data") + @Test + void whenUpdatingDataWithCustomPatchOnly_thenOptionsCarryTheFields() { + var options = + Channel.channelBatchUpdater() + .updateData(filterByCids(), null, Map.of("group", "old"), List.of("location_id")) + .getOptions(); + + Assertions.assertEquals(ChannelBatchOperation.UPDATE_DATA, options.getOperation()); + Assertions.assertNull(options.getData()); + Assertions.assertEquals(Map.of("group", "old"), options.getCustomSet()); + Assertions.assertEquals(List.of("location_id"), options.getCustomUnset()); + } + + @DisplayName("ChannelBatchUpdater leaves the custom patch unset when only data is given") + @Test + void whenUpdatingDataOnly_thenCustomPatchStaysNull() { + var data = new ChannelDataUpdate(); + data.setFrozen(true); + + var options = Channel.channelBatchUpdater().updateData(filterByCids(), data).getOptions(); + + Assertions.assertNull(options.getCustomSet()); + Assertions.assertNull(options.getCustomUnset()); + } +} From 561771fce56e38936b2338631ad78b0436656fe5 Mon Sep 17 00:00:00 2001 From: Kanat Date: Tue, 8 Sep 2026 15:38:25 -0400 Subject: [PATCH 2/4] refactor(channel): replace the batch custom-patch overload with updateCustom The 4-arg updateData(filter, data, customSet, customUnset) made the dominant case read as updateData(filter, null, Map.of("group", "old"), null): a null sandwich for a request that only patches one custom key. Drop that overload and restore updateData(filter, data) to its pre-existing form. The patch-only case is now updateCustom(filter, customSet, customUnset), with no data argument, and the rare combined case is updateData(filter, data, ChannelCustomPatch), a two-member helper value type next to ChannelsBatchOptions that is unpacked into custom_set and custom_unset and never serialized itself. The wire shape is unchanged: ChannelsBatchOptions still carries customSet and customUnset as the root-level custom_set and custom_unset. Co-Authored-By: Claude Opus 5 --- .../channel_management/batch-updates.md | 15 ++++- .../getstream/chat/java/models/Channel.java | 27 ++++++++ .../chat/java/models/ChannelBatchUpdater.java | 52 +++++++++++--- .../java/ChannelBatchCustomPatchTest.java | 67 +++++++++++++++++-- 4 files changed, 144 insertions(+), 17 deletions(-) diff --git a/docs/channels/channel_management/batch-updates.md b/docs/channels/channel_management/batch-updates.md index 69dc021b2..fcecfa05d 100644 --- a/docs/channels/channel_management/batch-updates.md +++ b/docs/channels/channel_management/batch-updates.md @@ -112,13 +112,24 @@ var updater = Channel.channelBatchUpdater(); var filter = new ChannelsBatchFilters(); filter.setCids(Map.of("$in", List.of("messaging:a", "messaging:b"))); +var resp = updater.updateCustom(filter, Map.of("group", "old"), List.of("location_id")).request(); +``` + +Pass `null` for either argument to only set or only delete keys. + +To change other channel properties in the same request, use the `updateData` overload that takes a `ChannelCustomPatch` alongside the channel data: + +```java +var data = new ChannelDataUpdate(); +data.setFrozen(true); + var resp = updater - .updateData(filter, null, Map.of("group", "old"), List.of("location_id")) + .updateData(filter, data, new ChannelCustomPatch(Map.of("group", "old"), null)) .request(); ``` -The same fields are available on `ChannelsBatchOptions` directly, via `setCustomSet` and `setCustomUnset`, and can be combined with the other channel data properties in the same `updateData` request as long as `custom` is not set. +The same fields are also available on `ChannelsBatchOptions` directly, via `setCustomSet` and `setCustomUnset`. Most of the operations require additional parameters to be specified, such as the _members_ to add or remove, or the _channelData_ to update. diff --git a/src/main/java/io/getstream/chat/java/models/Channel.java b/src/main/java/io/getstream/chat/java/models/Channel.java index 4909ca8d9..e1e186999 100644 --- a/src/main/java/io/getstream/chat/java/models/Channel.java +++ b/src/main/java/io/getstream/chat/java/models/Channel.java @@ -2087,6 +2087,33 @@ public static class ChannelsBatchFilters { private Object types; } + /** + * A patch of individual custom keys for a batch channel update: keys to merge in and keys to + * delete, leaving every other custom key untouched. + * + *

This is a helper-level type only and is never serialized. {@link + * ChannelBatchUpdater#updateData(ChannelsBatchFilters, ChannelDataUpdate, ChannelCustomPatch)} + * unpacks it into {@link ChannelsBatchOptions#setCustomSet(Map)} and {@link + * ChannelsBatchOptions#setCustomUnset(List)}, which are the two fields the request carries; the + * patch itself never appears in the request body. + */ + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class ChannelCustomPatch { + /** + * Custom keys to merge into each matched channel's existing custom object. Keys are dot-paths, + * so {@code a.b} sets key {@code b} inside object {@code a}. + */ + @Nullable private Map customSet; + + /** + * Custom keys to delete from each matched channel's existing custom object. Keys are dot-paths; + * deleting a key that does not exist is a no-op. + */ + @Nullable private List customUnset; + } + /** Represents options for batch channel updates */ @Data @NoArgsConstructor diff --git a/src/main/java/io/getstream/chat/java/models/ChannelBatchUpdater.java b/src/main/java/io/getstream/chat/java/models/ChannelBatchUpdater.java index d9edef731..8e8a31d78 100644 --- a/src/main/java/io/getstream/chat/java/models/ChannelBatchUpdater.java +++ b/src/main/java/io/getstream/chat/java/models/ChannelBatchUpdater.java @@ -191,35 +191,65 @@ public ChannelsBatchUpdateRequest unarchive( @NotNull public ChannelsBatchUpdateRequest updateData( @NotNull ChannelsBatchFilters filter, @NotNull ChannelDataUpdate data) { - return updateData(filter, data, null, null); + ChannelsBatchOptions options = new ChannelsBatchOptions(); + options.setOperation(ChannelBatchOperation.UPDATE_DATA); + options.setFilter(filter); + options.setData(data); + return Channel.updateBatch(options); + } + + /** + * Updates data on channels matching the filter and patches individual custom keys with the given + * patch, leaving every other custom key untouched. + * + *

Use {@link #updateCustom(ChannelsBatchFilters, Map, List)} when only custom keys change. The + * patch cannot be combined with {@code data.custom}, which replaces the whole custom object; the + * backend validates that and the other combinations it rejects. + * + * @param filter the filter to match channels + * @param data channel data to update + * @param patch the custom keys to merge in and to delete + * @return the batch update request + */ + @NotNull + public ChannelsBatchUpdateRequest updateData( + @NotNull ChannelsBatchFilters filter, + @NotNull ChannelDataUpdate data, + @NotNull ChannelCustomPatch patch) { + ChannelsBatchOptions options = new ChannelsBatchOptions(); + options.setOperation(ChannelBatchOperation.UPDATE_DATA); + options.setFilter(filter); + options.setData(data); + options.setCustomSet(patch.getCustomSet() != null ? new HashMap<>(patch.getCustomSet()) : null); + options.setCustomUnset( + patch.getCustomUnset() != null ? new ArrayList<>(patch.getCustomUnset()) : null); + return Channel.updateBatch(options); } /** - * Updates data on channels matching the filter, patching individual custom keys instead of - * replacing the whole custom object. + * Patches individual custom keys on channels matching the filter, leaving every other custom key + * and every other channel property untouched. * *

{@code customSet} merges its keys into each channel's existing custom object and {@code - * customUnset} deletes its keys, both leaving every other custom key untouched. Keys are - * dot-paths. They cannot be combined with {@code data.custom}, which replaces the whole object; - * the backend validates that and the other combinations it rejects. Pass a null {@code data} to - * send a custom patch on its own. + * customUnset} deletes its keys, unlike {@code data.custom}, which replaces the whole object. + * Keys are dot-paths, so {@code a.b} addresses key {@code b} inside object {@code a}. Use {@link + * #updateData(ChannelsBatchFilters, ChannelDataUpdate, ChannelCustomPatch)} to change other + * channel properties in the same request. The backend owns the rules for which combinations it + * rejects. * * @param filter the filter to match channels - * @param data channel data to update, or null to only patch custom keys * @param customSet custom keys to merge in, or null * @param customUnset custom keys to delete, or null * @return the batch update request */ @NotNull - public ChannelsBatchUpdateRequest updateData( + public ChannelsBatchUpdateRequest updateCustom( @NotNull ChannelsBatchFilters filter, - @Nullable ChannelDataUpdate data, @Nullable Map customSet, @Nullable List customUnset) { ChannelsBatchOptions options = new ChannelsBatchOptions(); options.setOperation(ChannelBatchOperation.UPDATE_DATA); options.setFilter(filter); - options.setData(data); options.setCustomSet(customSet != null ? new HashMap<>(customSet) : null); options.setCustomUnset(customUnset != null ? new ArrayList<>(customUnset) : null); return Channel.updateBatch(options); diff --git a/src/test/java/io/getstream/chat/java/ChannelBatchCustomPatchTest.java b/src/test/java/io/getstream/chat/java/ChannelBatchCustomPatchTest.java index 50354924c..e1a64866a 100644 --- a/src/test/java/io/getstream/chat/java/ChannelBatchCustomPatchTest.java +++ b/src/test/java/io/getstream/chat/java/ChannelBatchCustomPatchTest.java @@ -6,9 +6,11 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.getstream.chat.java.models.Channel; import io.getstream.chat.java.models.Channel.ChannelBatchOperation; +import io.getstream.chat.java.models.Channel.ChannelCustomPatch; import io.getstream.chat.java.models.Channel.ChannelDataUpdate; import io.getstream.chat.java.models.Channel.ChannelsBatchFilters; import io.getstream.chat.java.models.Channel.ChannelsBatchOptions; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -72,12 +74,12 @@ void whenCustomPatchNotSet_thenOmittedFromTheRequest() throws Exception { Assertions.assertFalse(root.has("custom_unset")); } - @DisplayName("ChannelBatchUpdater carries the custom patch without data") + @DisplayName("updateCustom carries the custom patch and sends no data") @Test - void whenUpdatingDataWithCustomPatchOnly_thenOptionsCarryTheFields() { + void whenUpdatingCustomOnly_thenOptionsCarryTheFieldsWithoutData() { var options = Channel.channelBatchUpdater() - .updateData(filterByCids(), null, Map.of("group", "old"), List.of("location_id")) + .updateCustom(filterByCids(), Map.of("group", "old"), List.of("location_id")) .getOptions(); Assertions.assertEquals(ChannelBatchOperation.UPDATE_DATA, options.getOperation()); @@ -86,7 +88,27 @@ void whenUpdatingDataWithCustomPatchOnly_thenOptionsCarryTheFields() { Assertions.assertEquals(List.of("location_id"), options.getCustomUnset()); } - @DisplayName("ChannelBatchUpdater leaves the custom patch unset when only data is given") + @DisplayName("updateData carries channel data and the custom patch together") + @Test + void whenUpdatingDataWithAPatch_thenOptionsCarryBoth() { + var data = new ChannelDataUpdate(); + data.setFrozen(true); + + var options = + Channel.channelBatchUpdater() + .updateData( + filterByCids(), + data, + new ChannelCustomPatch(Map.of("group", "old"), List.of("location_id"))) + .getOptions(); + + Assertions.assertEquals(ChannelBatchOperation.UPDATE_DATA, options.getOperation()); + Assertions.assertEquals(Boolean.TRUE, options.getData().getFrozen()); + Assertions.assertEquals(Map.of("group", "old"), options.getCustomSet()); + Assertions.assertEquals(List.of("location_id"), options.getCustomUnset()); + } + + @DisplayName("updateData leaves the custom patch unset when only data is given") @Test void whenUpdatingDataOnly_thenCustomPatchStaysNull() { var data = new ChannelDataUpdate(); @@ -97,4 +119,41 @@ void whenUpdatingDataOnly_thenCustomPatchStaysNull() { Assertions.assertNull(options.getCustomSet()); Assertions.assertNull(options.getCustomUnset()); } + + @DisplayName("ChannelCustomPatch is unpacked into the two request fields and never serialized") + @Test + void whenUpdatingDataWithAPatch_thenThePatchItselfIsAbsentFromTheRequest() throws Exception { + var data = new ChannelDataUpdate(); + data.setFrozen(true); + + // ChannelService.updateBatch sends the options object as the request body, so serializing + // it is the wire payload. + var options = + Channel.channelBatchUpdater() + .updateData( + filterByCids(), + data, + new ChannelCustomPatch(Map.of("group", "old"), List.of("location_id"))) + .getOptions(); + + String body = MAPPER.writeValueAsString(options); + JsonNode root = MAPPER.readTree(body); + + // The patch is unpacked into the two flat fields, and no nested patch object survives. + Assertions.assertEquals("old", root.path("custom_set").path("group").asText()); + Assertions.assertEquals( + List.of("location_id"), List.of(root.path("custom_unset").get(0).asText())); + Assertions.assertFalse(body.contains("customSet")); + Assertions.assertFalse(body.contains("customUnset")); + Assertions.assertFalse(body.contains("Patch")); + for (var name : List.of("patch", "custom_patch", "customPatch", "channelCustomPatch")) { + Assertions.assertFalse(root.has(name), "unexpected field " + name); + } + // The root carries only the fields ChannelsBatchOptions declares; members is serialized as + // null because the options object has no class-level NON_NULL inclusion. + var fields = new ArrayList(); + root.fieldNames().forEachRemaining(fields::add); + Assertions.assertEquals( + List.of("operation", "filter", "members", "data", "custom_set", "custom_unset"), fields); + } } From 51290ff4f2b8dba7bca726a0251d576623760146 Mon Sep 17 00:00:00 2001 From: Kanat Date: Tue, 8 Sep 2026 16:01:50 -0400 Subject: [PATCH 3/4] refactor(channel): use updateData for batch custom patches --- .../channel_management/batch-updates.md | 19 +++++-- .../getstream/chat/java/models/Channel.java | 16 +++--- .../chat/java/models/ChannelBatchUpdater.java | 54 ++++--------------- .../java/ChannelBatchCustomPatchTest.java | 43 ++++++++------- 4 files changed, 52 insertions(+), 80 deletions(-) diff --git a/docs/channels/channel_management/batch-updates.md b/docs/channels/channel_management/batch-updates.md index fcecfa05d..e69d0a6a7 100644 --- a/docs/channels/channel_management/batch-updates.md +++ b/docs/channels/channel_management/batch-updates.md @@ -112,12 +112,16 @@ var updater = Channel.channelBatchUpdater(); var filter = new ChannelsBatchFilters(); filter.setCids(Map.of("$in", List.of("messaging:a", "messaging:b"))); -var resp = updater.updateCustom(filter, Map.of("group", "old"), List.of("location_id")).request(); -``` +var update = + ChannelBatchDataUpdateOptions.builder() + .customSet(Map.of("group", "old")) + .customUnset(List.of("location_id")) + .build(); -Pass `null` for either argument to only set or only delete keys. +var resp = updater.updateData(filter, update).request(); +``` -To change other channel properties in the same request, use the `updateData` overload that takes a `ChannelCustomPatch` alongside the channel data: +To change other channel properties in the same request, add `data` to the same options object: ```java var data = new ChannelDataUpdate(); @@ -125,7 +129,12 @@ data.setFrozen(true); var resp = updater - .updateData(filter, data, new ChannelCustomPatch(Map.of("group", "old"), null)) + .updateData( + filter, + ChannelBatchDataUpdateOptions.builder() + .data(data) + .customSet(Map.of("group", "old")) + .build()) .request(); ``` diff --git a/src/main/java/io/getstream/chat/java/models/Channel.java b/src/main/java/io/getstream/chat/java/models/Channel.java index e1e186999..37a2b28cc 100644 --- a/src/main/java/io/getstream/chat/java/models/Channel.java +++ b/src/main/java/io/getstream/chat/java/models/Channel.java @@ -2088,19 +2088,17 @@ public static class ChannelsBatchFilters { } /** - * A patch of individual custom keys for a batch channel update: keys to merge in and keys to - * delete, leaving every other custom key untouched. - * - *

This is a helper-level type only and is never serialized. {@link - * ChannelBatchUpdater#updateData(ChannelsBatchFilters, ChannelDataUpdate, ChannelCustomPatch)} - * unpacks it into {@link ChannelsBatchOptions#setCustomSet(Map)} and {@link - * ChannelsBatchOptions#setCustomUnset(List)}, which are the two fields the request carries; the - * patch itself never appears in the request body. + * Options for updating channel data in a batch. This helper is unpacked into {@link + * ChannelsBatchOptions} and is not serialized itself. */ @Data @NoArgsConstructor @AllArgsConstructor - public static class ChannelCustomPatch { + @Builder + public static class ChannelBatchDataUpdateOptions { + /** Other channel data to update. */ + @Nullable private ChannelDataUpdate data; + /** * Custom keys to merge into each matched channel's existing custom object. Keys are dot-paths, * so {@code a.b} sets key {@code b} inside object {@code a}. diff --git a/src/main/java/io/getstream/chat/java/models/ChannelBatchUpdater.java b/src/main/java/io/getstream/chat/java/models/ChannelBatchUpdater.java index 8e8a31d78..f7df7fd6d 100644 --- a/src/main/java/io/getstream/chat/java/models/ChannelBatchUpdater.java +++ b/src/main/java/io/getstream/chat/java/models/ChannelBatchUpdater.java @@ -4,9 +4,7 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; -import java.util.Map; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; /** Provides convenience methods for batch channel operations. */ public class ChannelBatchUpdater { @@ -199,59 +197,27 @@ public ChannelsBatchUpdateRequest updateData( } /** - * Updates data on channels matching the filter and patches individual custom keys with the given - * patch, leaving every other custom key untouched. + * Updates data on channels matching the filter. * - *

Use {@link #updateCustom(ChannelsBatchFilters, Map, List)} when only custom keys change. The - * patch cannot be combined with {@code data.custom}, which replaces the whole custom object; the - * backend validates that and the other combinations it rejects. + *

{@code customSet} and {@code customUnset} patch individual custom keys, leaving every other + * custom key untouched. They cannot be combined with {@code data.custom}, which replaces the + * whole custom object; the backend validates that and the other combinations it rejects. * * @param filter the filter to match channels - * @param data channel data to update - * @param patch the custom keys to merge in and to delete + * @param update options containing channel data and custom keys to update * @return the batch update request */ @NotNull public ChannelsBatchUpdateRequest updateData( - @NotNull ChannelsBatchFilters filter, - @NotNull ChannelDataUpdate data, - @NotNull ChannelCustomPatch patch) { + @NotNull ChannelsBatchFilters filter, @NotNull ChannelBatchDataUpdateOptions update) { ChannelsBatchOptions options = new ChannelsBatchOptions(); options.setOperation(ChannelBatchOperation.UPDATE_DATA); options.setFilter(filter); - options.setData(data); - options.setCustomSet(patch.getCustomSet() != null ? new HashMap<>(patch.getCustomSet()) : null); + options.setData(update.getData()); + options.setCustomSet( + update.getCustomSet() != null ? new HashMap<>(update.getCustomSet()) : null); options.setCustomUnset( - patch.getCustomUnset() != null ? new ArrayList<>(patch.getCustomUnset()) : null); - return Channel.updateBatch(options); - } - - /** - * Patches individual custom keys on channels matching the filter, leaving every other custom key - * and every other channel property untouched. - * - *

{@code customSet} merges its keys into each channel's existing custom object and {@code - * customUnset} deletes its keys, unlike {@code data.custom}, which replaces the whole object. - * Keys are dot-paths, so {@code a.b} addresses key {@code b} inside object {@code a}. Use {@link - * #updateData(ChannelsBatchFilters, ChannelDataUpdate, ChannelCustomPatch)} to change other - * channel properties in the same request. The backend owns the rules for which combinations it - * rejects. - * - * @param filter the filter to match channels - * @param customSet custom keys to merge in, or null - * @param customUnset custom keys to delete, or null - * @return the batch update request - */ - @NotNull - public ChannelsBatchUpdateRequest updateCustom( - @NotNull ChannelsBatchFilters filter, - @Nullable Map customSet, - @Nullable List customUnset) { - ChannelsBatchOptions options = new ChannelsBatchOptions(); - options.setOperation(ChannelBatchOperation.UPDATE_DATA); - options.setFilter(filter); - options.setCustomSet(customSet != null ? new HashMap<>(customSet) : null); - options.setCustomUnset(customUnset != null ? new ArrayList<>(customUnset) : null); + update.getCustomUnset() != null ? new ArrayList<>(update.getCustomUnset()) : null); return Channel.updateBatch(options); } } diff --git a/src/test/java/io/getstream/chat/java/ChannelBatchCustomPatchTest.java b/src/test/java/io/getstream/chat/java/ChannelBatchCustomPatchTest.java index e1a64866a..397292e43 100644 --- a/src/test/java/io/getstream/chat/java/ChannelBatchCustomPatchTest.java +++ b/src/test/java/io/getstream/chat/java/ChannelBatchCustomPatchTest.java @@ -5,8 +5,8 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import io.getstream.chat.java.models.Channel; +import io.getstream.chat.java.models.Channel.ChannelBatchDataUpdateOptions; import io.getstream.chat.java.models.Channel.ChannelBatchOperation; -import io.getstream.chat.java.models.Channel.ChannelCustomPatch; import io.getstream.chat.java.models.Channel.ChannelDataUpdate; import io.getstream.chat.java.models.Channel.ChannelsBatchFilters; import io.getstream.chat.java.models.Channel.ChannelsBatchOptions; @@ -74,12 +74,17 @@ void whenCustomPatchNotSet_thenOmittedFromTheRequest() throws Exception { Assertions.assertFalse(root.has("custom_unset")); } - @DisplayName("updateCustom carries the custom patch and sends no data") + @DisplayName("updateData supports a custom-only patch without a null data placeholder") @Test void whenUpdatingCustomOnly_thenOptionsCarryTheFieldsWithoutData() { var options = Channel.channelBatchUpdater() - .updateCustom(filterByCids(), Map.of("group", "old"), List.of("location_id")) + .updateData( + filterByCids(), + ChannelBatchDataUpdateOptions.builder() + .customSet(Map.of("group", "old")) + .customUnset(List.of("location_id")) + .build()) .getOptions(); Assertions.assertEquals(ChannelBatchOperation.UPDATE_DATA, options.getOperation()); @@ -88,7 +93,7 @@ void whenUpdatingCustomOnly_thenOptionsCarryTheFieldsWithoutData() { Assertions.assertEquals(List.of("location_id"), options.getCustomUnset()); } - @DisplayName("updateData carries channel data and the custom patch together") + @DisplayName("updateData carries channel data and custom patches together") @Test void whenUpdatingDataWithAPatch_thenOptionsCarryBoth() { var data = new ChannelDataUpdate(); @@ -98,8 +103,11 @@ void whenUpdatingDataWithAPatch_thenOptionsCarryBoth() { Channel.channelBatchUpdater() .updateData( filterByCids(), - data, - new ChannelCustomPatch(Map.of("group", "old"), List.of("location_id"))) + ChannelBatchDataUpdateOptions.builder() + .data(data) + .customSet(Map.of("group", "old")) + .customUnset(List.of("location_id")) + .build()) .getOptions(); Assertions.assertEquals(ChannelBatchOperation.UPDATE_DATA, options.getOperation()); @@ -120,37 +128,28 @@ void whenUpdatingDataOnly_thenCustomPatchStaysNull() { Assertions.assertNull(options.getCustomUnset()); } - @DisplayName("ChannelCustomPatch is unpacked into the two request fields and never serialized") + @DisplayName("The helper options are unpacked and never serialized") @Test void whenUpdatingDataWithAPatch_thenThePatchItselfIsAbsentFromTheRequest() throws Exception { var data = new ChannelDataUpdate(); data.setFrozen(true); - // ChannelService.updateBatch sends the options object as the request body, so serializing - // it is the wire payload. var options = Channel.channelBatchUpdater() .updateData( filterByCids(), - data, - new ChannelCustomPatch(Map.of("group", "old"), List.of("location_id"))) + ChannelBatchDataUpdateOptions.builder() + .data(data) + .customSet(Map.of("group", "old")) + .customUnset(List.of("location_id")) + .build()) .getOptions(); - String body = MAPPER.writeValueAsString(options); - JsonNode root = MAPPER.readTree(body); + JsonNode root = MAPPER.readTree(MAPPER.writeValueAsString(options)); - // The patch is unpacked into the two flat fields, and no nested patch object survives. Assertions.assertEquals("old", root.path("custom_set").path("group").asText()); Assertions.assertEquals( List.of("location_id"), List.of(root.path("custom_unset").get(0).asText())); - Assertions.assertFalse(body.contains("customSet")); - Assertions.assertFalse(body.contains("customUnset")); - Assertions.assertFalse(body.contains("Patch")); - for (var name : List.of("patch", "custom_patch", "customPatch", "channelCustomPatch")) { - Assertions.assertFalse(root.has(name), "unexpected field " + name); - } - // The root carries only the fields ChannelsBatchOptions declares; members is serialized as - // null because the options object has no class-level NON_NULL inclusion. var fields = new ArrayList(); root.fieldNames().forEachRemaining(fields::add); Assertions.assertEquals( From 4019485963848a0aa4647f035a69b336c6ffa2f0 Mon Sep 17 00:00:00 2001 From: Kanat Date: Wed, 9 Sep 2026 10:56:07 -0400 Subject: [PATCH 4/4] fix(channel): omit null custom data in batch updates Omit ChannelDataUpdate.custom when it is unset so the v1 extra-fields decoder does not treat null as a whole custom replacement. This allows data to be combined with custom_set/custom_unset and prevents data-only updates from replacing existing custom data. Replace the ordered root-field assertion with targeted wire assertions for data.custom and helper-only field names. --- .../java/io/getstream/chat/java/models/Channel.java | 1 + .../chat/java/ChannelBatchCustomPatchTest.java | 10 +++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/main/java/io/getstream/chat/java/models/Channel.java b/src/main/java/io/getstream/chat/java/models/Channel.java index 37a2b28cc..b7637d02f 100644 --- a/src/main/java/io/getstream/chat/java/models/Channel.java +++ b/src/main/java/io/getstream/chat/java/models/Channel.java @@ -2054,6 +2054,7 @@ public static class ChannelDataUpdate { @Nullable @JsonProperty("custom") + @JsonInclude(JsonInclude.Include.NON_NULL) private Map custom; @Nullable diff --git a/src/test/java/io/getstream/chat/java/ChannelBatchCustomPatchTest.java b/src/test/java/io/getstream/chat/java/ChannelBatchCustomPatchTest.java index 397292e43..b15ce40ee 100644 --- a/src/test/java/io/getstream/chat/java/ChannelBatchCustomPatchTest.java +++ b/src/test/java/io/getstream/chat/java/ChannelBatchCustomPatchTest.java @@ -10,7 +10,6 @@ import io.getstream.chat.java.models.Channel.ChannelDataUpdate; import io.getstream.chat.java.models.Channel.ChannelsBatchFilters; import io.getstream.chat.java.models.Channel.ChannelsBatchOptions; -import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -72,6 +71,7 @@ void whenCustomPatchNotSet_thenOmittedFromTheRequest() throws Exception { Assertions.assertFalse(root.has("custom_set")); Assertions.assertFalse(root.has("custom_unset")); + Assertions.assertFalse(root.path("data").has("custom")); } @DisplayName("updateData supports a custom-only patch without a null data placeholder") @@ -150,9 +150,9 @@ void whenUpdatingDataWithAPatch_thenThePatchItselfIsAbsentFromTheRequest() throw Assertions.assertEquals("old", root.path("custom_set").path("group").asText()); Assertions.assertEquals( List.of("location_id"), List.of(root.path("custom_unset").get(0).asText())); - var fields = new ArrayList(); - root.fieldNames().forEachRemaining(fields::add); - Assertions.assertEquals( - List.of("operation", "filter", "members", "data", "custom_set", "custom_unset"), fields); + Assertions.assertFalse(root.path("data").has("custom")); + Assertions.assertFalse(root.has("customSet")); + Assertions.assertFalse(root.has("customUnset")); + Assertions.assertFalse(root.has("update")); } }