'(?:TINY|SMALL|MEDIUM||BIG)INT | DECIMAL\s*\(\d+\s*\)', 'float' => 'FLOAT | DOUBLE | DECIMAL\s*\(\d+\s*,\s*\d+\s*\)', 'json' => 'JSON', 'bool' => '', // устанавливается только вручную // 'datetime' => 'TIMESTAMP|DATETIME', 'datetime' => '', ]; private const DATE_FORMAT_FOR_MYSQL = 'Y-m-d H:i:s'; /** * @var array|void = [ * 'name' => 'имя таблицы', * 'alias' => string|void, // alias таблицы для JOIN и SQL колонок * 'keys' => string[], // перечисление ключей * 'properties' => string[], * ] */ public $mainTableConfig = []; public $tables = []; // v.1.8.0: по факту как public свойство не нужно; // храним для совместимости с дочерними классами /** * @var = [ * 'identifier' => 'alias or name', * 'sql' => 'original SQL with no GROUPING keyword', * 'grouping' => true|void, * 'regexp' => '', * ] */ private $normalizedTableDeclaration; /** @var = [ self::$normalizedTableDeclaration ] */ private $tablesNormalized; // c v.1.8.0 /** @var = [ self::$normalizedTableDeclaration ] */ private $tablesRequireGroupBy; /** * @var = [ * 'type' => '', * 'sql' => '', * 'having_instead_of_where' => bool|void, * 'column_definition' => '', * 'value_type' => string, * 'JSON' => bool, * 'explode' => bool, * ] */ private $normalizedColumnDeclaration; /** @var = [ self::$normalizedColumnDeclaration ] */ public $columns = []; /** @var = [ self::$normalizedColumnDeclaration ] */ public $columnsNormalized = []; public $filesDir = ''; // абсолютный путь к каталогу с файлами сущностей (например, изображения), если они есть, служит для удаления файлов при удалении сущностей; каталог с файлами конкретной сущности будет иметь вид FILES_DIR/(id). private $invoker = []; // информация об объекте, вызвавшем данный экземпляр; нужна для предотвращения перекрёстного вызова родительской сущности из дочерней /** @var string[] */ private $FROMDeclaration; /** @var string */ private $groupByDeclaration; /** @var string[] */ private $HavingDeclaration; /** @var string */ private $orderByDeclaration; /** @var string */ private $limitDeclaration; // Структура $itemDeclaration дочерних классов не срабатывает // как ссылка, указанная здесь, в родительском в виде // 'where' => static::$itemDeclaration // https://github.com/klesun/deep-assoc-completion/issues/194 private $itemDeclaration; /** @var = [ * 'where' => [], * 'orderby' => 'expression DESC, {*field*} ASC|DESC, &GET_parameter', * 'limit' => int|string|array * ] */ private $queryParamsDeclaration; function __construct($arg = []) { $this->tablesNormalized = self::normalizeTables( $this->tables, $this->mainTableConfig ); $this->tablesRequireGroupBy = self::listTablesRequireGroupBy( $this->tablesNormalized ); $this->columnsNormalized = self::normalizeColumns( $this->columns, $this->tablesRequireGroupBy ); $this->invoker = $arg['invoker'] ?? []; } private static function normalizeTables($tables, $main_table_config) { if ($main_table_config) { $normalized[] = self::normalizeMainTableFromConfig($main_table_config); } else { $main_table_sql = $tables[0]; $identifier = self::extractMainTableIdentifierFromSQL($main_table_sql); $normalized[] = [ 'identifier' => $identifier, 'regexp' => self::makeRegexpForTableSearchByIdentifier($identifier), 'sql' => $main_table_sql ]; $tables = array_slice($tables, 1); } foreach ($tables as $sql) { $item = self::normalizeCommonTableItem($sql); $normalized[] = $item; } return $normalized ?? []; } /** @param $tables_normalized = self::$tablesNormalized */ private static function listTablesRequireGroupBy($tables_normalized) { // Ищем таблицы, которые включают группировку. // Не только имеющие флаг grouping, но и связанные с ними через JOIN $in_keys = array_combine( array_column($tables_normalized, 'identifier'), $tables_normalized ); $found = array_filter( $in_keys, function ($config) { return $config['grouping'] ?? false; } ); if (!$found) { return []; } // Каждую найденную таблицу ищем в SQL оставшихся $look_for = $found; $look_in = array_diff_key($in_keys, $found); do { $newly_found = []; foreach ($look_in as $in => $config) { foreach ($look_for as $for) { if (self::isTableInvolvedInSQL($config['sql'], $for)) { $newly_found[$in] = $config; // При первом же упоминании становится понятно, // что таблицу нужно внести в список. // Другие упоминания не ищем, переходим к следующей. continue 2; } } } $found = array_merge($newly_found, $found); $look_for = $newly_found; $look_in = array_diff_key($look_in, $look_for); } while ($look_for); return array_values($found); } /** * @param $config = self::$mainTableConfig * @return = self::$normalizedTableDeclaration */ private static function normalizeMainTableFromConfig($config) { if ($config) { $identifier = ( ($config['alias'] ?? '') ?: $config['name']); return [ 'identifier' => $identifier, 'regexp' => self::makeRegexpForTableSearchByIdentifier($identifier), 'sql' => self::makeSQLfromMainTableConfig($config) ]; } else { return []; } } private static function extractMainTableIdentifierFromSQL($sql) { // Варианты: // FROM table // FROM `database.table` // FROM table AS alias // FROM table alias preg_match( '/(\w+)`?$/', trim($sql), $matches ); return $matches[1]; } private static function makeSQLfromMainTableConfig($cfg) :string { return "FROM $cfg[name]" . (isset($cfg['alias']) ? " $cfg[alias]" : "") ; } /** @return = self::$normalizedTableDeclaration */ private static function normalizeCommonTableItem($sql) { $sql = trim($sql); $identifier = self::extractTableIdentifierFromJOIN($sql); $regexp = self::makeRegexpForTableSearchByIdentifier($identifier); $grouping = false; $grouping_keyword = 'GROUPING'; if (strpos($sql, $grouping_keyword) === 0 ) { $sql = ltrim( substr($sql, mb_strlen($grouping_keyword) ) ); $grouping = true; } $item = compact('identifier', 'regexp', 'sql'); if ($grouping) { $item += compact('grouping'); } return $item; } /** @return string|bool */ public static function extractTableIdentifierFromJOIN(string $join_sql) { // Все таблицы, кроме первой - это точно JOIN. // Интересующий отрезок там в конце - перед последним ON или USING. // (Запросы типа FROM table1, table2 не поддерживаем - неохота, // пусть через JOIN переписывают.) // С версии 1.9.6 специальным образом обрабатываем JSON_TABLE preg_match( '/\b JSON_TABLE\( .+ (?P \w+ )$/six', trim($join_sql), $matches ); if ($matches['ref'] ?? false) { return $matches['ref']; } // В начало рег. выражения ставим жадную конструкцию, // чтобы она поглотила всё возможное и совпадение захватывало // самый последний по счету фрагмент с таблицей и ON; // это важно для JOIN с подзапросами, внутри которых есть свои вложенные JOIN. preg_match( '/ .+ `?\b (?P \w+(\.\w+)* ) \b`? \s+ (ON|USING) .*? $/sx', trim($join_sql), $matches ); return $matches['ref'] ?? false; } /** Получает имя главной таблицы. * Требуется для операций записи. В том числе тех, где имя таблицы подставляется в INSERT, поэтому из её декларации нужно убирать alias, если он есть. * Бывает необходимость составлять SQL-код запросов вручную (например, для случая обновления колонки на основании текущего значения, типа "UPDATE ... SET col = col + 1"), для чего доступ к имени главной таблицы требуется в методах дочерних классов, поэтому функция protected, а не private. */ public function getMainTableName() :string { if ( ! ($name = $this->mainTableConfig['name'] ?? '')) { // Нужно именно имя, а не имя либо псевдоним, // достаём его непосредственно из SQL. $sql = trim( reset($this->tablesNormalized)['sql'] ); $regexp = '/FROM\s+(`?\w+(\.\w+)*`?)/'; preg_match($regexp, $sql, $matches); $name = $matches[1]; } return $name; } /** * @return array = [ self::$normalizedColumnDeclaration ] */ protected static function normalizeColumns($columns_config, $tables_require_group_by) { // Не private, чтобы можно было дополнять набор колонок // в методах дочерних классов. $columns = []; $preserve = array_fill_keys( [ 'sql', 'type', 'JSON', 'explode', 'column_definition', 'value_type' ], true ); foreach ($columns_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 (self::fieldRequiresGroupBy($field, $tables_require_group_by)) { // Очень долго, до 1 мс на длинных списках полей $field['having_instead_of_where'] = true; } 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; } } if (isset($field['value_type'])) { if ($field['value_type']) { if (!in_array($field['value_type'], array_keys(self::VALUE_TYPES))) { $msg = "Unknown value_type '$field[value_type]' in config " . print_r($field, 1) . "\n"; trigger_error($msg, E_USER_WARNING); } } } elseif ($field['column_definition'] ?? false) { $field['value_type'] = self::defineValueTypeFromSQL($field['column_definition']); } $columns[$name] = $field; } return $columns; } private static function fieldRequiresGroupBy($column_cfg, $tables_require_group_by) :bool { foreach ($tables_require_group_by as $table_cfg) { if (self::isTableInvolvedInSQL($column_cfg['sql'], $table_cfg)) { return true; } } return false; // Здесь глубокий поиск с помощью recognizeTablesInvolvedInSQL() // не нужен, поскольку список таблиц, нуждающихся в группировке, // составлен с учетом их связей с другими таблицами. // Достаточно просто проверить совпадение с одной из них. // До v.1.8.1 (27.11.2021) // return !empty(self::recognizeTablesInvolvedInSQL( // $column_cfg['sql'], // $tables_require_group_by // )); } /** Получает имя колонки с первичным ключом (обычно - "id") без alias. * Требуется для стандартных операций записи. В том числе для INSERT, где не разрешается использовать alias колонки, поэтому alias нужно убрать. * Бывает необходимость составлять SQL-код запросов вручную (например, для случая обновления колонки на основании текущего значения, типа "UPDATE ... SET col = col + 1"), для чего доступ к имени колонки требуется в методах дочерних классов, поэтому функция protected, а не private. * @param void необходимые данные берутся из свойства $columnsNormalized * @return string имя колонки */ protected function getIDcolumnName() { preg_match( '/^.*\b(\w+)$/', trim($this->getIDcolumnConfig()['sql']), $matches ); return $matches[1]; } protected function getIDcolumnConfig() { return reset($this->columnsNormalized); } /** * Получает имя параметра, соответствующего уникальному ключу, * для подстановки в where. * Метод getIDcolumnName() возвращает имя колонки в таблице БД. * Это важно, если имя параметра отличается от имени колонки. * Например, 'id' => [ 'sql' => 'table.ID' ]. * @return string */ public function getIDparamName() { reset($this->columnsNormalized); return key($this->columnsNormalized); } /** @return array = [ static::$itemDeclaration ] */ function getList($ids) { // 1.9.0: Убрал по соображениям безопасности. // Неосторожный вызов может пропустить SQL-инъекцию. // Нужно пользоваться find([$sql]) // if (is_string($ids)) // $ids = mysql_getcolumn($ids); $list = self::getDataFromIds($ids, $this->tablesNormalized, $this->columnsNormalized); // $list = $this->appendRelatedDataTo($list); foreach ($list as &$row) { $row = $this->preProcessData($row); $row = $this->processData($row); } return $list; } /** @return array = static::$itemDeclaration */ function getSingle($id) { if (!$id) return array(); $row = self::getDataFromIds($id, $this->tablesNormalized, $this->columnsNormalized); if (!$row) return array(); $data = [ $id => $row ]; $this->appendRelatedDataTo($data); $single = $this->preProcessData($data[$id]); $single = $this->processData($single); return $single; } private function preProcessData($row) { foreach ($this->columnsNormalized as $name => $cfg) { if (!isset($row[$name])) // может не быть, continue; // если поле только для данных одиночной сущности if ($cfg['value_type'] ?? false) { $row[$name] = self::bringValueToType($cfg['value_type'], $row[$name]); } else { // использовалось до введения value_type 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; } /** * @param array $query = self::$queryParamsDeclaration * @param mixed $total_count * @return array */ function findIds( $query = array(), &$total_count = 'NO' // по умолчанию общее количество строк не запрашивается ) { $config = [ 'columns' => $this->columnsNormalized, 'tables' => $this->tablesNormalized ]; $ids = self::getIds($config, $query, $total_count); $cfg = $this->getIDcolumnConfig(); if ($cfg['value_type'] ?? false) { foreach ($ids as &$id) { $id = self::bringValueToType($cfg['value_type'], $id); } } return $ids; } function findId($query = []) { $ids = $this->findIds(['limit' => 1] + $query); return ($ids) ? reset($ids) : null; } private static function appendParamsOfRelatedToQuery(&$query, $config) { foreach ($config['columns'] as $name => $cfg) { if (!isset($cfg['related'])) { // нет указаний на связанные сущности continue; } foreach ($cfg['related'] as $related_cfg) { if ($sql = self::makeQueryConditionFromRelatedCfg( $cfg['sql'], $related_cfg, $query )) { $query[] = $sql; } } } } /** @return string|bool */ private static function makeQueryConditionFromRelatedCfg( $column_sql, $related_cfg, $query ) { $tk = $related_cfg['target_key']; if (!isset($query['where'][$tk])) { // В запросе нет соотв. параметра - пропускаем. return false; } $w = $query['where'][$tk]; if (is_array($w)) { $w = self::dropEmptyValuesRecursive($w); if (!$w) { // непустых элементов в массиве не было - // следовательно, условия по связанной сущности отсутствуют return false; } } if (isset($related_cfg['where'])) { $w = $related_cfg['where'] + $w; } /** @var _List */ $instance = new $related_cfg['classname']; if (!isset($related_cfg['via'])) { // Обычный случай (связь 1 к 1) $related_sql = $instance->findIdsSQL(['where' => $w]); # Тут, по-хорошему, нужна еще проверка параметров поиска для связанной сущности # на предмет их актуальности: если среди них будут ключи, которых нет среди $columns, # подмассив пройдет проверку на пустоту, но в то же время никакие условия наложены не будут. # В результате от связанной сущности в качестве условия придет # список всех имеющихся идентификаторов, что лишено смысла # (наличие связанной сущности как таковой следует проводить через LEFT JOIN + NOT NULL). } else { if (!is_array($related_cfg['via'])) { // а) связь один ко многим напрямую $field_name = $related_cfg['via']; if (is_array($w)) { $q = ['where' => $w]; } else { $q = []; if ($w == true) { // v.1.7.2 (25.10.2021) // Специальный случай: простое условие // на существование связанной сущности. // (Пока только для случая один ко многим; // когда будет пример для 1 к 1 многим - см. // предыдущую часть if) } else { // Здесь должно быть простое условие // на отсутствие связанной сущности. // Однако вводить его пока не стал. // Можно запутаться в разнице между [], // который даст выборку без ограничений, // и NULL/FALSE/любой пустой скалярной величиной, // (empty вернёт true), для которой результат будет // прямо противоположным. $not = true; die("Not supported yet."); } } $related_sql = $instance->getSpecialDataSQL( ["DISTINCT {*$field_name*}"], $q ); unset($q); } 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']; $related_sql = " SELECT DISTINCT $id_column FROM $connecting_table "; if (is_array($w)) { if (!is_numeric(key($w))) { $s = $instance->findIdsSQL(['where' => $w]); } else { // id уже в готовом виде прямо в запросе $s = mysql_escape($w); } $related_sql .= "WHERE $list_column IN ( $s )"; unset($s); } else { // тут дополнительные условия не нужны; // просто получаем записи, которые вообще есть в таблице связей } } } return "$column_sql " . (($not ?? false) ? "NOT" : "") . " IN ( $related_sql )"; // до 23.11.2021 // // Обращение через ключ запроса может конфликтовать с явным указанием // // (если параметр, через который устанавливается связь, будет указан в запросе явно), // // поэтому конструируем конечный SQL. // return ($values) // ? "$column_sql IN (" . mysql_escape($values) . ")" // : "FALSE" ; } /** @param array $query = self::$queryParamsDeclaration */ function findIdsSQL( $query = array() // $columns = array(), // $tables = array() ) { // Здесь без дополнительных $columns и $tables: они пока нигде не используются. $config = [ 'columns' => $this->columnsNormalized, 'tables' => $this->tablesNormalized ]; return self::getIdsSQL($config, $query); } /** * @param array $query = self::$queryParamsDeclaration * @return array = [ static::$itemDeclaration ] */ function find( $query, &$total_count = 'NO', $columns = array(), $tables = array() ) { $ids = $this->findIds( $query, $total_count, $columns, $tables ); return $this->getList($ids); } /** * @param array $query = self::$queryParamsDeclaration * @return array = static::$itemDeclaration */ function findSingle($query) { $query['limit'] = 1; $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; } $fn = ($one_column) ? 'mysql_getcolumn' : 'mysql_gettable'; return $fn( $this->getSpecialDataSQL($custom_columns, $query) ); } public function getSpecialDataSQL($custom_columns, $query) { self::appendParamsOfRelatedToQuery( $query, [ 'columns' => $this->columnsNormalized, 'tables' => $this->tablesNormalized ] ); $parts = self::makeSQLparts( $query, $this->tablesNormalized, $this->columnsNormalized, $custom_columns ); return "SELECT $parts[SELECT]\n" . self::buildFromSQL($parts['FROM']) . self::buildWhereSQL($parts['WHERE'] ?? false) . self::buildGroupBySQL($parts['GROUP'] ?? false) . self::buildHavingSQL($parts['HAVING'] ?? false) . self::buildOrderBySQL($parts['ORDER'] ?? false) . self::buildLimitSQL($parts['LIMIT'] ?? false); } function getSpecialDataRow($custom_columns, $query) { $table = $this->getSpecialData($custom_columns, $query); $row = (count($table) > 0) ? reset($table) : []; return $row; } function getSpecialDataCell($custom_column, $query) { $row = $this->getSpecialDataRow([ $custom_column ], $query); return ($row) ? reset($row) : NULL; } 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 ); } function insertOnDuplicateKeyUpdateAndReturnId($data, $unique_keys) { $id = $this->write($data, $unique_keys, 'DUPLICATE'); if (!$id) { foreach ($unique_keys as $k => $v) { if (!is_numeric($k)) { $key = $k; $value = $v; } else { $key = $v; $value = $data[$key]; } $parts[] = "`$key` = " . mysql_escape($value); } $sql = implode(" AND ", $parts); $id = $this->findId([$sql]); } return $id; } /** Запись строго указанного набора полей с возможным преобразованием пустых знач. * @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; } if (!file_exists($dir)) { 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 appendRelatedDataTo(&$rows) { # 1. Отбираем колонки с ключом related $configs = array_column($this->columnsNormalized, 'related'); $configs = array_merge_recursive(...$configs); // снижаем вложенность # 2. Набираем уникальные значения колонок $source_values = self::getRelatedColumnValues($configs, $rows); # 3. Получаем данные для каждой колонки согласно конфигурации $related_data = array(); // данные связанных сущностей $multiplets = array(); // данные массивов "один ко многим" foreach ($configs as $i => $cfg) { $sk = $cfg['source_key']; if (!isset($source_values[$sk])) { continue; } $v = $source_values[$sk]; if ( ($this->invoker['classname'] ?? '') == $cfg['classname'] ) { // Условие на !multiple убрано, // т.к. "инициатором" может выступать // как сущность, находящаяся в иерархии один ко многим // внизу, так и сущность, находящаяся вверху. // Данные присоединяем здесь, а не в конце вторым проходом, // чтобы они уже были доступны для подчиненных сущностей. self::appendRelatedDataViaReference( $rows, $cfg, $this->invoker['listdata'] ); } else { $instance = new $cfg['classname']( [ 'invoker' => [ 'classname' => get_class($this), 'listdata' => &$rows, ], ] ); if (!isset($cfg['via'])) { $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'])) { $tmp = self::getRelatedDataViaStraightValues( $instance, $cfg, $v ); } else { list( 'data' => $tmp, 'multiplets' => $multiplet ) = self::getRelatedDataViaBondsTable( $instance, $cfg, $v ); } } if ( ! isset($multiplet) ) { // Простой случай: отношение один к одному. // Присвоение по ссылке в данном случае // ни на что не влияет, т.к. переменная $r локальная. // Используем его, чтобы задействовать уже имеющийся метод. self::appendRelatedDataViaReference( $rows, $cfg, $tmp ); } else { // отношение один ко многим $tk = $cfg['target_key']; foreach ($rows as &$row) { $sv = $row[$sk]; $row[$tk] = (isset($multiplet[$sv])) ? array_intersect_key( $tmp, array_fill_keys($multiplet[$sv], TRUE) ) : array() ; } unset($multiplet); } } } } private static function getIds($config, $query = array(), &$total_count = NULL) { // $config - [columns, tables, unique_keys] // $query - where, orderby, limit, (произвольные условия под where)] // $total_count - будет перезаписана // Выполняем запрос, возвращаем уникальные ключи // (вдобавок подставляем в запрос параметры // с помощью обычных меток - бывает удобно) $ids = mysql_getcolumn( self::getIdsSQL($config, $query) ); // 12.11.2021: зачем здесь еще раз подставлять значения из where? // (баг был обнаружен, когда среди условий есть дата со временем, // происходит замена минут и секунд на NULL) // $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#38337 // https://sqlinfo.ru/forum/viewtopic.php?pid=48751#p48751 // Вариант с 'NO' - для гибкости при передаче более 3-х аргументов. if (func_num_args() >= 3 AND $total_count !== 'NO') { $total_count = mysql_getcell( self::getSQLforCount($config, $query) ); } return $ids; } private static function getIdsSQL($config, $query) { self::appendParamsOfRelatedToQuery($query, $config); $parts = self::makeSQLparts($query, $config['tables'], $config['columns']); $id_column = reset($config['columns'])['sql']; return "SELECT $id_column\n" . self::buildFromSQL($parts['FROM']) . self::buildWhereSQL($parts['WHERE'] ?? false) . self::buildGroupBySQL($parts['GROUP'] ?? false) . self::buildHavingSQL($parts['HAVING'] ?? false) . self::buildOrderBySQL($parts['ORDER'] ?? false) . self::buildLimitSQL($parts['LIMIT'] ?? false); } private static function getSQLforCount($config, $query) { self::appendParamsOfRelatedToQuery($query, $config); $parts = self::makeSQLparts($query, $config['tables'], $config['columns']); // Для получения общего количества строк запроса с группировкой // его нужно обернуть в подзапрос, над которым уже выполнить COUNT(*). // При этом не важно, какое поле стоит в SELECT подзапроса, // можно поставить просто единицу, // что может дать ускорение в некоторых случаях. $group_by = $parts['GROUP'] ?? false; $sql = "SELECT " . (!$group_by ? "COUNT(*)" : "1") . "\n" . self::buildFromSQL($parts['FROM']) . self::buildWhereSQL($parts['WHERE'] ?? false) . self::buildGroupBySQL($group_by) . self::buildHavingSQL($parts['HAVING'] ?? false); if ($group_by) { $sql = "SELECT COUNT(*) FROM ( $sql ) AS t"; } return $sql; } private static function getDataFromIds( $ids, $tables_normalized, $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_normalized, $columns_normalized, $columns_for_select ); $sql = "SELECT $parts[SELECT]\n" . self::buildFromSQL($parts['FROM']) . self::buildWhereSQL($parts['WHERE'] ?? false) . self::buildGroupBySQL($parts['GROUP'] ?? '') ; $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); } /** * @return array = [ * 'SELECT' => '', * 'FROM' => self::$fromDeclaration, * 'WHERE' => '', * 'GROUP' => self::$groupByDeclaration|void, * 'ORDER' => self::$orderByDeclaration, * 'LIMIT' => self::$limitDeclaration, * ] */ private static function makeSQLparts($query, $tables_cfg, $sql_cfg, $for_select = array()) { $sql_expressions = $sql_cfg; // 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/HAVING $W = []; $H = []; // HAVING 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) { if (!($cfg['having_instead_of_where'] ?? false)) { $W[0][$i][] = $sql; } else { $H[] = $sql; } } } } } // Произвольные условия foreach ($query as $key => $value) if (is_numeric($key)) $W[] = $value; if ($W) { $parts['WHERE'] = $W; } if ($H) { $parts['HAVING'] = $H; } // 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. Составляем список таблиц. Включаем GROUP BY, если нужно. // JOIN таблиц, колонки которых не участвуют в запросе // (условиях или сортировке), // не несет функциональной нагрузки, при этом замедляет выполнение запроса // из-за, собственно, необходимости выполнять JOIN. // Поэтому из переданного списка таблиц включаем в запрос только нужные. // Ищем в частях SELECT, WHERE и ORDER BY. $tables_involved = self::recognizeTablesInvolvedInSQL( "$S " . self::buildWhereSQL($W) . self::buildGroupBySQL($parts['GROUP'] ?? false) . self::buildHavingSQL($parts['HAVING'] ?? false) . self::buildOrderBySQL($parts['ORDER'] ?? false), $tables_cfg ); $parts['FROM'] = array_column($tables_involved, 'sql'); if (!isset($query['groupby'])) { if (array_column($tables_involved, 'grouping')) { // Есть JOIN, требующие группировки // Группируем по главной колонке, // которую берем просто как первую в списке: $parts['GROUP'] = reset($sql_expressions)['sql']; } } else { // if ($query['groupby'] !== false) // Хотел сделать режим явного выключения группировки // для случая, когда нужно получить суммарный результат // и при этом уже есть группировки из-за GROUPING JOIN. // Так простол не получилось: запрос без GROUP BY // игнорирует условия в HAVING. $parts['GROUP'] = $query['groupby']; } // 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; } /** * @param string $sql * @param array $tables_normalized = [ self::$normalizedTableDeclaration ] * @return array = [ self::$normalizedTableDeclaration ] */ private static function recognizeTablesInvolvedInSQL($sql, $tables_normalized) { // Проверка нестрогая, но это не страшно:, // если она сработает неверно, включение лишней таблицы // не приведет к ошибкам; просто будет чуть медленне работать. // С точки зрения построения списка используемых таблиц // было бы удобней регистрировать все поля, задействованные в конкретном случае. // Для этого нужно потребовать все SQL-выражения записывать // через ссылки вида {*поле*}. // Это снизит удобство записи буквальных низкоуровневых SQL-запросов, // которые могут использоваться в частях WHERE, ORDER BY // и выражениях для getSpecialData(). // Кроме того, незарегистрированные в `$columns` поля // вообще нельзя будет использовать (т.к. нужно исключить // всё, что идет мимо регистратора полей), поэтому такая мера // снизит и функциональность SQL-запросов. // А т.к. буквальные SQL-запросы являются мощным и наглядным инструментом, // делать так не будем. $involved = []; // Для быстрого поиска таблиц по идентификаторам // делаем массив с идентификаторами в ключах. $tables_to_look_for = array_combine( array_column($tables_normalized, 'identifier'), $tables_normalized ) ; // Главная таблица всегда будет задействована, // поэтому сразу добавляем её, причем в самое начало, // т.к. SQL таблицы с FROM должен следовать первым. $main_table = array_slice($tables_to_look_for, 0, 1); array_shift($tables_to_look_for); // 1. Ищем в непосредственно в SQL-запросе foreach ($tables_to_look_for as $identifier => $cfg) { if (self::isTableInvolvedInSQL($sql, $cfg)) { $involved[$identifier] = $cfg; } } // 2. Находим связи найденных таблицы с другими через JOIN, // чтобы выстроить цепочку до главной таблицы. // Исключаем уже найденные из списка $tables_to_look_for = array_diff_key($tables_to_look_for, $involved); // Каждую из оставшихся таблиц ищем в SQL уже найденных $look_in = $involved; do { $newly_found = []; // Для простоты склеим SQL всех таблиц в одну строку // и станем проверять её одним махом. $common_sql = implode( "\n", array_column($look_in, 'sql') ); foreach ($tables_to_look_for as $identifier => $cfg) { if (self::isTableInvolvedInSQL($common_sql, $cfg)) { $newly_found[$identifier] = $cfg; unset($tables_to_look_for[$identifier]); } } // Найденные таблицы вставляем в начало списка, // т.к. они ближе к началу цепочки // и в части FROM/JOIN запроса должны следовать первыми. $involved = array_merge($newly_found, $involved); $look_in = $newly_found; } while ($look_in); // Главную таблицу вставляем в начало $involved = array_merge($main_table, $involved); return array_values($involved); } private static function isTableInvolvedInSQL($sql, $table_config) { return preg_match( $table_config['regexp'], $sql ); } /** * Регулярное выражение для поиска таблицы в SQL-запросе по имени/псевдониму. */ private static function makeRegexpForTableSearchByIdentifier($identifier) { return "/\b$identifier\./"; } /** * Получает 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 = self::dropEmptyValuesRecursive($value); if (!$value) { return ''; } } elseif (self::isEmptyValue($value)) { return ''; } // SQL-выражение параметра нужно заключать в скобки, // т.к. приоритет операторов внутри выражения может быть // ниже приоритета самого сравнения. if (!$cfg['type']) { $sql = (!is_callable($cfg['sql'])) ? self::buildSimpleParamSQLcommon($cfg['sql'], $value, $invert) : '(' . $cfg['sql']($value) . ')'; } elseif ($cfg['type'] == 'on-off') { // v.1.8.6: Для on-off действуем по-старому, как было до 1.8.5 if (!strlen($value)) { return ''; } $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 dropEmptyValuesRecursive($array) { return self::array_filter_recursive( $array, function ($value) { return !self::isEmptyValue($value); } ); } private static function isEmptyValue($value) :bool { // v.1.8.5: оставляем в фильтрах NULL, поэтому проверку // на основе strlen() использовать больше нельзя. return (is_string($value) AND $value === ''); } 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; } private static function buildSimpleParamSQLcommon( string $param_sql_definition, $value, bool $invert = false ) :string { if (!is_array($value)) { $sql = "($param_sql_definition) "; if (!is_null($value)) { $sql .= ($invert ? "!" : "") . "=" . mysql_escape($value); } else { $sql .= "IS " . ($invert ? "NOT" : "") . " NULL"; } } else { if ( ! ($keys_null = array_keys($value, NULL, true))) { $sql = self::buildSimpleParamSQLforArray( $param_sql_definition, $value, $invert ); } else { $sql = "($param_sql_definition) IS " . ($invert ? "NOT " : "") . "NULL"; $not_null_values = array_diff_key( $value, array_fill_keys($keys_null, true) ); if ($not_null_values) { $sql .= " " . (!$invert ? "OR" : "AND") . " " . self::buildSimpleParamSQLforArray( $param_sql_definition, $not_null_values, $invert ); } } } return $sql; } private static function buildSimpleParamSQLforArray( string $param_sql_definition, array $value, bool $invert = false ) :string { return "($param_sql_definition) " . ($invert ? "NOT " : "") . "IN (" . mysql_escape($value) . ")"; } /** @param $group_by self::$FROMDeclaration */ private static function buildFromSQL($from) { return ($from) ? implode("\n", $from) . "\n" : ""; } /** * Строит часть 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) { if (!$where) { return ''; } $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 \n"; } return $sql; } public function createMainTable($if_not_exists = true) { return mysql_q($this->createMainTableSQL($if_not_exists)); } public function createMainTableSQL($if_not_exists = true) :string { 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; } $create_definitions[] = self::getColumnNameFromSQL($cfg['sql']) . " " . $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; } private static function getColumnNameFromSQL(string $sql) { preg_match('/^`?(\w+\.)?(\w+)`?/', $sql, $matches); return $matches[2]; } public function changeOrModifyColumn( string $column_cfg_key, ?string $change_name_to = '', ?bool $rename_only = false ) { return mysql_q( $this->changeOrModifyColumnSQL( $column_cfg_key, $change_name_to, $rename_only ) ); } public function changeOrModifyColumnSQL( string $column_cfg_key, ?string $change_name_to = '', ?bool $rename_only = false ) :string { $cfg = $this->columnsNormalized[$column_cfg_key] ?? []; if (!$cfg) { trigger_error( "There is no '$column_cfg_key' among keys of \$columns config: " . print_r(array_keys($this->columnsNormalized), 1), E_USER_WARNING ); return ''; } if (!($cfg['column_definition'] ?? '')) { trigger_error( "Field '$column_cfg_key' has no column_definition: " . print_r($cfg, 1), E_USER_WARNING ); return ''; } $column_name = self::getColumnNameFromSQL($cfg['sql']); $sql = "ALTER TABLE " . $this->getMainTableName() . "\n"; if (!$change_name_to) { $sql .= "MODIFY COLUMN $column_name " . $cfg['column_definition']; } else { if ($rename_only) { $sql .= "RENAME COLUMN $column_name TO $change_name_to"; } else { $sql .= "CHANGE COLUMN $column_name $change_name_to " . $cfg['column_definition']; } } return $sql; } /** @param $group_by self::$groupByDeclaration */ private static function buildGroupBySQL($group_by) { return ($group_by) ? "GROUP BY $group_by\n" : ""; } /** @param $having self::$HavingDeclaration */ private static function buildHavingSQL($having) { return ($having) ? "HAVING (" . implode(") AND (", $having) . ")\n" : ""; } /** @param $order_by self::$orderByDeclaration */ private static function buildOrderBySQL($order_by) { return ($order_by) ? "ORDER BY $order_by\n" : ""; } /** @param $limit self::$limitDeclaration */ private static function buildLimitSQL($limit) { return ($limit) ? "LIMIT $limit\n" : ""; } private static function defineValueTypeFromSQL($sql) { $sql = trim($sql); foreach (self::VALUE_TYPES as $type => $regexp_part) { if (!$regexp_part) { // для bool continue; } $regexp = "/^(?:$regexp_part)/x"; if (preg_match($regexp, $sql)) { return $type; } } } /** @param $type = array_keys(self::VALUE_TYPES) */ private static function bringValueToType($type, $value) { if (is_null($value)) { return $value; } switch ($type) { case 'int': $output = intval($value); break; case 'float': $output = floatval($value); break; case 'bool': $output = boolval($value); break; case 'json': $output = json_decode($value, true); break; case 'datetime': $output = self::createSpecialDateTimeObject($value); break; default: $output = $value; } return $output; } private static function createSpecialDateTimeObject($value) { return (new class extends \DateTime { // https://stackoverflow.com/a/5450573/589600 public static function createFromFormat($format, $time, $object = NULL) { $ext_dt = new static(); $parent_dt = parent::createFromFormat($format, $time, $object); if (!$parent_dt) { return false; } $ext_dt->setTimestamp($parent_dt->getTimestamp()); return $ext_dt; } public function __toString() { return $this->format('Y-m-d H:i:s'); } })::createFromFormat(self::DATE_FORMAT_FOR_MYSQL, $value); } /** @var = [ * 'classname' => '', * 'multiple' => false, * 'via' => 'field name in the data of related entity', * 'source_key' => 'в нормализованном виде', * 'target_key' => 'в нормализованном виде', * ] */ private $relatedConfigDeclaration; /** * @param $related_configs = [ self::$relatedConfigDeclaration ] * @param array $data * @return array */ private static function getRelatedColumnValues($related_configs, $data) { foreach ($related_configs as $cfg) { $sk = $cfg['source_key']; $v = array_column($data, $sk); if ($v) { $column_values[$sk] = array_unique($v); } } return $column_values ?? []; } /** * @param object $instance_object // потомки _List * @param $related_config = self::$relatedConfigDeclaration * @param string[]|int[] $field_values * @return array */ private static function getRelatedDataViaStraightValues( $instance_object, $related_config, $field_values ) { $field_name = $related_config['via']; $cond = [ 'where' => [ $field_name => array_unique($field_values) ], ]; if (isset($related_config['where'])) { $cond['where'] += $related_config['where']; } if (isset($related_config['orderby'])) { $cond['orderby'] = $related_config['orderby']; } $data = []; $multiple = $related_config['multiple'] ?? false; foreach ($instance_object->find($cond) as $id => $row) { $key = $row[$field_name]; if (!$multiple) { $data[$key] = $row; } else { $data[$key] [$id] = $row; } } return $data; } /** * @param object $instance_object // потомки _List * @param $related_config = self::$relatedConfigDeclaration * @param string[]|int[] $field_values * @return = [ * 'data' => [], * 'multiplets' => string[]|int[] * ] */ private static function getRelatedDataViaBondsTable( $instance_object, $related_config, $field_values ) { $via = $related_config['via']; $id_column = $via['columns'][0]; $list_column = $via['columns'][1]; $connecting_table = $via['table']; $bonds = mysql_gettable(" SELECT $id_column AS id, $list_column AS value FROM $connecting_table WHERE $id_column IN (" . mysql_escape($field_values) . ") "); $values = []; $multiplets = []; foreach ($bonds as $row) { $values[] = $row['value']; $multiplets [$row['id']] [] = $row['value']; } $cond = [ 'where' => [ $instance_object->getIDcolumnName() => array_unique($values) ] ]; if (isset($related_config['orderby'])) { $cond['orderby'] = $related_config['orderby']; } $data = $instance_object->find($cond); return compact('data', 'multiplets'); } /** @param $related_config = self::$relatedConfigDeclaration */ private static function appendRelatedDataViaReference( array &$data_to_append_to, $related_config, array &$related_data ) { $sk = $related_config['source_key']; $tk = $related_config['target_key']; foreach ($data_to_append_to as &$row) { if (!array_key_exists($sk, $row)) { // isset($row[$sk]) даст false, если в значении NULL continue; } $sv = $row[$sk]; if (isset($row[$tk])) { // Соотв. элемент уже есть. Пропускаем. // Для поддержки повторного вызова. continue; } if (isset($related_data[$sv])) { $row[$tk] = &$related_data[$sv]; } else { $row[$tk] = []; } // В форме тернарного оператора почему-то вызывает ошибку // $row[$tk] = (isset($related_data[$sv])) // ? &$r[$sv] // : array(); } } }