diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 3b8b99b6b3..e1d3670522 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -6,6 +6,8 @@ - Added `Message.updateWith(Message? other)` — merges a server-side update onto the local message while preserving locally-known `poll`, `sharedLocation`, `ownReactions`, and nested `quotedMessage` enrichment when the server omits them. - Added `Channel.isOneToOne` — true when the channel is `isDistinct` and has exactly two members. For the looser count-only check, inline `channel.memberCount == 2`. - Added `IterableMergeX.merge` (keyed-map merge on `Iterable`, unsorted output) and `SortedListX.mergeSorted` (two-pointer merge on a sorted `List`). Splits what `SortedListX.merge` did in 9.24.0 — see Changed below. +- Added support for predefined filters for `QueryChannels` on `StreamChatClient` (`StreamChatClient.queryChannels` and `StreamChatClient.queryChannelsWithResult`). +- Added support for predefined filters for `QueryChannels` on `ChatPersistenceClient` (`ChatPersistenceClient.getChannelStatesByPredefinedFilter` and `ChatPersistenceClient.updateChannelQueriesByPredefinedFilter`), ⚠️ Deprecated diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index ab423a17e7..57ae899992 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -8,6 +8,7 @@ import 'package:rxdart/rxdart.dart'; import 'package:stream_chat/src/client/channel.dart'; import 'package:stream_chat/src/client/channel_delivery_reporter.dart'; import 'package:stream_chat/src/client/event_resolvers.dart' as event_resolvers; +import 'package:stream_chat/src/client/query_channels_result.dart'; import 'package:stream_chat/src/client/retry_policy.dart'; import 'package:stream_chat/src/core/api/attachment_file_uploader.dart'; import 'package:stream_chat/src/core/api/requests.dart'; @@ -46,6 +47,7 @@ import 'package:stream_chat/src/core/util/extension.dart'; import 'package:stream_chat/src/core/util/immutable_collection_subjects.dart'; import 'package:stream_chat/src/core/util/in_flight_cache.dart'; import 'package:stream_chat/src/core/util/list_extensions.dart'; +import 'package:stream_chat/src/core/util/predefined_filter_defaults.dart'; import 'package:stream_chat/src/core/util/utils.dart'; import 'package:stream_chat/src/db/chat_persistence_client.dart'; import 'package:stream_chat/src/event_type.dart'; @@ -643,12 +645,89 @@ class StreamChatClient { }); } - final _queryChannelsCache = InFlightCache>(); + final _queryChannelsCache = InFlightCache(); /// Requests channels with a given query. + /// + /// Either an inline [filter]/[channelStateSort] pair or a [predefinedFilter] + /// identifier (optionally interpolated with [filterValues] and [sortValues]) + /// can be supplied. + /// + /// Use [queryChannelsWithResult] if you also need the server-resolved + /// [PredefinedFilter] spec. Stream> queryChannels({ Filter? filter, SortOrder? channelStateSort, + String? predefinedFilter, + Map? filterValues, + Map? sortValues, + bool state = true, + bool watch = true, + bool presence = false, + int? memberLimit, + int? messageLimit, + PaginationParams paginationParams = const PaginationParams(), + bool waitForConnect = true, + }) async* { + await for (final result in _queryChannelsImpl( + filter: filter, + channelStateSort: channelStateSort, + predefinedFilter: predefinedFilter, + filterValues: filterValues, + sortValues: sortValues, + state: state, + watch: watch, + presence: presence, + memberLimit: memberLimit, + messageLimit: messageLimit, + paginationParams: paginationParams, + waitForConnect: waitForConnect, + )) { + yield result.channels; + } + } + + /// Requests channels with a given query, yielding a [QueryChannelsResult] + /// that carries both the live channel list and the server-resolved + /// [PredefinedFilter] spec (when one is associated with the query). + /// + /// Yields the offline-cached result first (when available), followed by + /// the online result. Concurrent identical online queries are coalesced + /// via [_queryChannelsCache]. + Stream queryChannelsWithResult({ + Filter? filter, + SortOrder? channelStateSort, + String? predefinedFilter, + Map? filterValues, + Map? sortValues, + bool state = true, + bool watch = true, + bool presence = false, + int? memberLimit, + int? messageLimit, + PaginationParams paginationParams = const PaginationParams(), + bool waitForConnect = true, + }) => _queryChannelsImpl( + filter: filter, + channelStateSort: channelStateSort, + predefinedFilter: predefinedFilter, + filterValues: filterValues, + sortValues: sortValues, + state: state, + watch: watch, + presence: presence, + memberLimit: memberLimit, + messageLimit: messageLimit, + paginationParams: paginationParams, + waitForConnect: waitForConnect, + ); + + Stream _queryChannelsImpl({ + Filter? filter, + SortOrder? channelStateSort, + String? predefinedFilter, + Map? filterValues, + Map? sortValues, bool state = true, bool watch = true, bool presence = false, @@ -665,6 +744,9 @@ class StreamChatClient { final hash = generateHash([ filter, channelStateSort, + predefinedFilter, + filterValues, + sortValues, state, watch, presence, @@ -674,16 +756,19 @@ class StreamChatClient { ]); // Per-caller offline emit — local persistence, not coalesced. - var offlineChannels = []; + QueryChannelsResult? offlineResult; try { - offlineChannels = await queryChannelsOffline( + offlineResult = await _queryChannelsOfflineImpl( filter: filter, + predefinedFilter: predefinedFilter, + filterValues: filterValues, + sortValues: sortValues, channelStateSort: channelStateSort, messageLimit: messageLimit, paginationParams: paginationParams, ); - if (offlineChannels.isNotEmpty) yield offlineChannels; + if (offlineResult.channels.isNotEmpty) yield offlineResult; } catch (e, stk) { logger.warning('Error querying channels offline', e, stk); // Continue to online query even if offline fails @@ -696,9 +781,12 @@ class StreamChatClient { final result = await _queryChannelsCache.run( hash, () => - queryChannelsOnline( + _queryChannelsOnlineImpl( filter: filter, sort: channelStateSort, + predefinedFilter: predefinedFilter, + filterValues: filterValues, + sortValues: sortValues, state: state, watch: watch, presence: presence, @@ -718,7 +806,7 @@ class StreamChatClient { } catch (e, stk) { logger.severe('Error querying channels online', e, stk); // Only rethrow if we have no channels to show the user - if (offlineChannels.isEmpty) rethrow; + if (offlineResult == null || offlineResult.channels.isEmpty) rethrow; } } @@ -726,6 +814,40 @@ class StreamChatClient { Future> queryChannelsOnline({ Filter? filter, SortOrder? sort, + String? predefinedFilter, + Map? filterValues, + Map? sortValues, + bool state = true, + bool watch = true, + bool presence = false, + int? memberLimit, + int? messageLimit, + bool waitForConnect = true, + PaginationParams paginationParams = const PaginationParams(), + }) async { + final result = await _queryChannelsOnlineImpl( + filter: filter, + sort: sort, + predefinedFilter: predefinedFilter, + filterValues: filterValues, + sortValues: sortValues, + state: state, + watch: watch, + presence: presence, + memberLimit: memberLimit, + messageLimit: messageLimit, + waitForConnect: waitForConnect, + paginationParams: paginationParams, + ); + return result.channels; + } + + Future _queryChannelsOnlineImpl({ + Filter? filter, + SortOrder? sort, + String? predefinedFilter, + Map? filterValues, + Map? sortValues, bool state = true, bool watch = true, bool presence = false, @@ -756,6 +878,9 @@ class StreamChatClient { final res = await _chatApi.channel.queryChannels( filter: filter, sort: sort, + predefinedFilter: predefinedFilter, + filterValues: filterValues, + sortValues: sortValues, state: state, watch: watch, presence: presence, @@ -771,7 +896,10 @@ class StreamChatClient { Please make sure to take a look at the Flutter tutorial: https://getstream.io/chat/flutter/tutorial If your application already has users and channels, you might need to adjust your query channel as explained in the docs https://getstream.io/chat/docs/query_channels/?language=dart '''); - return []; + return QueryChannelsResult( + channels: const [], + predefinedFilter: res.predefinedFilter, + ); } final channels = res.channels; @@ -786,40 +914,106 @@ class StreamChatClient { // Submit delivery report for the channels fetched in this query. await channelDeliveryReporter.submitForDelivery(updateData.value); - await chatPersistenceClient?.updateChannelQueries( - filter, - channels.map((c) => c.channel!.cid).toList(), - // Clear the query cache if we are refreshing. - clearQueryCache: (paginationParams.offset ?? 0) == 0, - ); + final cachedCids = channels.map((c) => c.channel!.cid).toList(); + // Clear the query cache if we are refreshing. + final clearQueryCache = (paginationParams.offset ?? 0) == 0; + + if (predefinedFilter == null) { + await chatPersistenceClient?.updateChannelQueries( + filter, + cachedCids, + clearQueryCache: clearQueryCache, + ); + } else { + // Note: predefinedFilter will never be null here + final resolvedFilter = res.predefinedFilter?.filter ?? const Filter.empty(); + final resolvedSort = res.predefinedFilter?.sort ?? resolvedFilter.predefinedFilterFallbackSort; + + await chatPersistenceClient?.updateChannelQueriesByPredefinedFilter( + predefinedFilter, + cachedCids, + filter: resolvedFilter, + sort: resolvedSort, + filterValues: filterValues, + sortValues: sortValues, + clearQueryCache: clearQueryCache, + ); + } this.state.addChannels(updateData.key); - return updateData.value; + return QueryChannelsResult( + channels: updateData.value, + predefinedFilter: res.predefinedFilter, + ); } /// Requests channels with a given query from the Persistence client. Future> queryChannelsOffline({ Filter? filter, + String? predefinedFilter, + Map? filterValues, + Map? sortValues, SortOrder? channelStateSort, int? messageLimit, PaginationParams paginationParams = const PaginationParams(), }) async { - final offlineChannels = await chatPersistenceClient?.getChannelStates( + final result = await _queryChannelsOfflineImpl( filter: filter, + predefinedFilter: predefinedFilter, + filterValues: filterValues, + sortValues: sortValues, channelStateSort: channelStateSort, - // Default limit is set to 25 in backend. - messageLimit: messageLimit ?? 25, + messageLimit: messageLimit, paginationParams: paginationParams, ); + return result.channels; + } + + Future _queryChannelsOfflineImpl({ + Filter? filter, + String? predefinedFilter, + Map? filterValues, + Map? sortValues, + SortOrder? channelStateSort, + int? messageLimit, + PaginationParams paginationParams = const PaginationParams(), + }) async { + final QueryChannelsResponse res; + if (predefinedFilter == null) { + final channels = await chatPersistenceClient?.getChannelStates( + filter: filter, + channelStateSort: channelStateSort, + // Default limit is set to 25 in backend. + messageLimit: messageLimit ?? 25, + paginationParams: paginationParams, + ); + res = QueryChannelsResponse()..channels = channels ?? const []; + } else { + res = + await chatPersistenceClient?.getChannelStatesByPredefinedFilter( + filterName: predefinedFilter, + filterValues: filterValues, + sortValues: sortValues, + messageLimit: messageLimit ?? 25, + paginationParams: paginationParams, + ) ?? + (QueryChannelsResponse()..channels = const []); + } - if (offlineChannels == null || offlineChannels.isEmpty) { + if (res.channels.isEmpty) { logger.info('No channels found in offline storage for the given query'); - return []; + return QueryChannelsResult( + channels: const [], + predefinedFilter: res.predefinedFilter, + ); } - final updatedData = _mapChannelStateToChannel(offlineChannels); - state.addChannels(updatedData.key); - return updatedData.value; + final updateData = _mapChannelStateToChannel(res.channels); + state.addChannels(updateData.key); + return QueryChannelsResult( + channels: updateData.value, + predefinedFilter: res.predefinedFilter, + ); } MapEntry, List> _mapChannelStateToChannel( diff --git a/packages/stream_chat/lib/src/client/query_channels_result.dart b/packages/stream_chat/lib/src/client/query_channels_result.dart new file mode 100644 index 0000000000..ee11660f10 --- /dev/null +++ b/packages/stream_chat/lib/src/client/query_channels_result.dart @@ -0,0 +1,22 @@ +import 'package:stream_chat/src/client/channel.dart'; +import 'package:stream_chat/src/core/models/predefined_filter.dart'; + +/// The result of a `queryChannelsWithResult` call on [StreamChatClient]. +/// +/// Carries the live [Channel] instances matching the query alongside the +/// server-resolved [PredefinedFilter] spec (when one is associated with the +/// query). +class QueryChannelsResult { + /// Creates a new [QueryChannelsResult]. + const QueryChannelsResult({ + required this.channels, + this.predefinedFilter, + }); + + /// The live [Channel] instances matching the query. + final List channels; + + /// The server-resolved predefined-filter spec, or null when the query did + /// not use a `predefinedFilter`. + final PredefinedFilter? predefinedFilter; +} diff --git a/packages/stream_chat/lib/src/core/api/channel_api.dart b/packages/stream_chat/lib/src/core/api/channel_api.dart index b2addba7d6..dfe389af15 100644 --- a/packages/stream_chat/lib/src/core/api/channel_api.dart +++ b/packages/stream_chat/lib/src/core/api/channel_api.dart @@ -49,9 +49,17 @@ class ChannelApi { } /// Requests channels with a given query from the API. + /// + /// Either an inline [filter]/[sort] pair or a [predefinedFilter] identifier + /// (optionally interpolated with [filterValues] and [sortValues]) can be + /// provided. When a predefined filter is used, the server resolves it and + /// returns the materialized filter/sort on [QueryChannelsResponse]. Future queryChannels({ Filter? filter, SortOrder? sort, + String? predefinedFilter, + Map? filterValues, + Map? sortValues, int? memberLimit, int? messageLimit, bool state = true, @@ -71,6 +79,9 @@ class ChannelApi { // passed options if (sort != null) 'sort': sort, if (filter != null) 'filter_conditions': filter, + if (predefinedFilter != null) 'predefined_filter': predefinedFilter, + if (filterValues != null) 'filter_values': filterValues, + if (sortValues != null) 'sort_values': sortValues, if (memberLimit != null) 'member_limit': memberLimit, if (messageLimit != null) 'message_limit': messageLimit, diff --git a/packages/stream_chat/lib/src/core/api/responses.dart b/packages/stream_chat/lib/src/core/api/responses.dart index c5bd2e92ab..9b375b3037 100644 --- a/packages/stream_chat/lib/src/core/api/responses.dart +++ b/packages/stream_chat/lib/src/core/api/responses.dart @@ -14,6 +14,7 @@ import 'package:stream_chat/src/core/models/message_reminder.dart'; import 'package:stream_chat/src/core/models/poll.dart'; import 'package:stream_chat/src/core/models/poll_option.dart'; import 'package:stream_chat/src/core/models/poll_vote.dart'; +import 'package:stream_chat/src/core/models/predefined_filter.dart'; import 'package:stream_chat/src/core/models/push_preference.dart'; import 'package:stream_chat/src/core/models/reaction.dart'; import 'package:stream_chat/src/core/models/read.dart'; @@ -76,6 +77,13 @@ class QueryChannelsResponse extends _BaseResponse { @JsonKey(defaultValue: []) late List channels; + /// Predefined filter spec as resolved by the server. + /// + /// Populated when the request used `predefined_filter`. Contains the + /// preset name and the materialized `filter`/`sort` that were applied. + @JsonKey(name: 'predefined_filter') + PredefinedFilter? predefinedFilter; + /// Create a new instance from a json static QueryChannelsResponse fromJson(Map json) => _$QueryChannelsResponseFromJson(json); } diff --git a/packages/stream_chat/lib/src/core/api/responses.g.dart b/packages/stream_chat/lib/src/core/api/responses.g.dart index 5ab176b798..c4fc6f1650 100644 --- a/packages/stream_chat/lib/src/core/api/responses.g.dart +++ b/packages/stream_chat/lib/src/core/api/responses.g.dart @@ -30,7 +30,12 @@ QueryChannelsResponse _$QueryChannelsResponseFromJson( ) => QueryChannelsResponse() ..duration = json['duration'] as String? ..channels = - (json['channels'] as List?)?.map((e) => ChannelState.fromJson(e as Map)).toList() ?? []; + (json['channels'] as List?)?.map((e) => ChannelState.fromJson(e as Map)).toList() ?? [] + ..predefinedFilter = json['predefined_filter'] == null + ? null + : PredefinedFilter.fromJson( + json['predefined_filter'] as Map, + ); TranslateMessageResponse _$TranslateMessageResponseFromJson( Map json, diff --git a/packages/stream_chat/lib/src/core/api/sort_order.dart b/packages/stream_chat/lib/src/core/api/sort_order.dart index 2a1c7cad58..21fb60b6f0 100644 --- a/packages/stream_chat/lib/src/core/api/sort_order.dart +++ b/packages/stream_chat/lib/src/core/api/sort_order.dart @@ -64,6 +64,17 @@ class SortOption { }) : direction = SortOption.ASC, _comparator = comparator; + /// Creates a [SortOption] from its JSON-serialized representation. + /// + /// Reconstructs via [SortOption.desc] / [SortOption.asc] based on the + /// `direction` field; `nullOrdering` defaults follow each constructor and + /// any custom comparator is discarded (comparators are not serialized). + factory SortOption.fromJson(Map json) { + final field = json['field'] as String; + final direction = json['direction'] as int; + return direction == SortOption.DESC ? SortOption.desc(field) : SortOption.asc(field); + } + /// Ascending order (1) static const ASC = 1; diff --git a/packages/stream_chat/lib/src/core/models/predefined_filter.dart b/packages/stream_chat/lib/src/core/models/predefined_filter.dart new file mode 100644 index 0000000000..b54680e540 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/predefined_filter.dart @@ -0,0 +1,40 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:stream_chat/src/core/api/sort_order.dart'; +import 'package:stream_chat/src/core/models/channel_state.dart'; +import 'package:stream_chat/src/core/models/filter.dart'; + +part 'predefined_filter.g.dart'; + +/// The resolved predefined filter spec returned by the server. +/// +/// When `predefined_filter` is provided on a `queryChannels` request, the +/// server resolves the template (interpolating any `filter_values` and +/// `sort_values`) and echoes the materialized `filter` and `sort` on the +/// response under this key. +@JsonSerializable(createToJson: false) +class PredefinedFilter { + /// Creates a new instance. + const PredefinedFilter({ + required this.name, + required this.filter, + this.sort, + }); + + /// Create a new instance from a json. + factory PredefinedFilter.fromJson(Map json) => _$PredefinedFilterFromJson(json); + + /// Identifier of the predefined filter on the server. + final String name; + + /// Filter conditions as resolved by the server. + /// + /// Wrapped in [Filter.raw] — the SDK does not evaluate filters locally. + /// Access the underlying map via [Filter.value] or [Filter.toJson]. + @JsonKey(fromJson: _filterFromJson) + final Filter filter; + + /// Sort specification as resolved by the server. + final SortOrder? sort; + + static Filter _filterFromJson(Map json) => Filter.raw(value: json); +} diff --git a/packages/stream_chat/lib/src/core/models/predefined_filter.g.dart b/packages/stream_chat/lib/src/core/models/predefined_filter.g.dart new file mode 100644 index 0000000000..152f047bd8 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/predefined_filter.g.dart @@ -0,0 +1,19 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'predefined_filter.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PredefinedFilter _$PredefinedFilterFromJson(Map json) => PredefinedFilter( + name: json['name'] as String, + filter: PredefinedFilter._filterFromJson( + json['filter'] as Map, + ), + sort: (json['sort'] as List?) + ?.map( + (e) => SortOption.fromJson(e as Map), + ) + .toList(), +); diff --git a/packages/stream_chat/lib/src/core/util/predefined_filter_defaults.dart b/packages/stream_chat/lib/src/core/util/predefined_filter_defaults.dart new file mode 100644 index 0000000000..1829898ed5 --- /dev/null +++ b/packages/stream_chat/lib/src/core/util/predefined_filter_defaults.dart @@ -0,0 +1,51 @@ +import 'package:stream_chat/src/core/api/sort_order.dart'; +import 'package:stream_chat/src/core/models/channel_state.dart'; +import 'package:stream_chat/src/core/models/filter.dart'; + +/// Defaults used in the predefined-filter query flow. +extension PredefinedFilterDefaults on Filter { + /// Fallback sort applied when the server doesn't echo back a sort spec + /// for a predefined-filter query, so the persisted spec is never null. + /// + /// Picks the sort field that matches the filter's time predicate (if any), + /// falling back to `last_updated` otherwise — this mirrors the server's + /// default sort for channel queries. + SortOrder get predefinedFilterFallbackSort { + if (_touchesField('last_message_at')) { + return const [SortOption.desc(ChannelSortKey.lastMessageAt)]; + } + return const [SortOption.desc(ChannelSortKey.lastUpdated)]; + } + + bool _touchesField(String field) { + if (key == field) return true; + final v = value; + if (v is List) { + return v.any((sub) => sub._touchesField(field)); + } + if (v is Map) { + return _mapTouchesField(v, field); + } + return false; + } +} + +bool _mapTouchesField(Map map, String field) { + for (final entry in map.entries) { + final key = entry.key; + if (!key.startsWith(r'$')) { + if (key == field) return true; + continue; + } + // Group operator like $or / $and / $nor — recurse into list items. + final value = entry.value; + if (value is List) { + for (final item in value) { + if (item is Map && _mapTouchesField(item, field)) { + return true; + } + } + } + } + return false; +} diff --git a/packages/stream_chat/lib/src/db/chat_persistence_client.dart b/packages/stream_chat/lib/src/db/chat_persistence_client.dart index 4daf3f87f6..92ce1a450d 100644 --- a/packages/stream_chat/lib/src/db/chat_persistence_client.dart +++ b/packages/stream_chat/lib/src/db/chat_persistence_client.dart @@ -1,5 +1,6 @@ import 'package:collection/collection.dart'; import 'package:stream_chat/src/core/api/requests.dart'; +import 'package:stream_chat/src/core/api/responses.dart'; import 'package:stream_chat/src/core/api/sort_order.dart'; import 'package:stream_chat/src/core/models/attachment_file.dart'; import 'package:stream_chat/src/core/models/channel_model.dart'; @@ -146,6 +147,47 @@ abstract class ChatPersistenceClient { bool clearQueryCache = false, }); + /// Returns the stored response for a predefined-filter query. + /// + /// The query is identified by [filterName] and the optional [filterValues] + /// and [sortValues] interpolation maps. Use [messageLimit] to limit messages + /// per channel and [paginationParams] to paginate results. + /// + /// The returned [QueryChannelsResponse] carries the persisted resolved + /// `predefinedFilter` spec (filter + sort) so offline callers can apply the + /// same filter/order the server applied on the last online query. + /// + /// Default implementation returns an empty response; persistence + /// implementations that support predefined-filter caching should override + /// this. + Future getChannelStatesByPredefinedFilter({ + required String filterName, + Map? filterValues, + Map? sortValues, + int? messageLimit, + PaginationParams? paginationParams, + }) async => QueryChannelsResponse()..channels = const []; + + /// Update list of channel queries for a predefined-filter query. + /// + /// The query is identified by [filterName] and the optional [filterValues] + /// and [sortValues] interpolation maps. [filter] and [sort] are the + /// server-resolved spec values, persisted so subsequent offline reads can + /// reconstruct the same filter/order. If [clearQueryCache] is true, prior + /// cids and metadata for this query are deleted before the insert. + /// + /// Default implementation is a no-op; persistence implementations that + /// support predefined-filter caching should override this. + Future updateChannelQueriesByPredefinedFilter( + String filterName, + List cids, { + required Filter filter, + required SortOrder sort, + Map? filterValues, + Map? sortValues, + bool clearQueryCache = false, + }) async {} + /// Remove a message by [messageId] Future deleteMessageById(String messageId) => deleteMessageByIds([messageId]); diff --git a/packages/stream_chat/lib/stream_chat.dart b/packages/stream_chat/lib/stream_chat.dart index 08d0d6d21d..a1838d310b 100644 --- a/packages/stream_chat/lib/stream_chat.dart +++ b/packages/stream_chat/lib/stream_chat.dart @@ -20,6 +20,7 @@ export 'src/client/channel.dart'; export 'src/client/channel_delivery_reporter.dart'; export 'src/client/client.dart'; export 'src/client/key_stroke_handler.dart'; +export 'src/client/query_channels_result.dart'; export 'src/client/retry_policy.dart'; export 'src/core/api/attachment_file_uploader.dart'; export 'src/core/api/requests.dart'; @@ -59,6 +60,7 @@ export 'src/core/models/poll.dart'; export 'src/core/models/poll_option.dart'; export 'src/core/models/poll_vote.dart'; export 'src/core/models/poll_voting_mode.dart'; +export 'src/core/models/predefined_filter.dart'; export 'src/core/models/privacy_settings.dart'; export 'src/core/models/push_preference.dart'; export 'src/core/models/reaction.dart'; diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index 2a686f46f4..48e1b82941 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -496,6 +496,7 @@ void main() { registerFallbackValue(FakeEvent()); registerFallbackValue(const PaginationParams()); registerFallbackValue(FakeChannelState()); + registerFallbackValue(const Filter.empty()); }); setUp(() async { @@ -790,6 +791,275 @@ void main() { verify(() => persistence.updateChannelThreads(any(), any())).called(persistentChannelStates.length); }, ); + + test( + 'queryChannelsOnline with predefined filter persists via *ByPredefinedFilter with resolved sort', + () async { + const filterName = 'sample-app-list'; + const filterValues = {'user_id': 'test-user-id'}; + const sortValues = {'pinned_at': true}; + + final channelStates = List.generate( + 3, + (i) => ChannelState(channel: ChannelModel(cid: 'test-type-$i:test-id-$i')), + ); + + when( + () => api.channel.queryChannels( + predefinedFilter: filterName, + filterValues: filterValues, + sortValues: sortValues, + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + ), + ).thenAnswer( + (_) async => QueryChannelsResponse() + ..channels = channelStates + ..predefinedFilter = const PredefinedFilter( + name: filterName, + filter: Filter.empty(), + sort: [SortOption.desc('last_message_at')], + ), + ); + + when(() => persistence.getChannelThreads(any())).thenAnswer((_) async => >{}); + when(() => persistence.updateChannelState(any())).thenAnswer((_) async {}); + when(() => persistence.updateChannelThreads(any(), any())).thenAnswer((_) async {}); + when( + () => persistence.updateChannelQueriesByPredefinedFilter( + any(), + any(), + filter: any(named: 'filter'), + sort: any(named: 'sort'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), + clearQueryCache: any(named: 'clearQueryCache'), + ), + ).thenAnswer((_) => Future.value()); + + await delay(1100); + clearInteractions(persistence); + + await client.queryChannelsOnline( + predefinedFilter: filterName, + filterValues: filterValues, + sortValues: sortValues, + ); + + // Inline persistence write is NOT called. + verifyNever( + () => persistence.updateChannelQueries(any(), any(), clearQueryCache: any(named: 'clearQueryCache')), + ); + + verify( + () => persistence.updateChannelQueriesByPredefinedFilter( + filterName, + channelStates.map((s) => s.channel!.cid).toList(), + filter: const Filter.empty(), + sort: const [SortOption.desc('last_message_at')], + filterValues: filterValues, + sortValues: sortValues, + clearQueryCache: true, + ), + ).called(1); + }, + ); + + test( + 'queryChannelsOffline with predefined filter reads via *ByPredefinedFilter', + () async { + const filterName = 'sample-app-list'; + const filterValues = {'user_id': 'test-user-id'}; + const sortValues = {'pinned_at': true}; + + final channelStates = List.generate( + 3, + (i) => ChannelState(channel: ChannelModel(cid: 'test-type-$i:test-id-$i')), + ); + + when( + () => persistence.getChannelStatesByPredefinedFilter( + filterName: filterName, + filterValues: filterValues, + sortValues: sortValues, + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + ), + ).thenAnswer((_) async => QueryChannelsResponse()..channels = channelStates); + + when(() => persistence.getChannelThreads(any())).thenAnswer((_) async => >{}); + when(() => persistence.updateChannelState(any())).thenAnswer((_) async {}); + when(() => persistence.updateChannelThreads(any(), any())).thenAnswer((_) async {}); + + await delay(1100); + clearInteractions(persistence); + + final channels = await client.queryChannelsOffline( + predefinedFilter: filterName, + filterValues: filterValues, + sortValues: sortValues, + ); + + expect(channels, hasLength(channelStates.length)); + + verifyNever( + () => persistence.getChannelStates( + filter: any(named: 'filter'), + channelStateSort: any(named: 'channelStateSort'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + ), + ); + + verify( + () => persistence.getChannelStatesByPredefinedFilter( + filterName: filterName, + filterValues: filterValues, + sortValues: sortValues, + messageLimit: 25, + paginationParams: const PaginationParams(), + ), + ).called(1); + }, + ); + + test( + 'queryChannelsWithResult yields QueryChannelsResult with predefinedFilter=null for inline filter', + () async { + final channelStates = List.generate( + 2, + (i) => ChannelState(channel: ChannelModel(cid: 'test-type-$i:test-id-$i')), + ); + + when( + () => persistence.getChannelStates( + filter: any(named: 'filter'), + channelStateSort: any(named: 'channelStateSort'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + ), + ).thenAnswer((_) async => const []); + + when( + () => api.channel.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + ), + ).thenAnswer( + (_) async => QueryChannelsResponse()..channels = channelStates, + ); + + when(() => persistence.getChannelThreads(any())).thenAnswer((_) async => >{}); + when(() => persistence.updateChannelState(any())).thenAnswer((_) async {}); + when(() => persistence.updateChannelThreads(any(), any())).thenAnswer((_) async {}); + when( + () => persistence.updateChannelQueries(any(), any(), clearQueryCache: any(named: 'clearQueryCache')), + ).thenAnswer((_) => Future.value()); + + await delay(1100); + clearInteractions(persistence); + + final results = await client.queryChannelsWithResult().toList(); + + // Persistence returned empty, so only the online emission is yielded. + expect(results, hasLength(1)); + expect(results.single.channels, hasLength(channelStates.length)); + expect(results.single.predefinedFilter, isNull); + }, + ); + + test( + 'queryChannelsWithResult yields QueryChannelsResult with predefinedFilter populated for predefined query', + () async { + const filterName = 'sample-app-list'; + const filterValues = {'user_id': 'test-user-id'}; + const sortValues = {'pinned_at': true}; + + final channelStates = List.generate( + 2, + (i) => ChannelState(channel: ChannelModel(cid: 'test-type-$i:test-id-$i')), + ); + + const resolvedSort = [SortOption.desc('last_message_at')]; + const resolvedFilter = Filter.empty(); + const expectedPredefinedFilter = PredefinedFilter( + name: filterName, + filter: resolvedFilter, + sort: resolvedSort, + ); + + when( + () => persistence.getChannelStatesByPredefinedFilter( + filterName: any(named: 'filterName'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + ), + ).thenAnswer((_) async => QueryChannelsResponse()..channels = const []); + + when( + () => api.channel.queryChannels( + predefinedFilter: filterName, + filterValues: filterValues, + sortValues: sortValues, + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + ), + ).thenAnswer( + (_) async => QueryChannelsResponse() + ..channels = channelStates + ..predefinedFilter = expectedPredefinedFilter, + ); + + when(() => persistence.getChannelThreads(any())).thenAnswer((_) async => >{}); + when(() => persistence.updateChannelState(any())).thenAnswer((_) async {}); + when(() => persistence.updateChannelThreads(any(), any())).thenAnswer((_) async {}); + when( + () => persistence.updateChannelQueriesByPredefinedFilter( + any(), + any(), + filter: any(named: 'filter'), + sort: any(named: 'sort'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), + clearQueryCache: any(named: 'clearQueryCache'), + ), + ).thenAnswer((_) => Future.value()); + + await delay(1100); + clearInteractions(persistence); + + final results = await client + .queryChannelsWithResult( + predefinedFilter: filterName, + filterValues: filterValues, + sortValues: sortValues, + ) + .toList(); + + // Persistence returned empty, so only the online emission is yielded. + expect(results, hasLength(1)); + expect(results.single.channels, hasLength(channelStates.length)); + expect(results.single.predefinedFilter, isNotNull); + expect(results.single.predefinedFilter!.name, equals(filterName)); + expect(results.single.predefinedFilter!.sort, equals(resolvedSort)); + }, + ); }); test('`.disconnectUser` should reset state and user', () async { diff --git a/packages/stream_chat/test/src/core/api/channel_api_test.dart b/packages/stream_chat/test/src/core/api/channel_api_test.dart index 1c4ae95c13..5ae5990931 100644 --- a/packages/stream_chat/test/src/core/api/channel_api_test.dart +++ b/packages/stream_chat/test/src/core/api/channel_api_test.dart @@ -176,7 +176,93 @@ void main() { expect(res.channels, isNotEmpty); verify( - () => client.get(path, queryParameters: any(named: 'queryParameters')), + () => client.get(path, queryParameters: {'payload': payload}), + ).called(1); + verifyNoMoreInteractions(client); + }); + + test('queryChannels with predefined filter', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + + const predefinedFilter = 'sample-app-list'; + const filterValues = {'user_id': 'test-user-id'}; + const sortValues = {'pinned_first': true}; + + const path = '/channels'; + + final channelState = _generateChannelState(channelId, channelType); + + final payload = jsonEncode({ + // default options + 'state': true, + 'watch': true, + 'presence': false, + + // passed options + 'predefined_filter': predefinedFilter, + 'filter_values': filterValues, + 'sort_values': sortValues, + + // pagination + ...const PaginationParams().toJson(), + }); + + final resolvedSort = [ + {'field': 'last_message_at', 'direction': -1}, + ]; + + when( + () => client.get( + path, + queryParameters: { + 'payload': payload, + }, + ), + ).thenAnswer( + (_) async => successResponse( + path, + data: { + 'channels': [channelState.toJson()], + 'predefined_filter': { + 'name': predefinedFilter, + 'filter': { + 'members': { + r'$in': ['test-user-id'], + }, + }, + 'sort': resolvedSort, + }, + }, + ), + ); + + final res = await channelApi.queryChannels( + predefinedFilter: predefinedFilter, + filterValues: filterValues, + sortValues: sortValues, + ); + + expect(res, isNotNull); + expect(res.channels, isNotEmpty); + expect(res.predefinedFilter, isNotNull); + expect(res.predefinedFilter!.name, predefinedFilter); + expect( + res.predefinedFilter!.filter, + const Filter.raw( + value: { + 'members': { + r'$in': ['test-user-id'], + }, + }, + ), + ); + expect(res.predefinedFilter!.sort, hasLength(1)); + expect(res.predefinedFilter!.sort!.first.field, 'last_message_at'); + expect(res.predefinedFilter!.sort!.first.direction, SortOption.DESC); + + verify( + () => client.get(path, queryParameters: {'payload': payload}), ).called(1); verifyNoMoreInteractions(client); }); diff --git a/packages/stream_chat/test/src/core/models/predefined_filter_test.dart b/packages/stream_chat/test/src/core/models/predefined_filter_test.dart new file mode 100644 index 0000000000..721300005d --- /dev/null +++ b/packages/stream_chat/test/src/core/models/predefined_filter_test.dart @@ -0,0 +1,41 @@ +import 'package:stream_chat/src/core/api/sort_order.dart'; +import 'package:stream_chat/src/core/models/predefined_filter.dart'; +import 'package:test/test.dart'; + +void main() { + const filterJson = { + r'$or': [ + { + 'type': {r'$eq': 'messaging'}, + }, + { + r'$and': [ + {'frozen': false}, + { + 'members': { + r'$in': ['user-1', 'user-2'], + }, + }, + ], + }, + ], + }; + + final json = { + 'name': 'unread', + 'filter': filterJson, + 'sort': [ + {'field': 'last_message_at', 'direction': -1}, + ], + }; + + test('PredefinedFilter.fromJson parses all fields', () { + final parsed = PredefinedFilter.fromJson(json); + + expect(parsed.name, 'unread'); + expect(parsed.filter.value, filterJson); + expect(parsed.sort, hasLength(1)); + expect(parsed.sort!.first.field, 'last_message_at'); + expect(parsed.sort!.first.direction, SortOption.DESC); + }); +} diff --git a/packages/stream_chat/test/src/core/util/predefined_filter_defaults_test.dart b/packages/stream_chat/test/src/core/util/predefined_filter_defaults_test.dart new file mode 100644 index 0000000000..13a0873832 --- /dev/null +++ b/packages/stream_chat/test/src/core/util/predefined_filter_defaults_test.dart @@ -0,0 +1,79 @@ +import 'package:stream_chat/src/core/api/sort_order.dart'; +import 'package:stream_chat/src/core/models/channel_state.dart'; +import 'package:stream_chat/src/core/models/filter.dart'; +import 'package:stream_chat/src/core/util/predefined_filter_defaults.dart'; +import 'package:test/test.dart'; + +void main() { + group('predefinedFilterFallbackSort', () { + test('returns lastUpdated desc when filter is empty', () { + final sort = const Filter.empty().predefinedFilterFallbackSort; + + expect(sort, hasLength(1)); + expect(sort.single.field, equals(ChannelSortKey.lastUpdated)); + expect(sort.single.direction, equals(SortOption.DESC)); + }); + + test('returns lastMessageAt desc when raw filter touches last_message_at', () { + const filter = Filter.raw( + value: { + 'last_message_at': {r'$gt': '2024-01-01T00:00:00Z'}, + }, + ); + + final sort = filter.predefinedFilterFallbackSort; + + expect(sort.single.field, equals(ChannelSortKey.lastMessageAt)); + expect(sort.single.direction, equals(SortOption.DESC)); + }); + + test(r'returns lastMessageAt desc when last_message_at is nested under $or', () { + const filter = Filter.raw( + value: { + r'$or': [ + { + 'type': {r'$eq': 'messaging'}, + }, + { + 'last_message_at': {r'$gt': '2024-01-01T00:00:00Z'}, + }, + ], + }, + ); + + final sort = filter.predefinedFilterFallbackSort; + + expect(sort.single.field, equals(ChannelSortKey.lastMessageAt)); + }); + + test('returns lastUpdated desc when filter touches only other fields', () { + const filter = Filter.raw( + value: { + r'$and': [ + {'frozen': false}, + { + 'members': { + r'$in': ['u1', 'u2'], + }, + }, + ], + }, + ); + + final sort = filter.predefinedFilterFallbackSort; + + expect(sort.single.field, equals(ChannelSortKey.lastUpdated)); + }); + + test('returns lastMessageAt desc when typed Filter.and touches last_message_at', () { + final filter = Filter.and([ + Filter.equal('type', 'messaging'), + Filter.greater('last_message_at', '2024-01-01T00:00:00Z'), + ]); + + final sort = filter.predefinedFilterFallbackSort; + + expect(sort.single.field, equals(ChannelSortKey.lastMessageAt)); + }); + }); +} diff --git a/packages/stream_chat_flutter_core/CHANGELOG.md b/packages/stream_chat_flutter_core/CHANGELOG.md index bfe7433fbc..283e88e2f1 100644 --- a/packages/stream_chat_flutter_core/CHANGELOG.md +++ b/packages/stream_chat_flutter_core/CHANGELOG.md @@ -13,6 +13,7 @@ ✅ Added +- Added support for predefined filters on `StreamChannelListController`. - Added `StreamMessageComposerController.isEditing` getter. - Added `StreamMessageComposerController.clearCommand()`; setting `command = null` is now an alias for it. diff --git a/packages/stream_chat_flutter_core/lib/src/stream_channel_list_controller.dart b/packages/stream_chat_flutter_core/lib/src/stream_channel_list_controller.dart index bd671a0bcc..08b12a5c3f 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_channel_list_controller.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_channel_list_controller.dart @@ -38,6 +38,15 @@ class StreamChannelListController extends PagedValueNotifier { /// /// * `sort` is the sorting used for the channels matching the filters. /// + /// * `predefinedFilter` is the name of the server-defined filter. If set, it + /// takes precedence over [filter] and [channelStateSort]. + /// + /// `* filterValues` are the values used to interpolate placeholders in the + /// [predefinedFilter] filter definition on the server. + /// + /// * `sortValues` are the values used to interpolate placeholders in the + /// [predefinedFilter] sort definition on the server. + /// /// * `presence` sets whether you'll receive user presence updates via the /// websocket events. /// @@ -51,11 +60,15 @@ class StreamChannelListController extends PagedValueNotifier { StreamChannelListEventHandler? eventHandler, this.filter, this.channelStateSort = defaultChannelListSort, + this.predefinedFilter, + this.filterValues, + this.sortValues, this.presence = true, this.limit = defaultChannelPagedLimit, this.messageLimit, this.memberLimit, }) : _eventHandler = eventHandler ?? StreamChannelListEventHandler(), + _resolvedChannelStateSort = channelStateSort, super(const PagedValue.loading()); /// Creates a [StreamChannelListController] from the passed [value]. @@ -65,11 +78,15 @@ class StreamChannelListController extends PagedValueNotifier { StreamChannelListEventHandler? eventHandler, this.filter, this.channelStateSort = defaultChannelListSort, + this.predefinedFilter, + this.filterValues, + this.sortValues, this.presence = true, this.limit = defaultChannelPagedLimit, this.messageLimit, this.memberLimit, - }) : _eventHandler = eventHandler ?? StreamChannelListEventHandler(); + }) : _eventHandler = eventHandler ?? StreamChannelListEventHandler(), + _resolvedChannelStateSort = channelStateSort; /// The client to use for the channels list. final StreamChatClient client; @@ -95,6 +112,28 @@ class StreamChannelListController extends PagedValueNotifier { /// Direction can be ascending or descending. final SortOrder? channelStateSort; + /// The sort actually applied to incoming events. Seeded from + /// [channelStateSort] and overwritten whenever a query response carries a + /// resolved [PredefinedFilter.sort], so event-driven inserts keep matching + /// the server-resolved order even when callers only specify + /// [predefinedFilter]. + SortOrder? _resolvedChannelStateSort; + + /// Identifier of a server-side predefined filter to query channels with. + /// + /// When set, the server resolves the preset and returns the materialized + /// channels. [filterValues] and [sortValues] interpolate placeholders in + /// the preset definition. + final String? predefinedFilter; + + /// Values used to interpolate placeholders in the [predefinedFilter] + /// filter definition on the server. + final Map? filterValues; + + /// Values used to interpolate placeholders in the [predefinedFilter] + /// sort definition on the server. + final Map? sortValues; + /// If true you’ll receive user presence updates via the websocket events final bool presence; @@ -110,7 +149,7 @@ class StreamChannelListController extends PagedValueNotifier { @override set value(PagedValue newValue) { - super.value = switch (channelStateSort) { + super.value = switch (_resolvedChannelStateSort) { null => newValue, final channelSort => newValue.maybeMap( orElse: () => newValue, @@ -131,14 +170,19 @@ class StreamChannelListController extends PagedValueNotifier { _kDefaultBackendPaginationLimit, ); try { - await for (final channels in client.queryChannels( + await for (final result in client.queryChannelsWithResult( filter: filter, channelStateSort: channelStateSort, + predefinedFilter: predefinedFilter, + filterValues: filterValues, + sortValues: sortValues, memberLimit: memberLimit, messageLimit: messageLimit, presence: presence, paginationParams: PaginationParams(limit: limit), )) { + _resolveSort(result); + final channels = result.channels; final nextKey = channels.length < limit ? null : channels.length; value = PagedValue( items: channels, @@ -160,14 +204,18 @@ class StreamChannelListController extends PagedValueNotifier { final previousValue = value.asSuccess; try { - await for (final channels in client.queryChannels( + await for (final result in client.queryChannelsWithResult( filter: filter, channelStateSort: channelStateSort, + predefinedFilter: predefinedFilter, + filterValues: filterValues, + sortValues: sortValues, memberLimit: memberLimit, messageLimit: messageLimit, presence: presence, paginationParams: PaginationParams(limit: limit, offset: nextPageKey), )) { + final channels = result.channels; final previousItems = previousValue.items; final newItems = previousItems + channels; final nextKey = channels.length < limit ? null : newItems.length; @@ -184,6 +232,11 @@ class StreamChannelListController extends PagedValueNotifier { } } + void _resolveSort(QueryChannelsResult result) { + final resolved = result.predefinedFilter?.sort; + if (resolved != null) _resolvedChannelStateSort = resolved; + } + /// Replaces the previously loaded channels with the passed [channels]. set channels(List channels) { if (value.isSuccess) { diff --git a/packages/stream_chat_flutter_core/test/stream_channel_list_controller_test.dart b/packages/stream_chat_flutter_core/test/stream_channel_list_controller_test.dart new file mode 100644 index 0000000000..e43eb03bc5 --- /dev/null +++ b/packages/stream_chat_flutter_core/test/stream_channel_list_controller_test.dart @@ -0,0 +1,286 @@ +// ignore_for_file: avoid_redundant_argument_values + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/stream_chat.dart' hide Success; +import 'package:stream_chat_flutter_core/src/paged_value_notifier.dart'; +import 'package:stream_chat_flutter_core/src/stream_channel_list_controller.dart'; + +import 'mocks.dart'; + +void main() { + setUpAll(() { + registerFallbackValue(const PaginationParams()); + }); + + final client = MockClient(); + + setUp(() { + when(client.on).thenAnswer((_) => const Stream.empty()); + }); + + tearDown(() { + reset(client); + }); + + test('creates with loading state by default', () { + final controller = StreamChannelListController(client: client); + expect(controller.value, isA()); + }); + + test('fromValue preserves the provided value', () { + const value = PagedValue(items: []); + final controller = StreamChannelListController.fromValue( + value, + client: client, + ); + expect(controller.value, same(value)); + }); + + test('doInitialLoad forwards inline filter and sort to queryChannels', () async { + final filter = Filter.in_('members', const ['u1']); + const sort = [SortOption.desc(ChannelSortKey.lastMessageAt)]; + + when( + () => client.queryChannelsWithResult( + filter: any(named: 'filter'), + channelStateSort: any(named: 'channelStateSort'), + predefinedFilter: any(named: 'predefinedFilter'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + presence: any(named: 'presence'), + paginationParams: any(named: 'paginationParams'), + ), + ).thenAnswer((_) => Stream.value(const QueryChannelsResult(channels: []))); + + final controller = StreamChannelListController( + client: client, + filter: filter, + channelStateSort: sort, + ); + + await controller.doInitialLoad(); + await pumpEventQueue(); + + verify( + () => client.queryChannelsWithResult( + filter: filter, + channelStateSort: sort, + predefinedFilter: null, + filterValues: null, + sortValues: null, + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + presence: any(named: 'presence'), + paginationParams: any(named: 'paginationParams'), + ), + ).called(1); + }); + + test('doInitialLoad forwards predefinedFilter, filterValues, sortValues to queryChannels', () async { + const presetName = 'sample_app_filter'; + const filterValues = {'user_id': 'u1'}; + const sortValues = {'preset': 'recent'}; + + when( + () => client.queryChannelsWithResult( + filter: any(named: 'filter'), + channelStateSort: any(named: 'channelStateSort'), + predefinedFilter: any(named: 'predefinedFilter'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + presence: any(named: 'presence'), + paginationParams: any(named: 'paginationParams'), + ), + ).thenAnswer((_) => Stream.value(const QueryChannelsResult(channels: []))); + + final controller = StreamChannelListController( + client: client, + predefinedFilter: presetName, + filterValues: filterValues, + sortValues: sortValues, + channelStateSort: null, + ); + + await controller.doInitialLoad(); + await pumpEventQueue(); + + verify( + () => client.queryChannelsWithResult( + filter: null, + channelStateSort: null, + predefinedFilter: presetName, + filterValues: filterValues, + sortValues: sortValues, + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + presence: any(named: 'presence'), + paginationParams: any(named: 'paginationParams'), + ), + ).called(1); + }); + + test('doInitialLoad transitions to error state on exception', () async { + final exception = Exception('API unavailable'); + when( + () => client.queryChannelsWithResult( + filter: any(named: 'filter'), + channelStateSort: any(named: 'channelStateSort'), + predefinedFilter: any(named: 'predefinedFilter'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + presence: any(named: 'presence'), + paginationParams: any(named: 'paginationParams'), + ), + ).thenAnswer((_) => Stream.error(exception)); + + final controller = StreamChannelListController( + client: client, + channelStateSort: null, + ); + + await controller.doInitialLoad(); + await pumpEventQueue(); + + expect(controller.value, isA()); + expect( + (controller.value as Error).error.message, + contains('API unavailable'), + ); + }); + + test('loadMore appends new channels and forwards inline filter', () async { + final filter = Filter.in_('members', const ['u1']); + const nextPageKey = 2; + + final existing = [MockChannel(), MockChannel()]; + final fetched = [MockChannel()]; + + when( + () => client.queryChannelsWithResult( + filter: any(named: 'filter'), + channelStateSort: any(named: 'channelStateSort'), + predefinedFilter: any(named: 'predefinedFilter'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + presence: any(named: 'presence'), + paginationParams: any(named: 'paginationParams'), + ), + ).thenAnswer((_) => Stream.value(QueryChannelsResult(channels: fetched))); + + final controller = StreamChannelListController.fromValue( + PagedValue( + items: existing, + nextPageKey: nextPageKey, + ), + client: client, + filter: filter, + channelStateSort: null, + ); + + await controller.loadMore(nextPageKey); + await pumpEventQueue(); + + expect( + controller.value.asSuccess.items, + equals([...existing, ...fetched]), + ); + + final captured = verify( + () => client.queryChannelsWithResult( + filter: filter, + channelStateSort: null, + predefinedFilter: null, + filterValues: null, + sortValues: null, + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + presence: any(named: 'presence'), + paginationParams: captureAny(named: 'paginationParams'), + ), + ).captured; + + expect(captured, hasLength(1)); + expect((captured.single as PaginationParams).offset, equals(nextPageKey)); + }); + + test('loadMore appends new channels and forwards predefined params', () async { + const presetName = 'sample_app_filter'; + const filterValues = {'user_id': 'u1'}; + const sortValues = {'preset': 'recent'}; + const nextPageKey = 2; + + final existing = [MockChannel(), MockChannel()]; + final fetched = [MockChannel()]; + + when( + () => client.queryChannelsWithResult( + filter: any(named: 'filter'), + channelStateSort: any(named: 'channelStateSort'), + predefinedFilter: any(named: 'predefinedFilter'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + presence: any(named: 'presence'), + paginationParams: any(named: 'paginationParams'), + ), + ).thenAnswer((_) => Stream.value(QueryChannelsResult(channels: fetched))); + + final controller = StreamChannelListController.fromValue( + PagedValue( + items: existing, + nextPageKey: nextPageKey, + ), + client: client, + predefinedFilter: presetName, + filterValues: filterValues, + sortValues: sortValues, + channelStateSort: null, + ); + + await controller.loadMore(nextPageKey); + await pumpEventQueue(); + + expect( + controller.value.asSuccess.items, + equals([...existing, ...fetched]), + ); + + final captured = verify( + () => client.queryChannelsWithResult( + filter: null, + channelStateSort: null, + predefinedFilter: presetName, + filterValues: filterValues, + sortValues: sortValues, + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + presence: any(named: 'presence'), + paginationParams: captureAny(named: 'paginationParams'), + ), + ).captured; + + expect(captured, hasLength(1)); + expect((captured.single as PaginationParams).offset, equals(nextPageKey)); + }); + + test('channels setter replaces items while keeping success state', () { + const initial = PagedValue(items: []); + final controller = StreamChannelListController.fromValue( + initial, + client: client, + channelStateSort: null, + )..channels = const []; + + expect(controller.value.isSuccess, isTrue); + }); +} diff --git a/packages/stream_chat_persistence/CHANGELOG.md b/packages/stream_chat_persistence/CHANGELOG.md index 01cf269eb9..63b5863bcc 100644 --- a/packages/stream_chat_persistence/CHANGELOG.md +++ b/packages/stream_chat_persistence/CHANGELOG.md @@ -1,5 +1,9 @@ ## Upcoming Beta +✅ Added + +- Added support for predefined filters for `QueryChannels` on `StreamChatPersistenceClient`. + 🔄 Changed - Raised minimum versions of bundled dependencies (`drift`, `path`, `path_provider`) to current resolved versions. diff --git a/packages/stream_chat_persistence/lib/src/converter/converter.dart b/packages/stream_chat_persistence/lib/src/converter/converter.dart index bf8115e4b5..cdd0a2d40d 100644 --- a/packages/stream_chat_persistence/lib/src/converter/converter.dart +++ b/packages/stream_chat_persistence/lib/src/converter/converter.dart @@ -1,4 +1,6 @@ +export 'filter_converter.dart'; export 'list_converter.dart'; export 'map_converter.dart'; export 'reaction_groups_converter.dart'; +export 'sort_order_converter.dart'; export 'voting_visibility_converter.dart'; diff --git a/packages/stream_chat_persistence/lib/src/converter/filter_converter.dart b/packages/stream_chat_persistence/lib/src/converter/filter_converter.dart new file mode 100644 index 0000000000..56857c6470 --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/converter/filter_converter.dart @@ -0,0 +1,24 @@ +import 'dart:convert'; + +import 'package:drift/drift.dart'; +import 'package:stream_chat/stream_chat.dart'; + +/// A [TypeConverter] that serializes a [Filter] to and from its JSON [String] +/// representation. +/// +/// Used by the `channel_query_metadata` table to persist the server-resolved +/// filter spec associated with a predefined-filter query, so that offline +/// reads can reconstruct the full resolved spec. +class FilterConverter extends TypeConverter { + /// Creates a new instance. + const FilterConverter(); + + @override + Filter fromSql(String fromDb) { + final value = jsonDecode(fromDb) as Map; + return Filter.raw(value: value); + } + + @override + String toSql(Filter value) => jsonEncode(value.toJson()); +} diff --git a/packages/stream_chat_persistence/lib/src/converter/sort_order_converter.dart b/packages/stream_chat_persistence/lib/src/converter/sort_order_converter.dart new file mode 100644 index 0000000000..6d42b96465 --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/converter/sort_order_converter.dart @@ -0,0 +1,26 @@ +import 'dart:convert'; + +import 'package:drift/drift.dart'; +import 'package:stream_chat/stream_chat.dart'; + +/// A [TypeConverter] that serializes a [SortOrder] of [ChannelState] to and +/// from its JSON [String] representation. +/// +/// Used by the `channel_query_metadata` table to persist the server-resolved +/// sort spec associated with a predefined-filter query, so that offline reads +/// can apply the same ordering. +class ChannelStateSortOrderConverter extends TypeConverter, String> { + /// Creates a new instance. + const ChannelStateSortOrderConverter(); + + @override + SortOrder fromSql(String fromDb) { + final list = jsonDecode(fromDb) as List; + return list.cast>().map(SortOption.fromJson).toList(growable: false); + } + + @override + String toSql(SortOrder value) { + return jsonEncode(value.map((o) => o.toJson()).toList()); + } +} diff --git a/packages/stream_chat_persistence/lib/src/dao/channel_query_dao.dart b/packages/stream_chat_persistence/lib/src/dao/channel_query_dao.dart index a8f0d922f8..070d304ebb 100644 --- a/packages/stream_chat_persistence/lib/src/dao/channel_query_dao.dart +++ b/packages/stream_chat_persistence/lib/src/dao/channel_query_dao.dart @@ -4,6 +4,7 @@ import 'package:drift/drift.dart'; import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat_persistence/src/db/drift_chat_database.dart'; import 'package:stream_chat_persistence/src/entity/channel_queries.dart'; +import 'package:stream_chat_persistence/src/entity/channel_queries_metadata.dart'; import 'package:stream_chat_persistence/src/entity/channels.dart'; import 'package:stream_chat_persistence/src/entity/users.dart'; import 'package:stream_chat_persistence/src/mapper/mapper.dart'; @@ -11,7 +12,7 @@ import 'package:stream_chat_persistence/src/mapper/mapper.dart'; part 'channel_query_dao.g.dart'; /// The Data Access Object for operations in [ChannelQueries] table. -@DriftAccessor(tables: [ChannelQueries, Channels, Users]) +@DriftAccessor(tables: [ChannelQueries, ChannelQueriesMetadata, Channels, Users]) class ChannelQueryDao extends DatabaseAccessor with _$ChannelQueryDaoMixin { /// Creates a new channel query dao instance ChannelQueryDao(super.db); @@ -24,6 +25,19 @@ class ChannelQueryDao extends DatabaseAccessor with _$Channel return hash; } + String _computeHashForPredefined( + String filterName, + Map? filterValues, + Map? sortValues, + ) { + final payload = { + 'name': filterName, + if (filterValues != null) 'filter_values': filterValues, + if (sortValues != null) 'sort_values': sortValues, + }; + return 'p:${base64Encode(utf8.encode(jsonEncode(payload)))}'; + } + /// Update list of channel queries /// If [clearQueryCache] is true before the insert /// the list of matching rows will be deleted @@ -50,7 +64,52 @@ class ChannelQueryDao extends DatabaseAccessor with _$Channel }); }); + /// Update list of channel queries for a predefined-filter query. /// + /// Writes the cached cids to [ChannelQueries] and the server-resolved + /// [filter] and [sort] to [ChannelQueriesMetadata] in a single transaction. + /// If [clearQueryCache] is true, prior cids and metadata for this query are + /// deleted before the insert. + Future updateChannelQueriesByPredefinedFilter( + String filterName, + List cids, { + required Filter filter, + required SortOrder sort, + Map? filterValues, + Map? sortValues, + bool clearQueryCache = false, + }) async => transaction(() async { + final hash = _computeHashForPredefined(filterName, filterValues, sortValues); + + if (clearQueryCache) { + await batch((it) { + it + ..deleteWhere( + channelQueries, + (c) => c.queryHash.equals(hash), + ) + ..deleteWhere( + channelQueriesMetadata, + (m) => m.queryHash.equals(hash), + ); + }); + } + + await batch((it) { + it + ..insertAllOnConflictUpdate( + channelQueries, + cids.map((cid) => ChannelQueryEntity(queryHash: hash, channelCid: cid)).toList(), + ) + ..insert( + channelQueriesMetadata, + ChannelQueryMetadataEntity(queryHash: hash, filter: filter, sort: sort), + mode: InsertMode.insertOrReplace, + ); + }); + }); + + /// Get the CIDs stored under the hash generated by [filter]. Future> getCachedChannelCids(Filter? filter) { final hash = _computeHash(filter); return (select(channelQueries)..where((c) => c.queryHash.equals(hash))).map((c) => c.channelCid).get(); @@ -74,4 +133,42 @@ class ChannelQueryDao extends DatabaseAccessor with _$Channel return cachedChannels; } + + /// Get the cached channels and persisted filter/sort spec for a + /// predefined-filter query. + /// + /// Returns a record `(channels, filter, sort)`. The two + /// spec fields are null when no metadata row exists for this query. + Future<(List, Filter?, SortOrder?)> getChannelsAndSpecByPredefinedFilter( + String filterName, { + Map? filterValues, + Map? sortValues, + }) async { + final hash = _computeHashForPredefined(filterName, filterValues, sortValues); + + final cidsQuery = (select(channelQueries)..where((c) => c.queryHash.equals(hash))).map((c) => c.channelCid).get(); + + final metadataQuery = (select(channelQueriesMetadata)..where((m) => m.queryHash.equals(hash))).getSingleOrNull(); + + final (cachedCids, metadata) = await (cidsQuery, metadataQuery).wait; + + if (cachedCids.isEmpty) { + return (const [], metadata?.filter, metadata?.sort); + } + + final cachedChannels = await (select(channels)..where((c) => c.cid.isIn(cachedCids))) + .join([ + leftOuterJoin(users, channels.createdById.equalsExp(users.id)), + ]) + .map((row) { + final createdByEntity = row.readTableOrNull(users); + final channelEntity = row.readTable(channels); + return channelEntity.toChannelModel( + createdBy: createdByEntity?.toUser(), + ); + }) + .get(); + + return (cachedChannels, metadata?.filter, metadata?.sort); + } } diff --git a/packages/stream_chat_persistence/lib/src/dao/channel_query_dao.g.dart b/packages/stream_chat_persistence/lib/src/dao/channel_query_dao.g.dart index 19a74b2cc5..20ce4e4152 100644 --- a/packages/stream_chat_persistence/lib/src/dao/channel_query_dao.g.dart +++ b/packages/stream_chat_persistence/lib/src/dao/channel_query_dao.g.dart @@ -5,6 +5,7 @@ part of 'channel_query_dao.dart'; // ignore_for_file: type=lint mixin _$ChannelQueryDaoMixin on DatabaseAccessor { $ChannelQueriesTable get channelQueries => attachedDatabase.channelQueries; + $ChannelQueriesMetadataTable get channelQueriesMetadata => attachedDatabase.channelQueriesMetadata; $ChannelsTable get channels => attachedDatabase.channels; $UsersTable get users => attachedDatabase.users; ChannelQueryDaoManager get managers => ChannelQueryDaoManager(this); @@ -17,6 +18,10 @@ class ChannelQueryDaoManager { _db.attachedDatabase, _db.channelQueries, ); + $$ChannelQueriesMetadataTableTableManager get channelQueriesMetadata => $$ChannelQueriesMetadataTableTableManager( + _db.attachedDatabase, + _db.channelQueriesMetadata, + ); $$ChannelsTableTableManager get channels => $$ChannelsTableTableManager(_db.attachedDatabase, _db.channels); $$UsersTableTableManager get users => $$UsersTableTableManager(_db.attachedDatabase, _db.users); } diff --git a/packages/stream_chat_persistence/lib/src/dao/draft_message_dao.g.dart b/packages/stream_chat_persistence/lib/src/dao/draft_message_dao.g.dart index aeb0b29f96..d9de110f6d 100644 --- a/packages/stream_chat_persistence/lib/src/dao/draft_message_dao.g.dart +++ b/packages/stream_chat_persistence/lib/src/dao/draft_message_dao.g.dart @@ -4,17 +4,15 @@ part of 'draft_message_dao.dart'; // ignore_for_file: type=lint mixin _$DraftMessageDaoMixin on DatabaseAccessor { - $ChannelsTable get channels => attachedDatabase.channels; - $MessagesTable get messages => attachedDatabase.messages; $DraftMessagesTable get draftMessages => attachedDatabase.draftMessages; + $MessagesTable get messages => attachedDatabase.messages; DraftMessageDaoManager get managers => DraftMessageDaoManager(this); } class DraftMessageDaoManager { final _$DraftMessageDaoMixin _db; DraftMessageDaoManager(this._db); - $$ChannelsTableTableManager get channels => $$ChannelsTableTableManager(_db.attachedDatabase, _db.channels); - $$MessagesTableTableManager get messages => $$MessagesTableTableManager(_db.attachedDatabase, _db.messages); $$DraftMessagesTableTableManager get draftMessages => $$DraftMessagesTableTableManager(_db.attachedDatabase, _db.draftMessages); + $$MessagesTableTableManager get messages => $$MessagesTableTableManager(_db.attachedDatabase, _db.messages); } diff --git a/packages/stream_chat_persistence/lib/src/dao/location_dao.g.dart b/packages/stream_chat_persistence/lib/src/dao/location_dao.g.dart index f94ce58c57..278b814111 100644 --- a/packages/stream_chat_persistence/lib/src/dao/location_dao.g.dart +++ b/packages/stream_chat_persistence/lib/src/dao/location_dao.g.dart @@ -4,8 +4,6 @@ part of 'location_dao.dart'; // ignore_for_file: type=lint mixin _$LocationDaoMixin on DatabaseAccessor { - $ChannelsTable get channels => attachedDatabase.channels; - $MessagesTable get messages => attachedDatabase.messages; $LocationsTable get locations => attachedDatabase.locations; LocationDaoManager get managers => LocationDaoManager(this); } @@ -13,7 +11,5 @@ mixin _$LocationDaoMixin on DatabaseAccessor { class LocationDaoManager { final _$LocationDaoMixin _db; LocationDaoManager(this._db); - $$ChannelsTableTableManager get channels => $$ChannelsTableTableManager(_db.attachedDatabase, _db.channels); - $$MessagesTableTableManager get messages => $$MessagesTableTableManager(_db.attachedDatabase, _db.messages); $$LocationsTableTableManager get locations => $$LocationsTableTableManager(_db.attachedDatabase, _db.locations); } diff --git a/packages/stream_chat_persistence/lib/src/dao/member_dao.g.dart b/packages/stream_chat_persistence/lib/src/dao/member_dao.g.dart index f202f4fc11..65ef3d0f13 100644 --- a/packages/stream_chat_persistence/lib/src/dao/member_dao.g.dart +++ b/packages/stream_chat_persistence/lib/src/dao/member_dao.g.dart @@ -4,7 +4,6 @@ part of 'member_dao.dart'; // ignore_for_file: type=lint mixin _$MemberDaoMixin on DatabaseAccessor { - $ChannelsTable get channels => attachedDatabase.channels; $MembersTable get members => attachedDatabase.members; $UsersTable get users => attachedDatabase.users; MemberDaoManager get managers => MemberDaoManager(this); @@ -13,7 +12,6 @@ mixin _$MemberDaoMixin on DatabaseAccessor { class MemberDaoManager { final _$MemberDaoMixin _db; MemberDaoManager(this._db); - $$ChannelsTableTableManager get channels => $$ChannelsTableTableManager(_db.attachedDatabase, _db.channels); $$MembersTableTableManager get members => $$MembersTableTableManager(_db.attachedDatabase, _db.members); $$UsersTableTableManager get users => $$UsersTableTableManager(_db.attachedDatabase, _db.users); } diff --git a/packages/stream_chat_persistence/lib/src/dao/message_dao.g.dart b/packages/stream_chat_persistence/lib/src/dao/message_dao.g.dart index b044680b30..7c0acb0a3d 100644 --- a/packages/stream_chat_persistence/lib/src/dao/message_dao.g.dart +++ b/packages/stream_chat_persistence/lib/src/dao/message_dao.g.dart @@ -4,7 +4,6 @@ part of 'message_dao.dart'; // ignore_for_file: type=lint mixin _$MessageDaoMixin on DatabaseAccessor { - $ChannelsTable get channels => attachedDatabase.channels; $MessagesTable get messages => attachedDatabase.messages; $UsersTable get users => attachedDatabase.users; MessageDaoManager get managers => MessageDaoManager(this); @@ -13,7 +12,6 @@ mixin _$MessageDaoMixin on DatabaseAccessor { class MessageDaoManager { final _$MessageDaoMixin _db; MessageDaoManager(this._db); - $$ChannelsTableTableManager get channels => $$ChannelsTableTableManager(_db.attachedDatabase, _db.channels); $$MessagesTableTableManager get messages => $$MessagesTableTableManager(_db.attachedDatabase, _db.messages); $$UsersTableTableManager get users => $$UsersTableTableManager(_db.attachedDatabase, _db.users); } diff --git a/packages/stream_chat_persistence/lib/src/dao/pinned_message_reaction_dao.g.dart b/packages/stream_chat_persistence/lib/src/dao/pinned_message_reaction_dao.g.dart index 0487e73aa9..57bb730d16 100644 --- a/packages/stream_chat_persistence/lib/src/dao/pinned_message_reaction_dao.g.dart +++ b/packages/stream_chat_persistence/lib/src/dao/pinned_message_reaction_dao.g.dart @@ -4,7 +4,6 @@ part of 'pinned_message_reaction_dao.dart'; // ignore_for_file: type=lint mixin _$PinnedMessageReactionDaoMixin on DatabaseAccessor { - $PinnedMessagesTable get pinnedMessages => attachedDatabase.pinnedMessages; $PinnedMessageReactionsTable get pinnedMessageReactions => attachedDatabase.pinnedMessageReactions; $UsersTable get users => attachedDatabase.users; PinnedMessageReactionDaoManager get managers => PinnedMessageReactionDaoManager(this); @@ -13,10 +12,6 @@ mixin _$PinnedMessageReactionDaoMixin on DatabaseAccessor { class PinnedMessageReactionDaoManager { final _$PinnedMessageReactionDaoMixin _db; PinnedMessageReactionDaoManager(this._db); - $$PinnedMessagesTableTableManager get pinnedMessages => $$PinnedMessagesTableTableManager( - _db.attachedDatabase, - _db.pinnedMessages, - ); $$PinnedMessageReactionsTableTableManager get pinnedMessageReactions => $$PinnedMessageReactionsTableTableManager( _db.attachedDatabase, _db.pinnedMessageReactions, diff --git a/packages/stream_chat_persistence/lib/src/dao/poll_vote_dao.g.dart b/packages/stream_chat_persistence/lib/src/dao/poll_vote_dao.g.dart index fee64718d1..5e2a262643 100644 --- a/packages/stream_chat_persistence/lib/src/dao/poll_vote_dao.g.dart +++ b/packages/stream_chat_persistence/lib/src/dao/poll_vote_dao.g.dart @@ -4,7 +4,6 @@ part of 'poll_vote_dao.dart'; // ignore_for_file: type=lint mixin _$PollVoteDaoMixin on DatabaseAccessor { - $PollsTable get polls => attachedDatabase.polls; $PollVotesTable get pollVotes => attachedDatabase.pollVotes; $UsersTable get users => attachedDatabase.users; PollVoteDaoManager get managers => PollVoteDaoManager(this); @@ -13,7 +12,6 @@ mixin _$PollVoteDaoMixin on DatabaseAccessor { class PollVoteDaoManager { final _$PollVoteDaoMixin _db; PollVoteDaoManager(this._db); - $$PollsTableTableManager get polls => $$PollsTableTableManager(_db.attachedDatabase, _db.polls); $$PollVotesTableTableManager get pollVotes => $$PollVotesTableTableManager(_db.attachedDatabase, _db.pollVotes); $$UsersTableTableManager get users => $$UsersTableTableManager(_db.attachedDatabase, _db.users); } diff --git a/packages/stream_chat_persistence/lib/src/dao/reaction_dao.g.dart b/packages/stream_chat_persistence/lib/src/dao/reaction_dao.g.dart index 4bc2458415..9cb7327ac5 100644 --- a/packages/stream_chat_persistence/lib/src/dao/reaction_dao.g.dart +++ b/packages/stream_chat_persistence/lib/src/dao/reaction_dao.g.dart @@ -4,8 +4,6 @@ part of 'reaction_dao.dart'; // ignore_for_file: type=lint mixin _$ReactionDaoMixin on DatabaseAccessor { - $ChannelsTable get channels => attachedDatabase.channels; - $MessagesTable get messages => attachedDatabase.messages; $ReactionsTable get reactions => attachedDatabase.reactions; $UsersTable get users => attachedDatabase.users; ReactionDaoManager get managers => ReactionDaoManager(this); @@ -14,8 +12,6 @@ mixin _$ReactionDaoMixin on DatabaseAccessor { class ReactionDaoManager { final _$ReactionDaoMixin _db; ReactionDaoManager(this._db); - $$ChannelsTableTableManager get channels => $$ChannelsTableTableManager(_db.attachedDatabase, _db.channels); - $$MessagesTableTableManager get messages => $$MessagesTableTableManager(_db.attachedDatabase, _db.messages); $$ReactionsTableTableManager get reactions => $$ReactionsTableTableManager(_db.attachedDatabase, _db.reactions); $$UsersTableTableManager get users => $$UsersTableTableManager(_db.attachedDatabase, _db.users); } diff --git a/packages/stream_chat_persistence/lib/src/dao/read_dao.g.dart b/packages/stream_chat_persistence/lib/src/dao/read_dao.g.dart index 417fd7985f..5ed73de23d 100644 --- a/packages/stream_chat_persistence/lib/src/dao/read_dao.g.dart +++ b/packages/stream_chat_persistence/lib/src/dao/read_dao.g.dart @@ -4,7 +4,6 @@ part of 'read_dao.dart'; // ignore_for_file: type=lint mixin _$ReadDaoMixin on DatabaseAccessor { - $ChannelsTable get channels => attachedDatabase.channels; $ReadsTable get reads => attachedDatabase.reads; $UsersTable get users => attachedDatabase.users; ReadDaoManager get managers => ReadDaoManager(this); @@ -13,7 +12,6 @@ mixin _$ReadDaoMixin on DatabaseAccessor { class ReadDaoManager { final _$ReadDaoMixin _db; ReadDaoManager(this._db); - $$ChannelsTableTableManager get channels => $$ChannelsTableTableManager(_db.attachedDatabase, _db.channels); $$ReadsTableTableManager get reads => $$ReadsTableTableManager(_db.attachedDatabase, _db.reads); $$UsersTableTableManager get users => $$UsersTableTableManager(_db.attachedDatabase, _db.users); } diff --git a/packages/stream_chat_persistence/lib/src/db/drift_chat_database.dart b/packages/stream_chat_persistence/lib/src/db/drift_chat_database.dart index 822d191bf4..fb71583ac8 100644 --- a/packages/stream_chat_persistence/lib/src/db/drift_chat_database.dart +++ b/packages/stream_chat_persistence/lib/src/db/drift_chat_database.dart @@ -24,6 +24,7 @@ part 'drift_chat_database.g.dart'; Members, Reads, ChannelQueries, + ChannelQueriesMetadata, ConnectionEvents, ], daos: [ @@ -57,7 +58,7 @@ class DriftChatDatabase extends _$DriftChatDatabase { // you should bump this number whenever you change or add a table definition. @override - int get schemaVersion => 1000 + 30; + int get schemaVersion => 1000 + 31; // Store DateTime as ISO-8601 text to preserve sub-second precision. @override diff --git a/packages/stream_chat_persistence/lib/src/db/drift_chat_database.g.dart b/packages/stream_chat_persistence/lib/src/db/drift_chat_database.g.dart index 7dd00aafaa..43b31e3a8e 100644 --- a/packages/stream_chat_persistence/lib/src/db/drift_chat_database.g.dart +++ b/packages/stream_chat_persistence/lib/src/db/drift_chat_database.g.dart @@ -10396,6 +10396,278 @@ class ChannelQueriesCompanion extends UpdateCompanion { } } +class $ChannelQueriesMetadataTable extends ChannelQueriesMetadata + with TableInfo<$ChannelQueriesMetadataTable, ChannelQueryMetadataEntity> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $ChannelQueriesMetadataTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _queryHashMeta = const VerificationMeta( + 'queryHash', + ); + @override + late final GeneratedColumn queryHash = GeneratedColumn( + 'query_hash', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + @override + late final GeneratedColumnWithTypeConverter filter = GeneratedColumn( + 'filter', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ).withConverter($ChannelQueriesMetadataTable.$converterfilter); + @override + late final GeneratedColumnWithTypeConverter, String> sort = + GeneratedColumn( + 'sort', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ).withConverter>( + $ChannelQueriesMetadataTable.$convertersort, + ); + @override + List get $columns => [queryHash, filter, sort]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'channel_queries_metadata'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('query_hash')) { + context.handle( + _queryHashMeta, + queryHash.isAcceptableOrUnknown(data['query_hash']!, _queryHashMeta), + ); + } else if (isInserting) { + context.missing(_queryHashMeta); + } + return context; + } + + @override + Set get $primaryKey => {queryHash}; + @override + ChannelQueryMetadataEntity map( + Map data, { + String? tablePrefix, + }) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ChannelQueryMetadataEntity( + queryHash: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}query_hash'], + )!, + filter: $ChannelQueriesMetadataTable.$converterfilter.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}filter'], + )!, + ), + sort: $ChannelQueriesMetadataTable.$convertersort.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}sort'], + )!, + ), + ); + } + + @override + $ChannelQueriesMetadataTable createAlias(String alias) { + return $ChannelQueriesMetadataTable(attachedDatabase, alias); + } + + static TypeConverter $converterfilter = const FilterConverter(); + static TypeConverter, String> $convertersort = const ChannelStateSortOrderConverter(); +} + +class ChannelQueryMetadataEntity extends DataClass implements Insertable { + /// The query hash this metadata is associated with. Matches the hashes + /// produced by `ChannelQueryDao` for predefined-filter queries. + final String queryHash; + + /// The server-resolved filter spec to surface on offline reads. + final Filter filter; + + /// The server-resolved sort spec to apply on offline reads. + final SortOrder sort; + const ChannelQueryMetadataEntity({ + required this.queryHash, + required this.filter, + required this.sort, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['query_hash'] = Variable(queryHash); + { + map['filter'] = Variable( + $ChannelQueriesMetadataTable.$converterfilter.toSql(filter), + ); + } + { + map['sort'] = Variable( + $ChannelQueriesMetadataTable.$convertersort.toSql(sort), + ); + } + return map; + } + + factory ChannelQueryMetadataEntity.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ChannelQueryMetadataEntity( + queryHash: serializer.fromJson(json['queryHash']), + filter: serializer.fromJson(json['filter']), + sort: serializer.fromJson>(json['sort']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'queryHash': serializer.toJson(queryHash), + 'filter': serializer.toJson(filter), + 'sort': serializer.toJson>(sort), + }; + } + + ChannelQueryMetadataEntity copyWith({ + String? queryHash, + Filter? filter, + SortOrder? sort, + }) => ChannelQueryMetadataEntity( + queryHash: queryHash ?? this.queryHash, + filter: filter ?? this.filter, + sort: sort ?? this.sort, + ); + ChannelQueryMetadataEntity copyWithCompanion( + ChannelQueriesMetadataCompanion data, + ) { + return ChannelQueryMetadataEntity( + queryHash: data.queryHash.present ? data.queryHash.value : this.queryHash, + filter: data.filter.present ? data.filter.value : this.filter, + sort: data.sort.present ? data.sort.value : this.sort, + ); + } + + @override + String toString() { + return (StringBuffer('ChannelQueryMetadataEntity(') + ..write('queryHash: $queryHash, ') + ..write('filter: $filter, ') + ..write('sort: $sort') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(queryHash, filter, sort); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ChannelQueryMetadataEntity && + other.queryHash == this.queryHash && + other.filter == this.filter && + other.sort == this.sort); +} + +class ChannelQueriesMetadataCompanion extends UpdateCompanion { + final Value queryHash; + final Value filter; + final Value> sort; + final Value rowid; + const ChannelQueriesMetadataCompanion({ + this.queryHash = const Value.absent(), + this.filter = const Value.absent(), + this.sort = const Value.absent(), + this.rowid = const Value.absent(), + }); + ChannelQueriesMetadataCompanion.insert({ + required String queryHash, + required Filter filter, + required SortOrder sort, + this.rowid = const Value.absent(), + }) : queryHash = Value(queryHash), + filter = Value(filter), + sort = Value(sort); + static Insertable custom({ + Expression? queryHash, + Expression? filter, + Expression? sort, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (queryHash != null) 'query_hash': queryHash, + if (filter != null) 'filter': filter, + if (sort != null) 'sort': sort, + if (rowid != null) 'rowid': rowid, + }); + } + + ChannelQueriesMetadataCompanion copyWith({ + Value? queryHash, + Value? filter, + Value>? sort, + Value? rowid, + }) { + return ChannelQueriesMetadataCompanion( + queryHash: queryHash ?? this.queryHash, + filter: filter ?? this.filter, + sort: sort ?? this.sort, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (queryHash.present) { + map['query_hash'] = Variable(queryHash.value); + } + if (filter.present) { + map['filter'] = Variable( + $ChannelQueriesMetadataTable.$converterfilter.toSql(filter.value), + ); + } + if (sort.present) { + map['sort'] = Variable( + $ChannelQueriesMetadataTable.$convertersort.toSql(sort.value), + ); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ChannelQueriesMetadataCompanion(') + ..write('queryHash: $queryHash, ') + ..write('filter: $filter, ') + ..write('sort: $sort, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + class $ConnectionEventsTable extends ConnectionEvents with TableInfo<$ConnectionEventsTable, ConnectionEventEntity> { @override final GeneratedDatabase attachedDatabase; @@ -10872,6 +11144,7 @@ abstract class _$DriftChatDatabase extends GeneratedDatabase { late final $MembersTable members = $MembersTable(this); late final $ReadsTable reads = $ReadsTable(this); late final $ChannelQueriesTable channelQueries = $ChannelQueriesTable(this); + late final $ChannelQueriesMetadataTable channelQueriesMetadata = $ChannelQueriesMetadataTable(this); late final $ConnectionEventsTable connectionEvents = $ConnectionEventsTable( this, ); @@ -10914,6 +11187,7 @@ abstract class _$DriftChatDatabase extends GeneratedDatabase { members, reads, channelQueries, + channelQueriesMetadata, connectionEvents, ]; @override @@ -17700,6 +17974,173 @@ typedef $$ChannelQueriesTableProcessedTableManager = ChannelQueryEntity, PrefetchHooks Function() >; +typedef $$ChannelQueriesMetadataTableCreateCompanionBuilder = + ChannelQueriesMetadataCompanion Function({ + required String queryHash, + required Filter filter, + required SortOrder sort, + Value rowid, + }); +typedef $$ChannelQueriesMetadataTableUpdateCompanionBuilder = + ChannelQueriesMetadataCompanion Function({ + Value queryHash, + Value filter, + Value> sort, + Value rowid, + }); + +class $$ChannelQueriesMetadataTableFilterComposer extends Composer<_$DriftChatDatabase, $ChannelQueriesMetadataTable> { + $$ChannelQueriesMetadataTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get queryHash => $composableBuilder( + column: $table.queryHash, + builder: (column) => ColumnFilters(column), + ); + + ColumnWithTypeConverterFilters get filter => $composableBuilder( + column: $table.filter, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnWithTypeConverterFilters, SortOrder, String> get sort => + $composableBuilder( + column: $table.sort, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); +} + +class $$ChannelQueriesMetadataTableOrderingComposer + extends Composer<_$DriftChatDatabase, $ChannelQueriesMetadataTable> { + $$ChannelQueriesMetadataTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get queryHash => $composableBuilder( + column: $table.queryHash, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get filter => $composableBuilder( + column: $table.filter, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get sort => $composableBuilder( + column: $table.sort, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$ChannelQueriesMetadataTableAnnotationComposer + extends Composer<_$DriftChatDatabase, $ChannelQueriesMetadataTable> { + $$ChannelQueriesMetadataTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get queryHash => $composableBuilder(column: $table.queryHash, builder: (column) => column); + + GeneratedColumnWithTypeConverter get filter => + $composableBuilder(column: $table.filter, builder: (column) => column); + + GeneratedColumnWithTypeConverter, String> get sort => + $composableBuilder(column: $table.sort, builder: (column) => column); +} + +class $$ChannelQueriesMetadataTableTableManager + extends + RootTableManager< + _$DriftChatDatabase, + $ChannelQueriesMetadataTable, + ChannelQueryMetadataEntity, + $$ChannelQueriesMetadataTableFilterComposer, + $$ChannelQueriesMetadataTableOrderingComposer, + $$ChannelQueriesMetadataTableAnnotationComposer, + $$ChannelQueriesMetadataTableCreateCompanionBuilder, + $$ChannelQueriesMetadataTableUpdateCompanionBuilder, + ( + ChannelQueryMetadataEntity, + BaseReferences<_$DriftChatDatabase, $ChannelQueriesMetadataTable, ChannelQueryMetadataEntity>, + ), + ChannelQueryMetadataEntity, + PrefetchHooks Function() + > { + $$ChannelQueriesMetadataTableTableManager( + _$DriftChatDatabase db, + $ChannelQueriesMetadataTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => $$ChannelQueriesMetadataTableFilterComposer( + $db: db, + $table: table, + ), + createOrderingComposer: () => $$ChannelQueriesMetadataTableOrderingComposer( + $db: db, + $table: table, + ), + createComputedFieldComposer: () => $$ChannelQueriesMetadataTableAnnotationComposer( + $db: db, + $table: table, + ), + updateCompanionCallback: + ({ + Value queryHash = const Value.absent(), + Value filter = const Value.absent(), + Value> sort = const Value.absent(), + Value rowid = const Value.absent(), + }) => ChannelQueriesMetadataCompanion( + queryHash: queryHash, + filter: filter, + sort: sort, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String queryHash, + required Filter filter, + required SortOrder sort, + Value rowid = const Value.absent(), + }) => ChannelQueriesMetadataCompanion.insert( + queryHash: queryHash, + filter: filter, + sort: sort, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0.map((e) => (e.readTable(table), BaseReferences(db, table, e))).toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$ChannelQueriesMetadataTableProcessedTableManager = + ProcessedTableManager< + _$DriftChatDatabase, + $ChannelQueriesMetadataTable, + ChannelQueryMetadataEntity, + $$ChannelQueriesMetadataTableFilterComposer, + $$ChannelQueriesMetadataTableOrderingComposer, + $$ChannelQueriesMetadataTableAnnotationComposer, + $$ChannelQueriesMetadataTableCreateCompanionBuilder, + $$ChannelQueriesMetadataTableUpdateCompanionBuilder, + ( + ChannelQueryMetadataEntity, + BaseReferences<_$DriftChatDatabase, $ChannelQueriesMetadataTable, ChannelQueryMetadataEntity>, + ), + ChannelQueryMetadataEntity, + PrefetchHooks Function() + >; typedef $$ConnectionEventsTableCreateCompanionBuilder = ConnectionEventsCompanion Function({ Value id, @@ -17952,6 +18393,10 @@ class $DriftChatDatabaseManager { $$MembersTableTableManager get members => $$MembersTableTableManager(_db, _db.members); $$ReadsTableTableManager get reads => $$ReadsTableTableManager(_db, _db.reads); $$ChannelQueriesTableTableManager get channelQueries => $$ChannelQueriesTableTableManager(_db, _db.channelQueries); + $$ChannelQueriesMetadataTableTableManager get channelQueriesMetadata => $$ChannelQueriesMetadataTableTableManager( + _db, + _db.channelQueriesMetadata, + ); $$ConnectionEventsTableTableManager get connectionEvents => $$ConnectionEventsTableTableManager(_db, _db.connectionEvents); } diff --git a/packages/stream_chat_persistence/lib/src/entity/channel_queries_metadata.dart b/packages/stream_chat_persistence/lib/src/entity/channel_queries_metadata.dart new file mode 100644 index 0000000000..99441f81e6 --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/entity/channel_queries_metadata.dart @@ -0,0 +1,24 @@ +// coverage:ignore-file +import 'package:drift/drift.dart'; +import 'package:stream_chat_persistence/src/converter/converter.dart'; + +/// Represents a [ChannelQueriesMetadata] table in `DriftChatDatabase`. +/// +/// Holds side-information about a channel query keyed by its [queryHash]. +/// Stores the server-resolved filter and sort spec used by predefined-filter +/// queries so that offline reads can reconstruct the full resolved spec. +@DataClassName('ChannelQueryMetadataEntity') +class ChannelQueriesMetadata extends Table { + /// The query hash this metadata is associated with. Matches the hashes + /// produced by `ChannelQueryDao` for predefined-filter queries. + TextColumn get queryHash => text()(); + + /// The server-resolved filter spec to surface on offline reads. + TextColumn get filter => text().map(const FilterConverter())(); + + /// The server-resolved sort spec to apply on offline reads. + TextColumn get sort => text().map(const ChannelStateSortOrderConverter())(); + + @override + Set get primaryKey => {queryHash}; +} diff --git a/packages/stream_chat_persistence/lib/src/entity/entity.dart b/packages/stream_chat_persistence/lib/src/entity/entity.dart index 58fb6a164d..8aecaf5af4 100644 --- a/packages/stream_chat_persistence/lib/src/entity/entity.dart +++ b/packages/stream_chat_persistence/lib/src/entity/entity.dart @@ -1,4 +1,5 @@ export 'channel_queries.dart'; +export 'channel_queries_metadata.dart'; export 'channels.dart'; export 'connection_events.dart'; export 'draft_messages.dart'; diff --git a/packages/stream_chat_persistence/lib/src/stream_chat_persistence_client.dart b/packages/stream_chat_persistence/lib/src/stream_chat_persistence_client.dart index a7db994af0..abf027357a 100644 --- a/packages/stream_chat_persistence/lib/src/stream_chat_persistence_client.dart +++ b/packages/stream_chat_persistence/lib/src/stream_chat_persistence_client.dart @@ -309,32 +309,81 @@ class StreamChatPersistenceClient extends ChatPersistenceClient { assert(_debugIsConnected, ''); _logger.info('getChannelStates'); - // 1) Lightweight load — channel rows + createdBy user only. final channelModels = await db!.channelQueryDao.getChannels(filter: filter); - // 2) Wrap each model in a sort envelope. No state loaded yet. + return _getChannelStatesPage( + channelModels, + channelStateSort, + paginationParams, + ); + } + + @override + Future getChannelStatesByPredefinedFilter({ + required String filterName, + Map? filterValues, + Map? sortValues, + int? messageLimit, + PaginationParams? paginationParams, + }) async { + assert(_debugIsConnected, ''); + _logger.info('getChannelStatesByPredefinedFilter'); + + final (channelModels, filter, sort) = await db!.channelQueryDao.getChannelsAndSpecByPredefinedFilter( + filterName, + filterValues: filterValues, + sortValues: sortValues, + ); + + final channels = await _getChannelStatesPage( + channelModels, + sort, + paginationParams, + ); + final predefinedFilter = filter == null + ? null + : PredefinedFilter( + name: filterName, + filter: filter, + sort: sort, + ); + + return QueryChannelsResponse() + ..channels = channels + ..predefinedFilter = predefinedFilter; + } + + // Wraps channel models in sort envelopes, attaches memberships when the + // sort needs them, sorts, slices the requested page, and hydrates only the + // page with full channel state. + Future> _getChannelStatesPage( + List channelModels, + SortOrder? channelStateSort, + PaginationParams? paginationParams, + ) async { + // 1) Wrap each model in a sort envelope. No state loaded yet. var envelopes = channelModels.map((m) => ChannelState(channel: m)).toList(growable: false); - // 3) If sort uses `pinnedAt`, preload the current user's memberships in + // 2) If sort uses `pinnedAt`, preload the current user's memberships in // one batched query and attach them to the envelopes. final clientUserId = userId; if (clientUserId != null && _sortRequiresMembership(channelStateSort)) { envelopes = await _attachMemberships(envelopes, clientUserId); } - // 4) Sort using the existing comparator — same logic as today, just on - // envelopes instead of fully-hydrated states. + // 3) Sort using the comparator — on envelopes instead of fully-hydrated + // states. if (channelStateSort != null && channelStateSort.isNotEmpty) { envelopes.sort(channelStateSort.compare); } - // 5) Slice the page. + // 4) Slice the page. final total = envelopes.length; final offset = (paginationParams?.offset ?? 0).clamp(0, total); final limit = paginationParams?.limit ?? (total - offset); final pagedCids = envelopes.skip(offset).take(limit).map((s) => s.channel!.cid).toList(); - // 6) Hydrate ONLY the page. + // 5) Hydrate ONLY the page. return Future.wait(pagedCids.map(getChannelStateByCid)); } @@ -353,6 +402,29 @@ class StreamChatPersistenceClient extends ChatPersistenceClient { ); } + @override + Future updateChannelQueriesByPredefinedFilter( + String filterName, + List cids, { + required Filter filter, + required SortOrder sort, + Map? filterValues, + Map? sortValues, + bool clearQueryCache = false, + }) { + assert(_debugIsConnected, ''); + _logger.info('updateChannelQueriesByPredefinedFilter'); + return db!.channelQueryDao.updateChannelQueriesByPredefinedFilter( + filterName, + cids, + filter: filter, + sort: sort, + filterValues: filterValues, + sortValues: sortValues, + clearQueryCache: clearQueryCache, + ); + } + @override Future updateChannels(List channels) { assert(_debugIsConnected, ''); diff --git a/packages/stream_chat_persistence/test/src/converter/filter_converter_test.dart b/packages/stream_chat_persistence/test/src/converter/filter_converter_test.dart new file mode 100644 index 0000000000..bee55dccb0 --- /dev/null +++ b/packages/stream_chat_persistence/test/src/converter/filter_converter_test.dart @@ -0,0 +1,28 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_persistence/src/converter/filter_converter.dart'; + +void main() { + const converter = FilterConverter(); + + test('empty filter round-trips as empty', () { + const original = Filter.empty(); + + final decoded = converter.fromSql(converter.toSql(original)); + + expect(decoded.toJson(), isEmpty); + }); + + test('raw map filter round-trips unchanged', () { + const original = Filter.raw( + value: { + 'type': 'messaging', + 'members': ['user-1', 'user-2'], + }, + ); + + final decoded = converter.fromSql(converter.toSql(original)); + + expect(decoded.toJson(), original.toJson()); + }); +} diff --git a/packages/stream_chat_persistence/test/src/converter/sort_order_converter_test.dart b/packages/stream_chat_persistence/test/src/converter/sort_order_converter_test.dart new file mode 100644 index 0000000000..eddfe2d8db --- /dev/null +++ b/packages/stream_chat_persistence/test/src/converter/sort_order_converter_test.dart @@ -0,0 +1,27 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_persistence/src/converter/sort_order_converter.dart'; + +void main() { + const converter = ChannelStateSortOrderConverter(); + + test('non-empty SortOrder round-trips unchanged', () { + const original = >[ + SortOption.desc(ChannelSortKey.pinnedAt), + SortOption.asc(ChannelSortKey.createdAt), + ]; + + final decoded = converter.fromSql(converter.toSql(original)); + + expect(decoded.length, original.length); + for (var i = 0; i < original.length; i++) { + expect(decoded[i].field, original[i].field); + expect(decoded[i].direction, original[i].direction); + } + }); + + test('empty SortOrder round-trips as empty', () { + final decoded = converter.fromSql(converter.toSql(const [])); + expect(decoded, isEmpty); + }); +} diff --git a/packages/stream_chat_persistence/test/src/dao/channel_query_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/channel_query_dao_test.dart index 4696539ecb..5aa4bee0f0 100644 --- a/packages/stream_chat_persistence/test/src/dao/channel_query_dao_test.dart +++ b/packages/stream_chat_persistence/test/src/dao/channel_query_dao_test.dart @@ -140,6 +140,108 @@ void main() { }); }); + Future _insertChannelsForCids(List cids) async { + final users = List.generate(cids.length, (i) => User(id: 'user_${cids[i]}')); + final channelModels = List.generate( + cids.length, + (i) => ChannelModel( + id: 'id_${cids[i]}', + type: 'messaging', + cid: cids[i], + createdBy: users[i], + config: ChannelConfig(), + ), + ); + await database.userDao.updateUsers(users); + await database.channelDao.updateChannels(channelModels); + } + + test('updateChannelQueriesByPredefinedFilter', () async { + const filterName = 'sample-app-list'; + const filterValues = {'user_id': 'testUserId'}; + const sortValues = {'pinned_at': true}; + const cids = ['testCid1', 'testCid2', 'testCid3']; + final filter = Filter.equal('type', 'messaging'); + const sort = >[ + SortOption.desc(ChannelSortKey.pinnedAt), + SortOption.desc(ChannelSortKey.lastMessageAt), + ]; + + await _insertChannelsForCids(cids); + await channelQueryDao.updateChannelQueriesByPredefinedFilter( + filterName, + cids, + filter: filter, + sort: sort, + filterValues: filterValues, + sortValues: sortValues, + ); + + final (cachedChannels, storedFilter, storedSort) = await channelQueryDao.getChannelsAndSpecByPredefinedFilter( + filterName, + filterValues: filterValues, + sortValues: sortValues, + ); + + expect(cachedChannels.map((c) => c.cid).toSet(), cids.toSet()); + expect(storedFilter, isNotNull); + expect(storedFilter!.toJson(), filter.toJson()); + expect(storedSort, isNotNull); + expect(storedSort!.length, 2); + expect(storedSort.first.field, ChannelSortKey.pinnedAt); + expect(storedSort.first.direction, SortOption.DESC); + expect(storedSort.last.field, ChannelSortKey.lastMessageAt); + expect(storedSort.last.direction, SortOption.DESC); + }); + + test('clear queryCache before updateChannelQueriesByPredefinedFilter', () async { + const filterName = 'sample-app-list'; + const filterValues = {'user_id': 'testUserId'}; + const sortValues = {'pinned_at': true}; + const oldCids = ['oldCid1', 'oldCid2']; + const newCids = ['newCid1']; + final filter = Filter.equal('type', 'messaging'); + const sort = >[ + SortOption.desc(ChannelSortKey.pinnedAt), + SortOption.desc(ChannelSortKey.lastMessageAt), + ]; + + await _insertChannelsForCids([...oldCids, ...newCids]); + await channelQueryDao.updateChannelQueriesByPredefinedFilter( + filterName, + oldCids, + filter: filter, + sort: sort, + filterValues: filterValues, + sortValues: sortValues, + ); + await channelQueryDao.updateChannelQueriesByPredefinedFilter( + filterName, + newCids, + filter: filter, + sort: sort, + filterValues: filterValues, + sortValues: sortValues, + clearQueryCache: true, + ); + + final (cachedChannels, storedFilter, storedSort) = await channelQueryDao.getChannelsAndSpecByPredefinedFilter( + filterName, + filterValues: filterValues, + sortValues: sortValues, + ); + + expect(cachedChannels.map((c) => c.cid).toSet(), newCids.toSet()); + expect(storedFilter, isNotNull); + expect(storedFilter!.toJson(), filter.toJson()); + expect(storedSort, isNotNull); + expect(storedSort!.length, 2); + expect(storedSort.first.field, ChannelSortKey.pinnedAt); + expect(storedSort.first.direction, SortOption.DESC); + expect(storedSort.last.field, ChannelSortKey.lastMessageAt); + expect(storedSort.last.direction, SortOption.DESC); + }); + tearDown(() async { await database.disconnect(); }); diff --git a/packages/stream_chat_persistence/test/stream_chat_persistence_client_test.dart b/packages/stream_chat_persistence/test/stream_chat_persistence_client_test.dart index 135d317b8f..df957d80d3 100644 --- a/packages/stream_chat_persistence/test/stream_chat_persistence_client_test.dart +++ b/packages/stream_chat_persistence/test/stream_chat_persistence_client_test.dart @@ -404,6 +404,158 @@ void main() { verify(() => mockDatabase.channelQueryDao.updateChannelQueries(filter, cids)).called(1); }); + test('updateChannelQueriesByPredefinedFilter forwards all args to dao', () async { + const filterName = 'sample-app-list'; + const filterValues = {'user_id': 'testUserId'}; + const sortValues = {'pinned_at': true}; + const cids = ['messaging:c0']; + final filter = Filter.equal('type', 'messaging'); + const sort = >[ + SortOption.desc(ChannelSortKey.lastMessageAt), + ]; + + when( + () => mockDatabase.channelQueryDao.updateChannelQueriesByPredefinedFilter( + filterName, + cids, + filter: filter, + sort: sort, + filterValues: filterValues, + sortValues: sortValues, + clearQueryCache: true, + ), + ).thenAnswer((_) => Future.value()); + + await client.updateChannelQueriesByPredefinedFilter( + filterName, + cids, + filter: filter, + sort: sort, + filterValues: filterValues, + sortValues: sortValues, + clearQueryCache: true, + ); + + verify( + () => mockDatabase.channelQueryDao.updateChannelQueriesByPredefinedFilter( + filterName, + cids, + filter: filter, + sort: sort, + filterValues: filterValues, + sortValues: sortValues, + clearQueryCache: true, + ), + ).called(1); + }); + + test('getChannelStatesByPredefinedFilter applies persisted sort and paginates', () async { + const filterName = 'sample-app-list'; + const filterValues = {'user_id': 'test-user-id'}; + const sortValues = {'pinned_at': true}; + const currentUserId = 'test-user-id'; + + // Three channel models with distinct cids so sort + pagination are + // observable in which cid gets hydrated. + final c0 = ChannelModel(cid: 'messaging:p0'); + final c1 = ChannelModel(cid: 'messaging:p1'); + final c2 = ChannelModel(cid: 'messaging:p2'); + final channels = [c0, c1, c2]; + const cids = ['messaging:p0', 'messaging:p1', 'messaging:p2']; + + // Persisted sort uses `pinnedAt` so membership preload kicks in. + const persistedSort = >[ + SortOption.desc(ChannelSortKey.pinnedAt), + ]; + + // pinnedAt values chosen so descending order is [p1, p2, p0]. With + // offset 1 / limit 1 the only paged cid is p2. + final baseDate = DateTime.utc(2025); + final memberships = { + 'messaging:p0': Member( + userId: currentUserId, + pinnedAt: baseDate.add(const Duration(days: 1)), + ), + 'messaging:p1': Member( + userId: currentUserId, + pinnedAt: baseDate.add(const Duration(days: 3)), + ), + 'messaging:p2': Member( + userId: currentUserId, + pinnedAt: baseDate.add(const Duration(days: 2)), + ), + }; + + final persistedFilter = Filter.equal('type', 'messaging'); + + when( + () => mockDatabase.channelQueryDao.getChannelsAndSpecByPredefinedFilter( + filterName, + filterValues: filterValues, + sortValues: sortValues, + ), + ).thenAnswer((_) async => (channels, persistedFilter, persistedSort)); + when(() => mockDatabase.memberDao.getMembershipsForChannels(any(), any())).thenAnswer((_) async => memberships); + + // Only p2 should be hydrated — stub every per-cid DAO call. + const pagedCid = 'messaging:p2'; + final messages = List.generate(3, (i) => Message()); + final members = List.generate(3, (i) => Member()); + final reads = List.generate( + 3, + (i) => Read( + user: User(id: 'testUserId$i'), + lastRead: DateTime.now(), + lastReadMessageId: 'lastMessageId$i', + ), + ); + when(() => mockDatabase.channelDao.getChannelByCid(pagedCid)).thenAnswer((_) async => c2); + when(() => mockDatabase.memberDao.getMembersByCid(pagedCid)).thenAnswer((_) async => members); + when(() => mockDatabase.readDao.getReadsByCid(pagedCid)).thenAnswer((_) async => reads); + when(() => mockDatabase.messageDao.getMessagesByCid(pagedCid)).thenAnswer((_) async => messages); + when(() => mockDatabase.pinnedMessageDao.getMessagesByCid(pagedCid)).thenAnswer((_) async => messages); + when(() => mockDatabase.draftMessageDao.getDraftMessageByCid(pagedCid)).thenAnswer((_) async => null); + + final result = await client.getChannelStatesByPredefinedFilter( + filterName: filterName, + filterValues: filterValues, + sortValues: sortValues, + paginationParams: const PaginationParams(offset: 1, limit: 1), + ); + + // Persisted sort + offset + limit applied: only p2 is returned. + expect(result.channels, hasLength(1)); + expect(result.channels.single.channel!.cid, pagedCid); + + // Persisted predefined-filter spec surfaced on the response. + expect(result.predefinedFilter, isNotNull); + expect(result.predefinedFilter!.name, filterName); + expect(result.predefinedFilter!.filter.toJson(), persistedFilter.toJson()); + expect(result.predefinedFilter!.sort, persistedSort); + + // DAO called with the predefined keys. + verify( + () => mockDatabase.channelQueryDao.getChannelsAndSpecByPredefinedFilter( + filterName, + filterValues: filterValues, + sortValues: sortValues, + ), + ).called(1); + + // Memberships batched in a single query for all cids + connected user. + final capturedMembershipArgs = verify( + () => mockDatabase.memberDao.getMembershipsForChannels(captureAny(), captureAny()), + ).captured; + expect(capturedMembershipArgs, hasLength(2)); + expect(capturedMembershipArgs.first, equals(cids)); + expect(capturedMembershipArgs.last, equals(currentUserId)); + + // Only the paged cid was hydrated — p0 and p1 were skipped. + verify(() => mockDatabase.channelDao.getChannelByCid(pagedCid)).called(1); + verifyNever(() => mockDatabase.channelDao.getChannelByCid('messaging:p0')); + verifyNever(() => mockDatabase.channelDao.getChannelByCid('messaging:p1')); + }); + test('deleteMessageById', () async { const messageId = 'testMessageId'; when(() => mockDatabase.messageDao.deleteMessageByIds([messageId])).thenAnswer((_) async => 1); diff --git a/sample_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/sample_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 9c12df59c6..5db441f58a 100644 --- a/sample_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/sample_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -5,6 +5,24 @@ + + + + + + + + + + { late final _channelListController = StreamChannelListController( client: StreamChat.of(context).client, - filter: Filter.in_('members', [StreamChat.of(context).currentUser!.id]), - channelStateSort: [ - const SortOption.desc( - ChannelSortKey.pinnedAt, - nullOrdering: NullOrdering.nullsLast, - ), - const SortOption.desc(ChannelSortKey.lastUpdated), - ], + predefinedFilter: 'stream_chat_flutter_sample_app', + filterValues: {'user_id': StreamChat.of(context).currentUser!.id}, limit: 30, );