Correct Way to Initialize New CKRecord - cloudkit

I am using Realm for my local cache in a Mac app, and CloudKit for sync. Up until now, I have been initializing CKRecord objects like this:
let record = CKRecord(recordType: "Workspace", recordID: CKRecordID(recordName: workspace.recordName, zoneID: "..."))
The object workspace is my locally cached object and its recordName matches the CKRecord's recordName.
I recently learned about encodeSystemFields and that I need to store the record's metadata in my local cache. But as far as I can tell, the only way to initialize an object with that meta data is like this:
let coder = NSKeyedUnarchiver(forReadingWith: object.recordData!)
coder.requiresSecureCoding = true
let record = CKRecord(coder: coder)
coder.finishDecoding()
But if I initialize my CKRecord with coder, how can I specify my recordID and zoneID?

You're only going to be using that initializer if you already have a record (you're modifying or deleting). The encoded system fields contain that information so you don't need to specify that directly. If you're creating a new record you will use one of the other initializers to generate those specifically, for example like so.
So for example in my app when making a record to sync with iCloud I just check for the existence of metadata, and if it is there I use CKRecord(coder:), otherwise CKRecord(recordType: recordID:) like so:
if let ckMetaData = object.value(forKey: Schema.GenericFieldNames.ckMetaData) as? Data
{
// MetaData exists
if self.debug
{
print("🗻 RecordFromObject \(self.objectName) We have Metadata. This will update an existing record in iCloud")
}
let unarchiver = NSKeyedUnarchiver(forReadingWith: ckMetaData)
unarchiver.requiresSecureCoding = true
self.outputRecord = CKRecord(coder: unarchiver)
}
else
{
// No MetaData
if self.debug
{
print("🗻 RecordFromObject \(self.objectName) We have no Metadata. This record will be new to iCloud!")
}
let objectID = object.objectID.uriRepresentation().absoluteString
let recordID = CKRecordID(recordName: objectID, zoneID: self.inputRecordZoneID)
self.outputRecord = CKRecord(recordType: object.entity.managedObjectClassName, recordID: recordID)
}

Related

Migrating existing app from NSPersistentContainer to NSPersistentCloudKitContainer

I have an app that uses local device only CoreData (NSPersistentContainer). I am looking to migrate so the app is compatible with NSPersistentCloudKitContainer. I understand all the CloudKit setup for NSPersistentCloudKitContainer, but how do I migrate the data that is on the player's phones to iCloud? (i.e. how do I migrate existing core data from NSPersistentContainer to NSPersistentCloudKitContainer)?
A good intro how to do this is given in the 2019 WWDC video „Using Core Data With CloudKit“.
The essential points are:
Replace NSPersistentContainer by ist subclass NSPersistentClouKitContainer.
Initialize the iCloud schema using the container function initializeCloudKitSchema (this has to be done only once after the core data model has been set up or changed).
In the iCloud Dashboard, make every custom type (these are the types starting with CD_) queryable.
In the iCloud Dashboard, set the security type of all CD_ record types for all authenticated users to read/write.
Implement history tracking for core data (here are Apple’s suggestions).
In case you have multiple persistent stores (e.g. a local store relevant only to one device, a private store shared with all users with the same Apple ID, and a shared store shared with other users), one way to set up this is the following:
private (set) lazy var persistentContainer: NSPersistentCloudKitContainer! = {
// This app uses 3 stores:
// - A local store that is user-specific,
// - a private store that is synchronized with the iCloud private database, and
// - a shared store that is synchronized with the iCloud shared database.
let persistentStoresLoadedLock = DispatchGroup.init() // Used to wait for loading the persistent stores
// Configure local store
// --------------------------------------------------------------------------------------------------
let appDocumentsDirectory = try! FileManager.default.url(for: .documentDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true)
let coreDataLocalURL = appDocumentsDirectory.appendingPathComponent("CoreDataLocal.sqlite")
let localStoreDescription = NSPersistentStoreDescription(url: coreDataLocalURL)
localStoreDescription.configuration = localConfigurationName
// --------------------------------------------------------------------------------------------------
// Create a container that can load the private store as well as CloudKit-backed stores.
let container = NSPersistentCloudKitContainer(name: appName)
assert(container.persistentStoreDescriptions.count == 1, "###\(#function): Failed to retrieve a persistent store description.")
let firstPersistentStoreDescription = container.persistentStoreDescriptions.first!
let storeURL = firstPersistentStoreDescription.url!
let storeURLwithoutLastPathComponent = storeURL.deletingLastPathComponent
// Configure private store
// --------------------------------------------------------------------------------------------------
let privateStoreDescription = firstPersistentStoreDescription
privateStoreDescription.configuration = privateConfigurationName
// The options below have to be set before loadPersistentStores
// Enable history tracking and remote notifications
privateStoreDescription.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
privateStoreDescription.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
privateStoreDescription.cloudKitContainerOptions!.databaseScope = .private
// --------------------------------------------------------------------------------------------------
// Configure shared store
// --------------------------------------------------------------------------------------------------
let sharedStoreURL = storeURLwithoutLastPathComponent().appendingPathComponent("Shared")
let sharedStoreDescription = NSPersistentStoreDescription(url: sharedStoreURL)
sharedStoreDescription.configuration = sharedConfigurationName
sharedStoreDescription.timeout = firstPersistentStoreDescription.timeout
sharedStoreDescription.type = firstPersistentStoreDescription.type
sharedStoreDescription.isReadOnly = firstPersistentStoreDescription.isReadOnly
sharedStoreDescription.shouldAddStoreAsynchronously = firstPersistentStoreDescription.shouldAddStoreAsynchronously
sharedStoreDescription.shouldInferMappingModelAutomatically = firstPersistentStoreDescription.shouldInferMappingModelAutomatically
sharedStoreDescription.shouldMigrateStoreAutomatically = firstPersistentStoreDescription.shouldMigrateStoreAutomatically
// The options below have to be set before loadPersistentStores
// Enable history tracking and remote notifications
sharedStoreDescription.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
sharedStoreDescription.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
sharedStoreDescription.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions.init(containerIdentifier: "iCloud.com.zeh4soft.shop")
// For sharing see https://developer.apple.com/documentation/cloudkit/shared_records
// and https://medium.com/#adammillers/cksharing-step-by-step-33800c8950d2
sharedStoreDescription.cloudKitContainerOptions!.databaseScope = .shared
// --------------------------------------------------------------------------------------------------
container.persistentStoreDescriptions = [localStoreDescription, privateStoreDescription, sharedStoreDescription]
for _ in 1 ... container.persistentStoreDescriptions.count { persistentStoresLoadedLock.enter() }
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
// The completion handler will be called once for each persistent store that is created.
guard error == nil else {
/*
Apple suggests to replace this implementation with code to handle the error appropriately.
However, there is not really an option to handle it, see <https://stackoverflow.com/a/45801384/1987726>.
Typical reasons for an error here include:
* The parent directory does not exist, cannot be created, or disallows writing.
* The persistent store is not accessible, due to permissions or data protection when the device is locked.
* The device is out of space.
* The store could not be migrated to the current model version.
Check the error message to determine what the actual problem was.
*/
fatalError("###\(#function): Failed to load persistent stores: \(error!)")
}
if storeDescription.configuration == self.privateConfigurationName {
/*
Only if the schema has been changed, it has to be re-initialized.
Due to an Apple bug, this can currently (iOS 13) only be done with a .private database!
A re-initialization requires to run the app once using the scheme with the "-initSchema" argument.
After schema init, ensure in the Dashboard:
For every custom type, recordID and modTime must have queryable indexes.
All CD record types must have read/write security type for authenticated users.
Run later always a scheme without the "-initSchema" argument.
*/
if ProcessInfo.processInfo.arguments.contains("-initSchema") {
do {
try container.initializeCloudKitSchema(options: .printSchema)
} catch {
print("-------------------- Could not initialize cloud kit schema --------------------")
}
}
}
persistentStoresLoadedLock.leave() // Called for all stores
})
let waitResult = persistentStoresLoadedLock.wait(timeout: .now() + 100) // Wait for local, private and shared stores loaded
if waitResult != .success { fatalError("Timeout while loading persistent stores") }
return container
} ()
EDIT:
privateConfigurationName, as well as sharedConfigurationName are Strings:
let privateConfigurationName = "Private"
let sharedConfigurationName = "Shared"
and Private and Shared are used as Configuration names in the Coredata model, e.g.:
One has to assign there the entities to the persistent store(s).
A warning:
I you assign the same entity to multiple persistent stores, a save of a managed context will store it in all assigned stores, except you assign a specific store, see this post.
Likewise, a fetch will fetch a record from all persistent stores the entity is assigned to, except you set affectedStores in the fetch request, see the docs.
I did the following :
Replaced NSPersistentContainer by NSPersistentCloudKitContainer
And added enabled history tracking
container = NSPersistentCloudKitContainer(name: "myApp") // <<<<< this
if inMemory {
container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
}
let description = container.persistentStoreDescriptions.first
description?.setOption(true as NSNumber,
forKey: NSPersistentHistoryTrackingKey) // <<<<< this
container.viewContext.automaticallyMergesChangesFromParent = true
container.loadPersistentStores(...)
EDIT: Well, I talked too quick. It does not work :(
I found that people have little tricks here https://developer.apple.com/forums/thread/120328
(for example edit the items to trigger a sync, or to manually transfer each object as said explained here https://medium.com/#dmitrydeplov/coredata-cloudkit-integration-for-a-live-app-57b6cfda84ad)
But there is no actual answers...

iOS Swift - reading a persisted data model after having altered properties in that model

Thanks in advance for your help.
I want to persist data such as a user's stats. Let's say I have a data model, a class 'Stats' with a few properties, and it gets saved to the user's device. Supposing that I've released the app, users are recording their stats but then later on I want to make changes to the class - more or fewer properties, maybe even renaming them (etc.), ahead of a new build release. But after these changes have been made, the type 'Stats' is now different to the one the users have saved on their device, so it won't be able to decode and it seems like all the user's previous data up until that point would be lost/unattainable.
How can I add make these kinds of changes to the class in a way in which the PropertyListDecoder will still be able to decode the stats that are still on the user's device?
This is basically what I have:
class Stat: Codable {
let questionCategory = questionCategory()
var timesAnsweredCorrectly: Int = 0
var timesAnsweredFirstTime: Int = 0
var timesFailed: Int = 0
static func saveToFile(stats: [Stat]) {
let propertyListEncoder = PropertyListEncoder()
let encodedSettings = try? propertyListEncoder.encode(stats)
try? encodedSettings?.write(to: archiveURL, options: .noFileProtection)
}
static func loadFromFile() -> [Stat]? {
let propertyListDecoder = PropertyListDecoder()
if let retrievedSettingsData = try? Data(contentsOf: archiveURL), let decodedSettings = try? propertyListDecoder.decode([Stat].self, from: retrievedSettingsData) {
return decodedSettings
} else {
return nil
}
}
}
static let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
static let archiveURL = documentsDirectory.appendingPathComponent("savedVerbStats").appendingPathExtension("plist")
It seems that even just adding a new property to 'Stat' will cause the user's previous persisted data to become un-decodable as type 'Stat', and loadFromFile() will return nil.
Any advice would be great! I'm sure I'm going about this the wrong way. I figured that the array [Stat] would be too big to persist in UserDefaults but even then I think this problem would still exist... Can't find anything about it online; it seems that once you've got your users using a persisted class you can't then alter it. I tried using default values for the new properties but the result is the same.
The only solution I can think of is breaking down the class into literals and saving all these in some kind of a tuple/dictionary form instead. Then I would decode that raw data, and have a function to assemble and create the class out of whatever relevant data can still be taken from the old version of the 'Stat' type. Seems like a big workaround and I'm sure you guys know a much a better way.
Thanks!!
Removing a property is easy enough. Just delete its definition from the Stat class and existing data for that property will be deleted when you read and save stats again.
The key to adding new properties is to make them optional. For example:
var newProperty: Int?
When a previously existing stat is decoded the first time, this property will be nil, but all the other properties will be set correctly. You can set and save the new property as needed.
It may be a minor inconvenience to have all new properties as optional, but it opens the door to other possible migration schemes without losing data.
EDIT: Here is a more complicated migration scheme that avoids optionals for new properties.
class Stat: Codable {
var timesAnsweredCorrectly: Int = 0
var timesAnsweredFirstTime: Int = 0
var timesFailed: Int = 0
//save all stats in the new Stat2 format
static func saveToFile(stats: [Stat2]) {
let propertyListEncoder = PropertyListEncoder()
let encodedSettings = try? propertyListEncoder.encode(stats)
try? encodedSettings?.write(to: archiveURL, options: .noFileProtection)
}
//return all stats in the new Stat2 format
static func loadFromFile() -> [Stat2]? {
let propertyListDecoder = PropertyListDecoder()
//first, try to decode existing stats as Stat2
if let retrievedSettingsData = try? Data(contentsOf: archiveURL), let decodedSettings = try? propertyListDecoder.decode([Stat2].self, from: retrievedSettingsData) {
return decodedSettings
} else if let retrievedSettingsData = try? Data(contentsOf: archiveURL), let decodedSettings = try? propertyListDecoder.decode([Stat].self, from: retrievedSettingsData) {
//since we couldn't decode as Stat2, we decoded as Stat
//convert existing Stat instances to Stat2, giving the newProperty an initial value
var newStats = [Stat2]()
for stat in decodedSettings {
let newStat = Stat2()
newStat.timesAnsweredCorrectly = stat.timesAnsweredCorrectly
newStat.timesAnsweredFirstTime = stat.timesAnsweredFirstTime
newStat.timesFailed = stat.timesFailed
newStat.newProperty = 0
newStats.append(newStat)
}
return newStats
} else {
return nil
}
}
static let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
static let archiveURL = documentsDirectory.appendingPathComponent("savedVerbStats").appendingPathExtension("plist")
}
class Stat2: Stat {
var newProperty: Int = 0
}
Following on from Mike's response, I came up with a migration scheme that seems to solve the issue of the optionals and doesn't require any new classes every time the data model is altered. Part of the issue is that a developer may change or add properties to a class that gets persisted and Xcode would never flag this as an issue, which may result in your user's app trying to read the previous data class saved to the device, returning nil and in all likelihood overwriting all the data in question with a reformatted model.
Instead of writing the class (e.g. Stat) to the disk (which is what Apple suggests in its teaching resources), I save a new struct "StatData" which comprises only optional properties of the data that I want to write to the file:
struct StatData: Codable {
let key: String
let timesAnsweredCorrectly: Int?
let timesAnsweredFirstTime: Int?
let timesFailed: Int?
}
That way I can read the properties from the file and any added or deleted properties from the struct would just return nil instead of making the entire struct unreadable. Then I have two functions to convert 'StatData' into 'Stat' (and back), providing default values in case any have been returned nil.
static func convertToData(_ stats: [Stat]) -> [StatData] {
var data = [StatData]()
for stat in stats {
let dataItem = StatData(key: stat.key, timesAnsweredCorrectly: stat.timesAnsweredCorrectly, timesAnsweredFirstTime: stat.timesAnsweredFirstTime, timesFailed: stat.timesFailed)
data.append(dataItem)
}
return data
}
static func convertFromData(_ statsData: [StatData]) -> [Stat] {
// if any of these properties weren't previously saved to the device, they will return the default values but the rest of the data will remain accessible.
var stats = [Stat]()
for item in statsData {
let stat = stat.init(key: item.key, timesAnsweredCorrectly: item.timesAnsweredCorrectly ?? 0, timesAnsweredFirstTime: item.timesAnsweredFirstTime ?? 0, timesFailed: item.timesFailed ?? 0)
stats.append(stat)
}
return stats
}
I then call these functions when reading or saving the data to disk. The advantage of this is that I can choose which properties from the Stat class that I want to save, and because the StatData model is a struct, the memberwise initializer will warn any developer who changes the data model that they will also need to account for the change when reading old data from the file.
This seems to do the job. Any comments or other suggestions would be appreciated

Core Data Object was written to, but never read

As I try to update an existing entry in my Core Data DB, I fetch the desired item by id, change it to a new item and save in context.
However, when I fetch the object and replace it, I get the warning "Core Data Object was written to, but never read." It does make sense since I'm not really using that object, but as I understand it, just giving it a value saves it in Core Data.
static var current: User? {
didSet {
if var userInCoreData = User.get(with: current?.id), let current = current { //userInCoreData is the value with the warning
userInCoreData = current
}
CoreDataManager.saveInContext()
}
}
static func get(with id: String?) -> User? {
guard let id = id else { return nil }
let request: NSFetchRequest = User.fetchRequest()
let predicate = NSPredicate(format: "id = %#", id)
request.predicate = predicate
do {
let users = try CoreDataManager.managedContext.fetch(request)
return users.first
} catch let error {
print(error.localizedDescription)
return nil
}
}
I want to make sure, is this the recommended process to overwrite a value in Core Data, or am I doing something wrong?
This section
if var userInCoreData = User.get(with: current?.id), let current = current { //userInCoreData is the value with the warning
userInCoreData = current
}
seems just updating local variable userInCoreData, not User object in Core Data.
So the warning says "you fetched data from core data and set to a variable, but you set another value to the variable soon, never use the first value from core data. Is it OK?"
What you really want to do is something like this?
if var userInCoreData = User.get(with: current?.id), let current = current {
userInCoreData.someValue = current.someValue
userInCoreData.anotherValue = current.anotherValue
}

Object has been deleted or invalidated realm

I have this class inherit from Object:
class Location: Object {
dynamic var id: String = ""
dynamic var name: String = ""
override class func primaryKey() -> String {
return "id"
}
}
This class is used as an instance inside my manager like this:
class LocationServiceAPI {
fileprivate var _location: Location?
var location: Location? {
get {
if _location == nil {
let realm = try! Realm()
_location = realm.objects(Location.self).first
}
return _location
}
set {
let realm = try! Realm()
if let newValue = newValue {
// delete previous locations
let locations = realm.objects(Location.self)
try! realm.write {
realm.delete(locations)
}
// store new location
try! realm.write {
realm.add(newValue, update: true)
_location = newValue
}
} else {
let locations = realm.objects(Location.self)
try! realm.write {
realm.delete(locations)
}
}
}
}
}
So whenever I get a location I delete the old one (new and old locations could be identical) and replace it with the new one, then I used the newValue as new value for the property _location but whenever I try to access the location it gives me 'Object has been deleted or invalidated'.
I am really confused since location will hold the value passed from the setter but not the realm!!
Note: If I stop the deleting then It will work fine.
The Object has been deleted or invalidated error will occur if an object has been deleted from a Realm, but you subsequently try and access a stored property of an instance of that object that your code was hanging onto since before the deletion.
You'll need to examine your logic paths and make sure there's no way you're deleting the location object, and not subsequently updating the _location property. There's no mention of deleting the object in the sample code you've provided, but your if let newValue = newValue line of code would mean that _location wouldn't actually get cleared if you passed in nil.
Finally, it's possible to manually check if an object has been deleted from a Realm by calling _location.invalidated, so if this happens a lot, it might be a good idea to include some extra checks in your code as well.
Without knowing really anything about your app and your design choices, it looks like you're trying to avoid reading/writing to the DB too often by caching the location property. Unless you're working with tons of LocationServiceAPI objects it shouldn't be a real performance penalty to actually read/write directly in the DB, like this :
class LocationServiceAPI {
var location: Location? {
get {
let realm = try! Realm()
return realm.objects(Location.self).first
}
set {
let realm = try! Realm()
if let newValue = newValue {
// store new location
try! realm.write {
realm.add(newValue, update: true)
}
} else {
// delete the record from Realm
...
}
}
}
}
Also, I would in general avoid keeping Realm objects along for longer periods, I don't say it's not possible but in general it leads to issues like you've experienced (especially if do multi-threading). In most cases I'd rather fetch the object from DB, use it, change it and save it back in the DB asap. If keeping references to specific records in the DB is necessary I'd rather keep the id and re-fetch it when I need it.

Can't save or load objects

I am new to Realm and its the first time I am using it. I followed every step from the guide and its inserted in my project just fine. I created a model and a function to insert the object into the realm database.
Somehow I keep getting errors. Here is what I do.
my function
do {
let realm = try Realm()
let proposition = Proposition()
proposition.name = (currentProposition.name)
proposition.energyType = (currentProposition.energyType)
proposition.lifetime = (currentProposition.lifetime)
proposition.saving = (currentProposition.saving)
proposition.investing = (currentProposition.investing)
if let _ = propositionsArray.indexOf(proposition) {
try! realm.write {
realm.delete(proposition)
loadPropositions()
}
} else {
try! realm.write {
realm.add(proposition)
loadPropositions()
}
}
} catch let error as NSError {
print("Add proposition error \(error)")
}
Here is my model
import RealmSwift
import Foundation
class Proposition : Object {
dynamic var name: String = ""
dynamic var energyType: String = ""
dynamic var lifetime = 0
dynamic var saving = 0
dynamic var investing = 0
}
Somehow I keep getting the following error
Can someone tell me what I am doing wrong?
The errors you're seeing indicate that the data model defined by your application does not match the data model of the Realm you're opening. This is usually due to changing your data model. In this case, the errors mention that you've added the lifetime, saving, and investing properties, and changed name and energyType to be non-nullable.
There are two ways to accommodate changes to your data model:
If you're in early development and don't need to support your old data model, you can simply remove the Realm files and start over with empty data.
You can perform a migration to have Realm update the data model of the Realm file. See the Migrations section of the Realm documentation for information about how to perform a migration.