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

Обзор

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

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

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

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

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

Подзапрос не является этапом верхнего уровня. Это выражение, которое можно использовать на любом этапе, где допускаются выражения, например 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 = collection(db, "cities");

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

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

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

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

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

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

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

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

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

Node.js

let results = await execute(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")));

Ответ

{
  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 execute(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")));

Ответ

{
  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 execute(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")));

Ответ

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

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

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

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

Node.js

let results = await execute(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")));

Ответ

{
  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 execute(db.pipeline()
  .collection("cities")
  .addFields(subcollection("restaurants")
    .toArrayExpression()
    .length()
    .as("restaurant_count")));

Ответ

{
  __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 execute(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)));

Ответ

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

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

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

Node.js

let results = await execute(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)));

Ответ

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

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

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

Node.js

let results = await execute(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")));

Ответ

{
  __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 execute(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");

Ответ

{
  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 МиБ на материализованные данные применяется ко всему запросу, включая все объединенные документы.