SwiftData Model: How to define Relationships, Optional Types and Enums

During WWDC 2023, SwiftData was introduced as a powerful framework that simplifies data persistence and management with its declarative approach. Previously, you’ve probably used  CoreData, a popular persistence framework. However, integrating Core Data into your app can sometimes be a complex endeavor, especially for defining the Schema. I see SwiftData as the next evolution of CoreData. While both frameworks serve the purpose of data management, SwiftData offers a streamlined and intuitive approach, especially when it comes to defining the schema.

In this blog post, I will show you how to define your schema with SwiftData.We will explore how SwiftData simplifies the process of data modeling and why it can be a better choice over Core Data, particularly when it comes to defining the schema directly in your code.

If you have an existing CoreData project, you can take advantage of Xcode tools that help generate the model files. Read more about the process in this post.

⬇️ download the project from GitHub https://github.com/gahntpo/SnippetBox-SwiftData

Requirement: Xcode 15

Defining a Data Model in Swift

I am using a SnippetBox project as a demo. SnippetBox is a handy tool designed to help programmers save and organize pieces of code that they use frequently. Think of it as a special box where you can store all your precious code snippets. With SnippetBox, you can easily add, edit, and categorize your snippets by language or tags. You can even group them into folders for better organization. No more wasting time searching for that piece of code you wrote a while ago. With SnippetBox, your code snippets are always just a few clicks away. Let’s dive in and see how it works!

SwiftData demo project with folders and snippet collections
Snippbox demo project where you can save code snippets.

I added different property types for Snippet like Date, UUID, and Bool. Some more advanced use cases are images and enums. Each Snippet can belong to one folder and a folder can have many snippets, which is represented by a one-to-many relationship. On the other hand, each snippet can have many tags, and a tag can be added to many snippets, which is a many-to-many relationship. In the following graph, you can see the schema.

SwiftData model example with relationships
Schema overview for a code snippet app with a one-to-many relationship to Folder and a many-to-many relationship to Tag.

Class vs Struct in Swift Data Modeling

SwiftUI works well with structs in order to properly update the views. For iOS 17 and macOS 14, SwiftUI did get a new Observation data flow, which allows using of classes as models. SwiftData uses these mechanisms. In SwiftData models are defined as classes, which is the same as in CoreData.

However, this will limit the use of SwiftData objects in view models like:

class ViewModel: ObservableObject {
   @Published var snippet: Snippet
}

If you would use this view model in your SwiftUI views and changed a property in the object snippet, the objectWillChange in the view model will not be triggered and your views will not be updated. SwiftData objects are meant to be used directly in SwiftUI views as state properties.

Creating A SwiftData Schema and Understanding SwiftData’s Schema Macros

Macros make it easy to quickly add data persistence to your app and fine tune the schema definition. I will show you the most important macros that you can use with SwiftData.

If you have a model definition in code, you can decorate your class with the @Model macro:

import Foundation
import SwiftData

@Model final class Snippet {
    var uuid: UUID
    var creationDate: Date
    var code: String
    var image: Data?
    var isFavorite: BooL
    var notes: String
    var title: String
    var language: String?
    var folder: Folder? = nil
    var tags: [Tag]? = nil
}

SwiftData will automatically create a Schema from your model file. Your model will also have PersistentModel conformance:

protocol PersistentModel : AnyObject, Observable, Hashable, Identifiable

You can see the new Observable protocol that will give you life updates when used in SwiftUI views. The model will also get an object identifier and becomes Identifiable.

One might think that the default behavior that SwiftData assigns to your models would be sufficient, and in many cases, it is. But, there may be times when you would want to adjust it to suit your specific needs better. This is where SwiftData’s schema macros really shine, offering you the flexibility to customize how your app interacts with persistent data. You will learn in the following sections about the ´@Attribute´ macro that allows you to e.g. add uniqueness constraints to properties and rename properties. You can also fine-tune how relationships work with the ´@Relationship´ macro, which helps you to set delete rules.

Writing initializers for your SwiftData models

SwiftData models are class and thus don’t get a default initializer. You need to provide your own initialisers. Luckily Xcode can help you with that and automatically generate inits for you. Simply start typing init and use Xcode auto-completion. Here is the Snippet model with a generated initializer:

import Foundation
import SwiftData

@Model final class Snippet {

    let uuid: UUID
    let creationDate: Date
    var code: String
    var image: Data?
    var isFavorite: BooL
    var notes: String
    var title: String
    var language: String?
    var folder: Folder? = nil
    var tags: [Tag]? = nil
    
     init(
        code: String = "",
        image: Data? = nil,
        isFavorite: Bool = false,
        language_: String? = nil,
        notes: String = "",
        title: String = "",
        folder: Folder? = nil,
        tags_: [Tag] = []
    ) {
        self.creationDate = Date()
        self.uuid = UUID()
        self.image = image
        self.code = code
        self.isFavorite = isFavorite
        self.language_ = language_
        self.notes = notes
        self.title = title

        self.folder = folder
        self.tags = tags
    }
}

Note that I am setting the ´creationDate´ and ´uuid´ properties in the initializer. You could add the default values directly to the properties like so:

@Model final class Snippet {
    let uuid: UUID = UUID()
    let creationDate: Date = Date()
    ...
}

However, this is not working correctly for Xcode 15. Apple suggests using the init as a fix.

What property types can I use with SwiftData

You can use basic types for your SwiftData properties including String, Date, and number types like Int, Float, and Double.

Optional and Non-Optional Types in SwiftData

SwiftData allows you to take advantage of Swift data types. You can define properties as optional and non-optional. If you use non-optional values, make sure to set default values. In the Snippet example, I set the String properties as empty strings in the initialiser:

@Model final class Snippet {

     ...
    var code: String
    var notes: String
    var title: String
     init(
        code: String = "",
        image: Data? = nil,
        isFavorite: Bool = false,
        language_: String? = nil,
        notes: String = "",
        title: String = "",
        folder: Folder? = nil,
        tags_: [Tag] = []
    ) {
         ...
        self.code = code
        self.notes = notes
        self.title = title
    }
}

I also want to allow the user to store an image for each code snippet. This is optional. My image data property is thus defined with an optional:

@Model final class Snippet {
   ...
    
   var image: Data?
    
   init(
        code: String = "",
        image: Data? = nil,
        isFavorite: Bool = false,
        language_: String? = nil,
        notes: String = "",
        title: String = "",
        folder: Folder? = nil,
        tags_: [Tag] = []
    ) {
         ...
        self.image = image
    }
}

Working With Custom Types for SwiftData properties

You can store custom types in SwiftData as long as you conform them to the Codable protocol. For example, I want to store a color type for my Tag model:

struct ColorData: Codable {
    var red: Double = 1
    var green: Double = 1
    var blue: Double = 1
    var opacity: Double = 1

    var color: Color {
        Color(red: red, green: green, blue: blue, opacity: opacity)
    }

    init(red: Double, green: Double, blue: Double, opacity: Double) {
        self.red = red
        self.green = green
        self.blue = blue
        self.opacity = opacity
    }

    init(color: Color)  {
        let components = color.cgColor?.components ?? []

        if components.count > 0 {
            self.red  = Double(components[0])
        }

        if components.count > 1 {
            self.green = Double(components[1])
        }

        if components.count > 2 {
            self.blue = Double(components[2])
        }

        if components.count > 3 {
            self.opacity = Double(components[3])
        }
    }
}

@Model final public class Tag {
    ...
   var colorData: ColorData = ColorData(red: 1, green: 1, blue: 1, opacity: 1)
}

Note: Xcode was crashing on me when I used the redo feature together with custom types. Make sure to setup your model container like:

@main
struct SnippetBoxApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
            //.modelContainer(for: Snippet.self, isUndoEnabled: true) // crashes when using custom type codable like enum
            .modelContainer(for: Snippet.self)
        }
    }
}

Can you use Enums with SwiftData?

You can directly use enum types with your SwiftData properties, as long as they conform to Codable. For example, I want to store the programming language for each code snippet. If the programming language is defined as an enum:

enum CodingLanguage: String, Codable, CaseIterable, Identifiable {
    case swift
    case objectivec
    case pyton
    case css
    case typescript

    var id: Self { return self }
}

You can directly use this in the Snippet model like so:

@Model final class Snippet {
    ...
   var codingLanguage: CodeEditor.Language = .swift
}

Note: Xcode was crashing on me when I used the redo feature together with custom types and enums. I also was not able to filter for enum properties with @Query and predicates.

Free SwiftUI Layout Book

Get Hands-on with SwiftUI Layout.

Master SwiftUI layouts with real-world examples and step-by-step guide. Building Complex and Responsive Interfaces.

Adjusting the Model Definition with the @Attribute macro

Once, you have defined your properties, you can further fine-tune your schema definition with Swift macros. The first macro that I am showing you is the @Attribute macro.

@attached(member) public macro Attribute(_ options: PropertyOptions..., originalName: String? = nil, hashModifier: String? = nil)

Renaming a property in SwiftData with lightweight migration

You can rename a property with the @Attribute(originalName:) macro. For example I can rename my folder name property like so:

@Model final class Folder {
  @Attribute(originalName: "name") var newName: String
  ...
}

SwiftData takes care of the schema migration and you can access the database value now from the ´newName´ property.

@Attribute macro with property options

The following property options are available:

  • unique:  ensures the property’s value is unique across all models of the same type
  • transient: enables the context to disregard the property when saving the owning model
  • transformable: transforms the property’s value between an in-memory form and a persisted form
  • externalStorage: stores the property’s value as binary data adjacent to the model storage
  • encrypt: stores the property’s value in an encrypted form
  • preserveValueOnDeletion: preserves the property’s value in the persistent history when the context deletes the owning model
  • spotlight: indexes the property’s value so it can appear in Spotlight search results

I will show you examples of these in the following sections.

Adding Uniqueness Constraints to a Property in SwiftData

You can make a property in SwiftData unique like so:

@Model final class Folder {
     @Attribute(.unique) public var id: String
     ...
}

If you try to insert an object with the same value id, the existing object will be updated with the value properties of the object you wanted to insert. This behavior is called an insert.

For example, use a unique id for data in an app, where you fetch from the server and store locally in SwiftData. If you add the same object id again, the existing  object is updated. This can help you keep your app’s data up-to-date and consistent.

Unique constraints work with primate value types like Numeric, String, and UUID.

Non-persistent Data with the @Transient Macro

If you do not want to store property in your database and you only want to cash it, you can use the @Transient macro:

@Attribute(.transient)
var title: String = ""

or the @Attribute(.transient) macro like so:

@Transient
var title: String = ""

If you set this property, you can access the value as long as the app is running. When your user quotes and relaunches the app, the value is gone. You should provide a default value, as you cannot assure a value exists.

How to see images in SwiftData with computed properties

You can add computed properties to your model class. In the following, I am creating a computed property that generates a ´UIImage´ from the data stored in SwiftData:

import SwiftUI
import SwiftData

@Model final public class Snippet {
    ...
    
    var imageData: Data?
    
    var image: UIImage? {
        UIImage(data: imageData)
   }
}

Note that with the public Xcode release you no longer need to annotate computed properties with @Transient.

Storing data separately from your database

Large data should not be stored directly in your database because this can make your database slow. You can tell SwiftData to externally store property with the externalStorage property option. For example, I would want to store my snippet image externally:

@Attribute(.externalStorage)
var imageData: Data?

Defining Relationships and Delete Rules in SwiftData

SwiftData will automatically determine relationships from your model classes. As an example, I want a one-to-many relationship between Snippet and Folder. Snippet has a property pointing to Folder:

@Model final public class Snippet {
   var folder: Folder?
   ...
}

and folder has a property pointing to many Snippets that it can contain:

@Model final public class Folder {
   var snippets: [Snippet]
   ...
}

If you use classes for your property types, SwiftData will recognise these as relationships. On the other hand, if you use value types, it uses them as stored properties.

As a second relationship, I want to have a many-to-many relationship between Tag and Snippet. Snippet has a property that points to many Tags:

@Model final public class Snippet {
    var tags: [Tag] = []
    ...
}

and Tag has a property holding reference to many Snippets:

@Model final public class Tag {
    var snippets: [Snippet] = []
    ...
}

Note that for the many-to-many relationship, I used non-optional arrays. If you want to use iCloud sync, you would need to change this to optional arrays.

Setting explicit inverse relationships

In some cases, you want to explicitly set what inverse property is used. In this case, you can use the @Relationship macro like so:

@Model final public class Snippet {

    @Relationship(inverse: \Tag.snippets)
    var tags: [Tag]? = nil
    ...
}

How to set delete rules in SwiftData

With the ´@Relationship´ macro you can set delete rules for your SwiftData relationships. For example, I want to delete all the snippets in a folder, when the folder is deleted. For that, I would set a cascading delete rule like so:

@Model final public class Folder {
    @Relationship(.cascade, inverse: \Snippet.folder)
    var snippets: [Snippet]
    ...
}

The options for delete rules are the same options available for CoreData:

  • cascade: A rule that deletes any related objects.
  • deny: A rule that prevents the deletion of an object because it contains one or more references to other objects.
  • nullify: A rule that nullifies the related object’s reference to the deleted object.
  • noAction: A rule that doesn’t make changes to any related objects. You have to manually updated references to the deleted object. Otherwise, your data will be in an inconsistent state and may reference models that don’t exist.

The default delete rule is nullify.

Conclusion

In this blog post, I explored data modeling in SwiftData, a data management framework.  I should you how SwiftData models data using regular Swift code. You saw how to use Schema macros like `@Model` to easily add data persistence to your data. I explore how to create initializers, and what property types are compatible with SwiftData, including optional, non-optional, custom, and enum types. The article details how to adjust model definitions using the @Attribute macro and discusses property options. Lastly, I touch on defining relationships and delete rules in SwiftData, using examples of relationships between different entities.

I find SwiftData offers many advantages over CoreData, like the optional handling. But SwiftData does not offer as many features as CoreData e.g. sectioned fetch requests. It looks to me like we are still far away from a complete and well-working version of SwiftData. If you want to use SwiftData for your future projects, it might be better to wait for iOS 18.

Further Reading:

FAQ

What is SwiftData and how is it different from CoreData?

SwiftData is a framework introduced in WWDC 2023 that simplifies data persistence and management with a declarative approach. CoreData and SwiftUI use a lot of the same strategies. Both can use SQLight as a database. Unlike CoreData, SwiftData allows you to model your data using regular Swift code, without the need for external files or tools.

How do I define a Data Model in SwiftData?

To define a Data Model in SwiftData, you can use the @Model macro to decorate your class. SwiftData automatically creates a schema from your model file, providing it with ‘PersistentModel’ conformance.

How do I create a SwiftData Schema?

wiftData uses macros to help you define your schema quickly and easily. The most important macros are @Model, @Attribute, and @Relationship, each serving a different purpose in fine-tuning your schema.

What property types can I use with SwiftData?

In SwiftData, you can use basic types for your properties, such as String, Date, and numeric types like Int, Float, and Double. Custom types also work, if they are structs and conform to the Codable protocol.

Can I use Enums with SwiftData?

Yes, you can use enums with SwiftData as long as they conform to the Codable protocol. You can directly use enum types with your SwiftData properties.

How do I store data separately from my database in SwiftData?

In SwiftData, you can store large data separately from your database using the ´@Attribute(.externalStorage)’ macro. It tells SwiftData to store the property in a separate file from your database file.

Can I use custom types for SwiftData properties?

Yes, SwiftData supports custom types as long as they conform to the Codable protocol. For instance, you can store a color type for your model.

4 thoughts on “SwiftData Model: How to define Relationships, Optional Types and Enums”

  1. I would like to thank you for taking the time to write about SwiftData in steps with explanations. I love following Stewart Lynch’s classes, and this gave me a little more information so I could take his classes with example projects with a better understanding. I have spent a few days taking notes from you and understand this takes time to give the world. I am teaching myself SwiftUI from ground 0 because I need a skill (after becoming disabled) I can use from home to try and still have a future, and its been the hardest thing I have ever tried to do, but I won’t quit. Therefore I appreciate what you have done for us more than I can express.

    Reply

Leave a Comment

Subscribe to My Newsletter

Want the latest iOS development trends and insights delivered to your inbox? Subscribe to our newsletter now!

Newsletter Form