comsave/salesforce-mapper-bundle

View on GitHub
src/LogicItLab/Salesforce/MapperBundle/Mapper.php

Summary

Maintainability
F
4 days
Test Coverage
F
55%
<?php

namespace LogicItLab\Salesforce\MapperBundle;

use DateTime;
use DateTimeZone;
use Doctrine\Common\Cache\Cache;
use InvalidArgumentException;
use LogicItLab\Salesforce\MapperBundle\Annotation;
use LogicItLab\Salesforce\MapperBundle\Annotation as Salesforce;
use LogicItLab\Salesforce\MapperBundle\Annotation\AnnotationReader;
use LogicItLab\Salesforce\MapperBundle\Event\BeforeSaveEvent;
use LogicItLab\Salesforce\MapperBundle\Query\Builder;
use LogicItLab\Salesforce\MapperBundle\Response\MappedRecordIterator;
use Phpforce\SoapClient\ClientInterface;
use Phpforce\SoapClient\Result;
use ReflectionClass;
use ReflectionObject;
use stdClass;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Traversable;
use UnexpectedValueException;

/**
 * This mapper makes interaction with the Salesforce API using full objects
 * much easier
 *
 * Working with the mapper requires you to annotate your objects. A set of
 * standard objects is included in the Model directory. If you need access
 * to custom properties on these objects, it is recommended to
 * extend the standard objects, add the properties and annotate them
 * (using @Salesforce\Field annotations). If you want this mapper to accept
 * completely custom objects, you can extend from Model/AbstractModel, and add
 * a @Salesforce\SObject annotation.
 *
 * @author Logic It Lab <team@logicitlab.com>
 */
class Mapper
{
    /**
     * Salesforce client
     *
     * @var ClientInterface
     */
    private $client;

    /**
     * Salesforce annotations reader
     *
     * @var AnnotationReader
     */
    private $annotationReader;

    /**
     * Cache
     *
     * @var Cache
     */
    private $cache;

    /**
     * Symfony event dispatcher
     *
     * @var EventDispatcherInterface
     */
    private $eventDispatcher;

    protected $unitOfWork;

    protected $objectDescriptions = array();

    /**
     * Construct mapper
     *
     * @param SoapClient $soapClient
     * @param AnnotationReader $annotationReader
     * @param Cache $cache
     */
    public function __construct(ClientInterface $client, AnnotationReader $annotationReader, Cache $cache)
    {
        $this->client = $client;
        $this->annotationReader = $annotationReader;
        $this->cache = $cache;
        $this->unitOfWork = new UnitOfWork($this, $this->annotationReader);
    }


    /**
     * Get event dispatcher
     *
     * @return type EventDispatcherInterface
     */
    public function getEventDispatcher()
    {
        return $this->eventDispatcher;
    }

    /**
     * Set event dispatcher
     *
     * @param EventDispatcherInterface $eventDispatcher
     * @return Mapper
     */
    public function setEventDispatcher(EventDispatcherInterface $eventDispatcher)
    {
        $this->eventDispatcher = $eventDispatcher;
        return $this;
    }

    /**
     * Get object count
     *
     * @param string $modelClass Model class name
     * @param boolean $includeDeleted
     * @param array $criteria
     * @return int
     */
    public function count($modelClass, $includeDeleted = false, array $criteria = array())
    {
        $object = $this->annotationReader->getSalesforceObject($modelClass);
        if (null === $object) {
            throw new UnexpectedValueException('Model has no Salesforce annotation');
        }

        $query = trim("select count() from {$object->name} "
            . $this->getQueryWherePart($criteria, $modelClass));

        if (true === $includeDeleted) {
            $result = $this->client->queryAll($query);
        } else {
            $result = $this->client->query($query);
        }

        if ($result) {
            return $result->count();
        }
    }

    /**
     * Delete one or more records from Salesforce
     *
     * @param array|Traversable $models
     * @return array
     */
    public function delete($models)
    {
        if (!is_array($models) && !($models instanceof Traversable)) {
            throw new InvalidArgumentException('$models must be iterable');
        }

        $ids = array();
        foreach ($models as $model) {
            $ids[] = $model->getId();
        }

        return $this->client->delete($ids);
    }

    /**
     * Find one object by id
     *
     * @param mixed $model Model object or class name
     * @param string $id Object id
     * @param int $related Number of levels of related records to include
     * @return object       Mapped domain object
     */
    public function find($model, $id, $related = 1)
    {
        $query = $this->getQuerySelectPart($model, $related)
            . sprintf(' where Id=\'%s\'', $id);

        $result = $this->client->query($query);
        $mappedRecordIterator = new MappedRecordIterator($result, $this, $model);

        return $mappedRecordIterator->first();
    }

    /**
     * Find multiple objects by criteria and return result as an iterator
     *
     * @param object $model Model object or class name
     * @param array $criteria Criteria as key/value pairs
     * @param array $order Order to sort by as key/value pairs
     * @param int $related Number of levels of related records to include
     * @param bool $deleted Whether to include deleted records
     * @param bool $returnAsArray Whether to return as array
     * @return MappedRecordIterator
     */
    public function findBy(
        $model,
        array $criteria,
        array $order = array(),
        $related = 1,
        $deleted = false,
        $returnAsArray = false
    ) {
        $query = $this->getQuerySelectPart($model, $related)
            . $this->getQueryWherePart($criteria, $model)
            . $this->getQueryOrderByPart($order, $model);

        if (true === $deleted) {
            $result = $this->client->queryAll($query);
        } else {
            $result = $this->client->query($query);
        }

        if ($returnAsArray) {
            return iterator_to_array(new MappedRecordIterator($result, $this, $model));
        }
        return new MappedRecordIterator($result, $this, $model);
    }

    /**
     * Find one object by criteria
     *
     * @param object $model
     * @param array $criteria
     * @param array $order
     * @param int $related
     * @param bool $deleted
     * @return object
     */
    public function findOneBy(
        $model,
        array $criteria,
        array $order = array(),
        $related = 2,
        $deleted = false
    ) {
        $iterator = $this->findBy($model, $criteria, $order, $related, $deleted);
        return $iterator->first();
    }

    /**
     * Fetch all objects of a certain type
     *
     * @param object $model Model object or class name
     * @param array $order Order to sort by as key/value pairs
     * @param boolean $related Number of levels of related records to include
     * @param boolean $deleted Whether to include deleted records
     * @return MappedRecordIterator
     */
    public function findAll(
        $model,
        array $order = array(),
        $related = 1,
        $deleted = false
    ) {
        return $this->findBy($model, array(), $order, $related, $deleted);
    }

    /**
     * Get object description, if possible from cache
     *
     * @param object $model Model object or class name
     * @return Response\DescribeSObjectResult
     * @throws InvalidArgumentException
     */
    public function getObjectDescription($model)
    {
        $object = $this->annotationReader->getSalesforceObject($model);

        if (!isset($this->objectDescriptions[$object->name])) {
            $this->objectDescriptions[$object->name] =
                $this->doGetObjectDescription($object->name);
        }

        return $this->objectDescriptions[$object->name];
    }

    /**
     * Save one or more domain models to Salesforce
     *
     * @param mixed $model One model or array of models
     * @return Result\SaveResult[]
     */
    public function save($model)
    {
        if (is_array($model)) {
            $models = $model;
        } elseif ($model instanceof Traversable) {
            $models = array();
            foreach ($model as $m) {
                $models[] = $m;
            }
        } else {
            $models = array($model);
        }

        if ($this->eventDispatcher) {
            $event = new BeforeSaveEvent($models);
            $this->eventDispatcher->dispatch(Events::beforeSave, $event);
        }

        $objectsToBeCreated = array();
        $objectsToBeUpdated = array();
        $modelsWithoutId = array();

        foreach ($models as $model) {
            $object = $this->annotationReader->getSalesforceObject($model);
            $sObject = $this->mapToSalesforceObject($model);
            if (isset($sObject->Id) && null !== $sObject->Id) {
                $objectsToBeUpdated[$object->name][] = $sObject;
            } else {
                $objectsToBeCreated[$object->name][] = $sObject;
                $modelsWithoutId[$object->name][] = $model;
            }
        }

        $results = array();
        foreach ($objectsToBeCreated as $objectName => $sObjects) {
            $reflClass = new ReflectionClass(current(
                $modelsWithoutId[$objectName]
            ));
            $reflProperty = $reflClass->getProperty('id');
            $reflProperty->setAccessible(true);

            $saveResults = $this->client->create($sObjects, $objectName) ?? [];
            for ($i = 0; $i < count($saveResults); $i++) {
                $newId = $saveResults[$i]->getId();
                $model = $modelsWithoutId[$objectName][$i];
                $reflProperty->setValue($model, $newId);
            }

            $results['created'] = $saveResults;
        }

        foreach ($objectsToBeUpdated as $objectName => $sObjects) {
            $results['updated'] = $this->client->update($sObjects, $objectName);
        }

        return $results;
    }

    /**
     * Map a Salesforce object to a domain model object
     *
     * Uses reflection instead of setters because read-only properties on
     * ojects should not need a setter.
     *
     * @param object $sObject
     * @param string $modelClass Model class name
     * @return object A mapped instantiation of the model class
     */
    public function mapToDomainObject($sObject, $modelClass)
    {
        // Try to find mapped model in unit of work
        if ($this->unitOfWork->find($modelClass, $sObject->Id)) {
            return $this->unitOfWork->find($modelClass, $sObject->Id);
        }

        $model = new $modelClass();
        $reflObject = new ReflectionObject($model);

        // Set Salesforce property values on domain object
        $fields = $this->annotationReader->getSalesforceFields($modelClass);
        foreach ($fields as $name => $field) {
            if (isset($sObject->{$field->name})) {
                // Use reflection to set the protected/private properties
                $reflProperty = $reflObject->getProperty($name);
                $reflProperty->setAccessible(true);
                $reflProperty->setValue($model, $sObject->{$field->name});
            }
        }

        // Set Salesforce relations on domain object
        $relations = $this->annotationReader->getSalesforceRelations($modelClass);
        foreach ($relations as $property => $relation) {

            // Relation name must be set
            if (isset($sObject->{$relation->name})) {
                $value = $sObject->{$relation->name};
                if ($value instanceof Result\RecordIterator) {
                    $value = new MappedRecordIterator(
                        $value, $this, $relation->class
                    );
                } else {
                    $value = $this->mapToDomainObject(
                        $sObject->{$relation->name}, $relation->class
                    );
                }

                $reflProperty = $reflObject->getProperty($property);
                $reflProperty->setAccessible(true);
                $reflProperty->setValue($model, $value);
            }
        }

        // Add mapped model to unit of work
        $this->unitOfWork->addToIdentityMap($model);

        return $model;
    }

    /**
     * Map a PHP model object to a Salesforce object
     *
     * The PHP object must be properly annoated
     *
     * @param mixed $model PHP model object
     * @return stdClass
     */
    public function mapToSalesforceObject($model)
    {
        $sObject = new stdClass;
        $sObject->fieldsToNull = array();

        $objectDescription = $this->getObjectDescription($model);
        $reflClass = new ReflectionClass($model);
        $mappedProperties = $this->annotationReader->getSalesforceFields($model);
        $mappedRelations = $this->annotationReader->getSalesforceRelations($model);
        $allMappings = $mappedProperties->toArray() + $mappedRelations;

        foreach ($allMappings as $property => $mapping) {
            if ($mapping instanceof Annotation\Field) {
                $fieldDescription = $objectDescription->getField($mapping->name);
                $fieldName = $mapping->name;
            } elseif ($mapping instanceof Annotation\Relation
                && $mapping->field) {
                // Only one-to-one and one-to-many relations will be saved
                $fieldDescription = $objectDescription->getField($mapping->field);
                $fieldName = $mapping->field;
            } else {
                // Do not save many-to-many relations
                continue;
            }

            if (!$fieldDescription) {
                throw new InvalidArgumentException(sprintf(
                    'Field %s (for property ‘%s’) does not exist on %s. '
                    . 'If you think it does, try emptying your cache.',
                    $fieldName, $property, $objectDescription->getName()
                ));
            }

            // If the object is created, only allow creatable fields.
            // If the object is updated, only allow updatable.
            if (($model->getId() && $fieldDescription->isUpdateable())
                || (!$model->getId() && $fieldDescription->isCreateable())
                // for 'Id' field:
                || $fieldDescription->isIdLookup()) {

                // Get value through reflection
                $reflProperty = $reflClass->getProperty($property);
                $reflProperty->setAccessible(true);
                $value = $reflProperty->getValue($model);

                if ($mapping instanceof Annotation\Relation) {
                    // @todo Implements recursive saving for new related
                    // records, too. This only works for already existing
                    // records.
                    if (method_exists($value, 'getId') && $value->getId()) {
                        $value = $value->getId();
                        $sObject->{$fieldDescription->getName()} = $value;
                        continue;
                    }
                }

                if (null === $value || (is_string($value) && $value === '')) {
                    // Do not set fieldsToNull on create
                    if ($model->getId()) {
                        $sObject->fieldsToNull[] = $fieldDescription->getName();
                    }
                } else {
                    $sObject->{$fieldDescription->getName()} = $value;
                }
            }
        }

        // Strip all values from fields to null for which values have been
        // set in the SObject
        if (isset($sObject->fieldsToNull)) {
            foreach ($sObject->fieldsToNull as $fieldToNull) {
                if (isset($sObject->$fieldToNull)) {
                    $key = array_search($fieldToNull, $sObject->fieldsToNull);
                    if ($key !== false) {
                        unset($sObject->fieldsToNull[$key]);
                    }
                }
            }
        }

        return $sObject;
    }

    /**
     * Get object description for Salesforce object
     *
     * @param string $objectName Name of the Salesforce object
     * @return DescribeSObjectResult
     * @throws InvalidArgumentException
     */
    private function doGetObjectDescription($objectName)
    {
        $cacheId = sprintf('logicitlab_salesforce_mapper.object_description.%s',
            $objectName);
        if ($this->cache->contains($cacheId)) {
            return $this->cache->fetch($cacheId);
        }

        $descriptions = $this->client->describeSObjects(array($objectName));
        if (count($descriptions) === 0) {
            throw new InvalidArgumentException('Salesforce object does not exist');
        }

        $description = /* @var $description DescribeSObjectResult */
            $descriptions[0];
        $this->cache->save($cacheId, $description);
        return $description;
    }


    /**
     * Get query basis
     *
     * @param string $modelClass Model class name
     * @param int $related Number of levels of related records to include
     *                           in query
     *                           0: do not include related records
     *                           1: include one level of related records, for
     *                              instance owner on opportunity
     *                           2: include two levels, for instance owner and
     *                              account owner on opportunity.
     * @return string
     */
    private function getQuerySelectPart($modelClass, $related)
    {
        $object = $this->annotationReader->getSalesforceObject($modelClass);
        $fields = $this->getFields($modelClass, $related);
        $oneToMany = $this->getOneToManySubqueries($modelClass, $related);

        $select = $this->getSelect($object->name, $fields, $oneToMany);
        return $select;
    }

    private function getSelect($object, $fields, $subqueries = array())
    {
        $select = 'select '
            . implode(',', $fields);
        if (count($subqueries) > 0) {
            $select .= ', ' . implode(',', $subqueries);
        }

        $select .= ' from ' . $object;
        return $select;
    }

    /**
     * Get SOQL where query part based on criteria array
     *
     * @param array $criteria
     * @return string
     */
    private function getQueryWherePart(array $criteria, $model)
    {
        $whereParts = array();
        $object = $this->annotationReader->getSalesforceObject($model);
        $fields = $this->annotationReader->getSalesforceFields($model);
        $objectDescription = $this->doGetObjectDescription($object->name);

        foreach ($criteria as $key => $value) {

            // Check if the criterion has an operator
            $keyParts = explode(' ', $key);

            // Criterion key has an operator part
            if (isset($keyParts[1])) {
                $operator = $keyParts[1];
            } else {
                // Criterion key has no operator, so add it ourselves
                $operator = '=';
            }

            $name = $keyParts[0];
            $field = $this->annotationReader->getSalesforceField($model, $name);
            if (!$field) {
                throw new InvalidArgumentException('Invalid field ' . $name);
            }

            if (is_array($value)) {
                $quotedValueList = array();

                foreach ($value as $v) {
                    $quotedValueList[] = $this->getQuotedWhereValue($field, $v, $objectDescription);
                }

                $quotedValue = '(' . implode(',', $quotedValueList) . ')';
            } else {
                $quotedValue = $this->getQuotedWhereValue($field, $value, $objectDescription);
            }

            $whereParts[] = sprintf('%s %s %s',
                $field->name,
                $operator,
                $quotedValue
            );
        }

        if (!empty($whereParts)) {
            return ' where ' . implode(' and ', $whereParts);
        }
    }

    /**
     * Get quoted where value
     *
     * @param Annotation\Field $field
     * @param mixed $value
     * @param DescribeSObjectResult $description
     * @return string
     * @throws InvalidArgumentException
     * @link http://www.salesforce.com/us/developer/docs/api/Content/field_types.htm#topic-title
     */
    private function getQuotedWhereValue(
        Annotation\Field $field,
        $value,
        Result\DescribeSObjectResult $description
    ) {
        $fieldDescription = $description->getField($field->name);
        if (!$fieldDescription) {
            throw new InvalidArgumentException(
                sprintf('\'%s\' on object %s is not a valid field',
                    $field->name,
                    $description->getName()
                )
            );
        }

        switch ($fieldDescription->getType()) {
            case 'date':
                if ($value instanceof DateTime) {
                    return $value->format('Y-m-d');
                }
            case 'datetime':
                if ($value instanceof DateTime) {
                    $value = $value->setTimeZone(new DateTimeZone('UTC'));
                    return $value->format('Y-m-d\TH:i:sP');
                } elseif (null != $value) {
                    // A text representation, such as ‘yesterday’: these should
                    // not be enclosed in quotes
                    return $value;
                } else {
                    return 'null';
                }
            case 'boolean':
                return $value ? 'true' : 'false';
            case 'double':
            case 'currency':
            case 'percent':
            case 'int':
                return $value;
            default:
                return "'" . addslashes($value) . "'";
        }
    }

    /**
     * Get SOQL order by query part from order by array
     *
     * @param array $orderBy
     * @return string
     */
    private function getQueryOrderByPart(array $orderBy, $model)
    {
        $orderParts = array();
        foreach ($orderBy as $field => $direction) {
            $fieldAnnotation = $this->annotationReader->getSalesforceField($model, $field);
            $orderParts[] = $fieldAnnotation->name . ' ' . $direction;
        }

        if (!empty($orderParts)) {
            return ' order by ' . implode(',', $orderParts);
        }
    }

    /**
     * Get Salesforce fields and its relations from a Salesforce-annotated model
     *
     * @param string $modelClass
     * @param int $includeRelatedLevels
     * @param string $ignoreObject Salesforce object name of model for which
     *                              fields should not be returned
     * @return array
     */
    public function getFields($modelClass, $includeRelatedLevels, $ignoreObject = null)
    {
        $fields = array();

        foreach ($this->annotationReader->getSalesforceFields($modelClass) as $field) {
            $fields[] = $field->name;
        }

        $description = $this->getObjectDescription($modelClass);

        if ($includeRelatedLevels > 0) {
            foreach ($this->annotationReader->getSalesforceRelations($modelClass) as $relation) {

                // Only process one-to-one and many-to-one relations here;
                // one-to-many relations must be looked up as subquery.
                if (!$relation->field) {
                    continue;
                }

                // Check whether we can find this relation
                $relationshipField = $description->getRelationshipField($relation->field);
                if (!$relationshipField) {
                    throw new InvalidArgumentException(
                        'Field ' . $relation->field . ' does not exist on ' . $description->getName());
                    continue;
                }

                // If the referenced object should be ignored, don't fetch its
                // fields
                if ($ignoreObject && $relationshipField->references($ignoreObject)) {
                    continue;
                }

                $relatedFields = $this->getFields($relation->class, --$includeRelatedLevels);
                foreach ($relatedFields as $relatedField) {
                    $fields[] = sprintf('%s.%s', $relationshipField->getRelationshipName(), $relatedField);
                }
            }
        }

        return $fields;
    }

    /**
     * Gets subqueries (sub selects) for annoted one-to-many relations on the
     * model
     *
     * @param object $model
     * @param int $includeRelatedLevels
     */
    public function getOneToManySubqueries($model, $includeRelatedLevels)
    {
        $relations = $this->annotationReader->getSalesforceRelations($model);
        $object = $this->annotationReader->getSalesforceObject($model);
        $subqueries = array();

        if ($includeRelatedLevels > 0) {
            foreach ($relations as $relation) {
                // Only process one-to-many relations here
                if ($relation->field) {
                    continue;
                }

                $fields = $this->getFields($relation->class, $includeRelatedLevels, $object->name);
                $subqueries[] = sprintf('(%s)',
                    $this->getSelect($relation->name, $fields));
            }
        }

        return $subqueries;
    }

    public function getClassMetadata($className)
    {
        $class;
    }

    public function merge($merge)
    {
    }

    /**
     * Create query builder
     *
     * @return Builder
     */
    public function createQueryBuilder()
    {
        return new Builder($this, $this->client, $this->annotationReader);
    }

    /*
     * Get unit of work
     *
     * @return UnitOfWork
     */
    public function getUnitOfWork()
    {
        return $this->unitOfWork;
    }

    /**
     * @return ClientInterface
     */
    public function getClient()
    {
        return $this->client;
    }
}