diff --git a/Sources/StreamCore/OpenAPI/Query/Filter+Local.swift b/Sources/StreamCore/OpenAPI/Query/Filter+Local.swift index cf1dfbb..c46d37b 100644 --- a/Sources/StreamCore/OpenAPI/Query/Filter+Local.swift +++ b/Sources/StreamCore/OpenAPI/Query/Filter+Local.swift @@ -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. @@ -122,6 +123,10 @@ private struct FilterMatcher: 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 @@ -242,6 +247,28 @@ private struct FilterMatcher: 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 diff --git a/Sources/StreamCore/OpenAPI/Query/Filter.swift b/Sources/StreamCore/OpenAPI/Query/Filter.swift index bb4bd53..55951f3 100644 --- a/Sources/StreamCore/OpenAPI/Query/Filter.swift +++ b/Sources/StreamCore/OpenAPI/Query/Filter.swift @@ -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 @@ -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. @@ -217,50 +239,17 @@ 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] { if filterOperator.isGroup { // Filters with group operators are encoded in the following form: // { $: [ , ] } @@ -268,7 +257,7 @@ extension Filter { 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: @@ -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 - } - } -} diff --git a/Sources/StreamCore/OpenAPI/Query/FilterOperator.swift b/Sources/StreamCore/OpenAPI/Query/FilterOperator.swift index 0ea21d8..9ccca5f 100644 --- a/Sources/StreamCore/OpenAPI/Query/FilterOperator.swift +++ b/Sources/StreamCore/OpenAPI/Query/FilterOperator.swift @@ -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 { diff --git a/Sources/StreamCore/OpenAPI/Query/FilterValue+Location.swift b/Sources/StreamCore/OpenAPI/Query/FilterValue+Location.swift new file mode 100644 index 0000000..fb04b32 --- /dev/null +++ b/Sources/StreamCore/OpenAPI/Query/FilterValue+Location.swift @@ -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) + ]) + } +} diff --git a/Sources/StreamCore/OpenAPI/Query/FilterValue.swift b/Sources/StreamCore/OpenAPI/Query/FilterValue.swift new file mode 100644 index 0000000..9228d5d --- /dev/null +++ b/Sources/StreamCore/OpenAPI/Query/FilterValue.swift @@ -0,0 +1,59 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +/// 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 { + var rawJSON: RawJSON { get } +} + +// MARK: - Supported Built-In Filter Values + +extension Bool: FilterValue { + public var rawJSON: RawJSON { .bool(self) } +} + +extension Date: FilterValue { + /// Dates are automatically converted to RFC3339 format when serialized. + public var rawJSON: RawJSON { .string(RFC3339DateFormatter.string(from: self)) } +} + +extension Double: FilterValue { + public var rawJSON: RawJSON { .number(self) } +} + +extension Float: FilterValue { + public var rawJSON: RawJSON { .number(Double(self)) } +} + +extension Int: FilterValue { + public var rawJSON: RawJSON { .number(Double(self)) } +} + +extension String: FilterValue { + public var rawJSON: RawJSON { .string(self) } +} + +extension URL: FilterValue { + /// URLs are automatically converted to their absolute string representation when serialized. + public var rawJSON: RawJSON { .string(absoluteString) } +} + +extension Array: FilterValue where Element: FilterValue { + public var rawJSON: RawJSON { .array(self.map(\.rawJSON)) } +} + +extension Dictionary: FilterValue where Key == String, Value == RawJSON { + public var rawJSON: RawJSON { .dictionary(self) } +} + +extension Optional: FilterValue where Wrapped: FilterValue { + public var rawJSON: RawJSON { + guard let value = self else { return .nil } + return value.rawJSON + } +} diff --git a/StreamCore.xcodeproj/project.pbxproj b/StreamCore.xcodeproj/project.pbxproj index 953cd22..15cd3e1 100644 --- a/StreamCore.xcodeproj/project.pbxproj +++ b/StreamCore.xcodeproj/project.pbxproj @@ -420,7 +420,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 2600; - LastUpgradeCheck = 2600; + LastUpgradeCheck = 2610; TargetAttributes = { 4F1E6A7C2E9FB58600BA0F44 = { CreatedOnToolsVersion = 26.0.1; @@ -917,6 +917,7 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 6.0; @@ -978,6 +979,7 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_VERSION = 6.0; VALIDATE_PRODUCT = YES; diff --git a/StreamCore.xcodeproj/xcshareddata/xcschemes/StreamAttachments.xcscheme b/StreamCore.xcodeproj/xcshareddata/xcschemes/StreamAttachments.xcscheme index 21f0246..e8506c3 100644 --- a/StreamCore.xcodeproj/xcshareddata/xcschemes/StreamAttachments.xcscheme +++ b/StreamCore.xcodeproj/xcshareddata/xcschemes/StreamAttachments.xcscheme @@ -1,6 +1,6 @@