Data Transformation & Migration

Data Transformation and Migration

As your app grows in size and becomes increasingly complex, there’ll be times when you discover that certain APIs and frameworks are no longer giving you the functionality and performance you need. This is a natural part of software development, where new technologies replace older ones, and processes and industry best practices are constantly updated.

When you decide to upgrade your app to use new frameworks, you need to consider more than just the new way of working. You also need to consider a Migration Strategy.

A migration strategy is a plan to ensure that all important parts of an old system are transferred to the new system. These could be user accounts, user data, user features, and more! The list of what can be contained in a migration can grow large very quickly.

In the case of app data, your main concern is how to transfer the data being stored in one place to another place. Perhaps you want to move your data from AppStorage to Swift Data or even the other way around!

Take a look at an example.

Performing Analysis of a Persistence Solution

In this example, imagine you’re tasked with evaluating an app’s storage functionality and deciding whether it should begin to use Swift Data. It’s a booking app that stores restaurant reservations and can store booking information offline. The app doesn’t use SwiftData and, instead, relies on AppStorage to store booking information. Here’s what the code looks like:

struct Booking: Codable, Identifiable {
  let id: UUID
  let restaurantName: String
  let date: Date
  let numberOfPeople: Int
}

struct ContentView: View {
  @AppStorage("bookings") private var bookingsData: Data = Data()
  @State private var bookings: [Booking] = []
  @State private var isShowingAddBooking = false

  var body: some View {
    NavigationView {
      List {
        ForEach(bookings) { booking in
          VStack(alignment: .leading) {
            Text(booking.restaurantName)
              .font(.headline)
            Text("Date: \(formattedDate(booking.date))")
            Text("People: \(booking.numberOfPeople)")
          }
        }
        .onDelete(perform: deleteBooking)
    }
    .navigationTitle("Restaurant Bookings")
    .toolbar {
      Button("Add Booking") {
        isShowingAddBooking = true
      }
    }
    .sheet(isPresented: $isShowingAddBooking) {
      AddBookingView { newBooking in
        addBooking(newBooking: newBooking)
      }
    }
  }
  .onAppear(perform: loadBookings)
}

  private func loadBookings() {
    if let decodedBookings = try? JSONDecoder().decode([Booking].self, from: bookingsData) {
      bookings = decodedBookings
    }
  }

  private func saveBookings() {
    if let encodedBookings = try? JSONEncoder().encode(bookings) {
      bookingsData = encodedBookings
    }
  }

  private func addBooking(newBooking: Booking) {
    bookings.append(newBooking)
    saveBookings()
  }

  private func deleteBooking(at offsets: IndexSet) {
    bookings.remove(atOffsets: offsets)
    saveBookings()
  }

private func formattedDate(_ date: Date) -> String {
    let formatter = DateFormatter()
    formatter.dateStyle = .medium
    formatter.timeStyle = .short
    return formatter.string(from: date)
  }
}

struct AddBookingView: View {
  @State var restaurantName: String = ""
  @State var date: Date = Date()
  @State var numberOfPeople: Int = 1
  let addBooking: (Booking) -> Void
  @Environment(\.presentationMode) var presentationMode

  var body: some View {
    NavigationView {
      Form {
        TextField("Restaurant Name", text: $restaurantName)
        DatePicker("Date", selection: $date, displayedComponents: [.date, .hourAndMinute])
        Stepper("Number of People: \(numberOfPeople)", value: $numberOfPeople, in: 1...20)
      }
      .navigationTitle("Add Booking")
      .toolbar {
        Button("Save") {
          let newBooking = Booking(
                              id: UUID(),
                              restaurantName: restaurantName,
                              date: date,
                              numberOfPeople: numberOfPeople)
          addBooking(newBooking)
          presentationMode.wrappedValue.dismiss()
        }
      }
    }
  }
}

In this code, you can see the app has two views. ContentView, which shows a list of bookings, and AddBookingView, which uses a Form to add a booking. The ContentView is relying on AppStorage to store the booking data using a Data object.

When the View needs to unload the data from AppStorage, it uses JSONDecoder to load the bookings and JSONEncoder to save them when a new booking is added. The data is parsed into a Booking object.

This code looks to be in good shape. It does the job expected and doesn’t appears to have any fundamental flaws. Or does it?

If you recall from the previous lesson, AppStorage is a great choice for apps that needs to store small amounts of data. In this case though, it could be possible for the app to store information for a hundred bookings. That’s a lot of information for AppStorage to keep track of. Also consider the fact that all of that information is stored in JSON format with no validation that the JSON is valid.

It’s situations like this where a decision needs to be made for the long term benefit of the app. If you recall the benefits of Swift Data, it’s a persistence solution that works using a database, providing schema validation and works great in cases where offline use is expected. This is a good solution for you to move towards here and stop using AppStorage this way.

Implementing a Data Migration

Now you know that adding Swift Data is a good idea. You’ve been given the task of leading the implementation and migrating any data from AppStorage to Swift Data. Here are some of the tasks you’ll need to do.

  • Add Swift Data to the app.
  • Create a new booking object for Swift Data.
  • Replace any writes to AppStorage with writes to Swift Data instead.
  • Refactor the old AppStorage booking object so it doesn’t conflict with the Swift Data booking object.
  • Refactor the app to read from Swift Data when it populates the list.
  • Add a mechanism to check if the app has tried to migrate from App Storage to Swift Data and do the migration if not.
  • Clean up any old booking data stored in App Storage.

That’s a large list of items. Fortunately, thanks to SwiftUI and Swift Data, it’s also quite easy to implement. Check out an implementation of this below:

// In BookingApp.swift
@main
struct BookingApp: App {
  var body: some Scene {
    WindowGroup {
      ContentView()
    }.modelContainer(for: Booking.self) // 1
  }
}

// 2
struct OldBooking: Codable, Identifiable {
  let id: UUID
  let restaurantName: String
  let date: Date
  let numberOfPeople: Int
}

// 3
@Model
final class Booking {
  var restaurantName: String
  var date: Date
  var numberOfPeople: Int

  init(restaurantName: String, date: Date, numberOfPeople: Int) {
    self.restaurantName = restaurantName
    self.date = date
    self.numberOfPeople = numberOfPeople
  }
}

struct ContentView: View {
  // 4
  @Environment(\.modelContext) private var modelContext
  @Query private var bookings: [Booking]
  @AppStorage("bookings") private var oldBookingsData: Data = Data()
  // 5
  @AppStorage("hasMigrated") private var hasMigrated = false
  @State private var isShowingAddBooking = false

  var body: some View {
    NavigationView {
      List {
        ForEach(bookings) { booking in
          VStack(alignment: .leading) {
            Text(booking.restaurantName)
              .font(.headline)
            Text("Date: \(formattedDate(booking.date))")
            Text("People: \(booking.numberOfPeople)")
          }
        }
        .onDelete(perform: deleteBookings)
      }
      .navigationTitle("Restaurant Bookings")
      .toolbar {
        Button("Add Booking") {
          isShowingAddBooking = true
        }
      }
      .sheet(isPresented: $isShowingAddBooking) {
        AddBookingView()
      }
    }
    .onAppear {
      // 6
      if !hasMigrated {
        migrateFromAppStorage()
        hasMigrated = true
      }
    }
  }

  // 7
  private func migrateFromAppStorage() {
    guard !oldBookingsData.isEmpty else { return }
      do {
        let oldBookings = try JSONDecoder().decode([OldBooking].self, from: oldBookingsData)
        for oldBooking in oldBookings {
          let newBooking = Booking(
                            restaurantName: oldBooking.restaurantName,
                            date: oldBooking.date,
                            numberOfPeople: oldBooking.numberOfPeople)
          modelContext.insert(newBooking)
        }
        // Clear the old data after successful migration
        oldBookingsData = Data()
      } catch {
        print("Migration failed: \(error)")
      }
  }

  private func deleteBookings(at offsets: IndexSet) {
    for index in offsets {
      modelContext.delete(bookings[index])
    }
  }

  private func formattedDate(_ date: Date) -> String {
    let formatter = DateFormatter()
    formatter.dateStyle = .medium
    formatter.timeStyle = .short
    return formatter.string(from: date)
  }
}

struct AddBookingView: View {
  // 8
  @Environment(\.modelContext) private var modelContext
  @Environment(\.dismiss) private var dismiss
  @State var restaurantName: String = ""
  @State var date: Date = Date()
  @State var numberOfPeople: Int = 1

  var body: some View {
    NavigationStack {
      Form {
        TextField("Restaurant Name", text: $restaurantName)
        DatePicker("Date", selection: $date, displayedComponents: [.date, .hourAndMinute])
        Stepper("Number of People: \(numberOfPeople)", value: $numberOfPeople, in: 1...20)
      }
      .navigationTitle("Add Booking")
      .toolbar {
        Button("Save") {
          addBooking()
        }
      }
    }
  }

  private func addBooking() {
    // 9
    let newBooking = Booking(restaurantName: restaurantName, date: date, numberOfPeople: numberOfPeople)
    modelContext.insert(newBooking)
    dismiss()
  }
}

Step through what’s changed as part of migrating to Swift Data in this code:

  1. The .modifierContainer environment is passed through to the WindowGroup. Making the Group aware of the models being used in Swift Data.

  2. The original Booking struct is renamed to OldBooking. You still need the object so apps can migrate any data from App Storage. Renaming it makes it clear it’s an old version of Booking.

  3. You create a new Booking struct and use the @Model macro from Swift Data to generate additional code. Conforming the object to Hashable, Identifiable, Observable, and PersistentModel.

  4. The ContentView receives an environment property called modelContext. This is used to read and write data to Swift Data. A @Query property is also added so that the View can read all stored Bookings from Swift Data and show them in the list.

  5. Another AppStorage property called hasMigrated is added to ContentView. This is needed to know if the app has tried to perform a migration to Swift Data. Since App Storage is intended to store small amounts of data, storing this Boolean check in App Storage is a great choice.

  6. In the .onAppear modifier the app checks to see if the app has migrated the Bookings from App Storage to Swift Data. If it hasn’t, then it starts the migration.

  7. The migrateFromAppStorage function is where the migration occurs. It tries to decode all the old stored bookings in App Storage, convert them to the new Swift Data Booking struct, and insert them into Swift Data. Finally, it cleans up App Storage, so no old booking data is lingering there.

  8. In AddBookingView, the lambda to pass the booking has been replaced by an environment property to access the modelContext. This way, the created booking is written to Swift Data straight away and updates the list in ContentView.

  9. In addBooking, the details of the booking are saved to the new Swift Data Booking struct, then saved using modelContext.insert().

In nine steps, you’ve implemented a Migration Strategy to ensure Swift Data is the primary way to store Bookings and also migrate away from using AppStorage for the use case. You also moved the app to using App Storage in a more acceptable way by storing a migration flag inside and clearing out the old Booking data. Well done! Now that you understand how to perform a Data Migration, it’s your turn to try it out in the next section.

See forum comments
Download course materials from Github
Previous: Introduction: Data Transformation & Migration Next: Demo