Prechádzať zdrojové kódy

Import glucose JSON data to CoreData

Sam King 1 rok pred
rodič
commit
99a1d7b9a6

+ 18 - 0
Model/Helper/GlucoseStored+helper.swift

@@ -174,3 +174,21 @@ extension GlucoseStored {
         BloodGlucose.Direction(rawValue: direction ?? "")
     }
 }
+
+extension Glucose {
+    func store(in context: NSManagedObjectContext) throws {
+        guard let glucoseValue = glucose ?? sgv else {
+            throw JSONImporterError.missingGlucoseValueInGlucoseEntry
+        }
+
+        let glucoseEntry = GlucoseStored(context: context)
+        glucoseEntry.id = _id.flatMap({ UUID(uuidString: $0) }) ?? UUID()
+        glucoseEntry.date = date
+        glucoseEntry.glucose = Int16(glucoseValue)
+        glucoseEntry.direction = direction?.rawValue
+        glucoseEntry.isManual = type == .manual
+        glucoseEntry.isUploadedToNS = true
+        glucoseEntry.isUploadedToHealth = true
+        glucoseEntry.isUploadedToTidepool = true
+    }
+}

+ 61 - 122
Model/JSONImporter.swift

@@ -1,95 +1,78 @@
 import CoreData
 import Foundation
 
-// MARK: - JSONImporter Class with Generic Import Function
+/// Migration-specific errors that might happen during migration
+enum JSONImporterError: Error {
+    case missingGlucoseValueInGlucoseEntry
+}
+
+// MARK: - JSONImporter Class
 
-/// Class responsible for importing JSON data into Core Data.
+/// Responsible for importing JSON data into Core Data.
+///
+/// The importer handles two important states:
+/// - JSON files stored in the file system that contain data to import
+/// - Existing entries in CoreData that should not be duplicated
+///
+/// Imports are performed when a JSON file exists. The importer checks
+/// CoreData for existing entries to avoid duplicating records from partial imports.
 class JSONImporter {
     private let context: NSManagedObjectContext
-    private let fileManager = FileManager.default
+    private let coreDataStack: CoreDataStack
 
     /// Initializes the importer with a Core Data context.
-    init(context: NSManagedObjectContext) {
+    init(context: NSManagedObjectContext, coreDataStack: CoreDataStack) {
         self.context = context
+        self.coreDataStack = coreDataStack
     }
 
-    /// Generic function to import data from a JSON file into Core Data.
+    /// Reads and parses a JSON file from the file system.
+    ///
     /// - Parameters:
-    ///   - userDefaultsKey: Key to check if data has already been imported.
-    ///   - filePathComponent: Path component of the JSON file.
-    ///   - dtoType: The DTO type conforming to `ImportableDTO`.
-    ///   - dateDecodingStrategy: The date decoding strategy for JSON decoding.
-    func importDataIfNeeded<T: ImportableDTO>(
-        userDefaultsKey: String,
-        filePathComponent: String,
-        dtoType _: T.Type,
-        dateDecodingStrategy: JSONDecoder.DateDecodingStrategy = .iso8601
-    ) async {
-        let hasImported = UserDefaults.standard.bool(forKey: userDefaultsKey)
-
-        guard !hasImported else {
-            debugPrint("\(filePathComponent) already imported. Skipping import.")
-            return
-        }
-
-        do {
-            // Get the file path for the JSON file
-            guard let filePath = fileManager.urls(for: .documentDirectory, in: .userDomainMask)
-                .first?
-                .appendingPathComponent(filePathComponent),
-                fileManager.fileExists(atPath: filePath.path)
-            else {
-                debugPrint("\(DebuggingIdentifiers.failed) File not found: \(filePathComponent).")
-                return
-            }
-
-            let data = try Data(contentsOf: filePath)
-            let decoder = JSONDecoder()
-            decoder.dateDecodingStrategy = dateDecodingStrategy
-
-            var entries: [T] = []
-
-            do {
-                // Decode as either an array or as a single object
-                if let array = try? decoder.decode([T].self, from: data) {
-                    debugPrint("\(DebuggingIdentifiers.succeeded) Decoded \(array.count) entries as an array.")
-                    entries = array
-                } else if let singleObject = try? decoder.decode(T.self, from: data) {
-                    debugPrint("\(DebuggingIdentifiers.succeeded) Decoded a single object.")
-                    entries = [singleObject]
-                } else {
-                    debugPrint(
-                        "\(DebuggingIdentifiers.failed) Failed to decode \(filePathComponent) as either an array or a single object."
-                    )
-                    return
-                }
-            }
+    ///   - url: The URL of the JSON file to read.
+    /// - Returns: A decoded object of the specified type.
+    /// - Throws: An error if the file cannot be read or decoded.
+    private func readJsonFile<T: Decodable>(url: URL) throws -> T {
+        let data = try Data(contentsOf: url)
+        let decoder = JSONCoding.decoder
+        decoder.dateDecodingStrategy = .millisecondsSince1970
+        return try decoder.decode(T.self, from: data)
+    }
 
-            // Save the DTOs into Core Data
-            await context.perform {
-                for entry in entries {
-                    _ = entry.store(in: self.context)
-                }
+    /// Retrieves the set of dates for all glucose values currently stored in CoreData.
+    ///
+    /// - Returns: A set of dates corresponding to existing glucose readings.
+    /// - Throws: An error if the fetch operation fails.
+    private func fetchGlucoseDates() async throws -> Set<Date> {
+        let allReadings = try await coreDataStack.fetchEntitiesAsync(
+            ofType: GlucoseStored.self,
+            onContext: context,
+            predicate: NSPredicate(format: "TRUEPREDICATE"),
+            key: "date",
+            ascending: false
+        ) as? [GlucoseStored] ?? []
+
+        return Set(allReadings.compactMap(\.date))
+    }
 
-                do {
-                    guard self.context.hasChanges else {
-                        return
-                    }
-                    try self.context.save()
-                    debugPrint("\(DebuggingIdentifiers.succeeded) \(filePathComponent) successfully imported into Core Data.")
-                } catch {
-                    debugPrint("\(DebuggingIdentifiers.failed) Failed to save \(filePathComponent) to Core Data: \(error)")
-                }
+    /// Imports glucose history from a JSON file into CoreData.
+    ///
+    /// The function reads glucose data from the provided JSON file and stores new entries
+    /// in CoreData, skipping entries with dates that already exist in the database.
+    ///
+    /// - Parameters:
+    ///   - url: The URL of the JSON file containing glucose history.
+    /// - Throws:
+    ///   - JSONImporterError.missingGlucoseValueInGlucoseEntry if a glucose entry is missing a value.
+    ///   - An error if the file cannot be read or decoded.
+    ///   - An error if the CoreData operation fails.
+    func importGlucoseHistory(url: URL) async throws {
+        let glucoseHistory: [Glucose] = try readJsonFile(url: url)
+        let existingDates = try await fetchGlucoseDates()
+        for glucoseEntry in glucoseHistory {
+            if !existingDates.contains(glucoseEntry.date) {
+                try glucoseEntry.store(in: context)
             }
-
-            // Delete the JSON file after successful import
-            try fileManager.removeItem(at: filePath)
-            debugPrint("\(DebuggingIdentifiers.succeeded) \(filePathComponent) deleted after successful import.")
-
-            // Update UserDefaults to indicate that the data has been imported
-            UserDefaults.standard.set(true, forKey: userDefaultsKey)
-        } catch {
-            debugPrint("\(DebuggingIdentifiers.failed) Error importing \(filePathComponent): \(error)")
         }
     }
 }
@@ -97,49 +80,5 @@ class JSONImporter {
 // MARK: - Extension for Specific Import Functions
 
 extension JSONImporter {
-    func importPumpHistoryIfNeeded() async {
-        await importDataIfNeeded(
-            userDefaultsKey: "pumpHistoryImported",
-            filePathComponent: OpenAPS.Monitor.pumpHistory,
-            dtoType: PumpEventDTO.self,
-            dateDecodingStrategy: .iso8601
-        )
-    }
-
-    func importCarbHistoryIfNeeded() async {
-        await importDataIfNeeded(
-            userDefaultsKey: "carbHistoryImported",
-            filePathComponent: OpenAPS.Monitor.carbHistory,
-            dtoType: CarbEntryDTO.self,
-            dateDecodingStrategy: .iso8601
-        )
-    }
-
-    func importGlucoseHistoryIfNeeded() async {
-        await importDataIfNeeded(
-            userDefaultsKey: "glucoseHistoryImported",
-            filePathComponent: OpenAPS.Monitor.glucose,
-            dtoType: GlucoseEntryDTO.self,
-            dateDecodingStrategy: .iso8601
-        )
-    }
-
-    func importDeterminationHistoryIfNeeded() async {
-        await importDataIfNeeded(
-            userDefaultsKey: "enactedHistoryImported",
-            filePathComponent: OpenAPS.Enact.enacted,
-            dtoType: DeterminationDTO.self,
-            dateDecodingStrategy: .iso8601
-        )
-    }
-}
-
-// MARK: - Protocol Definition
-
-/// A protocol that ensures a Data Transfer Object (DTO) can be stored in Core Data.
-/// It requires a method to map the DTO to its corresponding Core Data managed object.
-protocol ImportableDTO: Decodable {
-    associatedtype ManagedObject: NSManagedObject
-    /// Converts the DTO into a Core Data managed object.
-    func store(in context: NSManagedObjectContext) -> ManagedObject
+    func importGlucoseHistoryIfNeeded() async {}
 }

+ 17 - 1
Trio.xcodeproj/project.pbxproj

@@ -246,6 +246,8 @@
 		3B4BA7902D8DC0EC0069D5B8 /* TidepoolServiceKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B4BA7892D8DC0EC0069D5B8 /* TidepoolServiceKitUI.framework */; };
 		3B4BA7912D8DC0EC0069D5B8 /* TidepoolServiceKitUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3B4BA7892D8DC0EC0069D5B8 /* TidepoolServiceKitUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
 		3B997DCB2DC00849006B6BB2 /* JSONImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B997DCA2DC00849006B6BB2 /* JSONImporter.swift */; };
+		3B997DCF2DC00A3A006B6BB2 /* JSONImporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B997DCE2DC00A3A006B6BB2 /* JSONImporterTests.swift */; };
+		3B997DD32DC02AEF006B6BB2 /* glucose.json in Resources */ = {isa = PBXBuildFile; fileRef = 3B997DD12DC02AEF006B6BB2 /* glucose.json */; };
 		3BAD36B22D7CDC1A00CC298D /* MainLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BAD36B12D7CDC1400CC298D /* MainLoadingView.swift */; };
 		3BAD36CC2D7D420E00CC298D /* CoreDataInitializationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BAD36CB2D7D420500CC298D /* CoreDataInitializationCoordinator.swift */; };
 		3BD9687C2D8DDD4600899469 /* SlideButton in Frameworks */ = {isa = PBXBuildFile; productRef = 3BD9687B2D8DDD4600899469 /* SlideButton */; };
@@ -1043,6 +1045,8 @@
 		3B4BA7882D8DC0EC0069D5B8 /* TidepoolServiceKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = TidepoolServiceKit.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		3B4BA7892D8DC0EC0069D5B8 /* TidepoolServiceKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = TidepoolServiceKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		3B997DCA2DC00849006B6BB2 /* JSONImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONImporter.swift; sourceTree = "<group>"; };
+		3B997DCE2DC00A3A006B6BB2 /* JSONImporterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONImporterTests.swift; sourceTree = "<group>"; };
+		3B997DD12DC02AEF006B6BB2 /* glucose.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = glucose.json; sourceTree = "<group>"; };
 		3BAD36B12D7CDC1400CC298D /* MainLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainLoadingView.swift; sourceTree = "<group>"; };
 		3BAD36CB2D7D420500CC298D /* CoreDataInitializationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataInitializationCoordinator.swift; sourceTree = "<group>"; };
 		3BDEA2DC60EDE0A3CA54DC73 /* TargetsEditorProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TargetsEditorProvider.swift; sourceTree = "<group>"; };
@@ -2531,17 +2535,27 @@
 		38FCF3EE25E9028E0078B0D1 /* TrioTests */ = {
 			isa = PBXGroup;
 			children = (
+				3B997DD22DC02AEF006B6BB2 /* JSONImporterData */,
 				BD8FC05C2D6618BE00B95AED /* BolusCalculatorTests */,
 				BD8FC0552D66187700B95AED /* CoreDataTests */,
 				38FCF3F125E9028E0078B0D1 /* Info.plist */,
+				CEE9A65D2BBC9F6500EB5194 /* CalibrationsTests.swift */,
 				38FCF3F825E902C20078B0D1 /* FileStorageTests.swift */,
+				3B997DCE2DC00A3A006B6BB2 /* JSONImporterTests.swift */,
 				CE1F6DD82BADF4620064EB8D /* PluginManagerTests.swift */,
-				CEE9A65D2BBC9F6500EB5194 /* CalibrationsTests.swift */,
 				BD8FC0532D66186000B95AED /* TestError.swift */,
 			);
 			path = TrioTests;
 			sourceTree = "<group>";
 		};
+		3B997DD22DC02AEF006B6BB2 /* JSONImporterData */ = {
+			isa = PBXGroup;
+			children = (
+				3B997DD12DC02AEF006B6BB2 /* glucose.json */,
+			);
+			path = JSONImporterData;
+			sourceTree = "<group>";
+		};
 		4E8C7B59F8065047ECE20965 /* View */ = {
 			isa = PBXGroup;
 			children = (
@@ -3875,6 +3889,7 @@
 			isa = PBXResourcesBuildPhase;
 			buildActionMask = 2147483647;
 			files = (
+				3B997DD32DC02AEF006B6BB2 /* glucose.json in Resources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};
@@ -4561,6 +4576,7 @@
 				CEE9A65E2BBC9F6500EB5194 /* CalibrationsTests.swift in Sources */,
 				BD8FC0622D6619E600B95AED /* OverrideStorageTests.swift in Sources */,
 				BD8FC0592D66189700B95AED /* TestAssembly.swift in Sources */,
+				3B997DCF2DC00A3A006B6BB2 /* JSONImporterTests.swift in Sources */,
 				BD8FC0662D661A0000B95AED /* GlucoseStorageTests.swift in Sources */,
 				BD8FC05B2D6618AF00B95AED /* DeterminationStorageTests.swift in Sources */,
 				CE1F6DD92BADF4620064EB8D /* PluginManagerTests.swift in Sources */,

+ 2 - 1
Trio/Sources/Models/Glucose.swift

@@ -1,6 +1,6 @@
 import Foundation
 
-struct Glucose: JSON {
+struct Glucose: JSON, Decodable {
     let sgv: Int?
     let glucose: Int?
     let type: GlucoseType
@@ -8,6 +8,7 @@ struct Glucose: JSON {
     let date: Date
     let filtered: Double?
     let direction: Direction?
+    let _id: String?
 }
 
 enum GlucoseType: String, JSON {

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 3012 - 0
TrioTests/JSONImporterData/glucose.json


+ 54 - 0
TrioTests/JSONImporterTests.swift

@@ -0,0 +1,54 @@
+//
+//  JSONImporterTests.swift
+//  Trio
+//
+//  Created by Cengiz Deniz on 21.04.25.
+//
+import CoreData
+import Foundation
+import Swinject
+import Testing
+
+@testable import Trio
+
+class BundleReference {}
+
+@Suite("JSON Importer Tests") struct JSONImporterTests: Injectable {
+    var coreDataStack: CoreDataStack!
+    var context: NSManagedObjectContext!
+    var importer: JSONImporter!
+
+    init() async throws {
+        // In-memory Core Data for tests
+        coreDataStack = try await CoreDataStack.createForTests()
+        context = coreDataStack.newTaskContext()
+        importer = JSONImporter(context: context, coreDataStack: coreDataStack)
+    }
+
+    @Test("Import glucose history with value checks") func testImportGlucoseHistoryDetails() async throws {
+        let testBundle = Bundle(for: BundleReference.self)
+        let path = testBundle.path(forResource: "glucose", ofType: "json")!
+        let url = URL(filePath: path)
+
+        try await importer.importGlucoseHistory(url: url)
+        // run the import againt to check our deduplication logic
+        try await importer.importGlucoseHistory(url: url)
+
+        let allReadings = try await coreDataStack.fetchEntitiesAsync(
+            ofType: GlucoseStored.self,
+            onContext: context,
+            predicate: NSPredicate(format: "TRUEPREDICATE"),
+            key: "date",
+            ascending: false
+        ) as? [GlucoseStored] ?? []
+
+        #expect(allReadings.count == 274)
+        #expect(allReadings.first?.glucose == 115)
+        #expect(allReadings.first?.date == Date(timeIntervalSince1970: 1_745_868_771.726578))
+        #expect(allReadings.last?.glucose == 127)
+        #expect(allReadings.last?.date == Date(timeIntervalSince1970: 1_745_782_670.3270996))
+
+        let manualCount = allReadings.filter({ $0.isManual }).count
+        #expect(manualCount == 1)
+    }
+}