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

Запись на курс через mutation

Чтение курсов и авторов уже выражено типами схемы. Теперь добавим mutation записи студента на курс. Операция должна изменить предметное состояние, вернуть конкретную Enrollment и иметь понятное поведение при повторе. Слово mutation само не создаёт транзакцию и не проверяет право студента. Это корневой тип языка для изменяющих действий; правила хранения и доступа по-прежнему устанавливает приложение.

Используем вымышленных студентов s01 и s02. Пока выбираем ID через input, чтобы сначала разобрать форму операции. Сервер слушает только loopback и не является настоящим сервисом записи пользователей. В седьмом уроке субъект попадёт в контекст, и запись другого студента будет запрещена. Такой порядок позволяет отдельно понять ввод mutation и последующее решение о полномочиях.

Вход и выход операции

В схеме появляется EnrollInput с courseId и studentId. Input-тип описывает значения, принимаемые клиентом, а Enrollment описывает результат. Это разные позиции договора: даже похожие поля не позволяют использовать обычный объектный тип схемы вместо входного. Вход не содержит resolver и вложенное поведение ответа. Разделение особенно полезно, когда сервер сам выдаёт ID, дату или состояние принятого события.

Mutation получает один обязательный аргумент input и возвращает обязательную Enrollment. В результате можно запросить ID записи, studentId и вложенный Course. Клиент не обязан снова знать, как сервер связал запись с курсом. Однако серверу важно не возвращать отсутствующий курс в non-null поле: предметная проверка должна отклонить неизвестный courseId прежде, чем создать объект результата.

mutation Join($input:EnrollInput!){
 enroll(input:$input){id studentId course{id title}}
}

Это полный документ изменяющей операции. Значения передаются объектом variables: input содержит courseId c01 и studentId s01. Документ остаётся одинаковым для других допустимых значений. Его нельзя заменять GET-ссылкой «записаться», потому что наш транспорт выполняет операции только через POST. Также не стоит соединять пользовательский ID с текстом query; объявленный input уже предоставляет проверяемое место данных.

Предметная проверка

Resolver сначала ищет курс и проверяет, что студент входит в учебный набор. Неизвестный курс даёт код NOT_FOUND, неизвестный студент — BAD_USER_INPUT. Оба случая возникают до сохранения. Это ожидаемые причины отказа предметной операции. Приложение не должно создавать Enrollment с произвольной строкой вместо Course и надеяться, что проверка GraphQL потом автоматически исправит хранилище. Сериализация результата происходит после изменения и не заменяет предварительный договор.

В нашем Map ключ записи составлен из studentId и courseId. Пара соответствует предметному правилу: один студент имеет не более одной записи на конкретный курс. Если ключ уже найден, resolver возвращает существующую Enrollment. Это идемпотентное поведение для выбранного действия без дополнительных побочных эффектов. Здесь нет списания денег, отправки письма или запуска фоновой задачи, которые потребовали бы отдельного контроля повторения.

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

Последовательность и атомарность

GraphQL выполняет верхние поля mutation последовательно. Это помогает выражать порядок операций, но не означает откат всех полей при поздней ошибке. Если документ содержит две независимые записи, первая могла сохраниться, а вторая отказать. Для общей транзакции нужно реализовать предметную операцию, которая принимает весь набор и подтверждает его на одной границе хранилища. Язык не добавляет такую гарантию автоматически.

Наше сохранение в Map происходит синхронно внутри одного процесса и без await между проверкой ключа и set. Это небольшой учебный договор. При переходе к SQL или нескольким серверам предварительный поиск не гарантирует уникальность: нужен constraint либо согласованная транзакция. Нельзя объявить конкурентную безопасность только потому, что локальный массив из трёх курсов кажется неизменным. Смена источника меняет реальные условия выполнения.

Также mutation не запрещает разработчику случайно изменить состояние внутри query-resolver. Схема задаёт намерение клиента, а правильную реализацию устанавливает команда. В нашей Академии courses и course только читают. Запись вынесена в enroll и названа предметно. Такой договор позволяет клиенту понимать, какое обращение допустимо повторить ради обновления экрана, а какое требует осознанного действия пользователя.

Результат и повтор

Для первой записи s01 на c01 ожидается Enrollment с ключом s01:c01 и тем же курсом «Статический сайт». Повтор той же пары возвращает ту же запись без увеличения количества Enrollment. Другая пара s01:c02 создаёт отдельный предметный объект. Это удобный сценарий чтения функции: меняется только отношение студента и курса, а исходные карточки Course и Author не переписываются.

Ошибка после изменения имеет особую границу. Допустим, запись сохранилась, но получение необязательных метаданных автора в ответе позже не удалось. Клиент не должен автоматически повторять любое действие только из-за errors. Следующая глава объяснит частичный результат и покажет, что error отдельного поля не является доказательством отсутствия mutation. Для операций с более сильными последствиями потребуется сохраняемый идентификатор намерения и договор восстановления результата.

Пример не использует постоянную базу: Enrollment исчезают после остановки процесса. Значит, повтор после перезапуска создаёт запись снова, потому что прежний Map утрачен. Это отличается от повтора в одном работающем экземпляре. В тексте мы не называем хранение устойчивым и не обещаем воспроизведение прежнего результата между запусками. Подключение базы станет отдельным возможным продолжением, а не скрытой зависимостью текущего курса.

При подготовке урока сервер, mutation и проверки не выполнялись. Полное состояние lesson-05 содержит схему, функции, данные и документы. Читатель может проследить допустимые ветки по исходникам и затем самостоятельно выполнить примеры в своей среде. Следующий шаг намеренно добавит недоступную метаинформацию автора одного курса, чтобы разобрать data вместе с errors и причины распространения null по схеме.

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

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

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

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