Быстрое и ленивое объяснение состоит в том, чтобы использовать $geoNear
и $bucket
этапы конвейера агрегации для получения результата:
.aggregate([
{
"$geoNear": {
"near": {
"type": "Point",
"coordinates": [
-95.712891,
37.09024
]
},
"spherical": true,
"distanceField": "distance",
"distanceMultiplier": 0.001
}
},
{
"$bucket": {
"groupBy": "$distance",
"boundaries": [
0, 5, 10, 20, 50, 100, 500
],
"default": "greater than 500km",
"output": {
"count": {
"$sum": 1
},
"docs": {
"$push": "$$ROOT"
}
}
}
}
])
Более длинная форма состоит в том, что вы, вероятно, должны понимать «почему?» часть того, как это решает проблему, и, возможно, даже понимать, что, хотя это действительно относится, по крайней мере, к одному оператору агрегации, только введенному в последнее время Версии MongoDB, на самом деле все это стало возможным вплоть до MongoDB 2.4.
Использование $ geoNear
Основное, что нужно искать в любой «группировке», в основном будет поле "distance"
, добавляемое к результату «ближнего» запроса, указывающее, насколько далеко этот результат от координат, используемых при поиске. К счастью, это именно то, что делает $geoNear
этап конвейера агрегации.
Базовая стадия будет выглядеть примерно так:
{
"$geoNear": {
"near": {
"type": "Point",
"coordinates": [
-95.712891,
37.09024
]
},
"spherical": true,
"distanceField": "distance",
"distanceMultiplier": 0.001
}
},
Этот этап имеет три обязательных аргумента, которые должны быть предоставлены:
вблизи - местоположение, используемое для запроса. Это может быть либо в виде устаревшей пары координат, либо в виде данных GeoJSON. Все, что касается GeoJSON, в основном рассматривается в метрах для результатов, поскольку это стандарт GeoJSON.
сферический - Обязательный , но только в том случае, если тип индекса равен 2dsphere
. По умолчанию это false
, но вам, вероятно, нужен индекс 2dsphere
для любых реальных данных геолокации на поверхности Земли.
distanceField - Это также всегда требуется, и это имя поля, которое будет добавлено в документ, который будет содержать расстояние от запрашиваемого местоположения через near
. Этот результат будет в радианах или метрах в зависимости от типа формата данных, используемого в аргументе near
. На результат также влияет необязательный аргумент , как указано далее.
Необязательный аргумент:
Главное, что мы собираемся сделать, это просто вернуть «ближайшие» документы (по умолчанию до 100) в порядке от ближайшего к дальней и включить поле, названное distanceField
в существующее содержимое документа и то, что было упомянуто ранее как фактический вывод, который позволит вам «группировать».
distanceMultiplier
здесь просто конвертирует значение по умолчанию метров GeoJSON в километров для вывода. Если вы хотите, чтобы миль на выходе, вы бы изменили множитель. то есть:
"distanceMultiplier": 0.000621371
Это совершенно необязательно, но вам необходимо знать, какие единицы (преобразованные или нет) должны быть применены в следующей «группировке» стадии:
Фактическая «группировка» сводится к трем различным вариантам в зависимости от вашей доступной MongoDB и ваших реальных потребностей:
Вариант 1 - $ bucket
Стадия конвейера $bucket
была добавлена с MongoDB 3.4. Это на самом деле один из немногих «этапов конвейера» , которые были добавлены в эту версию и которые на самом деле больше похожи на макрофункцию или на базовую форму сокращений для записи комбинация этапов трубопровода и реальных операторов. Подробнее об этом позже.
Основными основными аргументами являются выражение groupBy
, boundaries
, которое задает нижние границы для "группирования" диапазонов, и параметр default
, который в основном применяется как * «ключ группировки» или поле _id
в выходных данных всякий раз, когда данные, соответствующие выражению groupBy
, не попадают между записями, определенными с помощью boundaries
.
{
"$bucket": {
"groupBy": "$distance",
"boundaries": [
0, 5, 10, 20, 50, 100, 500
],
"default": "greater than 500km",
"output": {
"count": {
"$sum": 1
},
"docs": {
"$push": "$$ROOT"
}
}
}
}
Другой раздел - output
, который в основном содержит те же выражения накопителя, которые вы использовали бы с $group
, и который действительно должен дать вам представление о том, какой этап конвейера агрегации это $bucket
фактически расширяется до. Они выполняют фактический «сбор данных» за «ключ группировки».
Хотя это полезно, существует одна небольшая ошибка с $bucket
, заключающаяся в том, что выход _id
всегда будет только значениями, определенными в boundaries
или в параметре default
, где данные попадают вне ограничения boundaries
. Если вы хотите что-то «лучше» , это обычно делается при последующей обработке результатов клиентом, например:
result = result
.map(({ _id, ...e }) =>
({
_id: (!isNaN(parseFloat(_id)) && isFinite(_id))
? `less than ${bounds[bounds.indexOf(_id)+1]}km`
: _id,
...e
})
);
Это заменит любые простые числовые значения в возвращенных полях _id
более значимой «строкой», описывающей то, что на самом деле группируется.
Обратите внимание, что, хотя default
является «необязательным» , вы получите серьезную ошибку в случае, если какие-либо данные выходят за пределы границ диапазона. На самом деле возвращаемая очень конкретная ошибка приводит нас к следующему случаю.
Вариант 2 - $ group и $ switch
Из сказанного выше вы, возможно, поняли, что "макро-перевод" из $bucket
стадии конвейера фактически становится $group
стадией и тот, который специально применяет оператор $switch
в качестве аргумента для поля _id
для группировки. Опять оператор $switch
был представлен в MongoDB 3.4.
По сути, это действительно руководство построение того, что было показано выше с использованием $bucket
, с небольшой тонкой настройкой на выходе полей _id
и немного менее кратко с выражениями, которые производятся первым. Фактически, вы можете использовать вывод "объяснения" конвейера агрегации, чтобы увидеть что-то "похожее" на следующий листинг, но с использованием определенного этапа конвейера выше:
{
"$group": {
"_id": {
"$switch": {
"branches": [
{
"case": {
"$and": [
{
"$lt": [
"$distance",
5
]
},
{
"$gte": [
"$distance",
0
]
}
]
},
"then": "less than 5km"
},
{
"case": {
"$and": [
{
"$lt": [
"$distance",
10
]
}
]
},
"then": "less than 10km"
},
{
"case": {
"$and": [
{
"$lt": [
"$distance",
20
]
}
]
},
"then": "less than 20km"
},
{
"case": {
"$and": [
{
"$lt": [
"$distance",
50
]
}
]
},
"then": "less than 50km"
},
{
"case": {
"$and": [
{
"$lt": [
"$distance",
100
]
}
]
},
"then": "less than 100km"
},
{
"case": {
"$and": [
{
"$lt": [
"$distance",
500
]
}
]
},
"then": "less than 500km"
}
],
"default": "greater than 500km"
}
},
"count": {
"$sum": 1
},
"docs": {
"$push": "$$ROOT"
}
}
}
Фактически, кроме более ясного "маркировки" единственная действительная разница - $bucket
, использующая выражение $gte
вместе с $lte
на каждом case
. В этом нет необходимости из-за того, как $switch
на самом деле работает и как логические условия "проваливаются" точно так же, как в обычном использовании аналога логического блока switch
.
Это действительно больше относится к личным предпочтениям к тому, насколько вы более счастливы, определяя выходные "строки" для _id
в операторах case
или если вы в порядке со значениями постобработки чтобы переформатировать подобные вещи.
В любом случае, они в основном возвращают один и тот же вывод (за исключением того, что существует определенный порядок до $bucket
результатов), как и наш третий вариант.
Вариант 3 - $ group и $ cond
Как уже отмечалось, все вышесказанное в основном основано на операторе $switch
, но, как и его аналог в различных реализациях языка программирования, «оператор switch» на самом деле является более чистым и более удобным способом написания if .. then .. else if ...
и так далее. MongoDB также имеет выражение if .. then .. else
прямо к MongoDB 2.2 с $cond
:
{
"$group": {
"_id": {
"$cond": [
{
"$and": [
{
"$lt": [
"$distance",
5
]
},
{
"$gte": [
"$distance",
0
]
}
]
},
"less then 5km",
{
"$cond": [
{
"$and": [
{
"$lt": [
"$distance",
10
]
}
]
},
"less then 10km",
{
"$cond": [
{
"$and": [
{
"$lt": [
"$distance",
20
]
}
]
},
"less then 20km",
{
"$cond": [
{
"$and": [
{
"$lt": [
"$distance",
50
]
}
]
},
"less then 50km",
{
"$cond": [
{
"$and": [
{
"$lt": [
"$distance",
100
]
}
]
},
"less then 100km",
"greater than 500km"
]
}
]
}
]
}
]
}
]
},
"count": {
"$sum": 1
},
"docs": {
"$push": {
"_id": "$_id",
"location_point": "$location_point",
"distance": "$distance"
}
}
}
}
Опять же, на самом деле все одинаково, главное отличие состоит в том, что вместо «чистого массива» параметров для обработки в качестве «дел» вместо этого используется вложенный набор условий, в которых else
просто содержитдругой $cond
, вплоть до конца "границ", а затем else
содержит только значение default
.
Так как мы по крайней мере «притворяясь» , что мы возвращаемся к MongoDB 2.4 (что является ограничением для фактического запуска с $geoNear
, тогда другие вещи, такие как $$ROOT
, не будут доступны в этомверсии, поэтому вместо этого вы просто назовете все выражения полей документа, чтобы добавить это содержимое с $push
.
Генерация кода
Всеэто действительно должно сводиться к тому, что «группировка» на самом деле выполняется с помощью $bucket
и что это, вероятно, то, что вы использовали бы, если вы не хотите какую-то настройку вывода или если ваш MongoDBверсия не поддерживает его (хотя, вероятно, в настоящее время не следует запускать MongoDB под 3.4).
Конечно, любая другая форма длиннее в требуемом синтаксисе, но на самом деле это тот же массивАргументы могут быть применены для создания и запуска любой из показанных выше форм.
Ниже приведен пример списка (для NodeJS), который демонстрирует, что на самом деле это просто простой процесс, чтобы сгенерировать все здесь из простого массива bounds
для группировки и даже нескольких определенных опций, которые могут быть повторно использованы в операциях конвейера, а также для любой клиентской предварительной или постобработки для генерации инструкций конвейера или для манипулирования возвращенными результатами в "более симпатичный" формат вывода.
const { Schema } = mongoose = require('mongoose');
const uri = 'mongodb://localhost:27017/test',
options = { useNewUrlParser: true };
mongoose.set('useFindAndModify', false);
mongoose.set('useCreateIndex', true);
mongoose.set('debug', true);
const geoSchema = new Schema({
location_point: {
type: { type: String, enum: ["Point"], default: "Point" },
coordinates: [Number, Number]
}
});
geoSchema.index({ "location_point": "2dsphere" },{ background: false });
const GeoModel = mongoose.model('GeoModel', geoSchema, 'geojunk');
const [{ location_point: near }] = data = [
[ -95.712891, 37.09024 ],
[ -95.712893, 37.09024 ],
[ -85.712883, 37.09024 ]
].map(coordinates => ({ location_point: { type: 'Point', coordinates } }));
const log = data => console.log(JSON.stringify(data, undefined, 2));
(async function() {
try {
const conn = await mongoose.connect(uri, options);
// Clean data
await Promise.all(
Object.entries(conn.models).map(([k,m]) => m.deleteMany())
);
// Insert data
await GeoModel.insertMany(data);
const bounds = [ 5, 10, 20, 50, 100, 500 ];
const distanceField = "distance";
// Run three sample cases
for ( let test of [0,1,2] ) {
let pipeline = [
{ "$geoNear": {
near,
"spherical": true,
distanceField,
"distanceMultiplier": 0.001
}},
(() => {
// Standard accumulators
const output = {
"count": { "$sum": 1 },
"docs": { "$push": "$$ROOT" }
};
switch (test) {
case 0:
log("Using $bucket");
return (
{ "$bucket": {
"groupBy": `$${distanceField}`,
"boundaries": [ 0, ...bounds ],
"default": `greater than ${[...bounds].pop()}km`,
output
}}
);
case 1:
log("Manually using $switch");
let branches = bounds.map((bound,i) =>
({
'case': {
'$and': [
{ '$lt': [ `$${distanceField}`, bound ] },
...((i === 0) ? [{ '$gte': [ `$${distanceField}`, 0 ] }]: [])
]
},
'then': `less than ${bound}km`
})
);
return (
{ "$group": {
"_id": {
"$switch": {
branches,
"default": `greater than ${[...bounds].pop()}km`
}
},
...output
}}
);
case 2:
log("Legacy using $cond");
let _id = null;
for (let i = bounds.length -1; i > 0; i--) {
let rec = {
'$cond': [
{ '$and': [
{ '$lt': [ `$${distanceField}`, bounds[i-1] ] },
...((i == 1) ? [{ '$gte': [ `$${distanceField}`, 0 ] }] : [])
]},
`less then ${bounds[i-1]}km`
]
};
if ( _id == null ) {
rec['$cond'].push(`greater than ${bounds[i]}km`);
} else {
rec['$cond'].push( _id );
}
_id = rec;
}
// Older MongoDB may require each field instead of $$ROOT
output.docs.$push =
["_id", "location_point", distanceField]
.reduce((o,e) => ({ ...o, [e]: `$${e}` }),{});
return ({ "$group": { _id, ...output } });
}
})()
];
let result = await GeoModel.aggregate(pipeline);
// Text based _id for test: 0 with $bucket
if ( test === 0 )
result = result
.map(({ _id, ...e }) =>
({
_id: (!isNaN(parseFloat(_id)) && isFinite(_id))
? `less than ${bounds[bounds.indexOf(_id)+1]}km`
: _id,
...e
})
);
log({ pipeline, result });
}
} catch (e) {
console.error(e)
} finally {
mongoose.disconnect();
}
})()
И пример вывода (и, конечно, ВСЕ перечисленные выше списки генерируются из этого кода):
Mongoose: geojunk.createIndex({ location_point: '2dsphere' }, { background: false })
"Using $bucket"
{
"result": [
{
"_id": "less than 5km",
"count": 2,
"docs": [
{
"_id": "5ca897dd2efdc41b79d5fe94",
"location_point": {
"type": "Point",
"coordinates": [
-95.712891,
37.09024
]
},
"__v": 0,
"distance": 0
},
{
"_id": "5ca897dd2efdc41b79d5fe95",
"location_point": {
"type": "Point",
"coordinates": [
-95.712893,
37.09024
]
},
"__v": 0,
"distance": 0.00017759511720976155
}
]
},
{
"_id": "greater than 500km",
"count": 1,
"docs": [
{
"_id": "5ca897dd2efdc41b79d5fe96",
"location_point": {
"type": "Point",
"coordinates": [
-85.712883,
37.09024
]
},
"__v": 0,
"distance": 887.5656539981669
}
]
}
]
}
"Manually using $switch"
{
"result": [
{
"_id": "greater than 500km",
"count": 1,
"docs": [
{
"_id": "5ca897dd2efdc41b79d5fe96",
"location_point": {
"type": "Point",
"coordinates": [
-85.712883,
37.09024
]
},
"__v": 0,
"distance": 887.5656539981669
}
]
},
{
"_id": "less than 5km",
"count": 2,
"docs": [
{
"_id": "5ca897dd2efdc41b79d5fe94",
"location_point": {
"type": "Point",
"coordinates": [
-95.712891,
37.09024
]
},
"__v": 0,
"distance": 0
},
{
"_id": "5ca897dd2efdc41b79d5fe95",
"location_point": {
"type": "Point",
"coordinates": [
-95.712893,
37.09024
]
},
"__v": 0,
"distance": 0.00017759511720976155
}
]
}
]
}
"Legacy using $cond"
{
"result": [
{
"_id": "greater than 500km",
"count": 1,
"docs": [
{
"_id": "5ca897dd2efdc41b79d5fe96",
"location_point": {
"type": "Point",
"coordinates": [
-85.712883,
37.09024
]
},
"distance": 887.5656539981669
}
]
},
{
"_id": "less then 5km",
"count": 2,
"docs": [
{
"_id": "5ca897dd2efdc41b79d5fe94",
"location_point": {
"type": "Point",
"coordinates": [
-95.712891,
37.09024
]
},
"distance": 0
},
{
"_id": "5ca897dd2efdc41b79d5fe95",
"location_point": {
"type": "Point",
"coordinates": [
-95.712893,
37.09024
]
},
"distance": 0.00017759511720976155
}
]
}
]
}