Архитектура
АрхитектураУправление порядком разрешения полей

Управление порядком разрешения полей

Цель директивы @export, предоставляемой Multiple Query Execution, — экспортировать значение поля (или набора полей) в переменную, чтобы использовать её в другом месте запроса.

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

Gato GraphQL предоставляет возможность управлять порядком выполнения полей через сам запрос. Движок загружает данные итерационно для каждого типа: сначала разрешая все поля первого встреченного в запросе типа, затем все поля второго, и так далее, пока не останется типов для обработки.

Например, следующий запрос, включающий объекты типов Director, Film и Actor:

{
  directors {
    name
    films {
      title
      actors {
        name
      }
    }
  }
}

...разрешается движком GraphQL в таком порядке:

Обработка типов по итерациям

Если после обработки тип снова встречается в запросе для получения незагруженных данных (например, из дополнительных объектов или дополнительных полей уже загруженных объектов), то тип добавляется в конец списка итераций повторно.

Например, если мы также запрашиваем поле preferredDirector у Actor (которое возвращает объект типа Director) вот так:

{
  directors {
    name
    films {
      title
      actors {
        name
        preferredDirector {
          name
        }
      }
    }
  }
}

...то движок GraphQL обрабатывает запрос в следующем порядке:

Повторяющиеся типы в итерациях

Посмотрим, как это проявляется при выполнении @export в одном запросе. В первой попытке мы создаём запрос так, как делали бы обычно, не задумываясь о порядке выполнения полей:

query GetPostsAuthorNames {
  user(by: { id: 1 }) {
    name @export(as: "authorName")
  }
  posts(filter: { search: $authorName }) {
    id
    title
  }
}

При выполнении запроса получаем такой ответ:

Выполнение запроса с использованием переменной

...содержащий следующую ошибку:

{
  "errors": [
    {
      "message": "Expression 'authorName' is undefined",
    }
  ]
}

Эта ошибка означает, что в момент чтения переменной $authorName она ещё не была установлена — её значение было undefined.

Разберёмся, почему это происходит. Сначала проанализируем, какие типы встречаются в запросе (добавлены в виде комментариев ниже):

# Type: Root
query GetPostsAuthorNames {
  # Type: User
  user(by: {id: 1}) {
    # Type: String
    name @export(as: "authorName")
  }
  # Type: Post
  posts(filter: { search: $authorName }) {
    # Type: ID
    id
    # Type: String
    title
  }
}

Для обработки типов и загрузки их данных движок загрузки данных добавляет корневой тип запроса Root в список FIFO (First-In, First-Out — «первым вошёл, первым вышел»), делая [Root] начальным списком, передаваемым алгоритму, а затем последовательно итерирует по типам:

#ОперацияСписок
0Подготовить список FIFO[Root]
1aИзвлечь первый тип из списка (Root)[]
1bОбработать все поля, запрошенные из типа Root:
user(by: {id: 1})
posts(filter: { search: $authorName })
Добавить их типы (User и Post) в список
[User, Post]
2aИзвлечь первый тип из списка (User)[Post]
2bОбработать поле, запрошенное из типа User:
name @export(as: "authorName")
Поскольку это скалярный тип (String), добавлять его в список не нужно
[Post]
3aИзвлечь первый тип из списка (Post)[]
3bОбработать все поля, запрошенные из типа Post:
id
title
Поскольку это скалярные типы (ID и String), добавлять их в список не нужно
[]
4Список пуст, итерация завершается. 

Здесь видна проблема: @export выполняется на шаге 2b, но был прочитан на шаге 1b.

Именно здесь нам нужно управлять потоком выполнения полей. Реализованное решение заключается в откладывании момента чтения экспортированной переменной — достигается это путём искусственного запроса поля self из типа Root.

Поле self, как следует из его названия, возвращает тот же объект; применённое к объекту Root, оно возвращает тот же объект Root. Возможно, возникнет вопрос: «если корневой объект у меня уже есть, зачем его снова получать?». Дело в том, что алгоритм движка будет вынужден добавить эту новую ссылку на Root в конец списка FIFO, и мы можем намеренно распределять запрашиваемые поля до или после каждой из этих итераций.

Вот почему поле posts(filter:{ search: $authorName }) помещается внутрь поля self в приведённом выше запросе, и выполнение запроса даёт ожидаемый ответ:

query GetPostsAuthorNames {
  user(by: {id: 1}) {
    name @export(as: "authorName")
  }
  self {
    posts(filter: { search: $authorName }) {
      id
      title
    }
  }
}

Выполнение первого запроса с @export

Рассмотрим порядок обработки типов для этого запроса, чтобы понять, почему он работает корректно:

#ОперацияСписок
0Подготовить список FIFO[Root]
1aИзвлечь первый тип из списка (Root)[]
1bОбработать все поля, запрошенные из типа Root:
user(by: {id: 1})
self
Добавить их типы (User и Root) в список
[User, Root]
2aИзвлечь первый тип из списка (User)[Root]
2bОбработать поле, запрошенное из типа User:
name @export(as: "authorName")
Поскольку это скалярный тип (String), добавлять его в список не нужно
[Root]
3aИзвлечь первый тип из списка (Root)[]
3bОбработать поле, запрошенное из типа Root:
posts(filter:{ search: $authorName })
Добавить его тип (Post) в список
[Post]
4aИзвлечь первый тип из списка (Post)[]
4bОбработать все поля, запрошенные из типа Post:
id
title
Поскольку это скалярные типы (ID и String), добавлять их в список не нужно
[]
5Список пуст, итерация завершается. 

Теперь видно, что проблема решена: @export выполняется на шаге 2b, а читается на шаге 3b.

Multiple Query Execution делает именно это при разделении запросов: преобразует документ GraphQL, добавляя поля self, чтобы поля каждой операции выполнялись только после того, как будут разрешены все поля всех предыдущих операций.