diff --git a/packages/redis/.gitignore b/packages/redis/.gitignore new file mode 100644 index 00000000..d220238f --- /dev/null +++ b/packages/redis/.gitignore @@ -0,0 +1,14 @@ +# See https://www.dartlang.org/tools/private-files.html + +# Files and directories created by pub +.packages +.pub/ +build/ +# If you're building an application, you may want to check-in your pubspec.lock +pubspec.lock + +# Directory created by dartdoc +# If you don't generate documentation locally you can remove this line. +doc/api/ +.dart_tool +dump.rdb diff --git a/packages/redis/.travis.yml b/packages/redis/.travis.yml new file mode 100644 index 00000000..2f3d3f5f --- /dev/null +++ b/packages/redis/.travis.yml @@ -0,0 +1,3 @@ +language: dart +services: + - redis-server \ No newline at end of file diff --git a/packages/redis/CHANGELOG.md b/packages/redis/CHANGELOG.md new file mode 100644 index 00000000..b23475f1 --- /dev/null +++ b/packages/redis/CHANGELOG.md @@ -0,0 +1,2 @@ +# 1.0.0 +* First version. \ No newline at end of file diff --git a/packages/redis/LICENSE b/packages/redis/LICENSE new file mode 100644 index 00000000..f8e6088a --- /dev/null +++ b/packages/redis/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 The Angel Framework + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/redis/README.md b/packages/redis/README.md new file mode 100644 index 00000000..bba67f1a --- /dev/null +++ b/packages/redis/README.md @@ -0,0 +1,66 @@ +# redis +[![Pub](https://img.shields.io/pub/v/angel_redis.svg)](https://pub.dartlang.org/packages/angel_redis) +[![build status](https://travis-ci.org/angel-dart/redis.svg)](https://travis-ci.org/angel-dart/redis) + +Redis-enabled services for the Angel framework. +`RedisService` can be used alone, *or* as the backend of a +[`CacheService`](https://github.com/angel-dart/cache), +and thereby cache the results of calling an upstream database. + +## Installation +`package:angel_redis` requires Angel 2. + +In your `pubspec.yaml`: + +```yaml +dependencies: + angel_framework: ^2.0.0-alpha + angel_redis: ^1.0.0 +``` + +## Usage +Pass an instance of `RespCommands` (from `package:resp_client`) to the `RedisService` constructor. +You can also pass an optional prefix, which is recommended if you are using `angel_redis` for multiple +logically-separate collections. Redis is a flat key-value store; by prefixing the keys used, +`angel_redis` can provide the experience of using separate stores, rather than a single node. + +Without a prefix, there's a chance that different collections can overwrite one another's data. + +## Notes +* Neither `index`, nor `modify` is atomic; each performs two separate queries.`angel_redis` stores data as JSON strings, rather than as Redis hashes, so an update-in-place is impossible. +* `index` uses Redis' `KEYS` functionality, so use it sparingly in production, if at all. In a larger database, it can quickly +become a bottleneck. +* `remove` uses `MULTI`+`EXEC` in a transaction. +* Prefer using `update`, rather than `modify`. The former only performs one query, though it does overwrite the current +contents for a given key. +* When calling `create`, it's possible that you may already have an `id` in mind to insert into the store. For example, +when caching another database, you'll preserve the ID or primary key of an item. `angel_redis` heeds this. If no +`id` is present, then an ID will be created via an `INCR` call. + +## Example +Also present at `example/main.dart`: + +```dart +import 'package:angel_redis/angel_redis.dart'; +import 'package:resp_client/resp_client.dart'; +import 'package:resp_client/resp_commands.dart'; + +main() async { + var connection = await connectSocket('localhost'); + var client = new RespClient(connection); + var service = new RedisService(new RespCommands(client), prefix: 'example'); + + // Create an object + await service.create({'id': 'a', 'hello': 'world'}); + + // Read it... + var read = await service.read('a'); + print(read['hello']); + + // Delete it. + await service.remove('a'); + + // Close the connection. + await connection.close(); +} +``` \ No newline at end of file diff --git a/packages/redis/example/main.dart b/packages/redis/example/main.dart new file mode 100644 index 00000000..c5614d23 --- /dev/null +++ b/packages/redis/example/main.dart @@ -0,0 +1,22 @@ +import 'package:angel_redis/angel_redis.dart'; +import 'package:resp_client/resp_client.dart'; +import 'package:resp_client/resp_commands.dart'; + +main() async { + var connection = await connectSocket('localhost'); + var client = new RespClient(connection); + var service = new RedisService(new RespCommands(client), prefix: 'example'); + + // Create an object + await service.create({'id': 'a', 'hello': 'world'}); + + // Read it... + var read = await service.read('a'); + print(read['hello']); + + // Delete it. + await service.remove('a'); + + // Close the connection. + await connection.close(); +} diff --git a/packages/redis/lib/angel_redis.dart b/packages/redis/lib/angel_redis.dart new file mode 100644 index 00000000..e0b029dd --- /dev/null +++ b/packages/redis/lib/angel_redis.dart @@ -0,0 +1 @@ +export 'src/redis_service.dart'; diff --git a/packages/redis/lib/src/redis_service.dart b/packages/redis/lib/src/redis_service.dart new file mode 100644 index 00000000..1651a73d --- /dev/null +++ b/packages/redis/lib/src/redis_service.dart @@ -0,0 +1,97 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:resp_client/resp_client.dart'; +import 'package:resp_client/resp_commands.dart'; + +/// An Angel service that reads and writes JSON within a Redis store. +class RedisService extends Service> { + final RespCommands respCommands; + + /// An optional string prefixed to keys before they are inserted into Redis. + /// + /// Consider using this if you are using several different Redis collections + /// within a single application. + final String prefix; + + RedisService(this.respCommands, {this.prefix}); + + String _applyPrefix(String id) => prefix == null ? id : '$prefix:$id'; + + @override + Future>> index( + [Map params]) async { + var result = + await respCommands.client.writeArrayOfBulk(['KEYS', _applyPrefix('*')]); + var keys = result.payload.map((RespType s) => s.payload) as Iterable; + if (keys.isEmpty) return []; + result = await respCommands.client.writeArrayOfBulk(['MGET']..addAll(keys)); + return result.payload + .map>( + (RespType s) => json.decode(s.payload) as Map) + .toList(); + } + + @override + Future> read(String id, + [Map params]) async { + var value = await respCommands.get(_applyPrefix(id)); + + if (value == null) { + throw new AngelHttpException.notFound( + message: 'No record found for ID $id'); + } else { + return json.decode(value); + } + } + + @override + Future> create(Map data, + [Map params]) async { + String id; + if (data['id'] != null) + id = data['id'] as String; + else { + var keyVar = await respCommands.client + .writeArrayOfBulk(['INCR', _applyPrefix('angel_redis:id')]); + id = keyVar.payload.toString(); + data = new Map.from(data)..['id'] = id; + } + + await respCommands.set(_applyPrefix(id), json.encode(data)); + return data; + } + + @override + Future> modify(String id, Map data, + [Map params]) async { + var input = await read(id); + input.addAll(data); + return await update(id, input, params); + } + + @override + Future> update(String id, Map data, + [Map params]) async { + data = new Map.from(data)..['id'] = id; + await respCommands.set(_applyPrefix(id), json.encode(data)); + return data; + } + + @override + Future> remove(String id, + [Map params]) async { + var client = respCommands.client; + await client.writeArrayOfBulk(['MULTI']); + await client.writeArrayOfBulk(['GET', _applyPrefix(id)]); + await client.writeArrayOfBulk(['DEL', _applyPrefix(id)]); + var result = await client.writeArrayOfBulk(['EXEC']); + var str = result.payload[0] as RespBulkString; + + if (str.payload == null) + throw new AngelHttpException.notFound( + message: 'No record found for ID $id'); + else + return json.decode(str.payload); + } +} diff --git a/packages/redis/pubspec.yaml b/packages/redis/pubspec.yaml new file mode 100644 index 00000000..5acaf22e --- /dev/null +++ b/packages/redis/pubspec.yaml @@ -0,0 +1,13 @@ +name: angel_redis +version: 1.0.0 +description: An Angel service provider for Redis. Works well for caching volatile data. +author: Tobe O +homepage: https://github.com/angel-dart/redis +environment: + sdk: ">=2.0.0-dev <3.0.0" +dependencies: + angel_framework: ^2.0.0-alpha + angel_http_exception: ^1.0.0 + resp_client: ^0.1.6 +dev_dependencies: + test: ^1.0.0 diff --git a/packages/redis/test/all_test.dart b/packages/redis/test/all_test.dart new file mode 100644 index 00000000..8164ec2d --- /dev/null +++ b/packages/redis/test/all_test.dart @@ -0,0 +1,78 @@ +import 'package:angel_http_exception/angel_http_exception.dart'; +import 'package:angel_redis/angel_redis.dart'; +import 'package:resp_client/resp_client.dart'; +import 'package:resp_client/resp_commands.dart'; +import 'package:test/test.dart'; + +main() async { + RespServerConnection connection; + RedisService service; + + setUp(() async { + connection = await connectSocket('localhost'); + service = new RedisService(new RespCommands(new RespClient(connection)), + prefix: 'angel_redis_test'); + }); + + tearDown(() => connection.close()); + + test('index', () async { + // Wipe + await service.respCommands.flushDb(); + await service.create({'id': '0', 'name': 'Tobe'}); + await service.create({'id': '1', 'name': 'Osakwe'}); + + var output = await service.index(); + expect(output, hasLength(2)); + expect(output[1], {'id': '0', 'name': 'Tobe'}); + expect(output[0], {'id': '1', 'name': 'Osakwe'}); + }); + + test('create with id', () async { + var input = {'id': 'foo', 'bar': 'baz'}; + var output = await service.create(input); + expect(input, output); + }); + + test('create without id', () async { + var input = {'bar': 'baz'}; + var output = await service.create(input); + print(output); + expect(output.keys, contains('id')); + expect(output, containsPair('bar', 'baz')); + }); + + test('read', () async { + var id = 'poobah${new DateTime.now().millisecondsSinceEpoch}'; + var input = await service.create({'id': id, 'bar': 'baz'}); + expect(await service.read(id), input); + }); + + test('modify', () async { + var id = 'jamboree${new DateTime.now().millisecondsSinceEpoch}'; + await service.create({'id': id, 'bar': 'baz', 'yes': 'no'}); + var output = await service.modify(id, {'bar': 'quux'}); + expect(output, {'id': id, 'bar': 'quux', 'yes': 'no'}); + expect(await service.read(id), output); + }); + + test('update', () async { + var id = 'hoopla${new DateTime.now().millisecondsSinceEpoch}'; + await service.create({'id': id, 'bar': 'baz'}); + var output = await service.update(id, {'yes': 'no'}); + expect(output, {'id': id, 'yes': 'no'}); + expect(await service.read(id), output); + }); + + test('remove', () async { + var id = 'gelatin${new DateTime.now().millisecondsSinceEpoch}'; + var input = await service.create({'id': id, 'bar': 'baz'}); + expect(await service.remove(id), input); + expect(await service.respCommands.exists([id]), 0); + }); + + test('remove nonexistent', () async { + expect(() => service.remove('definitely_does_not_exist'), + throwsA(const TypeMatcher())); + }); +}