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
27 changes: 27 additions & 0 deletions Sources/StreamCore/OpenAPI/Query/Filter+Local.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Copyright © 2025 Stream.io Inc. All rights reserved.
//

import CoreLocation
import Foundation

/// A filter matcher which rrases the type of the value the filter matches against.
Expand Down Expand Up @@ -122,6 +123,10 @@ private struct FilterMatcher<Model, Value>: Sendable where Model: Sendable, Valu
return Self.isIn(localRawJSONValue, filterRawJSONValue)
case .pathExists:
return Self.pathExists(localRawJSONValue, filterRawJSONValue)
case .near:
return Self.isNear(localRawJSONValue, filterRawJSONValue)
case .withinBounds:
return Self.isWithinBounds(localRawJSONValue, filterRawJSONValue)
case .and, .or:
log.debug("Should never try to match compound operators")
return false
Expand Down Expand Up @@ -242,6 +247,28 @@ private struct FilterMatcher<Model, Value>: Sendable where Model: Sendable, Valu
}
}

static func isNear(_ localRawJSONValue: RawJSON, _ filterRawJSONValue: RawJSON) -> Bool {
switch (localRawJSONValue, filterRawJSONValue) {
case (.dictionary(let localDictionaryValue), .dictionary(let filterDictionaryValue)):
guard let location = CLLocationCoordinate2D(from: localDictionaryValue) else { return false }
guard let filterRegion = CircularRegion(from: filterDictionaryValue) else { return false }
return filterRegion.contains(location)
default:
return false
}
}

static func isWithinBounds(_ localRawJSONValue: RawJSON, _ filterRawJSONValue: RawJSON) -> Bool {
switch (localRawJSONValue, filterRawJSONValue) {
case (.dictionary(let localDictionaryValue), .dictionary(let filterDictionaryValue)):
guard let location = CLLocationCoordinate2D(from: localDictionaryValue) else { return false }
guard let filterRegion = BoundingBox(from: filterDictionaryValue) else { return false }
return filterRegion.contains(location)
default:
return false
}
}

// MARK: -

/// PostgreSQL-style full-text search for tokenized text and word boundary matching
Expand Down
102 changes: 31 additions & 71 deletions Sources/StreamCore/OpenAPI/Query/Filter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,6 @@ public protocol Filter: FilterValue, Sendable {
init(filterOperator: FilterOperator, field: FilterField, value: any FilterValue)
}

/// A protocol that defines values that can be used in filters.
///
/// This protocol is automatically conformed to by common Swift types like `String`, `Int`, `Bool`, etc.
public protocol FilterValue: Sendable {}

/// A protocol that defines how filter fields are represented as strings.
///
/// This protocol allows for type-safe field names while maintaining the ability to convert to string values
Expand Down Expand Up @@ -200,6 +195,33 @@ extension Filter {
Self(filterOperator: .query, field: field, value: value)
}

/// Creates a filter that uses the Haversine formula to find values
/// within the specified distance from the given coordinates.
///
/// This filter matches locations that fall within a circular region defined by a center point
/// and a radius. The distance calculation uses the Haversine formula to account for Earth's curvature.
///
/// - Parameters:
/// - field: The field containing location coordinates to query.
/// - value: The circular region defining the center point and radius to match against.
/// - Returns: A filter that matches when the location field is within the specified circular region.
public static func near(_ field: FilterField, _ value: CircularRegion) -> Self {
Self(filterOperator: .near, field: field, value: value)
}

/// Creates a filter that finds values within the specified rectangular bounding box.
///
/// This filter matches locations that fall within a rectangular geographic region defined by
/// northeast and southwest corner coordinates.
///
/// - Parameters:
/// - field: The field containing location coordinates to query.
/// - value: The bounding box defining the rectangular region to match against.
/// - Returns: A filter that matches when the location field is within the specified bounding box.
public static func withinBounds(_ field: FilterField, _ value: BoundingBox) -> Self {
Self(filterOperator: .withinBounds, field: field, value: value)
}

/// Creates a filter that combines multiple filters with a logical AND operation.
///
/// - Parameter filters: An array of filters to combine.
Expand All @@ -217,58 +239,25 @@ extension Filter {
}
}

// MARK: - Supported Filter Values

/// Extends `Bool` to conform to `FilterValue` for use in filters.
extension Bool: FilterValue {}

/// Extends `Date` to conform to `FilterValue` for use in filters.
/// Dates are automatically converted to RFC3339 format when serialized.
extension Date: FilterValue {}

/// Extends `Double` to conform to `FilterValue` for use in filters.
extension Double: FilterValue {}

/// Extends `Float` to conform to `FilterValue` for use in filters.
extension Float: FilterValue {}

/// Extends `Int` to conform to `FilterValue` for use in filters.
extension Int: FilterValue {}

/// Extends `String` to conform to `FilterValue` for use in filters.
extension String: FilterValue {}

/// Extends `URL` to conform to `FilterValue` for use in filters.
/// URLs are automatically converted to their absolute string representation when serialized.
extension URL: FilterValue {}

/// Extends `Array` to conform to `FilterValue` when its elements also conform to `FilterValue`.
/// This allows arrays of filter values to be used in filters (e.g., for the `in` operator).
extension Array: FilterValue where Element: FilterValue {}

/// Extends `Dictionary` to conform to `FilterValue` when the key is `String` and value is `RawJSON`.
/// This allows dictionaries to be used in filters for complex object matching.
extension Dictionary: FilterValue where Key == String, Value == RawJSON {}

extension Optional: FilterValue where Wrapped: FilterValue {}

// MARK: - Filter to RawJSON Conversion

extension Filter {
public var rawJSON: RawJSON { .dictionary(toRawJSONDictionary()) }

/// Converts the filter to a `RawJSON` representation for API communication.
///
/// This method handles both regular filters and group filters (AND/OR combinations).
///
/// - Returns: A dictionary representation of the filter in `RawJSON` format.
public func toRawJSON() -> [String: RawJSON] {
public func toRawJSONDictionary() -> [String: RawJSON] {
Copy link
Contributor Author

@laevandus laevandus Nov 18, 2025

Choose a reason for hiding this comment

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

This is used for API requests in the Feeds SDK. rawJSON above comes from the FilterValue protocol and is used by toRawJSONDictionary. All in all, toRawJSONDictionary is just a convenience for API requests.
rawJSON was moved to the protocol since then it is better to manage conformance of all the types. Discovered some mapping issues like Int was not correctly encoded to RawJSON thanks to this change (there was a big switch statement before which).

if filterOperator.isGroup {
// Filters with group operators are encoded in the following form:
// { $<operator>: [ <filter 1>, <filter 2> ] }
guard let filters = value as? [any Filter] else {
log.error("Unknown filter value used with \(filterOperator)")
return [:]
}
let rawJSONFilters = filters.map { $0.toRawJSON() }.map { RawJSON.dictionary($0) }
let rawJSONFilters = filters.map(\.rawJSON)
return [filterOperator.rawValue: .array(rawJSONFilters)]
} else {
// Normal filters are encoded in the following form:
Expand All @@ -277,32 +266,3 @@ extension Filter {
}
}
}

extension FilterValue {
/// Converts the filter value to its `RawJSON` representation.
///
/// This property handles the conversion of various Swift types to their appropriate JSON representation
/// for API communication.
var rawJSON: RawJSON {
switch self {
case let boolValue as Bool:
.bool(boolValue)
case let dateValue as Date:
.string(RFC3339DateFormatter.string(from: dateValue))
case let doubleValue as Double:
.number(doubleValue)
case let intValue as Int:
.number(Double(intValue))
case let stringValue as String:
.string(stringValue)
case let urlValue as URL:
.string(urlValue.absoluteString)
case let arrayValue as [any FilterValue]:
.array(arrayValue.map(\.rawJSON))
case let dictionaryValue as [String: RawJSON]:
.dictionary(dictionaryValue)
default:
.nil
}
}
}
6 changes: 6 additions & 0 deletions Sources/StreamCore/OpenAPI/Query/FilterOperator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ public enum FilterOperator: String, Sendable {

/// Matches if the value contains JSON with the given path.
case pathExists = "$path_exists"

/// Matches if the location is within the specified circular area.
case near = "$near"

/// Matches if the location is within the specified rectangular area.
case withinBounds = "$within_bounds"
}

extension FilterOperator {
Expand Down
152 changes: 152 additions & 0 deletions Sources/StreamCore/OpenAPI/Query/FilterValue+Location.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
//
// Copyright © 2025 Stream.io Inc. All rights reserved.
//

import CoreLocation
import Foundation

/// A circular geographic region defined by a center point and a radius.
///
/// Use `CircularRegion` to represent a circular area on Earth's surface, typically used for location-based
/// queries such as finding all points within a certain distance from a center coordinate.
///
/// ## Example
/// ```swift
/// let center = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194)
/// let region = CircularRegion(center: center, radiusInMeters: 5000)
/// ```
public struct CircularRegion {
/// The center coordinate of the circular region.
public let center: CLLocationCoordinate2D

/// The radius of the circular region in meters.
public let radius: Double

/// Creates a circular region with the specified center and radius.
///
/// - Parameters:
/// - center: The center coordinate of the circular region.
/// - radiusInMeters: The radius of the region in meters.
public init(center: CLLocationCoordinate2D, radiusInMeters: Double) {
self.center = center
self.radius = radiusInMeters
}

/// Creates a circular region with the specified center and radius.
///
/// - Parameters:
/// - center: The center coordinate of the circular region.
/// - radiusInKM: The radius of the region in kilometers. This value is automatically converted to meters.
public init(center: CLLocationCoordinate2D, radiusInKM: Double) {
self.center = center
self.radius = radiusInKM * 1000.0
}
}

extension CircularRegion: FilterValue {
static let rawJSONDistanceInKmKey = "distance"

init?(from rawJSON: [String: RawJSON]) {
guard let center = CLLocationCoordinate2D(from: rawJSON) else { return nil }
guard let distanceInKm = rawJSON[Self.rawJSONDistanceInKmKey]?.numberValue else { return nil }
self.center = center
self.radius = distanceInKm * 1000.0
}

public var rawJSON: RawJSON {
[
CLLocationCoordinate2D.rawJSONLatitudeKey: .number(center.latitude),
CLLocationCoordinate2D.rawJSONLongitudeKey: .number(center.longitude),
Self.rawJSONDistanceInKmKey: .number(radius / 1000.0)
]
}

func contains(_ coordinate: CLLocationCoordinate2D) -> Bool {
let centerLocation = CLLocation(latitude: center.latitude, longitude: center.longitude)
let coordinateLocation = CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)
let distance = centerLocation.distance(from: coordinateLocation)
return distance <= radius
}
}

/// A rectangular geographic region defined by northeast and southwest corner coordinates.
///
/// Use `BoundingBox` to represent a rectangular area on Earth's surface, typically used for location-based
/// queries such as finding all points within a specific geographic boundary.
///
/// The bounding box is defined by two corner coordinates:
/// - `northeast`: The northeast (top-right) corner of the rectangle
/// - `southwest`: The southwest (bottom-left) corner of the rectangle
///
/// ## Example
/// ```swift
/// let ne = CLLocationCoordinate2D(latitude: 40.7128, longitude: -74.0060)
/// let sw = CLLocationCoordinate2D(latitude: 40.7580, longitude: -73.9855)
/// let boundingBox = BoundingBox(northeast: ne, southwest: sw)
/// ```
public struct BoundingBox {
/// The northeast (top-right) corner coordinate of the bounding box.
public let northeast: CLLocationCoordinate2D

/// The southwest (bottom-left) corner coordinate of the bounding box.
public let southwest: CLLocationCoordinate2D

/// Creates a bounding box with the specified corner coordinates.
///
/// - Parameters:
/// - northeast: The northeast (top-right) corner coordinate.
/// - southwest: The southwest (bottom-left) corner coordinate.
public init(northeast: CLLocationCoordinate2D, southwest: CLLocationCoordinate2D) {
self.northeast = northeast
self.southwest = southwest
}
}

extension BoundingBox: FilterValue {
static let rawJSONNortheastLatitudeKey = "ne_lat"
static let rawJSONNortheastLongitudeKey = "ne_lng"
static let rawJSONSouthwestLatitudeKey = "sw_lat"
static let rawJSONSouthwestLongitudeKey = "sw_lng"

init?(from rawJSON: [String: RawJSON]) {
guard let neLatitude = rawJSON[Self.rawJSONNortheastLatitudeKey]?.numberValue else { return nil }
guard let neLongitude = rawJSON[Self.rawJSONNortheastLongitudeKey]?.numberValue else { return nil }
guard let swLatitude = rawJSON[Self.rawJSONSouthwestLatitudeKey]?.numberValue else { return nil }
guard let swLongitude = rawJSON[Self.rawJSONSouthwestLongitudeKey]?.numberValue else { return nil }
northeast = CLLocationCoordinate2D(latitude: neLatitude, longitude: neLongitude)
southwest = CLLocationCoordinate2D(latitude: swLatitude, longitude: swLongitude)
}

public var rawJSON: RawJSON {
[
Self.rawJSONNortheastLatitudeKey: .number(northeast.latitude),
Self.rawJSONNortheastLongitudeKey: .number(northeast.longitude),
Self.rawJSONSouthwestLatitudeKey: .number(southwest.latitude),
Self.rawJSONSouthwestLongitudeKey: .number(southwest.longitude)
]
}

func contains(_ coordinate: CLLocationCoordinate2D) -> Bool {
guard coordinate.latitude >= southwest.latitude && coordinate.latitude <= northeast.latitude else { return false }
guard coordinate.longitude >= southwest.longitude && coordinate.longitude <= northeast.longitude else { return false }
return true
}
}

extension CLLocationCoordinate2D: FilterValue {
static let rawJSONLatitudeKey = "lat"
static let rawJSONLongitudeKey = "lng"

init?(from rawJSON: [String: RawJSON]) {
guard let latitude = rawJSON[CLLocationCoordinate2D.rawJSONLatitudeKey]?.numberValue else { return nil }
guard let longitude = rawJSON[CLLocationCoordinate2D.rawJSONLongitudeKey]?.numberValue else { return nil }
self.init(latitude: latitude, longitude: longitude)
}

public var rawJSON: RawJSON {
.dictionary([
Self.rawJSONLatitudeKey: .number(latitude),
Self.rawJSONLongitudeKey: .number(longitude)
])
}
}
Loading