Skip to content

Add KeyPath-based QueryFilter API for Type-Safe Filtering #149

@leogdion

Description

@leogdion

Overview

Add KeyPath-based overloads to the QueryFilter API to enable type-safe, compile-time checked filtering with IDE autocomplete support, while maintaining backward compatibility with the existing string-based API.

Current API (String-Based)

let filters = [
    QueryFilter.equals("username", .string("alice")),
    QueryFilter.greaterThan("age", .int64(21)),
    QueryFilter.beginsWith("email", .string("admin@"))
]

try await service.queryRecords(
    recordType: "User",
    filters: filters
)

Problems:

  • ❌ Prone to typos ("userName" vs "username")
  • ❌ No compile-time validation
  • ❌ No IDE autocomplete
  • ❌ Not refactoring-safe
  • ❌ Manual FieldValue type specification (.string(), .int64(), etc.)

Proposed API (KeyPath-Based)

let filters = [
    QueryFilter.equals(\.username, "alice"),           // Type-inferred
    QueryFilter.greaterThan(\.age, 21),                // Type-safe
    QueryFilter.beginsWith(\.email, "admin@")          // Autocomplete
]

try await service.queryRecords(
    recordType: "User",
    filters: filters
)

Benefits:

  • ✅ Compile-time validation of field existence
  • ✅ Full IDE autocomplete support
  • ✅ Automatic type inference (no .string(), .int64() needed)
  • ✅ Refactoring-safe (renaming properties updates all queries)
  • ✅ Backward compatible with string-based API

Implementation Phases

Phase 1: Core Infrastructure

1.1 CloudKitValueConvertible Protocol

Add automatic conversion from Swift types to FieldValue:

/// Protocol for types that can be automatically converted to CloudKit FieldValue
public protocol CloudKitValueConvertible {
    func toFieldValue() -> FieldValue
}

extension String: CloudKitValueConvertible {
    public func toFieldValue() -> FieldValue { .string(self) }
}

extension Int: CloudKitValueConvertible {
    public func toFieldValue() -> FieldValue { .int64(Int64(self)) }
}

extension Int64: CloudKitValueConvertible {
    public func toFieldValue() -> FieldValue { .int64(self) }
}

extension Double: CloudKitValueConvertible {
    public func toFieldValue() -> FieldValue { .double(self) }
}

extension Bool: CloudKitValueConvertible {
    public func toFieldValue() -> FieldValue { .boolean(self) }
}

extension Date: CloudKitValueConvertible {
    public func toFieldValue() -> FieldValue { .date(self) }
}

extension Array: CloudKitValueConvertible where Element: CloudKitValueConvertible {
    public func toFieldValue() -> FieldValue {
        .list(self.map { $0.toFieldValue() })
    }
}

1.2 KeyPath Field Name Extraction

Add mechanism to extract CloudKit field names from KeyPaths:

extension CloudKitRecord {
    /// Extract CloudKit field name from a KeyPath
    /// Default implementation uses property name via Mirror
    static func fieldName<T>(for keyPath: KeyPath<Self, T>) -> String {
        // Implementation using Mirror or macro-generated mapping
        // Returns the property name as a string
    }
    
    /// Override point for custom field name mappings
    /// Use when CloudKit field names differ from Swift property names
    static var fieldNameMappings: [PartialKeyPath<Self>: String] { [:] }
}

Example usage:

struct User: CloudKitRecord {
    var username: String
    var emailAddress: String
    
    // Override for custom CloudKit field names
    static var fieldNameMappings: [PartialKeyPath<User>: String] {
        [\.emailAddress: "email"]  // CloudKit uses "email", Swift uses "emailAddress"
    }
}

User.fieldName(for: \.username)      // → "username"
User.fieldName(for: \.emailAddress)  // → "email" (from mapping)

Phase 2: QueryFilter KeyPath Overloads

Add KeyPath-based overloads for all existing filter types:

2.1 Equality Filters

extension QueryFilter {
    /// Equality filter using KeyPath
    public static func equals<Record: CloudKitRecord, Value: CloudKitValueConvertible>(
        _ keyPath: KeyPath<Record, Value>,
        _ value: Value
    ) -> QueryFilter {
        let fieldName = Record.fieldName(for: keyPath)
        return .equals(fieldName, value.toFieldValue())
    }
    
    /// Not equals filter using KeyPath
    public static func notEquals<Record: CloudKitRecord, Value: CloudKitValueConvertible>(
        _ keyPath: KeyPath<Record, Value>,
        _ value: Value
    ) -> QueryFilter {
        let fieldName = Record.fieldName(for: keyPath)
        return .notEquals(fieldName, value.toFieldValue())
    }
}

2.2 Comparison Filters

extension QueryFilter {
    public static func lessThan<Record: CloudKitRecord, Value: CloudKitValueConvertible>(
        _ keyPath: KeyPath<Record, Value>,
        _ value: Value
    ) -> QueryFilter
    
    public static func lessThanOrEquals<Record: CloudKitRecord, Value: CloudKitValueConvertible>(
        _ keyPath: KeyPath<Record, Value>,
        _ value: Value
    ) -> QueryFilter
    
    public static func greaterThan<Record: CloudKitRecord, Value: CloudKitValueConvertible>(
        _ keyPath: KeyPath<Record, Value>,
        _ value: Value
    ) -> QueryFilter
    
    public static func greaterThanOrEquals<Record: CloudKitRecord, Value: CloudKitValueConvertible>(
        _ keyPath: KeyPath<Record, Value>,
        _ value: Value
    ) -> QueryFilter
}

2.3 String Filters

extension QueryFilter {
    /// String begins with filter using KeyPath
    public static func beginsWith<Record: CloudKitRecord>(
        _ keyPath: KeyPath<Record, String>,
        _ prefix: String
    ) -> QueryFilter
    
    public static func notBeginsWith<Record: CloudKitRecord>(
        _ keyPath: KeyPath<Record, String>,
        _ prefix: String
    ) -> QueryFilter
    
    public static func containsAllTokens<Record: CloudKitRecord>(
        _ keyPath: KeyPath<Record, String>,
        _ tokens: String
    ) -> QueryFilter
}

2.4 List Filters

extension QueryFilter {
    /// IN filter using KeyPath
    public static func `in`<Record: CloudKitRecord, Value: CloudKitValueConvertible>(
        _ keyPath: KeyPath<Record, Value>,
        _ values: [Value]
    ) -> QueryFilter
    
    public static func notIn<Record: CloudKitRecord, Value: CloudKitValueConvertible>(
        _ keyPath: KeyPath<Record, Value>,
        _ values: [Value]
    ) -> QueryFilter
    
    /// List contains filter using KeyPath
    public static func listContains<Record: CloudKitRecord, Element: CloudKitValueConvertible>(
        _ keyPath: KeyPath<Record, [Element]>,
        _ value: Element
    ) -> QueryFilter
    
    public static func notListContains<Record: CloudKitRecord, Element: CloudKitValueConvertible>(
        _ keyPath: KeyPath<Record, [Element]>,
        _ value: Element
    ) -> QueryFilter
}

Phase 3: Optional Value Support

Handle optional properties gracefully:

extension QueryFilter {
    /// Equality filter for optional values
    public static func equals<Record: CloudKitRecord, Value: CloudKitValueConvertible>(
        _ keyPath: KeyPath<Record, Value?>,
        _ value: Value?
    ) -> QueryFilter {
        let fieldName = Record.fieldName(for: keyPath)
        if let value = value {
            return .equals(fieldName, value.toFieldValue())
        } else {
            // Handle nil comparison - CloudKit doesn't support IS NULL
            // May need to throw error or use alternative approach
            fatalError("CloudKit does not support nil comparisons")
        }
    }
}

Usage Examples

Before (String-Based)

struct Article: CloudKitRecord {
    var title: String
    var author: String
    var publishedAt: Date
    var viewCount: Int
    var tags: [String]
    var isPublished: Bool
}

// Current API - error-prone
let filters = [
    QueryFilter.equals("author", .string("Alice")),
    QueryFilter.greaterThan("viewCount", .int64(100)),
    QueryFilter.listContains("tags", .string("swift")),
    QueryFilter.equals("isPublsihed", .boolean(true))  // ❌ Typo!
]

After (KeyPath-Based)

// New API - type-safe
let filters = [
    QueryFilter.equals(\.author, "Alice"),              // ✅ Autocomplete
    QueryFilter.greaterThan(\.viewCount, 100),          // ✅ Type-safe Int
    QueryFilter.listContains(\.tags, "swift"),          // ✅ Array element type
    QueryFilter.equals(\.isPublished, true)             // ✅ Compiler catches typos
]

Mixed Usage (Backward Compatible)

// Can use both APIs together
let filters = [
    QueryFilter.equals(\.author, "Alice"),              // KeyPath-based
    QueryFilter.equals("legacyField", .string("value")) // String-based (for dynamic fields)
]

Custom Field Mappings

struct User: CloudKitRecord {
    var username: String
    var emailAddress: String
    var registeredAt: Date
    
    // CloudKit schema uses different field names
    static var fieldNameMappings: [PartialKeyPath<User>: String] {
        [
            \.emailAddress: "email",
            \.registeredAt: "registration_date"
        ]
    }
}

// KeyPath automatically uses custom mapping
QueryFilter.equals(\.emailAddress, "test@example.com")
// → Generates: equals("email", .string("test@example.com"))

Testing Strategy

Unit Tests

@Suite("QueryFilter KeyPath Tests")
struct QueryFilterKeyPathTests {
    struct TestRecord: CloudKitRecord {
        static var cloudKitRecordType: String { "TestRecord" }
        var recordName: String
        var name: String
        var age: Int
        var email: String
        var tags: [String]
        var isActive: Bool
        var score: Double?
        
        static var fieldNameMappings: [PartialKeyPath<TestRecord>: String] {
            [\.email: "emailAddress"]
        }
        
        // Required protocol methods...
    }
    
    @Test("Equality filter with KeyPath")
    func equalityFilter() throws {
        let filter = QueryFilter.equals(\.name, "Alice")
        
        #expect(filter.fieldName == "name")
        #expect(filter.comparator == .EQUALS)
        #expect(filter.fieldValue == .string("Alice"))
    }
    
    @Test("Comparison filter with KeyPath")
    func comparisonFilter() throws {
        let filter = QueryFilter.greaterThan(\.age, 21)
        
        #expect(filter.fieldName == "age")
        #expect(filter.comparator == .GREATER_THAN)
        #expect(filter.fieldValue == .int64(21))
    }
    
    @Test("String filter with KeyPath")
    func stringFilter() throws {
        let filter = QueryFilter.beginsWith(\.email, "admin@")
        
        #expect(filter.fieldName == "emailAddress")  // Uses custom mapping
        #expect(filter.comparator == .BEGINS_WITH)
    }
    
    @Test("List filter with KeyPath")
    func listFilter() throws {
        let filter = QueryFilter.listContains(\.tags, "swift")
        
        #expect(filter.fieldName == "tags")
        #expect(filter.comparator == .LIST_CONTAINS)
    }
    
    @Test("Automatic type conversion")
    func typeConversion() throws {
        // Int automatically converts to Int64
        let intFilter = QueryFilter.equals(\.age, 25)
        #expect(intFilter.fieldValue == .int64(25))
        
        // String converts directly
        let stringFilter = QueryFilter.equals(\.name, "Bob")
        #expect(stringFilter.fieldValue == .string("Bob"))
        
        // Bool converts
        let boolFilter = QueryFilter.equals(\.isActive, true)
        #expect(boolFilter.fieldValue == .boolean(true))
    }
}

Integration Tests

@Suite("CloudKitService KeyPath Query Tests")
struct KeyPathQueryIntegrationTests {
    @Test("Query with KeyPath filters")
    func queryWithKeyPathFilters() async throws {
        let service = CloudKitService(/* ... */)
        
        let filters = [
            QueryFilter.equals(\.author, "Alice"),
            QueryFilter.greaterThan(\.viewCount, 100)
        ]
        
        let results = try await service.queryRecords(
            recordType: "Article",
            filters: filters
        )
        
        #expect(results.count > 0)
    }
    
    @Test("Mixed string and KeyPath filters")
    func mixedFilters() async throws {
        let service = CloudKitService(/* ... */)
        
        let filters = [
            QueryFilter.equals(\.author, "Alice"),           // KeyPath
            QueryFilter.equals("dynamicField", .string("x")) // String
        ]
        
        let results = try await service.queryRecords(
            recordType: "Article",
            filters: filters
        )
        
        // Should work without issues
    }
}

File Changes

New Files

  • Sources/MistKit/Protocols/CloudKitValueConvertible.swift - Value conversion protocol
  • Sources/MistKit/Extensions/CloudKitRecord+FieldMapping.swift - KeyPath to field name extraction

Modified Files

  • Sources/MistKit/PublicTypes/QueryFilter.swift - Add KeyPath-based factory methods
  • Tests/MistKitTests/QueryFilterTests.swift - Add KeyPath tests
  • Tests/MistKitTests/CloudKitServiceQueryTests.swift - Add integration tests

Acceptance Criteria

  • CloudKitValueConvertible protocol implemented for all supported types
  • KeyPath field name extraction working for all CloudKitRecord types
  • Custom field name mapping support via fieldNameMappings
  • All QueryFilter factory methods have KeyPath-based overloads
  • Automatic type conversion eliminates need for .string(), .int64(), etc.
  • String-based API remains unchanged (backward compatible)
  • Can mix string-based and KeyPath-based filters in same query
  • Comprehensive unit tests for all filter types
  • Integration tests with CloudKitService
  • Documentation with migration examples
  • No breaking changes to existing API

Platform Requirements

  • iOS 14.0+, macOS 11.0+, tvOS 14.0+, watchOS 7.0+ (same as current MistKit)
  • Swift 5.9+ (for KeyPath improvements, but works on earlier versions)

Related Issues

TBD - Will link to KeyPath-based QuerySort and Foundation Predicate issues

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions