Как подружить свой тип данных с Views

При разработке модулей для Drupal мы частенько создаем программно новые типы материалов (то есть нод). В процессе иногда возникает соблазн быстренько запрограммировать вывод какой-нибудь отсортированной коллекции этих материалов. Например, вывод нод-городов, отсортированных по алфавиту, или по древности, или сгруппированных по странам. Таких полезных коллекций может образоваться достаточно много и программно создавать каждую из них — не очень эффективно. Особенно если впоследствии нам захочется что-то поменять, объединить, или переделать. Гораздо аккуратнее — использовать модуль Views.

Вообще говоря, Views — один из краеугольных камней разработки на Drupal. Хотите помесячный архив нод? Views. Хотите вывести только заголовки и даты нод? Views. Хотите вывести ноды, сгруппированные по именам авторов? Views. Хотите получить список IP, с которых пользователь Василий заходил на сайт в течение Великого поста? Тоже Views. Один из самых популярных ответов на вопрос «Как в Друпале сделать…» — Views. Так что, чем быстрее разработчик подружится с этим прекрасным модулем — тем лучше. Для этого, кстати, есть неплохой мануал на английском языке: http://views-help.doc.logrus.com/.

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

Далее — о том, как это сделать.

Предполагаю, что читатель уже умеет программно создавать свои типы материалов. Рассказывать буду на примере модуля, в котором новый тип уже создан. А чтобы не публиковать код модуля с описанием нового типа, предлагаю просто его скачать:
Скачать модуль mytest.
В модуле уже реализовано все, о чем дальше пойдет речь в заметке.

Итак, у нас есть тип материала testnode, в который мы добавили два целочисленных поля — int_count и int_total, хранящиеся в таблице БД {testnode}. Научим Views загружать эти поля из базы данных, выводить их, а также сортировать и фильтровать по ним коллекцию.

Перво-наперво, доложим Views о том, какую версию API мы используем (а используем мы версию Views 2.8 с Drupal 6.x). Для этого добавим в наш файл mytest.module вызов хука hook_views_api():

function mytest_views_api() {
  return array('api' => 2);
} //mytest_views_api

Затем создадим файл mytest.views.inc, в котором модуль Views самостоятельно сможет найти вызов нужных хуков. Пришла пора рассказать Views про нашу таблицу. Для этого используется хук hook_views_data(), возвращающий массив с информацией об описываемых таблицах и их полях. Реализация хука должна возвращать ассоциативный массив формата $data['имя_таблицы']['имена_полей']. Также в массиве для каждой таблицы может содержаться подмассив $data['имя_таблицы']['table'] с общей информацией о таблице. Рассмотрим хук по порядку:

function mytest_views_data() {
  $data = array();
  $data['testnode'] = array(
    'table' => array(<br>
    'group' => t('Testnode'),
  ),
  //...

Подмассив ['table'] содержит основную информацию о таблице. Элемент ['group'] — это имя группы, в которой объявляемые нами поля появятся в выпадающих списках Views. В принципе, можно указывать имя группы для каждого описываемого поля, но если этого не делать — имя группы унаследуется из секции ['table'].

Прежде чем перечислять поля таблицы {testnode}, небольшое лирическое отступление о Views. Что, собственно, делает модуль Views, когда пользователь указывает в его удобных, хотя и запутанных на первый взгляд колонках интерфейса аргументы, фильтры, поля, отношения и способы сортировки? Правильно, модуль Views по заданным настройкам составляет SQL-запрос, вытягивающий из базы данных те данные, которые пользователь указал, и сортирует их так, как пользователю надо. Так что, чтобы пользователь мог указывать во Views поля таблицы {testnode}, нужно рассказать модулю Views, что он должен вставлять в SQL-запрос.

Для каждого поля нашей таблицы мы можем указывать следующую информацию:

  • ['title'] — заголовок поля, таким он будет показан пользователю в интерфейсе Views;
  • ['group'] — если хочется, можем указать для поля группу, отличную от указанной в ['table'];
  • ['help'] — здесь можно задать текст подсказки по данному полю, который пользователь увидит мелким шрифтом рядом с заголовком;
  • ['real field'] — если в массиве $data мы в качестве ключа указали для данного поля синоним, то в ['real field'] нужно привести настоящее название поля в таблице БД.

Перечисленные элементы — заголовок, помощь, группа — имеют скорее декоративное назначение. Кроме них нам нужно заполнить элементы массива, имеющие непосредственное отношение к логике работы Views. Это элементы ['field'], ['filter'], ['sort'], ['argument'] и ['relationship'], отвечающие соответственно за чтение поля из БД, фильтрацию поля (с помощью WHERE), сортировку поля (ORDER BY), использование поля в качестве аргумента (в URL) и отношения (то есть присоединение другой таблицы через данное поле). Все пять элементов должны быть ассоциативными массивами. В них нужно обязательно привести значение элемента ['handler'], а также, по необходимости, дополнительные элементы, которые могут варьироваться в зависимости от значения ['handler'].

Здесь нужно еще одно лирическое отступление. В отличие от ядра Друпала и большинства сторонних модулей, API модуля Views — объектно-ориентированный (насколько это возможно). Товарищей, привыкших программировать под Друпал, Views API может несколько смутить, но смею заверить: это плюс, а не минус. Кстати, по поводу отсутствия объектно-ориентированного подхода в «капельке» разные граждане периодически поднимают бучу. Автор статьи, однако, относится к этой особенности безразлично и принимает ее как данность, с которой нужно работать, а не хаять почем зря.

Рассмотрение объектно-ориентированной модели Views API, в принципе, должно быть темой отдельной большой статьи. Поэтому здесь коснемся только того, что нас непосредственно волнует, то есть хэндлеров. Понимающий английское письмо читатель может ознакомиться с подробным описанием Views API на сайте: http://views2.logrus.com/doc/html/index.html.

Корнем модели является класс views_object, а уже от него наследуется класс views_handler. Ветка производных от views_handler классов управляет поведением заданных пользователем элементов представления (аргументов, полей, фильтров и так далее). То есть хэндлеры — это управляющие классы, задача которых следить, чтобы все что нужно было добавлено в запрос, чтобы из БД был получен нужный результат, чтобы был сгенерирован правильный текст поля и так далее. Таким образом, чтобы пользователь мог использовать поля из таблицы {testnode} для генерации своих представлений, нужно указать для каждого поля правильные классы-хэндлеры.

Хэндлеры бывают очень разные в зависимости от типа данных, необходимого эффекта, оказываемого ими на запрос, сложности фильтра, который мы хотим использовать и тому подобное. Для нашего тестового модуля нам понадобятся хэндлеры views_handler_field (для вставки в запрос обычного поля), views_handler_filter_numeric (чтобы в фильтре можно было пользоваться значением поля как числом) и views_handler_sort (обычный хэндлер для сортировки ASC/DESC без извратов). В результате поля таблицы {testnode} мы опишем так:

    //... это продолжение хука mytest_views_data
    'tid' => array(
      'title' => t('Test Node ID'),
      'field' => array(
        'handler' => 'views_handler_field',
      ),
      'filter' => array(
        'handler' => 'views_handler_filter_numeric',
      ),
    ),
    'nid' => array(
      'title' => t('Node ID'),
      'field' => array(
        'handler' => 'views_handler_field',
      ),
      'filter' => array(
        'handler' => 'views_handler_filter_numeric',
      ),
    ),
    'int_count' => array(
      'title' => t('Count'),
      'field' => array(
        'handler' => 'views_handler_field',
      ),
      'sort' => array(
        'handler' => 'views_handler_sort',
      ),
      'filter' => array(
        'handler' => 'views_handler_filter_numeric',
      ),
    ),
    'int_total' => array(
      'title' => t('Total'),
      'field' => array(
        'handler' => 'views_handler_field',
      ),
      'sort' => array(
        'handler' => 'views_handler_sort',
      ),
      'filter' => array(
        'handler' => 'views_handler_filter_numeric',
      ),
    ),
  );

Теперь необходимо снова вернуться к секции массива ['table']. Помимо имени группы, в ней содержится важная информация о том, базовая ли это таблица, или ее нужно присоединять к другой при генерации представления. Базовая таблица — это таблица, центральная для Views, как например таблица node для Node view или таблица user — для User view. Будь наша таблица базовой, мы задали бы для нее в массиве $data['имя_таблицы']['table']['base'] элементы ['field'] (название поля-первичного ключа), ['title'] (заголовок таблицы), ['help'] (текст подсказки) и ['database'] (информация о базе данных, если таблица находится не в базе Друпала).

Но в рассматриваемом примере таблица не базовая, поэтому рассмотрим другой массив — $data['имя_таблицы']['table']['join']. В нем мы должны привести информацию о том, как и к какой базовой таблице Views должен присоединить данные таблицы {testnode}:

//join to node
  $data['testnode']['table']['join']['node'] = array(
    'left_field' => 'nid',
    'field' => 'nid',
  );
    return $data;<br>
} //function mytest_views_data<br><br>

Поскольку в нашей таблице содержатся поля для нового типа ноды testnode, очевидно, что мы хотели бы присоединить их к таблице {node}, так чтобы поля int_count и int_total могли выводиться в Node View. Для этого в секции ['join'] мы создаем массив ['node'] (имя базовой таблицы) и указываем поля, на базе которых можно объединить данные: left_field — поле в таблице {node} и field — поле в таблице {testnode}. Таблицы, как видно из кода, объединяются на базе поля nid.

На этом интеграция таблицы {testnode} закончена. Модуль можно установить и Views сразу позволит добавлять к представлению поля int_count и int_total, а также сортировать и фильтровать по их значениям вывод представления.

Теперь же решим задачу добавления к «вьюхе» виртуального, то есть несуществующего в таблице {testnode} поля. Пусть оно называется percentage и вычисляется из существующих значений int_count и int_total по формуле (int_count*100/int_total), то есть представляет собой некое процентное соотношение count от total. На этом примере посмотрим, как добавить виртуальное поле во Views, как пользоваться хэндлерами с суффиксом formula и как писать свои унаследованные от существующих хэндлеры.

Во-первых, нам необходимо добавить поле , для чего, как мы уже знаем, служит элемент ['field']:

  //... продолжение хука mytest_views_data
  $data['testnode']['percentage'] = array(
    'title' => t('Percentage'),
    'field' => array(
      'handler' => 'mytest_handler_field_percentage',
    ),
  //...

Очевидно, мы не можем использовать стандартный field-хэндлер, поскольку он добавит в SQL-запрос поле percentage, а в таблице {testnode} такого поля нет. Вместо этого мы бы хотели ничего не добавлять в запрос, а на выходе получить результат в виде числа. Для этого нам потребуется собственный хэндлер, который мы назовем mytest_handler_field_percentage. Общепринятая практика при программировании под Views рекомендует, чтобы первым словом в названии хэндлера было имя модуля, затем слово handler и та опция Views, которой он управляет (то есть field), и в конце название поля таблицы БД. Реализацию хэндлера разместим в файле mytest_handler_field_percentage.inc:

/**
 * Класс генерирует виртуальное поле, равное round(int_count*100/int_total);
 */
class mytest_handler_field_percentage extends views_handler_field_numeric {
  function query() {
    return ;
  }
  function render($values) {
    if (isset($values->testnode_int_count) && isset($values->testnode_int_total)) {
      return round($values->testnode_int_count*100/$values->testnode_int_total);
    } else {
      return 0;
    }
  }
}

В качестве родительского мы берем хэндлер views_handler_field_numeric, так как этот класс уже умеет обращаться с численными полями, а наше виртуальное поле — это число. В первую очередь мы переопределяем метод query(), который отвечает за модификацию SQL-запроса. В нашем случае мы не хотим ничего добавлять в запрос, поэтому метод query() — пустой.

Затем озаботимся результатом, который будет записан в поле после выполнения запроса Views. За это отвечает метод render($values), где в объекте $values находятся все считанные из БД значения. В переопределенном методе render() мы вычисляем и возвращаем результат по считанным из базы полям int_count и int_total. Обратите внимание, что имя таблицы {testnode} является префиксом для обоих полей. Если же int_count или int_total не были считаны из БД при выполнении запроса, метод render() вернет 0. Правда, такой подход подразумевает, что пользователь должен вручную добавить поля int_count и int_total к представлению. Если мы захотим автоматизировать процесс и добавлять эти поля всякий раз, когда нам нужно поле percentage, то необходимо реализовать метод add_additional_fields(), либо добавить поля int_count и int_total в свойство additional_field в конструкторе хэндлера, либо добавить их к запросу непосредственно в методе query().

Теперь, когда новое виртуальное поле описано, мы бы хотели иметь возможность сортировать по нему коллекции данных. Для этого подходит уже существующий хэндлер views_handler_sort_formula. Этот хэндлер дополнительным параметром требует формулу, по вычисленному значению которой будет отсортирована коллекция. Заданная формула будет вставлена в запрос Views в части ORDER BY.

  //... продолжение массива $data['testnode']['percentage']
  'sort' => array(
      'handler' => 'views_handler_sort_formula',
      'formula' => 'ROUND(testnode.int_count*100/testnode.int_total)',
    ),
  //...

Еще мы хотели бы научить Views фильтровать выборку из БД по виртуального полю percentage. Для этого тоже создадим новый хэндлер:

  //...
    'filter' => array(
      'handler' => 'mytest_handler_filter_percentage',
    ),

Хэндлер mytest_handler_filter_percentage разместим в одноименном .inc-файле. Наследовать его будем от numeric-фильтра:

class mytest_handler_filter_percentage extends views_handler_filter_numeric {
  function query() {
    $this->ensure_my_table();
    $field = "ROUND(testnode.int_count*100/testnode.int_total)";$info = $this->operators();
    if (!empty($info[$this->operator]['method'])) {
      $this->{$info[$this->operator]['method']}($field);
    }
  }
}

Здесь мы переопределяем метод query() чтобы вместо названия поля в WHERE-часть запроса была добавлена наша формула. Для этого мы сначала вызовом $this->ensure_my_table() убеждаемся в том, что в запрос будет добавлено имя таблицы, в которой находятся поля int_count и int_total, затем определяем операцию, по которому происходит фильтрация, и указываем для этой операции нашу формулу. Смысл наследования numeric-фильтра в том, что для него уже определены операции сравнения чисел (больше, больше или равно, меньше и так далее). По сути, реализация метода скопирована из реализации родителя, изменить потребовалось только значение $field.

Наконец, прежде чем Views увидит наши новые хэндлеры, необходимо добавить в файл mytest.views.inc реализацию хука hook_views_handlers:

function mytest_views_handlers() {
  $handlers = array();
  $handlers['handlers']['mytest_handler_field_percentage'] = array(
    'parent' => 'views_handler_field_numeric',
    'file' => 'mytest_handler_field_percentage.inc',
  );
  $handlers['handlers']['mytest_handler_filter_percentage'] = array(
    'parent' => 'views_handler_filter_numeric',
    'file' => 'mytest_handler_filter_percentage.inc',
  );
  return $handlers;
} //function mytest_views_handlers

Хук просто возвращает массив новых хэндлеров с информацией о том, какие классы-хэндлеры они наследуют и в каких файлах их искать.

Вот и все. Мы научили Views считывать, выводить и использовать для сортировки и фильтрации результатов запроса виртуальное поле percentage. Осталось только почистить кэш Views со страницы 'admin/build/views/tools' и модуль готов к работе. Задача решена.

Комментарии