Návrh databázové třídy - díl V - Logování změn
V dalším pokračování seriálu o objektovém programování v PHP si ukážeme, jak přidat do ukázkové třídy DB logování konkrétních SQL dotazů. Rozebereme si tedy znovu metody na insert, update a delete, které rozšíříme o další zápis do nové tabulky, kam budeme ukládat informace o provedených změnách.
Nejdřív ale krátká rekapitulace. Soubor článků o objektech v PHP jsem začal psát už poměrně dávno, ještě před pauzou v blogování. Jelikož jsem měl ale na články vcelku pozitivní ohlasy, byla by asi škoda seriál nedokončit, byť opožděně.
V prvním dílu jsme si stanovili cíle a řekli si něco o rozhraní nad třídou. V dílu druhém jsme si ukázali základní metody na dotazy typu select, insert, update a delete, které si dnes připomeneme a upravíme. Třetí díl byl vlastně o skládání objektů, a to ukázka třídy Query, jejíž instanci vracely metody mateřské třídy Db. Poslední, čtvrtý díl byl spíš takový návod jak jednoduše exportovat databázi bez přístupu do PHPMyAdmina; hodně kódu a méně povídání.
Dnešní článek bude podobně stavěný jako ten předchozí. Ukážeme si, jak logovat SQL dotazy, povíme si proč spolu s vysvětlením, u kterých metod má rozšíření o logování smysl a u kterých ne. V seriálu budou následovat ještě 2 další články: slovo závěrem s odkazem na celý zdrojový kód ke stažení a navíc ještě jeden krátký text o dědičnosti. Té jsem se zatím nevěnoval a jelikož se jedná o poměrně důležitou kapitolu v objektově orientovaném programování, zaslouží si článek navíc.
Princip logování a kdy má smysl
Základní princip je poměrně triviální: u každého provedeného dotazu zkopírujeme celé jeho znění do další tabulky. Metoda insert poskládá SQL dotaz dle zadaných parametrů a provede ho vlastně na 2 tabulkách. Metody update a delete pak upraví respektive smažou záznam z databáze spolu se zavoláním dalšího insertu na pozadí do logovací tabulky. U žádných dalších metod pak logování samozřejmě nemá význam.
Samotné logování má smysl v jednom jediném případě, a to v administraci či na jiných místech aplikace, kde mají uživatelé přístup k úpravě dat. Ve výsledku totiž dostaneme takovou jednoduchou historii provedených změn. Chtěl bych zdůraznit, že v žádném případě nepůjde o nějaké revize článků či kategorií, to je samo o sobě neskutečně komplexní funkcionalita, kterou rozhodně neobslouží pár metod v databázové třídě.
Využití v praxi
Pokud si spravujeme vlastní malé stránky jako například nyní já a stane se nám nehoda, kdy si například omylem umažeme kus napsaného textu, v databázi pak snadno dohledáme předchozí provedený update ainformace zachráníme. Pakliže má do administrace přístup více uživatelů, můžeme velice snadno zjistit, kdo kde co rozbil :-) Tento případ se může na první pohled zdát poněkud úsměvný, ale z praxe vím, že k takovýmto situacím dochází dnes a denně... Jako jeden z příkladů mohu uvést editaci uživatelského profilu na e-shopu. Může se stát, že zákazník odešle objednávku, další den si v profilu upraví adresu a pak se bude hádat, že mu objednávka byla odeslána na adresu špatnou. A člověk je pak zbytečně za blbce. Bohužel, i tak to chodí. Takže je fajn mít tyto stavy podchycené.
Struktura tabulek
Dost bylo teorie, přejdeme k samotnému kódu. V databázi bude potřeba založit 2 nové tabulky.
CREATE TABLE `project_log` (
`id` bigint(20) unsigned auto_increment,
`user_id` int(10) default '0',
`table_name` varchar(64) NOT NULL,
`method` varchar(16) NOT NULL,
`insert_id` bigint(20) DEFAULT NULL,
`whole_query` longtext,
`timestamp` timestamp default CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY (`user_id`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8 ;
CREATE TABLE `project_log_items` (
`id` bigint(20) unsigned auto_increment,
`parent_id` bigint(20) unsigned NOT NULL,
`col_name` text,
`col_value` text,
PRIMARY KEY (`id`),
KEY (`parent_id`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8 ;
První bude obsahovat základní informace o provedeném dotazu: ID uživatele referující na interní tabulku v daném systému, jméno tabulky, kde úprava proběhla, název fukce, která logování zavolala, ID záznamu pro dotazy typu insert, celé znění SQL dotazu a informaci o čase. Do druhé tabulky s referencí na první pak budeme ukládat veškeré údaje sloupec po sloupci. Díky tomu pak budeme mít zpětný přístup k naprosto všem změnám včetně smazaných řádků. Opět bych ale zdůraznil, že se rozhodně nejdená o nějaký archív smazaných položek nebo koš; logovací tabulky jsou primárně určeny pro vkládání a nic jiného. Předpokládáme zde obrovský objem dat, takže jakýkoli výpis záznamů s případným filtrováním by nám docela zavařil dráty.
Rozšíření třídy Db
private $log = false;
private $user = 0;
private $logTable;
private $tableItems;
Ve čtyřech nových proměnných s viditelností private bude uloženo vše potřebné pro logování: ID uživatele, jména tabulek a stav zapnuto / vypnuto.
public function enableLog($user = 0){
$this->log = true;
if($user){
$this->user = $user;
}
}
public function setLogTable($logTable = null){
if($logTable){
$this->logTable = $logTable;
}
}
public function setLogItemsTable($tableItems = null){
if($tableItems){
$this->tableItems = $tableItems;
}
}
public function disableLog(){
$this->log = false;
}
Další 4 nové metody typu set budou tyto proměnné nastavovat. Metoda enableLog() bude volána přímo s parametrem ID uživatele, jelikož předpokládáme její zavolání někde uvnitř administračního modulu po přihlášení. Další dvě metody umožní přenastavení výchozích jmen tabulek a nakonec zbývá už jen poslední funkce pro vypnutí. Vypínač bude mít i velice důležitou interní funkci, k tomu ale až za chvíli.
Metoda insert
Základní znění metody insert jsme si ukázali hned v druhém dílu, nyní si ji rozšíříme.
public function insert($table, array $items){
try {
if(empty($items)){
throw new DbException('No values to insert.', var_export($items, true));
}
$keys = array();
$vals = array();
foreach ($items as $key => $t){
if($t === 0 || $t === '0'){
$vals[$key] = "'0'";
$keys[] = "`{$key}`";
} elseif(!empty($t)){
$vals[$key] = "'".$this->escape($t)."'";
$keys[] = "`{$key}`";
}
}
$qtable = $this->replacePrefix($table);
$final_query = "INSERT INTO {$qtable} ( ".implode("\n,", $keys)." ) VALUES ( ".implode(",\n ", $vals)." )";
$final_query_log = "INSERT INTO {$qtable} ( ".implode(",", $keys)." ) VALUES ( ".implode(", ", $vals)." ) ;";
$insertId = $this->query($final_query, array(), false)->insertId();
if($this->log){
$this->log = false;
$this->insert($this->logTable, array(
'user_id' => $this->user,
'table_name' => $this->replacePrefix($table),
'method' => __FUNCTION__,
'insert_id' => $insertId,
'whole_query' => $final_query_log,
));
$this->log = true;
}
return $insertId;
}catch(Exception $e){
$e->show();
}
}
Úprava spočívá v bloku začínajícím podmínkou if($this->log). Pokud je tedy logovaní aktivní, nejdříve ho vypneme, uložíme data a pak ho znovu zapneme. Proč to? Funkce totiž volá sama sebe, takže kdybychom tak neučinili, celé by se to zacyklilo a metoda by volala sama sebe pořád dokola do nekonečna. A logovat logování fakt nechceme. Ve funkci insert logujeme až na závěr, abychom už znali mysql_insert_id a mohli uložit referenci na právě vytvořený záznam.
Metoda update
Update bude o jednu úroveň náročnější, protože zde už využijeme i druhé tabulky. Tady naopak zálohujeme záznam v jeho podobě před úpravou. Nejdříve tedy proběhne select na řádek / řádky, jenž cílíme v podmínce updatu, získané údaje uložíme a nakonec až přepíšeme.
public function update($table, $id, array $items, $col = 'id'){
try {
if(empty($items)){
throw new DbException('No values to update.', var_export($items, true));
}
if(empty($id)){
throw new DbException('No id.', var_export($id, true));
}
$update = array();
foreach ($items as $key => $item){
$update[$key] = "`{$key}` = '".$this->escape($item)."'";
}
$qtable = $this->replacePrefix($table);
$final_query = "UPDATE {$qtable} SET ".implode(",\n ", $update)." WHERE `{$col}` = '{$id}'";
$final_query_log = "UPDATE {$qtable} SET ".implode(" ", $update)." WHERE `{$col}` = '{$id}' ;";
if($this->log){
$this->log = false;
$_entry = $this->query("SELECT * FROM {$table} WHERE `{$col}` = '{$id}'")->assocList();
foreach ($_entry as $_row){
$_log_id = $this->insert($this->logTable, array(
'user_id' => $this->user,
'table_name' => $this->replacePrefix($table),
'method' => __FUNCTION__,
'whole_query' => $final_query_log,
));
foreach ($_row as $_key => $_col){
$this->insert($this->tableItems, array(
'parent_id' => $_log_id,
'col_name' => $_key,
'col_value' => $_col
));
}
}
$this->log = true;
}
return $this->query($final_query, array(), false)->affectedRows();
}catch(Exception $e){
$e->show();
}
}
Logování opět nejdříve vypneme před zavoláním patřičných insertů.
Metoda delete
Jak jsem naznačil výše, i při mazání řádků z databáze vlastně provedeme nejdřív jejich zálohu. Chování bude úplně stejné jako u metody insert.
public function delete($table, array $cond){
try {
if(empty($cond)){
throw new DbException('No condition.', var_export($cond, true));
}
$c = array();
foreach ($cond as $key => $t){
$c[] = "`{$key}` = '".$this->escape($t)."'";
}
$qtable = $this->replacePrefix($table);
$string = implode(' AND ', $c);
$final_query = "DELETE FROM {$qtable} WHERE {$string}";
$final_query_log = "DELETE FROM {$qtable} WHERE {$string} ;";
if($this->log){
$this->log = false;
$_entry = $this->query("SELECT * FROM {$table} WHERE {$string}")->assocList();
foreach ($_entry as $_row){
$_log_id = $this->insert($this->logTable, array(
'user_id' => $this->user,
'table_name' => $this->replacePrefix($table),
'method' => __FUNCTION__,
'whole_query' => $final_query,
));
foreach ($_row as $_key => $_col){
$this->insert($this->tableItems, array(
'parent_id' => $_log_id,
'col_name' => $_key,
'col_value' => $_col
));
}
}
$this->log = true;
}
return $this->query($final_query, array(), false)->affectedRows();
}catch(Exception $e){
$e->show();
}
}
Závěrem
To by bylo v kostce asi vše. V příštím dílu bych seriál o databázové třídě uzavřel, abych mohl navázat slíbeným článkem o dědičnosti.
Edit 4. 5. 2020:
Text se týká PHP 5. Pod PHP 7 už třída fungovat nebude, protože všechny mysql_ funkce skončí chybou.
Komentáře k článku:
Buďte první, kdo článek okomentuje!