Как мы поддерживаем согласованность наших API-интерфейсов GraphQL с течением времени при изменении базовых структур данных? Это частый вопрос, с которым мы сталкиваемся при создании программного обеспечения в Интернете. У нас есть мобильные приложения и приложения для реагирования или сторонние потребители, которые полагаются на структуру и данные, предоставляемые нашими API. Истина нашего хранилища данных может сильно отличаться от представленного API. Давайте адаптируем несколько распространенных сценариев, используя функции, доступные в Absinthe в Elixir / Phoenix / Ecto.

Один из способов справиться с этим - продолжить представление исходного поля, но с использованием данных из другого источника. Я бы назвал это «псевдонимом» или отображением данных одного поля в другое. Фундаментальная идея Absinthe заключается в том, что имя поля, представленное в API, не обязательно должно быть ключом к источнику данных, который мы используем.

Я расскажу о двух сценариях рефакторинга базы данных, через которые я недавно прошел, сохраняя при этом обратную совместимость API:

  1. Переименование столбца
  2. Перемещение столбца в другую таблицу

Переименование столбца

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

Начнем с таблицы и столбца (пример из Postgres):

CREATE TABLE firmware_versions (
  id SERIAL,
  semantic_version_number text
);
CREATE TABLE devices (
  id SERIAL,
  firmware_version_id integer REFERENCES firmware_versions(id),
  desired_firmware_version_id integer REFERENCES firmware_versions(id)
);

А тип объекта Absinthe будет выглядеть примерно так (я пропустил загрузчики данных и преобразователи):

@desc "Firmware version"
object :firmware_version do
  @desc "Database ID of the firmware version"
  field(:id, :integer)
@desc "Firmware version expressed as a semantic version number"
  field(:semantic_version_number, :string)
end
@desc "A network-connected device"
object :device do
  …
  @desc "Current firmware version of the device"
  field(:firmware_version, :firmware_version, resolve: dataloader(Device))
@desc “Firmware version we are attempting to update to"
  field(:desired_firmware_version, :firmware_version, resolve: dataloader(Device))
end

Изменение object_type

Что произойдет, если мы изменим имя столбца с desired_firmware_version_id на requested_firmware_version_id? Это, по общему признанию, надуманный пример, призванный подчеркнуть точку зрения. Помимо обычной миграции базы данных для изменения таблицы, нам нужно только напрямую изменить наш тип GraphQL, чтобы устройство поддерживало интерфейс для наших пользователей.

field(:requested_firmware_version, :firmware_version, resolve: dataloader(Device), name: "desired_firmware_version")

Требуются два изменения. Для начала мы обновляем первый аргумент в field, чтобы он соответствовал новому имени столбца (в данном случае ассоциации). Затем мы добавляем параметр для name и передаем строку (двоичную), которая соответствует имени предыдущего столбца в API.

Если вы планируете со временем отказаться от старого имени поля, добавьте параметр deprecate, доступный на field, и передайте ему строку с причиной отмены. Все остальное сделает абсент.

Переход к новому столу

Что произойдет, если мы внесем более радикальные изменения в структуру наших данных, добавив отношение к смеси? Будь то новая таблица или существующая таблица, которая лучше подходит для наших данных, наши структуры могут измениться более существенным образом. В этом примере мы начнем с той же начальной структуры таблицы devices, что и выше. Вместо этого мы добавим новую таблицу, а не переименовываем столбец.

CREATE TABLE firmware_update_requests (
  id SERIAL,
  pending boolean DEFAULT true,
  device_id integer REFERENCES devices(id),
  firmware_version_id integer REFERENCES firmware_versions(id)
);
CREATE UNIQUE INDEX pending_request_index ON firmware_update_requests(device_id, pending);

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

Добавьте виртуальное поле и ассоциацию Ecto

Чтобы он заработал, нам просто нужно получить данные из firmware_version_id этой новой таблицы в место в записи устройства, где наш псевдоним Absinthe field может получить к ним доступ.

Мы воспользуемся комбинацией двух функций Ecto: освободим место для firmware_version_id, используя поле virtual в схеме, и добавим нашу связь, не определяя поле (поскольку мы уже сделали это в виртуальном поле).

# in MyApp.Device module
schema :devices do
 # snip …
 field(:requested_firmware_version_id, :integer, virtual: true)
 belongs_to(:requested_firmware_version, MyApp.FirmwareVersion, define_field: false)
end

Это создает место для присоединения данных к устройству и позволяет нам искать их как ассоциацию.

Обновите резольвер абсента

Теперь мы можем изменить наш запрос, чтобы загрузить эти данные. Для абсента мы делаем это в резольвере:

defp with_pending_request(query \\ MyApp.Device) do
 from(
   d in query,
   left_join: r in MyApp.FirmwareUpdateRequest,
   on: d.id == r.device_id,
   on: r.pending == true,
   select: %{d | requested_firmware_version_id: r.firmware_version_id}
 )
end

Давайте разберемся с этим. Начнем с аргумента query. Это Ecto.Query для записей из MyApp.Device. Затем мы left_join нашу firmware_update_request запись о совпадающем device_id и ограничиваем ее pending запросами, так что мы присоединяемся только к одной записи для каждого устройства.

Наконец, моя любимая деталь - это select. Он объединяет firmware_version_id в новое виртуальное поле, которое мы добавили в схему MyApp.Device. Имея эти данные, мы можем использовать предыдущее изменение типа объекта Absinthe field для параметра name, и все будет работать.

Все вместе сейчас

Со всеми этими изменениями: используя преобразователь для загрузки версии микропрограммы из ассоциации firmware_update_request в виртуальное поле Ecto и используя псевдоним поля в Absinthe, мы успешно сопоставили столбец из связанной таблицы с исходным полем в нашем GraphQL API. Таким образом, мы сохранили внешнюю структуру и данные нашего API при изменении базового хранилища данных.

Хотя именно так я решил эту конкретную проблему, это, конечно, не единственный способ решить эту проблему. Другие решения могут включать обратные вызовы преобразователя (появятся в следующей версии Absinthe) или просто настраиваемый преобразователь, который возвращает правильно сформированную карту. Какой бы путь вы ни выбрали, я надеюсь, что это даст вам некоторую новую информацию об абсенте и экто и их функциях.