Search code examples
jsonswiftswiftdata

SwiftData Restoring, managing duplicates with relationships?


I'm learning SwiftUI. I've developed an app for my personal needs and maybe also for other people. It uses CloudKit.

I'm almost at the end of the process of creating. I'm right now at the time where I want to backup data from SwiftData and to restore them. I do such things via JSON.

To be simple: I have 2 models: entry and criteria.

import SwiftData
import SwiftUI
import UniformTypeIdentifiers

enum EntryTypes: Codable, Identifiable, CaseIterable {
    case Morning, GoodThings, DayReview, SpeechGreeting, Breathing
    var id: Self {
        self
    }
}

@Model
class Entry: Codable, Identifiable {
    
    enum CodingKeys: CodingKey {
        case uuid, creationDate, type, isCompleted, summary, criterias
    }
    
    // General settings of an Entry
    var uuid: UUID = UUID()
    var creationDate: Date = Date.now
    var type: EntryTypes = EntryTypes.Morning
    var isCompleted: Bool = false
    
    var summary: String = ""

    @Relationship(inverse: \Criteria.entryCriterias) var criterias: [Criteria]?
 
    init(uuid: UUID = .init(), creationDate: Date = .init(), type: EntryTypes = EntryTypes.Morning, isCompleted: Bool = false) {
        // General init
        self.uuid = uuid
        self.creationDate = creationDate
        self.type = type
        self.isCompleted = isCompleted
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)

        try container.encode(uuid, forKey: .uuid)
        try container.encode(creationDate, forKey: .creationDate)
        try container.encode(type, forKey: .type)
        try container.encode(isCompleted, forKey: .isCompleted)
        try container.encode(summary, forKey: .summary)
        try container.encode(criterias, forKey: .criterias)
    }
    
    required init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)

        uuid = try container.decode(UUID.self, forKey: .uuid)
        creationDate = try container.decode(Date.self, forKey: .creationDate)
        type = try container.decode(EntryTypes.self, forKey: .type)
        isCompleted = try container.decode(Bool.self, forKey: .isCompleted)
        summary = try container.decode(String.self, forKey: .summary)
        criterias = try container.decode([Criteria]?.self, forKey: .criterias)
    }
    
    static func getCriterias(entry: Entry) -> [Criteria] {
        if let criterias = entry.criterias {
            return criterias
        } else {
            return []
        }
    }
}

And

import Foundation
import SwiftData


enum CriteriaTypes: String, Codable, Identifiable, CaseIterable {
    case Morning, GoodThings, DayReview, SpeechGreeting, Genre, Activities, Emotions, Evaluations, EvaluationsOptions
    var id: Self {
        self
    }
    
    var descr: String {
        switch self {
        case .Morning:
            String(localized: "App.MorningRoutine")
        case .GoodThings:
            String(localized: "App.BestThings")
        case .DayReview:
            String(localized: "App.DayReview")
        case .SpeechGreeting:
            String(localized: "App.SpeechGreetings")
        case .Genre:
            String(localized: "App.Genre")
        case .Activities:
            String(localized: "App.Activities")
        case .Emotions:
            String(localized: "App.Emotions")
        case .Evaluations:
            String(localized: "App.Evaluations")
        case .EvaluationsOptions:
            String(localized: "App.EvaluationsOptions")
        }
    }
}

@Model
class Criteria: Codable {
    var uuid: UUID = UUID()
    var criteriaType: CriteriaTypes = CriteriaTypes.Morning
    var name: String = ""
    
    var entryCriterias: [Entry]?
    
    enum CodingKeys: CodingKey {
        case uuid, criteriaType, name, entryCriterias
    }
    
    init(uuid: String, criteriaType: CriteriaTypes, name: String) {
        self.uuid = UUID(uuidString: uuid) ?? UUID()
        self.criteriaType = criteriaType
        self.name = name
    }
    
    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)

        try container.encode(uuid, forKey: .uuid)
        try container.encode(criteriaType, forKey: .criteriaType)
        try container.encode(name, forKey: .name)
        //try container.encodeIfPresent(entryCriterias, forKey: .entryCriterias)
    }
    
    required init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)

        uuid = try container.decode(UUID.self, forKey: .uuid)
        criteriaType = try container.decode(CriteriaTypes.self, forKey: .criteriaType)
        name = try container.decode(String.self, forKey: .name)
        //entryCriterias = try container.decode([Entry]?.self, forKey: .entryCriterias)
    }
    
    
    static func loadCriteriaInDatabase(context: ModelContext) {
        do {

            try context.delete(model: Criteria.self)
            
            for sleepingEval in Constant.evaluations {
                context.insert(sleepingEval)
                try context.save()
            }
            
        }
        catch {
            print("Loading Error: \(error)")
        }
    }
}

enum Constant {
    static let evaluations: [Criteria] = [
        Criteria(uuid: "45556163-5D36-48D2-97DE-BEFE7D4530AB", criteriaType: CriteriaTypes.Evaluations, name:"Poor"),
        Criteria(uuid: "55D46CC5-58C8-41E9-965D-7D1609D9F390", criteriaType: CriteriaTypes.Evaluations, name:"Good"),
        Criteria(uuid: "49DAD2A6-A464-475B-81BF-D45C9FFF73D8", criteriaType: CriteriaTypes.Evaluations, name:"Excellent")
    ]
}

Criteria contains data loaded at 1st launch and could be used for the user to create its own criteria. There is a relationship between them named "criterias".

When I export data, it saves all criteria elements and entry elements.

import Foundation
import SwiftData
import SwiftUI
import UniformTypeIdentifiers

struct Database: Codable {
    let entries: [Entry]
    let criterias: [Criteria]
}

struct DatabaseDocument: FileDocument {
    static var readableContentTypes: [UTType] { [.json] }
    static var writableContentTypes: [UTType] { [.json] }
    
    var database: Database

    init(database: Database) {
        self.database = database
    }

    init(configuration: ReadConfiguration) throws {
        guard let data = configuration.file.regularFileContents else {
            throw CocoaError(.fileReadCorruptFile)
        }
        database = try JSONDecoder().decode(Database.self, from: data)
    }

    func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
        let data = try JSONEncoder().encode(database)
        return FileWrapper(regularFileWithContents: data)
    }
}


struct SavingRestoringDataView: View {
    
    @Environment(\.modelContext) var modelContext
    
    @Query var entries: [Entry]
    @Query var criterias: [Criteria]
    
    @State private var isExporting = false
    @State private var isImporting = false
    @State private var jsonData: Data?

    var body: some View {
        VStack {
            Button("Export Database") {
                isExporting = true
            }
            .fileExporter(
                isPresented: $isExporting,
                document: DatabaseDocument(database: Database(
                    entries: entries,
                    criterias: criterias
                )),
                contentType: .json,
                defaultFilename: "database.json"
            ) { result in
                if case .success = result {
                    print("Exported successfully")
                } else {
                    print("Export failed")
                }
            }
        }
        
        VStack {
             Button("Import Database") {
                 isImporting = true
             }
             .fileImporter(
                 isPresented: $isImporting,
                 allowedContentTypes: [.json],
                 allowsMultipleSelection: false
             ) { result in
                 switch result {
                 case .success(let urls):
                     guard let url = urls.first else { return }
                     if url.startAccessingSecurityScopedResource() {
                         importDatabase(from: url)
                     }
                 case .failure(let error):
                     print("Failed to import: \(error.localizedDescription)")
                 }
             }
             // Use the imported data, e.g., displaying it in a list
             List(entries) { entry in
                 Text(entry.summary)
             }
         }
         .padding()
    }
    
    private func importDatabase(from url: URL) {
        do {
            let data = try Data(contentsOf: url)
            if let database = restoreDatabase(from: data) {
                // Bug correction thanks to Joakim : 
                try modelContext.delete(model: Entry.self)
                try modelContext.delete(model: Criteria.self)

                for entry in database.entries {
                    modelContext.insert(entry)
                }
                for criteria in database.criterias {
                    modelContext.insert(criteria)
                }
                print("Import successful")
            }
        } catch {
            print("Error reading file: \(error)")
        }
    }
    
    func restoreDatabase(from jsonData: Data) -> Database? {
        let decoder = JSONDecoder()
        //decoder.dateDecodingStrategy = .iso8601
        
        do {
            let database = try decoder.decode(Database.self, from: jsonData)
            return database
        } catch {
            print("Failed to decode data: \(error)")
            return nil
        }
    }
}


#Preview {
    SavingRestoringDataView()
}

Suppose I have 3 criteria in database. I create an entry and 1 new criteria. I link 2 criteria (1 new and 1 existing) to the entry. When I restore data, it will create 6 criteria in database (the initial 4 and the 2 new). I will get duplicates with same uuid because some entries will use the same criteria.

Example of output :

{"entries":[{"isCompleted":false,"creationDate":751453639.00294,"uuid":"22EF502C-7C36-4CE7-B1BE-12FEE731B1C4","summary":"It’s a good day","criterias":[{"name":"Excellent","uuid":"49DAD2A6-A464-475B-81BF-D45C9FFF73D8","criteriaType":"Evaluations"},{"uuid":"801E5B56-5FC2-49F8-B32C-6793B5AB874F","criteriaType":"Emotions","name":"Cool"}],"type":{"Morning":{}}}],"criterias":[{"uuid":"45556163-5D36-48D2-97DE-BEFE7D4530AB","criteriaType":"Evaluations","name":"Poor"},{"name":"Good","uuid":"55D46CC5-58C8-41E9-965D-7D1609D9F390","criteriaType":"Evaluations"},{"uuid":"49DAD2A6-A464-475B-81BF-D45C9FFF73D8","name":"Excellent","criteriaType":"Evaluations"},{"name":"Cool","uuid":"801E5B56-5FC2-49F8-B32C-6793B5AB874F","criteriaType":"Emotions"}]}

In the above exemple: when importing, it will add Cool and Excellent Criterias twice, because there were selected in the entry.

I don't understand how do I manage such thing. I do a lot of researches on Internet

Thanks a lot in advance for any advice.


Solution

  • This could be solved by breaking up the work in different parts and encode and decode each model by itself instead of encoding everything into one array of Entity objects.

    We can do this by encoding the criterias relationship in Entry into an array of UUID values instead and then when decoding we store that array temporarily and use it to map between Entry and Criteria objects.

    First we need a new property on Entry but we make it transient so it won't get persisted.

    class Entry: Codable, Identifiable {
        //...
        @Transient private var criteriaIdentifiers: [UUID] = []
        //...
    }
    

    Then we makes changes to the encoding/decoding code

    enum CodingKeys: CodingKey {
        case uuid, creationDate, type, isCompleted, summary, criteriaIdentifiers // new name, not necessary but it makes things clearer
    }
    
    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        // encode other props...
        try container.encodeIfPresent(criterias?.map(\.uuid), forKey: .criteriaIdentifiers)
    }
    
    required init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        // decode other props...
        criteriaIdentifiers = try container.decodeIfPresent([UUID].self, forKey: .criteriaIdentifiers) ?? []
    }
    

    Then we make two changes to the importDatabase function, run the Critera loop first and add mapping to the other loop

    for criteria in database.criterias {
        modelContext.insert(criteria)
    }
    
    for entry in database.entries {
        entry.update(using: database.criterias)
        modelContext.insert(entry)
    }
    

    and the update function

    func update(using allCriterias: [Criteria]) {
        var related: [Criteria] = []
        for identifier in criteriaIdentifiers {
            if let criteria = allCriterias.first(where: { $0.uuid == identifier }) {
                related.append(criteria)
            }
        }
        self.criterias = related
        self.criteriaIdentifiers = []
    }