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.
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 = []
}