Návrh databázové třídy - díl V - Logování změn

Wed, 9 Jul 2014 10:01:53 +1100

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.