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:
- Jumping Jacks: 60 seconds
- Lunges: 60 seconds
- Sit-ups: 60 seconds
- Jumping Jacks: 60 seconds
- Sit-ups: 60 seconds
- Jumping Jacks: 60 seconds
In my implementation, each Activity
is wrapped in a ListItem
, however the result produces something like this:
Morning Circuit:
- ListItem -> Jumping Jacks: 60 seconds
- ListItem -> nil
- ListItem -> Lunges: 60 seconds
- ListItem -> Sit-ups: 60 seconds
- ListItem -> nil
- 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
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:
As you can see, this produces odd results.
- Duplicate activities appear as "Unknown Activity". Core Data is disallowing them, even though they are connected to a unique list items.
- 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
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:
When I run that with Xcode 13.2.1 on macOS 11.6, I get the following output:
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.
Your
Activity
toListItem
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 relationshipnil
because it can only be attached to oneListItem
.As a general rule every ? and ! should be preceded by an
if else
,if let
orguard
so you can detect these things and react them.