Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
101 changes: 101 additions & 0 deletions spec/MongoCollection.spec.js
Original file line number Diff line number Diff line change
@@ -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/);
});
});
});
50 changes: 48 additions & 2 deletions src/Adapters/Storage/Mongo/MongoCollection.js
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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;
}
Expand Down
Loading