Вывод и фильтрация больших таблиц данных с помощью плагина DataTables + ColumnFilter. Часть 1

в 1:18, , рубрики: DataTables, DataTables Column Filter, jquery, jquery plugin, таблицы данных, метки: , , , ,

Веб-разработчики довольно часто сталкиваются с задачей отображения на странице данных в табличной форме с возможностью их сортировки, поиска/фильтрации, разбиения на страницы и удобной навигации по ним. Например, для меня это стало актуально при создании админки для одного сайта. Каждый разработчик решает эту задачу по-своему. Многие не сильно с этим заморачиваются и в результате получаются интерфейсы в стиле ранних версий phpMyAdmin, с полным обновлением страницы после любого клика мышью. Но сейчас уже не начало 2000-х, а значит, пора уже и админки переводить на AJAX и jQuery. Мне в этом очень помог замечательный плагин DataTables, который избавил меня от необходимости изобретать велосипед и писать тонны кода. С его помощью я смог в сжатые сроки придать админке современный вид. Теперь я хочу поделиться некоторым опытом, накопленным за время использования этого плагина.

В этой статье я расскажу как с помощью DataTables и его дополнения Column Filter организовать постраничный вывод больших объемов данных с поддержкой индивидуальной фильтрации по столбцам. В этом случае необходимо задействовать в плагине режим обработки данных на стороне сервера. На сайте плагина есть подробный пример реализации этого режима. Здесь же я рассмотрю практические аспекты применения дополнения Column Filter и особенности реализации обработки данных на стороне сервера в этом случае.

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

Вывод и фильтрация больших таблиц данных с помощью плагина DataTables + ColumnFilter. Часть 1

В отличие от самого плагина его дополнение Column Filter к сожалению не может похвастать подробной документацией. Множество его возможностей и особенностей почему-то нигде не описаны. Так, в примере настройки Column Filter не указана полная форма инициализации списка (ее можно найти только в исходниках дополнения), вместо нее используется краткая форма:

values: [ 'A', 'B', 'C' ]

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

values: [ {value:1, label:"Текст 1"}, {value:2, label:"Текст 2"}, ... ]

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

На основе примера с сайта DataTables я написал PHP-класс, реализующий обработку данных на стороне сервера, в котором содержатся изменения, необходимые для нормальной работы дополнения Column Filter.

Вот его исходный код:

<?php

abstract class DataTableBase
{

/*
 * Вывод данных из таблицы в формате JSON:
 * Параметры:
 *  $sTable [string] - имя таблицы (используется в запросе SELECT)
 *  $aCols [array of string] - массив столбцов для сортировки и фильтрации
 *  (optional:)
 *  $sIndexCol [string] - индексируемый столбец
 *  $aColFltFlags [array of bool] - массив флажков, определяющих, 
 *   подлежит ли столбец общей фильтрации
 *  $sFetchCols [string] - список выбираемых столбцов
 *  $sTableXtra [string] - дополнение для блока FROM (для INNER JOIN)
 *  $sPreCond [string] - необходимое условие (используется в блоке WHERE)
 * Возвращает:
 *  [array] - данные в формате JSON
 *  false - в случае ошибки 
 */
function output($sTable, $aCols,
  $sIndexCol='', $aColFltFlags=0, $sFetchCols='', $sTableXtra='', $sPreCond='')
{
  // Проверка параметров:
  if (!$sTable || !is_array($aCols) || !($n= count($aCols))) return '';
  for ($i= 0; $i< $n && !$aCols[$i]; $i++) ;
  if ($i>= $n) return '';
  if (!$sIndexCol) {
    $sIndexCol= $aCols[$i];
  }
  if (!is_array($aColFltFlags) || count($aCols) != $n) {
    $aColFltFlags= array_fill(0, $n, false);
  }
  if (!$sFetchCols) {
    $sFetchCols= '*';
  }

  /* Разбиение на страницы  */
  $sLimit = "";
  if (isset($_REQUEST['iDisplayStart']) &&
      isset($_REQUEST['iDisplayLength']) &&
      ($n= $_REQUEST['iDisplayLength']) != '-1') {
    $sLimit = 'LIMIT '. $this->sqlEsc($_REQUEST['iDisplayStart']). ', '.
      $this->sqlEsc($n);
  }

  /* блок ORDER */
  $sOrder= '';
  if (isset($_REQUEST['iSortCol_0']) && isset($_REQUEST['iSortingCols'])) {
    for ($n= intval($_REQUEST['iSortingCols']), $i=0 ; $i < $n; $i++) {
      if (isset($_REQUEST['iSortCol_'.$i]) &&
          ($s= $_REQUEST['iSortCol_'.$i])!='' &&
          isset($_REQUEST['bSortable_'.($i_col= intval($s))]) &&
          $_REQUEST['bSortable_'.$i_col] == 'true' &&
          isset($aCols[$i_col])) {
        $sOrder.= ", ". $aCols[$i_col]. " ".
          ((strtolower($_REQUEST['sSortDir_'.$i])=='desc')? 'DESC' : 'ASC');
      }
    }
    if ($sOrder) $sOrder= 'ORDER BY '. substr($sOrder, 2);
  }

  /* Фильтрация столбцов */

  $sRangeSep= '';
  if (isset($_REQUEST['sRangeSeparator']) &&
      strlen($s= $this->sqlEsc($_REQUEST['sRangeSeparator'])) == 1 &&
      preg_match('/^[^^-/]$/', $s)) {
    $sRangeSep= $s;
    $val= '((?:d+|d{2,4}.d{2}.d{2}|))';
    $sRangeRegex= '/^'.$val.'['.$sRangeSep.']'.$val.'$/';
  }
  /* Общая фильтрация */
  $sWhere = $sPreCond? "WHERE $sPreCond": '';
  if (isset($_REQUEST['sSearch']) && ($find= $_REQUEST['sSearch']) != '') {
    $conds_or= '';
    foreach ($aCols as $i=> $col) {
      if ($col && $aColFltFlags[$i])
        $conds_or.= " OR ". $col. " LIKE '%".$this->sqlEsc($find)."%'";
    }
    if ($conds_or)
      $sWhere.= ($sWhere? " AND " : "WHERE "). '('. substr($conds_or, 4). ')';
  }
  /* Индивидуальная фильтрация */
  $conds_and= '';
  foreach ($aCols as $i=> $col) {
    if ($col && isset($_REQUEST['bSearchable_'.$i]) &&
        $_REQUEST['bSearchable_'.$i] == "true" && 
        isset($_REQUEST['sSearch_'.$i]) &&
        ($find= $_REQUEST['sSearch_'.$i]) != '' && $find != $sRangeSep) {
      $conds_and.= " AND ";
      if (is_numeric($find))
        $conds_and.= "$col = '$find'";
      else if (preg_match('/^[d,]+$/', $find)) {
        $arr= array();
        foreach (explode(',', $find) as $v)
          $arr[]= "$col = '$v'";
        $conds_and.= '('. implode(" OR ", $arr). ')';
      }
      else if ($sRangeSep && preg_match($sRangeRegex, $find, $arr)) {
        list(, $v1, $v2)= $arr;
        $conds_and.=
          (($v1!= '')? ("$col >= '$v1'") : '').
          (($v2!= '')? ((($v1!= '')? ' AND ': ''). "$col <= '$v2'") : '');
      }
      else
        $conds_and.= "$col LIKE '%".$this->sqlEsc($find)."%' ";
    }
  }
  if ($conds_and)
    $sWhere.= ($sWhere? " AND " : "WHERE "). substr($conds_and, 4);

  /* SQL запросы */

  /* Подсчет общего кол-ва строк в таблице */
  $nTotal = $this->sqlCount("
    SELECT COUNT($sIndexCol) FROM $sTable
  ");

  /* Выборка данных для вывода */
  $aData= $this->sqlData("
    SELECT SQL_CALC_FOUND_ROWS
    $sFetchCols
    FROM $sTable$sTableXtra
    $sWhere
    $sOrder
    $sLimit
  ");

  /* Подсчет кол-ва отфильтрованных строк в таблице */
  $nFiltered = $this->sqlCount("
    SELECT FOUND_ROWS()
  ");

  /* Выходной массив */
  $aOutput = array(
    "sEcho" => intval($_REQUEST['sEcho']),
    "iTotalRecords" => $nTotal,
    "iTotalDisplayRecords" => $nFiltered,
    "aaData" => is_array($aData)? $this->buildData($aData): array()
  );
  return json_encode($aOutput);
}

/*
 * Подготовка списка для вывода:
 * Параметры:
 *  $aMap [array] - массив значений списка
 * Возвращает:
 *  [array] - подготовленный список
 *  false - в случае ошибки 
 */
static
function buildDataMap($aMap)
{
  if (!is_array($aMap)) return false;
  $arr= array();
  foreach ($aMap as $id=> $title) {
    $arr[]= array('value'=> $id, 'label'=> $title);
  }
  return $arr;
}

/*
 * Вывод списка в формате JSON:
 * Параметры:
 *  $sTable [string] - имя таблицы
 *  $sIdCol [string] - индексируемый столбец
 *  $sTitleCol [string] - текстовый столбец
 *  $sCond [string] - условие фильтрации
 * Возвращает:
 *  [string] - список в формате JSON
 *  false - в случае ошибки 
 */
function outputDataMap($sTable, $sIdCol, $sTitleCol, $sCond='')
{
  return json_encode(self::buildDataMap($this->sqlDataMap(
    $sTable, $sIdCol, $sTitleCol, $sCond
  )));
}

/*
 * ПЕРЕОПРЕДЕЛЯЕМЫЕ МЕТОДЫ:
 */

function sqlEsc($s)
{
  return mysql_real_escape_string($s);
}

/*
 * Подсчет кол-ва строк в таблице (SQL-запросом):
 * Параметры:
 *  $sQuery [string] - строка запроса
 * Возвращает:
 *  [number] - кол-во строк
 *  false - в случае ошибки 
 */
protected
function sqlCount($sQuery)
{
  if (!($h_res= mysql_query($sQuery))) return false;
  return is_array($a_row= mysql_fetch_row($h_res))? $a_row[0]: false;
}

/*
 * Выборка данных из таблицы (SQL-запросом):
 * Параметры:
 *  $sQuery [string] - строка запроса
 * Возвращает:
 *  [array] - массив данных
 *  false - в случае ошибки 
 */
protected
function sqlData($sQuery)
{
  if (!($h_res= mysql_query($sQuery)) || !mysql_num_rows($h_res)) return false;
  $a_res= array();
  while ($a_row= mysql_fetch_assoc($h_res)) {
    if (count($a_row)) $a_res[]= $a_row;
  }
  mysql_free_result($h_res);
  return count($a_res)? $a_res: false;
}

/*
 * Выборка значений списка из БД (SQL-запросом):
 * Параметры:
 *  $sTable [string] - имя таблицы
 *  $sIdCol [string] - индексируемый столбец
 *  $sTitleCol [string] - текстовый столбец
 *  $sCond [string] - условие фильтрации
 * Возвращает:
 *  [array] - значения списка
 *  false - в случае ошибки 
 */
protected
function sqlDataMap($sTable, $sIdCol, $sTitleCol, $sCond='')
{
  $s_qry= "SELECT $sIdCol, $sTitleCol FROM $sTable".
    ($sCond? " WHERE $sCond": '');
  if (!($h_res= mysql_query($s_qry)) || !mysql_num_rows($h_res)) return false;
  $a_res= array();
  while ($a_row= mysql_fetch_row($h_res)) {
    if (count($a_row) == 2) $a_res[$a_row[0]]= $a_row[1];
  }
  mysql_free_result($h_res);
  return count($a_res)? $a_res : false;
}

/*
 * Подготовка данных для вывода (этот метод должен быть определен в наследнике):
 * Параметры:
 *  $aData [array] - данные выбранные из БД (методом 'sqlData')
 * Возвращает:
 *  [array] - подготовленные данные
 */
abstract protected
function buildData($aData);

}

?>

Данный класс поддерживает все типы фильтров для столбцов, используемые в Column Filter:

  • обычный текст;
  • числа и диапазоны чисел;
  • даты и диапазоны дат;
  • списки.

Следует отметить, что диапазоны дат в Column Filter реализуются с помощью Jquery UI, поэтому для их нормальной работы необходимо подключение этого плагина, а также соответствующих стилей и ресурсов. В основном по этой причине (мне не очень нравится этот громоздкий плагин) я и не стал использовать в моем примере фильтр с диапазоном дат, хотя в принципе это возможно – рассмотренный класс поддерживает обработку диапазонов дат.

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

P.S.: На странице примера установлено ограничение на количество одновременных Ajax-запросов к серверу. К сожалению мне пришлось это сделать, чтобы избежать возможных проблем с хабраэффектом (по этой же причине было сокращено количество данных). Если вы испытываете проблемы с отображением таблицы, просто обновите страницу.

Автор: xmeoff

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js