|
| 1 | +import Foundation |
| 2 | +import CoreData |
| 3 | + |
| 4 | +/// A wrapper around a temporary file in a temporary directory. The directory |
| 5 | +/// has been especially created for the file, so it's safe to delete when you're |
| 6 | +/// done working with the file. |
| 7 | +/// |
| 8 | +/// Call `deleteDirectory` when you no longer need the file. |
| 9 | +struct TemporaryFile { |
| 10 | + let directoryURL: URL |
| 11 | + let fileURL: URL |
| 12 | + /// Deletes the temporary directory and all files in it. |
| 13 | + let deleteDirectory: () throws -> Void |
| 14 | + |
| 15 | + /// Creates a temporary directory with a unique name and initializes the |
| 16 | + /// receiver with a `fileURL` representing a file named `filename` in that |
| 17 | + /// directory. |
| 18 | + /// |
| 19 | + /// - Note: This doesn't create the file! |
| 20 | + init(creatingTempDirectoryForFilename filename: String) throws { |
| 21 | + let (directory, deleteDirectory) = try FileManager.default |
| 22 | + .urlForUniqueTemporaryDirectory() |
| 23 | + self.directoryURL = directory |
| 24 | + self.fileURL = directory.appendingPathComponent(filename) |
| 25 | + self.deleteDirectory = deleteDirectory |
| 26 | + } |
| 27 | +} |
| 28 | + |
| 29 | +extension FileManager { |
| 30 | + /// Creates a temporary directory with a unique name and returns its URL. |
| 31 | + /// |
| 32 | + /// - Returns: A tuple of the directory's URL and a delete function. |
| 33 | + /// Call the function to delete the directory after you're done with it. |
| 34 | + /// |
| 35 | + /// - Note: You should not rely on the existence of the temporary directory |
| 36 | + /// after the app is exited. |
| 37 | + func urlForUniqueTemporaryDirectory(preferredName: String? = nil) throws |
| 38 | + -> (url: URL, deleteDirectory: () throws -> Void) |
| 39 | + { |
| 40 | + let basename = preferredName ?? UUID().uuidString |
| 41 | + |
| 42 | + var counter = 0 |
| 43 | + var createdSubdirectory: URL? = nil |
| 44 | + repeat { |
| 45 | + do { |
| 46 | + let subdirName = counter == 0 ? basename : "\(basename)-\(counter)" |
| 47 | + let subdirectory = temporaryDirectory |
| 48 | + .appendingPathComponent(subdirName, isDirectory: true) |
| 49 | + try createDirectory(at: subdirectory, withIntermediateDirectories: false) |
| 50 | + createdSubdirectory = subdirectory |
| 51 | + } catch CocoaError.fileWriteFileExists { |
| 52 | + // Catch file exists error and try again with another name. |
| 53 | + // Other errors propagate to the caller. |
| 54 | + counter += 1 |
| 55 | + } |
| 56 | + } while createdSubdirectory == nil |
| 57 | + |
| 58 | + let directory = createdSubdirectory! |
| 59 | + let deleteDirectory: () throws -> Void = { |
| 60 | + try self.removeItem(at: directory) |
| 61 | + } |
| 62 | + return (directory, deleteDirectory) |
| 63 | + } |
| 64 | +} |
| 65 | + |
| 66 | + |
| 67 | +/// Safely copies the specified `NSPersistentStore` to a temporary file. |
| 68 | +/// Useful for backups. |
| 69 | +/// |
| 70 | +/// - Parameter index: The index of the persistent store in the coordinator's |
| 71 | +/// `persistentStores` array. Passing an index that doesn't exist will trap. |
| 72 | +/// |
| 73 | +/// - Returns: The URL of the backup file, wrapped in a TemporaryFile instance |
| 74 | +/// for easy deletion. |
| 75 | +extension NSPersistentStoreCoordinator { |
| 76 | + func backupPersistentStore(atIndex index: Int) throws -> TemporaryFile { |
| 77 | + // Inspiration: https://stackoverflow.com/a/22672386 |
| 78 | + // Documentation for NSPersistentStoreCoordinate.migratePersistentStore: |
| 79 | + // "After invocation of this method, the specified [source] store is |
| 80 | + // removed from the coordinator and thus no longer a useful reference." |
| 81 | + // => Strategy: |
| 82 | + // 1. Create a new "intermediate" NSPersistentStoreCoordinator and add |
| 83 | + // the original store file. |
| 84 | + // 2. Use this new PSC to migrate to a new file URL. |
| 85 | + // 3. Drop all reference to the intermediate PSC. |
| 86 | + precondition(persistentStores.indices.contains(index), "Index \(index) doesn't exist in persistentStores array") |
| 87 | + let sourceStore = persistentStores[index] |
| 88 | + let backupCoordinator = NSPersistentStoreCoordinator(managedObjectModel: managedObjectModel) |
| 89 | + |
| 90 | + let intermediateStoreOptions = (sourceStore.options ?? [:]) |
| 91 | + .merging([NSReadOnlyPersistentStoreOption: true], |
| 92 | + uniquingKeysWith: { $1 }) |
| 93 | + let intermediateStore = try backupCoordinator.addPersistentStore( |
| 94 | + ofType: sourceStore.type, |
| 95 | + configurationName: sourceStore.configurationName, |
| 96 | + at: sourceStore.url, |
| 97 | + options: intermediateStoreOptions |
| 98 | + ) |
| 99 | + |
| 100 | + let backupStoreOptions: [AnyHashable: Any] = [ |
| 101 | + NSReadOnlyPersistentStoreOption: true, |
| 102 | + // Disable write-ahead logging. Benefit: the entire store will be |
| 103 | + // contained in a single file. No need to handle -wal/-shm files. |
| 104 | + // https://developer.apple.com/library/content/qa/qa1809/_index.html |
| 105 | + NSSQLitePragmasOption: ["journal_mode": "DELETE"], |
| 106 | + // Minimize file size |
| 107 | + NSSQLiteManualVacuumOption: true, |
| 108 | + ] |
| 109 | + |
| 110 | + // Filename format: basename-date.sqlite |
| 111 | + // E.g. "MyStore-20180221T200731.sqlite" (time is in UTC) |
| 112 | + func makeFilename() -> String { |
| 113 | + let basename = sourceStore.url?.deletingPathExtension().lastPathComponent ?? "store-backup" |
| 114 | + let dateFormatter = ISO8601DateFormatter() |
| 115 | + dateFormatter.formatOptions = [.withYear, .withMonth, .withDay, .withTime] |
| 116 | + let dateString = dateFormatter.string(from: Date()) |
| 117 | + return "\(basename)-\(dateString).sqlite" |
| 118 | + } |
| 119 | + |
| 120 | + let backupFilename = makeFilename() |
| 121 | + let backupFile = try TemporaryFile(creatingTempDirectoryForFilename: backupFilename) |
| 122 | + try backupCoordinator.migratePersistentStore(intermediateStore, to: backupFile.fileURL, options: backupStoreOptions, withType: NSSQLiteStoreType) |
| 123 | + return backupFile |
| 124 | + } |
| 125 | +} |
| 126 | + |
| 127 | +extension CoreDataPlusStore { |
| 128 | + |
| 129 | + |
| 130 | + /// Backs up your database to a .sqlite file. Safely copies the specified `NSPersistentStore` to a temporary file, so this works even if your database is in use. |
| 131 | + /// |
| 132 | + /// - Parameters: |
| 133 | + /// - fileName: Name of the file to save to |
| 134 | + /// - completion: The URL on the filesystem of the backup is permanent. If you want to remove it, use `FileManager` to delete the file once you're finished using it. |
| 135 | + public func backupToFile(fileName: String, completion: (URL?)->Void ){ |
| 136 | + var result: URL? |
| 137 | + let storeCoordinator: NSPersistentStoreCoordinator = persistentContainer.persistentStoreCoordinator |
| 138 | + do { |
| 139 | + let backupFile = try storeCoordinator.backupPersistentStore(atIndex: 0) |
| 140 | + defer { |
| 141 | + // Delete temporary directory when done |
| 142 | + try! backupFile.deleteDirectory() |
| 143 | + } |
| 144 | + print("The backup is at \"\(backupFile.fileURL.path)\"") |
| 145 | + |
| 146 | + let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! |
| 147 | + let destURL = documentsURL.appendingPathComponent("\(fileName).sqlite") |
| 148 | + |
| 149 | + if FileManager.default.fileExists(atPath: destURL.path) { |
| 150 | + try! FileManager.default.removeItem(at: destURL) |
| 151 | + } |
| 152 | + try! FileManager.default.copyItem(at: backupFile.fileURL, to: destURL) |
| 153 | + |
| 154 | + completion(destURL) |
| 155 | + } catch { |
| 156 | + completion(nil) |
| 157 | + print("Error backing up Core Data store: \(error)") |
| 158 | + } |
| 159 | + |
| 160 | + } |
| 161 | +} |
0 commit comments