Как выполнять объединения с подзапросами

Обзор

Версия Firestore Enterprise поддерживает объединения в реляционном стиле с помощью коррелированных подзапросов. В отличие от многих баз данных NoSQL, которые часто требуют денормализации данных или выполнения нескольких клиентских запросов, подзапросы позволяют объединять и агрегировать данные из связанных коллекций или подколлекций непосредственно на сервере.

Подзапросы – это выражения, которые выполняют вложенный конвейер для каждого документа, обработанного внешним запросом. Это позволяет использовать сложные шаблоны извлечения данных, например получать документ вместе с элементами связанной с ним подколлекции или объединять логически связанные данные из разных корневых коллекций.

Основные понятия

В этом разделе представлены основные концепции использования подзапросов для выполнения объединений в операциях конвейера.

Подзапросы как выражения

Подзапрос не является этапом верхнего уровня. Это выражение, которое можно использовать на любом этапе, где допустимы выражения, например select(...), add_fields(...), where(...) или sort(...).

Cloud Firestore поддерживает три типа подзапросов:

  • Подзапросы массивов. Материализуйте весь набор результатов подзапроса в виде массива документов.
  • Скалярные подзапросы. Возвращают одно значение, например количество, среднее значение или определенное поле из связанного документа.
  • subcollection(...) Подзапросы – упрощенные объединения для родительско-дочерних отношений "один ко многим".

Область действия и переменные

При написании соединения вложенному подзапросу часто требуется ссылаться на поля из "внешнего" документа (родительского). Чтобы связать эти области, используйте этап let(...) (в некоторых SDK он называется define(...)). Он позволяет определить переменные в родительской области, на которые затем можно ссылаться в подзапросе с помощью функции variable(...).

Синтаксис

В разделах ниже приведены общие сведения о синтаксисе для объединения.

Этап let(...)

Этап let(...) (в некоторых SDK он называется define(...)) не является этапом фильтрации. Он явно переносит данные из родительской области в именованную переменную для использования в последующих вложенных областях.

Подзапросы массивов

Подзапрос массива – это особый случай подзапроса выражения, который материализует весь набор результатов подзапроса в массив. Если подзапрос не возвращает строк, он оценивается как пустой массив. Она никогда не возвращает массив null. Такие запросы полезны, когда в итоговом результате нужны полные результаты, например при материализации вложенной или коррелированной коллекции.

В подзапросе можно фильтровать, сортировать и агрегировать данные, чтобы уменьшить объем данных, которые нужно получить и вернуть, и снизить стоимость запроса. Порядок подзапроса соблюдается, то есть этап sort(...) в подзапросе определяет порядок результатов в конечном массиве.

Используйте оболочку SDK toArrayExpression(), чтобы преобразовать запрос в массив.

Скалярные подзапросы

Скалярные подзапросы часто используются на этапе select(...) или where(...), поскольку они позволяют фильтровать или получать результат подзапроса без непосредственной материализации полного запроса.

Скалярный подзапрос, который не возвращает результатов, будет вычисляться как null, а подзапрос, который возвращает несколько элементов, приведет к ошибке выполнения.

Если скалярный подзапрос возвращает только одно поле на результат, это поле повышается до результата верхнего уровня для подзапроса. Чаще всего это происходит, когда подзапрос заканчивается на select(field("user_name")) или aggregate(countAll().as("total")), а его схема содержит только одно поле. В противном случае, когда подзапрос может возвращать несколько полей, они упаковываются в карту.

Используйте оболочку SDK toScalarExpression(), чтобы преобразовать запрос в скалярное выражение.

subcollection(...) подзапроса

Хотя этап ввода subcollection(...) предлагается как этап, он позволяет выполнять объединения в иерархической модели данных Cloud Firestore. В иерархической модели запросы часто должны извлекать документ вместе с данными из его собственных подколлекций. Этого можно добиться, используя этап ввода collection_group(...), за которым следует фильтр по родительской ссылке, но subcollection(...) предоставляет гораздо более лаконичный синтаксис.

Помимо неявного условия соединения, это действует аналогично подзапросу массива, возвращая пустой результат, если совпадений не найдено, даже если вложенная коллекция не существует.

По сути, это синтаксический сахар: он автоматически использует __name__ документа во внешней области видимости в качестве ключа соединения для разрешения иерархических отношений. Это делает его предпочтительным способом выполнения поиска по коллекциям, связанным в отношениях родитель-потомок.

Примеры

Пример данных

Ниже приведен пример загрузки набора тестовых данных, который будет использоваться во всех последующих примерах.

Node.js

// Load set of cities.
const cities = db.collection("cities");

await cities.doc("SF").set({
  name: "San Francisco",
  state: "CA",
  country: "USA",
});
await cities.doc("LA").set({
  name: "Los Angeles",
  state: "CA",
  country: "USA"
});
await cities.doc("DC").set({
  name: "Washington, D.C.",
  state: null,
  country: "USA"
});
await cities.doc("TOK").set({
  name: "Tokyo",
  state: null,
  country: "Japan"
});

// Load restaurants in various cities.
const sfRestaurants = db.collection("cities/SF/restaurants");
const laRestaurants = db.collection("cities/LA/restaurants");
const dcRestaurants = db.collection("cities/DC/restaurants");

const rest1 = await sfRestaurants.add({
  name: "Golden Gate Pizza",
  type: "pizza",
  owner_id: "Mario Rossi"
});
const rest2 = await sfRestaurants.add({
  name: "Bay Area Burger",
  type: "burger",
  owner_id: "Sarah Jenkins"
});
const rest3 = await sfRestaurants.add({
  name: "Sunset Taco",
  type: "mexican",
  owner_id: "Edward"
});

const rest4 = await laRestaurants.add({
  name: "Hollywood Sushi",
  type: "sushi",
  owner_id: "Ken Kenji"
});
const rest5 = await laRestaurants.add({
  name: "Venice Pizza",
  type: "pizza",
  owner_id: "Luigi Romano"
});

const rest6 = await dcRestaurants.add({
  name: "Capitol Tacos",
  type: "mexican",
  owner_id: "Maria Garcia"
});
const rest7 = await dcRestaurants.add({
  name: "Georgetown Coffee",
  type: "cafe",
  owner_id: "David Kim"
});

// Load collection of reviews.
const reviews = db.collection("reviews");

await reviews.add({ restaurant: rest1, rating: 5, reviewer_id: "Alice" });
await reviews.add({ restaurant: rest1, rating: 4, reviewer_id: "Bob" });
await reviews.add({ restaurant: rest2, rating: 4, reviewer_id: "Charlie" });
await reviews.add({ restaurant: rest3, rating: 5, reviewer_id: "Diana" });
await reviews.add({ restaurant: rest3, rating: 4, reviewer_id: "Edward" });
await reviews.add({ restaurant: rest3, rating: 4, reviewer_id: "Fiona" });
// rest4 has 0 reviews
await reviews.add({ restaurant: rest5, rating: 3, reviewer_id: "George" });
await reviews.add({ restaurant: rest6, rating: 5, reviewer_id: "Hannah" });
await reviews.add({ restaurant: rest6, rating: 4, reviewer_id: "Ian" });
await reviews.add({ restaurant: rest7, rating: 5, reviewer_id: "Julia" });

Поиск документа в другой коллекции

Следующий запрос к группе коллекций reviews выполняет поиск в группе коллекций restaurant, используя ссылку на первичный ключ.

Node.js

let results = await db.pipeline()
  .collectionGroup("reviews")
  .define(field("restaurant").as("restaurant_name"))
  .addFields(db.pipeline()
    .collectionGroup("restaurant")
    .where(field("__name__").equal(variable("restaurant_name")))
    .select("name", "type")
    .toScalarExpression()
    .as("restaurant"))
  .execute();

Ответ

{
  rating: 5,
  reviewer_id "Alice",
  restaurant: { name: "Golden Gate Pizza", type: "pizza" }
},
{
  rating: 4,
  reviewer_id "Bob",
  restaurant: { name: "Golden Gate Pizza", type: "pizza" }
},
{
  rating: 4,
  reviewer_id "Charlie",
  restaurant: { name: "Bay Area Burger", type: "burger" }
},
{
  rating: 5,
  reviewer_id "Diana",
  restaurant: { name: "Sunset Taco", type: "mexican" }
},
{
  rating: 4,
  reviewer_id "Edward",
  restaurant: { name: "Sunset Taco", type: "mexican" }
},
{
  rating: 4,
  reviewer_id "Fiona",
  restaurant: { name: "Sunset Taco", type: "mexican" }
},
{
  rating: 3,
  reviewer_id "George",
  restaurant: { name: "Venice Pizza", type: "pizza" }
},
{
  rating: 5,
  reviewer_id "Hannah",
  restaurant: { name: "Capitol Tacos", type: "mexican" }
},
{
  rating: 4,
  reviewer_id "Ian",
  restaurant: { name: "Capitol Tacos", type: "mexican" }
},
{
  rating: 5,
  reviewer_id "Julia",
  restaurant: { name: "Georgetown Coffee", type: "cafe" }
}

Как объединить несколько коллекций

Следующий запрос извлекает все пиццерии из группы коллекций restaurants и использует подзапрос массива для извлечения и встраивания связанных с ними отзывов непосредственно в ответ.

Node.js

let results = await db.pipeline()
  .collectionGroup("restaurants")
  .where(field("type").equal("pizza"))
  .define(field("__name__").as("restaurant_name"))
  .select(
    field("name"),
    db.pipeline()
      .collectionGroup("reviews")
      .where(field("restaurant").equal(variable("restaurant_name")))
      .select("rating", "reviewer_id")
      .toArrayExpression()
      .as("reviews"))
  .execute();

Ответ

{
  name: "Golden Gate Pizza",
  reviews: [
    { rating: 5, reviewer_id "Alice" },
    { rating: 4, reviewer_id "Bob" }
  ]
},
{
  name: "Venice Pizza",
  type: "pizza",
  owner_id: "Luigi Romano",
  reviews: [
    { rating: 3, reviewer_id "George" }
  ]
}

Агрегирование данных из нескольких коллекций

В следующем запросе к группе коллекций restaurants используется коррелированный подзапрос, чтобы получить среднюю оценку для каждого ресторана из группы коллекций reviews.

Node.js

let results = await db.pipeline()
  .collectionGroup("restaurants")
  .where(field("type").equal("pizza"))
  .define(field("__name__").as("restaurant_name"))
  .select(
    field("name"),
    db.pipeline()
      .collectionGroup("reviews")
      .where(field("restaurant").equal(variable("restaurant_name")))
      .aggregate(average("rating").as("avg_rating"))
      .toScalarExpression()
      .as("avg_rating"))
  .execute();

Ответ

{
  name: "Golden Gate Pizza",
  avg_rating: 4.5
},
{
  name: "Venice Pizza",
  avg_rating: 3.0
}

N лучших в группе (подзапрос с ограничением)

Следующий запрос извлекает все документы из группы коллекций restaurants и использует коррелированный подзапрос для извлечения двух отзывов с наивысшим рейтингом для каждого ресторана.

Это позволяет избежать слишком большого массива отзывов и превышения лимита памяти для запроса.

Node.js

let results = await db.pipeline()
  .collectionGroup("restaurants")
  .define(field("__name__").as("restaurant_name"))
  .select(
    field("name"),
    db.pipeline()
      .collectionGroup("reviews")
      .where(field("restaurant").equal(variable("restaurant_name")))
      .sort(field("rating").descending())
      .limit(2)
      .select("rating", "reviewer_id")
      .toArrayExpression()
      .as("top_reviews"))
  .execute();

Ответ

{
  name: "Golden Gate Pizza",
  top_reviews: [
    { rating: 5, reviewer_id "Alice" },
    { rating: 4, reviewer_id "Bob" }
  ]
},
{
  name: "Bay Area Burger",
  top_reviews: [
    { rating: 4, reviewer_id "Charlie" }
  ]
},
{
  name: "Sunset Taco",
  top_reviews: [
    { rating: 5, reviewer_id "Diana" },
    { rating: 4, reviewer_id "Edward" }
  ]
},
{
  name: "Hollywood Sushi",
  top_reviews: []
},
{
  name: "Venice Pizza",
  top_reviews: [
    { rating: 3, reviewer_id "George" }
  ]
},
{
  name: "Capitol Tacos",
  top_reviews: [
    { rating: 5, reviewer_id "Hannah" },
    { rating: 4, reviewer_id "Ian" }
  ]
},
{
  name: "Georgetown Coffee",
  top_reviews: [
    { rating: 5, reviewer_id "Julia" }
  ]
}

Объединение вложенных подборок

Следующий запрос сканирует коллекцию cities и использует этап subcollection(...) для неявного объединения документов из вложенной коллекции, чтобы найти количество ресторанов в каждом городе.

Node.js

let results = await db.pipeline()
  .collection("cities")
  .addFields(subcollection("restaurants")
    .toArrayExpression()
    .length()
    .as("restaurant_count"))
  .execute();

Ответ

{
  __name__: cities/SF,
  name: "San Francisco",
  state: "CA",
  country: "USA",
  restaurant_count: 3
},
{
  __name__: cities/LA,
  name: "Los Angeles",
  state: "CA",
  country: "USA",
  restaurant_count: 2
},
{
  __name__: cities/DC,
  name: "Washington, D.C.",
  state: null,
  country: "USA",
  restaurant_count: 2
},
{
  __name__: cities/TOK,
  name: "Tokyo",
  state: null,
  country: "Japan",
  restaurant_count: 0
}

Как задать несколько условий объединения

Следующий запрос сканирует группу коллекций restaurants и выполняет объединение по нескольким полям с группой коллекций reviews, чтобы найти владельцев, которые оставляют отзывы о собственных ресторанах.

Node.js

let results = await db.pipeline()
  .collectionGroup("restaurants")
  .define(field("owner_id"), field("__name__"))
  .where(db.pipeline()
    .collectionGroup("reviews")
    .where(field("restaurant").equal(variable("__name__")))
    .where(field("author").equal(variable("owner_id")))
    .aggregate(count().as("c"))
    .toScalarExpression()
    .greaterThan(0))
  .execute();

Ответ

{
  __name__: cities/SF/restaurants/X9An0HIlx29A9GPuRthS,
  name: "Sunset Taco",
  type: "mexican",
  owner_id: "Edward"
}

Антиобъединение (NOT EXISTS)

Следующий запрос сканирует группу коллекций restaurants и находит все рестораны, у которых ещё нет отзывов.

Node.js

let results = await db.pipeline()
  .collectionGroup("restaurants")
  .define(field("__name__").as("restaurant_name"))
  .where(db.pipeline()
    .collectionGroup("reviews")
    .where(field("restaurant").equal(variable("restaurant_name")))
    .aggregate(count().as("review_count"))
    .toScalarExpression()
    .equal(0))
  .execute();

Ответ

{
  __name__: "cities/LA/restaurants/X9An0HIlx29A9GPuRthS",
  name: "Hollywood Sushi",
  type: "sushi",
  owner_id: "Ken Kenji"
}

Подзапрос как объединение

Следующий запрос выравнивает связь между каждой пиццерией и отзывами о ней. Поместив подзапрос в этап unnest(...), сервер дублирует внешний документ ресторана для каждого соответствующего отзыва, создавая плоские объединенные документы (аналогично SQL INNER JOIN).

Node.js

let results = await db.pipeline()
  .collectionGroup("restaurants")
  .where(field("type").equal("pizza"))
  .define(field("__name__").as("restaurant_name"))
  .unnest(
    db.pipeline()
      .collectionGroup("reviews")
      .where(field("restaurant").equal(variable("restaurant_name")))
      .select("rating", "reviewer_id")
      .toArrayExpression()
      .as("review"))
  .execute();

Ответ

{
  __name__: "cities/SF/restaurants/xU4pu8nFpnJDPZOwcSPP",
  name: "Golden Gate Pizza",
  type: "pizza",
  owner_id: "Mario Rossi"
  review: { rating: 5, reviewer_id "Alice" }
},
{
  __name__: "cities/SF/restaurants/xU4pu8nFpnJDPZOwcSPP",
  name: "Golden Gate Pizza",
  type: "pizza",
  owner_id: "Mario Rossi",
  review: { rating: 4, reviewer_id "Bob" }
},
{
  __name__: "cities/LA/restaurants/6CYntvNgbYzgaW652Gq1",
  name: "Venice Pizza",
  type: "pizza",
  owner_id: "Luigi Romano",
  review: { rating: 3, reviewer_id "George" }
}

Некоррелированный подзапрос в качестве фильтра

В следующем запросе к коллекции reviews используется некоррелированный подзапрос к ней же, чтобы найти отзывы с оценкой выше средней.

Node.js

let results = await db.pipeline()
  .collection("reviews")
  // Average review rating is 4.3
  .where(field("rating").greaterThan(db.pipeline()
    .collection("reviews")
    .aggregate(average("rating").as("avg"))
    .toScalarExpression()))
  .select("rating", "reviewer_id")
  .execute();

Ответ

{
  rating: 5,
  reviewer_id "Alice"
},
{
  rating: 5,
  reviewer_id "Diana"
},
{
  rating: 5,
  reviewer_id "Hannah"
},
{
  rating: 5,
  reviewer_id "Julia"
}

Рекомендации

  • Управляйте памятью с помощью toArrayExpression(). Будьте осторожны с подзапросами toArrayExpression(), поскольку материализация большого количества документов может исчерпать лимит памяти запроса (128 МиБ). Чтобы избежать этого, используйте select(...) в подзапросе, чтобы возвращать только необходимые поля, и применяйте фильтры where(...), чтобы ограничить количество возвращаемых документов. При необходимости используйте limit(...), чтобы ограничить количество документов, возвращаемых подзапросом.
  • Индексирование. Убедитесь, что поля, используемые в предложении where(...) подзапроса, проиндексированы. Эффективные объединения основаны на возможности выполнять поиск по индексу, а не полное сканирование таблицы.

Дополнительные рекомендации по работе с запросами вы найдете в руководстве по оптимизации запросов.

Ограничения

  • Область действия subcollection(...). Этап ввода subcollection(...) поддерживается только в подзапросах, поскольку для разрешения иерархических отношений и выполнения объединения требуется контекст родительского документа.
  • Глубина вложенности. Подзапросы могут быть вложены друг в друга до 20 раз.
  • Использование памяти. Ограничение в 128 МиБ на материализованные данные применяется ко всему запросу, включая все объединенные документы.