Skip to content

Commit

Permalink
Add $graphLookup aggregation pipeline stage
Browse files Browse the repository at this point in the history
  • Loading branch information
alcaeus committed Oct 12, 2017
1 parent 30b5b27 commit 158c009
Show file tree
Hide file tree
Showing 9 changed files with 674 additions and 5 deletions.
10 changes: 10 additions & 0 deletions lib/Doctrine/ODM/MongoDB/Aggregation/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,16 @@ public function bucketAuto()
return $this->addStage(new Stage\BucketAuto($this));
}

/**
* @param string $from
*
* @return Stage\GraphLookup
*/
public function graphLookup($from)
{
return $this->addStage(new Stage\GraphLookup($this, $from, $this->dm, $this->class));
}

/**
* @return Stage\Match
*/
Expand Down
248 changes: 248 additions & 0 deletions lib/Doctrine/ODM/MongoDB/Aggregation/Stage/GraphLookup.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
<?php
/*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* This software consists of voluntary contributions made by many individuals
* and is licensed under the MIT license. For more information, see
* <http://www.doctrine-project.org>.
*/

namespace Doctrine\ODM\MongoDB\Aggregation\Stage;

use Doctrine\Common\Persistence\Mapping\MappingException as BaseMappingException;
use Doctrine\MongoDB\Aggregation\Stage as BaseStage;
use Doctrine\ODM\MongoDB\Aggregation\Builder;
use Doctrine\ODM\MongoDB\DocumentManager;
use Doctrine\ODM\MongoDB\Mapping\ClassMetadata;
use Doctrine\ODM\MongoDB\Mapping\ClassMetadataInfo;
use Doctrine\ODM\MongoDB\Mapping\MappingException;
use Doctrine\ODM\MongoDB\Types\Type;

class GraphLookup extends BaseStage\GraphLookup
{
/**
* @var DocumentManager
*/
private $dm;

/**
* @var ClassMetadata
*/
private $class;

/**
* @var ClassMetadata
*/
private $targetClass;

/**
* @param Builder $builder
* @param string $from Target collection for the $graphLookup operation to
* search, recursively matching the connectFromField to the connectToField.
* @param DocumentManager $documentManager
* @param ClassMetadata $class
*/
public function __construct(Builder $builder, $from, DocumentManager $documentManager, ClassMetadata $class)
{
$this->dm = $documentManager;
$this->class = $class;

parent::__construct($builder, $from);
}

/**
* @param string $from
* @return $this
*/
public function from($from)
{
// $from can either be
// a) a field name indicating a reference to a different document. Currently, only REFERENCE_STORE_AS_ID is supported
// b) a Class name
// c) a collection name
// In cases b) and c) the local and foreign fields need to be filled
if ($this->class->hasReference($from)) {
return $this->fromReference($from);
}

// Check if mapped class with given name exists
try {
$this->targetClass = $this->dm->getClassMetadata($from);
} catch (BaseMappingException $e) {
return parent::from($from);
}

if ($this->targetClass->isSharded()) {
throw MappingException::cannotUseShardedCollectionInLookupStages($this->targetClass->name);
}

return parent::from($this->targetClass->getCollection());
}

public function connectFromField($connectFromField)
{
// No targetClass mapping - simply use field name as is
if (!$this->targetClass) {
return parent::connectFromField($connectFromField);
}

// connectFromField doesn't have to be a reference - in this case, just convert the field name
if (!$this->targetClass->hasReference($connectFromField)) {
return parent::connectFromField($this->convertTargetFieldName($connectFromField));
}

// connectFromField is a reference - do a sanity check
$referenceMapping = $this->targetClass->getFieldMapping($connectFromField);
if ($referenceMapping['targetDocument'] !== $this->targetClass->name) {
throw MappingException::connectFromFieldMustReferenceSameDocument($connectFromField);
}

if ($referenceMapping['isOwningSide']) {
switch ($referenceMapping['storeAs']) {
case ClassMetadataInfo::REFERENCE_STORE_AS_ID:
case ClassMetadataInfo::REFERENCE_STORE_AS_REF:
$referencedFieldName = ClassMetadataInfo::getReferenceFieldName($referenceMapping['storeAs'], $referenceMapping['name']);
break;

default:
throw MappingException::cannotLookupDbRefReference($this->class->name, $connectFromField);
}

parent::connectFromField($referencedFieldName);
} else {
if (isset($referenceMapping['repositoryMethod'])) {
throw MappingException::repositoryMethodLookupNotAllowed($this->class->name, $connectFromField);
}

$mappedByMapping = $this->targetClass->getFieldMapping($referenceMapping['mappedBy']);
switch ($mappedByMapping['storeAs']) {
case ClassMetadataInfo::REFERENCE_STORE_AS_ID:
case ClassMetadataInfo::REFERENCE_STORE_AS_REF:
$referencedFieldName = ClassMetadataInfo::getReferenceFieldName($mappedByMapping['storeAs'], $mappedByMapping['name']);
break;

default:
throw MappingException::cannotLookupDbRefReference($this->class->name, $connectFromField);
}

parent::connectFromField($referencedFieldName);
}

return $this;
}

public function connectToField($connectToField)
{
return parent::connectToField($this->convertTargetFieldName($connectToField));
}

/**
* @param string $fieldName
* @return $this
* @throws MappingException
*/
private function fromReference($fieldName)
{
if (! $this->class->hasReference($fieldName)) {
MappingException::referenceMappingNotFound($this->class->name, $fieldName);
}

$referenceMapping = $this->class->getFieldMapping($fieldName);
$this->targetClass = $this->dm->getClassMetadata($referenceMapping['targetDocument']);
if ($this->targetClass->isSharded()) {
throw MappingException::cannotUseShardedCollectionInLookupStages($this->targetClass->name);
}

parent::from($this->targetClass->getCollection());

if ($referenceMapping['isOwningSide']) {
switch ($referenceMapping['storeAs']) {
case ClassMetadataInfo::REFERENCE_STORE_AS_ID:
case ClassMetadataInfo::REFERENCE_STORE_AS_REF:
$referencedFieldName = ClassMetadataInfo::getReferenceFieldName($referenceMapping['storeAs'], $referenceMapping['name']);
break;

default:
throw MappingException::cannotLookupDbRefReference($this->class->name, $fieldName);
}

$this
->startWith('$' . $referencedFieldName)
->connectToField('_id');

// A self-reference indicates that we can also fill the "connectFromField" accordingly
if ($this->targetClass->name === $this->class->name) {
$this->connectFromField($referencedFieldName);
}
} else {
if (isset($referenceMapping['repositoryMethod'])) {
throw MappingException::repositoryMethodLookupNotAllowed($this->class->name, $fieldName);
}

$mappedByMapping = $this->targetClass->getFieldMapping($referenceMapping['mappedBy']);
switch ($mappedByMapping['storeAs']) {
case ClassMetadataInfo::REFERENCE_STORE_AS_ID:
case ClassMetadataInfo::REFERENCE_STORE_AS_REF:
$referencedFieldName = ClassMetadataInfo::getReferenceFieldName($mappedByMapping['storeAs'], $mappedByMapping['name']);
break;

default:
throw MappingException::cannotLookupDbRefReference($this->class->name, $fieldName);
}

$this
->startWith('$' . $referencedFieldName)
->connectToField('_id');

// A self-reference indicates that we can also fill the "connectFromField" accordingly
if ($this->targetClass->name === $this->class->name) {
$this->connectFromField($referencedFieldName);
}
}

return $this;
}

protected function convertExpression($expression)
{
if (is_array($expression)) {
return array_map([$this, 'convertExpression'], $expression);
} elseif (is_string($expression) && substr($expression, 0, 1) === '$') {
return '$' . $this->getDocumentPersister($this->class)->prepareFieldName(substr($expression, 1));
} else {
return Type::convertPHPToDatabaseValue(parent::convertExpression($expression));
}
}

protected function convertTargetFieldName($fieldName)
{
if (is_array($fieldName)) {
return array_map([$this, 'convertTargetFieldName'], $fieldName);
}

if (!$this->targetClass) {
return $fieldName;
}

return $this->getDocumentPersister($this->targetClass)->prepareFieldName($fieldName);
}

/**
* @param ClassMetadata $class
* @return \Doctrine\ODM\MongoDB\Persisters\DocumentPersister
*/
private function getDocumentPersister(ClassMetadata $class)
{
return $this->dm->getUnitOfWork()->getDocumentPersister($class->name);
}
}
4 changes: 2 additions & 2 deletions lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Lookup.php
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ private function fromReference($fieldName)
break;

default:
throw MappingException::cannotLookupNonIdReference($this->class->name, $fieldName);
throw MappingException::cannotLookupDbRefReference($this->class->name, $fieldName);
}

$this
Expand All @@ -136,7 +136,7 @@ private function fromReference($fieldName)
break;

default:
throw MappingException::cannotLookupNonIdReference($this->class->name, $fieldName);
throw MappingException::cannotLookupDbRefReference($this->class->name, $fieldName);
}

$this
Expand Down
11 changes: 8 additions & 3 deletions lib/Doctrine/ODM/MongoDB/Mapping/MappingException.php
Original file line number Diff line number Diff line change
Expand Up @@ -362,9 +362,9 @@ public static function noMultiKeyShardKeys($className, $fieldName)
* @param string $fieldName
* @return MappingException
*/
public static function cannotLookupNonIdReference($className, $fieldName)
public static function cannotLookupDbRefReference($className, $fieldName)
{
return new self("Cannot use reference '$fieldName' in class '$className' for lookup. Only ID references are allowed in \$lookup stages.");
return new self("Cannot use reference '$fieldName' in class '$className' for lookup or graphLookup: dbRef references are not supported.");
}

/**
Expand All @@ -374,7 +374,7 @@ public static function cannotLookupNonIdReference($className, $fieldName)
*/
public static function repositoryMethodLookupNotAllowed($className, $fieldName)
{
return new self("Cannot use reference '$fieldName' in class '$className' for lookup. repositoryMethod is not supported in \$lookup stages.");
return new self("Cannot use reference '$fieldName' in class '$className' for lookup or graphLookup. repositoryMethod is not supported in \$lookup and \$graphLookup stages.");
}

/**
Expand Down Expand Up @@ -405,4 +405,9 @@ public static function referencePrimersOnlySupportedForInverseReferenceMany($cla
{
return new self("Cannot use reference priming on '$fieldName' in class '$className'. Reference priming is only supported for inverse references");
}

public static function connectFromFieldMustReferenceSameDocument($fieldName)
{
return new self("Cannot use field '$fieldName' as connectFromField in \$graphLookup stage. Reference must target the document itself.");
}
}
Loading

0 comments on commit 158c009

Please sign in to comment.