Created
March 25, 2025 22:11
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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