handle deep geo-near queries (#1216)

a dedicated mongKey is added in geo.nearFilter for mongoDB
fixes geo min distance tests as filter now expects an array
This commit is contained in:
Corentin H 2017-04-06 14:25:04 +02:00 committed by Sakib Hasan
parent fc2f66c514
commit 6f88cf1930
3 changed files with 242 additions and 65 deletions

View File

@ -12,82 +12,109 @@ var assert = require('assert');
*/
exports.nearFilter = function nearFilter(where) {
var result = false;
function nearSearch(clause, parentKeys) {
if (typeof clause !== 'object') {
return false;
}
parentKeys = parentKeys || [];
if (where && typeof where === 'object') {
Object.keys(where).forEach(function(key) {
var ex = where[key];
if (ex && ex.near) {
result = {
near: ex.near,
maxDistance: ex.maxDistance,
minDistance: ex.minDistance,
unit: ex.unit,
key: key,
};
Object.keys(clause).forEach(function(clauseKey) {
if (Array.isArray(clause[clauseKey])) {
clause[clauseKey].forEach(function(el, index) {
var ret = nearSearch(el, parentKeys.concat(clauseKey).concat(index));
if (ret) return ret;
});
} else {
if (clause[clauseKey].hasOwnProperty('near')) {
var result = clause[clauseKey];
nearResults.push({
near: result.near,
maxDistance: result.maxDistance,
minDistance: result.minDistance,
unit: result.unit,
// If key is at root, define a single string, otherwise append it to the full path array
mongoKey: parentKeys.length ? parentKeys.concat(clauseKey) : clauseKey,
key: clauseKey,
});
}
}
});
}
var nearResults = [];
nearSearch(where);
return result;
return (!nearResults.length ? false : nearResults);
};
/*!
* Filter a set of objects using the given `nearFilter`.
* Filter a set of results using the given filters returned by `nearFilter()`.
* Can support multiple locations, but will include results from all of them.
*
* WARNING: "or" operator with GeoPoint does not work as expected, eg:
* {where: {or: [{location: {near: (29,-90)}},{name:'Sean'}]}}
* Will actually work as if you had used "and". This is because geo filtering
* takes place outside of the SQL query, so the result set of "name = Sean" is
* returned by the database, and then the location filtering happens in the app
* logic. So the "near" operator is always an "and" of the SQL filters, and "or"
* of other GeoPoint filters.
*
* Additionally, since this step occurs after the SQL result set is returned,
* if using GeoPoints with pagination the result set may be smaller than the
* page size. The page size is enforced at the DB level, and then we may
* remove results at the Geo-app level. If we "limit: 25", but 4 of those results
* do not have a matching geopoint field, the request will only return 21 results.
* This may make it erroneously look like a given page is the end of the result set.
*/
exports.filter = function(arr, filter) {
var origin = filter.near;
var max = filter.maxDistance > 0 ? filter.maxDistance : false;
var min = filter.minDistance > 0 ? filter.minDistance : false;
var unit = filter.unit;
var key = filter.key;
// create distance index
exports.filter = function(rawResults, filters) {
var distances = {};
var result = [];
var results = [];
arr.forEach(function(obj) {
var loc = obj[key];
filters.forEach(function(filter) {
var origin = filter.near;
var max = filter.maxDistance > 0 ? filter.maxDistance : false;
var min = filter.minDistance > 0 ? filter.minDistance : false;
var unit = filter.unit;
var key = filter.key;
// filter out objects without locations
if (!loc) return;
// create distance index
rawResults.forEach(function(result) {
var loc = result[key];
if (!(loc instanceof GeoPoint)) {
loc = GeoPoint(loc);
}
// filter out results without locations
if (!loc) return;
if (typeof loc.lat !== 'number') return;
if (typeof loc.lng !== 'number') return;
if (!(loc instanceof GeoPoint)) loc = GeoPoint(loc);
var d = GeoPoint.distanceBetween(origin, loc, {type: unit});
if (typeof loc.lat !== 'number') return;
if (typeof loc.lng !== 'number') return;
if (min && d < min) {
return;
}
if (max && d > max) {
// dont add
} else {
distances[obj.id] = d;
result.push(obj);
}
var d = GeoPoint.distanceBetween(origin, loc, {type: unit});
// filter result if distance is either < minDistance or > maxDistance
if ((min && d < min) || (max && d > max)) return;
distances[result.id] = d;
results.push(result);
});
results.sort(function(resA, resB) {
var a = resA[key];
var b = resB[key];
if (a && b) {
var da = distances[resA.id];
var db = distances[resB.id];
if (db === da) return 0;
return da > db ? 1 : -1;
} else {
return 0;
}
});
});
return result.sort(function(objA, objB) {
var a = objA[key];
var b = objB[key];
if (a && b) {
var da = distances[objA.id];
var db = distances[objB.id];
if (db === da) return 0;
return da > db ? 1 : -1;
} else {
return 0;
}
});
return results;
};
exports.GeoPoint = GeoPoint;
@ -285,4 +312,3 @@ function geoDistance(x1, y1, x2, y2, options) {
return 2 * Math.asin(f) * EARTH_RADIUS[type];
}

View File

@ -38,6 +38,7 @@ describe('basic-querying', function() {
name: String,
},
],
addressLoc: {type: 'GeoPoint'},
});
db.automigrate(done);
@ -553,6 +554,147 @@ describe('basic-querying', function() {
});
});
describe('geo queries', function() {
describe('near filter', function() {
it('supports a basic "near" query', function(done) {
User.find({
where: {
addressLoc: {
near: '29.9,-90.07',
},
},
}, function(err, users) {
if (err) done(err);
users.should.have.property('length', 3);
users[0].name.should.equal('John Lennon');
users[0].addressLoc.should.not.be.null();
done();
});
});
it('supports "near" inside a coumpound query with "and"', function(done) {
User.find({
where: {
and: [
{
addressLoc: {
near: '29.9,-90.07',
},
},
{
vip: true,
},
],
},
}, function(err, users) {
if (err) done(err);
users.should.have.property('length', 2);
users[0].name.should.equal('John Lennon');
users[0].addressLoc.should.not.be.null();
users[0].vip.should.be.true();
done();
});
});
it('supports "near" inside a complex coumpound query with multiple "and"', function(done) {
User.find({
where: {
and: [
{
and: [
{
addressLoc: {
near: '29.9,-90.07',
},
},
{
order: 2,
},
],
},
{
vip: true,
},
],
},
}, function(err, users) {
if (err) done(err);
users.should.have.property('length', 1);
users[0].name.should.equal('John Lennon');
users[0].addressLoc.should.not.be.null();
users[0].vip.should.be.true();
users[0].order.should.equal(2);
done();
});
});
it('supports multiple "near" queries with "or"', function(done) {
User.find({
where: {
or: [
{
addressLoc: {
near: '29.9,-90.04',
maxDistance: 300,
},
},
{
addressLoc: {
near: '22.97,-88.03',
maxDistance: 50,
},
},
],
},
}, function(err, users) {
if (err) done(err);
users.should.have.property('length', 2);
users[0].addressLoc.should.not.be.null();
users[0].name.should.equal('Paul McCartney');
users[1].addressLoc.should.not.equal(null);
users[1].name.should.equal('John Lennon');
done();
});
});
it('supports multiple "near" queries with "or" ' +
'inside a coumpound query with "and"', function(done) {
User.find({
where: {
and: [
{
or: [
{
addressLoc: {
near: '29.9,-90.04',
maxDistance: 300,
},
},
{
addressLoc: {
near: '22.7,-89.03',
maxDistance: 50,
},
},
],
},
{
vip: true,
},
],
},
}, function(err, users) {
if (err) done(err);
users.should.have.property('length', 1);
users[0].addressLoc.should.not.be.null();
users[0].name.should.equal('John Lennon');
users[0].vip.should.be.true();
done();
});
});
});
});
it('should only include fields as specified', function(done) {
var remaining = 0;
@ -590,8 +732,9 @@ describe('basic-querying', function() {
}
sample({name: true}).expect(['name']);
sample({name: false}).expect(['id', 'seq', 'email', 'role', 'order', 'birthday', 'vip',
'address', 'friends']);
sample({name: false}).expect([
'id', 'seq', 'email', 'role', 'order', 'birthday', 'vip', 'address', 'friends', 'addressLoc',
]);
sample({name: false, id: true}).expect(['id']);
sample({id: true}).expect(['id']);
sample('id').expect(['id']);
@ -1005,6 +1148,7 @@ function seed(done) {
{name: 'George Harrison'},
{name: 'Ringo Starr'},
],
addressLoc: {lat: 29.97, lng: -90.03},
},
{
seq: 1,
@ -1025,8 +1169,15 @@ function seed(done) {
{name: 'George Harrison'},
{name: 'Ringo Starr'},
],
addressLoc: {lat: 22.97, lng: -88.03},
},
{
seq: 2,
name: 'George Harrison',
order: 5,
vip: false,
addressLoc: {lat: 22.7, lng: -89.03},
},
{seq: 2, name: 'George Harrison', order: 5, vip: false},
{seq: 3, name: 'Ringo Starr', order: 6, vip: false},
{seq: 4, name: 'Pete Best', order: 4},
{seq: 5, name: 'Stuart Sutcliffe', order: 3, vip: true},

View File

@ -147,8 +147,8 @@ describe('GeoPoint', function() {
},
};
var filter = nearFilter(where);
filter.key.should.equal('location');
filter.should.have.properties({
filter[0].key.should.equal('location');
filter[0].should.have.properties({
key: 'location',
near: {
lat: 40.77492964101182,
@ -187,7 +187,7 @@ describe('GeoPoint', function() {
lng: 121.483687,
},
}];
var filter = {
var filter = [{
key: 'location',
near: {
lat: 30.278562,
@ -195,7 +195,7 @@ describe('GeoPoint', function() {
},
unit: 'meters',
minDistance: 10000,
};
}];
var results = geoFilter(points, filter);
results.length.should.be.equal(3);
});