Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 50 additions & 1 deletion docs/channels/channel_management/batch-updates.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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:
Expand Down
48 changes: 48 additions & 0 deletions src/main/java/io/getstream/chat/java/models/Channel.java
Original file line number Diff line number Diff line change
Expand Up @@ -2054,6 +2054,7 @@ public static class ChannelDataUpdate {

@Nullable
@JsonProperty("custom")
@JsonInclude(JsonInclude.Include.NON_NULL)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Should Fix] This also changes behavior for plain updateData calls that never touch custom: before this line the SDK always sent "custom": null, which replaced each matched channel's whole custom object. Please call that out in the release notes so upgrading users know their earlier batch updateData calls were affected.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a Release note section to the PR description covering the impact on existing updateData calls.

private Map<String, Object> custom;

@Nullable
Expand Down Expand Up @@ -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<String, Object> 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<String> customUnset;
}

/** Represents options for batch channel updates */
@Data
@NoArgsConstructor
Expand All @@ -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<String, Object> 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<String> customUnset;
}

@Getter
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -194,4 +195,29 @@ public ChannelsBatchUpdateRequest updateData(
options.setData(data);
return Channel.updateBatch(options);
}

/**
* Updates data on channels matching the filter.
*
* <p>{@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());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Must Fix] data plus customSet in one request always comes back 400.

ChannelDataUpdate has no null-inclusion setting, so Jackson writes every unset field, custom included:

{"operation":"updateData","filter":{...},"members":null,
 "data":{"frozen":true,"disabled":null,"custom":null,"team":null,...},
 "custom_set":{"group":"old"},"custom_unset":["location_id"]}

On the v1 route data.custom is the extra-fields sink, and a null-valued key is still captured into it (kit/jsonextra/decode_test.go:870, spec section 5.3). So Data.Custom decodes to ExtraFields{"custom": nil}, which is non-nil, and ChannelBatchUpdateRequest.ValidateStruct (chat/lib/chat/controller/v1/payload/channel_batch_update.go:57) returns:

'custom' (and any other custom field sent directly in 'data') replaces the whole custom object and thus cannot be used together with 'custom_set' or 'custom_unset'

That is the second example in the PR description and in docs/channels/channel_management/batch-updates.md. The custom-only path is fine, only the combined one fails.

Same root cause, already shipping today on the existing updateData(filter, data) path: "custom": null makes HasCustomUpdate() true, so chat/monolith/tasks/channelstasks/batch/operations/update_channel_data_processor.go:59-63 writes the custom column and replaces each matched channel's whole custom object with {"custom": null}. The channel display name lives in custom, so a batch updateData that only sets frozen deletes it.

Fix: @JsonInclude(JsonInclude.Include.NON_NULL) on ChannelDataUpdate.custom (Channel.java:2056), or class-level on ChannelDataUpdate to match ChannelsBatchFilters at Channel.java:2079.

How I checked

Jackson side, same mapper config as DefaultClient.buildRetrofitClient (ALL=NONE, FIELD=ANY), same annotations as ChannelDataUpdate and post-PR ChannelsBatchOptions:

data + patch  : {"operation":"updateData","filter":{"cids":{"$in":["messaging:a"]}},"members":null,"data":{"frozen":true,"disabled":null,"custom":null,"team":null,"config_overrides":null,"auto_translation_enabled":null,"auto_translation_language":null},"custom_set":{"group":"old"},"custom_unset":["location_id"]}
patch only    : {"operation":"updateData","filter":{"cids":{"$in":["messaging:a"]}},"members":null,"data":null,"custom_set":{"group":"old"}}

Backend side, feeding that exact body through jsonextra.Unmarshal into payload.ChannelBatchUpdateRequest on chat main (085fdb38adf):

java-shape:     Custom==nil=false  Custom=jsonextra.ExtraFields{"custom":interface {}(nil)}
java-shape:     ValidateStruct=400 'custom' ... cannot be used together with 'custom_set' or 'custom_unset'
omitted-shape:  Custom==nil=true   ValidateStruct=<nil>
custom-only:    Custom==nil=true   ValidateStruct=<nil>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 40194859ChannelDataUpdate.custom is now omitted when null, covering both the combined patch and existing data-only paths.

options.setCustomSet(
update.getCustomSet() != null ? new HashMap<>(update.getCustomSet()) : null);
options.setCustomUnset(
update.getCustomUnset() != null ? new ArrayList<>(update.getCustomUnset()) : null);
return Channel.updateBatch(options);
}
}
158 changes: 158 additions & 0 deletions src/test/java/io/getstream/chat/java/ChannelBatchCustomPatchTest.java
Original file line number Diff line number Diff line change
@@ -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<String, Object> 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<String, Object> 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"));
}
}
Loading