-
Notifications
You must be signed in to change notification settings - Fork 13
Open
Labels
enhancementNew feature or requestNew feature or request
Milestone
Description
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
FieldValuetype 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 protocolSources/MistKit/Extensions/CloudKitRecord+FieldMapping.swift- KeyPath to field name extraction
Modified Files
Sources/MistKit/PublicTypes/QueryFilter.swift- Add KeyPath-based factory methodsTests/MistKitTests/QueryFilterTests.swift- Add KeyPath testsTests/MistKitTests/CloudKitServiceQueryTests.swift- Add integration tests
Acceptance Criteria
-
CloudKitValueConvertibleprotocol implemented for all supported types - KeyPath field name extraction working for all
CloudKitRecordtypes - Custom field name mapping support via
fieldNameMappings - All
QueryFilterfactory 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
- KeyPath | Swift.org
- CloudKit Web Services Reference
- Current implementation:
Sources/MistKit/PublicTypes/QueryFilter.swift:1
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
enhancementNew feature or requestNew feature or request