tiny ‘n’ smart
database layer

Odkazy: dibi | API reference

Oznámení

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

LeanMapper:hasMany – další sloupce (příznaky) ve vazební tabulce

před 5 lety

VojtaSim
Člen | 55

Zdravím
nastal mi problém jak přes LeanMapper číst a ukládat obsah i jiných sloupců než vazebních, které jsou ve vazební tabulce.
Tabulka:

id user_id group_id isSuspended
  – tabulka user – tabulka group – příznak pro dočasné opuštění skupiny

Problém je v tom, že když potřebuji uživatele suspendovat z dané skupiny tak nemám moc možností. Princip fungování metod ->addToGroups() jsem pochopil, ale ke sloupci isSuspended se tak nemůžu dostat.

Potřeboval bych radu jakým způsobem tohle řešit (vlastní Mapper, filtry, …) a popřípadě nakopnout z kódem

před 5 lety

besanek
Člen | 128

před 5 lety

VojtaSim
Člen | 55

besanek napsal(a):

Pomůže tohle? https://github.com/…er/issues/50

Jo, metoda s filtry pomohla, ale jenom s načítáním, ukládání je už horší a muselo by se to řešit podobně jako v příkladu s překlady, proto si myslím že metoda kde se vazební tabulka řeší samostatnou entitou je menší zlo.
Lze někde najít pokračování, o kterém se @Tharos zmiňoval na gitu?

před 5 lety

Šaman
Člen | 2275

Já používám samostatnou entitu. Z hlediska ER návrhu to také samostatná entita je.

před 5 lety

VojtaSim
Člen | 55

Šaman napsal(a):

Já používám samostatnou entitu. Z hlediska ER návrhu to také samostatná entita je.

Díky, půjdu cestou nejmenšího odporu a použiju samostatnou entitu

před 5 lety

Tharos
Člen | 1042

Ahoj,

po řadě zkušeností s touto záležitostí bych Ti doporučil následující…

Nic nezkazíš tím, když si pro takovou tabulku nadefinuješ plnokrevnou entitu. Je to určitě cesta nejmenšího odporu. Zda je to optimální z hlediska OOP je sporné. Já třeba tvrdím, že se tím občas až příliš „přiznává“ relační databáze, ale nikomu nevymlouvám opačný názor.


Co bych Ti možná doporučil je řešení, které používám osobně hodně často. Vychází z následujících předpokladů:

  • Skrýt tu spojovací tabulku je většinou žádoucí při čtení dat (zjednodušší se traverzování, nemusíš dělat $user=>groupMemberships=>group, ale stačí pouze $user=>groups, což se mně osobně hodně líbí)
  • Při zápisu do databáze se ale naopak hodí mít pro tu relaci samostatnou entitu, je to nejpraktičtější

Takto to může vypadat v praxi:

/*
CREATE TABLE "user" (
  "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
  "firstname" text NULL,
  "surname" text NOT NULL
);

 CREATE TABLE "group" (
  "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
  "name" text NOT NULL
);

CREATE TABLE "user_group" (
  "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
  "user_id" integer NOT NULL,
  "group_id" integer NOT NULL,
  "isSuspended" integer NOT NULL,
  FOREIGN KEY ("user_id") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
  FOREIGN KEY ("group_id") REFERENCES "group" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);

INSERT INTO "user" ("id", "firstname", "surname") VALUES (1,    'Vojtěch', 'Kohout');
INSERT INTO "user" ("id", "firstname", "surname") VALUES (2,    'John', 'Doe');

INSERT INTO "group" ("id", "name") VALUES (1,   'Developer');
INSERT INTO "group" ("id", "name") VALUES (2,   'Consultant');
INSERT INTO "group" ("id", "name") VALUES (3,   'Copywriter');

INSERT INTO "user_group" ("id", "user_id", "group_id", "isSuspended") VALUES (1,    1,  1,  '0');
INSERT INTO "user_group" ("id", "user_id", "group_id", "isSuspended") VALUES (2,    1,  2,  1);
INSERT INTO "user_group" ("id", "user_id", "group_id", "isSuspended") VALUES (3,    2,  2,  '0');
*/


/**
 * @property int $id
 * @property string|null $firstname
 * @property string $surname
 * @property UsersGroup[] $groups m:belongsToMany
 */
class User extends Entity
{
}

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

/**
 * @property bool $isSuspended
 * @property User $user m:hasOne
 */
class UsersGroup extends Group
{
}

/**
 * @property int $id
 * @property User $user m:hasOne
 * @property Group $group m:hasOne
 * @property bool $isSuspended
 */
class GroupMembership extends Entity
{
}

class MappingHelper
{

    public function readGroupMappings(IMapper $mapper)
    {
        return [
            'groupTable' => $groupTable = $mapper->getTable(Group::class),
            'usersGroupTable' => $usersGroupTable = $mapper->getTable(UsersGroup::class),
            'relColumn' => $mapper->getRelationshipColumn($usersGroupTable, $groupTable),
            'groupPk' => $mapper->getPrimaryKey($groupTable),
            'usersGroupPk' => $mapper->getPrimaryKey($usersGroupTable),
        ];
    }

}

class Mapper extends DefaultMapper
{

    protected $defaultEntityNamespace = null;

    private $mappingHelper;


    public function __construct(MappingHelper $mappingHelper)
    {
        $this->mappingHelper = $mappingHelper;
    }


    public function getTable($entityClass)
    {
        if ($entityClass === UsersGroup::class or $entityClass === GroupMembership::class) {
            return 'user_group';
        }
        return parent::getTable($entityClass);
    }


    public function getEntityClass($table, Row $row = null)
    {
        if ($table === 'user_group') {
            $column = $this->getColumn(UsersGroup::class, 'name');
            return isset($row->$column) ? UsersGroup::class : GroupMembership::class;
        }
        return parent::getEntityClass($table, $row);
    }


    public function getImplicitFilters($entityClass, Caller $caller = null)
    {
        if ($entityClass === UsersGroup::class) {
            return new ImplicitFilters(function (Fluent $statement) {
                extract($this->mappingHelper->readGroupMappings($this));
                $statement->select('%n.*, %n.%n', $groupTable, $usersGroupTable, $usersGroupPk)
                    ->join($groupTable)->on('%n.%n = %n.%n', $usersGroupTable, $relColumn, $groupTable, $groupPk)
                ;
            });
        }
        return parent::getImplicitFilters($entityClass, $caller);
    }

}

abstract class BaseRepository extends Repository
{

    public function find($id)
    {
        $row = $this->createFluent()->where('%n = %i', $this->mapper->getPrimaryKey($this->getTable()), $id)->fetch();
        if ($row === false) {
            throw new \Exception('Entity was not found.');
        }
        return $this->createEntity($row);
    }

    public function findAll()
    {
        return $this->createEntities(
            $this->createFluent()->fetchAll()
        );
    }

}

class UserRepository extends BaseRepository
{
}

/**
 * @table user_group
 */
class GroupMembershipRepository extends BaseRepository
{
}

class GroupRepository extends BaseRepository
{

    public function persist(Entity $entity)
    {
        if ($entity instanceof UsersGroup) {
            throw new InvalidArgumentException;
        }
        return parent::persist($entity);
    }

}

$dbConfig = [
    'driver' => 'sqlite3',
    'database' => 'path-to-database',
];

$connection = new Connection($dbConfig);

$connection->onEvent[] = function ($event) use (&$queries) {
    $queries[] = $event->sql;
};

$mapper = new Mapper(new MappingHelper);
$entityFactory = new DefaultEntityFactory;

$userRepository = new UserRepository($connection, $mapper, $entityFactory);
$groupRepository = new GroupRepository($connection, $mapper, $entityFactory);
$groupMembershipRepository = new GroupMembershipRepository($connection, $mapper, $entityFactory);

// reading

foreach ($userRepository->findAll() as $user) {
    echo "$user->firstname\n";
    foreach ($user->groups as $group) {
        echo "\t$group->name (" . ($group->isSuspended ? '1' : '0') . ")\n";
    }
}

// persistence

$user = $userRepository->find(1);
$group = $groupRepository->find(1);

$groupMembership = new GroupMembership;
$groupMembership->assign([
    'user' => $user,
    'group' => $group,
    'isSuspended' => true,
]);

$groupMembershipRepository->persist($groupMembership);

Rád bych v té ukázce vyzdvihl pár věcí:

  • Pracuje se v ní s dost vysokou mírou abstrakce, veškerá mapování se čtou z mapperu. V menší aplikaci bych se s tím asi tak nepáral a třeba ten JOIN v implicitním filtru bych napsal přímočařeji (normálně bych tam ty databázové identifikátory napsal a nečetl je z toho MappingHelper)
  • Všimni si, že v tom implicitním filtru v části SELECT nakonec připojuji ID z té spojovací tabulky. Řeší se tím limit dibi, že při spojení záznamů z více tabulek může dotaz obsahovat typicky více sloupců pojmenovaných id a dibi namapuje jenom ten poslední. Takto se tím posledním stává id z té spojovací tabulky, což chceme, protože to nese identitu těch UsersGroup. Je to trochu hack :), zato perfektně funčkní. Mimochodem ti, kdo pojemnovávají PK jako user_id, group_id… tenhle problém vůbec nemusí řešit. Zjišťuji, že taková kovence má něco do sebe. :)
  • Všimni si té podmínky v Mapper::getTable a Mapper::getEntityClass. Pro entity GroupMembership a UsersGroup se zkrátka hlavní tabulkou stává ta spojovací. A už je jedno, jestli jsou k ní při-joinovaná data z group (v případě UsersGroup) nebo ne (v případě GroupMembership). Dále je v getEntityClass zapotřebí naimplementovat jistý polymorfismus…
  • Všimni si, že entitu UsersGroup není možné persistovat, slouží fakt jenom pro čtení dat

Tohle jeosvědčené řešení, které osobně často používám.


No a pak je ještě jedno řešení, rozšíření předchozího, a totiž umožnit persistenci i té UsersGroup entity.

Logicky to musí řešit GroupRepository, ale je to piplačka, protože v přetížených metodách Repository::insertIntoDatabase a Repository::updateInDatabase musíš řešit, jestli Ti zrovna přišla instance Group nebo UsersGroup a v případě té druhé musíš entitu rozebrat, část dat nasypat do tabulky group, část do té spojovací tabulky, musíš řešit, jestli potřebný záznam v group už není atp…

Osobně se mi tohle příliš neosvěčilo pro příliš velký overhead.


Je to takhle podle Tvého gusta? A co ostatní, přijde vám to rozumné? :) Vítám jakýkoliv feedback.

Editoval Tharos (1. 6. 2014 22:05)

před 5 lety

VojtaSim
Člen | 55

@Tharos V případě uživatelů a skupin použiji tvojí metodu. V druhém případě kdy se jedná o odkazy v navigaci a do spojovací tabulky se přepletl i 3. cizí klíč už si myslím bude vhodnější samostatná entita, protože se tam řeší i překlad entit.

Tabulka vypadá takhle:

Kde:

  • page_id odkazuje na tabulku page
  • podle language_id se vybírá jaký překlad z tabulky page_translation se přijoinuje
  • se aplikuje filtr na seřazení podle pole sort

Musel jsem si ale trochu poupravit Mapper a filter Translator

// Mapper
public function getImplicitFilters($entityClass, Caller $caller = null)
{
    if (is_subclass_of($entityClass, $this->defaultEntityNamespace . '\TranslatableEntity')) { // doplněno defaultEntityNamespace
        if ($caller->isEntity()) { // caller se mi pořád jevil jako instance třídy Caller a ne Entity, použil jsem metodu isEntity()
            return array('translateFromEntity');
        } else {
            return new ImplicitFilters(array('translate'), array(
                'translate' => array($this->getTable($entityClass)),
            ));
        }
    }
    return parent::getImplicitFilters($entityClass, $caller);
}

// Translator
public function translateFromEntity(Fluent $statement, Entity $entity, Property $property, Language $lang = null)
{
    if ($lang === null && isset($entity->language_id)) {
        $lang = $entity->language_id; // vybere se language_id nehledě na to jestli je to TranslatableEntity (ale musí existovat)
    }

    $targetTable = $property->getRelationship()->getTargetTable();
    $this->translate($statement, $targetTable, $lang);
}

před 5 lety

VojtaSim
Člen | 55

@Tharos btw. LeanMapper je super, hlavně možnost napsat si vlastní query v repositáři (nebo přes filtry), které by se těžko tlačilo do jiných ORM. Skvěle mi pasuje na můj atypický projekt, kde potřebuji mít kontrolu na dotazy ale zároveň aby to pořád bylo ORM.

před 5 lety

Tharos
Člen | 1042

VojtaSim napsal(a):

V druhém případě kdy se jedná o odkazy v navigaci a do spojovací tabulky se přepletl i 3. cizí klíč už si myslím bude vhodnější samostatná entita, protože se tam řeší i překlad entit.

To rozhodně bude vhodnější. Taková tabulka už vyjadřuje mnohem víc, než jen vazbu.

VojtaSim napsal(a):

LeanMapper je super, hlavně možnost napsat si vlastní query v repositáři (nebo přes filtry), které by se těžko tlačilo do jiných ORM. Skvěle mi pasuje na můj atypický projekt, kde potřebuji mít kontrolu na dotazy ale zároveň aby to pořád bylo ORM.

Díky za zpětnou vazbu!