GraphQL Bridge initial implementation

This commit is contained in:
Theodor Diaconu 2018-03-29 19:04:43 +03:00
parent 1cf94bc8c2
commit 94a7acd0c0
22 changed files with 1167 additions and 235 deletions

View file

@ -2,22 +2,27 @@
[![Build Status](https://api.travis-ci.org/cult-of-coders/grapher.svg?branch=master)](https://travis-ci.org/cult-of-coders/grapher)
*Grapher* is a Data Fetching Layer on top of Meteor and MongoDB. It is production ready and battle tested.
_Grapher_ is a Data Fetching Layer on top of Meteor and MongoDB. It is production ready and battle tested.
Main features:
- Innovative way to make MongoDB relational
- Reactive data graphs for high availability
- Incredible performance
- Denormalization ability
- Connection to external data sources
- Usable from anywhere
* Innovative way to make MongoDB relational
* Blends in with Apollo GraphQL making it highly performant
* Reactive data graphs for high availability
* Incredible performance
* Denormalization ability
* Connection to external data sources
* Usable from anywhere
It marks a stepping stone into evolution of data, enabling developers to write complex and secure code,
while maintaining the code base easy to understand.
Grapher 1.3 is LTS until 2024
[Read more about the GraphQL Bridge](docs/graphql.md)
### Installation
```
meteor add cultofcoders:grapher
```
@ -34,42 +39,43 @@ Grapher cheatsheet, after you've learned it's powers this is the document will b
### Useful packages
- Live View: https://github.com/cult-of-coders/grapher-live
- Graphical Grapher: https://github.com/Herteby/graphical-grapher
- React HoC: https://github.com/cult-of-coders/grapher-react
- VueJS: https://github.com/Herteby/grapher-vue
* Live View: https://github.com/cult-of-coders/grapher-live
* Graphical Grapher: https://github.com/Herteby/graphical-grapher
* React HoC: https://github.com/cult-of-coders/grapher-react
* VueJS: https://github.com/Herteby/grapher-vue
### Premium Support
If you are looking to integrate Grapher in your apps and want online or on-site consulting and training,
If you are looking to integrate Grapher in your apps and want online or on-site consulting and training,
shoot us an e-mail contact@cultofcoders.com, we will be more than happy to aid you.
### Quick Illustration
Query:
```js
createQuery({
posts: {
title: 1,
author: {
fullName: 1
fullName: 1,
},
comments: {
text: 1,
createdAt: 1,
author: {
fullName: 1
}
fullName: 1,
},
},
categories: {
name: 1
}
}
name: 1,
},
},
}).fetch();
```
Result:
```
[
{

View file

@ -2,16 +2,14 @@
Use this as a cheatsheet after you have read the full documentation.
- [Adding Links](#adding-links)
- [Adding Reducers](#adding-reducers)
- [Creating Named Queries](#creating-named-queries)
- [Exposing Named Queries](#exposing-named-queries)
- [Using Queries](#using-queries)
- [Caching Named Queries](#caching-named-queries)
- [Creating Global Queries](#creating-global-queries)
- [Exposing Global Queries](#exposing-global-queries)
* [Adding Links](#adding-links)
* [Adding Reducers](#adding-reducers)
* [Creating Named Queries](#creating-named-queries)
* [Exposing Named Queries](#exposing-named-queries)
* [Using Queries](#using-queries)
* [Caching Named Queries](#caching-named-queries)
* [Creating Global Queries](#creating-global-queries)
* [Exposing Global Queries](#exposing-global-queries)
### Adding Links
@ -25,20 +23,20 @@ Collection.addLinks({
denormalize: {
field, // String
body, // Body from related collection
}
}
})
},
},
});
Collection.addLinks({
linkName: {
collection, // Mongo.Collection
inversedBy, // The link name from the other side
inversedBy, // The link name from the other side
denormalize: {
field, // String
body, // Body from related collection
}
}
})
},
},
});
```
### Adding Reducers
@ -49,26 +47,83 @@ Collection.addReducers({
body, // Object, dependency graph
compute(object) {
// anything
}
}
})
},
},
});
```
### Creating Named Queries
```js
Collection.createQuery('queryName', {
$options, // Mongo Options {sort, limit, skip}
$filters, // Mongo Filters
$filter({filters, options, params}) {}, // Function or [Function]
$postOptions, // {limit, sort, skip}
$postFilters, // any sift() available filters
$postFilter(results, params) {}, // Function => results, or [Function] => results
body, // The query body
}, {
params, // Default parameters
validateParams, // Object or Function
})
Collection.createQuery(
'queryName',
{
$options, // Mongo Options {sort, limit, skip}
$filters, // Mongo Filters
$filter({ filters, options, params }) {}, // Function or [Function]
$postOptions, // {limit, sort, skip}
$postFilters, // any sift() available filters
$postFilter(results, params) {}, // Function => results, or [Function] => results
body, // The query body
},
{
params, // Default parameters
validateParams, // Object or Function
}
);
```
### Creating GraphQL Queries
```js
const Query = {
users(_, args, context, ast) {
const query = Users.astToQuery(ast, {
// Manipulate the transformed body
embody({body, getArgs}) {}
$filters, // Mongo Filters/Selector
$options, // Mongo Options
// It will only allow you to query against this body graph
// Meaning it won't allow fields outside, links outside, or deeper nested than the ones you specify
intersect: Body,
// Useful when you don't have an intersection body, to restrict the limit of depth, to avoid a nested GraphQL attack
maxDepth,
// Automatically enforces a maximum number of results
maxLimit, // Integer
// Simply removes from the graph what fields it won't allow
// Can work with deep strings like 'comments.author'
deny, // String[]
})
return query.fetch();
}
}
```
Setting global defaults for all `astToQuery` queries:
```js
import { setAstToQueryDefaults } from 'meteor/cultofcoders:grapher';
setAstToQueryDefaults({
maxLimit: 100,
maxDepth: 5,
});
```
Getting the db context to inject it:
```js
import { db } from 'meteor/cultofcoders:grapher';
// db.users
// db.posts
// db.${collectionName}
```
### Exposing Named Queries
@ -80,8 +135,8 @@ query.expose({
publication, // Boolean
unblock, // Boolean
validateParams, // Function or Object
embody // Object which extends the body server-side securely, or Function(body, params)
})
embody, // Object which extends the body server-side securely, or Function(body, params)
});
```
### Creating and Exposing Resolvers
@ -95,7 +150,7 @@ query.expose({
firewall, // Function or [Function]
});
query.resolve(function (params) {
query.resolve(function(params) {
// this.userId
return [];
});
@ -104,28 +159,31 @@ query.resolve(function (params) {
### Using Queries
```js
query.setParams({}) // extends current params
query.setParams({}); // extends current params
```
#### Server-Side
```js
query.clone({params}).fetch();
query.clone({params}).fetchOne();
query.clone({params}).getCount();
query.clone({ params }).fetch();
query.clone({ params }).fetchOne();
query.clone({ params }).getCount();
```
#### Client-Side
Static:
```js
query.clone({params}).fetch((err, res) => {});
query.clone({params}).fetchOne((err, res) => {});
query.clone({params}).getCount((err, res) => {});
query.clone({ params }).fetch((err, res) => {});
query.clone({ params }).fetchOne((err, res) => {});
query.clone({ params }).getCount((err, res) => {});
```
Reactive:
```js
const query = userListQuery.clone({params});
const query = userListQuery.clone({ params });
const handle = query.subscribe(); // handle.ready()
const data = query.fetch();
@ -136,13 +194,16 @@ const count = query.getCount();
```
#### Caching Named Queries
```js
import {MemoryResultCacher} from 'meteor/cultofcoders:grapher';
import { MemoryResultCacher } from 'meteor/cultofcoders:grapher';
// server-side
query.cacheResults(new MemoryResultCacher({
ttl: 60 * 1000, // 60 seconds
}))
query.cacheResults(
new MemoryResultCacher({
ttl: 60 * 1000, // 60 seconds
})
);
```
#### Creating Global Queries
@ -151,26 +212,26 @@ query.cacheResults(new MemoryResultCacher({
Collection.createQuery({
$options, // Mongo Options {sort, limit, skip}
$filters, // Mongo Filters
$filter({filters, options, params}) {}, // Function or [Function]
$filter({ filters, options, params }) {}, // Function or [Function]
$postOptions, // {limit, sort, skip}
$postFilters, // any sift() available filters
$postFilter, // Function => results, or [Function] => results
body, // the rest of the object
})
});
```
#### Exposing Global Queries
```js
Collection.expose({
firewall(filters, options, userId) {}, // Function or [Function]
firewall(filters, options, userId) {}, // Function or [Function]
publication, // Boolean
method, // Boolean
blocking, // Boolean
maxLimit, // Number
maxLimit, // Number
maxDepth, // Number
restrictedFields, // [String]
restrictLinks, // [String] or Function,
body, // Object or Function(userId) => Object
});
```
```

92
docs/graphql.md Normal file
View file

@ -0,0 +1,92 @@
## GraphQL Bridge
You will start by installing [`cultofcoders:apollo`](https://github.com/cult-of-coders/apollo) package, which makes it super easy to get your barebones Meteor app up with GraphQL Services.
## Creating your Queries
The 4th argument that we receive inside the resolver, is the AST (Abstract Source Tree), which represents the query we receive. Based on that information, we can extract the Grapher's body, and we also added some very useful options, to enable security and customisation.
```js
const Query = {
users(_, args, context, ast) {
return Users.astToQuery(ast, {
// Manipulate the transformed body
embody({body, getArgs}) {}
$filters, // Mongo Filters/Selector
$options, // Mongo Options
// It will only allow you to query against this body graph
// Meaning it won't allow fields outside, links outside, or deeper nested than the ones you specify
intersect: Body,
// Useful when you don't have an intersection body, to restrict the limit of depth, to avoid a nested GraphQL attack
maxDepth,
// Automatically enforces a maximum number of results
maxLimit, // Integer
// Simply removes from the graph what fields it won't allow
// Can work with deep strings like 'comments.author'
deny, // String[]
}).fetch();
}
}
```
## Mapping Fields
There may be scenarios where your database field is different from the field in the API you expose, Grapher treats that easily by exposing an `addFieldMap` function:
```js
Users.addFieldMap({
createdAt: 'created_at',
});
```
Meaning that the body received from GraphQL is going to properly handle the situation. What happens behind, basically, we create a reducer for that field.
## Global Config
Setting global defaults for all `astToQuery` queries:
```js
import { setAstToQueryDefaults } from 'meteor/cultofcoders:grapher';
setAstToQueryDefaults({
maxLimit: 100,
maxDepth: 5,
});
```
## Resolver's Context
```js
import { db } from 'meteor/cultofcoders:grapher';
// Inject db in your context
db.users
db.${collectionName}
```
## GraphQL Directives
It would be nice if we could configure our directives directly inside GraphQL right? Something like this:
```js
type User @mongo(name: "users") {
comments: [Comment] @link(to: "user")
}
type Comment @mongo(name: "comments") {
user: User @link(field: "userId")
post: Post @link(field: "commentId")
createdAt: Date @map("created_at")
}
type Post @mongo(name: "posts") {
comments: [Comment] @link(to="post")
}
```
Find out more here: https://github.com/cult-of-coders/grapher-schema-directives

View file

@ -2,13 +2,13 @@
Let's learn Grapher. It's quite easy and it's time to change the way you think about your data.
Before we dive into Grapher you need to know to fundamentals of Meteor, but not mandatory, you can
Before we dive into Grapher you need to know to fundamentals of Meteor, but not mandatory, you can
learn the principles either way.
- http://www.meteor-tuts.com/chapters/1/intro.html
- http://docs.meteor.com/api/collections.html
- http://docs.meteor.com/api/methods.html
- http://docs.meteor.com/api/pubsub.html
* http://www.meteor-tuts.com/chapters/1/intro.html
* http://docs.meteor.com/api/collections.html
* http://docs.meteor.com/api/methods.html
* http://docs.meteor.com/api/pubsub.html
### [Introduction](introduction.md)
@ -20,7 +20,7 @@ Learn the various ways of linking collections to each other.
### [Linker Engine](linker_engine.md)
Learn about how you can programatically set and fetch related data.
Learn about how you can programatically set and fetch related data.
### [Query Options](query_options.md)
@ -39,7 +39,7 @@ Learn the right way to define and expose queries to the client in a secure manne
Read about the tool that makes Grapher so performant.
### [Denormalization](denormalization.md)
Learn how to denormalize your data to enable even more performance, and super advanced searching.
### [Caching Results](caching_results.md)
@ -59,6 +59,10 @@ Learn about some good ways to structure your code some and about common pitfalls
If you want to use Grapher in a React Native app so from any other language as an API, it is possible
### [GraphQL Bridge](graphql.md)
If you want to use Grapher in a React Native app so from any other language as an API, it is possible
### [API](api.md)
After you've learned about Grapher, here's your cheatsheet.
After you've learned about Grapher, here's your cheatsheet.

11
lib/db.js Normal file
View file

@ -0,0 +1,11 @@
import { Mongo } from 'meteor/mongo';
class DBProxy {
get(key) {
return Mongo.Collection.get(key);
}
}
const db = new DBProxy();
export default db;

View file

@ -1,11 +1,14 @@
import Exposure from './exposure.js';
_.extend(Mongo.Collection.prototype, {
Object.assign(Mongo.Collection.prototype, {
expose(config) {
if (!Meteor.isServer) {
throw new Meteor.Error('not-allowed', `You can only expose a collection server side. ${this._name}`);
throw new Meteor.Error(
'not-allowed',
`You can only expose a collection server side. ${this._name}`
);
}
new Exposure(this, config);
}
});
},
});

View file

@ -16,5 +16,5 @@ _.extend(Mongo.Collection.prototype, {
return new Query(this, body, options);
}
}
});
},
});

2
lib/graphql/index.js Normal file
View file

@ -0,0 +1,2 @@
export { default as astToBody } from './lib/astToBody';
export { default as astToQuery } from './lib/astToQuery';

View file

@ -0,0 +1,32 @@
export const Symbols = {
ARGUMENTS: Symbol('arguments'),
};
export default function astToBody(ast) {
const fieldNodes = ast.fieldNodes;
const body = extractSelectionSet(ast.fieldNodes[0].selectionSet);
return body;
}
function extractSelectionSet(set) {
let body = {};
set.selections.forEach(el => {
if (!el.selectionSet) {
body[el.name.value] = 1;
} else {
body[el.name.value] = extractSelectionSet(el.selectionSet);
if (el.arguments.length) {
let argumentMap = {};
el.arguments.forEach(arg => {
argumentMap[arg.name.value] = arg.value.value;
});
body[el.name.value][Symbols.ARGUMENTS] = argumentMap;
}
}
});
return body;
}

View file

@ -0,0 +1,148 @@
import { check, Match } from 'meteor/check';
import astToBody, { Symbols } from './astToBody';
import defaults from './defaults';
import intersectDeep from '../../query/lib/intersectDeep';
import enforceMaxLimit from '../../exposure/lib/enforceMaxLimit';
const Errors = {
MAX_DEPTH: 'The maximum depth of this request exceeds the depth allowed.',
};
export default function astToQuery(ast, config = {}) {
const collection = this;
check(config, {
embody: Match.Maybe(Function),
$filters: Match.Maybe(Object),
$options: Match.Maybe(Object),
maxDepth: Match.Maybe(Number),
maxLimit: Match.Maybe(Number),
deny: Match.Maybe([String]),
intersect: Match.Maybe(Object),
});
config = Object.assign(
{
$options: {},
$filters: {},
},
defaults,
config
);
// get the body
let body = astToBody(ast);
// first we do the intersection
if (config.intersect) {
body = intersectDeep(config.intersect, body);
}
// enforce the maximum amount of data we allow to retrieve
if (config.maxLimit) {
enforceMaxLimit(config.$options, config.maxLimit);
}
// figure out depth based
if (config.maxDepth) {
const currentMaxDepth = getMaxDepth(body);
if (currentMaxDepth > config.maxDepth) {
throw Errors.MAX_DEPTH;
}
}
if (config.deny) {
deny(body, config.deny);
}
Object.assign(body, {
$filters,
$options,
});
if (config.embody) {
const getArgs = createGetArgs(body);
config.embody.call(null, {
body,
getArgs,
});
}
// we return the query
return this.createQuery(body);
}
export function getMaxDepth(body) {
let depths = [];
for (key in body) {
if (_.isObject(body[key])) {
depths.push(getMaxDepth(body[key]));
}
}
if (depths.length === 0) {
return 1;
}
return Math.max(...depths) + 1;
}
export function deny(body, fields) {
fields.forEach(field => {
let parts = field.split('.');
let accessor = body;
while (parts.length != 0) {
if (parts.length === 1) {
delete accessor[parts[0]];
} else {
if (!_.isObject(accessor)) {
break;
}
accessor = accessor[parts[0]];
}
parts.shift();
}
});
return clearEmptyObjects(body);
}
export function clearEmptyObjects(body) {
// clear empty nodes then back-propagate
for (let key in body) {
if (_.isObject(body[key])) {
const shouldDelete = clearEmptyObjects(body[key]);
if (shouldDelete) {
delete body[key];
}
}
}
return Object.keys(body).length === 0;
}
export function createGetArgs(body) {
return function(path) {
const parts = path.split('.');
let stopped = false;
let accessor = body;
for (var i = 0; i < parts.length; i++) {
if (!accessor) {
stopped = true;
break;
}
if (accessor[parts[i]]) {
accessor = accessor[parts[i]];
}
}
if (stopped) {
return {};
}
if (accessor) {
return accessor[Symbols.ARGUMENTS] || {};
}
};
}

View file

@ -0,0 +1,7 @@
let defaults = {};
export default defaults;
export function setAstToQueryDefaults(object) {
Object.assign(defaults, object);
}

343
lib/graphql/testing/ast.js Normal file
View file

@ -0,0 +1,343 @@
export default {
fieldName: 'users',
fieldNodes: [
{
kind: 'Field',
name: { kind: 'Name', value: 'users', loc: { start: 6, end: 11 } },
arguments: [],
directives: [],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: {
kind: 'Name',
value: 'fullname',
loc: { start: 12, end: 20 },
},
arguments: [],
directives: [],
loc: { start: 12, end: 20 },
},
{
kind: 'Field',
name: {
kind: 'Name',
value: 'comments',
loc: { start: 21, end: 29 },
},
arguments: [
{
kind: 'Argument',
name: {
kind: 'Name',
value: 'approved',
loc: { start: 30, end: 38 },
},
value: {
kind: 'BooleanValue',
value: true,
loc: { start: 39, end: 43 },
},
loc: { start: 30, end: 43 },
},
],
directives: [],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: {
kind: 'Name',
value: 'text',
loc: { start: 45, end: 49 },
},
arguments: [],
directives: [],
loc: { start: 45, end: 49 },
},
{
kind: 'Field',
name: {
kind: 'Name',
value: 'user',
loc: { start: 50, end: 54 },
},
arguments: [],
directives: [],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: {
kind: 'Name',
value: 'firstname',
loc: { start: 55, end: 64 },
},
arguments: [],
directives: [],
loc: { start: 55, end: 64 },
},
],
loc: { start: 54, end: 65 },
},
loc: { start: 50, end: 65 },
},
],
loc: { start: 44, end: 66 },
},
loc: { start: 21, end: 66 },
},
],
loc: { start: 11, end: 67 },
},
loc: { start: 6, end: 67 },
},
],
returnType: '[User]',
parentType: 'Query',
path: { key: 'users' },
schema: {
_queryType: 'Query',
_mutationType: 'Mutation',
_subscriptionType: 'Subscription',
_directives: [
{
name: 'skip',
description:
'Directs the executor to skip this field or fragment when the `if` argument is true.',
locations: ['FIELD', 'FRAGMENT_SPREAD', 'INLINE_FRAGMENT'],
args: [
{
name: 'if',
description: 'Skipped when true.',
type: 'Boolean!',
},
],
},
{
name: 'include',
description:
'Directs the executor to include this field or fragment only when the `if` argument is true.',
locations: ['FIELD', 'FRAGMENT_SPREAD', 'INLINE_FRAGMENT'],
args: [
{
name: 'if',
description: 'Included when true.',
type: 'Boolean!',
},
],
},
{
name: 'deprecated',
description:
'Marks an element of a GraphQL schema as no longer supported.',
locations: ['FIELD_DEFINITION', 'ENUM_VALUE'],
args: [
{
name: 'reason',
description:
'Explains why this element was deprecated, usually also including a suggestion for how to access supported similar data. Formatted in [Markdown](https://daringfireball.net/projects/markdown/).',
type: 'String',
defaultValue: 'No longer supported',
},
],
},
],
astNode: {
kind: 'SchemaDefinition',
directives: [],
operationTypes: [
{
kind: 'OperationTypeDefinition',
operation: 'query',
type: {
kind: 'NamedType',
name: {
kind: 'Name',
value: 'Query',
loc: { start: 18, end: 23 },
},
loc: { start: 18, end: 23 },
},
loc: { start: 11, end: 23 },
},
{
kind: 'OperationTypeDefinition',
operation: 'mutation',
type: {
kind: 'NamedType',
name: {
kind: 'Name',
value: 'Mutation',
loc: { start: 36, end: 44 },
},
loc: { start: 36, end: 44 },
},
loc: { start: 26, end: 44 },
},
{
kind: 'OperationTypeDefinition',
operation: 'subscription',
type: {
kind: 'NamedType',
name: {
kind: 'Name',
value: 'Subscription',
loc: { start: 61, end: 73 },
},
loc: { start: 61, end: 73 },
},
loc: { start: 47, end: 73 },
},
],
loc: { start: 0, end: 75 },
},
_typeMap: {
Query: 'Query',
User: 'User',
String: 'String',
Boolean: 'Boolean',
Comment: 'Comment',
Mutation: 'Mutation',
Subscription: 'Subscription',
ReactiveEvent: 'ReactiveEvent',
ID: 'ID',
JSON: 'JSON',
__Schema: '__Schema',
__Type: '__Type',
__TypeKind: '__TypeKind',
__Field: '__Field',
__InputValue: '__InputValue',
__EnumValue: '__EnumValue',
__Directive: '__Directive',
__DirectiveLocation: '__DirectiveLocation',
Date: 'Date',
},
_implementations: {},
__validationErrors: [],
},
fragments: {},
operation: {
kind: 'OperationDefinition',
operation: 'query',
variableDefinitions: [],
directives: [],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: {
kind: 'Name',
value: 'users',
loc: { start: 6, end: 11 },
},
arguments: [],
directives: [],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: {
kind: 'Name',
value: 'fullname',
loc: { start: 12, end: 20 },
},
arguments: [],
directives: [],
loc: { start: 12, end: 20 },
},
{
kind: 'Field',
name: {
kind: 'Name',
value: 'comments',
loc: { start: 21, end: 29 },
},
arguments: [
{
kind: 'Argument',
name: {
kind: 'Name',
value: 'approved',
loc: { start: 30, end: 38 },
},
value: {
kind: 'BooleanValue',
value: true,
loc: { start: 39, end: 43 },
},
loc: { start: 30, end: 43 },
},
],
directives: [],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: {
kind: 'Name',
value: 'text',
loc: { start: 45, end: 49 },
},
arguments: [],
directives: [],
loc: { start: 45, end: 49 },
},
{
kind: 'Field',
name: {
kind: 'Name',
value: 'user',
loc: { start: 50, end: 54 },
},
arguments: [],
directives: [],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: {
kind: 'Name',
value: 'firstname',
loc: {
start: 55,
end: 64,
},
},
arguments: [],
directives: [],
loc: {
start: 55,
end: 64,
},
},
],
loc: { start: 54, end: 65 },
},
loc: { start: 50, end: 65 },
},
],
loc: { start: 44, end: 66 },
},
loc: { start: 21, end: 66 },
},
],
loc: { start: 11, end: 67 },
},
loc: { start: 6, end: 67 },
},
],
loc: { start: 5, end: 68 },
},
loc: { start: 0, end: 68 },
},
variableValues: {},
};

View file

@ -0,0 +1,18 @@
import astToBody, { Symbols } from '../lib/astToBody';
import ast from './ast.js';
describe('#astToBody', function() {
it('Should properly parse the body with arguments and such', function() {
const result = astToBody(ast);
assert.equal(result.fullname, 1);
assert.isObject(result.comments);
const commentArgs = result.comments[Symbols.ARGUMENTS];
assert.equal(commentArgs.approved, true);
assert.equal(result.comments.text, 1);
assert.isObject(result.comments.user);
assert.equal(result.comments.user.firstname, 1);
});
});

View file

@ -0,0 +1,131 @@
import { Symbols } from '../lib/astToBody';
import astToQuery, {
deny,
createGetArgs,
getMaxDepth,
} from '../lib/astToQuery';
describe('#astToQuery', function() {
it('#createGetArgs', function() {
const getArgs = createGetArgs({
a: {
[Symbols.ARGUMENTS]: { approved: false },
b: {
[Symbols.ARGUMENTS]: { approved: true },
c: {
[Symbols.ARGUMENTS]: { approved: true },
},
d: {},
},
},
});
assert.isFalse(getArgs('a').approved);
assert.isTrue(getArgs('a.b').approved);
assert.isTrue(getArgs('a.b.c').approved);
assert.isTrue(Object.keys(getArgs('a.b.d')).length === 0);
});
it('#deny', function() {
let body = {
test: 1,
testDeny: 1,
nested: {
testDeny: 1,
testAllow: 1,
},
nestedEmpty: {
disallow: 1,
},
nestedDeny: {
a: 1,
b: 1,
c: 1,
},
heavy: {
nest: {
ting: {
wup: {
denyThis: 1,
},
},
},
},
};
deny(body, [
'testDeny',
'nested.testDeny',
'nestedEmpty.disallow',
'nestedDeny',
'heavy.nest.ting.wup.denyThis',
]);
assert.isDefined(body.test);
assert.isUndefined(body.testDeny);
assert.isDefined(body.nested.testAllow);
assert.isUndefined(body.nested.testDeny);
assert.isUndefined(body.nestedDeny);
assert.isUndefined(body.heavy);
});
it('#getMaxDepth', function() {
let body = {
a: 1,
b: 2,
};
assert.equal(getMaxDepth(body), 1);
body = {
a: {
b: 1,
},
b: 1,
};
assert.equal(getMaxDepth(body), 2);
body = {
a: {
b: 1,
c: {
d: {
a: 1,
},
},
},
b: 1,
c: {
a: 1,
},
};
assert.equal(getMaxDepth(body), 4);
body = {
a: {
b: {
c: {
d: {
e: {
a: 1,
},
},
},
},
},
b: {
c: {
d: {
e: {
a: 1,
},
},
},
},
};
assert.equal(getMaxDepth(body), 6);
});
});

View file

@ -0,0 +1,2 @@
import './astToBody.test';
import './astToQuery.test';

View file

@ -1,8 +1,8 @@
import { Mongo } from 'meteor/mongo';
import {LINK_STORAGE} from './constants.js';
import { LINK_STORAGE } from './constants.js';
import Linker from './linker.js';
_.extend(Mongo.Collection.prototype, {
Object.assign(Mongo.Collection.prototype, {
/**
* The data we add should be valid for config.schema.js
*/
@ -13,17 +13,21 @@ _.extend(Mongo.Collection.prototype, {
_.each(data, (linkConfig, linkName) => {
if (this[LINK_STORAGE][linkName]) {
throw new Meteor.Error(`You cannot add the link with name: ${linkName} because it was already added to ${this._name} collection`)
throw new Meteor.Error(
`You cannot add the link with name: ${linkName} because it was already added to ${
this._name
} collection`
);
}
const linker = new Linker(this, linkName, linkConfig);
_.extend(this[LINK_STORAGE], {
[linkName]: linker
[linkName]: linker,
});
});
},
getLinks() {
return this[LINK_STORAGE];
},
@ -46,32 +50,39 @@ _.extend(Mongo.Collection.prototype, {
let linkData = this[LINK_STORAGE];
if (!linkData) {
throw new Meteor.Error(`There are no links defined for collection: ${this._name}`);
throw new Meteor.Error(
`There are no links defined for collection: ${this._name}`
);
}
if (!linkData[name]) {
throw new Meteor.Error(`There is no link ${name} for collection: ${this._name}`);
throw new Meteor.Error(
`There is no link ${name} for collection: ${this._name}`
);
}
const linker = linkData[name];
let object = objectOrId;
if (typeof(objectOrId) == 'string') {
if (typeof objectOrId == 'string') {
if (!linker.isVirtual()) {
object = this.findOne(objectOrId, {
fields: {
[linker.linkStorageField]: 1
}
[linker.linkStorageField]: 1,
},
});
} else {
object = {_id: objectOrId};
object = { _id: objectOrId };
}
if (!object) {
throw new Meteor.Error(`We could not find any object with _id: "${objectOrId}" within the collection: ${this._name}`);
throw new Meteor.Error(
`We could not find any object with _id: "${objectOrId}" within the collection: ${
this._name
}`
);
}
}
return linkData[name].createLink(object);
}
},
});

View file

@ -4,89 +4,88 @@
// CommentCollection,
// ResolverCollection
//} from './collections.js';
let PostCollection = new Mongo.Collection('test_post');
let CategoryCollection = new Mongo.Collection('test_category');
let CommentCollection = new Mongo.Collection('test_comment');
let ResolverCollection = new Mongo.Collection('test_resolver');
PostCollection.addLinks({
'comments': {
comments: {
type: '*',
collection: CommentCollection,
field: 'commentIds',
index: true
index: true,
},
'autoRemoveComments': {
autoRemoveComments: {
type: '*',
collection: CommentCollection,
field: 'autoRemoveCommentIds',
autoremove: true
autoremove: true,
},
'autoRemovingSelfComments': {
autoRemovingSelfComments: {
type: '*',
collection: CommentCollection,
field: 'autoRemovingSelfCommentsIds',
},
'metaComments': {
metaComments: {
type: '*',
collection: CommentCollection,
metadata: true
metadata: true,
},
category: {
collection: CategoryCollection,
type: '1'
type: '1',
},
metaCategory: {
metadata: true,
collection: CategoryCollection,
type: '1'
type: '1',
},
inversedComment: {
collection: CommentCollection,
inversedBy: 'inversedPost'
}
inversedBy: 'inversedPost',
},
});
CommentCollection.addLinks({
post: {
collection: PostCollection,
inversedBy: 'comments'
inversedBy: 'comments',
},
inversedPost: {
collection: PostCollection,
field: 'postId'
field: 'postId',
},
autoRemovePosts: {
collection: PostCollection,
inversedBy: 'autoRemovingSelfComments',
autoremove: true
autoremove: true,
},
metaPost: {
collection: PostCollection,
inversedBy: 'metaComments'
}
inversedBy: 'metaComments',
},
});
CategoryCollection.addLinks({
'posts': {
posts: {
collection: PostCollection,
inversedBy: 'category'
inversedBy: 'category',
},
'metaPosts': {
metaPosts: {
collection: PostCollection,
inversedBy: 'metaCategory'
}
inversedBy: 'metaCategory',
},
});
describe('Collection Links', function () {
describe('Collection Links', function() {
PostCollection.remove({});
CategoryCollection.remove({});
CommentCollection.remove({});
it('Test Many', function () {
let postId = PostCollection.insert({'text': 'abc'});
let commentId = CommentCollection.insert({'text': 'abc'});
it('Test Many', function() {
let postId = PostCollection.insert({ text: 'abc' });
let commentId = CommentCollection.insert({ text: 'abc' });
let post = PostCollection.findOne(postId);
const link = PostCollection.getLink(post, 'comments');
@ -97,9 +96,9 @@ describe('Collection Links', function () {
assert.lengthOf(link.find().fetch(), 0);
});
it('Tests One', function () {
let postId = PostCollection.insert({'text': 'abc'});
let categoryId = CategoryCollection.insert({'text': 'abc'});
it('Tests One', function() {
let postId = PostCollection.insert({ text: 'abc' });
let categoryId = CategoryCollection.insert({ text: 'abc' });
let post = PostCollection.findOne(postId);
@ -113,14 +112,14 @@ describe('Collection Links', function () {
assert.lengthOf(link.find().fetch(), 0);
});
it('Tests One Meta', function () {
let postId = PostCollection.insert({'text': 'abc'});
let categoryId = CategoryCollection.insert({'text': 'abc'});
it('Tests One Meta', function() {
let postId = PostCollection.insert({ text: 'abc' });
let categoryId = CategoryCollection.insert({ text: 'abc' });
let post = PostCollection.findOne(postId);
let link = PostCollection.getLink(post, 'metaCategory');
link.set(categoryId, {date: new Date()});
link.set(categoryId, { date: new Date() });
assert.lengthOf(link.find().fetch(), 1);
let metadata = link.metadata();
@ -129,7 +128,7 @@ describe('Collection Links', function () {
assert.instanceOf(metadata.date, Date);
link.metadata({
updated: new Date()
updated: new Date(),
});
post = PostCollection.findOne(postId);
@ -140,14 +139,14 @@ describe('Collection Links', function () {
assert.lengthOf(link.find().fetch(), 0);
});
it('Tests Many Meta', function () {
let postId = PostCollection.insert({'text': 'abc'});
let commentId = CommentCollection.insert({'text': 'abc'});
it('Tests Many Meta', function() {
let postId = PostCollection.insert({ text: 'abc' });
let commentId = CommentCollection.insert({ text: 'abc' });
let post = PostCollection.findOne(postId);
let metaCommentsLink = PostCollection.getLink(post, 'metaComments');
metaCommentsLink.add(commentId, {date: new Date});
metaCommentsLink.add(commentId, { date: new Date() });
assert.lengthOf(metaCommentsLink.find().fetch(), 1);
// verifying reverse search
@ -160,7 +159,7 @@ describe('Collection Links', function () {
assert.isObject(metadata);
assert.instanceOf(metadata.date, Date);
metaCommentsLink.metadata(commentId, {updated: new Date});
metaCommentsLink.metadata(commentId, { updated: new Date() });
post = PostCollection.findOne(postId);
metaCommentsLink = PostCollection.getLink(post, 'metaComments');
@ -172,62 +171,74 @@ describe('Collection Links', function () {
assert.lengthOf(metaCommentsLink.find().fetch(), 0);
});
it('Tests $meta filters for One & One-Virtual', function () {
let postId = PostCollection.insert({'text': 'abc'});
let categoryId = CategoryCollection.insert({'text': 'abc'});
it('Tests $meta filters for One & One-Virtual', function() {
let postId = PostCollection.insert({ text: 'abc' });
let categoryId = CategoryCollection.insert({ text: 'abc' });
let post = PostCollection.findOne(postId);
let postMetaCategoryLink = PostCollection.getLink(post, 'metaCategory');
postMetaCategoryLink.set(categoryId, {valid: true});
postMetaCategoryLink.set(categoryId, { valid: true });
let result = postMetaCategoryLink.fetch({$meta: {valid: true}});
let result = postMetaCategoryLink.fetch({ $meta: { valid: true } });
assert.isObject(result);
result = postMetaCategoryLink.fetch({$meta: {valid: false}});
result = postMetaCategoryLink.fetch({ $meta: { valid: false } });
assert.isUndefined(result);
const metaCategoryPostLink = CategoryCollection.getLink(categoryId, 'metaPosts');
const metaCategoryPostLink = CategoryCollection.getLink(
categoryId,
'metaPosts'
);
result = metaCategoryPostLink.fetch({$meta: {valid: true}});
result = metaCategoryPostLink.fetch({ $meta: { valid: true } });
assert.lengthOf(result, 1);
result = metaCategoryPostLink.fetch({$meta: {valid: false}});
result = metaCategoryPostLink.fetch({ $meta: { valid: false } });
assert.lengthOf(result, 0);
});
it('Tests $meta filters for Many & Many-Virtual', function () {
let postId = PostCollection.insert({'text': 'abc'});
let commentId1 = CommentCollection.insert({'text': 'abc'});
let commentId2 = CommentCollection.insert({'text': 'abc'});
it('Tests $meta filters for Many & Many-Virtual', function() {
let postId = PostCollection.insert({ text: 'abc' });
let commentId1 = CommentCollection.insert({ text: 'abc' });
let commentId2 = CommentCollection.insert({ text: 'abc' });
let postMetaCommentsLink = PostCollection.getLink(postId, 'metaComments');
let postMetaCommentsLink = PostCollection.getLink(
postId,
'metaComments'
);
postMetaCommentsLink.add(commentId1, {approved: true});
postMetaCommentsLink.add(commentId2, {approved: false});
postMetaCommentsLink.add(commentId1, { approved: true });
postMetaCommentsLink.add(commentId2, { approved: false });
let result = postMetaCommentsLink.fetch({$meta: {approved: true}});
let result = postMetaCommentsLink.fetch({ $meta: { approved: true } });
assert.lengthOf(result, 1);
result = postMetaCommentsLink.fetch({$meta: {approved: false}});
result = postMetaCommentsLink.fetch({ $meta: { approved: false } });
assert.lengthOf(result, 1);
const comment1MetaPostsLink = CommentCollection.getLink(commentId1, 'metaPost');
result = comment1MetaPostsLink.fetch({$meta: {approved: true}});
const comment1MetaPostsLink = CommentCollection.getLink(
commentId1,
'metaPost'
);
result = comment1MetaPostsLink.fetch({ $meta: { approved: true } });
assert.lengthOf(result, 1);
result = comment1MetaPostsLink.fetch({$meta: {approved: false}});
result = comment1MetaPostsLink.fetch({ $meta: { approved: false } });
assert.lengthOf(result, 0);
const comment2MetaPostsLink = CommentCollection.getLink(commentId2, 'metaPost');
result = comment2MetaPostsLink.fetch({$meta: {approved: true}});
const comment2MetaPostsLink = CommentCollection.getLink(
commentId2,
'metaPost'
);
result = comment2MetaPostsLink.fetch({ $meta: { approved: true } });
assert.lengthOf(result, 0);
result = comment2MetaPostsLink.fetch({$meta: {approved: false}});
result = comment2MetaPostsLink.fetch({ $meta: { approved: false } });
assert.lengthOf(result, 1);
});
it('Tests inversedBy findings', function () {
let postId = PostCollection.insert({'text': 'abc'});
let commentId = CommentCollection.insert({'text': 'abc'});
it('Tests inversedBy findings', function() {
let postId = PostCollection.insert({ text: 'abc' });
let commentId = CommentCollection.insert({ text: 'abc' });
let post = PostCollection.findOne(postId);
let comment = CommentCollection.findOne(commentId);
@ -246,17 +257,19 @@ describe('Collection Links', function () {
assert.notInclude(post.commentIds, comment._id);
});
it ('Should auto-save object', function () {
let comment = {text: 'abc'};
it('Should auto-save object', function() {
let comment = { text: 'abc' };
let postId = PostCollection.insert({text: 'hello'});
const postLink = PostCollection.getLink(postId, 'comments').add(comment);
let postId = PostCollection.insert({ text: 'hello' });
const postLink = PostCollection.getLink(postId, 'comments').add(
comment
);
assert.isDefined(comment._id);
assert.lengthOf(postLink.fetch(), 1);
});
it ('Should have indexes set up', function () {
it('Should have indexes set up', function() {
const raw = PostCollection.rawCollection();
const indexes = Meteor.wrapAsync(raw.indexes, raw)();
@ -267,58 +280,77 @@ describe('Collection Links', function () {
assert.isObject(found);
});
it ('Should auto-remove some objects', function () {
let comment = {text: 'abc'};
it('Should auto-remove some objects', function() {
let comment = { text: 'abc' };
let postId = PostCollection.insert({text: 'hello'});
let postId = PostCollection.insert({ text: 'hello' });
let postLink = PostCollection.getLink(postId, 'comments').add(comment);
assert.isNotNull(comment._id);
PostCollection.remove(postId);
assert.isNotNull(CommentCollection.findOne(comment._id));
comment = {text: 'abc'};
postId = PostCollection.insert({text: 'hello'});
postLink = PostCollection.getLink(postId, 'autoRemoveComments').add(comment);
comment = { text: 'abc' };
postId = PostCollection.insert({ text: 'hello' });
postLink = PostCollection.getLink(postId, 'autoRemoveComments').add(
comment
);
assert.isDefined(comment._id);
PostCollection.remove(postId);
assert.isUndefined(CommentCollection.findOne(comment._id));
});
it('Should allow actions from inversed links', function () {
let comment = {text: 'abc'};
it('Should allow actions from inversed links', function() {
let comment = { text: 'abc' };
let postId = PostCollection.insert({text: 'hello'});
let postId = PostCollection.insert({ text: 'hello' });
const commentId = CommentCollection.insert(comment);
CommentCollection.getLink(commentId, 'post').set(postId);
assert.lengthOf(PostCollection.getLink(postId, 'comments').fetch(), 1);
CommentCollection.getLink(commentId, 'post').add({text: 'hi there'});
CommentCollection.getLink(commentId, 'post').add({ text: 'hi there' });
let insertedPostViaVirtual = PostCollection.findOne({text: 'hi there'});
let insertedPostViaVirtual = PostCollection.findOne({
text: 'hi there',
});
assert.isObject(insertedPostViaVirtual);
assert.lengthOf(PostCollection.getLink(insertedPostViaVirtual, 'comments').fetch(), 1);
assert.lengthOf(
PostCollection.getLink(insertedPostViaVirtual, 'comments').fetch(),
1
);
const category = CategoryCollection.findOne();
let postsCategoryLink = CategoryCollection.getLink(category, 'posts');
postsCategoryLink.add(insertedPostViaVirtual);
assert.equal(category._id, PostCollection.getLink(insertedPostViaVirtual, 'category').fetch()._id);
assert.equal(
category._id,
PostCollection.getLink(insertedPostViaVirtual, 'category').fetch()
._id
);
// TESTING META
let categoryMetaPostLink = CategoryCollection.getLink(category, 'metaPosts');
categoryMetaPostLink.add(insertedPostViaVirtual, {testValue: 'boom!'});
let categoryMetaPostLink = CategoryCollection.getLink(
category,
'metaPosts'
);
categoryMetaPostLink.add(insertedPostViaVirtual, {
testValue: 'boom!',
});
let postMetaCategoryLink = PostCollection.getLink(insertedPostViaVirtual, 'metaCategory');
let postMetaCategoryLink = PostCollection.getLink(
insertedPostViaVirtual,
'metaCategory'
);
assert.equal('boom!', postMetaCategoryLink.metadata().testValue);
});
it('Should fail when you try to add a non-existing link', function (done) {
let postId = PostCollection.insert({text: 'hello'});
it('Should fail when you try to add a non-existing link', function(done) {
let postId = PostCollection.insert({ text: 'hello' });
try {
PostCollection.getLink(postId, 'comments').add('XXXXXXX');
@ -328,12 +360,15 @@ describe('Collection Links', function () {
}
});
it('Should work with autoremoval from inversed and direct link', function () {
it('Should work with autoremoval from inversed and direct link', function() {
// autoremoval from direct side
let postId = PostCollection.insert({text: 'autoremove'});
const postAutoRemoveCommentsLink = PostCollection.getLink(postId, 'autoRemoveComments');
let postId = PostCollection.insert({ text: 'autoremove' });
const postAutoRemoveCommentsLink = PostCollection.getLink(
postId,
'autoRemoveComments'
);
postAutoRemoveCommentsLink.add({text: 'hello'});
postAutoRemoveCommentsLink.add({ text: 'hello' });
assert.lengthOf(postAutoRemoveCommentsLink.find().fetch(), 1);
let commentId = postAutoRemoveCommentsLink.find().fetch()[0]._id;
@ -342,12 +377,14 @@ describe('Collection Links', function () {
PostCollection.remove(postId);
assert.isUndefined(CommentCollection.findOne(commentId));
// now from inversed side
commentId = CommentCollection.insert({text: 'autoremove'});
commentId = CommentCollection.insert({ text: 'autoremove' });
const commentAutoRemovePostsLink = CommentCollection.getLink(commentId, 'autoRemovePosts');
commentAutoRemovePostsLink.add({text: 'Hello'});
const commentAutoRemovePostsLink = CommentCollection.getLink(
commentId,
'autoRemovePosts'
);
commentAutoRemovePostsLink.add({ text: 'Hello' });
assert.lengthOf(commentAutoRemovePostsLink.find().fetch(), 1);
postId = commentAutoRemovePostsLink.find().fetch()[0]._id;
@ -356,4 +393,4 @@ describe('Collection Links', function () {
CommentCollection.remove(commentId);
assert.isUndefined(PostCollection.findOne(postId));
});
});
});

View file

@ -1,4 +1,5 @@
import {check} from 'meteor/check';
import { check } from 'meteor/check';
import addFieldMap from './lib/addFieldMap';
const storage = '__reducers';
Object.assign(Mongo.Collection.prototype, {
@ -16,20 +17,28 @@ Object.assign(Mongo.Collection.prototype, {
}
if (this.getLinker(reducerName)) {
throw new Meteor.Error(`You cannot add the reducer with name: ${reducerName} because it is already defined as a link in ${this._name} collection`)
throw new Meteor.Error(
`You cannot add the reducer with name: ${reducerName} because it is already defined as a link in ${
this._name
} collection`
);
}
if (this[reducerConfig][reducerName]) {
throw new Meteor.Error(`You cannot add the reducer with name: ${reducerName} because it was already added to ${this._name} collection`)
throw new Meteor.Error(
`You cannot add the reducer with name: ${reducerName} because it was already added to ${
this._name
} collection`
);
}
check(reducerConfig, {
body: Object,
reduce: Function
reduce: Function,
});
_.extend(this[storage], {
[reducerName]: reducerConfig
[reducerName]: reducerConfig,
});
});
},
@ -42,5 +51,10 @@ Object.assign(Mongo.Collection.prototype, {
if (this[storage]) {
return this[storage][name];
}
}
});
},
/**
* This creates reducers that makes sort of aliases for the database fields we use
*/
addFieldMap,
});

View file

@ -0,0 +1,20 @@
/**
* @param {[niceField: string]: dbField} map
*/
export default function addFieldMap(map) {
const collection = this;
let reducers = {};
for (let key in map) {
const dbField = map[key];
reducers[key] = {
body: {
[dbField]: 1,
},
reduce(obj) {
return obj[dbField];
},
};
}
collection.addReducers(reducers);
}

View file

@ -2,22 +2,14 @@ import './lib/extension.js';
import './lib/links/extension.js';
import './lib/query/reducers/extension.js';
export {
default as createQuery
} from './lib/createQuery.js';
export { default as createQuery } from './lib/createQuery.js';
export {
default as prepareForProcess
default as prepareForProcess,
} from './lib/query/lib/prepareForProcess';
export {
default as Query
} from './lib/query/query.client';
export { default as Query } from './lib/query/query.client';
export {
default as NamedQuery
} from './lib/namedQuery/namedQuery.client';
export { default as NamedQuery } from './lib/namedQuery/namedQuery.client';
export {
default as compose
} from './lib/compose';
export { default as compose } from './lib/compose';

View file

@ -7,27 +7,21 @@ import './lib/namedQuery/expose/extension.js';
import NamedQueryStore from './lib/namedQuery/store';
import LinkConstants from './lib/links/constants';
export {
NamedQueryStore,
LinkConstants
}
export { NamedQueryStore, LinkConstants };
export { default as createQuery } from './lib/createQuery.js';
export { default as Exposure } from './lib/exposure/exposure.js';
export {
default as createQuery
} from './lib/createQuery.js';
export {
default as Exposure
} from './lib/exposure/exposure.js';
export {
default as MemoryResultCacher
default as MemoryResultCacher,
} from './lib/namedQuery/cache/MemoryResultCacher';
export {
default as BaseResultCacher
default as BaseResultCacher,
} from './lib/namedQuery/cache/BaseResultCacher';
export {
default as compose
} from './lib/compose';
export { default as compose } from './lib/compose';
export * from './lib/graphql';
export { default as db } from './lib/db';

View file

@ -68,7 +68,7 @@ Package.onTest(function(api) {
api.addFiles('lib/query/testing/bootstrap/index.js');
// When you play with tests you should comment this to make tests go faster.
api.addFiles('lib/query/testing/bootstrap/fixtures.js', 'server');
// api.addFiles('lib/query/testing/bootstrap/fixtures.js', 'server');
api.addFiles('lib/query/testing/server.test.js', 'server');
api.addFiles('lib/query/testing/client.test.js', 'client');
@ -82,6 +82,10 @@ Package.onTest(function(api) {
api.addFiles('lib/query/counts/testing/server.test.js', 'server');
api.addFiles('lib/query/counts/testing/client.test.js', 'client');
// NAMED QUERIES
api.addFiles('lib/namedQuery/testing/server.test.js', 'server');
api.addFiles('lib/namedQuery/testing/client.test.js', 'client');
// GRAPHQL
api.addFiles('lib/graphql/testing/index.js', 'server');
});