diff --git a/.gitignore b/.gitignore index 7c280441..5d2a807f 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,5 @@ doc/api/ # Don't commit pubspec lock file # (Library packages only! Remove pattern if developing an application package) pubspec.lock + +rethinkdb_data/ \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..d8efdb66 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,4 @@ +language: dart +addons: + rethinkdb: '2.3' +before_script: 'dart test/bootstrap.dart' \ No newline at end of file diff --git a/README.md b/README.md index 9a9ffef1..69c110b2 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,86 @@ # rethink + +[![version 1.0.0](https://img.shields.io/badge/pub-1.0.0-brightgreen.svg)](https://pub.dartlang.org/packages/angel_rethink) +[![build status](https://travis-ci.org/angel-dart/rethink.svg?branch=master)](https://travis-ci.org/angel-dart/rethink) + RethinkDB-enabled services for the Angel framework. + +# Installation +Add the following to your `pubspec.yaml`: + +```yaml +dependencies: + angel_rethink: ^1.0.0 +``` + +# Usage +This library exposes one main class: `RethinkService`. By default, these services will even +listen to [changefeeds](https://www.rethinkdb.com/docs/changefeeds/ruby/) from the database, +which makes them very suitable for WebSocket use. + +However, only `CREATED`, `UPDATED` and `REMOVED` events will be fired. This is technically not +a problem, as it lowers the numbers of events you have to handle on the client side. ;) + +## Model +`Model` is class with no real functionality; however, it represents a basic document, and your services should host inherited classes. +Other Angel service providers host `Model` as well, so you will easily be able to modify your application if you ever switch databases. + +```dart +class User extends Model { + String username; + String password; +} + +main() async { + var r = new RethinkDb(); + var conn = await r.connect(); + + app.use('/api/users', new RethinkService(conn, r.table('users'))); + + // Add type de/serialization if you want + app.use('/api/users', new TypedService(new RethinkService(conn, r.table('users')))); + + // You don't have to even use a table... + app.use('/api/pro_users', new RethinkService(conn, r.table('users').filter({'membership': 'pro'}))); + + app.service('api/users').afterCreated.listen((event) { + print("New user: ${event.result}"); + }); +} +``` + +## RethinkService +This class interacts with a `Table` (from `package:rethinkdb_driver`) and serializes data to and from Maps. + +## RethinkTypedService +Does the same as above, but serializes to and from a target class using `package:json_god` and its support for reflection. + +## Querying +You can query these services as follows: + + /path/to/service?foo=bar + +The above will query the database to find records where 'foo' equals 'bar'. + +The former will sort result in ascending order of creation, and so will the latter. + +You can use advanced queries: + +```dart +// Pass an actual query... +service.index({'query': r.table('foo').filter(...)}); + +// Or, a function that creates a query from a table... +service.index({'query': (table) => table.getAll('foo')}); + +// Or, a Map, which will be transformed into a `filter` query: +service.index({'query': {'foo': 'bar', 'baz': 'quux'}}); +``` + +You can also apply sorting by adding a `reql` parameter on the server-side. + +```dart +service.index({'reql': (query) => query.sort(...)}); +``` + +See the tests for more usage examples. diff --git a/lib/angel_rethink.dart b/lib/angel_rethink.dart new file mode 100644 index 00000000..e0f27f42 --- /dev/null +++ b/lib/angel_rethink.dart @@ -0,0 +1 @@ +export 'src/rethink_service.dart'; \ No newline at end of file diff --git a/lib/src/rethink_service.dart b/lib/src/rethink_service.dart new file mode 100644 index 00000000..98e98097 --- /dev/null +++ b/lib/src/rethink_service.dart @@ -0,0 +1,194 @@ +import 'dart:async'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:json_god/json_god.dart' as god; +import 'package:rethinkdb_driver/rethinkdb_driver.dart'; + +// Extends a RethinkDB query. +typedef RqlQuery QueryCallback(RqlQuery query); + +/// Queries a single RethinkDB table or query. +class RethinkService extends Service { + /// If set to `true`, clients can remove all items by passing a `null` `id` to `remove`. + /// + /// `false` by default. + final bool allowRemoveAll; + + /// If set to `true`, parameters in `req.query` are applied to the database query. + final bool allowQuery; + + final bool debug; + + /// If set to `true`, then a HookedService mounted over this instance + /// will fire events when RethinkDB pushes events. + /// + /// Good for scaling. ;) + final bool listenForChanges; + + final Connection connection; + + /// Doesn't actually have to be a table, just a RethinkDB query. + /// + /// However, a table is the most common usecase. + final RqlQuery table; + + RethinkService(this.connection, this.table, + {this.allowRemoveAll: false, + this.allowQuery: true, + this.debug: false, + this.listenForChanges: true}) + : super() {} + + RqlQuery _getQuery(RqlQuery query, Map params) { + if (params != null) + params['broadcast'] = + params.containsKey('broadcast') ? params['broadcast'] : false; + + var q = _getQueryInner(query, params); + + if (params?.containsKey('reql') == true && params['reql'] is QueryCallback) + q = params['reql'](q); + + return q ?? query; + } + + RqlQuery _getQueryInner(RqlQuery query, Map params) { + if (params == null || !params.containsKey('query')) + return null; + else { + if (params['query'] is RqlQuery) + return params['query']; + else if (params['query'] is QueryCallback) + return params['query'](table); + else if (params['query'] is! Map || allowQuery != true) + return query; + else { + Map q = params['query']; + return q.keys.map((k) => k.toString()).fold(query, + (out, key) { + var val = q[key]; + + if (val is RequestContext || + val is ResponseContext || + key == 'provider' || + val is Providers) + return out; + else { + return out.filter({k.toString(): val}); + } + }); + } + } + } + + Future _sendQuery(RqlQuery query) async { + var result = await query.run(connection); + + if (result is Cursor) + return await result.toList(); + else if (result is Map && result['generated_keys'] is List) { + if (result['generated_keys'].length == 1) + return await read(result['generated_keys'].first); + return await Future.wait(result['generated_keys'].map(read)); + } else + return result; + } + + Map _serialize(data) { + if (data is Map) + return data; + else + return god.serializeObject(data); + } + + Map _squeeze(Map data) { + return data.keys.fold({}, (map, k) => map..[k.toString()] = data[k]); + } + + void onHooked(HookedService hookedService) { + if (listenForChanges == true) { + listenToQuery(table, hookedService); + } + } + + Future listenToQuery(RqlQuery query, HookedService hookedService) async { + Feed feed = await query.changes({'include_types': true}).run(connection); + + feed.listen((Map event) { + String type = event['type']?.toString(); + var newVal = event['new_val'], oldVal = event['old_val']; + + if (type == 'add') { + // Create + hookedService.fire(HookedServiceEvent.CREATED, newVal); + } else if (type == 'change') { + // Update + hookedService.fire(HookedServiceEvent.UPDATED, newVal, (e) { + e + ..id = oldVal['id'] + ..data = newVal; + }); + } else if (type == 'remove') { + // Remove + hookedService.fire(HookedServiceEvent.CREATED, oldVal, (e) { + e.id = oldVal['id']; + }); + } + }); + } + + @override + Future index([Map params]) async { + var query = _getQuery(table, params); + return await _sendQuery(query); + } + + @override + Future read(id, [Map params]) async { + var query = _getQuery(table.get(id?.toString()), params); + var found = await _sendQuery(query); + print('Found for $id: $found'); + + if (found == null) { + throw new AngelHttpException.notFound( + message: 'No record found for ID $id'); + } else + return found; + } + + @override + Future create(data, [Map params]) async { + if (table is! Table) + throw new StateError( + 'RethinkServices can only create data within tables.'); + + var d = _serialize(data); + var q = table as Table; + var query = _getQuery(q.insert(_squeeze(d)), params); + return await _sendQuery(query); + } + + @override + Future modify(id, data, [Map params]) => update(id, data, params); + + @override + Future update(id, data, [Map params]) async { + var query = _getQuery(table.get(id?.toString()), params).update(data); + await _sendQuery(query); + return await read(id, params); + } + + @override + Future remove(id, [Map params]) async { + if (id == null || + id == 'null' && + (allowRemoveAll == true || + params?.containsKey('provider') != true)) { + return await _sendQuery(table.delete()); + } else { + var prior = await read(id, params); + var query = _getQuery(table.get(id), params).delete(); + await _sendQuery(query); + return prior; + } + } +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 00000000..aab3367b --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,13 @@ +name: angel_rethink +version: 1.0.0 +description: RethinkDB-enabled services for the Angel framework. +author: Tobe O +environment: + sdk: ">=1.19.0" +homepage: https://github.com/angel-dart/rethink +dependencies: + angel_framework: ^1.0.0-dev + rethinkdb_driver: ^2.3.1 +dev_dependencies: + angel_test: ^1.0.0-dev + test: ^0.12.0 \ No newline at end of file diff --git a/test/README.md b/test/README.md new file mode 100644 index 00000000..2b9eed87 --- /dev/null +++ b/test/README.md @@ -0,0 +1,6 @@ +# Tests + +The tests expect you to have installed RethinkDB. You must have a `test` database +available, and a server ready at the default port. + +Also, the tests expect a table named `todos`. \ No newline at end of file diff --git a/test/bootstrap.dart b/test/bootstrap.dart new file mode 100644 index 00000000..01e9454e --- /dev/null +++ b/test/bootstrap.dart @@ -0,0 +1,7 @@ +import 'package:rethinkdb_driver/rethinkdb_driver.dart'; + +main() async { + var r = new Rethinkdb(); + var conn = await r.connect(); + await r.tableCreate('todos').run(conn); +} \ No newline at end of file diff --git a/test/common.dart b/test/common.dart new file mode 100644 index 00000000..15a4b813 --- /dev/null +++ b/test/common.dart @@ -0,0 +1,10 @@ +class Todo { + String title; + bool completed; + + Todo({this.title, this.completed: false}); + + Map toJson() { + return {'title': title, 'completed': completed == true}; + } +} diff --git a/test/generic_test.dart b/test/generic_test.dart new file mode 100644 index 00000000..038c86d3 --- /dev/null +++ b/test/generic_test.dart @@ -0,0 +1,87 @@ +import 'package:angel_client/angel_client.dart' as c; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_rethink/angel_rethink.dart'; +import 'package:angel_test/angel_test.dart'; +import 'package:rethinkdb_driver/rethinkdb_driver.dart'; +import 'package:test/test.dart'; +import 'common.dart'; + +main() { + Angel app; + TestClient client; + Rethinkdb r; + c.Service todoService; + + setUp(() async { + r = new Rethinkdb(); + var conn = await r.connect(); + + app = new Angel(); + app.use('/todos', new RethinkService(conn, r.table('todos'))); + + app.onError((e, req, res) async { + print('Whoops: $e'); + }); + + app.fatalErrorStream.listen((e) { + print('Whoops: ${e.error}'); + print(e.stack); + }); + + client = await connectTo(app); + todoService = client.service('todos'); + }); + + tearDown(() => client.close()); + + test('index', () async { + var result = await todoService.index(); + print('Response: $result'); + expect(result, isList); + }); + + test('create+read', () async { + var todo = new Todo(title: 'Clean your room'); + var creation = await todoService.create(todo.toJson()); + print('Creation: $creation'); + + var id = creation['id']; + var result = await todoService.read(id); + + print('Response: $result'); + expect(result, isMap); + expect(result['id'], equals(id)); + expect(result['title'], equals(todo.title)); + expect(result['completed'], equals(todo.completed)); + }); + + test('update', () async { + var todo = new Todo(title: 'Clean your room'); + var creation = await todoService.create(todo.toJson()); + print('Creation: $creation'); + + var id = creation['id']; + var result = await todoService.update(id, {'title': 'Eat healthy'}); + + print('Response: $result'); + expect(result, isMap); + expect(result['id'], equals(id)); + expect(result['title'], equals('Eat healthy')); + expect(result['completed'], equals(todo.completed)); + }); + + test('remove', () async { + var todo = new Todo(title: 'Clean your room'); + var creation = await todoService.create(todo.toJson()); + print('Creation: $creation'); + + var id = creation['id']; + var result = await todoService.remove(id); + + print('Response: $result'); + expect(result, isMap); + expect(result['id'], equals(id)); + expect(result['title'], equals(todo.title)); + expect(result['completed'], equals(todo.completed)); + }); +}