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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1137,12 +1138,11 @@ internal class DomainMapping(
defaultPreference = ChatPreferenceToggle.fromValue(defaultPreference),
)

internal fun List<Map<String, Any>>?.toSortDomain(): QuerySorter<Channel>? {
internal fun List<SortParamRequest>?.toSortDomain(): QuerySorter<Channel>? {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,15 @@ import io.getstream.chat.android.models.NeutralFilterObject
*
* Returns `null` if the map is `null` or cannot be parsed.
*/
internal fun Map<String, Any>?.toFilterDomainWithFields(): Pair<FilterObject, Set<String>>? {
internal fun Map<String, Any?>?.toFilterDomainWithFields(): Pair<FilterObject, Set<String>>? {
if (this == null) return null
val fields = mutableSetOf<String>()
val filter = parseFilterMap(this, fields) ?: return null
return filter to fields
}

@Suppress("ComplexMethod", "SpreadOperator")
private fun parseFilterMap(map: Map<String, Any>, fields: MutableSet<String>): FilterObject? {
private fun parseFilterMap(map: Map<String, Any?>, fields: MutableSet<String>): FilterObject? {
if (map.isEmpty()) return NeutralFilterObject

if (map.size == 2 && map.containsKey(KEY_DISTINCT) && map.containsKey(KEY_MEMBERS)) {
Expand All @@ -62,7 +62,7 @@ private fun parseFilterMap(map: Map<String, Any>, fields: MutableSet<String>): F
}

@Suppress("SpreadOperator")
private fun parseSingleEntry(key: String, value: Any, fields: MutableSet<String>): FilterObject? = when (key) {
private fun parseSingleEntry(key: String, value: Any?, fields: MutableSet<String>): 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) }
Expand All @@ -71,29 +71,31 @@ private fun parseSingleEntry(key: String, value: Any, fields: MutableSet<String>

@Suppress("UNCHECKED_CAST")
private fun parseLogicalOperator(
value: Any,
value: Any?,
fields: MutableSet<String>,
factory: (Array<FilterObject>) -> FilterObject,
): FilterObject? {
val list = value as? List<*> ?: return null
val filters = list.mapNotNull { item ->
(item as? Map<String, Any>)?.let { parseFilterMap(it, fields) }
(item as? Map<String, Any?>)?.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<String>): FilterObject? {
private fun parseFieldFilter(fieldName: String, value: Any?, fields: MutableSet<String>): 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<String, Any>
val operatorMap = value as Map<String, Any?>
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))
Expand All @@ -103,7 +105,9 @@ private fun parseFieldFilter(fieldName: String, value: Any, fields: MutableSet<S
KEY_LESS_THAN -> 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 -> {
Expand All @@ -124,6 +128,17 @@ private fun parseFieldFilter(fieldName: String, value: Any, fields: MutableSet<S
}
}

/**
* The backend matches a null operand as `IS NULL` / `IS NOT NULL`, which is what `$exists` checks for a
* column. For a custom field it means a key explicitly set to null, which [FilterObject] cannot express, so
* that case reads as the key being absent. Any other operator with a null operand is dropped.
*/
private fun parseNullOperand(fieldName: String, opKey: String): FilterObject? = when (opKey) {
KEY_EQUALS -> 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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChannelResponse>,
val predefined_filter: ParsedPredefinedFilterResponse? = null,
)

@JsonClass(generateAdapter = true)
internal data class ParsedPredefinedFilterResponse(
val name: String,
val filter: Map<String, Any>,
val sort: List<Map<String, Any>>? = null,
)
Original file line number Diff line number Diff line change
@@ -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<String, Any?> = emptyMap(),

@Json(name = "sort")
internal val sort: List<io.getstream.chat.android.network.models.SortParamRequest>? = emptyList(),
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1505,8 +1506,8 @@ internal class DomainMappingTest {
*/
@ParameterizedTest
@MethodSource("toSortDomainArguments")
fun `List of sort maps is correctly mapped to QuerySorter`(
input: List<Map<String, Any>>?,
fun `List of sort params is correctly mapped to QuerySorter`(
input: List<SortParamRequest>?,
expected: QuerySorter<Channel>?,
) {
val sut = Fixture().get()
Expand Down Expand Up @@ -1540,29 +1541,24 @@ internal class DomainMappingTest {
fun toSortDomainArguments() = listOf(
// null/error → null
Arguments.of(null, null),
Arguments.of(emptyList<Map<String, Any>>(), 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<SortParamRequest>(), 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<Channel>("created_at"),
),
Arguments.of(
listOf(mapOf("field" to "last_message_at", "direction" to -1)),
listOf(SortParamRequest(field = "last_message_at", direction = -1)),
descByName<Channel>("last_message_at"),
),
// Double direction (Moshi edge case)
Arguments.of(
listOf(mapOf("field" to "created_at", "direction" to -1.0)),
descByName<Channel>("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<Channel>("created_at").ascByName("name"),
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Any>?,
input: Map<String, Any?>?,
expectedFilter: FilterObject?,
expectedFields: Set<String>?,
) {
Expand Down Expand Up @@ -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"),
),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Channel>,
) {
val sut = DomainMapping(
Expand All @@ -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)
}

Expand Down
Loading
Loading