JSONImporter.swift 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. import CoreData
  2. import Foundation
  3. /// Migration-specific errors that might happen during migration
  4. enum JSONImporterError: Error {
  5. case missingGlucoseValueInGlucoseEntry
  6. case tempBasalAndDurationMismatch
  7. case missingRequiredPropertyInPumpEntry
  8. case suspendResumePumpEventMismatch
  9. case duplicatePumpEvents
  10. }
  11. // MARK: - JSONImporter Class
  12. /// Responsible for importing JSON data into Core Data.
  13. ///
  14. /// The importer handles two important states:
  15. /// - JSON files stored in the file system that contain data to import
  16. /// - Existing entries in CoreData that should not be duplicated
  17. ///
  18. /// Imports are performed when a JSON file exists. The importer checks
  19. /// CoreData for existing entries to avoid duplicating records from partial imports.
  20. class JSONImporter {
  21. private let context: NSManagedObjectContext
  22. private let coreDataStack: CoreDataStack
  23. /// Initializes the importer with a Core Data context.
  24. init(context: NSManagedObjectContext, coreDataStack: CoreDataStack) {
  25. self.context = context
  26. self.coreDataStack = coreDataStack
  27. }
  28. /// Reads and parses a JSON file from the file system.
  29. ///
  30. /// - Parameters:
  31. /// - url: The URL of the JSON file to read.
  32. /// - Returns: A decoded object of the specified type.
  33. /// - Throws: An error if the file cannot be read or decoded.
  34. private func readJsonFile<T: Decodable>(url: URL) throws -> T {
  35. let data = try Data(contentsOf: url)
  36. let decoder = JSONCoding.decoder
  37. return try decoder.decode(T.self, from: data)
  38. }
  39. /// Retrieves the set of dates for all glucose values currently stored in CoreData.
  40. ///
  41. /// - Parameters: the start and end dates to fetch glucose values, inclusive
  42. /// - Returns: A set of dates corresponding to existing glucose readings.
  43. /// - Throws: An error if the fetch operation fails.
  44. private func fetchGlucoseDates(start: Date, end: Date) async throws -> Set<Date> {
  45. let allReadings = try await coreDataStack.fetchEntitiesAsync(
  46. ofType: GlucoseStored.self,
  47. onContext: context,
  48. predicate: .predicateForDateBetween(start: start, end: end),
  49. key: "date",
  50. ascending: false
  51. ) as? [GlucoseStored] ?? []
  52. return Set(allReadings.compactMap(\.date))
  53. }
  54. /// Retrieves the set of timestamps for all pump evets currently stored in CoreData.
  55. ///
  56. /// - Parameters: the start and end dates to fetch pump events, inclusive
  57. /// - Returns: A set of dates corresponding to existing pump events.
  58. /// - Throws: An error if the fetch operation fails.
  59. private func fetchPumpTimestamps(start: Date, end: Date) async throws -> Set<Date> {
  60. let allReadings = try await coreDataStack.fetchEntitiesAsync(
  61. ofType: PumpEventStored.self,
  62. onContext: context,
  63. predicate: .predicateForTimestampBetween(start: start, end: end),
  64. key: "timestamp",
  65. ascending: false
  66. ) as? [PumpEventStored] ?? []
  67. return Set(allReadings.compactMap(\.timestamp))
  68. }
  69. /// Imports glucose history from a JSON file into CoreData.
  70. ///
  71. /// The function reads glucose data from the provided JSON file and stores new entries
  72. /// in CoreData, skipping entries with dates that already exist in the database.
  73. ///
  74. /// - Parameters:
  75. /// - url: The URL of the JSON file containing glucose history.
  76. /// - now: The current time, used to skip old entries
  77. /// - Throws:
  78. /// - JSONImporterError.missingGlucoseValueInGlucoseEntry if a glucose entry is missing a value.
  79. /// - An error if the file cannot be read or decoded.
  80. /// - An error if the CoreData operation fails.
  81. func importGlucoseHistory(url: URL, now: Date) async throws {
  82. let twentyFourHoursAgo = now - 24.hours.timeInterval
  83. let glucoseHistoryFull: [BloodGlucose] = try readJsonFile(url: url)
  84. let existingDates = try await fetchGlucoseDates(start: twentyFourHoursAgo, end: now)
  85. // only import glucose values from the last 24 hours that don't exist
  86. let glucoseHistory = glucoseHistoryFull
  87. .filter { $0.dateString >= twentyFourHoursAgo && $0.dateString <= now && !existingDates.contains($0.dateString) }
  88. // Create a background context for batch processing
  89. let backgroundContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
  90. backgroundContext.parent = context
  91. try await backgroundContext.perform {
  92. for glucoseEntry in glucoseHistory {
  93. try glucoseEntry.store(in: backgroundContext)
  94. }
  95. try backgroundContext.save()
  96. }
  97. try await context.perform {
  98. try self.context.save()
  99. }
  100. }
  101. /// combines tempBasal and tempBasalDuration events into one PumpHistoryEvent
  102. private func combineTempBasalAndDuration(pumpHistory: [PumpHistoryEvent]) throws -> [PumpHistoryEvent] {
  103. let tempBasal = pumpHistory.filter({ $0.type == .tempBasal }).sorted { $0.timestamp < $1.timestamp }
  104. let tempBasalDuration = pumpHistory.filter({ $0.type == .tempBasalDuration }).sorted { $0.timestamp < $1.timestamp }
  105. let nonTempBasal = pumpHistory.filter { $0.type != .tempBasal && $0.type != .tempBasalDuration }
  106. guard tempBasal.count == tempBasalDuration.count else {
  107. throw JSONImporterError.tempBasalAndDurationMismatch
  108. }
  109. let combinedTempBasal = try zip(tempBasal, tempBasalDuration).map { rate, duration in
  110. guard rate.timestamp == duration.timestamp else {
  111. throw JSONImporterError.tempBasalAndDurationMismatch
  112. }
  113. return PumpHistoryEvent(
  114. id: duration.id,
  115. type: .tempBasal,
  116. timestamp: duration.timestamp,
  117. duration: duration.durationMin,
  118. rate: rate.rate,
  119. temp: rate.temp
  120. )
  121. }
  122. return (combinedTempBasal + nonTempBasal).sorted { $0.timestamp < $1.timestamp }
  123. }
  124. /// checks for pumpHistory inconsistencies that might cause issues if we import these events into CoreData
  125. private func checkForInconsistencies(pumpHistory: [PumpHistoryEvent]) throws {
  126. // make sure that pump suspends / resumes match up
  127. let suspendsAndResumes = pumpHistory.filter({ $0.type == .pumpSuspend || $0.type == .pumpResume })
  128. .sorted { $0.timestamp < $1.timestamp }
  129. for (current, next) in zip(suspendsAndResumes, suspendsAndResumes.dropFirst()) {
  130. guard current.type != next.type else {
  131. throw JSONImporterError.suspendResumePumpEventMismatch
  132. }
  133. }
  134. // check for duplicate events
  135. struct TypeTimestamp: Hashable {
  136. let timestamp: Date
  137. let type: EventType
  138. }
  139. let duplicates = Dictionary(grouping: pumpHistory) { TypeTimestamp(timestamp: $0.timestamp, type: $0.type) }
  140. .values.first(where: { $0.count > 1 })
  141. if duplicates != nil {
  142. throw JSONImporterError.duplicatePumpEvents
  143. }
  144. }
  145. /// Imports pump history from a JSON file into CoreData.
  146. ///
  147. /// The function reads pump history data from the provided JSON file and stores new entries
  148. /// in CoreData, skipping entries with timestamps that already exist in the database.
  149. ///
  150. /// - Parameters:
  151. /// - url: The URL of the JSON file containing pump history.
  152. /// - now: The current time, used to skip old entries
  153. /// - Throws:
  154. /// - JSONImporterError.tempBasalAndDurationMismatch if we can't match tempBasals with their duration.
  155. /// - An error if the file cannot be read or decoded.
  156. /// - An error if the CoreData operation fails.
  157. func importPumpHistory(url: URL, now: Date) async throws {
  158. let twentyFourHoursAgo = now - 24.hours.timeInterval
  159. let pumpHistoryRaw: [PumpHistoryEvent] = try readJsonFile(url: url)
  160. let existingTimestamps = try await fetchPumpTimestamps(start: twentyFourHoursAgo, end: now)
  161. let pumpHistoryFiltered = pumpHistoryRaw
  162. .filter { $0.timestamp >= twentyFourHoursAgo && $0.timestamp <= now && !existingTimestamps.contains($0.timestamp) }
  163. let pumpHistory = try combineTempBasalAndDuration(pumpHistory: pumpHistoryFiltered)
  164. try checkForInconsistencies(pumpHistory: pumpHistory)
  165. // Create a background context for batch processing
  166. let backgroundContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
  167. backgroundContext.parent = context
  168. try await backgroundContext.perform {
  169. for pumpEntry in pumpHistory {
  170. try pumpEntry.store(in: backgroundContext)
  171. }
  172. try backgroundContext.save()
  173. }
  174. try await context.perform {
  175. try self.context.save()
  176. }
  177. }
  178. }
  179. // MARK: - Extension for Specific Import Functions
  180. extension BloodGlucose {
  181. /// Helper function to convert `BloodGlucose` to `GlucoseStored` while importing JSON glucose entries
  182. func store(in context: NSManagedObjectContext) throws {
  183. guard let glucoseValue = glucose ?? sgv else {
  184. throw JSONImporterError.missingGlucoseValueInGlucoseEntry
  185. }
  186. let glucoseEntry = GlucoseStored(context: context)
  187. glucoseEntry.id = _id.flatMap({ UUID(uuidString: $0) }) ?? UUID()
  188. glucoseEntry.date = dateString
  189. glucoseEntry.glucose = Int16(glucoseValue)
  190. glucoseEntry.direction = direction?.rawValue
  191. glucoseEntry.isManual = type == "Manual"
  192. glucoseEntry.isUploadedToNS = true
  193. glucoseEntry.isUploadedToHealth = true
  194. glucoseEntry.isUploadedToTidepool = true
  195. }
  196. }
  197. extension PumpHistoryEvent {
  198. /// Helper function to convert `PumpHistoryEvent` to `PumpEventStored` while importing JSON pump histories
  199. func store(in context: NSManagedObjectContext) throws {
  200. let pumpEntry = PumpEventStored(context: context)
  201. pumpEntry.id = id
  202. pumpEntry.timestamp = timestamp
  203. pumpEntry.type = type.rawValue
  204. pumpEntry.isUploadedToNS = true
  205. pumpEntry.isUploadedToHealth = true
  206. pumpEntry.isUploadedToTidepool = true
  207. if type == .bolus {
  208. guard let amount = amount else {
  209. throw JSONImporterError.missingRequiredPropertyInPumpEntry
  210. }
  211. let bolusEntry = BolusStored(context: context)
  212. bolusEntry.amount = NSDecimalNumber(decimal: amount)
  213. bolusEntry.isSMB = isSMB ?? false
  214. bolusEntry.isExternal = isExternal ?? false
  215. pumpEntry.bolus = bolusEntry
  216. } else if type == .tempBasal {
  217. guard let rate = rate, let duration = duration else {
  218. throw JSONImporterError.missingRequiredPropertyInPumpEntry
  219. }
  220. let tempEntry = TempBasalStored(context: context)
  221. tempEntry.rate = NSDecimalNumber(decimal: rate)
  222. tempEntry.duration = Int16(duration)
  223. tempEntry.tempType = temp?.rawValue
  224. pumpEntry.tempBasal = tempEntry
  225. }
  226. }
  227. }
  228. extension JSONImporter {
  229. func importGlucoseHistoryIfNeeded() async {}
  230. }