grapher/docs/linking_collections.md
2017-12-01 13:11:06 +02:00

11 KiB

Linking Collections

Let's learn what type of links we can define 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 collection, contains 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.

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:

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 very large, and it's going to be hard to make it performant.

To define the link illustrated in the example above, we create a separate links.js file that is imported separately outside the collection module. We need to define the links in their own module, after all collections have been defined, because there will be situations where, when you define the links where you define the collection, 2 collections import each other, leading to some strange behaviors.

// file: /imports/db/posts/links.js
import Posts from '...';

Posts.addLinks({
    'author': {
        type: 'one',
        collection: Meteor.users,
        field: 'authorId',
    }
})

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 it's up to you to decide this.

Because we linked Posts with Meteor.users it means that we can also get all posts of an user. Because in a way Meteor.users is also linked with Posts but an inversed way. We refer to it as an Inversed Link.

// 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. Defining inversed links allows us to do:

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.

// file: /imports/db/posts/links.js
import Posts from '...';
import Categories from '...';

Posts.addLinks({
    'categories': {
        type: 'many',
        collection: Categories,
        field: 'categoryIds',
    }
})

In this case, categoryIds is an array of strings ([categoryId1, categoryId2, ...]), each string, representing _id from Categories collection.

Let's also create an inversed link from Categories, so you can use it inside the query

// file: /imports/db/posts/links.js
import Categories from '...';
import Posts from '...';

Categories.addLinks({
    'posts': {
        collection: Posts,
        inversedBy: 'categories'
    }
})

By defining this, I can query for a category, and get all the posts it has.

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 also store when he joined that group and what roles he has in it.

// 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', // This is the _id of the group
        roles: 'ADMIN',
        createdAt: Date,
    }
}

Let's see how this works out in our query:

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 it works from inversed side as well:

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},
        ...
    ]
}

The same principles above apply, we still store $metadata field.

I know what question comes to your mind right now, what if I want to put a field inside the metadata, which represents an id from other collection, like I want to store who added that user (addedBy) to the group.

No-one stops you from storing it as a string, but if this is one of Grapher's current limitations, it can't fetch links that are inside the metadata.

But Grapher makes you think relational again, and you can abstract it to another collection:

// file: /imports/db/groupUserLinks/links.js
import Groups from '...';
import GroupUserLinks from '...';

// file: /imports/db/users/links.js
Meteor.users.addLinks({
    groupLink: {
        collection: GroupUserLinks,
        type: 'one',
        field: 'groupLinkId',
    }
})

GroupUserLinks.addLinks({
    user: {
        collection: Meteor.users,
        inversedBy: 'groupLink',
    },
    adder: {
        type: 'one',
        collection: Meteor.users,
        field: 'addedBy'
    },
    group: {
        type: 'one',
        collection: Meteor.users,
        field: 'groupId'
    }
})

And the query will look like this:

Meteor.users.createQuery({
    groupLink: {
        group: {
            name: 1,
        },
        adder: {
            firstName: 1
        },
        roles: 1,
        createdAt: 1,
    }
})

No one stops you from linking a collection to itself, say you have a list of friends which are also users:

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!

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, we have Comments and Posts linked, by defining a one link from Comments to Posts, and an inversed link from Posts to Comments.

When you fetch comments, from posts, the inversed side, they will return an array.

But if you want to have a OneToOne relationship, and you want Grapher to give you a single object in return, you can do:

Meteor.users.addLinks({
    paymentProfile: {
        collection: PaymentProfiles,
        inversedBy: 'user'
    }
});

PaymentProfiles.addLinks({
    user: {
        field: 'userId',
        collection: Meteor.users,
        type: 'one',
        unique: true
    }
})

Now fetching:

Meteor.users.createQuery({
    paymentProfile: {
        type: 1,
        last4digits: 1,
    } 
});

paymentProfile inside user will be an object because it knows it should be unique.

Data Consistency

We clean out leftover links from deleted collection items.

Let's say I have a Threads collection with some memberIds linked to 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

Meteor.users.addLinks({
    'posts': {
        collection: Posts,
        inversedBy: 'author',
        autoremove: true
    }
});

After you delete 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.

Please use with caution, sometimes it's better to explicitly delete it, but there will be situations, where you don't care and this makes your code cleaner.

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:

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, but you can run a Collection._ensureIndex separately.

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 linking 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.

Continue Reading or Back to Table of Contents