From 5e7daaaff33482ee6571a3a8a9c5b99bfa428732 Mon Sep 17 00:00:00 2001 From: matt debergalis Date: Thu, 19 Jan 2012 18:32:44 -0800 Subject: [PATCH] New Collection API The new Collection API separates query handles from result sets. It allows template iterators to only redraw changed objects instead of entire result sets. This implementation also sets the stage for minimongo indexes and better invalidation performance. collection.find() now returns a Collection.Query handle. To retrieve results we provide these methods on Collection.Query: Iterators (encouraged way to access results): * query.forEach(function (obj) { ... }); * results = query.map(function (obj) { ... }); Cursor-based retrieval (iterators are built on fetch): * docs = query.fetch(maxlen); // return next [maxlen] (all) docs. * doc = query.get(skip); // return next doc, skipping [skip] (0) docs. Counter: * length = query.count(); // number of results in query. Live queries (replaces findLive): * live_handle = query.observe({added: function (obj, idx) { ... }, removed: function (id, idx) { ... }, changed: function (obj, idx) { ... }, moved: function (obj, old_idx, new_idx) { ... }}); Convenience finders: * doc = collection.findOne({color: 'red'}); * doc = collection.findOne(id_val); On the client, calling forEach(), map(), fetch(), get(), or findOne() inside an invalidation context will register a dependency on the entire query result. Any change to any objects invalidates the context. Calling count() inside an invalidation context will register a dependency that only triggers if objects enter or leave the result set. Calling observe() does not register a dependency. --- app/meteor/skel/client/~name~.js | 4 +- examples/leaderboard/leaderboard.js | 12 +- examples/todos/client/todos.js | 6 +- examples/todos/server/bootstrap.js | 2 +- packages/livedata/livedata_client.js | 24 +- packages/livedata/livedata_server.js | 25 +- packages/livedata/mongo_driver.js | 79 +++++- packages/liveui/liveui.js | 28 +-- packages/liveui/liveui_tests.js | 28 +-- packages/minimongo/minimongo.js | 348 ++++++++++++++++---------- packages/minimongo/minimongo_tests.js | 94 ++++++- packages/templating/deftemplate.js | 4 +- 12 files changed, 429 insertions(+), 225 deletions(-) diff --git a/app/meteor/skel/client/~name~.js b/app/meteor/skel/client/~name~.js index 3fb60df8dd..58c4b81eb4 100644 --- a/app/meteor/skel/client/~name~.js +++ b/app/meteor/skel/client/~name~.js @@ -7,9 +7,9 @@ Template.button_demo.events = { }; Template.button_demo.ever_pressed = function (options) { - return Clicks.find().length > 0; + return Clicks.find().count() > 0; }; Template.button_demo.press_count = function () { - return Clicks.find().length; + return Clicks.find().count(); }; diff --git a/examples/leaderboard/leaderboard.js b/examples/leaderboard/leaderboard.js index a1eee8c3fb..53e5a82b8f 100644 --- a/examples/leaderboard/leaderboard.js +++ b/examples/leaderboard/leaderboard.js @@ -13,8 +13,7 @@ if (Meteor.is_client) { $(document).ready(function () { // List the players by score. You can click to select a player. - var scores = Meteor.ui.renderList(Players, { - sort: {score: -1}, // sort from high to low score + var scores = Meteor.ui.renderList(Players.find({}, {sort: {score: -1}}), { render: function (player) { if (Session.equals("selected_player", player._id)) var style = "player selected"; @@ -40,7 +39,7 @@ if (Meteor.is_client) { if (!selected_player) return $('
Click a player to select
'); - var player = Players.find(selected_player); + var player = Players.findOne(selected_player); return $('
' + player.name + '
'); }, { @@ -60,12 +59,13 @@ if (Meteor.is_client) { if (Meteor.is_server) { // Publish the top 10 players, live, to any client that wants them. - Meteor.publish("top10", {collection: Players, sort: {score: -1}, - limit: 10}); + Meteor.publish("top10", {collection: Players, + sort: {score: -1}, + limit: 10}); // On server startup, create some players if the database is empty. Meteor.startup(function () { - if (Players.find().length === 0) { + if (Players.find().count() === 0) { var names = ["Glinnes Hulden", "Shira Hulden", "Denzel Warhound", "Lute Casagave", "Akadie", "Thammas, Lord Gensifer", "Ervil Savat", "Duissane Trevanyi", "Sagmondo Bandolio", diff --git a/examples/todos/client/todos.js b/examples/todos/client/todos.js index d405375192..5734816dda 100644 --- a/examples/todos/client/todos.js +++ b/examples/todos/client/todos.js @@ -8,8 +8,8 @@ Meteor.subscribe('lists', {}, function () { // Once the lists have loaded, select the first one. if (!Session.get('list_id')) { var lists = Lists.find({}, {sort: {name: 1}, limit: 1}); - if (lists.length) - Router.setList(lists[0]._id); + if (lists.count() > 0) + Router.setList(lists.get(0)._id); } }); @@ -23,7 +23,7 @@ Meteor.autosubscribe(function () { Template.tag_filter.tags = function () { // Pick out the unique tags from all tasks. - var tags = _(Todos.find()) + var tags = _(Todos.find().fetch()) .chain().pluck('tags').compact().flatten().sort().uniq(true).value(); // for some reason, .map can't be chained on IE8. underscore bug? tags = _.map(tags, function (tag) { return {tag: tag} }); diff --git a/examples/todos/server/bootstrap.js b/examples/todos/server/bootstrap.js index b1fa1a88ee..394e1ec27c 100644 --- a/examples/todos/server/bootstrap.js +++ b/examples/todos/server/bootstrap.js @@ -1,6 +1,6 @@ // if the database is empty on server start, create some sample data. Meteor.startup(function () { - if (Lists.find().length === 0) { + if (Lists.find().count() === 0) { var data = [ {name: "* Seven Principles *", contents: [ diff --git a/packages/livedata/livedata_client.js b/packages/livedata/livedata_client.js index 1507078948..caffc0cc5b 100644 --- a/packages/livedata/livedata_client.js +++ b/packages/livedata/livedata_client.js @@ -26,7 +26,7 @@ if (typeof Meteor === "undefined") Meteor = {}; // XXX this is all a little whack. Need to think about how we handle // removes, etc. _.each(changes.inserted || [], function (elt) { - if (!coll.find(elt._id)) { + if (!coll.findOne(elt._id)) { coll._collection.insert(elt); } else { // we already added it locally! this is the case after an insert @@ -58,7 +58,7 @@ if (typeof Meteor === "undefined") Meteor = {}; // add new subscriptions at the end. this way they take effect after // the handlers and we don't see flicker. - _.each(subs.find(), function (sub) { + subs.find().forEach(function (sub) { msg_list.push( ['subscribe', { _id: sub._id, name: sub.name, args: sub.args}]); @@ -73,7 +73,7 @@ if (typeof Meteor === "undefined") Meteor = {}; }); - var subsToken = subs.findLive({}, { + var subsToken = subs.find({}).observe({ added: function (sub) { Meteor._stream.emit('subscribe', { _id: sub._id, name: sub.name, args: sub.args}); @@ -100,6 +100,14 @@ if (typeof Meteor === "undefined") Meteor = {}; _name: name, _collection: new Collection(), + find: function (selector, options) { + return this._collection.find(selector, options); + }, + + findOne: function (selector) { + return this._collection.findOne(selector); + }, + insert: function (obj) { // Generate an id for the object. // XXX mutates the object passed in. that is not cool. @@ -117,14 +125,6 @@ if (typeof Meteor === "undefined") Meteor = {}; return obj; }, - find: function (selector, options) { - return this._collection.find(selector, options); - }, - - findLive: function (selector, options) { - return this._collection.findLive(selector, options); - }, - update: function (selector, mutator, options) { if (typeof(selector) === "string") selector = {_id: selector}; @@ -214,7 +214,7 @@ if (typeof Meteor === "undefined") Meteor = {}; // return an object with a stop method. var token = {stop: function () { if (!id) return; // must have an id (local from above). - // just update the database. findLive takes care of the rest. + // just update the database. observe takes care of the rest. subs.update({_id: id}, {$inc: {count: -1}}); }}; diff --git a/packages/livedata/livedata_server.js b/packages/livedata/livedata_server.js index 574d700aad..1ffab6bf6a 100644 --- a/packages/livedata/livedata_server.js +++ b/packages/livedata/livedata_server.js @@ -227,7 +227,7 @@ if (typeof Meteor === "undefined") Meteor = {}; sort: opt("sort"), skip: opt("skip"), limit: opt("limit") - })); + }).fetch()); }; publishes[name] = func; @@ -255,6 +255,15 @@ if (typeof Meteor === "undefined") Meteor = {}; // and minimongo diverge. we should track each of those down and // kill it. + find: function (selector, options) { + var cursor = new Meteor._mongo_driver.Cursor(this._name, selector, options); + return cursor; + }, + + findOne: function (selector, options) { + return this.find(selector, options).get(0); + }, + insert: function (doc) { // do id allocation here, so we never end up with an ObjectID. // This only happens if some calls this directly on the server, @@ -274,14 +283,6 @@ if (typeof Meteor === "undefined") Meteor = {}; return doc; }, - find: function (selector, options) { - return Meteor._mongo_driver.find(this._name, selector, options); - }, - - findLive: function () { - throw new Error("findLive isn't supported on the server"); - }, - update: function (selector, mod, options) { return Meteor._mongo_driver.update(this._name, selector, mod, options); }, @@ -308,4 +309,10 @@ if (typeof Meteor === "undefined") Meteor = {}; return ret; }; + Meteor.Collection.Query = function (collection, selector, options) { + if (!options) options = {}; + + this.cursor = Meteor._mongo_driver.find(this._name, selector, options); + }; + })(); diff --git a/packages/livedata/mongo_driver.js b/packages/livedata/mongo_driver.js index aa21fe61dc..60df033a44 100644 --- a/packages/livedata/mongo_driver.js +++ b/packages/livedata/mongo_driver.js @@ -51,8 +51,7 @@ function withCollection (collection_name, callback) { //////////// Public API ////////// - -function find (collection_name, selector, options) { +function Cursor (collection_name, selector, options) { var future = new Future; withCollection(collection_name, function(err, collection) { // XXX err handling @@ -76,15 +75,75 @@ function find (collection_name, selector, options) { if (options && options.skip) cursor = cursor.skip(options.skip); - var func = single_result ? cursor.nextObject : cursor.toArray; - func.call(cursor, function(err, result) { - // XXX error handling! - future.return(result); - }); + future.return(cursor); + }); + this.cursor = future.wait(); +}; + +Cursor.prototype.forEach = function (callback) { + var self = this; + var future = new Future; + + self.cursor.each(function (err, doc) { + if (err || !doc) + future.return(err); + else + callback(null, doc); }); return future.wait(); }; +Cursor.prototype.map = function (callback) { + var self = this; + var res = []; + self.forEach(function (doc) { + res.push(callback(doc)); + }); + return res; +}; + +Cursor.prototype.rewind = function () { + var self = this; + + self.cursor.rewind(); +}; + +Cursor.prototype.fetch = function (length) { + var self = this; + var future = new Future; + var res = []; + + self.cursor.each(function (err, doc) { + if (err) + future.return(err); + else if (!doc) + future.return(res); + + res.push(doc); + + if (length && res.length === length) + // immediately return w/o consuming another doc in the iterator + future.return(res); + }); + + return future.wait(); +}; + +Cursor.prototype.get = function (i) { + var self = this; + return self.fetch(i + 1)[0]; +}; + +Cursor.prototype.count = function () { + var self = this; + var future = new Future; + + self.cursor.count(function (err, res) { + future.return(res); + }); + + return future.wait(); +}; function insert (collection_name, document) { var future = new Future; @@ -125,7 +184,6 @@ function remove (collection_name, selector) { return future.wait(); }; - function update (collection_name, selector, mod, options) { var future = new Future; // XXX this blocks for the operation to complete (safe:true), because @@ -140,7 +198,6 @@ function update (collection_name, selector, mod, options) { if (typeof(options.multi) === "undefined") options.multi = true - withCollection(collection_name, function(err, collection) { // XXX err handling @@ -159,13 +216,13 @@ function update (collection_name, selector, mod, options) { }; Meteor._mongo_driver = { - find: find, + Cursor: Cursor, insert: insert, remove: remove, update: update }; // start database -init(__meteor_bootstrap__.mongo_url) +init(__meteor_bootstrap__.mongo_url); })(); diff --git a/packages/liveui/liveui.js b/packages/liveui/liveui.js index fade647695..de2eb7c6d4 100644 --- a/packages/liveui/liveui.js +++ b/packages/liveui/liveui.js @@ -148,12 +148,6 @@ Meteor.ui.render = function (render_func, events, event_data) { /// (specific to mongo at the moment, but will be generalized /// eventually.) /// -/// Undocumented elsewhere: you may pass in a findLive handle instead -/// of 'what'. In that case, we will use that query instead, and we -/// will take responsibility for calling stop() on it! Let's leave -/// undocumented for now, and document when the new database API -/// lands. -/// /// Exact GC semantics: /// - Slow path: When a database change happens, unconditionally /// update the rendering, but also schedule an onscreen check to @@ -167,11 +161,17 @@ Meteor.ui.render = function (render_func, events, event_data) { /// to only do the teardown if it is in fact still offscreen at /// flush()-time. /// https://app.asana.com/0/159908330244/382690197728 -Meteor.ui.renderList = function (what, options) { +Meteor.ui.renderList = function (query, options) { var outer_frag; var outer_range; var entry_ranges = []; + // protect against old invocations passing in a Collection or a + // LiveResultsSet. + // XXX remove in a few releases + if (!(query instanceof Collection.Query)) + throw new Error("insert_before: at least one entry must exist"); + // create the top-level document fragment/range that will be // returned by renderList. called exactly once, ever (and that call // will be before renderList returns.) returns nothing, sets @@ -192,7 +192,7 @@ Meteor.ui.renderList = function (what, options) { return; } - query.stop(); + live_results.stop(); if (!old_context.killed) Meteor.ui._cleanup(outer_range); }; @@ -360,9 +360,9 @@ Meteor.ui.renderList = function (what, options) { // the screen. (The user has from when we're created, to when // flush() is called, to put us on the screen.) outer_range.context.invalidate(); - } + }; - var query_opts = { + var observe_callbacks = { added: function (doc, before_idx) { check_onscreen(); var frag = render_doc(doc); @@ -406,13 +406,7 @@ Meteor.ui.renderList = function (what, options) { } }; - if (what instanceof Collection.LiveResultsSet) { - var query = what; - query.reconnect(query_opts); - } else { - query_opts.sort = options.sort; - var query = what.findLive(options.selector || {}, query_opts); - } + var live_results = query.observe(observe_callbacks); if (!outer_range) create_outer_range(render_empty()); diff --git a/packages/liveui/liveui_tests.js b/packages/liveui/liveui_tests.js index c5ff160d45..5873cd5412 100644 --- a/packages/liveui/liveui_tests.js +++ b/packages/liveui/liveui_tests.js @@ -349,8 +349,7 @@ test("render - events", function () { test("renderList - basics", function () { var c = Meteor.Collection(); - var r = Meteor.ui.renderList(c, { - sort: ["id"], + var r = Meteor.ui.renderList(c.find({}, {sort: ['id']}), { render: function (doc) { return DIV({id: doc.id}); }, @@ -428,8 +427,7 @@ test("renderList - removal", function () { c.update({id: "F"}, {$set: {id: "F2"}}); c.update({id: "C"}, {$set: {id: "C2"}}); - var r = Meteor.ui.renderList(c, { - sort: ["id"], + var r = Meteor.ui.renderList(c.find({}, {sort: ['id']}), { render: function (doc) { return DIV({id: doc.id}); }, @@ -479,8 +477,7 @@ test("renderList - removal", function () { test("renderList - default render empty", function () { var c = Meteor.Collection(); - var r = Meteor.ui.renderList(c, { - sort: ["id"], + var r = Meteor.ui.renderList(c.find({}, {sort: ['id']}), { render: function (doc) { return DIV({id: doc.id}); } @@ -496,8 +493,7 @@ test("renderList - default render empty", function () { test("renderList - change and move", function () { var c = Meteor.Collection(); - var r = Meteor.ui.renderList(c, { - sort: ["id"], + var r = Meteor.ui.renderList(c.find({}, {sort: ['id']}), { render: function (doc) { return DIV({id: doc.id}); } @@ -529,8 +525,7 @@ test("renderList - change and move", function () { test("renderList - termination", function () { var c = Meteor.Collection(); - var r = Meteor.ui.renderList(c, { - sort: ["id"], + var r = Meteor.ui.renderList(c.find({}, {sort: ['id']}), { render: function (doc) { return DIV({id: doc.id}); } @@ -557,8 +552,7 @@ test("renderList - termination", function () { c.remove(); c.insert({id: "A"}); c.insert({id: "B"}); - r = Meteor.ui.renderList(c, { - sort: ["id"], + r = Meteor.ui.renderList(c.find({}, {sort: ['id']}), { render: function (doc) { return DIV({id: doc.id}); } @@ -626,8 +620,7 @@ test("renderList - list items are reactive", function () { set_weather("there", "cloudy"); Meteor.flush(); var render_count = 0; - var r = Meteor.ui.renderList(c, { - sort: ["id"], + var r = Meteor.ui.renderList(c.find({}, {sort: ['id']}), { render: function (doc) { render_count++; if (doc.want_weather) @@ -814,8 +807,7 @@ test("renderList - multiple elements in an item", function () { function () { c.remove(); Meteor.flush(); - r = Meteor.ui.renderList(c, { - sort: ["moved", "index"], + r = Meteor.ui.renderList(c.find({}, {sort: ['moved', 'index']}), { render: function (doc) { var ret = []; for (var i = 0; i < lengths[doc.index]; i++) @@ -853,10 +845,10 @@ test("renderList - #each", function () { return get_weather(where); }, data: function () { - return c.findLive({x: {$lt: 5}}, {sort: ["x"]}); + return c.find({x: {$lt: 5}}, {sort: ["x"]}); }, data2: function () { - return c.findLive({x: {$gt: 5}}, {sort: ["x"]}); + return c.find({x: {$gt: 5}}, {sort: ["x"]}); } }); diff --git a/packages/minimongo/minimongo.js b/packages/minimongo/minimongo.js index 8ccc804d58..68c49ce261 100644 --- a/packages/minimongo/minimongo.js +++ b/packages/minimongo/minimongo.js @@ -1,37 +1,26 @@ // XXX indexes // XXX type checking on selectors (graceful error if malformed) +// XXX merge ad-hoc live query object and Query + +// XXX keep observe()s running after they're killed, because usually +// the dependency tracker is going to spin them up again right away. + +// Collection: a set of documents that supports queries and modifiers. + +// Query: a specification for a particular subset of documents, w/ +// a defined order, limit, and offset. creating a Query with Collection.find(), + +// LiveResultsSet: the return value of a live query. Collection = function () { this.docs = {}; // _id -> document (also containing id) - this.next_qid = 1; // query id generator + this.next_qid = 1; // live query id generator - // qid -> query object. keys: selector_f, sort_f, (callbacks) + // qid -> live query object. keys: selector_f, sort_f, (callbacks) this.queries = {}; }; - // XXX enforce rule that field names can't start with '$' or contain '.' - // (real mongodb does in fact enforce this) - // XXX possibly enforce that 'undefined' does not appear (we assume - // this in our handling of null and $exists) -Collection.prototype.insert = function (doc) { - var self = this; - doc = Collection._deepcopy(doc); - // XXX deal with mongo's binary id type? - if (!('_id' in doc)) - doc._id = Collection.uuid(); - // XXX check to see that there is no object with this _id yet? - self.docs[doc._id] = doc; - for (var qid in self.queries) { - var query = self.queries[qid]; - if (query.selector_f(doc)) - Collection._insertInResults(query, doc); - } -}; - -// XXX add one more sort form: "key" -// and tests, etc - // options may include sort, skip, limit, reactive // sort may be any of these forms: // {a: 1, b: -1} @@ -48,63 +37,221 @@ Collection.prototype.insert = function (doc) { // doc?) // // XXX sort does not yet support subkeys ('a.b') .. fix that! +// XXX add one more sort form: "key" +// XXX tests Collection.prototype.find = function (selector, options) { - var self = this; - options = options || {}; + return new Collection.Query(this, selector, options); +}; - var results = null; +// don't call this ctor directly. use Collection.find(). +Collection.Query = function (collection, selector, options) { + if (!options) options = {}; - if (typeof selector === 'string') { - // XXX fast path for single object. NOTE: this is actually a - // different return type than {_id: id} (either object or null, not - // array). Maybe rename this findOne to match mongo? - results = self.docs[selector] || null; - results = Collection._deepcopy(results); + this.collection = collection; + + if (typeof selector === "string") { + // stash for fast path + this.selector_id = selector; + this.selector_f = Collection._compileSelector({_id: selector}); } else { - - var selector_f = Collection._compileSelector(selector); - var sort_f = options.sort && Collection._compileSort(options.sort); - results = self._rawFind(selector_f, sort_f); - - if (options.skip) - results.splice(0, options.skip); - if (options.limit !== undefined) { - var limit = options.limit; - if (results.length > limit) - results.length = limit; - } - for (var i = 0; i < results.length; i++) - results[i] = Collection._deepcopy(results[i]); - + this.selector_f = Collection._compileSelector(selector); + this.sort_f = options.sort ? Collection._compileSort(options.sort) : null; + this.skip = options.skip; + this.limit = options.limit; } - // support Meteor.deps if present - var reactive = (options.reactive === undefined) ? true : options.reactive; - var context = reactive && typeof Meteor === "object" && Meteor.deps && - Meteor.deps.Context.current; + this.db_objects = null; + this.cursor_pos = 0; + + // by default, queries register w/ Meteor.deps when it is available. + if (typeof Meteor === "object" && Meteor.deps) + this.reactive = (options.reactive === undefined) ? true : options.reactive; +}; + +// fetch array of documents from the cursor. defaults to all +// remaining docs, or limited to specified length. returns [] once +// all documents have been consumed. +Collection.Query.prototype.fetch = function (length) { + var self = this; + + if (self.reactive) + self._markAsReactive({added: true, + removed: true, + changed: true, + moved: true}); + + if (self.db_objects === null) + self.db_objects = self._getRawObjects(); + + var idx_end = length ? (length + self.cursor_pos) : undefined; + var objects = self.db_objects.slice(self.cursor_pos, idx_end); + self.cursor_pos += objects.length; + + var results = []; + for (var i = 0; i < objects.length; i++) + results.push(Collection._deepcopy(objects[i])); + return results; +}; + +Collection.Query.prototype.rewind = function () { + var self = this; + self.db_objects = null; + self.cursor_pos = 0; +}; + +Collection.Query.prototype.get = function (offset) { + var self = this; + offset = offset || 0; + return self.fetch(offset + 1).pop(); +}; + +Collection.prototype.findOne = function (selector, options) { + return this.find(selector, options).get(); +}; + +Collection.STOP = 'stop'; +Collection.Query.prototype.forEach = function (callback) { + var self = this; + var doc; + + while ((doc = self.get())) + if (callback(doc) === Collection.STOP) + // callback requests we break out of forEach + return; +}; + +Collection.Query.prototype.map = function (callback) { + var self = this; + var res = []; + var doc; + + while ((doc = self.get())) + res.push(callback(doc)); + return res; +}; + +Collection.Query.prototype.count = function () { + var self = this; + + if (self.reactive) + self._markAsReactive({added: true, removed: true}); + + if (self.db_objects === null) + self.db_objects = self._getRawObjects(); + + return self.db_objects.length; +}; + +// the handle that comes back from observe. +Collection.LiveResultsSet = function () {}; + +Collection.Query.prototype.observe = function (options) { + var self = this; + + if (self.skip || self.limit) + throw new Error("cannot observe queries with skip or limit"); + + var qid = self.collection.next_qid++; + + // XXX merge this object w/ "this" Query. they're the same. + var query = self.collection.queries[qid] = { + selector_f: self.selector_f, // not fast pathed + sort_f: self.sort_f, + results: [] + }; + query.results = self._getRawObjects(); + + query.added = options.added || function () {}; + query.changed = options.changed || function () {}; + query.moved = options.moved || function () {}; + query.removed = options.removed || function () {}; + if (!options._suppress_initial) + for (var i = 0; i < query.results.length; i++) + query.added(Collection._deepcopy(query.results[i]), i); + + var handle = new Collection.LiveResultsSet; + _.extend(handle, { + stop: function () { + delete self.collection.queries[qid]; + }, + indexOf: function (id) { + for (var i = 0; i < query.results.length; i++) + if (query.results[i]._id === id) + return i; + return -1; + }, + collection: self.collection + }); + return handle; +}; + +// constructs sorted array of matching objects, but doesn't copy them. +// respects sort, skip, and limit properties of the query. +// if sort_f is falsey, no sort -- you get the natural order +Collection.Query.prototype._getRawObjects = function () { + var self = this; + + // fast path for single ID value + if (self.selector_id) + return [self.collection.docs[self.selector_id]]; + + // slow path for arbitrary selector, sort, skip, limit + var results = []; + for (var id in self.collection.docs) { + var doc = self.collection.docs[id]; + if (self.selector_f(doc)) + results.push(doc); + } + + if (self.sort_f) + results.sort(self.sort_f); + + var idx_start = self.skip || 0; + var idx_end = self.limit ? (self.limit + idx_start) : undefined; + return results.slice(idx_start, idx_end); +}; + +Collection.Query.prototype._markAsReactive = function (options) { + var self = this; + + var context = Meteor.deps.Context.current; + if (context) { var invalidate = _.bind(context.invalidate, context); - var new_options = _.clone(options); - _.extend(new_options, { - added: invalidate, - removed: invalidate, - changed: invalidate, - moved: invalidate, - _suppress_initial: true - }); + var handle = self.observe({added: options.added && invalidate, + removed: options.removed && invalidate, + changed: options.changed && invalidate, + moved: options.moved && invalidate, + _suppress_initial: true}); - var live_handle = self.findLive(selector, new_options); - context.on_invalidate(function () { - // XXX in many cases, the query will be immediately - // recreated. so we might want to let it linger for a little - // while and repurpose it if it comes back. this will save us - // work because we won't have to redo the initial find. - live_handle.stop(); - }); + // XXX in many cases, the query will be immediately + // recreated. so we might want to let it linger for a little + // while and repurpose it if it comes back. this will save us + // work because we won't have to redo the initial find. + context.on_invalidate(handle.stop); } +}; - return results; +// XXX enforce rule that field names can't start with '$' or contain '.' +// (real mongodb does in fact enforce this) +// XXX possibly enforce that 'undefined' does not appear (we assume +// this in our handling of null and $exists) +Collection.prototype.insert = function (doc) { + var self = this; + doc = Collection._deepcopy(doc); + // XXX deal with mongo's binary id type? + if (!('_id' in doc)) + doc._id = Collection.uuid(); + // XXX check to see that there is no object with this _id yet? + self.docs[doc._id] = doc; + + // trigger live queries that match + for (var qid in self.queries) { + var query = self.queries[qid]; + if (query.selector_f(doc)) + Collection._insertInResults(query, doc); + } }; // options to contain: @@ -118,9 +265,6 @@ Collection.prototype.find = function (selector, options) { // attributes available on returned query handle: // * stop(): end updates // * indexOf(id): return current index of object in result set, or -1 -// * reconnect({}): replace added, changed, moved, removed, from the -// arguments, and call added to deliver the current state of the -// query (XXX ugly hack to support templating) // * collection: the collection this query is querying // // iff x is a returned query handle, (x instanceof @@ -134,64 +278,6 @@ Collection.prototype.find = function (selector, options) { // query, not just its id // XXX document that initial results will definitely be delivered before we return [do, add to asana] -Collection.LiveResultsSet = function () {}; -Collection.prototype.findLive = function (selector, options) { - var self = this; - var qid = self.next_qid++; - if (typeof(selector) === "string") - selector = {_id: selector}; - - var query = self.queries[qid] = { - selector_f: Collection._compileSelector(selector), - sort_f: options.sort ? Collection._compileSort(options.sort) : null, - results: [] - }; - query.results = self._rawFind(query.selector_f, query.sort_f); - - var connect = function (options) { - query.added = options.added || function () {}; - query.changed = options.changed || function () {}; - query.moved = options.moved || function () {}; - query.removed = options.removed || function () {}; - if (!options._suppress_initial) - for (var i = 0; i < query.results.length; i++) - query.added(Collection._deepcopy(query.results[i]), i); - }; - - connect(options); - - var handle = new Collection.LiveResultsSet; - _.extend(handle, { - stop: function () { - delete self.queries[qid]; - }, - indexOf: function (id) { - for (var i = 0; i < query.results.length; i++) - if (query.results[i]._id === id) - return i; - return -1; - }, - reconnect: connect, - collection: this - }); - return handle; -}; - -// returns matching objects, but doesn't copy them -// if sort_f is falsey, no sort -- you get the natural order -Collection.prototype._rawFind = function (selector_f, sort_f) { - var self = this; - var results = []; - for (var id in self.docs) { - var doc = self.docs[id]; - if (selector_f(doc)) - results.push(doc); - } - if (sort_f) - results.sort(sort_f); - return results; -}; - Collection.prototype.remove = function (selector) { var self = this; var remove = []; @@ -214,7 +300,7 @@ Collection.prototype.remove = function (selector) { delete self.docs[remove[i]]; } - // run findLive callbacks _after_ we've removed the documents. + // run live query callbacks _after_ we've removed the documents. for (var i = 0; i < query_remove.length; i++) { Collection._removeFromResults(query_remove[i][0], query_remove[i][1]); } diff --git a/packages/minimongo/minimongo_tests.js b/packages/minimongo/minimongo_tests.js index a71b6b2fc3..95566e7d46 100644 --- a/packages/minimongo/minimongo_tests.js +++ b/packages/minimongo/minimongo_tests.js @@ -45,22 +45,39 @@ test("minimongo - basics", function () { c.insert({type: "cryptographer", name: "alice"}); c.insert({type: "cryptographer", name: "bob"}); c.insert({type: "cryptographer", name: "cara"}); - assert.length(c.find({type: "kitten"}), 2); - assert.length(c.find({type: "cryptographer"}), 3); + assert.equal(c.find().count(), 5); + assert.equal(c.find({type: "kitten"}).count(), 2); + assert.equal(c.find({type: "cryptographer"}).count(), 3); + assert.length(c.find({type: "kitten"}).fetch(), 2); + assert.length(c.find({type: "cryptographer"}).fetch(), 3); + c.remove({name: "cara"}); - assert.length(c.find({type: "kitten"}), 2); - assert.length(c.find({type: "cryptographer"}), 2); + assert.equal(c.find().count(), 4); + assert.equal(c.find({type: "kitten"}).count(), 2); + assert.equal(c.find({type: "cryptographer"}).count(), 2); + assert.length(c.find({type: "kitten"}).fetch(), 2); + assert.length(c.find({type: "cryptographer"}).fetch(), 2); + c.update({name: "snookums"}, {$set: {type: "cryptographer"}}); - assert.length(c.find({type: "kitten"}), 1); - assert.length(c.find({type: "cryptographer"}), 3); + assert.equal(c.find().count(), 4); + assert.equal(c.find({type: "kitten"}).count(), 1); + assert.equal(c.find({type: "cryptographer"}).count(), 3); + assert.length(c.find({type: "kitten"}).fetch(), 1); + assert.length(c.find({type: "cryptographer"}).fetch(), 3); c.remove({}); + assert.equal(0, c.find().count()); + c.insert({_id: 1, name: "strawberry", tags: ["fruit", "red", "squishy"]}); c.insert({_id: 2, name: "apple", tags: ["fruit", "red", "hard"]}); c.insert({_id: 3, name: "rose", tags: ["flower", "red", "squishy"]}); - assert.length(c.find({tags: "flower"}), 1); - assert.length(c.find({tags: "fruit"}), 2); - assert.length(c.find({tags: "red"}), 3); + + assert.equal(c.find({tags: "flower"}).count(), 1); + assert.equal(c.find({tags: "fruit"}).count(), 2); + assert.equal(c.find({tags: "red"}).count(), 3); + assert.length(c.find({tags: "flower"}).fetch(), 1); + assert.length(c.find({tags: "fruit"}).fetch(), 2); + assert.length(c.find({tags: "red"}).fetch(), 3); var ev = ""; var makecb = function (tag) { @@ -74,7 +91,7 @@ test("minimongo - basics", function () { assert.equal(ev, x); ev = ""; }; - c.findLive({tags: "flower"}, makecb('a')); + c.find({tags: "flower"}).observe(makecb('a')); expect("aa3_"); c.update({name: "rose"}, {$set: {tags: ["bloom", "red", "squishy"]}}); expect("ra3_"); @@ -88,6 +105,57 @@ test("minimongo - basics", function () { expect("aa4_"); }); +test("minimongo - cursors", function () { + var c = new Collection(); + var res; + + for (var i = 0; i < 20; i++) + c.insert({i: i}); + + var q = c.find(); + assert.equal(q.count(), 20); + + // first 5 + res = q.fetch(5); + assert.length(res, 5); + for (var i = 0; i < 5; i++) + assert.equal(res[i].i, i); + // next 5 + res = q.fetch(5); + assert.length(res, 5); + for (var i = 0; i < 5; i++) + assert.equal(res[i].i, i + 5); + // get + res = q.get(); + assert.equal(res.i, 10); + res = q.get(2); + assert.equal(res.i, 13); + // foreach should consume 3 objects + var count = 0; + q.forEach(function (obj) { + count++; + if (count === 3) + return Collection.STOP; + }); + res = q.get(0); + assert.equal(res.i, 17); + + // rewind + q.rewind(); + assert.equal(q.get().i, 0); + // remaining 19 in map + res = q.map(function (x) { return x.i; }); + assert.length(res, 19); + for (var i = 0; i < 19; i++) + assert.equal(res[i], i + 1); + + // findOne + assert.equal(c.findOne({i: 0}).i, 0); + assert.equal(c.findOne({i: 1}).i, 1); + var id = c.findOne({i: 2})._id; + assert.equal(c.findOne(id).i, 2); +}); + test("minimongo - misc", function () { // deepcopy var a = {a: [1, 2, 3], b: "x", c: true, d: {x: 12, y: [12]}, @@ -457,7 +525,7 @@ test("minimongo - sort", function () { c.insert({a: i, b: j, _id: i + "_" + j}); assert.equal( - c.find({a: {$gt: 10}}, {sort: {b: -1, a: 1}, limit: 5}), [ + c.find({a: {$gt: 10}}, {sort: {b: -1, a: 1}, limit: 5}).fetch(), [ {a: 11, b: 1, _id: "11_1"}, {a: 12, b: 1, _id: "12_1"}, {a: 13, b: 1, _id: "13_1"}, @@ -465,7 +533,7 @@ test("minimongo - sort", function () { {a: 15, b: 1, _id: "15_1"}]); assert.equal( - c.find({a: {$gt: 10}}, {sort: {b: -1, a: 1}, skip: 3, limit: 5}), [ + c.find({a: {$gt: 10}}, {sort: {b: -1, a: 1}, skip: 3, limit: 5}).fetch(), [ {a: 14, b: 1, _id: "14_1"}, {a: 15, b: 1, _id: "15_1"}, {a: 16, b: 1, _id: "16_1"}, @@ -473,7 +541,7 @@ test("minimongo - sort", function () { {a: 18, b: 1, _id: "18_1"}]); assert.equal( - c.find({a: {$gte: 20}}, {sort: {a: 1, b: -1}, skip: 50, limit: 5}), [ + c.find({a: {$gte: 20}}, {sort: {a: 1, b: -1}, skip: 50, limit: 5}).fetch(), [ {a: 45, b: 1, _id: "45_1"}, {a: 45, b: 0, _id: "45_0"}, {a: 46, b: 1, _id: "46_1"}, diff --git a/packages/templating/deftemplate.js b/packages/templating/deftemplate.js index eaab440f31..8e4b40414a 100644 --- a/packages/templating/deftemplate.js +++ b/packages/templating/deftemplate.js @@ -19,13 +19,13 @@ Meteor._pending_partials_idx_nonce = 0; // XXX another messy hack -- we reach into handlebars and extend #each // to know how to cooperate with pending_partials and minimongo -// findlive. +// observe. Meteor._hook_handlebars_each = function () { Meteor._hook_handlebars_each = function(){}; // install the hook only once var orig = Handlebars._default_helpers.each; Handlebars._default_helpers.each = function (context, options) { - if (!(context instanceof Collection.LiveResultsSet)) + if (!(context instanceof Collection.Query)) return orig(context, options); var id = Meteor._pending_partials_idx_nonce++;