Codable SwiftData
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:
@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.
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.
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
}
}
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... 🎉