diff --git a/packages/rethink/.gitignore b/packages/rethink/.gitignore new file mode 100644 index 00000000..20880a05 --- /dev/null +++ b/packages/rethink/.gitignore @@ -0,0 +1,31 @@ +# See https://www.dartlang.org/tools/private-files.html + +# Files and directories created by pub +.buildlog +.packages +.project +.pub/ +build/ +**/packages/ + +# Files created by dart2js +# (Most Dart developers will use pub build to compile Dart, use/modify these +# rules if you intend to use dart2js directly +# Convention is to use extension '.dart.js' for Dart compiled to Javascript to +# differentiate from explicit Javascript files) +*.dart.js +*.part.js +*.js.deps +*.js.map +*.info.json + +# Directory created by dartdoc +doc/api/ + +# Don't commit pubspec lock file +# (Library packages only! Remove pattern if developing an application package) +pubspec.lock + +rethinkdb_data/ +.idea +.dart_tool \ No newline at end of file diff --git a/packages/rethink/.travis.yml b/packages/rethink/.travis.yml new file mode 100644 index 00000000..d8efdb66 --- /dev/null +++ b/packages/rethink/.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/packages/rethink/CHANGELOG.md b/packages/rethink/CHANGELOG.md new file mode 100644 index 00000000..3c801a88 --- /dev/null +++ b/packages/rethink/CHANGELOG.md @@ -0,0 +1,3 @@ +# 1.1.0 +* Moved to `package:rethinkdb_driver` +* Fixed references to old hooked event names. \ No newline at end of file diff --git a/packages/rethink/LICENSE b/packages/rethink/LICENSE new file mode 100644 index 00000000..89074fd3 --- /dev/null +++ b/packages/rethink/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 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/rethink/README.md b/packages/rethink/README.md new file mode 100644 index 00000000..d899a0ff --- /dev/null +++ b/packages/rethink/README.md @@ -0,0 +1,87 @@ +# rethink +[![version 1.0.7](https://img.shields.io/badge/pub-1.0.7-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 +``` + +`package:rethinkdb_driver2` will be installed as well. + +# Usage +This library exposes one 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 `Query` (usually a table) 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/packages/rethink/lib/angel_rethink.dart b/packages/rethink/lib/angel_rethink.dart new file mode 100644 index 00000000..e0f27f42 --- /dev/null +++ b/packages/rethink/lib/angel_rethink.dart @@ -0,0 +1 @@ +export 'src/rethink_service.dart'; \ No newline at end of file diff --git a/packages/rethink/lib/src/rethink_service.dart b/packages/rethink/lib/src/rethink_service.dart new file mode 100644 index 00000000..da37906f --- /dev/null +++ b/packages/rethink/lib/src/rethink_service.dart @@ -0,0 +1,237 @@ +import 'dart:async'; +//import 'dart:io'; +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 buildQuery(RqlQuery initialQuery, Map params) { + if (params != null) + params['broadcast'] = params.containsKey('broadcast') + ? params['broadcast'] + : (listenForChanges != true); + + var q = _getQueryInner(initialQuery, params); + + if (params?.containsKey('reql') == true && params['reql'] is QueryCallback) + q = params['reql'](q); + + return q ?? initialQuery; + } + + RqlQuery _getQueryInner(RqlQuery query, Map params) { + if (params == null || !params.containsKey('query')) + return query; + 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.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({key.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; + } + + _serialize(data) { + if (data is Map) + return data; + else if (data is Iterable) + return data.map(_serialize).toList(); + else + return god.serializeObject(data); + } + + _squeeze(data) { + if (data is Map) + return data.keys.fold({}, (map, k) => map..[k.toString()] = data[k]); + else if (data is Iterable) + return data.map(_squeeze).toList(); + else + return data; + } + + 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.fireEvent( + hookedService.afterCreated, + new HookedServiceEvent( + true, null, null, this, HookedServiceEvent.created, + result: newVal)); + } else if (type == 'change') { + // Update + hookedService.fireEvent( + hookedService.afterCreated, + new HookedServiceEvent( + true, null, null, this, HookedServiceEvent.updated, + result: newVal, id: oldVal['id'], data: newVal)); + } else if (type == 'remove') { + // Remove + hookedService.fireEvent( + hookedService.afterCreated, + new HookedServiceEvent( + true, null, null, this, HookedServiceEvent.removed, + result: oldVal, id: oldVal['id'])); + } + }); + } + + @override + Future index([Map params]) async { + var query = buildQuery(table, params); + return await _sendQuery(query); + } + + @override + Future read(id, [Map params]) async { + var query = buildQuery(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 AngelHttpException.methodNotAllowed(); + + var d = _serialize(data); + var q = table as Table; + var query = buildQuery(q.insert(_squeeze(d)), params); + return await _sendQuery(query); + } + + @override + Future modify(id, data, [Map params]) async { + var d = _serialize(data); + + if (d is Map && d.containsKey('id')) { + try { + await read(d['id'], params); + } on AngelHttpException catch (e) { + if (e.statusCode == 404) + return await create(data, params); + else + rethrow; + } + } + + var query = buildQuery(table.get(id?.toString()), params).update(d); + await _sendQuery(query); + return await read(id, params); + } + + @override + Future update(id, data, [Map params]) async { + var d = _serialize(data); + + if (d is Map && d.containsKey('id')) { + try { + await read(d['id'], params); + } on AngelHttpException catch (e) { + if (e.statusCode == 404) + return await create(data, params); + else + rethrow; + } + } + + if (d is Map && !d.containsKey('id')) d['id'] = id.toString(); + var query = buildQuery(table.get(id?.toString()), params).replace(d); + 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 = buildQuery(table.get(id), params).delete(); + await _sendQuery(query); + return prior; + } + } +} diff --git a/packages/rethink/pubspec.yaml b/packages/rethink/pubspec.yaml new file mode 100644 index 00000000..9d5c6ce1 --- /dev/null +++ b/packages/rethink/pubspec.yaml @@ -0,0 +1,16 @@ +name: angel_rethink +version: 1.1.0 +description: RethinkDB-enabled services for the Angel framework. +author: Tobe O +environment: + sdk: ">=1.19.0 <3.0.0" +homepage: https://github.com/angel-dart/rethink +dependencies: + angel_framework: ^1.1.0 + json_god: ^2.0.0-beta + rethinkdb_driver: ^2.3.1 +dev_dependencies: + angel_client: ^1.1.0 + angel_test: ^1.1.0 + logging: ^0.11.3 + test: ^0.12.0 \ No newline at end of file diff --git a/packages/rethink/test/README.md b/packages/rethink/test/README.md new file mode 100644 index 00000000..2b9eed87 --- /dev/null +++ b/packages/rethink/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/packages/rethink/test/bootstrap.dart b/packages/rethink/test/bootstrap.dart new file mode 100644 index 00000000..5186437d --- /dev/null +++ b/packages/rethink/test/bootstrap.dart @@ -0,0 +1,11 @@ +import 'dart:io'; +import 'package:rethinkdb_driver/rethinkdb_driver.dart'; + +main() async { + var r = new Rethinkdb(); + r.connect().then((conn) { + r.tableCreate('todos').run(conn); + print('Done'); + exit(0); + }); +} diff --git a/packages/rethink/test/common.dart b/packages/rethink/test/common.dart new file mode 100644 index 00000000..15a4b813 --- /dev/null +++ b/packages/rethink/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/packages/rethink/test/generic_test.dart b/packages/rethink/test/generic_test.dart new file mode 100644 index 00000000..ee9ee169 --- /dev/null +++ b/packages/rethink/test/generic_test.dart @@ -0,0 +1,85 @@ +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:logging/logging.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.errorHandler = (e, req, res) async { + print('Whoops: $e'); + }; + + app.logger = new Logger.detached('angel')..onRecord.listen(print); + + 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('modify', () 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.modify(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)); + }); +}