Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -17,32 +17,69 @@ 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.
*
* 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<F : FilterField>
@StreamPublishedApi public sealed interface Filter<M, F : FilterField<M>>

internal data class BinaryOperationFilter<F : FilterField, V : Any>(
val operator: FilterOperator,
internal data class BinaryOperationFilter<M, F : FilterField<M>>(
val operator: BinaryOperator,
val field: F,
val value: V,
) : Filter<F>
val value: Any,
) : Filter<M, F>

internal data class CollectionOperationFilter<F : FilterField>(
internal val operator: FilterOperator,
val filters: Set<Filter<F>>,
) : Filter<F>
internal data class CollectionOperationFilter<M, F : FilterField<M>>(
internal val operator: CollectionOperator,
val filters: Set<Filter<M, F>>,
) : Filter<M, F>

/** Converts a [Filter] instance to a request map suitable for API queries. */
@StreamInternalApi
public fun Filter<*>.toRequest(): Map<String, Any> =
public fun Filter<*, *>.toRequest(): Map<String, Any> =
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 <M, F : FilterField<M>> Filter<M, F>.matches(item: M): Boolean =
when (this) {
is BinaryOperationFilter<M, F> -> {
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<M, F> -> {
when (operator) {
CollectionOperator.AND -> filters.all { it.matches(item) }
CollectionOperator.OR -> filters.any { it.matches(item) }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<M> {
/** 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?
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
*/
package io.getstream.android.core.api.filter
Copy link
Contributor

@VelikovPetar VelikovPetar Sep 19, 2025

Choose a reason for hiding this comment

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

Unrelated to this PR: I can see that the io.getstream.android.core.api.filter also contains the Sort logic. Perhaps we should move it to another package sort, or to rename this whole package to something like query, which will hold both filtering and sorting logic (would be good to unify this before the first major release to avoid breaking changes).

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I remember @aleksandar-apostolov not being fully convinced about the query name for filtering. I wanted to find an even more generic name, but couldn't. I asked cursor and it agrees with you :D

Screenshot 2025-09-19 at 16 27 16

It also suggested other stuff, but I don't think they're fitting

Screenshot 2025-09-19 at 16 28 52

Copy link
Collaborator

Choose a reason for hiding this comment

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

I was not convinced because it was called query and only had Filters.kt in it.
But if its 3 on 1 (including Cursor, feel free to rename it)

Here is Codex opinion btw:

The code in io.getstream.android.core.api.filter and io.getstream.android.core.internal.filter is all about the filtering DSL—operators, filter builders, and sort helpers—not about end-to-end “query” construction.

  • Public API surface such as Filters.and, Filters.or, and the extension helpers like FilterField.equal, FilterField.in, etc. are strictly building filter expressions that map onto the server-side filter JSON (Filters.kt:20-154).
  • Those helpers are backed by the internal pieces FilterOperator and SortFieldImpl (FilterOperator.kt:16-63, SortFieldImpl.kt:16-31), which simply translate our filter clauses into the $eq, $in, $and, … operators the API expects.
    A “query” in Stream terminology usually combines multiple concerns (filters, sort, pagination limits, watch flags, etc.), and there are already request DTOs and client methods that represent full queries elsewhere in the SDK. Renaming this package to query would therefore overstate what the code does and blur the separation between the low-level filter DSL and the higher-level query request objects. Keeping it as filter/filters makes it clear these classes are the filter-and-sort building blocks that other query-related components consume.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Just to clarify I asked Codex "Is query a better package name for the filter package." :)

Copy link
Collaborator Author

@gpunto gpunto Sep 19, 2025

Choose a reason for hiding this comment

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

I took Petar's first suggestion and moved sort code to its own package, so we avoid the query name, since core doesn't know anything about queries now

Copy link
Contributor

Choose a reason for hiding this comment

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

Looks good to me!
If @aleksandar-apostolov agrees as well, I would say to go ahead and merge it!


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 {
Expand All @@ -25,17 +26,17 @@ public object Filters {
* @param filters The filters to combine.
* @return A filter that matches when all provided filters match.
*/
public fun <F : FilterField> and(vararg filters: Filter<F>): Filter<F> =
CollectionOperationFilter(FilterOperator.AND, filters.toSet())
public fun <M, F : FilterField<M>> and(vararg filters: Filter<M, F>): Filter<M, F> =
CollectionOperationFilter(CollectionOperator.AND, filters.toSet())

/**
* Creates a filter that combines multiple filters with a logical OR operation.
*
* @param filters The filters to combine.
* @return A filter that matches when any of the specified filters match.
*/
public fun <F : FilterField> or(vararg filters: Filter<F>): Filter<F> =
CollectionOperationFilter(FilterOperator.OR, filters.toSet())
public fun <M, F : FilterField<M>> or(vararg filters: Filter<M, F>): Filter<M, F> =
CollectionOperationFilter(CollectionOperator.OR, filters.toSet())
}

/**
Expand All @@ -44,111 +45,111 @@ 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 : FilterField> F.equal(value: Any): Filter<F> =
BinaryOperationFilter(FilterOperator.EQUAL, this, value)
public fun <M, F : FilterField<M>> F.equal(value: Any): Filter<M, F> =
BinaryOperationFilter(BinaryOperator.EQUAL, this, value)

/**
* Creates a filter that checks if this field is greater than a specific value.
*
* @param value The value to check against.
* @return A filter that matches when this field is greater than the specified value.
*/
public fun <F : FilterField> F.greater(value: Any): Filter<F> =
BinaryOperationFilter(FilterOperator.GREATER, this, value)
public fun <M, F : FilterField<M>> F.greater(value: Any): Filter<M, F> =
BinaryOperationFilter(BinaryOperator.GREATER, this, value)

/**
* Creates a filter that checks if this field is greater than or equal to a specific value.
*
* @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 : FilterField> F.greaterOrEqual(value: Any): Filter<F> =
BinaryOperationFilter(FilterOperator.GREATER_OR_EQUAL, this, value)
public fun <M, F : FilterField<M>> F.greaterOrEqual(value: Any): Filter<M, F> =
BinaryOperationFilter(BinaryOperator.GREATER_OR_EQUAL, this, value)

/**
* Creates a filter that checks if this field is less than a specific value.
*
* @param value The value to check against.
* @return A filter that matches when this field is less than the specified value.
*/
public fun <F : FilterField> F.less(value: Any): Filter<F> =
BinaryOperationFilter(FilterOperator.LESS, this, value)
public fun <M, F : FilterField<M>> F.less(value: Any): Filter<M, F> =
BinaryOperationFilter(BinaryOperator.LESS, this, value)

/**
* Creates a filter that checks if this field is less than or equal to a specific value.
*
* @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 : FilterField> F.lessOrEqual(value: Any): Filter<F> =
BinaryOperationFilter(FilterOperator.LESS_OR_EQUAL, this, value)
public fun <M, F : FilterField<M>> F.lessOrEqual(value: Any): Filter<M, F> =
BinaryOperationFilter(BinaryOperator.LESS_OR_EQUAL, this, value)

/**
* Creates a filter that checks if this field's value is in a specific list of values.
*
* @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 : FilterField> F.`in`(values: List<Any>): Filter<F> =
BinaryOperationFilter(FilterOperator.IN, this, values.toSet())
public fun <M, F : FilterField<M>> F.`in`(values: List<Any>): Filter<M, F> =
BinaryOperationFilter(BinaryOperator.IN, this, values.toSet())

/**
* Creates a filter that checks if this field's value is in a specific set of values.
*
* @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 : FilterField> F.`in`(vararg values: Any): Filter<F> =
BinaryOperationFilter(FilterOperator.IN, this, values.toSet())
public fun <M, F : FilterField<M>> F.`in`(vararg values: Any): Filter<M, F> =
BinaryOperationFilter(BinaryOperator.IN, this, values.toSet())

/**
* Creates a filter that performs a full-text query on this field.
*
* @param value The query string to search for.
* @return A filter that matches based on the full-text query.
*/
public fun <F : FilterField> F.query(value: String): Filter<F> =
BinaryOperationFilter(FilterOperator.QUERY, this, value)
public fun <M, F : FilterField<M>> F.query(value: String): Filter<M, F> =
BinaryOperationFilter(BinaryOperator.QUERY, this, value)

/**
* Creates a filter that performs autocomplete matching on this field.
*
* @param value The string to autocomplete against.
* @return A filter that matches based on autocomplete functionality.
*/
public fun <F : FilterField> F.autocomplete(value: String): Filter<F> =
BinaryOperationFilter(FilterOperator.AUTOCOMPLETE, this, value)
public fun <M, F : FilterField<M>> F.autocomplete(value: String): Filter<M, F> =
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 : FilterField> F.exists(): Filter<F> =
BinaryOperationFilter(FilterOperator.EXISTS, this, true)
public fun <M, F : FilterField<M>> F.exists(): Filter<M, F> =
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 : FilterField> F.doesNotExist(): Filter<F> =
BinaryOperationFilter(FilterOperator.EXISTS, this, false)
public fun <M, F : FilterField<M>> F.doesNotExist(): Filter<M, F> =
BinaryOperationFilter(BinaryOperator.EXISTS, this, false)

/**
* Creates a filter that checks if this field contains a specific value.
*
* @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 : FilterField> F.contains(value: Any): Filter<F> =
BinaryOperationFilter(FilterOperator.CONTAINS, this, value)
public fun <M, F : FilterField<M>> F.contains(value: Any): Filter<M, F> =
BinaryOperationFilter(BinaryOperator.CONTAINS, this, value)

/**
* Creates a filter that checks if a specific path exists within this field.
*
* @param value The path to check for existence.
* @return A filter that matches when the specified path exists in this field.
*/
public fun <F : FilterField> F.pathExists(value: String): Filter<F> =
BinaryOperationFilter(FilterOperator.PATH_EXISTS, this, value)
public fun <M, F : FilterField<M>> F.pathExists(value: String): Filter<M, F> =
BinaryOperationFilter(BinaryOperator.PATH_EXISTS, this, value)
Original file line number Diff line number Diff line change
@@ -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<Any>).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
}
}
Loading