From 35f297c395cc0c19ebc4040b65f6a01c304677bb Mon Sep 17 00:00:00 2001 From: Theodor Diaconu Date: Thu, 30 Nov 2017 22:11:43 +0200 Subject: [PATCH] Added documentation --- CHANGELOG.md | 2 + MIGRATION.md | 7 +- README.md | 145 ++++++----- docs/api.md | 164 ++++++++++++ docs/caching_results.md | 139 ++++++++++ docs/denormalization.md | 148 +++++++++++ docs/global_exposure.md | 340 +++++++++++++++++++++++++ docs/hypernova.md | 77 ++++++ docs/introduction.md | 220 ++++++++++++++++ docs/linker_engine.md | 170 +++++++++++++ docs/linking_collections.md | 453 +++++++++++++++++++++++++++++++++ docs/named_queries.md | 398 +++++++++++++++++++++++++++++ docs/outside_grapher.md | 100 ++++++++ docs/query_options.md | 375 +++++++++++++++++++++++++++ docs/reducers.md | 196 ++++++++++++++ docs/structure_and_patterns.md | 108 ++++++++ docs/table_of_contents.md | 53 ++++ main.client.js | 4 + main.server.js | 8 + 19 files changed, 3048 insertions(+), 59 deletions(-) create mode 100644 docs/api.md create mode 100644 docs/caching_results.md create mode 100644 docs/denormalization.md create mode 100644 docs/global_exposure.md create mode 100644 docs/hypernova.md create mode 100644 docs/introduction.md create mode 100644 docs/outside_grapher.md create mode 100644 docs/query_options.md create mode 100644 docs/reducers.md create mode 100644 docs/structure_and_patterns.md create mode 100644 docs/table_of_contents.md diff --git a/CHANGELOG.md b/CHANGELOG.md index e29c951..2052ada 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ - Added link caching - Added named query results caching - Added subbody to NamedQuery +- Added named query first resolver +- Bug fixes and other small stuff ## 1.2.5 - Support for promises via .fetchSync and .fetchOneSync for client-side queries diff --git a/MIGRATION.md b/MIGRATION.md index 57e3c76..be31514 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -37,4 +37,9 @@ createNamedQuery('xxx', {}); // working createQuery('xxx', {}); -``` \ No newline at end of file +``` + +If you used `$postFilter` in your queries, rename it to `$postFilters` + +If you used resolver links, migrate to reducers. + diff --git a/README.md b/README.md index a15295b..6be418d 100644 --- a/README.md +++ b/README.md @@ -1,76 +1,105 @@ -Welcome to Grapher -================== +# Grapher 1.3 [![Build Status](https://api.travis-ci.org/cult-of-coders/grapher.svg?branch=master)](https://travis-ci.org/cult-of-coders/grapher) -Documentation -------------- -[http://grapher.cultofcoders.com](http://grapher.cultofcoders.com/) +*Grapher* is a data retrieval layer inside Meteor and MongoDB. -Long Term Support ------------------ -Version 1.2 will be supported until 2020. +Main features: +- Innovative way to make MongoDB relational +- Reactive data graphs for high availability +- Incredible performance +- Denormalization Modules +- Connection to external data sources +- Usable from anywhere -What ? ------- -*Grapher* is a high performance data fetcher and collection relationship manager for Meteor and MongoDB: +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. -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. -4. It is compatible with simpl-schema and the older version of it. - -Sample -------------- - -To give you an idea how this works, you can fetch the data like this: - -``` -{ - users: { - profile: 1, - githubTickets: {}, - posts: { - title: 1, - comments: { - text: 1, - date: 1, - author: { - profile: 1 - } - } - } - } -} -``` - -Updates -------- -Check-out the [CHANGELOG](CHANGELOG.md) for latest updates. - -Installation ------------- +## Installation ``` meteor add cultofcoders:grapher ``` -Useful packages and integrations --------------------------------- +## [Documentation](docs/table_of_contents.md) -#### Integration with React (cultofcoders:grapher-react) +## [API](docs/api.md) -Provides you with an easy to use "createQueryContainer" function. +## Quick Illustration -- [Atmosphere](https://atmospherejs.com/cultofcoders/grapher-react) -- [GitHub](https://github.com/cult-of-coders/grapher-react/) + + + + + +
+
+import {createQuery} from 'meteor/cultofcoders-grapher';
 
-#### Live View (cultofcoders:grapher-live)
+createQuery({
+    posts: {
+        title: 1,
+        author: {
+            fullName: 1
+        },
+        comments: {
+            text: 1,
+            createdAt: 1,
+            author: {
+                fullName: 1
+            }
+        },
+        categories: {
+            name: 1
+        }
+    }
+}).fetch();
+
+
+
+[
+    {
+        _id: 'postId',
+        title: 'Introducing Grapher',
+        author: {
+            _id: 'authorId',
+            fullName: 'John Smith
+        },
+        comments: [
+            {
+                _id: 'commentId',
+                text: 'Nice article!,
+                createdAt: Date,
+                author: {
+                    fullName: 1
+                }
+            }
+        ],
+        categories: [ {_id: 'categoryId', name: 'JavaScript'} ]
+    }
+]
+
+
+ +## Useful packages and integrations + +### Live View (cultofcoders:grapher-live) Provides a playground for grapher and provides documentation of your data -- [Atmosphere](https://atmospherejs.com/cultofcoders/grapher-live) -- [GitHub](https://github.com/cult-of-coders/grapher-live) +https://github.com/cult-of-coders/grapher-live -Boiler plate Meteor + React + Grapher -------------------------------------- -https://github.com/cult-of-coders/grapher-boilerplate +### Integration with UI Frameworks (cultofcoders:grapher-react) + +#### React +https://github.com/cult-of-coders/grapher-react + +#### Vue JS +https://github.com/Herteby/grapher-vue + +https://github.com/cult-of-coders/grapher-react + + +## Premium Support + +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. diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..e1dbc6b --- /dev/null +++ b/docs/api.md @@ -0,0 +1,164 @@ +# API + +Use this as a cheatsheet after you have read the full documentation. + +### Adding Links + +```js +Collection.addLinks({ + linkName: { + collection, // Mongo.Collection + type, // 'one' or 'many' + metadata, // Boolean + field, // String + denormalize: { + field, // String + body, // Body from related collection + } + } +}) + +Collection.addLinks({ + linkName: { + collection, // Mongo.Collection + inversedBy, // The link name from the other side + denormalize: { + field, // String + body, // Body from related collection + } + } +}) +``` + +### Adding Reducers + +```js +Collection.addReducers({ + reducerName: { + body, // Object, dependency graph + compute(object) { + // anything + } + } +}) +``` + +### Creating Named Query + +```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 +}) +``` + +### Exposing Named Query + +```js +query.expose({ + firewall(userId, params) {}, // Function or [Function] + method, // Boolean + publication, // Boolean + unblock, // Boolean + validateParams, // Function or Object + embody // Object which extends the body server-side securely +}) +``` + +### Creating and Exposting Resolvers + +```js +// both +const query = createQuery('queryName', () => {}); + +// server +query.expose({ + firewall, // Function or [Function] +}); + +query.resolve(function (params) { + // this.userId + return []; +}); +``` + +### Using Query + +```js +query.setParams({}) // extends current params +``` + +#### Server-Side +```js +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) => {}); +``` + +Reactive: +```js +const query = userListQuery.clone({params}); + +const handle = query.subscribe(); // handle.ready() +const data = query.fetch(); +const oneData = query.fetchOne(); + +const handleCount = query.subscribeCount(); +const count = query.getCount(); +``` + +#### Caching Named Queries +```js +import {MemoryResultCacher} from 'meteor/cultofcoders:grapher'; + +// server-side +query.cacheResults(new MemoryResultCacher({ + ttl: 60 * 1000, // 60 seconds +})) +``` + +#### Creating Global Query +```js +Collection.createQuery({ + $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, // Function => results, or [Function] => results + body, // the rest of the object +}) +``` + +#### Exposing Global Query + +```js +Collection.expose({ + firewall(filters, options, userId) {}, // Function or [Function] + publication, // Boolean + method, // Boolean + blocking, // Boolean + maxLimit, // Number + maxDepth, // Number + restrictedFields, // [String] + restrictLinks, // [String] or Function, + body, // Object or Function(userId) => Object +}); +``` \ No newline at end of file diff --git a/docs/caching_results.md b/docs/caching_results.md new file mode 100644 index 0000000..02edd8b --- /dev/null +++ b/docs/caching_results.md @@ -0,0 +1,139 @@ +# Caching Results + +Grapher is already performant enough to remove this necessity, but what if we want +to squeeze even more performance? What can we do? We cache'em up. + +Caching results only works for Named Queries. + +Let's say we have some very heavy queries, that do complex sorting +and complex filtering: + +```js +export default Meteor.users.createQuery('myFriendsEmails', { + $filter({filters, params}) { + filters.friendIds = params.userId; + }, + $options: { + sort: {createdAt: -1} + }, + email: 1, +}) +``` + +Ok maybe it's not that complex or heavy, but use your imagination, imagine we have 100,000,000 users +inside the database. And you just want to look for your friends in this big world. + +```js +// server-side +import myFriendsQuery from '...'; +import {MemoryResultCacher} from 'meteor/cultofcoders:grapher'; + +myFriendsQuery.expose({ + firewall(userId, params) { + params.userId = userId; + } +}); + +const cacher = new MemoryResultCacher({ + ttl: 60 * 1000, // 1 minute caching +}); + +myFriendsQuery.cacheResults(cacher); +``` + +That's it. If there is no cache, it fetches the database and stores the result in memory, and after 60s it clears the cache. +Any other calls done in between this time, will hit the cache. + +The caching is done by serializing the params and prefixed by query name, in our case, the cache id looks like: +``` +myFriendsEmails{userId:"XXX"} +count::myFriendsEmails{userId:"XXX"} +``` + +The cacher will be used regardless if you are on server or on the client. +The cacher also works with counts. When you use `query.getCount()` + +## Implementing your own + +The cacher that you provide exposes: +``` +fetch(cacheId, { + query: {fetch()}, + countCursor: {count()} +}) +generateQueryId(queryName, params) +``` + +If you want to roll-out your own cache that stores the data outside memory, and you want to share in your cloud, +you may want to use `redis` for that. + +```js +import {BaseResultCacher} from 'meteor/cultofcoders:grapher'; + +/** + * Redis Cacher + */ +export default class RedisCacher extends BaseResultCacher { + // this is the default one in case you need to override it + generateQueryId(queryName, params) { + return `${queryName}::${EJSON.stringify(params)}`; + } + + // in case of a count cursor cacheId gets prefixed with 'count::' + fetch(cacheId, fetchables) { + const {client, ttl} = this.config; + + const cacheData = client.get(cacheId); + if (cacheData) { + return EJSON.parse(cacheData); + } + + const data = BaseResultCacher.fetchData(fetchables); + client.set(cacheId, data, 'PX', ttl); + + return data; + } +} +``` + +And use it: + +```js +import RedisCacher from 'somewhere'; + +const cacher = new RedisCacher({ + client: redisClient, + ttl: 60 * 1000 +}); + +myFriendsQuery.cacheResults(cacher); +``` + +## Caching Resolver Queries + +As you may have guessed, this works with resolver queries as well, in their case, instead of the actual query, +we pass as `query` parameter an interface containing `fetch()`. + +And your cacher, will never receive `cursorCount` inside the `fetchables` object. + +Therefore you can use the same paradigms for your cache for resolver queries as well. + + +## Invalidation + +Unfortunately, if you want to invalidate your cache you can do it yourself manually, as this is not implemented, +but since you can hook in your own cacher, you can do whatever you want. + +## Conclusion + +If you have very heavy and frequent requests to the database, or any type of requests (resolver queries) you can think +about using a cache. + + + + + + + + + diff --git a/docs/denormalization.md b/docs/denormalization.md new file mode 100644 index 0000000..a755676 --- /dev/null +++ b/docs/denormalization.md @@ -0,0 +1,148 @@ +# Denormalization + +Grapher allows you to denormalize your links, leveraging the power of `herteby:denormalize` package. +You can learn more about its capabilities here: https://github.com/Herteby/denormalize + +You may need denormalization in such cases where: +1. You want to avoid another database fetch +2. You need ability to perform complex filtering + +This has some caveats that we'll explore, let's check out a simple example first: + +```js +// Assuming Images of schema: {_id, path, smallThumb, createdAt} +import Images from '...'; + +Meteor.users.addLinks({ + avatar: { + collection: Images, + field: 'avatarId', + type: 'one', + denormalize: { + field: 'avatarCache', // in which field to store the cache + // what fields to cache from Images, this only works for fields and not links + body: { + path: 1, + smallThumb: 1, + } + } + } +}) +``` + +If you did not add this since the beginning of your app, you need to add this into your migration script: + +```js +import {migrate} from 'meteor/herteby:denormalize' +migrate('users', 'avatarCache'); +``` + +Now if you are doing the following query: +```js +const user = Meteor.users.createQuery({ + avatar: { + smallThumbPath: 1, + } +}).fetchOne() +``` + +Grapher will check to see if avatar's body is a subbody of the denormalized body. If yes, it will hit the cache, +leading to a single database request. + +And the result will be as you expect it to be: +``` +{ + _id: 'XXX', + avatar: { + _id: 'YYY', + smallThumbPath: '/path/to/file.small.png' + } +} +``` + +It works very well with nested fields, so you don't have to worry about that. + +However, a query like this: +```js +const user = Meteor.users.createQuery({ + avatar: { + smallThumbPath: 1, + createdAt: 1, + } +}).fetchOne() +``` + +Will result in a subsequent database request, because `createdAt` is not in the denormalized body. But if you swap it with `path` then it will hit the cache. + +When the user sets a new Avatar, or the `Image` object of that Avatar gets updated. The cache gets automatically updated, +so you don't have to worry about anything. It's magical. + +Denormalization works with any type of links `one`, `many`, `meta` whether they are `direct` or `inversed`. + +We previously tackled the case where we needed `$postFilters` or `$postFilter` to retrieve some special kind of data: + +Let's take a case: we want to retrieve only the users that have reviewed a book of a certain type, and inside `books` collection, +we have `reviewedByUserIds`. + +```js +Books.addLinks({ + 'reviewedByUsers': { + type: 'many', + collection: Meteor.users, + field: 'reviewedByUserIds', + } +}) + +Meteor.users.addLinks({ + 'bookReviews': { + collection: Books, + inversedBy: 'reviewedByUsers', + denormalize: { + body: { + type: 1, + }, + field: 'bookReviewsCache', + } + } +}); +``` + +*Note that this case may not necessarily be the best example, this is just for illustration, +if you have a better example in mind, let us know so we can update this documentation.* + +And now, I want to get all the users that have reviewed books of type `Drama`, because I want +to send them an email about a new book. + +```js +const dramaticUsers = Meteor.users.createQuery({ + $filters: { + 'bookReviewsCache.type': 'Drama' + }, + email: 1, +}).fetch(); +``` + +Denormalization comes with a price: +1. It adds hooks to the database so it can properly update it, therefore a change somewhere can result +into additional computation. +2. If you are not careful it can lead to very big caches, which is not what you want unless you favor performance over storage. + +The package also supports caching fields and caching counts. You can define those caches outside Grapher without a problem, and specify those fields in your query. + +## Caution + +If you want to use deep filters, it will not work with denormalized cache, you can use `$postFilter()` method for that. + +Because if you put `$filters: {}` inside the body of the cache, it will regard it as a foreign field, and it will fetch the linked Collection for it. + +A current limitation for denormalized meta links, is that we will no longer be able to store the `$metadata` inside the nested object, because that +would require additional fetching of the link storage, + +## Conclusion + +Using denormalization can enable you to do wonderful things inside NoSQL, but also be careful because they come with a cost, +that may not be very noticeable in the beginning. But can also dramatically improve performance at the same time. + +I suggest that they should be used to cache things that rarely change such as an user's avatar, or when you need to do +powerful and frequent searches, that otherwise would have consumed more resources. + diff --git a/docs/global_exposure.md b/docs/global_exposure.md new file mode 100644 index 0000000..f2a17db --- /dev/null +++ b/docs/global_exposure.md @@ -0,0 +1,340 @@ +## Global Queries + +Global queries are not recommended because they are very hard to secure. + +But they are very interesting in what they offer and they can prove to be very useful. +You can expose an API that has access to all or certain parts of your database, without +defining a named query for each. + +The difference between a `Named Query` and a `Global Query` is that the later +does not have their form defined on the server, the client can query for anything that he wishes +as long as the query is exposed and respects the security options. + +A `Global Query` is as almost feature rich as a `Named Query` with the exception of caching. + +#### Real life usage + +- You have a public database, you just want to expose it +- You have a multi-tenant system, and you want to give full database access to the tenant admin +- Other cases as well + +In order to query for a collection from the client-side and fetch it or subscribe to it. You must expose it. + +Exposing a collection does the following things: + +- Creates a method called: exposure_{collectionName} which accepts a query +- Creates a publication called: exposure_{collectionName} which accepts a query and uses [reywood:publish-composite](https://atmospherejs.com/reywood/publish-composite) to achieve reactive relationships. +- If firewall is specified, it extends *find* method of your collection, allowing an extra parameter: + +``` +Collection.find(filters, options, userId); +``` + +If *userId* is undefined, the firewall and constraints will not be applied. If the *userId* is *null*, the firewall will be applied. This is to allows server-side fetching without any restrictions. + +#### Exposing a collection to everyone + +```js +// server-side +Meteor.users.expose(); +``` + +This means that any user, from the client can do: +```js +createQuery({ + users: { + services: 1, // yes, everything becomes exposed + anyLink: { + anySubLink: { + // and it can go on and on and on + } + } + } +}) +``` + +Ok this is very bad. Let's secure it. + +```js +Meteor.users.expose({ + restrictedFields: ['services'], + restrictLinks: ['anyLink'], +}); +``` + +Phiew, that's better. But is it? You'll have to keep track of all the link restrictions. + +#### Exposing a collection to logged in users + +```js +// server-side +Collection.expose({ + firewall(filters, options, userId) { + if (!userId) { + throw new Meteor.Error('...'); + } + } +}); +``` + +#### Exposure Options + +```js +Collection.expose({ + // it can also be an array of functions + firewall(filters, options, userId) { + filters.userId = userId; + }, + // Allow reactive query-ing + publication: true, + // Allow static query-in + method: true, + // Unblock() the method/publication + blocking: false, + // The publication/method will not allow data fetching for more than 100 items. + maxLimit: 100, + // The publication/method will not allow a query with more than 3 levels deep. + maxDepth: 3, + // This will clean up filters, options.sort and options.fields and remove those fields from there. + // It even removes it from deep filters with $or, $nin, etc + restrictedFields: ['services', 'secretField'], + // Array of strings or a function that has userId + restrictLinks: ['link1', 'link2'] +}); +``` + +#### Exposure firewalls are linked + +When querying for a data-graph like: +``` +{ + users: { + comments: {} + } +} +``` + +It is not necessary to have an exposure for *comments*, however if you do have it, and it has a firewall. The firewall rules will be applied. +The reason for this is security. + +Don't worry about performance. We went great lengths to retrieve data in as few MongoDB requests as possible, in the scenario above, +if you do have a firewall for users and comments, both will be called only once, because we only make 2 MongoDB requests. + +#### Setting Default Configuration + +``` +import { Exposure } from 'meteor/cultofcoders:grapher'; + +// Make sure you do this before exposing any collections. +Exposure.setConfig({ + firewall, + method, + publication, + blocking, + maxLimit, + maxDepth, + restrictedFields +}); +``` + +When you expose a collection, it will extend the global exposure methods. +The reason for this is you may want a global limit of 100, or you may want a maximum graph depth of 5 for all your exposed collections, +without having to specify this for each. + +Important: if global exposure has a firewall and the collection exposure has a firewall defined as well, +the collection exposure firewall will be applied. + +##### Taming The Firewall + +```js +// Apply filters based on userId +Collection.expose({ + firewall(filters, options, userId) { + if (!isAdmin(userId)) { + filters.isVisible = true; + } + } +}); +``` + +```js +// Make certain fields invisible for certain users +import { Exposure } from 'meteor/cultofcoders:grapher' +Collection.expose({ + firewall(filters, options, userId) { + if (!isAdmin(userId)) { + Exposure.restrictFields(filters, options, ['privateData']); + // it will remove all specified fields from filters, options.sort, options.fields + // this way you will not expose unwanted data. + } + } +}); +``` + +#### Restrict certain links by userId + +Compute restricted links when fetching the query: +```js +Collection.expose({ + restrictLinks(userId) { + return ['privateLink', 'anotherPrivateLink'] + } +}); +``` + +## Exposure Body + +If *body* is specified, it is first applied on the requested body and then the subsequent rules such as *restrictedFields*, *restrictLinks* +will apply still. + +This is for advanced usage and it completes the security of exposure. + +By using body, Grapher automatically assumes you have control over what you give, +meaning all firewalls from other exposures for linked elements in this body will be bypassed. + +The firewall of the current exposure still executes ofcourse. + +#### Basic Usage + +```js +Meteor.users.expose({ + body: { + firstName: 1, + groups: { + name: 1 + } + } +}) +``` + +If you query from the *client-side* something like: +```js +createQuery({ + users: { + firstName: 1, + lastName: 1, + groups: { + name: 1, + createdAt: 1, + } + } +}) +``` + +The intersected body will look like: +``` +{ + firstName: 1, + groups: { + name: 1, + } +} +``` + +Ok, but what if I want to have a different body based on the userId? +Body can also be a function that takes in an `userId`, and returns an actual body, an `Object`. + +```js +Collection.expose({ + body(userId) { + let body = { firstName: 1 }; + + if (isAdmin(userId)) { + _.extend(body, { lastName: 1 }) + } + + return body; + } +}) +``` + +Deep nesting with other links not be allowed unless your *body* specifies it. + +The special fields `$filters` and `$options` are allowed at any link level (including root). However, they will go through a phase of cleaning, +meaning it will only allow you to `filter` and `sort` for fields that exist in the body. + +This check goes deeply to verify "$and", "$or", "$nin" and "$not" special MongoDB selectors. This way you are sure you do not expose data you don't want to. +Because, given enough requests, a hacker playing with `$filters` and `$sort` options can figure out a field that you may not want to give him access to. + +If the *body* contains functions they will be computed before intersection. Each function will receive userId. + +```js +{ + linkName(userId) { return {test: 1} } +} + +// transforms into +{ + linkName: { + test: 1 + } +} +``` + +You can return *undefined* or *false* in your function if you want to disable the field/link for intersection. + +```js +{ + linkName(userId) { + if (isAdmin(userId)) { + return object; + } + } +} +``` + +#### Linking Grapher Exposure Bodies + +Now things start to get crazy around here! + +You can link bodies in your own way and also reference other bodies'links. +Functions are computed on-demand, meaning you can have self-referencing body functions: + +```js +// Comments ONE link to Users as 'user' +// Users INVERSED 'user' from Comments AS 'comments' + +const commentBody = function(userId) { + return { + user: userBody, + text: 1 + } +} + +const userBody = function(userId) { + if (isAdmin(userId)) { + return { + comments: commentBody + }; + } + + return somethingElse; +} + +Users.expose({ + body: userBody +}) + +Comments.expose({ + body: commentBody +}) +``` + +This will allow requests like: +```js +{ + users: { + comments: { + user: { + // It doesn't make much sense for this case + // but you can :) + } + } + } +} +``` + +## Conclusion + +The global queries are a very powerful tool to expose your full database, but unlike `Named Queries` they do +not benefit of `caching`. \ No newline at end of file diff --git a/docs/hypernova.md b/docs/hypernova.md new file mode 100644 index 0000000..f9c5f72 --- /dev/null +++ b/docs/hypernova.md @@ -0,0 +1,77 @@ +## Hypernova + +This is the crown jewl of Grapher. It was named like this because it felt like an explosion of data. + +Grapher is very performant. To understand what we're talking about let's take this example of a query + +```js +createQuery({ + posts: { + categories: { + name: 1, + }, + author: { + name: 1, + }, + comments: { + $options: {limit: 10}, + author: { + name: 1, + } + } + } +}) +``` + +### Queries Counting + +In a normal scenario, to retrieve this data graph we need to: +1. Fetch the posts +2. posts.length * Fetch categories for each post +3. posts.length * Fetch author for each post +4. posts.length * Fetch comments for each post +5. posts.length * 10 * Fetch author for each comment + +Assuming we have: +- 10 posts +- 2 categories per post +- 1 author per post +- 10 comments per post +- 1 author per comment + +We would have blasted the database with: +- Posts: 1 +- Categories: 10 +- Post authors: 10 +- Post comments: 10 +- Post comments authors: 100 + +This means 131 database requests. +Ok, you can cache some stuff, maybe some authors collide, but in order to write a performant code, +you would have to write a bunch of non-reusable code. + +But this is just a simple query, imagine something deeper nested. Grapher simply destroys any other +MongoDB "relational" ORM. + +### Hypernova to the rescue + +How many requests does the Hypernova? +- 1 for Posts +- 1 for all authors inside Posts +- 1 for all categories inside Posts +- 1 for all comments inside Posts +- 1 for all authors inside all comments + +The number of database is predictable, because it represents the number of collection nodes inside the graph. + +It does this by aggregating filters and then it reassembles data locally. + +Not only it makes 5 requests instead of 131, but it smartly re-uses categories and authors at each collection node, +meaning you will have less bandwidth consumed. + +Now you understand why this is a revolution for MongoDB. + +Keep in mind that Hypernova is only used for static queries. For reactive queries, we still rely on the recursive fetching. + + + diff --git a/docs/introduction.md b/docs/introduction.md new file mode 100644 index 0000000..6105a0c --- /dev/null +++ b/docs/introduction.md @@ -0,0 +1,220 @@ +# Welcome + +## Installation +``` +meteor add cultofcoders:grapher +``` + +## The 3 Modules + +Grapher is composed of 3 main modules, that work together: + +### Link Manager +This module allows you to configure relationships between collections and allows you to create denormalized links and resolver links. + +### Query +The query module is used for fetching your data in a friendly manner, such as: +```js +createQuery({ + users: { + firstName: 1 + } +}) +``` + +It abstracts your query into a graph composed of Collection Nodes, Field Nodes and Resolver Nodes, +it uses the **Link Manager** to construct this graph and if the fetching is done server-side (non-reactive queries), +it uses the **Hypernova Module** the crown jewl of Grapher, which heavily minimizes requests to database. + +### Exposure + +The exposure represents the layer between your queries and the client, allowing you to securely expose your queries, +only to users that have access. + + +### Your first query + +You can use Grapher, without defining any links, for example, let's say you have a method which returns a list of posts. + +```js +Meteor.methods({ + posts() { + return Posts.find({}, { + fields: { + title: 1, + createdAt: 1, + createdBy: 1, + } + }).fetch(); + } +}) +``` + +Transforming this into a Grapher query would simply look like this: + +```js +Meteor.methods({ + posts() { + const query = Posts.createQuery({ + title: 1, + createdAt: 1, + createdBy: 1, + }); + + return query.fetch(); + } +}) +``` + +One of the advantages that Grapher has, is the fact that it forces you to specify the fields you need, +you may find this cumbersome in the beginning, but as your application grows, new fields are added, +fields that need to be protected, you'll find yourself refactoring parts of your code-base which exposed +all the fields. + + +If, for example, you want to filter or sort your query, we have some special query variables to do so: + +```js +Meteor.methods({ + posts() { + // Previously Posts.find({isApproved: true}, {sort: '...', fields: '...'}); + const query = Posts.createQuery({ + $filters: { + isApproved: true, + }, + $options: { + sort: {createdAt: -1} + }, + title: 1, + createdAt: 1, + createdBy: 1, + }); + + return query.fetch(); + } +}) +``` + +If for example you are searching an element by `_id`, you may have `$filters: {_id: 'XXX'}`, then instead of `fetch()` you +can call `.fetchOne()` so it will return the first element found. + +As you may have noticed, the $filters and $options are the ones you pass to `find()`. + +The nature of a Query is to be re-usable. For this we introduce a special type of field called `$filter`. +And we allow the query to receive parameters before it executes: + +```js +export default Posts.createQuery({ + $filter({filters, options, params}) { + filters.isApproved = params.isApproved; + }, + $options: {sort: {createdAt: -1}}, + title: 1, + createdAt: 1, + createdBy: 1, +}); +``` + +The `$filter()` function receives a single object that contains 3 objects: `filters`, `options`, `params`. +The `filters` and `options` are initially what you provided in `$filters` and `$options` query, they will be empty +if they haven't been specified. + +The job of `$filter()` is to extend/modify `filters` and `options`, based on params. + +Lets see how we can use that query: + +```js +// assuming you exported it from '...' +import postListQuery from '...'; + +Meteor.methods({ + posts() { + return postListQuery.clone({ + isApproved: true + }).fetch() + } +}) +``` + +Whenever we want to use a modular query, we have to `clone()` it so it creates a new standalone instance, +that does not affect the exported one. The `clone()` accepts `params` as argument. +Those `params` will be passed to the `$filter` function. + +You could also use `setParams()` to configure parameters: + +```js +import postListQuery from '...'; + +Meteor.methods({ + posts() { + const query = postListQuery.clone(); + + query.setParams({ + isApproved: true, + }); + + return query.fetch(); + } +}) +``` + +A query can be smart enough to know what parameters it needs, for this we can use the awesome `check` library from Meteor: +http://docs.meteor.com/api/check.html + +```js +import {Match} from 'meteor/check'; + +export default Posts.createQuery({ + $filter({filters, options, params}) { + filters.isApproved = params.isApproved; + if (params.authorId) { + filters.authorId = params.authorId; + } + }, + ... +}, { + validateParams: { + isApproved: Boolean, + authorId: Match.Maybe(String), + } +}); +``` + +But you can craft your own validation: +```js +{ + validateParams(params) { + if (somethingIsWrong) { + throw new Meteor.Error('invalid-params', 'Explain why'); + } + } +} +``` + +Note: params validation is done prior to fetching the query. + +And if you want to set some default parameters: +```js +export default Posts.createQuery({...}, { + params: { + isApproved: true, + } +}); +``` + +## Conclusion + +This is the end of our introduction. As we can see, we can make queries modular and this already gives us +a big benefit. By abstracting them into their own modules we can keep our methods neat and clean, +and we haven't even arrived to the good parts. + + + + + + + + + + + diff --git a/docs/linker_engine.md b/docs/linker_engine.md index e69de29..edeafa6 100644 --- a/docs/linker_engine.md +++ b/docs/linker_engine.md @@ -0,0 +1,170 @@ +# Linker Engine + +The linker engine is composed of 3 components: +- Definition of Links +- Linked Data Retrieval +- Setting Links + +Let's explore how we can play with `Linked Data Retrieval` and `Setting Links`, assumming we already defined our links +using `addLinks()` method. + +Each collection gets extended with a `getLink` function: +```js +const linker = Collection.getLink(collectionItemId, 'linkName'); +``` + +A real life example (assuming you have a `direct` or `inversed` link with groups) +```js +const userGroupLinker = Meteor.users.getLink(userId, 'groups'); +``` + +Linker is polymorphic and it allows you to do almost magical things. It is so flexible that it will +allow you to use it very naturally. + +## Linked Data Retrieval + +```js +const userGroupLinker = Meteor.users.getLink(userId, 'groups'); + +// fetching the groups for that user: +const groups = userGroupLinker.find().fetch(); +// or for ease of use +const groups = userGroupLinker.fetch(); +// or to filter the nested elements +const groups = userGroupLinker.find(filters, options).fetch(); +// and again simpler +const groups = userGroupLinker.fetch(filters, options); + +// and if you want to performantly get a count() +// because find() returns a good ol' Mongo.Cursor +const groups = userGroupLinker.find(filters, options).count(); +``` + +This works with any kind of links from any side. + +## Setting Links + +This allows you to very easily link collections to each other, without relying on knowing the fields and how are they stored. +It also allows you to set links from any place `direct` and `inversed`, of any type `one` or `many` and `meta` links as well: + + +#### "One" Relationships + +Performing a `set()` will automatically execute the update or insert in the database. + +```js +const userPaymentProfileLink = Meteor.users.getLink(userId, 'paymentProfile'); + +userPaymentProfileLink.set(paymentProfileId); +// but it also works if you have the object directly if it has _id, for ease of use: +userPaymentProfileLink.set(paymentProfile); + +// it works from the other side as well +const paymentProfileUserLink = PaymentProfiles.getLink(paymentProfileId, 'user'); +paymentProfileUserLink.set(userId); // or a user object that contains `_id` +``` + +You can also `set` objects that aren't in the database yet: + +```js +const userPaymentProfileLink = Meteor.users.getLink(userId, 'paymentProfile'); + +userPaymentProfileLink.set({ + last4digits: '1234', +}); +``` + +This will insert into the `PaymentProfiles` collection and link it to user and it works from both `direct` and `inversed` side as well. + +To remove a link for a `one` relationship (no arguments required): +```js +userPaymentProfileLink.unset(); +// or +paymentProfileUserLink.unset(); +``` + +#### "Many" Relationships + +Same principles as above apply, with some minor changes, this time we use `add` and `remove` + +```js +const userGroupsLink = Meteor.users.getLink(userId, 'groups'); +userGroupsLink.add(groupId); +userGroupsLink.add(group); // object containing an _id +userGroupsLink.add({ + name: 1, +}); // will add the group to the database and link it accordingly +``` + +The methods `add()` and `remove()` also accept arrays +```js +userGroupsLink.add([ + groupId1, + groupId2 +]); + +userGroupsLink.remove(groupIds) +``` + +The same logic applies, you can add array of objects that contain `_id` or array of objects without `_id`, or a mixture of them. + +Ofcourse, the `remove()` cannot accept objects without `_id` as it makes no sense to do so. + +#### "Meta" Relationships + +Now things get very interesting, because `metadata` allows us to store additional information about the link, +it lets us **describe** the relationship. And again, this works from `direct` and `inversed` side as well, with the +same principles described as above. + +The `add()` and `set()` allow an additional parameter `metadata`: + +```js +// one +const userPaymentProfileLink = Meteor.users.getLink(userId, 'paymentProfile'); + +userPaymentProfileLink.set(paymentProfileId, { + createdAt: new Date() +}); + +// many +const userGroupsLink = Meteor.users.getLink(userId, 'groups'); + +userGroupsLink.add(groupId, { + createdAt: new Date(), +}) + +// if you add multiple objects, they will receive the same metadata +userGroupsLink.add([groupId1, groupId2], { + createdAt: new Date(), +}) +``` + +Updating existing metadata: +```js +// one +const userPaymentProfileLink = Meteor.users.getLink(userId, 'paymentProfile'); + +userPaymentProfileLink.metadata({ + updatedAt: new Date() +}); + +// many +userGroupsLink.metadata(groupId, { + createdAt: new Date(), +}) +userGroupsLink.metadata([groupId1, groupId2], { + createdAt: new Date(), +}) +``` + +Updating metadata only works with strings or objects that contain `_id`, and it works from both sides. + + + +## Conclusion + +By using this Programatic API to set your links instead of relying on updates, it makes your code much simpler to read, +much easier to migrate in the future to a new database relational model. + + + diff --git a/docs/linking_collections.md b/docs/linking_collections.md index e69de29..4d65d10 100644 --- a/docs/linking_collections.md +++ b/docs/linking_collections.md @@ -0,0 +1,453 @@ +# Linking Collections + +Let's learn what types of links we can do between collections, and what is the best way to do them. + +First, we begin with an illustration of the power of Grapher: + +Let's assume our posts, contain a field, called `authorId` which represents an actual `_id` from `Meteor.users`, +if you wanted to get the post and the author's name, you had to first fetch the post, +and then get the author's name based on his `_id`. + +Something like: +```js +Meteor.methods({ + getPost({postId}) { + let post = Posts.findOne(postId, { + fields: { + title: 1, + createdAt: 1, + authorId: 1, + } + }); + + if (!post) { throw new Meteor.Error('not-found') } + const author = Meteor.users.findOne(post.authorId, { + fields: { + firstName: 1, + lastName: 1 + } + }); + + Object.assign(post, {author}); + + return post; + } +}) +``` + +With Grapher, your code above is transformed to: + +```js +Meteor.methods({ + getPost({postId}) { + let post = Posts.createQuery({ + $filters: {_id: postId}, + title: 1, + createdAt: 1, + author: { + firstName: 1, + lastName: 1 + } + }); + + return post.fetchOne(); + } +}) +``` + +This is just a simple illustration, imagine the scenario, in which you had comments, +and the comments had authors, and you needed their avatar. Your code can easily +grow to many lines of code, and it will be much less performant because of the many round-trips you do to +the database. + +## Linking Types + +To make the link illustrated in the example above, we create a separate `links.js` file that is +imported separately outside the collection module. We'll understand later why. + +```js +// file: /imports/db/posts/links.js +import Posts from '...'; + +Posts.addLinks({ + 'author': { + type: 'one', + collection: Meteor.users, + field: 'authorId', + } +}) +``` + +That was it. You created the link, and now you can use the query illustrated above. +We decided to choose `author` as a name for our link and `authorId` the field to store it in, but any string will do. + +### Inversed Links + +Because we linked `Posts` with `Meteor.users` it means that we can also get `posts` if we are in a User. +But because the link is stored in `Posts` we need a new type of linking, and we call it `Inversed Link` + +```js +// file: /imports/db/users/links.js +import Posts from '...'; + +Meteor.users.addLinks({ + 'posts': { + collection: Posts, + inversedBy: 'author' + } +}) +``` + +`author` represents the link name that was defined inside Posts. By defining inversed links we can do: + +```js +Meteor.users.createQuery({ + posts: { + title: 1 + } +}) +``` + +### One and Many + +Above you've noticed a `type: 'one'` in the link definition, but let's say we have a `Post` that belongs to many `Categories`, +which have their own collection into the database. This means that we need to relate with more than a single element. + + +```js +// file: /imports/db/posts/links.js +import Posts from '...'; +import Categories from '...'; + +Posts.addLinks({ + 'author': { ... }, + 'categories': { + type: 'many', + collection: Categories, + field: 'categoryIds', + } +}) +``` + +In this case, `categoryIds` is an array of Strings, each String, representing `_id` from `Categories` collection. + +And, ofcourse, you can also create an inversed link from `Categories`, so you can use it inside the `query` +```js +// file: /imports/db/posts/links.js +import Categories from '...'; +import Posts from '...'; + +Categories.addLinks({ + 'posts': { + collection: Posts, + inversedBy: 'categories' + } +}) +``` + +## Meta Links + +We use a `meta` link when we want to add additional data about the relationship. For example, +a user can belong in a single `Group`, but we need to know when he joined that group and what roles he has in it. + +```js +// file: /imports/db/users/links.js +import Groups from '...' + +Meteor.users.addLinks({ + group: { + type: 'one', + collection: Groups, + field: 'groupLink', + metadata: true, + } +}) +``` + +Notice the new option `metadata: true` this means that `groupLink` is no longer a `String`, but an `Object` that looks like this: + +``` +// inside a Meteor.users document +{ + ... + groupLink: { + _id: 'XXX', + roles: 'ADMIN', + createdAt: Date, + } +} +``` + +Let's see how this works out in our query: + +```js +const user = Meteor.users.createQuery({ + $filters: {_id: userId}, + group: { + name: 1, + } +}).fetchOne() +``` + +`user` will look like this: + +``` +{ + _id: userId, + group: { + $metadata: { + roles: 'ADMIN', + createdAt: Date + }, + name: 'My Funky Group' + } +} +``` + +We store the metadata of the link inside a special `$metadata` field. And this works from inversed side as well: + +```js +Groups.addLinks({ + users: { + collection: Meteor.users, + inversedBy: 'group' + } +}); + +const group = Groups.createQuery({ + $filters: {_id: groupId}, + name: 1, + users: { + firstName: 1, + } +}).fetchOne() +``` + +`group` will look like: +``` +{ + _id: groupId, + name: 'My Funky Group', + users: [ + { + $metadata: { + roles: 'ADMIN', + createdAt: Date + }, + _id: userId, + firstName: 'My Funky FirstName', + } + ] +} +``` + +The same principles apply to `meta` links that are `type: 'many'`, if we change that in the example above. +The storage field will look like: + +``` +{ + groupLinks: [ + {_id: 'groupId', roles: 'ADMIN', createdAt: Date}, + ... + ] +} +``` + +And they work the same as you expect, from the inversed side as well. + +I know what question comes to your mind right now, what if I want to put a field inside the metadata, +I want to store who added that user (`addedBy`) to that link and fetch it, how do we do ? + +Currently, this is not possible, because it has deep implications with how the Hypernova Module works, +but you can achieve this in a very performant way, by abstracting it into a separate collection: + +```js +// file: /imports/db/groupUserLinks/links.js +import Groups from '...'; +import GroupUserLinks from '...'; + +GroupUserLinks.addLinks({ + user: { + type: 'one', + collection: Meteor.users, + field: 'userId' + }, + adder: { + type: 'one', + collection: Meteor.users, + field: 'addedBy' + }, + group: { + type: 'one', + collection: Meteor.users, + field: 'groupId' + } +}) + +// file: /imports/db/users/links.js +Meteor.users.addLinks({ + groupLink: { + collection: GroupUserLinks, + type: 'one', + field: 'groupLinkId', + } +}) +``` + +And the query will look like this: +```js +Meteor.users.createQuery({ + groupLink: { + group: { + name: 1, + }, + adder: { + firstName: 1 + }, + roles: 1, + createdAt: 1, + } +}) +``` + +## Link Loopback + +No one stops you from linking a collection to itself, say you have a list of friends which are also users: +```js +Meteor.users.addLinks({ + friends: { + collection: Meteor.users, + type: 'many', + field: 'friendIds', + } +}); +``` + +Say you want to get your friends, and friends of friends, and friends of friends of friends! +```js +Meteor.users.createQuery({ + $filters: {_id: userId}, + friends: { + nickname: 1, + friends: { + nickname: 1, + friends: { + nickname: 1, + } + } + } +}); +``` + +## Uniqueness + +The `type: 'one'` doesn't necessarily guarantee uniqueness from the inversed side. +For example, if we have inside `Comments` a link with `Posts` with `type: 'one'` inside postId, +it does not mean that when I fetch the posts with comments, comments will be an array. + +But if you want to have a one to one relationship, and you want grapher to give you an object, +instead of an array you can do so: + +```js +Meteor.users.addLinks({ + paymentProfile: { + collection: PaymentProfiles, + inversedBy: 'user' + } +}); + +PaymentProfiles.addLinks({ + user: { + field: 'userId', + collection: Meteor.users, + type: 'one', + unique: true + } +}) +``` + +Now fetching: +```js +Meteor.users.createQuery({ + paymentProfile: { + type: 1, + last4digits: 1, + } +}); +``` + +`paymentProfile` inside `user` will be an object because it knows it should be unique. + +## Data Consistency + +This is referring to having consistency amongst links. + +Let's say I have a `thread` with multiple `members` from `Meteor.users`. If a `user` is deleted from the database, we don't want to keep unexisting references. +So after we delete a user, all threads containing that users should be cleaned. + +This is done automatically by Grapher so you don't have to deal with it. +The only rule is that `Meteor.users` collection needs to have an inversed link to `Threads`. + +In conclusion, if you want to benefit of this, you have to define inversed links for every direct links. + +## Autoremoval + +```js +Meteor.users.addLinks({ + 'posts': { + collection: Posts, + inversedBy: 'author', + autoremove: true + } +}); +``` + +After you deleted a user, all the links that have `autoremove: true` will be deleted. + +This works from the `direct` side as well, not only from `inversed` side. + +## Indexing + +As a rule of thumb, you must index all of your links. Because that's how you achieve absolute performance. + +This is not done by default, to allow the developer flexibility, but you can do it simply enough from the direct side definition of the link: + +```js +PaymentProfiles.addLinks({ + user: { + field: 'userId', + collection: Meteor.users, + type: 'one', + unique: true, + index: true, + } +}) +``` + +The index is applied only on the `_id`, meaning that if you have `meta` links, other fields present in that object will not be indexed. + +If you have `unique: true` set, the index will also apply a unique constraint to it. + +## Top Level Fields + +Grapher currently supports only top level fields for storing data. One of the reasons it doesn't allow nested fields +is to enforce the developer to think relational and eliminate large and complex documents by abstracting them into collections. + +In the future, this limitation may change, but for now you can work around this and keep your code elegant. + +## Conclusion + +Using these simple techniques, you can create a beautiful database schemas inside MongoDB that are relational and very simple to fetch, +you will eliminate almost all your boilerplate code around this and allows you to focus on more important things. + +On top of a cleaner code you benefit from the `Hypernova Module` which minimizes the database requests to a +predictable number (no. of collection nodes). + + + + + + + + + diff --git a/docs/named_queries.md b/docs/named_queries.md index e69de29..c9af47b 100644 --- a/docs/named_queries.md +++ b/docs/named_queries.md @@ -0,0 +1,398 @@ +# Named Queries + +Before we explain what they are, we need to understand an alternative way of creating the queries. + +## Alternative Creation + +Currently, we only saw how to create a query starting from a collection, like: + +```js +Meteor.users.createQuery(body, options); +``` + +But Grapher also exposes a `createQuery` functionality: + +```js +import {createQuery} from 'meteor/cultofcoders:grapher'; + +createQuery({ + users: { + profile: 1, + } +}) +``` + +The first key inside the object needs to represent an existing collection name: +```js +const Posts = new Mongo.Collection('posts'); + +// then we can do: +createQuery({ + posts: { + title: 1 + } +}) +``` + +## What are Named Queries ? + +As the name implies, they are a query that are identified by a `name` (No shit Sherlock). +The difference is that they accept a `string` as the first argument. + +The `Named Query` has the same API as a normal `Query`, we'll understand the difference in this documentation. + +```js +// file: /imports/api/users/queries/userAdminList.js +export default Meteor.users.createQuery('userAdminList', { + $filters: { + roles: {$in: 'ADMIN'} + }, + name: 1, +}) +``` + +If you would like to use this query you have two ways: +```js +import userAdminListQuery from '/imports/db/users/queries/userAdminList.js'; + +const admins = userAdminListQuery.fetch(); +``` + +Or you could use `createQuery`: +```js +import {createQuery} from 'meteor/cultofcoders:grapher'; + +const admins = createQuery({ + userAdminList: {}, +}).fetch(); +// will return a clone of the named query if it is already loaded +``` + +Now notice the {}. Because `Named Queries` have their form well defined, they don't allow you +to specify fields directly, however they allow you to specify parameters when using `createQuery` + +Because the default `$filter()` allows as params `filters` and `options` for the top collection node, +you can do something like: + +```js +const admins = createQuery({ + userAdminList: { + options: {createdAt: -1} + }, +}).fetch(); +``` + +## Always go modular + +Ofcourse the recommended approach is to always use `modular`. Creating queries in files, and importing them accordingly. +The reason we expose such functionality is because if you want to use Grapher as an HTTP API, you do not have access +to the collections, so this becomes very handy, as you can transform a JSON request into a query. + +Therefore, in such a case, a modular approach will not work. + +In the client-side of Meteor it is recommended to always extract your queries into their own module, and import and clone them for use. + +## But why ? + +Why do we need another string? What is the purpose? + +It's because we are slowly beginning to cross to the client-side domain, which +is a dangerous and nasty place and we need to expose our queries in a very cautious and secure manner. + + +## Special $body + +`Named Queries` allow a special `$body` parameter. Let's see an advanced query: + +```js +const fullPostList = Posts.createQuery('fullPostList', { + title: 1, + author: { + firstName: 1, + lastName: 1, + }, + comments: { + text: 1, + createdAt: 1, + author: { + firstName: 1, + lastName: 1, + } + } +}) +``` + +There will be situations where you initially want to show less data for this, maybe just the `title`, but if the user, +clicks on that `title` let's say we want it to expand and then we need to fetch additional data. + +The way to do it is to use `$body` which intersects with the allowed data graph: +```js +fullPostsList.clone({ + $body: { + title: 1, + author: { + firstName: 1, + services: 1, // will be removed after intersection, and not queried + }, + otherLink: {} // will be removed after intersection, and not queried + } +}) +``` + +This will only fetch the intersection, the transformed body will look like: +```js +{ + title: 1, + author: {firstName: 1} +} +``` + +When we learn about exposure you will fully understand why the `$body` special parameter is useful. + +Be careful, if you use validation for params (and you should) to also add `$body` inside it: + +```js +import {Match} from 'meteor/check'; +const fullPostList = Posts.createQuery('fullPostList', {}, { + validateParams: { + $body: Match.Maybe(Object), + } +}); +``` + +## Exposure + +We are now crossing the bridge to client-side. + +We want our application users to be able of using these queries we define stand-alone. We initially said that Grapher +will become the `Data Fetching Layer` inside Meteor. Therefore, we will no longer rely on methods to give us the data from +our queries, even if it's still possible and up to you to decide, but this gives you many advantages, such as caching and performance monitoring. + +As you may have guessed, the exposure needs to happen on the server, but the query can be imported on the client. + +Let's say we want only Admins to be able to query our `userAdminList` query: + +```js +// file: /imports/api/users/queries/userAdminList.js +export default Meteor.users.createQuery('userAdminList', { + $filters: { + roles: {$in: 'ADMIN'} + }, + name: 1, +}) +``` + +Alternatively, you could have done: +```js +// file: /imports/api/users/queries/userAdminList.js +export default createQuery('userAdminList', { + users: { + $filters: { + roles: {$in: 'ADMIN'} + }, + name: 1, + } +}) +``` + +```js +// server-side (make sure it's imported from somewhere on the server only) +// file: /imports/api/users/queries/userAdminList.expose.js +import userAdminListQuery from './userAdminList'; + +userAdminListQuery.expose({ + firewall(userId, params) { + if (!Roles.userIsInRole(userId, 'ADMIN')) { + throw new Meteor.Error('not-allowed'); + } + + // in the firewall you also have the ability to modify the parameters + // that are going to hit the $filter() function in the query + } +}) +``` + +## Client-side + +Let's use it on the client side: +```js +// client side +import userAdminListQuery from '/imports/api/users/queries/userAdminList.js'; + +userAdminListQuery.clone().fetch((err, res) => { + // do something with err, res + // it will be an error if the current user is not an admin +}); +``` + +A beautiful part of Grapher lies in it's polymorphic ability, you can have a reactive query, just by using the same API: + +```js +import {Tracker} from 'meteor/tracker'; +import userAdminListQuery from '/imports/api/users/queries/userAdminList.js'; + +const query = userAdminListQuery.clone(); +const subscriptionHandle = query.subscribe(); +// if we did subscribe, we no longer need to supply a callback for fetch() +// as the query morphed into a reactive query + +Tracker.autorun(() => { + if (subscriptionHandle.ready()) { + console.log(query.fetch()); + query.unsubscribe(); + } +}) +``` + +You can do `meteor add tracker` if it complains it's missing `Tracker`. + + +You can also use `fetchOne()` on the client as well, even supply a callback: +```js +userAdminListQuery.clone().fetchOne((err, user) => { + // do something +}); +``` +You can fetch static queries using promises: + +```js +const users = await userAdminListQuery.clone().fetchSync(); +const user = await userAdminListQuery.clone().fetchOneSync(); +``` + +## Counters + +You can ofcourse use the same paradigm to use counts: +```js +// static query +query.getCount((err, count) => { + // do something +}); + +const count = await query.getCountSync(); + +// reactive counts +const handle = query.subscribeCount(); + +Tracker.autorun(() => { + if (handle.ready()) { + console.log(query.getCount()); + query.unsubscribeCount(); + } +}); +``` + +We do not subscribe to counts by default because they may be too expensive, but if you need them, +feel free to use them. + +## Behind the scenes + +When we are dealing with a static query (non-reactive), we make a call to the method that has been created when +we did `query.expose()`, the firewall gets applied and returns the result from the query. + +If we have a reactive query, `query.expose()` creates a publication end-point, and inside it, uses the `reywood:publish-composite` package +to automatically create the proper publication. + +When we do `query.fetch()` on a reactive query, it gives you a complete data graph with the items in the same form as you +would have received from a static query. When an element inside your data graph changes, the `fetch()` function is re-run +if you are inside a `Tracker.autorun()` or a reactive context, because in the back, we do `find().fetch()` on client-side collections. + +## Warning + +If you want your client-side **reactive** queries to work as expected you need to make sure that the `addLinks` and `addReducers` are loaded on the client as well. +Because the data assembly is now done on the client. + +## Expose Options + +```js +query.expose({ + // Secure your query + firewall(userId, params) { + // you can modify the parameters here + }, + // Allow the query to be fetched statically + method: true, // default + // Allow the query to be fetched reactively (for heavy queries, you may want to set it to false) + publication: true, // default + // Unblocks your method (and if you have .unblock() in publication context it also unblocks it) + unblock: true, // default + // This can be an object or a function(params) that you don't want to expose it on the client via + // The query options as it may hold some secret business data + // If you don't specify it the default validateParams from the query applies, but not both! + // If you allow subbody requests, don't forget to add {$body: Match.Maybe(Boolean)} + validateParams: {}, + // This deep extends your graph's body before processing it. + // For example, you want a hidden $filter() functionality, or anything else. + embody: { + $filter({filters, params}) { + // do something + }, + aLink: { + $filter() { + // works with links as well, because it's a deep extension + } + } + } +}) +``` + +## Resolvers + +Named Queries have the ability to morph themselves into a function that executes on the server. +There are situations where you want to retrieve some data, that is not necessarily inside a collection, +or it needs additional operations to give you the correct result. + +For example, let's say you want to provide the user with some analytic results, that perform some heavy +aggregations on the database, or call some external APIs. + +```js +// shared code between client and server +const getAnalytics = createQuery('getAnalytics', () => {}, { + validateParams: {} // Object or Function +}); +``` + +```js +// server code only +import getAnalyticsQuery from './getAnalytics'; + +getAnalyticsQuery.expose({ + firewall(userId, params) { + // ... + }, + validateParams: {} // Object or Function that you don't want exposed +}); + +getAnalyticsQuery.resolve(function(params) { + // perform your magic here + // you are in `Meteor.method` context, so you have access to `this.userId` +}) +``` + +The advantage of doing things like this, instead of relying on a method, is that you are now +using a uniform layer for fetching results, and on top of that, you can easily cache them. + +## Mix'em up + +The `firewall` can also be an array of functions. Allowing you to easily expose a query like: + +```js +function checkLoggedIn(userId, params) { + if (!userId) { + throw new Meteor.Error('not-allowed'); + } +} + +query.expose({ + firewall: [checkLoggedIn, (userId, params) => { + params.userId = userId; + // other stuff here if you want + }] +}) +``` + +## Conclusion + +We can now safely expose our queries to the client, and the client can use it in a simple and uniform way. +By this stage we already understand how powerful Grapher really is, but it still has some tricks. + diff --git a/docs/outside_grapher.md b/docs/outside_grapher.md new file mode 100644 index 0000000..860e52c --- /dev/null +++ b/docs/outside_grapher.md @@ -0,0 +1,100 @@ +## Grapher as an API + +If you like Grapher, you can use it in any other language/medium. + +You can expose it as an HTTP API, or a DDP API (as a Meteor.method()) for example, +because in ReactNative you have ways to connect to Meteor with: +https://www.npmjs.com/package/react-native-meteor#meteor-collections + +Basically what Grapher needs to properly execute is the query, +and if you have firewalls, you need to manually handle authorization yourself. + +### Exposing an HTTP API + +```js +// This is how you can create a sample endpoint + +import {createQuery} from 'meteor/cultofcoders:grapher'; +import Picker from 'meteor/meteorhacks:picker'; +import {EJSON} from 'meteor/ejson'; +import {Meteor} from 'meteor/meteor'; +import bodyParser from 'body-parser'; + +const grapherRoutes = Picker.filter(function () { + return true; +}); + +grapherRoutes.middleware(bodyParser.raw({ + 'type': 'application/ejson', +})); + +grapherRoutes.route('/grapher', function (req, res) { + const body = req.body.toString(); + const data = EJSON.parse(body); + + // lets say this is a named query that looks like + // {getUserList: params} + const {query} = data; + + // authorize the user somehow + // it's up to you to extract an userId + // or something else that you use for authorization + + const actualQuery = createQuery(query); + + // if it's not a named query and the collection is not exposed, don't allow it. + if (actualQuery.isGlobalQuery && !actualQuery.collection.__isExposedForGrapher) { + throw new Meteor.Error('not-allowed'); + } + + try { + const data = actualQuery.fetch({ + // the userId (User Identification) that hits the firewalls + // user id can be anything, an API key maybe, not only a 'string' + // it's up to you and your firewalls to decide + userId: 'XXX' + }); + + res.statusCode = 200; + res.end(EJSON.stringify({ + data, + })); + } catch (e) { + res.statusCode = 500; + res.end(EJSON.stringify({ + error: e.reason, + })); + } +}) +``` + +Now you can do HTTP requests of `Content-Type: application/ejson` to http://meteor-server/grapher and retrieve data. + +If you want to use Meteor's Methods as an HTTP API to also handle method calls, take a look here: +- https://github.com/cult-of-coders/fusion + +And more closely: https://github.com/cult-of-coders/fusion/blob/7ec5cd50c3a471c0bdd65c9fa482124c149dc243/fusion/server/route.js + +### Exposing a DDP Method + +```js +import {createQuery} from 'meteor/cultofcoders:grapher'; + +Meteor.methods({ + 'grapher'(query) { + const actualQuery = createQuery(query); + + if (actualQuery.isGlobalQuery && !actualQuery.collection.__isExposedForGrapher) { + throw new Meteor.Error('not-allowed'); + } + + return actualQuery.fetch({ + userId: this.userId, + }) + } +}) +``` + +## Conclusion + +Nothing stops you from using Grapher outside Meteor! diff --git a/docs/query_options.md b/docs/query_options.md new file mode 100644 index 0000000..fef81ab --- /dev/null +++ b/docs/query_options.md @@ -0,0 +1,375 @@ +# Query Options + +Let's learn what a query can do, we know it can fetch related links, but it has some +interesting features. + +## Nested fields + +Most likely you will have documents which organize data inside an object, such as `user` may have a `profile` object +that stores `firstName`, `lastName`, etc + +Grapher automatically detects these fields, as long as there is no link named `profile`: + +```js +const user = Meteor.users.createQuery({ + $filters: {_id: userId}, + profile: { + firstName: 1, + lastName: 1, + } +}).fetchOne(); +``` + +Now `user` will look like: +``` +{ + _id: userId, + profile: { + firstName: 'John', + lastName: 'Smith', + } +} +``` + +If you wanted to fetch the full `profile`, simply use `profile: 1` inside your query body. Alternatively, +you can also use `'profile.firstName': 1` and `'profile.lastName': 1` but it's less elegant. + +## Deep filtering + +Lets say we have a `Post` with `comments`: + +```js +import {Comments, Posts} from '/imports/db'; + +Posts.addLinks({ + comments: { + collection: Comments, + inversedBy: 'postId', + } +}) + +Comments.addLinks({ + post: { + type: 'one', + field: 'postId', + collection: Posts, + }, +}); +``` + +If any bit of the code written above creates confusion, try reading again the `Linking Collections` part of the documentation. + +We already know that we can query with `$filters`, `$options`, `$filter` and have some parameters. +The same logic applies for child collection nodes: + +```js +Posts.createQuery({ + title: 1, + comments: { + $filters: { + isApproved: true, + }, + text: 1, + } +}) +``` + +The query above will fetch as `comments` only the ones that have been approved. + +The `$filter` function share the same `params` across all collection nodes: + +```js +export default Posts.createQuery({ + $filter({filters, params}) { + if (params.lastWeekPosts) { + filters.createdAt = {$gt: date} + } + }, + $options: {createdAt: -1}, + title: 1, + comments: { + $filter({filters, params}) { + if (params.approvedCommentsOnly) { + filters.isApproved = true; + } + }, + $options: {createdAt: -1}, + text: 1, + } +}) +``` + +```js +const postsWithComments = postListQuery.clone({ + lastWeekPosts: true, + approvedCommentsOnly: true +}).fetch(); +``` + +### Default $filter() + +The $filter is a function that defaults to: +```js +function $filter({filters, options, params}) { + if (params.filters) { + Object.assign(filters, params.filters); + } + if (params.options) { + Object.assign(filters, params.options) + } +} +``` + +Which basically means you can easily configure your filters and options through params: +```js +const postsQuery = Posts.createQuery({ + title: 1, +}); + +const posts = postQuery.clone({ + filters: {isApproved: true}, + options: { + sort: {createdAt: -1}, + } +}).fetch(); +``` + +If you like to disable this functionality, add your own $filter() function or use a dummy one: +```js +{ + $filter: () => {}, +} +``` + +Note the default $filter() only applies to the top collection node, otherwise we would have headed into a lot of trouble. + +### Pagination + +There is a special field that extends the pre-fetch filtering process, and it's called `$paginate`: + +```js +const postsQuery = Posts.createQuery({ + $filter({filters, params}) { + filters.isApproved = params.postsApproved; + }, + $paginate: true, + title: 1, +}); + +const page = 1; +const perPage = 10; + +const posts = postsQuery.clone({ + postsApproved: true, + limit: perPage, + skip: (page - 1) * perPage +}).fetch() +``` + +This is mostly used for your convenience, as pagination is a common used technique and makes your code easier to read. + +Note that it doesn't override the $filter() function, it just applies `limit` and `skip` to the options, before $filter() runs. + +### Meta Filters + +Let's say we have `Users` that belong in `Groups` and they have some roles attached in the link description: + +```js +import {Groups} from '/imports/db'; + +Meteor.users.addLinks({ + groups: { + type: 'many', + collection: Groups, + field: 'groupLinks', + metadata: true, + } +}); + +Groups.addLinks({ + users: { + collection: Meteor.users, + inversedBy: 'groups', + } +}) +``` + +Let's assume the groupLinks looks like this: +```js +[ + { + _id: 'groupId', + roles: ['ADMIN'] + } +] +``` + +And you want to query users and fetch only the groups he is admin in: +```js +const users = Meteor.users.createQuery({ + name: 1, + groups: { + $filters: { + $meta: { + roles: {$in: 'ADMIN'} + } + } + } +}).fetch() +``` + +But what if you want to fetch the groups and all their admins ? Easy. +```js +const groups = Groups.createQuery({ + name: 1, + users: { + $filters: { + $meta: { + roles: {$in: 'ADMIN'} + } + } + } +}).fetch() +``` + +We have gone through great efforts to support such functionality, but it makes our code so easy to read and it doesn't impact performance. + +## Post Filtering + +This concept allows us to filter/manipulate data after we received it. + +For example, what if you want to get the users that are admins in at least one group: + +```js +const users = Meteor.users.createQuery({ + $postFilters: { + 'groups.$metadata.roles': {$in: 'ADMIN'}, + }, + name: 1, + groups: { + name :1, + } +}).fetch() +``` + +If you had a `many` relationship without `metadata` your $postFilters would look like: +``` +{ + 'groups.roles': {$in: 'ADMIN'}, +} +``` + +The `$postFilters` option uses the `sift` npm library (https://www.npmjs.com/package/sift) to make your filters look like mongo filters. + +In addition to `$postFilters` we've also got `$postOptions` that allows: +- limit +- sort +- skip + +They work exactly like you expect from $options, the difference is that they are applied after the data has been fetched. +```js +const users = Meteor.users.createQuery({ + $postOptions: { + sort: {'groups.name': 1}, + }, + name: 1, + groups: { + name :1, + } +}).fetch() +``` + +And to offer you full flexibility, we also allow a `$postFilter` function that needs +to return the new set of results. + +```js +const users = Meteor.users.createQuery({ + $postFilter(results, params) { + if (params.mustHaveGroupsAsAdmin) { + return results.filter(r => { + // your filter goes here. + }); + } + }, + name: 1, + groups: { + name: 1, + } +}, { + params: { + mustHaveGroupsAsAdmin: true + }, +}).fetch() +``` + +Note the fact that they only work for top level nodes, not for child collection nodes unlike `$filters` and `$options`. + +This type of queries that rely on post processing, can prove to be costly in some cases, because it will still fetch the users from the database +that don't have a group in which they are admin. There are alternatives to this to this in the `Denormalize` section of the documentation. + +It really depends on your context, but `$postFilters`, `$postOptions` and `$postFilter` can be very useful in some cases. + +## Counters + +If you want just to return the number of top level documents a query has: + +```js +query.getCount() +``` + +This will be very useful for pagination when we reach the client-side domain. + +## Mix'em up + +The options `$filter` and `$postFilter` also allow you to provide an array of functions: + +```js +function userContext({filters, params}) { + if (!params.userId) { + throw new Meteor.Error('not-allowed'); + } + + filters.userId = params.userId; +} + +const posts = Posts.createQuery({ + $filter: [userContext, ({filters, options, params}) => { + // do something + }], + $postFilter: [someOtherFunction], +}).fetch() +``` + +The example above is just to illustrate the possibility, in order to ensure that a `userId` param is sent you will use `validateParams` + +```js +Posts.createQuery({ + $filter({filters, options, params}) { + filters.userId = params.userId; + }, + title: 1, +}, { + validateParams: { + userId: String + } +}) +``` + +Validating params will also protect you from injections such as: + +```js +const query = postLists.clone({ + userId: {$nin: []}, +}) +``` + +When we cross to the client-side domain we need to be very wary of these type of injections. + +## Conclusion + +Query is a very powerful tool, very flexible, it allows us to do very complex things that would have taken us a lot of time +to do otherwise. + + + + diff --git a/docs/reducers.md b/docs/reducers.md new file mode 100644 index 0000000..b824916 --- /dev/null +++ b/docs/reducers.md @@ -0,0 +1,196 @@ +# Reducers + +The reducers are a sort of "smart fields" which allow you to compose different results +from your query. + +To achieve this we extend `Mongo.Collection` with an `addReducer()` method: +```js +Collection.addReducer({ + reducerName: { + body: graphDependencyBody, + reduce(object) { + return value; // can be anything, object, date, string, number, etc + } + } +}) +``` + +## Basics + +```js +Meteor.users.addReducers({ + fullName: { + body: { + profile: { + firstName: 1, + lastName: 1 + } + }, + reduce(object) { + const {profile} = object; + + return `${profile.firstName} ${profileLastName}`; + } + } +}) +``` + +Query: +```js +const user = Meteor.users.createQuery({ + fullName: 1, +}).fetchOne(); +``` + +Results to: +``` +{ + _id: 'XXX', + fullName: 'John Smith', +} +``` + +## Reducers and links + +Easily grab the data from your links (as deep as you want them), if you want to reduce it. + +```js +Meteor.users.addReducers({ + groupNames: { + body: { + // assuming you have a link called groups + groups: { name: 1 } + }, + reduce(object) { // a pure function that returns the data + return object.groups.map(group => group.name).join(',') + } + } +}) +``` + +Query: +```js +const user = Meteor.users.createQuery({ + groupNames: 1, +}).fetchOne(); +``` + +Result: +``` +{ + _id: 'XXX', + groupNames: ['Group 1', 'Group 2'], +} +``` + +Note that `groups: []` is not present in your result set. This is because we detect the fact that you +did not include it in the body of your query, however if you would have done: + +Query: +```js +const user = Meteor.users.createQuery({ + groupNames: 1, + groups: { + createdAt: 1, + } +}).fetchOne(); +``` + +Result: +``` +{ + _id: 'XXX', + groupNames: ['Group 1', 'Group 2'], + groups: [ + {_id: 'groupId1', createdAt: Date}, + {_id: 'groupId2', createdAt: Date}, + ] +} +``` + +Notice that group `name` is not there. This is because we clean leftovers so the result is predictable. + +## Reducers and reducers + +You can also use other reducers inside your reducers. + +``` +// setting up +Users.addReducers({ + fullName: {...} + fullNameWithRoles: { // the name of how you want to request it + body: { // the dependency, what info it needs to be able to reduce + fullName: 1, + roles: 1 + }, + reduce(object) { // a pure function that returns the data + return object.fullName + object.roles.join(','); + } + } +}) +``` + +And again, unless you specified `fullName: 1` in your query, it will not be present in the result set. + +## Params-aware reducers + +By default the reducer receives the parameters the query has. + +This can open the path to some nice customizations: +```js +Collection.addReducers({ + reducer: { + body, + reduce(user, params) {} + } +}) +``` + +Be aware that this reducer may be used from any queries with different types of parameters. + +## Reducers can do anything! + +If we want to just receive the number of posts a user has, we can use reducers for this: + +``` +Meteor.users.addReducers({ + postCount: { + body: {_id: 1}, + reduce(user) { + const linker = Users.getLink(user, 'posts'); + + return linker.find().count(); + } + } +}) +``` + +Or if you want to fetch some data from an external API: + +Note that these reducers need to be defined server-side only, and they can only work with static queries. + +```js +Projects.addReducers({ + githubStars: { + body: { + repository: 1, + }, + reduce(collectionItem) { + const {repository} = collectionItem; + const call = Meteor.wrapAsync(API.doSomething, API); + // you can use anything that is in sync + // don't return the result inside a callback because it won't work. + call(); + }, + } +}) +``` + +## Filtering by reducers + +If you want to filter reducers you can use `$postFilters` or `$postFilter` special functions. + +## Conclusion + +Reducers are a neat way to remove boilerplate from your code, especially for our infamous `emails[0].address`, +inside `Meteor.users` collection, check if you can figure out how to reduce it! diff --git a/docs/structure_and_patterns.md b/docs/structure_and_patterns.md new file mode 100644 index 0000000..388f49d --- /dev/null +++ b/docs/structure_and_patterns.md @@ -0,0 +1,108 @@ +# Structure and Patterns + +These are a set of recommended ways to handle things, they do not enforce +anything. + +We suggest you read: http://www.meteor-tuts.com/chapters/3/persistence-layer.html first. + +1. Store queries inside `/imports/api` under their own module and proper path. +2. Store `links` inside `/imports/db` along their collections definitions. +3. Create a an `/imports/db/index.js` that imports and exports all your collections +4. In `/imports/db/links.js` import all links from collections (`/imports/db/posts/links.js`) +5. In that `/imports/db/index.js` also `imports './links'` after you imported all collections. +6. Make sure you import `/imports/db/index.js` in both client and server environments. +7. For Named Queries, keep `query.js` and `query.expose.js` separated. +8. Create an `/imports/api/exposures.js` that imports all `.expose.js` files, and import that server-side. +8. When you import your queries suffix their with `Query` +9. Always `.clone()` queries before you use them client and server-side +10. Store reducers inside `links.js`, if the file becomes too large (> 100 lines), separate them. + +If you respect the patterns above you will avoid having the most common pitfalls with Grapher: + +**Reducer/Links not working?** +Make sure they are imported in the environment you use them client/server. + +**My link is not saved in the database** +Make sure you added it correctly to your SimpleSchema + + +## Fragments + +You will find yourself requiring often same fields for Users, such as email, fullName, and maybe avatar. + +For that let's create some fragments: +```js +// file: /imports/db/fragments/UserPublic.js +export default { + fullName: 1, + avatar: { + path: 1, + }, + email: 1, +} + +// file: /imports/db/fragments/index.js +export {default as UserPublicFragment} from './UserPublicFields'; +``` + +Now use it: +```js +import {UserPublicFragment} from '/imports/db/fragments'; +Invoices.createQuery({ + number: 1, + total: 1, + user: { + ...UserPublicFragment, + billingInfo: { + // etc + } + } +}) +``` + +You can also compose certain fragments: + +```js +import compose from 'meteor/cultofcoders:grapher'; +import { + UserPublicFragment, + UserBillingFragment, +} from '/imports/db/fragments'; + +Invoices.createQuery({ + number: 1, + total: 1, + user: { + ...compose( + UserPublicFragment, + UserBillingFragment + ) + } +}) +``` + +Compose uses a deep extension, so it works how you expected to work, especially if some fragments have shared bodies. + +## Scaling Reactivity + +If you want to have highly scalable reactive queries, think about moving from tailing MongoDB oplog to RedisOplog: +https://github.com/cult-of-coders/redis-oplog + +Grapher is fully compatible with it. You can configure $options, inside the $filter() on to allow namespaced watchers. + +Sample: + +```js +export default Messages.createQuery('messagesForThread', { + $filter({filters, options, params}) { + filters.threadId = params.threadId; + options.namespace = `thread::${params.threadId}`; + }, + text: 1, +}) +``` + + +## Conclusion + +This ends our journey through Grapher. We hope you enjoyed, and that you are going to use it. \ No newline at end of file diff --git a/docs/table_of_contents.md b/docs/table_of_contents.md new file mode 100644 index 0000000..2af396f --- /dev/null +++ b/docs/table_of_contents.md @@ -0,0 +1,53 @@ +# Table of Contents + +### [Introduction](introduction.md) + +You are new to Grapher and you want to learn the basics of what it can offer. + +### [Linking Collections](linking_collections.md) + +Learn how to link collections to each other. + +### [Linker Engine](linker_engine.md) + +Find out what powers Grapher behind the scenes + +### [Query Options](query_options.md) + +Learn more advanced ways to use your queries. + +### [Reducers](reducers.md) + +Remove complexity from your queries, make them a breeze. + +### [Named Queries](named_queries.md) + +Learn how to define and expose queries to the client. + +### [Hypernova](hypernova.md) + +Read about another tool that powers Grapher behind the scenes. + +### [Denormalization](denormalization.md) + +Learn how to denormalize your data to enable even more performance, and super advanced searching. + +### [Caching Results](caching_results.md) + +Leverage the power of Grapher to cache the most heavy or frequently used queries. + +### [Global Exposure](global_exposure.md) + +Learn about a neat way to expose your collections, careful, it may also be dangerous. + +### [Structure & Patterns](structure_and_patterns.md) + +Learn about some good ways to structure your code some and about common pitfalls. + +### [Outside Grapher](outside_grapher.md) + +Use Grapher outside Meteor with ease. + +### [API](api.md) + +Grapher Cheatsheet. \ No newline at end of file diff --git a/main.client.js b/main.client.js index 1d3b099..822b24f 100644 --- a/main.client.js +++ b/main.client.js @@ -17,3 +17,7 @@ export { export { default as NamedQuery } from './lib/namedQuery/namedQuery.client'; + +export { + default as compose +} from './lib/compose'; diff --git a/main.server.js b/main.server.js index 6c0feca..29aeadb 100644 --- a/main.server.js +++ b/main.server.js @@ -23,3 +23,11 @@ export { export { default as MemoryResultCacher } from './lib/namedQuery/cache/MemoryResultCacher'; + +export { + default as BaseResultCacher +} from './lib/namedQuery/cache/BaseResultCacher'; + +export { + default as compose +} from './lib/compose'; \ No newline at end of file