Merge pull request #5 from cult-of-coders/feature/hypernova

[RFC] Feature/hypernova
This commit is contained in:
Theodor Diaconu 2016-09-23 13:52:04 +03:00 committed by GitHub
commit 0d5fbcaa1c
53 changed files with 1143 additions and 368 deletions

1
.npm/package/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
node_modules

7
.npm/package/README Normal file
View file

@ -0,0 +1,7 @@
This directory and the files immediately inside it are automatically generated
when you change this package's NPM dependencies. Commit the files in this
directory (npm-shrinkwrap.json, .gitignore, and this README) to source control
so that others run the same versions of sub-dependencies.
You should NOT check in the node_modules directory that Meteor automatically
creates; if you are using git, the .gitignore file tells git to ignore it.

9
.npm/package/npm-shrinkwrap.json generated Normal file
View file

@ -0,0 +1,9 @@
{
"dependencies": {
"sift": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/sift/-/sift-3.2.6.tgz",
"from": "sift@3.2.6"
}
}
}

View file

@ -4,12 +4,19 @@ Welcome to Grapher
[![Build Status](https://api.travis-ci.org/cult-of-coders/grapher.svg)](https://travis-ci.org/cult-of-coders/grapher)
[![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/hyperium/hyper/master/LICENSE)
General
-------
*Grapher* is a Meteor package that will:
What ?
------
*Grapher* is a high performance data grapher will:
1. Allow you to work with Joins (also known as: Relationships or Links) between Collections;
2. Expose reactive/non-reactive fetching abilities using a GraphQL like syntax.
1. Makes data MongoDB denormalization easy (storing and linking data in different collections)
2. You can link your MongoDB data with any type of database, and fetch it via Queries
3. You have the same API for data-fetching whether you want your data to be reactive or not.
How does this compare to [ApolloStack](http://www.apollostack.com/) ?
- You can plug it in your Meteor app directly. It will just work.
- It is built for performance and high data load.
- Apollo tries to satisfy everybody, we are limited to Meteor only.
Updates
-------

View file

@ -109,7 +109,6 @@ OR from the collection directly:
```
const query = Posts.createQuery({
// $all: 1, // use this only when you want all fields without specifying them (NOT RECOMMENDED)
$filter({filters, options, params}) {
filters.isApproved = true;
options.limit = params.limit;

View file

@ -99,7 +99,6 @@ Notes:
- Use {} to specify a link, and 1 for a field.
- "_id" will always be fetched
- You must always specify the fields you need, otherwise it will only fetch _id
- If you want all fields, pass in {$all: 1}
```
const query = Posts.createQuery({
@ -116,7 +115,6 @@ const query = Posts.createQuery({
comments: {
text: 1,
// if you don't specify any local fields for the author, only "_id" field will be fetched
// use $all: 1, to get all fields
// this will enforce the use of query and retrieve only the data you need.
author: {
groups: {
@ -283,23 +281,6 @@ createQuery({
*posts* is the name of the collection. (when you create new Mongo.Collection("xxx"), "xxx" is the name of your collection)
Getting all the fields
======================
Though this is not recommended, sometimes especially when you are just testing around, you want to see all the fields
```
createQuery({
posts: {
$all: 1,
comments: {
$all: 1
}
}
});
```
The query above will fetch all the fields from every posts and every comment of every post.
#### React Integration
For integration with React try out [cultofcoders:grapher-react](https://github.com/cult-of-coders/grapher-react) package

View file

@ -42,11 +42,17 @@ export default class Exposure {
const collection = this.collection;
const firewall = this.firewall;
collection.__isExposedForGrapher = true;
if (firewall) {
collection.findSecure = (filters, options, userId) => {
collection.firewall = (filters, options, userId) => {
if (userId !== undefined) {
firewall(filters, options, userId);
}
};
collection.findSecure = (filters, options, userId) => {
collection.firewall(filters, options, userId);
return collection.find(filters, options);
}

View file

@ -35,6 +35,11 @@ export default new SimpleSchema({
type: Boolean,
defaultValue: false,
optional: true
},
unique: {
type: Boolean,
defaultValue: false,
optional: true
}
});

View file

@ -12,14 +12,14 @@ export default (isMany, metadata) => {
const schema = constructMetadataSchema(metadata);
fieldSchema = (isMany) ? {type: [schema]} : {type: schema};
} else {
fieldSchema = (isMany)
? {type: null, blackbox: true}
: {type: Object, blackbox: true};
if (isMany) {
fieldSchema = {type: [Object], blackbox: true};
} else {
fieldSchema = {type: Object, blackbox: true}
}
}
} else {
fieldSchema = (isMany)
? {type: [String]}
: {type: String};
fieldSchema = (isMany) ? {type: [String]} : {type: String};
}
fieldSchema.optional = true;

View file

@ -0,0 +1,75 @@
export default function createSearchFilters(object, fieldStorage, strategy, isVirtual) {
if (!isVirtual) {
switch (strategy) {
case 'one': return createOne(object, fieldStorage);
case 'one-meta': return createOneMeta(object, fieldStorage);
case 'many': return createMany(object, fieldStorage);
case 'many-meta': return createManyMeta(object, fieldStorage);
default:
throw new Meteor.Error(`Invalid linking strategy: ${strategy}`)
}
} else {
switch (strategy) {
case 'one': return createOneVirtual(object, fieldStorage);
case 'one-meta': return createOneMetaVirtual(object, fieldStorage);
case 'many': return createManyVirtual(object, fieldStorage);
case 'many-meta': return createManyMetaVirtual(object, fieldStorage);
default:
throw new Meteor.Error(`Invalid linking strategy: ${strategy}`)
}
}
}
export function createOne(object, fieldStorage) {
return {
_id: object[fieldStorage]
};
}
export function createOneVirtual(object, fieldStorage) {
return {
[fieldStorage]: object._id
};
}
export function createOneMeta(object, fieldStorage) {
const value = object[fieldStorage];
return {
_id: value ? value._id : value
};
}
export function createOneMetaVirtual(object, fieldStorage) {
return {
[fieldStorage + '._id']: object._id
};
}
export function createMany(object, fieldStorage) {
return {
_id: {
$in: object[fieldStorage] || []
}
};
}
export function createManyVirtual(object, fieldStorage) {
return {
[fieldStorage]: object._id
};
}
export function createManyMeta(object, fieldStorage) {
const value = object[fieldStorage];
return {
_id: {$in: _.pluck(value, '_id') || []}
};
}
export function createManyMetaVirtual(object, fieldStorage) {
return {
[fieldStorage + '._id']: object._id
};
}

View file

@ -5,9 +5,10 @@ export default class Link {
get isVirtual() { return this.linker.isVirtual() }
constructor(linker, object) {
constructor(linker, object, collection) {
this.linker = linker;
this.object = object;
this.linkedCollection = (collection) ? collection : linker.getLinkedCollection();
}
/**
@ -46,7 +47,7 @@ export default class Link {
find(filters = {}, options = {}, userId = null) {
let linker = this.linker;
const linkedCollection = linker.getLinkedCollection();
const linkedCollection = this.linkedCollection;
if (!linker.isVirtual()) {
this.clean();
@ -100,7 +101,7 @@ export default class Link {
identifyId(what, saveToDatabase) {
return SmartArgs.getId(what, {
saveToDatabase,
collection: this.linker.getLinkedCollection()
collection: this.linkedCollection
});
}
@ -110,7 +111,7 @@ export default class Link {
identifyIds(what, saveToDatabase) {
return SmartArgs.getIds(what, {
saveToDatabase,
collection: this.linker.getLinkedCollection()
collection: this.linkedCollection
});
}

View file

@ -66,6 +66,10 @@ export default class Linker {
*/
get linkStorageField()
{
if (this.isVirtual()) {
return this.linkConfig.relatedLinker.linkStorageField;
}
return this.linkConfig.field;
}
@ -91,11 +95,27 @@ export default class Linker {
return !this.isSingle();
}
/**
* If the relationship for this link contains metadata
*/
isMeta()
{
if (this.isVirtual()) {
return this.linkConfig.relatedLinker.isMeta();
}
return !!this.linkConfig.metadata;
}
/**
* @returns {boolean}
*/
isSingle()
{
if (this.isVirtual()) {
return this.linkConfig.relatedLinker.isSingle();
}
return _.contains(this.oneTypes, this.linkConfig.type);
}
@ -119,11 +139,11 @@ export default class Linker {
* @param object
* @returns {*}
*/
createLink(object)
createLink(object, collection)
{
let helperClass = this._getHelperClass();
return new helperClass(this, object);
return new helperClass(this, object, collection);
}
/**
@ -136,6 +156,16 @@ export default class Linker {
if (!this.linkConfig.collection) {
throw new Meteor.Error('invalid-config', `For the link ${this.linkName} you did not provide a collection. Collection is mandatory for non-resolver links.`)
}
if (typeof(this.linkConfig.collection) === 'string') {
const collectionName = this.linkConfig.collection;
this.linkConfig.collection = Mongo.Collection.get(collectionName);
if (!this.linkConfig.collection) {
throw new MeteorError('invalid-collection', `Could not find a collection with the name: ${collectionName}`);
}
}
if (this.isVirtual()) {
return this._prepareVirtual();
} else {
@ -256,15 +286,42 @@ export default class Linker {
}
_initIndex() {
if (Meteor.isServer && this.linkConfig.index) {
if (Meteor.isServer) {
let field = this.linkConfig.field;
if (this.linkConfig.metadata) {
field = field + '._id';
field = field + '._id';
}
this.mainCollection._ensureIndex({
[field]: 1
})
if (this.linkConfig.index) {
if (this.isVirtual()) {
throw new Meteor.Error('You cannot set index on an inversed link.');
}
let options;
if (this.linkConfig.unique) {
if (this.isMany()) {
throw new Meteor.Error('You cannot set unique property on a multi field.');
}
options = {unique: true}
}
this.mainCollection._ensureIndex({[field]: 1}, options);
} else {
if (this.linkConfig.unique) {
if (this.isVirtual()) {
throw new Meteor.Error('You cannot set unique property on an inversed link.');
}
if (this.isMany()) {
throw new Meteor.Error('You cannot set unique property on a multi field.');
}
this.mainCollection._ensureIndex({
[field]: 1
}, {unique: true})
}
}
}
}

View file

@ -0,0 +1,8 @@
Hypernova
=========
Is a grapher module that allows us to use aggregate from mongodb to dramatically reduce the number of fetching requests.
This module does 2 things:
- It merges the data at every node
- It assembles it to correct parents upon receiving it.

View file

@ -0,0 +1,109 @@
/**
* Its purpose is to create filters to get the related data in one request.
*/
export default class AggregateFilters {
constructor(collectionNode) {
this.collectionNode = collectionNode;
this.linker = collectionNode.linker;
this.isVirtual = this.linker.isVirtual();
this.linkStorageField = this.linker.linkStorageField;
}
get parentObjects() {
return this.collectionNode.parent.results;
}
create() {
switch (this.linker.strategy) {
case 'one':
return this.createOne();
case 'one-meta':
return this.createOneMeta();
case 'many':
return this.createMany();
case 'many-meta':
return this.createManyMeta();
default:
throw new Meteor.Error(`Invalid linker type: ${this.linker.type}`);
}
}
createOne() {
if (!this.isVirtual) {
return {
_id: {
$in: _.pluck(this.parentObjects, this.linkStorageField)
}
};
} else {
return {
[this.linkStorageField]: {
$in: _.pluck(this.parentObjects, '_id')
}
};
}
}
createOneMeta() {
if (!this.isVirtual) {
return {
_id: {
$in: _.pluck(
_.pluck(this.parentObjects, this.linkStorageField),
'_id'
)
}
};
} else {
const field = this.linkStorageField + '._id';
return {
[field]: {
$in: _.pluck(this.parentObjects, '_id')
}
};
}
}
createMany() {
if (!this.isVirtual) {
const arrayOfIds = _.pluck(this.parentObjects, this.linkStorageField);
return {
_id: {
$in: _.union(...arrayOfIds)
}
};
} else {
const arrayOfIds = _.pluck(this.parentObjects, '_id');
return {
[this.linkStorageField]: {
$in: _.union(...arrayOfIds)
}
};
}
}
createManyMeta() {
if (!this.isVirtual) {
let ids = [];
_.each(this.parentObjects, object => {
if (object[this.linkStorageField]) {
let localIds = _.pluck(object[this.linkStorageField], '_id');
ids = _.union(ids, ...localIds);
}
});
return {
_id: {$in: ids}
};
} else {
const field = this.linkStorageField + '._id';
return {
[field]: {
$in: _.pluck(this.parentObjects, '_id')
}
};
}
}
}

View file

@ -0,0 +1,48 @@
/**
* This only applies to inversed links. It will assemble the data in a correct manner.
*/
export default function (childCollectionNode, aggregateResults) {
const linker = childCollectionNode.linker;
const linkName = childCollectionNode.linkName;
let allResults = [];
if (linker.isMeta() && linker.isMany()) {
_.each(childCollectionNode.parent.results, parentResult => {
parentResult[linkName] = parentResult[linkName] || [];
const eligibleAggregateResults = _.filter(aggregateResults, aggregateResult => {
return _.contains(aggregateResult._id, parentResult._id)
});
if (eligibleAggregateResults.length) {
const datas = _.pluck(eligibleAggregateResults, 'data'); /// [ [x1, x2], [x2, x3] ]
_.each(datas, data => {
_.each(data, item => {
parentResult[linkName].push(item)
})
});
}
});
_.each(aggregateResults, aggregateResult => {
_.each(aggregateResult.data, item => allResults.push(item))
});
} else {
_.each(aggregateResults, aggregateResult => {
let parentResult = _.find(childCollectionNode.parent.results, (result) => {
return result._id == aggregateResult._id;
});
if (parentResult) {
parentResult[childCollectionNode.linkName] = aggregateResult.data;
}
_.each(aggregateResult.data, item => allResults.push(item))
});
}
childCollectionNode.results = allResults;
}

View file

@ -0,0 +1,45 @@
import createSearchFilters from '../../links/lib/createSearchFilters';
import sift from 'sift';
export default (childCollectionNode, {limit, skip}) => {
const parent = childCollectionNode.parent;
const linker = childCollectionNode.linker;
const strategy = linker.strategy;
const isVirtual = linker.isVirtual();
const isSingle = linker.isSingle();
const removeStorageField = !childCollectionNode.parentHasMyLinkStorageFieldSpecified();
const oneResult = (isVirtual && linker.linkConfig.relatedLinker.linkConfig.unique) || (!isVirtual) && isSingle;
const fieldStorage = linker.linkStorageField;
_.each(parent.results, result => {
const data = assembleData(childCollectionNode, result, {
fieldStorage, strategy, isVirtual, isSingle
});
result[childCollectionNode.linkName] = filterAssembledData(data, {limit, skip, oneResult})
});
if (removeStorageField) {
_.each(parent.results, result => delete result[fieldStorage]);
}
}
function filterAssembledData(data, {limit, skip, oneResult}) {
if (limit) {
return data.slice(skip, limit);
}
if (oneResult) {
return _.first(data);
}
return data;
}
function assembleData(childCollectionNode, result, {fieldStorage, strategy, isVirtual}) {
const filters = createSearchFilters(result, fieldStorage, strategy, isVirtual);
return sift(filters, childCollectionNode.results);
}

View file

@ -0,0 +1,55 @@
export default function (childCollectionNode, filters, options, userId) {
const linker = childCollectionNode.linker;
const linkStorageField = linker.linkStorageField;
const collection = childCollectionNode.collection;
let pipeline = [];
if (collection.firewall) {
collection.firewall(filters, options, userId);
}
pipeline.push({$match: filters});
if (options.sort) {
pipeline.push({$sort: options.sort})
}
let _id = linkStorageField;
if (linker.isMeta()) {
_id += '._id';
}
let dataPush = {};
_.each(options.fields, (value, field) => {
dataPush[field] = '$' + field
});
if (!dataPush._id) {
dataPush['_id'] = '$_id';
}
pipeline.push({
$group: {
_id: "$" + _id,
data: {
$push: dataPush
}
}
});
if (options.limit || options.skip) {
let $slice = ["$data"];
if (options.skip) $slice.push(options.skip);
if (options.limit) $slice.push(options.limit);
pipeline.push({
$project: {
_id: 1,
data: {$slice}
}
})
}
return pipeline;
}

View file

@ -0,0 +1,38 @@
import applyProps from '../lib/applyProps.js';
import LinkResolve from '../../links/linkTypes/linkResolve.js';
import storeHypernovaResults from './storeHypernovaResults.js';
import assembler from './assembler.js';
function hypernova(collectionNode, userId) {
_.each(collectionNode.collectionNodes, childCollectionNode => {
let {filters, options} = applyProps(childCollectionNode);
if (childCollectionNode.linker.isResolver()) {
_.each(collectionNode.results, result => {
const accessor = childCollectionNode.linker.createLink(result);
result[childCollectionNode.linkName] = accessor.find(filters, options);
});
} else {
storeHypernovaResults(childCollectionNode, userId);
hypernova(childCollectionNode, userId);
}
});
}
export default function hypernovaInit(collectionNode, userId) {
let {filters, options} = applyProps(collectionNode);
const collection = collectionNode.collection;
if (collection.findSecure) {
collectionNode.results = collection.findSecure(filters, options, userId).fetch();
} else {
collectionNode.results = collection.find(filters, options).fetch();
}
hypernova(collectionNode, userId);
return collectionNode.results;
}

View file

@ -0,0 +1,40 @@
import applyProps from '../lib/applyProps.js';
import AggregateFilters from './aggregateSearchFilters.js';
import assemble from './assembler.js';
import assembleAggregateResults from './assembleAggregateResults.js';
import buildAggregatePipeline from './buildAggregatePipeline.js';
export default function storeHypernovaResults(childCollectionNode, userId) {
if (childCollectionNode.parent.results.length === 0) {
return childCollectionNode.results = [];
}
let {filters, options} = applyProps(childCollectionNode);
const aggregateFilters = new AggregateFilters(childCollectionNode);
const linker = childCollectionNode.linker;
const isVirtual = linker.isVirtual();
const collection = childCollectionNode.collection;
_.extend(filters, aggregateFilters.create());
// if it's not virtual then we retrieve them and assemble them here.
if (!isVirtual) {
const filteredOptions = _.omit(options, 'limit');
if (collection.findSecure) {
childCollectionNode.results = collection.findSecure(filters, filteredOptions, userId).fetch();
} else {
childCollectionNode.results = collection.find(filters, filteredOptions, userId).fetch();
}
assemble(childCollectionNode, options);
} else {
// virtuals arrive here
let pipeline = buildAggregatePipeline(childCollectionNode, filters, options, userId);
const aggregateResults = collection.aggregate(pipeline, {explains: true});
assembleAggregateResults(childCollectionNode, aggregateResults);
}
}

View file

@ -26,7 +26,7 @@ function createNodes(root) {
if (linker) {
let subroot = new CollectionNode(linker.getLinkedCollection(), body, fieldName);
root.add(subroot);
root.add(subroot, linker);
return createNodes(subroot);
}

View file

@ -14,7 +14,7 @@ export default function compose(node, userId) {
if (linker.isVirtual()) {
options.fields = options.fields || {};
_.extend(options.fields, {
[linker.linkConfig.relatedLinker.linkStorageField]: 1
[linker.linkStorageField]: 1
});
}

View file

@ -1,5 +1,7 @@
import applyProps from './applyProps.js';
import createSearchFilters from '../../links/lib/createSearchFilters.js';
import LinkResolve from '../../links/linkTypes/linkResolve.js';
import sift from 'sift';
export default function fetch(node, parentObject, userId) {
let {filters, options} = applyProps(node);
@ -7,17 +9,24 @@ export default function fetch(node, parentObject, userId) {
let results = [];
if (parentObject) {
// composition
let accessor = node.linker.createLink(parentObject);
// because resolvers are run server side, we may need all the variables of the object in order
// to provide a proper fetch.
if (accessor instanceof LinkResolve) {
if (node.linker.isResolver()) {
let accessor = node.linker.createLink(parentObject, node.collection);
accessor.object = node.parent.collection.findOne(parentObject._id);
results = accessor.fetch(filters, options, userId);
} else {
const strategy = node.linker.strategy;
const isVirtual = node.linker.isVirtual();
const fieldStorage = node.linker.linkStorageField;
_.extend(filters, createSearchFilters(parentObject, fieldStorage, strategy, isVirtual));
if (node.collection.findSecure) {
results = node.collection.findSecure(filters, options, userId).fetch();
} else {
results = node.collection.find(filters, options).fetch();
}
}
results = accessor.fetch(filters, options, userId);
} else {
if (node.collection.findSecure) {
results = node.collection.findSecure(filters, options, userId).fetch();
@ -34,4 +43,4 @@ export default function fetch(node, parentObject, userId) {
});
return results;
}
}

View file

@ -8,6 +8,7 @@ export default class CollectionNode {
this.body = body;
this.props = {};
this.parent = null;
this.linker = null;
}
get collectionNodes() {
@ -18,21 +19,14 @@ export default class CollectionNode {
return _.filter(this.nodes, n => n instanceof FieldNode);
}
get linker() {
if (this.parent) {
return this.parent.collection.getLinker(this.linkName)
}
}
hasGlobalFieldNode() {
return !!_.find(this.fieldNodes, n => n.isGlobal());
}
/**
* @param node
* @param linker
*/
add(node) {
add(node, linker) {
node.parent = this;
node.linker = linker;
this.nodes.push(node);
}
@ -43,10 +37,6 @@ export default class CollectionNode {
applyFields(filters, options) {
let hasAddedAnyField = false;
if (this.hasGlobalFieldNode()) {
return options.fields = undefined;
}
_.each(this.fieldNodes, n => {
hasAddedAnyField = true;
n.applyFields(options.fields)
@ -56,9 +46,9 @@ export default class CollectionNode {
_.each(this.collectionNodes, (collectionNode) => {
let linker = collectionNode.linker;
if (!linker.isVirtual()) {
hasAddedAnyField = true;
if (linker && !linker.isVirtual()) {
options.fields[linker.linkStorageField] = 1;
hasAddedAnyField = true;
}
});
@ -72,4 +62,16 @@ export default class CollectionNode {
options.fields = {_id: 1};
}
}
parentHasMyLinkStorageFieldSpecified() {
const storageField = this.linker.linkStorageField;
if (this.parent) {
return !!_.find(this.parent.fieldNodes, fieldNode => {
return fieldNode.name == storageField
})
}
return false;
}
}

View file

@ -1,11 +1,7 @@
export default class FieldNode {
constructor(name, body) {
this.name = name;
this.body = body;
}
isGlobal() {
return this.name === '$all';
this.body = _.isObject(body) ? 1 : body;
}
applyFields(fields) {

View file

@ -1,6 +1,7 @@
import createGraph from './lib/createGraph.js';
import recursiveFetch from './lib/recursiveFetch.js';
import applyFilterFunction from './lib/applyFilterFunction.js';
import hypernova from './hypernova/hypernova.js';
export default class Query {
constructor(collection, body, params = {}) {
@ -80,7 +81,7 @@ export default class Query {
applyFilterFunction(this.body, this.params)
);
return recursiveFetch(node, null, options.userId);
return hypernova(node, options.userId);
}
}

View file

@ -0,0 +1,6 @@
import AuthorSchema from './schema.js';
const Authors = new Mongo.Collection('authors');
export default Authors;
Authors.attachSchema(AuthorSchema);

View file

@ -0,0 +1,21 @@
import Authors from './collection.js';
import Posts from '../posts/collection.js';
import Comments from '../comments/collection.js';
import Groups from '../groups/collection.js';
Authors.addLinks({
comments: {
collection: Comments,
inversedBy: 'author'
},
posts: {
collection: Posts,
inversedBy: 'author'
},
groups: {
type: 'many',
metadata: {},
collection: Groups,
field: 'groupIds'
}
});

View file

@ -0,0 +1,5 @@
export default new SimpleSchema({
name: {
type: String
}
});

View file

@ -0,0 +1,6 @@
import CommentSchema from './schema.js';
const Comments = new Mongo.Collection('comments');
export default Comments;
Comments.attachSchema(CommentSchema);

View file

@ -0,0 +1,19 @@
import Comments from './collection.js';
import Authors from '../authors/collection.js';
import Posts from '../posts/collection.js';
Comments.addLinks({
author: {
type: 'one',
collection: Authors,
field: 'authorId',
index: true
},
post: {
type: 'one',
collection: Posts,
field: 'postId',
index: true
}
});

View file

@ -0,0 +1,5 @@
export default new SimpleSchema({
text: {
type: String
}
});

View file

@ -0,0 +1,67 @@
import { Random } from 'meteor/random';
import { _ } from 'meteor/underscore';
import Authors from './authors/collection';
import Comments from './comments/collection';
import Posts from './posts/collection';
import Tags from './tags/collection';
import Groups from './groups/collection';
Authors.remove({});
Comments.remove({});
Posts.remove({});
Tags.remove({});
Groups.remove({});
const AUTHORS = 10;
const POST_PER_USER = 20;
const COMMENTS_PER_POST = 10;
const TAGS = ['JavaScript', 'Meteor', 'React', 'Other'];
const GROUPS = ['JavaScript', 'Meteor', 'React', 'Other'];
const COMMENT_TEXT_SAMPLES = [
'Good', 'Bad', 'Neutral'
];
console.log('[testing] Loading test fixtures ...');
let tags = TAGS.map(name => Tags.insert({name}));
let groups = GROUPS.map(name => Groups.insert({name}));
let authors = _.range(AUTHORS).map(idx => {
return Authors.insert({
name: 'Author - ' + idx
});
});
_.each(authors, (author) => {
const authorPostLink = Authors.getLink(author, 'posts');
const authorGroupLink = Authors.getLink(author, 'groups');
authorGroupLink.add(_.sample(groups), {
isAdmin: _.sample([true, false])
});
_.each(_.range(POST_PER_USER), (idx) => {
let post = {
title: `User Post - ${idx}`
};
authorPostLink.add(post);
const postCommentsLink = Posts.getLink(post, 'comments');
const postTagsLink = Posts.getLink(post, 'tags');
const postGroupLink = Posts.getLink(post, 'group');
postGroupLink.set(_.sample(groups), {random: Random.id()});
postTagsLink.add(_.sample(tags));
_.each(_.range(COMMENTS_PER_POST), (idx) => {
let comment = {
text: _.sample(COMMENT_TEXT_SAMPLES)
};
postCommentsLink.add(comment);
Comments.getLink(comment, 'author').set(_.sample(authors));
})
})
});
console.log('[ok] fixtures have been loaded.');

View file

@ -0,0 +1,6 @@
import GroupSchema from './schema.js';
const Groups = new Mongo.Collection('groups');
export default Groups;
Groups.attachSchema(GroupSchema);

View file

@ -0,0 +1,3 @@
import Groups from './collection.js';
Groups.expose();

View file

@ -0,0 +1,14 @@
import Groups from './collection.js';
import Authors from '../authors/collection.js';
import Posts from '../posts/collection.js';
Groups.addLinks({
authors: {
collection: Authors,
inversedBy: 'groups'
},
posts: {
collection: Posts,
inversedBy: 'group'
}
});

View file

@ -0,0 +1,5 @@
export default new SimpleSchema({
name: {
type: String
}
});

View file

@ -0,0 +1,11 @@
import './comments/links';
import './posts/links';
import './authors/links';
import './tags/links';
import './groups/links';
import Posts from './posts/collection.js';
if (Meteor.isServer) {
Posts.expose();
}

View file

@ -0,0 +1,6 @@
import PostSchema from './schema.js';
const Posts = new Mongo.Collection('posts');
export default Posts;
Posts.attachSchema(PostSchema);

View file

@ -0,0 +1,5 @@
import Posts from './collection.js';
Posts.expose((filters, options, userId) => {
});

View file

@ -0,0 +1,34 @@
import Posts from './collection.js';
import Authors from '../authors/collection.js';
import Comments from '../comments/collection.js';
import Tags from '../tags/collection.js';
import Groups from '../groups/collection.js';
Posts.addLinks({
author: {
type: 'one',
collection: Authors,
field: 'ownerId',
index: true
},
comments: {
collection: Comments,
inversedBy: 'post'
},
tags: {
collection: Tags,
type: 'many',
field: 'tagIds',
index: true
},
commentsCount: {
resolve(post) {
return Comments.find({postId: post._id}).count();
}
},
group: {
type: 'one',
collection: Groups,
metadata: {}
}
});

View file

@ -0,0 +1,5 @@
export default new SimpleSchema({
title: {
type: String
}
})

View file

@ -0,0 +1,6 @@
import TagSchema from './schema.js';
const Tags = new Mongo.Collection('tags');
export default Tags;
Tags.attachSchema(TagSchema);

View file

@ -0,0 +1,9 @@
import Tags from './collection.js';
import Posts from '../posts/collection.js';
Tags.addLinks({
posts: {
collection: Posts,
inversedBy: 'tags'
}
});

View file

@ -0,0 +1,5 @@
export default new SimpleSchema({
name: {
type: String
}
});

View file

@ -0,0 +1,67 @@
import { createQuery } from 'meteor/cultofcoders:grapher';
describe('Query Client Tests', function () {
it('Should work with queries via method call', function (done) {
const query = createQuery({
posts: {
$options: {limit: 5},
title: 1,
comments: {
$filters: {text: 'Good'},
text: 1
}
}
});
query.fetch((err, res) => {
assert.isUndefined(err);
assert.isArray(res);
_.each(res, post => {
assert.isString(post.title);
assert.isString(post._id);
_.each(post.comments, comment => {
assert.isString(comment._id);
assert.equal('Good', comment.text);
})
});
done();
});
});
it('Should work with queries reactively', function (done) {
const query = createQuery({
posts: {
$options: {limit: 5},
title: 1,
comments: {
$filters: {text: 'Good'},
text: 1
}
}
});
const handle = query.subscribe();
Tracker.autorun(c => {
if (handle.ready()) {
c.stop();
const res = query.fetch();
assert.isArray(res);
_.each(res, post => {
assert.isString(post.title);
assert.isString(post._id);
_.each(post.comments, comment => {
assert.isString(comment._id);
assert.equal('Good', comment.text);
})
});
done();
}
})
});
});

View file

@ -0,0 +1,242 @@
import { createQuery } from 'meteor/cultofcoders:grapher';
import Comments from './bootstrap/comments/collection.js';
describe('Hypernova', function () {
it('Should fetch One links correctly', function () {
const data = createQuery({
comments: {
text: 1,
author: {
name: 1
}
}
}).fetch();
assert.lengthOf(data, Comments.find().count());
assert.isTrue(data.length > 0);
_.each(data, comment => {
assert.isObject(comment.author);
assert.isString(comment.author.name);
assert.isString(comment.author._id);
assert.isTrue(_.keys(comment.author).length == 2);
})
});
it('Should fetch One links with limit and options', function () {
const data = createQuery({
comments: {
$options: {limit: 5},
text: 1
}
}).fetch();
assert.lengthOf(data, 5);
});
it('Should fetch One-Inversed links with limit and options', function () {
const data = createQuery({
authors: {
$options: {limit: 5},
comments: {
$filters: {text: 'Good'},
$options: {limit: 2},
text: 1
}
}
}).fetch();
assert.lengthOf(data, 5);
_.each(data, author => {
assert.lengthOf(author.comments, 2);
_.each(author.comments, comment => {
assert.equal('Good', comment.text);
})
})
});
it('Should fetch Many links correctly', function () {
const data = createQuery({
posts: {
$options: {limit: 5},
title: 1,
tags: {
text: 1
}
}
}).fetch();
assert.lengthOf(data, 5);
_.each(data, post => {
assert.isString(post.title);
assert.isArray(post.tags);
assert.isTrue(post.tags.length > 0);
})
});
it('Should fetch Many - inversed links correctly', function () {
const data = createQuery({
tags: {
name: 1,
posts: {
$options: {limit: 5},
title: 1
}
}
}).fetch();
_.each(data, tag => {
assert.isString(tag.name);
assert.isArray(tag.posts);
assert.lengthOf(tag.posts, 5);
_.each(tag.posts, post => {
assert.isString(post.title);
})
})
});
it('Should fetch One-Meta links correctly', function () {
const data = createQuery({
posts: {
$options: {limit: 5},
title: 1,
group: {
name: 1
}
}
}).fetch();
assert.lengthOf(data, 5);
_.each(data, post => {
assert.isString(post.title);
assert.isString(post._id);
assert.isObject(post.group);
assert.isString(post.group._id);
assert.isString(post.group.name);
})
});
it('Should fetch One-Meta inversed links correctly', function () {
const data = createQuery({
groups: {
name: 1,
posts: {
title: 1
}
}
}).fetch();
_.each(data, group => {
assert.isString(group.name);
assert.isString(group._id);
assert.lengthOf(_.keys(group), 3);
assert.isArray(group.posts);
_.each(group.posts, post => {
assert.isString(post.title);
assert.isString(post._id);
})
})
});
it('Should fetch Many-Meta links correctly', function () {
const data = createQuery({
authors: {
name: 1,
groups: {
$options: {limit: 1},
name: 1
}
}
}).fetch();
_.each(data, author => {
assert.isArray(author.groups);
assert.lengthOf(author.groups, 1);
_.each(author.groups, group => {
assert.isObject(group);
assert.isString(group._id);
assert.isString(group.name);
})
})
});
it('Should fetch Many-Meta inversed links correctly', function () {
const data = createQuery({
groups: {
name: 1,
authors: {
$options: {limit: 2},
name: 1
}
}
}).fetch();
_.each(data, group => {
assert.isArray(group.authors);
assert.isTrue(group.authors.length <= 2);
_.each(group.authors, author => {
assert.isObject(author);
assert.isString(author._id);
assert.isString(author.name);
})
})
});
it('Should fetch Resolver links properly', function () {
const data = createQuery({
posts: {
$options: {limit: 5},
commentsCount: 1
}
}).fetch();
assert.lengthOf(data, 5);
_.each(data, post => {
assert.equal(10, post.commentsCount);
})
})
it('Should fetch in depth properly at any given level.', function () {
const data = createQuery({
authors: {
$options: {limit: 5},
posts: {
$options: {limit: 5},
comments: {
$options: {limit: 5},
author: {
groups: {
posts: {
$options: {limit: 5},
author: {
name: 1
}
}
}
}
}
}
}
}).fetch();
assert.lengthOf(data, 5);
let arrivedInDepth = false;
_.each(data, author => {
_.each(author.posts, post => {
_.each(post.comments, comment => {
_.each(comment.author.groups, group => {
_.each(group.posts, post => {
assert.isString(post.author.name);
arrivedInDepth = true;
})
})
})
})
});
assert.isTrue(arrivedInDepth);
})
});

View file

@ -1,34 +0,0 @@
PostCollection = new Mongo.Collection('test_query_post');
CommentCollection = new Mongo.Collection(('test_query_comment'));
GroupCollection = new Mongo.Collection(('test_query_group'));
AuthorCollection = new Mongo.Collection(('test_query_author'));
PostCollection.addLinks({
'comments': {
collection: CommentCollection,
type: '*'
},
'groups': {
collection: GroupCollection,
type: '*',
metadata: {
isAdmin: {type: String}
}
},
author: {
collection: AuthorCollection,
type: 'one'
},
comment_resolve: {
resolve(object) {
return CommentCollection.find({resourceId: object._id}).fetch();
}
}
});
CommentCollection.addLinks({
author: {
collection: AuthorCollection,
type: '1'
}
});

View file

@ -1,98 +0,0 @@
import './bootstrap.js';
import createQuery from '../createQuery.js';
describe('Query Client Tests', function () {
it('Should return static data with helpers', function (done) {
const query = PostCollection.createQuery({
title: 1,
groups: 1
});
query.fetch((err, res) => {
assert.lengthOf(res, 2);
_.each(res, element => {
assert.isArray(element.groups);
});
done();
});
});
it('Should work with global queries', function (done) {
const query = createQuery({
test_query_post: {
title: 1,
groups: 1
}
});
query.fetch((err, res) => {
assert.lengthOf(res, 2);
_.each(res, element => {
assert.isArray(element.groups);
});
done();
});
});
it('Should work with global queries (reactively)', function (done) {
const query = createQuery({
test_query_post: {
title: 1,
groups: 1
}
});
const handle = query.subscribe();
Tracker.autorun(c => {
if (handle.ready()) {
c.stop();
const res = query.fetch();
assert.lengthOf(res, 2);
_.each(res, element => {
assert.isArray(element.groups);
});
done();
}
})
});
it('Should subscribe to links properly', function (done) {
const query = PostCollection.createQuery({
title: 1,
comments: {
$filters: {isBanned: false}
},
groups: {}
});
const subsHandle = query.subscribe();
Tracker.autorun((c) => {
if (subsHandle.ready()) {
c.stop();
let posts = PostCollection.find().fetch();
assert.lengthOf(posts, 2);
let firstPost = posts[0];
const commentsLink = PostCollection.getLink(firstPost, 'comments');
assert.lengthOf(commentsLink.find().fetch(), 2);
// check direct fetching
posts = query.fetch();
assert.lengthOf(posts, 2);
firstPost = posts[0];
assert.lengthOf(firstPost.comments, 2);
done();
}
})
});
});

View file

@ -1,136 +0,0 @@
import createQuery from '../createQuery.js';
describe('Query Server Tests', function () {
it('should return the propper data', function () {
const query = PostCollection.createQuery({
$filters: {'title': 'Hello'},
title: 1,
author: {
name: 1
},
nested: {
data: 1
},
comments: {
text: 1,
author: {
name: 1
}
},
groups: {
name: 1
},
comment_resolve: {
$filters: {test: '123'}
}
});
const data = query.fetch();
assert.equal(1, data.length);
let post = data[0];
//assert.isString(post.testModelFunction());
assert.equal('Yes', post.nested.data);
assert.equal(3, post.comments.length);
assert.equal(2, post.groups.length);
assert.isObject(post.author);
assert.equal('John McSmithie', post.author.name);
assert.lengthOf(post.comment_resolve, 1);
_.each(post.comments, comment => {
assert.isString(comment.author.name);
})
});
it('should apply filter function recursively', function () {
const query = PostCollection.createQuery({
$filter({filters, params}) {
if (params.title) {
filters.title = params.title;
}
}
});
query.setParams({title: 'Hello'});
assert.equal(1, query.fetch().length);
query.setParams({title: undefined});
assert.equal(2, query.fetch().length);
});
it('Should work with global createQuery', function () {
const query = createQuery({
test_query_post: {
title: 1,
groups: 1
}
});
const res = query.fetch();
assert.lengthOf(res, 2);
_.each(res, element => {
assert.isArray(element.groups);
});
});
it('Should fetch the fields properly', function () {
let res;
res = createQuery({
test_query_post: {
}
}).fetch();
_.each(res, element => {
assert.isDefined(element._id);
assert.isUndefined(element.title);
});
res = createQuery({
test_query_post: {
title: 1
}
}).fetch();
_.each(res, element => {
assert.isDefined(element._id);
assert.isDefined(element.title);
});
res = createQuery({
test_query_post: {
groups: {
}
}
}).fetch();
_.each(res, element => {
assert.isDefined(element._id);
_.each(element.groups, group => {
assert.isDefined(group._id);
assert.isUndefined(group.name);
})
});
res = createQuery({
test_query_post: {
$all: 1,
groups: {
$all: 1
}
}
}).fetch();
_.each(res, element => {
assert.isDefined(element.title);
assert.isDefined(element._id);
_.each(element.groups, group => {
assert.isDefined(group._id);
assert.isDefined(group.name);
})
});
})
});

View file

@ -5,22 +5,6 @@ export {
default as createQuery
} from './lib/query/createQuery.js';
export {
default as createGraph
} from './lib/query/lib/createGraph.js';
export {
default as recursiveFetch
} from './lib/query/lib/recursiveFetch.js';
export {
default as recursiveCompose
} from './lib/query/lib/recursiveCompose.js';
export {
default as applyFilterFunction
} from './lib/query/lib/applyFilterFunction.js';
export {
default as restrictFields
} from './lib/exposure/lib/restrictFields.js';

View file

@ -1 +1,6 @@
import './lib/exposure/extension.js';
import { checkNpmVersions } from 'meteor/tmeasday:check-npm-versions';
checkNpmVersions({
'sift': '3.2.x'
}, 'cultofcoders:grapher');

View file

@ -1,8 +1,8 @@
Package.describe({
name: 'cultofcoders:grapher',
version: '1.0.10',
version: '1.1.0',
// Brief, one-line summary of the package.
summary: 'Grapher is a way of linking/joining collections. And fetching data in a GraphQL style.',
summary: 'Grapher makes linking collections easily. And fetching data as a graph.',
// URL to the Git repository containing the source code for this package.
git: 'https://github.com/cult-of-coders/grapher',
// By default, Meteor will default to using README.md for documentation.
@ -10,6 +10,10 @@ Package.describe({
documentation: 'README.md'
});
Npm.depends({
'sift': '3.2.6'
});
Package.onUse(function (api) {
api.versionsFrom('1.3');
@ -21,6 +25,8 @@ Package.onUse(function (api) {
'matb33:collection-hooks@0.8.4',
'reywood:publish-composite@1.4.2',
'dburles:mongo-collection-instances@0.3.5',
'tmeasday:check-npm-versions@0.3.1',
'meteorhacks:aggregate@1.3.0',
'mongo'
];
@ -37,14 +43,16 @@ Package.onTest(function (api) {
api.use('ecmascript');
api.use('tracker');
api.use('practicalmeteor:mocha');
api.use('practicalmeteor:chai');
api.mainModule('lib/links/tests/main.js', 'server');
api.addFiles(['lib/query/tests/bootstrap.js']);
api.addFiles(['lib/query/tests/fixtures.js'], 'server');
api.addFiles('lib/query/testing/bootstrap/index.js');
api.mainModule('lib/query/tests/client.test.js', 'client');
api.mainModule('lib/query/tests/server.test.js', 'server');
// When you play with tests you should comment this to make tests go faster.
api.addFiles('lib/query/testing/bootstrap/fixtures.js', 'server');
api.mainModule('lib/query/testing/server.test.js', 'server');
api.mainModule('lib/query/testing/client.test.js', 'client');
});