From e697a30aa236f1af90c86f1be426fb1278b6066b Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 12 Jun 2026 21:47:39 +0200 Subject: [PATCH] fix(persistence): port poll DAO null-user fix and chunked size guard from #2731 Cherry-picks the review fixes from PR #2731 (commit 15af7401) onto v9: - PollDao: read users via readTableOrNull on the LEFT JOIN so polls whose creator user is missing from the local cache no longer crash. - chunked(): throw ArgumentError when size <= 0; add unit tests for chunked. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/stream_chat_persistence/CHANGELOG.md | 6 +++ .../lib/src/dao/poll_dao.dart | 12 ++--- .../lib/src/db/query_utils.dart | 3 ++ .../test/src/db/query_utils_test.dart | 49 +++++++++++++++++++ 4 files changed, 63 insertions(+), 7 deletions(-) create mode 100644 packages/stream_chat_persistence/test/src/db/query_utils_test.dart diff --git a/packages/stream_chat_persistence/CHANGELOG.md b/packages/stream_chat_persistence/CHANGELOG.md index 73a120207c..01f5fb108b 100644 --- a/packages/stream_chat_persistence/CHANGELOG.md +++ b/packages/stream_chat_persistence/CHANGELOG.md @@ -1,3 +1,9 @@ +## Upcoming Changes + +🐞 Fixed + +- `PollDao` no longer crashes when reading polls whose creator user is missing from the local cache. + ## 9.25.0 🚀 Performance diff --git a/packages/stream_chat_persistence/lib/src/dao/poll_dao.dart b/packages/stream_chat_persistence/lib/src/dao/poll_dao.dart index 8d974e15c4..ce646dc65d 100644 --- a/packages/stream_chat_persistence/lib/src/dao/poll_dao.dart +++ b/packages/stream_chat_persistence/lib/src/dao/poll_dao.dart @@ -20,7 +20,7 @@ class PollDao extends DatabaseAccessor with _$PollDaoMixin { Future _pollFromJoinRow(TypedResult row) async { final pollEntity = row.readTable(polls); - final userEntity = row.readTable(users); + final userEntity = row.readTableOrNull(users); final allVotes = await _db.pollVoteDao.getPollVotes(pollEntity.id); final latestAnswers = allVotes.where((it) => it.isAnswer); final ownVotesAndAnswers = allVotes.where((it) => it.userId == _db.userId); @@ -38,7 +38,7 @@ class PollDao extends DatabaseAccessor with _$PollDaoMixin { } return pollEntity.toPoll( - createdBy: userEntity.toUser(), + createdBy: userEntity?.toUser(), latestAnswers: latestAnswers.toList(), ownVotesAndAnswers: ownVotesAndAnswers.toList(), latestVotesByOption: latestVotesByOption, @@ -68,9 +68,7 @@ class PollDao extends DatabaseAccessor with _$PollDaoMixin { [leftOuterJoin(users, polls.createdById.equalsExp(users.id))]).get(); for (final row in rows) { final pollEntity = row.readTable(polls); - // Same as `_pollFromJoinRow` => reads users via `readTable` (not - // `readTableOrNull`) on a LEFT JOIN - final userEntity = row.readTable(users); + final userEntity = row.readTableOrNull(users); final allVotes = votesByPoll[pollEntity.id] ?? const []; result[pollEntity.id] = _buildPoll(pollEntity, userEntity, allVotes); } @@ -99,7 +97,7 @@ class PollDao extends DatabaseAccessor with _$PollDaoMixin { Poll _buildPoll( PollEntity pollEntity, - UserEntity userEntity, + UserEntity? userEntity, List allVotes, ) { final latestAnswers = allVotes.where((it) => it.isAnswer); @@ -118,7 +116,7 @@ class PollDao extends DatabaseAccessor with _$PollDaoMixin { } return pollEntity.toPoll( - createdBy: userEntity.toUser(), + createdBy: userEntity?.toUser(), latestAnswers: latestAnswers.toList(), ownVotesAndAnswers: ownVotesAndAnswers.toList(), latestVotesByOption: latestVotesByOption, diff --git a/packages/stream_chat_persistence/lib/src/db/query_utils.dart b/packages/stream_chat_persistence/lib/src/db/query_utils.dart index 1a2f966592..deabbee208 100644 --- a/packages/stream_chat_persistence/lib/src/db/query_utils.dart +++ b/packages/stream_chat_persistence/lib/src/db/query_utils.dart @@ -12,6 +12,9 @@ import 'dart:math' as math; /// chunk size of 900 leaves headroom for any other bound parameters that /// share the same statement (for example a `AND userId = ?` filter). Iterable> chunked(List input, [int size = 900]) sync* { + if (size <= 0) { + throw ArgumentError.value(size, 'size', 'must be greater than 0'); + } for (var i = 0; i < input.length; i += size) { yield input.sublist(i, math.min(i + size, input.length)); } diff --git a/packages/stream_chat_persistence/test/src/db/query_utils_test.dart b/packages/stream_chat_persistence/test/src/db/query_utils_test.dart new file mode 100644 index 0000000000..e51f1e8a25 --- /dev/null +++ b/packages/stream_chat_persistence/test/src/db/query_utils_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_persistence/src/db/query_utils.dart'; + +void main() { + group('chunked', () { + test('returns an empty iterable for an empty input', () { + expect(chunked([]).toList(), isEmpty); + }); + + test('yields a single chunk when input fits in one chunk', () { + final input = List.generate(5, (i) => i); + expect(chunked(input, 10).toList(), [input]); + }); + + test('splits input into evenly sized chunks', () { + final input = List.generate(6, (i) => i); + expect(chunked(input, 2).toList(), [ + [0, 1], + [2, 3], + [4, 5], + ]); + }); + + test('handles a trailing partial chunk', () { + final input = List.generate(7, (i) => i); + expect(chunked(input, 3).toList(), [ + [0, 1, 2], + [3, 4, 5], + [6], + ]); + }); + + test('uses a default size of 900', () { + final input = List.generate(1000, (i) => i); + final chunks = chunked(input).toList(); + expect(chunks, hasLength(2)); + expect(chunks[0], hasLength(900)); + expect(chunks[1], hasLength(100)); + }); + + test('throws ArgumentError when size is zero', () { + expect(() => chunked([1, 2, 3], 0).toList(), throwsArgumentError); + }); + + test('throws ArgumentError when size is negative', () { + expect(() => chunked([1, 2, 3], -1).toList(), throwsArgumentError); + }); + }); +}