// // Integration test of the geo code // // Basically, this tests adds a random number of docs with a random number of points, // given a 2d environment of random precision which is either randomly earth-like or of // random bounds, and indexes these points after a random amount of points have been added // with a random number of additional fields which correspond to whether the documents are // in randomly generated circular, spherical, box, and box-polygon shapes (and exact), // queried randomly from a set of query types. Each point is randomly either and object // or array, and all points and document data fields are nested randomly in arrays (or not). // // We approximate the user here as a random function :-) // // These random point fields can then be tested against all types of geo queries using these random // shapes. // // Tests can be easily reproduced by getting the test number from the output directly before a // test fails, and hard-wiring that as the test number. // (function() { "use strict"; load("jstests/libs/geo_math.js"); const conn = MongoRunner.runMongod(); assert.neq(null, conn, "mongod failed to start."); const db = conn.getDB("test"); var randEnvironment = function() { // Normal earth environment if (Random.rand() < 0.5) { return {max: 180, min: -180, bits: Math.floor(Random.rand() * 32) + 1, earth: true}; } var scales = [0.0001, 0.001, 0.01, 0.1, 1, 10, 100, 1000, 10000, 100000]; var scale = scales[Math.floor(Random.rand() * scales.length)]; var offset = Random.rand() * scale; var max = Random.rand() * scale + offset; var min = -Random.rand() * scale + offset; var bits = Math.floor(Random.rand() * 32) + 1; return {max: max, min: min, bits: bits, earth: false}; }; var randPoint = function(env, query) { if (query && Random.rand() > 0.5) return query.exact; if (env.earth) return [Random.rand() * 360 - 180, Random.rand() * 180 - 90]; var range = env.max - env.min; return [Random.rand() * range + env.min, Random.rand() * range + env.min]; }; var randLocType = function(loc, wrapIn) { return randLocTypes([loc], wrapIn)[0]; }; var randLocTypes = function(locs, wrapIn) { var rLocs = []; for (var i = 0; i < locs.length; i++) { rLocs.push(locs[i]); } if (wrapIn) { var wrappedLocs = []; for (var i = 0; i < rLocs.length; i++) { var wrapper = {}; wrapper[wrapIn] = rLocs[i]; wrappedLocs.push(wrapper); } return wrappedLocs; } return rLocs; }; var randDataType = function() { var scales = [1, 10, 100, 1000, 10000]; var docScale = scales[Math.floor(Random.rand() * scales.length)]; var locScale = scales[Math.floor(Random.rand() * scales.length)]; var numDocs = 40000; var maxLocs = 40000; // Make sure we don't blow past our test resources while (numDocs * maxLocs > 40000) { numDocs = Math.floor(Random.rand() * docScale) + 1; maxLocs = Math.floor(Random.rand() * locScale) + 1; } return {numDocs: numDocs, maxLocs: maxLocs}; }; function computexscandist(latDegrees, maxDistDegrees) { // See s2cap.cc // // Compute the range of longitudes covered by the cap. We use the law // of sines for spherical triangles. Consider the triangle ABC where // A is the north pole, B is the center of the cap, and C is the point // of tangency between the cap boundary and a line of longitude. Then // C is a right angle, and letting a,b,c denote the sides opposite A,B,C, // we have sin(a)/sin(A) = sin(c)/sin(C), or sin(A) = sin(a)/sin(c). // Here "a" is the cap angle, and "c" is the colatitude (90 degrees // minus the latitude). This formula also works for negative latitudes. // // Angle A is the difference of longitudes of B and C. var sin_c = Math.cos(deg2rad(latDegrees)); var sin_a = Math.sin(deg2rad(maxDistDegrees)); if (sin_a > sin_c) { // Double floating number error, return invalid distance return 180; } var angleA = Math.asin(sin_a / sin_c); return rad2deg(angleA); } function errorMarginForPoint(env) { if (!env.bits) { return 0.01; } var scalingFactor = Math.pow(2, env.bits); return ((env.max - env.min) / scalingFactor) * Math.sqrt(2); } function pointIsOK(startPoint, radius, env) { var error = errorMarginForPoint(env); var distDegrees = rad2deg(radius) + error; // TODO SERVER-24440: Points close to the north and south poles may fail to be returned by // $nearSphere queries answered using a "2d" index. We have empirically found that points // with latitudes between 89 and 90 degrees are potentially affected by this issue, so we // additionally reject any coordinates with a latitude that falls within that range. if ((startPoint[1] + distDegrees > 89) || (startPoint[1] - distDegrees < -89)) { return false; } var xscandist = computexscandist(startPoint[1], distDegrees); return (startPoint[0] + xscandist < 180) && (startPoint[0] - xscandist > -180); } var randQuery = function(env) { var center = randPoint(env); var sphereRadius = -1; var sphereCenter = null; if (env.earth) { // Get a start point that doesn't require wrapping // TODO: Are we a bit too aggressive with wrapping issues? var i; for (i = 0; i < 5; i++) { sphereRadius = Random.rand() * 45 * Math.PI / 180; sphereCenter = randPoint(env); if (pointIsOK(sphereCenter, sphereRadius, env)) { break; } } if (i == 5) sphereRadius = -1; } var box = [randPoint(env), randPoint(env)]; var boxPoly = [ [box[0][0], box[0][1]], [box[0][0], box[1][1]], [box[1][0], box[1][1]], [box[1][0], box[0][1]] ]; if (box[0][0] > box[1][0]) { var swap = box[0][0]; box[0][0] = box[1][0]; box[1][0] = swap; } if (box[0][1] > box[1][1]) { var swap = box[0][1]; box[0][1] = box[1][1]; box[1][1] = swap; } return { center: center, radius: box[1][0] - box[0][0], exact: randPoint(env), sphereCenter: sphereCenter, sphereRadius: sphereRadius, box: box, boxPoly: boxPoly }; }; var resultTypes = { "exact": function(loc) { return query.exact[0] == loc[0] && query.exact[1] == loc[1]; }, "center": function(loc) { return Geo.distance(query.center, loc) <= query.radius; }, "box": function(loc) { return loc[0] >= query.box[0][0] && loc[0] <= query.box[1][0] && loc[1] >= query.box[0][1] && loc[1] <= query.box[1][1]; }, "sphere": function(loc) { return (query.sphereRadius >= 0 ? (Geo.sphereDistance(query.sphereCenter, loc) <= query.sphereRadius) : false); }, "poly": function(loc) { return loc[0] >= query.box[0][0] && loc[0] <= query.box[1][0] && loc[1] >= query.box[0][1] && loc[1] <= query.box[1][1]; } }; var queryResults = function(locs, query, results) { if (!results["center"]) { for (var type in resultTypes) { results[type] = {docsIn: 0, docsOut: 0, locsIn: 0, locsOut: 0}; } } var indResults = {}; for (var type in resultTypes) { indResults[type] = {docIn: false, locsIn: 0, locsOut: 0}; } for (var type in resultTypes) { var docIn = false; for (var i = 0; i < locs.length; i++) { if (resultTypes[type](locs[i])) { results[type].locsIn++; indResults[type].locsIn++; indResults[type].docIn = true; } else { results[type].locsOut++; indResults[type].locsOut++; } } if (indResults[type].docIn) results[type].docsIn++; else results[type].docsOut++; } return indResults; }; var randQueryAdditions = function(doc, indResults) { for (var type in resultTypes) { var choice = Random.rand(); if (Random.rand() < 0.25) doc[type] = (indResults[type].docIn ? {docIn: "yes"} : {docIn: "no"}); else if (Random.rand() < 0.5) doc[type] = (indResults[type].docIn ? {docIn: ["yes"]} : {docIn: ["no"]}); else if (Random.rand() < 0.75) doc[type] = (indResults[type].docIn ? [{docIn: "yes"}] : [{docIn: "no"}]); else doc[type] = (indResults[type].docIn ? [{docIn: ["yes"]}] : [{docIn: ["no"]}]); } }; var randIndexAdditions = function(indexDoc) { for (var type in resultTypes) { if (Random.rand() < 0.5) continue; var choice = Random.rand(); if (Random.rand() < 0.5) indexDoc[type] = 1; else indexDoc[type + ".docIn"] = 1; } }; var randYesQuery = function() { var choice = Math.floor(Random.rand() * 7); if (choice == 0) return {$ne: "no"}; else if (choice == 1) return "yes"; else if (choice == 2) return /^yes/; else if (choice == 3) return {$in: ["good", "yes", "ok"]}; else if (choice == 4) return {$exists: true}; else if (choice == 5) return {$nin: ["bad", "no", "not ok"]}; else if (choice == 6) return {$not: /^no/}; }; var locArray = function(loc) { if (loc.x) return [loc.x, loc.y]; if (!loc.length) return [loc[0], loc[1]]; return loc; }; var locsArray = function(locs) { if (locs.loc) { const arr = []; for (var i = 0; i < locs.loc.length; i++) arr.push(locArray(locs.loc[i])); return arr; } else { const arr = []; for (var i = 0; i < locs.length; i++) arr.push(locArray(locs[i].loc)); return arr; } }; var minBoxSize = function(env, box) { return env.bucketSize * Math.pow(2, minBucketScale(env, box)); }; var minBucketScale = function(env, box) { if (box.length && box[0].length) box = [box[0][0] - box[1][0], box[0][1] - box[1][1]]; if (box.length) box = Math.max(box[0], box[1]); print(box); print(env.bucketSize); return Math.ceil(Math.log(box / env.bucketSize) / Math.log(2)); }; // TODO: Add spherical $uniqueDocs tests var numTests = 100; // Our seed will change every time this is run, but // each individual test will be reproducible given // that seed and test number var seed = new Date().getTime(); // seed = 175 + 288 + 12 for (var test = 0; test < numTests; test++) { Random.srand(seed + test); // Random.srand( 42240 ) // Random.srand( 7344 ) var t = db.testAllGeo; t.drop(); print("Generating test environment #" + test); var env = randEnvironment(); // env.bits = 11 var query = randQuery(env); var data = randDataType(); // data.numDocs = 5; data.maxLocs = 1; var paddingSize = Math.floor(Random.rand() * 10 + 1); var results = {}; var totalPoints = 0; print("Calculating target results for " + data.numDocs + " docs with max " + data.maxLocs + " locs "); var bulk = t.initializeUnorderedBulkOp(); for (var i = 0; i < data.numDocs; i++) { var numLocs = Math.floor(Random.rand() * data.maxLocs + 1); totalPoints += numLocs; var multiPoint = []; for (var p = 0; p < numLocs; p++) { var point = randPoint(env, query); multiPoint.push(point); } var indResults = queryResults(multiPoint, query, results); var doc; // Nest the keys differently if (Random.rand() < 0.5) doc = {locs: {loc: randLocTypes(multiPoint)}}; else doc = {locs: randLocTypes(multiPoint, "loc")}; randQueryAdditions(doc, indResults); doc._id = i; bulk.insert(doc); } assert.commandWorked(bulk.execute()); var indexDoc = {"locs.loc": "2d"}; randIndexAdditions(indexDoc); // "earth" is used to drive test setup and not a valid createIndex option or required at // this point. It must be removed before calling createIndex(). delete env.earth; assert.commandWorked(t.createIndex(indexDoc, env)); var padding = "x"; for (var i = 0; i < paddingSize; i++) padding = padding + padding; print(padding); printjson({ seed: seed, test: test, env: env, query: query, data: data, results: results, paddingSize: paddingSize }); // exact print("Exact query..."); assert.eq( results.exact.docsIn, t.find({"locs.loc": randLocType(query.exact), "exact.docIn": randYesQuery()}).count()); // $center print("Center query..."); print("Min box : " + minBoxSize(env, query.radius)); assert.eq(results.center.docsIn, t.find({ "locs.loc": {$within: {$center: [query.center, query.radius], $uniqueDocs: 1}}, "center.docIn": randYesQuery() }).count()); print("Center query update..."); var res = t.update({ "locs.loc": {$within: {$center: [query.center, query.radius], $uniqueDocs: true}}, "center.docIn": randYesQuery() }, {$set: {centerPaddingA: padding}}, false, true); assert.eq(results.center.docsIn, res.nModified); if (query.sphereRadius >= 0) { print("Center sphere query..."); // $centerSphere assert.eq( results.sphere.docsIn, t.find({ "locs.loc": {$within: {$centerSphere: [query.sphereCenter, query.sphereRadius]}}, "sphere.docIn": randYesQuery() }).count()); print("Center sphere query update..."); res = t.update({ "locs.loc": { $within: {$centerSphere: [query.sphereCenter, query.sphereRadius], $uniqueDocs: true} }, "sphere.docIn": randYesQuery() }, {$set: {spherePaddingA: padding}}, false, true); assert.eq(results.sphere.docsIn, res.nModified); } // $box print("Box query..."); assert.eq(results.box.docsIn, t.find({ "locs.loc": {$within: {$box: query.box, $uniqueDocs: true}}, "box.docIn": randYesQuery() }).count()); // $polygon print("Polygon query..."); assert.eq(results.poly.docsIn, t.find({ "locs.loc": {$within: {$polygon: query.boxPoly}}, "poly.docIn": randYesQuery() }).count()); // $near print("Near query..."); assert.eq(results.center.docsIn, t.find({"locs.loc": {$near: query.center, $maxDistance: query.radius}}).count(true), "Near query: center: " + query.center + "; radius: " + query.radius + "; docs: " + results.center.docsIn + "; locs: " + results.center.locsIn); if (query.sphereRadius >= 0) { print("Near sphere query..."); // $centerSphere assert.eq( results.sphere.docsIn, t.find({ "locs.loc": {$nearSphere: query.sphereCenter, $maxDistance: query.sphereRadius} }).count(true), "Near sphere query: sphere center: " + query.sphereCenter + "; radius: " + query.sphereRadius + "; docs: " + results.sphere.docsIn + "; locs: " + results.sphere.locsIn); } // $geoNear aggregation stage. const aggregationLimit = 2 * results.center.docsIn; if (aggregationLimit > 0) { var output = t.aggregate([ { $geoNear: { near: query.center, maxDistance: query.radius, includeLocs: "pt", distanceField: "dis", } }, {$limit: aggregationLimit} ]).toArray(); const errmsg = { limit: aggregationLimit, center: query.center, radius: query.radius, docs: results.center.docsIn, locs: results.center.locsIn, actualResult: output }; assert.eq(results.center.docsIn, output.length, tojson(errmsg)); let lastDistance = 0; for (var i = 0; i < output.length; i++) { var retDistance = output[i].dis; assert.close(retDistance, Geo.distance(locArray(query.center), output[i].pt)); assert.lte(retDistance, query.radius); assert.gte(retDistance, lastDistance); lastDistance = retDistance; } } // $polygon print("Polygon remove..."); res = t.remove({"locs.loc": {$within: {$polygon: query.boxPoly}}, "poly.docIn": randYesQuery()}); assert.eq(results.poly.docsIn, res.nRemoved); } MongoRunner.stopMongod(conn); })();