Кастомные условия поиска в @prisma-cms

Всем привет!

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

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

query users{
  users(
    first: 10
    where: {
      OR: [
        { username_contains: "search_string" }
        { fullname_contains: "search_string" }
        { email_contains: "search_string" }
      ]
    }
    orderBy: createdAt_DESC
  ) {
    id
    username
    fullname
    email
  }
}

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

Иногда такое перечисления полей в интерфейсах не очень удобно, тем более для простых сотрудников, которые не хотят заполнять несколько отдельных полей (в нашем случае username, fullname, email), а хотят заполнять одно поисковое поле и чтобы по нему "все искалось". При чем логика бывает более сложная (к примеру, заказы по номеру или владельцу, у которого имя или емейл совпадает С). Но GraphQL просто так ничего не принимает лишнего в запросах. Если вы хотите передавать какое-то новое поле в запросе, вы его должны описать в схеме и заставить сервер обрабатывать этот запрос. Вот решение для подобных случаев я и хочу описать.

1. Добавляем свое поле в API-схему

В файл схемы user.graphql дописываем

input UserWhereInput {
  search: String
}

Напоминаю, что при сборке API-схемы командой yarn build-api выполняется суммирование всех объектов схемы, таким образом на выходе мы получим UserWhereInput не только с полем search, но и с другими полями, описанными ранее, то есть фактически мы именно добавляем поле, а не просто объявляем новый объект.

2. Расширяем резолвер

Как я говорил ранее, API состоят из двух частей:

  1. Низкоуровневое, генерируемое самой призмой (и которое обрабатывается в docker-сервере).
  2. Внешнее общедоступное API.

Так вот, сейчас мы добавили новое поле search в наше фронтовое API и оно в запросе это поле примет. Но далее запрос улетает на низкоуровневое API, а там об этом поле ничего не известно, и в случае отправки такого запроса туда, мы получим ошибку от сервера, что такое поле в схеме не описано и запрос выполнить нельзя. Но мы сейчас и не будем пытаться заставить низкоуровневое API понимать поле search, наша задача в другом: приняв search на входе, отправить на низкоуровневое API измененный запрос, а именно с условием:

    where: {
      OR: [
        { username_contains: "search_string" }
        { fullname_contains: "search_string" }
        { email_contains: "search_string" }
      ]
    }

в то время как на вход (во внешнем API) мы будем принимать запрос попроще:

where: {
  search: "search_string"
}

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

Вот здесь я переопределяю два резолвера (users и usersConnection). Прежде чем отправить запрос далее, я вызываю метод addQueryConditions и передаю в него текущие условия параметры запроса. Вот этот метод:

  addQueryConditions(args, ctx, info) {

    const {
      modifyArgs,
    } = ctx;

    const {
      where,
    } = args;

    modifyArgs(where, this.injectWhere, info);

  }

Он в свою очередь берет из контекста метод modifyArgs и передает в него модификатор this.injectWhere. Вот его код для наглядности:

injectWhere(where) {

    let {
      search,
      ...other
    } = where || {};

    
    let condition;

    
    if (search !== undefined) {

      delete where.search;


      if (search) {

        condition = {
          OR: [
            {
              fullname_contains: search,
            },
            {
              username_contains: search,
            },
            {
              email_contains: search,
            },
          ],
        }

      }

    }


    if (condition) {

      /**
       * Если объект условия пустой, то во избежание лишней вложенности
       * присваиваем ему полученное условие
       */
      if (!Object.keys(where).length) {

        Object.assign(where, condition);

      }

      /**
       * Иначе нам надо добавить полученное условие в массив AND,
       * чтобы объединить с другими условиями
       */
      else {

        if (!where.AND) {

          where.AND = [];

        }

        where.AND.push(condition);

      }

    }

    return where;

  }

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

А вот результат для примера: https://prisma-cms.com/people?filters=%7B%22search%22%3A%22test%22%7D

Что хорошо в данной реализации, так это то, что она работает на любом уровне вложенности запроса и даже в массивах условий (AND и OR). Вот пример: https://prisma-cms.com/people?filters=%7B%22Resources_some%22%3A%7B%22CreatedBy%22%3A%7B%22search%22%3A%22test%22%7D%7D%7D

А еще этот метод за раз можно несколько раз вызывать (если вы написали несколько отдельных модификаторов, а не прописали все условия в одном).

Но если и минус, который скорее всего не будет решен. Дело в том, что мы добавили кастомное поле search в объект UserWhereInput. А этот объект используется не только в выборках пользователей, но и в запросах других объектов, связанных с пользователем, к примеру, в ресурсах. То есть если мы во фронте пропишем вот такой запрос:

query resources {
  resources (
    first: 10
    where:{
      CreatedBy:{
        search: "Fi1osof"
      }
    }
  ){
    id
    name
  }
}

то синтаксических ошибок мы не получим, технически здесь все ОК. Но при выполнении запроса мы получим ошибку

{
  "data": null,
  "errors": [
    {
      "message": "Variable \"$_v0_where\" got invalid value { CreatedBy: { search: \"Fi1osof\" } }; Field \"search\" is not defined by type UserWhereInput at value.CreatedBy.",
      "locations": [
        {
          "line": 15,
          "column": 3
        }
      ],
      "path": [
        "resources"
      ]
    }
  ]
}

Потому что в бэк-API такое поле неизвестно. Для решения этой проблемы придется еще и модификатор писать для ресурс-запросов.

Но в целом эта проблема не особо критична, о ней просто надо знать (чтобы не выполнять). Но для самостоятельных запросов метод очень полезный.