Отправная точка

Строить мы будем на базовом сервере GraphQL. Код можно скачать здесь или с помощью:

git clone --branch setup https://github.com/bjdixon/graphql-server.git

При клонировании/загрузке нужно создать экземпляр MongoDB и заменить строку подключения в файле ./index.js.

Убедитесь, что зависимости установлены:

npm install

Затем запустите сервер:

npm start

Что мы строим

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

Расширение схемы

В файле ./schema/index.js мы определили два типа объектов: Author и Book, используя язык определения схемы (SDL).

type Author {
  id: ID!
  name: String!
}
type Book {
  id: ID!
  name: String!
  pages: Int
}

У каждого типа объекта есть список свойств и связанный тип для каждого из них. В нашем примере у типа Book есть три свойства:

  • id — специальный тип ID;
  • name — строка;
  • pages — целое число.

Список всех поддерживаемых типов можно найти здесь.

В конце некоторых определений типов можно заметить !. Этот элемент указывает на то, что свойство является обязательным. Его можно увидеть в типе Query:

type Query {
  message: String!
  authors: [Author!]!
  books: [Book!]!
}

Свойство message требует строку, authors — список (обозначается квадратными скобками), а значения в этом списке должны иметь тип Author, хотя возврат пустого списка также допустим. На данный момент свойство authors должно быть либо null, либо списком. Нам также нужно убедиться, что если оно возвращает непустой список, то он содержит данные типа Author. Избавимся от запроса статического сообщения (message):

type Query {
  authors: [Author!]
  books: [Book!]!
}

Удаляем строку с распознавателем message:

message: () => 'hello world',

Помимо удаления message, разница в запросах мала. Строка author больше не заканчивается восклицательным знаком. В приведенном примере свойство authors может возвращать значение null или список, но если оно возвращает непустой список, то он может содержать только тип authors. Свойство books должно возвращать список (который может быть пустым), и он может содержать только books.

Создадим пару отношений в схеме. К типу Book добавим обязательное свойство author (предположим, что только один человек может быть автором книги), и это значение должно иметь тип Author. К типу Author добавим свойство books, которое может быть null или списком, чьи значения должны иметь тип Book. Отредактируйте ./schema/index.js:

type Author {
  id: ID!
  name: String!
  books: [Book!]
}
type Book {
  id: ID!
  name: String!
  pages: Int
  author: Author!
}

Проверка обновлений схемы

Для тестирования обновленной схемы посетите localhost:4000/graphql

Мы также можем перечислить авторов и вернуть их имена, но при этом получим null для их книг. Если попытаемся перечислить книги и вернуть их авторов, получим ошибку — автор книги должен быть типа Author, а у экземпляров книг его еще нет.

Ошибка при тестировании новой схемы

Нужно обновить базовые модели и мутации, которые создают книги и авторов.

Расширение моделей

Сначала расширим модель Author, добавив массив книг. Мы собираемся сохранить список объектов Mongo ObjectId (вы сможете увидеть это свойстве type для книг) и использовать свойство ref, чтобы сообщить Mongo, с какой моделью мы хотим его связать. Отредактируйте файл ./models/Author.js:

import mongoose from 'mongoose'

const Schema = mongoose.Schema

export const Author = mongoose.model('Author', {
  name: String,
  books: [{
    type: Schema.Types.ObjectId,
    ref: 'Book'
  }]
})

Точно так же нужно обновить модель Book, но вместо массива авторов добавим отношение к одному Author. В файле ./models/Book.js:

import mongoose from 'mongoose'

const Schema = mongoose.Schema

export const Book = mongoose.model('Book', {
  name: String,
  pages: Number,
  author: {
    type: Schema.Types.ObjectId,
    ref: 'Author'
  }
})

Теперь можно изменить три строки в схеме и создать первые отношения.

Во-первых, в типе Mutation мы добавляем автора при создании новой книги. Это будет строка, поскольку так Mongo хранит и принимает ID автора. В файле ./schema/index.js отредактируйте createBook в типе Mutation, добавив новый параметр Author:

type Mutation {
createAuthor(name: String!): Author!
createBook(name: String!, pages: Int, author: String!): Book!
}

Затем в том же файле внесем небольшое изменение в распознаватель createBook, добавив параметр author в функцию и в качестве аргумента в конструктор Book:

const resolvers = {
  Query: {
    authors: () => Author.find(),
    books: () => Book.find()
  },
  Mutation: {
    createAuthor: async (_, { name }) => {
      const author = new Author({ name });
      await author.save();
      return author;
    },
    createBook: async (_, { name, pages, author }) => {
      const book = new Book({ name, pages, author });
      await book.save();
      return book;
    }
  }
}

Проверка обновленных моделей и мутаций

Удалите старые экземпляры Book и Author в MongoDB — они не отражают обновленную схему. Создайте новый объект Author. Выполните запрос к Author, чтобы получить его ID — он понадобится при создании Book.

Запись id Author

Теперь создайте новый Book, используя только что созданный ID автора в качестве аргумента author.

Создание книги с помощью ID автора

Мы создали новую книгу без ошибок. В БД Mongo мы видим, что экземпляр книги имеет свойство Author с ObjectId нашего автора.

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

Ошибка при попытке увидеть детали об авторе книги

Отношения нужно не просто создавать, но и видеть, поэтому переходим к следующему пункту.

Связующие отношения в распознавателях

Мы будем использовать рекурсию.

В MongoDB мы храним свойство author для каждой книги в виде строки ObjectId, а массив книг каждого автора — это список ObjectId объектов Book. Сделаем эти ObjectId более полезными.

Создадим две вспомогательные функции, которые будут использоваться для расширения ObjectId автора книги в объект Author и массива Author, включающего ObjectId книг, в массив объектов Book. Определим эти функции в файле ./schema/index.js перед распознавателями, в которых они будут использоваться:

const books = async bookIds => {
  try {
    const books = await Book.find({_id: { $in: bookIds }})
    return books.map(book => ({
      ...book._doc,
      author: author.bind(this, book._doc.author)
    }))
  } catch {
    throw err
  }
}

const author = async authorId => {
  try {
    const author = await Author.findById(authorId)
    return {
      ...author._doc,
      books: books.bind(this, author._doc.books)
    }
  } catch (err) {
    throw err
  }
}

Функция books принимает массив ObjectId объекта Book в качестве аргумента, а затем находит все документы Book с этими ID. Затем она возвращает отображение Book поверх них, и каждая Book возвращает все свои свойства, кроме Author. Перезапишем его, когда оно запрашивается вызовом функции Author, которая была привязана к ObjectId автора Book.

Функция author, принимающая ObjectId объекта Author, находит документ Author, связанный с этим ObjectId, и возвращает все его свойства, за исключением массива книг. Он перезаписывается, когда это свойство запрашивается вызовом функции Books, которая возвращает все свойства для каждой книги, имеющей связанный ObjectId в массиве книг объекта Author.

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

Мы можем переписать наши распознаватели Query, используя удобные функции для заполнения связанного с Book объекта Author и связанных с Author объектов Book:

const resolvers = {
  Query: {
    authors: async () => {
      try {
        const authors = await Author.find()
        return authors.map(author => ({
          ...author._doc,
          books: books.bind(this, author._doc.books)
        }))
      } catch (err) {
        throw err
      }
    },
    books: async () => {
      try {
        const books = await Book.find()
        return books.map(book => ({
          ...book._doc,
          author: author.bind(this, book._doc.author)
        }))
      } catch (err) {
        throw err
      }
    }
  },

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

Закончим изменения распознавателя, переписав Mutation:

Const resolvers = {
  Query: {
    ...
   },
   Mutation: {
    createAuthor: async (_, { name }) => {
      try {
        const author = new Author({ name })
        await author.save()
        return author;
      } catch (err) {
        throw err
      }
    },
    createBook: async (_, { name, pages, author: authorId }) => {
      const book = new Book({ name, pages, author: authorId })
      try {
        const savedBook = await book.save()
        const authorRecord = await Author.findById(authorId)
        authorRecord.books.push(book)
        await authorRecord.save()
        return {
          ...savedBook._doc,
          author: author.bind(this, authorId)
        }
      } catch (err) {
        throw err
      }
    }
  }
}

Мутация createAuthor остается неизменной, за исключением добавления блока try/catch.

В мутации createBook нужно переименовать параметр author, который мы предоставляем в качестве аргумента от author к authorId, поскольку мы собираемся использовать функцию author и конфликт имен. После сохранения нового Book обновляем связанного Author, находя его документ, помещая новые ObjectId, принадлежащие Books, в массив книг объектов Author, а затем сохраняя запись. Используем нашу вспомогательную функцию для заполнения свойства Author по требованию при возврате нового объекта Book.

Проверка финальной версии

Мы внесли много изменений с момента последнего тестирования, поэтому удаляем всех ранее созданных Author и Book в MongoDB, а затем создаем нового Author, используя мутацию createAuthor. Не забудьте сохранить ID автора. Это понадобится при создании экземпляров Book.

Выполнив это, мы можем увидеть изменения, используя мутацию createBook и запросив возврат свойств Author:

Создание книги и отображение деталей об авторе

Создадим еще один Book, используя того же автора, но на этот раз при возврате автора для нового Book вернем имена всех его книг:

Создание новой книги и получение книг одного автора

Воспользуемся запросом Author, чтобы перечислить всех Author и названия их книг:

Отображение всех авторов и их книг

Для демонстрации рекурсии можно сделать: 

Это медленный процесс. Не надо этого делать.

Не рекомендую это делать  —  медленно и бессмысленно.

Чтобы доказать, что магия, расширяющая связанные свойства, выполняется в коде, а не в фоновом режиме, можете заглянуть в MongoDB. Там должен быть документ Author с массивом объектов Book, содержащим ObjectId, и документы Book со свойствами Author, которые являются ObjectId.

Полный рабочий код для этой статьи можно найти здесь.

Читайте также:

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи Brian Dixon: How to Add Relations to Your GraphQL Schema

Предыдущая статьяНе слушай профи - делай print()
Следующая статьяДоходчиво об обучении на основе многообразий с алгоритмами IsoMap, t-SNE и LLE