orm icon indicating copy to clipboard operation
orm copied to clipboard

Mapping through transitive relations for ValueObjects

Open SilverFire opened this issue 5 years ago • 19 comments

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'];
    }
}

SilverFire avatar Apr 16 '20 10:04 SilverFire

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.

wolfy-j avatar Apr 16 '20 10:04 wolfy-j

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 :)

SilverFire avatar Apr 16 '20 15:04 SilverFire

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?

SilverFire avatar Apr 16 '20 15:04 SilverFire

What about extract / hydrate methods?

wolfy-j avatar Apr 16 '20 15:04 wolfy-j

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

SilverFire avatar Apr 16 '20 15:04 SilverFire

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.

wolfy-j avatar Apr 16 '20 15:04 wolfy-j

Sorry I misread your Q. No, I don't think you need to create VO in init().

wolfy-j avatar Apr 16 '20 15:04 wolfy-j

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']);
    }
}

SilverFire avatar Apr 16 '20 15:04 SilverFire

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.

wolfy-j avatar Apr 16 '20 15:04 wolfy-j

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? :)

SilverFire avatar Apr 16 '20 16:04 SilverFire

This entities are OK. But Money does not need to go into Heap.

wolfy-j avatar Apr 16 '20 16:04 wolfy-j

Yes, sure) So you think it's OK to create dumb stdClass() in the init() method of VO mappers?

SilverFire avatar Apr 16 '20 16:04 SilverFire

Not a big fan of it but why not :)

wolfy-j avatar Apr 16 '20 16:04 wolfy-j

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

SilverFire avatar Apr 16 '20 16:04 SilverFire

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

wolfy-j avatar Apr 16 '20 16:04 wolfy-j

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.

SilverFire avatar Apr 17 '20 16:04 SilverFire

Sounds like something with “account” relation. I’ll check the code and try to eliminate side effects.

wolfy-j avatar Apr 18 '20 17:04 wolfy-j

Questions:

  1. Can you provide me a full example? I'm not sure if the mapping you provided at the top still valid.
  2. Have you tied to set CASCADE false for Bill => Account relation?

wolfy-j avatar May 11 '20 12:05 wolfy-j

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.

wolfy-j avatar May 11 '20 12:05 wolfy-j