In CoreData each entity 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 Activity
in a ListItem
entity, and use NSOrderedSet
to generate the list. Since list items are unique, I assumed this permits the same activity to appear more than once, but this produces unexpected results.
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
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
CodePudding user response:
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.
CodePudding user response:
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.