diff --git a/docs/channels/channel_management/batch-updates.md b/docs/channels/channel_management/batch-updates.md index 1ac25870a..e69d0a6a7 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,55 @@ 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 update = + ChannelBatchDataUpdateOptions.builder() + .customSet(Map.of("group", "old")) + .customUnset(List.of("location_id")) + .build(); + +var resp = updater.updateData(filter, update).request(); +``` + +To change other channel properties in the same request, add `data` to the same options object: + +```java +var data = new ChannelDataUpdate(); +data.setFrozen(true); + +var resp = + updater + .updateData( + filter, + ChannelBatchDataUpdateOptions.builder() + .data(data) + .customSet(Map.of("group", "old")) + .build()) + .request(); +``` + +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. 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..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 @@ -2087,6 +2088,31 @@ public static class ChannelsBatchFilters { private Object types; } + /** + * Options for updating channel data in a batch. This helper is unpacked into {@link + * ChannelsBatchOptions} and is not serialized itself. + */ + @Data + @NoArgsConstructor + @AllArgsConstructor + @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}. + */ + @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 @@ -2106,6 +2132,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..f7df7fd6d 100644 --- a/src/main/java/io/getstream/chat/java/models/ChannelBatchUpdater.java +++ b/src/main/java/io/getstream/chat/java/models/ChannelBatchUpdater.java @@ -2,6 +2,7 @@ import io.getstream.chat.java.models.Channel.*; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import org.jetbrains.annotations.NotNull; @@ -194,4 +195,29 @@ public ChannelsBatchUpdateRequest updateData( options.setData(data); return Channel.updateBatch(options); } + + /** + * Updates data on channels matching the filter. + * + *

{@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 update options containing channel data and custom keys to update + * @return the batch update request + */ + @NotNull + public ChannelsBatchUpdateRequest updateData( + @NotNull ChannelsBatchFilters filter, @NotNull ChannelBatchDataUpdateOptions update) { + ChannelsBatchOptions options = new ChannelsBatchOptions(); + options.setOperation(ChannelBatchOperation.UPDATE_DATA); + options.setFilter(filter); + options.setData(update.getData()); + options.setCustomSet( + update.getCustomSet() != null ? new HashMap<>(update.getCustomSet()) : null); + options.setCustomUnset( + 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 new file mode 100644 index 000000000..b15ce40ee --- /dev/null +++ b/src/test/java/io/getstream/chat/java/ChannelBatchCustomPatchTest.java @@ -0,0 +1,158 @@ +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.ChannelBatchDataUpdateOptions; +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")); + Assertions.assertFalse(root.path("data").has("custom")); + } + + @DisplayName("updateData supports a custom-only patch without a null data placeholder") + @Test + void whenUpdatingCustomOnly_thenOptionsCarryTheFieldsWithoutData() { + var options = + Channel.channelBatchUpdater() + .updateData( + filterByCids(), + ChannelBatchDataUpdateOptions.builder() + .customSet(Map.of("group", "old")) + .customUnset(List.of("location_id")) + .build()) + .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("updateData carries channel data and custom patches together") + @Test + void whenUpdatingDataWithAPatch_thenOptionsCarryBoth() { + var data = new ChannelDataUpdate(); + data.setFrozen(true); + + var options = + Channel.channelBatchUpdater() + .updateData( + filterByCids(), + ChannelBatchDataUpdateOptions.builder() + .data(data) + .customSet(Map.of("group", "old")) + .customUnset(List.of("location_id")) + .build()) + .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(); + data.setFrozen(true); + + var options = Channel.channelBatchUpdater().updateData(filterByCids(), data).getOptions(); + + Assertions.assertNull(options.getCustomSet()); + Assertions.assertNull(options.getCustomUnset()); + } + + @DisplayName("The helper options are unpacked and never serialized") + @Test + void whenUpdatingDataWithAPatch_thenThePatchItselfIsAbsentFromTheRequest() throws Exception { + var data = new ChannelDataUpdate(); + data.setFrozen(true); + + var options = + Channel.channelBatchUpdater() + .updateData( + filterByCids(), + ChannelBatchDataUpdateOptions.builder() + .data(data) + .customSet(Map.of("group", "old")) + .customUnset(List.of("location_id")) + .build()) + .getOptions(); + + JsonNode root = MAPPER.readTree(MAPPER.writeValueAsString(options)); + + 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(root.path("data").has("custom")); + Assertions.assertFalse(root.has("customSet")); + Assertions.assertFalse(root.has("customUnset")); + Assertions.assertFalse(root.has("update")); + } +}