From bc52337b1924097ba7ca0747ade264ee5fefa80a Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 18 Oct 2026 15:44:55 +0300 Subject: [PATCH] refactor(redis): replies are parsed once into a sealed RedisReply, command arguments are typed, RedisConnectionTestFake moves to test/ (#1571) --- lib/core/database/redis_connection.dart | 478 +++--------------- lib/core/database/redis_reply.dart | 157 ++++++ test/core/database/redis_connection_test.dart | 1 + .../database/redis_database_socket_test.dart | 1 + test/core/database/redis_reply_test.dart | 88 ++++ .../core/database/redis_set_keepttl_test.dart | 1 + .../database/redis_types_and_ttls_test.dart | 2 +- .../e2e_redis_cli_terminal_test.dart | 2 +- .../e2e_redis_keys_tree_and_types_test.dart | 1 + .../redis/redis_cli_workspace_test.dart | 2 +- .../features/redis/redis_key_editor_test.dart | 2 +- test/features/redis/redis_keys_view_test.dart | 2 +- test/support/redis_connection_test_fake.dart | 277 ++++++++++ 13 files changed, 613 insertions(+), 401 deletions(-) create mode 100644 lib/core/database/redis_reply.dart create mode 100644 test/core/database/redis_reply_test.dart create mode 100644 test/support/redis_connection_test_fake.dart diff --git a/lib/core/database/redis_connection.dart b/lib/core/database/redis_connection.dart index fe8c1bd6..37acffef 100644 --- a/lib/core/database/redis_connection.dart +++ b/lib/core/database/redis_connection.dart @@ -10,6 +10,7 @@ import 'package:querya_desktop/core/security/ssh_tunnel_config.dart'; import 'package:querya_desktop/core/security/ssl_certificate_support.dart'; import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:querya_desktop/core/database/redis_bulk.dart'; +import 'package:querya_desktop/core/database/redis_reply.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:redis/redis.dart' as redis; import 'package:querya_desktop/core/util/log_ignored.dart'; @@ -400,25 +401,38 @@ class RedisConnection with SshTunnelHost { } } + /// A command with typed arguments, its reply parsed once (#1571). + /// + /// Each argument is a `String`, `int`, `double`, `Uint8List` or + /// [RedisBulkValue] (a key or value that may not be UTF-8); anything else is + /// an [ArgumentError] here and not a malformed command on the wire. + Future command(List args) async => + RedisReply.from(await sendCommand([for (final a in args) _commandArg(a)])); + + static Object _commandArg(Object a) => switch (a) { + RedisBulkValue() => a.commandArg, + Uint8List() => redis.RedisBulk(a), + String() || int() || double() || redis.RedisBulk() => a, + _ => throw ArgumentError.value( + a, 'args', 'not a Redis command argument (${a.runtimeType})'), + }; + /// SELECT database index; later reconnects come back to it too. Future selectDatabase(int db) async { - await sendCommand(['SELECT', db]); + await command(['SELECT', db]); database = db; } /// DBSIZE — number of keys in the currently selected database. Future dbSize() async { - final result = await sendCommand(['DBSIZE']); - return redisReplyInt(result); + return (await command(['DBSIZE'])).asInt(); } /// CONFIG GET databases — max number of databases. Future getMaxDatabases() async { try { - final result = await sendCommand(['CONFIG', 'GET', 'databases']); - if (result is List && result.length >= 2) { - return redisReplyInt(result[1], 16); - } + final reply = (await command(['CONFIG', 'GET', 'databases'])).asArray(); + if (reply.length >= 2) return reply[1].asInt(); } catch (e) { debugPrint('RedisConnection.getMaxDatabases: $e'); // Some Redis instances don't allow CONFIG; fall back. @@ -433,31 +447,42 @@ class RedisConnection with SshTunnelHost { String? match, int count = 100, }) async { - final args = ['SCAN', cursor]; + final args = ['SCAN', cursor]; if (match != null && match.isNotEmpty) { args.addAll(['MATCH', match]); } args.addAll(['COUNT', count]); - final result = await sendCommand(args); - if (result is List && result.length == 2) { - final nextCursor = redisReplyInt(result[0]); - final keys = - (result[1] as List?)?.map(RedisBulkValue.fromReply).toList() ?? []; - return (nextCursor, keys); + return _cursorAndBulks(await command(args)); + } + + /// The cursor and the bulk strings of a SCAN / SSCAN reply. + static (int, List) _cursorAndBulks(RedisReply reply) { + final parts = reply.asArray(); + if (parts.length != 2) { + throw RedisProtocolException( + 'a scan reply has 2 parts, got ${parts.length}'); } - return (0, []); + return ( + parts[0].asInt(), + [for (final e in parts[1].asArray()) e.asBulk()], + ); } + /// Field / value pairs of an HGETALL or HSCAN array. + static Map _pairs(List items) => { + for (var i = 0; i + 1 < items.length; i += 2) + items[i].asBulk(): items[i + 1].asBulk(), + }; + /// TYPE key. Future keyType(Object key) async { - final result = await sendCommand(['TYPE', redisCommandArg(key)]); - return RedisBulkValue.fromReply(result).text ?? 'none'; + final reply = await command(['TYPE', key]); + return reply.asText() ?? 'none'; } /// TTL key (returns -1 if no expiry, -2 if missing). Future ttl(Object key) async { - final result = await sendCommand(['TTL', redisCommandArg(key)]); - return redisReplyInt(result, -1); + return (await command(['TTL', key])).asInt(); } /// Pipelined TYPE + TTL for a SCAN batch. @@ -477,15 +502,15 @@ class RedisConnection with SshTunnelHost { try { final typeFutures = >[ for (final key in keys) - sendCommand(['TYPE', key.commandArg]).then( - (v) => RedisBulkValue.fromReply(v).text ?? 'none', + command(['TYPE', key]).then( + (v) => v.asText() ?? 'none', onError: (_) => 'unknown', ), ]; final ttlFutures = >[ for (final key in keys) - sendCommand(['TTL', key.commandArg]).then( - (v) => redisReplyInt(v, -1), + command(['TTL', key]).then( + (v) => v.asInt(), onError: (_) => -1, ), ]; @@ -501,9 +526,7 @@ class RedisConnection with SshTunnelHost { /// GET. Returns null for a missing key. Non-UTF-8 values keep raw bytes. Future get(Object key) async { - final result = await sendCommand(['GET', redisCommandArg(key)]); - if (result == null) return null; - return RedisBulkValue.fromReply(result); + return (await command(['GET', key])).asBulkOrNull(); } /// SET key value [EX seconds]. @@ -556,15 +579,7 @@ class RedisConnection with SshTunnelHost { /// HGETALL key. Future> hgetall(Object key) async { - final result = await sendCommand(['HGETALL', redisCommandArg(key)]); - final map = {}; - if (result is List) { - for (var i = 0; i + 1 < result.length; i += 2) { - map[RedisBulkValue.fromReply(result[i])] = - RedisBulkValue.fromReply(result[i + 1]); - } - } - return map; + return _pairs((await command(['HGETALL', key])).asArray()); } /// HSCAN key cursor [COUNT count]. Returns (nextCursor, fields). @@ -573,27 +588,18 @@ class RedisConnection with SshTunnelHost { int cursor = 0, int count = redisCollectionPageSize, }) async { - final result = await sendCommand( - ['HSCAN', redisCommandArg(key), cursor, 'COUNT', count]); - if (result is List && result.length == 2) { - final nextCursor = redisReplyInt(result[0]); - final map = {}; - final pairs = result[1]; - if (pairs is List) { - for (var i = 0; i + 1 < pairs.length; i += 2) { - map[RedisBulkValue.fromReply(pairs[i])] = - RedisBulkValue.fromReply(pairs[i + 1]); - } - } - return (nextCursor, map); + final parts = + (await command(['HSCAN', key, cursor, 'COUNT', count])).asArray(); + if (parts.length != 2) { + throw RedisProtocolException( + 'a scan reply has 2 parts, got ${parts.length}'); } - return (0, {}); + return (parts[0].asInt(), _pairs(parts[1].asArray())); } /// HLEN key. Future hlen(Object key) async { - final result = await sendCommand(['HLEN', redisCommandArg(key)]); - return redisReplyInt(result); + return (await command(['HLEN', key])).asInt(); } /// HSET key field value. @@ -610,18 +616,13 @@ class RedisConnection with SshTunnelHost { /// LRANGE key start stop. Future> lrange(Object key, int start, int stop) async { - final result = - await sendCommand(['LRANGE', redisCommandArg(key), start, stop]); - if (result is List) { - return result.map(RedisBulkValue.fromReply).toList(); - } - return []; + final reply = await command(['LRANGE', key, start, stop]); + return [for (final e in reply.asArray()) e.asBulk()]; } /// LLEN key. Future llen(Object key) async { - final result = await sendCommand(['LLEN', redisCommandArg(key)]); - return redisReplyInt(result); + return (await command(['LLEN', key])).asInt(); } /// RPUSH key value. @@ -632,12 +633,7 @@ class RedisConnection with SshTunnelHost { /// LINDEX key index. Future lindex(Object key, int index) async { - final result = - await sendCommand(['LINDEX', redisCommandArg(key), index]); - if (result == null || _isRedisNil(result)) { - return null; - } - return RedisBulkValue.fromReply(result); + return (await command(['LINDEX', key, index])).asBulkOrNull(); } /// LSET key index value. @@ -649,18 +645,13 @@ class RedisConnection with SshTunnelHost { /// LREM key count element. Future lrem(Object key, int count, Object element) async { _assertWritable(); - final result = await sendCommand( - ['LREM', redisCommandArg(key), count, redisCommandArg(element)]); - return redisReplyInt(result); + return (await command(['LREM', key, count, element])).asInt(); } /// SMEMBERS key. Future> smembers(Object key) async { - final result = await sendCommand(['SMEMBERS', redisCommandArg(key)]); - if (result is List) { - return result.map(RedisBulkValue.fromReply).toList(); - } - return []; + final reply = await command(['SMEMBERS', key]); + return [for (final e in reply.asArray()) e.asBulk()]; } /// SSCAN key cursor [COUNT count]. Returns (nextCursor, members). @@ -669,21 +660,13 @@ class RedisConnection with SshTunnelHost { int cursor = 0, int count = redisCollectionPageSize, }) async { - final result = await sendCommand( - ['SSCAN', redisCommandArg(key), cursor, 'COUNT', count]); - if (result is List && result.length == 2) { - final nextCursor = redisReplyInt(result[0]); - final members = - (result[1] as List?)?.map(RedisBulkValue.fromReply).toList() ?? []; - return (nextCursor, members); - } - return (0, []); + return _cursorAndBulks( + await command(['SSCAN', key, cursor, 'COUNT', count])); } /// SCARD key. Future scard(Object key) async { - final result = await sendCommand(['SCARD', redisCommandArg(key)]); - return redisReplyInt(result); + return (await command(['SCARD', key])).asInt(); } /// SADD key member. @@ -701,24 +684,17 @@ class RedisConnection with SshTunnelHost { /// ZRANGE key start stop WITHSCORES → list of (member, score). Future> zrangeWithScores( Object key, int start, int stop) async { - final result = await sendCommand( - ['ZRANGE', redisCommandArg(key), start, stop, 'WITHSCORES']); - final list = <(RedisBulkValue, double)>[]; - if (result is List) { - for (var i = 0; i + 1 < result.length; i += 2) { - list.add(( - RedisBulkValue.fromReply(result[i]), - redisReplyDouble(result[i + 1]), - )); - } - } - return list; + final items = + (await command(['ZRANGE', key, start, stop, 'WITHSCORES'])).asArray(); + return [ + for (var i = 0; i + 1 < items.length; i += 2) + (items[i].asBulk(), items[i + 1].asDouble()), + ]; } /// ZCARD key. Future zcard(Object key) async { - final result = await sendCommand(['ZCARD', redisCommandArg(key)]); - return redisReplyInt(result); + return (await command(['ZCARD', key])).asInt(); } /// ZADD key score member. @@ -736,8 +712,7 @@ class RedisConnection with SshTunnelHost { /// DEL key. Future del(Object key) async { _assertWritable(); - final result = await sendCommand(['DEL', redisCommandArg(key)]); - return redisReplyInt(result); + return (await command(['DEL', key])).asInt(); } /// False once the server answered `UNLINK` with an unknown command (< 4.0). @@ -758,7 +733,7 @@ class RedisConnection with SshTunnelHost { for (var i = 0; i < list.length; i += chunkSize) [ for (final key in list.sublist(i, min(i + chunkSize, list.length))) - redisCommandArg(key), + key, ], ]; @@ -766,11 +741,11 @@ class RedisConnection with SshTunnelHost { var total = 0; var verb = _unlinkSupported == false ? 'DEL' : 'UNLINK'; try { - total += redisReplyInt(await sendCommand([verb, ...chunks.first])); + total += (await command([verb, ...chunks.first])).asInt(); } catch (e) { if (verb != 'UNLINK' || !_isUnknownCommand(e)) rethrow; verb = 'DEL'; - total += redisReplyInt(await sendCommand([verb, ...chunks.first])); + total += (await command([verb, ...chunks.first])).asInt(); } _unlinkSupported ??= verb == 'UNLINK'; if (chunks.length == 1) return total; @@ -782,8 +757,8 @@ class RedisConnection with SshTunnelHost { try { final replies = await Future.wait([ for (final chunk in chunks.skip(1)) - sendCommand([verb, ...chunk]).then( - (reply) => redisReplyInt(reply), + command([verb, ...chunk]).then( + (reply) => reply.asInt(), onError: (Object e, StackTrace st) { firstError ??= e; firstStack ??= st; @@ -815,15 +790,12 @@ class RedisConnection with SshTunnelHost { /// RENAMENX old new. Returns 1 if key was renamed, 0 if newKey already exists. Future renamenx(Object oldKey, String newKey) async { _assertWritable(); - final result = - await sendCommand(['RENAMENX', redisCommandArg(oldKey), newKey]); - return redisReplyInt(result); + return (await command(['RENAMENX', oldKey, newKey])).asInt(); } /// EXISTS key. Returns 1 if key exists, 0 if missing. Future exists(Object key) async { - final result = await sendCommand(['EXISTS', redisCommandArg(key)]); - return redisReplyInt(result); + return (await command(['EXISTS', key])).asInt(); } /// EXPIRE key seconds. @@ -858,292 +830,6 @@ class RedisConnection with SshTunnelHost { } } -/// In-memory stub for widget tests (no socket). Handles SELECT, DBSIZE, SCAN, -/// TYPE, TTL used by [RedisKeysView]. -class RedisConnectionTestFake extends RedisConnection { - RedisConnectionTestFake({ - this.firstScanKeys = const ['alpha', 'beta'], - this.secondScanKeys = const [], - this.dbSizeResult = 2, - this.getResult, - List? listItems, - this.llenResult, - this.hashFirstPage = const {}, - this.hashSecondPage = const {}, - this.hlenResult, - this.setFirstPage = const [], - this.setSecondPage = const [], - this.scardResult, - this.zsetItems = const <(String, double)>[], - this.zcardResult, - this.typeResult = 'string', - this.failType = false, - this.getBytesResult, - this.binaryScanKeys = const >[], - this.unlinkUnsupported = false, - }) : listItems = - listItems != null ? List.from(listItems) : [], - super( - id: -1, - name: 'test-fake', - host: 'localhost', - port: 6379, - ); - - final List firstScanKeys; - final List secondScanKeys; - final int dbSizeResult; - final String? getResult; - final List listItems; - final int? llenResult; - final Map hashFirstPage; - final Map hashSecondPage; - final int? hlenResult; - final List setFirstPage; - final List setSecondPage; - final int? scardResult; - final List<(String, double)> zsetItems; - final int? zcardResult; - final String typeResult; - final bool failType; - final List? getBytesResult; - final List> binaryScanKeys; - - /// Answers `UNLINK` like a server before Redis 4.0. - final bool unlinkUnsupported; - final List sentCommands = []; - - bool _firstScanDone = false; - bool _firstHscanDone = false; - bool _firstSscanDone = false; - - @override - bool get isConnected => _isConnected; - - @override - Future connect() async { - _isConnected = true; - _conn = null; - _command = null; - } - - @override - Future disconnect() async { - _isConnected = false; - final c = _conn; - _conn = null; - _command = null; - if (c != null) { - try { - await c.close(); - } catch (e) { - logIgnored('redis_connection', e); - } - } - } - - @override - Future sendCommand(List args) async { - if (!_isConnected) { - throw StateError('Not connected to Redis'); - } - final op = args.first.toString().toUpperCase(); - sentCommands.add(op); - switch (op) { - case 'SELECT': - return 'OK'; - case 'DBSIZE': - return dbSizeResult; - case 'SCAN': - final cursor = int.tryParse(args[1].toString()) ?? 0; - List keysFor(List named) => - [...named, ...binaryScanKeys]; - if (cursor == 0 && !_firstScanDone) { - _firstScanDone = true; - final next = secondScanKeys.isNotEmpty ? 1 : 0; - return [next, keysFor(firstScanKeys)]; - } - if (cursor == 1 && secondScanKeys.isNotEmpty) { - return [0, keysFor(secondScanKeys)]; - } - return [0, []]; - case 'TYPE': - if (failType) { - throw StateError('TYPE failed'); - } - return typeResult; - case 'TTL': - return -1; - case 'GET': - if (getBytesResult != null) return getBytesResult; - if (typeResult != 'string') { - throw StateError( - 'WRONGTYPE Operation against a key holding the wrong kind of value', - ); - } - return getResult; - case 'SET': - if (typeResult != 'string') { - throw StateError('SET refused: key is $typeResult'); - } - return 'OK'; - case 'HGETALL': - case 'SMEMBERS': - throw StateError('$op is unbounded; use a paged command'); - case 'HLEN': - return hlenResult ?? hashFirstPage.length + hashSecondPage.length; - case 'LLEN': - return llenResult ?? listItems.length; - case 'SCARD': - return scardResult ?? setFirstPage.length + setSecondPage.length; - case 'ZCARD': - return zcardResult ?? zsetItems.length; - case 'LRANGE': - return _sliceList(listItems, args); - case 'ZRANGE': - final stop = int.tryParse(args[3].toString()) ?? -1; - if (stop < 0) { - throw StateError('unbounded ZRANGE'); - } - final start = int.tryParse(args[2].toString()) ?? 0; - if (start >= zsetItems.length || start > stop) return []; - final end = (stop + 1).clamp(0, zsetItems.length); - final out = []; - for (final (member, score) in zsetItems.sublist(start, end)) { - out.add(member); - out.add(score); - } - return out; - case 'HSCAN': - return _pagedPairs( - cursor: int.tryParse(args[2].toString()) ?? 0, - first: hashFirstPage, - second: hashSecondPage, - firstDone: _firstHscanDone, - markFirst: () => _firstHscanDone = true, - ); - case 'SSCAN': - final cursor = int.tryParse(args[2].toString()) ?? 0; - if (cursor == 0 && !_firstSscanDone) { - _firstSscanDone = true; - final next = setSecondPage.isNotEmpty ? 1 : 0; - return [next, setFirstPage]; - } - if (cursor == 1 && setSecondPage.isNotEmpty) { - return [0, setSecondPage]; - } - return [0, []]; - case 'READONLY': - case 'READWRITE': - return 'OK'; - case 'EXISTS': - final target = args[1].toString(); - final allKeys = [...firstScanKeys, ...secondScanKeys]; - return allKeys.contains(target) ? 1 : 0; - case 'RENAME': - return 'OK'; - case 'RENAMENX': - final target = args[2].toString(); - final allKeys = [...firstScanKeys, ...secondScanKeys]; - return allKeys.contains(target) ? 0 : 1; - case 'RPUSH': - listItems.add(args[2].toString()); - return 'OK'; - case 'LINDEX': - final lindexIdx = int.tryParse(args[2].toString()) ?? 0; - if (lindexIdx >= 0 && lindexIdx < listItems.length) { - return listItems[lindexIdx]; - } - return null; - case 'LSET': - final idx = int.tryParse(args[2].toString()) ?? 0; - final val = args[3].toString(); - if (idx >= 0 && idx < listItems.length) { - listItems[idx] = val; - } - return 'OK'; - case 'LREM': - final count = int.tryParse(args[2].toString()) ?? 0; - final val = args[3].toString(); - var removed = 0; - if (count == 0) { - removed = listItems.where((e) => e == val).length; - listItems.removeWhere((e) => e == val); - } else if (count > 0) { - for (var i = 0; i < count; i++) { - final idx = listItems.indexOf(val); - if (idx != -1) { - listItems.removeAt(idx); - removed++; - } - } - } else { - for (var i = 0; i < count.abs(); i++) { - final idx = listItems.lastIndexOf(val); - if (idx != -1) { - listItems.removeAt(idx); - removed++; - } - } - } - return removed; - case 'UNLINK' when unlinkUnsupported: - throw Exception("ERR unknown command 'UNLINK'"); - case 'DEL' || 'UNLINK': - final allKeys = [...firstScanKeys, ...secondScanKeys]; - var count = 0; - for (var i = 1; i < args.length; i++) { - final target = args[i].toString(); - if (allKeys.contains(target)) { - count++; - } - } - return count; - default: - return null; - } - } - - List _sliceList(List items, List args) { - if (items.isEmpty) return const []; - final start = int.tryParse(args[2].toString()) ?? 0; - var stop = int.tryParse(args[3].toString()) ?? -1; - if (stop < 0) { - throw StateError('unbounded LRANGE'); - } - if (start >= items.length || start > stop) return const []; - final end = (stop + 1).clamp(0, items.length); - return items.sublist(start, end); - } - - List _pagedPairs({ - required int cursor, - required Map first, - required Map second, - required bool firstDone, - required void Function() markFirst, - }) { - List flatten(Map map) { - final out = []; - map.forEach((k, v) { - out.add(k); - out.add(v); - }); - return out; - } - - if (cursor == 0 && !firstDone) { - markFirst(); - final next = second.isNotEmpty ? 1 : 0; - return [next, flatten(first)]; - } - if (cursor == 1 && second.isNotEmpty) { - return [0, flatten(second)]; - } - return [0, []]; - } -} - bool _isRedisNil(Object? result) => result == null || result.toString().toLowerCase() == 'null'; diff --git a/lib/core/database/redis_reply.dart b/lib/core/database/redis_reply.dart new file mode 100644 index 00000000..24a70f73 --- /dev/null +++ b/lib/core/database/redis_reply.dart @@ -0,0 +1,157 @@ +import 'dart:typed_data'; + +import 'redis_bulk.dart'; + +/// The reply to a Redis command was not the shape the caller needs. +class RedisProtocolException implements Exception { + const RedisProtocolException(this.message); + + final String message; + + @override + String toString() => 'RedisProtocolException: $message'; +} + +/// A Redis reply, parsed once where it leaves the socket so that no caller +/// has to test `is List` / `is int` on a `dynamic` (#1571). +/// +/// The `redis` package gives integers as `int`, simple strings as `String`, +/// bulk strings as bytes (Querya parses with `RedisParserBulkBinary`), arrays +/// as `List` and nil as `null`. An error reply is thrown by the package and +/// never arrives here as a value; [RedisErrorReply] is for a caller that wants +/// to show one it caught. +sealed class RedisReply { + const RedisReply(); + + factory RedisReply.from(Object? raw) { + if (raw == null) return const RedisNil(); + if (raw is int) return RedisInt(raw); + if (raw is double) return RedisBulk(RedisBulkValue.fromReply(raw)); + if (raw is String) return RedisSimple(raw); + if (raw is Uint8List || raw is List) { + return RedisBulk(RedisBulkValue.fromReply(raw)); + } + if (raw is List) { + // Bulk bytes from a source that does not type them: all ints, or empty. + if (raw.isNotEmpty && raw.every((e) => e is int)) { + return RedisBulk(RedisBulkValue.fromReply(raw)); + } + return RedisArray([for (final e in raw) RedisReply.from(e)]); + } + return RedisBulk(RedisBulkValue.fromReply(raw)); + } + + bool get isNil => this is RedisNil; + + /// The integer: an integer reply, or a string/bulk that holds one. + int asInt(); + + /// The elements of an array reply. + List asArray(); + + /// The value as a bulk string; a nil is an empty one. + RedisBulkValue asBulk(); + + /// The UTF-8 text, or null when the bytes are not UTF-8. + String? asText() => asBulk().text; + + /// A double from a string/bulk or an integer reply. + double asDouble(); + + /// The bulk value, or null for a nil reply (`GET` of a missing key). + RedisBulkValue? asBulkOrNull() => isNil ? null : asBulk(); + + Never _mismatch(String wanted) => + throw RedisProtocolException('expected $wanted, got $runtimeType'); +} + +/// `$-1` / `*-1`: no value. +final class RedisNil extends RedisReply { + const RedisNil(); + + @override + int asInt() => _mismatch('an integer'); + @override + List asArray() => _mismatch('an array'); + @override + RedisBulkValue asBulk() => RedisBulkValue.fromReply(null); + @override + double asDouble() => _mismatch('a number'); +} + +final class RedisInt extends RedisReply { + const RedisInt(this.value); + + final int value; + + @override + int asInt() => value; + @override + List asArray() => _mismatch('an array'); + @override + RedisBulkValue asBulk() => RedisBulkValue.fromReply(value); + @override + double asDouble() => value.toDouble(); +} + +/// `+OK` style status, or a string a source gave as text. +final class RedisSimple extends RedisReply { + const RedisSimple(this.text); + + final String text; + + @override + int asInt() => int.tryParse(text) ?? _mismatch('an integer'); + @override + List asArray() => _mismatch('an array'); + @override + RedisBulkValue asBulk() => RedisBulkValue.utf8(text); + @override + double asDouble() => double.tryParse(text) ?? _mismatch('a number'); +} + +final class RedisBulk extends RedisReply { + const RedisBulk(this.value); + + final RedisBulkValue value; + + @override + int asInt() => int.tryParse(value.text ?? '') ?? _mismatch('an integer'); + @override + List asArray() => _mismatch('an array'); + @override + RedisBulkValue asBulk() => value; + @override + double asDouble() => double.tryParse(value.text ?? '') ?? _mismatch('a number'); +} + +final class RedisArray extends RedisReply { + const RedisArray(this.items); + + final List items; + + @override + int asInt() => _mismatch('an integer'); + @override + List asArray() => items; + @override + RedisBulkValue asBulk() => _mismatch('a bulk string'); + @override + double asDouble() => _mismatch('a number'); +} + +/// An error reply (`-ERR ...`) held as a value. +final class RedisErrorReply extends RedisReply { + const RedisErrorReply(this.message); + + final String message; + + @override + int asInt() => _mismatch('an integer'); + @override + List asArray() => _mismatch('an array'); + @override + RedisBulkValue asBulk() => _mismatch('a bulk string'); + @override + double asDouble() => _mismatch('a number'); +} diff --git a/test/core/database/redis_connection_test.dart b/test/core/database/redis_connection_test.dart index 8362a7fc..d7114535 100644 --- a/test/core/database/redis_connection_test.dart +++ b/test/core/database/redis_connection_test.dart @@ -1,6 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import '../../support/redis_connection_test_fake.dart'; void main() { group('RedisConnection initial state', () { diff --git a/test/core/database/redis_database_socket_test.dart b/test/core/database/redis_database_socket_test.dart index f43540de..e728c81c 100644 --- a/test/core/database/redis_database_socket_test.dart +++ b/test/core/database/redis_database_socket_test.dart @@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/core/database/redis_service.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import '../../support/redis_connection_test_fake.dart'; /// A RESP server that answers `PING` and `SELECT` and records every command. class _RecordingRedis { diff --git a/test/core/database/redis_reply_test.dart b/test/core/database/redis_reply_test.dart new file mode 100644 index 00000000..77f7c24c --- /dev/null +++ b/test/core/database/redis_reply_test.dart @@ -0,0 +1,88 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/redis_bulk.dart'; +import 'package:querya_desktop/core/database/redis_reply.dart'; + +import '../../support/redis_connection_test_fake.dart'; + +void main() { + group('RedisReply.from', () { + test('sorts what the redis package returns into the sealed types', () { + expect(RedisReply.from(null), isA()); + expect(RedisReply.from(7), isA()); + expect(RedisReply.from('OK'), isA()); + expect(RedisReply.from(Uint8List.fromList([104, 105])), isA()); + expect(RedisReply.from([104, 105]), isA()); + expect(RedisReply.from(['a', 1, null]), isA()); + }); + + test('an array holds replies, nested arrays included', () { + final a = RedisReply.from([ + '5', + ['x', 'y'], + ]).asArray(); + expect(a[0].asInt(), 5); + expect(a[1].asArray().map((e) => e.asText()), ['x', 'y']); + }); + }); + + group('accessors', () { + test('asInt reads an integer reply and a string that holds one', () { + expect(RedisReply.from(42).asInt(), 42); + expect(RedisReply.from('42').asInt(), 42); + expect(RedisReply.from(Uint8List.fromList([52, 50])).asInt(), 42); + expect(RedisReply.from(-2).asInt(), -2); + }); + + test('a wrong shape is a RedisProtocolException, not a fallback', () { + expect(() => RedisReply.from('abc').asInt(), + throwsA(isA())); + expect(() => RedisReply.from(null).asInt(), + throwsA(isA())); + expect(() => RedisReply.from(['a']).asInt(), + throwsA(isA())); + expect(() => RedisReply.from(5).asArray(), + throwsA(isA())); + expect(() => RedisReply.from(['a']).asBulk(), + throwsA(isA())); + }); + + test('nil is no value for GET and an empty bulk elsewhere', () { + expect(RedisReply.from(null).asBulkOrNull(), isNull); + expect(RedisReply.from('v').asBulkOrNull()?.text, 'v'); + expect(RedisReply.from(null).asBulk().isEmpty, isTrue); + }); + + test('bytes that are not UTF-8 keep their bytes and have no text', () { + final r = RedisReply.from(Uint8List.fromList([0xff, 0xfe])); + expect(r.asText(), isNull); + expect(r.asBulk().bytes, [0xff, 0xfe]); + }); + + test('asDouble reads scores', () { + expect(RedisReply.from('1.5').asDouble(), 1.5); + expect(RedisReply.from(2).asDouble(), 2.0); + expect(() => RedisReply.from('x').asDouble(), + throwsA(isA())); + }); + }); + + group('RedisConnection.command', () { + test('arguments are converted in one place', () async { + final fake = RedisConnectionTestFake(); + await fake.connect(); + await fake.command(['SELECT', 0]); + await fake.command(['TYPE', RedisBulkValue.utf8('k')]); + await fake.command(['TYPE', Uint8List.fromList([1, 2])]); + expect(fake.sentCommands, containsAll(['SELECT', 'TYPE'])); + }); + + test('an argument that is not a command argument is refused', () async { + final fake = RedisConnectionTestFake(); + await fake.connect(); + expect(() => fake.command(['GET', true]), throwsArgumentError); + expect(() => fake.command(['GET', ['a']]), throwsArgumentError); + }); + }); +} diff --git a/test/core/database/redis_set_keepttl_test.dart b/test/core/database/redis_set_keepttl_test.dart index a5cce05f..ec9cb0d8 100644 --- a/test/core/database/redis_set_keepttl_test.dart +++ b/test/core/database/redis_set_keepttl_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/database/redis_connection.dart'; +import '../../support/redis_connection_test_fake.dart'; class _SetCommandFake extends RedisConnectionTestFake { final List> commands = []; diff --git a/test/core/database/redis_types_and_ttls_test.dart b/test/core/database/redis_types_and_ttls_test.dart index b472e31e..2c8e51a5 100644 --- a/test/core/database/redis_types_and_ttls_test.dart +++ b/test/core/database/redis_types_and_ttls_test.dart @@ -1,6 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/database/redis_bulk.dart'; -import 'package:querya_desktop/core/database/redis_connection.dart'; +import '../../support/redis_connection_test_fake.dart'; /// Counts outbound commands to prove [typesAndTtls] fires TYPE+TTL without /// awaiting between keys (true pipeline enqueue). diff --git a/test/e2e/04_nosql_workspaces/e2e_redis_cli_terminal_test.dart b/test/e2e/04_nosql_workspaces/e2e_redis_cli_terminal_test.dart index 213f6174..37bd1c98 100644 --- a/test/e2e/04_nosql_workspaces/e2e_redis_cli_terminal_test.dart +++ b/test/e2e/04_nosql_workspaces/e2e_redis_cli_terminal_test.dart @@ -1,12 +1,12 @@ import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/redis/redis_cli_workspace.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; import '../../support/querya_theme_test_shell.dart'; +import '../../support/redis_connection_test_fake.dart'; const _timeout = Timeout(Duration(seconds: 60)); diff --git a/test/e2e/04_nosql_workspaces/e2e_redis_keys_tree_and_types_test.dart b/test/e2e/04_nosql_workspaces/e2e_redis_keys_tree_and_types_test.dart index 9972586e..537d9386 100644 --- a/test/e2e/04_nosql_workspaces/e2e_redis_keys_tree_and_types_test.dart +++ b/test/e2e/04_nosql_workspaces/e2e_redis_keys_tree_and_types_test.dart @@ -7,6 +7,7 @@ import 'package:querya_desktop/features/redis/redis_keys_view.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import '../../support/querya_theme_test_shell.dart'; +import '../../support/redis_connection_test_fake.dart'; const _timeout = Timeout(Duration(seconds: 60)); diff --git a/test/features/redis/redis_cli_workspace_test.dart b/test/features/redis/redis_cli_workspace_test.dart index e69b0bff..deab2b64 100644 --- a/test/features/redis/redis_cli_workspace_test.dart +++ b/test/features/redis/redis_cli_workspace_test.dart @@ -3,12 +3,12 @@ import 'dart:typed_data'; import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; -import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/redis/redis_cli_workspace.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; import '../../support/querya_theme_test_shell.dart'; +import '../../support/redis_connection_test_fake.dart'; class _CliTestRedisConnection extends RedisConnectionTestFake { @override diff --git a/test/features/redis/redis_key_editor_test.dart b/test/features/redis/redis_key_editor_test.dart index 9086cdec..f0dcfc14 100644 --- a/test/features/redis/redis_key_editor_test.dart +++ b/test/features/redis/redis_key_editor_test.dart @@ -3,13 +3,13 @@ import 'dart:typed_data'; import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/database/redis_bulk.dart'; -import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/features/redis/redis_key_editor.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn hide TextExtension; import '../../support/querya_theme_test_shell.dart'; +import '../../support/redis_connection_test_fake.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); diff --git a/test/features/redis/redis_keys_view_test.dart b/test/features/redis/redis_keys_view_test.dart index 62f78eba..2a2518b9 100644 --- a/test/features/redis/redis_keys_view_test.dart +++ b/test/features/redis/redis_keys_view_test.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/database/redis_bulk.dart'; -import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/features/redis/redis_keys_view.dart'; import '../../support/querya_theme_test_shell.dart'; +import '../../support/redis_connection_test_fake.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); diff --git a/test/support/redis_connection_test_fake.dart b/test/support/redis_connection_test_fake.dart new file mode 100644 index 00000000..d9197290 --- /dev/null +++ b/test/support/redis_connection_test_fake.dart @@ -0,0 +1,277 @@ + +import 'package:querya_desktop/core/database/redis_connection.dart'; + +/// In-memory stub for widget tests (no socket). Handles SELECT, DBSIZE, SCAN, +/// TYPE, TTL used by [RedisKeysView]. +class RedisConnectionTestFake extends RedisConnection { + RedisConnectionTestFake({ + this.firstScanKeys = const ['alpha', 'beta'], + this.secondScanKeys = const [], + this.dbSizeResult = 2, + this.getResult, + List? listItems, + this.llenResult, + this.hashFirstPage = const {}, + this.hashSecondPage = const {}, + this.hlenResult, + this.setFirstPage = const [], + this.setSecondPage = const [], + this.scardResult, + this.zsetItems = const <(String, double)>[], + this.zcardResult, + this.typeResult = 'string', + this.failType = false, + this.getBytesResult, + this.binaryScanKeys = const >[], + this.unlinkUnsupported = false, + }) : listItems = + listItems != null ? List.from(listItems) : [], + super( + id: -1, + name: 'test-fake', + host: 'localhost', + port: 6379, + ); + + final List firstScanKeys; + final List secondScanKeys; + final int dbSizeResult; + final String? getResult; + final List listItems; + final int? llenResult; + final Map hashFirstPage; + final Map hashSecondPage; + final int? hlenResult; + final List setFirstPage; + final List setSecondPage; + final int? scardResult; + final List<(String, double)> zsetItems; + final int? zcardResult; + final String typeResult; + final bool failType; + final List? getBytesResult; + final List> binaryScanKeys; + + /// Answers `UNLINK` like a server before Redis 4.0. + final bool unlinkUnsupported; + final List sentCommands = []; + + var _up = false; + bool _firstScanDone = false; + bool _firstHscanDone = false; + bool _firstSscanDone = false; + + @override + bool get isConnected => _up; + + @override + Future connect() async { + _up = true; + } + + @override + Future disconnect() async { + _up = false; + } + + @override + Future sendCommand(List args) async { + if (!_up) { + throw StateError('Not connected to Redis'); + } + final op = args.first.toString().toUpperCase(); + sentCommands.add(op); + switch (op) { + case 'SELECT': + return 'OK'; + case 'DBSIZE': + return dbSizeResult; + case 'SCAN': + final cursor = int.tryParse(args[1].toString()) ?? 0; + List keysFor(List named) => + [...named, ...binaryScanKeys]; + if (cursor == 0 && !_firstScanDone) { + _firstScanDone = true; + final next = secondScanKeys.isNotEmpty ? 1 : 0; + return [next, keysFor(firstScanKeys)]; + } + if (cursor == 1 && secondScanKeys.isNotEmpty) { + return [0, keysFor(secondScanKeys)]; + } + return [0, []]; + case 'TYPE': + if (failType) { + throw StateError('TYPE failed'); + } + return typeResult; + case 'TTL': + return -1; + case 'GET': + if (getBytesResult != null) return getBytesResult; + if (typeResult != 'string') { + throw StateError( + 'WRONGTYPE Operation against a key holding the wrong kind of value', + ); + } + return getResult; + case 'SET': + if (typeResult != 'string') { + throw StateError('SET refused: key is $typeResult'); + } + return 'OK'; + case 'HGETALL': + case 'SMEMBERS': + throw StateError('$op is unbounded; use a paged command'); + case 'HLEN': + return hlenResult ?? hashFirstPage.length + hashSecondPage.length; + case 'LLEN': + return llenResult ?? listItems.length; + case 'SCARD': + return scardResult ?? setFirstPage.length + setSecondPage.length; + case 'ZCARD': + return zcardResult ?? zsetItems.length; + case 'LRANGE': + return _sliceList(listItems, args); + case 'ZRANGE': + final stop = int.tryParse(args[3].toString()) ?? -1; + if (stop < 0) { + throw StateError('unbounded ZRANGE'); + } + final start = int.tryParse(args[2].toString()) ?? 0; + if (start >= zsetItems.length || start > stop) return []; + final end = (stop + 1).clamp(0, zsetItems.length); + final out = []; + for (final (member, score) in zsetItems.sublist(start, end)) { + out.add(member); + out.add(score); + } + return out; + case 'HSCAN': + return _pagedPairs( + cursor: int.tryParse(args[2].toString()) ?? 0, + first: hashFirstPage, + second: hashSecondPage, + firstDone: _firstHscanDone, + markFirst: () => _firstHscanDone = true, + ); + case 'SSCAN': + final cursor = int.tryParse(args[2].toString()) ?? 0; + if (cursor == 0 && !_firstSscanDone) { + _firstSscanDone = true; + final next = setSecondPage.isNotEmpty ? 1 : 0; + return [next, setFirstPage]; + } + if (cursor == 1 && setSecondPage.isNotEmpty) { + return [0, setSecondPage]; + } + return [0, []]; + case 'READONLY': + case 'READWRITE': + return 'OK'; + case 'EXISTS': + final target = args[1].toString(); + final allKeys = [...firstScanKeys, ...secondScanKeys]; + return allKeys.contains(target) ? 1 : 0; + case 'RENAME': + return 'OK'; + case 'RENAMENX': + final target = args[2].toString(); + final allKeys = [...firstScanKeys, ...secondScanKeys]; + return allKeys.contains(target) ? 0 : 1; + case 'RPUSH': + listItems.add(args[2].toString()); + return 'OK'; + case 'LINDEX': + final lindexIdx = int.tryParse(args[2].toString()) ?? 0; + if (lindexIdx >= 0 && lindexIdx < listItems.length) { + return listItems[lindexIdx]; + } + return null; + case 'LSET': + final idx = int.tryParse(args[2].toString()) ?? 0; + final val = args[3].toString(); + if (idx >= 0 && idx < listItems.length) { + listItems[idx] = val; + } + return 'OK'; + case 'LREM': + final count = int.tryParse(args[2].toString()) ?? 0; + final val = args[3].toString(); + var removed = 0; + if (count == 0) { + removed = listItems.where((e) => e == val).length; + listItems.removeWhere((e) => e == val); + } else if (count > 0) { + for (var i = 0; i < count; i++) { + final idx = listItems.indexOf(val); + if (idx != -1) { + listItems.removeAt(idx); + removed++; + } + } + } else { + for (var i = 0; i < count.abs(); i++) { + final idx = listItems.lastIndexOf(val); + if (idx != -1) { + listItems.removeAt(idx); + removed++; + } + } + } + return removed; + case 'UNLINK' when unlinkUnsupported: + throw Exception("ERR unknown command 'UNLINK'"); + case 'DEL' || 'UNLINK': + final allKeys = [...firstScanKeys, ...secondScanKeys]; + var count = 0; + for (var i = 1; i < args.length; i++) { + final target = args[i].toString(); + if (allKeys.contains(target)) { + count++; + } + } + return count; + default: + return null; + } + } + + List _sliceList(List items, List args) { + if (items.isEmpty) return const []; + final start = int.tryParse(args[2].toString()) ?? 0; + var stop = int.tryParse(args[3].toString()) ?? -1; + if (stop < 0) { + throw StateError('unbounded LRANGE'); + } + if (start >= items.length || start > stop) return const []; + final end = (stop + 1).clamp(0, items.length); + return items.sublist(start, end); + } + + List _pagedPairs({ + required int cursor, + required Map first, + required Map second, + required bool firstDone, + required void Function() markFirst, + }) { + List flatten(Map map) { + final out = []; + map.forEach((k, v) { + out.add(k); + out.add(v); + }); + return out; + } + + if (cursor == 0 && !firstDone) { + markFirst(); + final next = second.isNotEmpty ? 1 : 0; + return [next, flatten(first)]; + } + if (cursor == 1 && second.isNotEmpty) { + return [0, flatten(second)]; + } + return [0, []]; + } +}