Перейти к содержанию

Авторы и вложенные связи

Академия уже принимает запрос курса по переменной ID. Теперь добавим вложенный объект автора. Клиент сможет получить название курса и имя автора одной выборкой, а сервер свяжет Course с Author через resolver. В данных authorId присутствовал с первого состояния, но не был доступен в схеме. Это полезный пример: внутренняя форма хранения и внешний граф типов могут различаться, сохраняя один предметный смысл.

В нашем наборе Ирина имеет ID a01, Павел — a02. Первый и третий курсы созданы Ириной, второй — Павлом. Модель остаётся небольшой и хранится в памяти. Мы не заявляем оптимизацию SQL, потому что базы здесь нет. Сначала разберём правильное место получения связи, а пакетирование повторных обращений появится позже, когда будет понятен договор отдельного поля.

Связь на уровне типа

В схему добавляется type Author { id: ID! name: String! }, а Course получает author: Author. Отсутствие восклицательного знака у связи является выбором: сведения об авторе могут оказаться недоступными, но сам курс должен сохранить возможность отображения. Пока все исходные ссылки найдены, однако nullable граница пригодится в уроке о частичном результате. Обязательность полей нужно выбирать по смыслу данных, а не добавлять non-null ко всему ради красивой схемы.

Клиент запрашивает courses { id title author { name } }. Вложенная выборка нужна, потому что Author является объектом: язык должен знать, какие его поля получить. Запрос author без подразделения полей не является командой «вернуть весь объект». Такой договор делает форму ответа явной. Он также помогает клиенту не привязываться к внутренним свойствам, которые сервер может хранить для собственных задач.

async function fields(source,args,context,info){
 if(info.parentType.name==="Course"&&info.fieldName==="author"){const author=authors.get(source.authorId)??null;return author;}

 return defaultFieldResolver(source,args,context,info);
}

Это фрагмент функции fields в полном academy.mjs состояния lesson-04. Для поля Course.author возвращается объект из Map авторов, а для остальных случаев используется defaultFieldResolver. Результат связи затем становится источником для полей Author.name и Author.id. Клиентский документ описывает форму графа, а конкретная функция решает, откуда берётся каждый участок данных.

Аргументы resolver

Функция поля получает source, args, context и info. Source — текущий объект родительского уровня; здесь это Course. Args содержат аргументы выбранного поля. Context передаёт общие сведения текущего выполнения, например будущего пользователя и запросный загрузчик. Info помогает определить тип и имя поля. Не следует считать source глобальным корневым объектом: при продвижении по графу он меняется вместе с текущим родителем.

Проверка parentType и fieldName выделяет именно нашу связь. Если проверять только имя author, будущий другой тип с одноимённым полем может случайно получить неподходящее поведение. Явная пара делает договор заметнее в небольшом учебном resolver. Для большой схемы удобна отдельная карта функций по типам и полям, но принцип остаётся: имя одного поля не обязательно уникально во всём API.

Ветка остальных полей использует defaultFieldResolver. Он получает значение соответствующего свойства и учитывает функции полей. Поэтому корневые функции root и обычные свойства данных продолжают работать. Если забыть эту ветку и возвращать undefined для всего неизвестного, даже ID и title могут стать null, нарушив свои non-null-обещания. Добавление одной связи не должно незаметно отключать механизм чтения остальных типов.

Граф ответа и работа источника

Когда клиент запрашивает список с author, resolver связи вызывается для выбранных элементов. Ирина может потребоваться дважды, поскольку встречается в первом и третьем курсах. В нашем Map это дешёвая операция, но при SQL или HTTP-источнике такой рисунок может стать проблемой N+1. Одно HTTP-обращение клиента ещё не означает одно обращение серверного приложения к базе. GraphQL группирует договор ответа, а не автоматически оптимизирует источник данных.

Поэтому позже введём загрузчик, живущий только внутри запроса. Сейчас важно сохранить корректность: authorId курса выбирает нужного автора, а не первого по порядку или последнего найденного. Пакетирование без правильного сопоставления ID создаёт более быструю неправильную выдачу. Для списка разных курсов сервер должен удерживать соответствие ключа и результата даже тогда, когда хранилище вернуло авторов в другом порядке.

Новый внешний граф не обязывает раскрывать authorId как отдельное поле. Клиент может получить author.id, если ему действительно нужен идентификатор автора. Это уменьшает зависимость интерфейса от конкретного способа хранения связи. Например, внутренняя база позже может использовать числовой ключ, а API сохранить публичный строковый ID. Такой переход требует продуманного преобразования, но схема уже даёт место для него, не превращая внутреннюю колонку в обязательный публичный договор.

Отсутствующая связь

Текущая функция возвращает null, если автор не найден. Это допустимо для author:Author. Клиент должен уметь показать «автор не указан» вместо обращения к name без проверки. Необходимо отличать добровольное отсутствие метаданных от ошибки источника: на этом шаге Map не нашёл ключ, а позднее resolver может сообщить ошибку отдельным GraphQLError. Nullable положение определит, сколько соседних данных останется в ответе.

Рассмотрим временное удаление a01 из Map во время самостоятельного изменения исходника. Первый и третий курсы сохраняют ID и названия, а связи author становятся null. Это изменение собственного вымышленного набора, не действие с чужим сервисом. Затем восстановите автора и представьте прежний ответ. Такой вариант показывает смысл границы без необходимости менять всю схему или исключать карточки курса из списка.

В настоящем хранилище могут существовать ограничения внешнего ключа, которые не позволяют такую потерю. Но API иногда соединяет несколько независимых сервисов или архивные метаданные, где отсутствие связи возможно. Схема должна отражать фактические обещания источника. Нельзя объявить author обязательным только потому, что в двух учебных строках он нашёлся. Пример нужен для понимания решения, а не для универсального правила nullable всех отношений.

При подготовке урока программа и запросы не запускались. Полное состояние даёт ожидаемую выборку авторов и файлы для самостоятельного прохождения. Следующая глава добавит mutation записи студента на курс. Она использует тот же Course в результате, но изменит предметное состояние. Такое действие нельзя свести к ещё одному полю чтения: понадобятся input, правила уникальности и ясный договор повторного обращения.

Исходники урока

Оглавление серии · Скачать исходники. Полное самостоятельное состояние находится в lesson-04. Учебные приложения, зависимости, база и проверки при подготовке не запускались; описанный результат ожидается по исходникам. Материалы пока находятся в локальной редакционной версии.

Первичные источники: документация 1.