diff --git a/.github/workflows/smoke-checks.yml b/.github/workflows/smoke-checks.yml index 4702ea6f6b4..995713a8bc2 100644 --- a/.github/workflows/smoke-checks.yml +++ b/.github/workflows/smoke-checks.yml @@ -62,6 +62,7 @@ jobs: - run: bundle exec fastlane validate_public_interface - run: bundle exec fastlane pod_lint if: startsWith(github.event.pull_request.head.ref, 'release/') + - run: bundle exec fastlane validate_generated_code build-old-xcode: name: Build SDKs (Old Xcode) diff --git a/.swiftlint.yml b/.swiftlint.yml index 2a1dfd6cd69..da627bf39d2 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,5 +1,6 @@ excluded: - Scripts + - Sources/StreamChat/Generated - Sources/StreamChatUI/Generated - Sources/StreamChatUI/StreamSwiftyGif - Sources/StreamChatUI/StreamNuke diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d1e6b692bc..34bf6547da4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ 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 [#4120](https://github.com/GetStream/stream-chat-swift/pull/4120) ### 🐞 Fixed - Fix WebSocket reconnection getting stuck in `.disconnecting` after the device temporarily loses network connectivity [#4109](https://github.com/GetStream/stream-chat-swift/pull/4109) diff --git a/DemoApp/StreamChat/Components/DemoChatChannelListVC.swift b/DemoApp/StreamChat/Components/DemoChatChannelListVC.swift index 7c9880634cb..275e0086b0a 100644 --- a/DemoApp/StreamChat/Components/DemoChatChannelListVC.swift +++ b/DemoApp/StreamChat/Components/DemoChatChannelListVC.swift @@ -110,6 +110,36 @@ 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 predefinedArchivedChannelsQuery: ChannelListQuery = .init( + predefinedFilter: "user_per_channel_type_archived_hidden", + filterValues: [ + "channel_type": .string(ChannelType.messaging.rawValue), + "hidden": .bool(false), + "user_id": .string(currentUserId), + "archived": .bool(true) + ], + sortValues: nil + ) + + lazy var predefinedHiddenChannelsQuery: ChannelListQuery = .init( + predefinedFilter: "user_per_channel_type_archived_hidden", + filterValues: [ + "channel_type": .string(ChannelType.messaging.rawValue), + "hidden": .bool(true), + "user_id": .string(currentUserId), + "archived": .bool(false) + ], + sortValues: nil + ) + + lazy var livestreamChannelsQuery: ChannelListQuery = .init(filter: .equal(.type, to: .livestream)) + var demoRouter: DemoChatChannelListRouter? { router as? DemoChatChannelListRouter } @@ -268,6 +298,41 @@ final class DemoChatChannelListVC: ChatChannelListVC { } ) + let predefinedMessagingChannelsAction = UIAlertAction( + title: "Messaging (Predefined)", + style: .default, + handler: { [weak self] _ in + self?.title = "Messaging (Predefined)" + self?.setPredefinedMessagingChannelsQuery() + } + ) + + let predefinedArchivedChannelsAction = UIAlertAction( + title: "Archived (Predefined)", + style: .default, + handler: { [weak self] _ in + self?.title = "Archived (Predefined)" + self?.setPredefinedArchivedChannelsQuery() + } + ) + + let predefinedHiddenChannelsAction = UIAlertAction( + title: "Hidden (Predefined)", + style: .default, + handler: { [weak self] _ in + self?.title = "Hidden (Predefined)" + self?.setPredefinedHiddenChannelsQuery() + } + ) + + let livestreamChannelsAction = UIAlertAction( + title: "Livestream Channels", + style: .default + ) { [weak self] _ in + self?.title = "Livestream Channels" + self?.setLivestreamChannelsQuery() + } + presentAlert( title: "Filter Channels", actions: [ @@ -283,7 +348,11 @@ final class DemoChatChannelListVC: ChatChannelListVC { archivedChannelsAction, equalMembersAction, channelRoleChannelsAction, - taggedChannelsAction + taggedChannelsAction, + predefinedMessagingChannelsAction, + predefinedArchivedChannelsAction, + predefinedHiddenChannelsAction, + livestreamChannelsAction ].sorted(by: { $0.title ?? "" < $1.title ?? "" }), preferredStyle: .actionSheet, sourceView: filterChannelsButton @@ -344,6 +413,22 @@ final class DemoChatChannelListVC: ChatChannelListVC { replaceQuery(premiumTaggedChannelsQuery) } + func setPredefinedMessagingChannelsQuery() { + replaceQuery(predefinedMessagingChannelsQuery) + } + + func setPredefinedArchivedChannelsQuery() { + replaceQuery(predefinedArchivedChannelsQuery) + } + + func setPredefinedHiddenChannelsQuery() { + replaceQuery(predefinedHiddenChannelsQuery) + } + + func setLivestreamChannelsQuery() { + replaceQuery(livestreamChannelsQuery) + } + func setInitialChannelsQuery() { replaceQuery(initialQuery) } diff --git a/Githubfile b/Githubfile index 144dffafb0c..32b54a161b6 100644 --- a/Githubfile +++ b/Githubfile @@ -10,3 +10,4 @@ export INTERFACE_ANALYZER_VERSION='1.0.7' export SWIFT_LINT_VERSION='0.59.1' export SWIFT_FORMAT_VERSION='0.58.2' export SWIFT_GEN_VERSION='6.5.1' +export SOURCERY_VERSION='2.3.0' diff --git a/Makefile b/Makefile index fe5180b7e7d..d641efc989f 100644 --- a/Makefile +++ b/Makefile @@ -3,6 +3,9 @@ MAKEFLAGS += --silent bootstrap: ./Scripts/bootstrap.sh +generate: + sourcery --config Sources/StreamChat/.sourcery.yml + all_artifacts: echo "🏁 Starting at $$(date +%T)" make frameworks diff --git a/Package.swift b/Package.swift index fda25c4d214..8d2f8a6cb63 100644 --- a/Package.swift +++ b/Package.swift @@ -30,7 +30,7 @@ let package = Package( targets: [ .target( name: "StreamChat", - exclude: ["Info.plist"], + exclude: ["Info.plist", "Generated/PredefinedFilter.stencil"], resources: [.copy("Database/StreamChatModel.xcdatamodeld")] ), .target( diff --git a/Scripts/bootstrap.sh b/Scripts/bootstrap.sh index 122115dd34a..30481fd1473 100755 --- a/Scripts/bootstrap.sh +++ b/Scripts/bootstrap.sh @@ -57,6 +57,19 @@ if [ "${SKIP_SWIFT_BOOTSTRAP:-}" != true ]; then sudo sudo rm -f "$BIN_PATH" sudo sudo ln -s "$INSTALL_DIR/bin/swiftgen" "$BIN_PATH" swiftgen --version + + puts "Install Sourcery v${SOURCERY_VERSION}" + DOWNLOAD_URL="https://github.com/krzysztofzablocki/Sourcery/releases/download/${SOURCERY_VERSION}/sourcery-${SOURCERY_VERSION}.zip" + DOWNLOAD_PATH="/tmp/sourcery-${SOURCERY_VERSION}.zip" + INSTALL_DIR="/usr/local/lib/sourcery" + BIN_PATH="/usr/local/bin/sourcery" + wget "$DOWNLOAD_URL" -O "$DOWNLOAD_PATH" + sudo rm -rf "$INSTALL_DIR" + sudo mkdir -p "$INSTALL_DIR" + sudo unzip -o "$DOWNLOAD_PATH" -d "$INSTALL_DIR" + sudo rm -f "$BIN_PATH" + sudo ln -s "$INSTALL_DIR/bin/sourcery" "$BIN_PATH" + sourcery --version fi if [[ ${INSTALL_SONAR-default} == true ]]; then diff --git a/Sources/StreamChat/.sourcery.yml b/Sources/StreamChat/.sourcery.yml new file mode 100644 index 00000000000..65c79933e4e --- /dev/null +++ b/Sources/StreamChat/.sourcery.yml @@ -0,0 +1,7 @@ +sources: + - ./Query/ChannelListQuery.swift + - ./Query/Sorting/ChannelListSortingKey.swift +templates: + - ./Generated/PredefinedFilter.stencil +output: + ./Generated/PredefinedFilter+Generated.swift diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/ChannelListPayload.swift index 7a8a9020ff3..6103e93642b 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 50e3d7451c2..69cf6bcc500 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 { /// 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 @@ -82,8 +82,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() }, @@ -113,7 +120,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 @@ -158,7 +165,9 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt startChannelListObserverIfNeeded() channelListLinker.start(with: client.eventNotificationCenter) client.syncRepository.startTrackingChannelListController(self) - updateChannelList(completion) + updateChannelList { [weak self] result in + self?.callback { completion?(result.error) } + } } // MARK: - Actions @@ -184,9 +193,9 @@ 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 self.callback { completion?(nil) } case let .failure(error): self.callback { completion?(error) } @@ -213,24 +222,30 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt // MARK: - Helpers private func updateChannelList( - _ completion: ((_ error: Error?) -> Void)? = nil + _ completion: ((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)) } } } } @@ -278,6 +293,16 @@ 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() + 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 6952bbba100..0b294d0c472 100644 --- a/Sources/StreamChat/Database/DTOs/ChannelDTO.swift +++ b/Sources/StreamChat/Database/DTOs/ChannelDTO.swift @@ -227,7 +227,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 @@ -441,7 +441,7 @@ extension NSManagedObjectContext { } func delete(query: ChannelListQuery) { - guard let dto = channelListQuery(filterHash: query.filter.filterHash) else { return } + guard let dto = channelListQuery(query) else { return } delete(dto) } @@ -474,7 +474,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 0b977f305a2..a01d4251556 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 } @@ -35,33 +38,77 @@ 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) 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 { + if let sort = try [Sorting].predefinedFilterSort(fromJSONData: sortJSONData) { + updated.sort = sort + } + } 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) { + 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 111e08468a6..e40af7d3d07 100644 --- a/Sources/StreamChat/Database/DatabaseSession.swift +++ b/Sources/StreamChat/Database/DatabaseSession.swift @@ -349,16 +349,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 `ChannelListQueryDTO` corresponding to the given `ChannelListQuery` (looked up by `queryHash`). + /// - Parameter query: The channel list query. + 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? @@ -373,6 +377,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 cb91d28b21f..c47748612fa 100644 --- a/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents +++ b/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents @@ -117,6 +117,7 @@ + diff --git a/Sources/StreamChat/Generated/PredefinedFilter+Generated.swift b/Sources/StreamChat/Generated/PredefinedFilter+Generated.swift new file mode 100644 index 00000000000..88ce129f14f --- /dev/null +++ b/Sources/StreamChat/Generated/PredefinedFilter+Generated.swift @@ -0,0 +1,76 @@ +// Generated using Sourcery 2.3.0 — https://github.com/krzysztofzablocki/Sourcery +// DO NOT EDIT + +// Run `make generate` after changing channel-list FilterKey or ChannelListSortingKey members. + +import Foundation + +extension ChannelListFilterScope { + /// Enrichment closures keyed by server-side `rawValue`; each re-attaches a `FilterKey`'s local + /// wiring (key path, value/predicate mappers) to a decoded predefined-filter leaf. Stored as + /// closures because the keys differ in their generic `Value` type. + static let predefinedFilterKeyMapping: [String: @Sendable (Filter) -> Filter] = { + func map(_ key: FilterKey) -> (String, @Sendable (Filter) -> Filter) { + ( + 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), + ]) + }() +} + +extension ChannelListSortingKey { + static let predefinedSortingKeyMapping: [String: Self] = { + func map(_ key: Self) -> (String, Self) { (key.remoteKey, key) } + return Dictionary(uniqueKeysWithValues: [ + map(.default), + map(.cid), + map(.createdAt), + map(.hasUnread), + map(.lastMessageAt), + map(.memberCount), + map(.pinnedAt), + map(.unreadCount), + map(.updatedAt), + ]) + }() +} diff --git a/Sources/StreamChat/Generated/PredefinedFilter.stencil b/Sources/StreamChat/Generated/PredefinedFilter.stencil new file mode 100644 index 00000000000..f587af0b4c6 --- /dev/null +++ b/Sources/StreamChat/Generated/PredefinedFilter.stencil @@ -0,0 +1,39 @@ +// Run `make generate` after changing channel-list FilterKey or ChannelListSortingKey members. + +import Foundation + +extension ChannelListFilterScope { + /// Enrichment closures keyed by server-side `rawValue`; each re-attaches a `FilterKey`'s local + /// wiring (key path, value/predicate mappers) to a decoded predefined-filter leaf. Stored as + /// closures because the keys differ in their generic `Value` type. + static let predefinedFilterKeyMapping: [String: @Sendable (Filter) -> Filter] = { + func map(_ key: FilterKey) -> (String, @Sendable (Filter) -> Filter) { + ( + 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: [ +{% for type in types.all %}{% if type.name == "FilterKey" %}{% for variable in type.staticVariables|sorted:"name" %} map(.{{ variable.name|replace:"`","" }}), +{% endfor %}{% endif %}{% endfor %} ]) + }() +} + +extension ChannelListSortingKey { + static let predefinedSortingKeyMapping: [String: Self] = { + func map(_ key: Self) -> (String, Self) { (key.remoteKey, key) } + return Dictionary(uniqueKeysWithValues: [ +{% for type in types.all %}{% for variable in type.staticVariables|sorted:"name" %}{% if variable.definedInTypeName.name == "ChannelListSortingKey" and variable.typeName.name == "Self" %} map(.{{ variable.name|replace:"`","" }}), +{% endif %}{% endfor %}{% endfor %} ]) + }() +} diff --git a/Sources/StreamChat/Query/ChannelListQuery+PredefinedFilter.swift b/Sources/StreamChat/Query/ChannelListQuery+PredefinedFilter.swift new file mode 100644 index 00000000000..8bf2f272c1c --- /dev/null +++ b/Sources/StreamChat/Query/ChannelListQuery+PredefinedFilter.swift @@ -0,0 +1,62 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Foundation + +// 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. + 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 { + 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, ...}, ...]`). + static func predefinedFilterSort(fromJSONData data: Data) throws -> [Sorting]? { + guard !data.isEmpty else { return nil } + let raw = try JSONDecoder.default.decode([RawSortingItem].self, from: data) + return raw.compactMap { item in + guard let key = ChannelListSortingKey.predefinedSortingKeyMapping[item.field] else { + log.error("Can't apply CoreData keyPath for channel list sorting key '\(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 eda6a285deb..9bf090c2b19 100644 --- a/Sources/StreamChat/Query/ChannelListQuery.swift +++ b/Sources/StreamChat/Query/ChannelListQuery.swift @@ -17,12 +17,15 @@ public struct ChannelListQuery: Encodable, LocalConvertibleSortingQuery { 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, LocalConvertibleSortingQuery { 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, LocalConvertibleSortingQuery { 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 { @@ -71,6 +127,35 @@ public struct ChannelListQuery: Encodable, LocalConvertibleSortingQuery { try options.encode(to: encoder) try pagination.encode(to: encoder) } + + /// The stable identity used for locating / linking the corresponding `ChannelListQueryDTO`. + /// + /// For predefined-filter queries it 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 + } +} + +extension ChannelListQuery { + /// 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 { + guard filter.filterHash == other.filter.filterHash else { return false } + guard sort.map(\.description) == other.sort.map(\.description) else { return false } + return true + } } extension ChannelListQuery: CustomDebugStringConvertible { diff --git a/Sources/StreamChat/Query/Filter.swift b/Sources/StreamChat/Query/Filter.swift index 109ec0641c6..c479a80c917 100644 --- a/Sources/StreamChat/Query/Filter.swift +++ b/Sources/StreamChat/Query/Filter.swift @@ -531,35 +531,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 ) } } @@ -614,34 +654,37 @@ private struct FilterRightSide: Decodable { } self.operator = container.allKeys.first!.stringValue - var value: FilterValue? - - 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 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 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/Repositories/SyncRepository.swift b/Sources/StreamChat/Repositories/SyncRepository.swift index 137a338b5ed..d3e53683d87 100644 --- a/Sources/StreamChat/Repositories/SyncRepository.swift +++ b/Sources/StreamChat/Repositories/SyncRepository.swift @@ -193,7 +193,7 @@ class SyncRepository { // 2. Refresh channel lists operations.append(contentsOf: activeChannelLists.allObjects.map { RefreshChannelListOperation(channelList: $0, context: context) }) operations.append(contentsOf: activeChannelListControllers.allObjects.map { RefreshChannelListOperation(controller: $0, context: context) }) - + // 3. /sync (for channels what not part of active channel lists) operations.append(SyncEventsOperation(syncRepository: self, context: context, recovery: false)) diff --git a/Sources/StreamChat/StateLayer/ChannelList.swift b/Sources/StreamChat/StateLayer/ChannelList.swift index 08e2be59d3c..8c8054e62cd 100644 --- a/Sources/StreamChat/StateLayer/ChannelList.swift +++ b/Sources/StreamChat/StateLayer/ChannelList.swift @@ -10,7 +10,7 @@ public class ChannelList { private let client: ChatClient private let stateBuilder: StateBuilder let query: ChannelListQuery - + init( query: ChannelListQuery, dynamicFilter: ((ChatChannel) -> Bool)?, @@ -36,25 +36,25 @@ public class ChannelList { ) } } - + // MARK: - Accessing the State - + /// An observable object representing the current state of the channel list. @MainActor public lazy var state: ChannelListState = stateBuilder.build() - + /// Fetches the most recent state from the server and updates the local store. /// /// - Important: Loaded channels in ``ChannelListState/channels`` are reset. /// /// - 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``. /// /// - Important: If the pagination offset is 0 and cursor is nil, then loaded channels are reset. @@ -64,9 +64,15 @@ public class ChannelList { /// - 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``. /// /// - Parameter limit: The limit for the page size. The default limit is 20. @@ -74,19 +80,17 @@ public class ChannelList { /// - 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) } } @@ -97,7 +101,7 @@ extension ChannelList { _ database: DatabaseContainer, _ apiClient: APIClient ) -> ChannelListUpdater = ChannelListUpdater.init - + var stateBuilder: @MainActor ( _ query: ChannelListQuery, _ dynamicFilter: ((ChatChannel) -> Bool)?, diff --git a/Sources/StreamChat/StateLayer/ChannelListState+Observer.swift b/Sources/StreamChat/StateLayer/ChannelListState+Observer.swift index a279601c720..af90bdefedf 100644 --- a/Sources/StreamChat/StateLayer/ChannelListState+Observer.swift +++ b/Sources/StreamChat/StateLayer/ChannelListState+Observer.swift @@ -6,14 +6,15 @@ import Foundation extension ChannelListState { final class Observer { - private let channelListObserver: StateLayerDatabaseObserver + private var channelListObserver: StateLayerDatabaseObserver private let clientConfig: ChatClientConfig private let channelListLinker: ChannelListLinker private let channelListUpdater: ChannelListUpdater private let database: DatabaseContainer private let dynamicFilter: ((ChatChannel) -> Bool)? private let eventNotificationCenter: EventNotificationCenter - private let query: ChannelListQuery + private var query: ChannelListQuery + private var channelsDidChange: (@MainActor (StreamCollection) async -> Void)? init( query: ChannelListQuery, @@ -31,15 +32,10 @@ extension ChannelListState { self.query = query self.eventNotificationCenter = eventNotificationCenter - channelListObserver = StateLayerDatabaseObserver( + 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, @@ -56,6 +52,7 @@ extension ChannelListState { } func start(with handlers: Handlers) -> StreamCollection { + channelsDidChange = handlers.channelsDidChange do { channelListLinker.start(with: eventNotificationCenter) return try channelListObserver.startObserving(didChange: handlers.channelsDidChange) @@ -64,5 +61,38 @@ extension ChannelListState { return StreamCollection([]) } } + + func reload(with newQuery: ChannelListQuery) -> StreamCollection { + query = newQuery + channelListObserver = Self.makeChannelListObserver( + for: newQuery, + database: database, + clientConfig: clientConfig + ) + guard let channelsDidChange else { return StreamCollection([]) } + do { + return try channelListObserver.startObserving(didChange: channelsDidChange) + } catch { + log.error("Failed to restart the channel list observer after reload for query: \(newQuery)") + return StreamCollection([]) + } + } + + 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 + ) + } } } diff --git a/Sources/StreamChat/StateLayer/ChannelListState.swift b/Sources/StreamChat/StateLayer/ChannelListState.swift index 6d805e414d4..72052731cfa 100644 --- a/Sources/StreamChat/StateLayer/ChannelListState.swift +++ b/Sources/StreamChat/StateLayer/ChannelListState.swift @@ -17,6 +17,7 @@ import Foundation eventNotificationCenter: EventNotificationCenter, channelWatcherHandler: ChannelWatcherHandling ) { + let query = channelListUpdater.loadPredefinedFilter(for: query) ?? query self.query = query observer = Observer( query: query, @@ -33,8 +34,13 @@ import Foundation } /// 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 = StreamCollection([]) + + 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 93035c01674..06062328c18 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 { /// Makes a channels query call to the backend and updates the local storage with the results. @@ -14,7 +26,7 @@ class ChannelListUpdater: Worker { /// func update( channelListQuery: ChannelListQuery, - completion: ((Result<[ChatChannel], Error>) -> Void)? = nil + completion: ((Result) -> Void)? = nil ) { fetch(channelListQuery: channelListQuery) { [weak self] in switch $0 { @@ -23,8 +35,7 @@ class ChannelListUpdater: Worker { var initialActions: ((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(channelListQuery) else { return } queryDTO.channels.removeAll() } } @@ -41,12 +52,22 @@ class ChannelListUpdater: Worker { } } + /// 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 (Result, Error>) -> Void) { guard channelCount > 0 else { completion(.success(Set())) return } - + var allPages = [ChannelListQuery]() let pageSize = query.pagination.pageSize > 0 ? query.pagination.pageSize : .channelsPageSize for offset in stride(from: 0, to: channelCount, by: pageSize) { @@ -56,7 +77,7 @@ class ChannelListUpdater: Worker { } refreshLoadedChannels(for: allPages, refreshedChannelIds: Set(), completion: completion) } - + func refreshLoadedChannels(for query: ChannelListQuery, channelCount: Int) async throws -> Set { try await withCheckedThrowingContinuation { continuation in refreshLoadedChannels(for: query, channelCount: channelCount) { result in @@ -64,13 +85,13 @@ class ChannelListUpdater: Worker { } } } - + private func refreshLoadedChannels(for pageQueries: [ChannelListQuery], refreshedChannelIds: Set, completion: @escaping (Result, Error>) -> Void) { guard let nextQuery = pageQueries.first else { completion(.success(refreshedChannelIds)) return } - + let remaining = pageQueries.dropFirst() fetch(channelListQuery: nextQuery) { [weak self] result in switch result { @@ -80,10 +101,10 @@ class ChannelListUpdater: Worker { query: nextQuery, completion: { 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 { extension DatabaseSession { func getChannelWithQuery(cid: ChannelId, query: ChannelListQuery) -> (ChannelDTO, ChannelListQueryDTO)? { - guard let queryDTO = channelListQuery(filterHash: query.filter.filterHash) else { + guard let queryDTO = channelListQuery(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: ((DatabaseSession) -> Void)? = nil, - completion: ((Result<[ChatChannel], Error>) -> Void)? = nil + completion: ((Result) -> Void)? = nil ) { var channels: [ChatChannel] = [] + 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 87f2f7f6de5..127d3d753a1 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -2263,6 +2263,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, @@ -2308,6 +2309,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 9aa56b4790f..d3d67b9ba34 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) } @@ -379,12 +383,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) } func loadAllChannelListQueries() -> [ChannelListQueryDTO] { diff --git a/TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift b/TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift index 3ea7847969a..7e0ff4551d6 100644 --- a/TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift +++ b/TestTools/StreamChatTestTools/SpyPattern/Spy/ChannelListUpdater_Spy.swift @@ -47,11 +47,18 @@ final class ChannelListUpdater_Spy: ChannelListUpdater, Spy { override func update( channelListQuery: ChannelListQuery, - completion: ((Result<[ChatChannel], Error>) -> Void)? = nil + completion: ((Result) -> Void)? = nil ) { _update_queries.mutate { $0.append(channelListQuery) } - update_completion = completion - update_completion_result?.invoke(with: completion) + let resolvedQuery = loadPredefinedFilter(for: channelListQuery) + update_completion = { result in + let changedQuery: ChannelListQuery? = { + 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) { diff --git a/TestTools/StreamChatTestTools/TestData/FilterTestScope.swift b/TestTools/StreamChatTestTools/TestData/FilterTestScope.swift index af7a780336a..6e8f91c2cab 100644 --- a/TestTools/StreamChatTestTools/TestData/FilterTestScope.swift +++ b/TestTools/StreamChatTestTools/TestData/FilterTestScope.swift @@ -59,14 +59,15 @@ struct FilterCodingTestPair { .lessOrEqualDouble(), .inArrayInt(), .inArrayDouble(), - .notInArrayString(), + .inArrayString(), + .equalArrayInt(), .query(), .autocomplete(), .existsTrue(), .notExists(), .containsAndEqual(), .greaterOrLess(), - .nonEqualNorEqual() + .nor() ] } @@ -192,11 +193,23 @@ extension FilterCodingTestPair { return FilterCodingTestPair(json: json, filter: filter) } - static func nonEqualNorEqual() -> FilterCodingTestPair { - let json = #"{"$nor":[{"test_key_Bool":{"$ne":true}},{"test_key_Double":{"$eq":678.89999999999998}}]}"# + 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([ - .notEqual(.testKeyBool, to: true), - .equal(.testKeyDouble, to: 678.9) + .equal(.testKeyInt, to: 1), + .equal(.testKeyBool, to: true) ]) return FilterCodingTestPair(json: json, filter: filter) } diff --git a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/ChannelListPayload_Tests.swift index 904fb0aae1a..6220974e125 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 d08403255b9..c27fe375972 100644 --- a/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/ChannelListController/ChannelListController_Tests.swift @@ -265,6 +265,96 @@ 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_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 { @@ -842,6 +932,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() { diff --git a/Tests/StreamChatTests/Database/DTOs/ChannelListQueryDTO_Tests.swift b/Tests/StreamChatTests/Database/DTOs/ChannelListQueryDTO_Tests.swift new file mode 100644 index 00000000000..42f0278d5ba --- /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)) + 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)) + 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)) + 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..d4f1c57f1ea --- /dev/null +++ b/Tests/StreamChatTests/Query/ChannelListQuery_PredefinedFilter_Tests.swift @@ -0,0 +1,367 @@ +// +// 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) + // Guards against another `FilterKey` scope leaking into the generated mapping. + XCTAssertEqual(ChannelListFilterScope.predefinedFilterKeyMapping.count, expectedKeys.count) + } + + 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) + // Guards against another sorting-key model leaking into the generated mapping. + XCTAssertEqual(ChannelListSortingKey.predefinedSortingKeyMapping.count, expectedKeys.count) + 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 XCTUnwrap([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 XCTUnwrap([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 XCTUnwrap([Sorting].predefinedFilterSort(fromJSONData: json)) + + XCTAssertEqual(sort.count, 1) + XCTAssertEqual(sort.first?.key.remoteKey, ChannelListSortingKey.lastMessageAt.remoteKey) + XCTAssertEqual(sort.first?.direction, -1) + } + + func test_predefinedFilterSort_emptyData_returnsNil() throws { + XCTAssertNil(try [Sorting].predefinedFilterSort(fromJSONData: Data())) + } +} 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 ffd9c45e250..a9fe9e56128 100644 --- a/Tests/StreamChatTests/Query/Filter_Tests.swift +++ b/Tests/StreamChatTests/Query/Filter_Tests.swift @@ -125,4 +125,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 253e379dbc3..daed82e4e1c 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(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 58f689d72b8..a57c056c2c4 100644 --- a/Tests/StreamChatTests/StateLayer/ChannelList_Tests.swift +++ b/Tests/StreamChatTests/StateLayer/ChannelList_Tests.swift @@ -86,6 +86,72 @@ 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_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 { @@ -123,6 +189,27 @@ final class ChannelList_Tests: XCTestCase { 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) @@ -515,13 +602,15 @@ final class ChannelList_Tests: XCTestCase { loadState: Bool = true, filter: Filter? = nil, sort: [Sorting] = [.init(key: .createdAt, isAscending: true)], - dynamicFilter: ((ChatChannel) -> Bool)? = nil + dynamicFilter: ((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 4ab687ff847..51faec4f6ab 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) } AssertAsync { Assert.willBeTrue(queryDTO != nil) @@ -133,9 +133,7 @@ final class ChannelListUpdater_Tests: XCTestCase { } var channelsFromQuery: [ChatChannel] { - database.viewContext.channelListQuery( - filterHash: query.filter.filterHash - )?.channels.compactMap { try? $0.asModel() } ?? [] + database.viewContext.channelListQuery(query)?.channels.compactMap { try? $0.asModel() } ?? [] } XCTAssertEqual(channelsFromQuery.count, 3) @@ -169,9 +167,7 @@ final class ChannelListUpdater_Tests: XCTestCase { } var channelsFromQuery: [ChatChannel] { - database.viewContext.channelListQuery( - filterHash: query.filter.filterHash - )?.channels.compactMap { try? $0.asModel() } ?? [] + database.viewContext.channelListQuery(query)?.channels.compactMap { try? $0.asModel() } ?? [] } XCTAssertEqual(channelsFromQuery.count, 3) @@ -205,9 +201,7 @@ final class ChannelListUpdater_Tests: XCTestCase { } var channelsFromQuery: [ChatChannel] { - database.viewContext.channelListQuery( - filterHash: query.filter.filterHash - )?.channels.compactMap { try? $0.asModel() } ?? [] + database.viewContext.channelListQuery(query)?.channels.compactMap { try? $0.asModel() } ?? [] } XCTAssertEqual(channelsFromQuery.count, 3) @@ -409,9 +403,7 @@ final class ChannelListUpdater_Tests: XCTestCase { waitForExpectations(timeout: defaultTimeout) var channelsInQuery: [ChatChannel] { - database.viewContext.channelListQuery( - filterHash: query.filter.filterHash - )?.channels.compactMap { try? $0.asModel() } ?? [] + database.viewContext.channelListQuery(query)?.channels.compactMap { try? $0.asModel() } ?? [] } XCTAssertTrue(channelsInQuery.contains(where: { $0.cid == channel.cid })) @@ -431,9 +423,7 @@ final class ChannelListUpdater_Tests: XCTestCase { } var channelsInQuery: [ChatChannel] { - database.viewContext.channelListQuery( - filterHash: query.filter.filterHash - )?.channels.compactMap { try? $0.asModel() } ?? [] + database.viewContext.channelListQuery(query)?.channels.compactMap { try? $0.asModel() } ?? [] } XCTAssertTrue(channelsInQuery.contains(where: { $0.cid == channel.cid })) @@ -449,7 +439,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) + } } diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 463ccc666f7..3ea7172c54f 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -857,6 +857,20 @@ lane :copyright do ) end +desc 'Regenerate Sourcery code (predefined channel-list filter/sort mappings)' +lane :generate do + Dir.chdir('..') { sh('make generate') } +end + +desc 'Fail if generated code is stale (run `make generate` and commit the result)' +lane :validate_generated_code do + generate + Dir.chdir('..') do + changed = sh('git status --porcelain Sources/StreamChat/Generated/PredefinedFilter+Generated.swift', log: false).strip + UI.user_error!('Generated code is stale. Run `make generate` and commit the result.') unless changed.empty? + end +end + lane :validate_public_interface do next unless is_check_required(sources: sources_matrix[:public_interface], force_check: @force_check)