16

Ok, still in my toy app, I want to find out the average mileage on a group of car owners' odometers. This is pretty easy on the client but doesn't scale. Right? But on the server, I don't exactly see how to accomplish it.

Questions:

  1. How do you implement something on the server then use it on the client?
  2. How do you use the $avg aggregation function of mongo to leverage its optimized aggregation function?
  3. Or alternatively to (2) how do you do a map/reduce on the server and make it available to the client?

The suggestion by @HubertOG was to use Meteor.call, which makes sense and I did this:

# Client side
Template.mileage.average_miles = ->
  answer = null
  Meteor.call "average_mileage", (error, result) ->
    console.log "got average mileage result #{result}"
    answer = result
  console.log "but wait, answer = #{answer}"
  answer

# Server side
Meteor.methods average_mileage: ->
  console.log "server mileage called"
  total = count = 0
  r = Mileage.find({}).forEach (mileage) ->
    total += mileage.mileage
    count += 1
  console.log "server about to return #{total / count}"
  total / count

That would seem to work fine, but it doesn't because as near as I can tell Meteor.call is an asynchronous call and answer will always be a null return. Handling stuff on the server seems like a common enough use case that I must have just overlooked something. What would that be?

Thanks!

Neil Lunn
  • 148,042
  • 36
  • 346
  • 317
Steve Ross
  • 4,134
  • 1
  • 28
  • 40

4 Answers4

29

As of Meteor 0.6.5, the collection API doesn't support aggregation queries yet because there's no (straightforward) way to do live updates on them. However, you can still write them yourself, and make them available in a Meteor.publish, although the result will be static. In my opinion, doing it this way is still preferable because you can merge multiple aggregations and use the client-side collection API.

Meteor.publish("someAggregation", function (args) {
    var sub = this;
    // This works for Meteor 0.6.5
    var db = MongoInternals.defaultRemoteCollectionDriver().mongo.db;

    // Your arguments to Mongo's aggregation. Make these however you want.
    var pipeline = [
        { $match: doSomethingWith(args) },
        { $group: {
            _id: whatWeAreGroupingWith(args),
            count: { $sum: 1 }
        }}
    ];

    db.collection("server_collection_name").aggregate(        
        pipeline,
        // Need to wrap the callback so it gets called in a Fiber.
        Meteor.bindEnvironment(
            function(err, result) {
                // Add each of the results to the subscription.
                _.each(result, function(e) {
                    // Generate a random disposable id for aggregated documents
                    sub.added("client_collection_name", Random.id(), {
                        key: e._id.somethingOfInterest,                        
                        count: e.count
                    });
                });
                sub.ready();
            },
            function(error) {
                Meteor._debug( "Error doing aggregation: " + error);
            }
        )
    );
});

The above is an example grouping/count aggregation. Some things of note:

  • When you do this, you'll naturally be doing an aggregation on server_collection_name and pushing the results to a different collection called client_collection_name.
  • This subscription isn't going to be live, and will probably be updated whenever the arguments change, so we use a really simple loop that just pushes all the results out.
  • The results of the aggregation don't have Mongo ObjectIDs, so we generate some arbitrary ones of our own.
  • The callback to the aggregation needs to be wrapped in a Fiber. I use Meteor.bindEnvironment here but one can also use a Future for more low-level control.

If you start combining the results of publications like these, you'll need to carefully consider how the randomly generated ids impact the merge box. However, a straightforward implementation of this is just a standard database query, except it is more convenient to use with Meteor APIs client-side.

TL;DR version: Almost anytime you are pushing data out from the server, a publish is preferable to a method.

For more information about different ways to do aggregation, check out this post.

Andrew Mao
  • 35,740
  • 23
  • 143
  • 224
  • 3
    I don't want to leave this answer without a "thank you." It's a totally awesome answer. I got pulled away on another project temporarily, but Andrew, you obviously put a lot of thought into this and I'm very appreciative. – Steve Ross Sep 22 '13 at 16:41
  • @SteveRoss you're welcome. Thanks for the kind words! – Andrew Mao Oct 11 '13 at 13:43
  • Kudos for the excellent aggregation example. It was the only one that worked for me. And that you managed to do it without a package, with MongoInternals, and in a publish function... icing on red velvet cake. Thank you for sharing! – AbigailW Nov 15 '13 at 23:40
  • Great answer Andrew. Do you know of any packages that abstract the tricky parts out here? – Tom Coleman Apr 29 '14 at 08:10
  • @TomColeman https://github.com/jhoxray/meteor-mongo-extensions but it's not maintained. – Andrew Mao Apr 29 '14 at 16:16
  • Is there any way to get the subscription live/reactive? – while Jul 29 '14 at 15:47
  • @while Not easily. It's tough, which is why it hasn't been implemented yet. – Andrew Mao Jul 29 '14 at 17:30
  • Is this seriously the easiest way to do aggregations with Meteor? I need to query for matches using $in so the package mentioned above won't help because it only does other methods. – JohnAllen Sep 23 '14 at 23:06
  • @JohnAllen you're not just doing a database aggregation here, you're also sending it to the client. It's hard to do a live aggregation result, hence it hasn't been implemented yet. – Andrew Mao Sep 24 '14 at 00:20
  • Awesome, well thanks for posting this answer regardless! – JohnAllen Sep 24 '14 at 01:43
  • @AndrewMao I'm struggling to understand the client/server collection part of this. Are there two collections for ease of querying, or is this required? For my purposes, I'm attempting to sort results by number of `$in` match results so I'm not sure this is what I need. Is the above the easiest and correct way to do this? Sure wish there was some more examples or documentation for this. Have read through all of the Meteor group threads, I believe. – JohnAllen Sep 24 '14 at 03:23
  • If you didn't pick up on it, I just want to subscribe to a normal query here for one time calls - I just need the query sorted by number of matches. – JohnAllen Sep 24 '14 at 03:24
  • Is it possible to return a cursor (mongodb 2.6+) instead of the results so that it works the Meteor way as if doing a find() or do we still need to handle it using pub.added(), pub.ready(), etc? http://mongodb.github.io/node-mongodb-native/1.4/api-generated/collection.html#aggregate – rclai Nov 25 '14 at 13:29
  • @rclai89 Meteor cursors do live data updates with diffs, which seems like it's going to be nearly impossible for aggregations. Even if Mongo is returning a cursor, we're nowhere near getting live data cursors for aggregations yet. – Andrew Mao Nov 25 '14 at 19:47
2

I did this with the 'aggregate' method. (ver 0.7.x)

if(Meteor.isServer){
Future = Npm.require('fibers/future');
Meteor.methods({
    'aggregate' : function(param){
        var fut = new Future();
        MongoInternals.defaultRemoteCollectionDriver().mongo._getCollection(param.collection).aggregate(param.pipe,function(err, result){
            fut.return(result);
        });
        return fut.wait();
    }
    ,'test':function(param){
        var _param = {
            pipe : [
            { $unwind:'$data' },
            { $match:{ 
                'data.y':"2031",
                'data.m':'01',
                'data.d':'01'
            }},
            { $project : {
                '_id':0
                ,'project_id'               : "$project_id"
                ,'idx'                      : "$data.idx"
                ,'y'                        : '$data.y'
                ,'m'                        : '$data.m'
                ,'d'                        : '$data.d'
            }}
        ],
            collection:"yourCollection"
        }
        Meteor.call('aggregate',_param);
    }
});

}

ppillip
  • 39
  • 3
1

You can use Meteor.methods for that.

// server
Meteor.methods({
  average: function() {
    ...
    return something;
  },

});

// client

var _avg = {                      /* Create an object to store value and dependency */
  dep: new Deps.Dependency();
};

Template.mileage.rendered = function() {
  _avg.init = true;
};

Template.mileage.averageMiles = function() {
  _avg.dep.depend();              /* Make the function rerun when _avg.dep is touched */
  if(_avg.init) {                 /* Fetch the value from the server if not yet done */
    _avg.init = false; 
    Meteor.call('average', function(error, result) {
      _avg.val = result;
      _avg.dep.changed();         /* Rerun the helper */
    });
  }
  return _avg.val;
});
Hubert OG
  • 19,314
  • 7
  • 45
  • 73
  • So I'm happily going along getting new input values and so on. At what point is there a write-through to the server. Cuz what I'm seeing is that this would work except that the data on the client has not yet made it to the server. – Steve Ross Aug 29 '13 at 23:51
  • I've amended the original question. – Steve Ross Aug 30 '13 at 01:12
  • You can use dependencies to create reactivity where needed. I've updated my answer. The result may be a bit overcomplicated, at this moment I'm not sure how to do it simpler in your case. – Hubert OG Aug 30 '13 at 08:53
1

If you want reactivity, use Meteor.publish instead of Meteor.call. There's an example in the docs where they publish the number of messages in a given room (just above the documentation for this.userId), you should be able to do something similar.

Peppe L-G
  • 7,351
  • 2
  • 25
  • 50
  • So, reactivity seems good because whenever someone updates their mileage the average changes. So that would recommend publish/subscribe according to what you say. But it seems to me that pub/sub is more for returning filtered or mapped collections than scalar values like an average. This seems like it should only be a line or two of code -- wouldn't you think? – Steve Ross Aug 30 '13 at 17:04
  • @SteveRoss I agree that a common feature like this should be easy to write, but Meteor has no special support for it (yet, version 6.5), so the alternatives are using methods/call or publish/subscribe. Given the similar example in the docs, and the benefit of reactivity, I would go with publish/subscribe. – Peppe L-G Aug 30 '13 at 17:41
  • There's nothing wrong with publishing a collection containing just a single document with your scalar value. And who knows? Maybe one day you'll want more than one average... and then you can publish multiple documents :) – Andrew Wilcox Aug 31 '13 at 02:55