From 1fa2294b06fce671aacf70bffc7c9afc40224b91 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Mon, 15 Sep 2025 12:40:59 +0200 Subject: [PATCH 1/3] Implement support for local filtering --- .../android/core/api/filter/Filter.kt | 63 +++- .../android/core/api/filter/FilterField.kt | 8 +- .../android/core/api/filter/Filters.kt | 63 ++-- .../core/internal/filter/FilterOperations.kt | 99 ++++++ .../core/internal/filter/FilterOperator.kt | 19 +- .../core/api/filter/FilterMatchingTest.kt | 322 ++++++++++++++++++ .../core/api/filter/FilterToRequestTest.kt | 7 +- 7 files changed, 526 insertions(+), 55 deletions(-) create mode 100644 stream-android-core/src/main/java/io/getstream/android/core/internal/filter/FilterOperations.kt create mode 100644 stream-android-core/src/test/java/io/getstream/android/core/api/filter/FilterMatchingTest.kt diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/filter/Filter.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/filter/Filter.kt index f84cd5e..bcc1add 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/api/filter/Filter.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/filter/Filter.kt @@ -17,7 +17,9 @@ package io.getstream.android.core.api.filter import io.getstream.android.core.annotations.StreamInternalApi import io.getstream.android.core.annotations.StreamPublishedApi -import io.getstream.android.core.internal.filter.FilterOperator +import io.getstream.android.core.internal.filter.BinaryOperator +import io.getstream.android.core.internal.filter.CollectionOperator +import io.getstream.android.core.internal.filter.FilterOperations /** * Base interface for filters used in Stream API operations. @@ -25,24 +27,59 @@ import io.getstream.android.core.internal.filter.FilterOperator * Filters are used to specify criteria for querying and retrieving data from Stream services. Each * filter implementation defines specific matching logic for different comparison operations. */ -@StreamPublishedApi public sealed interface Filter +@StreamPublishedApi public sealed interface Filter> -internal data class BinaryOperationFilter( - val operator: FilterOperator, +internal data class BinaryOperationFilter>( + val operator: BinaryOperator, val field: F, - val value: V, -) : Filter + val value: Any, +) : Filter -internal data class CollectionOperationFilter( - internal val operator: FilterOperator, - val filters: Set>, -) : Filter +internal data class CollectionOperationFilter>( + internal val operator: CollectionOperator, + val filters: Set>, +) : Filter /** Converts a [Filter] instance to a request map suitable for API queries. */ @StreamInternalApi -public fun Filter<*>.toRequest(): Map = +public fun Filter<*, *>.toRequest(): Map = when (this) { is BinaryOperationFilter<*, *> -> mapOf(field.remote to mapOf(operator.remote to value)) - is CollectionOperationFilter<*> -> - mapOf(operator.remote to filters.map(Filter<*>::toRequest)) + is CollectionOperationFilter<*, *> -> + mapOf(operator.remote to filters.map(Filter<*, *>::toRequest)) + } + +/** Checks if this filter matches the given item. */ +@StreamInternalApi +public infix fun > Filter.matches(item: M): Boolean = + when (this) { + is BinaryOperationFilter -> { + val fieldValue = field.localValue(item) + val filterValue = value + val notNull = fieldValue != null + + with(FilterOperations) { + when (operator) { + BinaryOperator.EQUAL -> notNull && fieldValue == filterValue + BinaryOperator.GREATER -> notNull && fieldValue greater filterValue + BinaryOperator.LESS -> notNull && fieldValue less filterValue + BinaryOperator.GREATER_OR_EQUAL -> + notNull && fieldValue greaterOrEqual filterValue + BinaryOperator.LESS_OR_EQUAL -> notNull && fieldValue lessOrEqual filterValue + BinaryOperator.IN -> notNull && fieldValue `in` filterValue + BinaryOperator.QUERY -> notNull && search(filterValue, where = fieldValue) + BinaryOperator.AUTOCOMPLETE -> notNull && fieldValue autocompletes filterValue + BinaryOperator.EXISTS -> fieldValue exists filterValue + BinaryOperator.CONTAINS -> notNull && fieldValue contains filterValue + BinaryOperator.PATH_EXISTS -> notNull && fieldValue containsPath filterValue + } + } + } + + is CollectionOperationFilter -> { + when (operator) { + CollectionOperator.AND -> filters.all { it.matches(item) } + CollectionOperator.OR -> filters.any { it.matches(item) } + } + } } diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/filter/FilterField.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/filter/FilterField.kt index 3365360..4caa573 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/api/filter/FilterField.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/filter/FilterField.kt @@ -19,9 +19,13 @@ package io.getstream.android.core.api.filter * Interface representing a field that can be used in filters for querying data from the Stream API. * * Implementations of this interface should provide [remote], which is the name of the field as - * expected by the Stream API. + * expected by the Stream API, and [localValue], a function to extract the field's value from a + * model instance. */ -public interface FilterField { +public interface FilterField { /** The name of this field as expected by the Stream API. */ public val remote: String + + /** Function to extract the field's value from a model instance. */ + public val localValue: (M) -> Any? } diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/filter/Filters.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/filter/Filters.kt index 7f0402b..860a655 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/api/filter/Filters.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/filter/Filters.kt @@ -15,7 +15,8 @@ */ package io.getstream.android.core.api.filter -import io.getstream.android.core.internal.filter.FilterOperator +import io.getstream.android.core.internal.filter.BinaryOperator +import io.getstream.android.core.internal.filter.CollectionOperator /** Utility class for building filters. */ public object Filters { @@ -25,8 +26,8 @@ public object Filters { * @param filters The filters to combine. * @return A filter that matches when all provided filters match. */ - public fun and(vararg filters: Filter): Filter = - CollectionOperationFilter(FilterOperator.AND, filters.toSet()) + public fun > and(vararg filters: Filter): Filter = + CollectionOperationFilter(CollectionOperator.AND, filters.toSet()) /** * Creates a filter that combines multiple filters with a logical OR operation. @@ -34,8 +35,8 @@ public object Filters { * @param filters The filters to combine. * @return A filter that matches when any of the specified filters match. */ - public fun or(vararg filters: Filter): Filter = - CollectionOperationFilter(FilterOperator.OR, filters.toSet()) + public fun > or(vararg filters: Filter): Filter = + CollectionOperationFilter(CollectionOperator.OR, filters.toSet()) } /** @@ -44,8 +45,8 @@ public object Filters { * @param value The value to check equality against. * @return A filter that matches when this field equals the specified value. */ -public fun F.equal(value: Any): Filter = - BinaryOperationFilter(FilterOperator.EQUAL, this, value) +public fun > F.equal(value: Any): Filter = + BinaryOperationFilter(BinaryOperator.EQUAL, this, value) /** * Creates a filter that checks if this field is greater than a specific value. @@ -53,8 +54,8 @@ public fun F.equal(value: Any): Filter = * @param value The value to check against. * @return A filter that matches when this field is greater than the specified value. */ -public fun F.greater(value: Any): Filter = - BinaryOperationFilter(FilterOperator.GREATER, this, value) +public fun > F.greater(value: Any): Filter = + BinaryOperationFilter(BinaryOperator.GREATER, this, value) /** * Creates a filter that checks if this field is greater than or equal to a specific value. @@ -62,8 +63,8 @@ public fun F.greater(value: Any): Filter = * @param value The value to check against. * @return A filter that matches when this field is greater than or equal to the specified value. */ -public fun F.greaterOrEqual(value: Any): Filter = - BinaryOperationFilter(FilterOperator.GREATER_OR_EQUAL, this, value) +public fun > F.greaterOrEqual(value: Any): Filter = + BinaryOperationFilter(BinaryOperator.GREATER_OR_EQUAL, this, value) /** * Creates a filter that checks if this field is less than a specific value. @@ -71,8 +72,8 @@ public fun F.greaterOrEqual(value: Any): Filter = * @param value The value to check against. * @return A filter that matches when this field is less than the specified value. */ -public fun F.less(value: Any): Filter = - BinaryOperationFilter(FilterOperator.LESS, this, value) +public fun > F.less(value: Any): Filter = + BinaryOperationFilter(BinaryOperator.LESS, this, value) /** * Creates a filter that checks if this field is less than or equal to a specific value. @@ -80,8 +81,8 @@ public fun F.less(value: Any): Filter = * @param value The value to check against. * @return A filter that matches when this field is less than or equal to the specified value. */ -public fun F.lessOrEqual(value: Any): Filter = - BinaryOperationFilter(FilterOperator.LESS_OR_EQUAL, this, value) +public fun > F.lessOrEqual(value: Any): Filter = + BinaryOperationFilter(BinaryOperator.LESS_OR_EQUAL, this, value) /** * Creates a filter that checks if this field's value is in a specific list of values. @@ -89,8 +90,8 @@ public fun F.lessOrEqual(value: Any): Filter = * @param values The list of values to check against. * @return A filter that matches when this field's value is in the specified list. */ -public fun F.`in`(values: List): Filter = - BinaryOperationFilter(FilterOperator.IN, this, values.toSet()) +public fun > F.`in`(values: List): Filter = + BinaryOperationFilter(BinaryOperator.IN, this, values.toSet()) /** * Creates a filter that checks if this field's value is in a specific set of values. @@ -98,8 +99,8 @@ public fun F.`in`(values: List): Filter = * @param values The values to check against. * @return A filter that matches when this field's value is in the specified values. */ -public fun F.`in`(vararg values: Any): Filter = - BinaryOperationFilter(FilterOperator.IN, this, values.toSet()) +public fun > F.`in`(vararg values: Any): Filter = + BinaryOperationFilter(BinaryOperator.IN, this, values.toSet()) /** * Creates a filter that performs a full-text query on this field. @@ -107,8 +108,8 @@ public fun F.`in`(vararg values: Any): Filter = * @param value The query string to search for. * @return A filter that matches based on the full-text query. */ -public fun F.query(value: String): Filter = - BinaryOperationFilter(FilterOperator.QUERY, this, value) +public fun > F.query(value: String): Filter = + BinaryOperationFilter(BinaryOperator.QUERY, this, value) /** * Creates a filter that performs autocomplete matching on this field. @@ -116,24 +117,24 @@ public fun F.query(value: String): Filter = * @param value The string to autocomplete against. * @return A filter that matches based on autocomplete functionality. */ -public fun F.autocomplete(value: String): Filter = - BinaryOperationFilter(FilterOperator.AUTOCOMPLETE, this, value) +public fun > F.autocomplete(value: String): Filter = + BinaryOperationFilter(BinaryOperator.AUTOCOMPLETE, this, value) /** * Creates a filter that checks if this field exists. * * @return A filter that matches when this field exists. */ -public fun F.exists(): Filter = - BinaryOperationFilter(FilterOperator.EXISTS, this, true) +public fun > F.exists(): Filter = + BinaryOperationFilter(BinaryOperator.EXISTS, this, true) /** * Creates a filter that checks if this field does not exist. * * @return A filter that matches when this field does not exist. */ -public fun F.doesNotExist(): Filter = - BinaryOperationFilter(FilterOperator.EXISTS, this, false) +public fun > F.doesNotExist(): Filter = + BinaryOperationFilter(BinaryOperator.EXISTS, this, false) /** * Creates a filter that checks if this field contains a specific value. @@ -141,8 +142,8 @@ public fun F.doesNotExist(): Filter = * @param value The value to check for within this field. * @return A filter that matches when this field contains the specified value. */ -public fun F.contains(value: Any): Filter = - BinaryOperationFilter(FilterOperator.CONTAINS, this, value) +public fun > F.contains(value: Any): Filter = + BinaryOperationFilter(BinaryOperator.CONTAINS, this, value) /** * Creates a filter that checks if a specific path exists within this field. @@ -150,5 +151,5 @@ public fun F.contains(value: Any): Filter = * @param value The path to check for existence. * @return A filter that matches when the specified path exists in this field. */ -public fun F.pathExists(value: String): Filter = - BinaryOperationFilter(FilterOperator.PATH_EXISTS, this, value) +public fun > F.pathExists(value: String): Filter = + BinaryOperationFilter(BinaryOperator.PATH_EXISTS, this, value) diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/filter/FilterOperations.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/filter/FilterOperations.kt new file mode 100644 index 0000000..d508c68 --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/filter/FilterOperations.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2014-2025 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-core-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.android.core.internal.filter + +internal object FilterOperations { + infix fun Any.greater(that: Any) = anyCompare(this, that)?.let { it > 0 } == true + + infix fun Any.greaterOrEqual(that: Any) = anyCompare(this, that)?.let { it >= 0 } == true + + infix fun Any.less(that: Any) = anyCompare(this, that)?.let { it < 0 } == true + + infix fun Any.lessOrEqual(that: Any) = anyCompare(this, that)?.let { it <= 0 } == true + + private fun anyCompare(a: Any, b: Any): Int? { + if (a !is Comparable<*>) { + return null + } + + return try { + @Suppress("UNCHECKED_CAST") (a as Comparable).compareTo(b) + } catch (_: ClassCastException) { + // The types were not compatible for comparison + null + } + } + + infix fun Any?.exists(that: Any): Boolean = (that is Boolean) && (this != null) == that + + infix fun Any.`in`(that: Any): Boolean = + when (that) { + is Array<*> -> this in that + is Iterable<*> -> this in that + else -> false + } + + infix fun Any.contains(that: Any): Boolean = + when { + that `in` this -> true + + this is Map<*, *> && that is Map<*, *> -> { + // Partial match: check if all entries in 'that' are present in 'this' + that.all { (thatKey, thatValue) -> + val thisValue = this[thatKey] + + thisValue == thatValue || + thisValue != null && thatValue != null && thisValue contains thatValue + } + } + + else -> false + } + + private val whitespaceAndPunctuation = Regex("[\\s\\p{Punct}]+") + + infix fun Any.autocompletes(that: Any): Boolean { + if (this !is String || that !is String || that.isEmpty()) { + return false + } + + // Split the text into words using whitespace and punctuation as delimiters + return this.split(whitespaceAndPunctuation).any { word -> word.startsWith(that, true) } + } + + fun search(what: Any, where: Any): Boolean = + what is String && + where is String && + what.isNotEmpty() && + where.contains(what, ignoreCase = true) + + infix fun Any.containsPath(that: Any): Boolean { + if (this !is Map<*, *> || that !is String) return false + + val pathParts = that.split(".") + var current: Any? = this + + for (part in pathParts) { + when { + current !is Map<*, *> -> return false + part !in current -> return false + else -> current = current[part] + } + } + + return true + } +} diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/filter/FilterOperator.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/filter/FilterOperator.kt index fd1e0357..eabd178 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/filter/FilterOperator.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/filter/FilterOperator.kt @@ -16,7 +16,7 @@ package io.getstream.android.core.internal.filter /** Represents operators that can be used for filtering. */ -internal enum class FilterOperator( +internal enum class BinaryOperator( /** The name of this operator as expected by the Stream API. */ val remote: String ) { @@ -49,15 +49,20 @@ internal enum class FilterOperator( /** Matches values that exist/don't exist based on the specified boolean value. */ EXISTS("\$exists"), - /** Matches all the values specified in an array. */ - AND("\$and"), - - /** Matches at least one of the values specified in an array. */ - OR("\$or"), - /** Matches if the key array contains the given value. */ CONTAINS("\$contains"), /** Matches if the value contains JSON with the given path. */ PATH_EXISTS("\$path_exists"), } + +internal enum class CollectionOperator( + /** The name of this operator as expected by the Stream API. */ + val remote: String +) { + /** Matches all the values specified in an array. */ + AND("\$and"), + + /** Matches at least one of the values specified in an array. */ + OR("\$or"), +} diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/filter/FilterMatchingTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/filter/FilterMatchingTest.kt new file mode 100644 index 0000000..a102bb8 --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/filter/FilterMatchingTest.kt @@ -0,0 +1,322 @@ +/* + * Copyright (c) 2014-2025 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-core-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.android.core.api.filter + +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.junit.Test + +class FilterMatchingTest { + + @Test + fun `equal filter should match when field equals value`() { + val filter = TestFilterField.id.equal("user-123") + + val itemWithMatchingId = TestData(id = "user-123") + val itemWithDifferentId = TestData(id = "other-456") + + assertTrue(filter matches itemWithMatchingId) + assertFalse(filter matches itemWithDifferentId) + } + + @Test + fun `greater filter should match when field is greater than value`() { + val filter = TestFilterField.score.greater(80) + + val itemAboveThreshold = TestData(score = 85) + val itemBelowThreshold = TestData(score = 75) + + assertTrue(filter matches itemAboveThreshold) + assertFalse(filter matches itemBelowThreshold) + } + + @Test + fun `less filter should match when field is less than value`() { + val filter = TestFilterField.score.less(90) + + val itemBelowThreshold = TestData(score = 85) + val itemAboveThreshold = TestData(score = 95) + + assertTrue(filter matches itemBelowThreshold) + assertFalse(filter matches itemAboveThreshold) + } + + @Test + fun `greaterOrEqual filter should match when field is greater than or equal to value`() { + val filter = TestFilterField.score.greaterOrEqual(85) + + val itemExactlyAtThreshold = TestData(score = 85) + val itemAboveThreshold = TestData(score = 90) + val itemBelowThreshold = TestData(score = 80) + + assertTrue(filter matches itemExactlyAtThreshold) + assertTrue(filter matches itemAboveThreshold) + assertFalse(filter matches itemBelowThreshold) + } + + @Test + fun `lessOrEqual filter should match when field is less than or equal to value`() { + val filter = TestFilterField.rating.lessOrEqual(4.5) + + val itemExactlyAtThreshold = TestData(rating = 4.5) + val itemBelowThreshold = TestData(rating = 4.0) + val itemAboveThreshold = TestData(rating = 4.8) + + assertTrue(filter matches itemExactlyAtThreshold) + assertTrue(filter matches itemBelowThreshold) + assertFalse(filter matches itemAboveThreshold) + } + + @Test + fun `in filter should match when field value is in the collection`() { + val filter = TestFilterField.id.`in`("user-123", "user-456") + + val itemWithAllowedId = TestData(id = "user-123") + val itemWithDifferentId = TestData(id = "user-789") + val itemWithOtherAllowedId = TestData(id = "user-456") + + assertTrue(filter matches itemWithAllowedId) + assertFalse(filter matches itemWithDifferentId) + assertTrue(filter matches itemWithOtherAllowedId) + } + + @Test + fun `contains filter should match when list contains the value`() { + val filter = TestFilterField.tags.contains("important") + + val itemWithTargetTag = TestData(tags = listOf("urgent", "important", "other")) + val itemWithDifferentTags = TestData(tags = listOf("urgent", "normal")) + val itemWithNoTags = TestData(tags = emptyList()) + + assertTrue(filter matches itemWithTargetTag) + assertFalse(filter matches itemWithDifferentTags) + assertFalse(filter matches itemWithNoTags) + } + + @Test + fun `contains filter should match when map contains all key-value pairs including nested maps`() { + val filter = + TestFilterField.metadata.contains( + mapOf("category" to "test", "config" to mapOf("enabled" to true)) + ) + + val itemWithMatchingNestedData = + TestData( + metadata = + mapOf( + "category" to "test", + "priority" to 1, + "config" to mapOf("enabled" to true, "timeout" to 30), + ) + ) + val itemWithDifferentNestedValue = + TestData( + metadata = + mapOf( + "category" to "test", + "config" to mapOf("enabled" to false, "timeout" to 30), + ) + ) + val itemWithoutNestedMap = TestData(metadata = mapOf("category" to "test", "priority" to 1)) + + assertTrue(filter matches itemWithMatchingNestedData) + assertFalse(filter matches itemWithDifferentNestedValue) + assertFalse(filter matches itemWithoutNestedMap) + } + + @Test + fun `query filter should not match empty query`() { + val filter = TestFilterField.name.query("") + + val itemWithContent = TestData(name = "any content") + val itemWithEmptyName = TestData(name = "") + + assertFalse(filter matches itemWithContent) + assertFalse(filter matches itemWithEmptyName) + } + + @Test + fun `query filter should match partial words and case variations`() { + val filter = TestFilterField.name.query("PROD") + + val itemWithLowercase = TestData(name = "production server") + val itemWithMixedCase = TestData(name = "Development Production Environment") + val itemWithPartialMatch = TestData(name = "reproduced issue") + val itemWithoutMatch = TestData(name = "staging server") + + assertTrue(filter matches itemWithLowercase) + assertTrue(filter matches itemWithMixedCase) + assertTrue(filter matches itemWithPartialMatch) + assertFalse(filter matches itemWithoutMatch) + } + + @Test + fun `autocomplete filter should not match empty query`() { + val filter = TestFilterField.name.autocomplete("") + + val itemWithContent = TestData(name = "any content") + val itemWithEmptyName = TestData(name = "") + + assertFalse(filter matches itemWithContent) + assertFalse(filter matches itemWithEmptyName) + } + + @Test + fun `autocomplete filter should match word prefixes`() { + val filter = TestFilterField.name.autocomplete("con") + + val itemWithDotSeparation = TestData(name = "app.config.json") + val itemWithDashSeparation = TestData(name = "user-configuration-file") + val itemWithMixedPunctuation = TestData(name = "system/container,settings.xml") + val itemWithoutWordPrefix = TestData(name = "application") + val itemWithInWordMatch = TestData(name = "reconstruction") + + assertTrue(filter matches itemWithDotSeparation) + assertTrue(filter matches itemWithDashSeparation) + assertTrue(filter matches itemWithMixedPunctuation) + assertFalse(filter matches itemWithoutWordPrefix) + assertFalse(filter matches itemWithInWordMatch) + } + + @Test + fun `pathExists filter should match when specified path exists in field`() { + val filter = TestFilterField.metadata.pathExists("user.profile") + + val itemWithPath = TestData(metadata = mapOf("user" to mapOf("profile" to "data"))) + val itemWithoutPath = TestData(metadata = mapOf("other" to "data")) + + assertTrue(filter matches itemWithPath) + assertFalse(filter matches itemWithoutPath) + } + + @Test + fun `exists filter should match when field exists (is not null)`() { + val existsFilter = TestFilterField.metadata.exists() + + val itemWithMetadata = TestData(metadata = mapOf("key" to "value")) + val itemWithoutMetadata = TestData(metadata = null) + + assertTrue(existsFilter matches itemWithMetadata) + assertFalse(existsFilter matches itemWithoutMetadata) + } + + @Test + fun `doesNotExist filter should match when field does not exist (is null)`() { + val doesNotExistFilter = TestFilterField.metadata.doesNotExist() + + val itemWithMetadata = TestData(metadata = mapOf("key" to "value")) + val itemWithoutMetadata = TestData(metadata = null) + + assertFalse(doesNotExistFilter matches itemWithMetadata) + assertTrue(doesNotExistFilter matches itemWithoutMetadata) + } + + @Test + fun `and filter should match when all filters match`() { + val filter = + Filters.and(TestFilterField.score.greater(80), TestFilterField.isActive.equal(true)) + + val activeItemWithHighScore = TestData(score = 85, isActive = true) + val inactiveItemWithHighScore = TestData(score = 92, isActive = false) + val activeItemWithLowScore = TestData(score = 75, isActive = true) + + assertTrue(filter matches activeItemWithHighScore) + assertFalse(filter matches inactiveItemWithHighScore) + assertFalse(filter matches activeItemWithLowScore) + } + + @Test + fun `or filter should match when any filter matches`() { + val filter = + Filters.or(TestFilterField.score.greater(90), TestFilterField.isActive.equal(true)) + + val activeItemWithLowScore = TestData(score = 75, isActive = true) + val inactiveItemWithHighScore = TestData(score = 95, isActive = false) + val inactiveItemWithLowScore = TestData(score = 75, isActive = false) + + assertTrue(filter matches activeItemWithLowScore) + assertTrue(filter matches inactiveItemWithHighScore) + assertFalse(filter matches inactiveItemWithLowScore) + } + + @Test + fun `nested filters should work correctly`() { + val filter = + Filters.and( + TestFilterField.score.greater(75), + Filters.or( + TestFilterField.name.equal("Special Item"), + TestFilterField.rating.greater(4.0), + ), + ) + + val specialItemWithHighScore = TestData(score = 85, name = "Special Item", rating = 3.5) + val regularItemWithHighScoreAndRating = TestData(score = 90, name = "Regular", rating = 4.2) + val regularItemWithLowScoreAndRating = TestData(score = 70, name = "Regular", rating = 3.8) + + assertTrue(filter matches specialItemWithHighScore) + assertTrue(filter matches regularItemWithHighScoreAndRating) + assertFalse(filter matches regularItemWithLowScoreAndRating) + } + + @Test + fun `complex nested filters should work correctly`() { + val filter = + Filters.or( + Filters.and( + TestFilterField.score.greaterOrEqual(90), + TestFilterField.isActive.equal(false), + ), + Filters.and( + TestFilterField.rating.lessOrEqual(4.0), + TestFilterField.isActive.equal(true), + ), + ) + + val activeItemWithMediumScoreAndRating = TestData(score = 85, isActive = true, rating = 4.5) + val inactiveItemWithHighScore = TestData(score = 95, isActive = false, rating = 4.2) + val activeItemWithLowRating = TestData(score = 80, isActive = true, rating = 3.8) + + assertFalse(filter matches activeItemWithMediumScoreAndRating) + assertTrue(filter matches inactiveItemWithHighScore) + assertTrue(filter matches activeItemWithLowRating) + } + + private data class TestData( + val id: String = "default-id", + val name: String = "Default Name", + val score: Int = 0, + val rating: Double = 0.0, + val tags: List = emptyList(), + val metadata: Map? = null, + val isActive: Boolean = false, + ) + + private data class TestFilterField( + override val remote: String, + override val localValue: (TestData) -> T, + ) : FilterField { + companion object { + val id = TestFilterField("id", TestData::id) + val name = TestFilterField("name", TestData::name) + val score = TestFilterField("score", TestData::score) + val rating = TestFilterField("rating", TestData::rating) + val tags = TestFilterField("tags", TestData::tags) + val metadata = TestFilterField("metadata", TestData::metadata) + val isActive = TestFilterField("isActive", TestData::isActive) + } + } +} diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/filter/FilterToRequestTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/filter/FilterToRequestTest.kt index 1cf693d..7c52198 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/api/filter/FilterToRequestTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/filter/FilterToRequestTest.kt @@ -16,13 +16,14 @@ package io.getstream.android.core.api.filter import junit.framework.TestCase +import kotlin.test.fail import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.Parameterized @RunWith(Parameterized::class) internal class FilterToRequestTest( - private val filter: Filter<*>, + private val filter: Filter<*, *>, private val expectedRequest: Map, private val testName: String, ) { @@ -34,7 +35,9 @@ internal class FilterToRequestTest( } companion object { - private data class TestField(override val remote: String) : FilterField + private data class TestField(override val remote: String) : FilterField { + override val localValue: (Any) -> Any? = { fail("Shouldn't be called in these tests") } + } private val idField = TestField("id") private val createdAtField = TestField("created_at") From 521c54a98fa4834fcbd090826827a739435b8fe6 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Fri, 19 Sep 2025 16:02:09 +0200 Subject: [PATCH 2/3] Annotate Filters operation functions with StreamPublishedApi --- .../getstream/android/core/api/filter/Filters.kt | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/filter/Filters.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/filter/Filters.kt index 860a655..8f0ccae 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/api/filter/Filters.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/filter/Filters.kt @@ -15,6 +15,7 @@ */ package io.getstream.android.core.api.filter +import io.getstream.android.core.annotations.StreamPublishedApi import io.getstream.android.core.internal.filter.BinaryOperator import io.getstream.android.core.internal.filter.CollectionOperator @@ -26,6 +27,7 @@ public object Filters { * @param filters The filters to combine. * @return A filter that matches when all provided filters match. */ + @StreamPublishedApi public fun > and(vararg filters: Filter): Filter = CollectionOperationFilter(CollectionOperator.AND, filters.toSet()) @@ -35,6 +37,7 @@ public object Filters { * @param filters The filters to combine. * @return A filter that matches when any of the specified filters match. */ + @StreamPublishedApi public fun > or(vararg filters: Filter): Filter = CollectionOperationFilter(CollectionOperator.OR, filters.toSet()) } @@ -45,6 +48,7 @@ public object Filters { * @param value The value to check equality against. * @return A filter that matches when this field equals the specified value. */ +@StreamPublishedApi public fun > F.equal(value: Any): Filter = BinaryOperationFilter(BinaryOperator.EQUAL, this, value) @@ -54,6 +58,7 @@ public fun > F.equal(value: Any): Filter = * @param value The value to check against. * @return A filter that matches when this field is greater than the specified value. */ +@StreamPublishedApi public fun > F.greater(value: Any): Filter = BinaryOperationFilter(BinaryOperator.GREATER, this, value) @@ -63,6 +68,7 @@ public fun > F.greater(value: Any): Filter = * @param value The value to check against. * @return A filter that matches when this field is greater than or equal to the specified value. */ +@StreamPublishedApi public fun > F.greaterOrEqual(value: Any): Filter = BinaryOperationFilter(BinaryOperator.GREATER_OR_EQUAL, this, value) @@ -72,6 +78,7 @@ public fun > F.greaterOrEqual(value: Any): Filter = * @param value The value to check against. * @return A filter that matches when this field is less than the specified value. */ +@StreamPublishedApi public fun > F.less(value: Any): Filter = BinaryOperationFilter(BinaryOperator.LESS, this, value) @@ -81,6 +88,7 @@ public fun > F.less(value: Any): Filter = * @param value The value to check against. * @return A filter that matches when this field is less than or equal to the specified value. */ +@StreamPublishedApi public fun > F.lessOrEqual(value: Any): Filter = BinaryOperationFilter(BinaryOperator.LESS_OR_EQUAL, this, value) @@ -90,6 +98,7 @@ public fun > F.lessOrEqual(value: Any): Filter = * @param values The list of values to check against. * @return A filter that matches when this field's value is in the specified list. */ +@StreamPublishedApi public fun > F.`in`(values: List): Filter = BinaryOperationFilter(BinaryOperator.IN, this, values.toSet()) @@ -99,6 +108,7 @@ public fun > F.`in`(values: List): Filter = * @param values The values to check against. * @return A filter that matches when this field's value is in the specified values. */ +@StreamPublishedApi public fun > F.`in`(vararg values: Any): Filter = BinaryOperationFilter(BinaryOperator.IN, this, values.toSet()) @@ -108,6 +118,7 @@ public fun > F.`in`(vararg values: Any): Filter = * @param value The query string to search for. * @return A filter that matches based on the full-text query. */ +@StreamPublishedApi public fun > F.query(value: String): Filter = BinaryOperationFilter(BinaryOperator.QUERY, this, value) @@ -117,6 +128,7 @@ public fun > F.query(value: String): Filter = * @param value The string to autocomplete against. * @return A filter that matches based on autocomplete functionality. */ +@StreamPublishedApi public fun > F.autocomplete(value: String): Filter = BinaryOperationFilter(BinaryOperator.AUTOCOMPLETE, this, value) @@ -125,6 +137,7 @@ public fun > F.autocomplete(value: String): Filter = * * @return A filter that matches when this field exists. */ +@StreamPublishedApi public fun > F.exists(): Filter = BinaryOperationFilter(BinaryOperator.EXISTS, this, true) @@ -133,6 +146,7 @@ public fun > F.exists(): Filter = * * @return A filter that matches when this field does not exist. */ +@StreamPublishedApi public fun > F.doesNotExist(): Filter = BinaryOperationFilter(BinaryOperator.EXISTS, this, false) @@ -142,6 +156,7 @@ public fun > F.doesNotExist(): Filter = * @param value The value to check for within this field. * @return A filter that matches when this field contains the specified value. */ +@StreamPublishedApi public fun > F.contains(value: Any): Filter = BinaryOperationFilter(BinaryOperator.CONTAINS, this, value) @@ -151,5 +166,6 @@ public fun > F.contains(value: Any): Filter = * @param value The path to check for existence. * @return A filter that matches when the specified path exists in this field. */ +@StreamPublishedApi public fun > F.pathExists(value: String): Filter = BinaryOperationFilter(BinaryOperator.PATH_EXISTS, this, value) From 489fbb07511afa25e749a49a100166ed81582c95 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Fri, 19 Sep 2025 17:40:23 +0200 Subject: [PATCH 3/3] Move sort code to its own package --- .../io/getstream/android/core/api/{filter => sort}/Sort.kt | 2 +- .../android/core/api/{filter => sort}/SortComparator.kt | 2 +- .../android/core/api/{filter => sort}/SortDirection.kt | 2 +- .../android/core/api/{filter => sort}/SortField.kt | 2 +- .../getstream/android/core/internal/filter/SortFieldImpl.kt | 6 +++--- 5 files changed, 7 insertions(+), 7 deletions(-) rename stream-android-core/src/main/java/io/getstream/android/core/api/{filter => sort}/Sort.kt (96%) rename stream-android-core/src/main/java/io/getstream/android/core/api/{filter => sort}/SortComparator.kt (99%) rename stream-android-core/src/main/java/io/getstream/android/core/api/{filter => sort}/SortDirection.kt (95%) rename stream-android-core/src/main/java/io/getstream/android/core/api/{filter => sort}/SortField.kt (97%) diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/filter/Sort.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/sort/Sort.kt similarity index 96% rename from stream-android-core/src/main/java/io/getstream/android/core/api/filter/Sort.kt rename to stream-android-core/src/main/java/io/getstream/android/core/api/sort/Sort.kt index 640392a..b519d17 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/api/filter/Sort.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/sort/Sort.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.getstream.android.core.api.filter +package io.getstream.android.core.api.sort /** * A sort configuration that combines a sort field with a direction. diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/filter/SortComparator.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/sort/SortComparator.kt similarity index 99% rename from stream-android-core/src/main/java/io/getstream/android/core/api/filter/SortComparator.kt rename to stream-android-core/src/main/java/io/getstream/android/core/api/sort/SortComparator.kt index f8431a2..f74201f 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/api/filter/SortComparator.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/sort/SortComparator.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.getstream.android.core.api.filter +package io.getstream.android.core.api.sort import io.getstream.android.core.annotations.StreamInternalApi diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/filter/SortDirection.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/sort/SortDirection.kt similarity index 95% rename from stream-android-core/src/main/java/io/getstream/android/core/api/filter/SortDirection.kt rename to stream-android-core/src/main/java/io/getstream/android/core/api/sort/SortDirection.kt index c31e4c6..44f7b7d 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/api/filter/SortDirection.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/sort/SortDirection.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.getstream.android.core.api.filter +package io.getstream.android.core.api.sort /** * The direction of a sort operation. This enum defines whether a sort should be performed in diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/filter/SortField.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/sort/SortField.kt similarity index 97% rename from stream-android-core/src/main/java/io/getstream/android/core/api/filter/SortField.kt rename to stream-android-core/src/main/java/io/getstream/android/core/api/sort/SortField.kt index 5c2c002..e914402 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/api/filter/SortField.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/sort/SortField.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.getstream.android.core.api.filter +package io.getstream.android.core.api.sort import io.getstream.android.core.annotations.StreamInternalApi import io.getstream.android.core.annotations.StreamPublishedApi diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/filter/SortFieldImpl.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/filter/SortFieldImpl.kt index 276a0ac..fb7ae82 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/filter/SortFieldImpl.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/filter/SortFieldImpl.kt @@ -15,9 +15,9 @@ */ package io.getstream.android.core.internal.filter -import io.getstream.android.core.api.filter.AnySortComparator -import io.getstream.android.core.api.filter.SortComparator -import io.getstream.android.core.api.filter.SortField +import io.getstream.android.core.api.sort.AnySortComparator +import io.getstream.android.core.api.sort.SortComparator +import io.getstream.android.core.api.sort.SortField /** Private implementation of SortField */ internal class SortFieldImpl>(