Skip to content

Commit a374b5a

Browse files
committed
1 parent 8747141 commit a374b5a

File tree

6 files changed

+159
-24
lines changed

6 files changed

+159
-24
lines changed

Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift

Lines changed: 36 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,7 @@
2424
public var queryBinding: QueryBinding {
2525
let archiver = NSKeyedArchiver(requiringSecureCoding: true)
2626
queryOutput.encodeSystemFields(with: archiver)
27-
if isTesting {
28-
archiver.encode(queryOutput._recordChangeTag, forKey: "_recordChangeTag")
29-
}
27+
queryOutput.encodeMockSystemFieldsIfNeeded(with: archiver)
3028
return archiver.encodedData.queryBinding
3129
}
3230

@@ -44,16 +42,12 @@
4442
}
4543

4644
private init(data: Data) throws {
47-
let coder = try NSKeyedUnarchiver(forReadingFrom: data)
48-
coder.requiresSecureCoding = true
49-
guard let queryOutput = Record(coder: coder) else {
45+
let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data)
46+
unarchiver.requiresSecureCoding = true
47+
guard let queryOutput = Record(coder: unarchiver) else {
5048
throw DecodingError()
5149
}
52-
if isTesting {
53-
queryOutput._recordChangeTag =
54-
coder
55-
.decodeObject(of: NSNumber.self, forKey: "_recordChangeTag")?.intValue
56-
}
50+
queryOutput.decodeMockSystemFieldsIfNeeded(from: unarchiver)
5751
self.init(queryOutput: queryOutput)
5852
}
5953

@@ -66,9 +60,7 @@
6660
public var queryBinding: QueryBinding {
6761
let archiver = NSKeyedArchiver(requiringSecureCoding: true)
6862
queryOutput.encode(with: archiver)
69-
if isTesting {
70-
archiver.encode(queryOutput._recordChangeTag, forKey: "_recordChangeTag")
71-
}
63+
queryOutput.encodeMockSystemFieldsIfNeeded(with: archiver)
7264
return archiver.encodedData.queryBinding
7365
}
7466

@@ -86,22 +78,44 @@
8678
}
8779

8880
private init(data: Data) throws {
89-
let coder = try NSKeyedUnarchiver(forReadingFrom: data)
90-
coder.requiresSecureCoding = true
91-
guard let queryOutput = Record(coder: coder) else {
81+
let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data)
82+
unarchiver.requiresSecureCoding = true
83+
guard let queryOutput = Record(coder: unarchiver) else {
9284
throw DecodingError()
9385
}
94-
if isTesting {
95-
queryOutput._recordChangeTag =
96-
coder
97-
.decodeObject(of: NSNumber.self, forKey: "_recordChangeTag")?.intValue
98-
}
86+
queryOutput.decodeMockSystemFieldsIfNeeded(from: unarchiver)
9987
self.init(queryOutput: queryOutput)
10088
}
10189

10290
private struct DecodingError: Error {}
10391
}
10492

93+
extension CKRecord {
94+
fileprivate func encodeMockSystemFieldsIfNeeded(with coder: NSKeyedArchiver) {
95+
guard isTesting else { return }
96+
coder.encode(
97+
self._recordChangeTag,
98+
forKey: "_recordChangeTag"
99+
)
100+
coder.encode(
101+
self._modificationDate.map { $0 as NSDate },
102+
forKey: "_modificationDate"
103+
)
104+
}
105+
106+
fileprivate func decodeMockSystemFieldsIfNeeded(from coder: NSKeyedUnarchiver) {
107+
guard isTesting else { return }
108+
self._recordChangeTag = coder.decodeObject(
109+
of: NSNumber.self,
110+
forKey: "_recordChangeTag"
111+
)?.intValue
112+
self._modificationDate = coder.decodeObject(
113+
of: NSDate.self,
114+
forKey: "_modificationDate"
115+
) as Date?
116+
}
117+
}
118+
105119
extension CKDatabase.Scope {
106120
public struct RawValueRepresentation: QueryBindable, QueryRepresentable {
107121
public let queryOutput: CKDatabase.Scope
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
#if canImport(CloudKit)
2+
import CloudKit
3+
import IssueReporting
4+
import ObjectiveC
5+
6+
nonisolated(unsafe) private var modificationDateKey: UInt8 = 0
7+
8+
extension CKRecord {
9+
var _modificationDate: Date? {
10+
get {
11+
objc_getAssociatedObject(self, &modificationDateKey) as? Date
12+
}
13+
set {
14+
installMockSystemFieldOverridesOnce()
15+
objc_setAssociatedObject(self, &modificationDateKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
16+
}
17+
}
18+
19+
@objc fileprivate dynamic func _swizzled_modificationDate() -> Date? {
20+
if let override = objc_getAssociatedObject(self, &modificationDateKey) as? Date {
21+
return override
22+
}
23+
return self._swizzled_modificationDate()
24+
}
25+
}
26+
27+
private func installMockSystemFieldOverridesOnce() {
28+
_ = token
29+
}
30+
31+
private let token: Void = {
32+
guard
33+
let originalMethod = class_getInstanceMethod(
34+
CKRecord.self,
35+
#selector(getter: CKRecord.modificationDate)
36+
),
37+
let swizzledMethod = class_getInstanceMethod(
38+
CKRecord.self,
39+
#selector(CKRecord._swizzled_modificationDate)
40+
)
41+
else {
42+
reportIssue("Failed to swizzle CKRecord.modificationDate")
43+
return
44+
}
45+
method_exchangeImplementations(originalMethod, swizzledMethod)
46+
}()
47+
#endif

Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,18 @@
1111

1212
package struct State {
1313
private var lastRecordChangeTag = 0
14+
private var lastModificationDate = 0
1415
package var storage: [CKRecordZone.ID: Zone] = [:]
1516
var assets: [AssetID: Data] = [:]
1617
var deletedRecords: [(CKRecord.ID, CKRecord.RecordType)] = []
1718
mutating func nextRecordChangeTag() -> Int {
1819
lastRecordChangeTag += 1
1920
return lastRecordChangeTag
2021
}
22+
mutating func nextModificationDate() -> Date {
23+
lastModificationDate += 1
24+
return Date(timeIntervalSinceReferenceDate: TimeInterval(lastModificationDate))
25+
}
2126
}
2227

2328
struct AssetID: Hashable {
@@ -112,6 +117,7 @@
112117

113118
switch savePolicy {
114119
case .ifServerRecordUnchanged:
120+
let batchModificationDate = state.nextModificationDate()
115121
for recordToSave in recordsToSave {
116122
if let share = recordToSave as? CKShare {
117123
let isSavingRootRecord = recordsToSave.contains(where: {
@@ -189,6 +195,7 @@
189195
guard let copy = recordToSave.copy() as? CKRecord
190196
else { fatalError("Could not copy CKRecord.") }
191197
copy._recordChangeTag = state.nextRecordChangeTag()
198+
copy._modificationDate = batchModificationDate
192199

193200
for key in copy.allKeys() {
194201
guard let assetURL = (copy[key] as? CKAsset)?.fileURL

Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
@Suite(.printTimestamps) final class MergeConflictTests: BaseCloudKitTests, @unchecked Sendable
1515
{
1616
@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
17-
@Test func merge_clientRecordUpdatedBeforeServerRecord() async throws {
17+
@Test func clientRecordUpdatedBeforeServerRecord() async throws {
1818
try await userDatabase.userWrite { db in
1919
try db.seed {
2020
RemindersList(id: 1, title: "")
@@ -143,7 +143,7 @@
143143
id: 1,
144144
id🗓️: 0,
145145
isCompleted: 1,
146-
isCompleted🗓️: 30,
146+
isCompleted🗓️: 60,
147147
priority🗓️: 0,
148148
remindersListID: 1,
149149
remindersListID🗓️: 0,

Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -705,6 +705,31 @@
705705
#expect(error?.code == .limitExceeded)
706706
}
707707

708+
@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
709+
@Test func modificationDateIncreasesOnSubsequentSaves() throws {
710+
let record = CKRecord(recordType: "A", recordID: CKRecord.ID(recordName: "A"))
711+
712+
let (results1, _) = try syncEngine.private.database.modifyRecords(saving: [record])
713+
let saved1 = try #require(try results1[record.recordID]?.get())
714+
#expect(saved1.modificationDate == Date(timeIntervalSinceReferenceDate: 1))
715+
716+
let (results2, _) = try syncEngine.private.database.modifyRecords(saving: [saved1])
717+
let saved2 = try #require(try results2[record.recordID]?.get())
718+
#expect(saved2.modificationDate == Date(timeIntervalSinceReferenceDate: 2))
719+
}
720+
721+
@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
722+
@Test func modificationDateIsSharedWithinBatch() throws {
723+
let recordA = CKRecord(recordType: "A", recordID: CKRecord.ID(recordName: "A"))
724+
let recordB = CKRecord(recordType: "B", recordID: CKRecord.ID(recordName: "B"))
725+
726+
let (saveResults, _) = try syncEngine.private.database.modifyRecords(saving: [recordA, recordB])
727+
let savedA = try #require(try saveResults[recordA.recordID]?.get())
728+
let savedB = try #require(try saveResults[recordB.recordID]?.get())
729+
730+
#expect(savedA.modificationDate == savedB.modificationDate)
731+
}
732+
708733
@Test func records_limitExceeded() async throws {
709734
let remindersListRecord = CKRecord(
710735
recordType: RemindersList.tableName,
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
#if canImport(CloudKit)
2+
import CloudKit
3+
@testable import SQLiteData
4+
import Testing
5+
6+
@Suite
7+
struct MockSystemFieldsTests {
8+
@Test func modificationDateOverride() {
9+
let record = CKRecord(recordType: "record", recordID: CKRecord.ID(recordName: "A"))
10+
#expect(record.modificationDate == nil)
11+
12+
record._modificationDate = Date(timeIntervalSinceReferenceDate: 1)
13+
#expect(record.modificationDate == Date(timeIntervalSinceReferenceDate: 1))
14+
}
15+
16+
@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
17+
@Test func systemFieldsRepresentationRoundtrip() throws {
18+
let record = CKRecord(recordType: "record", recordID: CKRecord.ID(recordName: "A"))
19+
record._recordChangeTag = 42
20+
record._modificationDate = Date(timeIntervalSinceReferenceDate: 1)
21+
22+
let representation = CKRecord.SystemFieldsRepresentation(queryOutput: record)
23+
let result = try #require(CKRecord.SystemFieldsRepresentation(queryBinding: representation.queryBinding))
24+
25+
#expect(result.queryOutput._recordChangeTag == 42)
26+
#expect(result.queryOutput._modificationDate == Date(timeIntervalSinceReferenceDate: 1))
27+
}
28+
29+
@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
30+
@Test func allFieldsRepresentationRoundtrip() throws {
31+
let record = CKRecord(recordType: "record", recordID: CKRecord.ID(recordName: "A"))
32+
record._recordChangeTag = 42
33+
record._modificationDate = Date(timeIntervalSinceReferenceDate: 1)
34+
35+
let representation = CKRecord._AllFieldsRepresentation(queryOutput: record)
36+
let result = try #require(CKRecord._AllFieldsRepresentation(queryBinding: representation.queryBinding))
37+
38+
#expect(result.queryOutput._recordChangeTag == 42)
39+
#expect(result.queryOutput._modificationDate == Date(timeIntervalSinceReferenceDate: 1))
40+
}
41+
}
42+
#endif

0 commit comments

Comments
 (0)