Полноценный класс таблицы MySQL
Я ранее обещал, что в каждой главе части V книги обязательно будет присутствовать пример нетривиального кода на PHP, который (или идеи из которого) вы сможете использовать в своих программах. На этот раз "исходник"
оказался особенно большим, но это с лихвой оправдывается его функциональностью. Сейчас мы с вами разработаем полноценный класс, который существенно облегчает работу с таблицей MySQL, в значительной степени абстрагируя программиста не только от специфики этой СУБД, но и вообще от сложностей SQL-запросов. С помощью этого класса даже начинающий программист сможет построить форум, гостевую книгу, да и вообще любую программу, которая требует структурированного хранилища данных большого объема. Правда, для того, чтобы извлекать максимальную выгоду из использования класса, придется разобраться в механизме наследования, вкратце описанном чуть выше. Впрочем, класс прекрасно работает и сам по себе. Вот его некоторые отличительные черты.
r Кодирование и декодирование данных производится автоматически. Программисту не нужно заботиться о том, чтобы ставить слэши перед апострофами и другими специальными символами. Все, что от него требуется, — передать той или иной функции массив, представляющий собой запись.
r Таблица является с точки зрения программиста набором записей совершенно произвольной структуры (с произвольным числом полей). При создании таблицы указываются лишь ее несущие
поля, по которым можно в будущем вести поиск, сортировку и т. д. Все остальные поля перед помещением записи в таблицу подвергаются сериализации, а при чтении из таблицы — восстановлению, "прозрачно"
для вызывающей программы.
r В то же время имеется возможность добавления/удаления несущих столбцов "на лету", т. е. без какого бы то ни было специального запроса пользователя. Достаточно изменить список несущих полей при создании/открытии таблицы. Класс сам определяет, что именно изменилось, и применяет соответствующие действия по корректировке (вызывает нужные команды SQL).
r Поддерживается одно автоинкрементное поле с именем id, которое автоматически проставляется у записи при ее добавлении в таблицу. Указывать его в списке несущих полей не надо.
r Имеется набор стандартных операций, которые можно производить с таблицей: ее создание и удаление, вставка новой записи, обновление записи, удаление записей, выборка указанного числа записей с сортировкой. Кроме того, поддерживаются дополнительные операции, такие как подсчет числа записей, удовлетворяющих запросу, и получение всех уникальных значений в указанном столбце таблицы.
r Для каждой таблицы можно хранить один дополнительный блок информации любой структуры (например, это может быть даже многомерный ассоциативный массив). Выборка и запись этого блока осуществляются методами GetInfo() и SetInfo(). Блок информации нельзя получить никак иначе, кроме как посредством этих двух функций (он "не виден" даже для функции выборки).
r Для убыстрения работы программист может назначить для тех или иных столбцов таблицы режим индексирования (при использовании индекса MySQL тратит значительно меньше времени на поиск данных). Индексы, как и несущие поля, вставляются и удаляются автоматически при изменении параметров вызова конструктора. Помните, что хотя они и убыстряют работу, но зато занимают на диске довольно много места.
У этого класса есть один небольшой недостаток, который заставляет применять его аккуратно. Так как количества и размеры полей при вставке могут быть любыми, то злоумышленник может быстро "забить"
таблицу разного рода "мусором". Например, если таблица используется как хранилище для гостевой книги, то он может видоизменить форму отправки сообщения и вставить туда какое-нибудь текстовое поле, предварительно поместив в него пару мегабайтов текста. Чтобы избежать этой потенциальной "дыры"
в защите, рекомендуется перед вставкой записи в таблицу проверять, какой объем она занимает в сериализованном виде, и в случае превышения определенного числа байтов выводить предупреждение и завершать сценарий по die(). Думаю, читатель сам без труда добавит такую возможность в свои сценарии или же прямо в класс MysqlTable.
Согласитесь, не так уж и мало для каких-то четырехсот строчек кода.….. Листинг 31. 2 представляет собой исходный текст библиотеки, реализующей наш класс. Она предполагает, что соединение с MySQL уже открыто и выбрана верная текущая база данных.
Листинг 31.2. Полноценный класс MySQL-таблицы
<?
// MysqlTable — "прозрачная работа" с таблицей MySQL.
// Класс MysqlTable обычно делают базовым для какого-нибудь
// другого класса (например, CGuestBook), и переопределяют
// нужные функции.
// Поле для хранения сериализованных полей (снаружи "не видно")
define("DataField","__data__");
//******************* Вспомогательные функции *******************
// Если переменная пуста, инициализирует ее
function Def0(&$st,$def) { if(!isSet($st)||$st=="") $st=$def; }
// Подготавливает строку двоичных данных для помещения в таблицу.
function Apostrophs(&$st)
{ $st=str_replace(chr(0),"\\0",$st);
$st=ereg_replace("\\\\","\\\\",$st);
$st=ereg_replace("'","\\'",$st);
return $st;
}
// Упаковывает объект и превращает его в строку.
function SqlPack(&$obj)
{ $s=Serialize($obj); return Apostrophs($s); }
// Распаковывает строку и создает объект.
function SqlUnpack(&$st) { return Unserialize($st); }
//****************************************************************
//*** Далее идет описание класса таблицы.
// Каждая запись таблицы, помимо тех полей, которые указаны в
// конструкторе, будет иметь еще два поля — id (уникальный
// идентификатор записи) и __data__ (упакованный массив
// всех остальных полей). Кроме того, в запись можно вводить
// произвольные поля — они тоже будут сохраняться, но по
// ним нельзя будет вести поиск (предложение "select"),
// потому что эти поля будут автоматически сериализованы при
// добавлении/изменении записи и распакованы при извлечении.
class MysqlTable {
//*** Внутренние переменные
var $TableName; // имя таблицы
var $UniqVars; // список уникальных полей (имя=1, имя=1...)
var $Index; // для этих полей построены индексы (имя=1, имя=1...)
var $Fields; // все физические поля таблицы (имя=тип, имя=тип...)
var $Error; // текст последней ошибки ("", если нет)
var $JustCreated; // 1, если таблица была создана, а не загружена
//*** Внутренние функции
// Упаковывает поля массива в строку, за исключением тех, которые
// сами являются непосредственными полями в базе данных.
function _PackFields(&$Hash)
{ $Data=array();
foreach($Hash as $k=>$v) if($k!=DataField)
if(!isSet($this->Fields[$k])) $Data[$k]=$v;
return Serialize($Data);
}
// Виртуальная функция производного класса вызывается ПЕРЕД любым
// занесением данных в таблицу (добавлением и обновлением). То есть
// она предназначена для "прозрачной" автоматической генерации некоторых
// полей записи (например, времени ее изменения) в производном классе
// перед ее сохранением.
// Можно, к примеру, в таблице держать какую-нибудь дату в формате
// SDN, а "делать вид", что она хранится в обычном представлении
// "дд.мм.гггг".
// Если эта функция возвратит 0, то операция закончится с ошибкой.
function PreModify(&$Rec) { return 1; }
// Виртуальная функция вызывается ПОСЛЕ выборки записи из таблицы, а
// также в конце модификации записи. То есть она предназначена для
// "прозрачной" модификации только что полученной из таблицы записи.
// Возвращаясь к предыдущему примеру, мы можем при извлечении записи
// из таблицы STM-поле преобразовать в "дд.мм.гггг", и "никто ничего
// не заметит".
function PostSelect(&$Rec) { return; }
// Возвращает имя таблицы
function GetTableName() { return $this->TableName; }
// Возвращает результат запроса select. В дальнейшем этот результат
// (дескриптор) будет, скорее всего, обработан при помощи GetResult().
// $Expr — выражение SQL, по которому будет идти выборка
// $Order — правила сортировки (по умолчанию — по убыванию id)
function TableSelectQuery($Expr="",$Order="id desc")
{ $this->Error="";
if(!$Expr) $Expr="1=1";
$r=mysql_query("select * from ".$this->TableName.
" where ($Expr) and (id>1) order by $Order");
if(!$r) { $this->Error=mysql_error(); return; }
return $r;
}
function SelectQuery($Expr="",$Order="id desc")
{ return $this->TableSelectQuery($Expr,$Order); }
// Возвращает результат предыдущего запроса select (точнее, очередную
// найденную запись) в виде распакованного (!) массива. Если
// SelectQuery() нашла несколько записей, то, последовательно вызывая
// GetResult(), можно считать их все. Метод делает всю "черную" работу
// по сериализации. Еще раз: если у результата несколько строк, то метод
// возвращает очередную. Если строки кончились, возвращает "".
// Чаще всего в вызове этой функции (и функции SelectQuery) нет
// необходимости — можно воспользоваться методом Select(), который по
// запросу сразу возвращает массив со всеми обработанными результатами!
function TableGetResult($r)
{ $this->Error="";
// Выбираем очередную строку в виде массива
if($r) $Result=mysql_fetch_array($r);
else $this->Error=mysql_error();
if(!@is_array($Result)) return;
// Перебираем все поля таблицы и записываем их в массив $Hash
$Hash=array();
foreach($this->Fields as $k=>$i)
if(isSet($Result[$k])) $Hash[$k]=$Result[$k];
// Распаковываем поле с данными
$Hash+=SqlUnpack($Hash[DataField]); unSet($Hash[DataField]);
$this->PostSelect($Hash);
// Все сделано
return $Hash;
}
function GetResult($r) { return $this->TableGetResult($r); }
// Примечание: мы используем две функции, из которых GetResult()
// просто является синонимом для TableGetResult(), чтобы позволить
// производному классу вызывать функции MysqlTable, даже если они
// переопределены в нем. К сожалению, в PHP это единственный метод
// добиться цели.
// Аналог mysql_num_rows()
function GetNumRows($r) { return mysql_num_rows($r); }
// Аналог mysql_data_seek(). После вызова этой функции указатель на
// дескриптор $r "перескочит" на найденную запись номер $to, после
// чего GetResult() ее и возвратит.
function DataSeek($r,$to) { return mysql_data_seek($r,$to); }
// Создает или загружает таблицу по имени $Name.
// $Fields — список полей базы. Именно по ним в дальнейшем можно
// будет вести поиск и строить индекс. Кроме того, в запись можно будет
// добавлять ЛЮБЫЕ другие переменные, но они будут сериализованы, а
// потом восстановлены. Формат списка: массив с ключами — именами
// переменных и значениями — их типами. Если $Fields — не массив, то
// считается, что таблица открывается такой, какой она есть. В противном
// случае производится проверка: не добавились или не удалились ли какие-
// то поля или индексы и, если это так, то выполняется соответствующая
// модификация таблицы (кстати, это процесс довольно длительный).
// ВНИМАНИЕ: если в таблице было какое-то поле, которое сериализуется, то
// в будущем при добавлении этого поля к $Fields оно НЕ будет
// автоматически переведено в ранг несущих, т. е.
попросту
// пропадет (и наоборот).
// РЕКОМЕНДАЦИЯ: перечисляйте в $Fields те поля, для которых вы ТОЧНО
// уверены, что они будут всегда присутствовать в базе, а также те,
// по которым нужно будет вести поиск, строить индекс и использовать
// distinct.
// $Index — по каким полям нужно строить индекс. Индекс несколько
// увеличивает размер базы, но зато вырастает скорость поиска по ней
// (точнее, по тем полям, для которых используется индекс). Ключи — имена
// столбцов, значения — "размер" индекса (0, если по умолчанию, что чаще
// всего наиболее разумно)
function MysqlTable($Name,$Fields="",$Index="")
{ $this->TableName=$Name; $this->Error="";
if(is_array($Fields)) {
foreach($Fields as $k=>$v)
if(!eregi("not null",$v)) $Fields[$k]=$v." not null";
$Fields=array("id"=>"int auto_increment primary key")
+$Fields+array(DataField=>"mediumblob");
}
Def0($Index,array());
// Считываем из таблицы поле с ее параметрами
$this->Fields=array(DataField=>"mediumblob");
$Data=$this->TableGetResult(
mysql_query("select ".DataField." from $Name where id=1")
);
// Если таблица существует, то запрос окончится успешно.
// В этом случае нужно проверить, не изменилась ли таблица с момента
// последнего обращения, и если это так, то подкорректировать ее.
if(@is_array($Data)) {
if(!is_array($Fields)) {
$this->Error="Couldn't create table: no fields specified";
return;
}
Def0($Data["Fields"],array());
Def0($Data["Index"],array());
//** Возможно, что-то изменилось. Тогда выполняем alter table.
//1. Добавились поля?
$Lst=array();
foreach($Fields as $k=>$v) {
if(!isSet($Data["Fields"][$k])) $Lst[]="add $k $v";
else if($Data["Fields"][$k]!=$v) $Lst[]="change $k $k $v";
}
//2. Удалились поля?
foreach($Data["Fields"] as $k=>$v)
if(!isSet($Fields[$k])) $Lst[]="drop $k";
//3. Добавились индексы?
foreach($Index as $k=>$v) if(!isSet($Data["Index"][$k]))
$Lst[]="add index index_$k ($k".($v!=0?" ($v)":"").")";
//4. Удалились индексы?
foreach($Data["Index"] as $k=>$v)
if(!isSet($Index[$k])) $Lst[]="drop index index_$k";
if(count($Lst)) {
PrintDump($Lst);
if(!mysql_query("alter table $Name ".implode($Lst,","))) {
$this->Error=mysql_error();
return;
}
$Changed=1;
}
$this->JustCreated=0;
} else {
// Необходимо создать таблицу.
// BugFix by DM: При создании новой таблицы необходимо очистить
// переменную Error, иначе в ней остается ошибка от попытки
// чтения полей.
$this->Error="";
$Lst=array();
foreach($Fields as $k=>$v) $Lst[]="$k $v";
foreach($Index as $k=>$v)
$Lst[]="index index_$k ($k".($v!=0?" ($v)":"").")";
if(!mysql_query("create table $Name (".implode($Lst,",").")")) {
$this->Error=mysql_error();
return;
}
$this->JustCreated=1;
}
// Сохраняем информацию о таблице, если она поменялась
if(!empty($Changed)||$this->JustCreated) {
$Data["Fields"]=$Fields;
$Data["Index"]=$Index;
Def0($Data["Info"],array()); // Информации не было — делаем пустой
$Data=SqlPack($Data);
if($this->JustCreated) {
$Result=mysql_query("insert into $Name(id,".DataField.")
values(1,'$Data')");
} else {
$Result=mysql_query("update $Name set ".DataField.
"='$Data' where id=1");
}
if(!$Result) { $this->Error=mysql_error(); return; }
}
$this->Fields=$Fields;
$this->Index=$Index;
}
// Записывает в таблицу информацию, общую для всей таблицы. Эта
// информация может быть получена потом только при помощи метода
// GetInfo(), и никак иначе. Например, если таблица используется для
// гостевой книги, мы можем сюда записывать какие-нибудь параметры этой
// книги — скажем, имя и пароль владельца. $Inf может быть чем угодно —
// даже массивом.
function TableSetInfo($Inf)
{ $this->Error="";
// Читаем информационную запись
$r=mysql_query("select ".DataField." from ".
$this->TableName." where id=1");
if(!($Data=$this->GetResult($r))) return;
// Устанавливаем поле Info
$Data["Info"]=$Inf;
$Data=SqlPack($Data);
// Сохраняем результат
if(!mysql_query("update ".$this->TableName.
" set ".DataField."='$Data' where id=1"))
{ $this->Error=mysql_error(); return; }
return 1;
}
function SetInfo($Inf) { return $this->TableSetInfo(&$Inf); }
// Возвращает информацию о таблице, ранее занесенную в нее при помощи
// SetInfo. Если информация не была занесена, возвращает пустой массив.
function TableGetInfo()
{ $this->Error="";
// Читаем информационную запись
$r=mysql_query("select * from ".$this->TableName." where id=1");
// Если что-то не в порядке, GetResult установит поле Error у объекта
if(!($Data=$this->GetResult($r))) return array();
if(!@is_array($Data["Info"])) $Data["Info"]=array();
return $Data["Info"];
}
function GetInfo() { return $this->TableGetInfo(); }
// Уничтожает таблицу. Осторожно! Таблица удаляется без всяких
// предупреждений!!!
function TableDrop()
{ $this->Error="";
if(!mysql_query("drop table ".$this->TableName)) {
$this->Error=mysql_error();
return 0;
}
return 1;
}
function Drop() { return $this->TableDrop(); }
// Добавляет запись $Rec (обычно это ассоциативный массив с некоторыми
// установленными полями) в таблицу. Автоматически у нее проставляется
// id, а также проверяется, уникальны ли у записи те поля, которые должны
// быть уникальными (указываются в конструкторе). Возвращает 1 в случае
// успеха, при этом в $Rec содержится окончательно сформированная
// запись.
function TableAdd(&$Rec)
{ $this->Error="";
if(!$this->PreModify($Rec)) return 0;
// Иначе все в порядке. Добавляем запись.
$Rec[DataField]=$this->_PackFields($Rec);
// Составляем список имен полей и их значений
$LNames=$LVals=array();
foreach($this->Fields as $name=>$type) {
$LNames[]=$name;
$LVals[]="'".Apostrophs($Rec[$name])."'";
}
$LNames=implode($LNames,",");
$LVals=implode($LVals,",");
unSet($Rec[DataField]);
// Добавляем
if(!mysql_query("insert into ".$this->TableName.
"($LNames) values($LVals)"))
{ $this->Error=mysql_error(); return 0; }
$Rec["id"]=mysql_insert_id();
$this->PostSelect($Rec);
return 1;
}
function Add(&$Rec) { return $this->TableAdd(&$Rec); }
// Удаляет из таблицы записи, удовлетворяющие выражению $Expr.
// Например: $Tbl->Delete("(id=$id) or (id=0)");
function TableDelete($Expr)
{ $this->Error="";
if(!mysql_query("delete from ".$this->TableName.
" where ($Expr) and (id>1)"))
{ $this->Error=mysql_error(); return 0; }
return 1;
}
function Delete($Expr) { return $this->TableDelete($Expr); }
// Возвращает массив записей (ключ — id, значение — запись). В массив
// будет занесено не более $Num записей. Для каждой записи
// вызывается PostSelect()!
function TableSelect($Expr="",$Num=100000,$Order="id desc")
{ $this->Error="";
// Выполнить запрос
$r=$this->SelectQuery($Expr,$Order); if(!$r) return 0;
// Цикл по найденным записям
for($i=0,$Found=array(); $i<$Num&&($Rec=$this->GetResult($r)); $i++)
$Found[$Rec["id"]]=$Rec;
return $Found;
}
function Select($Expr="",$Num=100000,$Order="id desc")
{ return $this->TableSelect($Expr,$Num,$Order); }
// Обновляет запись в таблице, при этом запись $Upd изменяется и
// становится фактически такой, как она будет выглядеть после обновления.
// То есть к ней могут добавиться новые поля из таблицы. Если записи с
// таким id нет (когда $id не указан в параметрах, его значение берется
// равным $Upd["id"]), то генерируется ошибка!
// Возможно, в записи $Upd не задан идентификатор id (это бывает, если
// мы только что получили данные из формы). В этом случае можно этот
// идентификатор передать через $id.
// Итак, при обновлении id НЕ МЕНЯЕТСЯ по определению (в отличие от
// ДОБАВЛЕНИЯ, когда id всегда проставляется)!
function TableUpdate(&$Upd,$id=0)
{ $this->Error="";
// Если задан $id, то устанавливаем в записи этот идентификатор
if($id) $Upd["id"]=$id;
// Загружаем старую запись. Она должна быть одна.
$r=$this->SelectQuery("id=".$Upd["id"]);
$Rec=$this->GetResult($r);
// Если не удалось, значит, неверное обновление — записи
// еще не существует
if(!$Rec) { $this->Error="NotExists"; return 0; }
// Иначе все в порядке — добавляем. Сначала обновляем
// поля и упаковываем переменные
$Rec=$Upd+$Rec; $Upd=$Rec;
if(!$this->PreModify($Rec)) return 0;
$Rec[DataField]=$this->_PackFields($Rec);
// Затем составляем список полей для обновления
$Lst=array();
foreach($this->Fields as $name=>$type)
$Lst[]="$name='".Apostrophs($Rec[$name])."'";
$Lst=implode($Lst,",");
// Выполняем запрос
if(!mysql_query("update ".$this->TableName.
" set $Lst where id=".$Rec["id"]))
{ $this->Error=mysql_error(); return 0; }
$this->PostSelect($Rec);
return 1;
}
function Update(&$Upd,$id=0) { return $this->TableUpdate(&$Upd,$id); }
// Возвращает число записей, удовлетворяющих выражению $Expr.
// Если $Expr не задано, возвращает число ВСЕХ записей.
function TableGetCount($Expr="")
{ $this->Error="";
if(!$Expr) $Expr="1=1";
$r=mysql_query("select count(if(($Expr) and (id>1),1,NULL)) from ".
$this->TableName);
if(!$r) { $this->Error=mysql_error(); return 0; }
$a=mysql_fetch_array($r);
return $a[0];
}
function GetCount($Expr="") { return $this->TableGetCount($Expr); }
// Возвращает СПИСОК всех уникальных значений поля $field
// в таблице, удовлетворяющих тому же условию $Expr.
// ВНИМАНИЕ: эта функция работает лишь тогда, когда поле $field
// присутствовало среди полей $Fields при вызове конструктора.
// В противном случае генерируется ошибка.
// Рекомендуется при создании таблицы для поля $field создать индекс.
function TableGetDistinct($field,$Expr="")
{ $this->Error="";
if(!$Expr) $Expr="1=1";
$r=mysql_query("select distinct $field from ".
$this->TableName." where ($Expr) and (id>1)");
// distinct НЕ работает вместе с order by! Почему — неясно...
if(!$r) { $this->Error=mysql_error(); return 0; }
for($Arr=array(),$i=0,$n=mysql_num_rows($r); $i<$n; $i++)
$Arr[]=mysql_result($r,$i,0);
return $Arr;
}
function GetDistinct($field,$Expr="")
{ return $this->TableGetDistinct($field,$Expr); }
}; // Конец класса
?>
А вот пример применения этого класса (листинг 31.3). Делает он следующее: открывает таблицу в некоторой базе данных (если таблицы с таким именем не существует, создает ее) и добавляет одну пробную запись.
Листинг 31.3. Пример использования класса MysqlTable
<?
include "librarian.phl"; // подключаем библиотекарь
Uses("MysqlTable"); // подключаем модуль с классом таблицы
// Устанавливаем соединение с базой данных
mysql_connect("localhost");
mysql_select_db("test");
// Открываем таблицу
$t=new MysqlTable("test",array("t"=>"int"));
// Добавляем запись
$d=array("t"=>time());
$t->Add($d);
// Работаем с блоком информации
$Inf=$t->GetInfo();
$Inf["a"]=@$Inf["a"]+1;
$Inf["b"]=@$Inf["b"]+10;
echo $Inf["a"]," ",$Inf["b"],"<br>";
$t->SetInfo($Inf);
// Выбираем все записи и выводим их
$d=$t->Select();
foreach($d as $id=>$Data) {
echo "$id: ".$Data['t']."<br>";
}
?>
Попробуйте запустить этот сценарий (естественно, сделав так, чтобы ему был доступен библиотекарь), а затем понажимать кнопку Обновить
в браузере. Вы должны увидеть, что информация действительно накапливается в базе данных.