alpha
This commit is contained in:
parent
d8304b9104
commit
160c620f57
14 changed files with 535 additions and 0 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -5,6 +5,7 @@
|
|||
.packages
|
||||
.project
|
||||
.pub/
|
||||
.scripts-bin/
|
||||
build/
|
||||
**/packages/
|
||||
|
||||
|
|
1
.travis.yml
Normal file
1
.travis.yml
Normal file
|
@ -0,0 +1 @@
|
|||
language: dart
|
21
README.md
21
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`
|
7
lib/angel_relations.dart
Normal file
7
lib/angel_relations.dart
Normal file
|
@ -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';
|
79
lib/src/belongs_to.dart
Normal file
79
lib/src/belongs_to.dart
Normal file
|
@ -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);
|
||||
};
|
||||
}
|
74
lib/src/has_many.dart
Normal file
74
lib/src/has_many.dart
Normal file
|
@ -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);
|
||||
};
|
||||
}
|
76
lib/src/has_one.dart
Normal file
76
lib/src/has_one.dart
Normal file
|
@ -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);
|
||||
};
|
||||
}
|
2
lib/src/no_service.dart
Normal file
2
lib/src/no_service.dart
Normal file
|
@ -0,0 +1,2 @@
|
|||
ArgumentError noService(Pattern path) =>
|
||||
new ArgumentError("No service exists at path '$path'.");
|
21
lib/src/plural.dart
Normal file
21
lib/src/plural.dart
Normal file
|
@ -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';
|
||||
}
|
12
pubspec.yaml
Normal file
12
pubspec.yaml
Normal file
|
@ -0,0 +1,12 @@
|
|||
author: "Tobe O <thosakwe@gmail.com>"
|
||||
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
|
56
test/belongs_to_test.dart
Normal file
56
test/belongs_to_test.dart
Normal file
|
@ -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<Map>(
|
||||
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);
|
||||
});
|
||||
}
|
67
test/common.dart
Normal file
67
test/common.dart
Normal file
|
@ -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<Map> _items = [];
|
||||
|
||||
Iterable<Map> tailor(Iterable<Map> 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});
|
||||
}
|
61
test/has_many_test.dart
Normal file
61
test/has_many_test.dart
Normal file
|
@ -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<Map>(
|
||||
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<Map> 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));
|
||||
});
|
||||
}
|
57
test/has_one_test.dart
Normal file
57
test/has_one_test.dart
Normal file
|
@ -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<Map>(
|
||||
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);
|
||||
});
|
||||
}
|
Loading…
Reference in a new issue