tiny ‘n’ smart
database layer

Odkazy: dibi | API reference

Oznámení

Omlouváme se, provoz fóra byl ukončen

Lean Mapper – tenké ORM nad dibi

před 6 lety

Šaman
Člen | 2275

Souhlasím s tím, že entita funguje jako rozhraní.
Nechci po ní, aby mi nějak složitě zjišťovala název sloupce, jen aby mi vrátila nízkoúrovňový název property (tedy ten, který je uveden za ní v závorce a pokud není vyplněn, tak originální název po průchodu případnými konvencemi).
Tedy stejně jako navrhuješ $property->getExtra(), tak by mohlo existovat $property->getRowName(), resp. $entity->getRowName('author') (vrátí 'author_id').

před 6 lety

Tharos
Člen | 1042

Šaman napsal(a):

Tedy stejně jako navrhuješ $property->getExtra(), tak by mohlo existovat $property->getRowName()

To už existuje, použití viz tady.

OK, tímhle tuhle věc považuji za aktuálně vyřešenou.

před 6 lety

Tharos
Člen | 1042

@Filip111: Tak jsem ten příznak m:extra zavedl.

Hodnotou toho příznaku může být cokoliv, kromě pravé závorky (nechce se mi složitě parsovat úroveň zanoření závorek ve výrazu… snad časem, kdyby někdo cítil potřebu).

Nyní si můžeš vyrobit pro lokalizované entity něco na způsob následujícího základu:

abstract class BaseEntity extends \LeanMapper\Entity
{

    const LANG_CS = 'cs';

    const LANG_EN = 'en';

    const TRANSLATE = 'translate';

    private $translatableColumns;

    public function readTranslatableColumns()
    {
        if ($this->translatableColumns === null) {
            $this->translatableColumns = array();
            foreach ($this->getReflection()->getEntityProperties() as $entityProperty) {
                if ($entityProperty->getExtra() === self::TRANSLATE) {
                    $this->translatableColumns[] = $entityProperty->getColumn();
                }
            }
        }
        return $this->translatableColumns;
    }

}

Konkrétní entita pak může vypadat následovně:

/**
 * @property int $id
 * @property string $lang m:enum(parent::LANG_*)
 * @property string $name m:extra(translate)
 * @property string|null $description m:extra(translate)
 * @property string $isbn
 */
class Book extends BaseEntity
{
}

A v repositáři pak stačí při persistenci použít už jen $book->readTranslatableColumns().

Teď už by mělo jít vytvořit základ pro ty lokalizovatelné entity opravdu snadno a stručně.

Editoval Tharos (19. 6. 2013 22:47)

před 6 lety

Filip111
Člen | 244

@Tharos:
snažm se to vyzkoušet, ale mezitím jsem narazil na další potíž – chci entitu propojit na číselník jazyků.
Ty jsi to naznačil zjednodušeně pomocí konstatnt (mj. moc pěkná konstrukce LANG_*), já to mám v tabulce languages a primárním klíčem je code. (u všech číselníků, kde je primárním klíčem text to označuji code, takže se nejedná jen o jednu tabulku).

No a to je problém, protože ty to spojuješ pokaždé přes id.
https://github.com/…r/Result.php#L407

Ještě doplním definici property:

* @property Language $lang m:hasOne(lang:languages) m:extra(translatable)

Mj. mě zajímá jak se zachová propojení na číselník jazyků v kombinaci s m:extra(translatable)..snad dobře.

Editoval Filip111 (20. 6. 2013 9:22)

před 6 lety

Tharos
Člen | 1042

@Filip111: Tohle vyřeší vlastní konvence, které jsou v plánu. Bohužel to, že tabulka uchovávající data nějaké entity musí mít primární klíč id, je nyní jediná zadrátovaná věc z konvencí, kterou nelze změnit.

Je to víceméně relikt z doby, kdy jsem ještě nezamýšlel ORMko uvolnit a bylo v podstatě na míru mým konvencím. Já totiž i v číselnících používám jako primární klíč id. Dočasně je tedy nutné tu tabulku upravit… Anebo mít přechodně ten číselník redundantně ještě v nějaké BaseEntity v podobě konstant. Nebo snad přes nějaký dočasný pohled…

Dobrou zprávou je, že tohle ty konvence elegantně vyřeší a v noci na dnešek v mé hlavě vzaly za své poslední drobné implementační otazníky. Takže by měly spatřit světlo světa brzy.

Editoval Tharos (20. 6. 2013 9:57)

před 6 lety

bauer01
Člen | 31

Ahoj,
moc pěkná práce, klobouk dolů, zkusil jsem jej jen tak letmo použít na něčem menším a pracuje se s ním fakt pěkně. Co takhle využít milestones a issues na githubu a přehodit tam věci z roadmapy? Lépe by se sledovalo dění kolem vývoje ;-)

před 6 lety

Tharos
Člen | 1042

Ahoj, díky za odezvu. Na milestones na GitHubu se rád podívám. Aspoň bych se s nimi naučil pracovat. ;)

Co nevidět mě zase čekají práce na webu a dokumentaci, a tak určitě zvážím, jak nejlépe by šel GitHub využít.

před 6 lety

Filip111
Člen | 244

@Tharos:
Ok, budu se těšit na vlastní konvence.

Sorry, ale začal jsem s tím trochu pracovat, takže jsem narazil na další..dotaz/chybu?
Zkoušel jsem to i na tvém příkladu s překlady http://www.leanmapper.com/…lip111_2.zip

$page = new Page;
$page->dateCreated = '2012-01-01 12:00:00';
echo $page->lang;
$page->lang = 'sk';
$page->text = 'slovenský obsah stránky';

Vytvořím novou entitu a aniž bych nastavil property lang, chci z ní číst. Předpokládal bych, že se mi vrátí null, ale skončí to výjimkou Missing 'lang' value for requested row.

To je podle mě docela vážná chyba – entita by se měla tvářit jako konzistentní objekt, který má vždy k dispozici všechny svoje property, bez ohledu na to jestli jsou prázné či naplněné.

před 6 lety

Tharos
Člen | 1042

@Filip111: Tohle je záměr. Silně si za tímhle chováním stojím a rád to vysvětlím.

Pokud entita obsahuje nějakou neinicializovanou položku, pokus o její přečtení IMHO má skončit chybou. Pokud by taková akce vrátila null, je nemožné poznat, jestli je v položce uložená skutečně hodnota null, anebo jestli jen položka není zinicializovaná.

Představ si, že se například překlepneš v nějakém mapování a Row, který entitě předáš, bude namísto description obsahovat decsription. Pak by volání $book->description vracelo null a to by nesmírně mátlo (protože v databázi by description bylo).

Já také chci, aby entita byla za všech okolností konzistentní objekt. A proto vyžaduji, aby všechny položky, které se někdo snaží číst, byly zinicializované a nedocházelo k různým překvapením. Kdo potřebuje pracovat jen s nějakou podmnožinou entity, lze k tomu využít dědičnost (jako jsem tu uváděl příklad s Article a ArticlePreface).

Kdyby PHP znalo undefined, tady by pro něj bylo ideální využití…

Edit: Ještě přihodím jeden argument pro současné chování :).

/**
 * @property string $name
 */
class Book extends Entity
{
}

$book = new Book;
$name = $book->name;

Co by měla obsahovat proměnná $name? null? A není to logický nesmysl, když přece položka $book->name podle definice null obsahovat nemůže (není typu string|null)? :)

Editoval Tharos (20. 6. 2013 14:25)

před 6 lety

Tharos
Člen | 1042

Ahoj,

právě jsem otevřel release větev v1.4.0.

Přehled všech změn, které se odehrály od poslední stable verze 1.3.0, jsou na webu knihovny.

Z webu je patrné, že jsem ty persistence M:N vazeb posunul až za podporu vlastních konvencí, protože udělat ty konvence jako první dává lepší smysl (protože ta persistence také bude konvence respektovat).

Kdybyste kdokoliv měli v rukávu nějaký bug nebo chtěli ještě něco drobného do verze 1.4.0 začlenit, teď je chvíli prostor. Prostě než to mergnu do masteru a otaguju, čímž verzi 1.4.0 „formálně“ vydám.

před 6 lety

Filip111
Člen | 244

@Tharos:
Toho jsem se přesně bál…že si to zase odůvodníš :)

Co by měla obsahovat proměnná $name? null…nemám s tím problém, to že podle definice nesmí obsahovat null vnímám tak, že mi nepůjde nad touto entitou persist, protože name nesmí obsahovat null (a pravděpodobně i v DB je definice sloupce NOT NULL)

Nedalo mi to a zkusil jsem Doctrine2. Vytvořím entitu, všechny její prvky se mi vrátí jako null, pokud nejsou nastavené. Zkoušel jsem ještě nastavit jednomu prvku aby byl not null, ale úplě to ignoruje. Když mu nastavím nullable=false tak mi stejně dovolí vložit do něj null i uložit (padne to až na vyjímku při ukládání, protože v DB je sloupec nastaven na not null). Nejsem v Doctrine2 tak zdatnej, možná dělám něco blbě.

Já bych tedy preferoval, aby se vracelo u neinicializovaných prvků null. V případě property, kde null není povoleno jen zkontrolovat před uložením do DB, zda je null.

Každopádně null hodnota u neinicializovaných prvků mi přijde praktická (a člověk je na to celkem zvyklý). Běžně si vytvořím ve fasádě entitu (a je úplně jedno jak vznikla jestli načtením z DB nebo vytvořením z formuláře) a pošlu ji dál ke zpracování. Následné funkce vůbec neřeší, jak vznikla a nějak s ní pracují dál.
Jednoduše stylem:

if ($entity->cat) ...

Pokud se ale bude jednat o novou entitu s některými neinicializovanými prvky, bude to peklo a každou chvíli někde vyskočí výjimka. A místo jednoduchých ifů dávat všude catch mi přijde docela nesmysl.

před 6 lety

Michal Vyšinský
Člen | 614

Ahoj,

Když mu nastavím nullable=false tak mi stejně dovolí vložit do něj null i uložit (padne to až na vyjímku > při ukládání, protože v DB je sloupec nastaven na not null). Nejsem v Doctrine2 tak zdatnej, možná dělám > něco blbě.

Atribut „nullable“ v anotaci @Column se bere v potaz pouze při vytváření schématu (schémata ?) databáze z entity. Jakoukoliv následnou vstupní validaci si musíš už napsat sám.

před 6 lety

Tharos
Člen | 1042

Filip111 napsal(a):

Toho jsem se přesně bál…že si to zase odůvodníš :)

:)

Co by měla obsahovat proměnná $name? null…nemám s tím problém, to že podle definice nesmí obsahovat null vnímám tak, že mi nepůjde nad touto entitou persist, protože name nesmí obsahovat null (a pravděpodobně i v DB je definice sloupce NOT NULL)

Není to bohužel tak bezproblémové, jak se na první pohled zdá. Já osobně entity hojně využívám k vyjádření nějaké složitější business logiky. A považ třeba jen následující kód:

/**
 * @property bool $banned
 * @property bool $hasPaid
 */
class Visitor extends Entity
{

    public function canViewArticle()
    {
        return !$this->banned and $this->hasPaid;
    }

}
$visitor = new Visitor;
if ($visitor->canViewArticle()) {
    // Kdo ví, jestli může! S neinicializovanými položkami je loterie, kdo touhle podmínkou projde.
}

Tohle je dokonalé podhoubí pro velmi zákeřné chyby. Nehledě na to, že ta logika může být mnohem spletitější.

Nedalo mi to a zkusil jsem Doctrine2. Vytvořím entitu, všechny její prvky se mi vrátí jako null, pokud nejsou nastavené. Zkoušel jsem ještě nastavit jednomu prvku aby byl not null, ale úplě to ignoruje. Když mu nastavím nullable=false tak mi stejně dovolí vložit do něj null i uložit (padne to až na vyjímku při ukládání, protože v DB je sloupec nastaven na not null). Nejsem v Doctrine2 tak zdatnej, možná dělám něco blbě.

To je ale z mého pohledu nedostatek v Doctrine (i když tohle mé hodnocení je povrchní – určitě to tam půjde nějak hezky řešit). Prostě když mám property, která nesmí být null, nesmím při jejím čtení dostat null.

Tady jde i o kód, který s tou získanou hodnotou dále pracuje a třeba na deklarovaný typ spoléhá. To mám pak u položek, které mají v definici řečeno, že nemohou obsahovat null, ještě testovat, jestli ho náhodou přece jenom neobsahují?

/**
 * @property Author $author
 */
class Book extends Entity
{
}
$book = $bookRepository->find(1);
echo $book->author->name; // Trying to get property of non-object in…

Já bych tedy preferoval, aby se vracelo u neinicializovaných prvků null. V případě property, kde null není povoleno jen zkontrolovat před uložením do DB, zda je null.

Každopádně null hodnota u neinicializovaných prvků mi přijde praktická (a člověk je na to celkem zvyklý).

Tohle je ale podle mě prostě špatný návyk.

Běžně si vytvořím ve fasádě entitu (a je úplně jedno jak vznikla jestli načtením z DB nebo vytvořením z formuláře) a pošlu ji dál ke zpracování. Následné funkce vůbec neřeší, jak vznikla a nějak s ní pracují dál.
Jednoduše stylem:

if ($entity->cat) ...

Pokud se ale bude jednat o novou entitu s některými neinicializovanými prvky, bude to peklo a každou chvíli někde vyskočí výjimka. A místo jednoduchých ifů dávat všude catch mi přijde docela nesmysl.

Na místě, kde vytváříš entitu, je Tvou zodpovědností dostat ji do takového stavu, kdy je potřebně zinicializovaná. Obzvláště pokud ji pak předáváš někam dál.

Nemyslím si, že by to bylo až tolik práce navíc. A pořád jsem přesvědčen, že to je jediný způsob, jak mít entity skutečně spolehlivé a chovající se podle očekávání.

Editoval Tharos (20. 6. 2013 16:10)

před 6 lety

Vojtěch Dobeš
Člen | 1317

@Tharos +1, tenhle přístup se mi líbí :).

před 6 lety

Jan Tvrdík
Nette guru | 2550

Možná by stálo za to případ podporu pro něco jako m:default. Viz také https://github.com/…oty-v-entitě

před 6 lety

Tharos
Člen | 1042

@Jan Tvrdík: Dobrý nápad, to by leccos vyřešilo. :) Kromě m:default mě napadá i následující možnost zápisu:

@property bool $name = 'Default name'
@property bool $isPublished = 0
@property string|null $description = null

Plus mě napadá, že by v entitě mohlo být možné přepsat nějakou protected metodu initialize(), kterou by volal konstruktor a pomocí které by také bylo možné výchozí hodnoty zinicializovat:

/**
 * @property int $id
 * @property string $name
 * @property bool $published
 */
class Book
{

    protected function initialize()
    {
        $this->assign(array(
            'name' => 'Default name',
            'published' => true,
        ));
    }

}

Editoval Tharos (20. 6. 2013 17:43)

před 6 lety

tomas.lang
Člen | 54

@Tharos +1 co se týče inicializace hodnot :-)

před 6 lety

Filip111
Člen | 244

@CherryBoss:
ok, to jsem nevěděl – hledal jsem v dokumentaci, ale tam nullable moc detailně neřeší. Každopádně jsem si říkal, že dělam něco blbě, protože by to už dávno někdo řešil vzhledem k rozšířenosti Doctrine.

@Tharos:
Nedovedu si představit inicializovat entitu, kde je spoustu properties a ke každé explicitně uvádět že má hodnotu null nebo prázdný řetězec. Akorát to natáhne/znepřehlední kód a budu pořád otrocky něco opisovat.

Nicméně defaulní hodnota tento problém (vlastně feauture :) řeší k mé plné spokojenosti. Varianta $name = 'Default name' mi přijde přirozenější, ale pokud bys upřednostňoval m: anotace, je mi to jedno.

Jsem rád, že se do diskuse přidal vojtech.dobes a Jan Tvrdík. Považuju to trochu za záruku kvality – určitě budou mít přínosnější návrhy a komentáře než já ;)

Editoval Filip111 (20. 6. 2013 21:00)

před 6 lety

Tharos
Člen | 1042

Ahoj,

tak jsem dneska dopoledne vydal verzi 1.4.0. Přehled všech změn je v changelogu na webu.

Aktualizoval jsem i roadmap, aby bylo zřejmé, jakým směrem chci vývoj dále směřovat.

Než se ale do těch bodů pustím, chci zase pohnout s dokumentací. Aktuální dokumentaci upravím a doplním pro řadu 1.4.x, přičemž od řady 1.5.x bude existovat více dokumentací vedle sebe. Infrastrukturu už pro to mám víceméně hotovou…

Díky vám všem za odezvu, kterou jste mi zde poskytli!

před 6 lety

Tharos
Člen | 1042

@Filip111: Přidal jsem v develop větvi protected metodu initDefaults(), kterou volá konstruktor a pomocí které se dají defaultní hodnoty nastavit.

Každopádně je to ale jen první část řešení těch defaultních hodnot. Přibude ještě podpora v anotacích:

@property string $name = 'Default name'

před 6 lety

Tharos
Člen | 1042

Ahoj,

od dnešního dne Lean Mapper disponuje v develop větvi velmi zajímavou novou funkconalitou, a tou je podpora pro vlastní konvence (a nejen databázové).

Považuji tuhle vrstvu za výrazný posun vpřed. Když jsem develop verzi nasadil na jednu svojí aplikaci, ve které mám databázi navrženou podle „nestandardních“ konvencí, anotace se nádherně zjednodušily a také se mapování přesunulo na jedno místo. Zkrátka mi hned došlo, že tohle byl krok tím správným směrem. A že jsem ani pořádně netušil, že tohle v Lean Mapperu skutečně chybělo, byť jde jen o pohodlí :).

Mapper (vlastní konvence, chcete-li) stojí na tomto jednoduchém rozhraní:

namespace LeanMapper;

interface IMapper
{

    /**
     * Returns primary key name from given table name
     *
     * @param string $table
     * @return string
     */
    public function getPrimaryKey($table);

    /**
     * Returns table name from given fully qualified entity class name
     *
     * @param string $entityClass
     * @return string
     */
    public function getTable($entityClass);

    /**
     * Returns fully qualified entity class name from given table name
     *
     * @param string $table
     * @return string
     */
    public function getEntityClass($table);

    /**
     * Returns table column name from given fully qualified entity class name and entity field name
     *
     * @param string $entityClass
     * @param string $field
     * @return string
     */
    public function getColumn($entityClass, $field);

    /**
     * Returns entity field name from given table name and table column
     *
     * @param string $table
     * @param string $column
     * @return string
     */
    public function getEntityField($table, $column);

    /**
     * Returns relationship table name from given source table name and target table name
     *
     * @param string $sourceTable
     * @param string $targetTable
     * @return string
     */
    public function getRelationshipTable($sourceTable, $targetTable);

    /**
     * Returns name of column that contains foreign key from given source table name and target table name
     *
     * @param string $sourceTable
     * @param string $targetTable
     * @return string
     */
    public function getRelationshipColumn($sourceTable, $targetTable);

    /**
     * Returns table name from repository class name
     *
     * @param string $repositoryClass
     * @return string
     */
    public function getTableByRepositoryClass($repositoryClass);

}

Všimněte si, jak triviální je výchozí implementace vyjadřující preferované konvence.

Možnosti mapperu jsou velké. Umožňuje elegantně vyřešit převod $authorName na author_name, vyřešit rozdělení entit do více jmenných prostorů, vypořádat se s primárními klíčem nad textovým sloupcem code… a dalo by se pokračovat. Většinu záležitostí lze také vyjádřit velmi snadno. Pokud například spojovací tabulky pojmenováváte stylem book_x_tag, stačí mít takovýto vlastní mapper:

class CustomMapper extends LeanMapper\DefaultMapper
{

    protected $relationshipTableGlue = '_x_';

}

Možná se ptáte, jak moc velký BC break zavedení něčeho podobného přináší. Bylo výzvou tohle naimplementovat tak, aby dopady na „vysokoúrovňové“ API byly minimální. Myslím ale, že se to povedlo. :)

Nově stačí jen předávat repositářům kromě instance DibiConnection i nějakou implementaci IMapper. To je vše. Takže v typické Nette aplikaci, kde jsou repositáře zaregistrované jako služby v DI kontejneru, stačí jen přidat službu implementující IMapper a auto-wiring už se o vše postará. To, že se pak mapper uvnitř repositářů, entit, resultů atp. dostane všude tam, kde je ho zapotřebí, už si řeší Lean Mapper vnitřně sám.

Za zmíňku stojí následující chování:

$book = new Book;
$book->title = 'Nová kniha';
$book->description = 'lorem isupm';

$bookRepository->persist($book);

Pokud budeme chtít položku description persistovat do sloupce custom_description, bude to fungovat? Ano, protože entita přejme mapper z repositáře a persituje se už podle jeho pravidel.

API tedy zůstalo stejně přívětivé.

Pro úplnost uvedu, že spolu se zavedením mapperu vzala za své proměnná $defaultEntityNamespace v repositářích, protože ta je nově součástí mapperu (kam samozřejmě patří). A na závěr už bych jen rád dodal, že možnost doupřesnit mapování v anotacích zůstala zachována a má i nadále nevyšší prioritu.

Editoval Tharos (24. 6. 2013 22:49)

před 6 lety

Šaman
Člen | 2275

Super! Člověk si odjede na prodloužený víkend trochu zašermovat a jeho oblíbené ORM je zase o kus dál :)
Jdu si s tím pohrát a zjistit, jak co nejjednodušeji zařídit, aby mi metoda $bookRepository->findByDescription($description); vyhledávala nad custom_description s tím, že si tento název sloupce zjistí sama. Pokud bych narazil na nějaké neočekávané chování, dám vědět.


Ještě mě napadla jedna (a asi poslední) věc, kterou jsem kdy v ORM využil a tady nevím, jak toho dosáhnout. I když by asi šla přetížit metoda $repository->getEntityClass(), ale potřeboval bych jí nejprve předat načtená data.

Jde o to, že mám například několik typů výrobků (computer, television) v tabulce product (obstarávanou pomocí ProductRepository).
A jeden sloupec bude type a já bych rád podle tohoto sloupce změnil třídu, která se mi má z načtených dat vytvořit (např. class Computer extends Product). Tedy $productRepository->getById(10) mi vrátí nějakou třídu dědící z Product podle toho, co je vlastné pod tímto id uložené.
Doufám, že jsem to vysvětlil srozumitelně :)

Editoval Šaman (24. 6. 2013 16:44)

před 6 lety

Tharos
Člen | 1042

@Šaman: Oba Tvé problémy mají jednoduché řešení. :)


Ad findByDescription: Při implementaci mapperu jsem už tenhle Tvůj požadavek měl na paměti ;), takže tam, kde jsi doposud volal:

$property = $this->getReflection()->getEntityProperty($property);

stačí nově jen volat:

$property = $this->getReflection($this->mapper)->getEntityProperty($property);

Property, která se Ti vrátí, již mapperu využívá a při volání $property->getColumn() Ti vrátí ve výše uvedeném případě požadované custom_description.

Jak jsme se spolu bavili po Jabberu, tohle není problém mít statické… Pak jen ale musíš mapper předat z repositáře, protože staticky se nikde neudržuje. Určitě víš, jak to myslím. :) Volání pak bude vypadat zhruba:

$property = static::getReflection($mapper)->getEntityProperty($property);

Ad typ entity: Metoda createEntity v abstraktním repositáři disponuje užitečným parametrem $entityClass.

V Tvém případě by tedy mělo stačit následující:

public function getById($id) // or find($id)
{
    $row = $this->connection->select('*')->from($this->getTable())->where('id = %i', $id)->fetch();
    if ($row === false) {
        throw new \Exception('Entity was not found.');
    }
    $entityClass = $this->translateTypeToEntityClass($row['type']); // TODO: implement helper method
    return $this->createEntity($row, $entityClass);
}

Editoval Tharos (24. 6. 2013 17:25)

před 6 lety

besanek
Člen | 128

Takže už zase budu přesouvat $defaultEntityNamespace ? :D
Rozhodně dobrá práce! Byť konvence momentálně nevyužiji, tak v nich vidím velký potenciál. Hlavně co se týče při nasazování na již existující databáze.

Pokud bych si troufl Lean Mapper hodnotit po týdnu práce, určitě bych použil slovní spojení “nehází klacky pod nohy “. Používal jsem NotORM, Doctrine. Super nástroje, ale často jsem narazil na problém, který jsem neuměl čistě vyřešit.
Když jsem si chtěl v Lean Mapperu udělat jednoduchou cache, stačilo několik jednoduchých řádků do base repository.
Rozhodně pokračuj v tom co děláš :)

@Šaman [OT]: Zašermovat? Snad jsi nebyl v Budyni na Koruně? :)

před 6 lety

Tharos
Člen | 1042

besanek napsal(a):

Takže už zase budu přesouvat $defaultEntityNamespace ? :D

Naposledy. :)

Pokud bych si troufl Lean Mapper hodnotit po týdnu práce, určitě bych použil slovní spojení “nehází klacky pod nohy “. Používal jsem NotORM, Doctrine. Super nástroje, ale často jsem narazil na problém, který jsem neuměl čistě vyřešit.
Když jsem si chtěl v Lean Mapperu udělat jednoduchou cache, stačilo několik jednoduchých řádků do base repository.
Rozhodně pokračuj v tom co děláš :)

Díky za zhodnocení. :) Pokračovat rozhodně budu. Dorazilo mi i pár reakcí přes soukromé zprávy a můžu říct, že zatím přijetí Lean Mapperu překonalo má očekávání (přece jenom konkurence mezi podobnými nástroji je nemalá). Nad má původní očekávání je i to, jak se umí vypořádat se záludnostmi, které zde zazněly. Pořád čekám, až někdo přijde s něčím, na čem si vyláme zuby. :)

Mile mě překvapilo také včerejší vydání Dibi 2.1, což znamená, že ani ta „stará dobrá“ knihovna, nad kterou je Lean Mapper postaven, není úplně opuštěná a bez perspektivy. :)

Editoval Tharos (24. 6. 2013 21:00)

před 6 lety

Šaman
Člen | 2275

@Tharos: Ten popsaný způsob jak zajistit polymorfismus produktů je nepraktický v tom, že bych musel stejnou rutinu řešit v každé dotazovací metodě (findByPrice(), getByName(), ..). Ale navedl jsi mě ke správné metodě, konkrétně createEntity, kterou by mělo stačit mírně přetížit, aby se toto chování projevilo všude. Díky.

@besanek: [OT] Nikoliv, tentokrát to nebyla historická akce, ale fantasy LARP Zlenice.

Editoval Šaman (24. 6. 2013 21:06)

před 6 lety

Tharos
Člen | 1042

Ahoj,

Lean Mapper v develop větvi disponuje ode dnešního rána jednou užitečnou novou funkcionalitou – podporou správy a persistence jednoduchých M:N vazeb.

Jednoduchá M:N vazba je taková vazba, která je v relační databázi reprezentována spojovací tabulkou, která neobsahuje nic jiného, než odkazy do spojovaných tabulek a volitelně umělý primární klíč. Zpravidla má tedy taková tabulka dva nebo tři sloupce. Pokud potřebujete v Lean Mapperu pracovat se spojovací tabulkou, která pro každou vazbu obsahuje i nějaké další doplňující informace (zda je vazba aktivní, datum jejího vzniku…), best practice je mít pro takovou tabulku samostatnou entitu (a podle potřeby i repositář).

Lean Mapper nativně vrací kolekce entit v polích ($repository->findAll(), $book->tags…), přičemž každý si může tohle chování velmi snadno upravit a získávat kolekci plně podle svých potřeb. Tohle řešení mi přijde ideální, ale bohužel neumožňuje zavést API pro správu vazeb v plném rozsahu tak, jaké je popsané zde.

Současné API vypadá následovně:

// Nějaká úvodní kaše

class Mapper extends LeanMapper\DefaultMapper
{

    protected $defaultEntityNamespace = null;

    public function getPrimaryKey($table)
    {
        if ($table === 'tag') {
            return 'code';
        }
        return parent::getPrimaryKey($table);
    }

}

/**
 * @property string $code
 * @property string $name
 */
class Tag extends LeanMapper\Entity
{
}

/**
 * @property int $id
 * @property Tag[] $tags m:hasMany
 * @property DateTime|null $released
 * @property string $title
 * @property string|null $web
 * @property string $slogan
 */
class Application extends LeanMapper\Entity
{
}
$mapper = new Mapper;
$applicationRepository = new ApplicationRepository($connection, $mapper);
$tagRepository = new TagRepository($connection, $mapper);

$application = $applicationRepository->find(1);

$tag = $tagRepository->find('JavaScript'); // this is our primary key...

$application->addToTags($tag);

$application->removeFromTags($tag);

$application->addToTags('JavaScript');

$application->removeFromTags('JavaScript');

$applicationRepository->persist($application);

Jak vidno, tagy lze přidávat a odebírat buďto na základě instance Tag, anebo na základě ID tagu (v našem příkladu kódu).

Co je asi samozřejmé je, že pokud přistoupíte i před persistencí ke kolekci $tags (například tagy vypíšete), přidané a odebrané tagy se již projeví. V paměti tyto změny provedené už jsou, jen ještě nejsou persistované v databázi.

Co ale osobně považuji za velkou vychytávku je, jaké dotazy se vygenerují při volání persist(). Lean Mapper si inteligentně hlídá, co je zapotřebí v databázi přidat a odstranit a stav sesynchronizuje velmi malým počtem dotazů:

  1. Jedním multi-insertem hromadně vkládajícím potřebné nové vazby
  2. Voláním DELETE pro jednotlivé vazby s tím, že pokud například aplikace má tři vazby na tag JavaScript a dvě se mají odstranit, využije se LIMIT (DELETE ... WHERE `code` = 'JavaScript' LIMIT 2).

Také platí, že pokud například desetkrát odeberete vazbu na tag ‚SQL‘, který ale aplikace nemá ani jednou přiřazený, žádné dotazy se negenerují.


Aby vše takhle hezky mohlo fungovat, existuje jedno drobné omezení. Pokud vytváříte například novou aplikaci (new Application), je zapotřebí ji před tím, než jí začnete přiřazovat tagy, persistovat.

$application = new Application(array(
    'title' => 'New application',
    'slogan' => 'lorem ipsum',
));

$applicationRepository->persist($application);

$application->addToTags('PHP');
$application->addToTags('JavaScript');

$applicationRepository->persist($application);

Časem možná tohle omezení odstraním, zatím se za něj omlouvám. :) Při úpravě již existují entity načtené z repositáře takovéto persistování nadvakrát samozřejmě zapotřebí není:

$application = $applicationRepository->find(1);

$application->title = 'New title';

$application->addToTags('PHP');
$application->addToTags('JavaScript');

$applicationRepository->persist($application);

V plánu mám ještě metodu pro zjištění, zda $tags obsahují nějaký tag nebo ne (syntaxe bude asi $application->hasInTags($tag)).

Ono se bez toho dá ale ve spoustě případů žít. Pokud například máte formulář pro vytvoření aplikace obsahující mimo jiné i pole checkboxů pro výběr, které tagy se mají aplikaci rovnou přiřadit, lze pak jednoduše volat:

foreach ($checkboxes as $checkbox) {
    if ($checkbox->isChecked()) {
        $application->addToTags($checkbox->tagId);
    }
}

a při následné úpravě takové entity lze použít konstrukci:

foreach ($checkboxes as $checkbox) {
    $application->removeFromTags($checkbox->tagId);
    if ($checkbox->isChecked()) {
        $application->addToTags($checkbox->tagId);
    }
}

Je to takový hodně pseudokód :), ale myslím, že princip je z toho zřejmý. Vůbec nevadí, že se při update pro každý tag zavolá nejprve removeFromTags, protože vnitřní inteligence zajistí, že se při persistování odeberou přesně ty tagy, které je odebrat zapotřebí, a nápodobně se přidají i nové.

Mimochodem, implementace téhle správy byla překvapivě snadná. Celá tak nějak „zaplula“ do koncepce Lean Mapperu bez jakýchkoliv hacků nebo berliček


A když se tak koukám na roadmap, říkám si, verze 1.5 je v dohlednu. :)

Editoval Tharos (26. 6. 2013 15:30)

před 6 lety

castamir
Člen | 631

Takovej drobnej dotaz – proč se v getRowData automaticky mění personalId na personal_id když jsem žádné explicitní mapování v anotaci neuváděl? Je to matoucí, protože pokud neuvedu v anotaci tohle mapování, tak metoda getData zařve na neznámém personalId, protože má k dispozici informaci o personal_id, ale personalId už ne.

takhle je to duplicitní. A co se stane, když tam nedám v té anotaci mapování personalId ⇒ personal_id ale např. personal_number?

Editoval castamir (27. 6. 2013 11:37)

před 6 lety

Tharos
Člen | 1042

Neměl bys kus kódu k nahlédnutí? Abych to mohl reprodukovat… Stačí jen nástřel.

Rád se na to podívám a případný bug opravím. Anebo si chování obhájím ;), možná jde jen o nějaké nedorozumění. Žádné takové mapování by se samovolně odehrávat nemělo.

Editoval Tharos (27. 6. 2013 11:25)

před 6 lety

castamir
Člen | 631

už nic

Editoval castamir (27. 6. 2013 11:37)

před 6 lety

castamir
Člen | 631

Další problém mi tu přidělává nenašeptávání =/

Ad předchozí příspěvek – já jsem fakt blbec. Promiň :D

Editoval castamir (27. 6. 2013 11:36)

před 6 lety

Tharos
Člen | 1042

Super, díky za kód. Ani ho nemusím spouštět :). „Problém“ je v tom, že ty máš hodnotu položky personalId persistovanou v databázovém sloupci personal_id. To není v souladu s výchozími konvencemi (podle nich by se ten sloupec v databázi měl také jmenovat personalId), a tak je zapotřebí Lean Mapperu napovědět. Napovědět lze buďto přes explicitní uvedení sloupce v závorce v definici entity, anebo na úrovni vlastního mapperu:

class Mapper extends DefaultMapper
{

    public function getColumn($entityClass, $field)
    {
        if ($entityClass === 'Users' and $field === 'personalId') {
            return 'personal_id';
        }
        return parent::getColumn($entityClass, $field);
    }

    public function getEntityField($table, $column)
    {
        if ($table === 'users' and $column === 'personal_id') {
            return 'personalId';
        }
        return parent::getEntityField($table, $column);
    }

}

Tohle je samozřejmě totálně ad-hoc, typicky se dají konvence v mapperu zobecnit. Pak už Lean Mapper ví, co k čemu patří.

Entita zkrátka při „nedodržení konvencí“ bez navedení neví, že hodnotu pro svou položku personalId má hledat v „nízkoúrovňových datech“ ve sloupci personal_id (a mohlo by to být třeba i my_fancy_personal_id…).


Edit: vyřešeno, tak bezva. :)

Editoval Tharos (27. 6. 2013 11:50)

před 6 lety

Tharos
Člen | 1042

Ahoj,

mám takový malý RFC. Rád bych nyní významně vylepšil parser anotací a rád bych se dopředu svěřil se svými úmysly.

1) Oddělení komentáře

Stávající parser se snaží ponechat prostor pro komentář, takže anotace může vypadat například takto:

@property DateTime $created  Date and time of creation

Problémem je, že pokud například napíšete tohle:

@property Author $maintainer m:hasOne (maintainer_id)

parser si myslí, že (maintainer_id) už je komentář. Je sice hezké stanovit, že mezi příznakem a závorkami za ním nesmí být mezera, ale v tomhle se překlepnout je raz dva a problémem je, že to tiše projde. Jenomže co kdyby v těch závorkách skutečně byl komentář… Parser to prostě nemá šanci jednoznačně poznat.

Navrhuji tedy komentář uvádět následovně:

@property Author $maintainer m:hasOne(maintainer_id) # Person who cares about the application

Parser by pak mohl být tolerantní k různým mezerám mezi příznaky a závorkami a hlavně by parsování skončilo výjimkou, pokud by se objevil zjevný problém.

2) Nové příznaky

Nyní Lean Mapper zná následující příznaky:

m:hasOne
m:hasMany
m:belongsToOne
m:belongsToMany

m:enum
m:filter
m:extra

Mně by přišlo ideální, aby rozumněl těmto příznakům:

m:hasOne
m:hasMany
m:belongsToOne
m:belongsToMany

m:enum
m:filter
m:passthru
m:hintOnly

Příznak m:hintOnly říká, že položka je nadefinovaná pomocí metod (existují například metody getName() a setName($name)), ale anotace je přítomná kvůli napovídání v IDE. V rámci anotace by už pak nešlo použít nic ve smyslu m:hasOne atp., protože to už si řeší metody, které položku vyjadřují. Jde skutečně jenom o napovídání, a proto název hintOnly. Příklad za sto slov:

/**
 * @property string $name m:hintOnly
 */
class Author extends Entity
{

    public function getName()
    {
        return strtoupper($this->row->name);
    }

    public function setName($name)
    {
        $this->row->name = strtolower($name);
    }

}

$author = new Author;
$author-> // here comes the IDE hint

Příznak m:passthru má jednoduchý význam. Umožňuje prohnat hodnotu těsně před tím, než se přiřadí do patřičné položky v LeanMapper\Row (respektive těsně poté, co se z LeanMapper\Row přečte) skrze nějakou uživatelskou funkci. Užití je jednoduché:

/**
 * @property string $email m:passthru(checkEmail)
 */
class Author extends Entity
{

    private function checkEmail($email)
    {
        if (!Validator::isEmail($email)) {
            throw new Exception('Invalid e-mail address.');
        }
        return $email;
    }

}

$author = new Author;
$author->email = 'test'; // throws exception

Navrhuji, že by se detail příznaku mohl volitelně sestávat z jedné nebo ze dvou částí:

m:passthru(checkEmail) – checkEmail se použije při zápisu do položky i při čtení z položky
m:passthru(localizeEmail|checkEmail) – localizeEmail se použije při čtení položky, checkEmail při zápisu do položky
m:passthru(localizeEmail|) – použije se pouze localizeEmail při čtení položky, zápis do položky probíhá standardně
m:passthru(|checkEmail) – použije se pouze checkEmail při zápisu do položky, čtení z položky probíhá standardně

Přijde mi, že tohle je velmi efektivní způsob, jak stručně řešit všemožné specifické validace.

3) Vlastní příznaky

No a pak mě napadla ještě jedna taková věc. Zrušil bych příznak m:extra a namísto toho bych zavedl, že jakýkoliv jiný příznak (tj. cokoliv ve tvaru m:<nazev>(volitelné parametry)) by se nezahodilo a šlo by to pak v entitě číst z LeanMapper\Reflection\Property. Existovaly by metody ve smyslu hasCustomFlag($name) a readCustomFlag($name).

Mělo by to velmi široké využití. Například zde probírané překlady by pak mohly vypadat následovně:

/**
 * @property int $id
 * @property Lang $lang m:hasOne
 * @property string $name m:translate
 * @property string $description m:translate
 * @property string $keywords m:searchable(fulltext,catalogue)
 */
class Page extends Entity
{
}

Přidal jsem do ukázky i vlastní příznak m:searchable s parametry, který by mohl například řídit, zda a jakým způsobem lze v obsahu stránky vyhledávat.

4) Defaultní hodnoty

U položek typu boolean (bool), integer (int), float a string bych umožnil definici výchozí hodnoty v anotaci následujícím způsobem:

@property int $limit = 5
@property string $name = 'Default name'
@property string $description = "Lorem ipsum dolor sit amet"
@property bool $published = true

Samozřejmě v entitě zůstane metoda initDefaults() umožňující zinicializovat třeba i položky typu DateTime atp. Navrhuji, aby nastavování defaultní hodnoty stejné položky z anotace i z initDefaults() metody zároveň vedlo k výjimce.


Připadají vám tyto návrhy dobré a užitečné? Chybí tomu něco? Anebo tomu něco přebývá? Předem moc díky za názory.

Editoval Tharos (28. 6. 2013 8:35)

před 6 lety

Šaman
Člen | 2275

Mě se to líbí.

před 6 lety

Michal III
Člen | 84

Mně se to líbí rovněž. Co se týče výchozích hodnot objektů, nefungovalo by následující?

<?php
/**
  @property DateTime $date = new DateTime('yesterday')
*/
?>

Každopádně se mi líbí každičký jednotlivý návrh a celá knihovna se mi velice zamlouvá víc a víc.

před 6 lety

Tharos
Člen | 1042

@Michal III: Mít možnost takto zinicializovat například DateTime by bylo super, ale otázkou je, co s případnými složitějšími konstrukcemi. Když bude umožněno přiřadit:

new DateTime('yesterday')

někdo se toho chytne a pokusí se přiřadit:

new MyAuthor(array('id' => GlobalCounter::makeId(), "name" => strtoupper("Default \"name\""))

:) Psát parser, který by něco takového zchroustal, by bylo strašné… Anebo by to vedlo na nějaký ošklivý eval.

Ještě se ale podívám, co umožňuje zapsat například Neon. Pravdou je, že v omezené míře by to možná umožnit šlo (v duchu „název třídy a nějaké skalární atributy“ nebo volání nějaké továrny).

Tohle parsování není kešované na disku, ale jen v paměti. Zatím to není vůbec žádný problém, parsování je jednoduché, provede se pro každý typ entity jenom jednou a co Lean Mapper poctivě profiluji, úzká hrdla jsou pořád v Dibi. :) Nerad bych parsování zesložitil natolik, aby se vyplatilo kešovat na disk.

Určitě to ale ještě zvážím, díky za nakopnutí. :)

před 6 lety

Michal III
Člen | 84

@Tharos: Asi bych to řešil způsobem, který by byl opravdu značně omezený, tedy jen na skalární typy a případně pole se skalárními typy (array(1, 3) či [1, 3]). Na ony složitější konstrukce by tu opravdu byla metoda initDefaults(), jelikož ty „zrůdnosti“ by v anotacích ani nevypadaly moc přehledně. Každopádně pro jednoduché použití by to možná v rámci přehlednosti stálo za to.

Pokud by výchozí přiřazování objektů bylo z větší části (> 70%) používáno jednoduchým způsobem, tedy se skalárními typy, stálo by to nejspíš za implementaci, jinak bych to asi neřešil a ponechal Tvůj původní návrh :-).

před 6 lety

Tharos
Člen | 1042

Jenom taková technická… Odjíždím na dovolenou a až do 7. července budu mít velmi omezený přístup k internetu. Na případné bug reporty|dotazy|nápady budu moci reagovat až po svém návratu. Díky. :)

před 6 lety

castamir
Člen | 631

hlavně ať to není case sensitive ať půjde udělat oba zápisy ;o)

@property bool $published = true
@property bool $published = TRUE

před 6 lety

Šaman
Člen | 2275

Taky píšu TRUE a FALSE jako konstanty.

OT: @castamir: Nesouvisí nějak tvůj nick s (Rains of) Castamere? :)

před 6 lety

Filip111
Člen | 244

@Tharos:
mě se to líbí – vlastní m:příznaky jsou fajn,
možná k defaultním hodnotám bych přidal ještě možnost v anotaci definovat jako defautlní hodnotu array(). Stačí prázdné, bez složitých konstrukcí.

před 6 lety

castamir
Člen | 631

před 6 lety

besanek
Člen | 128

Moc pěkný RFC, hlavně velké + za m:passthru.

K tomu přiřazování defaultních objektů. Zápis

@property DateTime $date = new DateTime('yesterday')

se mi moc nelíbí. „new DateTime“ je redundantní, protože nic jiného než objekt DateTime by v $date vůbec být neměl. Navíc se mi to zdá jako cpaní PHP do komentářů.

Osobně bych viděl raději něco takového.

@property DateTime|null     // bude výchozí hodnota NULL
@property DateTime      // bude přiřazen DateTime s konstruktorem bez parametrů
@property DateTime $date=['yesterday'] //bude vytvořen DateTime('yesterday')

před 6 lety

Filip111
Člen | 244

@besanek
Já bych to nekomplikoval – $date=['yesterday'] je absolutně neprůhledný. Je zbytečný vymejšlet další jazyk nebo pseudozápisy. Omezil bych to na elementární typy včetně array(). Pro cokoliv složitějšího je tu initDefaults()

před 6 lety

Šaman
Člen | 2275

S defautlní hodnotou NULL u nullable položek souhlasím (pokud explicitně neuvedu jinou defaultní hodnotu).
A s tím DateTimem bych možná (nebude-li to na úkor rychlé a čisté implementaci) akceptoval všechno, co skousne konstruktor DateTimu (‚now‘, ‚20.1.2011‘, ‚2011/20/1‘, ‚-1 day‘) s tím, že pokud není nic uvedeno, vytvoří se DateTime bez parametrů, tedy ‚now‘. Nebude to o nic magičtější, než samotný DateTime.

před 6 lety

besanek
Člen | 128

Teď si nejsem jist, že jsme se zcela pochopili.
Souhlasím, že pro cokoli složitějšího je initDefaults(), ovšem složitější si představuji volání funkcí či jiné objekty. Předávání základních datových typů by šlo pomocí anotací.

@property Object $foo=['foo', 'bar', 123] // zavolá new Object('foo', 'bar', 123)
@property Object $foo=('foo', 'bar', 123) // popř. kulaté závorky :)

Trochu jsem se inspiroval v c++ seznamem inicializací. :) Ale jen jsem se pokusil rozvinout nápad s tvorbou objektu. Pokud by to mělo nějaké výkonnostní dopady, tak to nemá smysl implementovat.

před 6 lety

Jirda
Člen | 111

Jsem taky pro to, aby se ta syntaxe zbytecne nekomplikovala. Taky bych to omezil pouze na ty elementarni typy a pro slozitejsi proste initDefaults().

Editoval Jirda (29. 6. 2013 22:35)

před 6 lety

Michal III
Člen | 84

@Jirda:
Myslím si, že syntaxe se zavedením neelementárních typů nekomplikuje, komplikuje se spíše parser (tedy při použití té standardní @property DateTime $date = new DateTime syntaxe).

@besanek:

besanek napsal(a):

@property Object $foo=['foo', 'bar', 123] // zavolá new Object('foo', 'bar', 123)
@property Object $foo=('foo', 'bar', 123) // popř. kulaté závorky :)

Řekl bych, že tímto bychom se zbavili redundantnosti za cenu nepřehlednosti a magické syntaxe. Hranaté závorky by ve mně evokovaly spíše pole a je zbytečné si zapamatovávat novou syntaxi.

Mimochodem nemůže nastat situace, že bychom jako výchozí hodnotu chtěli objekt potomka? Špatný příklad: @property Date $date = new DateTime.

před 6 lety

Etch
Člen | 404

Takovej zcela triviální dotaz:

Jde nějak jednoduše dosáhnout následujícího:

$params['limit'] = null;
$count = count($this->booksRepository->filterBooks($params));


$params['limit'] = 50;
$books = $this->booksRepository->filterBooks($params);

filterBooks($params) nedělá nic jiného, než že aplikuje na výsledek filtry podle parametrů. Jde to udělat nějak jednodušeji, než za pomocí nějaké specializované count metody v repository??

před 6 lety

Tharos
Člen | 1042

@Etch: Pokud nejdu z křížkem po funuse (byl jsem teď pár dní bez přístupu k internetu), tak já tohle řeším zhruba následovně:

class Fragment
{

    /** @var int */
    private $overallCount;

    /** @var int */
    private $fragmentCount;

    /** @var int|null */
    private $offset;

    /** @var array */
    private $data;


    /**
     * @param int $overalCount
     * @param int $fragmentCount
     * @param int|null $offset
     * @param array $data
     */
    public function __construct($overalCount, $fragmentCount, $offset, $data)
    {
        $this->overallCount = $overalCount;
        $this->fragmentCount = $fragmentCount;
        $this->offset = $offset;
        $this->data = $data;
    }

    /**
     * @return int
     */
    public function getOverallCount()
    {
        return $this->overallCount;
    }

    /**
     * @return int
     */
    public function getFragmentCount()
    {
        return $this->fragmentCount;
    }

    /**
     * @return int|null
     */
    public function getOffset()
    {
        return $this->offset;
    }

    /**
     * @return array
     */
    public function getData()
    {
        return $this->data;
    }

}

/**
 * @property int $id
 * @property string $name
 */
class Book extends Entity
{
}

class BookRepository extends Repository
{

    /**
     * @param array $filter
     * @return Fragment
     */
    public function findByFilter(array $filter)
    {
        $limit = array_key_exists('limit', $filter) ? $filter['limit'] : null;
        $offset = array_key_exists('offset', $filter) ? $filter['offset'] : null;

        $statement = $this->connection->select('COUNT(*)')
                ->from($this->getTable());
        $this->filterStatement($statement, $filter);
        $overallCount = $statement->fetchSingle();

        $statement = $this->connection->select('*')
                ->from($this->getTable());
        $this->filterStatement($statement, $filter);
        $data = $this->createEntities($statement->fetchAll($offset, $limit));

        return new Fragment($overallCount, count($data), $offset, $data);
    }

    /**
     * @param DibiFluent $statement
     * @param array $filter
     */
    private function filterStatement(DibiFluent $statement, array $filter)
    {
        // do something useful here
    }

}

//////////

$filter = array(
    'limit' => 1,
    'offset' => 2,
);

$booksFragment = $bookRepository->findByFilter($filter);

echo $booksFragment->getOverallCount() . "\n";

foreach ($booksFragment->getData() as $book) {
    echo $book->name . "\n";
}

Uvedený kód by ještě snesl učesání a zobecnění podle konkrétních potřeb, ale myšlenka by z něj měla být myslím dobře patrná.

Editoval Tharos (6. 7. 2013 23:13)

Stránky: Prev 1 2 3 4 5 6 … 23 Next