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_TOPOLOGY: replset
|
||||
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
|
||||
PARSE_SERVER_TEST_CACHE: redis
|
||||
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 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 {
|
||||
_mongoCollection: Collection;
|
||||
|
||||
@@ -51,8 +93,12 @@ export default class MongoCollection {
|
||||
if (error.code != 17007 && !error.message.match(/unable to find index for .geoNear/)) {
|
||||
throw error;
|
||||
}
|
||||
// Figure out what key needs an index
|
||||
const key = error.message.match(/field=([A-Za-z_0-9]+) /)[1];
|
||||
// Figure out which field needs a geo index.
|
||||
// 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) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user