tables), $matches ); $this->mainTable = $matches[1]; // 2. Определяем имя колонки с уникальным идентификатором записи $tmp = reset($this->columns); $sql = (is_array($tmp) AND isset($tmp['sql'])) ? $tmp['sql'] : $tmp ; // Сложные SQL-выражения в расчет не берем - // рассматриваем только случай, когда в качестве идентификатора // указана физическая колонка таблицы. preg_match( '/(\w+\.)?(\w+)/', $sql, $matches ); if ($matches) // все же допускаем, что в качестве идентификатора $this->IDColumn = $matches[2]; // указано сложное выражение // просто такой случай не обрабатываем } function getList($ids) { if (is_string($ids)) $ids = mysql_getcolumn($ids); $list = self::getDataFromIds($ids, $this->tables, $this->columns); // передачу $this->_idColumn с v.1.40 (9.04.2015) - пока упразднили $list = $this->getRelatedData($list); foreach ($list as &$row) $row = $this->processData($row); return $list; } function getSingle($id) { if (!$id) return array(); $row = self::getDataFromIds($id, $this->tables, $this->columns); $tmp = $this->getRelatedData(array($row)); // getRelatedData() рассчитан только на список $row = reset($tmp); unset($tmp); $row = $this->processData($row); return $row; } function getRelatedData($rows) { # 1. Формируем конфигурацию колонок $configs = array(); foreach ($this->columns as $column => $cfg) { if (!isset($cfg['related'])) continue; foreach ($cfg['related'] as $keyname => $value) { $tmp = compact('keyname', 'column'); if (!stripos($value, "WHERE")) { $tmp += array( 'keyname' => $keyname, 'class' => $value ); } else { // sql = SELECT ... FROM table WHERE id IN (?) preg_match('/\bWHERE\s+(\w+)\b/m', $value, $matches); $tmp += array( 'id' => $matches[1], 'sql' => $value ); } $configs[] = $tmp; } } # 2. Набираем уникальные значения колонок $values = array(); foreach ($configs as $cfg) { $v = array(); foreach ($rows as $row) if (isset($row[$cfg['column']])) $v[] = $row[$cfg['column']]; if ($v) $values[$cfg['column']] = array_unique($v); } # 3. Получаем данные для каждой колонки, применяя конфигурацию $extended_data = array(); foreach ($configs as $cfg) { if (!isset($values[$cfg['column']])) continue; $v = $values[$cfg['column']]; if (isset($cfg['class'])) { $C = new $cfg['class']; $tmp = $C->getList($v); } else { $cfg['sql'] = str_replace( '?', // В связи с прекращением в mysql-functions mysql_escape($v), // поддержки меток вида "?" $cfg['sql'] // проводим замену вручную ); $tmp = mysql_gettable($cfg['sql'], $cfg['id']); } $extended_data[$cfg['column']] = $tmp; } # 4. Раскладываем полученные данные по массивам foreach ($configs as $cfg) if (isset($extended_data[$cfg['column']])) foreach ($rows as &$row) $row[$cfg{'keyname'}] = (isset($extended_data[$cfg['column']][$row{$cfg['column']}])) ? $extended_data[$cfg['column']][$row{$cfg['column']}] : array(); return $rows; } function processData($row) { return $row; } function findIds( $query = array(), &$total_count = 'NO', // по умолчанию общее количество строк не запрашивается $columns = array(), $tables = array() ) { $config = array( 'columns' => self::normalizeColumns($this->columns), // колонки сделаем отдельно 'tables' => array_merge($this->tables, $tables) // (т.к. важно, которая на первой позиции) // 'unique_keys' => $this->_idColumn v.1.40 (9.04.2015) - пока упразднили ); // В отличие от таблиц, колонки приходится склеивать хитро: // array_merge приведет к размножению нижележащих ключей, // а оператор "+" - к потере содержимого массива // (имея, например, ключ sql в обоих наборах, получим не перезапись) // Поэтому вручную делаем + на втором уровне вложенности. foreach ($config['columns'] as $name => $cfg) { if (!isset($cfg['related'])) // нет указаний на связанные сущности continue; foreach ($cfg['related'] as $keyname => $classname) { if (stripos('WHERE', $classname)) // указан не класс, а SQL - пропускаем continue; if (!isset($query['where'][$keyname])) // в запросе отсутствует соотв. условие - пропускаем continue; $tmp = $query['where'][$keyname]; if (!is_array($tmp)) // параметры связанной сущности должны передаваться во вложенном массиве continue; // скалярная величина здесь не имеет смысла $tmp = self::array_filter_recursive($tmp, 'strlen'); // strlen - см. php.net/array_filter#111091 if (!$tmp) // непустых элементов в массиве не было - continue; // следовательно, условия по связанной сущности отсутствуют # Тут, по-хорошему, нужна еще проверка параметров поиска для связанной сущности # на предмет их актуальности: если среди них будут ключи, которых нет среди $columns, # подмассив пройдет проверку на пустоту, но в то же время никакие условия наложены не будут. # В результате от связанной сущности в качестве условия придет # список всех имеющихся идентификаторов, что лишено смысла # (наличие связанной сущности как таковой следует проводить через LEFT JOIN + NOT NULL). // Теперь добавляем условие в запрос $R = new $classname; $values = $R->findIds(array('where' => $tmp)); // Обращение через ключ запроса может конфликтовать с явным указанием // (если параметр, через который устанавливается связь, будет указан в запросе явно), // поэтому конструируем конечный SQL. $query[] = ($values) ? "$cfg[sql] IN (" . mysql_escape($values) . ")" : "FALSE" ; } } unset($fn_array_filter_recursive); return self::getIds($config, $query, $total_count); } function find( $query, &$total_count = 'NO', $columns = array(), $tables = array() ) { $ids = $this->findIds( $query, $total_count, $columns, $tables ); return $this->getList($ids); } function getSpecialData($custom_columns, $query) { if (!is_array($custom_columns)) { $custom_columns = array($custom_columns); $one_column = TRUE; // результат - одна колонка или несколько } else $one_column = FALSE; $parts = self::makeSQLparts($query, $this->tables, $this->columns, $custom_columns); $SQL = "SELECT $parts[SELECT]\n"; $SQL .= implode("\n", $parts['FROM']); if (isset($parts['WHERE'])) // части WHERE обрамляем скобками, чтобы внутренние OR и пр. не могли $SQL .= "\nWHERE (" . implode(") AND (", $parts['WHERE']) . ")"; // испортить логику выбора if (isset($parts['ORDER'])) $SQL .= "\nORDER BY $parts[ORDER]"; if (isset($parts['LIMIT'])) $SQL .= "\nLIMIT $parts[LIMIT]"; $fn = ($one_column) ? 'mysql_getcolumn' : 'mysql_gettable'; return $fn($SQL); } function write($data, $id = FALSE) { $upd = ($id) ? array($this->IDColumn => $id) : FALSE ; return mysql_write_row( $this->mainTable, $data, $upd ); } function delete($id) { $sql = " DELETE FROM $this->mainTable WHERE $this->IDColumn = " . mysql_escape($id) . " "; return mysql_q($sql); } private static function getIds($config, $query = array(), &$total_count = NULL) { // $config - [columns, tables, unique_keys] // $query - where, orderby, limit, (произвольные условия под where)] // $total_count - будет перезаписана // Выполняем запрос, возвращаем уникальные ключи // (вдобавок подставляем в запрос параметры // с помощью обычных меток - бывает удобно) $parts = self::makeSQLparts($query, $config['tables'], $config['columns']); // Получаем колонку - уникальный ключ $a = self::normalizeColumns($config['columns']); // PHP 5.3-friendly $b = reset($a); $SQL = "SELECT $b[sql]\n"; unset($a, $b); $SQL .= implode("\n", $parts['FROM']); if (isset($parts['WHERE'])) // части WHERE обрамляем скобками, чтобы внутренние OR и пр. не могли $SQL .= "\nWHERE (" . implode(") AND (", $parts['WHERE']) . ")"; // испортить логику выбора if (isset($parts['ORDER'])) $SQL .= "\nORDER BY $parts[ORDER]"; if (isset($parts['LIMIT'])) $SQL .= "\nLIMIT $parts[LIMIT]"; // $fn = (count($unique_keys) == 1) - 8.04.2015 - пока упразднили // ? 'mysql_getcolumn' // : 'mysql_gettable' ; // $ids = $fn( $ids = mysql_getcolumn( $SQL, FALSE, (isset($query['where']) ? $query['where'] : array() ) ); // Получаем общее количество записей, если просили // (про COUNT(*) vs SQL_CALC_FOUND_ROWS // см. http://sqlinfo.ru/forum/viewtopic.php?pid=38337). // Вариант с 'NO' - для гибкости при передаче более 3-х аргументов) if (func_num_args() >= 3 AND $total_count != 'NO') $total_count = mysql_getcell(" SELECT COUNT(*) " . implode("\n", $parts['FROM']) . ( (isset($parts['WHERE'])) ? "\nWHERE (" . implode(") AND (", $parts['WHERE']) . ")" : "" ) ); // print_pre($SQL); return $ids; } private static function getDataFromIds($ids, $tables, $columns_cfg, $id_column_sql = '') { if (!$ids) return array(); $single_mode = (!is_array($ids)); $columns_for_select = array(); $cfg_normalized = self::normalizeColumns($columns_cfg); foreach($cfg_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($cfg_normalized); // без reset не работает key $id_key = key($cfg_normalized); $where = array( $id_key => $ids ); } else ; // явная передача SQL-выражения для идентификатора пока не реализована $parts = self::makeSQLparts( compact('where'), $tables, $columns_cfg, $columns_for_select ); $sql = " SELECT $parts[SELECT] " . implode("\n", $parts['FROM']) . " WHERE " . $parts['WHERE'][0] . /* тут только одно условие - на id*/ " "; $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; } private static function normalizeColumns($config) { $columns = array(); foreach ($config as $key => $cfg) { if (is_string($cfg) AND is_numeric($key)) { // preg_replace('/^(.+\s+|.+\.)(?=\w)/', '', $cfg); preg_match('/`?(\w+)`?$/', $cfg, $m); // буквенная последовательность с конца $name = $m[1]; unset($m); } else $name = $key; $field = (is_array($cfg)) ? $cfg // все поля конфигурации сохраняем (типа related и пр.) : array() ; if (!is_array($cfg)) $field['sql'] = $cfg; else $field['sql'] = (isset($cfg['sql'])) ? $cfg['sql'] : $key ; // @: признак исключения выражения из колонок для getList (@:) и getSingle (@@:) if (preg_match('/^(@+):/', $name, $m) OR preg_match('/^(@+):/', $field['sql'], $m)) { $field[$m[1]] = TRUE; $field['sql'] = preg_replace('/^@{1,2}:/', '', $field['sql']); $name = preg_replace('/^@{1,2}:/', '', $name); } unset($m); // убираем AS перед псевдонимом // (v.1.81: псевдоним через пробел вместо AS не поддерживаем: // невозможно отличить, например, END в CASE ... END от псевдонима через пробел // без глубокого синтаксического анализа SQL) $field['sql'] = preg_replace( '/AS\s+\w+\s*$/', '', $field['sql'] ); $field['type'] = (is_array($cfg) AND isset($cfg['type'])) ? $cfg['type'] : FALSE ; $columns[$name] = $field; } return $columns; } static function replaceSQLshortcuts($string, $sql_cfg) { // private метод сделать нельзя, т.к. нужно вызывать его из замыкания // Замена меток вида '#ключ' на соотв. SQL-выражение из конфигурации полей $pattern = '/ \{\* (\w+) (?: \s+ (ASC|DESC) )? \*\} /x'; $fn = __FUNCTION__; $callback = function($matches) use ($sql_cfg, $fn) { if (isset($sql_cfg[$matches[1]])) { $out = $sql_cfg[$matches[1]]['sql']; # Замена меток направления сортировки {*^direction*} и {*^direction_opposite*} $straight = (isset($matches[2])) // текущее направление сортировки ? $matches[2] : 'ASC'; $opposite = ($straight == 'DESC') ? 'ASC' : 'DESC'; // противоположное направление сортировки $out = str_replace( [ '{*^direction*}', '{*^direction_opposite*}'], [ $straight, $opposite ], $out, $count // количество проведенных замен ); if (!$count AND isset($matches[2])) // метки не встретились - значит, направление просто нужно оставить так, $out .= " $matches[2]"; // как было именно в $matches; приписывать явное направление нельзя, // т.к. подставляемое выражение может быть составлено с уже указанным направлением // (чтоб не получались вещи типа "... colname DESC DESC") $out = self::$fn($out, $sql_cfg); // для перекрёстных ссылок нужна рекурсия } else $out = $matches[0]; // без изменений return $out; }; return preg_replace_callback($pattern, $callback, $string); } private static function makeSQLparts($query, $tables_cfg, $sql_cfg, $for_select = array()) { $tables = $tables_cfg; $sql_expressions = self::normalizeColumns($sql_cfg); // Список таблиц - см. п. 3. (требуется анализ частей WHERE и ORDER BY) // 0. SELECT (если передан список колонок - $for_select) $S = ''; if ($for_select) { $S = implode(",\n", $for_select); $S = self::replaceSQLshortcuts($S, $sql_expressions); } if ($S) $parts['SELECT'] = $S; // 1. WHERE $W = array(); if (isset($query['where'])) { foreach ($query['where'] as $name => $value) { if (!isset($sql_expressions[$name])) // Сначала проверяем наличие параметра с таким именем continue; // среди параметров поиска // (Это нужно сделать в самом начале, иначе полезут ошибки // из-за параметров от связанных сущностей у колонок из классов) $cfg = $sql_expressions[$name]; if (is_array($value)) { // удаляем из массива пустые значения; $value = array_filter($value, 'strlen'); // максимальная вложенность - случаи типа [min,max] if (!$value) // так что рекурсивная проверка не требуется continue; // strlen передается в качестве callback, // чтобы исключить пустые строки, но оставить нули; // см. php.net/array_filter#111091 } elseif (!strlen($value)) continue; // В случае сравнений выражение нужно заключить в скобки, // т.к. приоритет операторов внутри выражения может быть // ниже приоритета самого сравнения. if (!$cfg['type']) // обычная ситуация $W[] = "($cfg[sql])" . ( (is_array($value)) ? " IN (" . mysql_escape($value) . ")" // вообще здесь должно быть :$name, : " = " . mysql_escape($value) // но из-за вложенности в [min,max] ); // не получается elseif ($cfg['type'] == 'on-off') $W[] = $cfg['sql']; elseif (preg_match('/([\[\(])(\w*),(\w*)([\]\)])/', $cfg['type'], $matches)) { $min_key = $matches[2]; if (isset($value[$min_key])) { // проверять на пустоту не надо, т.к. уже проверили выше с помощью strlen $sign = ($matches[1] == '[') ? ">=" // нестрогий интервал : '>' ; // строгий интервал $W[] = "$cfg[sql] $sign " . mysql_escape($value[$min_key]); } $max_key = $matches[3]; if (isset($value[$max_key])) { $sign = ($matches[4] == ']') ? "<=" : '<' ; $W[] = "$cfg[sql] $sign " . mysql_escape($value[$max_key]); } } 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] LIKE " . mysql_escape($v); // mysql_escape - в самом конце, // иначе % от LIKE не войдет в кавычки } $W[] = implode(" OR ", $tmp2); unset($tmp, $tmp2); } else // что-то странное - кинем notice trigger_error( "Incorrect type '$cfg[type]' specified for '$name' column.", E_USER_NOTICE ); } } // Произвольные условия foreach ($query as $key => $value) if (is_numeric($key)) $W[] = $value; if ($W) $parts['WHERE'] = $W; // 2. ORDER BY if (isset($query['orderby'])) { // 2.3.1. Ловим метки параметров сортировки из запроса. // Синтаксис: // а) сокращенный: &sort || ... // б) полный (со скобками): { &sort || #... } ... // v.1.87 (17.02.2016): полный синтаксис перестали поддерживать, // т.к. закрывающая скобка может совпадать внутри конечного выражения, // а строить PCRE-шаблон для с учетом вложенных скобок пока не умеем :/ $regexp = '/ # открывающая скобка для полного синтаксиса ( \{ \s* )? & (?P \w+) ( \s* \|\| (?P .+ ) # \s* # (?P [^}]+ ) было, пока поддерживался полный синтаксис )? # закрывающая скобка для полного синтаксиса (?(1) \s* \} ) # если была открывающая скобка - ловим закрывающую /x'; $callback = function($matches) use ($query, $sql_expressions) { $out = ''; // Если установлен ключ запроса для сортировки - проверяем его if (isset($query['where'][$matches['orderby_key']])) { $val = $query['where'][$matches['orderby_key']]; // Параметр может иметь строго форму 'имя ASC|DESC' или просто 'имя', // где (имя) должно встречаться в конфигурации колонок - // в таком случае вставляем параметр в неизменном виде, // добавляя спереди '#' - получится '#имя ASC|DESC'. // Потом метка '#имя ASC|DESC' заменится на соответствующее SQL-выражение // с учетом направления! preg_match( '/^(\w+)(?:\s+(DESC|ASC))?$/', $val, $m); if ($m AND isset($sql_expressions[$m[1]]) ) $out = "{*$val*}"; unset($m); } // Если ничего не нашли - возвращаем выражение по умолчанию, если его передали if (!$out) $out = (isset($matches['default'])) ? $matches['default'] : '' ; return $out; }; $O = preg_replace_callback($regexp, $callback, $query['orderby']); unset($regexp, $callback); // 2.3.2. Заменяем метки вида '#ключ' на соотв. SQL-выражение из конфигурации полей $O = self::replaceSQLshortcuts($O, $sql_expressions); } else $O = ''; if ($O) $parts['ORDER'] = $O; // 3. Составляем список таблиц. // JOIN таблиц, колонки которых не участвуют в запросе (условиях или сортировке), // не несет функциональной нагрузки, при этом замедляет выполнение запроса // из-за, собственно, необходимости выполнять JOIN. // Поэтому из переданного списка таблиц включаем в запрос только нужные. $T = array(); // Основная таблица (FROM) нужна всегда, её добавляем безусловно. $T[] = reset($tables); // Остальные таблицы проверяем по вхождению строки вида 'n.', // в частях SELECT, WHERE или ORDER BY // где n - имя или alias таблицы согласно переданным аргументам // Проверка нестрогая, но это и не страшно (в том редком случае, // когда проверка сработает неверно, включение лишней таблицы // не приведет ни к каким серьезным последствиям). // // Также нужно поискать в части JOIN. // Например, после анализа WHERE и ORDER BY запроса // FROM order_items i // JOIN marks m ON t.mark = m.markname // WHERE (m.markalias LIKE 'Suunto%') // таблица `t` не вошла в список. Но т.к. она требуется для связи // с таблицей `m`, её тоже нужно включить в запрос. // То есть, уже ПОСЛЕ составления списка необходимых таблиц // нужно проверить оставшиеся на связь с ними. // 2.4.1. Получим для каждой таблицы ссылку $tables_to_check = array(); foreach(array_slice($tables, 1) as $sql) { // все таблицы, кроме первой // Все таблицы, кроме первой - это точно JOIN // интересующий отрезок там в конце - перед последним ON или USING // (запросы без ON считаем дикостью и код под них не пишем) // (запросы типа FROM table1, table2 не поддерживаем - неохота, // пусть через JOIN переписывают). // В начало рег. выражения ставим жадную конструкцию, // чтобы она поглотила всё возможное, и совпадение захватывало // самый последний по счету фрагмент с таблицей и ON; // это важно для JOIN с подзапросами, внутри которых есть свои вложенные JOIN. preg_match( '/ .+ `?\b (?P \w+(\.\w+)* ) \b`? \s+ (ON|USING) .*? $/sx', trim($sql), $matches ); $regexp = "/\b$matches[ref]\./"; $tables_to_check[$regexp] = $sql; } unset($matches); // 2.4.2. Ищем в частях SELECT, WHERE и ORDER BY // Т.к. ищем везде единообразно, для удобства // просто склеим все исследуемые строки в одну, где и будем искать. $haystack = "$S " . implode(" ", $W) . " $O"; foreach ($tables_to_check as $regexp => $sql) { if (preg_match($regexp, $haystack)) { $T[] = $sql; unset($tables_to_check[$regexp]); // исключаем таблицу из списка для проверки } } unset($haystack); // 2.4.3. Теперь ищем среди найденных таблиц ссылки на остальные // Поиск нужно проводить заново после каждого пополнения списка, // т.к. каждая новая итерация может выявить необходимость включения // других таблиц (в зависимости от того, через сколько звеньев JOIN // они связаны) do { $list_has_grown = FALSE; // Таблицы в JOIN должны следовать строго в порядке их указания; // найденную таблицу вставляем в список ПЕРЕД той, которая // на нее ссылается. foreach ($tables_to_check as $regexp => $sql) { foreach ($T as $n => $table) { if (preg_match($regexp, $table)) { // вставляем найденную таблицу ДО той, которая на нее ссылается $T = array_merge( array_slice($T, 0, $n), array( $sql ), array_slice($T, $n) ); unset($tables_to_check[$regexp]); $list_has_grown = TRUE; break; // По текущей таблице поиск прекращаем } } } } while ($tables_to_check AND $list_has_grown); unset($list_has_grown); unset($tables_to_check); if ($T) $parts['FROM'] = $T; // 4. LIMIT $L = FALSE; if (isset($query['limit']) AND $query['limit']) { $q = $query['limit']; if (is_array($q)) { if (isset($q['page']) AND isset($q['per_page'])) // строго указан каждый параметр $L = array_map('intval', $q); else { // Параметры указаны в виде [ 'p', count ] // или [ 'p', ['N',default,max] ] // // имя переменной для номера страницы - в $q[0], // для количества записей на странице - в $q[1][0] $L['page'] = isset($query['where'][$q[0]]) ? intval($query['where'][$q[0]]) : 1; 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]); } } } elseif (is_numeric(trim($q))) $L = array( 'page' => 1, 'per_page' => $q ); elseif (preg_match('/^(\d+)(\s*,\s*\d+)?$/', trim($q), $matches)) $L = $q; // передали LIMIT в явном виде unset($q); } if ($L) $parts['LIMIT'] = (is_string($L)) ? $L : ( ($L['page'] - 1) * $L['per_page'] ) // арифметические операции - неявный intval для безопасности . ", " . intval($L['per_page']) ; return $parts; } 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; } } ?>