Skip to content

Instantly share code, notes, and snippets.

@disc0infern0
Created March 25, 2025 22:11
Show Gist options
  • Save disc0infern0/951639e7271066d87949e684e11e782f to your computer and use it in GitHub Desktop.
Save disc0infern0/951639e7271066d87949e684e11e782f to your computer and use it in GitHub Desktop.
Demonstrating issues with ModelActor - updates on a background ModelActor do not trigger Query updates on MainActor
import SwiftUI
import SwiftData
@main
struct swiftdataRelationsApp: App {
let database = Database(for: [Currency.self, ExchangeRate.self ], isPreview: true)
var body: some Scene {
WindowGroup {
// ContentView(database: database)
ContentView()
}
.modelContainer(database.container)
.environment(\.database, database)
}
}
extension EnvironmentValues {
@Entry var database = Database(for: [])
}
@Observable
final class Database: Sendable {
//struct Database: Sendable {
let container: ModelContainer
@MainActor var mainContext: ModelContext { container.mainContext }
private let task: Task<Background, Never>
let isPreview: Bool
init(for tables: [any PersistentModel.Type], isPreview: Bool = true) {
self.isPreview = isPreview
let schema = Schema(tables)
let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: isPreview)
do {
let container = try ModelContainer(for: schema, configurations: [modelConfiguration])
self.task = Task.detached { await Background.create(container: container) }
self.container = container
} catch {
fatalError("Could not create ModelContainer: \(error)")
}
}
@ModelActor
actor Background {
static nonisolated func create(container: ModelContainer) async -> Background {
Background(modelContainer: container)
}
}
public var background: Background {
get async {
return await task.value
}
}
}
extension Database.Background {
func addRates(to persistentId: PersistentIdentifier) {
guard let selectedCurrency = self[persistentId, as: Currency.self] else {
fatalError ("Currency object does not exist") // throw MyError.objectNotExist
}
for month in 1...12 {
let monthYear = 2025*100+month
let randomRate: Double = .random(in: 0...5)
let idx = selectedCurrency.rates.firstIndex { $0.monthYear == monthYear }
if let idx {
// Kludgey fix for updating not refreshing mainActor Query is to delete and re-insert
// modelContext.delete( selectedCurrency.rates[idx] )
selectedCurrency.rates[idx].rate = randomRate
} else { // comment out the else for fix
let aRate = ExchangeRate( month: month, year: 2025, currency: selectedCurrency, rate: randomRate )
modelContext.insert(aRate) // or: // selectedCurrency.rates.append(aRate)
} // fix
}
try? modelContext.save()
}
func printRates(for persistentId: PersistentIdentifier) {
guard let selectedCurrency = self[persistentId, as: Currency.self] else {
fatalError ("Currency object does not exist") // throw MyError.objectNotExist
}
for rate in selectedCurrency.rates.sorted() {
print("\(selectedCurrency.code) \(rate.monthYear): rate: \(rate.rate)" )
}
}
func addCurrencies() {
let letters = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
for _ in 1...5 {
let randomString = [1,2,3].reduce(into: "") { result, _ in
result += String(letters.randomElement()!) }
let newcurrency = Currency(code: randomString)
modelContext.insert(newcurrency)
}
try? modelContext.save()
}
/// Currently unused
func withContext<T, Failure: Error>(
_ block: @Sendable (isolated Database.Background, ModelContext) async throws( Failure ) -> sending T ) async throws(Failure) -> sending T {
try await block(self, modelContext)
}
}
struct ContentView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.database) private var database
// let database: Database
@State private var actionCount = 0
@State private var selectedCurrency: Currency?
@State private var selectedRate: ExchangeRate?
@State private var currencyAction: CurrencyAction = .initialise
@Query private var currencies: [Currency]
var body: some View {
NavigationSplitView {
List(selection: $selectedCurrency) {
ForEach(currencies) { currency in
NavigationLink(value: currency) {
Text("currency \(currency.code)")
}
}
}
.navigationSplitViewColumnWidth(min: 130, ideal: 180)
} detail: {
VStack {
if let selectedCurrency {
if selectedCurrency.rates.isEmpty {
Text("No rates yet")
} else {
// ShowRates(selectedCurrency: selectedCurrency, selectedRate: $selectedRate)
ShowRates(database: database, selectedCurrency: selectedCurrency, selectedRate: $selectedRate)
}
} else {
Text("Select a currency")
}
}
.toolbar {
ToolbarItem {
Button { currencyAction = .addRates(selectedCurrency!.id) } label: {
Label("Add addRates", systemImage: "plus")
}
.disabled(selectedCurrency == nil)
}
}
}
/// Perform all async calls to the background modelActor here to take advantage of automatic cancellation
.task(id: currencyAction) {
await currencyAction.run(on: database)
currencyAction = .none
}
}
enum CurrencyAction: Equatable {
case addRates(PersistentIdentifier), initialise, none
func run(on database: Database) async {
switch self {
case .initialise:
await database.background.addCurrencies()
case .addRates(let id):
await database.background.addRates(to: id )
case .none: break
}
}
}
}
#Preview {
let database = Database(for: [Currency.self, ExchangeRate.self ], isPreview: true)
ContentView()
// ContentView(database: database)
.modelContainer(database.container)
.environment(\.database, database)
}
struct ShowRates: View {
@Environment(\.modelContext) var modelContext
// @Environment(\.database) private var database
let database: Database
let selectedCurrency: Currency
@Binding var selectedRate: ExchangeRate?
@State var rates: [ExchangeRate] = []
@State var ratesAction: RatesAction = .initialise
private var sortedRates: [ExchangeRate] { selectedCurrency.rates.sorted() }
var body: some View {
List(selection: $selectedRate) {
ForEach(sortedRates) { rate in
NavigationLink(value: rate) {
HStack {
Text("monthYear: \(rate.monthYear)")
Text("rate: \(rate.rate)")
}
}
}
}
.navigationSplitViewColumnWidth(min: 180, ideal: 200)
.toolbar {
ToolbarItem {
Button(action: deleteRate) {
Label("Delete rate", systemImage: "minus")
}
}
ToolbarItem {
Button{ ratesAction = .printRates(selectedCurrency.id) } label: {
Label("printRates", systemImage: "shoeprints.fill")
}
}
}
.task(id: ratesAction) {
await ratesAction.run(on: database)
ratesAction = .none
}
}
enum RatesAction: Equatable {
case printRates(PersistentIdentifier), initialise, none
func run(on database: Database) async {
switch self {
case .printRates(let id):
await database.background.printRates(for: id)
case .initialise: break // placeholder for initialisation actions
case .none: break
}
}
}
private func deleteRate() {
if let selectedRate {
withAnimation {
modelContext.delete(selectedRate)
}
/// Omitting the save command below will mean the background modelActor will not see the deletion
try? modelContext.save()
}
}
}
@Model
final class ExchangeRate: Comparable, Equatable, Hashable {
#Unique<ExchangeRate>([\.monthYear, \.currency])
var monthYear: Int
// @Relationship(deleteRule: .cascade, inverse: \Currency.rates)
var currency: Currency?
var rate: Double = 0
init(month: Int, year: Int, currency: Currency, rate: Double) {
self.monthYear = year*100+month
self.currency = currency
self.rate = rate
}
static func <(lhs: ExchangeRate, rhs: ExchangeRate) -> Bool {
lhs.monthYear < rhs.monthYear
}
}
@Model
final class Currency: Comparable, Equatable, Hashable {
#Unique<Currency>([\.code])
var code: String
var rates: [ExchangeRate] = []
static func <(lhs: Currency, rhs: Currency) -> Bool {
lhs.code < rhs.code
}
init(code: String, rates: [ExchangeRate] = []) {
self.code = code
self.rates = rates
}
static func == (lhs: Currency, rhs: Currency) -> Bool {
lhs.code == rhs.code
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment