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

Create DataStoreQuery type to perform query #23867

Draft
wants to merge 3 commits into
base: trunk
Choose a base branch
from
Draft
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
14 changes: 6 additions & 8 deletions WordPress/Classes/Services/UserService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,11 @@ import WordPressUI

/// UserService is responsible for fetching user acounts via the .org REST API – it's the replacement for `UsersService` (the XMLRPC-based approach)
///
actor UserService: UserServiceProtocol {
actor UserService: UserServiceProtocol, UserDataStoreProvider {
private let client: WordPressClient

private let _dataStore: InMemoryUserDataStore = .init()
var dataStore: any UserDataStore {
_dataStore
}
private let _dataStore: InMemoryDataStore<DisplayUser> = .init()
var userDataStore: any DataStore<DisplayUser> { _dataStore }

private var _currentUser: UserWithEditContext?
private var currentUser: UserWithEditContext? {
Expand All @@ -32,10 +30,10 @@ actor UserService: UserServiceProtocol {
var started = false
for try await users in sequence {
if !started {
try await dataStore.delete(query: .all)
try await _dataStore.delete(query: .all)
}

try await dataStore.store(users.compactMap { DisplayUser(user: $0) })
try await _dataStore.store(users.compactMap { DisplayUser(user: $0) })

started = true
}
Expand All @@ -53,7 +51,7 @@ actor UserService: UserServiceProtocol {

// Remove the deleted user from the cached users list.
if result.deleted {
try await dataStore.delete(query: .id([id]))
try await _dataStore.delete(query: .identifier(in: [id]))
}
}

Expand Down
31 changes: 0 additions & 31 deletions WordPress/Classes/Users/InMemoryUserDataStore.swift
Original file line number Diff line number Diff line change
@@ -1,33 +1,2 @@
import Foundation
import Combine

public actor InMemoryUserDataStore: UserDataStore, InMemoryDataStore {
public typealias T = DisplayUser

public var storage: [T.ID: T] = [:]
public let updates: PassthroughSubject<Set<T.ID>, Never> = .init()

deinit {
updates.send(completion: .finished)
}

public func list(query: Query) throws -> [T] {
switch query {
case .all:
return Array(storage.values)
case let .id(ids):
return storage.reduce(into: []) {
if ids.contains($1.key) {
$0.append($1.value)
}
}
case let .search(keyword):
let theKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
if theKeyword.isEmpty {
return Array(storage.values)
} else {
return storage.values.search(theKeyword, using: \.searchString)
}
}
}
}
47 changes: 29 additions & 18 deletions WordPress/Classes/Users/UserProvider.swift
Original file line number Diff line number Diff line change
@@ -1,28 +1,41 @@
import Foundation
import Combine

public protocol UserDataStore: DataStore where T == DisplayUser, Query == UserDataStoreQuery {
}

public enum UserDataStoreQuery: Equatable {
case all
case id(Set<DisplayUser.ID>)
case search(String)
}

public protocol UserServiceProtocol: Actor {
var dataStore: any UserDataStore { get }

func fetchUsers() async throws

func isCurrentUserCapableOf(_ capability: String) async -> Bool

func setNewPassword(id: Int32, newPassword: String) async throws

func deleteUser(id: Int32, reassigningPostsTo newUserId: Int32) async throws

func allUsers() async throws -> [DisplayUser]

func streamSearchResult(input: String) async -> AsyncStream<Result<[DisplayUser], Error>>

func streamAll() async -> AsyncStream<Result<[DisplayUser], Error>>
}

protocol UserDataStoreProvider: Actor {
var userDataStore: any DataStore<DisplayUser> { get }
}

actor MockUserProvider: UserServiceProtocol {
extension UserServiceProtocol where Self: UserDataStoreProvider {
func allUsers() async throws -> [DisplayUser] {
try await userDataStore.list(query: .all)
}

func streamSearchResult(input: String) async -> AsyncStream<Result<[DisplayUser], Error>> {
await userDataStore.listStream(query: .search(input, transform: \.searchString))
}

func streamAll() async -> AsyncStream<Result<[DisplayUser], Error>> {
await userDataStore.listStream(query: .all)
}
}

actor MockUserProvider: UserServiceProtocol, UserDataStoreProvider {

enum Scenario {
case infinitLoading
Expand All @@ -32,10 +45,8 @@ actor MockUserProvider: UserServiceProtocol {

var scenario: Scenario

private let _dataStore: InMemoryUserDataStore = .init()
var dataStore: any UserDataStore {
_dataStore
}
private let _dataStore: InMemoryDataStore<DisplayUser> = .init()
var userDataStore: any DataStore<DisplayUser> { _dataStore }

nonisolated let usersUpdates: AsyncStream<[DisplayUser]>
private let usersUpdatesContinuation: AsyncStream<[DisplayUser]>.Continuation
Expand All @@ -62,8 +73,8 @@ actor MockUserProvider: UserServiceProtocol {
let dummyDataUrl = URL(string: "https://my.api.mockaroo.com/users.json?key=067c9730")!
let response = try await URLSession.shared.data(from: dummyDataUrl)
let users = try JSONDecoder().decode([DisplayUser].self, from: response.0)
try await _dataStore.delete(query: .all)
try await _dataStore.store(users)
try await userDataStore.delete(query: .all)
try await userDataStore.store(users)
case .error:
throw URLError(.timedOut)
}
Expand Down
3 changes: 1 addition & 2 deletions WordPress/Classes/Users/ViewModel/UserDeleteViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,7 @@ public class UserDeleteViewModel: ObservableObject {

func fetchOtherUsers() async {
do {
let users = try await userService.dataStore.list(query: .all)
self.otherUsers = users
self.otherUsers = try await userService.allUsers()
.filter { $0.id != self.user.id } // Don't allow re-assigning to yourself
.sorted(using: KeyPathComparator(\.username))
} catch {
Expand Down
19 changes: 16 additions & 3 deletions WordPress/Classes/Users/ViewModel/UserListViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import WordPressShared
@MainActor
class UserListViewModel: ObservableObject {

enum Mode: Equatable {
case allUsers
case search(String)
}

enum RoleSection: Hashable, Comparable {
case me
case role(String)
Expand Down Expand Up @@ -46,7 +51,7 @@ class UserListViewModel: ObservableObject {
private var initialLoad = false

@Published
private(set) var query: UserDataStoreQuery = .all
private(set) var mode: Mode = .allUsers

@Published
private(set) var sortedUsers: [Section] = []
Expand All @@ -57,7 +62,8 @@ class UserListViewModel: ObservableObject {
@Published
var searchTerm: String = "" {
didSet {
self.query = .search(searchTerm)
let keyword = searchTerm.trimmingCharacters(in: .whitespacesAndNewlines)
self.mode = keyword.isEmpty ? .allUsers : .search(keyword)
}
}

Expand All @@ -82,7 +88,14 @@ class UserListViewModel: ObservableObject {
}

func performQuery() async {
let usersUpdates = await userService.dataStore.listStream(query: query)
let usersUpdates: AsyncStream<Result<[DisplayUser], Error>>
switch mode {
case .allUsers:
usersUpdates = await userService.streamAll()
case let .search(keyword):
usersUpdates = await userService.streamSearchResult(input: keyword)
}

for await users in usersUpdates {
switch users {
case let .success(users):
Expand Down
2 changes: 1 addition & 1 deletion WordPress/Classes/Users/Views/UserListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ public struct UserListView: View {
}
}
}
.task(id: viewModel.query) {
.task(id: viewModel.mode) {
await viewModel.performQuery()
}
.task { await viewModel.onAppear() }
Expand Down
56 changes: 50 additions & 6 deletions WordPress/Classes/Utility/DataStore/DataStore.swift
Original file line number Diff line number Diff line change
@@ -1,16 +1,60 @@
import Foundation
import WordPressShared

/// An abstraction of local data storage, with CRUD operations.
public protocol DataStore: Actor {
associatedtype T: Identifiable & Sendable
associatedtype Query
public protocol DataStore<T>: Actor {
associatedtype T: Identifiable & Sendable where T.ID: Sendable

func list(query: Query) async throws -> [T]
func delete(query: Query) async throws
func list(query: DataStoreQuery<T>) async throws -> [T]
func delete(query: DataStoreQuery<T>) async throws
func store(_ data: [T]) async throws

/// An AsyncStream that produces up-to-date results for the given query.
///
/// The `AsyncStream` should not finish as long as the `DataStore` remains alive and valid.
func listStream(query: Query) -> AsyncStream<Result<[T], Error>>
func listStream(query: DataStoreQuery<T>) -> AsyncStream<Result<[T], Error>>
}

public struct DataStoreQuery<T: Identifiable & Sendable>: Sendable where T.ID: Sendable {
public indirect enum Filter: Sendable {
case identifier(Set<T.ID>)
case closure(@Sendable (T) -> Bool)
case and(lhs: Filter, rhs: Filter)
case or(lhs: Filter, rhs: Filter)

func evaluate(on value: T) -> Bool {
switch self {
case let .identifier(ids):
ids.contains(value.id)
case let .closure(closure):
closure(value)
case let .and(lhs, rhs):
lhs.evaluate(on: value) && rhs.evaluate(on: value)
case let .or(lhs, rhs):
lhs.evaluate(on: value) || rhs.evaluate(on: value)
}
}
}

var filter: Filter?
var sortBy: [SortDescriptor<T>] = []

public func perform(on data: any Sequence<T>) -> [T] {
var result: any Sequence<T> = data
if let filter {
result = result.filter { filter.evaluate(on: $0) }
}
return result.sorted(using: sortBy)
}

public static var all: Self { .init() }

public static func identifier(in ids: Set<T.ID>) -> Self {
.init(filter: .identifier(ids))
}

public static func search(_ query: String, minScore: Double = 0.7, transform: @escaping (T) -> String) -> Self {
let term = StringRankedSearch(searchTerm: query)
return .init(filter: .closure { term.score(for: transform($0)) >= minScore })
}
}
24 changes: 15 additions & 9 deletions WordPress/Classes/Utility/DataStore/InMemoryDataStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,24 @@ import Foundation
import Combine

/// A `DataStore` type that stores data in memory.
public protocol InMemoryDataStore: DataStore {
public actor InMemoryDataStore<T: Identifiable & Sendable>: DataStore where T.ID: Sendable {
/// A `Dictionary` to store the data in memory.
var storage: [T.ID: T] { get set }
var storage: [T.ID: T] = [:]

/// A publisher for sending and subscribing data changes.
///
/// The publisher emits events when data changes, with identifiers of changed models.
///
/// The publisher does not complete as long as the `InMemoryDataStore` remains alive and valid.
var updates: PassthroughSubject<Set<T.ID>, Never> { get }
}
let updates: PassthroughSubject<Set<T.ID>, Never> = .init()

deinit {
updates.send(completion: .finished)
}

public extension InMemoryDataStore {
func delete(query: Query) async throws {
public func delete(query: DataStoreQuery<T>) async throws {
var updated = Set<T.ID>()
let result = try await list(query: query)
let result = try list(query: query)
result.forEach {
if storage.removeValue(forKey: $0.id) != nil {
updated.insert($0.id)
Expand All @@ -29,7 +31,7 @@ public extension InMemoryDataStore {
}
}

func store(_ data: [T]) async throws {
public func store(_ data: [T]) async throws {
var updated = Set<T.ID>()
data.forEach {
updated.insert($0.id)
Expand All @@ -41,7 +43,11 @@ public extension InMemoryDataStore {
}
}

func listStream(query: Query) -> AsyncStream<Result<[T], Error>> {
public func list(query: DataStoreQuery<T>) throws -> [T] {
query.perform(on: storage.values)
}

public func listStream(query: DataStoreQuery<T>) -> AsyncStream<Result<[T], Error>> {
let stream = AsyncStream<Result<[T], Error>>.makeStream()

let updatingTask = Task { [weak self] in
Expand Down
8 changes: 4 additions & 4 deletions WordPress/WordPressTest/DataStoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ struct InMemoryDataStoreTests {

@Test
func testUpdatesAfterCreation() async {
let store: InMemoryUserDataStore = InMemoryUserDataStore()
let store = InMemoryDataStore<DisplayUser>()
let stream = await store.listStream(query: .all)

await confirmation("The stream produces an update") { confirmation in
Expand All @@ -20,7 +20,7 @@ struct InMemoryDataStoreTests {

@Test
func testUpdatesAfterStore() async {
let store: InMemoryUserDataStore = InMemoryUserDataStore()
let store = InMemoryDataStore<DisplayUser>()
let stream = await store.listStream(query: .all)

Task.detached {
Expand All @@ -37,7 +37,7 @@ struct InMemoryDataStoreTests {

@Test
func testUpdatesAfterDelete() async throws {
let store: InMemoryUserDataStore = InMemoryUserDataStore()
let store = InMemoryDataStore<DisplayUser>()
try await store.store([.MockUser])

let stream = await store.listStream(query: .all)
Expand All @@ -56,7 +56,7 @@ struct InMemoryDataStoreTests {

@Test
func testStreamTerminates() async {
var store: InMemoryUserDataStore? = InMemoryUserDataStore()
var store: InMemoryDataStore<DisplayUser>? = .init()
let stream = await store!.listStream(query: .all)

Task.detached {
Expand Down
Loading