Skip to content

Latest commit

 

History

History
379 lines (304 loc) · 8.75 KB

query_options.md

File metadata and controls

379 lines (304 loc) · 8.75 KB

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:

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 want to fetch the full profile, 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:

import {Comments, Posts} from '/imports/db';

Comments.addLinks({
    post: {
        type: 'one',
        field: 'postId',
        collection: Posts,
    },
});

Posts.addLinks({
    comments: {
        collection: Comments,
        inversedBy: 'post',
    }
})

If any bit of the code written above creates confusion, take another look on Linking Collections.

We already know that we can query with $filters, $options, $filter and have some parameters. The same logic applies for child collection nodes:

Posts.createQuery({
    title: 1,
    comments: {
        $filters: {
            isApproved: true,
        },
        text: 1,
    }
})

The query above will fetch as comments only the ones that have been approved and that are linked with the post.

The $filter function shares the same params across all collection nodes:

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,
    }
})
const postsWithComments = postListQuery.clone({
    lastWeekPosts: true,
    approvedCommentsOnly: true
}).fetch();

Default $filter()

The $filter is a function that defaults to:

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:

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:

{
    $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, that allows us to receive limit and skip params:

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 was created 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. It only works for the top level node, not for the child collection nodes.

Meta Filters

Let's say we have users that belong in groups and they have some roles attached in the link description:

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:

[
    {
        _id: 'groupId',
        roles: ['ADMIN']
    }
]

And you want to query users and fetch only the groups he is admin in:

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? It's the same.

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 retrived it and assembled it.

The $postFilters option uses the sift npm library (https://www.npmjs.com/package/sift) to make your filters look like MongoDB filters.

For example, what if you want to get the users that are admins in at least one group:

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'},  
}

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.

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.

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 these special fields only work for top level nodes, not for child collection nodes unlike $filters, $options, and $filter.

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 Denormalization 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:

query.getCount()

This will be very useful for pagination when we reach the client-side domain, or you just need a count. Note that getCount() applies only the processed filters but not options.

Mix'em up

Both functions $filter and $postFilter also allow you to provide an array of functions:

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

The example above is just to illustrate the possibility, in order to ensure that a userId param is sent you will use validateParams.

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:

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.