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

refactor: create local stores and UTS - WPB-12100 #2141

Open
wants to merge 15 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 13 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
172 changes: 157 additions & 15 deletions WireDomain/Project/WireDomain Project.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,11 @@ struct UserClientAddEventProcessor: UserClientAddEventProcessorProtocol {
func processEvent(_ event: UserClientAddEvent) async throws {
do {
let localUserClient = try await repository.fetchOrCreateClient(
with: event.client.id
id: event.client.id
)

try await repository.updateClient(
with: event.client.id,
id: event.client.id,
from: event.client,
isNewClient: localUserClient.isNew
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,16 @@
// along with this program. If not, see http://www.gnu.org/licenses/.
//

import CoreData
import Foundation
import WireAPI
import WireDataModel

protocol ConnectionsLocalStoreProtocol {
// sourcery: AutoMockable
public protocol ConnectionsLocalStoreProtocol {

/// Save connection and related objects to local storage.
/// - Parameter connectionInfo: connection object

func storeConnection(
_ connectionPayload: Connection
_ connectionInfo: ConnectionInfo
Copy link
Contributor Author

@jullianm jullianm Nov 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You'll see changes like this in this PR: storage layer should not be aware of API objects so we create a domain model between repository and local store, here's the steps:

  1. Repo will fetch API model from remote.
  2. Repo will prepare data for the local store by mapping it to a domain model
  3. Repo will pass this model to the local store
  4. Local store will rely that model to manipulate (fetch, update, create) NSManagedObject

) async throws
}

Expand All @@ -44,15 +45,12 @@ final class ConnectionsLocalStore: ConnectionsLocalStoreProtocol {

// MARK: - Public

/// Save connection and related objects to local storage.
/// - Parameter connectionPayload: connection object from WireAPI

public func storeConnection(_ connectionPayload: Connection) async throws {
public func storeConnection(_ connectionInfo: ConnectionInfo) async throws {
try await context.perform { [self] in

let connection = try storedConnection(from: connectionPayload)
let connection = try storedConnection(from: connectionInfo)

let conversation = try storedConversation(from: connectionPayload, with: connection)
let conversation = try storedConversation(from: connectionInfo, with: connection)

connection.to.oneOnOneConversation = conversation

Expand All @@ -66,7 +64,10 @@ final class ConnectionsLocalStore: ConnectionsLocalStoreProtocol {
/// - storedConnection: ZMConnection object stored locally
/// - Returns: conversation object stored locally

private func storedConversation(from connection: Connection, with storedConnection: ZMConnection) throws -> ZMConversation {
private func storedConversation(
from connection: ConnectionInfo,
with storedConnection: ZMConnection
) throws -> ZMConversation {
guard let conversationID = connection.conversationID ?? connection.qualifiedConversationID?.uuid else {
throw ConnectionsRepositoryError.missingConversationId
}
Expand All @@ -87,7 +88,9 @@ final class ConnectionsLocalStore: ConnectionsLocalStoreProtocol {
/// - Parameter connection: connection payload from WireAPI
/// - Returns: connection object stored locally

private func storedConnection(from connection: Connection) throws -> ZMConnection {
private func storedConnection(
from connection: ConnectionInfo
) throws -> ZMConnection {
guard let userID = connection.receiverID ?? connection.receiverQualifiedID?.uuid else {
throw ConnectionsRepositoryError.missingReceiverId
}
Expand All @@ -98,7 +101,7 @@ final class ConnectionsLocalStore: ConnectionsLocalStoreProtocol {
in: context
)

storedConnection.status = connection.status.toDomainModel()
storedConnection.status = connection.status
storedConnection.lastUpdateDateInGMT = connection.lastUpdate
return storedConnection
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,18 @@ extension WireAPI.ConnectionStatus {
}

}

extension WireAPI.Connection {

func toDomainModel() -> ConnectionInfo {
.init(
senderID: senderID,
receiverID: receiverID,
receiverQualifiedID: receiverQualifiedID?.toDomainModel(),
conversationID: conversationID,
qualifiedConversationID: qualifiedConversationID?.toDomainModel(),
lastUpdate: lastUpdate,
status: status.toDomainModel()
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ public struct ConnectionsRepository: ConnectionsRepositoryProtocol {
await withThrowingTaskGroup(of: Void.self) { taskGroup in
for connection in connections {
taskGroup.addTask {
try await connectionsLocalStore.storeConnection(connection)
try await connectionsLocalStore.storeConnection(connection.toDomainModel())
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prepare data for the store by mapping it to a domain model (so the local store doesn't know about the API layer)

}
}
}
Expand All @@ -80,7 +80,7 @@ public struct ConnectionsRepository: ConnectionsRepositoryProtocol {
public func updateConnection(
_ connection: Connection
) async throws {
try await connectionsLocalStore.storeConnection(connection)
try await connectionsLocalStore.storeConnection(connection.toDomainModel())
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
//
// Wire
// Copyright (C) 2024 Wire Swiss GmbH
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see http://www.gnu.org/licenses/.
//

import WireDataModel

public struct ConnectionInfo: Sendable {
public let senderID: UUID?
public let receiverID: UUID?
public let receiverQualifiedID: WireDataModel.QualifiedID?
public let conversationID: UUID?
public let qualifiedConversationID: WireDataModel.QualifiedID?
public let lastUpdate: Date
public let status: WireDataModel.ZMConnectionStatus
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
// along with this program. If not, see http://www.gnu.org/licenses/.
//

import CoreData
import WireAPI
Copy link
Contributor Author

@jullianm jullianm Nov 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This local store still uses a WireAPI.Conversation object I didn't do it in this PR because we still have conversation events to tackle but eventually it will be mapped to a domain model as well.

We also have a SystemMessage model and message creation related methods in this repo this will be removed (in another PR) now that we have a Message dedicated component (Repository and local store)

import WireDataModel

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
//
// Wire
// Copyright (C) 2024 Wire Swiss GmbH
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see http://www.gnu.org/licenses/.
//

import WireDataModel

// sourcery: AutoMockable
public protocol ConversationLabelsLocalStoreProtocol {

/// Save label and related conversations objects to local storage.
/// - Parameter conversationLabel: conversation label from WireAPI

func storeLabel(
_ conversationLabel: ConversationLabelInfo
) async throws

/// Delete old `folder` labels and related conversations objects from local storage.
/// - Parameter excludedLabels: remote labels that should be excluded from deletion.
/// - Only old labels of type `folder` are deleted, `favorite` labels always remain in the local storage.

func deleteOldLabelsLocally(
excludedLabels: [ConversationLabelInfo]
) async throws
}

public final class ConversationLabelsLocalStore: ConversationLabelsLocalStoreProtocol {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This store was created. Storage operations were moved out from the related repository.


// MARK: - Error

enum Error: Swift.Error {
case failedToStoreLabelLocally(UUID)
}

// MARK: - Properties

private let context: NSManagedObjectContext
private let logger = WireLogger(tag: "conversation-labels")

// MARK: - Object lifecycle

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

// MARK: - Public

/// Save label and related conversations objects to local storage.
/// - Parameter conversationLabel: conversation label from WireAPI

public func storeLabel(
_ conversationLabel: ConversationLabelInfo
) async throws {
try await context.perform { [context] in
var created = false
let label: Label? = if conversationLabel.type == Label.Kind.favorite.rawValue {
Label.fetchFavoriteLabel(in: context)
} else {
Label.fetchOrCreate(remoteIdentifier: conversationLabel.id, create: true, in: context, created: &created)
}

guard let label else {
throw Error.failedToStoreLabelLocally(conversationLabel.id)
}

label.name = conversationLabel.name
label.kind = Label.Kind(rawValue: conversationLabel.type) ?? .folder

let conversations = ZMConversation.fetchObjects(
withRemoteIdentifiers: Set(conversationLabel.conversationIDs),
in: context
) as? Set<ZMConversation> ?? Set()

label.conversations = conversations
label.modifiedKeys = nil

do {
try context.save()
} catch {
throw Error.failedToStoreLabelLocally(conversationLabel.id)
}
}
}

public func deleteOldLabelsLocally(
excludedLabels: [ConversationLabelInfo]
) async throws {
try await context.perform { [self] in
let uuids = excludedLabels.map { $0.id.uuidData as NSData }
let predicateFormat = "type == \(Label.Kind.folder.rawValue) AND NOT remoteIdentifier_data IN %@"

let predicate = NSPredicate(
format: predicateFormat,
uuids as CVarArg
)

let fetchRequest: NSFetchRequest<NSFetchRequestResult>
fetchRequest = NSFetchRequest(entityName: Label.entityName())
fetchRequest.predicate = predicate

/// Since batch operations bypass the context processing,
/// relationships rules are often ignored (e.g delete rule)
/// Nevertheless, CoreData automatically handles two specific scenarios:
/// `Cascade` delete rule and `Nullify` delete rule on an optional property
/// Since `conversations` is nullify and optional, we can safely perform a batch delete.

let deleteRequest = NSBatchDeleteRequest(
fetchRequest: fetchRequest
)

deleteRequest.resultType = .resultTypeObjectIDs

do {
let batchDelete = try context.execute(deleteRequest) as? NSBatchDeleteResult

guard let deleteResult = batchDelete?.result as? [NSManagedObjectID] else {
throw ConversationLabelsRepositoryError.failedToDeleteStoredLabels
}

let deletedObjects: [AnyHashable: Any] = [
NSDeletedObjectsKey: deleteResult
]

/// Since `NSBatchDeleteRequest` only operates at the SQL level (in the persistent store itself),
/// we need to manually update our in-memory objects after execution.

NSManagedObjectContext.mergeChanges(
fromRemoteContextSave: deletedObjects,
into: [context]
)

} catch {
logger.error("Failed to delete old labels: \(error)")
throw error
}
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
//
// Wire
// Copyright (C) 2024 Wire Swiss GmbH
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see http://www.gnu.org/licenses/.
//

import WireAPI

extension WireAPI.ConversationLabel {

func toDomainModel() -> ConversationLabelInfo {
.init(
id: id,
name: name,
type: type,
conversationIDs: conversationIDs
)
}

}
Loading
Loading