Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 36 additions & 22 deletions Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,7 @@
public var queryBinding: QueryBinding {
let archiver = NSKeyedArchiver(requiringSecureCoding: true)
queryOutput.encodeSystemFields(with: archiver)
if isTesting {
archiver.encode(queryOutput._recordChangeTag, forKey: "_recordChangeTag")
}
queryOutput.encodeMockSystemFieldsIfNeeded(with: archiver)
return archiver.encodedData.queryBinding
}

Expand All @@ -44,16 +42,12 @@
}

private init(data: Data) throws {
let coder = try NSKeyedUnarchiver(forReadingFrom: data)
coder.requiresSecureCoding = true
guard let queryOutput = Record(coder: coder) else {
let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data)
unarchiver.requiresSecureCoding = true
guard let queryOutput = Record(coder: unarchiver) else {
throw DecodingError()
}
if isTesting {
queryOutput._recordChangeTag =
coder
.decodeObject(of: NSNumber.self, forKey: "_recordChangeTag")?.intValue
}
queryOutput.decodeMockSystemFieldsIfNeeded(from: unarchiver)
self.init(queryOutput: queryOutput)
}

Expand All @@ -66,9 +60,7 @@
public var queryBinding: QueryBinding {
let archiver = NSKeyedArchiver(requiringSecureCoding: true)
queryOutput.encode(with: archiver)
if isTesting {
archiver.encode(queryOutput._recordChangeTag, forKey: "_recordChangeTag")
}
queryOutput.encodeMockSystemFieldsIfNeeded(with: archiver)
return archiver.encodedData.queryBinding
}

Expand All @@ -86,22 +78,44 @@
}

private init(data: Data) throws {
let coder = try NSKeyedUnarchiver(forReadingFrom: data)
coder.requiresSecureCoding = true
guard let queryOutput = Record(coder: coder) else {
let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data)
unarchiver.requiresSecureCoding = true
guard let queryOutput = Record(coder: unarchiver) else {
throw DecodingError()
}
if isTesting {
queryOutput._recordChangeTag =
coder
.decodeObject(of: NSNumber.self, forKey: "_recordChangeTag")?.intValue
}
queryOutput.decodeMockSystemFieldsIfNeeded(from: unarchiver)
self.init(queryOutput: queryOutput)
}

private struct DecodingError: Error {}
}

extension CKRecord {
fileprivate func encodeMockSystemFieldsIfNeeded(with coder: NSKeyedArchiver) {
guard isTesting else { return }
coder.encode(
self._recordChangeTag,
forKey: "_recordChangeTag"
)
coder.encode(
self._modificationDate.map { $0 as NSDate },
forKey: "_modificationDate"
)
}

fileprivate func decodeMockSystemFieldsIfNeeded(from coder: NSKeyedUnarchiver) {
guard isTesting else { return }
self._recordChangeTag = coder.decodeObject(
of: NSNumber.self,
forKey: "_recordChangeTag"
)?.intValue
self._modificationDate = coder.decodeObject(
of: NSDate.self,
forKey: "_modificationDate"
) as Date?
}
}

extension CKDatabase.Scope {
public struct RawValueRepresentation: QueryBindable, QueryRepresentable {
public let queryOutput: CKDatabase.Scope
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#if canImport(CloudKit)
import CloudKit
import IssueReporting
import ObjectiveC

nonisolated(unsafe) private var modificationDateKey: UInt8 = 0

extension CKRecord {
var _modificationDate: Date? {
get {
objc_getAssociatedObject(self, &modificationDateKey) as? Date
}
set {
installMockSystemFieldOverridesOnce()
objc_setAssociatedObject(self, &modificationDateKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
}

@objc fileprivate dynamic func _swizzled_modificationDate() -> Date? {
if let override = objc_getAssociatedObject(self, &modificationDateKey) as? Date {
return override
}
return self._swizzled_modificationDate()
}
}

private func installMockSystemFieldOverridesOnce() {
_ = token
}

private let token: Void = {
guard
let originalMethod = class_getInstanceMethod(
CKRecord.self,
#selector(getter: CKRecord.modificationDate)
),
let swizzledMethod = class_getInstanceMethod(
CKRecord.self,
#selector(CKRecord._swizzled_modificationDate)
)
else {
reportIssue("Failed to swizzle CKRecord.modificationDate")
return
}
method_exchangeImplementations(originalMethod, swizzledMethod)
}()
#endif
7 changes: 7 additions & 0 deletions Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,18 @@

package struct State {
private var lastRecordChangeTag = 0
private var lastModificationDate = 0
package var storage: [CKRecordZone.ID: Zone] = [:]
var assets: [AssetID: Data] = [:]
var deletedRecords: [(CKRecord.ID, CKRecord.RecordType)] = []
mutating func nextRecordChangeTag() -> Int {
lastRecordChangeTag += 1
return lastRecordChangeTag
}
mutating func nextModificationDate() -> Date {
lastModificationDate += 1
return Date(timeIntervalSinceReferenceDate: TimeInterval(lastModificationDate))
}
}

struct AssetID: Hashable {
Expand Down Expand Up @@ -112,6 +117,7 @@

switch savePolicy {
case .ifServerRecordUnchanged:
let batchModificationDate = state.nextModificationDate()
for recordToSave in recordsToSave {
if let share = recordToSave as? CKShare {
let isSavingRootRecord = recordsToSave.contains(where: {
Expand Down Expand Up @@ -189,6 +195,7 @@
guard let copy = recordToSave.copy() as? CKRecord
else { fatalError("Could not copy CKRecord.") }
copy._recordChangeTag = state.nextRecordChangeTag()
copy._modificationDate = batchModificationDate

for key in copy.allKeys() {
guard let assetURL = (copy[key] as? CKAsset)?.fileURL
Expand Down
4 changes: 2 additions & 2 deletions Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
@Suite(.printTimestamps) final class MergeConflictTests: BaseCloudKitTests, @unchecked Sendable
{
@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
@Test func merge_clientRecordUpdatedBeforeServerRecord() async throws {
@Test func clientRecordUpdatedBeforeServerRecord() async throws {
try await userDatabase.userWrite { db in
try db.seed {
RemindersList(id: 1, title: "")
Expand Down Expand Up @@ -143,7 +143,7 @@
id: 1,
id🗓️: 0,
isCompleted: 1,
isCompleted🗓️: 30,
isCompleted🗓️: 60,
priority🗓️: 0,
remindersListID: 1,
remindersListID🗓️: 0,
Expand Down
25 changes: 25 additions & 0 deletions Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -705,6 +705,31 @@
#expect(error?.code == .limitExceeded)
}

@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
@Test func modificationDateIncreasesOnSubsequentSaves() throws {
let record = CKRecord(recordType: "A", recordID: CKRecord.ID(recordName: "A"))

let (results1, _) = try syncEngine.private.database.modifyRecords(saving: [record])
let saved1 = try #require(try results1[record.recordID]?.get())
#expect(saved1.modificationDate == Date(timeIntervalSinceReferenceDate: 1))

let (results2, _) = try syncEngine.private.database.modifyRecords(saving: [saved1])
let saved2 = try #require(try results2[record.recordID]?.get())
#expect(saved2.modificationDate == Date(timeIntervalSinceReferenceDate: 2))
}

@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
@Test func modificationDateIsSharedWithinBatch() throws {
let recordA = CKRecord(recordType: "A", recordID: CKRecord.ID(recordName: "A"))
let recordB = CKRecord(recordType: "B", recordID: CKRecord.ID(recordName: "B"))

let (saveResults, _) = try syncEngine.private.database.modifyRecords(saving: [recordA, recordB])
let savedA = try #require(try saveResults[recordA.recordID]?.get())
let savedB = try #require(try saveResults[recordB.recordID]?.get())

#expect(savedA.modificationDate == savedB.modificationDate)
}

@Test func records_limitExceeded() async throws {
let remindersListRecord = CKRecord(
recordType: RemindersList.tableName,
Expand Down
42 changes: 42 additions & 0 deletions Tests/SQLiteDataTests/CloudKitTests/MockSystemFieldsTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#if canImport(CloudKit)
import CloudKit
@testable import SQLiteData
import Testing

@Suite
struct MockSystemFieldsTests {
@Test func modificationDateOverride() {
let record = CKRecord(recordType: "record", recordID: CKRecord.ID(recordName: "A"))
#expect(record.modificationDate == nil)

record._modificationDate = Date(timeIntervalSinceReferenceDate: 1)
#expect(record.modificationDate == Date(timeIntervalSinceReferenceDate: 1))
}

@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
@Test func systemFieldsRepresentationRoundtrip() throws {
let record = CKRecord(recordType: "record", recordID: CKRecord.ID(recordName: "A"))
record._recordChangeTag = 42
record._modificationDate = Date(timeIntervalSinceReferenceDate: 1)

let representation = CKRecord.SystemFieldsRepresentation(queryOutput: record)
let result = try #require(CKRecord.SystemFieldsRepresentation(queryBinding: representation.queryBinding))

#expect(result.queryOutput._recordChangeTag == 42)
#expect(result.queryOutput._modificationDate == Date(timeIntervalSinceReferenceDate: 1))
}

@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
@Test func allFieldsRepresentationRoundtrip() throws {
let record = CKRecord(recordType: "record", recordID: CKRecord.ID(recordName: "A"))
record._recordChangeTag = 42
record._modificationDate = Date(timeIntervalSinceReferenceDate: 1)

let representation = CKRecord._AllFieldsRepresentation(queryOutput: record)
let result = try #require(CKRecord._AllFieldsRepresentation(queryBinding: representation.queryBinding))

#expect(result.queryOutput._recordChangeTag == 42)
#expect(result.queryOutput._modificationDate == Date(timeIntervalSinceReferenceDate: 1))
}
}
#endif