diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ff8d05161a..b03037de3c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). # Upcoming +## StreamChat +### ✅ Added +- Add `ChannelListQuery(predefinedFilter:filterValues:sortValues:)` for creating channel list queries with predefined filters [#4113](https://github.com/GetStream/stream-chat-swift/pull/4113) + ## StreamChatUI ### 🐞 Fixed - Fix attachment upload overlay text and action buttons being illegible in dark mode [#4111](https://github.com/GetStream/stream-chat-swift/pull/4111) diff --git a/DemoApp/StreamChat/Components/DemoChatChannelListVC.swift b/DemoApp/StreamChat/Components/DemoChatChannelListVC.swift index 58bac6f0d37..7dbf91af8a8 100644 --- a/DemoApp/StreamChat/Components/DemoChatChannelListVC.swift +++ b/DemoApp/StreamChat/Components/DemoChatChannelListVC.swift @@ -110,6 +110,17 @@ final class DemoChatChannelListVC: ChatChannelListVC { lazy var premiumTaggedChannelsQuery: ChannelListQuery = .init(filter: .in(.filterTags, values: ["premium"])) + lazy var predefinedMessagingChannelsQuery: ChannelListQuery = .init( + predefinedFilter: "user_per_channel_type_channels", + filterValues: ["channel_type": .string(ChannelType.messaging.rawValue), "user_id": .string(currentUserId)], + sortValues: nil + ) + + lazy var predefinedLivestreamChannelsQuery: ChannelListQuery = .init( + predefinedFilter: "user_per_channel_type_channels", + filterValues: ["channel_type": .string(ChannelType.livestream.rawValue), "user_id": .string(currentUserId)], + sortValues: nil + ) lazy var livestreamChannelsQuery: ChannelListQuery = .init(filter: .equal(.type, to: .livestream)) var demoRouter: DemoChatChannelListRouter? { @@ -270,6 +281,23 @@ final class DemoChatChannelListVC: ChatChannelListVC { } ) + let predefinedMessagingChannelsAction = UIAlertAction( + title: "Predefined: messaging", + style: .default, + handler: { [weak self] _ in + self?.title = "Predefined: messaging" + self?.setPredefinedMessagingChannelsQuery() + } + ) + + let predefinedLivestreamChannelsAction = UIAlertAction( + title: "Predefined: livestream", + style: .default, + handler: { [weak self] _ in + self?.title = "Predefined: livestream" + self?.setPredefinedLivestreamChannelsQuery() + } + ) let livestreamChannelsAction = UIAlertAction( title: "Livestream Channels", style: .default @@ -294,6 +322,8 @@ final class DemoChatChannelListVC: ChatChannelListVC { equalMembersAction, channelRoleChannelsAction, taggedChannelsAction, + predefinedMessagingChannelsAction, + predefinedLivestreamChannelsAction, livestreamChannelsAction ].sorted(by: { $0.title ?? "" < $1.title ?? "" }), preferredStyle: .actionSheet, @@ -355,6 +385,14 @@ final class DemoChatChannelListVC: ChatChannelListVC { replaceQuery(premiumTaggedChannelsQuery) } + func setPredefinedMessagingChannelsQuery() { + replaceQuery(predefinedMessagingChannelsQuery) + } + + func setPredefinedLivestreamChannelsQuery() { + replaceQuery(predefinedLivestreamChannelsQuery) + } + func setLivestreamChannelsQuery() { replaceQuery(livestreamChannelsQuery) } diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift index 1eeee0927ae..baa123d07db 100644 --- a/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift +++ b/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift @@ -7,24 +7,48 @@ import Foundation struct ChannelListPayload { /// A list of channels response (see `ChannelQuery`). let channels: [ChannelPayload] + + /// Server-resolved predefined filter, present only when the query was made with a predefined filter. + let predefinedFilter: PredefinedFilterPayload? + + init(channels: [ChannelPayload], predefinedFilter: PredefinedFilterPayload? = nil) { + self.channels = channels + self.predefinedFilter = predefinedFilter + } } extension ChannelListPayload: Decodable { enum CodingKeys: String, CodingKey { case channels + case predefinedFilter = "predefined_filter" } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let channels = try container .decodeArrayIgnoringFailures([ChannelPayload].self, forKey: .channels) + let predefinedFilter = try container + .decodeIfPresent(PredefinedFilterPayload.self, forKey: .predefinedFilter) self.init( - channels: channels + channels: channels, + predefinedFilter: predefinedFilter ) } } +final class PredefinedFilterPayload: Decodable, Sendable { + let name: String + let filter: [String: RawJSON] + let sort: [[String: RawJSON]] + + init(name: String, filter: [String: RawJSON], sort: [[String: RawJSON]]) { + self.name = name + self.filter = filter + self.sort = sort + } +} + struct ChannelPayload { let channel: ChannelDetailPayload diff --git a/Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift b/Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift index b7b0f206eef..a1d5207b960 100644 --- a/Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift +++ b/Sources/StreamChat/Controllers/ChannelListController/ChannelListController.swift @@ -35,7 +35,7 @@ extension ChatClient { /// - Note: For an async-await alternative of the `ChatChannelListController`, please check ``ChannelList`` in the async-await supported [state layer](https://getstream.io/chat/docs/sdk/ios/client/state-layer/state-layer-overview/). public class ChatChannelListController: DataController, DelegateCallable, DataStoreProvider, @unchecked Sendable { /// The query specifying and filtering the list of channels. - public let query: ChannelListQuery + public internal(set) var query: ChannelListQuery /// The `ChatClient` instance this controller belongs to. public let client: ChatClient @@ -51,11 +51,7 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt } /// The worker used to fetch the remote data and communicate with servers. - private lazy var worker: ChannelListUpdater = self.environment - .channelQueryUpdaterBuilder( - client.databaseContainer, - client.apiClient - ) + private let worker: ChannelListUpdater /// The worker used to update current user data. private lazy var currentUserUpdater: CurrentUserUpdater = self.environment @@ -82,8 +78,15 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt } private(set) lazy var channelListObserver: BackgroundListDatabaseObserver = { + if let updated = worker.loadPredefinedFilter(for: query) { + query = updated + } + return makeChannelListObserver() + }() + + private func makeChannelListObserver() -> BackgroundListDatabaseObserver { let request = ChannelDTO.channelListFetchRequest(query: self.query, chatClientConfig: client.config) - let observer = self.environment.createChannelListDatabaseObserver( + let observer = environment.createChannelListDatabaseObserver( client.databaseContainer, request, { try $0.asModel() }, @@ -101,7 +104,7 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt } } return observer - }() + } var _basePublishers: Any? /// An internal backing object for all publicly available Combine publishers. We use it to simplify the way we expose @@ -117,10 +120,18 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt private let filter: (@Sendable (ChatChannel) -> Bool)? private let environment: Environment - private lazy var channelListLinker: ChannelListLinker = self.environment - .channelListLinkerBuilder( - query, filter, client.config, client.databaseContainer, worker, client.channelWatcherHandler + private var channelListLinker: ChannelListLinker + + private func makeChannelListLinker() -> ChannelListLinker { + environment.channelListLinkerBuilder( + query, + filter, + client.config, + client.databaseContainer, + worker, + client.channelWatcherHandler ) + } /// Creates a new `ChannelListController`. /// @@ -139,6 +150,16 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt self.filter = filter self.environment = environment self.deliveryCriteriaValidator = environment.deliveryCriteriaValidatorBuilder() + let worker = environment.channelQueryUpdaterBuilder(client.databaseContainer, client.apiClient) + self.worker = worker + channelListLinker = environment.channelListLinkerBuilder( + query, + filter, + client.config, + client.databaseContainer, + worker, + client.channelWatcherHandler + ) super.init() } @@ -146,14 +167,11 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt startChannelListObserverIfNeeded() channelListLinker.start(with: client.eventNotificationCenter) client.syncRepository.startTrackingChannelListController(self) - updateChannelList { [weak self] error in - guard let completion else { return } - self?.callback { - completion(error) - } + updateChannelList { result in + self.callback { completion?(result.error) } } } - + // MARK: - Actions /// Loads next channels from backend. @@ -179,9 +197,13 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt updatedQuery.pagination = Pagination(pageSize: limit, offset: channels.count) worker.update(channelListQuery: updatedQuery) { result in switch result { - case let .success(channels): - self.markChannelsAsDeliveredIfNeeded(channels: channels) - self.hasLoadedAllPreviousChannels = channels.count < limit + case let .success(updateResult): + self.markChannelsAsDeliveredIfNeeded(channels: updateResult.channels) + self.hasLoadedAllPreviousChannels = updateResult.channels.count < limit + if let updatedQuery = updateResult.updatedQuery { + self.query = updatedQuery + self.updateChannelListObserver() + } self.callback { completion?(nil) } case let .failure(error): self.callback { completion?(error) } @@ -199,28 +221,34 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt // MARK: - Helpers private func updateChannelList( - _ completion: (@MainActor (_ error: Error?) -> Void)? = nil + _ completion: (@MainActor (Result) -> Void)? = nil ) { let limit = query.pagination.pageSize worker.update( channelListQuery: query ) { [weak self] result in switch result { - case let .success(channels): + case let .success(updateResult): self?.state = .remoteDataFetched - self?.hasLoadedAllPreviousChannels = channels.count < limit - + self?.hasLoadedAllPreviousChannels = updateResult.channels.count < limit + // Mark channels as delivered if synchronization was successful - self?.markChannelsAsDeliveredIfNeeded(channels: channels) + self?.markChannelsAsDeliveredIfNeeded(channels: updateResult.channels) + + // Predefined filters can update local query representation (query gets backend defined filter and sort which must be set to FRC) + if let updatedQuery = updateResult.updatedQuery { + self?.query = updatedQuery + self?.updateChannelListObserver() + } - self?.callback { completion?(nil) } + self?.callback { completion?(.success(updateResult)) } case let .failure(error): self?.state = .remoteDataFetchFailed(ClientError(with: error)) - self?.callback { completion?(error) } + self?.callback { completion?(.failure(error)) } } } } - + /// Marks channels as delivered if they meet the specified criteria. /// - Parameter channels: The channels to evaluate for marking as delivered. private func markChannelsAsDeliveredIfNeeded(channels: [ChatChannel]) { @@ -264,6 +292,18 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt log.error("Failed to perform fetch request with error: \(error). This is an internal error.") } } + + private func updateChannelListObserver() { + channelListObserver = makeChannelListObserver() + channelListLinker = makeChannelListLinker() + channelListLinker.start(with: client.eventNotificationCenter) + do { + try channelListObserver.startObserving() + } catch { + state = .localDataFetchFailed(ClientError(with: error)) + log.error("Failed to update the channel list observer: \(error)") + } + } } extension ChatChannelListController { diff --git a/Sources/StreamChat/Database/DTOs/ChannelDTO.swift b/Sources/StreamChat/Database/DTOs/ChannelDTO.swift index 6b8dbb4c2a1..f4e29ba593f 100644 --- a/Sources/StreamChat/Database/DTOs/ChannelDTO.swift +++ b/Sources/StreamChat/Database/DTOs/ChannelDTO.swift @@ -234,7 +234,7 @@ extension NSManagedObjectContext { // the query won't be saved, which will cause any future // channels to not become linked to this query if let query = query { - _ = saveQuery(query: query) + _ = saveQuery(query: query, predefinedFilter: payload.predefinedFilter) } return payload.channels.compactMapLoggingError { channelPayload in @@ -444,7 +444,7 @@ extension NSManagedObjectContext { } func delete(query: ChannelListQuery) { - guard let dto = channelListQuery(filterHash: query.filter.filterHash) else { return } + guard let dto = channelListQuery(query: query) else { return } delete(dto) } @@ -477,7 +477,7 @@ extension ChannelDTO { request.sortDescriptors = sortDescriptors.isEmpty ? [ChannelListSortingKey.defaultSortDescriptor] : sortDescriptors - let matchingQuery = NSPredicate(format: "ANY queries.filterHash == %@", query.filter.filterHash) + let matchingQuery = NSPredicate(format: "ANY queries.filterHash == %@", query.queryHash) let notDeleted = NSPredicate(format: "deletedAt == nil") var subpredicates: [NSPredicate] = [ diff --git a/Sources/StreamChat/Database/DTOs/ChannelListQueryDTO.swift b/Sources/StreamChat/Database/DTOs/ChannelListQueryDTO.swift index 3163de1868b..f28e825718c 100644 --- a/Sources/StreamChat/Database/DTOs/ChannelListQueryDTO.swift +++ b/Sources/StreamChat/Database/DTOs/ChannelListQueryDTO.swift @@ -12,14 +12,17 @@ class ChannelListQueryDTO: NSManagedObject { /// Serialized `Filter` JSON which can be used in cases the query needs to be repeated, i.e. for newly created channels. @NSManaged var filterJSONData: Data + /// Serialized sort JSON returned by the server for predefined-filter queries. + @NSManaged var sortJSONData: Data? + // MARK: - Relationships @NSManaged var channels: Set - static func load(filterHash: String, context: NSManagedObjectContext) -> ChannelListQueryDTO? { + static func load(query: ChannelListQuery, context: NSManagedObjectContext) -> ChannelListQueryDTO? { load( keyPath: #keyPath(ChannelListQueryDTO.filterHash), - equalTo: filterHash, + equalTo: query.queryHash, context: context ).first as? Self } @@ -35,33 +38,75 @@ class ChannelListQueryDTO: NSManagedObject { } extension NSManagedObjectContext { - func channelListQuery(filterHash: String) -> ChannelListQueryDTO? { - ChannelListQueryDTO.load(filterHash: filterHash, context: self) + func channelListQuery(query: ChannelListQuery) -> ChannelListQueryDTO? { + ChannelListQueryDTO.load(query: query, context: self) } - func saveQuery(query: ChannelListQuery) -> ChannelListQueryDTO { - if let existingDTO = channelListQuery(filterHash: query.filter.filterHash) { - return existingDTO + /// Returns the query with persisted predefined filter/sort applied. + /// `nil` when the input has no `predefinedFilter` or no cached DTO exists. + /// Callers compare `filter`/`sort` against the input (see `ChannelListQuery.isFilterEqual(to:)`) + /// to detect whether the cached resolution actually differs from the current query. + func loadPredefinedFilter(for query: ChannelListQuery) -> ChannelListQuery? { + guard let predefinedFilter = query.predefinedFilter, !predefinedFilter.isEmpty, + let dto = channelListQuery(query: query) else { + return nil } - let request = ChannelListQueryDTO.fetchRequest( - keyPath: #keyPath(ChannelListQueryDTO.filterHash), - equalTo: query.filter.filterHash - ) - let newDTO = NSEntityDescription.insertNewObject(into: self, for: request) - newDTO.filterHash = query.filter.filterHash - - let jsonData: Data + var updated = query do { - jsonData = try JSONEncoder.default.encode(query.filter) + if let filter = try Filter.predefinedFilter(fromJSONData: dto.filterJSONData) { + updated.filter = filter + } } catch { - log.error("Failed encoding query Filter data with error: \(error).") - jsonData = Data() + log.error("Failed decoding predefined filter from persisted data with error: \(error).") + } + if let sortJSONData = dto.sortJSONData { + do { + updated.sort = try [Sorting].predefinedFilterSort(fromJSONData: sortJSONData) + } catch { + log.error("Failed decoding predefined sort from persisted data with error: \(error).") + } + } + return updated + } + + func saveQuery(query: ChannelListQuery, predefinedFilter: PredefinedFilterPayload? = nil) -> ChannelListQueryDTO { + let dto: ChannelListQueryDTO + if let existingDTO = channelListQuery(query: query) { + dto = existingDTO + } else { + let request = ChannelListQueryDTO.fetchRequest( + keyPath: #keyPath(ChannelListQueryDTO.filterHash), + equalTo: query.queryHash + ) + let newDTO = NSEntityDescription.insertNewObject(into: self, for: request) + newDTO.filterHash = query.queryHash + + let jsonData: Data + do { + jsonData = try JSONEncoder.default.encode(query.filter) + } catch { + log.error("Failed encoding query Filter data with error: \(error).") + jsonData = Data() + } + newDTO.filterJSONData = jsonData + dto = newDTO } - newDTO.filterJSONData = jsonData + if let predefinedFilter { + do { + dto.filterJSONData = try JSONEncoder.default.encode(predefinedFilter.filter) + } catch { + log.error("Failed encoding predefined filter from response with error: \(error).") + } + do { + dto.sortJSONData = try JSONEncoder.default.encode(predefinedFilter.sort) + } catch { + log.error("Failed encoding predefined sort from response with error: \(error).") + } + } - return newDTO + return dto } func loadAllChannelListQueries() -> [ChannelListQueryDTO] { diff --git a/Sources/StreamChat/Database/DatabaseSession.swift b/Sources/StreamChat/Database/DatabaseSession.swift index da49deeb19a..de62d3a4875 100644 --- a/Sources/StreamChat/Database/DatabaseSession.swift +++ b/Sources/StreamChat/Database/DatabaseSession.swift @@ -346,16 +346,20 @@ protocol ChannelDatabaseSession { cache: PreWarmedCache? ) throws -> ChannelDTO - /// Loads channel list query with the given filter hash from the database. - /// - Parameter filterHash: The filter hash. - func channelListQuery(filterHash: String) -> ChannelListQueryDTO? + /// Loads the persisted query DTO for the given `ChannelListQuery`. + /// Looks up by the query's `queryHash`. + func channelListQuery(query: ChannelListQuery) -> ChannelListQueryDTO? + + /// Returns the query with persisted predefined filter/sort applied. + /// `nil` when the input has no `predefinedFilter` or no cached DTO exists. + func loadPredefinedFilter(for query: ChannelListQuery) -> ChannelListQuery? /// Loads all channel list queries from the database. /// - Returns: The array of channel list queries. func loadAllChannelListQueries() -> [ChannelListQueryDTO] @discardableResult - func saveQuery(query: ChannelListQuery) -> ChannelListQueryDTO + func saveQuery(query: ChannelListQuery, predefinedFilter: PredefinedFilterPayload?) -> ChannelListQueryDTO /// Fetches `ChannelDTO` with the given `cid` from the database. func channel(cid: ChannelId) -> ChannelDTO? @@ -370,6 +374,13 @@ protocol ChannelDatabaseSession { func deleteDraftMessage(in cid: ChannelId, threadId: MessageId?) } +extension ChannelDatabaseSession { + @discardableResult + func saveQuery(query: ChannelListQuery) -> ChannelListQueryDTO { + saveQuery(query: query, predefinedFilter: nil) + } +} + protocol ChannelReadDatabaseSession { /// Creates a new `ChannelReadDTO` object in the database. Throws an error if the ChannelRead fails to be created. @discardableResult diff --git a/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents b/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents index 8addfeafeb0..bb53823dd93 100644 --- a/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents +++ b/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents @@ -116,6 +116,7 @@ + diff --git a/Sources/StreamChat/Query/ChannelListQuery+PredefinedFilter.swift b/Sources/StreamChat/Query/ChannelListQuery+PredefinedFilter.swift new file mode 100644 index 00000000000..442211e7359 --- /dev/null +++ b/Sources/StreamChat/Query/ChannelListQuery+PredefinedFilter.swift @@ -0,0 +1,67 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Foundation +import StreamCore + +// MARK: - Filter + +extension Filter where Scope == ChannelListFilterScope { + /// Decodes a channel-list filter from persisted JSON and re-attaches Core Data wiring + /// (keyPath, valueMapper, predicateMapper) for every node whose key matches a known + /// `FilterKey`. Unknown keys pass through unchanged. + /// + /// Returns `nil` for empty `data`: `ChannelListQueryDTO.filterJSONData` falls back to + /// empty `Data()` when filter encoding fails, so empty input means "no persisted filter" + /// rather than a decode failure. + static func predefinedFilter(fromJSONData data: Data) throws -> Filter? { + guard !data.isEmpty else { return nil } + let decoded = try JSONDecoder.default.decode(Filter.self, from: data) + return decoded.applyCoreDataFilteringKeys() + } + + /// Walks the filter tree and replaces each leaf with an enriched copy that carries + /// the Core Data wiring for its key. Group operators (`$and` / `$or` / `$nor`) recurse. + private func applyCoreDataFilteringKeys() -> Filter { + if `operator`.isGroupOperator { + guard let children = value as? [Filter] else { + return self + } + return Filter( + operator: `operator`, + key: key, + value: children.map { $0.applyCoreDataFilteringKeys() }, + isCollectionFilter: isCollectionFilter + ) + } + guard let key else { return self } + guard let mapFilter = ChannelListFilterScope.predefinedFilterKeyMapping[key] else { + StreamCore.log.error("Can't apply CoreData keyPath for channel list filtering key '\(key)'.") + return self + } + return mapFilter(self) + } +} + +// MARK: - Sort + +extension Array where Element == Sorting { + /// Decodes a server-resolved sort array (`[{"field": ..., "direction": -1|1, ...}, ...]`). + /// Unknown `field` values are dropped because they cannot map to a typed key. + static func predefinedFilterSort(fromJSONData data: Data) throws -> [Sorting] { + let raw = try JSONDecoder.default.decode([RawSortingItem].self, from: data) + return raw.compactMap { item in + guard let key = ChannelListSortingKey.predefinedSortingKeyMapping[item.field] else { + StreamCore.log.error("Can't apply CoreData keyPath for channel list sorting field '\(item.field)'.") + return nil + } + return Sorting(key: key, isAscending: item.direction == 1) + } + } +} + +private struct RawSortingItem: Decodable { + let field: String + let direction: Int +} diff --git a/Sources/StreamChat/Query/ChannelListQuery.swift b/Sources/StreamChat/Query/ChannelListQuery.swift index fe1f810e1c2..190e2355531 100644 --- a/Sources/StreamChat/Query/ChannelListQuery.swift +++ b/Sources/StreamChat/Query/ChannelListQuery.swift @@ -17,12 +17,15 @@ public struct ChannelListQuery: Encodable, Sendable, LocalConvertibleSortingQuer case pagination case messagesLimit = "message_limit" case membersLimit = "member_limit" + case predefinedFilter = "predefined_filter" + case filterValues = "filter_values" + case sortValues = "sort_values" } /// A filter for the query (see `Filter`). - public let filter: Filter + public internal(set) var filter: Filter /// A sorting for the query (see `Sorting`). - public let sort: [Sorting] + public internal(set) var sort: [Sorting] /// A pagination. public var pagination: Pagination /// A number of messages inside each channel. @@ -31,6 +34,15 @@ public struct ChannelListQuery: Encodable, Sendable, LocalConvertibleSortingQuer public let membersLimit: Int? /// Query options. public var options: QueryOptions = [.watch] + /// The name of a server-side predefined filter to apply to this query. + /// + /// When set, the filter and sort templates configured for the predefined filter on the server + /// are used, and `filter` / `sort` on this query are ignored by the server. + public let predefinedFilter: String? + /// Values substituted into the predefined filter's filter template placeholders. + public let filterValues: [String: RawJSON]? + /// Values substituted into the predefined filter's sort template placeholders. + public let sortValues: [String: RawJSON]? /// Init a channels query. /// - Parameters: @@ -51,14 +63,58 @@ public struct ChannelListQuery: Encodable, Sendable, LocalConvertibleSortingQuer pagination = Pagination(pageSize: pageSize) self.messagesLimit = messagesLimit self.membersLimit = membersLimit + predefinedFilter = nil + filterValues = nil + sortValues = nil + } + + /// Init a channels query that uses a server-side predefined filter. + /// + /// The predefined filter's filter and sort templates (configured server-side) determine the + /// effective filter and sort. Placeholders in those templates are substituted using + /// `filterValues` and `sortValues`. + /// + /// - Parameters: + /// - predefinedFilter: name of the server-side predefined filter to apply. + /// - filterValues: values substituted into the predefined filter's filter template placeholders. + /// - sortValues: values substituted into the predefined filter's sort template placeholders. + /// - pageSize: a page size for pagination. + /// - messagesLimit: a number of messages for the channel to be retrieved. Pass `nil` to omit the request value. + /// - membersLimit: a number of members for the channel to be retrieved. Pass `nil` to omit the request value. + public init( + predefinedFilter: String, + filterValues: [String: RawJSON]? = nil, + sortValues: [String: RawJSON]? = nil, + pageSize: Int = .channelsPageSize, + messagesLimit: Int? = nil, + membersLimit: Int? = nil + ) { + filter = .and([]) + sort = [] + pagination = Pagination(pageSize: pageSize) + self.messagesLimit = messagesLimit + self.membersLimit = membersLimit + self.predefinedFilter = predefinedFilter + self.filterValues = filterValues + self.sortValues = sortValues } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(filter, forKey: .filter) - if !sort.isEmpty { - try container.encode(sort, forKey: .sort) + if let predefinedFilter, !predefinedFilter.isEmpty { + try container.encode(predefinedFilter, forKey: .predefinedFilter) + if let filterValues, !filterValues.isEmpty { + try container.encode(filterValues, forKey: .filterValues) + } + if let sortValues, !sortValues.isEmpty { + try container.encode(sortValues, forKey: .sortValues) + } + } else { + try container.encode(filter, forKey: .filter) + if !sort.isEmpty { + try container.encode(sort, forKey: .sort) + } } if let messagesLimit { @@ -73,6 +129,34 @@ public struct ChannelListQuery: Encodable, Sendable, LocalConvertibleSortingQuer } } +extension ChannelListQuery { + /// A hash that uniquely identifies this query for Core Data persistence. + /// + /// For predefined-filter queries the hash is derived from the predefined filter name plus + /// `filterValues` and `sortValues` (keys sorted to keep the hash deterministic). For + /// traditional queries it falls back to `filter.filterHash`, leaving existing on-disk + /// hashes unchanged. + var queryHash: String { + if let predefinedFilter, !predefinedFilter.isEmpty { + return [ + predefinedFilter, + filterValues.flatMap { $0.isEmpty ? nil : $0.sortedDescription }, + sortValues.flatMap { $0.isEmpty ? nil : $0.sortedDescription } + ] + .compactMap { $0 } + .joined(separator: "-") + } + return filter.filterHash + } + + /// Whether `filter` and `sort` match `other` for purposes of deciding whether the local + /// observer needs to be rebuilt after a predefined-filter resolution. + func isFilterEqual(to other: ChannelListQuery) -> Bool { + filter.filterHash == other.filter.filterHash + && sort.map(\.description) == other.sort.map(\.description) + } +} + extension ChannelListQuery: CustomDebugStringConvertible { public var debugDescription: String { "Filter: \(filter) | Sort: \(sort)" @@ -325,3 +409,73 @@ internal extension FilterKey where Scope == ChannelListFilterScope { ) } } + +// MARK: - Predefined Filter Support + +extension ChannelListFilterScope { + /// Maps a decoded predefined filter leaf to a locally filterable one. + /// + /// Predefined filters are decoded from server-provided JSON, so their leaves only carry the + /// remote key and value. The strongly typed `FilterKey` also stores local-only details, such + /// as key paths and predicate/value mappers, but those details cannot be decoded from JSON. + /// This closure captures one typed key and rebuilds a decoded leaf with that local wiring. + typealias PredefinedFilterMapper = @Sendable (Filter) -> Filter + + /// Registry of every hardcoded channel-list `FilterKey`, keyed by the server-side `rawValue`. + /// + /// The dictionary stores enrichment closures instead of `FilterKey` values because the keys are + /// generic over different `Value` types and cannot be stored together directly. + /// + /// - Important: Always add new filter keys to the map. + static let predefinedFilterKeyMapping: [String: PredefinedFilterMapper] = { + func map( + _ key: FilterKey + ) -> (String, PredefinedFilterMapper) { + ( + key.rawValue, + { filter in + Filter( + operator: filter.operator, + key: filter.key, + value: filter.value, + valueMapper: key.valueMapper, + keyPathString: key.keyPathString, + isCollectionFilter: key.isCollectionFilter, + predicateMapper: key.predicateMapper + ) + } + ) + } + return Dictionary( + uniqueKeysWithValues: [ + map(.archived), + map(.blocked), + map(.channelRole), + map(.cid), + map(.createdAt), + map(.createdBy), + map(.deletedAt), + map(.disabled), + map(.filterTags), + map(.frozen), + map(.hasUnread), + map(.hidden), + map(.id), + map(.imageURL), + map(.invite), + map(.joined), + map(.lastMessageAt), + map(.lastUpdatedAt), + map(.memberCount), + map(.memberName), + map(.members), + map(.muted), + map(.name), + map(.pinned), + map(.team), + map(.type), + map(.updatedAt) + ] + ) + }() +} diff --git a/Sources/StreamChat/Query/Filter.swift b/Sources/StreamChat/Query/Filter.swift index 41d38385cbf..ff7697e25ba 100644 --- a/Sources/StreamChat/Query/Filter.swift +++ b/Sources/StreamChat/Query/Filter.swift @@ -507,35 +507,75 @@ extension Filter: Codable { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: ArbitraryKey.self) - for key in container.allKeys { - if key.stringValue.hasPrefix("$") { - // The right side should be an array of other filters - let filters = try container.decode([Filter].self, forKey: key) - self.init( - operator: key.stringValue, - key: nil, - value: filters, - isCollectionFilter: false - ) - return - - } else { - // The right side should be FilterRightSide - let rightSide = try container.decode(FilterRightSide.self, forKey: key) - self.init( - operator: rightSide.operator, - key: key.stringValue, - value: rightSide.value, - isCollectionFilter: false - ) - return - } + let keys = container.allKeys + + guard let firstKey = keys.first else { + throw DecodingError.dataCorruptedError( + forKey: ArbitraryKey(""), + in: container, + debugDescription: "Filter logic structure is incorrect" + ) } - throw DecodingError.dataCorruptedError( - forKey: container.allKeys.last ?? ArbitraryKey(""), - in: container, - debugDescription: "Filter logic structure is incorrect" + // Multiple keys at one level are implicitly ANDed (Mongo-style), whether each key is a + // field (`{"type": "messaging"}`) or a group operator (`{"$or": [...]}`). This mirrors the + // backend, whose query parser ANDs every top-level key regardless of kind. Keys are sorted + // so the decoded tree is stable regardless of `allKeys` ordering. + if keys.count > 1 { + self.init( + operator: FilterOperator.and.rawValue, + key: nil, + value: try keys + .sorted { $0.stringValue < $1.stringValue } + .map { try Self.decodeNode(in: container, forKey: $0) }, + isCollectionFilter: false + ) + return + } + + self = try Self.decodeNode(in: container, forKey: firstKey) + } + + /// Decodes a single key as either a group-operator node (`$and`/`$or`/`$nor`, whose value is an + /// array of sub-filters) or a leaf (`{field: value}` / `{field: {$op: value}}`). + private static func decodeNode( + in container: KeyedDecodingContainer, + forKey key: ArbitraryKey + ) throws -> Filter { + if key.stringValue.hasPrefix("$") { + // The right side should be an array of other filters + let filters = try container.decode([Filter].self, forKey: key) + return Filter( + operator: key.stringValue, + key: nil, + value: filters, + isCollectionFilter: false + ) + } + return try decodeLeaf(in: container, forKey: key) + } + + private static func decodeLeaf( + in container: KeyedDecodingContainer, + forKey key: ArbitraryKey + ) throws -> Filter { + // Long form: { key: { $op: value } } + if let rightSide = try? container.decode(FilterRightSide.self, forKey: key) { + return Filter( + operator: rightSide.operator, + key: key.stringValue, + value: rightSide.value, + isCollectionFilter: false + ) + } + + // Short form (implicit $eq): { key: value } + let value = try container.decodeFilterValue(forKey: key) + return Filter( + operator: FilterOperator.equal.rawValue, + key: key.stringValue, + value: value, + isCollectionFilter: false ) } } @@ -590,34 +630,37 @@ private struct FilterRightSide: Decodable { } self.operator = container.allKeys.first!.stringValue - var value: FilterValue? - - if let intValue = try? container.decode(Int.self, forKey: key) { - value = intValue - } else if let doubleValue = try? container.decode(Double.self, forKey: key) { - value = doubleValue - } else if let dateValue = try? container.decode(Date.self, forKey: key) { - value = dateValue - } else if let stringValue = try? container.decode(String.self, forKey: key) { - value = stringValue - } else if let boolValue = try? container.decode(Bool.self, forKey: key) { - value = boolValue - } else if let stringArray = try? container.decode([String].self, forKey: key) { - value = stringArray - } else if let intArray = try? container.decode([Int].self, forKey: key) { - value = intArray - } else if let doubleArray = try? container.decode([Double].self, forKey: key) { - value = doubleArray - } + self.value = try container.decodeFilterValue(forKey: key) + } +} - if let value = value { - self.value = value - } else { - throw DecodingError.dataCorruptedError( - forKey: key, - in: container, - debugDescription: "The data can't be decoded as `FilterValue`." - ) +private extension KeyedDecodingContainer { + /// Decodes a scalar (or homogeneous array) JSON value as `FilterValue`. + func decodeFilterValue(forKey key: Key) throws -> FilterValue { + if (try? decodeNil(forKey: key)) == true { + // `Optional` is the only `FilterValue`-conforming optional (see line 86). + return TeamId?.none + } else if let intValue = try? decode(Int.self, forKey: key) { + return intValue + } else if let doubleValue = try? decode(Double.self, forKey: key) { + return doubleValue + } else if let dateValue = try? decode(Date.self, forKey: key) { + return dateValue + } else if let stringValue = try? decode(String.self, forKey: key) { + return stringValue + } else if let boolValue = try? decode(Bool.self, forKey: key) { + return boolValue + } else if let stringArray = try? decode([String].self, forKey: key) { + return stringArray + } else if let intArray = try? decode([Int].self, forKey: key) { + return intArray + } else if let doubleArray = try? decode([Double].self, forKey: key) { + return doubleArray } + throw DecodingError.dataCorruptedError( + forKey: key, + in: self, + debugDescription: "The data can't be decoded as `FilterValue`." + ) } } diff --git a/Sources/StreamChat/Query/Sorting/ChannelListSortingKey.swift b/Sources/StreamChat/Query/Sorting/ChannelListSortingKey.swift index 837396652b9..2821a51ebba 100644 --- a/Sources/StreamChat/Query/Sorting/ChannelListSortingKey.swift +++ b/Sources/StreamChat/Query/Sorting/ChannelListSortingKey.swift @@ -78,6 +78,28 @@ extension ChannelListSortingKey { } extension ChannelListSortingKey { + /// Registry of every hardcoded channel-list sorting key, keyed by the server-side `remoteKey`. + /// + /// - Important: Always add new sorting keys to the map. + static let predefinedSortingKeyMapping: [String: Self] = { + func map(_ key: Self) -> (String, Self) { + (key.remoteKey, key) + } + return Dictionary( + uniqueKeysWithValues: [ + map(.cid), + map(.createdAt), + map(.default), + map(.hasUnread), + map(.lastMessageAt), + map(.memberCount), + map(.pinnedAt), + map(.unreadCount), + map(.updatedAt) + ] + ) + }() + static var defaultSortDescriptor: NSSortDescriptor { let dateKeyPath: KeyPath = \ChannelDTO.defaultSortingAt return .init(keyPath: dateKeyPath, ascending: false) diff --git a/Sources/StreamChat/Repositories/SyncOperations.swift b/Sources/StreamChat/Repositories/SyncOperations.swift index 78236cc9695..5957bf8a2bd 100644 --- a/Sources/StreamChat/Repositories/SyncOperations.swift +++ b/Sources/StreamChat/Repositories/SyncOperations.swift @@ -101,11 +101,11 @@ final class RefreshChannelListOperation: AsyncOperation, @unchecked Sendable { Task { do { let channelIds = try await channelList.refreshLoadedChannels() - log.debug("Synced \(channelIds.count) channels in a channel list (\(channelList.query.filter)", subsystems: .offlineSupport) + log.debug("Synced \(channelIds.count) channels in a channel list", subsystems: .offlineSupport) context.synchedChannelIds.formUnion(channelIds) done(.continue) } catch { - log.error("Failed refreshing channel list (\(channelList.query.filter) with error \(error)", subsystems: .offlineSupport) + log.error("Failed refreshing channel list with error \(error)", subsystems: .offlineSupport) done(.retry) } } diff --git a/Sources/StreamChat/StateLayer/ChannelList.swift b/Sources/StreamChat/StateLayer/ChannelList.swift index 7583c882fb8..c40f90e3d11 100644 --- a/Sources/StreamChat/StateLayer/ChannelList.swift +++ b/Sources/StreamChat/StateLayer/ChannelList.swift @@ -9,7 +9,6 @@ public class ChannelList: @unchecked Sendable { private let channelListUpdater: ChannelListUpdater private let client: ChatClient @MainActor private var stateBuilder: StateBuilder - let query: ChannelListQuery init( query: ChannelListQuery, @@ -18,7 +17,6 @@ public class ChannelList: @unchecked Sendable { environment: Environment = .init() ) { self.client = client - self.query = query let channelListUpdater = environment.channelListUpdater( client.databaseContainer, client.apiClient @@ -48,11 +46,11 @@ public class ChannelList: @unchecked Sendable { /// /// - Throws: An error while communicating with the Stream API. public func get() async throws { - let pagination = Pagination(pageSize: query.pagination.pageSize) + let pagination = Pagination(pageSize: await state.query.pagination.pageSize) try await loadChannels(with: pagination) client.syncRepository.startTrackingChannelList(self) } - + // MARK: - Channel List Pagination /// Loads channels for the specified pagination parameters and updates ``ChannelListState/channels``. @@ -64,7 +62,13 @@ public class ChannelList: @unchecked Sendable { /// - Throws: An error while communicating with the Stream API. /// - Returns: An array of channels for the pagination. @discardableResult public func loadChannels(with pagination: Pagination) async throws -> [ChatChannel] { - try await channelListUpdater.loadChannels(query: query, pagination: pagination) + var query = await state.query + query.pagination = pagination + let result = try await channelListUpdater.update(channelListQuery: query) + if let updatedQuery = result.updatedQuery { + await state.setQuery(updatedQuery) + } + return result.channels } /// Loads more channels and updates ``ChannelListState/channels``. @@ -74,19 +78,17 @@ public class ChannelList: @unchecked Sendable { /// - Throws: An error while communicating with the Stream API. /// - Returns: An array of loaded channels. @discardableResult public func loadMoreChannels(limit: Int? = nil) async throws -> [ChatChannel] { - let limit = limit ?? query.pagination.pageSize + let pageSize = await state.query.pagination.pageSize + let limit = limit ?? pageSize let count = await state.channels.count - return try await channelListUpdater.loadNextChannels( - query: query, - limit: limit, - loadedChannelsCount: count - ) + return try await loadChannels(with: Pagination(pageSize: limit, offset: count)) } // MARK: - Internal func refreshLoadedChannels() async throws -> Set { let count = await state.channels.count + let query = await state.query return try await channelListUpdater.refreshLoadedChannels(for: query, channelCount: count) } } diff --git a/Sources/StreamChat/StateLayer/ChannelListState+Observer.swift b/Sources/StreamChat/StateLayer/ChannelListState+Observer.swift index f02c61fc9c9..25d187e788b 100644 --- a/Sources/StreamChat/StateLayer/ChannelListState+Observer.swift +++ b/Sources/StreamChat/StateLayer/ChannelListState+Observer.swift @@ -6,15 +6,17 @@ import Foundation extension ChannelListState { final class Observer { - private let channelListObserver: StateLayerDatabaseObserver + private var channelListObserver: StateLayerDatabaseObserver private let clientConfig: ChatClientConfig - private let channelListLinker: ChannelListLinker + private var channelListLinker: ChannelListLinker private let channelListUpdater: ChannelListUpdater private let database: DatabaseContainer - private let dynamicFilter: ((ChatChannel) -> Bool)? private let eventNotificationCenter: EventNotificationCenter - private let query: ChannelListQuery - + private let dynamicFilter: (@Sendable (ChatChannel) -> Bool)? + private let channelWatcherHandler: ChannelWatcherHandling + private var query: ChannelListQuery + private var channelsDidChange: (@Sendable @MainActor ([ChatChannel]) async -> Void)? + init( query: ChannelListQuery, dynamicFilter: (@Sendable (ChatChannel) -> Bool)?, @@ -27,35 +29,32 @@ extension ChannelListState { self.clientConfig = clientConfig self.channelListUpdater = channelListUpdater self.database = database - self.dynamicFilter = dynamicFilter self.query = query self.eventNotificationCenter = eventNotificationCenter - - channelListObserver = StateLayerDatabaseObserver( + self.dynamicFilter = dynamicFilter + self.channelWatcherHandler = channelWatcherHandler + + channelListObserver = Self.makeChannelListObserver( + for: query, database: database, - fetchRequest: ChannelDTO.channelListFetchRequest( - query: query, - chatClientConfig: clientConfig - ), - itemCreator: { try $0.asModel() }, - itemReuseKeyPaths: (\ChatChannel.cid.rawValue, \ChannelDTO.cid), - runtimeSorting: query.runtimeSortingValues + clientConfig: clientConfig ) - channelListLinker = ChannelListLinker( - query: query, - filter: dynamicFilter, + channelListLinker = Self.makeChannelListLinker( + for: query, + dynamicFilter: dynamicFilter, clientConfig: clientConfig, - databaseContainer: database, - worker: channelListUpdater, + channelListUpdater: channelListUpdater, + database: database, channelWatcherHandler: channelWatcherHandler ) } - + struct Handlers { let channelsDidChange: @Sendable @MainActor ([ChatChannel]) async -> Void } - + func start(with handlers: Handlers) -> [ChatChannel] { + channelsDidChange = handlers.channelsDidChange do { channelListLinker.start(with: eventNotificationCenter) return try channelListObserver.startObserving(didChange: handlers.channelsDidChange) @@ -64,5 +63,65 @@ extension ChannelListState { return [] } } + + func reload(with newQuery: ChannelListQuery) -> [ChatChannel] { + query = newQuery + channelListObserver = Self.makeChannelListObserver( + for: newQuery, + database: database, + clientConfig: clientConfig + ) + channelListLinker = Self.makeChannelListLinker( + for: newQuery, + dynamicFilter: dynamicFilter, + clientConfig: clientConfig, + channelListUpdater: channelListUpdater, + database: database, + channelWatcherHandler: channelWatcherHandler + ) + guard let channelsDidChange else { return [] } + channelListLinker.start(with: eventNotificationCenter) + do { + return try channelListObserver.startObserving(didChange: channelsDidChange) + } catch { + log.error("Failed to restart the channel list observer after reload for query: \(newQuery)") + return [] + } + } + + private static func makeChannelListObserver( + for query: ChannelListQuery, + database: DatabaseContainer, + clientConfig: ChatClientConfig + ) -> StateLayerDatabaseObserver { + StateLayerDatabaseObserver( + database: database, + fetchRequest: ChannelDTO.channelListFetchRequest( + query: query, + chatClientConfig: clientConfig + ), + itemCreator: { try $0.asModel() }, + itemReuseKeyPaths: (\ChatChannel.cid.rawValue, \ChannelDTO.cid), + runtimeSorting: query.runtimeSortingValues + ) + } + + private static func makeChannelListLinker( + for query: ChannelListQuery, + dynamicFilter: (@Sendable (ChatChannel) -> Bool)?, + clientConfig: ChatClientConfig, + channelListUpdater: ChannelListUpdater, + database: DatabaseContainer, + channelWatcherHandler: ChannelWatcherHandling + ) -> ChannelListLinker { + ChannelListLinker( + query: query, + filter: dynamicFilter, + clientConfig: clientConfig, + databaseContainer: database, + worker: channelListUpdater, + channelWatcherHandler: channelWatcherHandler + ) + } } } diff --git a/Sources/StreamChat/StateLayer/ChannelListState.swift b/Sources/StreamChat/StateLayer/ChannelListState.swift index 5d1dd4e917d..0ea3143460b 100644 --- a/Sources/StreamChat/StateLayer/ChannelListState.swift +++ b/Sources/StreamChat/StateLayer/ChannelListState.swift @@ -8,7 +8,7 @@ import Foundation /// Represents a list of channels matching to the specified query. @MainActor public final class ChannelListState: ObservableObject { private let observer: Observer - + init( query: ChannelListQuery, dynamicFilter: (@Sendable (ChatChannel) -> Bool)?, @@ -18,6 +18,7 @@ import Foundation eventNotificationCenter: EventNotificationCenter, channelWatcherHandler: ChannelWatcherHandling ) { + let query = channelListUpdater.loadPredefinedFilter(for: query) ?? query self.query = query observer = Observer( query: query, @@ -32,10 +33,15 @@ import Foundation with: .init(channelsDidChange: { [weak self] in self?.channels = $0 }) ) } - + /// The query used for filtering the list of channels. - public let query: ChannelListQuery - + public internal(set) var query: ChannelListQuery + /// An array of channels for the specified ``ChannelListQuery``. @Published public internal(set) var channels: [ChatChannel] = [] + + func setQuery(_ query: ChannelListQuery) { + self.query = query + channels = observer.reload(with: query) + } } diff --git a/Sources/StreamChat/Utils/Dictionary+Extensions.swift b/Sources/StreamChat/Utils/Dictionary+Extensions.swift index d811d65dad8..45b3636c764 100644 --- a/Sources/StreamChat/Utils/Dictionary+Extensions.swift +++ b/Sources/StreamChat/Utils/Dictionary+Extensions.swift @@ -20,3 +20,13 @@ extension Dictionary { return result } } + +extension Dictionary where Key == String { + /// A deterministic `key=value` representation with keys sorted ascending, joined by `,`. + /// Useful for building stable hash inputs from unordered dictionaries. + var sortedDescription: String { + sorted { $0.key < $1.key } + .map { "\($0.key)=\($0.value)" } + .joined(separator: ",") + } +} diff --git a/Sources/StreamChat/Workers/ChannelListUpdater.swift b/Sources/StreamChat/Workers/ChannelListUpdater.swift index 28dd407556a..ce0644b35e2 100644 --- a/Sources/StreamChat/Workers/ChannelListUpdater.swift +++ b/Sources/StreamChat/Workers/ChannelListUpdater.swift @@ -4,6 +4,18 @@ import CoreData +struct ChannelListUpdateResult: Sendable { + let channels: [ChatChannel] + let updatedQuery: ChannelListQuery? +} + +extension ChannelListUpdateResult { + /// Convenience for tests that don't simulate predefined-filter resolution. + init(channels: [ChatChannel]) { + self.init(channels: channels, updatedQuery: nil) + } +} + /// Makes a channels query call to the backend and updates the local storage with the results. class ChannelListUpdater: Worker, @unchecked Sendable { /// Makes a channels query call to the backend and updates the local storage with the results. @@ -14,7 +26,7 @@ class ChannelListUpdater: Worker, @unchecked Sendable { /// func update( channelListQuery: ChannelListQuery, - completion: (@Sendable (Result<[ChatChannel], Error>) -> Void)? = nil + completion: (@Sendable (Result) -> Void)? = nil ) { fetch(channelListQuery: channelListQuery) { [weak self] in switch $0 { @@ -23,8 +35,7 @@ class ChannelListUpdater: Worker, @unchecked Sendable { var initialActions: (@Sendable (DatabaseSession) -> Void)? if isInitialFetch { initialActions = { session in - let filterHash = channelListQuery.filter.filterHash - guard let queryDTO = session.channelListQuery(filterHash: filterHash) else { return } + guard let queryDTO = session.channelListQuery(query: channelListQuery) else { return } queryDTO.channels.removeAll() } } @@ -41,6 +52,16 @@ class ChannelListUpdater: Worker, @unchecked Sendable { } } + /// See `ChannelDatabaseSession.loadPredefinedFilter(for:)` for return semantics. + func loadPredefinedFilter(for query: ChannelListQuery) -> ChannelListQuery? { + guard let predefinedFilter = query.predefinedFilter, !predefinedFilter.isEmpty else { return nil } + do { + return try database.readAndWait { $0.loadPredefinedFilter(for: query) } + } catch { + return nil + } + } + func refreshLoadedChannels(for query: ChannelListQuery, channelCount: Int, completion: @escaping @Sendable (Result, Error>) -> Void) { guard channelCount > 0 else { completion(.success(Set())) @@ -80,10 +101,10 @@ class ChannelListUpdater: Worker, @unchecked Sendable { query: nextQuery, completion: { [weak self] writeResult in switch writeResult { - case .success(let writtenChannels): + case .success(let writeResult): self?.refreshLoadedChannels( for: Array(remaining), - refreshedChannelIds: refreshedChannelIds.union(writtenChannels.map(\.cid)), + refreshedChannelIds: refreshedChannelIds.union(writeResult.channels.map(\.cid)), completion: completion ) case .failure(let error): @@ -170,7 +191,7 @@ class ChannelListUpdater: Worker, @unchecked Sendable { extension DatabaseSession { func getChannelWithQuery(cid: ChannelId, query: ChannelListQuery) -> (ChannelDTO, ChannelListQueryDTO)? { - guard let queryDTO = channelListQuery(filterHash: query.filter.filterHash) else { + guard let queryDTO = channelListQuery(query: query) else { log.debug("Channel list query has not yet created \(query)") return nil } @@ -189,52 +210,33 @@ private extension ChannelListUpdater { payload: ChannelListPayload, query: ChannelListQuery, initialActions: (@Sendable (DatabaseSession) -> Void)? = nil, - completion: (@Sendable (Result<[ChatChannel], Error>) -> Void)? = nil + completion: (@Sendable (Result) -> Void)? = nil ) { nonisolated(unsafe) var channels: [ChatChannel] = [] + nonisolated(unsafe) var resolvedQuery: ChannelListQuery? database.write { session in initialActions?(session) channels = session.saveChannelList(payload: payload, query: query).compactMap { try? $0.asModel() } + if let loadedQuery = session.loadPredefinedFilter(for: query), !loadedQuery.isFilterEqual(to: query) { + resolvedQuery = loadedQuery + } } completion: { error in if let error = error { log.error("Failed to save `ChannelListPayload` to the database. Error: \(error)") completion?(.failure(error)) } else { - completion?(.success(channels)) + completion?(.success(.init(channels: channels, updatedQuery: resolvedQuery))) } } } } extension ChannelListUpdater { - @discardableResult func update(channelListQuery: ChannelListQuery) async throws -> [ChatChannel] { + func update(channelListQuery: ChannelListQuery) async throws -> ChannelListUpdateResult { try await withCheckedThrowingContinuation { continuation in update(channelListQuery: channelListQuery) { result in continuation.resume(with: result) } } } - - // MARK: - - - func loadChannels(query: ChannelListQuery, pagination: Pagination) async throws -> [ChatChannel] { - try await update(channelListQuery: query.withPagination(pagination)) - } - - func loadNextChannels( - query: ChannelListQuery, - limit: Int, - loadedChannelsCount: Int - ) async throws -> [ChatChannel] { - let pagination = Pagination(pageSize: limit, offset: loadedChannelsCount) - return try await update(channelListQuery: query.withPagination(pagination)) - } -} - -private extension ChannelListQuery { - func withPagination(_ pagination: Pagination) -> Self { - var query = self - query.pagination = pagination - return query - } } diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index e9683c3921e..6e9643e3f69 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -867,6 +867,7 @@ StreamChatTests/Database/DataStore_Tests.swift, StreamChatTests/Database/DTOs/AttachmentDTO_Tests.swift, StreamChatTests/Database/DTOs/ChannelDTO_Tests.swift, + StreamChatTests/Database/DTOs/ChannelListQueryDTO_Tests.swift, StreamChatTests/Database/DTOs/ChannelMemberListQueryDTO_Tests.swift, StreamChatTests/Database/DTOs/ChannelMuteDTO_Tests.swift, StreamChatTests/Database/DTOs/ChannelReadDTO_Tests.swift, @@ -911,6 +912,7 @@ StreamChatTests/Models/Poll_Tests.swift, StreamChatTests/Models/User_Tests.swift, StreamChatTests/Query/ChannelListFilterScope_Tests.swift, + StreamChatTests/Query/ChannelListQuery_PredefinedFilter_Tests.swift, StreamChatTests/Query/ChannelListQuery_Tests.swift, StreamChatTests/Query/ChannelMemberListQuery_Tests.swift, StreamChatTests/Query/ChannelQuery_Tests.swift, diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift index 4fb34bde7aa..0414f73c364 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift @@ -58,6 +58,10 @@ class DatabaseSession_Mock: DatabaseSession { underlyingSession.saveChannelList(payload: payload, query: query) } + func loadPredefinedFilter(for query: ChannelListQuery) -> ChannelListQuery? { + underlyingSession.loadPredefinedFilter(for: query) + } + func saveQuery(query: ReactionListQuery) throws -> ReactionListQueryDTO? { try underlyingSession.saveQuery(query: query) } @@ -375,12 +379,12 @@ class DatabaseSession_Mock: DatabaseSession { underlyingSession.loadChannelReads(for: userId) } - func saveQuery(query: ChannelListQuery) -> ChannelListQueryDTO { - underlyingSession.saveQuery(query: query) + func saveQuery(query: ChannelListQuery, predefinedFilter: PredefinedFilterPayload?) -> ChannelListQueryDTO { + underlyingSession.saveQuery(query: query, predefinedFilter: predefinedFilter) } - func channelListQuery(filterHash: String) -> ChannelListQueryDTO? { - underlyingSession.channelListQuery(filterHash: filterHash) + func channelListQuery(query: ChannelListQuery) -> ChannelListQueryDTO? { + underlyingSession.channelListQuery(query: query) } func loadAllChannelListQueries() -> [ChannelListQueryDTO] { diff --git a/TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift b/TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift index 932fa0b9835..9d5975d49d3 100644 --- a/TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift +++ b/TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift @@ -12,6 +12,7 @@ final class ChannelListUpdater_Spy: ChannelListUpdater, Spy, @unchecked Sendable @Atomic var update_queries: [ChannelListQuery] = [] @Atomic var update_completion: ((Result<[ChatChannel], Error>) -> Void)? @Atomic var update_completion_result: Result<[ChatChannel], Error>? + @Atomic var update_updatedQuery: ChannelListQuery? @Atomic var fetch_queries: [ChannelListQuery] = [] @Atomic var fetch_completion: ((Result) -> Void)? @@ -26,14 +27,17 @@ final class ChannelListUpdater_Spy: ChannelListUpdater, Spy, @unchecked Sendable var startWatchingChannels_completion_success = false var link_callCount = 0 + @Atomic var link_queries: [ChannelListQuery] = [] var link_completion: ((Error?) -> Void)? var unlink_callCount = 0 + @Atomic var unlink_queries: [ChannelListQuery] = [] func cleanUp() { update_queries.removeAll() update_completion = nil update_completion_result = nil + update_updatedQuery = nil fetch_queries.removeAll() fetch_completion = nil @@ -43,15 +47,34 @@ final class ChannelListUpdater_Spy: ChannelListUpdater, Spy, @unchecked Sendable startWatchingChannels_cids.removeAll() startWatchingChannels_completion = nil startWatchingChannels_completion_success = false + + link_callCount = 0 + link_queries.removeAll() + link_completion = nil + + unlink_callCount = 0 + unlink_queries.removeAll() } override func update( channelListQuery: ChannelListQuery, - completion: ((Result<[ChatChannel], Error>) -> Void)? = nil + completion: (@Sendable (Result) -> Void)? = nil ) { _update_queries.mutate { $0.append(channelListQuery) } - update_completion = completion - update_completion_result?.invoke(with: completion) + let resolvedQuery = loadPredefinedFilter(for: channelListQuery) + let updatedQueryOverride = update_updatedQuery + update_completion = { [weak self] result in + defer { self?.update_completion = nil } + let changedQuery: ChannelListQuery? = { + if let updatedQuery = updatedQueryOverride { + return updatedQuery + } + guard let resolvedQuery, !resolvedQuery.isFilterEqual(to: channelListQuery) else { return nil } + return resolvedQuery + }() + completion?(result.map { ChannelListUpdateResult(channels: $0, updatedQuery: changedQuery) }) + } + update_completion_result?.invoke(with: update_completion) } override func markAllRead(completion: ((Error?) -> Void)? = nil) { @@ -81,6 +104,7 @@ final class ChannelListUpdater_Spy: ChannelListUpdater, Spy, @unchecked Sendable completion: ((Error?) -> Void)? = nil ) { link_callCount += 1 + _link_queries.mutate { $0.append(query) } link_completion = completion } @@ -90,6 +114,7 @@ final class ChannelListUpdater_Spy: ChannelListUpdater, Spy, @unchecked Sendable completion: ((Error?) -> Void)? = nil ) { unlink_callCount += 1 + _unlink_queries.mutate { $0.append(query) } } override func startWatchingChannels(withIds ids: [ChannelId], completion: ((Error?) -> Void)?) { diff --git a/TestTools/StreamChatTestTools/TestData/FilterTestScope.swift b/TestTools/StreamChatTestTools/TestData/FilterTestScope.swift index 934b6a5645c..dbd8ad59da4 100644 --- a/TestTools/StreamChatTestTools/TestData/FilterTestScope.swift +++ b/TestTools/StreamChatTestTools/TestData/FilterTestScope.swift @@ -58,12 +58,15 @@ struct FilterCodingTestPair { .lessOrEqualDouble(), .inArrayInt(), .inArrayDouble(), + .inArrayString(), + .equalArrayInt(), .query(), .autocomplete(), .existsTrue(), .notExists(), .containsAndEqual(), - .greaterOrLess() + .greaterOrLess(), + .nor() ] } @@ -164,6 +167,27 @@ extension FilterCodingTestPair { return FilterCodingTestPair(json: json, filter: filter) } + static func inArrayString() -> FilterCodingTestPair { + let json = #"{"test_key_ArrayString":{"$in":["a","b"]}}"# + let filter: Filter = .in(.testKeyArrayString, values: ["a", "b"]) + return FilterCodingTestPair(json: json, filter: filter) + } + + static func equalArrayInt() -> FilterCodingTestPair { + let json = #"{"test_key_ArrayInt":{"$eq":[1,2]}}"# + let filter: Filter = .equal(.testKeyArrayInt, values: [1, 2]) + return FilterCodingTestPair(json: json, filter: filter) + } + + static func nor() -> FilterCodingTestPair { + let json = #"{"$nor":[{"test_key_Int":{"$eq":1}},{"test_key_Bool":{"$eq":true}}]}"# + let filter: Filter = .nor([ + .equal(.testKeyInt, to: 1), + .equal(.testKeyBool, to: true) + ]) + return FilterCodingTestPair(json: json, filter: filter) + } + private static func existsFilter(exists: Bool) -> FilterCodingTestPair { let json = "{\"test_key_Int\":{\"$exists\":\(exists)}}" let filter: Filter = .exists(.testKeyInt, exists: exists) diff --git a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift index b1522c11a00..a78b9b45bba 100644 --- a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift +++ b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift @@ -62,6 +62,40 @@ final class ChannelListPayload_Tests: XCTestCase { XCTAssertEqual(payload.channels.count, 2) } + func test_channelListPayload_decodesPredefinedFilter() throws { + let json = """ + { + "channels": [], + "predefined_filter": { + "name": "user_per_channel_type_channels", + "filter": { "members": { "$in": ["r2-d2"] }, "type": "messaging" }, + "sort": [ + { "direction": -1, "field": "last_message_at", "type": "string" }, + { "direction": -1, "field": "created_at", "type": "string" } + ] + } + } + """.data(using: .utf8)! + + let payload = try JSONDecoder.default.decode(ChannelListPayload.self, from: json) + + let predefined = try XCTUnwrap(payload.predefinedFilter) + XCTAssertEqual(predefined.name, "user_per_channel_type_channels") + XCTAssertEqual(predefined.filter["type"], .string("messaging")) + XCTAssertEqual(predefined.filter["members"], .dictionary(["$in": .array([.string("r2-d2")])])) + XCTAssertEqual(predefined.sort.count, 2) + XCTAssertEqual(predefined.sort.first?["field"], .string("last_message_at")) + XCTAssertEqual(predefined.sort.first?["direction"], .number(-1)) + } + + func test_channelListPayload_predefinedFilter_isNilWhenAbsent() throws { + let json = "{ \"channels\": [] }".data(using: .utf8)! + + let payload = try JSONDecoder.default.decode(ChannelListPayload.self, from: json) + + XCTAssertNil(payload.predefinedFilter) + } + func saveChannelListPayload(_ payload: ChannelListPayload, database: DatabaseContainer_Spy, timeout: TimeInterval = 20) { let writeCompleted = expectation(description: "DB write complete") database.write({ session in diff --git a/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift b/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift index d12b2280edd..96b8f75d124 100644 --- a/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift @@ -245,6 +245,124 @@ final class ChannelListController_Tests: XCTestCase { XCTAssertEqual(controller.channels.map(\.cid), [channelId]) } + // MARK: - Predefined filter resolution + + func test_synchronize_predefinedFilterQuery_createsObserverWithCachedResolvedFilterAndSort() throws { + // GIVEN: a controller built with a predefined query (placeholder filter + empty sort) + let predefinedQuery = ChannelListQuery( + predefinedFilter: "user_per_channel_type_channels", + filterValues: ["user_id": "r2-d2"] + ) + query = predefinedQuery + controller = ChatChannelListController(query: predefinedQuery, client: client, environment: env.environment) + + // AND: the DTO carries server-resolved filter/sort JSON (different from placeholder) + let resolvedFilterJSON = #"{"type":"messaging"}"#.data(using: .utf8)! + let resolvedSortJSON = #"[{"field":"last_message_at","direction":-1}]"#.data(using: .utf8)! + try client.databaseContainer.writeSynchronously { session in + let dto = session.saveQuery(query: predefinedQuery) + dto.filterJSONData = resolvedFilterJSON + dto.sortJSONData = resolvedSortJSON + } + + // Snapshot the observer identity after lazy init. The cached predefined filter is applied before observer creation. + let observerBefore = ObjectIdentifier(controller.channelListObserver) + XCTAssertEqual(controller.query.filter.key, "type") + XCTAssertEqual(controller.query.filter.value as? String, "messaging") + XCTAssertEqual(controller.query.sort.count, 1) + XCTAssertEqual(controller.query.sort.first?.key.remoteKey, ChannelListSortingKey.lastMessageAt.remoteKey) + + // WHEN: synchronize completes successfully + let exp = expectation(description: "synchronize completes") + var receivedError: Error? + controller.synchronize { error in + receivedError = error + exp.fulfill() + } + env.channelListUpdater?.update_completion?(.success([])) + waitForExpectations(timeout: defaultTimeout) + + // THEN: no error, query has resolved values, observer is not rebuilt again for the same effective query + XCTAssertNil(receivedError) + XCTAssertEqual(controller.query.filter.key, "type") + XCTAssertEqual(controller.query.filter.value as? String, "messaging") + XCTAssertEqual(controller.query.sort.count, 1) + XCTAssertEqual(controller.query.sort.first?.key.remoteKey, ChannelListSortingKey.lastMessageAt.remoteKey) + XCTAssertEqual(controller.query.sort.first?.direction, -1) + XCTAssertEqual(ObjectIdentifier(controller.channelListObserver), observerBefore) + } + + func test_synchronize_predefinedFilterQuery_whenRemoteResolutionChangesQuery_rebuildsLinkerWithResolvedQuery() throws { + let predefinedQuery = ChannelListQuery( + predefinedFilter: .unique, + filterValues: ["user_id": "r2-d2"] + ) + query = predefinedQuery + controller = ChatChannelListController(query: predefinedQuery, client: client, environment: env.environment) + + var resolvedQuery = predefinedQuery + resolvedQuery.filter = try XCTUnwrap( + Filter.predefinedFilter(fromJSONData: #"{"type":"messaging"}"#.data(using: .utf8)!) + ) + resolvedQuery.sort = try [Sorting].predefinedFilterSort( + fromJSONData: #"[{"field":"last_message_at","direction":-1}]"#.data(using: .utf8)! + ) + _ = controller.channelListObserver + env.channelListUpdater?.update_updatedQuery = resolvedQuery + + let exp = expectation(description: "synchronize completes") + controller.synchronize { _ in exp.fulfill() } + env.channelListUpdater?.update_completion?(.success([])) + waitForExpectations(timeout: defaultTimeout) + + XCTAssertGreaterThanOrEqual(env.channelListLinkerQueries.count, 2) + XCTAssertEqual(env.channelListLinkerQueries.last?.filter.key, "type") + XCTAssertEqual(env.channelListLinkerQueries.last?.sort.first?.key.remoteKey, ChannelListSortingKey.lastMessageAt.remoteKey) + } + + func test_synchronize_predefinedFilterQuery_whenNetworkFails_keepsCachedResolvedFilterAndReportsError() throws { + let predefinedQuery = ChannelListQuery( + predefinedFilter: "user_per_channel_type_channels", + filterValues: ["user_id": "r2-d2"] + ) + query = predefinedQuery + controller = ChatChannelListController(query: predefinedQuery, client: client, environment: env.environment) + try client.databaseContainer.writeSynchronously { session in + let dto = session.saveQuery(query: predefinedQuery) + dto.filterJSONData = #"{"type":"messaging"}"#.data(using: .utf8)! + dto.sortJSONData = #"[{"field":"last_message_at","direction":-1}]"#.data(using: .utf8)! + } + _ = controller.channelListObserver + + let error = TestError() + let exp = expectation(description: "synchronize completes") + var receivedError: Error? + controller.synchronize { error in + receivedError = error + exp.fulfill() + } + env.channelListUpdater?.update_completion?(.failure(error)) + waitForExpectations(timeout: defaultTimeout) + + XCTAssertEqual(receivedError as? TestError, error) + XCTAssertEqual(controller.query.filter.key, "type") + XCTAssertEqual(controller.query.sort.first?.key.remoteKey, ChannelListSortingKey.lastMessageAt.remoteKey) + } + + func test_synchronize_nonPredefinedQuery_doesNotRebuildObserver() { + // GIVEN: a controller built with a non-predefined query (default in setUp) + let observerBefore = ObjectIdentifier(controller.channelListObserver) + + // WHEN: synchronize completes successfully + let exp = expectation(description: "synchronize completes") + controller.synchronize { _ in exp.fulfill() } + env.channelListUpdater?.update_completion?(.success([])) + waitForExpectations(timeout: defaultTimeout) + + // THEN: the observer instance is unchanged + XCTAssertEqual(ObjectIdentifier(controller.channelListObserver), observerBefore) + } + // MARK: - Change propagation tests func test_changesInTheDatabase_arePropagated() throws { @@ -806,6 +924,32 @@ final class ChannelListController_Tests: XCTestCase { AssertAsync.canBeReleased(&weakController) } + func test_loadNextChannels_predefinedFilterQuery_appliesResolvedFilterAndRebuildsObserver() throws { + let predefinedQuery = ChannelListQuery( + predefinedFilter: "user_per_channel_type_channels", + filterValues: ["user_id": "r2-d2"] + ) + query = predefinedQuery + controller = ChatChannelListController(query: predefinedQuery, client: client, environment: env.environment) + try client.databaseContainer.writeSynchronously { session in + let dto = session.saveQuery(query: predefinedQuery) + dto.filterJSONData = #"{"type":"messaging"}"#.data(using: .utf8)! + dto.sortJSONData = #"[{"field":"last_message_at","direction":-1}]"#.data(using: .utf8)! + } + + let exp = expectation(description: "load next completes") + controller.loadNextChannels { error in + XCTAssertNil(error) + exp.fulfill() + } + env.channelListUpdater?.update_completion?(.success([])) + waitForExpectations(timeout: defaultTimeout) + + XCTAssertEqual(controller.query.filter.key, "type") + XCTAssertEqual(controller.query.filter.value as? String, "messaging") + XCTAssertEqual(controller.query.sort.first?.key.remoteKey, ChannelListSortingKey.lastMessageAt.remoteKey) + } + // MARK: - Refresh Loaded Channels func test_refreshLoadedChannels_whenSucceedsThenControllerSucceeds() { @@ -2069,6 +2213,7 @@ private class TestEnvironment { @Atomic var currentUserUpdater: CurrentUserUpdater_Mock? @Atomic var deliveryCriteriaValidator: MessageDeliveryCriteriaValidator_Mock? @Atomic var channelWatcherHandler: ChannelWatcherHandler_Mock? + @Atomic var channelListLinkerQueries: [ChannelListQuery] = [] lazy var environment: ChatChannelListController.Environment = .init( @@ -2080,6 +2225,7 @@ private class TestEnvironment { return self.channelListUpdater! }, channelListLinkerBuilder: { [unowned self] query, filter, config, database, worker, _ in + self._channelListLinkerQueries.mutate { $0.append(query) } self.channelWatcherHandler = ChannelWatcherHandler_Mock() self.channelWatcherHandler?.attemptToWatch_completion_success = true return ChannelListLinker( diff --git a/Tests/StreamChatTests/Database/DTOs/ChannelListQueryDTO_Tests.swift b/Tests/StreamChatTests/Database/DTOs/ChannelListQueryDTO_Tests.swift new file mode 100644 index 00000000000..2e5390ecc71 --- /dev/null +++ b/Tests/StreamChatTests/Database/DTOs/ChannelListQueryDTO_Tests.swift @@ -0,0 +1,189 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import CoreData +@testable import StreamChat +@testable import StreamChatTestTools +import XCTest + +final class ChannelListQueryDTO_Tests: XCTestCase { + var database: DatabaseContainer! + + override func setUp() { + super.setUp() + database = DatabaseContainer_Spy() + } + + override func tearDown() { + AssertAsync.canBeReleased(&database) + database = nil + super.tearDown() + } + + func test_saveQuery_withoutPredefinedFilter_writesFilterFromQueryAndLeavesSortNil() throws { + let cid = ChannelId.unique + let query = ChannelListQuery(filter: .equal(.cid, to: cid)) + + try database.writeSynchronously { session in + _ = (session as! NSManagedObjectContext).saveQuery(query: query) + } + + try database.readSynchronously { session in + let dto = try XCTUnwrap((session as! NSManagedObjectContext).channelListQuery(query: query)) + let savedFilter = try JSONDecoder.default.decode(Filter.self, from: dto.filterJSONData) + XCTAssertEqual(savedFilter.filterHash, query.filter.filterHash) + XCTAssertNil(dto.sortJSONData) + } + } + + func test_saveQuery_withPredefinedFilter_writesFilterAndSortFromResponse() throws { + let query = ChannelListQuery( + predefinedFilter: "user_per_channel_type_channels", + filterValues: ["user_id": "r2-d2"] + ) + let predefinedFilter = PredefinedFilterPayload( + name: "user_per_channel_type_channels", + filter: [ + "members": .dictionary(["$in": .array([.string("r2-d2")])]), + "type": .string("messaging") + ], + sort: [ + ["direction": .number(-1), "field": .string("last_message_at"), "type": .string("string")], + ["direction": .number(-1), "field": .string("created_at"), "type": .string("string")] + ] + ) + + try database.writeSynchronously { session in + _ = (session as! NSManagedObjectContext).saveQuery(query: query, predefinedFilter: predefinedFilter) + } + + try database.readSynchronously { session in + let dto = try XCTUnwrap((session as! NSManagedObjectContext).channelListQuery(query: query)) + let savedFilter = try JSONDecoder.default.decode([String: RawJSON].self, from: dto.filterJSONData) + XCTAssertEqual(savedFilter, predefinedFilter.filter) + + let sortData = try XCTUnwrap(dto.sortJSONData) + let savedSort = try JSONDecoder.default.decode([[String: RawJSON]].self, from: sortData) + XCTAssertEqual(savedSort, predefinedFilter.sort) + } + } + + func test_saveQuery_withPredefinedFilter_overwritesExistingDTO() throws { + let query = ChannelListQuery( + predefinedFilter: "user_per_channel_type_channels", + filterValues: ["user_id": "r2-d2"] + ) + let first = PredefinedFilterPayload( + name: "user_per_channel_type_channels", + filter: ["type": .string("messaging")], + sort: [["field": .string("last_message_at"), "direction": .number(-1)]] + ) + let second = PredefinedFilterPayload( + name: "user_per_channel_type_channels", + filter: ["type": .string("livestream")], + sort: [["field": .string("created_at"), "direction": .number(1)]] + ) + + try database.writeSynchronously { session in + _ = (session as! NSManagedObjectContext).saveQuery(query: query, predefinedFilter: first) + } + try database.writeSynchronously { session in + _ = (session as! NSManagedObjectContext).saveQuery(query: query, predefinedFilter: second) + } + + try database.readSynchronously { session in + let dto = try XCTUnwrap((session as! NSManagedObjectContext).channelListQuery(query: query)) + let savedFilter = try JSONDecoder.default.decode([String: RawJSON].self, from: dto.filterJSONData) + XCTAssertEqual(savedFilter, second.filter) + + let sortData = try XCTUnwrap(dto.sortJSONData) + let savedSort = try JSONDecoder.default.decode([[String: RawJSON]].self, from: sortData) + XCTAssertEqual(savedSort, second.sort) + } + } + + func test_loadPredefinedFilter_persistedDTO_returnsQueryWithDecodedFilterAndSort() throws { + let query = ChannelListQuery( + predefinedFilter: "user_per_channel_type_channels", + filterValues: ["user_id": "r2-d2"] + ) + let payload = PredefinedFilterPayload( + name: "user_per_channel_type_channels", + filter: ["type": .string("messaging")], + sort: [["field": .string("last_message_at"), "direction": .number(-1)]] + ) + try database.writeSynchronously { session in + _ = session.saveQuery(query: query, predefinedFilter: payload) + } + + let updated = try XCTUnwrap(database.readAndWait { session in + session.loadPredefinedFilter(for: query) + }) + + XCTAssertEqual(updated.filter.key, "type") + XCTAssertEqual(updated.filter.value as? String, "messaging") + XCTAssertEqual(updated.filter.keyPathString, #keyPath(ChannelDTO.typeRawValue)) + XCTAssertEqual(updated.sort.count, 1) + XCTAssertEqual(updated.sort.first?.key.remoteKey, ChannelListSortingKey.lastMessageAt.remoteKey) + XCTAssertEqual(updated.sort.first?.direction, -1) + } + + func test_loadPredefinedFilter_noPersistedDTO_returnsNil() throws { + let query = ChannelListQuery(predefinedFilter: "user_per_channel_type_channels") + + let updated = try database.readAndWait { session in + session.loadPredefinedFilter(for: query) + } + + XCTAssertNil(updated) + } + + func test_loadPredefinedFilter_persistedDTOWithNilSort_returnsQueryWithDecodedFilterAndEmptySort() throws { + let query = ChannelListQuery(predefinedFilter: "user_per_channel_type_channels") + let payload = PredefinedFilterPayload( + name: "user_per_channel_type_channels", + filter: ["type": .string("messaging")], + sort: [] + ) + try database.writeSynchronously { session in + let dto = session.saveQuery(query: query, predefinedFilter: payload) + dto.sortJSONData = nil + } + + let updated = try XCTUnwrap(database.readAndWait { session in + session.loadPredefinedFilter(for: query) + }) + + XCTAssertEqual(updated.filter.key, "type") + XCTAssertEqual(updated.filter.value as? String, "messaging") + XCTAssertTrue(updated.sort.isEmpty) + } + + func test_loadPredefinedFilter_invalidPersistedJSON_returnsQueryUnchanged() throws { + let query = ChannelListQuery(predefinedFilter: "user_per_channel_type_channels") + try database.writeSynchronously { session in + let dto = session.saveQuery(query: query, predefinedFilter: nil) + dto.filterJSONData = Data("not-json".utf8) + dto.sortJSONData = Data("not-json".utf8) + } + + let updated = try XCTUnwrap(database.readAndWait { session in + session.loadPredefinedFilter(for: query) + }) + + XCTAssertEqual(updated.filter.filterHash, query.filter.filterHash) + XCTAssertEqual(updated.sort.map(\.description), query.sort.map(\.description)) + XCTAssertEqual(updated.predefinedFilter, query.predefinedFilter) + } + + func test_loadPredefinedFilter_nonPredefinedQuery_returnsNil() throws { + let query = ChannelListQuery(filter: .equal(.cid, to: .unique)) + + let updated = try database.readAndWait { session in + session.loadPredefinedFilter(for: query) + } + + XCTAssertNil(updated) + } +} diff --git a/Tests/StreamChatTests/Query/ChannelListQuery_PredefinedFilter_Tests.swift b/Tests/StreamChatTests/Query/ChannelListQuery_PredefinedFilter_Tests.swift new file mode 100644 index 00000000000..df5dc9703c5 --- /dev/null +++ b/Tests/StreamChatTests/Query/ChannelListQuery_PredefinedFilter_Tests.swift @@ -0,0 +1,359 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import CoreData +@testable import StreamChat +@testable import StreamChatTestTools +import XCTest + +final class ChannelListQuery_PredefinedFilter_Tests: XCTestCase { + func test_predefinedFilterKeyMapping_includeEveryHardcodedChannelListFilterKey() { + let expectedKeys: Set = [ + FilterKey.cid.rawValue, + FilterKey.id.rawValue, + FilterKey.name.rawValue, + FilterKey.imageURL.rawValue, + FilterKey.type.rawValue, + FilterKey.lastMessageAt.rawValue, + FilterKey.createdBy.rawValue, + FilterKey.createdAt.rawValue, + FilterKey.updatedAt.rawValue, + FilterKey.deletedAt.rawValue, + FilterKey.hidden.rawValue, + FilterKey.frozen.rawValue, + FilterKey.disabled.rawValue, + FilterKey.blocked.rawValue, + FilterKey.archived.rawValue, + FilterKey.pinned.rawValue, + FilterKey.members.rawValue, + FilterKey.memberCount.rawValue, + FilterKey.team.rawValue, + FilterKey.joined.rawValue, + FilterKey.muted.rawValue, + FilterKey.invite.rawValue, + FilterKey.memberName.rawValue, + FilterKey.lastUpdatedAt.rawValue, + FilterKey.channelRole.rawValue, + FilterKey.filterTags.rawValue, + FilterKey.hasUnread.rawValue + ] + + XCTAssertEqual(Set(ChannelListFilterScope.predefinedFilterKeyMapping.keys), expectedKeys) + } + + func test_predefinedFilterSortingKeys_includeEveryHardcodedChannelListSortingKey() { + let expectedKeys: Set = [ + ChannelListSortingKey.default.remoteKey, + ChannelListSortingKey.createdAt.remoteKey, + ChannelListSortingKey.updatedAt.remoteKey, + ChannelListSortingKey.lastMessageAt.remoteKey, + ChannelListSortingKey.pinnedAt.remoteKey, + ChannelListSortingKey.memberCount.remoteKey, + ChannelListSortingKey.cid.remoteKey, + ChannelListSortingKey.hasUnread.remoteKey, + ChannelListSortingKey.unreadCount.remoteKey + ] + + XCTAssertEqual(Set(ChannelListSortingKey.predefinedSortingKeyMapping.keys), expectedKeys) + XCTAssertTrue(ChannelListSortingKey.predefinedSortingKeyMapping.values.allSatisfy { $0.localKey != nil }) + } + + func test_predefinedFilter_fromJSONData_implicitEqual_attachesKeyPathAndValueMapper() throws { + let json = #"{"type":"messaging"}"#.data(using: .utf8)! + + let filter = try XCTUnwrap(Filter.predefinedFilter(fromJSONData: json)) + + XCTAssertEqual(filter.operator, FilterOperator.equal.rawValue) + XCTAssertEqual(filter.key, "type") + XCTAssertEqual(filter.value as? String, "messaging") + XCTAssertEqual(filter.keyPathString, #keyPath(ChannelDTO.typeRawValue)) + XCTAssertNotNil(filter.valueMapper) + XCTAssertNotNil(filter.predicate) + } + + func test_predefinedFilter_fromJSONData_inOperator_attachesKeyPath() throws { + let json = #"{"members":{"$in":["r2-d2"]}}"#.data(using: .utf8)! + + let filter = try XCTUnwrap(Filter.predefinedFilter(fromJSONData: json)) + + XCTAssertEqual(filter.operator, FilterOperator.in.rawValue) + XCTAssertEqual(filter.key, "members") + XCTAssertEqual(filter.value as? [String], ["r2-d2"]) + XCTAssertEqual(filter.keyPathString, #keyPath(ChannelDTO.members.user.id)) + } + + func test_predefinedFilter_fromJSONData_collectionKey_preservesCollectionFilterFlag() throws { + let json = #"{"member.user.name":{"$autocomplete":"Leia"}}"#.data(using: .utf8)! + + let filter = try XCTUnwrap(Filter.predefinedFilter(fromJSONData: json)) + + XCTAssertEqual(filter.operator, FilterOperator.autocomplete.rawValue) + XCTAssertEqual(filter.key, "member.user.name") + XCTAssertEqual(filter.value as? String, "Leia") + XCTAssertEqual(filter.keyPathString, #keyPath(ChannelDTO.members.user.name)) + XCTAssertTrue(filter.isCollectionFilter) + } + + func test_predefinedFilter_fromJSONData_groupOperator_enrichesAllChildrenMixedForms() throws { + let json = #"{"$and":[{"type":"messaging"},{"members":{"$in":["r2-d2"]}}]}"#.data(using: .utf8)! + + let filter = try XCTUnwrap(Filter.predefinedFilter(fromJSONData: json)) + + XCTAssertEqual(filter.operator, FilterOperator.and.rawValue) + let children = try XCTUnwrap(filter.value as? [Filter]) + XCTAssertEqual(children.count, 2) + + let typeChild = try XCTUnwrap(children.first { $0.key == "type" }) + XCTAssertEqual(typeChild.operator, FilterOperator.equal.rawValue) + XCTAssertEqual(typeChild.keyPathString, #keyPath(ChannelDTO.typeRawValue)) + + let membersChild = try XCTUnwrap(children.first { $0.key == "members" }) + XCTAssertEqual(membersChild.operator, FilterOperator.in.rawValue) + XCTAssertEqual(membersChild.keyPathString, #keyPath(ChannelDTO.members.user.id)) + } + + func test_predefinedFilter_fromJSONData_multiKeyObject_decodesAsImplicitAnd() throws { + let json = #"{"members":{"$in":["r2-d2"]},"type":"messaging"}"#.data(using: .utf8)! + + let filter = try XCTUnwrap(Filter.predefinedFilter(fromJSONData: json)) + + XCTAssertEqual(filter.operator, FilterOperator.and.rawValue) + let children = try XCTUnwrap(filter.value as? [Filter]) + XCTAssertEqual(children.count, 2) + XCTAssertNotNil(children.first { $0.key == "members" }) + XCTAssertNotNil(children.first { $0.key == "type" }) + } + + func test_predefinedFilter_fromJSONData_mixedFieldAndGroupOperator_keepsAllKeysAsImplicitAnd() throws { + // A field key (`type`) alongside a group-operator key (`$or`) at the same level. The backend + // ANDs both, so neither may be dropped: `type == messaging AND (member-of OR frozen)`. + let json = #"{"type":"messaging","$or":[{"members":{"$in":["amy"]}},{"frozen":true}]}"#.data(using: .utf8)! + + let filter = try XCTUnwrap(Filter.predefinedFilter(fromJSONData: json)) + + XCTAssertEqual(filter.operator, FilterOperator.and.rawValue) + let children = try XCTUnwrap(filter.value as? [Filter]) + XCTAssertEqual(children.count, 2) + + // The bare field key is enriched as a leaf. + let typeChild = try XCTUnwrap(children.first { $0.key == "type" }) + XCTAssertEqual(typeChild.operator, FilterOperator.equal.rawValue) + XCTAssertEqual(typeChild.keyPathString, #keyPath(ChannelDTO.typeRawValue)) + + // The group operator is preserved with its children enriched. + let orChild = try XCTUnwrap(children.first { $0.operator == FilterOperator.or.rawValue }) + let orGrandchildren = try XCTUnwrap(orChild.value as? [Filter]) + XCTAssertEqual(orGrandchildren.count, 2) + XCTAssertEqual( + orGrandchildren.first { $0.key == "members" }?.keyPathString, + #keyPath(ChannelDTO.members.user.id) + ) + + // Every condition contributes to the predicate (nothing silently dropped). + XCTAssertNotNil(filter.predicate) + } + + func test_predefinedFilter_fromJSONData_nullValue_decodesNilTeam() throws { + let json = #"{"team":null}"#.data(using: .utf8)! + + let filter = try XCTUnwrap(Filter.predefinedFilter(fromJSONData: json)) + + XCTAssertEqual(filter.operator, FilterOperator.equal.rawValue) + XCTAssertEqual(filter.key, "team") + XCTAssertNil(filter.value as? TeamId) + XCTAssertEqual(filter.keyPathString, #keyPath(ChannelDTO.team)) + XCTAssertNotNil(filter.predicate) + } + + func test_predefinedFilter_fromJSONData_nonNullTeam_decodesValueAndKeyPath() throws { + let json = #"{"team":"red"}"#.data(using: .utf8)! + + let filter = try XCTUnwrap(Filter.predefinedFilter(fromJSONData: json)) + + XCTAssertEqual(filter.operator, FilterOperator.equal.rawValue) + XCTAssertEqual(filter.key, "team") + XCTAssertEqual(filter.value as? String, "red") + XCTAssertEqual(filter.keyPathString, #keyPath(ChannelDTO.team)) + XCTAssertNotNil(filter.predicate) + } + + func test_predefinedFilter_fromJSONData_nullTeamInsideGroup_decodesNil() throws { + let json = #"{"$and":[{"team":null},{"type":"messaging"}]}"#.data(using: .utf8)! + + let filter = try XCTUnwrap(Filter.predefinedFilter(fromJSONData: json)) + + XCTAssertEqual(filter.operator, FilterOperator.and.rawValue) + let children = try XCTUnwrap(filter.value as? [Filter]) + let teamChild = try XCTUnwrap(children.first { $0.key == "team" }) + XCTAssertNil(teamChild.value as? TeamId) + XCTAssertEqual(teamChild.keyPathString, #keyPath(ChannelDTO.team)) + XCTAssertNotNil(children.first { $0.key == "type" }) + } + + func test_predefinedFilter_fromJSONData_nullTeamInMultiKey_keepsBothKeys() throws { + let json = #"{"team":null,"type":"messaging"}"#.data(using: .utf8)! + + let filter = try XCTUnwrap(Filter.predefinedFilter(fromJSONData: json)) + + XCTAssertEqual(filter.operator, FilterOperator.and.rawValue) + let children = try XCTUnwrap(filter.value as? [Filter]) + XCTAssertEqual(children.count, 2) + XCTAssertNil(try XCTUnwrap(children.first { $0.key == "team" }).value as? TeamId) + XCTAssertNotNil(children.first { $0.key == "type" }) + } + + func test_predefinedFilter_fromJSONData_unknownKey_passesThrough() throws { + let json = #"{"made_up_field":"x"}"#.data(using: .utf8)! + + let filter = try XCTUnwrap(Filter.predefinedFilter(fromJSONData: json)) + + XCTAssertEqual(filter.operator, FilterOperator.equal.rawValue) + XCTAssertEqual(filter.key, "made_up_field") + XCTAssertEqual(filter.value as? String, "x") + XCTAssertNil(filter.keyPathString) + // Without Core Data wiring the leaf produces no local predicate (rather than crashing). + XCTAssertNil(filter.valueMapper) + XCTAssertNil(filter.predicate) + } + + func test_predefinedFilter_fromJSONData_groupWithUnknownKey_dropsUnwiredLeafFromPredicate() throws { + let json = #"{"$and":[{"type":"messaging"},{"made_up_field":"x"}]}"#.data(using: .utf8)! + + let filter = try XCTUnwrap(Filter.predefinedFilter(fromJSONData: json)) + + // The unknown leaf carries no keyPath, so it contributes no predicate; the known leaf still does. + let predicate = try XCTUnwrap(filter.predicate) + XCTAssertTrue( + predicate.predicateFormat.contains("typeRawValue"), + "Expected the known `type` leaf to drive the predicate; got: \(predicate.predicateFormat)" + ) + XCTAssertFalse( + predicate.predicateFormat.contains("made_up_field"), + "Expected the unknown leaf to be dropped from the predicate; got: \(predicate.predicateFormat)" + ) + } + + func test_predefinedFilter_fromJSONData_predicateMapperKey_isPreserved() throws { + let json = #"{"archived":true}"#.data(using: .utf8)! + + let filter = try XCTUnwrap(Filter.predefinedFilter(fromJSONData: json)) + + XCTAssertEqual(filter.operator, FilterOperator.equal.rawValue) + XCTAssertEqual(filter.key, "archived") + XCTAssertNotNil(filter.predicateMapper) + + let predicate = try XCTUnwrap(filter.predicate) + XCTAssertTrue( + predicate.predicateFormat.contains("archivedAt"), + "Expected predicate to reference archivedAt; got: \(predicate.predicateFormat)" + ) + XCTAssertTrue( + predicate.predicateFormat.contains("!= nil"), + "Expected archived=true to map to `archivedAt != nil`; got: \(predicate.predicateFormat)" + ) + } + + func test_predefinedFilter_fromJSONData_emptyData_returnsNil() throws { + XCTAssertNil(try Filter.predefinedFilter(fromJSONData: Data())) + } + + func test_predefinedFilter_fromJSONData_membersInArray_decodesArrayValueAndKeyPath() throws { + let json = #"{"members":{"$in":["amy","leia","r2-d2"]}}"#.data(using: .utf8)! + + let filter = try XCTUnwrap(Filter.predefinedFilter(fromJSONData: json)) + + XCTAssertEqual(filter.operator, FilterOperator.in.rawValue) + XCTAssertEqual(filter.key, "members") + XCTAssertEqual(filter.value as? [String], ["amy", "leia", "r2-d2"]) + XCTAssertEqual(filter.keyPathString, #keyPath(ChannelDTO.members.user.id)) + XCTAssertNotNil(filter.predicate) + } + + func test_predefinedFilter_fromJSONData_numericGreaterThan_attachesKeyPath() throws { + let json = #"{"member_count":{"$gt":5}}"#.data(using: .utf8)! + + let filter = try XCTUnwrap(Filter.predefinedFilter(fromJSONData: json)) + + XCTAssertEqual(filter.operator, FilterOperator.greater.rawValue) + XCTAssertEqual(filter.key, "member_count") + XCTAssertEqual(filter.value as? Int, 5) + XCTAssertEqual(filter.keyPathString, #keyPath(ChannelDTO.memberCount)) + XCTAssertNotNil(filter.predicate) + } + + func test_predefinedFilter_fromJSONData_dateValue_roundTripsThroughDecoder() throws { + // Build via the DSL + encoder so the ISO8601 string matches CodableHelper's formatter exactly. + let date = Date(timeIntervalSince1970: 1_600_000_000) + let encoded = try JSONEncoder.default.encode(Filter.less(.createdAt, than: date)) + + let filter = try XCTUnwrap(Filter.predefinedFilter(fromJSONData: encoded)) + + XCTAssertEqual(filter.operator, FilterOperator.less.rawValue) + XCTAssertEqual(filter.key, "created_at") + XCTAssertEqual(filter.value as? Date, date) + XCTAssertEqual(filter.keyPathString, #keyPath(ChannelDTO.createdAt)) + XCTAssertNotNil(filter.predicate) + } + + func test_predefinedFilter_fromJSONData_predicateMapperKeysInGroup_enrichBoth() throws { + let json = #"{"$or":[{"archived":true},{"pinned":true}]}"#.data(using: .utf8)! + + let filter = try XCTUnwrap(Filter.predefinedFilter(fromJSONData: json)) + + XCTAssertEqual(filter.operator, FilterOperator.or.rawValue) + let children = try XCTUnwrap(filter.value as? [Filter]) + XCTAssertEqual(children.count, 2) + XCTAssertNotNil(try XCTUnwrap(children.first { $0.key == "archived" }).predicateMapper) + XCTAssertNotNil(try XCTUnwrap(children.first { $0.key == "pinned" }).predicateMapper) + + let predicate = try XCTUnwrap(filter.predicate) + XCTAssertTrue(predicate.predicateFormat.contains("archivedAt"), predicate.predicateFormat) + XCTAssertTrue(predicate.predicateFormat.contains("pinnedAt"), predicate.predicateFormat) + } + + func test_predefinedFilterSort_decodesKnownFields() throws { + let json = #""" + [ + {"field": "last_message_at", "direction": -1}, + {"field": "created_at", "direction": 1} + ] + """#.data(using: .utf8)! + + let sort = try [Sorting].predefinedFilterSort(fromJSONData: json) + + XCTAssertEqual(sort.count, 2) + XCTAssertEqual(sort[0].key.remoteKey, ChannelListSortingKey.lastMessageAt.remoteKey) + XCTAssertEqual(sort[0].direction, -1) + XCTAssertEqual(sort[1].key.remoteKey, ChannelListSortingKey.createdAt.remoteKey) + XCTAssertEqual(sort[1].direction, 1) + } + + func test_predefinedFilterSort_dropsUnknownFields() throws { + let json = #""" + [ + {"field": "made_up_field", "direction": -1}, + {"field": "last_message_at", "direction": -1} + ] + """#.data(using: .utf8)! + + let sort = try [Sorting].predefinedFilterSort(fromJSONData: json) + + XCTAssertEqual(sort.count, 1) + XCTAssertEqual(sort.first?.key.remoteKey, ChannelListSortingKey.lastMessageAt.remoteKey) + } + + func test_predefinedFilterSort_handlesExtraJSONFields() throws { + let json = #""" + [ + {"field": "last_message_at", "direction": -1, "type": "string"} + ] + """#.data(using: .utf8)! + + let sort = try [Sorting].predefinedFilterSort(fromJSONData: json) + + XCTAssertEqual(sort.count, 1) + XCTAssertEqual(sort.first?.key.remoteKey, ChannelListSortingKey.lastMessageAt.remoteKey) + XCTAssertEqual(sort.first?.direction, -1) + } +} diff --git a/Tests/StreamChatTests/Query/ChannelListQuery_Tests.swift b/Tests/StreamChatTests/Query/ChannelListQuery_Tests.swift index cd06bed1158..06f81d9801e 100644 --- a/Tests/StreamChatTests/Query/ChannelListQuery_Tests.swift +++ b/Tests/StreamChatTests/Query/ChannelListQuery_Tests.swift @@ -69,6 +69,115 @@ final class ChannelListQuery_Tests: XCTestCase { XCTAssertEqual(runtimeSorting.count, 3) } + func test_channelListQuery_predefinedFilter_encodedCorrectly() throws { + let pageSize = Int.channelsPageSize + var query = ChannelListQuery( + predefinedFilter: "user_messaging_channels", + filterValues: ["user_id": "user123"], + sortValues: ["sort_field": "last_message_at"], + pageSize: pageSize + ) + query.options = .watch + + let expectedData: [String: Any] = [ + "limit": pageSize, + "predefined_filter": "user_messaging_channels", + "filter_values": ["user_id": "user123"], + "sort_values": ["sort_field": "last_message_at"], + "watch": true + ] + + let expectedJSON = try JSONSerialization.data(withJSONObject: expectedData, options: []) + let encodedJSON = try JSONEncoder.default.encode(query) + + AssertJSONEqual(expectedJSON, encodedJSON) + } + + func test_channelListQuery_predefinedFilter_omitsEmptyValueDictionaries() throws { + let pageSize = Int.channelsPageSize + var query = ChannelListQuery( + predefinedFilter: "user_messaging_channels", + pageSize: pageSize + ) + query.options = .watch + + let expectedData: [String: Any] = [ + "limit": pageSize, + "predefined_filter": "user_messaging_channels", + "watch": true + ] + + let expectedJSON = try JSONSerialization.data(withJSONObject: expectedData, options: []) + let encodedJSON = try JSONEncoder.default.encode(query) + + AssertJSONEqual(expectedJSON, encodedJSON) + } + + func test_channelListQuery_predefinedFilter_setsExpectedProperties() { + let query = ChannelListQuery( + predefinedFilter: "team_channels", + filterValues: ["channel_type": "messaging"], + sortValues: ["sort_field": "created_at"] + ) + + XCTAssertEqual(query.predefinedFilter, "team_channels") + XCTAssertEqual(query.filterValues?["channel_type"], "messaging") + XCTAssertEqual(query.sortValues?["sort_field"], "created_at") + XCTAssertTrue(query.sort.isEmpty) + } + + func test_channelListQuery_traditionalInit_leavesPredefinedFieldsNil() { + let query = ChannelListQuery(filter: .equal(.cid, to: .unique)) + + XCTAssertNil(query.predefinedFilter) + XCTAssertNil(query.filterValues) + XCTAssertNil(query.sortValues) + } + + // MARK: - queryHash + + func test_queryHash_traditionalQuery_equalsFilterFilterHash() { + let filter = Filter.equal(.cid, to: .unique) + let query = ChannelListQuery(filter: filter) + + XCTAssertEqual(query.queryHash, filter.filterHash) + } + + func test_queryHash_predefinedQuery_isStableAcrossKeyOrdering() { + let queryA = ChannelListQuery( + predefinedFilter: "team_channels", + filterValues: ["channel_type": "messaging", "team_name": "engineering", "user_id": "user123"], + sortValues: ["primary_sort": "last_message_at", "secondary_sort": "created_at"] + ) + let queryB = ChannelListQuery( + predefinedFilter: "team_channels", + filterValues: ["user_id": "user123", "team_name": "engineering", "channel_type": "messaging"], + sortValues: ["secondary_sort": "created_at", "primary_sort": "last_message_at"] + ) + + XCTAssertEqual(queryA.queryHash, queryB.queryHash) + } + + func test_queryHash_predefinedQuery_differsWhenFilterValuesDiffer() { + let queryA = ChannelListQuery( + predefinedFilter: "user_messaging_channels", + filterValues: ["user_id": "user123"] + ) + let queryB = ChannelListQuery( + predefinedFilter: "user_messaging_channels", + filterValues: ["user_id": "user456"] + ) + + XCTAssertNotEqual(queryA.queryHash, queryB.queryHash) + } + + func test_queryHash_predefinedQuery_differsFromTraditionalQuery() { + let predefinedQuery = ChannelListQuery(predefinedFilter: "user_messaging_channels") + let traditionalQuery = ChannelListQuery(filter: .and([])) + + XCTAssertNotEqual(predefinedQuery.queryHash, traditionalQuery.queryHash) + } + func test_channelListQuery_encodedOmitsLimitsWhenNil() throws { let cid = ChannelId.unique let filter = Filter.equal(.cid, to: cid) diff --git a/Tests/StreamChatTests/Query/Filter_Tests.swift b/Tests/StreamChatTests/Query/Filter_Tests.swift index 6a87bbbe58a..3afb887d0e0 100644 --- a/Tests/StreamChatTests/Query/Filter_Tests.swift +++ b/Tests/StreamChatTests/Query/Filter_Tests.swift @@ -115,4 +115,129 @@ final class Filter_Tests: XCTestCase { XCTAssertEqual(filter, decoded) } } + + // MARK: - Implicit `$eq` decoding + + func test_filterDecoding_implicitEqual_string() throws { + let filter: Filter = try #"{"name":"general"}"#.deserializeFilterThrows() + XCTAssertEqual(filter.operator, FilterOperator.equal.rawValue) + XCTAssertEqual(filter.key, "name") + XCTAssertEqual(filter.value as? String, "general") + } + + func test_filterDecoding_implicitEqual_bool() throws { + let filter: Filter = try #"{"frozen":true}"#.deserializeFilterThrows() + XCTAssertEqual(filter.operator, FilterOperator.equal.rawValue) + XCTAssertEqual(filter.key, "frozen") + XCTAssertEqual(filter.value as? Bool, true) + } + + func test_filterDecoding_implicitEqual_int() throws { + let filter: Filter = try #"{"member_count":5}"#.deserializeFilterThrows() + XCTAssertEqual(filter.operator, FilterOperator.equal.rawValue) + XCTAssertEqual(filter.key, "member_count") + XCTAssertEqual(filter.value as? Int, 5) + } + + func test_filterDecoding_implicitEqual_array() throws { + let filter: Filter = try #"{"members":["r2-d2","c-3po"]}"#.deserializeFilterThrows() + XCTAssertEqual(filter.operator, FilterOperator.equal.rawValue) + XCTAssertEqual(filter.key, "members") + XCTAssertEqual(filter.value as? [String], ["r2-d2", "c-3po"]) + } + + func test_filterDecoding_longFormStillWorks() throws { + let filter: Filter = try #"{"name":{"$eq":"general"}}"#.deserializeFilterThrows() + XCTAssertEqual(filter.operator, FilterOperator.equal.rawValue) + XCTAssertEqual(filter.key, "name") + XCTAssertEqual(filter.value as? String, "general") + } + + func test_filterDecoding_implicitEqual_double() throws { + let filter: Filter = try #"{"test_key_Double":13.5}"#.deserializeFilterThrows() + XCTAssertEqual(filter.operator, FilterOperator.equal.rawValue) + XCTAssertEqual(filter.key, "test_key_Double") + XCTAssertEqual(filter.value as? Double, 13.5) + } + + func test_filterDecoding_implicitEqual_intArray() throws { + let filter: Filter = try #"{"test_key_ArrayInt":[1,2,3]}"#.deserializeFilterThrows() + XCTAssertEqual(filter.operator, FilterOperator.equal.rawValue) + XCTAssertEqual(filter.key, "test_key_ArrayInt") + XCTAssertEqual(filter.value as? [Int], [1, 2, 3]) + } + + // MARK: - Implicit `$and` decoding + + func test_filterDecoding_mixedFieldAndGroupOperator_decodesAsImplicitAnd() throws { + // A field key and a group-operator key at the same level are ANDed; neither is dropped. + let filter: Filter = try #"{"name":"general","$or":[{"a":"1"},{"b":"2"}]}"#.deserializeFilterThrows() + + XCTAssertEqual(filter.operator, FilterOperator.and.rawValue) + let children = try XCTUnwrap(filter.value as? [Filter]) + XCTAssertEqual(children.count, 2) + XCTAssertNotNil(children.first { $0.key == "name" }) + XCTAssertNotNil(children.first { $0.operator == FilterOperator.or.rawValue }) + } + + func test_filterDecoding_multipleGroupOperators_decodeAsImplicitAnd() throws { + // Two group operators at the same level are ANDed. + let filter: Filter = try #"{"$and":[{"a":"1"}],"$or":[{"b":"2"}]}"#.deserializeFilterThrows() + + XCTAssertEqual(filter.operator, FilterOperator.and.rawValue) + let children = try XCTUnwrap(filter.value as? [Filter]) + XCTAssertEqual(children.count, 2) + XCTAssertNotNil(children.first { $0.operator == FilterOperator.and.rawValue }) + XCTAssertNotNil(children.first { $0.operator == FilterOperator.or.rawValue }) + } + + func test_filterDecoding_implicitAnd_threeBareKeys_decodesAllLeaves() throws { + let filter: Filter = try #"{"a":1,"b":"x","c":true}"#.deserializeFilterThrows() + + XCTAssertEqual(filter.operator, FilterOperator.and.rawValue) + let children = try XCTUnwrap(filter.value as? [Filter]) + XCTAssertEqual(children.count, 3) + XCTAssertEqual(children.first { $0.key == "a" }?.value as? Int, 1) + XCTAssertEqual(children.first { $0.key == "b" }?.value as? String, "x") + XCTAssertEqual(children.first { $0.key == "c" }?.value as? Bool, true) + } + + func test_filterDecoding_implicitMultiKeyNestedInsideGroup_decodesAsImplicitAnd() throws { + // The multi-key → implicit-`$and` rule must apply at every level, not just the top. + let filter: Filter = try #"{"$or":[{"a":1,"b":2},{"c":3}]}"#.deserializeFilterThrows() + + XCTAssertEqual(filter.operator, FilterOperator.or.rawValue) + let orChildren = try XCTUnwrap(filter.value as? [Filter]) + XCTAssertEqual(orChildren.count, 2) + + // The multi-key object decodes to a nested implicit `$and`. + let nestedAnd = try XCTUnwrap(orChildren.first { $0.operator == FilterOperator.and.rawValue }) + let nestedAndChildren = try XCTUnwrap(nestedAnd.value as? [Filter]) + XCTAssertEqual(nestedAndChildren.count, 2) + XCTAssertNotNil(nestedAndChildren.first { $0.key == "a" }) + XCTAssertNotNil(nestedAndChildren.first { $0.key == "b" }) + + // The single-key object stays a plain leaf. + XCTAssertNotNil(orChildren.first { $0.key == "c" }) + } + + func test_filterDecoding_nestedGroups_decodeRecursively() throws { + let filter: Filter = try #"{"$and":[{"$or":[{"a":1}]},{"x":1}]}"#.deserializeFilterThrows() + + XCTAssertEqual(filter.operator, FilterOperator.and.rawValue) + let children = try XCTUnwrap(filter.value as? [Filter]) + XCTAssertEqual(children.count, 2) + XCTAssertNotNil(children.first { $0.operator == FilterOperator.or.rawValue }) + XCTAssertNotNil(children.first { $0.key == "x" }) + } + + func test_filterDecoding_norGroup_decodes() throws { + let filter: Filter = try #"{"$nor":[{"a":1},{"b":2}]}"#.deserializeFilterThrows() + + XCTAssertEqual(filter.operator, FilterOperator.nor.rawValue) + let children = try XCTUnwrap(filter.value as? [Filter]) + XCTAssertEqual(children.count, 2) + XCTAssertNotNil(children.first { $0.key == "a" }) + XCTAssertNotNil(children.first { $0.key == "b" }) + } } diff --git a/Tests/StreamChatTests/Query/Sorting/ListDatabaseObserver+Sorting_Tests.swift b/Tests/StreamChatTests/Query/Sorting/ListDatabaseObserver+Sorting_Tests.swift index 997a96d69b2..5b2817fbf5a 100644 --- a/Tests/StreamChatTests/Query/Sorting/ListDatabaseObserver+Sorting_Tests.swift +++ b/Tests/StreamChatTests/Query/Sorting/ListDatabaseObserver+Sorting_Tests.swift @@ -354,7 +354,7 @@ final class ListDatabaseObserver_Sorting_Tests: XCTestCase { ) } - guard let queryDTO = session.channelListQuery(filterHash: self.query.filter.filterHash) else { + guard let queryDTO = session.channelListQuery(query: self.query) else { return } for channel in channels { diff --git a/Tests/StreamChatTests/StateLayer/ChannelList_Tests.swift b/Tests/StreamChatTests/StateLayer/ChannelList_Tests.swift index 4f7f6102816..a2a67ca8b6c 100644 --- a/Tests/StreamChatTests/StateLayer/ChannelList_Tests.swift +++ b/Tests/StreamChatTests/StateLayer/ChannelList_Tests.swift @@ -31,8 +31,9 @@ final class ChannelList_Tests: XCTestCase { func test_restoringState_whenDatabaseHasEntries_thenStateIsUpdated() async throws { let channelListPayload = makeMatchingChannelListPayload(channelCount: 5, createdAtOffset: 0) + let query = await channelList.state.query try await env.client.mockDatabaseContainer.write { session in - session.saveChannelList(payload: channelListPayload, query: self.channelList.query) + session.saveChannelList(payload: channelListPayload, query: query) } await setUpChannelList(usesMockedChannelUpdater: true) XCTAssertEqual(channelListPayload.channels.map(\.channel.cid.rawValue), await channelList.state.channels.map(\.cid.rawValue)) @@ -41,11 +42,12 @@ final class ChannelList_Tests: XCTestCase { func test_restoringState_whenDatabaseHasEntriesWhichShouldBeIgnored_thenStateOnlyIncludesQueryMatchingResults() async throws { let matchingChannelListPayload = makeMatchingChannelListPayload(channelCount: 5, createdAtOffset: 0) let deletedChannelPayload = makeMatchingChannelPayload(createdAtOffset: 5) + let query = await channelList.state.query try await env.client.mockDatabaseContainer.write { session in // These match with the query - session.saveChannelList(payload: matchingChannelListPayload, query: self.channelList.query) + session.saveChannelList(payload: matchingChannelListPayload, query: query) // Should be ignored because it was deleted - let dto = try session.saveChannel(payload: deletedChannelPayload, query: self.channelList.query, cache: nil) + let dto = try session.saveChannel(payload: deletedChannelPayload, query: query, cache: nil) dto.deletedAt = .unique // Unrelated channel to the query try session.saveChannel(payload: self.dummyPayload(with: .unique)) @@ -59,8 +61,9 @@ final class ChannelList_Tests: XCTestCase { func test_get_whenLocalStoreHasChannels_thenGetResetsChannels() async throws { // Existing state let channelListPayload = makeMatchingChannelListPayload(channelCount: 10, createdAtOffset: 0) + let query = await channelList.state.query try await env.client.mockDatabaseContainer.write { session in - session.saveChannelList(payload: channelListPayload, query: self.channelList.query) + session.saveChannelList(payload: channelListPayload, query: query) } await setUpChannelList(usesMockedChannelUpdater: false) @@ -86,6 +89,112 @@ final class ChannelList_Tests: XCTestCase { await XCTAssertEqual(nextChannelListPayload.channels.map(\.channel.cid.rawValue), channelList.state.channels.map(\.cid.rawValue)) } + // MARK: - Predefined filter resolution + + func test_restoringState_predefinedFilterQuery_appliesCachedResolvedFilterBeforeGet() async throws { + let predefinedQuery = ChannelListQuery( + predefinedFilter: "user_per_channel_type_channels", + filterValues: ["user_id": "r2-d2"] + ) + let resolvedFilterJSON = #"{"type":"messaging"}"#.data(using: .utf8)! + let resolvedSortJSON = #"[{"field":"last_message_at","direction":-1}]"#.data(using: .utf8)! + try await env.client.mockDatabaseContainer.write { session in + let dto = session.saveQuery(query: predefinedQuery) + dto.filterJSONData = resolvedFilterJSON + dto.sortJSONData = resolvedSortJSON + } + + await setUpChannelList(usesMockedChannelUpdater: true, query: predefinedQuery) + + let stateQuery = await channelList.state.query + XCTAssertEqual(stateQuery.filter.key, "type") + XCTAssertEqual(stateQuery.filter.value as? String, "messaging") + XCTAssertEqual(stateQuery.sort.first?.key.remoteKey, ChannelListSortingKey.lastMessageAt.remoteKey) + } + + func test_get_predefinedFilterQuery_appliesResolvedFilterAndMirrorsOnList() async throws { + // GIVEN: a ChannelList built with a predefined query (placeholder filter + empty sort) + let predefinedQuery = ChannelListQuery( + predefinedFilter: "user_per_channel_type_channels", + filterValues: ["user_id": "r2-d2"] + ) + await setUpChannelList(usesMockedChannelUpdater: true, query: predefinedQuery) + + // AND: the DTO carries server-resolved filter/sort JSON + let resolvedFilterJSON = #"{"type":"messaging"}"#.data(using: .utf8)! + let resolvedSortJSON = #"[{"field":"last_message_at","direction":-1}]"#.data(using: .utf8)! + try await env.client.mockDatabaseContainer.write { session in + let dto = session.saveQuery(query: predefinedQuery) + dto.filterJSONData = resolvedFilterJSON + dto.sortJSONData = resolvedSortJSON + } + env.channelListUpdaterMock.update_completion_result = .success([]) + + // WHEN: get() completes + try await channelList.get() + + // THEN: state.query exposes the resolved values + let stateQuery = await channelList.state.query + XCTAssertEqual(stateQuery.filter.key, "type") + XCTAssertEqual(stateQuery.filter.value as? String, "messaging") + XCTAssertEqual(stateQuery.sort.count, 1) + XCTAssertEqual(stateQuery.sort.first?.key.remoteKey, ChannelListSortingKey.lastMessageAt.remoteKey) + XCTAssertEqual(stateQuery.sort.first?.direction, -1) + } + + func test_loadChannels_predefinedFilterQuery_whenRemoteResolutionChangesQuery_rebuildsLinkerWithResolvedQuery() async throws { + let predefinedQuery = ChannelListQuery( + predefinedFilter: .unique, + filterValues: ["user_id": "r2-d2"] + ) + await setUpChannelList(usesMockedChannelUpdater: true, dynamicFilter: { _ in true }, query: predefinedQuery) + + var resolvedQuery = predefinedQuery + resolvedQuery.filter = try XCTUnwrap( + Filter.predefinedFilter(fromJSONData: #"{"type":"messaging"}"#.data(using: .utf8)!) + ) + resolvedQuery.sort = try [Sorting].predefinedFilterSort( + fromJSONData: #"[{"field":"last_message_at","direction":-1}]"#.data(using: .utf8)! + ) + env.channelListUpdaterMock.update_updatedQuery = resolvedQuery + env.channelListUpdaterMock.update_completion_result = .success([]) + + try await channelList.loadChannels(with: .init(pageSize: 5, offset: 0)) + + let incomingChannelPayload = makeMatchingChannelPayload(createdAtOffset: 1) + let incomingCid = incomingChannelPayload.channel.cid + try await env.client.mockDatabaseContainer.write { session in + _ = session.saveQuery(query: resolvedQuery) + try session.saveChannel(payload: incomingChannelPayload) + } + + let event = NotificationAddedToChannelEvent( + channel: .mock(cid: incomingCid), + unreadCount: nil, + member: .mock(id: .unique), + createdAt: .unique + ) + let eventExpectation = XCTestExpectation(description: "Event processed") + env.client.eventNotificationCenter.process([event], completion: { eventExpectation.fulfill() }) + await fulfillment(of: [eventExpectation], timeout: defaultTimeout) + + XCTAssertEqual(env.channelListUpdaterMock.link_queries.map(\.filter.key), ["type"]) + XCTAssertEqual(env.channelListUpdaterMock.link_queries.first?.sort.first?.key.remoteKey, ChannelListSortingKey.lastMessageAt.remoteKey) + } + + func test_get_nonPredefinedQuery_leavesQueryUnchanged() async throws { + await setUpChannelList(usesMockedChannelUpdater: true) + env.channelListUpdaterMock.update_completion_result = .success([]) + + let queryBefore = await channelList.state.query + + try await channelList.get() + + let queryAfter = await channelList.state.query + XCTAssertEqual(queryAfter.filter.filterHash, queryBefore.filter.filterHash) + XCTAssertEqual(queryAfter.sort.map(\.description), queryBefore.sort.map(\.description)) + } + // MARK: - Pagination and Channel Updater Arguments func test_loadChannels_whenChannelUpdaterSucceeds_thenLoadSucceeds() async throws { @@ -97,8 +206,9 @@ final class ChannelList_Tests: XCTestCase { let result = try await channelList.loadChannels(with: pagination) XCTAssertEqual(env.channelListUpdaterMock.update_queries.count, 1) - XCTAssertEqual(env.channelListUpdaterMock.update_queries.first?.filter, channelList.query.filter) - XCTAssertEqual(env.channelListUpdaterMock.update_queries.first?.sort, channelList.query.sort) + let stateQuery = await channelList.state.query + XCTAssertEqual(env.channelListUpdaterMock.update_queries.first?.filter, stateQuery.filter) + XCTAssertEqual(env.channelListUpdaterMock.update_queries.first?.sort, stateQuery.sort) XCTAssertEqual(env.channelListUpdaterMock.update_queries.first?.pagination.pageSize, pageSize) XCTAssertEqual(env.channelListUpdaterMock.update_queries.first?.pagination.offset, 0) XCTAssertEqual(responseChannels, result) @@ -117,12 +227,34 @@ final class ChannelList_Tests: XCTestCase { let result = try await channelList.loadMoreChannels(limit: pageSize) XCTAssertEqual(env.channelListUpdaterMock.update_queries.count, 1) - XCTAssertEqual(env.channelListUpdaterMock.update_queries.first?.filter, channelList.query.filter) - XCTAssertEqual(env.channelListUpdaterMock.update_queries.first?.sort, channelList.query.sort) + let stateQuery = await channelList.state.query + XCTAssertEqual(env.channelListUpdaterMock.update_queries.first?.filter, stateQuery.filter) + XCTAssertEqual(env.channelListUpdaterMock.update_queries.first?.sort, stateQuery.sort) XCTAssertEqual(env.channelListUpdaterMock.update_queries.first?.pagination.pageSize, pageSize) XCTAssertEqual(env.channelListUpdaterMock.update_queries.first?.pagination.offset, 0) XCTAssertEqual(responseChannels, result) } + + func test_loadMoreChannels_predefinedFilterQuery_appliesResolvedFilterAndMirrorsOnList() async throws { + let predefinedQuery = ChannelListQuery( + predefinedFilter: "user_per_channel_type_channels", + filterValues: ["user_id": "r2-d2"] + ) + await setUpChannelList(usesMockedChannelUpdater: true, query: predefinedQuery) + try await env.client.mockDatabaseContainer.write { session in + let dto = session.saveQuery(query: predefinedQuery) + dto.filterJSONData = #"{"type":"messaging"}"#.data(using: .utf8)! + dto.sortJSONData = #"[{"field":"last_message_at","direction":-1}]"#.data(using: .utf8)! + } + env.channelListUpdaterMock.update_completion_result = .success([]) + + try await channelList.loadMoreChannels() + + let stateQuery = await channelList.state.query + XCTAssertEqual(stateQuery.filter.key, "type") + XCTAssertEqual(stateQuery.filter.value as? String, "messaging") + XCTAssertEqual(stateQuery.sort.first?.key.remoteKey, ChannelListSortingKey.lastMessageAt.remoteKey) + } func test_loadMoreChannels_whenChannelUpdaterFails_thenLoadFails() async throws { env.channelListUpdaterMock.update_completion_result = .failure(testError) @@ -146,8 +278,9 @@ final class ChannelList_Tests: XCTestCase { func test_loadMoreChannels_whenAPIRequestSucceeds_thenStateUpdates() async throws { // Initial DB state let existingChannelListPayload = makeMatchingChannelListPayload(channelCount: 2, createdAtOffset: 0) + let query = await channelList.state.query try await env.client.mockDatabaseContainer.write { session in - session.saveChannelList(payload: existingChannelListPayload, query: self.channelList.query) + session.saveChannelList(payload: existingChannelListPayload, query: query) } await setUpChannelList(usesMockedChannelUpdater: false) @@ -234,8 +367,9 @@ final class ChannelList_Tests: XCTestCase { XCTAssertEqual(incomingChannelListPayload.channels.map(\.channel.cid.rawValue), channels.map(\.cid.rawValue)) expectation.fulfill() } + let query = await channelList.state.query try await env.client.mockDatabaseContainer.write { session in - session.saveChannelList(payload: incomingChannelListPayload, query: self.channelList.query) + session.saveChannelList(payload: incomingChannelListPayload, query: query) } await fulfillment(of: [expectation], timeout: defaultTimeout) cancellable.cancel() @@ -263,8 +397,9 @@ final class ChannelList_Tests: XCTestCase { XCTAssertTrue(channels.allSatisfy(\.isPinned), channels.filter { !$0.isPinned }.map(\.cid.rawValue).joined()) expectation.fulfill() } + let query = await channelList.state.query try await env.client.mockDatabaseContainer.write { session in - session.saveChannelList(payload: incomingChannelListPayload, query: self.channelList.query) + session.saveChannelList(payload: incomingChannelListPayload, query: query) } await fulfillment(of: [expectation], timeout: defaultTimeout) cancellable.cancel() @@ -300,8 +435,9 @@ final class ChannelList_Tests: XCTestCase { XCTAssertEqual([true, true, false, false, false], channels.map(\.isPinned)) expectation.fulfill() } + let query = await channelList.state.query try await env.client.mockDatabaseContainer.write { session in - session.saveChannelList(payload: incomingChannelListPayload, query: self.channelList.query) + session.saveChannelList(payload: incomingChannelListPayload, query: query) } await fulfillment(of: [expectation], timeout: defaultTimeout) cancellable.cancel() @@ -336,8 +472,9 @@ final class ChannelList_Tests: XCTestCase { XCTAssertTrue(channels.allSatisfy(\.isArchived), channels.filter { !$0.isArchived }.map(\.cid.rawValue).joined()) expectation.fulfill() } + let query = await channelList.state.query try await env.client.mockDatabaseContainer.write { session in - session.saveChannelList(payload: incomingChannelListPayload, query: self.channelList.query) + session.saveChannelList(payload: incomingChannelListPayload, query: query) } await fulfillment(of: [expectation], timeout: defaultTimeout) cancellable.cancel() @@ -409,8 +546,9 @@ final class ChannelList_Tests: XCTestCase { await setUpChannelList(usesMockedChannelUpdater: false, dynamicFilter: { _ in true }) // Create channel list let existingChannelListPayload = makeMatchingChannelListPayload(channelCount: 1, createdAtOffset: 0) + let query = await channelList.state.query try await env.client.mockDatabaseContainer.write { session in - session.saveChannelList(payload: existingChannelListPayload, query: self.channelList.query) + session.saveChannelList(payload: existingChannelListPayload, query: query) } // New channel event @@ -450,8 +588,9 @@ final class ChannelList_Tests: XCTestCase { // Create channel list let existingChannelListPayload = makeMatchingChannelListPayload(channelCount: 1, createdAtOffset: 0) let existingCid = try XCTUnwrap(existingChannelListPayload.channels.first?.channel.cid) + let query = await channelList.state.query try await env.client.mockDatabaseContainer.write { session in - session.saveChannelList(payload: existingChannelListPayload, query: self.channelList.query) + session.saveChannelList(payload: existingChannelListPayload, query: query) } // Ensure that the channel is in the state XCTAssertEqual(existingChannelListPayload.channels.map(\.channel.cid.rawValue), await channelList.state.channels.map(\.cid.rawValue)) @@ -483,8 +622,9 @@ final class ChannelList_Tests: XCTestCase { let pageCount = 2 let loadedCount = pageCount * Int.channelsPageSize let existingChannelListPayload = makeMatchingChannelListPayload(channelCount: loadedCount, createdAtOffset: 0) + let query = await channelList.state.query try await env.client.mockDatabaseContainer.write { session in - session.saveChannelList(payload: existingChannelListPayload, query: self.channelList.query) + session.saveChannelList(payload: existingChannelListPayload, query: query) } // Ensure that the channel is in the state @@ -515,13 +655,15 @@ final class ChannelList_Tests: XCTestCase { loadState: Bool = true, filter: Filter? = nil, sort: [Sorting] = [.init(key: .createdAt, isAscending: true)], - dynamicFilter: (@Sendable (ChatChannel) -> Bool)? = nil + dynamicFilter: (@Sendable (ChatChannel) -> Bool)? = nil, + query: ChannelListQuery? = nil ) { + let resolvedQuery = query ?? ChannelListQuery( + filter: filter ?? .in(.members, values: [memberId]), + sort: sort + ) channelList = ChannelList( - query: ChannelListQuery( - filter: filter ?? .in(.members, values: [memberId]), - sort: sort - ), + query: resolvedQuery, dynamicFilter: dynamicFilter, client: env.client, environment: env.channelListEnvironment(usesMockedUpdater: usesMockedChannelUpdater) diff --git a/Tests/StreamChatTests/Workers/ChannelListUpdater_Tests.swift b/Tests/StreamChatTests/Workers/ChannelListUpdater_Tests.swift index 833b5fe1991..99a1ec4d4ed 100644 --- a/Tests/StreamChatTests/Workers/ChannelListUpdater_Tests.swift +++ b/Tests/StreamChatTests/Workers/ChannelListUpdater_Tests.swift @@ -112,7 +112,7 @@ final class ChannelListUpdater_Tests: XCTestCase { // Assert the data is stored in the DB var queryDTO: ChannelListQueryDTO? { - database.viewContext.channelListQuery(filterHash: query.filter.filterHash) + database.viewContext.channelListQuery(query: query) } AssertAsync { Assert.willBeTrue(queryDTO != nil) @@ -134,7 +134,7 @@ final class ChannelListUpdater_Tests: XCTestCase { var channelsFromQuery: [ChatChannel] { database.viewContext.channelListQuery( - filterHash: query.filter.filterHash + query: query )?.channels.compactMap { try? $0.asModel() } ?? [] } @@ -170,7 +170,7 @@ final class ChannelListUpdater_Tests: XCTestCase { var channelsFromQuery: [ChatChannel] { database.viewContext.channelListQuery( - filterHash: query.filter.filterHash + query: query )?.channels.compactMap { try? $0.asModel() } ?? [] } @@ -206,7 +206,7 @@ final class ChannelListUpdater_Tests: XCTestCase { var channelsFromQuery: [ChatChannel] { database.viewContext.channelListQuery( - filterHash: query.filter.filterHash + query: query )?.channels.compactMap { try? $0.asModel() } ?? [] } @@ -410,7 +410,7 @@ final class ChannelListUpdater_Tests: XCTestCase { var channelsInQuery: [ChatChannel] { database.viewContext.channelListQuery( - filterHash: query.filter.filterHash + query: query )?.channels.compactMap { try? $0.asModel() } ?? [] } @@ -432,7 +432,7 @@ final class ChannelListUpdater_Tests: XCTestCase { var channelsInQuery: [ChatChannel] { database.viewContext.channelListQuery( - filterHash: query.filter.filterHash + query: query )?.channels.compactMap { try? $0.asModel() } ?? [] } @@ -449,7 +449,59 @@ final class ChannelListUpdater_Tests: XCTestCase { private func channels(for query: ChannelListQuery, database: DatabaseContainer) -> Set { let request = NSFetchRequest(entityName: ChannelListQueryDTO.entityName) - request.predicate = NSPredicate(format: "filterHash == %@", query.filter.filterHash) + request.predicate = NSPredicate(format: "filterHash == %@", query.queryHash) return (try? database.viewContext.fetch(request).first)?.channels ?? Set() } + + // MARK: - Update Predefined Filter + + func test_update_predefinedFilterPayload_returnsQueryWithDecodedFilterAndSort() throws { + let query = ChannelListQuery( + predefinedFilter: "user_per_channel_type_channels", + filterValues: ["user_id": "r2-d2"] + ) + let payload = PredefinedFilterPayload( + name: "user_per_channel_type_channels", + filter: ["type": .string("messaging")], + sort: [["field": .string("last_message_at"), "direction": .number(-1)]] + ) + let response = ChannelListPayload( + channels: [], + predefinedFilter: payload + ) + let expectation = expectation(description: "update completes") + nonisolated(unsafe) var captured: Result? + + listUpdater.update(channelListQuery: query) { result in + captured = result + expectation.fulfill() + } + apiClient.test_simulateResponse(.success(response)) + wait(for: [expectation], timeout: defaultTimeout) + + let result = try XCTUnwrap(captured).get() + let updated = try XCTUnwrap(result.updatedQuery) + XCTAssertEqual(updated.filter.key, "type") + XCTAssertEqual(updated.filter.value as? String, "messaging") + XCTAssertEqual(updated.sort.count, 1) + XCTAssertEqual(updated.sort.first?.key.remoteKey, ChannelListSortingKey.lastMessageAt.remoteKey) + XCTAssertEqual(updated.sort.first?.direction, -1) + } + + func test_update_whenResolvedQueryDoesNotChange_returnsNilQuery() throws { + let query = ChannelListQuery(filter: .in(.members, values: [.unique])) + let response = ChannelListPayload(channels: []) + let expectation = expectation(description: "update completes") + nonisolated(unsafe) var captured: Result? + + listUpdater.update(channelListQuery: query) { result in + captured = result + expectation.fulfill() + } + apiClient.test_simulateResponse(.success(response)) + wait(for: [expectation], timeout: defaultTimeout) + + let result = try XCTUnwrap(captured).get() + XCTAssertNil(result.updatedQuery) + } }