'имя таблицы', * 'alias' => string|void, // alias таблицы для JOIN и SQL колонок * 'keys' => string[], // перечисление ключей * 'properties' => string[], * ] */ public $mainTableConfig = []; public $tables = []; public $columns = []; public $columnsNormalized = []; public $filesDir = ''; // абсолютный путь к каталогу с файлами сущностей (например, изображения), если они есть, служит для удаления файлов при удалении сущностей; каталог с файлами конкретной сущности будет иметь вид FILES_DIR/(id). private $invoker = []; // информация об объекте, вызвавшем данный экземпляр; нужна для предотвращения перекрёстного вызова родительской сущности из дочерней function __construct($arg = []) { $this->invoker = $arg['invoker'] ?? []; if ($this->mainTableConfig) { $main_table = "FROM " . $this->mainTableConfig['name'] . " " . ($this->mainTableConfig['alias'] ?? "") ; array_unshift($this->tables, $main_table); } $this->columnsNormalized = self::normalizeColumns($this->columns); } protected static function normalizeColumns($config) { // не private, чтобы можно было дополнять набор колонок в методов дочерних классов $columns = []; $preserve = array_fill_keys( [ 'sql', 'type', 'JSON', 'explode', 'column_definition' ], true ); foreach ($config as $key => $cfg) { // Определяем следующие свойства: // - имя параметра (строго буквенно-цифровая строка) // - SQL-выражение // - включать ли значение параметра в выдачу для getList(), getSingle() // Возможные варианты: // 'sql' // 'name' => 'sql' // 'name' => [ 'sql' => ... ] // 'sql' => [ ... ] (нет sql) // В name также может содержаться @:, @@:; // name как SQL-выражение может быть заключено `` // Если свойство sql явно не указано, используется name if (!is_array($cfg)) { if (is_numeric($key)) { $name = $cfg; $field = []; } else { $name = $key; $field = [ 'sql' => $cfg ]; } } else { $name = $key; $field = array_intersect_key($cfg, $preserve); // в неизменном виде переносим некоторые ключи } if (preg_match('/(@@?):\s*/', $name, $m)) { $field[ $m[1] ] = TRUE; $name = str_replace($m[0], '', $name); } unset($m); if ( !isset($field['sql']) ) { $field['sql'] = $name; $name = preg_replace('/^.+\.(\w+)$/', '$1', $name); // table.column -> column } if ( !isset($field['type']) ) $field['type'] = FALSE; if (is_array($cfg) AND isset($cfg['related'])) foreach ($cfg['related'] as $keyname => $value) { // Приводим конфигурацию связанных сущностей к следующему виду: // source_key - название ключа - источника данных в основном массиве данных // (оно же - название ключа с верхнего уровня конфигурационного массива, дублируем для удобства) // target_key (string) - название ключа для записи полученных значений // classname (string) - имя класса для получения данных // [via] - одной сущности из данного класса соответствует несколько связанных (увеличивает вложенность соотв. массива данных) // (string) - имя поля соотв. класса // (array) - связь через отдельную таблицу // via.table - имя таблицы // via.columns (string[]) имена колонок со значениями: // via.columns.0 - source_key основных сущностей (соответствующих этому классу) // via.columns.1 - некоторого ключа связанных сущностей; используется его первичный ключ (поиск по getList), если не указано иное // [sort] (string) - сортировка для случая "один ко многим" $tmp = array( 'source_key' => $name, // дублируем для дальнейшего удобства 'target_key' => $keyname, ); if ( ! is_array($value) ) $tmp['classname'] = $value; else $tmp += $value; // 0.1.98 убрана поддержка SQL-выражений в качестве связанной сущности; // потом если сильно понадобится - вернем // if (stripos($value, "WHERE")) { // // sql = SELECT ... FROM table WHERE id IN (?) // preg_match('/\bWHERE\s+(\w+)\b/m', $value, $matches); // $tmp += array( // 'id' => $matches[1], // 'sql' => $value // ); // } $field['related'][] = $tmp; } $columns[$name] = $field; } return $columns; } /** Получает имя главной таблицы. * Требуется для операций записи. В том числе тех, где имя таблицы подставляется в INSERT, поэтому из её декларации нужно убирать alias, если он есть. * Бывает необходимость составлять SQL-код запросов вручную (например, для случая обновления колонки на основании текущего значения, типа "UPDATE ... SET col = col + 1"), для чего доступ к имени главной таблицы требуется в методах дочерних классов, поэтому функция protected, а не private. * @param void необходимые данные берутся из свойства $tables * @return string имя таблицы */ public function getMainTableName() { $sql = trim( reset($this->tables) ); $regexp = '/FROM\s+(`?\w+(\.\w+)*`?)/'; preg_match($regexp, $sql, $matches); return $matches[1]; } /** Получает имя колонки с первичным ключом (обычно - "id") без alias. * Требуется для стандартных операций записи. В том числе для INSERT, где не разрешается использовать alias колонки, поэтому alias нужно убрать. * Бывает необходимость составлять SQL-код запросов вручную (например, для случая обновления колонки на основании текущего значения, типа "UPDATE ... SET col = col + 1"), для чего доступ к имени колонки требуется в методах дочерних классов, поэтому функция protected, а не private. * @param void необходимые данные берутся из свойства $columnsNormalized * @return string имя колонки */ protected function getIDcolumnName() { $sql = reset($this->columnsNormalized)['sql']; $name = preg_replace('/^.*\b(\w+)$/', '$1', trim($sql) ); return $name; } /** * Получает имя параметра, соответствующего уникальному ключу, * для подстановки в where. * Метод getIDcolumnName() возвращает имя колонки в таблице БД. * Это важно, если имя параметра отличается от имени колонки. * Например, 'id' => [ 'sql' => 'table.ID' ]. * @return string */ public function getIDparamName() { reset($this->columnsNormalized); return key($this->columnsNormalized); } function getList($ids) { if (is_string($ids)) $ids = mysql_getcolumn($ids); $list = self::getDataFromIds($ids, $this->tables, $this->columnsNormalized); $list = $this->getRelatedData($list); foreach ($list as &$row) { $row = $this->preProcessData($row); $row = $this->processData($row); } return $list; } function getSingle($id) { if (!$id) return array(); $row = self::getDataFromIds($id, $this->tables, $this->columnsNormalized); if (!$row) return array(); $tmp = $this->getRelatedData(array($row)); // getRelatedData() рассчитан только на список $row = reset($tmp); unset($tmp); $row = $this->preProcessData($row); $row = $this->processData($row); return $row; } private function preProcessData($row) { foreach ($this->columnsNormalized as $name => $cfg) { if (!isset($row[$name])) // может не быть, continue; // если поле только для данных одиночной сущности if (isset($cfg['JSON'])) $row[$name] = json_decode($row[$name], TRUE); elseif (isset($cfg['explode'])) $row[$name] = array_filter( array_map('trim', explode( $cfg['explode'], $row[$name] ) ) ); } return $row; } function processData($row) { return $row; } function findIds( $query = array(), &$total_count = 'NO', // по умолчанию общее количество строк не запрашивается $columns = array(), $tables = array() ) { $config = array( 'columns' => $this->columnsNormalized, 'tables' => array_merge($this->tables, $tables) // (т.к. важно, которая на первой позиции) ); // В отличие от таблиц, колонки приходится склеивать хитро: // array_merge приведет к размножению нижележащих ключей, // а оператор "+" - к потере содержимого массива // (имея, например, ключ sql в обоих наборах, получим не перезапись) // Поэтому вручную делаем + на втором уровне вложенности. foreach ($config['columns'] as $name => $cfg) { if (!isset($cfg['related'])) // нет указаний на связанные сущности continue; foreach ($cfg['related'] as $related_cfg) { // это вообще пока отключили // if (stripos('WHERE', $classname)) // указан не класс, а SQL - пропускаем // continue; $tk = $related_cfg['target_key']; if (!isset($query['where'][$tk])) // в запросе отсутствует соотв. условие - пропускаем continue; $w = $query['where'][$tk]; if (is_array($w)) $w = self::array_filter_recursive($w, 'strlen'); // strlen - см. php.net/array_filter#111091 if (!$w) // непустых элементов в массиве не было - continue; // следовательно, условия по связанной сущности отсутствуют $instance = new $related_cfg['classname']; if (!isset($related_cfg['via'])) { // Обычный случай (связь 1 к 1) $values = $instance->findIds(array('where' => $w)); # Тут, по-хорошему, нужна еще проверка параметров поиска для связанной сущности # на предмет их актуальности: если среди них будут ключи, которых нет среди $columns, # подмассив пройдет проверку на пустоту, но в то же время никакие условия наложены не будут. # В результате от связанной сущности в качестве условия придет # список всех имеющихся идентификаторов, что лишено смысла # (наличие связанной сущности как таковой следует проводить через LEFT JOIN + NOT NULL). } else { if (!is_array($related_cfg['via'])) { // а) связь один ко многим напрямую $field_name = $related_cfg['via']; // С версии 1.5.4 (10.10.2020) в getSpecialData() // передаём не исходный where, а список id, // полученный отдельной операцией, т.к. // getSpecialData() не поддерживает обращение // к связанным сущностям связанных сущностей // $tmp = $instance->getSpecialData( // [ "DISTINCT {*$field_name*}" ], // [ 'where' => $w ] // ); $related_ids = $instance->findIds(['where' => $w]); if ($related_ids) { $tmp = $instance->getSpecialData( [ "DISTINCT {*$field_name*}" ], [ 'where' => [ $instance->getIDparamName() => $related_ids ] ] ); if (function_exists('array_column')) { $values = array_column($tmp, $field_name); } else { foreach ($tmp as $row) $values[] = $row[$field_name]; } unset($tmp); } else { $values = []; } unset($related_ids); } else { // Возможны следующие варианты условий по связанной сущности при связи один ко многим через дополнительную таблицу связей: // 1. Стандартный ассоциативный массив с параметрами поиска. // 2. Плоский массив с числовыми ключами, содержит набор id связанной сущности; позволяет составить условие без обращения к классу связанной сущности, для этого достаточно таблицы связей // 3. TRUE действует как условие наличия хотя бы одной записи в таблице связей, соответствующей конкретной сущности $id_column = $related_cfg['via']['columns'][0]; $list_column = $related_cfg['via']['columns'][1]; $connecting_table = $related_cfg['via']['table']; $sql = " SELECT DISTINCT $id_column FROM $connecting_table "; if (is_array($w)) { if ( ! is_numeric( key($w) ) ) # 1. $related_ids = $instance->findIds( array('where' => $w) ); else # 2. id уже в готовом виде прямо в запросе $related_ids = $w; $sql .= "WHERE $list_column IN (" . mysql_escape($related_ids) . ")"; } else # 3. просто получаем записи, которые вообще есть в таблице связей ; // дополнительные условия не нужны $values = mysql_getcolumn($sql); } } // Обращение через ключ запроса может конфликтовать с явным указанием // (если параметр, через который устанавливается связь, будет указан в запросе явно), // поэтому конструируем конечный SQL. $query[] = ($values) ? "$cfg[sql] IN (" . mysql_escape($values) . ")" : "FALSE" ; } } return self::getIds($config, $query, $total_count); } function find( $query, &$total_count = 'NO', $columns = array(), $tables = array() ) { $ids = $this->findIds( $query, $total_count, $columns, $tables ); return $this->getList($ids); } function findSingle($query) { $ids = $this->findIds($query); if (!$ids) return []; $id = reset($ids); return $this->getSingle($id); } function getSpecialData($custom_columns, $query) { if (!is_array($custom_columns)) { $custom_columns = array($custom_columns); $one_column = TRUE; // результат - одна колонка или несколько } else $one_column = FALSE; // В версии 1.5.4 обнаружилась проблема: // getSpecialData() не поддерживает условия // по связанным сущностям связанных сущностей $parts = self::makeSQLparts($query, $this->tables, $this->columnsNormalized, $custom_columns); $SQL = "SELECT $parts[SELECT]\n"; $SQL .= implode("\n", $parts['FROM']); if (isset($parts['WHERE'])) $SQL .= self::buildWhereSQL($parts['WHERE']); if (isset($parts['ORDER'])) $SQL .= "\nORDER BY $parts[ORDER]"; if (isset($parts['LIMIT'])) $SQL .= "\nLIMIT $parts[LIMIT]"; $fn = ($one_column) ? 'mysql_getcolumn' : 'mysql_gettable'; return $fn($SQL); } function getSpecialDataRow($custom_columns, $query) { $table = $this->getSpecialData($custom_columns, $query); $row = (count($table) > 0) ? reset($table) : []; return $row; } function write($data, $id = FALSE, $mode = FALSE) { $upd = ($id) ? ( is_array($id) ? $id : array( $this->getIDcolumnName() => $id ) ) : FALSE ; return mysql_write_row( $this->getMainTableName(), $data, $upd, $mode ); } /** Запись строго указанного набора полей с возможным преобразованием пустых знач. * @param array $data необработанный массив с данными для записи * @param int|string $id уникальный идентификтор записи (для новой записи пуст) * @param array $fields_cfg перечисление допущенных к записи полей * В простейшем случае это просто плоский массив, где перечислены поля. * Однако каждый из его элементов может принимать и более сложные формы: * а) Ключ - строка, значение - 0 или пустая строка; * Ключ содержит имя поля, которое допускается к записи. Значение будет передано для записи в случае, если поле содержит пустую строку. Частный случай - пустая же строка, если её все-таки нужно отправить на запись вместо NULL, или 0. * б) Ключ - строка, значение - массив, содержащий ключ on_empty. * Ключ элемента обрабатывается так же, как в случае "а", содержимое on_empty - так же, как значение в случае "а". * Поддержка такого варианта записи сделана для возможности использования совместных конфигураций полей форм, которые предназначены не только для этого инструмента (а, к примеру, еще и для проверки корректности заполнения и др.) и потому каждому полю там соответствует массив. * @param bool $drop_empty_values предписывает не допускать к записи поля, содержащие пустую строку. */ function writeRestricted($data, $id, $fields_cfg, $drop_empty_values = FALSE) { // 1. Нормализуем инструкции по обработке полей $normalized = []; foreach ($fields_cfg as $key => $value) { if ( is_numeric($key) ) $normalized[$value] = [ 'on_empty' => NULL ]; else { if ( is_array($value)) $config = $value; else { if (is_callable($value)) $config = [ 'callback' => $value ]; else $config = [ 'on_empty' => $value ]; } if (!isset($config['on_empty'])) $config['on_empty'] = NULL; $normalized[$key] = $config; } } // 2. Проверяем данные $write = []; foreach ($data as $key => $value) { if ( ! isset($normalized[$key]) ) continue; $config = $normalized[$key]; if ( $value === '' ) { if ($drop_empty_values) continue; $value = $config['on_empty']; } // автоматическое преобразование JSON на основе конфигурации if ($this->columnsNormalized[$key]['JSON'] ?? FALSE) { if (!is_null($value)) // NULL в строковый эквивалент при хранении в БД преобразовывать не будем $value = json_encode($value, JSON_UNESCAPED_UNICODE); // http://php.net/json_encode // http://php.net/manual/ru/json.constants.php } if ($config['callback'] ?? FALSE) $value = $config['callback']($value); $write[$key] = $value; } // 3. Пишем return $this->write($write, $id); } function delete($id) { $sql = " DELETE FROM " . $this->getMainTableName() . " WHERE " . $this->getIDcolumnName() . " = " . mysql_escape($id) . " "; mysql_q($sql); if ($this->filesDir) self::deleteDir($this->filesDir . "/$id"); } function deleteDir($dir) { if ( rtrim($dir, '/') == $this->filesDir ) { $classname = get_class($this); // __CLASS__ и get_class() без аргумента возвращают имя родительского класса - _List, а не имя конкретного дочернего trigger_error("$dir is $classname's class root files directory, won't delete it. Probably, empty \$id was passed to delete().", E_USER_WARNING); return FALSE; } // При использовании rm -rf высока цена ошибки, поэтому опишем удаление в явном виде foreach (scandir($dir) as $name) { if ($name == '.' OR $name == '..') continue; $path = "$dir/$name"; if (is_dir($path)) ($this->__FUNCTION__)($path); else unlink($path); } rmdir($dir); } function getRelatedData($rows) { # 1. Отбираем колонки с ключом related $configs = array(); foreach ($this->columnsNormalized as $name => $cfg) if (isset($cfg['related'])) $configs = array_merge($configs, $cfg['related']); # 2. Набираем уникальные значения колонок $source_values = array(); foreach ($configs as $cfg) { $sk = $cfg['source_key']; $v = array(); foreach ($rows as $row) if (isset($row[$sk])) $v[] = $row[$sk]; if ($v) $source_values[$sk] = array_unique($v); } unset($cfg, $sk, $v); # 3. Получаем данные для каждой колонки согласно конфигурации $related_data = array(); // данные связанных сущностей $multiplets = array(); // данные массивов "один ко многим" foreach ($configs as $i => $cfg) { $sk = $cfg['source_key']; if (!isset($source_values[$sk])) continue; $instance = new $cfg['classname']( [ 'invoker' => [ 'classname' => get_class($this), 'listdata' => $rows, ], ] ); if ( ($this->invoker['classname'] ?? '') == $cfg['classname'] AND ! ($cfg['multiple'] ?? FALSE) ) { $tmp = $this->invoker['listdata']; // echo "Not getting the same data again!\n"; } elseif (!isset($cfg['via'])) { $v = $source_values[$sk]; $tmp = $instance->getList( array_unique($v) ); // Чтобы была поддержка массива id, нужно что-то такое: // $tmp = $instance->find( [ // 'where' => [ $instance->getIDColumnName() => array_unique($v) ] // ] ); // Тест - любая JSON-колонка, где в массиве перечислены идентификаторы: // 'JSON' => TRUE, // 'related' => [ // 'some_key' => [ // 'multiple' => TRUE, // 'classname' => 'SomeClass', // ] // ], // В $v это дает вот такое: // Array // ( // [0] => ["3", "1", "4", "2", "5", "6", "10", "12", "11", "7"] // [1] => ["3", "1", "4", "5", "10", "11", "7"] // ) } else { if (!is_array($cfg['via'])) { $v = $source_values[$sk]; $field_name = $cfg['via']; $cond = array( 'where' => array( $field_name => array_unique($v) ), ); if (isset($cfg['where'])) $cond['where'] += $cfg['where']; if (isset($cfg['orderby'])) $cond['orderby'] = $cfg['orderby']; $tmp = array(); foreach ($instance->find($cond) as $id => $row) { if ($cfg['multiple'] ?? true) { $tmp[ $row[$field_name] ] [$id] = $row; } else { $tmp[ $row[$field_name] ] = $row; } } unset($field_name, $conf); } else { // связь через дополнительную таблицу $id_column = $cfg['via']['columns'][0]; $id_values = $source_values[ $cfg['source_key'] ]; $list_column = $cfg['via']['columns'][1]; $connecting_table = $cfg['via']['table']; $sql = " SELECT $id_column AS id, $list_column AS value FROM $connecting_table WHERE $id_column IN (" . mysql_escape($id_values) . ") "; $bonds = mysql_gettable($sql); $v = array(); $m = array(); foreach ($bonds as $row) { $v[] = $row['value']; $m [ $row['id'] ] [] = $row['value']; } $multiplets[$i] = $m; $cond = array( 'where' => array( $instance->getIDcolumnName() => array_unique($v) ) ); if (isset($cfg['orderby'])) $cond['orderby'] = $cfg['orderby']; $tmp = $instance->find($cond); } } // 0.1.98 убрана поддержка SQL-выражений в качестве связанной сущности; // потом если сильно понадобится - вернем // см. также normalizeColumns() // if (!isset($cfg['class'])) { // $cfg['sql'] = str_replace( // '?', // В связи с прекращением в mysql-functions // mysql_escape($v), // поддержки меток вида "?" // $cfg['sql'] // проводим замену вручную // ); // $tmp = mysql_gettable($cfg['sql'], $cfg['id']); // } $related_data[$i] = $tmp; } unset($i, $cfg, $sk); # 4. Раскладываем полученные данные по массивам foreach ($configs as $i => $cfg) { if (!isset($related_data[$i])) continue; $sk = $cfg['source_key']; // название ключа-источника основного массива данных $sv; // значение ключа-источника основного массива данных (заполняется в цикле для каждой записи) $tk = $cfg['target_key']; $r = $related_data[$i]; if ( ! isset($multiplets[$i]) ) { // простой случай: отношение один к одному foreach ($rows as &$row) { $sv = $row[$sk]; $row[$tk] = (isset($r[$sv])) ? $r[$sv] : array(); } } else { // отношение один ко многим - задействуем multiplets $m = $multiplets[$i]; foreach ($rows as &$row) { $sv = $row[$sk]; $row[$tk] = (isset($m[$sv])) ? array_intersect_key( $r, array_fill_keys($m[$sv], TRUE) ) : array() ; } } } return $rows; } private static function getIds($config, $query = array(), &$total_count = NULL) { // $config - [columns, tables, unique_keys] // $query - where, orderby, limit, (произвольные условия под where)] // $total_count - будет перезаписана // Выполняем запрос, возвращаем уникальные ключи // (вдобавок подставляем в запрос параметры // с помощью обычных меток - бывает удобно) $parts = self::makeSQLparts($query, $config['tables'], $config['columns']); // Получаем колонку - уникальный ключ $a = $config['columns']; // PHP 5.3-friendly $b = reset($a); $SQL = "SELECT $b[sql]\n"; unset($a, $b); $SQL .= implode("\n", $parts['FROM']); if (isset($parts['WHERE'])) $SQL .= self::buildWhereSQL($parts['WHERE']); if (isset($parts['ORDER'])) $SQL .= "\nORDER BY $parts[ORDER]"; if (isset($parts['LIMIT'])) $SQL .= "\nLIMIT $parts[LIMIT]"; // $fn = (count($unique_keys) == 1) - 8.04.2015 - пока упразднили // ? 'mysql_getcolumn' // : 'mysql_gettable' ; // $ids = $fn( $ids = mysql_getcolumn( $SQL, FALSE, (isset($query['where']) ? $query['where'] : array() ) ); unset($SQL); // Получаем общее количество записей, если просили // (про COUNT(*) vs SQL_CALC_FOUND_ROWS // см. http://sqlinfo.ru/forum/viewtopic.php?pid=38337). // Вариант с 'NO' - для гибкости при передаче более 3-х аргументов) if (func_num_args() >= 3 AND $total_count !== 'NO') { $total_count = mysql_getcell(" SELECT COUNT(*) " . implode("\n", $parts['FROM']) . self::buildWhereSQL($parts['WHERE'] ?? []) ); } return $ids; } private static function getDataFromIds($ids, $tables, $columns_normalized, $id_column_sql = '') { if (!$ids) return array(); $single_mode = (!is_array($ids)); $columns_for_select = array(); foreach($columns_normalized as $alias => $row) if (isset($row['@@']) OR (!$single_mode AND isset($row['@']))) continue; else $columns_for_select[] = "$row[sql] AS `$alias`"; // ` - обязательно $id_key = ''; if (!$id_column_sql) { reset($columns_normalized); // без reset не работает key $id_key = key($columns_normalized); $where = array( $id_key => $ids ); } else ; // явная передача SQL-выражения для идентификатора пока не реализована $parts = self::makeSQLparts( compact('where'), $tables, $columns_normalized, $columns_for_select ); $sql = " SELECT $parts[SELECT] " . implode("\n", $parts['FROM']) . self::buildWhereSQL($parts['WHERE']) ; $unordered_data = mysql_gettable($sql, $id_key); if ($single_mode) // Одиночная запись - все просто $final_data = reset($unordered_data); else { # Сохраняем порядок, в коротом передали список id $final_data = array(); foreach ($ids as $id) if (isset($unordered_data[$id])) $final_data[$id] = $unordered_data[$id]; } return $final_data; } static function replaceSQLshortcuts($string, $sql_cfg) { // private метод сделать нельзя, т.к. нужно вызывать его из замыкания // Замена меток вида '{*ключ*}' на соотв. SQL-выражение из конфигурации полей $pattern = '/ \{\* (\w+) (?: \s+ (ASC|DESC) )? \*\} /x'; $fn = __FUNCTION__; $callback = function($matches) use ($sql_cfg, $fn) { if (isset($sql_cfg[$matches[1]])) { $out = $sql_cfg[$matches[1]]['sql']; # Замена меток направления сортировки {*^direction*} и {*^direction_opposite*} $straight = (isset($matches[2])) // текущее направление сортировки ? $matches[2] : 'ASC'; $opposite = ($straight == 'DESC') ? 'ASC' : 'DESC'; // противоположное направление сортировки $out = str_replace( [ '{*^direction*}', '{*^direction_opposite*}'], [ $straight, $opposite ], $out, $count // количество проведенных замен ); if (!$count AND isset($matches[2])) // метки не встретились - значит, направление просто нужно оставить так, $out .= " $matches[2]"; // как было именно в $matches; приписывать явное направление нельзя, // т.к. подставляемое выражение может быть составлено с уже указанным направлением // (чтоб не получались вещи типа "... colname DESC DESC") $out = self::$fn($out, $sql_cfg); // для перекрёстных ссылок нужна рекурсия } else $out = $matches[0]; // без изменений return $out; }; return preg_replace_callback($pattern, $callback, $string); } private static function makeSQLparts($query, $tables_cfg, $sql_cfg, $for_select = array()) { $tables = $tables_cfg; // $sql_expressions = self::normalizeColumns($sql_cfg); // до версии 1.96 $sql_expressions = $sql_cfg; // Список таблиц - см. п. 3. (требуется анализ частей WHERE и ORDER BY) // 0. SELECT (если передан список колонок - $for_select) $S = ''; if ($for_select) { $S = implode(",\n", $for_select); $S = self::replaceSQLshortcuts($S, $sql_expressions); } if ($S) $parts['SELECT'] = $S; // 1. WHERE $W = array(); if (isset($query['where'])) { // v.1.3.0: Вместо одного ассоциативного массива с параметрами работаем с несколькими, они действуют как OR. // Если на верхнем уровне встречаются элементы с нечисловыми ключами, то включаем их во все группы. $condition_groups = []; $common = []; foreach ($query['where'] as $name => $value) { if (is_numeric($name)) $condition_groups[$name] = $value; else $common[$name] = $value; } if (!$condition_groups) // если ни одной группы не нашлось - создаем пустую $condition_groups[] = []; if ($common) foreach ($condition_groups as &$value) $value = $value + $common; // включаем общие ассоциативные параметры // Именно в таком порядке: чтобы при конфликте частные параметры группы имели приоритет перед общими unset($value); foreach ($condition_groups as $i => $group) { foreach ($group as $key => $value) { // v.1.5.0: ловим инверсию условия - ключи вида "!name" $key = trim($key); if (substr($key, 0, 1) == '!') { $invert = TRUE; $name = trim(substr($key, 1)); } else { $invert = FALSE; $name = $key; } $cfg = $sql_expressions[$name] ?? NULL; if (is_null($cfg)) continue; $sql = self::paramWhereSQL($cfg, $value, $invert); if ($sql) $W[0][$i][] = $sql; } } } // Произвольные условия foreach ($query as $key => $value) if (is_numeric($key)) $W[] = $value; if ($W) $parts['WHERE'] = $W; // 2. ORDER BY if (isset($query['orderby'])) { // 2.3.1. Ловим метки параметров сортировки из запроса. // Синтаксис: // а) сокращенный: &sort || ... // Например: // &sort || {*PARAM*} // &sort || {*PARAM DESC*} - направление прямо внутри {*...*} // б) полный (со скобками): { &sort || #... } ... // v.1.87 (17.02.2016): полный синтаксис перестали поддерживать, // т.к. закрывающая скобка может совпадать внутри конечного выражения, // а строить PCRE-шаблон для с учетом вложенных скобок пока не умеем :/ $regexp = '/ # открывающая скобка для полного синтаксиса ( \{ \s* )? & (?P \w+) ( \s* \|\| (?P .+ ) # \s* # (?P [^}]+ ) было, пока поддерживался полный синтаксис )? # закрывающая скобка для полного синтаксиса (?(1) \s* \} ) # если была открывающая скобка - ловим закрывающую /x'; $callback = function($matches) use ($query, $sql_expressions) { $out = ''; // Если установлен ключ запроса для сортировки - проверяем его if (isset($query['where'][$matches['orderby_key']])) { $val = $query['where'][$matches['orderby_key']]; // Параметр может иметь строго форму 'имя ASC|DESC' или просто 'имя', // где (имя) должно встречаться в конфигурации колонок - // в таком случае вставляем параметр в неизменном виде, // добавляя спереди '#' - получится '#имя ASC|DESC'. // Потом метка '#имя ASC|DESC' заменится на соответствующее SQL-выражение // с учетом направления! preg_match( '/^(\w+)(?:\s+(DESC|ASC))?$/', $val, $m); if ($m AND isset($sql_expressions[$m[1]]) ) $out = "{*$val*}"; unset($m); } // Если ничего не нашли - возвращаем выражение по умолчанию, если его передали if (!$out) $out = (isset($matches['default'])) ? $matches['default'] : '' ; return $out; }; $O = preg_replace_callback($regexp, $callback, $query['orderby']); unset($regexp, $callback); // 2.3.2. Заменяем метки вида '{*ключ*}' на соотв. SQL-выражение из конфигурации полей $O = self::replaceSQLshortcuts($O, $sql_expressions); } else $O = ''; if ($O) $parts['ORDER'] = $O; // 3. Составляем список таблиц. // JOIN таблиц, колонки которых не участвуют в запросе (условиях или сортировке), // не несет функциональной нагрузки, при этом замедляет выполнение запроса // из-за, собственно, необходимости выполнять JOIN. // Поэтому из переданного списка таблиц включаем в запрос только нужные. $T = array(); // Основная таблица (FROM) нужна всегда, её добавляем безусловно. $T[] = reset($tables); // Остальные таблицы проверяем по вхождению строки вида 'n.', // в частях SELECT, WHERE или ORDER BY // где n - имя или alias таблицы согласно переданным аргументам // Проверка нестрогая, но это и не страшно (в том редком случае, // когда проверка сработает неверно, включение лишней таблицы // не приведет ни к каким серьезным последствиям). // // Также нужно поискать в части JOIN. // Например, после анализа WHERE и ORDER BY запроса // FROM order_items i // JOIN marks m ON t.mark = m.markname // WHERE (m.markalias LIKE 'Suunto%') // таблица `t` не вошла в список. Но т.к. она требуется для связи // с таблицей `m`, её тоже нужно включить в запрос. // То есть, уже ПОСЛЕ составления списка необходимых таблиц // нужно проверить оставшиеся на связь с ними. // 3.4.1. Получим для каждой таблицы ссылку $tables_to_check = array(); foreach(array_slice($tables, 1) as $sql) { // все таблицы, кроме первой // Все таблицы, кроме первой - это точно JOIN // интересующий отрезок там в конце - перед последним ON или USING // (запросы без ON считаем дикостью и код под них не пишем) // (запросы типа FROM table1, table2 не поддерживаем - неохота, // пусть через JOIN переписывают). // В начало рег. выражения ставим жадную конструкцию, // чтобы она поглотила всё возможное, и совпадение захватывало // самый последний по счету фрагмент с таблицей и ON; // это важно для JOIN с подзапросами, внутри которых есть свои вложенные JOIN. preg_match( '/ .+ `?\b (?P \w+(\.\w+)* ) \b`? \s+ (ON|USING) .*? $/sx', trim($sql), $matches ); $regexp = "/\b$matches[ref]\./"; $tables_to_check[$regexp] = $sql; } unset($matches); // 3.4.2. Ищем в частях SELECT, WHERE и ORDER BY // Т.к. ищем везде единообразно, для удобства // просто склеим все исследуемые строки в одну, где и будем искать. $haystack = "$S " . self::buildWhereSQL($W) . " $O"; foreach ($tables_to_check as $regexp => $sql) { if (preg_match($regexp, $haystack)) { $T[] = $sql; unset($tables_to_check[$regexp]); // исключаем таблицу из списка для проверки } } unset($haystack); // 3.4.3. Теперь ищем среди найденных таблиц ссылки на остальные // Поиск нужно проводить заново после каждого пополнения списка, // т.к. каждая новая итерация может выявить необходимость включения // других таблиц (в зависимости от того, через сколько звеньев JOIN // они связаны) do { $list_has_grown = FALSE; // Таблицы в JOIN должны следовать строго в порядке их указания; // найденную таблицу вставляем в список ПЕРЕД той, которая // на нее ссылается. foreach ($tables_to_check as $regexp => $sql) { foreach ($T as $n => $table) { if (preg_match($regexp, $table)) { // вставляем найденную таблицу ДО той, которая на нее ссылается $T = array_merge( array_slice($T, 0, $n), array( $sql ), array_slice($T, $n) ); unset($tables_to_check[$regexp]); $list_has_grown = TRUE; break; // По текущей таблице поиск прекращаем } } } } while ($tables_to_check AND $list_has_grown); unset($list_has_grown); unset($tables_to_check); if ($T) $parts['FROM'] = $T; // 4. LIMIT $L = FALSE; if (isset($query['limit']) AND $query['limit']) { $q = $query['limit']; if (is_array($q)) { if (isset($q['page']) AND isset($q['per_page'])) { // строго указан каждый параметр $L = array_map('intval', $q); } else { // Параметры указаны в виде [ 'p', count ] // или [ 'p', ['N',default,max], offset, tail ] // // имя переменной для номера страницы - в $q[0], // для количества записей на странице - в $q[1][0] // для offset по страницам - в $q[2] // выдать весь список с p по offset или только последние N записей - в $q[3] (для «Показать еще» при догрузке по AJAX) $L['page'] = isset($query['where'][$q[0]]) ? intval($query['where'][$q[0]]) : 1; if (isset($q[2]) AND isset($query['where'][$q[2]])) // offset по страницам $L['offset'] = intval($query['where'][$q[2]]); else $L['offset'] = 0; if (!is_array($q[1])) $L['per_page'] = $q[1]; else { $L['per_page'] = isset($query['where'][$q[1][0]]) ? intval($query['where'][$q[1][0]]) : $q[1][1]; if (isset($q[1][2])) // проверка на max $L['per_page'] = min($L['per_page'], $q[1][2]); } $L['tail'] = ( isset($q[3]) AND $q[3] ); } } elseif (is_numeric(trim($q))) $L = array( 'page' => 1, 'per_page' => $q, 'offset' => 0, 'tail' => FALSE ); elseif (preg_match('/^(\d+)(\s*,\s*\d+)?$/', trim($q), $matches)) $L = $q; // передали LIMIT в явном виде unset($q); } if ($L) { if (is_string($L)) $parts['LIMIT'] = $L; else { if ($L['page'] < 1) { $L['page'] = 1; $msg = "Negative page number value received, " . "probably some hacker's attempt. " . "Original query:\n" . "
" . print_r($query, 1) . "
"; trigger_error($msg, E_USER_WARNING); unset($msg); } if (!isset($L['tail'])) $L['tail'] = FALSE ; if (!isset($L['offset'])) $L['offset'] = 0; if (!$L['tail']) { // tail: показываем только последнюю страницу из page+offset $from = ($L['page'] - 1) * $L['per_page']; // арифметические операции - неявный intval для безопасности $length = (1 + $L['offset']) * intval($L['per_page']); } else { $from = ($L['page'] - 1 + $L['offset']) * $L['per_page']; $length = intval($L['per_page']); } $parts['LIMIT'] = "$from, $length"; } } return $parts; } /** * Получает SQL-выражение для конкретного указанного параметра в массиве where. * * @param array $cfg конфигурация параметра * @param string|array $value значение параметра * @param bool $invert инвертировать ли действие параметра * @return string */ private static function paramWhereSQL($cfg, $value, $invert) { // пустые значения параметров игнорируем if (is_array($value)) { // удаляем из массива пустые значения; $value = array_filter($value, 'strlen'); // максимальная вложенность - случаи типа [min,max] if (!$value) // так что рекурсивная проверка не требуется return ''; // strlen передается в качестве callback, // чтобы исключить пустые строки, но оставить нули; // см. php.net/array_filter#111091 } elseif (!strlen($value)) return ''; // SQL-выражение параметра нужно заключать в скобки, // т.к. приоритет операторов внутри выражения может быть // ниже приоритета самого сравнения. if (!$cfg['type']) { if (!is_callable($cfg['sql'])) { $sql = "($cfg[sql]) "; // обычная ситуация if (is_array($value)) { $operator = (!$invert ? "" : "NOT ") . "IN"; $sql .= "$operator (" . mysql_escape($value) . ")"; } else { $operator = (!$invert ? "" : "!") . "="; $sql .= "$operator " . mysql_escape($value); } } else { $sql = '(' . $cfg['sql']($value) . ')'; } } elseif ($cfg['type'] == 'on-off') { $sql = "($cfg[sql])"; $on = !empty($value); if (!$on OR $invert) $sql .= " = FALSE"; // инвертированная форма, скорее всего, будет работать медленнее прямой } elseif (preg_match('/([\[\(])(\w*),(\w*)([\]\)])/', $cfg['type'], $matches)) { $tmpsql = []; // тут части нужно вручную сшивать через AND if (is_array($value)) { // Стандартный случай: передали массив с ключами min,max $min_key = $matches[2]; if (isset($value[$min_key])) { // проверять на пустоту не надо, т.к. уже проверили выше с помощью strlen $sign = ($matches[1] == '[') ? ( !$invert ? ">=" : "<" ) // в нормально режиме нестрогий интервал : ( !$invert ? '>' : "=<" ) // в нормально режиме строгий интервал ; $tmpsql[] = "($cfg[sql]) $sign " . mysql_escape($value[$min_key]); } $max_key = $matches[3]; if (isset($value[$max_key])) { $sign = ($matches[4] == ']') ? ( !$invert ? "<=" : ">" ) : ( !$invert ? '<' : "=<" ) ; $tmpsql[] = "($cfg[sql]) $sign " . mysql_escape($value[$max_key]); } // С v.1.4.5 поддерживаем также простое перечисление массивом // Определяем его по числовым ключам $with_numkeys = array_filter( $value, function($k) { return is_numeric($k); }, ARRAY_FILTER_USE_KEY ); if ($with_numkeys) { $tmpcfg = $cfg; $tmpcfg['type'] = ''; $tmpsql[] = (__CLASS__. '::' . __FUNCTION__)($tmpcfg, $with_numkeys, $invert); // $tmpsql[] = "($cfg[sql]) IN (" . mysql_escape($with_numkeys) . ")"; } unset($with_numkeys); } else { // Упрощенный случай: передали скалярную величину $tmpcfg = $cfg; $tmpcfg['type'] = ''; $tmpsql[] = (__CLASS__. '::' . __FUNCTION__)($tmpcfg, $value, $invert); // $tmpsql[] = "($cfg[sql]) = " . mysql_escape($value); - старое } if ($tmpsql) $sql = implode( ( !$invert ? ' AND ' : ' OR ' ), $tmpsql); unset($tmpsql); } elseif (strpos($cfg['type'], 'LIKE') !== FALSE) { // здесь может быть и массив, так что вместо простого IN (...) // нужно объединять случаи вручную через OR $tmp = (is_array($value)) ? $value : array($value) ; $tmp2 = array(); foreach ($tmp as $v) { // Экранируем (вручную) символы % и _ внутри аргумента: // mysql_real_escape_string не экранирует % и _, // поэтому возможна инъекция со стороны пользователя. // (кроме случая, когда тип поля указан как "голый" LIKE - // без % по краям - использование LIKE в такой форме // имеет смысл только в случае, если пользователю-оператору // веб-формы предоставляется возможность использовать // мета-символы напрямую). if (strpos($cfg['type'], '%') !== FALSE) $v = str_replace( array('%', '_'), array('\%', '\_'), $v ); // Не забывать, что % и _ нужно экранировать только // при использовании оператора LIKE, причем в правой его части. // Для всех других случаев (в т.ч. использования в INSERT, // остальных частях SELECT и пр., даже в левой части того же LIKE) // экранирование не нужно! $v = str_replace('LIKE', $v, $cfg['type']); // получится '%$value%' $tmp2[] = "($cfg[sql]) " . ( !$invert ? "" : "NOT " ) . "LIKE " . mysql_escape($v) ; // mysql_escape - в самом конце, иначе % от LIKE не войдет в кавычки } $sql = implode( ( !$invert ? " OR " : " AND " ), $tmp2); unset($tmp, $tmp2); } else { // что-то странное - кинем notice trigger_error( "Incorrect type '$cfg[type]' specified for '$name' column.", E_USER_NOTICE ); return ''; } return $sql; } private static function array_filter_recursive($input, $callback = FALSE) { # derived from http://php.net/manual/en/function.array-filter.php # http://stackoverflow.com/questions/8311074/how-to-call-the-current-anonymous-function-in-php foreach ($input as $key => &$value) { if (is_array($value)) { $fn = __FUNCTION__ ; $value = self::$fn($value, $callback); } elseif (!$callback($value)) unset($input[$key]); } return $input; } /** * Строит часть WHERE на основании массива с условиями. * * Алгоритм такой: * - условия, перечисленные на верхнем уровне, объединяются через AND * - если элемент верхнего уровня - строка, то он используется как есть * - если же это массив, то его элементы объединяются через OR * * Т.е. массив вида * * [ * "a = 1", * [ * [ "b = 5", "c = 10" ], * [ "b = 100", "c = 200" ], * ] * * даст SQL "(a = 1) AND ( (b = 5 AND c = 10) OR (b = 100 AND c = 200) )". * * Каждую часть заключаем в круглые скобки для ограничения действия операторов AND и OR, которые могут там встретиться. * * @param array $where * @return string */ private static function buildWhereSQL($where) { $sql = ''; foreach ($where as $value) { $sql .= "("; if (!is_array($value)) $sql .= $value; else { foreach ($value as $v) { $sql .= "("; $sql .= (!is_array($v)) ? $v : "( " . implode(") AND (", $v) . " )"; $sql .= ") OR "; } $sql = mb_substr($sql, 0, -1*mb_strlen(' OR ') ); } $sql .=") AND "; } if ($sql) { $sql = mb_substr($sql, 0, -1*mb_strlen(' AND ') ); $sql = " WHERE $sql "; } return $sql; } function generateMainTableSQL($if_not_exists = true) { if (!$this->mainTableConfig) { $msg = "mainTableConfig is empty, can't generate main table's SQL."; trigger_error($msg, E_USER_WARNING); return ''; } $sql = "CREATE TABLE "; if ($if_not_exists) { $sql .= "IF NOT EXISTS "; } $sql .= $this->mainTableConfig['name'] . " (\n"; $create_definitions = []; foreach ($this->columnsNormalized as $cfg) { if (!isset($cfg['column_definition'])) { continue; } preg_match('/^`?(\w+\.)?(\w+)`?/', $cfg['sql'], $matches); $column_name = $matches[2]; $create_definitions[] = $column_name . " " . $cfg['column_definition']; } if (!$create_definitions) { $msg = "No column config with 'column_defition' parameter found, " . "can't generate main table SQL."; trigger_error($msg, E_USER_WARNING); return ''; } $sql .= " " . implode(",\n ", $create_definitions); if ($this->mainTableConfig['keys'] ?? false) { $sql .= ",\n " . implode(",\n ", $this->mainTableConfig['keys']); } $sql .= "\n)"; foreach ($this->mainTableConfig['properties'] ?? [] as $k => $v) { $sql .= " "; $sql .= (is_numeric($k)) ? $v : "$k = $v" ; } return $sql; } }