Раздел 36 · MongoDB с нуля

Соединения: $lookup и $unwind

junior~25 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

В прошлом уроке мы перекраивали документы одной коллекции. В уроке про аккумуляторы было первое соединение: $lookup подтягивал покупателя к заказу по customer_id. Сегодня идём дальше. Научимся разворачивать массивы в отдельные документы, соединять заказы с книгами через их позиции, находить документы без пары и писать $lookup со своим конвейером. Примеры на учебной базе магазина.

Связка ключей

Представь связку ключей на кольце. Пока ключи на кольце, это один предмет. Чтобы рассортировать их по замкам, кольцо разбирают: каждый ключ ложится на стол отдельно, и к каждому можно приклеить бирку «от какой двери».

В заказе массив позиций это такое кольцо. Пока позиции внутри заказа, сгруппировать их по книгам нельзя: $group работает с документами, а не с элементами массивов. Сначала кольцо надо разобрать.

$unwind: один документ на каждый элемент

$unwind берёт документ с массивом и выдаёт столько документов, сколько элементов в массиве. В каждом поле массива заменено одним элементом, остальные поля копируются. Заказ 3, где две книги:

db.orders.aggregate([
  { $match: { _id: 3 } },
  { $unwind: '$items' },
  { $project: { customer_id: 1, title: '$items.title', qty: '$items.qty' } }
])
[
  { _id: 3, customer_id: 2, title: 'Совершенный код', qty: 1 },
  { _id: 3, customer_id: 2, title: 'Шинель', qty: 1 }
]

Один заказ стал двумя документами, и у обоих _id: 3. Это нормально: после $unwind поле _id перестаёт быть уникальным в потоке. Уникальность нужна в коллекции, а не в промежуточных документах конвейера.

Теперь позиции можно группировать. Сколько штук каждой книги продано, без отменённых заказов:

db.orders.aggregate([
  { $match: { status: { $ne: 'cancelled' } } },
  { $unwind: '$items' },
  { $group: { _id: '$items.title', sold: { $sum: '$items.qty' } } },
  { $sort: { sold: -1, _id: 1 } }
])
[
  { _id: 'Мастер и Маргарита', sold: 2 },
  { _id: 'Пикник на обочине', sold: 2 },
  { _id: 'Грокаем алгоритмы', sold: 1 },
  { _id: 'Совершенный код', sold: 1 },
  { _id: 'Трудно быть богом', sold: 1 },
  { _id: 'Шинель', sold: 1 }
]

«Мастера и Маргариту» брали в двух разных заказах по одной штуке, «Пикник» один раз, но две штуки. После $unwind это неважно: каждая позиция стала документом, и $sum сложил количество.

Если ты проходил соединения в PostgreSQL, то узнаешь в $unwind таблицу позиций заказа: там позиции и так лежат по одной строке.

Куда пропадают документы с пустым массивом

Развернём отзывы книг:

db.books.aggregate([
  { $unwind: '$reviews' },
  { $project: { _id: 0, title: 1, user: '$reviews.user', rating: '$reviews.rating' } }
])
[
  { title: 'Мастер и Маргарита', user: 'Ольга', rating: 5 },
  { title: 'Мастер и Маргарита', user: 'Павел', rating: 4 },
  { title: 'Пикник на обочине', user: 'Роман', rating: 5 },
  { title: 'Грокаем алгоритмы', user: 'Павел', rating: 5 },
  { title: 'Грокаем алгоритмы', user: 'Светлана', rating: 3 }
]

Из девяти книг в ответе только три. У остальных поля reviews нет, и $unwind их молча выбросил: ключей на кольце не было, класть на стол нечего. Это частая ошибка в отчётах вида «средняя оценка по жанрам»: книги без отзывов исчезают, а вместе с ними целые жанры.

Сохранить такие документы помогает длинная форма с параметром preserveNullAndEmptyArrays:

db.books.aggregate([
  { $match: { genre: 'повесть' } },
  { $unwind: { path: '$reviews', preserveNullAndEmptyArrays: true } },
  { $project: { _id: 0, title: 1, rating: '$reviews.rating' } }
])
[ { title: 'Собачье сердце' }, { title: 'Шинель' }, { title: 'Нос' } ]

Все три повести на месте, только без оценки. Обрати внимание: поля rating в ответе нет вовсе. $project не создаёт поле, если путь ведёт в пустоту. Это та же логика, что у LEFT JOIN с NULL в SQL, только вместо NULL поля просто нет.

урок из курса

Урок из курса «MongoDB с нуля». Курс покупается один раз и остаётся навсегда: 500 ₽.

Оплата пока не подключена: премиум и курсы открываются промокодом.