OPTIONAL MATCH и работа с отсутствием

Как вернуть узлы вместе с их связями, не теряя тех, у кого связей нет — графовый LEFT JOIN.

OPTIONAL MATCH — вариант MATCH, который при отсутствии совпадения не отбрасывает строку, а подставляет null вместо ненайденных переменных.

Это последний кирпич в фундаменте чтения. Мы уже умеем находить паттерны (MATCH), фильтровать их (WHERE) и оформлять результат (RETURN). Но во всех этих инструментах есть общая черта: они работают с тем, что точно есть. А реальные данные неполны: у человека может не быть аватарки, у заказа — отзыва, у фильма — указанного режиссёра. Если строить отчёт обычным MATCH, все такие «неполные» сущности тихо выпадут, и вы получите искажённую картину. OPTIONAL MATCH — это про умение работать с отсутствием как с полноправным случаем, а не как с ошибкой.

Проблема обычного MATCH

Обычный MATCH работает как INNER JOIN: если паттерн не нашёлся, строка исчезает целиком. Запрос «все люди и фильмы, где они снимались» потеряет людей, которые нигде не снимались:

MATCH (p:Person)-[:ACTED_IN]->(m:Movie)
RETURN p.name, m.title

Режиссёры без актёрских ролей сюда не попадут. А нам, может, нужны все люди — с фильмами или без. Это типичная задача отчётности: «список всех сотрудников и их проектов, включая тех, кто пока без проекта», «все товары и их отзывы, даже если отзывов ноль». Везде, где требуется «полный левый список плюс то, что к нему прицепилось», обычный MATCH подводит, потому что молча выкидывает бездетные строки. Важно осознать: он делает это тихо, без ошибки — вы просто недосчитаетесь людей и можете долго не замечать пропажу.

OPTIONAL MATCH спешит на помощь

Сначала находим всех людей обычным MATCH, потом опционально цепляем фильмы:

MATCH (p:Person)
OPTIONAL MATCH (p)-[:ACTED_IN]->(m:Movie)
RETURN p.name, m.title

Теперь в результате есть все люди. У тех, кто нигде не снимался, m.title будет null. Это прямой аналог SQL LEFT JOIN.

Сопоставим формы напрямую, чтобы закрепить аналогию. Слева — Cypher, справа — его SQL-двойник:

SELECT p.name, m.title
FROM person p
LEFT JOIN acted_in a ON a.person_id = p.id
LEFT JOIN movie m ON m.id = a.movie_id;

Видно, что в SQL «левизна» выражается ключевым словом LEFT в JOIN, а в Cypher — ключевым словом OPTIONAL перед MATCH. Идея одна: ведущая сторона (люди) сохраняется целиком, а ведомая (фильмы) подцепляется, где есть, и заполняется null, где нет.

Обработка null через coalesce

Чтобы вместо null показать заглушку, есть функция coalesce — возвращает первый ненулевой аргумент:

MATCH (p:Person)
OPTIONAL MATCH (p)-[:ACTED_IN]->(m:Movie)
RETURN p.name, coalesce(m.title, '— нет ролей —') AS film

Имя coalesce переводится примерно как «слиться воедино»: функция перебирает аргументы слева направо и возвращает первый, который не null. Записать можно и цепочкой: coalesce(p.nickname, p.name, 'аноним') вернёт ник, если он есть, иначе имя, иначе заглушку. Это рабочий приём для подстановки значений по умолчанию прямо в RETURN, без ветвлений.

Считаем с учётом нулей

OPTIONAL MATCH незаменим для подсчётов «у кого сколько» с нулями. «Сколько фильмов у каждого человека, включая тех, у кого ноль»:

MATCH (p:Person)
OPTIONAL MATCH (p)-[:ACTED_IN]->(m:Movie)
RETURN p.name, count(m) AS films
ORDER BY films DESC

Хитрость: count(m) считает только не-null значения, поэтому у бесфильмовых людей честно выйдет 0, а сами они из результата не пропадут. Сравните с ловушкой count(*): звёздочка считает строки, а у бесфильмового человека строка-то есть (одна, с null вместо фильма), так что count(*) выдал бы ему 1 вместо честного 0. Поэтому при подсчётах после OPTIONAL MATCH считайте именно опциональную переменную — count(m), а не count(*). Это частый источник «магической единицы» в отчётах.

Как работает под капотом

OPTIONAL MATCH выполняется как обычный обход, но с гарантией: если для текущей строки совпадений нет, строка не удаляется, а переменные опционального паттерна становятся null. Поэтому порядок важен: сначала идёт обязательная часть (что точно должно быть), затем опциональная (что может быть). Поменяете местами — и смысл LEFT JOIN сломается. По сути, это управление тем, какая сторона «ведущая».

Есть и тонкость «всё или ничего» внутри одного OPTIONAL MATCH: либо весь его паттерн совпал, либо все его переменные становятся null разом. Нельзя получить «полусовпадение». Так, OPTIONAL MATCH (p)-[:ACTED_IN]->(m:Movie)<-[:DIRECTED]-(d:Person) для человека, который снимался, но в фильме без указанного режиссёра, не даст совпадения вовсе — и m, и d окажутся null, хотя фильм формально есть. Если важно получить фильм даже без режиссёра, разнесите это на два отдельных OPTIONAL MATCH: первый цепляет фильм, второй — опционально режиссёра к найденному фильму. Дробление длинного опционального паттерна на короткие — стандартный способ управлять тем, что именно «обнуляется вместе».

Частые ошибки

  • Ставить WHERE на опциональную переменную в основном блоке. Условие на m после OPTIONAL MATCH снова отбросит null-строки — пишите фильтр внутри опциональной части.
  • Путать порядок. Обязательный MATCH — первым, OPTIONAL — после; иначе теряете нужные узлы.
  • Забывать про null в агрегатах. count(m) игнорирует null (даёт 0), но collect(m) соберёт список без null — это обычно то, что нужно.

Итоги

  • OPTIONAL MATCH — графовый LEFT JOIN: сохраняет строку, подставляя null вместо ненайденного.
  • Порядок строг: обязательная часть — первой, опциональная — после.
  • coalesce заменяет null заглушкой; count по null даёт честный 0.
  • Фильтры на опциональные переменные ставьте внутри OPTIONAL-части, иначе вернёте INNER-семантику.
Проверьте себя
1. Аналогом какой SQL-конструкции является OPTIONAL MATCH?
AINNER JOIN
BLEFT JOIN
CUNION
DGROUP BY
2. Сколько вернёт count(m) для человека без единого фильма после OPTIONAL MATCH?
Anull
BОшибку
C0, потому что count игнорирует null
DСлучайное число
3. Почему важен порядок MATCH и OPTIONAL MATCH?
AПорядок не важен
BСначала идёт обязательная часть (ведущая), потом опциональная; перестановка ломает LEFT-семантику
COPTIONAL всегда должен быть первым
DОни исключают друг друга