diff --git a/History.md b/History.md index 67addea432..e0971b676c 100644 --- a/History.md +++ b/History.md @@ -30,6 +30,8 @@ * Improve behavior of `$ne`, `$nin`, and `$not` selectors with objects containing arrays. #1451 +* `$near` operator for `2d` and `2dsphere` indices. + #### DDP * Fix infinite loop if a client disconnects while a long yielding method is diff --git a/LICENSE.txt b/LICENSE.txt index 5ea8dd9809..20e02a2541 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -533,6 +533,11 @@ uid2: https://github.com/coreh/uid2 Copyright (c) 2013 Marco Aurelio +---------- +geojson-utils: https://github.com/maxogden/geojson-js-utils +---------- + +Copyright (c) 2010 Max Ogden ============== Apache License diff --git a/packages/geojson-utils/.gitignore b/packages/geojson-utils/.gitignore new file mode 100644 index 0000000000..677a6fc263 --- /dev/null +++ b/packages/geojson-utils/.gitignore @@ -0,0 +1 @@ +.build* diff --git a/packages/geojson-utils/geojson-utils.js b/packages/geojson-utils/geojson-utils.js new file mode 100644 index 0000000000..82d8068263 --- /dev/null +++ b/packages/geojson-utils/geojson-utils.js @@ -0,0 +1,380 @@ +(function () { + var gju = {}; + + // Export the geojson object for **CommonJS** + if (typeof module !== 'undefined' && module.exports) { + module.exports = gju; + } + + // adapted from http://www.kevlindev.com/gui/math/intersection/Intersection.js + gju.lineStringsIntersect = function (l1, l2) { + var intersects = []; + for (var i = 0; i <= l1.coordinates.length - 2; ++i) { + for (var j = 0; j <= l2.coordinates.length - 2; ++j) { + var a1 = { + x: l1.coordinates[i][1], + y: l1.coordinates[i][0] + }, + a2 = { + x: l1.coordinates[i + 1][1], + y: l1.coordinates[i + 1][0] + }, + b1 = { + x: l2.coordinates[j][1], + y: l2.coordinates[j][0] + }, + b2 = { + x: l2.coordinates[j + 1][1], + y: l2.coordinates[j + 1][0] + }, + ua_t = (b2.x - b1.x) * (a1.y - b1.y) - (b2.y - b1.y) * (a1.x - b1.x), + ub_t = (a2.x - a1.x) * (a1.y - b1.y) - (a2.y - a1.y) * (a1.x - b1.x), + u_b = (b2.y - b1.y) * (a2.x - a1.x) - (b2.x - b1.x) * (a2.y - a1.y); + if (u_b != 0) { + var ua = ua_t / u_b, + ub = ub_t / u_b; + if (0 <= ua && ua <= 1 && 0 <= ub && ub <= 1) { + intersects.push({ + 'type': 'Point', + 'coordinates': [a1.x + ua * (a2.x - a1.x), a1.y + ua * (a2.y - a1.y)] + }); + } + } + } + } + if (intersects.length == 0) intersects = false; + return intersects; + } + + // Bounding Box + + function boundingBoxAroundPolyCoords (coords) { + var xAll = [], yAll = [] + + for (var i = 0; i < coords[0].length; i++) { + xAll.push(coords[0][i][1]) + yAll.push(coords[0][i][0]) + } + + xAll = xAll.sort(function (a,b) { return a - b }) + yAll = yAll.sort(function (a,b) { return a - b }) + + return [ [xAll[0], yAll[0]], [xAll[xAll.length - 1], yAll[yAll.length - 1]] ] + } + + gju.pointInBoundingBox = function (point, bounds) { + return !(point.coordinates[1] < bounds[0][0] || point.coordinates[1] > bounds[1][0] || point.coordinates[0] < bounds[0][1] || point.coordinates[0] > bounds[1][1]) + } + + // Point in Polygon + // http://www.ecse.rpi.edu/Homepages/wrf/Research/Short_Notes/pnpoly.html#Listing the Vertices + + function pnpoly (x,y,coords) { + var vert = [ [0,0] ] + + for (var i = 0; i < coords.length; i++) { + for (var j = 0; j < coords[i].length; j++) { + vert.push(coords[i][j]) + } + vert.push([0,0]) + } + + var inside = false + for (var i = 0, j = vert.length - 1; i < vert.length; j = i++) { + if (((vert[i][0] > y) != (vert[j][0] > y)) && (x < (vert[j][1] - vert[i][1]) * (y - vert[i][0]) / (vert[j][0] - vert[i][0]) + vert[i][1])) inside = !inside + } + + return inside + } + + gju.pointInPolygon = function (p, poly) { + var coords = (poly.type == "Polygon") ? [ poly.coordinates ] : poly.coordinates + + var insideBox = false + for (var i = 0; i < coords.length; i++) { + if (gju.pointInBoundingBox(p, boundingBoxAroundPolyCoords(coords[i]))) insideBox = true + } + if (!insideBox) return false + + var insidePoly = false + for (var i = 0; i < coords.length; i++) { + if (pnpoly(p.coordinates[1], p.coordinates[0], coords[i])) insidePoly = true + } + + return insidePoly + } + + gju.numberToRadius = function (number) { + return number * Math.PI / 180; + } + + gju.numberToDegree = function (number) { + return number * 180 / Math.PI; + } + + // written with help from @tautologe + gju.drawCircle = function (radiusInMeters, centerPoint, steps) { + var center = [centerPoint.coordinates[1], centerPoint.coordinates[0]], + dist = (radiusInMeters / 1000) / 6371, + // convert meters to radiant + radCenter = [gju.numberToRadius(center[0]), gju.numberToRadius(center[1])], + steps = steps || 15, + // 15 sided circle + poly = [[center[0], center[1]]]; + for (var i = 0; i < steps; i++) { + var brng = 2 * Math.PI * i / steps; + var lat = Math.asin(Math.sin(radCenter[0]) * Math.cos(dist) + + Math.cos(radCenter[0]) * Math.sin(dist) * Math.cos(brng)); + var lng = radCenter[1] + Math.atan2(Math.sin(brng) * Math.sin(dist) * Math.cos(radCenter[0]), + Math.cos(dist) - Math.sin(radCenter[0]) * Math.sin(lat)); + poly[i] = []; + poly[i][1] = gju.numberToDegree(lat); + poly[i][0] = gju.numberToDegree(lng); + } + return { + "type": "Polygon", + "coordinates": [poly] + }; + } + + // assumes rectangle starts at lower left point + gju.rectangleCentroid = function (rectangle) { + var bbox = rectangle.coordinates[0]; + var xmin = bbox[0][0], + ymin = bbox[0][1], + xmax = bbox[2][0], + ymax = bbox[2][1]; + var xwidth = xmax - xmin; + var ywidth = ymax - ymin; + return { + 'type': 'Point', + 'coordinates': [xmin + xwidth / 2, ymin + ywidth / 2] + }; + } + + // from http://www.movable-type.co.uk/scripts/latlong.html + gju.pointDistance = function (pt1, pt2) { + var lon1 = pt1.coordinates[0], + lat1 = pt1.coordinates[1], + lon2 = pt2.coordinates[0], + lat2 = pt2.coordinates[1], + dLat = gju.numberToRadius(lat2 - lat1), + dLon = gju.numberToRadius(lon2 - lon1), + a = Math.pow(Math.sin(dLat / 2), 2) + Math.cos(gju.numberToRadius(lat1)) + * Math.cos(gju.numberToRadius(lat2)) * Math.pow(Math.sin(dLon / 2), 2), + c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + // Earth radius is 6371 km + return (6371 * c) * 1000; // returns meters + }, + + // checks if geometry lies entirely within a circle + // works with Point, LineString, Polygon + gju.geometryWithinRadius = function (geometry, center, radius) { + if (geometry.type == 'Point') { + return gju.pointDistance(geometry, center) <= radius; + } else if (geometry.type == 'LineString' || geometry.type == 'Polygon') { + var point = {}; + var coordinates; + if (geometry.type == 'Polygon') { + // it's enough to check the exterior ring of the Polygon + coordinates = geometry.coordinates[0]; + } else { + coordinates = geometry.coordinates; + } + for (var i in coordinates) { + point.coordinates = coordinates[i]; + if (gju.pointDistance(point, center) > radius) { + return false; + } + } + } + return true; + } + + // adapted from http://paulbourke.net/geometry/polyarea/javascript.txt + gju.area = function (polygon) { + var area = 0; + // TODO: polygon holes at coordinates[1] + var points = polygon.coordinates[0]; + var j = points.length - 1; + var p1, p2; + + for (var i = 0; i < points.length; j = i++) { + var p1 = { + x: points[i][1], + y: points[i][0] + }; + var p2 = { + x: points[j][1], + y: points[j][0] + }; + area += p1.x * p2.y; + area -= p1.y * p2.x; + } + + area /= 2; + return area; + }, + + // adapted from http://paulbourke.net/geometry/polyarea/javascript.txt + gju.centroid = function (polygon) { + var f, x = 0, + y = 0; + // TODO: polygon holes at coordinates[1] + var points = polygon.coordinates[0]; + var j = points.length - 1; + var p1, p2; + + for (var i = 0; i < points.length; j = i++) { + var p1 = { + x: points[i][1], + y: points[i][0] + }; + var p2 = { + x: points[j][1], + y: points[j][0] + }; + f = p1.x * p2.y - p2.x * p1.y; + x += (p1.x + p2.x) * f; + y += (p1.y + p2.y) * f; + } + + f = gju.area(polygon) * 6; + return { + 'type': 'Point', + 'coordinates': [y / f, x / f] + }; + }, + + gju.simplify = function (source, kink) { /* source[] array of geojson points */ + /* kink in metres, kinks above this depth kept */ + /* kink depth is the height of the triangle abc where a-b and b-c are two consecutive line segments */ + kink = kink || 20; + source = source.map(function (o) { + return { + lng: o.coordinates[0], + lat: o.coordinates[1] + } + }); + + var n_source, n_stack, n_dest, start, end, i, sig; + var dev_sqr, max_dev_sqr, band_sqr; + var x12, y12, d12, x13, y13, d13, x23, y23, d23; + var F = (Math.PI / 180.0) * 0.5; + var index = new Array(); /* aray of indexes of source points to include in the reduced line */ + var sig_start = new Array(); /* indices of start & end of working section */ + var sig_end = new Array(); + + /* check for simple cases */ + + if (source.length < 3) return (source); /* one or two points */ + + /* more complex case. initialize stack */ + + n_source = source.length; + band_sqr = kink * 360.0 / (2.0 * Math.PI * 6378137.0); /* Now in degrees */ + band_sqr *= band_sqr; + n_dest = 0; + sig_start[0] = 0; + sig_end[0] = n_source - 1; + n_stack = 1; + + /* while the stack is not empty ... */ + while (n_stack > 0) { + + /* ... pop the top-most entries off the stacks */ + + start = sig_start[n_stack - 1]; + end = sig_end[n_stack - 1]; + n_stack--; + + if ((end - start) > 1) { /* any intermediate points ? */ + + /* ... yes, so find most deviant intermediate point to + either side of line joining start & end points */ + + x12 = (source[end].lng() - source[start].lng()); + y12 = (source[end].lat() - source[start].lat()); + if (Math.abs(x12) > 180.0) x12 = 360.0 - Math.abs(x12); + x12 *= Math.cos(F * (source[end].lat() + source[start].lat())); /* use avg lat to reduce lng */ + d12 = (x12 * x12) + (y12 * y12); + + for (i = start + 1, sig = start, max_dev_sqr = -1.0; i < end; i++) { + + x13 = source[i].lng() - source[start].lng(); + y13 = source[i].lat() - source[start].lat(); + if (Math.abs(x13) > 180.0) x13 = 360.0 - Math.abs(x13); + x13 *= Math.cos(F * (source[i].lat() + source[start].lat())); + d13 = (x13 * x13) + (y13 * y13); + + x23 = source[i].lng() - source[end].lng(); + y23 = source[i].lat() - source[end].lat(); + if (Math.abs(x23) > 180.0) x23 = 360.0 - Math.abs(x23); + x23 *= Math.cos(F * (source[i].lat() + source[end].lat())); + d23 = (x23 * x23) + (y23 * y23); + + if (d13 >= (d12 + d23)) dev_sqr = d23; + else if (d23 >= (d12 + d13)) dev_sqr = d13; + else dev_sqr = (x13 * y12 - y13 * x12) * (x13 * y12 - y13 * x12) / d12; // solve triangle + if (dev_sqr > max_dev_sqr) { + sig = i; + max_dev_sqr = dev_sqr; + } + } + + if (max_dev_sqr < band_sqr) { /* is there a sig. intermediate point ? */ + /* ... no, so transfer current start point */ + index[n_dest] = start; + n_dest++; + } else { /* ... yes, so push two sub-sections on stack for further processing */ + n_stack++; + sig_start[n_stack - 1] = sig; + sig_end[n_stack - 1] = end; + n_stack++; + sig_start[n_stack - 1] = start; + sig_end[n_stack - 1] = sig; + } + } else { /* ... no intermediate points, so transfer current start point */ + index[n_dest] = start; + n_dest++; + } + } + + /* transfer last point */ + index[n_dest] = n_source - 1; + n_dest++; + + /* make return array */ + var r = new Array(); + for (var i = 0; i < n_dest; i++) + r.push(source[index[i]]); + + return r.map(function (o) { + return { + type: "Point", + coordinates: [o.lng, o.lat] + } + }); + } + + // http://www.movable-type.co.uk/scripts/latlong.html#destPoint + gju.destinationPoint = function (pt, brng, dist) { + dist = dist/6371; // convert dist to angular distance in radians + brng = gju.numberToRadius(brng); + + var lat1 = gju.numberToRadius(pt.coordinates[0]); + var lon1 = gju.numberToRadius(pt.coordinates[1]); + + var lat2 = Math.asin( Math.sin(lat1)*Math.cos(dist) + + Math.cos(lat1)*Math.sin(dist)*Math.cos(brng) ); + var lon2 = lon1 + Math.atan2(Math.sin(brng)*Math.sin(dist)*Math.cos(lat1), + Math.cos(dist)-Math.sin(lat1)*Math.sin(lat2)); + lon2 = (lon2+3*Math.PI) % (2*Math.PI) - Math.PI; // normalise to -180..+180ยบ + + return { + 'type': 'Point', + 'coordinates': [gju.numberToDegree(lat2), gju.numberToDegree(lon2)] + }; + }; + +})(); diff --git a/packages/geojson-utils/geojson-utils.tests.js b/packages/geojson-utils/geojson-utils.tests.js new file mode 100644 index 0000000000..73cc5d1554 --- /dev/null +++ b/packages/geojson-utils/geojson-utils.tests.js @@ -0,0 +1,102 @@ +var gju = GeoJSON; + +Tinytest.add("geojson-utils - line intersects", function (test) { + var diagonalUp = { "type": "LineString","coordinates": [ + [0, 0], [10, 10] + ]} + var diagonalDown = { "type": "LineString","coordinates": [ + [10, 0], [0, 10] + ]} + var farAway = { "type": "LineString","coordinates": [ + [100, 100], [110, 110] + ]} + + test.isTrue(gju.lineStringsIntersect(diagonalUp, diagonalDown)); + test.isFalse(gju.lineStringsIntersect(diagonalUp, farAway)); +}); + +// Used by two tests +var box = { + "type": "Polygon", + "coordinates": [ + [ [0, 0], [10, 0], [10, 10], [0, 10] ] + ] +}; + +Tinytest.add("geojson-utils - inside/outside of the box", function (test) { + + var inBox = {"type": "Point", "coordinates": [5, 5]} + var outBox = {"type": "Point", "coordinates": [15, 15]} + + test.isTrue(gju.pointInPolygon(inBox, box)); + test.isFalse(gju.pointInPolygon(outBox, box)); +}); + +Tinytest.add("geojson-utils - drawCircle", function (test) { + test.length(gju.drawCircle(10, {"type": "Point", "coordinates": [0, 0]}). + coordinates[0], 15); + test.length(gju.drawCircle(10, {"type": "Point", "coordinates": [0, 0]}, 50). + coordinates[0], 50); +}); + +Tinytest.add("geojson-utils - centroid", function (test) { + var centroid = gju.rectangleCentroid(box) + test.equal(centroid.coordinates[0], 5); + test.equal(centroid.coordinates[1], 5); +}); + +Tinytest.add("geojson-utils - point distance", function (test) { + var fairyLand = {"type": "Point", + "coordinates": [-122.260000705719, 37.80919060818706]} + var navalBase = {"type": "Point", + "coordinates": [-122.32083320617676, 37.78774223089045]} + test.equal(Math.floor(gju.pointDistance(fairyLand, navalBase)), 5852); +}); + +Tinytest.add("geojson-utils - points distance generated tests", function (test) { + var floatEqual = function (a, b) { + test.isTrue(Math.abs(a - b) < 0.000001); + }; + + // Pairs of points we will be looking a distance between + var tests = [[[-19.416501816827804,-13.442164216190577], [8.694866622798145,-8.511979941977188]], + [[151.2841189110186,-56.14564002258703], [167.77983197313733,0.05544793023727834]], + [[100.28413630579598,-88.02313695591874], [36.48786173714325,53.44207073468715]], + [[-70.34899035631679,76.51596869179048], [154.91605914011598,-73.60970971290953]], + [[96.28682994353585,58.77288202662021], [-118.33936230326071,72.07877089688554]], + [[140.35530551429838,10.507104953983799], [-67.73368513956666,38.075836981181055]], + [[69.55582775664516,86.25450283149257], [-18.446230484172702,6.116170521359891]], + [[163.83647522330284,-65.7211532376241], [-159.2198902608361,-78.42975475382991]], + [[-178.9383797585033,-54.87420454365201], [-175.35227065649815,-84.04084282391705]], + [[-48.63219943456352,11.284161149058491], [-179.12627786491066,-51.95622375886887]], + [[140.29684206470847,-67.20720696030185], [-109.37452355003916,36.03131077555008]], + [[-154.6698773431126,58.322094617411494], [61.18583445576951,-4.3424885796848685]], + [[122.5562841903884,10.43972848681733], [-11.756078708684072,-43.86124441982247]], + [[-67.91648306301795,-86.38826347864233], [163.577536230674,12.987319261068478]], + [[91.65140007715672,17.595150742679834], [135.80393003183417,22.307532118167728]], + [[-112.70280818711035,34.45729674655013], [-127.42168210959062,-25.51327457977459]], + [[-161.55807900894433,-77.40711871231906], [-92.66313794790767,-89.12077954714186]], + [[39.966264681424946,9.890176948625594], [-159.88646019320004,40.60383598925546]], + [[-57.48232689569704,86.64061016729102], [59.53941993578337,-75.73194969259202]], + [[-142.0938081513159,80.76813141163439], [14.891517050098628,64.56322408467531]]]; + + // correct distance between two points + var answers = [3115066.2536578891, 6423493.2321747802, 15848950.0402601473, + 18714226.5425080135, 5223022.7731127860, 13874476.3135112207, + 9314403.3309389465, 1831929.5917785936, 3244710.9344544266, + 13691492.4666933995, 14525055.6462231465, 13261602.4336371962, + 14275427.5511620939, 11699799.3615680672, 4628773.1129429890, + 6846704.0253010122, 1368055.9401701286, 14041503.0409814864, + 18560499.7346975356, 3793112.6186894816]; + + _.each(tests, function (pair, testN) { + var distance = GeoJSON.pointDistance.apply(this, _.map(pair, toGeoJSONPoint)); + test.isTrue(Math.abs(distance - answers[testN]) < 0.00000001, + "Wrong distance between points " + JSON.stringify(pair) + ": " + distance); + }); + + function toGeoJSONPoint (coordinates) { + return { type: "Point", coordinates: coordinates }; + } +}); + diff --git a/packages/geojson-utils/package.js b/packages/geojson-utils/package.js new file mode 100644 index 0000000000..a82fb5e62d --- /dev/null +++ b/packages/geojson-utils/package.js @@ -0,0 +1,16 @@ +Package.describe({ + summary: 'GeoJSON utility functions (from https://github.com/maxogden/geojson-js-utils)', + internal: true +}); + +Package.on_use(function (api) { + api.export('GeoJSON'); + api.add_files(['pre.js', 'geojson-utils.js', 'post.js']); +}); + +Package.on_test(function (api) { + api.use('tinytest'); + api.use('geojson-utils'); + api.add_files(['geojson-utils.tests.js'], 'client'); +}); + diff --git a/packages/geojson-utils/post.js b/packages/geojson-utils/post.js new file mode 100644 index 0000000000..7ac8bff75e --- /dev/null +++ b/packages/geojson-utils/post.js @@ -0,0 +1,4 @@ +// This exports object was created in pre.js. Now copy the `exports` object +// from it into the package-scope variable `GeoJSON`, which will get exported. +GeoJSON = module.exports; + diff --git a/packages/geojson-utils/pre.js b/packages/geojson-utils/pre.js new file mode 100644 index 0000000000..99ad3ca65d --- /dev/null +++ b/packages/geojson-utils/pre.js @@ -0,0 +1,4 @@ +// Define an object named exports. This will cause geojson-utils.js to put `gju` +// as a field on it, instead of in the global namespace. See also post.js. +module = {exports:{}}; + diff --git a/packages/minimongo/NOTES b/packages/minimongo/NOTES index 017aeda689..e2db1fb0e2 100644 --- a/packages/minimongo/NOTES +++ b/packages/minimongo/NOTES @@ -46,7 +46,7 @@ It just hasn't been looked at/thought about yet. upsert combined with $-operators might work, but hasn't actually been looked at or tested. -In general, the API needs tests, espectially update. (On the other +In general, the API needs tests, especially update. (On the other hand, the underlying selector and mutator code is quite well tested.) ## OTHER STUFF ## diff --git a/packages/minimongo/minimongo.js b/packages/minimongo/minimongo.js index 5bee132f17..2ddb61b84d 100644 --- a/packages/minimongo/minimongo.js +++ b/packages/minimongo/minimongo.js @@ -87,12 +87,18 @@ LocalCollection.Cursor = function (collection, selector, options) { if (LocalCollection._selectorIsId(selector)) { // stash for fast path self.selector_id = LocalCollection._idStringify(selector); - self.selector_f = LocalCollection._compileSelector(selector); + self.selector_f = LocalCollection._compileSelector(selector, self); self.sort_f = undefined; } else { + // MongoDB throws different errors on different branching operators + // containing $near + if (isGeoQuerySpecial(selector)) + throw new Error("$near can't be inside $or/$and/$nor/$not"); + self.selector_id = undefined; - self.selector_f = LocalCollection._compileSelector(selector); - self.sort_f = options.sort ? LocalCollection._compileSort(options.sort) : null; + self.selector_f = LocalCollection._compileSelector(selector, self); + self.sort_f = (isGeoQuery(selector) || options.sort) ? + LocalCollection._compileSort(options.sort || [], self) : null; } self.skip = options.skip; self.limit = options.limit; @@ -488,7 +494,7 @@ LocalCollection.prototype.remove = function (selector, callback) { var remove = []; var queriesToRecompute = []; - var selector_f = LocalCollection._compileSelector(selector); + var selector_f = LocalCollection._compileSelector(selector, self); // Avoid O(n) for "remove a single doc by ID". var specificIds = LocalCollection._idsMatchedBySelector(selector); @@ -555,7 +561,7 @@ LocalCollection.prototype.update = function (selector, mod, options, callback) { } if (!options) options = {}; - var selector_f = LocalCollection._compileSelector(selector); + var selector_f = LocalCollection._compileSelector(selector, self); // Save the original results of any query that we might need to // _recomputeResults on, because _modifyAndNotify will mutate the objects in @@ -1175,3 +1181,23 @@ LocalCollection._compileProjection = function (fields) { return res; }; }; + +// Searches $near operator in the selector recursively +// (including all $or/$and/$nor/$not branches) +var isGeoQuery = function (selector) { + return _.any(selector, function (val, key) { + // Note: _.isObject matches objects and arrays + return key === "$near" || (_.isObject(val) && isGeoQuery(val)); + }); +}; + +// Checks if $near appears under some $or/$and/$nor/$not branch +var isGeoQuerySpecial = function (selector) { + return _.any(selector, function (val, key) { + if (_.contains(['$or', '$and', '$nor', '$not'], key)) + return isGeoQuery(val); + // Note: _.isObject matches objects and arrays + return _.isObject(val) && isGeoQuerySpecial(val); + }); +}; + diff --git a/packages/minimongo/minimongo_tests.js b/packages/minimongo/minimongo_tests.js index 46146a51ef..34df9dba0b 100644 --- a/packages/minimongo/minimongo_tests.js +++ b/packages/minimongo/minimongo_tests.js @@ -2266,3 +2266,98 @@ Tinytest.add("minimongo - count on cursor with limit", function(test){ c.stop(); }); + +Tinytest.add("minimongo - $near operator tests", function (test) { + var coll = new LocalCollection(); + coll.insert({ rest: { loc: [2, 3] } }); + coll.insert({ rest: { loc: [-3, 3] } }); + coll.insert({ rest: { loc: [5, 5] } }); + + test.equal(coll.find({ 'rest.loc': { $near: [0, 0], $maxDistance: 30 } }).count(), 3); + test.equal(coll.find({ 'rest.loc': { $near: [0, 0], $maxDistance: 4 } }).count(), 1); + var points = coll.find({ 'rest.loc': { $near: [0, 0], $maxDistance: 6 } }).fetch(); + _.each(points, function (point, i, points) { + test.isTrue(!i || distance([0, 0], point.rest.loc) >= distance([0, 0], points[i - 1].rest.loc)); + }); + + function distance(a, b) { + var x = a[0] - b[0]; + var y = a[1] - b[1]; + return Math.sqrt(x * x + y * y); + } + + // GeoJSON tests + coll = new LocalCollection(); + var data = [{ "category" : "BURGLARY", "descript" : "BURGLARY OF STORE, FORCIBLE ENTRY", "address" : "100 Block of 10TH ST", "location" : { "type" : "Point", "coordinates" : [ -122.415449723856, 37.7749518087273 ] } }, + { "category" : "WEAPON LAWS", "descript" : "POSS OF PROHIBITED WEAPON", "address" : "900 Block of MINNA ST", "location" : { "type" : "Point", "coordinates" : [ -122.415386041221, 37.7747879744156 ] } }, + { "category" : "LARCENY/THEFT", "descript" : "GRAND THEFT OF PROPERTY", "address" : "900 Block of MINNA ST", "location" : { "type" : "Point", "coordinates" : [ -122.41538270191, 37.774683628213 ] } }, + { "category" : "LARCENY/THEFT", "descript" : "PETTY THEFT FROM LOCKED AUTO", "address" : "900 Block of MINNA ST", "location" : { "type" : "Point", "coordinates" : [ -122.415396041221, 37.7747879744156 ] } }, + { "category" : "OTHER OFFENSES", "descript" : "POSSESSION OF BURGLARY TOOLS", "address" : "900 Block of MINNA ST", "location" : { "type" : "Point", "coordinates" : [ -122.415386041221, 37.7747879734156 ] } } + ]; + + _.each(data, function (x, i) { coll.insert(_.extend(x, { x: i })); }); + + var close15 = coll.find({ location: { $near: { + $geometry: { type: "Point", + coordinates: [-122.4154282, 37.7746115] }, + $maxDistance: 15 } } }).fetch(); + test.length(close15, 1); + test.equal(close15[0].descript, "GRAND THEFT OF PROPERTY"); + + var close20 = coll.find({ location: { $near: { + $geometry: { type: "Point", + coordinates: [-122.4154282, 37.7746115] }, + $maxDistance: 20 } } }).fetch(); + test.length(close20, 4); + test.equal(close20[0].descript, "GRAND THEFT OF PROPERTY"); + test.equal(close20[1].descript, "PETTY THEFT FROM LOCKED AUTO"); + test.equal(close20[2].descript, "POSSESSION OF BURGLARY TOOLS"); + test.equal(close20[3].descript, "POSS OF PROHIBITED WEAPON"); + + // Any combinations of $near with $or/$and/$nor/$not should throw an error + test.throws(function () { + coll.find({ location: { + $not: { + $near: { + $geometry: { + type: "Point", + coordinates: [-122.4154282, 37.7746115] + }, $maxDistance: 20 } } } }); + }); + test.throws(function () { + coll.find({ + $and: [ { location: { $near: { $geometry: { type: "Point", coordinates: [-122.4154282, 37.7746115] }, $maxDistance: 20 }}}, + { x: 0 }] + }); + }); + test.throws(function () { + coll.find({ + $or: [ { location: { $near: { $geometry: { type: "Point", coordinates: [-122.4154282, 37.7746115] }, $maxDistance: 20 }}}, + { x: 0 }] + }); + }); + test.throws(function () { + coll.find({ + $nor: [ { location: { $near: { $geometry: { type: "Point", coordinates: [-122.4154282, 37.7746115] }, $maxDistance: 1 }}}, + { x: 0 }] + }); + }); + test.throws(function () { + coll.find({ + $and: [{ + $and: [{ + location: { + $near: { + $geometry: { + type: "Point", + coordinates: [-122.4154282, 37.7746115] + }, + $maxDistance: 1 + } + } + }] + }] + }); + }); +}); + diff --git a/packages/minimongo/package.js b/packages/minimongo/package.js index 7502f33d08..dec89d4788 100644 --- a/packages/minimongo/package.js +++ b/packages/minimongo/package.js @@ -7,6 +7,8 @@ Package.on_use(function (api) { api.export('LocalCollection'); api.use(['underscore', 'json', 'ejson', 'ordered-dict', 'deps', 'random', 'ordered-dict']); + // This package is used for geo-location queries such as $near + api.use('geojson-utils'); api.add_files([ 'minimongo.js', 'selector.js', @@ -17,6 +19,7 @@ Package.on_use(function (api) { }); Package.on_test(function (api) { + api.use('geojson-utils', 'client'); api.use('minimongo', 'client'); api.use('test-helpers', 'client'); api.use(['tinytest', 'underscore', 'ejson', 'ordered-dict', diff --git a/packages/minimongo/selector.js b/packages/minimongo/selector.js index 057e0d9195..3a1e5394a8 100644 --- a/packages/minimongo/selector.js +++ b/packages/minimongo/selector.js @@ -29,7 +29,7 @@ var hasOperators = function(valueSelector) { return !!theseAreOperators; // {} has no operators }; -var compileValueSelector = function (valueSelector) { +var compileValueSelector = function (valueSelector, selector, cursor) { if (valueSelector == null) { // undefined or null return function (value) { return _anyIfArray(value, function (x) { @@ -74,12 +74,13 @@ var compileValueSelector = function (valueSelector) { _.each(valueSelector, function (operand, operator) { if (!_.has(VALUE_OPERATORS, operator)) throw new Error("Unrecognized operator: " + operator); + // Special case for location operators operatorFunctions.push(VALUE_OPERATORS[operator]( - operand, valueSelector.$options)); + operand, valueSelector, cursor)); }); - return function (value) { + return function (value, doc) { return _.all(operatorFunctions, function (f) { - return f(value); + return f(value, doc); }); }; } @@ -95,38 +96,38 @@ var compileValueSelector = function (valueSelector) { // XXX can factor out common logic below var LOGICAL_OPERATORS = { - "$and": function(subSelector) { + "$and": function(subSelector, operators, cursor) { if (!isArray(subSelector) || _.isEmpty(subSelector)) throw Error("$and/$or/$nor must be nonempty array"); - var subSelectorFunctions = _.map( - subSelector, compileDocumentSelector); - return function (doc) { + var subSelectorFunctions = _.map(subSelector, function (selector) { + return compileDocumentSelector(selector, cursor); }); + return function (doc, wholeDoc) { return _.all(subSelectorFunctions, function (f) { - return f(doc); + return f(doc, wholeDoc); }); }; }, - "$or": function(subSelector) { + "$or": function(subSelector, operators, cursor) { if (!isArray(subSelector) || _.isEmpty(subSelector)) throw Error("$and/$or/$nor must be nonempty array"); - var subSelectorFunctions = _.map( - subSelector, compileDocumentSelector); - return function (doc) { + var subSelectorFunctions = _.map(subSelector, function (selector) { + return compileDocumentSelector(selector, cursor); }); + return function (doc, wholeDoc) { return _.any(subSelectorFunctions, function (f) { - return f(doc); + return f(doc, wholeDoc); }); }; }, - "$nor": function(subSelector) { + "$nor": function(subSelector, operators, cursor) { if (!isArray(subSelector) || _.isEmpty(subSelector)) throw Error("$and/$or/$nor must be nonempty array"); - var subSelectorFunctions = _.map( - subSelector, compileDocumentSelector); - return function (doc) { + var subSelectorFunctions = _.map(subSelector, function (selector) { + return compileDocumentSelector(selector, cursor); }); + return function (doc, wholeDoc) { return _.all(subSelectorFunctions, function (f) { - return !f(doc); + return !f(doc, wholeDoc); }); }; }, @@ -141,6 +142,13 @@ var LOGICAL_OPERATORS = { } }; +// Each value operator is a function with args: +// - operand - Anything +// - operators - Object - operators on the same level (neighbours) +// - cursor - Object - original cursor +// returns a function with args: +// - value - a value the operator is tested against +// - doc - the whole document tested in this query var VALUE_OPERATORS = { "$in": function (operand) { if (!isArray(operand)) @@ -212,11 +220,11 @@ var VALUE_OPERATORS = { if (!isArray(operand)) throw new Error("Argument to $nin must be array"); var inFunction = VALUE_OPERATORS.$in(operand); - return function (value) { + return function (value, doc) { // Field doesn't exist, so it's not-in operand if (value === undefined) return true; - return !inFunction(value); + return !inFunction(value, doc); }; }, @@ -255,7 +263,8 @@ var VALUE_OPERATORS = { }; }, - "$regex": function (operand, options) { + "$regex": function (operand, operators) { + var options = operators.$options; if (options !== undefined) { // Options passed in $options (even the empty string) always overrides // options in the RegExp object itself. (See also @@ -287,22 +296,80 @@ var VALUE_OPERATORS = { return function (value) { return true; }; }, - "$elemMatch": function (operand) { - var matcher = compileDocumentSelector(operand); - return function (value) { + "$elemMatch": function (operand, selector, cursor) { + var matcher = compileDocumentSelector(operand, cursor); + return function (value, doc) { if (!isArray(value)) return false; return _.any(value, function (x) { - return matcher(x); + return matcher(x, doc); }); }; }, - "$not": function (operand) { - var matcher = compileValueSelector(operand); - return function (value) { - return !matcher(value); + "$not": function (operand, operators, cursor) { + var matcher = compileValueSelector(operand, operators, cursor); + return function (value, doc) { + return !matcher(value, doc); }; + }, + + "$near": function (operand, operators, cursor) { + function distanceCoordinatePairs (a, b) { + a = pointToArray(a); + b = pointToArray(b); + var x = a[0] - b[0]; + var y = a[1] - b[1]; + if (_.isNaN(x) || _.isNaN(y)) + return null; + return Math.sqrt(x * x + y * y); + } + // Makes sure we get 2 elements array and assume the first one to be x and + // the second one to y no matter what user passes. + // In case user passes { lon: x, lat: y } returns [x, y] + function pointToArray (point) { + return _.map(point, _.identity); + } + // GeoJSON query is marked as $geometry property + var mode = _.isObject(operand) && _.has(operand, '$geometry') ? "2dsphere" : "2d"; + var maxDistance = mode === "2d" ? operators.$maxDistance : operand.$maxDistance; + var point = mode === "2d" ? operand : operand.$geometry; + return function (value, doc) { + var dist = null; + switch (mode) { + case "2d": + dist = distanceCoordinatePairs(point, value); + break; + case "2dsphere": + // XXX: for now, we don't calculate the actual distance between, say, + // polygon and circle. If people care about this use-case it will get + // a priority. + if (value.type === "Point") + dist = GeoJSON.pointDistance(point, value); + else + dist = GeoJSON.geometryWithinRadius(value, point, maxDistance) ? + 0 : maxDistance + 1; + break; + } + // Used later in sorting by distance, since $near queries are sorted by + // distance from closest to farthest. + if (cursor) { + if (!cursor._distance) + cursor._distance = {}; + cursor._distance[doc._id] = dist; + } + + // Distance couldn't parse a geometry object + if (dist === null) + return false; + + return maxDistance === undefined ? true : dist <= maxDistance; + }; + }, + + "$maxDistance": function () { + // evaluation happens in the $near operator + return function () { return true; } } }; @@ -536,7 +603,7 @@ LocalCollection._makeLookupFunction = function (key) { }; // The main compilation function for a given selector. -var compileDocumentSelector = function (docSelector) { +var compileDocumentSelector = function (docSelector, cursor) { var perKeySelectors = []; _.each(docSelector, function (subSelector, key) { if (key.substr(0, 1) === '$') { @@ -544,11 +611,13 @@ var compileDocumentSelector = function (docSelector) { // this function), or $where. if (!_.has(LOGICAL_OPERATORS, key)) throw new Error("Unrecognized logical operator: " + key); - perKeySelectors.push(LOGICAL_OPERATORS[key](subSelector)); + perKeySelectors.push( + LOGICAL_OPERATORS[key](subSelector, docSelector, cursor)); } else { var lookUpByIndex = LocalCollection._makeLookupFunction(key); - var valueSelectorFunc = compileValueSelector(subSelector); - perKeySelectors.push(function (doc) { + var valueSelectorFunc = + compileValueSelector(subSelector, docSelector, cursor); + perKeySelectors.push(function (doc, wholeDoc) { var branchValues = lookUpByIndex(doc); // We apply the selector to each "branched" value and return true if any // match. However, for "negative" selectors like $ne or $not we actually @@ -569,15 +638,20 @@ var compileDocumentSelector = function (docSelector) { (subSelector.$not || subSelector.$ne || subSelector.$nin)) ? _.all : _.any; - return combiner(branchValues, valueSelectorFunc); + return combiner(branchValues, function (val) { + return valueSelectorFunc(val, wholeDoc); + }); }); } }); - return function (doc) { + return function (doc, wholeDoc) { + // If called w/o wholeDoc, doc is considered the original by default + if (wholeDoc === undefined) + wholeDoc = doc; return _.all(perKeySelectors, function (f) { - return f(doc); + return f(doc, wholeDoc); }); }; }; @@ -585,7 +659,7 @@ var compileDocumentSelector = function (docSelector) { // Given a selector, return a function that takes one argument, a // document, and returns true if the document matches the selector, // else false. -LocalCollection._compileSelector = function (selector) { +LocalCollection._compileSelector = function (selector, cursor) { // you can pass a literal function instead of a selector if (selector instanceof Function) return function (doc) {return selector.call(doc);}; @@ -608,7 +682,7 @@ LocalCollection._compileSelector = function (selector) { EJSON.isBinary(selector)) throw new Error("Invalid selector: " + selector); - return compileDocumentSelector(selector); + return compileDocumentSelector(selector, cursor); }; // Give a sort spec, which can be in any of these forms: @@ -624,7 +698,7 @@ LocalCollection._compileSelector = function (selector) { // first object comes first in order, 1 if the second object comes // first, or 0 if neither object comes before the other. -LocalCollection._compileSort = function (spec) { +LocalCollection._compileSort = function (spec, cursor) { var sortSpecParts = []; if (spec instanceof Array) { @@ -652,8 +726,14 @@ LocalCollection._compileSort = function (spec) { throw Error("Bad sort specification: ", JSON.stringify(spec)); } + // If there are no sorting rules specified, try to sort on _distance hidden + // fields on cursor we may acquire if query involved $near operator. if (sortSpecParts.length === 0) - return function () {return 0;}; + return function (a, b) { + if (!cursor || !cursor._distance) + return 0; + return cursor._distance[a._id] - cursor._distance[b._id]; + }; // reduceValue takes in all the possible values for the sort key along various // branches, and returns the min or max value (according to the bool @@ -705,3 +785,4 @@ LocalCollection._compileSort = function (spec) { return 0; }; }; +