Skip to content

Codable SwiftData

GitHub

Swift

A proof-of-concept for using SwiftData and the Codable protocol for working with JSON in offline-first iOS apps. The goal here is to explore some different approaches to working with SwiftData entities, and remote JSON data. This POC does not include creating data client-side. All data is fetched from a remote API and persisted on the device.

NOTE

Since this POC was written, SwiftData has come a long way and so has the documentation. Apple has added an offical guide for persisting read-only data that is a much more complete example.**

Getting Started

First define the model:

swift
@Model
class Post: Codable, Equatable {
    @Attribute(.unique)
    var id: Int
    var title: String
    var author: String

    required init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)

        id = try container.decode(Int.self, forKey: .id)
        title = try container.decode(String.self, forKey: .title)
        author = try container.decode(String.self, forKey: .author)
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(id, forKey: .id)
        try container.encode(title, forKey: .title)
        try container.encode(author, forKey: .author)
    }

    enum CodingKeys: CodingKey {
        case id, title, author
    }
}

Now we'll create a generic repository for working with SwiftData entities. This is a little out of scope for this POC, but it's a good starting point for a more complete solution.

swift
struct ModelRepository<Model: PersistentModel> {
    private let context: ModelContext

    init(context: ModelContext) {
        self.context = context
    }

    func getAll() throws -> [Model] {
        let params = FetchDescriptor<Model>()
        let result = try context.fetch(params)

        return result
    }

    func deleteEntities(_ models: [Model]) {
        for model in models {
            context.delete(model)
        }
    }

    /// Save changes made to the local data store
    func save() throws {
        if context.hasChanges {
            try context.save()
        }
    }

    /// Add models to the local data store
    func create(_ models: [Model]) {
        for model in models {
            context.insert(model)
        }
    }
}

More abstraction! 😅 Now we can create a repository for our Post model. This will handle syncing the local data with the remote data.

swift
struct PostRepository {
    func createLocalPost(title: String, author: String) async throws -> Post {
        let url = URL(string: "http://localhost:3000/posts")!
        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")

        let newPost = CreatePostDto(title: title, author: author) // Assuming id will be generated by the server
        let encoder = JSONEncoder()
        request.httpBody = try encoder.encode(newPost)

        let (data, response) = try await URLSession.shared.data(for: request)

        guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 201 else {
            throw URLError(.badServerResponse)
        }

        let createdPost = try JSONDecoder().decode(Post.self, from: data)
        return createdPost
    }

    func getRemotePosts() async throws -> [Post] {
        // simulate long loading
        try await Task.sleep(nanoseconds: 3_000_000_000)

        let url = URL(string: "http://localhost:3000/posts")!

        let session = URLSession.shared

        // Make the network request
        let (data, response) = try await session.data(from: url)

        // Check for HTTP errors
        guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
            throw URLError(.badServerResponse)
        }

        // Decode the data into an array of Post
        let decoder = JSONDecoder()
        let posts = try decoder.decode([Post].self, from: data)

        return posts
    }

    struct CreatePostDto: Encodable {
        var title: String
        var author: String
    }
}
swift
struct PostRepository {
    private let repository: ModelRepository<Post>

    init(context: ModelContext) {
        repository = ModelRepository(context: context)
    }

    func sync() async {
        do {
            // fetch data from the api
            let remotePosts = try getRemotePosts()

            // load local posts
            var localPosts = try repository.getAll()

            // first delete stale posts
            let postsToDelete = checkPostsForDeletion(localPosts: localPosts, remotePosts: remotePosts)
            repository.deleteEntities(postsToDelete)
            updateLocalPosts(with: remotePosts, in: &localPosts)
            repository.create(remotePosts)
            try repository.save()
        } catch {
            print(error.localizedDescription)
        }
    }

    func updateLocalPosts(with remotePosts: [Post], in localPosts: inout [Post]) {
        for (index, localPost) in localPosts.enumerated() {
            if let matchingRemotePost = remotePosts.first(where: { $0.id == localPost.id }),
               localPost != matchingRemotePost
            {
                localPosts[index] = matchingRemotePost
            }
        }
    }

    private func checkPostsForDeletion(localPosts: [Post], remotePosts: [Post]) -> [Post] {
        var postsToDelete: [Post] = []

        let remotePostIds = Set(remotePosts.map { $0.id })

        for localPost in localPosts {
            if !remotePostIds.contains(localPost.id) {
                postsToDelete.append(localPost)
            }
        }

        return postsToDelete
    }

    // ... existing code ...
}

Add it to the UI and you're ready to roll... 🎉