mirror of
https://github.com/parse-community/parse-server
synced 2026-08-09 13:03:18 +00:00
fix: GeoPoint distance queries fail with an internal server error on MongoDB 8.3 and later (#10572)
This commit is contained in:
@@ -187,6 +187,10 @@ jobs:
|
|||||||
MONGODB_VERSION: 8.0.4
|
MONGODB_VERSION: 8.0.4
|
||||||
MONGODB_TOPOLOGY: replset
|
MONGODB_TOPOLOGY: replset
|
||||||
NODE_VERSION: 24.11.0
|
NODE_VERSION: 24.11.0
|
||||||
|
- name: MongoDB 8.3, ReplicaSet
|
||||||
|
MONGODB_VERSION: 8.3.4
|
||||||
|
MONGODB_TOPOLOGY: replset
|
||||||
|
NODE_VERSION: 24.11.0
|
||||||
- name: Redis Cache
|
- name: Redis Cache
|
||||||
PARSE_SERVER_TEST_CACHE: redis
|
PARSE_SERVER_TEST_CACHE: redis
|
||||||
MONGODB_VERSION: 8.0.4
|
MONGODB_VERSION: 8.0.4
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { MongoClient } = require('mongodb');
|
||||||
|
const MongoCollection = require('../lib/Adapters/Storage/Mongo/MongoCollection').default;
|
||||||
|
const { findGeoIndexField } = require('../lib/Adapters/Storage/Mongo/MongoCollection');
|
||||||
|
|
||||||
|
describe_only_db('mongo')('MongoCollection', () => {
|
||||||
|
describe('findGeoIndexField', () => {
|
||||||
|
it('extracts the field constrained by $nearSphere', () => {
|
||||||
|
const query = { construct: 'line', location: { $nearSphere: [-121.5, 38.5], $maxDistance: 2.5 } };
|
||||||
|
expect(findGeoIndexField(query)).toBe('location');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts the field constrained by $near', () => {
|
||||||
|
expect(findGeoIndexField({ region: { $near: [0, 0] } })).toBe('region');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recurses into $and to find the geo field', () => {
|
||||||
|
const query = { $and: [{ a: 1 }, { loc: { $nearSphere: [0, 0] } }] };
|
||||||
|
expect(findGeoIndexField(query)).toBe('loc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined when there is no geo operator', () => {
|
||||||
|
expect(findGeoIndexField({ a: 1, b: { $gt: 2 } })).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for empty / non-object queries', () => {
|
||||||
|
expect(findGeoIndexField({})).toBeUndefined();
|
||||||
|
expect(findGeoIndexField(null)).toBeUndefined();
|
||||||
|
expect(findGeoIndexField(undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not treat $geoWithin as requiring an index', () => {
|
||||||
|
const query = { location: { $geoWithin: { $centerSphere: [[0, 0], 1] } } };
|
||||||
|
expect(findGeoIndexField(query)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not recurse into $or (MongoDB forbids $near inside $or)', () => {
|
||||||
|
const query = { $or: [{ a: 1 }, { loc: { $nearSphere: [0, 0] } }] };
|
||||||
|
expect(findGeoIndexField(query)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('lazy geo index creation', () => {
|
||||||
|
const collectionName = 'MongoCollectionLazyGeoIndexTest';
|
||||||
|
let client;
|
||||||
|
let rawCollection;
|
||||||
|
|
||||||
|
const geoQuery = { location: { $nearSphere: [-121.5, 38.5], $maxDistance: 2.526 } };
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
client = new MongoClient(databaseURI);
|
||||||
|
await client.connect();
|
||||||
|
rawCollection = client.db().collection(collectionName);
|
||||||
|
// Start from a clean collection with NO geo index so the lazy-creation path is exercised.
|
||||||
|
await rawCollection.drop().catch(() => {});
|
||||||
|
await rawCollection.insertMany([
|
||||||
|
{ _id: '1', location: [-121, 38] },
|
||||||
|
{ _id: '2', location: [-122, 39] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rawCollection.drop().catch(() => {});
|
||||||
|
await client.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a 2d index on demand and returns results for a $nearSphere query on an un-indexed field', async () => {
|
||||||
|
const mongoCollection = new MongoCollection(rawCollection);
|
||||||
|
const results = await mongoCollection.find(geoQuery);
|
||||||
|
expect(results.length).toBe(2);
|
||||||
|
const indexes = await rawCollection.indexes();
|
||||||
|
const hasGeoIndex = indexes.some(index => index.key && index.key.location === '2d');
|
||||||
|
expect(hasGeoIndex).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it_only_mongodb_version('>=8.3')('MongoDB 8.3+ reports the geoNear "no index" error without the field name', async () => {
|
||||||
|
let error;
|
||||||
|
try {
|
||||||
|
await rawCollection.find(geoQuery).toArray();
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
expect(error).toBeDefined();
|
||||||
|
expect(error.message).toMatch(/unable to find index for .geoNear/);
|
||||||
|
expect(error.message).not.toMatch(/field=/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it_only_mongodb_version('<8.3')('older MongoDB reports the geoNear "no index" error with the field name', async () => {
|
||||||
|
let error;
|
||||||
|
try {
|
||||||
|
await rawCollection.find(geoQuery).toArray();
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
expect(error).toBeDefined();
|
||||||
|
expect(error.message).toMatch(/unable to find index for .geoNear/);
|
||||||
|
expect(error.message).toMatch(/field=location/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,48 @@
|
|||||||
const mongodb = require('mongodb');
|
const mongodb = require('mongodb');
|
||||||
const Collection = mongodb.Collection;
|
const Collection = mongodb.Collection;
|
||||||
|
|
||||||
|
// Query operators that require a geospatial index and therefore trigger
|
||||||
|
// on-demand `2d` index creation. `$geoWithin` / `$geoIntersects` are intentionally
|
||||||
|
// excluded: they can run as a collection scan and never raise a "no index" error.
|
||||||
|
const GEO_INDEX_QUERY_OPERATORS = ['$nearSphere', '$near', '$geoNear'];
|
||||||
|
|
||||||
|
// Find the field in a Mongo query document that is constrained by a geo operator
|
||||||
|
// requiring a geospatial index. Returns the field name (e.g. 'location'), or
|
||||||
|
// undefined if none is found. Used as the reliable source of truth for on-demand
|
||||||
|
// geo index creation, since the MongoDB error message that used to carry the field
|
||||||
|
// name (`... field=<name> ...`) was dropped in MongoDB 8.3+.
|
||||||
|
//
|
||||||
|
// A geo-near expression must be top-level or inside `$and`: MongoDB rejects it inside
|
||||||
|
// `$or` / `$nor` ("geo $near must be top-level expr") and forbids more than one per
|
||||||
|
// query ("Too many geoNear expressions"). So there is at most one field to find, and
|
||||||
|
// `$and` is the only combinator we need to recurse into.
|
||||||
|
export function findGeoIndexField(query) {
|
||||||
|
if (!query || typeof query !== 'object') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
for (const field of Object.keys(query)) {
|
||||||
|
const value = query[field];
|
||||||
|
// Recurse into `$and`, which holds an array of sub-queries.
|
||||||
|
if (field === '$and' && Array.isArray(value)) {
|
||||||
|
for (const subQuery of value) {
|
||||||
|
const found = findGeoIndexField(subQuery);
|
||||||
|
if (found) {
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
value &&
|
||||||
|
typeof value === 'object' &&
|
||||||
|
GEO_INDEX_QUERY_OPERATORS.some(op => Object.prototype.hasOwnProperty.call(value, op))
|
||||||
|
) {
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export default class MongoCollection {
|
export default class MongoCollection {
|
||||||
_mongoCollection: Collection;
|
_mongoCollection: Collection;
|
||||||
|
|
||||||
@@ -51,8 +93,12 @@ export default class MongoCollection {
|
|||||||
if (error.code != 17007 && !error.message.match(/unable to find index for .geoNear/)) {
|
if (error.code != 17007 && !error.message.match(/unable to find index for .geoNear/)) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
// Figure out what key needs an index
|
// Figure out which field needs a geo index.
|
||||||
const key = error.message.match(/field=([A-Za-z_0-9]+) /)[1];
|
// Older MongoDB embeds the field name in the error message (`... field=<name> ...`);
|
||||||
|
// MongoDB 8.3+ shortened the message to `unable to find index for $geoNear query`
|
||||||
|
// and no longer includes it, so fall back to reading the field from the query itself.
|
||||||
|
const messageMatch = error.message.match(/field=([A-Za-z_0-9]+) /);
|
||||||
|
const key = (messageMatch && messageMatch[1]) || findGeoIndexField(query);
|
||||||
if (!key) {
|
if (!key) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user