Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Migrate from CoreData to SwiftData #514

Closed
wants to merge 8 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions BeeKit/BeeDataPoint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@ import SwiftyJSON
public protocol BeeDataPoint {
var requestid: String { get }
var daystamp: Daystamp { get }
var value: NSNumber { get }
var value: Double { get }
var comment: String { get }
}

/// A data point we have created locally (e.g. from user input, or HealthKit)
public struct NewDataPoint : BeeDataPoint {
public let requestid: String
public let daystamp: Daystamp
public let value: NSNumber
public let value: Double
public let comment: String
}
2 changes: 1 addition & 1 deletion BeeKit/GoalExtensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ extension Goal {
}

/// A hint for the value the user is likely to enter, based on past data points
public var suggestedNextValue: NSNumber? {
public var suggestedNextValue: Double? {
let candidateDatapoints = self.recentData
.filter { !$0.isMeta }
.sorted(using: [SortDescriptor(\.updatedAt, order: .reverse)])
Expand Down
2 changes: 1 addition & 1 deletion BeeKit/HeathKit/CategoryHealthKitMetric.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ class CategoryHealthKitMetric : HealthKitMetric {

let id = "apple-heath-" + date.description
let datapointValue = self.hkDatapointValueForSamples(samples: samples, startOfDate: date.start(deadline: deadline))
return NewDataPoint(requestid: id, daystamp: date, value: NSNumber(value: datapointValue), comment: "Auto-entered via Apple Health")
return NewDataPoint(requestid: id, daystamp: date, value: datapointValue, comment: "Auto-entered via Apple Health")
}

/// Predict to filter samples to those relevant to this metric, for cases where with cannot be encoded in the healthkit query
Expand Down
2 changes: 1 addition & 1 deletion BeeKit/HeathKit/QuantityHealthKitMetric.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ class QuantityHealthKitMetric : HealthKitMetric {
}

let id = "apple-health-" + daystamp.description
results.append(NewDataPoint(requestid: id, daystamp: daystamp, value: NSNumber(value: datapointValue), comment: "Auto-entered via Apple Health"))
results.append(NewDataPoint(requestid: id, daystamp: daystamp, value: datapointValue, comment: "Auto-entered via Apple Health"))
}

return results
Expand Down
29 changes: 15 additions & 14 deletions BeeKit/Managers/CurrentUserManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
// Copyright (c) 2015 APB. All rights reserved.
//

import CoreData
import SwiftData
import Foundation
import KeychainSwift
import OSLog
Expand Down Expand Up @@ -37,15 +37,15 @@ public class CurrentUserManager {

private let keychain = KeychainSwift(keyPrefix: CurrentUserManager.keychainPrefix)
private let requestManager: RequestManager
private let container: BeeminderPersistentContainer
private let container: ModelContainer

fileprivate static var allKeys: [String] {
[accessTokenKey, usernameKey, deadbeatKey, defaultLeadtimeKey, defaultAlertstartKey, defaultDeadlineKey, beemTZKey]
}

let userDefaults = UserDefaults(suiteName: Constants.appGroupIdentifier)!

init(requestManager: RequestManager, container: BeeminderPersistentContainer) {
init(requestManager: RequestManager, container: ModelContainer) {
self.requestManager = requestManager
self.container = container
migrateValuesToCoreData()
Expand All @@ -63,26 +63,26 @@ public class CurrentUserManager {
return
}

let context = container.newBackgroundContext()
let context = ModelContext(container)

// Create a new user
let _ = User(context: context,
let user = User(
username: userDefaults.object(forKey: CurrentUserManager.usernameKey) as! String,
deadbeat: userDefaults.object(forKey: CurrentUserManager.deadbeatKey) != nil,
timezone: userDefaults.object(forKey: CurrentUserManager.beemTZKey) as? String ?? "Unknown",
defaultAlertStart: (userDefaults.object(forKey: CurrentUserManager.defaultAlertstartKey) ?? 0) as! Int,
defaultDeadline: (userDefaults.object(forKey: CurrentUserManager.defaultDeadlineKey) ?? 0) as! Int,
defaultLeadTime: (userDefaults.object(forKey: CurrentUserManager.defaultLeadtimeKey) ?? 0) as! Int
)
)
context.insert(user)
try! context.save()
}


public func user(context: NSManagedObjectContext? = nil) -> User? {
public func user(context: ModelContext? = nil) -> User? {
do {
let request = NSFetchRequest<User>(entityName: "User")
let requestContext = context ?? container.newBackgroundContext()
let users = try requestContext.fetch(request)
let modelContext = context ?? ModelContext(container)
let users = try modelContext.fetch(FetchDescriptor<User>())
return users.first
} catch {
logger.error("Unable to fetch users \(error)")
Expand All @@ -91,14 +91,14 @@ public class CurrentUserManager {
}

private func modifyUser(_ callback: (User)->()) throws {
let context = container.newBackgroundContext()
let context = ModelContext(container)
guard let user = self.user(context: context) else { return }
callback(user)
try context.save()
}

private func deleteUser() throws {
let context = container.newBackgroundContext()
let context = ModelContext(container)

// Delete any existing users. We expect at most one, but delete all to be safe.
while let user = self.user(context: context) {
Expand Down Expand Up @@ -192,15 +192,16 @@ public class CurrentUserManager {
func handleSuccessfulSignin(_ responseJSON: JSON) async throws {
try deleteUser()

let context = container.newBackgroundContext()
let context = ModelContext(container)

let _ = User(context: context,
let user = User(
username: responseJSON[CurrentUserManager.usernameKey].string!,
deadbeat: responseJSON["deadbeat"].boolValue,
timezone: responseJSON[CurrentUserManager.beemTZKey].string!,
defaultAlertStart: responseJSON[CurrentUserManager.defaultAlertstartKey].intValue,
defaultDeadline: responseJSON[CurrentUserManager.defaultDeadlineKey].intValue,
defaultLeadTime: responseJSON[CurrentUserManager.defaultLeadtimeKey].intValue)
context.insert(user)
try context.save()

if responseJSON["deadbeat"].boolValue {
Expand Down
11 changes: 6 additions & 5 deletions BeeKit/Managers/DataPointManager.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Foundation
import OSLog
import SwiftData

import SwiftyJSON

Expand All @@ -12,9 +13,9 @@ public class DataPointManager {
private let datapointValueEpsilon = 0.00000001

let requestManager: RequestManager
let container: BeeminderPersistentContainer
let container: ModelContainer

init(requestManager: RequestManager, container: BeeminderPersistentContainer) {
init(requestManager: RequestManager, container: ModelContainer) {
self.requestManager = requestManager
self.container = container
}
Expand All @@ -25,7 +26,7 @@ public class DataPointManager {
}
}

private func updateDatapoint(goal : Goal, datapoint : DataPoint, datapointValue : NSNumber) async throws {
private func updateDatapoint(goal : Goal, datapoint : DataPoint, datapointValue : Double) async throws {
let val = datapoint.value
if datapointValue == val {
return
Expand All @@ -50,7 +51,7 @@ public class DataPointManager {
let response = try await requestManager.get(url: "api/v1/users/{username}/goals/\(goal.slug)/datapoints.json", parameters: params)
let responseJSON = JSON(response!)

return responseJSON.arrayValue.map({ DataPoint.fromJSON(context: goal.managedObjectContext!, goal: goal, json: $0) })
return responseJSON.arrayValue.map({ DataPoint.fromJSON(goal: goal, json: $0) })
}

/// Retrieve all data points on or after the daystamp provided
Expand Down Expand Up @@ -123,7 +124,7 @@ public class DataPointManager {
try await deleteDatapoint(goal: goal, datapoint: datapoint)
}

if !isApproximatelyEqual(firstDatapoint.value.doubleValue, newDataPoint.value.doubleValue) {
if !isApproximatelyEqual(firstDatapoint.value, newDataPoint.value) {
logger.notice("Updating datapoint for \(goal.id) on \(firstDatapoint.daystamp, privacy: .public) from \(firstDatapoint.value) to \(newDataPoint.value)")

try await updateDatapoint(goal: goal, datapoint: firstDatapoint, datapointValue: newDataPoint.value)
Expand Down
33 changes: 17 additions & 16 deletions BeeKit/Managers/GoalManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
//

import Foundation
import CoreData
import SwiftData
import SwiftyJSON
import OSLog
import OrderedCollections
Expand All @@ -21,13 +21,13 @@ public actor GoalManager {

private let requestManager: RequestManager
private nonisolated let currentUserManager: CurrentUserManager
private let container: BeeminderPersistentContainer
private let container: ModelContainer

public var goalsFetchedAt : Date? = nil

private var queuedGoalsBackgroundTaskRunning : Bool = false

init(requestManager: RequestManager, currentUserManager: CurrentUserManager, container: BeeminderPersistentContainer) {
init(requestManager: RequestManager, currentUserManager: CurrentUserManager, container: ModelContainer) {
self.requestManager = requestManager
self.currentUserManager = currentUserManager
self.container = container
Expand All @@ -41,7 +41,7 @@ public actor GoalManager {
}

/// Return the state of goals the last time they were fetched from the server. This could have been an arbitrarily long time ago.
public nonisolated func staleGoals(context: NSManagedObjectContext) -> Set<Goal>? {
public nonisolated func staleGoals(context: ModelContext) -> [Goal]? {
guard let user = self.currentUserManager.user(context: context) else {
return nil
}
Expand All @@ -64,15 +64,14 @@ public actor GoalManager {
await performPostGoalUpdateBookkeeping()
}

public func refreshGoal(_ goalID: NSManagedObjectID) async throws {
let context = container.newBackgroundContext()
let goal = try context.existingObject(with: goalID) as! Goal
public func refreshGoal(_ goalID: PersistentIdentifier) async throws {
let context = ModelContext(container)
let goal = context.model(for: goalID) as! Goal

let responseObject = try await requestManager.get(url: "/api/v1/users/\(currentUserManager.username!)/goals/\(goal.slug)?datapoints_count=5", parameters: nil)
let goalJSON = JSON(responseObject!)

// The goal may have changed during the network operation, reload latest version
context.refresh(goal, mergeChanges: false)
goal.updateToMatch(json: goalJSON)

try context.save()
Expand All @@ -89,20 +88,23 @@ public actor GoalManager {
}

// Update CoreData representation
let context = container.newBackgroundContext()
let context = ModelContext(container)
// The user may have logged out while waiting for the data, so ignore if so
if let user = self.currentUserManager.user(context: context) {

// Create and update existing goals
for goalJSON in responseGoals {
let goalId = goalJSON["id"].stringValue
let request = NSFetchRequest<Goal>(entityName: "Goal")
request.predicate = NSPredicate(format: "id == %@", goalId)
let descriptor = FetchDescriptor<Goal>(
predicate: #Predicate<Goal> { $0.id == goalId }
)

// TODO: Better error handling of failure here?
if let existingGoal = try! context.fetch(request).first {
if let existingGoal = try! context.fetch(descriptor).first {
existingGoal.updateToMatch(json: goalJSON)
} else {
let _ = Goal(context: context, owner: user, json: goalJSON)
let goal = Goal(owner: user, json: goalJSON)
context.insert(goal)
}
}

Expand All @@ -127,7 +129,6 @@ public actor GoalManager {

// Notify all listeners of the update
await Task { @MainActor in
container.viewContext.refreshAllObjects()
NotificationCenter.default.post(name: Notification.Name(rawValue: GoalManager.goalsUpdatedNotificationName), object: self)
}.value
}
Expand All @@ -147,7 +148,7 @@ public actor GoalManager {
do {
while true {
// If there are no queued goals then we are complete and can stop checking
let context = container.newBackgroundContext()
let context = ModelContext(container)
guard let user = currentUserManager.user(context: context) else { break }
let queuedGoals = user.goals.filter { $0.queued }
if queuedGoals.isEmpty {
Expand All @@ -158,7 +159,7 @@ public actor GoalManager {
try await withThrowingTaskGroup(of: Void.self) { group in
for goal in queuedGoals {
group.addTask {
try await self.refreshGoal(goal.objectID)
try await self.refreshGoal(goal.persistentModelID)
}
}
try await group.waitForAll()
Expand Down
28 changes: 14 additions & 14 deletions BeeKit/Managers/HealthStoreManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
// Copyright © 2017 APB. All rights reserved.
//

import CoreData
import SwiftData
import Foundation
import HealthKit
import OSLog
Expand All @@ -22,7 +22,7 @@ public class HealthStoreManager {
private let logger = Logger(subsystem: "com.beeminder.beeminder", category: "HealthStoreManager")

private let goalManager: GoalManager
private let container: NSPersistentContainer
private let container: ModelContainer

// TODO: Public for now to use from config
public let healthStore = HKHealthStore()
Expand All @@ -34,7 +34,7 @@ public class HealthStoreManager {
/// Protect concurrent modifications to the connections dictionary
private let monitorsSemaphore = DispatchSemaphore(value: 1)

init(goalManager: GoalManager, container: NSPersistentContainer) {
init(goalManager: GoalManager, container: ModelContainer) {
self.goalManager = goalManager
self.container = container
}
Expand All @@ -50,17 +50,17 @@ public class HealthStoreManager {
}

/// Start listening for background updates to the supplied goal if we are not already doing so
public func ensureUpdatesRegularly(goalID: NSManagedObjectID) async throws {
let context = container.newBackgroundContext()
let goal = try context.existingObject(with: goalID) as! Goal
public func ensureUpdatesRegularly(goalID: PersistentIdentifier) async throws {
let context = ModelContext(container)
let goal = context.model(for: goalID) as! Goal
guard let metricName = goal.healthKitMetric else { return }
try await self.ensureUpdatesRegularly(metricNames: [metricName], removeMissing: false)
}

/// Ensure we have background update listeners for all known goals such that they
/// will be updated any time the health data changes.
public func ensureGoalsUpdateRegularly() async throws {
let context = container.newBackgroundContext()
let context = ModelContext(container)
guard let goals = goalManager.staleGoals(context: context) else { return }
let metrics = goals.compactMap { $0.healthKitMetric }.filter { $0 != "" }
return try await ensureUpdatesRegularly(metricNames: metrics, removeMissing: true)
Expand All @@ -73,7 +73,7 @@ public class HealthStoreManager {
public func silentlyInstallObservers() {
logger.notice("Silently installing observer queries")

let context = container.newBackgroundContext()
let context = ModelContext(container)
guard let goals = goalManager.staleGoals(context: context) else { return }
let metrics = goals.compactMap { $0.healthKitMetric }.filter { $0 != "" }
let monitors = updateKnownMonitors(metricNames: metrics, removeMissing: true)
Expand All @@ -89,9 +89,9 @@ public class HealthStoreManager {
/// - Parameters:
/// - goal: The healthkit-connected goal to be updated
/// - days: How many days of history to update. Supplying 1 will update the current day.
public func updateWithRecentData(goalID: NSManagedObjectID, days: Int) async throws {
let context = container.newBackgroundContext()
let goal = try context.existingObject(with: goalID) as! Goal
public func updateWithRecentData(goalID: PersistentIdentifier, days: Int) async throws {
let context = ModelContext(container)
let goal = context.model(for: goalID) as! Goal
try await updateWithRecentData(goal: goal, days: days)
try await goalManager.refreshGoal(goalID)
}
Expand All @@ -102,13 +102,13 @@ public class HealthStoreManager {
logger.notice("Updating all goals with recent day for last \(days, privacy: .public) days")

// We must create this context in a backgrounfd thread as it will be used in background threads
let context = container.newBackgroundContext()
let context = ModelContext(container)
guard let goals = goalManager.staleGoals(context: context) else { return }
let goalsWithHealthData = goals.filter { $0.healthKitMetric != nil && $0.healthKitMetric != "" }

try await withThrowingTaskGroup(of: Void.self) { group in
for goal in goalsWithHealthData {
let goalID = goal.objectID
let goalID = goal.persistentModelID
group.addTask {
// This is a new thread, so we are not allowed to use the goal object from CoreData
// TODO: This will generate lots of unneccesary reloads
Expand Down Expand Up @@ -177,7 +177,7 @@ public class HealthStoreManager {

private func updateGoalsForMetricChange(metricName: String, metric: HealthKitMetric) async {
do {
let context = container.newBackgroundContext()
let context = ModelContext(container)
guard let allGoals = goalManager.staleGoals(context: context) else { return }
let goalsForMetric = allGoals.filter { $0.healthKitMetric == metricName }
if goalsForMetric.count == 0 {
Expand Down
Loading