Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Throw hydrator exceptions when encountering invalid types #2073

Merged
merged 1 commit into from
Oct 2, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions lib/Doctrine/ODM/MongoDB/Hydrator/HydratorException.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Doctrine\ODM\MongoDB\Hydrator;

use Doctrine\ODM\MongoDB\MongoDBException;
use function sprintf;

/**
* MongoDB ODM Hydrator Exception
Expand All @@ -25,4 +26,27 @@ public static function hydratorNamespaceRequired() : self
{
return new self('You must configure a hydrator namespace. See docs for details');
}

public static function associationTypeMismatch(string $className, string $fieldName, string $expectedType, string $actualType)
{
return new self(sprintf(
'Expected association for field "%s" in document of type "%s" to be of type "%s", "%s" received.',
$fieldName,
$className,
$expectedType,
$actualType
));
}

public static function associationItemTypeMismatch(string $className, string $fieldName, $key, string $expectedType, string $actualType)
{
return new self(sprintf(
'Expected association item with key "%s" for field "%s" in document of type "%s" to be of type "%s", "%s" received.',
$key,
$fieldName,
$className,
$expectedType,
$actualType
));
}
}
25 changes: 22 additions & 3 deletions lib/Doctrine/ODM/MongoDB/Hydrator/HydratorFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,11 @@ private function generateHydratorClass(ClassMetadata $class, string $hydratorCla
/** @ReferenceOne */
if (isset(\$data['%1\$s'])) {
\$reference = \$data['%1\$s'];

if (\$this->class->fieldMappings['%2\$s']['storeAs'] !== ClassMetadata::REFERENCE_STORE_AS_ID && ! is_array(\$reference)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I understand correctly, REFERENCE_STORE_AS_ID is the only strategy that stores an arbitrary value. All others nest the reference in an embedded document (either a DBRef or ODM's newer style that omits the $-prefixed field names), correct?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct :)

throw HydratorException::associationTypeMismatch('%3\$s', '%1\$s', 'array', gettype(\$reference));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was originally confused as to the value of %3\$s, and assumed it was literal PHP code based on the previous usage. Further inspection revealed that this is in a completely separate sprintf() call and it's the class name string, which is also added 18 lines below. 👍

It's a shame we can't use more meaningful labels for these things.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that is very unfortunate. We may be able to improve this using Heredoc, but that's a change for a different release.

}

\$className = \$this->unitOfWork->getClassNameForAssociation(\$this->class->fieldMappings['%2\$s'], \$reference);
\$identifier = ClassMetadata::getReferenceId(\$reference, \$this->class->fieldMappings['%2\$s']['storeAs']);
\$targetMetadata = \$this->dm->getClassMetadata(\$className);
Expand All @@ -257,7 +262,8 @@ private function generateHydratorClass(ClassMetadata $class, string $hydratorCla
EOF
,
$mapping['name'],
$mapping['fieldName']
$mapping['fieldName'],
$class->getName()
);
} elseif ($mapping['association'] === ClassMetadata::REFERENCE_ONE && $mapping['isInverseSide']) {
if (isset($mapping['repositoryMethod']) && $mapping['repositoryMethod']) {
Expand Down Expand Up @@ -305,6 +311,11 @@ private function generateHydratorClass(ClassMetadata $class, string $hydratorCla

/** @Many */
\$mongoData = isset(\$data['%1\$s']) ? \$data['%1\$s'] : null;

if (\$mongoData !== null && ! is_array(\$mongoData)) {
throw HydratorException::associationTypeMismatch('%3\$s', '%1\$s', 'array', gettype(\$mongoData));
}

\$return = \$this->dm->getConfiguration()->getPersistentCollectionFactory()->create(\$this->dm, \$this->class->fieldMappings['%2\$s']);
\$return->setHints(\$hints);
\$return->setOwner(\$document, \$this->class->fieldMappings['%2\$s']);
Expand All @@ -318,7 +329,8 @@ private function generateHydratorClass(ClassMetadata $class, string $hydratorCla
EOF
,
$mapping['name'],
$mapping['fieldName']
$mapping['fieldName'],
$class->getName()
);
} elseif ($mapping['association'] === ClassMetadata::EMBED_ONE) {
$code .= sprintf(
Expand All @@ -327,6 +339,11 @@ private function generateHydratorClass(ClassMetadata $class, string $hydratorCla
/** @EmbedOne */
if (isset(\$data['%1\$s'])) {
\$embeddedDocument = \$data['%1\$s'];

if (! is_array(\$embeddedDocument)) {
throw HydratorException::associationTypeMismatch('%3\$s', '%1\$s', 'array', gettype(\$embeddedDocument));
}

\$className = \$this->unitOfWork->getClassNameForAssociation(\$this->class->fieldMappings['%2\$s'], \$embeddedDocument);
\$embeddedMetadata = \$this->dm->getClassMetadata(\$className);
\$return = \$embeddedMetadata->newInstance();
Expand All @@ -347,7 +364,8 @@ private function generateHydratorClass(ClassMetadata $class, string $hydratorCla
EOF
,
$mapping['name'],
$mapping['fieldName']
$mapping['fieldName'],
$class->getName()
);
}
}
Expand All @@ -360,6 +378,7 @@ private function generateHydratorClass(ClassMetadata $class, string $hydratorCla
namespace $namespace;

use Doctrine\ODM\MongoDB\DocumentManager;
use Doctrine\ODM\MongoDB\Hydrator\HydratorException;
use Doctrine\ODM\MongoDB\Hydrator\HydratorInterface;
use Doctrine\ODM\MongoDB\Query\Query;
use Doctrine\ODM\MongoDB\UnitOfWork;
Expand Down
23 changes: 22 additions & 1 deletion lib/Doctrine/ODM/MongoDB/Persisters/DocumentPersister.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use DateTime;
use Doctrine\Common\Persistence\Mapping\MappingException;
use Doctrine\ODM\MongoDB\DocumentManager;
use Doctrine\ODM\MongoDB\Hydrator\HydratorException;
use Doctrine\ODM\MongoDB\Hydrator\HydratorFactory;
use Doctrine\ODM\MongoDB\Iterator\CachingIterator;
use Doctrine\ODM\MongoDB\Iterator\HydratingIterator;
Expand Down Expand Up @@ -48,6 +49,7 @@
use function explode;
use function get_class;
use function get_object_vars;
use function gettype;
use function implode;
use function in_array;
use function is_array;
Expand Down Expand Up @@ -664,15 +666,24 @@ private function loadEmbedManyCollection(PersistentCollectionInterface $collecti
$embeddedDocuments = $collection->getMongoData();
$mapping = $collection->getMapping();
$owner = $collection->getOwner();

if (! $embeddedDocuments) {
return;
}

if ($owner === null) {
throw PersistentCollectionException::ownerRequiredToLoadCollection();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This exception already existed but just wasn't used here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep. This was introduced whenever we require the owner to be set (PHPStan found a few instances where we assumed it would be set). Since we now need the owner to generate the correct exception message, we have to also throw it here.

}

foreach ($embeddedDocuments as $key => $embeddedDocument) {
$className = $this->uow->getClassNameForAssociation($mapping, $embeddedDocument);
$embeddedMetadata = $this->dm->getClassMetadata($className);
$embeddedDocumentObject = $embeddedMetadata->newInstance();

if (! is_array($embeddedDocument)) {
throw HydratorException::associationItemTypeMismatch(get_class($owner), $mapping['name'], $key, 'array', gettype($embeddedDocument));
}

$this->uow->setParentAssociation($embeddedDocumentObject, $mapping, $owner, $mapping['name'] . '.' . $key);

$data = $this->hydratorFactory->hydrate($embeddedDocumentObject, $embeddedDocument, $collection->getHints());
Expand All @@ -693,12 +704,22 @@ private function loadReferenceManyCollectionOwningSide(PersistentCollectionInter
{
$hints = $collection->getHints();
$mapping = $collection->getMapping();
$owner = $collection->getOwner();
$groupedIds = [];

if ($owner === null) {
throw PersistentCollectionException::ownerRequiredToLoadCollection();
}

$sorted = isset($mapping['sort']) && $mapping['sort'];

foreach ($collection->getMongoData() as $key => $reference) {
$className = $this->uow->getClassNameForAssociation($mapping, $reference);
$className = $this->uow->getClassNameForAssociation($mapping, $reference);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noting this is just a whitespace change.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep - assignment operator alignment strikes again. See doctrine/coding-standard#107.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh yeah, I totally forgot about that PR


if ($mapping['storeAs'] !== ClassMetadata::REFERENCE_STORE_AS_ID && ! is_array($reference)) {
throw HydratorException::associationItemTypeMismatch(get_class($owner), $mapping['name'], $key, 'array', gettype($reference));
}

$identifier = ClassMetadata::getReferenceId($reference, $mapping['storeAs']);
$id = $this->dm->getClassMetadata($className)->getPHPIdentifierValue($identifier);

Expand Down
88 changes: 88 additions & 0 deletions tests/Doctrine/ODM/MongoDB/Tests/HydratorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
namespace Doctrine\ODM\MongoDB\Tests;

use DateTime;
use Doctrine\ODM\MongoDB\Hydrator\HydratorException;
use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM;
use Doctrine\ODM\MongoDB\PersistentCollection;
use Doctrine\ODM\MongoDB\PersistentCollection\PersistentCollectionInterface;
use Doctrine\ODM\MongoDB\Query\Query;
use ProxyManager\Proxy\GhostObjectInterface;

Expand Down Expand Up @@ -65,6 +67,92 @@ public function testReadOnly()
$this->assertFalse($this->uow->isInIdentityMap($user->embedOne));
$this->assertFalse($this->uow->isInIdentityMap($user->embedMany[0]));
}

public function testEmbedOneWithWrongType()
{
$user = new HydrationClosureUser();

$this->expectException(HydratorException::class);
$this->expectExceptionMessage('Expected association for field "embedOne" in document of type "' . HydrationClosureUser::class . '" to be of type "array", "string" received.');

$this->dm->getHydratorFactory()->hydrate($user, [
'_id' => 1,
'embedOne' => 'jon',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Glad to see we're still using @jwage as a data fixture.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The beauty of copy/paste 😅

]);
}

public function testEmbedManyWithWrongType()
{
$user = new HydrationClosureUser();

$this->expectException(HydratorException::class);
$this->expectExceptionMessage('Expected association for field "embedMany" in document of type "' . HydrationClosureUser::class . '" to be of type "array", "string" received.');

$this->dm->getHydratorFactory()->hydrate($user, [
'_id' => 1,
'embedMany' => 'jon',
]);
}

public function testEmbedManyWithWrongElementType()
{
$user = new HydrationClosureUser();

$this->dm->getHydratorFactory()->hydrate($user, [
'_id' => 1,
'embedMany' => ['jon'],
]);

$this->assertInstanceOf(PersistentCollectionInterface::class, $user->embedMany);

$this->expectException(HydratorException::class);
$this->expectExceptionMessage('Expected association item with key "0" for field "embedMany" in document of type "' . HydrationClosureUser::class . '" to be of type "array", "string" received.');

$user->embedMany->initialize();
}

public function testReferenceOneWithWrongType()
{
$user = new HydrationClosureUser();

$this->expectException(HydratorException::class);
$this->expectExceptionMessage('Expected association for field "referenceOne" in document of type "' . HydrationClosureUser::class . '" to be of type "array", "string" received.');

$this->dm->getHydratorFactory()->hydrate($user, [
'_id' => 1,
'referenceOne' => 'jon',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out of curiosity, do we bother testing for malformed DBRef or ODM-style reference objects? I realize that's probably out of scope for this PR.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe we have no tests for such scenarios

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't have these, but adding them would probably make sense. Although I'd argue that we may want to be less strict when reading these references (e.g., accept ['id' => 'foo', 'collection' => 'foo']) when reading a DBRef object.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On one hand it'd be nice but on the other it could give false impression that changing one storage strategy to another works "just like that" which can bite back when using aggregations

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opened #2077 to track this.

]);
}

public function testReferenceManyWithWrongType()
{
$user = new HydrationClosureUser();

$this->expectException(HydratorException::class);
$this->expectExceptionMessage('Expected association for field "referenceMany" in document of type "' . HydrationClosureUser::class . '" to be of type "array", "string" received.');

$this->dm->getHydratorFactory()->hydrate($user, [
'_id' => 1,
'referenceMany' => 'jon',
]);
}

public function testReferenceManyWithWrongElementType()
{
$user = new HydrationClosureUser();

$this->dm->getHydratorFactory()->hydrate($user, [
'_id' => 1,
'referenceMany' => ['jon'],
]);

$this->assertInstanceOf(PersistentCollectionInterface::class, $user->referenceMany);

$this->expectException(HydratorException::class);
$this->expectExceptionMessage('Expected association item with key "0" for field "referenceMany" in document of type "' . HydrationClosureUser::class . '" to be of type "array", "string" received.');

$user->referenceMany->initialize();
}
}

/** @ODM\Document */
Expand Down