PHP класс для удобной и безопасной работы с MySQL
После написания статьи про защиту от инъекций я взялся за написание класса, реализующего изложенные в ней идеи.
А точнее, поскольку ключевой функционал уже использовался в рамках рабочего фремворка, я занялся выделением его в самостоятельный класс. Пользуясь случаем, хочу поблагодарить участников PHPClub-а за помощь в исправлении нескольких критических ошибок и полезные замечания. Ниже я постараюсь описать основные особенности, но сначала небольшой
Есть несколько способов работы с SQL — можно использовать квери-билдер, можно ORM, можно работать с чистым SQL. Я избрал последний вариант, потому что мне он ближе. Я совсем не считаю первые два плохими. Просто лично мне всегда было тесно в их рамках. Но я ни в коем случае не утверждаю, что мой вариант лучше. Это просто ещё один вариант. Который можно использовать, в том числе, и при написании ORM-а. В любом случае, я считаю, что наличие безопасного способа работать с чистым SQL не может принести какой-либо вред. Но при этом, возможно, поможет последним оставшимся приверженцам использования mysql_* в коде приложения, отказаться, наконец, от этой порочной практики.
- 100% защита от SQL инъекций
- При этом защита очень удобная в применении, делающая код короче, а не длиннее
- Универсальность, портабельность и простота освоения
Безопасность
- Любые — без исключений! — динамические элементы попадают в запрос только через плейсхолдеры.
- Всё, что не получается подставить через плейсхолдеры — прогоняется сначала через белый список.
Удобство и краткость кода приложения
- query() — возвращает mysqli resource. Может использоваться традиционно, с fetch() и т.д.
- getOne() — возвращает скаляр, первый элемент первой строки результата
- getRow() — одномерный массив, первую строку результата
- getCol() — одномерный массив скаляров — колонку таблицы
- getAll() — двумерный массив, индексированный числами по порядку
- getInd() — двумерный массив, индексированный значениями поля, указанного первым параметром
- getIndCol() — массив скаляров, индексированный полем из первого параметра. Незаменимо для составления словарей вида key => value
$data = $db->getAll("SELECT * FROM ?n WHERE mod=?s LIMIT ?i",$table,$mod,$limit);
В этом коде есть только необходимые и значащие элементы, но нет ничего лишнего и повторяющегося. Все потроха аккуратно упрятаны внутрь класса: хелпер getAll() позволяет получить сразу нужный результат без написания циклов в коде приложения, а типизованные плейсхолдеры позволяют безопасно добавлять в запрос динамические элементы любых типов без прописывания привязок (bind_param) вручную. Extra DRY код! В случаях использования плейсхолдеров ?a и ?u разница в количестве кода становится ещё больше:
$data = $db->getAll("SELECT * FROM table WHERE category IN (?a)",$ids);
Универсальность и простота освоения
- Очень маленький API — пол-дюжины плейсхолдеров и столько же хелперов.
- Мы работаем со старым добрым SQL, который не надо заново учить.
- На первый взгляд незаметная, но невероятно полезная функция parse(), которая изначально предназначалась только для отладки, но в итоге выросла до ключевого элемента при составлении сложных запросов.
$sqlpart = ''; if (!empty($var)) < $sqlpart = $db->parse(" AND field = ?s", $var); > $data = $db->getAll("SELECT * FROM table WHERE a=?i ?p", $id, $sqlpart);
Здесь важно отметить несколько моментов.
Во-первых поскольку мы не связаны родным API, никто не запрещает нам пропарсить не весь запрос целиком, а только его часть. Это оказывается супер-удобно для запросов, собирающихся в соответствии какой-либо логикой: мы парсим только часть запроса, а затем она подставляется в основной запрос через специальный «холостой» плейсхолдер, чтобы избежать повторного парсинга (и соблюсти правило «любые элементы подставляются только через плейсхолдер»).
Но, к сожалению, это является слабым местом всего класса. В отличие от всех остальных плейсхолдеров (которые, даже будучи использованы неверно, никогда не приведут к инъекции) некорректное использование плейсхолдера ?p может к ней привести.
Однако защита от дурака сильно усложнила бы класс, но при этом все равно никак не защитила бы от тупой вставки переменной в строку запроса. Поэтому я решил оставить как есть. Но если вы знаете способ, как без слишком большого оверинжиниринга решить эту проблему — я был бы благодарен за идеи.
Тем не менее, в итоге мы получили мощный и лёгкий генератор запросов, который с лихвой оправдывает этот небольшой недостаток.
Мощный потому, что мы не ограничены синтаксисом квери-билдера, «SQL-ем, написанным на PHP» — мы пишем чистый SQL.
Лёгкий потому, что весь API составления запросов состоит из полудюжины плейсхолдеров и функции parse()
Вот мой любимый пример — вставка с использованием функций Mysql
$data = array('field'=>$value,'field2'=>$value); $sql = "INSERT INTO table SET ts=unix_timestamp(), ip=inet_aton(?s),?u"; $db->query($sql, $ip, $data);
С одной стороны, мы сохраняем синтаксис SQL, с другой — делаем его безопасным, а с третьей — капитально сокращаем количество кода.
Подробнее о типизованных плейсхолдерах
- самое главное — безопасность. Добавив переменную черз плейсхолдер, мы можем быть уверены в том, что она будет корректно отформатирована.
- локальность форматирования. Это не менее важный момент. Во-первых, данные форматируются непосредственно перед попаданием в запрос, и не затрагивают исходную переменную, которая потом может быть использована где-то ещё. Во-вторых, данные форматируются ровно там, где нужно, а не до начала работы скрипта, как при magic quotes, и не в десяти возможных местах кода несколькими разработчиками, каждый из которых может понадеяться на другого.
Кроме того, типизованный плейсхолдер — это ОЧЕНЬ удобно!
Во-первых, потому что становится ненужным специальный оператор для привязки значения к плейсхолдеру (но при этом сохраняется возможность указать тип передаваемого значения!)
Во-вторых, раз уж мы изобрели типизованный плейсхолдер — мы можем налепить этих плейсхолдеров огромное количество, для решения множества рутинных задач по составлению SQL запросов.
В первую очередь сделаем плейсхолдер для идентификаторов — нам его отчаянно не хватает в реальной, а не воображаемой авторами стандартных API, жизни. Как только девелопер сталкивается с необходимостью динамически добавить в запрос имя поля — каждый начинает извращаться по-своему, кто в лес, кто по дрова. Здесь же всё унифицировано с остальными элементами запроса, и добавление идентификатора становится не сложнее добавления строки. Но при этом идентификатор форматируется не как строка, а в соответствии со своими собственными правилами — заключается в обратные кавычки, а внутри эти кавычки экранируются удвоением.
Дальше — больше. Следующая головная боль любого разработчика, когда-либо пытавшегося использовать стандартные prepared statements в реальной жизни — оператор IN(). Вуаля, у нас есть плейсхолдер и для этой операции! Подстановка массива становится не сложнее любых других элементов, плюс она унифицирована с ними — никаких отдельных функций, меняется всего лишь буква в плейсхолдере.
Точно таким же образом делаем и плейсхолдер для SET. Не удержусь и продемонстрирую, насколько простым становится код для такого замороченного запроса, как INSERT… ON DUPLICATE:
$data = array('offers_in' => $in, 'offers_out' => $out); $sql = "INSERT INTO stats SET pid=?i,dt=CURDATE(),?u ON DUPLICATE KEY UPDATE ?u"; $db->query($sql,$pid,$data,$data);
- ?s («string») — строки (а также DATE, FLOAT и DECIMAL).
- ?i («integer») — целые числа.
- ?n («name») — имена полей и таблиц
- ?p («parsed») — для вставки уже обработанных частей запроса
- ?a («array») — набор значений для IN (строка вида ‘a’,’b’,’c’)
- ?u («update») — набор значений для SET (строка вида `field`=’value’,`field`=’value’)
Кстати, как многие могли заметить, этот класс во многом напоминает библиотеку DbSimple Дмитрия Котерова. Но у меня есть принципиальные расхождения с некоторыми идеями, заложенными в неё.
Во-первых, я противник любой магии, когда одна и та же функция может возвращать различный результат в зависимости от типа переданных данных. Это, возможно, чуть упрощает написание, но при этом чудовищно затрудняет сопровождение и отладку кода. Поэтому в моем классе вся магия сведена к минимуму, а все операции и типы данных всегда прописываются явно.
Во-вторых, в DbSimple немного, на мой взгляд, переусложнённый синтаксис. С одной стороны, фигурные скобки — гениальная идея. С другой — а зачем, если в нашем распоряжении вся мощь PHP? Поэтому я решил пойти другим путём и весто «внутренней» — заведомо ограниченной — логики ввёл «внешнюю», ограниченную лишь синтаксисом РНР. Главное, чтобы любые динамические элементы попадали в запрос только через плейсхолдеры, а остальное зависит лишь от фантазии разработчика (и функции parse()).
Код класса доступен на Гитхабе, github.com/colshrapnel/safemysql/blob/master/safemysql.class.php
Cheat sheet с основными командами и примерами: phpfaq.ru/misc/safemysql_cheatsheet_ru.pdf
Хорошее представление о возможностях можно получить на странице примеров документации (к сожалению, ещё не законченной), phpfaq.ru/safemysql
Там же есть ответы на часто задаваемые вопросы, такие как «почему ты не используешь родные prepared statements?» и пр.
Тем не менее, буду рад ответить на любые вопросы в комментариях, а так же улучшить по вашим замечаниям как сам класс, так и эту статью.
PHP Class MySQLi — СУБД MySQL
На сегодняшний день существует множество статей с готовыми решениями, как работать с СУБД MySQL. Однако, почти в каждой статье приводятся примеры процедурного вызова функций. Начиная с php-версии 5.5.0 расширение MySQL будет удалено из сборки и вообще, это расширение уже устарело. Вместо него будем использовать расширение MySQLi в ООП.
Данная статья является готовым решением для работы с СУБД.
При разработке очередного проекта, я столкнулся с тем, что в каждом методе следующего написанного класса с обращением к СУБД, мне нужно было обращаться к классу работы с СУБД, для очередного select, insert, update и т.п., то есть тягать в функции параметра global с объявленной переменной вне класса. А устанавливать новое соединение с базой — слишком затратно, да и не выгодно, только ресурсы системы тратить. И решил написать переписать класс работы с базой, с сохранением экземпляра класса, чтобы обращаться сразу напрямую к методам класса из любой области видимости переменной.
Для просмотра исходника смотрим под кат.
Для инициализации класса достаточно создать экземпляр класса.
Переменную $db не следует удалять после объявления, т.к. ее адрес используется для дальнейшей работы со статическими методами класса.
catch (Exception $e) < exit($e->getMessage()); > ?>
Ниже приведу содержимое класса DB.class.php
connect_error) throw new Exception("Connect failed: %s", $mysqli->connect_error); self::$mysqli = &$mysqli; self::$mysqli->query('SET NAMES utf8 COLLATE utf8_general_ci'); > public static function GetRows($rows, $single = false) < $result = array(); if ($rows === false) return $result; if ($single) return $rows->fetch_assoc(); while ($row = $rows->fetch_assoc()) array_push($result, $row); $rows->free(); return $result; > public static function Select($sql, $single = false) < $result = self::$mysqli->query($sql); return self::$db->GetRows($result, $single); > public static function Update($data, $table, $where) < $sets = ''; foreach ($data as $column =>$value) < $sets .= $sets ? ', ' : ''; $sets .= "`$column` = '$value'"; >$sql = "UPDATE $table SET $sets WHERE $where"; self::$mysqli->query($sql); > public static function Insert($data, $table) < $columns = ""; $values = ""; foreach ($data as $column =>$value) < $columns .= $columns ? ', ' : ''; $columns .= "`$column`"; $values .= $values ? ', ' : ''; $values .= "'$value'"; >$sql = "INSERT INTO $table ($columns) VALUES ($values)"; self::$mysqli->query($sql); return self::$mysqli->insert_id; > public static function CountRows($table, $request = false) < $sql = "SELECT COUNT(*) FROM $table "; $sql .= $request ? $request : ''; $result = self::$mysqli->query($sql); $count = $result->fetch_array(); $result->free(); return $count[0]; > public static function Query($sql) < return self::$mysqli->query($sql); > public static function Delete($table, $where) < $sql = "DELETE FROM $table WHERE $where"; self::$mysqli->query($sql); > public static function Close() < self::$mysqli->close(); > function __destruct() < self::$db->Close(); > > ?>