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
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ě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::getTable
aMapper::getEntityClass
. Pro entityGroupMembership
aUsersGroup
se 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 vgetEntityClass
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!