Search code examples
swiftasynchronouscloudkitckquery

Swift CloudKit and CKQuery: how to iteratively retrieve records when queryResultBlock returns a query cursor


I am using CloudKit to retrieve records from a private database using CKQuery, using the CKQueryOperation.queryResultBlock in an async function. I've found several examples of this using queryCompletionBlock but that has been deprecated and replaced by queryResultBlock, with precious little documentation available as to how to implement it. My function works great as long as a query completion cursor is not returned (<=100 records), but I'm unable to figure out how to iterate it.

Here's the code I'm using:

public func queryRecords(recordType: CKRecord.RecordType, predicate: NSPredicate) async throws -> [CKRecord] {
    var resultRecords: [CKRecord] = []
    let db = container.privateCloudDatabase
    let query = CKQuery(recordType: recordType, predicate: predicate)
    let operation = CKQueryOperation(query: query)
    let operationQueue = OperationQueue() // for > 100 records
    operationQueue.maxConcurrentOperationCount = 1 // for > 100 records
    operation.zoneID = zoneID
    debugPrint("query for recordType=\(recordType) in zone \(zoneID.zoneName) with predicate \(predicate)")
    return try await withCheckedThrowingContinuation { continuation in
        operation.queryResultBlock = { result in
            switch result {
            case .failure(let error):
                debugPrint(error)
                continuation.resume(throwing: error)
            case .success(let ckquerycursor):
                debugPrint("successful query completion after \(resultRecords.count) record(s) returned")
                if let ckquerycursor = ckquerycursor {
                    debugPrint("***** received a query cursor, need to fetch another batch! *****")
                    let newOperation = CKQueryOperation(cursor: ckquerycursor)  // for > 100 records
                    newOperation.queryResultBlock = operation.queryResultBlock // for > 100 records
                    newOperation.database = db // for > 100 records
                    operationQueue.addOperation(newOperation) // for > 100 records
                }
                continuation.resume(returning: resultRecords)
            }
        }
        operation.recordMatchedBlock = { (recordID, result1) in
            switch result1 {
            case .failure(let error):
                debugPrint(error)
            case .success(let ckrecord):
                resultRecords.append(ckrecord)
            }
        }
        db.add(operation)
    }
}

I've attempted to implement code from similar examples but with no success: the code above results in a fatal error "SWIFT TASK CONTINUATION MISUSE" as the line

continuation.resume(returning: resultRecords)

is apparently called multiple times (illegal). The lines commented with "// for > 100 records" represent the code I've added to iterate; everything else works fine for records sets of 100 or less.

Do I need to iteratively call the queryRecords function itself, passing the query cursor if it exists, or is it possible to add the iterative operations to the queue as I've attempted to do here?

If anyone has done this before using queryResultBlock (not deprecated queryCompletionBlock) please help! Thanks!


Solution

  • No need for queryResultBlock in Swift 5.5.

    I use this because my CKRecord types are always named the same as their Swift counterparts. You can replace recordType: "\(Record.self)" with your recordType if you want, instead.

    public extension CKDatabase {
      /// Request `CKRecord`s that correspond to a Swift type.
      ///
      /// - Parameters:
      ///   - recordType: Its name has to be the same in your code, and in CloudKit.
      ///   - predicate: for the `CKQuery`
      func records<Record>(
        type _: Record.Type,
        zoneID: CKRecordZone.ID? = nil,
        predicate: NSPredicate = .init(value: true)
      ) async throws -> [CKRecord] {
        try await withThrowingTaskGroup(of: [CKRecord].self) { group in
          func process(
            _ records: (
              matchResults: [(CKRecord.ID, Result<CKRecord, Error>)],
              queryCursor: CKQueryOperation.Cursor?
            )
          ) async throws {
            group.addTask {
              try records.matchResults.map { try $1.get() }
            }
            
            if let cursor = records.queryCursor {
              try await process(self.records(continuingMatchFrom: cursor))
            }
          }
    
          try await process(
            records(
              matching: .init(
                recordType: "\(Record.self)",
                predicate: predicate
              ),
              inZoneWith: zoneID
            )
          )
          
          return try await group.reduce(into: [], +=)
        }
      }
    }