Oznámení
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

- 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 
ida dibi namapuje jenom ten poslední. Takto se tím posledním stáváidz té spojovací tabulky, což chceme, protože to nese identitu těchUsersGroup. Je to trochu hack :), zato perfektně funčkní. Mimochodem ti, kdo pojemnovávají PK jakouser_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::getTableaMapper::getEntityClass. Pro entityGroupMembershipaUsersGroupse zkrátka hlavní tabulkou stává ta spojovací. A už je jedno, jestli jsou k ní při-joinovaná data zgroup(v případěUsersGroup) nebo ne (v případěGroupMembership). Dále je vgetEntityClasszapotřebí naimplementovat jistý polymorfismus… - Všimni si, že entitu 
UsersGroupnení 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_translationse 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!