Databázový model

Rudolf Svátek 2016-06-28 13:08

Celou aplikaci, tak jak by měla vypadat po dnešním díle si můžeš stáhnout: kapitola4.zip.

Redakční systém RS::RS jsem stavěl v podle vzoru MVC, čili Model-View-Controller. Doporučuji hezké čtení na stránkách Nette.

Model je zodpovědný za práci s databází a získává data přímo z tabulek. Pak je někomu předá a víc se nestará. Můžeš mít jednoduchý model, který se vejde do jedné třídy v jednom souboru a bude umět všechno sám. Já si model rozdělil na 3 vrstvy:

  • entita - vytváří objekt, který kopíruje jednu tabulku v databázi
  • mapper - zodpovídá za získávání dat z tabulek, nebo za manipulaci s nimi
  • repository - sada funkcí, které se mohou volat z presenteru

Pro každou tabulku tedy budu vyrábět třídu Entity, Mapper a Repository

Vím, že budu asi čelit námitkám, že jde o anemický návrhový vzor, který se prý používat nemá, ale já na něm nenašel nic, co by mě nutilo, abych to vymyslel jinak. Jak jsem už zmiňoval, tento seriál není návod na to, jak to dělat dobře. Spíš je to můj deníček, jak jsem to dělal já :-)

BaseMapper

Nevím jak ty, ale já jsem celkem líný člověk. Co nemusím, to nedělám. Když tedy zjistím, že nějaký kód píšu už podruhé, přemýšlím, jak se tomu vyhnout. Dělám to tak, že společné metody napíšu do jedné Base třídy, od které pak dědím. U tříd, které dědí z nějaké takové Base třídy, už jen doplním to, co má být jinak a je specifické jen pro tuto jednu třídu.

Mappery jsou toho příkladem. Každý mapper by měl umět základní příkazy s databází jako Select, Delete, Insert či Update a co já vím, co si ještě vzpomeneš. Je ale nepříjemné psát pro každou entitu znovu dokola, jak má vypadat vložení nového záznamu, když se příkazy vlastně liší jen názvem tabulky a strukturou dat. V mapperech by vlastně stačilo jen vědět o jakou tabulku jde, s jakými daty chceme pracovat a sdělit to BaseMapperu. Tím se každý Mapper dost zjednoduší. Pokud nemá žádnou speciální funkčnost, stačí pouze konstruktor.

BaseMapper bude implementovat rozhraní IMapper:

<?php
 
namespace App\Model;
 
interface IMapper 
{
    function get($id);
    function getAll();
    function getOneWhere($cond);
    function getAllWhere($cond);
    function getAllWhereOr($cond);
    function itemToArray($item);
    function returnItemOrNull($data);
}

Můj BaseMapper leží v souboru app\model\BaseMapper.php. Je to abstraktní třída, protože nebudeme přímo vytvářet její instance. K tomu budou sloužit třídy, které od BaseMapper budou dědit. Začneme tedy takto:

<?php
 
namespace App\Model;
 
use Dibi\Connection;
use Dibi\Exception;
use Dibi\ForeignKeyConstraintViolationException;
use Nette\Object;
use Tracy\Debugger;
use Tracy\ILogger;
use Nette\Application\BadRequestException;
 
/**
 * Class BaseMapper
 * @package App\Model
 */
abstract class BaseMapper extends Object implements IMapper 
{
 
    /** @var Connection */
    protected $db;
    protected $tableName;
    protected $primaryKey;
    protected $entityName;
 
    /**
     * Class constructor
     *
     * @param Connection $db
     */
    public function __construct(Connection $db) {
        $this->db = $db;
        $this->tableName = $this->getTableName();
        $this->primaryKey = $this->getPrimaryKey();
        $this->entityName = $this->getEntityName();
    }

Každý budoucí Mapper potřebuje vědět, s jakou tabulkou pracuje, jaký je primární klíč a jaké entitě bude data předávat. Mohl bych v každém Mapperu tyto hodnoty nastavovat, ale zase mi to zavání psaním vlastně stejných kusů kódu. předám tedy BaseMapperu starost o to, aby si potřebné informace zjistil. Dělá to pomocí 3 funkcí:

	/**
	 * @return mixed
	 */
	private function getPrimaryKey() {
		$primaryKey = $this->db->getDatabaseInfo()->getTable($this->tableName)->getPrimaryKey()->getColumns();
		return $primaryKey[0]->name;
	}

	/**
	 * @return mixed
	 */
	private function getTableName() {
		return strtolower(str_replace('Mapper', '', str_replace(__NAMESPACE__ . '\\', '', get_called_class())));
	}

	/**
	 * @return mixed
	 */
	private function getEntityName() {
		return str_replace('Mapper', 'Entity', get_called_class());
	}

BaseManager umí zjistit, která třída jej volala, když vytvářea svůj objekt. Tak například, pokud vznikne objekt SettingsMapper, který dědí od BaseMapper, bude o tom BaseMapper vědět a umí vrátit název jeho třídy. Díky tomu máme možnost určit všechny potřebné názvy. Tím pádem je ale jasné, že nemáme takovou volnost v pojmenovávání tabulek a tříd. Musí se to prostě jmenovat stejně.

Kupříkladu budeš chtít pracovat s komentáři. Pak musíš založit v MySQL tabulku a k ní Mapper, Entity a Repository takto:

  • název tabulky - comments
  • název třídy s Mapperem - MapperComments
  • název třídy s Entitou - EntityComments
  • název třídy s Repozitory - RepositoryComments

Tohle prosím dodržuj, nebo se to zhroutí. A ano - vím, že takové křehké závislosti nejsou moc dobré. Ale nespoléhám na uživatele, nýbrž takto omezuji jen programátora, což jsem prozatím jen já :-). Za to pohodlí menšího kódu mi to stojí.

Základem práce s databází je výběr dat. Vytvoř funkci pro výběr jednoho řádku podle ID:

	/**
	 * @param $id
	 * @return null|Object
	 * @throws BadRequestException
	 */
	public function get($id) {
		$row = $this->db->select('*')
				->from($this->tableName)
				->where($this->primaryKey . ' = %i', $id)
				->fetch();

		return $this->returnItemOrNull($row);
	}

	/**
	 * @param $data
	 * @return $this->entityName|null
	 */
	public function returnItemOrNull($data) {
		if ($data)
			return $this->arrayToItem($data, new $this->entityName);
		else
			return NULL;
	}

Funkce vybere jeden záznam. Pokud takový záznam nenajde, vyhodí výjimku. Jestliže je ale úspěšná, zavolá funkci returnItemOrNull. Ta zase vrátí objekt typu Entity. Všimni si, že nepotřebujeme nikde ručně nastavovat názvy tabulek, entit apod. Vše se zjišťuje automaticky - na tohle jsem celkem pyšný :-)

Často potřebuješ získat jeden záznam, ale podmínkou je něco jiného, než ID. Takže vytvoř funkci:

	/**
	 * @param $cond
	 * @return $this ->getEntityName()|null
	 * @throws BadRequestException
	 */
	public function getOneWhere($cond) {
		$row = $this->db->select('*')
				->from($this->tableName)
				->where('%and', $cond)
				->fetch();

		return $this->returnItemOrNull($row);
	}

Parametrem je asociativní pole. Pokud je v poli více prvků, jsou spojeny podmínkou AND. Opět se vrací nalezený záznam jako Entita.

Také výběr všech řádků tabulky se bude hodit:

	/**
	 * @return \Dibi\Fluent
	 */
	public function getAll() {
		return $this->db->select('*')
						->from($this->tableName);
	}

Podobně jako při výběru jednoho řádku, můžeme i pro výběr vícero řádků použít podmínky. Já třeba doplnil 2 funkce, z nichž jedna podmínky spojuje operátorem AND a druhá pomocí OR.

	/**
	 * @param $cond
	 * @return \Dibi\Fluent
	 */
	public function getAllWhere($cond) {
		return $this->db->select('*')
						->from($this->tableName)
						->where('%and', $cond);
	}

	/**
	 * @param $cond
	 * @return \Dibi\Fluent
	 */
	public function getAllWhereOr($cond) {
		return $this->db->select('*')
						->from($this->tableName)
						->where('%or', $cond);
	}

Dá se to řešit jinak - třeba argumentem funkce. To už si zařiď podle svého. Taky to pořád ladím a upravuji, takže nic není hotovo.

Nalezený řádek lze vracet jako Entitu. Nebo naopak někdy j potřeba Entitu vrátit jako pole. K tomu slouží 2 funkce:

	/**
	 * @param object $item
	 * @return array
	 */
	public function itemToArray($item) {
		$methods = get_class_methods(key(class_implements($item)));
		$array = [];
		foreach ($methods as $method)
			$array[$method] = $item->$method();
		return $array;
	}

	/**
	 * @param $data
	 * @param $item
	 * @return $this->getEntityName()
	 */
	private function arrayToItem($data, $item) {
		$methods = get_class_methods(key(class_implements($item)));
		foreach ($methods as $method)
			$item->$method($data[$method]);
		return $item;
	}

Ta divná konstrukce get_class_methods(key(class_implements($item))) umí zjistit seznam funkcí z třídního rozhraní. Proto v Interface musí být vyžadovány všechny funkce, které jsou v Entity použity. Opět platíme závislostmi za pohodlí. Je trochu otázka, zda metoda itemToArray nepatří spíš do BaseEntity, nebo dokonce přímo do konkrétní Entity, ale zatím to mám takto velmi chudokrevně.

Další metoda slouží k ukládání dat. To se děje ve 2 případech. Buď při insertu nového řádku, nebo při update stávajícího záznamu. Metoda save() bude umět obojí.

	/**
	 * @param $item
	 * @return \Dibi\Result|int
	 */
	public function save($item) {
		if ($item->{$this->primaryKey}() === NULL) {
			$data = $this->itemToArray($item);
			try {
				$this->db->insert($this->tableName, $data)->execute();
				return $this->db->insertId();
			} catch (Exception $e) {
				Debugger::log(printf("An error has occurred: %s\n", $e->getMessage()), ILogger::ERROR);
				return NULL;
			}
		} else {
			$data = $this->itemToArray($item);
			try {
				$this->db->update($this->tableName, $data)
						->where($this->primaryKey . ' = %i', $item->{$this->primaryKey}())->execute();
				return $item->{$this->primaryKey}();
			} catch (ForeignKeyConstraintViolationException $e) {
				Debugger::log($e->getMessage(), ILogger::ERROR);
				return NULL;
			}
		}
	}

Parametrem je objekt Entity. Je buď s hodnotou NULL v primárním klíči, potom jde o vkládání nového záznamu, nebo už v primárním klíči nějakou hodnotu má a pak půjde o úpravy stávajícího záznamu. Vkládání dat je vždy kritická operace. Takže když se něco nepovede, zalogujeme chybu.

Poslední funkcí budeme mazat záznamy.

	/**
	 * @param $id
	 */
	public function delete($id) {
		try {
			$this->db->delete($this->tableName)
					->where($this->primaryKey . ' = %i', $id)
					->execute();
		} catch (Exception $e) {
			Debugger::log($e->getMessage(), ILogger::ERROR);
		}
	}

Mazání probíhá na základě známého ID. Nezdar opět zalogujeme.

Celý soubor BaseMapper tedy vypadá takto:

<?php
 
namespace App\Model;
 
use Dibi\Connection;
use Dibi\Exception;
use Dibi\ForeignKeyConstraintViolationException;
use Nette\Object;
use Tracy\Debugger;
use Tracy\ILogger;
use Nette\Application\BadRequestException;
 
/**
 * Class BaseMapper
 * @package App\Model
 */
abstract class BaseMapper extends Object implements IMapper 
{
 
    /** @var Connection */
    protected $db;
    protected $tableName;
    protected $primaryKey;
    protected $entityName;
 
    /**
     * Class constructor
     *
     * @param Connection $db
     */
    public function __construct(Connection $db) {
        $this->db = $db;
        $this->tableName = $this->getTableName();
        $this->primaryKey = $this->getPrimaryKey();
        $this->entityName = $this->getEntityName();
    }
 
    /**
     * @param $id
     * @return null|Object
     * @throws BadRequestException
     */
    public function get($id) {
        $row = $this->db->select('*')
                ->from($this->tableName)
                ->where($this->primaryKey . ' = %i', $id)
                ->fetch();
 
        return $this->returnItemOrNull($row);
    }
 
    /**
     * @param $cond
     * @return $this ->getEntityName()|null
     * @throws BadRequestException
     */
    public function getOneWhere($cond) {
        $row = $this->db->select('*')
                ->from($this->tableName)
                ->where('%and', $cond)
                ->fetch();
 
        return $this->returnItemOrNull($row);
    }
 
    /**
     * @return \Dibi\Fluent
     */
    public function getAll() {
        return $this->db->select('*')
                        ->from($this->tableName);
    }
 
    /**
     * @param $cond
     * @return \Dibi\Fluent
     */
    public function getAllWhere($cond) {
        return $this->db->select('*')
                        ->from($this->tableName)
                        ->where('%and', $cond);
    }
 
    /**
     * @param $cond
     * @return \Dibi\Fluent
     */
    public function getAllWhereOr($cond) {
        return $this->db->select('*')
                        ->from($this->tableName)
                        ->where('%or', $cond);
    }
 
    /**
     * @param $item
     * @return \Dibi\Result|int
     */
    public function save($item) {
        if ($item->{$this->primaryKey}() === NULL) {
            $data = $this->itemToArray($item);
            try {
                $this->db->insert($this->tableName, $data)->execute();
                return $this->db->insertId();
            } catch (Exception $e) {
                Debugger::log(printf("An error has occurred: %s\n", $e->getMessage()), ILogger::ERROR);
                return NULL;
            }
        } else {
            $data = $this->itemToArray($item);
            try {
                $this->db->update($this->tableName, $data)
                        ->where($this->primaryKey . ' = %i', $item->{$this->primaryKey}())->execute();
                return $item->{$this->primaryKey}();
            } catch (ForeignKeyConstraintViolationException $e) {
                Debugger::log($e->getMessage(), ILogger::ERROR);
                return NULL;
            }
        }
    }
 
    /**
     * @param $id
     */
    public function delete($id) {
        try {
            $this->db->delete($this->tableName)
                    ->where($this->primaryKey . ' = %i', $id)
                    ->execute();
        } catch (Exception $e) {
            Debugger::log($e->getMessage(), ILogger::ERROR);
        }
    }
 
    /**
     * @param $data
     * @return $this->entityName|null
     */
    public function returnItemOrNull($data) {
        if ($data)
            return $this->arrayToItem($data, new $this->entityName);
        else
            return NULL;
    }
 
    public function begin() {
        $this->db->begin();
    }
 
    public function commit() {
        $this->db->commit();
    }
 
    public function rollback() {
        $this->db->rollback();
    }
 
    /**
     * @param object $item
     * @return array
     */
    public function itemToArray($item) {
        $methods = get_class_methods(key(class_implements($item)));
        $array = [];
        foreach ($methods as $method)
            $array[$method] = $item->$method();
        return $array;
    }
 
    /**
     * @param $data
     * @param $item
     * @return $this->getEntityName()
     */
    private function arrayToItem($data, $item) {
        $methods = get_class_methods(key(class_implements($item)));
        foreach ($methods as $method)
            $item->$method($data[$method]);
        return $item;
    }
 
    /**
     * @return mixed
     */
    private function getPrimaryKey() {
        $primaryKey = $this->db->getDatabaseInfo()->getTable($this->tableName)->getPrimaryKey()->getColumns();
        return $primaryKey[0]->name;
    }
 
    /**
     * @return mixed
     */
    private function getTableName() {
        return strtolower(str_replace('Mapper', '', str_replace(__NAMESPACE__ . '\\', '', get_called_class())));
    }
 
    /**
     * @return mixed
     */
    private function getEntityName() {
        return str_replace('Mapper', 'Entity', get_called_class());
    }
 
}

Při vývoji mě tlačil čas, takže ještě musím zapracovat na ošetření výjimek a trochu to i přeuspořádat a optimalizovat, ale i v tomhle stavu už to aspoň funguje.

BaseRepository

Repozitáře také pracují s daty. Patří přece do Modelu. Nepřistupují ale přímo k úložišti. O to se má starat Mapper. Výhodou takového oddělení je, že lze snadno změnit úložiště, aniž bych musel v repository přepsat víc, než jeden řádek. Repozitář by měl umět pracovat s interface odpovídajícího mapperu. BaseRepository nebude výjimkou.

V té nejjednodušší variantě obsahuje repositář jen funkce, které volají funkci Mapperu a vracej výsledek. Občas ale potřebuješ získaná data ještě nějak upravit. A to je právě práce repositáře. Neměl by se o to starat Mapper. Ten jen vytáhne surová data z úložiště a předá to výš.

BaseRepository je celkem obecný a tak neobsahuje žádnou další logiku:

<?php

namespace App\Model;

use Nette\Object;

/**
 * Class BaseRepository
 * @package App\Model
 */
abstract class BaseRepository extends Object 
{

	/** @var BaseMapper */
	private $mapper;

	/**
	 * BaseRepository constructor.
	 * @param BaseMapper $mapper
	 */
	public function __construct(BaseMapper $mapper) {
		$this->mapper = $mapper;
	}

	/**
	 * @param $id
	 * @return null|$this->mapper->getEntityName()
	 */
	public function get($id) {
		return $this->mapper->get($id);
	}

	/**
	 * @param array $cond
	 * @return $this->mapper->getEntityName()|null
	 */
	public function getOneWhere(array $cond) {
		return $this->mapper->getOneWhere($cond);
	}

	/**
	 * @return \Dibi\Fluent
	 */
	public function getAll() {
		return $this->mapper->getAll();
	}

	/**
	 * @param array $cond
	 * @return \Dibi\Fluent
	 */
	public function getAllWhere(array $cond) {
		return $this->mapper->getAllWhere($cond);
	}

	/**
	 * @param array $cond
	 * @return \Dibi\Fluent
	 */
	public function getAllWhereOr(array $cond) {
		return $this->mapper->getAllWhereOr($cond);
	}

	/**
	 * @param object $item
	 * @return array|null
	 */
	public function save($item) {
		return $this->mapper->save($item);
	}

	/**
	 * @param $id
	 */
	public function delete($id) {
		$this->mapper->delete($id);
	}

	public function begin() {
		$this->mapper->begin();
	}

	public function commit() {
		$this->mapper->commit();
	}

	public function rollback() {
		$this->mapper->rollback();
	}

	/**
	 * @param $item
	 * @return array
	 */
	public function itemToArray($item) {
		return $this->mapper->itemToArray($item);
	}

}

To je dnes vše a příště už zkusíme napsat model pro tabulku Users, která by měla uchovávat informace o uživatelích.

Celou aplikaci, tak jak by měla vypadat po dnešním díle si můžeš stáhnout: kapitola4.zip.

Redakční systém RS::RS Předchozí kapitola

Autentifikace uživatele

Redakční systém RS::RS Celý seriál

Vývoj redakčního systému v PHP

Redakční systém RS::RS Následující kapitola

Model pro tabulku Users

Komentáře (0)

Přidej svůj komentář

O mně

Jmenuji se Rudolf Svátek. Jsem lektor výpočetní techniky a PHP programátor. Stavím firemní stránky a eshopy. Aby se mi to dělalo pohodlně, vytvořil jsem redakční systém RS::RS, který ti tu nabízím k použití.

Rychlý kontakt na mně

  • Rudolf Svátek
  • Telefon:
    +420 777 828 353
  • Email:
  • Adresa:
    Josefa Hory 1097/5
    736 01 Havířov
    ČR



Tyto stránky používají Cookies. Používáním stránek s tím souhlasíte Další informace