skip to Main Content

In CoreData each instance of NSManagedObject is unique. This is why CoreData uses NSSet (and its ordered counterpart NSOrderedSet) to represent collections. However, I need a list that permits an item to appear more than once.

My intuition was to wrap each object in a ListItem entity, and use NSOrderedSet to generate the list. Since the list items themselves are unique, the objects can appear as many times as needed in the list. However this produces unexpected results.

Example App

In this example app, iFitnessRoutine, the user can select from a list of activities such as jumping jacks, sit-ups, and lunges. They can then construct a FitnessCircuit to create a list of activities and perform each for a certain duration. For example:

Morning Circuit:

  1. Jumping Jacks: 60 seconds
  2. Lunges: 60 seconds
  3. Sit-ups: 60 seconds
  4. Jumping Jacks: 60 seconds
  5. Sit-ups: 60 seconds
  6. Jumping Jacks: 60 seconds

In my implementation, each Activity is wrapped in a ListItem, however the result produces something like this:

Morning Circuit:

  1. ListItem -> Jumping Jacks: 60 seconds
  2. ListItem -> nil
  3. ListItem -> Lunges: 60 seconds
  4. ListItem -> Sit-ups: 60 seconds
  5. ListItem -> nil
  6. ListItem -> nil

I can add multiple list items, but duplicate activities are not being set.


My data model looks like this, with the listItems relationship defined as an NSOrderedSet. For CodeGen, I use class definition to have Core Data automatically generate the NSManagedObject subclasses.

iFitnessRoutine.xcdatamodeld

enter image description here


I set up my Core Data stack as usual, and populate it with seed data if necessary.

AppDelegate.swift

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    self.addSeedDataIfNecessary()
    return true
}

lazy var persistentContainer: NSPersistentContainer { ... }

func addSeedDataIfNecessary() {
    
    // 1. Check if there are fitness circuits.
    // Otherwise create "MorningRoutine"
    let fitnessCircuitRequest = FitnessCircuit.fetchRequest()
    fitnessCircuitRequest.sortDescriptors = []
    let fitnessCircuits = try! self.persistentContainer.viewContext.fetch(fitnessCircuitRequest)
    if fitnessCircuits.isEmpty {
        let fitnessCircuit = FitnessCircuit(context: self.persistentContainer.viewContext)
        fitnessCircuit.name = "Morning Routine"
        try! self.persistentContainer.viewContext.save()
    } else {
        print("Fitness Circuits already seeded")
    }
    
    // 2. Check if there are activities
    // Otherwise create "Jumping Jacks", "Sit-up", and "Lunges"
    let activitiesRequest = Activity.fetchRequest()
    activitiesRequest.sortDescriptors = []
    let activities = try! self.persistentContainer.viewContext.fetch(activitiesRequest)
    if activities.isEmpty {
        let activityNames = ["Jumping Jacks", "Sit-Ups", "Lunges"]
        for activityName in activityNames {
            let activity = Activity(context: self.persistentContainer.viewContext)
            activity.name = activityName
        }
        try! self.persistentContainer.viewContext.save()
    } else {
        print("Activities already seeded")
    }
    
}

Over in RoutineTableViewController, I create FetchedResultsController to get the routine, and populate the table with its activities. To add an activity, I simply create a new list item and assign it a random activity.

RoutineTableViewController.swift

class FitnessCircuitTableViewController: UITableViewController, NSFetchedResultsControllerDelegate {
    
    var fetchedResultsController: NSFetchedResultsController<FitnessCircuit>!
    var persistentContainer: NSPersistentContainer!
    
    var fitnessCircuit: FitnessCircuit! {
        return self.fetchedResultsController!.fetchedObjects!.first!
    }
    
    //MARK: - Configuration
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // 1. Grab the persistent container from AppDelegate
        self.persistentContainer = (UIApplication.shared.delegate as! AppDelegate).persistentContainer
        
        // 2. Configure FetchedResultsController
        let fetchRequest = FitnessCircuit.fetchRequest()
        fetchRequest.sortDescriptors = []
        self.fetchedResultsController = .init(fetchRequest: fetchRequest, managedObjectContext: self.persistentContainer.viewContext, sectionNameKeyPath: nil, cacheName: nil)
        self.fetchedResultsController.delegate = self
        
        // 3. Perform initial fetch
        try! self.fetchedResultsController.performFetch()
        
        // 4. Update the title with the circuit's name.
        self.navigationItem.title = self.fitnessCircuit!.name
    }
    
    
    //MARK: - FetchedResultsControllerDelegate
    
    func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
        self.tableView.reloadData()
    }

    
    //MARK: - IBActions
    
    @IBAction func addButtonPressed(_ sender: Any) {
        // 1. Get all activities
        let activityRequest = Activity.fetchRequest()
        activityRequest.sortDescriptors = []
        let activities = try! self.persistentContainer.viewContext.fetch(activityRequest)
        
        // 2. Create a new list item with a random activity, and save.
        let newListItem = ListItem(context: self.persistentContainer.viewContext)
        newListItem.activity = activities.randomElement()!
        self.fitnessCircuit.addToListItems(newListItem)
        
        try! self.persistentContainer.viewContext.save()
    }
    
    //MARK: - TableView
    
    override func numberOfSections(in tableView: UITableView) -> Int {
        return 1
    }
    
    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return self.fitnessCircuit.listItems?.count ?? 0
    }
    
    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        // Create a table view cell with index path and activity name
        let cell = UITableViewCell()
        let listItem = self.listItemForIndexPath(indexPath)
        var contentConfig = cell.defaultContentConfiguration()
        let activityName = listItem.activity?.name ?? "Unknown Activity"
        contentConfig.text = "(indexPath.row). " + activityName
        cell.contentConfiguration = contentConfig
        return cell
        
    }
 
    private func listItemForIndexPath(_ indexPath: IndexPath) -> ListItem {
        let listItems = self.fitnessCircuit.listItems!.array as! [ListItem]
        return listItems[indexPath.row]
    }
    
}

This is the result I get:

enter image description here


As you can see, this produces odd results.

  1. Duplicate activities appear as "Unknown Activity". Core Data is disallowing them, even though they are connected to a unique list items.
  2. Whenever it does this, it inserts the list item to a random index in the list. Otherwise, it is appended to the list as expected.

Any help would be greatly appreciated.
Cheers

2

Answers


  1. I think you have a uniquing constraint set up in your Activity entity. Can’t see it in your code, but if you look at the entity in the visual model editor, I bet it’s there.

    An NSSet allows you to have multiple items with the same value, if they’re different items. That is, you can have multiple activities with the same name, you just can’t add multiple references to the same activity.

    Here’s some example code I just threw together in Playgrounds. I use a simplified version of your Core Data object model. The first chunk is just me building the model in code, because Playgrounds doesn’t have the visual editor for managed object models:

    import CoreData
    
    // MARK: - Core Data MOM
    /// This is a Managed Object Model built in code rather than with the visual editor. The code here corresponds pretty directly to the settings in the visual editor.
    let FitnessCircuitDescription:NSEntityDescription = {
        let entity = NSEntityDescription()
        entity.name = "FitnessCircuit"
        entity.managedObjectClassName = "FitnessCircuit"
        entity.properties.append({
            let property = NSAttributeDescription()
            property.name = "name"
            property.attributeType = .stringAttributeType
            return property
        }())
        entity.properties.append({
            let relationship = NSRelationshipDescription()
            relationship.name = "activities"
            relationship.isOrdered = true
            relationship.deleteRule = .cascadeDeleteRule
            return relationship
        }())
        entity.uniquenessConstraints = [[entity.propertiesByName["name"]!]]
        return entity
    }()
    let FitnessActivityDescription:NSEntityDescription = {
        let entity = NSEntityDescription()
        entity.name = "FitnessActivity"
        entity.managedObjectClassName = "FitnessActivity"
        entity.properties.append({
            let property = NSAttributeDescription()
            property.name = "name"
            property.attributeType = .stringAttributeType
            return property
        }())
        entity.properties.append({
            let relationship = NSRelationshipDescription()
            relationship.name = "fitnessCircuit"
            relationship.deleteRule = .nullifyDeleteRule
            relationship.maxCount = 1
            return relationship
        }())
        return entity
    }()
    
    FitnessCircuitDescription.relationshipsByName["activities"]!.destinationEntity = FitnessActivityDescription
    FitnessCircuitDescription.relationshipsByName["activities"]!.inverseRelationship = FitnessActivityDescription.relationshipsByName["fitnessCircuit"]!
    FitnessActivityDescription.relationshipsByName["fitnessCircuit"]!.destinationEntity = FitnessCircuitDescription
    FitnessActivityDescription.relationshipsByName["fitnessCircuit"]!.inverseRelationship = FitnessCircuitDescription.relationshipsByName["activities"]!
    
    let iFitnessRoutineModel = NSManagedObjectModel()
    iFitnessRoutineModel.entities.append(FitnessCircuitDescription)
    iFitnessRoutineModel.entities.append(FitnessActivityDescription)
    
    
    
    // MARK: - Core Data Classes
    /// This stuff is handled for you if you have Codegen set to Class Definition. Don't have that option in Playgrounds.
    @objc(FitnessCircuit)
    public class FitnessCircuit: NSManagedObject {
        @NSManaged var name:String
        @NSManaged var activities:NSOrderedSet
        
        @objc(insertObject:inActivitiesAtIndex:)
        @NSManaged public func insertIntoActivities(_ value: FitnessActivity, at idx: Int)
        @objc(removeObjectFromActivitiesAtIndex:)
        @NSManaged public func removeFromActivities(at idx: Int)
        @objc(insertActivities:atIndexes:)
        @NSManaged public func insertIntoActivities(_ values: [FitnessActivity], at indexes: NSIndexSet)
        @objc(removeActivitiesAtIndexes:)
        @NSManaged public func removeFromActivities(at indexes: NSIndexSet)
        @objc(replaceObjectInActivitiesAtIndex:withObject:)
        @NSManaged public func replaceActivities(at idx: Int, with value: FitnessActivity)
        @objc(replaceActivitiesAtIndexes:withActivities:)
        @NSManaged public func replaceActivities(at indexes: NSIndexSet, with values: [FitnessActivity])
        @objc(addActivitiesObject:)
        @NSManaged public func addToActivities(_ value: FitnessActivity)
        @objc(removeActivitiesObject:)
        @NSManaged public func removeFromActivities(_ value: FitnessActivity)
        @objc(addActivities:)
        @NSManaged public func addToActivities(_ values: NSOrderedSet)
        @objc(removeActivities:)
        @NSManaged public func removeFromActivities(_ values: NSOrderedSet)
        
        @nonobjc func fetchRequest() -> NSFetchRequest<FitnessCircuit> {
            return NSFetchRequest<FitnessCircuit>(entityName: "FitnessCircuit")
        }
    }
    
    @objc(FitnessActivity)
    public class FitnessActivity: NSManagedObject {
        @NSManaged var name:String
        @NSManaged var fitnessCircuit:FitnessCircuit?
        
        @nonobjc func fetchRequest() -> NSFetchRequest<FitnessActivity> {
            return NSFetchRequest<FitnessActivity>(entityName: "FitnessActivity")
        }
    }
    
    
    
    // MARK: - Core Data Extensions
    /// Simple extension to give us a typed array to deal with rather than an ordered set.
    extension FitnessCircuit {
        public dynamic var activityArray: [FitnessActivity] {
            return self.activities.array as? [FitnessActivity] ?? []
        }
    }
    
    
    
    // MARK: - Core Data Stack
    /// I set this to go to /dev/null so things aren't actually saved to disk. You can set the path to /tmp/iFitnessRoutine if you want to see how it writes data to storage.
    let container = NSPersistentContainer(name: "iFitnessRoutine Container", managedObjectModel: iFitnessRoutineModel)
    container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
    container.loadPersistentStores(completionHandler: { (storeDescription, error) in
        if let error = error as NSError? { fatalError("Unresolved error (error), (error.userInfo)") }
    })
    container.viewContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
    
    
    
    // MARK: - Application logic
    /// Here's where we start building the objects and connecting them to each other.
    let circuit1 = FitnessCircuit(context: container.viewContext)
    circuit1.name = "Morning Circuit"
    circuit1.addToActivities({ () -> FitnessActivity in
        let activity = FitnessActivity(context: container.viewContext)
        activity.name = "Jumping Jacks: 60 seconds"
        return activity}())
    circuit1.addToActivities({ () -> FitnessActivity in
        let activity = FitnessActivity(context: container.viewContext)
        activity.name = "Lunges: 60 seconds"
        return activity}())
    circuit1.addToActivities({ () -> FitnessActivity in
        let activity = FitnessActivity(context: container.viewContext)
        activity.name = "Sit-ups: 60 seconds"
        return activity}())
    circuit1.addToActivities({ () -> FitnessActivity in
        let activity = FitnessActivity(context: container.viewContext)
        activity.name = "Jumping Jacks: 60 seconds"
        return activity}())
    circuit1.addToActivities({ () -> FitnessActivity in
        let activity = FitnessActivity(context: container.viewContext)
        activity.name = "Sit-ups: 60 seconds"
        return activity}())
    circuit1.addToActivities({ () -> FitnessActivity in
        let activity = FitnessActivity(context: container.viewContext)
        activity.name = "Jumping Jacks: 60 seconds"
        return activity}())
    
    try! container.viewContext.save()
    
    /// Now, to prove there's nothing up my sleeves, let's pull the data back out of the database and work solely with that, rather than the objects we built above.
    let circuitFetch = FitnessCircuit.fetchRequest()
    let circuits = try! container.viewContext.fetch(circuitFetch) as! [FitnessCircuit]
    for circuit in circuits {
        print("Circuit name: (circuit.name)")
        for activity in circuit.activityArray {
            print(activity.name)
        }
    }
    

    When I run that with Xcode 13.2.1 on macOS 11.6, I get the following output:

    Circuit name: Morning Circuit
    Jumping Jacks: 60 seconds
    Lunges: 60 seconds
    Sit-ups: 60 seconds
    Jumping Jacks: 60 seconds
    Sit-ups: 60 seconds
    Jumping Jacks: 60 seconds
    

    The "Jumping Jacks: 60 seconds" items are all different objects stored in Core Data. This works because I don’t have any uniquing set up for activities, just for circuits.

    Login or Signup to reply.
  2. Your Activity to ListItem relationship is one to one.

    But it should be one to many. When you reassign the activity to the "newest" exercise it makes the previous relationship nil because it can only be attached to one ListItem.

    As a general rule every ? and ! should be preceded by an if else, if let or guard so you can detect these things and react them.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search