Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
478 changes: 82 additions & 396 deletions lib/core/database/redis_connection.dart

Large diffs are not rendered by default.

157 changes: 157 additions & 0 deletions lib/core/database/redis_reply.dart
Original file line number Diff line number Diff line change
@@ -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<int>) {
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<RedisReply> 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<RedisReply> 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<RedisReply> 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<RedisReply> 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<RedisReply> 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<RedisReply> items;

@override
int asInt() => _mismatch('an integer');
@override
List<RedisReply> 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<RedisReply> asArray() => _mismatch('an array');
@override
RedisBulkValue asBulk() => _mismatch('a bulk string');
@override
double asDouble() => _mismatch('a number');
}
1 change: 1 addition & 0 deletions test/core/database/redis_connection_test.dart
Original file line number Diff line number Diff line change
@@ -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', () {
Expand Down
1 change: 1 addition & 0 deletions test/core/database/redis_database_socket_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
88 changes: 88 additions & 0 deletions test/core/database/redis_reply_test.dart
Original file line number Diff line number Diff line change
@@ -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<RedisNil>());
expect(RedisReply.from(7), isA<RedisInt>());
expect(RedisReply.from('OK'), isA<RedisSimple>());
expect(RedisReply.from(Uint8List.fromList([104, 105])), isA<RedisBulk>());
expect(RedisReply.from(<int>[104, 105]), isA<RedisBulk>());
expect(RedisReply.from(<Object?>['a', 1, null]), isA<RedisArray>());
});

test('an array holds replies, nested arrays included', () {
final a = RedisReply.from(<Object?>[
'5',
<Object?>['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<RedisProtocolException>()));
expect(() => RedisReply.from(null).asInt(),
throwsA(isA<RedisProtocolException>()));
expect(() => RedisReply.from(<Object?>['a']).asInt(),
throwsA(isA<RedisProtocolException>()));
expect(() => RedisReply.from(5).asArray(),
throwsA(isA<RedisProtocolException>()));
expect(() => RedisReply.from(<Object?>['a']).asBulk(),
throwsA(isA<RedisProtocolException>()));
});

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<RedisProtocolException>()));
});
});

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', <String>['a']]), throwsArgumentError);
});
});
}
1 change: 1 addition & 0 deletions test/core/database/redis_set_keepttl_test.dart
Original file line number Diff line number Diff line change
@@ -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<List<dynamic>> commands = [];
Expand Down
2 changes: 1 addition & 1 deletion test/core/database/redis_types_and_ttls_test.dart
Original file line number Diff line number Diff line change
@@ -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).
Expand Down
Original file line number Diff line number Diff line change
@@ -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));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down
2 changes: 1 addition & 1 deletion test/features/redis/redis_cli_workspace_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion test/features/redis/redis_key_editor_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion test/features/redis/redis_keys_view_test.dart
Original file line number Diff line number Diff line change
@@ -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();
Expand Down
Loading
Loading