What I'm trying to do:
I'm trying to learn how to incorporate Core Data with NSFetchedResultsController and UITableViewDiffableDataSource, to create a simple table UI, that smoothly handles additions and deletions to the table of items in Core Data.
I've started by building out some core data code, then I added on a TableView, hooked it up with manual fetches at first, then moved to NSFetchedResultsController, with the "old" batch update behavior, now I'm moving that code to using DiffableDataSource.
The error:
I have two functions for deleting items in my core data store. Either by a secret menu (Accessed via shake), which contains a delete all button, or via the classic swipe to delete gesture.
Using the delete all button never seems to error, but swipe to delete does around 50% of the time.
The error message is as follows:
Fatal error: Unable to delete note: Error Domain=NSCocoaErrorDomain Code=134030 "An error occurred while saving." UserInfo={NSAffectedObjectsErrorKey=(
"<Note: 0x6000013af930> (entity: Note; id: 0x6000030f9ba0 <x-coredata:///Note/t82D95BE5-DDAE-4684-B19E-4CDA842DF89A2>; data: {\n creation = nil;\n text = nil;\n})"
)}: file /Users/philip/Git/Noter/Noter/Noter/Note+CoreDataClass.swift, line 58
Edit: I've narrowed down the error conditions further.
The error only happens specifically when I try to delete the object that was most recently created.
That is, create a new note, then delete that same note.
If I create two notes, and delete the one created first, I get no error. After that deletion, I can similarly go in and delete the second note, without any error.
Similarly if I create a note, stop the simulator, then restart it, loading the note from Core Data, I'm able to delete that note without any issues.
What I've tried:
Based on the fact that the error only happens using swipe to delete, I am thinking the problem could be somehow tied to UITableViewDiffableDataSource, which still has sub-par documentation.
I've tried inspecting my Core Data Database using an SQL viewer (Liya), the database appears to have the records I expect, and I see them being created and deleted correctly whilst creating either 1, or 1000 records using my debug menu, deleting all the records using the debug menu also looks right.
I have enabled the -com.apple.CoreData.SQLDebug 1
argument, to see the SQL output. Again it seems like the insertions and deletions are working fine. Except when the error happens.
I've also tried to use the debugger and step through the issue, although for some strange reason, it seems like using the frame variable
lldb command at my breakpoint (The beginning of the tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool
function) causes the breakpoint to fall through, and the code just continues on to the error.
The code:
This is an excerpt of what I believe to be the relevant code, the full source is also available at https://github.com/Hanse00/Noter/tree/master/Noter/Noter. I'd appreciate any help in understanding the issue.
ViewController.swift
import UIKit
import CoreData
// MARK: - UITableViewDiffableDataSource
class NoteDataSource<SectionIdentifierType>: UITableViewDiffableDataSource<SectionIdentifierType, NSManagedObjectID> where SectionIdentifierType: Hashable {
var container: NSPersistentContainer!
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
return true
}
override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
if editingStyle == .delete {
guard let objectID = itemIdentifier(for: indexPath) else {
fatalError("Unable to find note for indexPath: \(indexPath)")
}
guard let note = container.viewContext.object(with: objectID) as? Note else {
fatalError("Could not load note for id: \(objectID)")
}
Note.delete(note: note, from: container)
}
}
}
// MARK: - ViewController
class ViewController: UIViewController {
lazy var formatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .short
return formatter
}()
lazy var fetchedResultsController: NSFetchedResultsController<Note> = {
let controller = NSFetchedResultsController(fetchRequest: Note.sortedFetchRequest(), managedObjectContext: self.container.viewContext, sectionNameKeyPath: nil, cacheName: nil)
controller.delegate = self
return controller
}()
var container: NSPersistentContainer!
var dataSource: NoteDataSource<Section>!
@IBOutlet var tableView: UITableView!
enum Section: CaseIterable {
case main
}
override func viewDidLoad() {
super.viewDidLoad()
configureDataSource()
tableView.delegate = self
}
override func viewDidAppear(_ animated: Bool) {
do {
try fetchedResultsController.performFetch()
} catch {
fatalError("Failed to fetch entities: \(error)")
}
}
func configureDataSource() {
dataSource = NoteDataSource(tableView: tableView, cellProvider: { (tableView: UITableView, indexPath: IndexPath, objectID: NSManagedObjectID) -> UITableViewCell? in
let cell = tableView.dequeueReusableCell(withIdentifier: "NoteCell", for: indexPath)
guard let note = self.container.viewContext.object(with: objectID) as? Note else {
fatalError("Could not load note for id: \(objectID)")
}
cell.textLabel?.text = note.text
cell.detailTextLabel?.text = self.formatter.string(from: note.creation)
return cell
})
dataSource.container = container
}
// MARK: User Interaction
override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
if motion == .motionShake {
addRandomNotePrompt()
}
}
func addRandomNotePrompt() {
let alert = UIAlertController(title: "Add Random Note", message: nil, preferredStyle: .actionSheet)
let addAction = UIAlertAction(title: "Add a Note", style: .default) { (action) in
Note.createRandomNote(in: self.container)
}
let add1000Action = UIAlertAction(title: "Add 1000 Notes", style: .default) { (action) in
Note.createRandomNotes(notes: 1000, in: self.container)
}
let deleteAction = UIAlertAction(title: "Delete all Notes", style: .destructive) { (action) in
Note.deleteAll(in: self.container)
}
let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil)
alert.addAction(addAction)
alert.addAction(add1000Action)
alert.addAction(deleteAction)
alert.addAction(cancelAction)
present(alert, animated: true)
}
}
// MARK: - UITableViewDelegate
extension ViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
}
}
// MARK: - NSFetchedResultsControllerDelegate
extension ViewController: NSFetchedResultsControllerDelegate {
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) {
dataSource.apply(snapshot as NSDiffableDataSourceSnapshot<Section, NSManagedObjectID>, animatingDifferences: true)
}
}
Note+CoreDataClass.swift
import Foundation
import CoreData
@objc(Note)
public class Note: NSManagedObject {
public class func fetchRequest() -> NSFetchRequest<Note> {
return NSFetchRequest<Note>(entityName: "Note")
}
public class func sortedFetchRequest() -> NSFetchRequest<Note> {
let request: NSFetchRequest<Note> = fetchRequest()
let sort = NSSortDescriptor(key: "creation", ascending: false)
request.sortDescriptors = [sort]
return request
}
public class func createRandomNote(in container: NSPersistentContainer) {
let thingsILike = ["Trains", "Food", "to party party!", "Swift"]
let text = "I like \(thingsILike.randomElement()!)"
let dayOffset = Int.random(in: -365...365)
let hourOffset = Int.random(in: -12...12)
let dateOffsetDays = Calendar.current.date(byAdding: .day, value: dayOffset, to: Date())!
let date = Calendar.current.date(byAdding: .hour, value: hourOffset, to: dateOffsetDays)!
let note = Note(context: container.viewContext)
note.creation = date
note.text = text
do {
try container.viewContext.save()
} catch {
fatalError("Unable to save: \(error)")
}
}
public class func createRandomNotes(notes count: Int, in container: NSPersistentContainer) {
for _ in 1...count {
createRandomNote(in: container)
}
}
public class func delete(note: Note, from container: NSPersistentContainer) {
do {
container.viewContext.delete(note)
try container.viewContext.save()
} catch {
fatalError("Unable to delete note: \(error)")
}
}
public class func deleteAll(in container: NSPersistentContainer) {
do {
let notes = try container.viewContext.fetch(fetchRequest()) as! [Note]
for note in notes {
delete(note: note, from: container)
}
} catch {
fatalError("Unable to delete notes: \(error)")
}
}
}
Note+CoreDataProperties.swift
import Foundation
import CoreData
extension Note {
@NSManaged public var text: String
@NSManaged public var creation: Date
}
Thank you!