diff --git a/.gitignore b/.gitignore index 7c280441..427e911b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ .packages .project .pub/ +.scripts-bin/ build/ **/packages/ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..de2210c9 --- /dev/null +++ b/.travis.yml @@ -0,0 +1 @@ +language: dart \ No newline at end of file diff --git a/README.md b/README.md index e754f7b1..ba42e1cd 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,23 @@ # relations +[![version 1.0.0-alpha](https://img.shields.io/badge/pub-v1.0.0--alpha-red.svg)](https://pub.dartlang.org/packages/angel_relations) +[![build status](https://travis-ci.org/angel-dart/relations.svg)](https://travis-ci.org/angel-dart/relations) + Database-agnostic relations between Angel services. + +```dart +// Authors owning one book +app.service('authors').afterAll( + relations.hasOne('books', as: 'book', foreignKey: 'authorId')); + +// Or multiple +app.service('authors').afterAll( + relations.hasMany('books', foreignKey: 'authorId')); + +// Or, books belonging to authors +app.service('books').afterAll(relations.belongsTo('authors')); +``` + +Currently supports: +* `hasOne` +* `hasMany` +* `belongsTo` \ No newline at end of file diff --git a/lib/angel_relations.dart b/lib/angel_relations.dart new file mode 100644 index 00000000..f359f819 --- /dev/null +++ b/lib/angel_relations.dart @@ -0,0 +1,7 @@ +/// Hooks to populate data returned from services, in a fashion +/// reminiscent of a relational database. +library angel_relations; + +export 'src/belongs_to.dart'; +export 'src/has_many.dart'; +export 'src/has_one.dart'; \ No newline at end of file diff --git a/lib/src/belongs_to.dart b/lib/src/belongs_to.dart new file mode 100644 index 00000000..acf7d110 --- /dev/null +++ b/lib/src/belongs_to.dart @@ -0,0 +1,79 @@ +import 'dart:async'; +import 'dart:mirrors'; +import 'package:angel_framework/angel_framework.dart'; +import 'plural.dart' as pluralize; +import 'no_service.dart'; + +/// Represents a relationship in which the current [service] "belongs to" +/// a single member of the service at [servicePath]. Use [as] to set the name +/// on the target object. +/// +/// Defaults: +/// * [foreignKey]: `userId` +/// * [localKey]: `id` +HookedServiceEventListener belongsTo(Pattern servicePath, + {String as, + String foreignKey, + String localKey, + getForeignKey(obj), + assignForeignObject(foreign, obj)}) { + return (HookedServiceEvent e) async { + var ref = e.service.app.service(servicePath); + var foreignName = as?.isNotEmpty == true + ? as + : pluralize.singular(servicePath.toString()); + if (ref == null) throw noService(servicePath); + + String localId = localKey; + + if (localId == null) { + localId = foreignName + 'Id'; + print('No local key provided for belongsTo, defaulting to \'$localId\'.'); + } + + _getForeignKey(obj) { + if (getForeignKey != null) + return getForeignKey(obj); + else if (obj is Map) + return obj[localId]; + else if (obj is Extensible) + return obj.properties[localId]; + else if (localId == null || localId == 'userId') + return obj.userId; + else + return reflect(obj).getField(new Symbol(localId)).reflectee; + } + + _assignForeignObject(foreign, obj) { + if (assignForeignObject != null) + return assignForeignObject(foreign, obj); + else if (obj is Map) + obj[foreignName] = foreign; + else if (obj is Extensible) + obj.properties[foreignName] = foreign; + else + reflect(obj).setField(new Symbol(foreignName), foreign); + } + + _normalize(obj) async { + if (obj != null) { + var id = await _getForeignKey(obj); + var indexed = await ref.index({ + 'query': {foreignKey ?? 'id': id} + }); + + if (indexed?.isNotEmpty != true) { + await _assignForeignObject(null, obj); + } else { + var child = indexed.first; + await _assignForeignObject(child, obj); + } + } + } + + if (e.result is Iterable) { + await Future.wait(e.result.map(_normalize)); + } else + await _normalize(e.result); + }; +} diff --git a/lib/src/has_many.dart b/lib/src/has_many.dart new file mode 100644 index 00000000..1ca1f58c --- /dev/null +++ b/lib/src/has_many.dart @@ -0,0 +1,74 @@ +import 'dart:async'; +import 'dart:mirrors'; +import 'package:angel_framework/angel_framework.dart'; +import 'plural.dart' as pluralize; +import 'no_service.dart'; + +/// Represents a relationship in which the current [service] "owns" +/// members of the service at [servicePath]. Use [as] to set the name +/// on the target object. +/// +/// Defaults: +/// * [foreignKey]: `userId` +/// * [localKey]: `id` +HookedServiceEventListener hasMany(Pattern servicePath, + {String as, + String foreignKey, + String localKey, + getLocalKey(obj), + assignForeignObjects(foreign, obj)}) { + return (HookedServiceEvent e) async { + var ref = e.service.app.service(servicePath); + var foreignName = + as?.isNotEmpty == true ? as : pluralize.plural(servicePath.toString()); + if (ref == null) throw noService(servicePath); + + if (foreignKey == null) + print( + 'WARNING: No foreign key provided for hasMany, defaulting to \'userId\'.'); + + _getLocalKey(obj) { + if (getLocalKey != null) + return getLocalKey(obj); + else if (obj is Map) + return obj[localKey ?? 'id']; + else if (obj is Extensible) + return obj.properties[localKey ?? 'id']; + else if (localKey == null || localKey == 'id') + return obj.id; + else + return reflect(obj).getField(new Symbol(localKey ?? 'id')).reflectee; + } + + _assignForeignObjects(foreign, obj) { + if (assignForeignObjects != null) + return assignForeignObjects(foreign, obj); + else if (obj is Map) + obj[foreignName] = foreign; + else if (obj is Extensible) + obj.properties[foreignName] = foreign; + else + reflect(obj).setField(new Symbol(foreignName), foreign); + } + + _normalize(obj) async { + if (obj != null) { + var id = await _getLocalKey(obj); + var indexed = await ref.index({ + 'query': {foreignKey ?? 'userId': id} + }); + + if (indexed?.isNotEmpty != true) { + await _assignForeignObjects([], obj); + } else { + await _assignForeignObjects(indexed, obj); + } + } + } + + if (e.result is Iterable) { + await Future.wait(e.result.map(_normalize)); + } else + await _normalize(e.result); + }; +} diff --git a/lib/src/has_one.dart b/lib/src/has_one.dart new file mode 100644 index 00000000..d8e3112b --- /dev/null +++ b/lib/src/has_one.dart @@ -0,0 +1,76 @@ +import 'dart:async'; +import 'dart:mirrors'; +import 'package:angel_framework/angel_framework.dart'; +import 'plural.dart' as pluralize; +import 'no_service.dart'; + +/// Represents a relationship in which the current [service] "owns" +/// a single member of the service at [servicePath]. Use [as] to set the name +/// on the target object. +/// +/// Defaults: +/// * [foreignKey]: `userId` +/// * [localKey]: `id` +HookedServiceEventListener hasOne(Pattern servicePath, + {String as, + String foreignKey, + String localKey, + getLocalKey(obj), + assignForeignObject(foreign, obj)}) { + return (HookedServiceEvent e) async { + var ref = e.service.app.service(servicePath); + var foreignName = as?.isNotEmpty == true + ? as + : pluralize.singular(servicePath.toString()); + if (ref == null) throw noService(servicePath); + + if (foreignKey == null) + print( + 'WARNING: No foreign key provided for hasOne, defaulting to \'userId\'.'); + + _getLocalKey(obj) { + if (getLocalKey != null) + return getLocalKey(obj); + else if (obj is Map) + return obj[localKey ?? 'id']; + else if (obj is Extensible) + return obj.properties[localKey ?? 'id']; + else if (localKey == null || localKey == 'id') + return obj.id; + else + return reflect(obj).getField(new Symbol(localKey ?? 'id')).reflectee; + } + + _assignForeignObject(foreign, obj) { + if (assignForeignObject != null) + return assignForeignObject(foreign, obj); + else if (obj is Map) + obj[foreignName] = foreign; + else if (obj is Extensible) + obj.properties[foreignName] = foreign; + else + reflect(obj).setField(new Symbol(foreignName), foreign); + } + + _normalize(obj) async { + if (obj != null) { + var id = await _getLocalKey(obj); + var indexed = await ref.index({ + 'query': {foreignKey ?? 'userId': id} + }); + + if (indexed?.isNotEmpty != true) { + await _assignForeignObject(null, obj); + } else { + var child = indexed.first; + await _assignForeignObject(child, obj); + } + } + } + + if (e.result is Iterable) { + await Future.wait(e.result.map(_normalize)); + } else + await _normalize(e.result); + }; +} diff --git a/lib/src/no_service.dart b/lib/src/no_service.dart new file mode 100644 index 00000000..e9d78658 --- /dev/null +++ b/lib/src/no_service.dart @@ -0,0 +1,2 @@ +ArgumentError noService(Pattern path) => + new ArgumentError("No service exists at path '$path'."); \ No newline at end of file diff --git a/lib/src/plural.dart b/lib/src/plural.dart new file mode 100644 index 00000000..972d2fdd --- /dev/null +++ b/lib/src/plural.dart @@ -0,0 +1,21 @@ +String singular(String path) { + var str = path.trim().split('/').where((str) => str.isNotEmpty).last; + + if (str.endsWith('ies')) + return str.substring(0, str.length - 3) + 'y'; + else if (str.endsWith('s')) + return str.substring(0, str.length - 1); + else + return str; +} + +String plural(String path) { + var str = path.trim().split('/').where((str) => str.isNotEmpty).last; + + if (str.endsWith('y')) + return str.substring(0, str.length - 1) + 'ies'; + else if (str.endsWith('s')) + return str; + else + return str + 's'; +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 00000000..c1792cbf --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,12 @@ +author: "Tobe O " +description: "Database-agnostic relations between Angel services." +homepage: "https://github.com/angel-dart/relations.git" +name: "angel_relations" +version: 1.0.0-alpha +environment: + sdk: ">=1.19.0" +dependencies: + angel_framework: "^1.0.0-dev" +dev_dependencies: + angel_seeder: ^0.0.0 + test: ^0.12.0 diff --git a/test/belongs_to_test.dart b/test/belongs_to_test.dart new file mode 100644 index 00000000..d398e923 --- /dev/null +++ b/test/belongs_to_test.dart @@ -0,0 +1,56 @@ +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_relations/angel_relations.dart' as relations; +import 'package:angel_seeder/angel_seeder.dart'; +import 'package:test/test.dart'; +import 'common.dart'; + +main() { + Angel app; + + setUp(() async { + app = new Angel() + ..use('/authors', new MapService()) + ..use('/books', new MapService()); + + await app.configure(seed( + 'authors', + new SeederConfiguration( + count: 10, + template: {'name': (Faker faker) => faker.person.name()}, + callback: (Map author, seed) { + return seed( + 'books', + new SeederConfiguration(delete: false, count: 10, template: { + 'authorId': author['id'], + 'title': (Faker faker) => + 'I love to eat ${faker.food.dish()}' + })); + }))); + + app.service('books').afterAll(relations.belongsTo('authors')); + }); + + test('index', () async { + var books = await app.service('books').index(); + print(books); + + expect(books, allOf(isList, isNotEmpty)); + + for (Map book in books) { + expect(book.keys, contains('author')); + + Map author = book['author']; + expect(author['id'], equals(book['authorId'])); + } + }); + + test('create', () async { + var warAndPeace = await app + .service('books') + .create(new Book(title: 'War and Peace').toJson()); + + print(warAndPeace); + expect(warAndPeace.keys, contains('author')); + expect(warAndPeace['author'], isNull); + }); +} diff --git a/test/common.dart b/test/common.dart new file mode 100644 index 00000000..65b48d28 --- /dev/null +++ b/test/common.dart @@ -0,0 +1,67 @@ +import 'package:angel_framework/angel_framework.dart'; +import 'package:json_god/json_god.dart' as god; + +class MapService extends Service { + final List _items = []; + + Iterable tailor(Iterable items, Map params) { + if (params == null) return items; + + var r = items; + + if (params != null && params['query'] is Map) { + Map query = params['query']; + + for (var key in query.keys) { + r = r.where((m) => m[key] == query[key]); + } + } + + return r; + } + + @override + index([params]) async => tailor(_items, params).toList(); + + @override + read(id, [Map params]) async { + return tailor(_items, params).firstWhere((m) => m['id'] == id, + orElse: () => throw new AngelHttpException.notFound()); + } + + @override + create(data, [params]) async { + Map d = data is Map ? data : god.serializeObject(data); + d['id'] = _items.length.toString(); + _items.add(d); + return d; + } + + @override + remove(id, [params]) async { + if (id == null) _items.clear(); + } +} + +class Author { + String id, name; + + Author({this.id, this.name}); + + Map toJson() => {'id': id, 'name': name}; +} + +class Book { + String authorId, title; + + Book({this.authorId, this.title}); + + Map toJson() => {'authorId': authorId, 'title': title}; +} + +class Chapter { + String bookId, title; + int pageCount; + + Chapter({this.bookId, this.title, this.pageCount}); +} diff --git a/test/has_many_test.dart b/test/has_many_test.dart new file mode 100644 index 00000000..e27b19e4 --- /dev/null +++ b/test/has_many_test.dart @@ -0,0 +1,61 @@ +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_relations/angel_relations.dart' as relations; +import 'package:angel_seeder/angel_seeder.dart'; +import 'package:test/test.dart'; +import 'common.dart'; + +main() { + Angel app; + + setUp(() async { + app = new Angel() + ..use('/authors', new MapService()) + ..use('/books', new MapService()); + + await app.configure(seed( + 'authors', + new SeederConfiguration( + count: 10, + template: {'name': (Faker faker) => faker.person.name()}, + callback: (Map author, seed) { + return seed( + 'books', + new SeederConfiguration(delete: false, count: 10, template: { + 'authorId': author['id'], + 'title': (Faker faker) => + 'I love to eat ${faker.food.dish()}' + })); + }))); + + app + .service('authors') + .afterAll(relations.hasMany('books', foreignKey: 'authorId')); + }); + + test('index', () async { + var authors = await app.service('authors').index(); + print(authors); + + expect(authors, allOf(isList, isNotEmpty)); + + for (Map author in authors) { + expect(author.keys, contains('books')); + + List books = author['books']; + + for (var book in books) { + expect(book['authorId'], equals(author['id'])); + } + } + }); + + test('create', () async { + var tolstoy = await app + .service('authors') + .create(new Author(name: 'Leo Tolstoy').toJson()); + + print(tolstoy); + expect(tolstoy.keys, contains('books')); + expect(tolstoy['books'], allOf(isList, isEmpty)); + }); +} diff --git a/test/has_one_test.dart b/test/has_one_test.dart new file mode 100644 index 00000000..a5aed15c --- /dev/null +++ b/test/has_one_test.dart @@ -0,0 +1,57 @@ +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_relations/angel_relations.dart' as relations; +import 'package:angel_seeder/angel_seeder.dart'; +import 'package:test/test.dart'; +import 'common.dart'; + +main() { + Angel app; + + setUp(() async { + app = new Angel() + ..use('/authors', new MapService()) + ..use('/books', new MapService()); + + await app.configure(seed( + 'authors', + new SeederConfiguration( + count: 10, + template: {'name': (Faker faker) => faker.person.name()}, + callback: (Map author, seed) { + return seed( + 'books', + new SeederConfiguration(delete: false, count: 10, template: { + 'authorId': author['id'], + 'title': (Faker faker) => + 'I love to eat ${faker.food.dish()}' + })); + }))); + + app.service('authors').afterAll( + relations.hasOne('books', as: 'book', foreignKey: 'authorId')); + }); + + test('index', () async { + var authors = await app.service('authors').index(); + print(authors); + + expect(authors, allOf(isList, isNotEmpty)); + + for (Map author in authors) { + expect(author.keys, contains('book')); + + Map book = author['book']; + expect(book['authorId'], equals(author['id'])); + } + }); + + test('create', () async { + var tolstoy = await app + .service('authors') + .create(new Author(name: 'Leo Tolstoy').toJson()); + + print(tolstoy); + expect(tolstoy.keys, contains('book')); + expect(tolstoy['book'], isNull); + }); +}