diff --git a/packages/rethinkdb/AUTHORS.md b/packages/rethinkdb/AUTHORS.md new file mode 100644 index 00000000..ac95ab58 --- /dev/null +++ b/packages/rethinkdb/AUTHORS.md @@ -0,0 +1,12 @@ +Primary Authors +=============== + +* __[Thomas Hii](dukefirehawk.apps@gmail.com)__ + + Thomas is the current maintainer of the code base. He has refactored and migrated the + code base to support NNBD. + +* __[Tobe O](thosakwe@gmail.com)__ + + Tobe has written much of the original code prior to NNBD migration. He has moved on and + is no longer involved with the project. diff --git a/packages/rethinkdb/CHANGELOG.md b/packages/rethinkdb/CHANGELOG.md new file mode 100644 index 00000000..d7fc5630 --- /dev/null +++ b/packages/rethinkdb/CHANGELOG.md @@ -0,0 +1,35 @@ +# Change Log + +## 8.0.0 + +* Require Dart >= 3.3 +* Updated `lints` to 4.0.0 + +## 7.0.0 + +* Skipped release + +## 6.0.0 + +* Skipped release + +## 5.0.0 + +* Skipped release + +## 4.0.0 + +* Skipped release + +## 3.0.0 + +* Skipped release + +## 2.0.0 + +* Migrated to support Dart >= 2.12 NNBD + +## 1.1.0 + +* Moved to `package:rethinkdb_driver` +* Fixed references to old hooked event names diff --git a/packages/rethinkdb/LICENSE b/packages/rethinkdb/LICENSE new file mode 100644 index 00000000..df5e0635 --- /dev/null +++ b/packages/rethinkdb/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2021, dukefirehawk.com +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/rethinkdb/README.md b/packages/rethinkdb/README.md new file mode 100644 index 00000000..702444da --- /dev/null +++ b/packages/rethinkdb/README.md @@ -0,0 +1,94 @@ +# Angel3 RethinkDB + +[![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: + angel3_rethink: ^8.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/rethinkdb/analysis_options.yaml b/packages/rethinkdb/analysis_options.yaml new file mode 100644 index 00000000..ea2c9e94 --- /dev/null +++ b/packages/rethinkdb/analysis_options.yaml @@ -0,0 +1 @@ +include: package:lints/recommended.yaml \ No newline at end of file diff --git a/packages/rethinkdb/example/example.dart b/packages/rethinkdb/example/example.dart new file mode 100644 index 00000000..50cee9e0 --- /dev/null +++ b/packages/rethinkdb/example/example.dart @@ -0,0 +1,18 @@ +import 'package:angel3_framework/angel3_framework.dart'; +import 'package:angel3_rethinkdb/angel3_rethinkdb.dart'; +import 'package:belatuk_rethinkdb/belatuk_rethinkdb.dart'; +import 'package:logging/logging.dart'; + +void main() async { + RethinkDb r = RethinkDb(); + var conn = await r.connect(); + + Angel app = Angel(); + app.use('/todos', RethinkService(conn, r.table('todos'))); + + app.errorHandler = (e, req, res) async { + print('Whoops: $e'); + }; + + app.logger = Logger.detached('angel')..onRecord.listen(print); +} diff --git a/packages/rethinkdb/lib/angel3_rethinkdb.dart b/packages/rethinkdb/lib/angel3_rethinkdb.dart new file mode 100644 index 00000000..e0f27f42 --- /dev/null +++ b/packages/rethinkdb/lib/angel3_rethinkdb.dart @@ -0,0 +1 @@ +export 'src/rethink_service.dart'; \ No newline at end of file diff --git a/packages/rethinkdb/lib/src/rethink_service.dart b/packages/rethinkdb/lib/src/rethink_service.dart new file mode 100644 index 00000000..aa5059da --- /dev/null +++ b/packages/rethinkdb/lib/src/rethink_service.dart @@ -0,0 +1,283 @@ +import 'dart:async'; +//import 'dart:io'; +import 'package:angel3_framework/angel3_framework.dart'; +import 'package:belatuk_json_serializer/belatuk_json_serializer.dart' as god; +import 'package:belatuk_rethinkdb/belatuk_rethinkdb.dart'; + +// Extends a RethinkDB query. +typedef QueryCallback = RqlQuery Function(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) { + 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) as RqlQuery; + } + + return q; + } + + RqlQuery _getQueryInner(RqlQuery query, Map params) { + if (!params.containsKey('query')) { + return query; + } else { + if (params['query'] is RqlQuery) { + return params['query'] as RqlQuery; + } else if (params['query'] is QueryCallback) { + return params['query'](table) as RqlQuery; + } else if (params['query'] is! Map || allowQuery != true) { + return query; + } else { + var q = params['query'] as Map; + 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)); + return await result['generated_keys'].map(read); + } else { + return result; + } + } + + dynamic _serialize(data) { + if (data is Map) { + return data; + } else if (data is Iterable) { + return data.map(_serialize).toList(); + } else { + return god.serializeObject(data); + } + } + + dynamic _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; + } + } + + @override + void onHooked(HookedService hookedService) { + if (listenForChanges == true) { + listenToQuery(table, hookedService); + } + } + + Future listenToQuery(RqlQuery query, HookedService hookedService) async { + var feed = + await query.changes({'include_types': true}).run(connection) as Feed; + + Future onData(dynamic event) { + if (event != null && event is Map) { + var type = event['type']?.toString(); + var newVal = event['new_val']; + var oldVal = event['old_val']; + + if (type == 'add') { + // Create + hookedService.fireEvent( + hookedService.afterCreated, + HookedServiceEvent( + true, null, null, this, HookedServiceEvent.created, + result: newVal)); + } else if (type == 'change') { + // Update + hookedService.fireEvent( + hookedService.afterCreated, + HookedServiceEvent( + true, null, null, this, HookedServiceEvent.updated, + result: newVal, id: oldVal['id'], data: newVal)); + } else if (type == 'remove') { + // Remove + hookedService.fireEvent( + hookedService.afterCreated, + HookedServiceEvent( + true, null, null, this, HookedServiceEvent.removed, + result: oldVal, id: oldVal['id'])); + } + } + return Future.value(); + } + + feed.listen(onData); +/* + feed.listen((Map event) { + var type = event['type']?.toString(); + var newVal = event['new_val'], oldVal = event['old_val']; + + if (type == 'add') { + // Create + hookedService.fireEvent( + hookedService.afterCreated, + HookedServiceEvent( + true, null, null, this, HookedServiceEvent.created, + result: newVal)); + } else if (type == 'change') { + // Update + hookedService.fireEvent( + hookedService.afterCreated, + HookedServiceEvent( + true, null, null, this, HookedServiceEvent.updated, + result: newVal, id: oldVal['id'], data: newVal)); + } else if (type == 'remove') { + // Remove + hookedService.fireEvent( + hookedService.afterCreated, + HookedServiceEvent( + true, null, null, this, HookedServiceEvent.removed, + result: oldVal, id: oldVal['id'])); + } + }); + */ + } + + // TODO: Invalid override method +/* + @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 AngelHttpException.notFound(message: 'No record found for ID $id'); + } else { + return found; + } + } + + @override + Future create(data, [Map? params]) async { + if (table is! Table) throw 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/rethinkdb/pubspec.yaml b/packages/rethinkdb/pubspec.yaml new file mode 100644 index 00000000..74fd8acd --- /dev/null +++ b/packages/rethinkdb/pubspec.yaml @@ -0,0 +1,24 @@ +name: angel3_rethinkdb +version: 8.0.0 +description: RethinkDB-enabled services for the Angel3 framework. +publish_to: none +environment: + sdk: ">=3.3.0 <4.0.0" +homepage: https://angel3-framework.web.app/ +repository: https://github.com/dart-backend/angel/tree/master/packages/rethinkdb +dependencies: + angel3_framework: ^8.4.0 + belatuk_json_serializer: ^7.0.0 + belatuk_rethinkdb: ^1.0.0 + +dev_dependencies: + angel3_client: ^8.0.0 + angel3_test: ^8.0.0 + logging: ^1.2.0 + test: ^1.25.0 + lints: ^4.0.0 + +dependency_overrides: + belatuk_rethinkdb: + path: ../../../rethink_db + \ No newline at end of file diff --git a/packages/rethinkdb/test/README.md b/packages/rethinkdb/test/README.md new file mode 100644 index 00000000..59324524 --- /dev/null +++ b/packages/rethinkdb/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`. diff --git a/packages/rethinkdb/test/bootstrap.dart b/packages/rethinkdb/test/bootstrap.dart new file mode 100644 index 00000000..39c2fbaf --- /dev/null +++ b/packages/rethinkdb/test/bootstrap.dart @@ -0,0 +1,11 @@ +import 'dart:io'; +import 'package:belatuk_rethinkdb/belatuk_rethinkdb.dart'; + +void main() async { + var r = RethinkDb(); + await r.connect().then((conn) { + r.tableCreate('todos').run(conn); + print('Done'); + exit(0); + }); +} diff --git a/packages/rethinkdb/test/common.dart b/packages/rethinkdb/test/common.dart new file mode 100644 index 00000000..a7eacea9 --- /dev/null +++ b/packages/rethinkdb/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/rethinkdb/test/generic_test.dart b/packages/rethinkdb/test/generic_test.dart new file mode 100644 index 00000000..6eb0063f --- /dev/null +++ b/packages/rethinkdb/test/generic_test.dart @@ -0,0 +1,85 @@ +import 'package:angel3_client/angel3_client.dart' as c; +import 'package:angel3_framework/angel3_framework.dart'; +import 'package:angel3_rethinkdb/angel3_rethinkdb.dart'; +import 'package:angel3_test/angel3_test.dart'; +import 'package:logging/logging.dart'; +import 'package:belatuk_rethinkdb/belatuk_rethinkdb.dart'; +import 'package:test/test.dart'; +import 'common.dart'; + +void main() { + Angel app; + late TestClient client; + RethinkDb r; + late c.Service todoService; + + setUp(() async { + r = RethinkDb(); + var conn = await r.connect(); + + app = Angel(); + app.use('/todos', RethinkService(conn, r.table('todos'))); + + app.errorHandler = (e, req, res) async { + print('Whoops: $e'); + }; + + app.logger = 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 = 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 = 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 = 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)); + }); +}