Skip to content

Commit e650441

Browse files
leogdionclaude
andcommitted
Fix QueryFilter IN/NOT_IN serialization to preserve type information (#192)
The IN and NOT_IN filters were stripping type metadata from list items, causing CloudKit Web Services to reject queries with HTTP 400 errors. Changes: - Fix FilterBuilder to use CustomFieldValuePayload preserving types - Re-enable GUID queries in CelestraCloud ArticleSyncService - Optimize ArticleCloudKitService to combine filters at query time - Update tests to reflect server-side filtering behavior All tests passing: MistKit (311/311), CelestraCloud (115/115) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent eb9d2e5 commit e650441

3 files changed

Lines changed: 21 additions & 34 deletions

File tree

Examples/CelestraCloud/Sources/CelestraCloudKit/Services/ArticleCloudKitService.swift

Lines changed: 9 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -110,24 +110,22 @@ public struct ArticleCloudKitService: Sendable {
110110
_ guids: [String],
111111
feedRecordName: String?
112112
) async throws(CloudKitError) -> [Article] {
113-
// CloudKit Web Services has issues with combining .in() with other filters.
114-
// Current approach: Use .in() ONLY for GUID filtering (single filter, no combinations).
115-
// Feed filtering is done in-memory (line 135-136) to avoid the .in() + filter issue.
116-
//
117-
// Known limitation: Cannot efficiently query by both GUID and feedRecordName in one query.
118-
// This is acceptable because GUID queries are typically small batches (<150 items).
119-
//
120-
// Alternative considered: Multiple single-GUID queries would be significantly slower
121-
// and hit rate limits faster. The in-memory filter is the pragmatic solution.
122-
let filters: [QueryFilter] = [.in("guid", guids.map { FieldValue.string($0) })]
113+
// Query articles by GUID using the IN filter.
114+
// Now that issue #192 is fixed, we can combine .in() with other filters.
115+
// If feedRecordName is specified, we filter at query time for efficiency.
116+
var filters: [QueryFilter] = [.in("guid", guids.map { FieldValue.string($0) })]
117+
if let feedName = feedRecordName {
118+
filters.append(.equals("feedRecordName", FieldValue.string(feedName)))
119+
}
120+
123121
let records = try await recordOperator.queryRecords(
124122
recordType: "Article",
125123
filters: filters,
126124
sortBy: nil,
127125
limit: 200,
128126
desiredKeys: nil
129127
)
130-
let articles = records.compactMap { record in
128+
return records.compactMap { record in
131129
do {
132130
return try Article(from: record)
133131
} catch {
@@ -137,12 +135,6 @@ public struct ArticleCloudKitService: Sendable {
137135
return nil
138136
}
139137
}
140-
141-
// Filter by feedRecordName in-memory if specified
142-
if let feedName = feedRecordName {
143-
return articles.filter { $0.feedRecordName == feedName }
144-
}
145-
return articles
146138
}
147139

148140
// MARK: - Create Operations

Examples/CelestraCloud/Sources/CelestraCloudKit/Services/ArticleSyncService.swift

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -72,14 +72,11 @@ public struct ArticleSyncService: Sendable {
7272
feedRecordName: String
7373
) async throws(CloudKitError) -> ArticleSyncResult {
7474
// 1. Query existing articles by GUID
75-
// TEMPORARY: Skip GUID query due to CloudKit Web Services .in() operator issue
76-
// TODO: Fix query or implement alternative deduplication strategy
77-
let existingArticles: [Article] = []
78-
// let guids = items.map(\.guid)
79-
// let existingArticles = try await articleService.queryArticlesByGUIDs(
80-
// guids,
81-
// feedRecordName: feedRecordName
82-
// )
75+
let guids = items.map(\.guid)
76+
let existingArticles = try await articleService.queryArticlesByGUIDs(
77+
guids,
78+
feedRecordName: feedRecordName
79+
)
8380

8481
// 2. Categorize into new vs modified (pure function)
8582
let categorization = categorizer.categorize(

Examples/CelestraCloud/Tests/CelestraCloudTests/Services/ArticleCloudKitService+Query.swift

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -114,27 +114,25 @@ extension ArticleCloudKitService {
114114
let mock = MockCloudKitRecordOperator()
115115
let service = CelestraCloudKit.ArticleCloudKitService(recordOperator: mock)
116116

117-
// Mock returns 2 articles: one matching feed, one not
117+
// Now that issue #192 is fixed, feedRecordName filter is applied at query time.
118+
// Mock returns only the matching article (CloudKit would filter server-side).
118119
let matchingFields = createArticleRecordFields(guid: "guid-1")
119-
let nonMatchingFields = createArticleRecordFields(guid: "guid-2")
120-
.merging(["feedRecordName": .string("other-feed")]) { _, new in new }
121120

122121
mock.queryRecordsResult = .success([
123-
createMockRecordInfo(recordName: "article-1", fields: matchingFields),
124-
createMockRecordInfo(recordName: "article-2", fields: nonMatchingFields)
122+
createMockRecordInfo(recordName: "article-1", fields: matchingFields)
125123
])
126124

127125
let result = try await service.queryArticlesByGUIDs(
128126
["guid-1", "guid-2"],
129127
feedRecordName: "feed-123"
130128
)
131129

132-
// Verify CloudKit query behavior
130+
// Verify CloudKit query combines filters
133131
#expect(mock.queryCalls.count == 1)
134-
// Should have 1 filter (GUID only), feedRecordName filtered in-memory
135-
#expect(mock.queryCalls[0].filters?.count == 1)
132+
// Should have 2 filters: IN for GUID + EQUALS for feedRecordName
133+
#expect(mock.queryCalls[0].filters?.count == 2)
136134

137-
// Verify in-memory filtering works
135+
// Verify filtering at query time works correctly
138136
#expect(result.count == 1) // Only matching article returned
139137
#expect(result[0].guid == "guid-1")
140138
#expect(result[0].feedRecordName == "feed-123")

0 commit comments

Comments
 (0)