diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt index 3edf2687e95..7702d549d52 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt @@ -131,6 +131,7 @@ import io.getstream.chat.android.network.models.QueryPollsResponse import io.getstream.chat.android.network.models.ReactionGroupResponse import io.getstream.chat.android.network.models.ReactionResponse import io.getstream.chat.android.network.models.ReadStateResponse +import io.getstream.chat.android.network.models.SortParamRequest import io.getstream.chat.android.network.models.UnreadCountsChannel import io.getstream.chat.android.network.models.UnreadCountsChannelType import io.getstream.chat.android.network.models.UnreadCountsThread @@ -1137,12 +1138,11 @@ internal class DomainMapping( defaultPreference = ChatPreferenceToggle.fromValue(defaultPreference), ) - internal fun List>?.toSortDomain(): QuerySorter? { + internal fun List?.toSortDomain(): QuerySorter? { if (isNullOrEmpty()) return null - return fold(QuerySortByField()) { sort, sortSpecMap -> - val fieldName = sortSpecMap[QuerySorter.KEY_FIELD_NAME] as? String ?: return null - val direction = (sortSpecMap[QuerySorter.KEY_DIRECTION] as? Number)?.toInt() ?: return null - when (direction) { + return fold(QuerySortByField()) { sort, sortParam -> + val fieldName = sortParam.field ?: return null + when (sortParam.direction) { SortDirection.ASC.value -> sort.asc(fieldName) SortDirection.DESC.value -> sort.desc(fieldName) else -> return null diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/FilterDomainMapping.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/FilterDomainMapping.kt index 94c12cc3125..8c6a45f55c3 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/FilterDomainMapping.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/FilterDomainMapping.kt @@ -33,7 +33,7 @@ import io.getstream.chat.android.models.NeutralFilterObject * * Returns `null` if the map is `null` or cannot be parsed. */ -internal fun Map?.toFilterDomainWithFields(): Pair>? { +internal fun Map?.toFilterDomainWithFields(): Pair>? { if (this == null) return null val fields = mutableSetOf() val filter = parseFilterMap(this, fields) ?: return null @@ -41,7 +41,7 @@ internal fun Map?.toFilterDomainWithFields(): Pair, fields: MutableSet): FilterObject? { +private fun parseFilterMap(map: Map, fields: MutableSet): FilterObject? { if (map.isEmpty()) return NeutralFilterObject if (map.size == 2 && map.containsKey(KEY_DISTINCT) && map.containsKey(KEY_MEMBERS)) { @@ -62,7 +62,7 @@ private fun parseFilterMap(map: Map, fields: MutableSet): F } @Suppress("SpreadOperator") -private fun parseSingleEntry(key: String, value: Any, fields: MutableSet): FilterObject? = when (key) { +private fun parseSingleEntry(key: String, value: Any?, fields: MutableSet): FilterObject? = when (key) { KEY_AND -> parseLogicalOperator(value, fields) { Filters.and(*it) } KEY_OR -> parseLogicalOperator(value, fields) { Filters.or(*it) } KEY_NOR -> parseLogicalOperator(value, fields) { Filters.nor(*it) } @@ -71,29 +71,31 @@ private fun parseSingleEntry(key: String, value: Any, fields: MutableSet @Suppress("UNCHECKED_CAST") private fun parseLogicalOperator( - value: Any, + value: Any?, fields: MutableSet, factory: (Array) -> FilterObject, ): FilterObject? { val list = value as? List<*> ?: return null val filters = list.mapNotNull { item -> - (item as? Map)?.let { parseFilterMap(it, fields) } + (item as? Map)?.let { parseFilterMap(it, fields) } } if (filters.isEmpty()) return null return factory(filters.toTypedArray()) } @Suppress("ComplexMethod", "DEPRECATION") -private fun parseFieldFilter(fieldName: String, value: Any, fields: MutableSet): FilterObject? { +private fun parseFieldFilter(fieldName: String, value: Any?, fields: MutableSet): FilterObject? { fields.add(fieldName) + if (value == null) return parseNullOperand(fieldName, KEY_EQUALS) if (value !is Map<*, *>) { return Filters.eq(fieldName, normalizeValue(value)) } @Suppress("UNCHECKED_CAST") - val operatorMap = value as Map + val operatorMap = value as Map if (operatorMap.isEmpty()) return null val (opKey, opValue) = operatorMap.entries.first() + if (opValue == null) return parseNullOperand(fieldName, opKey) return when (opKey) { KEY_EQUALS -> Filters.eq(fieldName, normalizeValue(opValue)) @@ -103,7 +105,9 @@ private fun parseFieldFilter(fieldName: String, value: Any, fields: MutableSet Filters.lessThan(fieldName, normalizeValue(opValue)) KEY_LESS_THAN_OR_EQUALS -> Filters.lessThanEquals(fieldName, normalizeValue(opValue)) KEY_IN -> { - val values = (opValue as? Collection<*>)?.map { normalizeValue(it ?: return null) } ?: return null + // SQL `IN` never matches a null element, so the nulls are dropped. + val values = (opValue as? Collection<*>)?.mapNotNull { it?.let(::normalizeValue) } ?: return null + if (values.isEmpty()) return null Filters.`in`(fieldName, values) } KEY_NOT_IN -> { @@ -124,6 +128,17 @@ private fun parseFieldFilter(fieldName: String, value: Any, fields: MutableSet Filters.notExists(fieldName) + KEY_NOT_EQUALS -> Filters.exists(fieldName) + else -> null +} + private fun normalizeValue(value: Any): Any = when { value is Double && value == value.toLong().toDouble() -> { val longVal = value.toLong() diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/response/QueryChannelsResponse.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/response/QueryChannelsResponse.kt index 4a0140384c4..b30c0ad60e4 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/response/QueryChannelsResponse.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/response/QueryChannelsResponse.kt @@ -17,16 +17,10 @@ package io.getstream.chat.android.client.api2.model.response import com.squareup.moshi.JsonClass +import io.getstream.chat.android.network.models.ParsedPredefinedFilterResponse @JsonClass(generateAdapter = true) internal data class QueryChannelsResponse( val channels: List, val predefined_filter: ParsedPredefinedFilterResponse? = null, ) - -@JsonClass(generateAdapter = true) -internal data class ParsedPredefinedFilterResponse( - val name: String, - val filter: Map, - val sort: List>? = null, -) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/ParsedPredefinedFilterResponse.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/ParsedPredefinedFilterResponse.kt new file mode 100644 index 00000000000..ddcf75d82fd --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/ParsedPredefinedFilterResponse.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress( + "ArrayInDataClass", + "EnumEntryName", + "RemoveRedundantQualifierName", + "UnusedImport", +) + +package io.getstream.chat.android.network.models + +import com.squareup.moshi.Json + +/** + * + */ +@com.squareup.moshi.JsonClass(generateAdapter = true) +internal data class ParsedPredefinedFilterResponse( + @Json(name = "name") + internal val name: String, + + @Json(name = "filter") + internal val filter: Map = emptyMap(), + + @Json(name = "sort") + internal val sort: List? = emptyList(), +) diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTest.kt index 62bec2e2d89..60783079a79 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTest.kt @@ -52,7 +52,6 @@ import io.getstream.chat.android.client.api2.model.response.FlagResponse import io.getstream.chat.android.client.api2.model.response.MessageResponse import io.getstream.chat.android.client.api2.model.response.MessagesResponse import io.getstream.chat.android.client.api2.model.response.MuteUserResponse -import io.getstream.chat.android.client.api2.model.response.ParsedPredefinedFilterResponse import io.getstream.chat.android.client.api2.model.response.QueryChannelsResponse import io.getstream.chat.android.client.api2.model.response.QueryDraftMessagesResponse import io.getstream.chat.android.client.api2.model.response.QueryGroupedChannelsGroup @@ -140,6 +139,7 @@ import io.getstream.chat.android.network.models.MembersResponse import io.getstream.chat.android.network.models.MessageActionRequest import io.getstream.chat.android.network.models.MessageRequest import io.getstream.chat.android.network.models.MuteChannelRequest +import io.getstream.chat.android.network.models.ParsedPredefinedFilterResponse import io.getstream.chat.android.network.models.PollOptionInput import io.getstream.chat.android.network.models.PollOptionRequest import io.getstream.chat.android.network.models.PollOptionResponse @@ -2314,7 +2314,7 @@ internal class MoshiChatApiTest { predefinedFilter = ParsedPredefinedFilterResponse( name = "my-filter", filter = mapOf("type" to "messaging"), - sort = listOf(mapOf("field" to "created_at", "direction" to 1)), + sort = listOf(SortParamRequest(field = "created_at", direction = 1)), ), ) assertEquals( diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTestArguments.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTestArguments.kt index d950a6176a5..3ffe00defbd 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTestArguments.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTestArguments.kt @@ -35,7 +35,6 @@ import io.getstream.chat.android.client.api2.model.response.FlagResponse import io.getstream.chat.android.client.api2.model.response.MessageResponse import io.getstream.chat.android.client.api2.model.response.MessagesResponse import io.getstream.chat.android.client.api2.model.response.MuteUserResponse -import io.getstream.chat.android.client.api2.model.response.ParsedPredefinedFilterResponse import io.getstream.chat.android.client.api2.model.response.QueryChannelsResponse import io.getstream.chat.android.client.api2.model.response.QueryDraftMessagesResponse import io.getstream.chat.android.client.api2.model.response.QueryGroupedChannelsGroup @@ -68,6 +67,7 @@ import io.getstream.chat.android.network.models.GetUserGroupResponse import io.getstream.chat.android.network.models.ListDevicesResponse import io.getstream.chat.android.network.models.ListUserGroupsResponse import io.getstream.chat.android.network.models.MembersResponse +import io.getstream.chat.android.network.models.ParsedPredefinedFilterResponse import io.getstream.chat.android.network.models.PollOptionResponse import io.getstream.chat.android.network.models.PollResponse import io.getstream.chat.android.network.models.PollVoteResponse @@ -80,6 +80,7 @@ import io.getstream.chat.android.network.models.RemoveUserGroupMembersResponse import io.getstream.chat.android.network.models.Response import io.getstream.chat.android.network.models.SearchRolesResponse import io.getstream.chat.android.network.models.SearchUserGroupsResponse +import io.getstream.chat.android.network.models.SortParamRequest import io.getstream.chat.android.network.models.UnblockUsersResponse import io.getstream.chat.android.network.models.UpdateLiveLocationRequest import io.getstream.chat.android.network.models.UpdateMemberPartialResponse @@ -538,7 +539,7 @@ internal object MoshiChatApiTestArguments { predefined_filter = ParsedPredefinedFilterResponse( name = "android_sample_filter", filter = mapOf("type" to "messaging"), - sort = listOf(mapOf("field" to "last_message_at", "direction" to -1)), + sort = listOf(SortParamRequest(field = "last_message_at", direction = -1)), ), ), ).toRetrofitCall(), diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt index 3f161338eb9..e6fc281e681 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt @@ -131,6 +131,7 @@ import io.getstream.chat.android.network.models.DeliveryReceiptsResponse import io.getstream.chat.android.network.models.FullUserResponse import io.getstream.chat.android.network.models.PrivacySettingsResponse import io.getstream.chat.android.network.models.ReadReceiptsResponse +import io.getstream.chat.android.network.models.SortParamRequest import io.getstream.chat.android.network.models.TypingIndicatorsResponse import io.getstream.chat.android.network.models.UserMuteResponse import io.getstream.chat.android.network.models.UserResponse @@ -1505,8 +1506,8 @@ internal class DomainMappingTest { */ @ParameterizedTest @MethodSource("toSortDomainArguments") - fun `List of sort maps is correctly mapped to QuerySorter`( - input: List>?, + fun `List of sort params is correctly mapped to QuerySorter`( + input: List?, expected: QuerySorter?, ) { val sut = Fixture().get() @@ -1540,29 +1541,24 @@ internal class DomainMappingTest { fun toSortDomainArguments() = listOf( // null/error → null Arguments.of(null, null), - Arguments.of(emptyList>(), null), - Arguments.of(listOf(mapOf("direction" to -1)), null), - Arguments.of(listOf(mapOf("field" to "created_at")), null), - Arguments.of(listOf(mapOf("field" to "created_at", "direction" to 0)), null), + Arguments.of(emptyList(), null), + Arguments.of(listOf(SortParamRequest(direction = -1)), null), + Arguments.of(listOf(SortParamRequest(field = "created_at")), null), + Arguments.of(listOf(SortParamRequest(field = "created_at", direction = 0)), null), // valid parsing Arguments.of( - listOf(mapOf("field" to "created_at", "direction" to 1)), + listOf(SortParamRequest(field = "created_at", direction = 1)), ascByName("created_at"), ), Arguments.of( - listOf(mapOf("field" to "last_message_at", "direction" to -1)), + listOf(SortParamRequest(field = "last_message_at", direction = -1)), descByName("last_message_at"), ), - // Double direction (Moshi edge case) - Arguments.of( - listOf(mapOf("field" to "created_at", "direction" to -1.0)), - descByName("created_at"), - ), // multiple fields Arguments.of( listOf( - mapOf("field" to "created_at", "direction" to -1), - mapOf("field" to "name", "direction" to 1), + SortParamRequest(field = "created_at", direction = -1), + SortParamRequest(field = "name", direction = 1), ), descByName("created_at").ascByName("name"), ), diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/FilterDomainMappingTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/FilterDomainMappingTest.kt index 866d4dcd3b0..ad8ef79a6f1 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/FilterDomainMappingTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/FilterDomainMappingTest.kt @@ -32,7 +32,7 @@ internal class FilterDomainMappingTest { @ParameterizedTest @MethodSource("toFilterDomainWithFieldsArguments") fun `Map is parsed to FilterObject with the set of referenced field names`( - input: Map?, + input: Map?, expectedFilter: FilterObject?, expectedFields: Set?, ) { @@ -282,6 +282,48 @@ internal class FilterDomainMappingTest { ), setOf("type", "last_message_at"), ), + + // --- Null operands: equality maps to $exists, other operators are dropped --- + Arguments.of( + mapOf("team" to null), + Filters.notExists("team"), + setOf("team"), + ), + Arguments.of( + mapOf("team" to mapOf("\$eq" to null)), + Filters.notExists("team"), + setOf("team"), + ), + Arguments.of( + mapOf("team" to mapOf("\$ne" to null)), + Filters.exists("team"), + setOf("team"), + ), + Arguments.of( + mapOf("member_count" to mapOf("\$gt" to null)), + null, + null, + ), + Arguments.of( + mapOf("type" to "messaging", "team" to mapOf("\$gt" to null)), + Filters.eq("type", "messaging"), + setOf("type", "team"), + ), + Arguments.of( + mapOf("type" to mapOf("\$in" to listOf("messaging", null))), + Filters.`in`("type", listOf("messaging")), + setOf("type"), + ), + Arguments.of( + mapOf("type" to mapOf("\$in" to listOf(null))), + null, + null, + ), + Arguments.of( + mapOf("\$and" to listOf(mapOf("type" to "messaging"), mapOf("team" to null))), + Filters.and(Filters.eq("type", "messaging"), Filters.notExists("team")), + setOf("type", "team"), + ), ) } } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/QuerySortByFieldRoundTripTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/QuerySortByFieldRoundTripTest.kt index 23ff476ba93..c08f36befb2 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/QuerySortByFieldRoundTripTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/QuerySortByFieldRoundTripTest.kt @@ -16,6 +16,7 @@ package io.getstream.chat.android.client.api2.mapping +import io.getstream.chat.android.client.api2.toSortParams import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.NoOpChannelTransformer import io.getstream.chat.android.models.NoOpMessageTransformer @@ -35,7 +36,7 @@ internal class QuerySortByFieldRoundTripTest { */ @ParameterizedTest @MethodSource("roundTripArguments") - fun `QuerySortByField survives round-trip through toDto and toSortDomain`( + fun `QuerySortByField survives round-trip through toSortParams and toSortDomain`( original: QuerySortByField, ) { val sut = DomainMapping( @@ -44,8 +45,8 @@ internal class QuerySortByFieldRoundTripTest { messageTransformer = NoOpMessageTransformer, userTransformer = NoOpUserTransformer, ) - val dto = original.toDto() - val restored = with(sut) { dto.toSortDomain() } + val sortParams = original.toSortParams() + val restored = with(sut) { sortParams.toSortDomain() } assertEquals(original, restored) } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/PredefinedFilterParsingTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/PredefinedFilterParsingTest.kt new file mode 100644 index 00000000000..7f3d0d75a5d --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/PredefinedFilterParsingTest.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.parser2 + +import io.getstream.chat.android.client.api2.mapping.DomainMapping +import io.getstream.chat.android.client.api2.mapping.toFilterDomainWithFields +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.Filters +import io.getstream.chat.android.models.NoOpChannelTransformer +import io.getstream.chat.android.models.NoOpMessageTransformer +import io.getstream.chat.android.models.NoOpUserTransformer +import io.getstream.chat.android.models.querysort.QuerySortByField.Companion.ascByName +import io.getstream.chat.android.models.querysort.QuerySortByField.Companion.descByName +import io.getstream.chat.android.network.models.ParsedPredefinedFilterResponse +import org.amshove.kluent.shouldBeEqualTo +import org.junit.jupiter.api.Test + +/** + * Decodes the predefined filter from JSON, so a regression in reading `sort` fails here instead of the + * mapper silently falling back to the default sort. + */ +internal class PredefinedFilterParsingTest { + private val parser = ParserFactory.createMoshiChatParser() + private val mapping = DomainMapping( + currentUserIdProvider = { null }, + channelTransformer = NoOpChannelTransformer, + messageTransformer = NoOpMessageTransformer, + userTransformer = NoOpUserTransformer, + ) + + @Test + fun `The predefined filter sort is decoded from the server shape`() { + val json = """ + { + "name": "android_sample_filter", + "filter": { "type": "messaging" }, + "sort": [ + { "field": "created_at", "direction": 1 }, + { "field": "last_updated", "direction": -1 } + ] + } + """.trimIndent() + + val parsed = parser.fromJson(json, ParsedPredefinedFilterResponse::class.java) + val sort = with(mapping) { parsed.sort.toSortDomain() } + + sort shouldBeEqualTo ascByName("created_at").descByName("last_updated") + } + + @Test + fun `A predefined filter without a sort decodes to no sort`() { + val json = """{ "name": "android_sample_filter", "filter": { "type": "messaging" } }""" + + val parsed = parser.fromJson(json, ParsedPredefinedFilterResponse::class.java) + + with(mapping) { parsed.sort.toSortDomain() } shouldBeEqualTo null + } + + @Test + fun `A null filter condition decodes to a missing field check`() { + val json = """ + { + "name": "android_sample_filter", + "filter": { "type": "messaging", "team": null, "disabled": { "${'$'}ne": null } } + } + """.trimIndent() + + val parsed = parser.fromJson(json, ParsedPredefinedFilterResponse::class.java) + + parsed.filter.toFilterDomainWithFields()?.first shouldBeEqualTo Filters.and( + Filters.eq("type", "messaging"), + Filters.notExists("team"), + Filters.exists("disabled"), + ) + } +}