Skip to content

Instantly share code, notes, and snippets.

@ole
Last active January 1, 2024 16:52
Show Gist options
  • Save ole/e113a716158e26c1089a1d74b468deed to your computer and use it in GitHub Desktop.
Save ole/e113a716158e26c1089a1d74b468deed to your computer and use it in GitHub Desktop.
How to make a copy of a Core Data SQLite database. See https://oleb.net/blog/2018/03/core-data-sqlite-backup/ for more.
import CoreData
import Foundation
/// Safely copies the specified `NSPersistentStore` to a temporary file.
/// Useful for backups.
///
/// - Parameter index: The index of the persistent store in the coordinator's
/// `persistentStores` array. Passing an index that doesn't exist will trap.
///
/// - Returns: The URL of the backup file, wrapped in a TemporaryFile instance
/// for easy deletion.
extension NSPersistentStoreCoordinator {
func backupPersistentStore(atIndex index: Int) throws -> TemporaryFile {
// Inspiration: https://stackoverflow.com/a/22672386
// Documentation for NSPersistentStoreCoordinate.migratePersistentStore:
// "After invocation of this method, the specified [source] store is
// removed from the coordinator and thus no longer a useful reference."
// => Strategy:
// 1. Create a new "intermediate" NSPersistentStoreCoordinator and add
// the original store file.
// 2. Use this new PSC to migrate to a new file URL.
// 3. Drop all reference to the intermediate PSC.
precondition(persistentStores.indices.contains(index), "Index \(index) doesn't exist in persistentStores array")
let sourceStore = persistentStores[index]
let backupCoordinator = NSPersistentStoreCoordinator(managedObjectModel: managedObjectModel)
let intermediateStoreOptions = (sourceStore.options ?? [:])
.merging([NSReadOnlyPersistentStoreOption: true],
uniquingKeysWith: { $1 })
let intermediateStore = try backupCoordinator.addPersistentStore(
ofType: sourceStore.type,
configurationName: sourceStore.configurationName,
at: sourceStore.url,
options: intermediateStoreOptions
)
let backupStoreOptions: [AnyHashable: Any] = [
NSReadOnlyPersistentStoreOption: true,
// Disable write-ahead logging. Benefit: the entire store will be
// contained in a single file. No need to handle -wal/-shm files.
// https://developer.apple.com/library/content/qa/qa1809/_index.html
NSSQLitePragmasOption: ["journal_mode": "DELETE"],
// Minimize file size
NSSQLiteManualVacuumOption: true,
]
// Filename format: basename-date.sqlite
// E.g. "MyStore-20180221T200731.sqlite" (time is in UTC)
func makeFilename() -> String {
let basename = sourceStore.url?.deletingPathExtension().lastPathComponent ?? "store-backup"
let dateFormatter = ISO8601DateFormatter()
dateFormatter.formatOptions = [.withYear, .withMonth, .withDay, .withTime]
let dateString = dateFormatter.string(from: Date())
return "\(basename)-\(dateString).sqlite"
}
let backupFilename = makeFilename()
let backupFile = try TemporaryFile(creatingTempDirectoryForFilename: backupFilename)
try backupCoordinator.migratePersistentStore(intermediateStore, to: backupFile.fileURL, options: backupStoreOptions, withType: NSSQLiteStoreType)
return backupFile
}
}
/// A wrapper around a temporary file in a temporary directory. The directory
/// has been especially created for the file, so it's safe to delete when you're
/// done working with the file.
///
/// Call `deleteDirectory` when you no longer need the file.
struct TemporaryFile {
let directoryURL: URL
let fileURL: URL
/// Deletes the temporary directory and all files in it.
let deleteDirectory: () throws -> Void
/// Creates a temporary directory with a unique name and initializes the
/// receiver with a `fileURL` representing a file named `filename` in that
/// directory.
///
/// - Note: This doesn't create the file!
init(creatingTempDirectoryForFilename filename: String) throws {
let (directory, deleteDirectory) = try FileManager.default.urlForUniqueTemporaryDirectory()
self.directoryURL = directory
self.fileURL = directory.appendingPathComponent(filename)
self.deleteDirectory = deleteDirectory
}
}
extension FileManager {
/// Creates a temporary directory with a unique name and returns its URL.
///
/// - Returns: A tuple of the directory's URL and a delete function.
/// Call the function to delete the directory after you're done with it.
///
/// - Note: You should not rely on the existence of the temporary directory
/// after the app is exited.
func urlForUniqueTemporaryDirectory(preferredName: String? = nil) throws -> (url: URL, deleteDirectory: () throws -> Void) {
let basename = preferredName ?? UUID().uuidString
var counter = 0
var createdSubdirectory: URL? = nil
repeat {
do {
let subdirName = counter == 0 ? basename : "\(basename)-\(counter)"
let subdirectory = temporaryDirectory.appendingPathComponent(subdirName, isDirectory: true)
try createDirectory(at: subdirectory, withIntermediateDirectories: false)
createdSubdirectory = subdirectory
} catch CocoaError.fileWriteFileExists {
// Catch file exists error and try again with another name.
// Other errors propagate to the caller.
counter += 1
}
} while createdSubdirectory == nil
let directory = createdSubdirectory!
let deleteDirectory: () throws -> Void = {
try self.removeItem(at: directory)
}
return (directory, deleteDirectory)
}
}
@andredewaard
Copy link

Works perfect! Any help with how to export this file via the fileExporter?

@ozmpai
Copy link

ozmpai commented May 10, 2021

@jonnyholland

It's just NSPersistentStoreDescription

lazy var storePath: URL = { FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! }()

lazy var privateConfiguration: NSPersistentStoreDescription = {
        let privateConfig = NSPersistentStoreDescription(url: storePath.appendingPathComponent("MainFile.sqlite"))
        return privateConfig
}()
public lazy var persistentContainer: NSPersistentCloudKitContainer = { 
   let container = NSPersistentCloudKitContainer(name: self.momName, managedObjectModel: mom!)
   container.persistentStoreDescriptions = [self.privateConfiguration]

   container.loadPersistentStores(completionHandler:  ....etc) {}
}

@ozmpai
Copy link

ozmpai commented May 10, 2021

@andredewaard

What do you mean with fileExporter? SwiftUI's ViewModifier?

@andredewaard
Copy link

andredewaard commented May 12, 2021

@andredewaard

What do you mean with fileExporter? SwiftUI's ViewModifier?

Yes, i mean the view modifier. Already found a solution though. dont know if its the best way

struct SettingsView: View {
    let sqliteFile = UTType(exportedAs: "....", conformingTo: .database)
  
    @State private var isExportingDatabasePickerOpen: Bool = false
    @State private var isExportingDatabase: Bool = false
    @State private var exportedDatabaseFile: TemporaryFile? = nil
   
    @State private var somethingWentWrong: Bool = false
    @State private var somethingWentWrongTitle: String = ""
    @State private var somethingWentWrongMessage: String = ""

    var body: some View {
        List {
            Section(header: Text("Backup")) {
                Button(action: exportData) {
                    HStack {
                        Image(systemName: "square.and.arrow.up")
                        Text("Export backup")
                    }
                }
            }
        }
        .listStyle(InsetGroupedListStyle())           
        .fileExporter(
            isPresented: $isExportingDatabasePickerOpen,
            document: SqlDocument(url: exportedDatabaseFile?.fileURL ?? URL(fileURLWithPath: "")),
            contentType: sqliteFile,
            onCompletion: { result in
                exportingDatabase(result: result)
            }
        )
        .alert(isPresented: $somethingWentWrong) {
            Alert(title: Text("Oops something went wrong"))
        }
        .navigationTitle("Settings")
    }
    
    func exportData() {
        let persistenceController = PersistenceController.shared
        let storeCoordinator: NSPersistentStoreCoordinator = persistenceController.container.persistentStoreCoordinator
        do {
            let backupFile = try storeCoordinator.backupPersistentStore(atIndex: 0)
            exportedDatabaseFile = backupFile
            isExportingDatabasePickerOpen.toggle()
        } catch {
            openSomethingWentWrongAlert(title: "Oops something went wrong!", message: error.localizedDescription)
        }
    }
    
    func exportingDatabase(result: Result<URL, Error>) {
        switch result {
            case .success(let url):
                print("Saved to \(url)")
            case .failure(let error):
                openSomethingWentWrongAlert(title: "Oops something went wrong!", message: error.localizedDescription)
        }
    }
   
    struct SqlDocument: FileDocument {
        
        static var readableContentTypes: [UTType] { [.database] }

        var url: URL

        init(url: URL) {
            self.url = url
        }

        init(configuration: ReadConfiguration) throws {
            self.url = URL(fileURLWithPath: "")
        }

        func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
            let file = try! FileWrapper(url: url, options: .immediate)
            return file
        }
    }
    
}

@AbhishekKarale
Copy link

Thank you for the solution! Any idea how the info.plist looks with respect to Document Types / Exported Type. That whole area really confuses me. Thanks!

@rldaulton
Copy link

I have this working, but for the life of me, I can't figure out what I'm missing here...

When I restore the backup by replacing the persistent store, all of my entities have suddenly lost all reference to their /_EXTERNAL_DATA binary data. The binary data files (and the whole _SUPPORT directory) are carried over and intact, but suddenly I've lost all the images that were allowed as external storage.

Any ideas? I could really use some direction. Thank you so much for this helpful gist.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment