Выполняйте объединения с подзапросами.

Обзор

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

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

Концепции

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

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

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

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

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

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

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

Синтаксис

В следующих разделах представлен обзор синтаксиса для выполнения объединений (joins).

Этап let(...)

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

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

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

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

Используйте обертку SDK toArrayExpression() для преобразования запроса в массив.

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

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

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

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

Используйте обертку 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
}

Выборка Top-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"
}

Подзапрос в виде соединения (JOIN)

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

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