<?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\Persisters;

use Doctrine\Common\EventManager;
use Doctrine\Common\Persistence\Mapping\MappingException;
use Doctrine\MongoDB\CursorInterface;
use Doctrine\MongoDB\EagerCursor;
use Doctrine\ODM\MongoDB\Cursor;
use Doctrine\ODM\MongoDB\DocumentManager;
use Doctrine\ODM\MongoDB\Mapping\ClassMetadataInfo;
use Doctrine\ODM\MongoDB\Query\ReferencePrimer;
use Doctrine\ODM\MongoDB\Utility\CollectionHelper;
use Doctrine\ODM\MongoDB\Hydrator\HydratorFactory;
use Doctrine\ODM\MongoDB\LockException;
use Doctrine\ODM\MongoDB\LockMode;
use Doctrine\ODM\MongoDB\Mapping\ClassMetadata;
use Doctrine\ODM\MongoDB\MongoDBException;
use Doctrine\ODM\MongoDB\PersistentCollection\PersistentCollectionInterface;
use Doctrine\ODM\MongoDB\Proxy\Proxy;
use Doctrine\ODM\MongoDB\Query\CriteriaMerger;
use Doctrine\ODM\MongoDB\Query\Query;
use Doctrine\ODM\MongoDB\Types\Type;
use Doctrine\ODM\MongoDB\UnitOfWork;

/**
 * The DocumentPersister is responsible for persisting documents.
 *
 * @since       1.0
 */
class DocumentPersister
{
    /**
     * The PersistenceBuilder instance.
     *
     * @var PersistenceBuilder
     */
    private $pb;

    /**
     * The DocumentManager instance.
     *
     * @var DocumentManager
     */
    private $dm;

    /**
     * The EventManager instance
     *
     * @var EventManager
     */
    private $evm;

    /**
     * The UnitOfWork instance.
     *
     * @var UnitOfWork
     */
    private $uow;

    /**
     * The ClassMetadata instance for the document type being persisted.
     *
     * @var ClassMetadata
     */
    private $class;

    /**
     * The MongoCollection instance for this document.
     *
     * @var \MongoCollection
     */
    private $collection;

    /**
     * Array of queued inserts for the persister to insert.
     *
     * @var array
     */
    private $queuedInserts = array();

    /**
     * Array of queued inserts for the persister to insert.
     *
     * @var array
     */
    private $queuedUpserts = array();

    /**
     * The CriteriaMerger instance.
     *
     * @var CriteriaMerger
     */
    private $cm;

    /**
     * The CollectionPersister instance.
     *
     * @var CollectionPersister
     */
    private $cp;

    /**
     * Initializes this instance.
     *
     * @param PersistenceBuilder $pb
     * @param DocumentManager $dm
     * @param EventManager $evm
     * @param UnitOfWork $uow
     * @param HydratorFactory $hydratorFactory
     * @param ClassMetadata $class
     * @param CriteriaMerger $cm
     */
    public function __construct(
        PersistenceBuilder $pb,
        DocumentManager $dm,
        EventManager $evm,
        UnitOfWork $uow,
        HydratorFactory $hydratorFactory,
        ClassMetadata $class,
        CriteriaMerger $cm = null
    ) {
        $this->pb = $pb;
        $this->dm = $dm;
        $this->evm = $evm;
        $this->cm = $cm ?: new CriteriaMerger();
        $this->uow = $uow;
        $this->hydratorFactory = $hydratorFactory;
        $this->class = $class;
        $this->collection = $dm->getDocumentCollection($class->name);
        $this->cp = $this->uow->getCollectionPersister();
    }

    /**
     * @return array
     */
    public function getInserts()
    {
        return $this->queuedInserts;
    }

    /**
     * @param object $document
     * @return bool
     */
    public function isQueuedForInsert($document)
    {
        return isset($this->queuedInserts[spl_object_hash($document)]);
    }

    /**
     * Adds a document to the queued insertions.
     * The document remains queued until {@link executeInserts} is invoked.
     *
     * @param object $document The document to queue for insertion.
     */
    public function addInsert($document)
    {
        $this->queuedInserts[spl_object_hash($document)] = $document;
    }

    /**
     * @return array
     */
    public function getUpserts()
    {
        return $this->queuedUpserts;
    }

    /**
     * @param object $document
     * @return boolean
     */
    public function isQueuedForUpsert($document)
    {
        return isset($this->queuedUpserts[spl_object_hash($document)]);
    }

    /**
     * Adds a document to the queued upserts.
     * The document remains queued until {@link executeUpserts} is invoked.
     *
     * @param object $document The document to queue for insertion.
     */
    public function addUpsert($document)
    {
        $this->queuedUpserts[spl_object_hash($document)] = $document;
    }

    /**
     * Gets the ClassMetadata instance of the document class this persister is used for.
     *
     * @return ClassMetadata
     */
    public function getClassMetadata()
    {
        return $this->class;
    }

    /**
     * Executes all queued document insertions.
     *
     * Queued documents without an ID will inserted in a batch and queued
     * documents with an ID will be upserted individually.
     *
     * If no inserts are queued, invoking this method is a NOOP.
     *
     * @param array $options Options for batchInsert() and update() driver methods
     */
    public function executeInserts(array $options = array())
    {
        if ( ! $this->queuedInserts) {
            return;
        }

        $inserts = array();
        $options = $this->getWriteOptions($options);
        foreach ($this->queuedInserts as $oid => $document) {
            $data = $this->pb->prepareInsertData($document);

            // Set the initial version for each insert
            if ($this->class->isVersioned) {
                $versionMapping = $this->class->fieldMappings[$this->class->versionField];
                if ($versionMapping['type'] === 'int') {
                    $nextVersion = max(1, (int) $this->class->reflFields[$this->class->versionField]->getValue($document));
                    $this->class->reflFields[$this->class->versionField]->setValue($document, $nextVersion);
                } elseif ($versionMapping['type'] === 'date') {
                    $nextVersionDateTime = new \DateTime();
                    $nextVersion = new \MongoDate($nextVersionDateTime->getTimestamp());
                    $this->class->reflFields[$this->class->versionField]->setValue($document, $nextVersionDateTime);
                }
                $data[$versionMapping['name']] = $nextVersion;
            }

            $inserts[$oid] = $data;
        }

        if ($inserts) {
            try {
                $this->collection->batchInsert($inserts, $options);
            } catch (\MongoException $e) {
                $this->queuedInserts = array();
                throw $e;
            }
        }

        /* All collections except for ones using addToSet have already been
         * saved. We have left these to be handled separately to avoid checking
         * collection for uniqueness on PHP side.
         */
        foreach ($this->queuedInserts as $document) {
            $this->handleCollections($document, $options);
        }

        $this->queuedInserts = array();
    }

    /**
     * Executes all queued document upserts.
     *
     * Queued documents with an ID are upserted individually.
     *
     * If no upserts are queued, invoking this method is a NOOP.
     *
     * @param array $options Options for batchInsert() and update() driver methods
     */
    public function executeUpserts(array $options = array())
    {
        if ( ! $this->queuedUpserts) {
            return;
        }

        $options = $this->getWriteOptions($options);
        foreach ($this->queuedUpserts as $oid => $document) {
            try {
                $this->executeUpsert($document, $options);
                $this->handleCollections($document, $options);
                unset($this->queuedUpserts[$oid]);
            } catch (\MongoException $e) {
                unset($this->queuedUpserts[$oid]);
                throw $e;
            }
        }
    }

    /**
     * Executes a single upsert in {@link executeUpserts}
     *
     * @param object $document
     * @param array  $options
     */
    private function executeUpsert($document, array $options)
    {
        $options['upsert'] = true;
        $criteria = $this->getQueryForDocument($document);

        $data = $this->pb->prepareUpsertData($document);

        // Set the initial version for each upsert
        if ($this->class->isVersioned) {
            $versionMapping = $this->class->fieldMappings[$this->class->versionField];
            if ($versionMapping['type'] === 'int') {
                $nextVersion = max(1, (int) $this->class->reflFields[$this->class->versionField]->getValue($document));
                $this->class->reflFields[$this->class->versionField]->setValue($document, $nextVersion);
            } elseif ($versionMapping['type'] === 'date') {
                $nextVersionDateTime = new \DateTime();
                $nextVersion = new \MongoDate($nextVersionDateTime->getTimestamp());
                $this->class->reflFields[$this->class->versionField]->setValue($document, $nextVersionDateTime);
            }
            $data['$set'][$versionMapping['name']] = $nextVersion;
        }

        foreach (array_keys($criteria) as $field) {
            unset($data['$set'][$field]);
        }

        // Do not send an empty $set modifier
        if (empty($data['$set'])) {
            unset($data['$set']);
        }

        /* If there are no modifiers remaining, we're upserting a document with
         * an identifier as its only field. Since a document with the identifier
         * may already exist, the desired behavior is "insert if not exists" and
         * NOOP otherwise. MongoDB 2.6+ does not allow empty modifiers, so $set
         * the identifier to the same value in our criteria.
         *
         * This will fail for versions before MongoDB 2.6, which require an
         * empty $set modifier. The best we can do (without attempting to check
         * server versions in advance) is attempt the 2.6+ behavior and retry
         * after the relevant exception.
         *
         * See: https://jira.mongodb.org/browse/SERVER-12266
         */
        if (empty($data)) {
            $retry = true;
            $data = array('$set' => array('_id' => $criteria['_id']));
        }

        try {
            $this->collection->update($criteria, $data, $options);
            return;
        } catch (\MongoCursorException $e) {
            if (empty($retry) || strpos($e->getMessage(), 'Mod on _id not allowed') === false) {
                throw $e;
            }
        }

        $this->collection->update($criteria, array('$set' => new \stdClass), $options);
    }

    /**
     * Updates the already persisted document if it has any new changesets.
     *
     * @param object $document
     * @param array $options Array of options to be used with update()
     * @throws \Doctrine\ODM\MongoDB\LockException
     */
    public function update($document, array $options = array())
    {
        $update = $this->pb->prepareUpdateData($document);

        $query = $this->getQueryForDocument($document);

        foreach (array_keys($query) as $field) {
            unset($update['$set'][$field]);
        }

        if (empty($update['$set'])) {
            unset($update['$set']);
        }


        // Include versioning logic to set the new version value in the database
        // and to ensure the version has not changed since this document object instance
        // was fetched from the database
        $nextVersion = null;
        if ($this->class->isVersioned) {
            $versionMapping = $this->class->fieldMappings[$this->class->versionField];
            $currentVersion = $this->class->reflFields[$this->class->versionField]->getValue($document);
            if ($versionMapping['type'] === 'int') {
                $nextVersion = $currentVersion + 1;
                $update['$inc'][$versionMapping['name']] = 1;
                $query[$versionMapping['name']] = $currentVersion;
            } elseif ($versionMapping['type'] === 'date') {
                $nextVersion = new \DateTime();
                $update['$set'][$versionMapping['name']] = new \MongoDate($nextVersion->getTimestamp());
                $query[$versionMapping['name']] = new \MongoDate($currentVersion->getTimestamp());
            }
        }

        if ( ! empty($update)) {
            // Include locking logic so that if the document object in memory is currently
            // locked then it will remove it, otherwise it ensures the document is not locked.
            if ($this->class->isLockable) {
                $isLocked = $this->class->reflFields[$this->class->lockField]->getValue($document);
                $lockMapping = $this->class->fieldMappings[$this->class->lockField];
                if ($isLocked) {
                    $update['$unset'] = array($lockMapping['name'] => true);
                } else {
                    $query[$lockMapping['name']] = array('$exists' => false);
                }
            }

            $options = $this->getWriteOptions($options);

            $result = $this->collection->update($query, $update, $options);

            if (($this->class->isVersioned || $this->class->isLockable) && ! $result['n']) {
                throw LockException::lockFailed($document);
            } elseif ($this->class->isVersioned) {
                $this->class->reflFields[$this->class->versionField]->setValue($document, $nextVersion);
            }
        }

        $this->handleCollections($document, $options);
    }

    /**
     * Removes document from mongo
     *
     * @param mixed $document
     * @param array $options Array of options to be used with remove()
     * @throws \Doctrine\ODM\MongoDB\LockException
     */
    public function delete($document, array $options = array())
    {
        $query = $this->getQueryForDocument($document);

        if ($this->class->isLockable) {
            $query[$this->class->lockField] = array('$exists' => false);
        }

        $options = $this->getWriteOptions($options);

        $result = $this->collection->remove($query, $options);

        if (($this->class->isVersioned || $this->class->isLockable) && ! $result['n']) {
            throw LockException::lockFailed($document);
        }
    }

    /**
     * Refreshes a managed document.
     *
     * @param string $id
     * @param object $document The document to refresh.
     *
     * @deprecated The first argument is deprecated.
     */
    public function refresh($id, $document)
    {
        $query = $this->getQueryForDocument($document);
        $data = $this->collection->findOne($query);
        $data = $this->hydratorFactory->hydrate($document, $data);
        $this->uow->setOriginalDocumentData($document, $data);
    }

    /**
     * Finds a document by a set of criteria.
     *
     * If a scalar or MongoId is provided for $criteria, it will be used to
     * match an _id value.
     *
     * @param mixed   $criteria Query criteria
     * @param object  $document Document to load the data into. If not specified, a new document is created.
     * @param array   $hints    Hints for document creation
     * @param integer $lockMode
     * @param array   $sort     Sort array for Cursor::sort()
     * @throws \Doctrine\ODM\MongoDB\LockException
     * @return object|null The loaded and managed document instance or null if no document was found
     * @todo Check identity map? loadById method? Try to guess whether $criteria is the id?
     */
    public function load($criteria, $document = null, array $hints = array(), $lockMode = 0, array $sort = null)
    {
        // TODO: remove this
        if ($criteria === null || is_scalar($criteria) || $criteria instanceof \MongoId) {
            $criteria = array('_id' => $criteria);
        }

        $criteria = $this->prepareQueryOrNewObj($criteria);
        $criteria = $this->addDiscriminatorToPreparedQuery($criteria);
        $criteria = $this->addFilterToPreparedQuery($criteria);

        $cursor = $this->collection->find($criteria);

        if (null !== $sort) {
            $cursor->sort($this->prepareSortOrProjection($sort));
        }

        $result = $cursor->getSingleResult();

        if ($this->class->isLockable) {
            $lockMapping = $this->class->fieldMappings[$this->class->lockField];
            if (isset($result[$lockMapping['name']]) && $result[$lockMapping['name']] === LockMode::PESSIMISTIC_WRITE) {
                throw LockException::lockFailed($result);
            }
        }

        return $this->createDocument($result, $document, $hints);
    }

    /**
     * Finds documents by a set of criteria.
     *
     * @param array        $criteria Query criteria
     * @param array        $sort     Sort array for Cursor::sort()
     * @param integer|null $limit    Limit for Cursor::limit()
     * @param integer|null $skip     Skip for Cursor::skip()
     * @return Cursor
     */
    public function loadAll(array $criteria = array(), array $sort = null, $limit = null, $skip = null)
    {
        $criteria = $this->prepareQueryOrNewObj($criteria);
        $criteria = $this->addDiscriminatorToPreparedQuery($criteria);
        $criteria = $this->addFilterToPreparedQuery($criteria);

        $baseCursor = $this->collection->find($criteria);
        $cursor = $this->wrapCursor($baseCursor);

        if (null !== $sort) {
            $cursor->sort($sort);
        }

        if (null !== $limit) {
            $cursor->limit($limit);
        }

        if (null !== $skip) {
            $cursor->skip($skip);
        }

        return $cursor;
    }

    /**
     * @param object $document
     *
     * @return array
     * @throws MongoDBException
     */
    private function getShardKeyQuery($document)
    {
        if ( ! $this->class->isSharded()) {
            return array();
        }

        $shardKey = $this->class->getShardKey();
        $keys = array_keys($shardKey['keys']);
        $data = $this->uow->getDocumentActualData($document);

        $shardKeyQueryPart = array();
        foreach ($keys as $key) {
            $mapping = $this->class->getFieldMappingByDbFieldName($key);
            $this->guardMissingShardKey($document, $key, $data);

            if (isset($mapping['association']) && $mapping['association'] === ClassMetadata::REFERENCE_ONE) {
                $reference = $this->prepareReference(
                    $key,
                    $data[$mapping['fieldName']],
                    $mapping,
                    false
                );
                foreach ($reference as $keyValue) {
                    $shardKeyQueryPart[$keyValue[0]] = $keyValue[1];
                }
            } else {
                $value = Type::getType($mapping['type'])->convertToDatabaseValue($data[$mapping['fieldName']]);
                $shardKeyQueryPart[$key] = $value;
            }
        }

        return $shardKeyQueryPart;
    }

    /**
     * Wraps the supplied base cursor in the corresponding ODM class.
     *
     * @param CursorInterface $baseCursor
     * @return Cursor
     */
    private function wrapCursor(CursorInterface $baseCursor)
    {
        return new Cursor($baseCursor, $this->dm->getUnitOfWork(), $this->class);
    }

    /**
     * Checks whether the given managed document exists in the database.
     *
     * @param object $document
     * @return boolean TRUE if the document exists in the database, FALSE otherwise.
     */
    public function exists($document)
    {
        $id = $this->class->getIdentifierObject($document);
        return (boolean) $this->collection->findOne(array('_id' => $id), array('_id'));
    }

    /**
     * Locks document by storing the lock mode on the mapped lock field.
     *
     * @param object $document
     * @param int $lockMode
     */
    public function lock($document, $lockMode)
    {
        $id = $this->uow->getDocumentIdentifier($document);
        $criteria = array('_id' => $this->class->getDatabaseIdentifierValue($id));
        $lockMapping = $this->class->fieldMappings[$this->class->lockField];
        $this->collection->update($criteria, array('$set' => array($lockMapping['name'] => $lockMode)));
        $this->class->reflFields[$this->class->lockField]->setValue($document, $lockMode);
    }

    /**
     * Releases any lock that exists on this document.
     *
     * @param object $document
     */
    public function unlock($document)
    {
        $id = $this->uow->getDocumentIdentifier($document);
        $criteria = array('_id' => $this->class->getDatabaseIdentifierValue($id));
        $lockMapping = $this->class->fieldMappings[$this->class->lockField];
        $this->collection->update($criteria, array('$unset' => array($lockMapping['name'] => true)));
        $this->class->reflFields[$this->class->lockField]->setValue($document, null);
    }

    /**
     * Creates or fills a single document object from an query result.
     *
     * @param object $result The query result.
     * @param object $document The document object to fill, if any.
     * @param array $hints Hints for document creation.
     * @return object The filled and managed document object or NULL, if the query result is empty.
     */
    private function createDocument($result, $document = null, array $hints = array())
    {
        if ($result === null) {
            return null;
        }

        if ($document !== null) {
            $hints[Query::HINT_REFRESH] = true;
            $id = $this->class->getPHPIdentifierValue($result['_id']);
            $this->uow->registerManaged($document, $id, $result);
        }

        return $this->uow->getOrCreateDocument($this->class->name, $result, $hints, $document);
    }

    /**
     * Loads a PersistentCollection data. Used in the initialize() method.
     *
     * @param PersistentCollectionInterface $collection
     */
    public function loadCollection(PersistentCollectionInterface $collection)
    {
        $mapping = $collection->getMapping();
        switch ($mapping['association']) {
            case ClassMetadata::EMBED_MANY:
                $this->loadEmbedManyCollection($collection);
                break;

            case ClassMetadata::REFERENCE_MANY:
                if (isset($mapping['repositoryMethod']) && $mapping['repositoryMethod']) {
                    $this->loadReferenceManyWithRepositoryMethod($collection);
                } else {
                    if ($mapping['isOwningSide']) {
                        $this->loadReferenceManyCollectionOwningSide($collection);
                    } else {
                        $this->loadReferenceManyCollectionInverseSide($collection);
                    }
                }
                break;
        }
    }

    private function loadEmbedManyCollection(PersistentCollectionInterface $collection)
    {
        $embeddedDocuments = $collection->getMongoData();
        $mapping = $collection->getMapping();
        $owner = $collection->getOwner();
        if ($embeddedDocuments) {
            foreach ($embeddedDocuments as $key => $embeddedDocument) {
                $className = $this->uow->getClassNameForAssociation($mapping, $embeddedDocument);
                $embeddedMetadata = $this->dm->getClassMetadata($className);
                $embeddedDocumentObject = $embeddedMetadata->newInstance();

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

                $data = $this->hydratorFactory->hydrate($embeddedDocumentObject, $embeddedDocument, $collection->getHints());
                $id = $embeddedMetadata->identifier && isset($data[$embeddedMetadata->identifier])
                    ? $data[$embeddedMetadata->identifier]
                    : null;

                if (empty($collection->getHints()[Query::HINT_READ_ONLY])) {
                    $this->uow->registerManaged($embeddedDocumentObject, $id, $data);
                }
                if (CollectionHelper::isHash($mapping['strategy'])) {
                    $collection->set($key, $embeddedDocumentObject);
                } else {
                    $collection->add($embeddedDocumentObject);
                }
            }
        }
    }

    private function loadReferenceManyCollectionOwningSide(PersistentCollectionInterface $collection)
    {
        $hints = $collection->getHints();
        $mapping = $collection->getMapping();
        $groupedIds = array();

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

        foreach ($collection->getMongoData() as $key => $reference) {
            $className = $this->uow->getClassNameForAssociation($mapping, $reference);
            $mongoId = ClassMetadataInfo::getReferenceId($reference, $mapping['storeAs']);
            $id = $this->dm->getClassMetadata($className)->getPHPIdentifierValue($mongoId);

            // create a reference to the class and id
            $reference = $this->dm->getReference($className, $id);

            // no custom sort so add the references right now in the order they are embedded
            if ( ! $sorted) {
                if (CollectionHelper::isHash($mapping['strategy'])) {
                    $collection->set($key, $reference);
                } else {
                    $collection->add($reference);
                }
            }

            // only query for the referenced object if it is not already initialized or the collection is sorted
            if (($reference instanceof Proxy && ! $reference->__isInitialized__) || $sorted) {
                $groupedIds[$className][] = $mongoId;
            }
        }
        foreach ($groupedIds as $className => $ids) {
            $class = $this->dm->getClassMetadata($className);
            $mongoCollection = $this->dm->getDocumentCollection($className);
            $criteria = $this->cm->merge(
                array('_id' => array('$in' => array_values($ids))),
                $this->dm->getFilterCollection()->getFilterCriteria($class),
                isset($mapping['criteria']) ? $mapping['criteria'] : array()
            );
            $criteria = $this->uow->getDocumentPersister($className)->prepareQueryOrNewObj($criteria);
            $cursor = $mongoCollection->find($criteria);
            if (isset($mapping['sort'])) {
                $cursor->sort($mapping['sort']);
            }
            if (isset($mapping['limit'])) {
                $cursor->limit($mapping['limit']);
            }
            if (isset($mapping['skip'])) {
                $cursor->skip($mapping['skip']);
            }
            if ( ! empty($hints[Query::HINT_SLAVE_OKAY])) {
                $cursor->slaveOkay(true);
            }
            if ( ! empty($hints[Query::HINT_READ_PREFERENCE])) {
                $cursor->setReadPreference($hints[Query::HINT_READ_PREFERENCE], $hints[Query::HINT_READ_PREFERENCE_TAGS]);
            }
            $documents = $cursor->toArray(false);
            foreach ($documents as $documentData) {
                $document = $this->uow->getById($documentData['_id'], $class);
                if ($document instanceof Proxy && ! $document->__isInitialized()) {
                    $data = $this->hydratorFactory->hydrate($document, $documentData);
                    $this->uow->setOriginalDocumentData($document, $data);
                    $document->__isInitialized__ = true;
                }
                if ($sorted) {
                    $collection->add($document);
                }
            }
        }
    }

    private function loadReferenceManyCollectionInverseSide(PersistentCollectionInterface $collection)
    {
        $query = $this->createReferenceManyInverseSideQuery($collection);
        $documents = $query->execute()->toArray(false);
        foreach ($documents as $key => $document) {
            $collection->add($document);
        }
    }

    /**
     * @param PersistentCollectionInterface $collection
     *
     * @return Query
     */
    public function createReferenceManyInverseSideQuery(PersistentCollectionInterface $collection)
    {
        $hints = $collection->getHints();
        $mapping = $collection->getMapping();
        $owner = $collection->getOwner();
        $ownerClass = $this->dm->getClassMetadata(get_class($owner));
        $targetClass = $this->dm->getClassMetadata($mapping['targetDocument']);
        $mappedByMapping = isset($targetClass->fieldMappings[$mapping['mappedBy']]) ? $targetClass->fieldMappings[$mapping['mappedBy']] : array();
        $mappedByFieldName = ClassMetadataInfo::getReferenceFieldName(isset($mappedByMapping['storeAs']) ? $mappedByMapping['storeAs'] : ClassMetadataInfo::REFERENCE_STORE_AS_DB_REF, $mapping['mappedBy']);

        $criteria = $this->cm->merge(
            array($mappedByFieldName => $ownerClass->getIdentifierObject($owner)),
            $this->dm->getFilterCollection()->getFilterCriteria($targetClass),
            isset($mapping['criteria']) ? $mapping['criteria'] : array()
        );
        $criteria = $this->uow->getDocumentPersister($mapping['targetDocument'])->prepareQueryOrNewObj($criteria);
        $qb = $this->dm->createQueryBuilder($mapping['targetDocument'])
            ->setQueryArray($criteria);

        if (isset($mapping['sort'])) {
            $qb->sort($mapping['sort']);
        }
        if (isset($mapping['limit'])) {
            $qb->limit($mapping['limit']);
        }
        if (isset($mapping['skip'])) {
            $qb->skip($mapping['skip']);
        }
        if ( ! empty($hints[Query::HINT_SLAVE_OKAY])) {
            $qb->slaveOkay(true);
        }
        if ( ! empty($hints[Query::HINT_READ_PREFERENCE])) {
            $qb->setReadPreference($hints[Query::HINT_READ_PREFERENCE], $hints[Query::HINT_READ_PREFERENCE_TAGS]);
        }
        foreach ($mapping['prime'] as $field) {
            $qb->field($field)->prime(true);
        }

        return $qb->getQuery();
    }

    private function loadReferenceManyWithRepositoryMethod(PersistentCollectionInterface $collection)
    {
        $cursor = $this->createReferenceManyWithRepositoryMethodCursor($collection);
        $mapping = $collection->getMapping();
        $documents = $cursor->toArray(false);
        foreach ($documents as $key => $obj) {
            if (CollectionHelper::isHash($mapping['strategy'])) {
                $collection->set($key, $obj);
            } else {
                $collection->add($obj);
            }
        }
    }

    /**
     * @param PersistentCollectionInterface $collection
     *
     * @return CursorInterface
     */
    public function createReferenceManyWithRepositoryMethodCursor(PersistentCollectionInterface $collection)
    {
        $hints = $collection->getHints();
        $mapping = $collection->getMapping();
        $repositoryMethod = $mapping['repositoryMethod'];
        $cursor = $this->dm->getRepository($mapping['targetDocument'])
            ->$repositoryMethod($collection->getOwner());

        if ( ! $cursor instanceof CursorInterface) {
            throw new \BadMethodCallException("Expected repository method {$repositoryMethod} to return a CursorInterface");
        }

        if (!empty($mapping['prime'])) {
            if (!$cursor instanceof Cursor) {
                throw new \BadMethodCallException("Expected repository method {$repositoryMethod} to return a Cursor to allow for priming");
            }

            $referencePrimer = new ReferencePrimer($this->dm, $this->dm->getUnitOfWork());
            $primers = array_combine($mapping['prime'], array_fill(0, count($mapping['prime']), true));

            $cursor->enableReferencePriming($primers, $referencePrimer);
        }

        if (isset($mapping['sort'])) {
            $cursor->sort($mapping['sort']);
        }
        if (isset($mapping['limit'])) {
            $cursor->limit($mapping['limit']);
        }
        if (isset($mapping['skip'])) {
            $cursor->skip($mapping['skip']);
        }
        if ( ! empty($hints[Query::HINT_SLAVE_OKAY])) {
            $cursor->slaveOkay(true);
        }
        if ( ! empty($hints[Query::HINT_READ_PREFERENCE])) {
            $cursor->setReadPreference($hints[Query::HINT_READ_PREFERENCE], $hints[Query::HINT_READ_PREFERENCE_TAGS]);
        }

        return $cursor;
    }

    /**
     * Prepare a sort or projection array by converting keys, which are PHP
     * property names, to MongoDB field names.
     *
     * @param array $fields
     * @return array
     */
    public function prepareSortOrProjection(array $fields)
    {
        $preparedFields = array();

        foreach ($fields as $key => $value) {
            $preparedFields[$this->prepareFieldName($key)] = $value;
        }

        return $preparedFields;
    }

    /**
     * Prepare a mongodb field name and convert the PHP property names to MongoDB field names.
     *
     * @param string $fieldName
     * @return string
     */
    public function prepareFieldName($fieldName)
    {
        $fieldNames = $this->prepareQueryElement($fieldName, null, null, false);

        return $fieldNames[0][0];
    }

    /**
     * Adds discriminator criteria to an already-prepared query.
     *
     * This method should be used once for query criteria and not be used for
     * nested expressions. It should be called before
     * {@link DocumentPerister::addFilterToPreparedQuery()}.
     *
     * @param array $preparedQuery
     * @return array
     */
    public function addDiscriminatorToPreparedQuery(array $preparedQuery)
    {
        /* If the class has a discriminator field, which is not already in the
         * criteria, inject it now. The field/values need no preparation.
         */
        if ($this->class->hasDiscriminator() && ! isset($preparedQuery[$this->class->discriminatorField])) {
            $discriminatorValues = $this->getClassDiscriminatorValues($this->class);
            if (count($discriminatorValues) === 1) {
                $preparedQuery[$this->class->discriminatorField] = $discriminatorValues[0];
            } else {
                $preparedQuery[$this->class->discriminatorField] = array('$in' => $discriminatorValues);
            }
        }

        return $preparedQuery;
    }

    /**
     * Adds filter criteria to an already-prepared query.
     *
     * This method should be used once for query criteria and not be used for
     * nested expressions. It should be called after
     * {@link DocumentPerister::addDiscriminatorToPreparedQuery()}.
     *
     * @param array $preparedQuery
     * @return array
     */
    public function addFilterToPreparedQuery(array $preparedQuery)
    {
        /* If filter criteria exists for this class, prepare it and merge
         * over the existing query.
         *
         * @todo Consider recursive merging in case the filter criteria and
         * prepared query both contain top-level $and/$or operators.
         */
        if ($filterCriteria = $this->dm->getFilterCollection()->getFilterCriteria($this->class)) {
            $preparedQuery = $this->cm->merge($preparedQuery, $this->prepareQueryOrNewObj($filterCriteria));
        }

        return $preparedQuery;
    }

    /**
     * Prepares the query criteria or new document object.
     *
     * PHP field names and types will be converted to those used by MongoDB.
     *
     * @param array $query
     * @param bool $isNewObj
     * @return array
     */
    public function prepareQueryOrNewObj(array $query, $isNewObj = false)
    {
        $preparedQuery = array();

        foreach ($query as $key => $value) {
            // Recursively prepare logical query clauses
            if (in_array($key, array('$and', '$or', '$nor')) && is_array($value)) {
                foreach ($value as $k2 => $v2) {
                    $preparedQuery[$key][$k2] = $this->prepareQueryOrNewObj($v2, $isNewObj);
                }
                continue;
            }

            if (isset($key[0]) && $key[0] === '$' && is_array($value)) {
                $preparedQuery[$key] = $this->prepareQueryOrNewObj($value, $isNewObj);
                continue;
            }

            $preparedQueryElements = $this->prepareQueryElement($key, $value, null, true, $isNewObj);
            foreach ($preparedQueryElements as list($preparedKey, $preparedValue)) {
                $preparedQuery[$preparedKey] = is_array($preparedValue)
                    ? array_map('\Doctrine\ODM\MongoDB\Types\Type::convertPHPToDatabaseValue', $preparedValue)
                    : Type::convertPHPToDatabaseValue($preparedValue);
            }
        }

        return $preparedQuery;
    }

    /**
     * Prepares a query value and converts the PHP value to the database value
     * if it is an identifier.
     *
     * It also handles converting $fieldName to the database name if they are different.
     *
     * @param string $fieldName
     * @param mixed $value
     * @param ClassMetadata $class        Defaults to $this->class
     * @param bool $prepareValue Whether or not to prepare the value
     * @param bool $inNewObj Whether or not newObj is being prepared
     * @return array An array of tuples containing prepared field names and values
     */
    private function prepareQueryElement($fieldName, $value = null, $class = null, $prepareValue = true, $inNewObj = false)
    {
        $class = isset($class) ? $class : $this->class;

        // @todo Consider inlining calls to ClassMetadataInfo methods

        // Process all non-identifier fields by translating field names
        if ($class->hasField($fieldName) && ! $class->isIdentifier($fieldName)) {
            $mapping = $class->fieldMappings[$fieldName];
            $fieldName = $mapping['name'];

            if ( ! $prepareValue) {
                return [[$fieldName, $value]];
            }

            // Prepare mapped, embedded objects
            if ( ! empty($mapping['embedded']) && is_object($value) &&
                ! $this->dm->getMetadataFactory()->isTransient(get_class($value))) {
                return [[$fieldName, $this->pb->prepareEmbeddedDocumentValue($mapping, $value)]];
            }

            if (! empty($mapping['reference']) && is_object($value) && ! ($value instanceof \MongoId)) {
                try {
                    return $this->prepareReference($fieldName, $value, $mapping, $inNewObj);
                } catch (MappingException $e) {
                    // do nothing in case passed object is not mapped document
                }
            }

            // No further preparation unless we're dealing with a simple reference
            // We can't have expressions in empty() with PHP < 5.5, so store it in a variable
            $arrayValue = (array) $value;
            if (empty($mapping['reference']) || $mapping['storeAs'] !== ClassMetadataInfo::REFERENCE_STORE_AS_ID || empty($arrayValue)) {
                return [[$fieldName, $value]];
            }

            // Additional preparation for one or more simple reference values
            $targetClass = $this->dm->getClassMetadata($mapping['targetDocument']);

            if ( ! is_array($value)) {
                return [[$fieldName, $targetClass->getDatabaseIdentifierValue($value)]];
            }

            // Objects without operators or with DBRef fields can be converted immediately
            if ( ! $this->hasQueryOperators($value) || $this->hasDBRefFields($value)) {
                return [[$fieldName, $targetClass->getDatabaseIdentifierValue($value)]];
            }

            return [[$fieldName, $this->prepareQueryExpression($value, $targetClass)]];
        }

        // Process identifier fields
        if (($class->hasField($fieldName) && $class->isIdentifier($fieldName)) || $fieldName === '_id') {
            $fieldName = '_id';

            if ( ! $prepareValue) {
                return [[$fieldName, $value]];
            }

            if ( ! is_array($value)) {
                return [[$fieldName, $class->getDatabaseIdentifierValue($value)]];
            }

            // Objects without operators or with DBRef fields can be converted immediately
            if ( ! $this->hasQueryOperators($value) || $this->hasDBRefFields($value)) {
                return [[$fieldName, $class->getDatabaseIdentifierValue($value)]];
            }

            return [[$fieldName, $this->prepareQueryExpression($value, $class)]];
        }

        // No processing for unmapped, non-identifier, non-dotted field names
        if (strpos($fieldName, '.') === false) {
            return [[$fieldName, $value]];
        }

        /* Process "fieldName.objectProperty" queries (on arrays or objects).
         *
         * We can limit parsing here, since at most three segments are
         * significant: "fieldName.objectProperty" with an optional index or key
         * for collections stored as either BSON arrays or objects.
         */
        $e = explode('.', $fieldName, 4);

        // No further processing for unmapped fields
        if ( ! isset($class->fieldMappings[$e[0]])) {
            return [[$fieldName, $value]];
        }

        $mapping = $class->fieldMappings[$e[0]];
        $e[0] = $mapping['name'];

        // Hash and raw fields will not be prepared beyond the field name
        if ($mapping['type'] === Type::HASH || $mapping['type'] === Type::RAW) {
            $fieldName = implode('.', $e);

            return [[$fieldName, $value]];
        }

        if ($mapping['type'] == 'many' && CollectionHelper::isHash($mapping['strategy'])
                && isset($e[2])) {
            $objectProperty = $e[2];
            $objectPropertyPrefix = $e[1] . '.';
            $nextObjectProperty = implode('.', array_slice($e, 3));
        } elseif ($e[1] != '$') {
            $fieldName = $e[0] . '.' . $e[1];
            $objectProperty = $e[1];
            $objectPropertyPrefix = '';
            $nextObjectProperty = implode('.', array_slice($e, 2));
        } elseif (isset($e[2])) {
            $fieldName = $e[0] . '.' . $e[1] . '.' . $e[2];
            $objectProperty = $e[2];
            $objectPropertyPrefix = $e[1] . '.';
            $nextObjectProperty = implode('.', array_slice($e, 3));
        } else {
            $fieldName = $e[0] . '.' . $e[1];

            return [[$fieldName, $value]];
        }

        // No further processing for fields without a targetDocument mapping
        if ( ! isset($mapping['targetDocument'])) {
            if ($nextObjectProperty) {
                $fieldName .= '.'.$nextObjectProperty;
            }

            return [[$fieldName, $value]];
        }

        $targetClass = $this->dm->getClassMetadata($mapping['targetDocument']);

        // No further processing for unmapped targetDocument fields
        if ( ! $targetClass->hasField($objectProperty)) {
            if ($nextObjectProperty) {
                $fieldName .= '.'.$nextObjectProperty;
            }

            return [[$fieldName, $value]];
        }

        $targetMapping = $targetClass->getFieldMapping($objectProperty);
        $objectPropertyIsId = $targetClass->isIdentifier($objectProperty);

        // Prepare DBRef identifiers or the mapped field's property path
        $fieldName = ($objectPropertyIsId && ! empty($mapping['reference']) && $mapping['storeAs'] !== ClassMetadataInfo::REFERENCE_STORE_AS_ID)
            ? ClassMetadataInfo::getReferenceFieldName($mapping['storeAs'], $e[0])
            : $e[0] . '.' . $objectPropertyPrefix . $targetMapping['name'];

        // Process targetDocument identifier fields
        if ($objectPropertyIsId) {
            if ( ! $prepareValue) {
                return [[$fieldName, $value]];
            }

            if ( ! is_array($value)) {
                return [[$fieldName, $targetClass->getDatabaseIdentifierValue($value)]];
            }

            // Objects without operators or with DBRef fields can be converted immediately
            if ( ! $this->hasQueryOperators($value) || $this->hasDBRefFields($value)) {
                return [[$fieldName, $targetClass->getDatabaseIdentifierValue($value)]];
            }

            return [[$fieldName, $this->prepareQueryExpression($value, $targetClass)]];
        }

        /* The property path may include a third field segment, excluding the
         * collection item pointer. If present, this next object property must
         * be processed recursively.
         */
        if ($nextObjectProperty) {
            // Respect the targetDocument's class metadata when recursing
            $nextTargetClass = isset($targetMapping['targetDocument'])
                ? $this->dm->getClassMetadata($targetMapping['targetDocument'])
                : null;

            $fieldNames = $this->prepareQueryElement($nextObjectProperty, $value, $nextTargetClass, $prepareValue);

            return array_map(function ($preparedTuple) use ($fieldName) {
                list($key, $value) = $preparedTuple;

                return [$fieldName . '.' . $key, $value];
            }, $fieldNames);
        }

        return [[$fieldName, $value]];
    }

    /**
     * Prepares a query expression.
     *
     * @param array|object  $expression
     * @param ClassMetadata $class
     * @return array
     */
    private function prepareQueryExpression($expression, $class)
    {
        foreach ($expression as $k => $v) {
            // Ignore query operators whose arguments need no type conversion
            if (in_array($k, array('$exists', '$type', '$mod', '$size'))) {
                continue;
            }

            // Process query operators whose argument arrays need type conversion
            if (in_array($k, array('$in', '$nin', '$all')) && is_array($v)) {
                foreach ($v as $k2 => $v2) {
                    $expression[$k][$k2] = $class->getDatabaseIdentifierValue($v2);
                }
                continue;
            }

            // Recursively process expressions within a $not operator
            if ($k === '$not' && is_array($v)) {
                $expression[$k] = $this->prepareQueryExpression($v, $class);
                continue;
            }

            $expression[$k] = $class->getDatabaseIdentifierValue($v);
        }

        return $expression;
    }

    /**
     * Checks whether the value has DBRef fields.
     *
     * This method doesn't check if the the value is a complete DBRef object,
     * although it should return true for a DBRef. Rather, we're checking that
     * the value has one or more fields for a DBref. In practice, this could be
     * $elemMatch criteria for matching a DBRef.
     *
     * @param mixed $value
     * @return boolean
     */
    private function hasDBRefFields($value)
    {
        if ( ! is_array($value) && ! is_object($value)) {
            return false;
        }

        if (is_object($value)) {
            $value = get_object_vars($value);
        }

        foreach ($value as $key => $_) {
            if ($key === '$ref' || $key === '$id' || $key === '$db') {
                return true;
            }
        }

        return false;
    }

    /**
     * Checks whether the value has query operators.
     *
     * @param mixed $value
     * @return boolean
     */
    private function hasQueryOperators($value)
    {
        if ( ! is_array($value) && ! is_object($value)) {
            return false;
        }

        if (is_object($value)) {
            $value = get_object_vars($value);
        }

        foreach ($value as $key => $_) {
            if (isset($key[0]) && $key[0] === '$') {
                return true;
            }
        }

        return false;
    }

    /**
     * Gets the array of discriminator values for the given ClassMetadata
     *
     * @param ClassMetadata $metadata
     * @return array
     */
    private function getClassDiscriminatorValues(ClassMetadata $metadata)
    {
        $discriminatorValues = array($metadata->discriminatorValue);
        foreach ($metadata->subClasses as $className) {
            if ($key = array_search($className, $metadata->discriminatorMap)) {
                $discriminatorValues[] = $key;
            }
        }

        // If a defaultDiscriminatorValue is set and it is among the discriminators being queries, add NULL to the list
        if ($metadata->defaultDiscriminatorValue && array_search($metadata->defaultDiscriminatorValue, $discriminatorValues) !== false) {
            $discriminatorValues[] = null;
        }

        return $discriminatorValues;
    }

    private function handleCollections($document, $options)
    {
        // Collection deletions (deletions of complete collections)
        foreach ($this->uow->getScheduledCollections($document) as $coll) {
            if ($this->uow->isCollectionScheduledForDeletion($coll)) {
                $this->cp->delete($coll, $options);
            }
        }
        // Collection updates (deleteRows, updateRows, insertRows)
        foreach ($this->uow->getScheduledCollections($document) as $coll) {
            if ($this->uow->isCollectionScheduledForUpdate($coll)) {
                $this->cp->update($coll, $options);
            }
        }
        // Take new snapshots from visited collections
        foreach ($this->uow->getVisitedCollections($document) as $coll) {
            $coll->takeSnapshot();
        }
    }

    /**
     * If the document is new, ignore shard key field value, otherwise throw an exception.
     * Also, shard key field should be present in actual document data.
     *
     * @param object $document
     * @param string $shardKeyField
     * @param array  $actualDocumentData
     *
     * @throws MongoDBException
     */
    private function guardMissingShardKey($document, $shardKeyField, $actualDocumentData)
    {
        $dcs = $this->uow->getDocumentChangeSet($document);
        $isUpdate = $this->uow->isScheduledForUpdate($document);

        $fieldMapping = $this->class->getFieldMappingByDbFieldName($shardKeyField);
        $fieldName = $fieldMapping['fieldName'];

        if ($isUpdate && isset($dcs[$fieldName]) && $dcs[$fieldName][0] != $dcs[$fieldName][1]) {
            throw MongoDBException::shardKeyFieldCannotBeChanged($shardKeyField, $this->class->getName());
        }

        if (!isset($actualDocumentData[$fieldName])) {
            throw MongoDBException::shardKeyFieldMissing($shardKeyField, $this->class->getName());
        }
    }

    /**
     * Get shard key aware query for single document.
     *
     * @param object $document
     *
     * @return array
     */
    private function getQueryForDocument($document)
    {
        $id = $this->uow->getDocumentIdentifier($document);
        $id = $this->class->getDatabaseIdentifierValue($id);

        $shardKeyQueryPart = $this->getShardKeyQuery($document);
        $query = array_merge(array('_id' => $id), $shardKeyQueryPart);

        return $query;
    }

    /**
     * @param array $options
     *
     * @return array
     */
    private function getWriteOptions(array $options = array())
    {
        $defaultOptions = $this->dm->getConfiguration()->getDefaultCommitOptions();
        $documentOptions = [];
        if ($this->class->hasWriteConcern()) {
            $documentOptions['w'] = $this->class->getWriteConcern();
        }

        return array_merge($defaultOptions, $documentOptions, $options);
    }

    /**
     * @param string $fieldName
     * @param mixed $value
     * @param array $mapping
     * @param bool $inNewObj
     * @return array
     */
    private function prepareReference($fieldName, $value, array $mapping, $inNewObj)
    {
        $reference = $this->dm->createReference($value, $mapping);
        if ($inNewObj || $mapping['storeAs'] === ClassMetadataInfo::REFERENCE_STORE_AS_ID) {
            return [[$fieldName, $reference]];
        }

        switch ($mapping['storeAs']) {
            case ClassMetadataInfo::REFERENCE_STORE_AS_REF:
                $keys = ['id' => true];
                break;

            case ClassMetadataInfo::REFERENCE_STORE_AS_DB_REF:
            case ClassMetadataInfo::REFERENCE_STORE_AS_DB_REF_WITH_DB:
                $keys = ['$ref' => true, '$id' => true, '$db' => true];

            if ($mapping['storeAs'] === ClassMetadataInfo::REFERENCE_STORE_AS_DB_REF) {
                    unset($keys['$db']);
                }

                if (isset($mapping['targetDocument'])) {
                    unset($keys['$ref'], $keys['$db']);
                }
                break;

            default:
                throw new \InvalidArgumentException("Reference type {$mapping['storeAs']} is invalid.");
        }

        if ($mapping['type'] === 'many') {
            return [[$fieldName, ['$elemMatch' => array_intersect_key($reference, $keys)]]];
        } else {
            return array_map(
                function ($key) use ($reference, $fieldName) {
                    return [$fieldName . '.' . $key, $reference[$key]];
                },
                array_keys($keys)
            );
        }
    }
}
