'имя таблицы', * 'alias' => string|void, // alias таблицы для JOIN и SQL колонок * 'keys' => string[], // перечисление ключей * 'properties' => string[], * ] */ public $mainTableConfig = []; public $tables = []; // по факту как public свойство не нужно; // храним для совместимости с дочерними классами /** * @var = [ * 'identifier' => 'alias or name', * 'sql' => 'original SQL with no GROUPING keyword', * 'grouping' => true|void, * 'regexp' => '', * ] */ private $normalizedTableDeclaration; /** @var array = [ self::$normalizedTableDeclaration ] */ private $tablesNormalized; // c v.1.8.0 /** @var array = [ self::$normalizedTableDeclaration ] */ private $tablesRequireGroupBy; public $columns = []; public $columnsNormalized = []; /** * @var = [ * 'type' => '', * 'sql' => '', * 'having_instead_of_where' => bool|void, * 'column_definition' => '', * 'JSON' => bool, * 'explode' => bool, * ] */ private $normalizedColumnDeclaration; 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; 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 переписывают.) // В начало рег. выражения ставим жадную конструкцию, // чтобы она поглотила всё возможное и совпадение захватывало // самый последний по счету фрагмент с таблицей и 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' ], 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; } } $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() { $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->tablesNormalized, $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->tablesNormalized, $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' // по умолчанию общее количество строк не запрашивается ) { $config = [ 'columns' => $this->columnsNormalized, 'tables' => $this->tablesNormalized ]; self::appendParamsOfRelatedToQuery($query, $config); return self::getIds($config, $query, $total_count); } 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::array_filter_recursive($w, 'strlen'); // strlen - см. php.net/array_filter#111091 if (!$w) { // непустых элементов в массиве не было - // следовательно, условия по связанной сущности отсутствуют return false; } } /** @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]; } elseif ($w == true) { // v.1.7.2 (25.10.2021) // Специальный случай: простое условие // на существование связанной сущности. // (Пока только для случая один ко многим; // когда будет пример для 1 к 1 многим - см. // предыдущую часть if) $q = []; } else { 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 IN ( $related_sql )"; // до 23.11.2021 // // Обращение через ключ запроса может конфликтовать с явным указанием // // (если параметр, через который устанавливается связь, будет указан в запросе явно), // // поэтому конструируем конечный SQL. // return ($values) // ? "$column_sql IN (" . mysql_escape($values) . ")" // : "FALSE" ; } function findIdsSQL( $query = array() // $columns = array(), // $tables = array() ) { // Здесь без дополнительных $columns и $tables: они пока нигде не используются. $config = [ 'columns' => $this->columnsNormalized, 'tables' => $this->tablesNormalized ]; return self::getIdsSQL($config, $query); } 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; } $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 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 - будет перезаписана // Выполняем запрос, возвращаем уникальные ключи // (вдобавок подставляем в запрос параметры // с помощью обычных меток - бывает удобно) $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) { $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) { $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 { $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_unshift($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 = 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; } /** @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; } 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; } /** @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" : ""; } }