With doctrine ODM, can I embed many subdocuments in a main document?

364 views Asked by At

I try to save this JSON:

{
  "vendorId": "vendor-fc162cdffd73",
  "company": {
    "companyId": "bcos1.company.1806cf97-a756-4fbf-9081-fc162cdffd73",
    "companyVersion": 1,
    "companyName": "Delivery Inc.",
    "address": {
      "streetAddress": "300 Boren Ave",
      "city": "Seattle",
      "region": "US-WA",
      "country": "US",
      "postalCode": "98109",
      "storeName": "Seattle Store",
      "coordinates": {
        "latitude": "45.992820",
        "longitude": "45.992820"
      }
    },
    "emailAddress": "[email protected]",
    "phoneNumber": "1234567890",
    "websiteUrl": "delivery.com",
    "creationDate": "2022-03-06T21:00:52.222Z"
  },
  "creationDate": "2022-04-06T21:00:52.222Z"
}

Company is a subdocument this has address and address has coordinates subdocument. When I try to save with Hydratation, see example: https://www.doctrine-project.org/projects/doctrine-laminas-hydrator/en/3.0/basic-usage.html#example-4-embedded-entities

I got this error:

1) AppTest\Services\AccountsServiceTest::testNewAccount with data set #0 (array('{"companyId":"bcos1.com...222Z"}', '', ''))
array_flip(): Can only flip STRING and INTEGER values!

vendor/doctrine/doctrine-laminas-hydrator/src/DoctrineObject.php:488
vendor/doctrine/doctrine-laminas-hydrator/src/DoctrineObject.php:355
vendor/doctrine/doctrine-laminas-hydrator/src/DoctrineObject.php:165
src/App/Document/Repository/AccountRepository.php:67

In DoctrineObject line 488

    protected function toOne(string $target, $value): ?object
    {
        $metadata = $this->objectManager->getClassMetadata($target);
        if (is_array($value) && array_keys($value) !== $metadata->getIdentifier()) {
            // $value is most likely an array of fieldset data
            $identifiers = array_intersect_key(
                $value,
                array_flip($metadata->getIdentifier())
            );
            $object      = $this->find($identifiers, $target) ?: new $target();

            return $this->hydrate($value, $object);
        }

        return $this->find($value, $target);
    }

My code:

        $vendorAccountId = uniqid('vendor-account-id-');
        $account = new Account();

        $hydrator->hydrate($data, $account);

My main Entity:

<?php

namespace App\Document\Entity;

use Doctrine\ODM\MongoDB\Mapping\Annotations as MongoDB;

/**
 * @MongoDB\Document(db="awesome-company", collection="Account", repositoryClass="App\Document\Repository\AccountRepository")
 */
class Account
{
    /** @MongoDB\Id(name="_id") */
    private string $id;

    /** @MongoDB\Field(type="string", name="vendorAccountId") */
    private string $vendorAccountId;

    /**
     * @return string
     */
    public function getVendorAccountId(): string
    {
        return $this->vendorAccountId;
    }

    /**
     * @param string $vendorAccountId
     */
    public function setVendorAccountId(string $vendorAccountId): void
    {
        $this->vendorAccountId = $vendorAccountId;
    }

    /**
     * @MongoDB\EmbedOne(targetDocument=Company::class)
     */
    private Company $company;

    /**
     * @MongoDB\Field(type="string",name="realm")
     **/
    private $realm;

    /**
     * @MongoDB\Field(type="string",name="domain")
     **/
    private $domain;

    /**
     * @MongoDB\Field(type="date",name="created_at")
     **/
    private \DateTime $createdAt;


    public function __construct()
    {
        $this->company = new Company();
        $this->createdAt = new \DateTime();
    }

    /**
     * @return mixed
     */
    public function getCompany()
    {
        return $this->company;
    }

    /**
     * @param mixed $company
     */
    public function setCompany($company): void
    {
        $this->company = $company;
    }

    /**
     * @return mixed
     */
    public function getRealm()
    {
        return $this->realm;
    }

    /**
     * @param mixed $realm
     */
    public function setRealm($realm): void
    {
        $this->realm = $realm;
    }

    /**
     * @return mixed
     */
    public function getDomain()
    {
        return $this->domain;
    }

    /**
     * @param mixed $domain
     */
    public function setDomain($domain): void
    {
        $this->domain = $domain;
    }

    /**
     * @return \DateTime
     */
    public function getCreatedAt(): \DateTime
    {
        return $this->createdAt;
    }

    /**
     * @return string
     */
    public function getId(): string
    {
        return $this->id;
    }

    /**
     * @param string $id
     */
    public function setId(string $id): void
    {
        $this->id = $id;
    }

}

Company embed document:

<?php

namespace App\Document\Entity;

use Doctrine\ODM\MongoDB\Mapping\Annotations as MongoDB;

/** @MongoDB\EmbeddedDocument * */
class Company
{
    /**
     * @MongoDB\Field(type="string",name="company_id")
     **/
    private string $companyId;

    /**
     * @MongoDB\Field(type="int",name="company_version")
     **/
    private int $companyVersion;

    /**
     * @MongoDB\Field(type="string",name="company_name")
     **/
    private string $companyName;

    /**
     * @MongoDB\EmbedOne(targetDocument=Address::class)
     */
    private Address $address;

    /**
     * @MongoDB\Field(type="string",name="email_address")
     **/
    private string $emailAddress;

    /**
     * @MongoDB\Field(type="string",name="phone_number")
     **/
    private string $phoneNumber;

    /**
     * @MongoDB\Field(type="string",name="website_url")
     **/
    private string $websiteUrl;

    /**
     * @MongoDB\Field(type="date",name="creation_date")
     **/
    private \DateTime $creationDate;


    public function __construct()
    {
        $this->address = new Address();
    }

    public function getCompanyId(): string
    {
        return $this->companyId;
    }

    public function setCompanyId($companyId)
    {
        $this->companyId = $companyId;
    }

    public function getCompanyVersion(): int
    {
        return $this->companyVersion;
    }

    public function setCompanyVersion($companyVersion)
    {
        $this->companyVersion = $companyVersion;
    }

    public function getCreationDate(): \DateTime
    {
        return $this->creationDate;
    }

    public function setCreationDate($creationDate)
    {
        $this->creationDate = $creationDate;
    }

    public function getWebsiteUrl(): string
    {
        return $this->websiteUrl;
    }

    public function setWebsiteUrl($websiteUrl)
    {
        $this->websiteUrl = $websiteUrl;
    }

    public function getPhoneNumber(): string
    {
        return $this->phoneNumber;
    }

    public function setPhoneNumber($phoneNumber)
    {
        $this->phoneNumber = $phoneNumber;
    }

    public function getEmailAddress(): string
    {
        return $this->emailAddress;
    }

    public function setEmailAddress($emailAddress)
    {
        $this->emailAddress = $emailAddress;
    }

    public function getAddress(): Address
    {
        return $this->address;
    }

    public function setAddress(Address $address)
    {
        $this->address = $address;
    }

    public function getCompanyName(): string
    {
        return $this->companyName;
    }

    public function setCompanyName($companyName)
    {
        $this->companyName = $companyName;
    }
}
2

There are 2 answers

0
malarzm On

What you're trying to do is perfectly fine from ODM's perspective - you can have as many embeddables deep as you want. Unless there is something fishy going on in your Address or Coordinates embedded documents I would expect a bug in the laminas-hydrator package. The fact that ORM does not allow nested embeddables makes this scenario more likely. Best would be to try creating a failing test case and send a pull request with it.

In the meantime you can leverage ODM's hydrator:

$hydrator = $this->dm->getHydratorFactory()->getHydratorFor(Account::class);
$hydrator->hydrate(new Account(), $data, [Query::HINT_READ_ONLY => true]);

Please note the Query::HINT_READ_ONLY passed as a 3rd argument to hydrate. With this hint ODM will not mark hydrated objects as managed which is what you need for later insertion. Hydrated EmbedOne objects should be good to go without the hint, but EmbedMany may not work correctly without it. Please see https://github.com/doctrine/mongodb-odm/issues/1377 and https://github.com/doctrine/mongodb-odm/pull/1403 for more details about said hint.

0
Peter On

Thank you @malarzm. I do this:

  $coordinates = new Coordinates();
  $hydrator->hydrate($data['company']['address']['coordinates'], $coordinates);
  unset($data['company']['address']['coordinates']);

  $address = new Address();
  $hydrator->hydrate($data['company']['address'], $address);
  unset($data['company']['address']);

  $company = new Company();
  $hydrator->hydrate($data['company'], $company);
  unset($data['company']);

  $account = new Account();
  $hydrator->hydrate($data, $account);

  $address->setCoordinates($coordinates);
  $company->setAddress($address);
  $account->setCompany($company);

I realized unset() for each embed sud-document and finally I set each sub-document with set method of my entity.

It was the only way to do work fine.