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++;