Working with cursors

← Previous: Apostrophe's model layer: working with the database

Apostrophe users cursors to fetch docs from the database. An apostrophe-cursor object helps us conveniently fetch docs from the aposDocs mongodb collection using chainable "filter" methods. Much like a MongoDB or Doctrine cursor, but with many filters specific to Apostrophe that add a great deal of convenience. And it's possible to add your own filters.

An illustrated example

Let's say we've created a profiles module that extends apostrophe-pieces. Its configuration looks like this:

{
  modules: {
    profiles: {
      extend: 'apostrophe-pieces',
      name: 'profile',
      label: 'Profile',
      addFields: [
        {
          type: 'integer',
          name: 'reputation',
          label: 'Reputation'
        }
      ]
    }
  }
}

Now, from another module, we want to fetch the ten most recently updated profiles by reputation over 30:

return apos.docs.getManager('profile').find(req,
    {
      reputation: {
        $gte: 30
      }
    }
  ).sort({ updatedAt: -1 })
  .toArray(function(err, profiles) {
    // We can work with the profiles here
  });

What's going on here?

Full text search

So far this looks familiar to MongoDB developers. But Apostrophe adds some filter methods of its own that go beyond what you get out of the box with MongoDB.

Let's search for profiles related to shoes, based on the text of each document:

return apos.docs.getManager('profile').find(req,
    {
      reputation: {
        $gte: 30
      }
    }
  ).search('shoes')
  .toArray(function(err, profiles) {
    // We can work with the profiles relevant to shoes here
  });

The search filter performs a MongoDB full-text search and adjusts the sort order to be based on search quality, unless you explicitly ask for another order. And, Apostrophe has already taken care of ensuring that MongoDB indexes the content of your string schema fields and rich text widgets.

You can specify searchable: false for a schema field if you really don't want it to be considered for search.

There is also an autocomplete filter, which accepts with partial words, autocompletes them based on "high importance" words such as those in titles, and then feeds that back into the search filter. Autocomplete is great, but it can't find everything. So if you offer autocomplete, it's also a good idea to offer "full search" as well.

Pieces pages and filters

To make it easier to browse a listing of pieces, the apostrophe-pieces-pages module will automatically permit filters to be used as query string parameters, provided they are marked as safe for the public. You can try this with the search filter, which is marked as safe, or with any of the filters provided to you automatically for your schema fields, provided you use the piecesFilters option as shown below.

Built-in filters: every schema field gets one!

Every cursor object obtained via find from a manager automatically has methods with the same name as each field in the schema. For instance, you can write .slug('party').toArray(function(err, docs) { ... }) to find all docs with a slug (URL) that contains the word party. This works for most schema field types, although there are a few for which filters don't make sense or don't exist yet.

Filtering joins: browsing profiles by market

Let's say our profiles have a join with another content type, market. Each profile is for a salesperson who works in a particular market:

{
  modules: {
    profiles: {
      extend: 'apostrophe-pieces',
      name: 'profile',
      label: 'Profile',
      addFields: [
        {
          type: 'integer',
          name: 'reputation',
          label: 'Reputation'
        },
        {
          type: 'joinByOne',
          idField: 'marketId',
          withType: 'market',
          name: '_market',
          label: 'Market'
        }
      ]
    }
  }
}

We'd like to be able to fetch profiles by market. We could do that by writing a MongoDB criteria object, but if we're doing it often, it would be a lot nicer to call ._market(id). Better yet, .market(slug), which would allow us to have user-friendly query strings in the address bar.

Good news! You used to have to add these filters yourself; now they are built in. The _market filter expects an id, while the market filter expects a slug (underscores are for programmers, plain names are for the public).

For security reasons, these filters don't automatically become available for public use via query strings. However this does happen if you configure them with piecesFilters as shown below.

Creating filter UI with apostrophe-pieces-pages

If you are working with apostrophe-pieces-pages, you'll likely want to display links to each tag, each market, etc. and allow the user to filter the profiles.

This is easy thanks to the piecesFilters option:

  'profiles-pages': {
    extend: 'apostrophe-pieces-pages',
    piecesFilters: [
      {
        name: 'tags'
      },
      {
        name: 'market'
      }
    ]
  }

Here we're asking apostrophe-pieces-pages to automatically populate req.data.piecesFilters.tags and req.data.piecesFilters.market with arrays of choices.

Now we can take advantage of that:

{# Somewhere in lib/modules/profiles-pages/index.html #}

{# Link to all the tags, adding a parameter to the query string #}
<ul class="tag-filters">
  {% for tag in data.piecesFilters.tags %}
    <li><a href="{{ data._url | build({ tags: tag.value }) }}">{{ tag.label }}</a></li>
  {% endfor %}
</ul>

{# Link to all the markets, adding a parameter to the query string #}
<ul class="tag-filters">
  {% for market in data.piecesFilters.market %}
    <li><a href="{{ data._url | build({ market: market.value }) }}">{{ market.label }}</a></li>
  {% endfor %}
</ul>

Notice that even though tags and joins are very different animals, the template code is exactly the same. That's because the choices provided to us are always in a consistent format: the label is a label, while the value is intended to be the query string parameter for this particular filter. So you can easily write a universal nunjucks macro for filters.

Showing the current state of the filter

Usually we want to indicate the tag the user has already chosen. How can we do that?

{# Somewhere in lib/modules/profiles-pages/index.html #}

{# Link to all the tags, adding a parameter to the query string #}
<ul class="tag-filters">
  {% for tag in data.piecesFilters.tags %}
    <li><a href="{{ data._url | build({ tags: tag.value }) }}"
      class="{{ 'current' if data.query.tags == tag.value }}">{{ tag.label }}</a></li>
  {% endfor %}
</ul>

Here's the really interesting bit:

class="{{ 'current' if data.query.tags == tag.value }}"

The current query string is automatically unpacked to data.query for you as an object. So just compare data.query.tags to the value of each of the choices.

Here we're using the alternate if syntax for Nunjucks, for convenience.

Filtering on multiple values

You're not restricted to filtering on a single value for a join. If you pass an array to one of the filters for a join, you'll get back results that have any of the specified values.

If you want to be more restrictive and only display results that have all of the specified values, just add And to the filter name. For instance, _marketAnd() expects ids, and marketAnd() expects slugs.

It's possible to build query strings that contain arrays. It's usually easiest to do that in an actual old-fashioned GET-method form, perhaps with JavaScript code that enhances it with nicer-looking lists of links and sets multiple-select values in the form, triggering submit afterwards.

Custom filters

Here's how we would implement the market filter from scratch if it didn't already exist:

// In lib/modules/profiles/lib/cursor.js
module.exports = {
  construct: function(self, options) {
    self.addFilter('market', {
      def: false,
      launder: function(value) {
        return self.apos.launder.string(value);
      },
      safeFor: 'public',
      finalize: function(callback) {
        var slug = self.get('market');
        if (!slug) {
          return setImmediate(callback);
        }
        // Get the request object to pass to `find`
        var req = self.get('req');
        return self.apos.docs.getManager('market').find(req, {
          slug: slug
        }, {
          _id: 1
        }).toObject(function(err, market) {
          if (err) {
            return callback(err);
          }
          self.and({ marketId: market._id });
          return callback(null);
        });
      }
    });
  }
};

What's happening in this code?

Adding features to all cursors for pieces

You can change the behavior of all cursors for pieces. Just put your cursor definition, like the one above, in lib/modules/apostrophe-pieces/lib/cursor.js in your project.

Adding features to all cursors for pages

The apostrophe-pages-cursor type is used to fetch the current page for display on the site. It is also used to fetch its ancestors and children, and defines filters for those purposes. You can configure the filters that are called by default when fetching the current page via the filters option to apostrophe-pages.

But what if we want to add new filters to this type?

For this trick, you'll need to get slightly more comfortable with Apostrophe's use of moog to manage object-oriented programming.

But it's still pretty easy:

// in app.js
modules: {
  'extend-page-cursors': {}
}
// in lib/modules/extend-page-cursors/index.js
module.exports = {
  construct: function(self, options) {
    self.apos.define('apostrophe-pages-cursor', require('./lib/pagesCursor.js'));
  }
};
// in lib/modules/extend-page-cursors/lib/pagesCursor.js'
module.exports = {
  construct: function(self, options) {
    self.addFilter('yourFilterNameHere', { ... definition ... });
  }
}

What's happening in this code?

Whoa, that was intense... I mean cool

Cursors are one of the coolest things in Apostrophe. We used to say one of the most intense, before they were created for you automatically in most use cases. But now that they are built-in for most schema fields, it's tough to see them as anything but cool.

Next: Building a contact form →