Mapping through transitive relations for ValueObjects
Hi, @wolfy-j
I've tried to describe a mapping through a table, but found it a bit more complex than expected. Say we have the following tables structure:
Bill Account Currency
____________ _____________ ___________
id |-> id |-> id
account_id -| currency_id -| code
sum
and a class
class Bill
{
private int $id;
private \Money\Money $sum;
}
The Money\Money VO consists of an integer amount and \Money\Currency($currencyCode) VO. (You probably know money/money, right?)
I've managed to compose a mapping that builds a correct object, but it looks more complex than I would expect. Am I missing something, or this way is the shortest one?
👁 Mapping config
Bill::class => [
Schema::ROLE => 'bill',
Schema::MAPPER => Mapper::class,
Schema::DATABASE => 'default',
Schema::TABLE => 'bill',
Schema::PRIMARY_KEY => 'id',
Schema::COLUMNS => ['id'],
Schema::TYPECAST => [],
Schema::RELATIONS => [
'sum' => [
Relation::TYPE => Relation::BELONGS_TO,
Relation::LOAD => Relation::LOAD_EAGER,
Relation::TARGET => 'bill:sum',
Relation::SCHEMA => [
Relation::CASCADE => true,
Relation::INNER_KEY => 'id',
Relation::OUTER_KEY => 'id',
],
],
]
],
'bill:sum' => [
Schema::ENTITY => \Money\Money::class,
Schema::MAPPER => Mapper::class,
Schema::DATABASE => 'default',
Schema::TABLE => 'bill',
Schema::PRIMARY_KEY => 'id',
Schema::COLUMNS => [
'id',
'sum',
'account_id',
],
Schema::RELATIONS => [
'currency' => [
Relation::TYPE => Relation::BELONGS_TO,
Relation::LOAD => Relation::LOAD_EAGER,
Relation::TARGET => 'bill:account',
Relation::SCHEMA => [
Relation::CASCADE => true,
Relation::INNER_KEY => 'account_id',
Relation::OUTER_KEY => 'id',
],
],
],
],
'bill:account' => [
Schema::ENTITY => \Money\Currency::class,
Schema::MAPPER => Mapper::class,
Schema::DATABASE => 'default',
Schema::TABLE => 'account',
Schema::PRIMARY_KEY => 'id',
Schema::COLUMNS => [
'id',
'currency_id',
],
Schema::RELATIONS => [
'code' => [
Relation::TYPE => Relation::BELONGS_TO,
Relation::LOAD => Relation::LOAD_EAGER,
Relation::TARGET => 'bill:currency',
Relation::SCHEMA => [
Relation::CASCADE => true,
Relation::INNER_KEY => 'currency_id',
Relation::OUTER_KEY => 'id',
],
],
],
],
'bill:currency' => [
Schema::ENTITY => \Money\Currency::class,
Schema::MAPPER => CurrencyMapper::class,
Schema::DATABASE => 'default',
Schema::TABLE => 'currency',
Schema::PRIMARY_KEY => 'id',
Schema::COLUMNS => [
'id' => 'id',
'code' => 'name',
],
Schema::RELATIONS => [],
],
and mapping
<?php declare(strict_types=1);
use Cycle\ORM\Mapper\Mapper;
final class CurrencyMapper extends Mapper
{
/**
* @inheritDoc
*/
protected function fetchFields($entity): array
{
return [];
}
/**
* @inheritDoc
*/
public function init(array $data): array
{
return [new \stdClass(), $data];
}
public function hydrate($entity, array $data)
{
return $data['code'];
}
}
I think you can skip "sum" relation and deal with mapping on the Bill level (it will receive the data of account and currency if relations marked as eager or preloaded).
Since you have VO you'll need custom mapper anyway.
Yes, indeed, it's a bit more straightforward. Moved mappings to Bill.
Got something like this:
final class BillMapper extends Mapper
{
public function hydrate($entity, array $data)
{
$data['sum'] = new Money\Money(
$data['sum'],
new Currency($data['currency']->code)
);
return parent::hydrate($entity, $data);
}
}
I think I can add this to docs. If you also think so, point me to the right page, please. I'd say https://cycle-orm.dev/docs/advanced-manual :)
I also don't see much sense in creating inconsistent VO in init() to hydrate them sometime later.
Do you think that creating a fully-built VO in custom Mapper::init() is OK?
What about extract / hydrate methods?
What about extract / hydrate methods?
You mean extract / hydrate methods for VO that was fully created in init()?
public function hydrate($entity, array $data)
{
return $entity;
}
and extract stays with its default implementation
I would avoid creating VO in init() or parent object mapper and create it directly in hydrate(). This way you never have it in inconsistent state and you can use immutable objects.
Sorry I misread your Q. No, I don't think you need to create VO in init().
The attach() call here (to be precise, an offsetSet() call inside of attach()) requires $e to be an object, so I should create some placeholder object in the init() method to satisfy type.
Something like this
final class UnitMapper extends Mapper
{
protected function fetchFields($entity): array
{
return [];
}
public function init(array $data): array
{
return [new \stdClass(), $data];
}
public function hydrate($entity, array $data)
{
return Unit::create($data['name']);
}
}
Wait. You can init your parent entity in init() - Bill. But the Money don't need to be initiated in init(). You can create this VO object inside the hydrate() method of Bill Mapper. There are no need to put Money VO into Heap since it's VO and can't be addressed by reference.
That's also true, but both Unit and Currency entities are immutable objects in my domain.
They also exist in the DBMS and have a PK, so they can be created only once, put to Heap and be addressed by reference. Why not? :)
This entities are OK. But Money does not need to go into Heap.
Yes, sure)
So you think it's OK to create dumb stdClass() in the init() method of VO mappers?
Not a big fan of it but why not :)
We need a dumb object and stdClass seems to bee dumb enough)
What about writing docs? See end of https://github.com/cycle/orm/issues/88#issuecomment-614723084
Sure! We are always open with any help regarding docs. You can create new section if you want to. Just make sure to link it in readme and manifest.json
This seems to be a bit harder than I expected :)
After I managed to hydrate sum through the account relation...
public function hydrate($entity, array $data)
{
$data['sum'] = new Money($data['sum'], new Currency($data['account']->currency));
return $this->hydrator->hydrate($data, $entity);
}
... I got a read-only entity. To map everything back, I should break down the Money VO back to scalars. Easy-peasy:
public function extract($entity): array
{
$result = parent::extract($entity);
$result['sum'] = $result['sum']->getAmount();
return $result;
}
Should work, but the ORM::queueStore() notices, that the account relation has been disappeared and generates UPDATE ... SET account_id = null .... Not good, and remember that the original account relation data was NOT written in the Bill object during the mapping since there is no representative property in the Bill. Fine, прорвёмся :)
public function extract($entity): array
{
$result = parent::extract($entity);
$result['sum'] = $result['sum']->getAmount();
$result['account'] = $this->orm->getHeap()->get($entity)->getRelation('account');
// Todo: handle possible NPE after get()
return $result;
}
And it almost works. Everything results in a correct SQL query but after that a Transaction tries to synchronize a heap, calling $mapper->hydrate($e, ['sum' => '100500']) with a partial entity data.
Now I have to change my lovely-crafted hydrator to handle this partial $data.
public function hydrate($entity, array $data)
{
if (count(array_filter($this->hydrator->extract($entity))) > 0) {
// TODO: maybe there is a wiser way to detect a newly-crafted entity?
// `Node->status` is always `MANAGED`
if (!isset($data['account'])) {
// God bless me :)
$data['account'] = $this->orm->getHeap()->get($entity)->getRelation('account');
}
}
$data['sum'] = new Money($data['sum'], new Currency($data['account']->currency));
return $this->hydrator->hydrate($data, $entity);
}
Finally, seems to work. Not sexy though. Don't know what to suggest, but it's not a good idea to add this mess to a manual.
Sounds like something with “account” relation. I’ll check the code and try to eliminate side effects.
Questions:
- Can you provide me a full example? I'm not sure if the mapping you provided at the top still valid.
- Have you tied to set CASCADE false for Bill => Account relation?
I'm 99% positive that setting account relation cascade false (i.e. DO NOT WRITE) will give you what you want. It was made for read-only relations.