diff --git a/composer.json b/composer.json index 23c2608..4921093 100644 --- a/composer.json +++ b/composer.json @@ -33,7 +33,7 @@ "require": { }, "require-dev": { - "cakephp/cakephp": ">=3.2", + "cakephp/cakephp": "~3.2", "cakephp/cakephp-codesniffer": "2.*", "phpunit/phpunit": "4.1.*" }, diff --git a/config/bootstrap.php b/config/bootstrap.php index 9214bc9..5d78fa2 100644 --- a/config/bootstrap.php +++ b/config/bootstrap.php @@ -1,3 +1,13 @@ client())) { throw new RuntimeException(sprintf( 'The `%s` client has not been initialized', diff --git a/src/Association.php b/src/Association.php new file mode 100644 index 0000000..acaa0ee --- /dev/null +++ b/src/Association.php @@ -0,0 +1,752 @@ +{'_' . $property} = $options[$property]; + } + } + + if (empty($this->_className) && strpos($alias, '.')) { + $this->_className = $alias; + } + + list(, $name) = pluginSplit($alias); + $this->_name = $name; + + $this->_options($options); + + if (!empty($options['strategy'])) { + $this->strategy($options['strategy']); + } + } + + /** + * Sets the name for this association. If no argument is passed then the current + * configured name will be returned + * + * @param string|null $name Name to be assigned + * @return string + */ + public function name($name = null) + { + if ($name !== null) { + $this->_name = $name; + } + return $this->_name; + } + + /** + * Sets whether or not cascaded deletes should also fire callbacks. If no + * arguments are passed, the current configured value is returned + * + * @param bool|null $cascadeCallbacks cascade callbacks switch value + * @return bool + */ + public function cascadeCallbacks($cascadeCallbacks = null) + { + if ($cascadeCallbacks !== null) { + $this->_cascadeCallbacks = $cascadeCallbacks; + } + return $this->_cascadeCallbacks; + } + + /** + * The class name of the target endpoint object + * + * @return string + */ + public function className() + { + return $this->_className; + } + + /** + * Sets the endpoint instance for the source side of the association. If no arguments + * are passed, the current configured endpoint instance is returned + * + * @param \Cake\Datasource\RepositoryInterface|null $repository the instance to be assigned as source side + * @return \Cake\Datasource\RepositoryInterface + */ + public function source(RepositoryInterface $repository = null) + { + if ($repository === null) { + return $this->_sourceRepository; + } + return $this->_sourceRepository = $repository; + } + + /** + * Sets the endpoint instance for the target side of the association. If no arguments + * are passed, the current configured endpoint instance is returned + * + * @param \Cake\Datasource\RepositoryInterface|null $repository the instance to be assigned as target side + * @return \Cake\Datasource\RepositoryInterface + */ + public function target(RepositoryInterface $repository = null) + { + if ($repository === null && $this->_targetRepository) { + return $this->_targetRepository; + } + + if ($repository !== null) { + return $this->_targetRepository = $repository; + } + + if (strpos($this->_className, '.')) { + list($plugin) = pluginSplit($this->_className, true); + $registryAlias = $plugin . $this->_name; + } else { + $registryAlias = $this->_name; + } + + $config = []; + if (!EndpointRegistry::exists($registryAlias)) { + $config = ['className' => $this->_className]; + } + $this->_targetRepository = EndpointRegistry::get($registryAlias, $config); + + return $this->_targetRepository; + } + + /** + * Sets a list of conditions to be always included when fetching records from + * the target association. If no parameters are passed the current list is returned + * + * @param array|null $conditions list of conditions to be used + * @see \Muffin\Webservice\Query::where() for examples on the format of the array + * @return array + */ + public function conditions($conditions = null) + { + if ($conditions !== null) { + $this->_conditions = $conditions; + } + return $this->_conditions; + } + + /** + * Sets the name of the field representing the binding field with the target endpoint. + * When not manually specified the primary key of the owning side endpoint is used. + * + * If no parameters are passed the current field is returned + * + * @param string|null $key the endpoint field to be used to link both endpoints together + * @return string|array + */ + public function bindingKey($key = null) + { + if ($key !== null) { + $this->_bindingKey = $key; + } + + if ($this->_bindingKey === null) { + $this->_bindingKey = $this->isOwningSide($this->source()) ? + $this->source()->primaryKey() : + $this->target()->primaryKey(); + } + + return $this->_bindingKey; + } + + /** + * Sets the name of the field representing the foreign key to the target endpoint. + * If no parameters are passed the current field is returned + * + * @param string|null $key the key to be used to link both endpoints together + * @return string|array + */ + public function foreignKey($key = null) + { + if ($key !== null) { + $this->_foreignKey = $key; + } + return $this->_foreignKey; + } + + /** + * Sets whether the records on the target endpoint are dependent on the source endpoint. + * + * This is primarily used to indicate that records should be removed if the owning record in + * the source endpoint is deleted. + * + * If no parameters are passed the current setting is returned. + * + * @param bool|null $dependent Set the dependent mode. Use null to read the current state. + * @return bool + */ + public function dependent($dependent = null) + { + if ($dependent !== null) { + $this->_dependent = $dependent; + } + return $this->_dependent; + } + + /** + * Whether this association can be expressed directly in a query join + * + * @param array $options custom options key that could alter the return value + * @return bool + */ + public function canBeJoined(array $options = []) + { + $strategy = isset($options['strategy']) ? $options['strategy'] : $this->strategy(); + return $strategy == $this::STRATEGY_INCLUDED; + } + + /** + * Sets the property name that should be filled with data from the target endpoint + * in the source endpoint record. + * If no arguments are passed, the currently configured type is returned. + * + * @param string|null $name The name of the association property. Use null to read the current value. + * @return string + */ + public function property($name = null) + { + if ($name !== null) { + $this->_propertyName = $name; + } + if ($name === null && !$this->_propertyName) { + $this->_propertyName = $this->_propertyName(); + if (!$this->_sourceRepository) { + stackTrace(); + } + if (in_array($this->_propertyName, $this->_sourceRepository->schema()->columns())) { + $msg = 'Association property name "%s" clashes with field of same name of endpoint "%s".' . + ' You should explicitly specify the "propertyName" option.'; + trigger_error( + sprintf($msg, $this->_propertyName, $this->_sourceRepository->endpoint()), + E_USER_WARNING + ); + } + } + return $this->_propertyName; + } + + /** + * Returns default property name based on association name. + * + * @return string + */ + protected function _propertyName() + { + list(, $name) = pluginSplit($this->_name); + return Inflector::underscore($name); + } + + /** + * Sets the strategy name to be used to fetch associated records. Keep in mind + * that some association types might not implement but a default strategy, + * rendering any changes to this setting void. + * If no arguments are passed, the currently configured strategy is returned. + * + * @param string|null $name The strategy type. Use null to read the current value. + * @return string + * @throws \InvalidArgumentException When an invalid strategy is provided. + */ + public function strategy($name = null) + { + if ($name !== null) { + if (!in_array($name, $this->_validStrategies)) { + throw new InvalidArgumentException( + sprintf('Invalid strategy "%s" was provided', $name) + ); + } + $this->_strategy = $name; + } + return $this->_strategy; + } + + /** + * Sets the default finder to use for fetching rows from the target endpoint. + * If no parameters are passed, it will return the currently configured + * finder name. + * + * @param string|null $finder the finder name to use + * @return string + */ + public function finder($finder = null) + { + if ($finder !== null) { + $this->_finder = $finder; + } + return $this->_finder; + } + + /** + * Override this function to initialize any concrete association class, it will + * get passed the original list of options used in the constructor + * + * @param array $options List of options used for initialization + * @return void + */ + protected function _options(array $options) + { + } + + /** + * Alters a Query object to include the associated target endpoint data in the final + * result + * + * The options array accept the following keys: + * + * - includeFields: Whether to include target model fields in the result or not + * - foreignKey: The name of the field to use as foreign key, if false none + * will be used + * - conditions: array with a list of conditions to filter the join with, this + * will be merged with any conditions originally configured for this association + * - fields: a list of fields in the target endpoint to include in the result + * - type: The type of join to be used (e.g. INNER) + * the records found on this association + * - aliasPath: A dot separated string representing the path of association names + * followed from the passed query main endpoint to this association. + * - propertyPath: A dot separated string representing the path of association + * properties to be followed from the passed query main entity to this + * association + * - joinType: The SQL join type to use in the query. + * - negateMatch: Will append a condition to the passed query for excluding matches. + * with this association. + * + * @param \Cake\Datasource\QueryInterface $query the query to be altered to include the target endpoint data + * @param array $options Any extra options or overrides to be taken in account + * @return void + * @throws \RuntimeException if the query builder passed does not return a query + * object + */ + public function attachTo(QueryInterface $query, array $options = []) + { + $target = $this->target(); + + $endpoint = $target->endpoint(); + $options += [ + 'includeFields' => true, + 'foreignKey' => $this->foreignKey(), + 'conditions' => [], + 'fields' => [], + 'endpoint' => $endpoint, + 'finder' => $this->finder() + ]; + + if (!empty($options['foreignKey'])) { + $foreignKey = (array)$options['foreignKey']; + $bindingKey = (array)$this->bindingKey(); + + if (count($foreignKey) !== count($bindingKey)) { + $msg = 'Cannot match provided foreignKey for "%s", got "(%s)" but expected foreign key for "(%s)"'; + throw new RuntimeException(sprintf( + $msg, + $this->_name, + implode(', ', $foreignKey), + implode(', ', $bindingKey) + )); + } + } + + list($finder, $opts) = $this->_extractFinder($options['finder']); + $dummy = $this + ->find($finder, $opts) + ->eagerLoaded(true); + if (!empty($options['queryBuilder'])) { + $dummy = $options['queryBuilder']($dummy); + if (!($dummy instanceof Query)) { + throw new RuntimeException(sprintf( + 'Query builder for association "%s" did not return a query', + $this->name() + )); + } + } + + $dummy->where($options['conditions']); + $this->_dispatchBeforeFind($dummy); + + $options['conditions'] = $dummy->clause('where'); + + $this->_bindNewAssociations($query, $dummy, $options); + } + + /** + * Correctly nests a result row associated values into the correct array keys inside the + * source results. + * + * @param array $row The row to transform + * @param string $nestKey The array key under which the results for this association + * should be found + * @param bool $joined Whether or not the row is a result of a direct join + * with this association + * @return array + */ + public function transformRow($row, $nestKey, $joined) + { + $sourceAlias = $this->source()->alias(); + $nestKey = $nestKey ?: $this->_name; + if (isset($row[$sourceAlias])) { + $row[$sourceAlias][$this->property()] = $row[$nestKey]; + unset($row[$nestKey]); + } + return $row; + } + + /** + * Returns a modified row after appending a property for this association + * with the default empty value according to whether the association was + * joined or fetched externally. + * + * @param array $row The row to set a default on. + * @param bool $joined Whether or not the row is a result of a direct join + * with this association + * @return array + */ + public function defaultRowValue($row, $joined) + { + $sourceAlias = $this->source()->alias(); + if (isset($row[$sourceAlias])) { + $row[$sourceAlias][$this->property()] = null; + } + return $row; + } + + /** + * Proxies the finding operation to the target endpoint's find method + * and modifies the query accordingly based of this association + * configuration + * + * @param string|array|null $type the type of query to perform, if an array is passed, + * it will be interpreted as the `$options` parameter + * @param array $options The options to for the find + * @see \Cake\Datasource\RepositoryInterface::find() + * @return \Cake\Datasource\QueryInterface + */ + public function find($type = null, array $options = []) + { + $type = $type ?: $this->finder(); + list($type, $opts) = $this->_extractFinder($type); + return $this->target() + ->find($type, $options + $opts) + ->where($this->conditions()); + } + + /** + * Proxies the update operation to the target endpoint's updateAll method + * + * @param array $fields A hash of field => new value. + * @param mixed $conditions Conditions to be used, accepts anything Query::where() + * can take. + * @see \Cake\Datasource\RepositoryInterface::updateAll() + * @return bool Success Returns true if one or more rows are affected. + */ + public function updateAll($fields, $conditions) + { + $target = $this->target(); + $expression = $target->query() + ->where($this->conditions()) + ->where($conditions) + ->clause('where'); + return $target->updateAll($fields, $expression); + } + + /** + * Proxies the delete operation to the target endpoint's deleteAll method + * + * @param mixed $conditions Conditions to be used, accepts anything Query::where() + * can take. + * @return bool Success Returns true if one or more rows are affected. + * @see \Cake\Datasource\RepositoryInterface::deleteAll() + */ + public function deleteAll($conditions) + { + $target = $this->target(); + $expression = $target->query() + ->where($this->conditions()) + ->where($conditions) + ->clause('where'); + return $target->deleteAll($expression); + } + + /** + * Triggers beforeFind on the target endpoint for the query this association is + * attaching to + * + * @param \Muffin\Webservice\Query $query the query this association is attaching itself to + * @return void + */ + protected function _dispatchBeforeFind($query) + { + $query->triggerBeforeFind(); + } + + /** + * Helper method to infer the requested finder and its options. + * + * Returns the inferred options from the finder $type. + * + * ### Examples: + * + * The following will call the finder 'translations' with the value of the finder as its options: + * $query->contain(['Comments' => ['finder' => ['translations']]]); + * $query->contain(['Comments' => ['finder' => ['translations' => []]]]); + * $query->contain(['Comments' => ['finder' => ['translations' => ['locales' => ['en_US']]]]]); + * + * @param string|array $finderData The finder name or an array having the name as key + * and options as value. + * @return array + */ + protected function _extractFinder($finderData) + { + $finderData = (array)$finderData; + + if (is_numeric(key($finderData))) { + return [current($finderData), []]; + } + + return [key($finderData), current($finderData)]; + } + + /** + * Proxies property retrieval to the target endpoint. This is handy for getting this + * association's associations + * + * @param string $property the property name + * @return \Muffin\Webservice\Association + * @throws \RuntimeException if no association with such name exists + */ + public function __get($property) + { + return $this->target()->{$property}; + } + + /** + * Proxies the isset call to the target endpoint. This is handy to check if the + * target endpoint has another association with the passed name + * + * @param string $property the property name + * @return bool true if the property exists + */ + public function __isset($property) + { + return isset($this->target()->{$property}); + } + + /** + * Proxies method calls to the target endpoint. + * + * @param string $method name of the method to be invoked + * @param array $argument List of arguments passed to the function + * @return mixed + * @throws \BadMethodCallException + */ + public function __call($method, $argument) + { + return call_user_func_array([$this->target(), $method], $argument); + } + + /** + * Applies all attachable associations to `$query` out of the containments found + * in the `$surrogate` query. + * + * Copies all contained associations from the `$surrogate` query into the + * passed `$query`. Containments are altered so that they respect the associations + * chain from which they originated. + * + * @param \Cake\ORM\Query $query the query that will get the associations attached to + * @param \Cake\ORM\Query $surrogate the query having the containments to be attached + * @param array $options options passed to the method `attachTo` + * @return void + */ + protected function _bindNewAssociations($query, $surrogate, $options) + { + $loader = $surrogate->eagerLoader(); + $contain = $loader->contain(); + $matching = $loader->matching(); + + if (!$contain && !$matching) { + return; + } + + $newContain = []; + foreach ($contain as $alias => $value) { + $newContain[$options['aliasPath'] . '.' . $alias] = $value; + } + + $eagerLoader = $query->eagerLoader(); + $eagerLoader->contain($newContain); + + foreach ($matching as $alias => $value) { + $eagerLoader->matching( + $options['aliasPath'] . '.' . $alias, + $value['queryBuilder'], + $value + ); + } + } +} diff --git a/src/Association/BelongsTo.php b/src/Association/BelongsTo.php new file mode 100644 index 0000000..c024093 --- /dev/null +++ b/src/Association/BelongsTo.php @@ -0,0 +1,164 @@ +_foreignKey === null) { + $this->_foreignKey = $this->_modelKey($this->target()->alias()); + } + return $this->_foreignKey; + } + return parent::foreignKey($key); + } + + /** + * Handle cascading deletes. + * + * BelongsTo associations are never cleared in a cascading delete scenario. + * + * @param \Cake\Datasource\EntityInterface $entity The entity that started the cascaded delete. + * @param array $options The options for the original delete. + * @return bool Success. + */ + public function cascadeDelete(EntityInterface $entity, array $options = []) + { + return true; + } + + /** + * Returns default property name based on association name. + * + * @return string + */ + protected function _propertyName() + { + list(, $name) = pluginSplit($this->_name); + return Inflector::underscore(Inflector::singularize($name)); + } + + /** + * Returns whether or not the passed repository is the owning side for this + * association. This means that rows in the 'target' endpoint would miss important + * or required information if the row in 'source' did not exist. + * + * @param \Cake\Datasource\RepositoryInterface $side The potential repository with ownership + * @return bool + */ + public function isOwningSide(RepositoryInterface $side) + { + return $side === $this->target(); + } + + /** + * Get the relationship type. + * + * @return string Constant of either ONE_TO_ONE, MANY_TO_ONE, ONE_TO_MANY or MANY_TO_MANY. + */ + public function type() + { + return self::MANY_TO_ONE; + } + + /** + * Takes an entity from the source table and looks if there is a field + * matching the property name for this association. The found entity will be + * saved on the target table for this association by passing supplied + * `$options` + * + * @param \Cake\Datasource\EntityInterface $entity an entity from the source table + * @param array|\ArrayObject $options options to be passed to the save method in + * the target table + * @return bool|\Cake\Datasource\EntityInterface false if $entity could not be saved, otherwise it returns + * the saved entity + * @see \Cake\ORM\Table::save() + */ + public function saveAssociated(EntityInterface $entity, array $options = []) + { + $targetEntity = $entity->get($this->property()); + if (empty($targetEntity) || !($targetEntity instanceof EntityInterface)) { + return $entity; + } + + $table = $this->target(); + $targetEntity = $table->save($targetEntity, $options); + if (!$targetEntity) { + return false; + } + + $properties = array_combine( + (array)$this->foreignKey(), + $targetEntity->extract((array)$this->bindingKey()) + ); + $entity->set($properties, ['guard' => false]); + debug($entity); + return $entity; + } + + /** + * {@inheritDoc} + */ + protected function _linkField($options) + { + $links = []; + $name = $this->alias(); + + foreach ((array)$this->bindingKey() as $key) { + $links[] = sprintf('%s.%s', $name, $key); + } + + if (count($links) === 1) { + return $links[0]; + } + + return $links; + } + + /** + * {@inheritDoc} + */ + protected function _buildResultMap($fetchQuery, $options) + { + $resultMap = []; + $key = (array)$this->bindingKey(); + + foreach ($fetchQuery->all() as $result) { + $values = []; + foreach ($key as $k) { + $values[] = $result[$k]; + } + $resultMap[implode(';', $values)] = $result; + } + return $resultMap; + } +} diff --git a/src/Association/BelongsToMany.php b/src/Association/BelongsToMany.php new file mode 100644 index 0000000..fa44054 --- /dev/null +++ b/src/Association/BelongsToMany.php @@ -0,0 +1,1292 @@ +_targetForeignKey === null) { + $this->_targetForeignKey = $this->_modelKey($this->target()->alias()); + } + return $this->_targetForeignKey; + } + return $this->_targetForeignKey = $key; + } + + /** + * Sets the table instance for the junction relation. If no arguments + * are passed, the current configured table instance is returned + * + * @param string|\Muffin\Webservice\Model\Endpoint|null $endpoint Name or instance for the join table + * @return \Muffin\Webservice\Model\Endpoint + */ + public function junction($endpoint = null) + { + if ($endpoint === null) { + if (!empty($this->_junctionEndpoint)) { + return $this->_junctionEndpoint; + } + + if (!empty($this->_through)) { + $endpoint = $this->_through; + } else { + $endpointName = $this->_junctionTableName(); + $endpointAlias = Inflector::camelize($endpointName); + + $config = []; + if (!EndpointRegistry::exists($endpointAlias)) { + $config = ['endpoint' => $endpointName]; + } + $endpoint = EndpointRegistry::get($endpointAlias, $config); + } + } + + if (is_string($endpoint)) { + $endpoint = EndpointRegistry::get($endpoint); + } + $target = $this->target(); + $source = $this->source(); + + $this->_generateSourceAssociations($endpoint, $source); + $this->_generateTargetAssociations($endpoint, $source, $target); + $this->_generateJunctionAssociations($endpoint, $source, $target); + return $this->_junctionEndpoint = $endpoint; + } + + /** + * Generate reciprocal associations as necessary. + * + * Generates the following associations: + * + * - target hasMany junction e.g. Articles hasMany ArticlesTags + * - target belongsToMany source e.g Articles belongsToMany Tags. + * + * You can override these generated associations by defining associations + * with the correct aliases. + * + * @param \Muffin\Webservice\Model\Endpoint $junction The junction table. + * @param \Muffin\Webservice\Model\Endpoint $source The source table. + * @param \Muffin\Webservice\Model\Endpoint $target The target table. + * @return void + */ + protected function _generateTargetAssociations($junction, $source, $target) + { + $junctionAlias = $junction->alias(); + $sAlias = $source->alias(); + + if (!$target->association($junctionAlias)) { + $target->hasMany($junctionAlias, [ + 'targetRepository' => $junction, + 'foreignKey' => $this->targetForeignKey(), + ]); + } + if (!$target->association($sAlias)) { + $target->belongsToMany($sAlias, [ + 'sourceRepository' => $target, + 'targetRepository' => $source, + 'foreignKey' => $this->targetForeignKey(), + 'targetForeignKey' => $this->foreignKey(), + 'through' => $junction, + 'conditions' => $this->conditions(), + ]); + } + } + + /** + * Generate additional source table associations as necessary. + * + * Generates the following associations: + * + * - source hasMany junction e.g. Tags hasMany ArticlesTags + * + * You can override these generated associations by defining associations + * with the correct aliases. + * + * @param \Muffin\Webservice\Model\Endpoint $junction The junction table. + * @param \Muffin\Webservice\Model\Endpoint $source The source table. + * @return void + */ + protected function _generateSourceAssociations($junction, $source) + { + $junctionAlias = $junction->alias(); + if (!$source->association($junctionAlias)) { + $source->hasMany($junctionAlias, [ + 'targetRepository' => $junction, + 'foreignKey' => $this->foreignKey(), + ]); + } + } + + /** + * Generate associations on the junction table as necessary + * + * Generates the following associations: + * + * - junction belongsTo source e.g. ArticlesTags belongsTo Tags + * - junction belongsTo target e.g. ArticlesTags belongsTo Articles + * + * You can override these generated associations by defining associations + * with the correct aliases. + * + * @param \Muffin\Webservice\Model\Endpoint $junction The junction table. + * @param \Muffin\Webservice\Model\Endpoint $source The source table. + * @param \Muffin\Webservice\Model\Endpoint $target The target table. + * @return void + */ + protected function _generateJunctionAssociations($junction, $source, $target) + { + $tAlias = $target->alias(); + $sAlias = $source->alias(); + + if (!$junction->association($tAlias)) { + $junction->belongsTo($tAlias, [ + 'foreignKey' => $this->targetForeignKey(), + 'targetRepository' => $target + ]); + } + if (!$junction->association($sAlias)) { + $junction->belongsTo($sAlias, [ + 'foreignKey' => $this->foreignKey(), + 'targetRepository' => $source + ]); + } + } + + /** + * Alters a Query object to include the associated target table data in the final + * result + * + * The options array accept the following keys: + * + * - includeFields: Whether to include target model fields in the result or not + * - foreignKey: The name of the field to use as foreign key, if false none + * will be used + * - conditions: array with a list of conditions to filter the join with + * - fields: a list of fields in the target table to include in the result + * - type: The type of join to be used (e.g. INNER) + * + * @param \Cake\ORM\Query $query the query to be altered to include the target table data + * @param array $options Any extra options or overrides to be taken in account + * @return void + */ + public function attachTo(QueryInterface $query, array $options = []) + { + parent::attachTo($query, $options); + + $junction = $this->junction(); + $belongsTo = $junction->association($this->source()->alias()); + $cond = $belongsTo->_joinCondition(['foreignKey' => $belongsTo->foreignKey()]); + $cond += $this->junctionConditions(); + + if (isset($options['includeFields'])) { + $includeFields = $options['includeFields']; + } + + // Attach the junction table as well we need it to populate _joinData. + $assoc = $this->_targetRepository->association($junction->alias()); + $query->removeJoin($assoc->name()); + $options = array_intersect_key($options, ['joinType' => 1, 'fields' => 1]); + $options += [ + 'conditions' => $cond, + 'includeFields' => $includeFields, + 'foreignKey' => $this->targetForeignKey(), + ]; + $assoc->attachTo($query, $options); + $query->eagerLoader()->addToJoinsMap($junction->alias(), $assoc, true); + } + + /** + * {@inheritDoc} + */ + protected function _appendNotMatching($query, $options) + { + $target = $this->junction(); + if (!empty($options['negateMatch'])) { + $primaryKey = $query->aliasFields((array)$target->primaryKey(), $target->alias()); + $query->where(function ($exp) use ($primaryKey) { + array_map([$exp, 'isNull'], $primaryKey); + return $exp; + }); + } + } + + /** + * {@inheritDoc} + */ + public function transformRow($row, $nestKey, $joined) + { + $alias = $this->junction()->alias(); + if ($joined) { + $row[$this->target()->alias()][$this->_junctionProperty] = $row[$alias]; + unset($row[$alias]); + } + + return parent::transformRow($row, $nestKey, $joined); + } + + /** + * Get the relationship type. + * + * @return string + */ + public function type() + { + return self::MANY_TO_MANY; + } + + /** + * Return false as join conditions are defined in the junction table + * + * @param array $options list of options passed to attachTo method + * @return bool false + */ + protected function _joinCondition($options) + { + return false; + } + + /** + * Builds an array containing the results from fetchQuery indexed by + * the foreignKey value corresponding to this association. + * + * @param \Cake\ORM\Query $fetchQuery The query to get results from + * @param array $options The options passed to the eager loader + * @return array + * @throws \RuntimeException when the association property is not part of the results set. + */ + protected function _buildResultMap($fetchQuery, $options) + { + $resultMap = []; + $key = (array)$options['foreignKey']; + $property = $this->target()->association($this->junction()->alias())->property(); + $hydrated = $fetchQuery->hydrate(); + + foreach ($fetchQuery->all() as $result) { + if (!isset($result[$property])) { + throw new RuntimeException(sprintf( + '"%s" is missing from the belongsToMany results. Results cannot be created.', + $property + )); + } + $result[$this->_junctionProperty] = $result[$property]; + unset($result[$property]); + + if ($hydrated) { + $result->dirty($this->_junctionProperty, false); + } + + $values = []; + foreach ($key as $k) { + $values[] = $result[$this->_junctionProperty][$k]; + } + debug(implode(';', $values)); + $resultMap[implode(';', $values)][] = $result; + } + + return $resultMap; + } + + /** + * Clear out the data in the junction table for a given entity. + * + * @param \Cake\Datasource\EntityInterface $entity The entity that started the cascading delete. + * @param array $options The options for the original delete. + * @return bool Success. + */ + public function cascadeDelete(EntityInterface $entity, array $options = []) + { + if (!$this->dependent()) { + return true; + } + $foreignKey = (array)$this->foreignKey(); + $bindingKey = (array)$this->bindingKey(); + $conditions = []; + + if (!empty($bindingKey)) { + $conditions = array_combine($foreignKey, $entity->extract($bindingKey)); + } + + $table = $this->junction(); + $hasMany = $this->source()->association($table->alias()); + if ($this->_cascadeCallbacks) { + foreach ($hasMany->find('all')->where($conditions)->toList() as $related) { + $table->delete($related, $options); + } + return true; + } + + $conditions = array_merge($conditions, $hasMany->conditions()); + return $table->deleteAll($conditions); + } + + /** + * Returns boolean true, as both of the tables 'own' rows in the other side + * of the association via the joint table. + * + * @param \Muffin\Webservice\Model\Endpoint $side The potential Table with ownership + * @return bool + */ + public function isOwningSide(RepositoryInterface $side) + { + return true; + } + + /** + * Sets the strategy that should be used for saving. If called with no + * arguments, it will return the currently configured strategy + * + * @param string|null $strategy the strategy name to be used + * @throws \InvalidArgumentException if an invalid strategy name is passed + * @return string the strategy to be used for saving + */ + public function saveStrategy($strategy = null) + { + if ($strategy === null) { + return $this->_saveStrategy; + } + if (!in_array($strategy, [self::SAVE_APPEND, self::SAVE_REPLACE])) { + $msg = sprintf('Invalid save strategy "%s"', $strategy); + throw new InvalidArgumentException($msg); + } + return $this->_saveStrategy = $strategy; + } + + /** + * Takes an entity from the source table and looks if there is a field + * matching the property name for this association. The found entity will be + * saved on the target table for this association by passing supplied + * `$options` + * + * When using the 'append' strategy, this function will only create new links + * between each side of this association. It will not destroy existing ones even + * though they may not be present in the array of entities to be saved. + * + * When using the 'replace' strategy, existing links will be removed and new links + * will be created in the joint table. If there exists links in the database to some + * of the entities intended to be saved by this method, they will be updated, + * not deleted. + * + * @param \Cake\Datasource\EntityInterface $entity an entity from the source table + * @param array|\ArrayObject $options options to be passed to the save method in + * the target table + * @throws \InvalidArgumentException if the property representing the association + * in the parent entity cannot be traversed + * @return bool|\Cake\Datasource\EntityInterface false if $entity could not be saved, otherwise it returns + * the saved entity + * @see \Muffin\Webservice\Model\Endpoint::save() + * @see \Cake\ORM\Association\BelongsToMany::replaceLinks() + */ + public function saveAssociated(EntityInterface $entity, array $options = []) + { + $targetEntity = $entity->get($this->property()); + $strategy = $this->saveStrategy(); + + $isEmpty = in_array($targetEntity, [null, [], '', false], true); + if ($isEmpty && $entity->isNew()) { + return $entity; + } + if ($isEmpty) { + $targetEntity = []; + } + + if ($strategy === self::SAVE_APPEND) { + return $this->_saveTarget($entity, $targetEntity, $options); + } + + if ($this->replaceLinks($entity, $targetEntity, $options)) { + return $entity; + } + + return false; + } + + /** + * Persists each of the entities into the target table and creates links between + * the parent entity and each one of the saved target entities. + * + * @param \Cake\Datasource\EntityInterface $parentEntity the source entity containing the target + * entities to be saved. + * @param array|\Traversable $entities list of entities to persist in target table and to + * link to the parent entity + * @param array $options list of options accepted by `Table::save()` + * @throws \InvalidArgumentException if the property representing the association + * in the parent entity cannot be traversed + * @return \Cake\Datasource\EntityInterface|bool The parent entity after all links have been + * created if no errors happened, false otherwise + */ + protected function _saveTarget(EntityInterface $parentEntity, $entities, $options) + { + $joinAssociations = false; + if (!empty($options['associated'][$this->_junctionProperty]['associated'])) { + $joinAssociations = $options['associated'][$this->_junctionProperty]['associated']; + } + unset($options['associated'][$this->_junctionProperty]); + + if (!(is_array($entities) || $entities instanceof Traversable)) { + $name = $this->property(); + $message = sprintf('Could not save %s, it cannot be traversed', $name); + throw new InvalidArgumentException($message); + } + + $table = $this->target(); + $original = $entities; + $persisted = []; + + foreach ($entities as $k => $entity) { + if (!($entity instanceof EntityInterface)) { + break; + } + + if (!empty($options['atomic'])) { + $entity = clone $entity; + } + + if ($table->save($entity, $options)) { + $entities[$k] = $entity; + $persisted[] = $entity; + continue; + } + + if (!empty($options['atomic'])) { + $original[$k]->errors($entity->errors()); + return false; + } + } + + $options['associated'] = $joinAssociations; + $success = $this->_saveLinks($parentEntity, $persisted, $options); + if (!$success && !empty($options['atomic'])) { + $parentEntity->set($this->property(), $original); + return false; + } + + $parentEntity->set($this->property(), $entities); + return $parentEntity; + } + + /** + * Creates links between the source entity and each of the passed target entities + * + * @param \Cake\Datasource\EntityInterface $sourceEntity the entity from source table in this + * association + * @param array $targetEntities list of entities to link to link to the source entity using the + * junction table + * @param array $options list of options accepted by `Table::save()` + * @return bool success + */ + protected function _saveLinks(EntityInterface $sourceEntity, $targetEntities, $options) + { + $target = $this->target(); + $junction = $this->junction(); + $entityClass = $junction->resourceClass(); + $belongsTo = $junction->association($target->alias()); + $foreignKey = (array)$this->foreignKey(); + $assocForeignKey = (array)$belongsTo->foreignKey(); + $targetPrimaryKey = (array)$target->primaryKey(); + $bindingKey = (array)$this->bindingKey(); + $jointProperty = $this->_junctionProperty; + $junctionAlias = $junction->alias(); + + foreach ($targetEntities as $e) { + $joint = $e->get($jointProperty); + if (!$joint || !($joint instanceof EntityInterface)) { + $joint = new $entityClass([], ['markNew' => true, 'source' => $junctionAlias]); + } + $sourceKeys = array_combine($foreignKey, $sourceEntity->extract($bindingKey)); + $targetKeys = array_combine($assocForeignKey, $e->extract($targetPrimaryKey)); + + if ($sourceKeys !== $joint->extract($foreignKey)) { + $joint->set($sourceKeys, ['guard' => false]); + } + + if ($targetKeys !== $joint->extract($assocForeignKey)) { + $joint->set($targetKeys, ['guard' => false]); + } + + $saved = $junction->save($joint, $options); + + if (!$saved && !empty($options['atomic'])) { + return false; + } + + $e->set($jointProperty, $joint); + $e->dirty($jointProperty, false); + } + + return true; + } + + /** + * Associates the source entity to each of the target entities provided by + * creating links in the junction table. Both the source entity and each of + * the target entities are assumed to be already persisted, if they are marked + * as new or their status is unknown then an exception will be thrown. + * + * When using this method, all entities in `$targetEntities` will be appended to + * the source entity's property corresponding to this association object. + * + * This method does not check link uniqueness. + * + * ### Example: + * + * ``` + * $newTags = $tags->find('relevant')->toArray(); + * $articles->association('tags')->link($article, $newTags); + * ``` + * + * `$article->get('tags')` will contain all tags in `$newTags` after liking + * + * @param \Cake\Datasource\EntityInterface $sourceEntity the row belonging to the `source` side + * of this association + * @param array $targetEntities list of entities belonging to the `target` side + * of this association + * @param array $options list of options to be passed to the internal `save` call + * @throws \InvalidArgumentException when any of the values in $targetEntities is + * detected to not be already persisted + * @return bool true on success, false otherwise + */ + public function link(EntityInterface $sourceEntity, array $targetEntities, array $options = []) + { + $this->_checkPersistenceStatus($sourceEntity, $targetEntities); + $property = $this->property(); + $links = $sourceEntity->get($property) ?: []; + $links = array_merge($links, $targetEntities); + $sourceEntity->set($property, $links); + + return $this->junction()->connection()->transactional( + function () use ($sourceEntity, $targetEntities, $options) { + return $this->_saveLinks($sourceEntity, $targetEntities, $options); + } + ); + } + + /** + * Removes all links between the passed source entity and each of the provided + * target entities. This method assumes that all passed objects are already persisted + * in the database and that each of them contain a primary key value. + * + * ### Options + * + * Additionally to the default options accepted by `Table::delete()`, the following + * keys are supported: + * + * - cleanProperty: Whether or not to remove all the objects in `$targetEntities` that + * are stored in `$sourceEntity` (default: true) + * + * By default this method will unset each of the entity objects stored inside the + * source entity. + * + * ### Example: + * + * ``` + * $article->tags = [$tag1, $tag2, $tag3, $tag4]; + * $tags = [$tag1, $tag2, $tag3]; + * $articles->association('tags')->unlink($article, $tags); + * ``` + * + * `$article->get('tags')` will contain only `[$tag4]` after deleting in the database + * + * @param \Cake\Datasource\EntityInterface $sourceEntity an entity persisted in the source table for + * this association + * @param array $targetEntities list of entities persisted in the target table for + * this association + * @param array|bool $options list of options to be passed to the internal `delete` call, + * or a `boolean` + * @throws \InvalidArgumentException if non persisted entities are passed or if + * any of them is lacking a primary key value + * @return void + */ + public function unlink(EntityInterface $sourceEntity, array $targetEntities, $options = []) + { + if (is_bool($options)) { + $options = [ + 'cleanProperty' => $options + ]; + } else { + $options += ['cleanProperty' => true]; + } + + $this->_checkPersistenceStatus($sourceEntity, $targetEntities); + $property = $this->property(); + + $this->junction()->connection()->transactional( + function () use ($sourceEntity, $targetEntities, $options) { + $links = $this->_collectJointEntities($sourceEntity, $targetEntities); + foreach ($links as $entity) { + $this->_junctionEndpoint->delete($entity, $options); + } + } + ); + + $existing = $sourceEntity->get($property) ?: []; + if (!$options['cleanProperty'] || empty($existing)) { + return; + } + + $storage = new SplObjectStorage; + foreach ($targetEntities as $e) { + $storage->attach($e); + } + + foreach ($existing as $k => $e) { + if ($storage->contains($e)) { + unset($existing[$k]); + } + } + + $sourceEntity->set($property, array_values($existing)); + $sourceEntity->dirty($property, false); + } + + /** + * {@inheritDoc} + */ + public function conditions($conditions = null) + { + if ($conditions !== null) { + $this->_conditions = $conditions; + $this->_targetConditions = $this->_junctionConditions = []; + } + return $this->_conditions; + } + + /** + * Returns filtered conditions that reference the target table. + * + * Any string expressions, or expression objects will + * also be returned in this list. + * + * @return mixed Generally an array. If the conditions + * are not an array, the association conditions will be + * returned unmodified. + */ + protected function targetConditions() + { + if ($this->_targetConditions !== null) { + return $this->_targetConditions; + } + $conditions = $this->conditions(); + if (!is_array($conditions)) { + return $conditions; + } + $matching = []; + $alias = $this->alias() . '.'; + foreach ($conditions as $field => $value) { + if (is_string($field) && strpos($field, $alias) === 0) { + $matching[$field] = $value; + } elseif (is_int($field) || $value instanceof ExpressionInterface) { + $matching[$field] = $value; + } + } + return $this->_targetConditions = $matching; + } + + /** + * Returns filtered conditions that specifically reference + * the junction table. + * + * @return array + */ + protected function junctionConditions() + { + if ($this->_junctionConditions !== null) { + return $this->_junctionConditions; + } + $matching = []; + $conditions = $this->conditions(); + if (!is_array($conditions)) { + return $matching; + } + $alias = $this->_junctionAssociationName() . '.'; + foreach ($conditions as $field => $value) { + $isString = is_string($field); + if ($isString && strpos($field, $alias) === 0) { + $matching[$field] = $value; + } + // Assume that operators contain junction conditions. + // Trying to munge complex conditions could result in incorrect queries. + if ($isString && in_array(strtoupper($field), ['OR', 'NOT', 'AND', 'XOR'])) { + $matching[$field] = $value; + } + } + return $this->_junctionConditions = $matching; + } + + /** + * Proxies the finding operation to the target table's find method + * and modifies the query accordingly based of this association + * configuration. + * + * If your association includes conditions, the junction table will be + * included in the query's contained associations. + * + * @param string|array|null $type the type of query to perform, if an array is passed, + * it will be interpreted as the `$options` parameter + * @param array $options The options to for the find + * @see \Muffin\Webservice\Model\Endpoint::find() + * @return \Cake\ORM\Query + */ + public function find($type = null, array $options = []) + { + $type = $type ?: $this->finder(); + list($type, $opts) = $this->_extractFinder($type); + $query = $this->target() + ->find($type, $options + $opts) + ->where($this->targetConditions()); + + if (!$this->junctionConditions()) { + return $query; + } + + $belongsTo = $this->junction()->association($this->target()->alias()); + $conditions = $belongsTo->_joinCondition([ + 'foreignKey' => $this->foreignKey() + ]); + $conditions += $this->junctionConditions(); + return $this->_appendJunctionJoin($query, $conditions); + } + + /** + * Append a join to the junction table. + * + * @param \Cake\ORM\Query $query The query to append. + * @param string|array $conditions The query conditions to use. + * @return \Cake\ORM\Query The modified query. + */ + protected function _appendJunctionJoin($query, $conditions) + { + $name = $this->_junctionAssociationName(); + + $query->contain([$name]); + + return $query; + } + + /** + * Replaces existing association links between the source entity and the target + * with the ones passed. This method does a smart cleanup, links that are already + * persisted and present in `$targetEntities` will not be deleted, new links will + * be created for the passed target entities that are not already in the database + * and the rest will be removed. + * + * For example, if an article is linked to tags 'cake' and 'framework' and you pass + * to this method an array containing the entities for tags 'cake', 'php' and 'awesome', + * only the link for cake will be kept in database, the link for 'framework' will be + * deleted and the links for 'php' and 'awesome' will be created. + * + * Existing links are not deleted and created again, they are either left untouched + * or updated so that potential extra information stored in the joint row is not + * lost. Updating the link row can be done by making sure the corresponding passed + * target entity contains the joint property with its primary key and any extra + * information to be stored. + * + * On success, the passed `$sourceEntity` will contain `$targetEntities` as value + * in the corresponding property for this association. + * + * This method assumes that links between both the source entity and each of the + * target entities are unique. That is, for any given row in the source table there + * can only be one link in the junction table pointing to any other given row in + * the target table. + * + * Additional options for new links to be saved can be passed in the third argument, + * check `Table::save()` for information on the accepted options. + * + * ### Example: + * + * ``` + * $article->tags = [$tag1, $tag2, $tag3, $tag4]; + * $articles->save($article); + * $tags = [$tag1, $tag3]; + * $articles->association('tags')->replaceLinks($article, $tags); + * ``` + * + * `$article->get('tags')` will contain only `[$tag1, $tag3]` at the end + * + * @param \Cake\Datasource\EntityInterface $sourceEntity an entity persisted in the source table for + * this association + * @param array $targetEntities list of entities from the target table to be linked + * @param array $options list of options to be passed to the internal `save`/`delete` calls + * when persisting/updating new links, or deleting existing ones + * @throws \InvalidArgumentException if non persisted entities are passed or if + * any of them is lacking a primary key value + * @return bool success + */ + public function replaceLinks(EntityInterface $sourceEntity, array $targetEntities, array $options = []) + { + $bindingKey = (array)$this->bindingKey(); + $primaryValue = $sourceEntity->extract($bindingKey); + + if (count(array_filter($primaryValue, 'strlen')) !== count($bindingKey)) { + $message = 'Could not find primary key value for source entity'; + throw new InvalidArgumentException($message); + } + + return $this->junction()->connection()->transactional( + function () use ($sourceEntity, $targetEntities, $primaryValue, $options) { + $foreignKey = array_map([$this->_junctionEndpoint, 'aliasField'], (array)$this->foreignKey()); + $hasMany = $this->source()->association($this->_junctionEndpoint->alias()); + $existing = $hasMany->find('all') + ->where(array_combine($foreignKey, $primaryValue)); + + $associationConditions = $this->conditions(); + if ($associationConditions) { + $existing->contain($this->target()->alias()); + $existing->where($associationConditions); + } + + $jointEntities = $this->_collectJointEntities($sourceEntity, $targetEntities); + $inserts = $this->_diffLinks($existing, $jointEntities, $targetEntities, $options); + + if ($inserts && !$this->_saveTarget($sourceEntity, $inserts, $options)) { + return false; + } + + $property = $this->property(); + + if (count($inserts)) { + $inserted = array_combine( + array_keys($inserts), + (array)$sourceEntity->get($property) + ); + $targetEntities = $inserted + $targetEntities; + } + + ksort($targetEntities); + $sourceEntity->set($property, array_values($targetEntities)); + $sourceEntity->dirty($property, false); + return true; + } + ); + } + + /** + * Helper method used to delete the difference between the links passed in + * `$existing` and `$jointEntities`. This method will return the values from + * `$targetEntities` that were not deleted from calculating the difference. + * + * @param \Cake\ORM\Query $existing a query for getting existing links + * @param array $jointEntities link entities that should be persisted + * @param array $targetEntities entities in target table that are related to + * the `$jointEntities` + * @param array $options list of options accepted by `Table::delete()` + * @return array + */ + protected function _diffLinks($existing, $jointEntities, $targetEntities, $options = []) + { + $junction = $this->junction(); + $target = $this->target(); + $belongsTo = $junction->association($target->alias()); + $foreignKey = (array)$this->foreignKey(); + $assocForeignKey = (array)$belongsTo->foreignKey(); + + $keys = array_merge($foreignKey, $assocForeignKey); + $deletes = $indexed = $present = []; + + foreach ($jointEntities as $i => $entity) { + $indexed[$i] = $entity->extract($keys); + $present[$i] = array_values($entity->extract($assocForeignKey)); + } + + foreach ($existing as $result) { + $fields = $result->extract($keys); + $found = false; + foreach ($indexed as $i => $data) { + if ($fields === $data) { + unset($indexed[$i]); + $found = true; + break; + } + } + + if (!$found) { + $deletes[] = $result; + } + } + + $primary = (array)$target->primaryKey(); + $jointProperty = $this->_junctionProperty; + foreach ($targetEntities as $k => $entity) { + if (!($entity instanceof EntityInterface)) { + continue; + } + $key = array_values($entity->extract($primary)); + foreach ($present as $i => $data) { + if ($key === $data && !$entity->get($jointProperty)) { + unset($targetEntities[$k], $present[$i]); + break; + } + } + } + + if ($deletes) { + foreach ($deletes as $entity) { + $junction->delete($entity, $options); + } + } + + return $targetEntities; + } + + /** + * Throws an exception should any of the passed entities is not persisted. + * + * @param \Cake\Datasource\EntityInterface $sourceEntity the row belonging to the `source` side + * of this association + * @param array $targetEntities list of entities belonging to the `target` side + * of this association + * @return bool + * @throws \InvalidArgumentException + */ + protected function _checkPersistenceStatus($sourceEntity, array $targetEntities) + { + if ($sourceEntity->isNew()) { + $error = 'Source entity needs to be persisted before proceeding'; + throw new InvalidArgumentException($error); + } + + foreach ($targetEntities as $entity) { + if ($entity->isNew()) { + $error = 'Cannot link not persisted entities'; + throw new InvalidArgumentException($error); + } + } + + return true; + } + + /** + * Returns the list of joint entities that exist between the source entity + * and each of the passed target entities + * + * @param \Cake\Datasource\EntityInterface $sourceEntity The row belonging to the source side + * of this association. + * @param array $targetEntities The rows belonging to the target side of this + * association. + * @throws \InvalidArgumentException if any of the entities is lacking a primary + * key value + * @return array + */ + protected function _collectJointEntities($sourceEntity, $targetEntities) + { + $target = $this->target(); + $source = $this->source(); + $junction = $this->junction(); + $jointProperty = $this->_junctionProperty; + $primary = (array)$target->primaryKey(); + + $result = []; + $missing = []; + + foreach ($targetEntities as $entity) { + if (!($entity instanceof EntityInterface)) { + continue; + } + $joint = $entity->get($jointProperty); + + if (!$joint || !($joint instanceof EntityInterface)) { + $missing[] = $entity->extract($primary); + continue; + } + + $result[] = $joint; + } + + if (empty($missing)) { + return $result; + } + + $belongsTo = $junction->association($target->alias()); + $hasMany = $source->association($junction->alias()); + $foreignKey = (array)$this->foreignKey(); + $assocForeignKey = (array)$belongsTo->foreignKey(); + $sourceKey = $sourceEntity->extract((array)$source->primaryKey()); + + foreach ($missing as $key) { + $conditionSet[] = array_combine($foreignKey, $sourceKey) + array_combine($assocForeignKey, $key); + } + + $query = $hasMany->find('all')->where($conditionSet); + + return array_merge($result, $query->toArray()); + } + + /** + * Auxiliary function to construct a new Query object to return all the records + * in the target table that are associated to those specified in $options from + * the source table. + * + * This is used for eager loading records on the target table based on conditions. + * + * @param array $options options accepted by eagerLoader() + * @return \Cake\ORM\Query + * @throws \InvalidArgumentException When a key is required for associations but not selected. + */ + protected function _buildQuery($options) + { + stackTrace(); + $name = $this->_junctionAssociationName(); + $assoc = $this->target()->association($name); + $queryBuilder = false; + + if (!empty($options['queryBuilder'])) { + $queryBuilder = $options['queryBuilder']; + unset($options['queryBuilder']); + } + + $keys = $options['keys']; + $options['keys'] = []; + + $query = $this->_buildBaseQuery($options); + $query->where([], [], true); + + if ($queryBuilder) { + $query = $queryBuilder($query); + } + + $conditions = []; + foreach ($keys as $id) { + $conditions[][$this->_linkField($options)] = $id; + } + + $property = $this->target()->association($this->junction()->alias())->property(); + $query = $this->_appendJunctionJoin($query, $conditions); + $query->formatResults(function ($results) use ($property) { + return $results + ->filter(function ($row) use ($property) { + return isset($row[$property][0]); + }) + ->map(function (Resource $row) use ($property) { + $row[$property] = $row[$property][0]; + + $row->dirty($property, false); + + return $row; + }); + }); + + return $query; + } + + /** + * Generates a string used as a table field that contains the values upon + * which the filter should be applied + * + * @param array $options the options to use for getting the link field. + * @return string + */ + protected function _linkField($options) + { + $links = []; + $name = $this->_junctionAssociationName(); + + foreach ((array)$options['foreignKey'] as $key) { + $links[] = sprintf('%s.%s', $name, $key); + } + + if (count($links) === 1) { + return $links[0]; + } + + return $links; + } + + /** + * Returns the name of the association from the target table to the junction table, + * this name is used to generate alias in the query and to later on retrieve the + * results. + * + * @return string + */ + protected function _junctionAssociationName() + { + if (!$this->_junctionAssociationName) { + $this->_junctionAssociationName = $this->target() + ->association($this->junction()->alias()) + ->name(); + } + return $this->_junctionAssociationName; + } + + /** + * Sets the name of the junction table. + * If no arguments are passed the current configured name is returned. A default + * name based of the associated tables will be generated if none found. + * + * @param string|null $name The name of the junction table. + * @return string + */ + protected function _junctionTableName($name = null) + { + if ($name === null) { + if (empty($this->_junctionEndpointName)) { + $endpointNames = array_map('\Cake\Utility\Inflector::underscore', [ + $this->source()->endpoint(), + $this->target()->endpoint() + ]); + sort($endpointNames); + $this->_junctionEndpointName = implode('_', $endpointNames); + } + return $this->_junctionEndpointName; + } + return $this->_junctionEndpointName = $name; + } + + /** + * Parse extra options passed in the constructor. + * + * @param array $opts original list of options passed in constructor + * @return void + */ + protected function _options(array $opts) + { + $this->_externalOptions($opts); + if (!empty($opts['targetForeignKey'])) { + $this->targetForeignKey($opts['targetForeignKey']); + } + if (!empty($opts['joinTable'])) { + $this->_junctionTableName($opts['joinTable']); + } + if (!empty($opts['through'])) { + $this->_through = $opts['through']; + } + if (!empty($opts['saveStrategy'])) { + $this->saveStrategy($opts['saveStrategy']); + } + } +} diff --git a/src/Association/ExternalAssociationTrait.php b/src/Association/ExternalAssociationTrait.php new file mode 100644 index 0000000..0ea2fad --- /dev/null +++ b/src/Association/ExternalAssociationTrait.php @@ -0,0 +1,121 @@ +_foreignKey === null) { + $this->_foreignKey = $this->_modelKey($this->source()->endpoint()); + } + return $this->_foreignKey; + } + return parent::foreignKey($key); + } + + /** + * Sets the sort order in which target records should be returned. + * If no arguments are passed the currently configured value is returned + * + * @param mixed $sort A find() compatible order clause + * @return mixed + */ + public function sort($sort = null) + { + if ($sort !== null) { + $this->_sort = $sort; + } + return $this->_sort; + } + + /** + * {@inheritDoc} + */ + public function defaultRowValue($row, $joined) + { + $sourceAlias = $this->source()->alias(); + if (isset($row[$sourceAlias])) { + $row[$sourceAlias][$this->property()] = $joined ? null : []; + } + return $row; + } + + /** + * Returns the default options to use for the eagerLoader + * + * @return array + */ + protected function _defaultOptions() + { + return $this->_selectableOptions() + [ + 'sort' => $this->sort() + ]; + } + + /** + * {@inheritDoc} + */ + protected function _buildResultMap($fetchQuery, $options) + { + $resultMap = []; + $key = (array)$options['foreignKey']; + + foreach ($fetchQuery->all() as $result) { + $values = []; + foreach ($key as $k) { + $values[] = $result[$k]; + } + $resultMap[implode(';', $values)][] = $result; + } + return $resultMap; + } + + /** + * Parse extra options passed in the constructor. + * + * @param array $opts original list of options passed in constructor + * @return void + */ + protected function _options(array $opts) + { + if (isset($opts['sort'])) { + $this->sort($opts['sort']); + } + } +} diff --git a/src/Association/HasMany.php b/src/Association/HasMany.php new file mode 100644 index 0000000..d6b70ae --- /dev/null +++ b/src/Association/HasMany.php @@ -0,0 +1,504 @@ +source(); + } + + /** + * Sets the strategy that should be used for saving. If called with no + * arguments, it will return the currently configured strategy + * + * @param string|null $strategy the strategy name to be used + * @throws \InvalidArgumentException if an invalid strategy name is passed + * @return string the strategy to be used for saving + */ + public function saveStrategy($strategy = null) + { + if ($strategy === null) { + return $this->_saveStrategy; + } + if (!in_array($strategy, [self::SAVE_APPEND, self::SAVE_REPLACE])) { + $msg = sprintf('Invalid save strategy "%s"', $strategy); + throw new InvalidArgumentException($msg); + } + return $this->_saveStrategy = $strategy; + } + + /** + * Takes an entity from the source endpoint and looks if there is a field + * matching the property name for this association. The found entity will be + * saved on the target endpoint for this association by passing supplied + * `$options` + * + * @param \Cake\Datasource\EntityInterface $entity an entity from the source endpoint + * @param array|\ArrayObject $options options to be passed to the save method in + * the target endpoint + * @return bool|\Cake\Datasource\EntityInterface false if $entity could not be saved, otherwise it returns + * the saved entity + * @see \Cake\Datasource\RepositoryInterface::save() + * @throws \InvalidArgumentException when the association data cannot be traversed. + */ + public function saveAssociated(EntityInterface $entity, array $options = []) + { + $targetEntities = $entity->get($this->property()); + if (empty($targetEntities) && $this->_saveStrategy !== self::SAVE_REPLACE) { + return $entity; + } + + if (!is_array($targetEntities) && !($targetEntities instanceof Traversable)) { + $name = $this->property(); + $message = sprintf('Could not save %s, it cannot be traversed', $name); + throw new InvalidArgumentException($message); + } + + $foreignKey = (array)$this->foreignKey(); + $properties = array_combine( + $foreignKey, + $entity->extract((array)$this->bindingKey()) + ); + $target = $this->target(); + $original = $targetEntities; + $options['_sourceRepository'] = $this->source(); + + $unlinkSuccessful = null; + if ($this->_saveStrategy === self::SAVE_REPLACE) { + $unlinkSuccessful = $this->_unlinkAssociated($properties, $entity, $target, $targetEntities, $options); + } + + if ($unlinkSuccessful === false) { + return false; + } + + foreach ($targetEntities as $k => $targetEntity) { + if (!($targetEntity instanceof EntityInterface)) { + break; + } + + if (!empty($options['atomic'])) { + $targetEntity = clone $targetEntity; + } + + if ($properties !== $targetEntity->extract($foreignKey)) { + $targetEntity->set($properties, ['guard' => false]); + } + + if ($target->save($targetEntity, $options)) { + $targetEntities[$k] = $targetEntity; + continue; + } + + if (!empty($options['atomic'])) { + $original[$k]->errors($targetEntity->errors()); + $entity->set($this->property(), $original); + return false; + } + } + + $entity->set($this->property(), $targetEntities); + return $entity; + } + + /** + * Associates the source entity to each of the target entities provided. + * When using this method, all entities in `$targetEntities` will be appended to + * the source entity's property corresponding to this association object. + * + * This method does not check link uniqueness. + * Changes are persisted in the webservice and also in the source entity. + * + * ### Example: + * + * ``` + * $user = $users->get(1); + * $allArticles = $articles->find('all')->toArray(); + * $users->Articles->link($user, $allArticles); + * ``` + * + * `$user->get('articles')` will contain all articles in `$allArticles` after linking + * + * @param \Cake\Datasource\EntityInterface $sourceEntity the row belonging to the `source` side + * of this association + * @param array $targetEntities list of entities belonging to the `target` side + * of this association + * @param array $options list of options to be passed to the internal `save` call + * @return bool true on success, false otherwise + */ + public function link(EntityInterface $sourceEntity, array $targetEntities, array $options = []) + { + $saveStrategy = $this->saveStrategy(); + $this->saveStrategy(self::SAVE_APPEND); + $property = $this->property(); + + $currentEntities = array_unique( + array_merge( + (array)$sourceEntity->get($property), + $targetEntities + ) + ); + + $sourceEntity->set($property, $currentEntities); + + $savedEntity = $this->saveAssociated($sourceEntity, $options); + + $ok = ($savedEntity instanceof EntityInterface); + + $this->saveStrategy($saveStrategy); + + if ($ok) { + $sourceEntity->set($property, $savedEntity->get($property)); + $sourceEntity->dirty($property, false); + } + + return $ok; + } + + /** + * Removes all links between the passed source entity and each of the provided + * target entities. This method assumes that all passed objects are already persisted + * in the webservice and that each of them contain a primary key value. + * + * ### Options + * + * Additionally to the default options accepted by `Endpoint::delete()`, the following + * keys are supported: + * + * - cleanProperty: Whether or not to remove all the objects in `$targetEntities` that + * are stored in `$sourceEntity` (default: true) + * + * By default this method will unset each of the entity objects stored inside the + * source entity. + * + * Changes are persisted in the webservice and also in the source entity. + * + * ### Example: + * + * ``` + * $user = $users->get(1); + * $user->articles = [$article1, $article2, $article3, $article4]; + * $users->save($user, ['Associated' => ['Articles']]); + * $allArticles = [$article1, $article2, $article3]; + * $users->Articles->unlink($user, $allArticles); + * ``` + * + * `$article->get('articles')` will contain only `[$article4]` after deleting in the webservice + * + * @param \Cake\Datasource\EntityInterface $sourceEntity an entity persisted in the source endpoint for + * this association + * @param array $targetEntities list of entities persisted in the target endpoint for + * this association + * @param array $options list of options to be passed to the internal `delete` call + * @throws \InvalidArgumentException if non persisted entities are passed or if + * any of them is lacking a primary key value + * @return void + */ + public function unlink(EntityInterface $sourceEntity, array $targetEntities, $options = []) + { + if (is_bool($options)) { + $options = [ + 'cleanProperty' => $options + ]; + } else { + $options += ['cleanProperty' => true]; + } + + $foreignKey = (array)$this->foreignKey(); + $target = $this->target(); + $targetPrimaryKey = array_merge((array)$target->primaryKey(), $foreignKey); + $property = $this->property(); + + $conditions = [ + 'OR' => (new Collection($targetEntities)) + ->map(function ($entity) use ($targetPrimaryKey) { + return $entity->extract($targetPrimaryKey); + }) + ->toList() + ]; + + $this->_unlink($foreignKey, $target, $conditions, $options); + + $result = $sourceEntity->get($property); + if ($options['cleanProperty'] && $result !== null) { + $sourceEntity->set( + $property, + (new Collection($sourceEntity->get($property))) + ->reject( + function ($assoc) use ($targetEntities) { + return in_array($assoc, $targetEntities); + } + ) + ->toList() + ); + } + + $sourceEntity->dirty($property, false); + } + + /** + * Replaces existing association links between the source entity and the target + * with the ones passed. This method does a smart cleanup, links that are already + * persisted and present in `$targetEntities` will not be deleted, new links will + * be created for the passed target entities that are not already in the webservice + * and the rest will be removed. + * + * For example, if an author has many articles, such as 'article1','article 2' and 'article 3' and you pass + * to this method an array containing the entities for articles 'article 1' and 'article 4', + * only the link for 'article 1' will be kept in webservice, the links for 'article 2' and 'article 3' will be + * deleted and the link for 'article 4' will be created. + * + * Existing links are not deleted and created again, they are either left untouched + * or updated. + * + * This method does not check link uniqueness. + * + * On success, the passed `$sourceEntity` will contain `$targetEntities` as value + * in the corresponding property for this association. + * + * Additional options for new links to be saved can be passed in the third argument, + * check `Endpoint::save()` for information on the accepted options. + * + * ### Example: + * + * ``` + * $author->articles = [$article1, $article2, $article3, $article4]; + * $authors->save($author); + * $articles = [$article1, $article3]; + * $authors->association('articles')->replaceLinks($author, $articles); + * ``` + * + * `$author->get('articles')` will contain only `[$article1, $article3]` at the end + * + * @param \Cake\Datasource\EntityInterface $sourceEntity an entity persisted in the source endpoint for + * this association + * @param array $targetEntities list of entities from the target endpoint to be linked + * @param array $options list of options to be passed to the internal `save`/`delete` calls + * when persisting/updating new links, or deleting existing ones + * @throws \InvalidArgumentException if non persisted entities are passed or if + * any of them is lacking a primary key value + * @return bool success + */ + public function replace(EntityInterface $sourceEntity, array $targetEntities, array $options = []) + { + $property = $this->property(); + $sourceEntity->set($property, $targetEntities); + $saveStrategy = $this->saveStrategy(); + $this->saveStrategy(self::SAVE_REPLACE); + $result = $this->saveAssociated($sourceEntity, $options); + $ok = ($result instanceof EntityInterface); + + if ($ok) { + $sourceEntity = $result; + } + $this->saveStrategy($saveStrategy); + return $ok; + } + + /** + * Deletes/sets null the related objects according to the dependency between source and targets and foreign key nullability + * Skips deleting records present in $remainingEntities + * + * @param array $properties array of foreignKey properties + * @param \Cake\Datasource\EntityInterface $entity the entity which should have its associated entities unassigned + * @param \Cake\Datasource\RepositoryInterface $repository The associated repository + * @param array $remainingEntities Entities that should not be deleted + * @param array $options list of options accepted by `Endpoint::delete()` + * @return bool success + */ + protected function _unlinkAssociated(array $properties, EntityInterface $entity, RepositoryInterface $repository, array $remainingEntities = [], array $options = []) + { + $primaryKey = (array)$repository->primaryKey(); + $exclusions = new Collection($remainingEntities); + $exclusions = $exclusions->map( + function ($ent) use ($primaryKey) { + return $ent->extract($primaryKey); + } + ) + ->filter( + function ($v) { + return !in_array(null, array_values($v), true); + } + ) + ->toArray(); + + $conditions = $properties; + + if (count($exclusions) > 0) { + $conditions = [ + 'NOT' => [ + 'OR' => $exclusions + ], + $properties + ]; + } + + return $this->_unlink(array_keys($properties), $repository, $conditions, $options); + } + + /** + * Deletes/sets null the related objects matching $conditions. + * The action which is taken depends on the dependency between source and targets and also on foreign key nullability + * + * @param array $foreignKey array of foreign key properties + * @param \Cake\Datasource\RepositoryInterface $target The associated endpoint + * @param array $conditions The conditions that specifies what are the objects to be unlinked + * @param array $options list of options accepted by `Endpoint::delete()` + * @return bool success + */ + protected function _unlink(array $foreignKey, RepositoryInterface $target, array $conditions = [], array $options = []) + { + $mustBeDependent = (!$this->_foreignKeyAcceptsNull($target, $foreignKey) || $this->dependent()); + + if ($mustBeDependent) { + if ($this->_cascadeCallbacks) { + $conditions = new QueryExpression($conditions); + $conditions->traverse(function ($entry) use ($target) { + if ($entry instanceof FieldInterface) { + $entry->setField($target->aliasField($entry->getField())); + } + }); + $query = $this->find('all')->where($conditions); + $ok = true; + foreach ($query as $assoc) { + $ok = $ok && $target->delete($assoc, $options); + } + return $ok; + } + + $target->deleteAll($conditions); + return true; + } + + $updateFields = array_fill_keys($foreignKey, null); + $target->updateAll($updateFields, $conditions); + return true; + } + + /** + * Checks the nullable flag of the foreign key + * + * @param \Cake\Datasource\RepositoryInterface $repository the repository containing the foreign key + * @param array $properties the list of fields that compose the foreign key + * @return bool + */ + protected function _foreignKeyAcceptsNull(RepositoryInterface $repository, array $properties) + { + return !in_array( + false, + array_map( + function ($prop) use ($repository) { + return $repository->schema()->isNullable($prop); + }, + $properties + ) + ); + } + + /** + * {@inheritDoc} + */ + protected function _linkField($options) + { + $links = []; + $name = $this->alias(); + if ($options['foreignKey'] === false) { + $msg = 'Cannot have foreignKey = false for hasMany associations. ' . + 'You must provide a foreignKey column.'; + throw new RuntimeException($msg); + } + + foreach ((array)$options['foreignKey'] as $key) { + $links[] = sprintf('%s.%s', $name, $key); + } + + if (count($links) === 1) { + return $links[0]; + } + + return $links; + } + + /** + * Get the relationship type. + * + * @return string + */ + public function type() + { + return self::ONE_TO_MANY; + } + + /** + * Parse extra options passed in the constructor. + * + * @param array $opts original list of options passed in constructor + * @return void + */ + protected function _options(array $opts) + { + $this->_externalOptions($opts); + if (!empty($opts['saveStrategy'])) { + $this->saveStrategy($opts['saveStrategy']); + } + } +} diff --git a/src/Association/HasOne.php b/src/Association/HasOne.php new file mode 100644 index 0000000..8929484 --- /dev/null +++ b/src/Association/HasOne.php @@ -0,0 +1,144 @@ +_foreignKey === null) { + $this->_foreignKey = $this->_modelKey($this->source()->alias()); + } + return $this->_foreignKey; + } + return parent::foreignKey($key); + } + + /** + * Returns default property name based on association name. + * + * @return string + */ + protected function _propertyName() + { + list(, $name) = pluginSplit($this->_name); + return Inflector::underscore(Inflector::singularize($name)); + } + + /** + * Returns whether or not the passed endpoint is the owning side for this + * association. This means that rows in the 'target' endpoint would miss important + * or required information if the row in 'source' did not exist. + * + * @param \Muffin\Webservice\Model\Endpoint $side The potential Endpoint with ownership + * @return bool + */ + public function isOwningSide(Endpoint $side) + { + return $side === $this->source(); + } + + /** + * Get the relationship type. + * + * @return string + */ + public function type() + { + return self::ONE_TO_ONE; + } + + /** + * Takes an entity from the source endpoint and looks if there is a field + * matching the property name for this association. The found entity will be + * saved on the target endpoint for this association by passing supplied + * `$options` + * + * @param \Cake\Datasource\EntityInterface $entity an entity from the source endpoint + * @param array|\ArrayObject $options options to be passed to the save method in + * the target endpoint + * @return bool|\Cake\Datasource\EntityInterface false if $entity could not be saved, otherwise it returns + * the saved entity + * @see \Muffin\Webservice\Model\Endpoint::save() + */ + public function saveAssociated(EntityInterface $entity, array $options = []) + { + $targetEntity = $entity->get($this->property()); + if (empty($targetEntity) || !($targetEntity instanceof EntityInterface)) { + return $entity; + } + + $properties = array_combine( + (array)$this->foreignKey(), + $entity->extract((array)$this->bindingKey()) + ); + $targetEntity->set($properties, ['guard' => false]); + + if (!$this->target()->save($targetEntity, $options)) { + $targetEntity->unsetProperty(array_keys($properties)); + return false; + } + + return $entity; + } + + /** + * {@inheritDoc} + */ + protected function _linkField($options) + { + $links = []; + $name = $this->alias(); + + foreach ((array)$options['foreignKey'] as $key) { + $links[] = sprintf('%s.%s', $name, $key); + } + + if (count($links) === 1) { + return $links[0]; + } + + return $links; + } + + /** + * {@inheritDoc} + */ + protected function _buildResultMap($fetchQuery, $options) + { + $resultMap = []; + $key = (array)$options['foreignKey']; + + foreach ($fetchQuery->all() as $result) { + $values = []; + foreach ($key as $k) { + $values[] = $result[$k]; + } + $resultMap[implode(';', $values)] = $result; + } + return $resultMap; + } +} diff --git a/src/Association/QueryableAssociationTrait.php b/src/Association/QueryableAssociationTrait.php new file mode 100644 index 0000000..77ceac7 --- /dev/null +++ b/src/Association/QueryableAssociationTrait.php @@ -0,0 +1,213 @@ +strategy(); + return $strategy === $this::STRATEGY_QUERY; + } + + /** + * {@inheritDoc} + */ + public function eagerLoader(array $options) + { + $options += $this->_defaultOptions(); + $fetchQuery = $this->_buildQuery($options); + $resultMap = $this->_buildResultMap($fetchQuery, $options); + return $this->_resultInjector($fetchQuery, $resultMap, $options); + } + + /** + * Returns the default options to use for the eagerLoader + * + * @return array + */ + protected function _defaultOptions() + { + return [ + 'foreignKey' => $this->foreignKey(), + 'conditions' => [], + 'strategy' => $this->strategy(), + 'nestKey' => $this->_name + ]; + } + + /** + * Auxiliary function to construct a new Query object to return all the records + * in the target endpoint that are associated to those specified in $options from + * the source endpoint + * + * @param array $options options accepted by eagerLoader() + * @return \Muffin\Webservice\Query + * @throws \InvalidArgumentException When a key is required for associations but not selected. + */ + protected function _buildQuery($options) + { + $key = $this->_linkField($options); + $filter = $options['keys']; + + $finder = isset($options['finder']) ? $options['finder'] : $this->finder(); + list($finder, $opts) = $this->_extractFinder($finder); + $options += ['fields' => []]; + + $fetchQuery = $this + ->find($finder, $opts) + ->where($options['conditions']) + ->eagerLoaded(true) + ->hydrate($options['query']->hydrate()); + + $fetchQuery = $this->_addFilteringCondition($fetchQuery, $key, $filter); + + if (!empty($options['sort'])) { + $fetchQuery->order($options['sort']); + } + + if (!empty($options['contain'])) { + $fetchQuery->contain($options['contain']); + } + + if (!empty($options['queryBuilder'])) { + $fetchQuery = $options['queryBuilder']($fetchQuery); + } + + return $fetchQuery; + } + + /** + * Appends any conditions required to load the relevant set of records in the + * target endpoint query given a filter key and some filtering values. + * + * @param \Muffin\Webservice\Query $query Target endpoint's query + * @param string|array $key the fields that should be used for filtering + * @param mixed $filter the value that should be used to match for $key + * @return \Muffin\Webservice\Query + */ + protected function _addFilteringCondition($query, $key, $filter) + { + if (is_array($key)) { + $conditions = []; + foreach ($key as $keyIndex => $keyName) { + $conditions[$keyName] = []; + foreach ($filter as $index => $value) { + $conditions[$keyName][$index] = $value[$keyIndex]; + } + } + } + if ((is_array($filter)) && (count($filter))) { + $filter = current($filter); + } + + $conditions = isset($conditions) ? $conditions : [$key => $filter]; + return $query->where($conditions); + } + + /** + * Generates a string used as a endpoint field that contains the values upon + * which the filter should be applied + * + * @param array $options The options for getting the link field. + * @return string|array + */ + protected abstract function _linkField($options); + + /** + * Builds an array containing the results from fetchQuery indexed by + * the foreignKey value corresponding to this association. + * + * @param \Muffin\Webservice\Query $fetchQuery The query to get results from + * @param array $options The options passed to the eager loader + * @return array + */ + protected abstract function _buildResultMap($fetchQuery, $options); + + /** + * Returns a callable to be used for each row in a query result set + * for injecting the eager loaded rows + * + * @param \Muffin\Webservice\Query $fetchQuery the Query used to fetch results + * @param array $resultMap an array with the foreignKey as keys and + * the corresponding target endpoint results as value. + * @param array $options The options passed to the eagerLoader method + * @return \Closure + */ + protected function _resultInjector($fetchQuery, $resultMap, $options) + { + $source = $this->source(); + $sAlias = $source->alias(); + $keys = $this->type() === $this::MANY_TO_ONE ? + $this->foreignKey() : + $this->bindingKey(); + + $sourceKeys = []; + foreach ((array)$keys as $key) { + $f = $fetchQuery->aliasField($key, $sAlias); + $sourceKeys[] = key($f); + } + + $nestKey = $options['nestKey']; + if (count($sourceKeys) > 1) { + return $this->_multiKeysInjector($resultMap, $sourceKeys, $nestKey); + } + + $sourceKey = $sourceKeys[0]; + debug($sourceKey); + return function ($row) use ($resultMap, $sourceKey, $nestKey) { +// debug($row); + debug($resultMap); + debug($row[$sourceKey]); + debug($resultMap[$row[$sourceKey]]); + + if (isset($row[$sourceKey], $resultMap[$row[$sourceKey]])) { + $row[$nestKey] = $resultMap[$row[$sourceKey]]; + } + + debug($row[$nestKey]); + + return $row; + }; + } + + /** + * Returns a callable to be used for each row in a query result set + * for injecting the eager loaded rows when the matching needs to + * be done with multiple foreign keys + * + * @param array $resultMap A keyed arrays containing the target endpoint + * @param array $sourceKeys An array with aliased keys to match + * @param string $nestKey The key under which results should be nested + * @return \Closure + */ + protected function _multiKeysInjector($resultMap, $sourceKeys, $nestKey) + { + return function ($row) use ($resultMap, $sourceKeys, $nestKey) { + $values = []; + foreach ($sourceKeys as $key) { + $values[] = $row[$key]; + } + + $key = implode(';', $values); + if (isset($resultMap[$key])) { + $row[$nestKey] = $resultMap[$key]; + } + return $row; + }; + } +} diff --git a/src/Connection.php b/src/Connection.php index ba39272..e02be03 100644 --- a/src/Connection.php +++ b/src/Connection.php @@ -2,11 +2,13 @@ namespace Muffin\Webservice; use Cake\Core\App; +use Cake\Datasource\ConnectionInterface; use Muffin\Webservice\Exception\MissingConnectionException; use Muffin\Webservice\Exception\MissingDriverException; use Muffin\Webservice\Exception\UnexpectedDriverException; +use Muffin\Webservice\Model\Schema\Collection; -class Connection +class Connection implements ConnectionInterface { /** * Constructor @@ -61,4 +63,87 @@ public function __call($method, $args) { return call_user_func_array([$this->_driver, $method], $args); } + + public function schemaCollection() + { + return new Collection($this); + } + + /** + * Get the configuration name for this connection. + * + * @return string + */ + public function configName() + { + // TODO: Implement configName() method. + } + + /** + * Get the configuration data used to create the connection. + * + * @return array + */ + public function config() + { + // TODO: Implement config() method. + } + + /** + * Executes a callable function inside a transaction, if any exception occurs + * while executing the passed callable, the transaction will be rolled back + * If the result of the callable function is `false`, the transaction will + * also be rolled back. Otherwise the transaction is committed after executing + * the callback. + * + * The callback will receive the connection instance as its first argument. + * + * @param callable $transaction The callback to execute within a transaction. + * @return mixed The return value of the callback. + * @throws \Exception Will re-throw any exception raised in $callback after + * rolling back the transaction. + */ + public function transactional(callable $transaction) + { + return $transaction($this); + } + + /** + * Run an operation with constraints disabled. + * + * Constraints should be re-enabled after the callback succeeds/fails. + * + * @param callable $operation The callback to execute within a transaction. + * @return mixed The return value of the callback. + * @throws \Exception Will re-throw any exception raised in $callback after + * rolling back the transaction. + */ + public function disableConstraints(callable $operation) + { + return $operation($this); + } + + /** + * Enables or disables query logging for this connection. + * + * @param bool|null $enable whether to turn logging on or disable it. + * Use null to read current value. + * @return bool + */ + public function logQueries($enable = null) + { + // TODO: Implement logQueries() method. + } + + /** + * Sets the logger object instance. When called with no arguments + * it returns the currently setup logger instance. + * + * @param object|null $instance logger object instance + * @return object logger instance + */ + public function logger($instance = null) + { + // TODO: Implement logger() method. + } } diff --git a/src/EagerLoader.php b/src/EagerLoader.php new file mode 100644 index 0000000..6910e6b --- /dev/null +++ b/src/EagerLoader.php @@ -0,0 +1,735 @@ + 1, + 'foreignKey' => 1, + 'conditions' => 1, + 'fields' => 1, + 'sort' => 1, + 'matching' => 1, + 'queryBuilder' => 1, + 'finder' => 1, + 'joinType' => 1, + 'strategy' => 1, + 'negateMatch' => 1 + ]; + + /** + * A list of associations that should be loaded with a separate query + * + * @var array + */ + protected $_loadExternal = []; + + /** + * Contains a list of the association names that are to be eagerly loaded + * + * @var array + */ + protected $_aliasList = []; + + /** + * Another EagerLoader instance that will be used for 'matching' associations. + * + * @var \Muffin\Webservice\EagerLoader + */ + protected $_matching; + + /** + * A map of endpoint aliases pointing to the association objects they represent + * for the query. + * + * @var array + */ + protected $_joinsMap = []; + + /** + * Controls whether or not fields from associated endpoints + * will be eagerly loaded. When set to false, no fields will + * be loaded from associations. + * + * @var bool + */ + protected $_autoFields = true; + + /** + * Sets the list of associations that should be eagerly loaded along for a + * specific endpoint using when a query is provided. The list of associated endpoints + * passed to this method must have been previously set as associations using the + * Endpoint API. + * + * Associations can be arbitrarily nested using dot notation or nested arrays, + * this allows this object to calculate joins or any additional queries that + * must be executed to bring the required associated data. + * + * Accepted options per passed association: + * + * - foreignKey: Used to set a different field to match both endpoints, if set to false + * no join conditions will be generated automatically + * - fields: An array with the fields that should be fetched from the association + * - queryBuilder: Equivalent to passing a callable instead of an options array + * - matching: Whether to inform the association class that it should filter the + * main query by the results fetched by that class. + * - joinType: For joinable associations, the SQL join type to use. + * - strategy: The loading strategy to use (join, select, subquery) + * + * @param array|string $associations list of endpoint aliases to be queried. + * When this method is called multiple times it will merge previous list with + * the new one. + * @return array Containments. + */ + public function contain($associations = []) + { + if (empty($associations)) { + return $this->_containments; + } + + $associations = (array)$associations; + $associations = $this->_reformatContain($associations, $this->_containments); + $this->_normalized = null; + $this->_loadExternal = []; + $this->_aliasList = []; + return $this->_containments = $associations; + } + + /** + * Remove any existing non-matching based containments. + * + * This will reset/clear out any contained associations that were not + * added via matching(). + * + * @return void + */ + public function clearContain() + { + $this->_containments = []; + $this->_normalized = null; + $this->_loadExternal = []; + $this->_aliasList = []; + } + + /** + * Set whether or not contained associations will load fields automatically. + * + * @param bool|null $value The value to set. + * @return bool The current value. + */ + public function autoFields($value = null) + { + if ($value !== null) { + $this->_autoFields = (bool)$value; + } + return $this->_autoFields; + } + + /** + * Adds a new association to the list that will be used to filter the results of + * any given query based on the results of finding records for that association. + * You can pass a dot separated path of associations to this method as its first + * parameter, this will translate in setting all those associations with the + * `matching` option. + * + * If called with no arguments it will return the current tree of associations to + * be matched. + * + * @param string|null $assoc A single association or a dot separated path of associations. + * @param callable|null $builder the callback function to be used for setting extra + * options to the filtering query + * @param array $options Extra options for the association matching, such as 'joinType' + * and 'fields' + * @return array The resulting containments array + */ + public function matching($assoc = null, callable $builder = null, $options = []) + { + if ($this->_matching === null) { + $this->_matching = new self(); + } + + if ($assoc === null) { + return $this->_matching->contain(); + } + + $assocs = explode('.', $assoc); + $last = array_pop($assocs); + $containments = []; + $pointer =& $containments; + $options += ['joinType' => 'INNER']; + $opts = ['matching' => true] + $options; + unset($opts['negateMatch']); + + foreach ($assocs as $name) { + $pointer[$name] = $opts; + $pointer =& $pointer[$name]; + } + + $pointer[$last] = ['queryBuilder' => $builder, 'matching' => true] + $options; + return $this->_matching->contain($containments); + } + + /** + * Returns the fully normalized array of associations that should be eagerly + * loaded for a endpoint. The normalized array will restructure the original array + * by sorting all associations under one key and special options under another. + * + * Each of the levels of the associations tree will converted to a \Cake\Datasource\EagerLoadable + * object, that contains all the information required for the association objects + * to load the information from the webservice. + * + * Additionally it will set an 'instance' key per association containing the + * association instance from the corresponding source endpoint + * + * @param \Cake\datasource\RepositoryInterface $repository The endpoint containing the association that + * will be normalized + * @return array + */ + public function normalized(RepositoryInterface $repository) + { + if ($this->_normalized !== null || empty($this->_containments)) { + return (array)$this->_normalized; + } + + $contain = []; + foreach ($this->_containments as $alias => $options) { + if (!empty($options['instance'])) { + $contain = (array)$this->_containments; + break; + } + $contain[$alias] = $this->_normalizeContain( + $repository, + $alias, + $options, + ['root' => null] + ); + } + + return $this->_normalized = $contain; + } + + /** + * Formats the containments array so that associations are always set as keys + * in the array. This function merges the original associations array with + * the new associations provided + * + * @param array $associations user provided containments array + * @param array $original The original containments array to merge + * with the new one + * @return array + */ + protected function _reformatContain($associations, $original) + { + $result = $original; + + foreach ((array)$associations as $endpoint => $options) { + $pointer =& $result; + if (is_int($endpoint)) { + $endpoint = $options; + $options = []; + } + + if ($options instanceof EagerLoadable) { + $options = $options->asContainArray(); + $endpoint = key($options); + $options = current($options); + } + + if (isset($this->_containOptions[$endpoint])) { + $pointer[$endpoint] = $options; + continue; + } + + if (strpos($endpoint, '.')) { + $path = explode('.', $endpoint); + $endpoint = array_pop($path); + foreach ($path as $t) { + $pointer += [$t => []]; + $pointer =& $pointer[$t]; + } + } + + if (is_array($options)) { + $options = isset($options['config']) ? + $options['config'] + $options['associations'] : + $options; + $options = $this->_reformatContain( + $options, + isset($pointer[$endpoint]) ? $pointer[$endpoint] : [] + ); + } + + if ($options instanceof Closure) { + $options = ['queryBuilder' => $options]; + } + + $pointer += [$endpoint => []]; + + if (isset($options['queryBuilder']) && isset($pointer[$endpoint]['queryBuilder'])) { + $first = $pointer[$endpoint]['queryBuilder']; + $second = $options['queryBuilder']; + $options['queryBuilder'] = function ($query) use ($first, $second) { + return $second($first($query)); + }; + } + + $pointer[$endpoint] = $options + $pointer[$endpoint]; + } + + return $result; + } + + /** + * Modifies the passed query to apply joins or any other transformation required + * in order to eager load the associations described in the `contain` array. + * This method will not modify the query for loading external associations, i.e. + * those that cannot be loaded without executing a separate query. + * + * @param \Muffin]Webservice\Query $query The query to be modified + * @param \Cake\Datasource\RepositoryInterface $repository The repository containing the associations + * @param bool $includeFields whether to append all fields from the associations + * to the passed query. This can be overridden according to the settings defined + * per association in the containments array + * @return void + */ + public function attachAssociations(Query $query, RepositoryInterface $repository, $includeFields) + { + if (empty($this->_containments) && $this->_matching === null) { + return; + } + + $attachable = $this->attachableAssociations($repository); + $processed = []; + do { + foreach ($attachable as $alias => $loadable) { + $config = $loadable->config() + [ + 'aliasPath' => $loadable->aliasPath(), + 'propertyPath' => $loadable->propertyPath(), + 'includeFields' => $includeFields, + ]; + $loadable->instance()->attachTo($query, $config); + $processed[$alias] = true; + } + + $newAttachable = $this->attachableAssociations($repository); + $attachable = array_diff_key($newAttachable, $processed); + } while (!empty($attachable)); + } + + /** + * Returns an array with the associations that can be fetched using a single query, + * the array keys are the association aliases and the values will contain an array + * with \Cake\Datasource\EagerLoadable objects. + * + * @param \Cake\Datasource\RepositoryInterface $repository The endpoint containing the associations to be + * attached + * @return array + */ + public function attachableAssociations(RepositoryInterface $repository) + { + $contain = $this->normalized($repository); + + $matching = $this->_matching ? $this->_matching->normalized($repository) : []; + $this->_fixStrategies(); + $this->_loadExternal = []; + return $this->_resolveJoins($contain, $matching); + } + + /** + * Returns an array with the associations that need to be fetched using a + * separate query, each array value will contain a \Cake\Datasource\EagerLoadable object. + * + * @param \Cake\Datasource\RepositoryInterface $repository The endpoint containing the associations + * to be loaded + * @return \Cake\Datasource\EagerLoadable[] + */ + public function externalAssociations(RepositoryInterface $repository) + { + if ($this->_loadExternal) { + return $this->_loadExternal; + } + + $this->attachableAssociations($repository); + return $this->_loadExternal; + } + + /** + * Auxiliary function responsible for fully normalizing deep associations defined + * using `contain()` + * + * @param \Cake\Datasource\RepositoryInterface $parent owning side of the association + * @param string $alias name of the association to be loaded + * @param array $options list of extra options to use for this association + * @param array $paths An array with two values, the first one is a list of dot + * separated strings representing associations that lead to this `$alias` in the + * chain of associations to be loaded. The second value is the path to follow in + * entities' properties to fetch a record of the corresponding association. + * @return array normalized associations + * @throws \InvalidArgumentException When containments refer to associations that do not exist. + */ + protected function _normalizeContain(RepositoryInterface $parent, $alias, $options, $paths) + { + $defaults = $this->_containOptions; + $instance = $parent->association($alias); + if (!$instance) { + throw new InvalidArgumentException( + sprintf('%s is not associated with %s', $parent->alias(), $alias) + ); + } + if ($instance->alias() !== $alias) { + throw new InvalidArgumentException(sprintf( + "You have contained '%s' but that association was bound as '%s'.", + $alias, + $instance->alias() + )); + } + + $paths += ['aliasPath' => '', 'propertyPath' => '', 'root' => $alias]; + $paths['aliasPath'] .= '.' . $alias; + $paths['propertyPath'] .= '.' . $instance->property(); + + $endpoint = $instance->target(); + + $extra = array_diff_key($options, $defaults); + $config = [ + 'associations' => [], + 'instance' => $instance, + 'config' => array_diff_key($options, $extra), + 'aliasPath' => trim($paths['aliasPath'], '.'), + 'propertyPath' => trim($paths['propertyPath'], '.') + ]; + $config['canBeJoined'] = $instance->canBeJoined($config['config']); + $eagerLoadable = new EagerLoadable($alias, $config); + + if ($config['canBeJoined']) { + $this->_aliasList[$paths['root']][$alias][] = $eagerLoadable; + } else { + $paths['root'] = $config['aliasPath']; + } + + foreach ($extra as $t => $assoc) { + $eagerLoadable->addAssociation( + $t, + $this->_normalizeContain($endpoint, $t, $assoc, $paths) + ); + } + + return $eagerLoadable; + } + + /** + * Iterates over the joinable aliases list and corrects the fetching strategies + * in order to avoid aliases collision in the generated queries. + * + * This function operates on the array references that were generated by the + * _normalizeContain() function. + * + * @return void + */ + protected function _fixStrategies() + { + foreach ($this->_aliasList as $aliases) { + foreach ($aliases as $configs) { + if (count($configs) < 2) { + continue; + } + foreach ($configs as $loadable) { + if (strpos($loadable->aliasPath(), '.')) { + $this->_correctStrategy($loadable); + } + } + } + } + } + + /** + * Changes the association fetching strategy if required because of duplicate + * under the same direct associations chain + * + * @param \Cake\Datasource\EagerLoadable $loadable The association config + * @return void + */ + protected function _correctStrategy($loadable) + { + $config = $loadable->config(); + $currentStrategy = isset($config['strategy']) ? + $config['strategy'] : + 'join'; + + if (!$loadable->canBeJoined() || $currentStrategy !== 'join') { + return; + } + + $config['strategy'] = Association::STRATEGY_SELECT; + $loadable->config($config); + $loadable->canBeJoined(false); + } + + /** + * Helper function used to compile a list of all associations that can be + * joined in the query. + * + * @param array $associations list of associations from which to obtain joins. + * @param array $matching list of associations that should be forcibly joined. + * @return array + */ + protected function _resolveJoins($associations, $matching = []) + { + $result = []; + foreach ($matching as $endpoint => $loadable) { + $result[$endpoint] = $loadable; + $result += $this->_resolveJoins($loadable->associations(), []); + } + foreach ($associations as $endpoint => $loadable) { + $inMatching = isset($matching[$endpoint]); + if (!$inMatching && $loadable->canBeJoined()) { + $result[$endpoint] = $loadable; + $result += $this->_resolveJoins($loadable->associations(), []); + continue; + } + + if ($inMatching) { + $this->_correctStrategy($loadable); + } + + $loadable->canBeJoined(false); + $this->_loadExternal[] = $loadable; + } + return $result; + } + + /** + * Decorates the passed statement object in order to inject data from associations + * that cannot be joined directly. + * + * @param \Cake\Datasource\QueryInterface $query The query for which to eager load external + * associations + * @param \Muffin\Webservice\WebserviceResultSetInterface $results The statement created after executing the $query + * @return \Muffin\Webservice\WebserviceResultSetInterface statement modified statement with extra loaders + */ + public function loadExternal($query, WebserviceResultSetInterface $results) + { + $external = $this->externalAssociations($query->repository()); + if (empty($external)) { + return $results; + } + +// $driver = $query->connection()->driver(); + list($collected, $results) = $this->_collectKeys($external, $query, $results); + + foreach ($external as $meta) { + $contain = $meta->associations(); + $instance = $meta->instance(); + $config = $meta->config(); + $alias = $instance->source()->alias(); + $path = $meta->aliasPath(); + + $requiresKeys = $instance->requiresKeys($config); + if ($requiresKeys && empty($collected[$path][$alias])) { + continue; + } + + $keys = isset($collected[$path][$alias]) ? $collected[$path][$alias] : null; + $f = $instance->eagerLoader( + $config + [ + 'query' => $query, + 'contain' => $contain, + 'keys' => $keys, + 'nestKey' => $meta->aliasPath() + ] + ); + foreach ($results as $index => &$result) { + $result = $f($result); + } + } + $results->rewind(); + + return $results; + } + + /** + * Returns an array having as keys a dotted path of associations that participate + * in this eager loader. The values of the array will contain the following keys + * + * - alias: The association alias + * - instance: The association instance + * - canBeJoined: Whether or not the association will be loaded using a JOIN + * - resourceClass: The entity that should be used for hydrating the results + * - nestKey: A dotted path that can be used to correctly insert the data into the results. + * - matching: Whether or not it is an association loaded through `matching()`. + * + * @param \Cake\Datasource\RepositoryInterface $repository The endpoint containing the association that + * will be normalized + * @return array + */ + public function associationsMap(RepositoryInterface $repository) + { + $map = []; + + if (!$this->matching() && !$this->contain() && empty($this->_joinsMap)) { + return $map; + } + + $visitor = function ($level, $matching = false) use (&$visitor, &$map) { + /* @var \Cake\Datasource\EagerLoadable[] $level */ + foreach ($level as $assoc => $meta) { + $canBeJoined = $meta->canBeJoined(); + $instance = $meta->instance(); + $associations = $meta->associations(); + $forMatching = $meta->forMatching(); + $map[] = [ + 'alias' => $assoc, + 'instance' => $instance, + 'canBeJoined' => $canBeJoined, + 'resourceClass' => $instance->target()->resourceClass(), + 'nestKey' => $canBeJoined ? $assoc : $meta->aliasPath(), + 'matching' => $forMatching !== null ? $forMatching : $matching + ]; + if ($canBeJoined && $associations) { + $visitor($associations, $matching); + } + } + }; + $visitor($this->_matching->normalized($repository), true); + $visitor($this->normalized($repository)); + $visitor($this->_joinsMap); + return $map; + } + + /** + * Registers a endpoint alias, typically loaded as a join in a query, as belonging to + * an association. This helps hydrators know what to do with the columns coming + * from such joined endpoint. + * + * @param string $alias The endpoint + * alias as it appears in the query. + * @param \Cake\Datasource\AssociationInterface $assoc The association object the alias represents; + * will be normalized + * @param bool $asMatching Whether or not this join results should be treated as a + * 'matching' association. + * @return void + */ + public function addToJoinsMap($alias, AssociationInterface $assoc, $asMatching = false) + { + $this->_joinsMap[$alias] = new EagerLoadable($alias, [ + 'aliasPath' => $alias, + 'instance' => $assoc, + 'canBeJoined' => true, + 'forMatching' => $asMatching, + ]); + } + + /** + * Helper function used to return the keys from the query records that will be used + * to eagerly load associations. + * + * @param \Cake\Datasource\EagerLoadable[] $external the list of external associations to be loaded + * @param \Cake\Datasource\QueryInterface $query The query from which the results where generated + * @param \Iterator $results The statement to work on + * @return array + */ + protected function _collectKeys($external, $query, Iterator $results) + { + $collectKeys = []; + foreach ($external as $meta) { + $instance = $meta->instance(); + if (!$instance->requiresKeys($meta->config())) { + continue; + } + + $source = $instance->source(); + $keys = $instance->type() === Association::MANY_TO_ONE ? + (array)$instance->foreignKey() : + (array)$instance->bindingKey(); + + $alias = $source->alias(); + $pkFields = []; + foreach ($keys as $key) { + $pkFields[] = key($query->aliasField($key, $alias)); + } + $collectKeys[$meta->aliasPath()] = [$alias, $pkFields, count($pkFields) === 1]; + } + + if (empty($collectKeys)) { + return [[], $results]; + } + + return [$this->_groupKeys($results, $collectKeys), $results]; + } + + /** + * Helper function used to iterate a statement and extract the columns + * defined in $collectKeys + * + * @param \Iterator $results The statement to read from. + * @param array $collectKeys The keys to collect + * @return array + */ + protected function _groupKeys(Iterator $results, $collectKeys) + { + $keys = []; + foreach ($results as $result) { + foreach ($collectKeys as $nestKey => $parts) { + // Missed joins will have null in the results. + if ($parts[2] === true && !isset($result[$parts[1][0]])) { + continue; + } + if ($parts[2] === true) { + $value = $result[$parts[1][0]]; + $keys[$nestKey][$parts[0]][$value] = $value; + continue; + } + + // Handle composite keys. + $collected = []; + foreach ($parts[1] as $key) { + $collected[] = $result[$key]; + } + $keys[$nestKey][$parts[0]][implode(';', $collected)] = $collected; + } + } + + $results->rewind(); + return $keys; + } +} diff --git a/src/Marshaller.php b/src/Marshaller.php index b15b086..02f7c28 100644 --- a/src/Marshaller.php +++ b/src/Marshaller.php @@ -3,7 +3,9 @@ use ArrayObject; use Cake\Collection\Collection; +use Cake\Datasource\AssociationsNormalizerTrait; use Cake\Datasource\EntityInterface; +use Cake\Datasource\InvalidPropertyInterface; use Muffin\Webservice\Model\Endpoint; use RuntimeException; @@ -14,6 +16,7 @@ */ class Marshaller { + use AssociationsNormalizerTrait; /** * The endpoint instance this marshaller is for. @@ -31,28 +34,73 @@ public function __construct(Endpoint $endpoint) { $this->_endpoint = $endpoint; } + /** + * Build the map of property => association names. + * + * @param array $options List of options containing the 'associated' key. + * @return array + */ + protected function _buildPropertyMap($options) + { + if (empty($options['associated'])) { + return []; + } + + $include = $options['associated']; + $map = []; + $include = $this->_normalizeAssociations($include); + foreach ($include as $key => $nested) { + if (is_int($key) && is_scalar($nested)) { + $key = $nested; + $nested = []; + } + $assoc = $this->_endpoint->association($key); + if ($assoc) { + $map[$assoc->property()] = ['association' => $assoc] + $nested + ['associated' => []]; + } + } + return $map; + } /** - * Hydrate one entity. + * Hydrate one entity and its associated data. * * ### Options: * - * * fieldList: A whitelist of fields to be assigned to the entity. If not present, - * the accessible fields list in the entity will be used. - * * accessibleFields: A list of fields to allow or deny in entity accessible fields. + * - validate: Set to false to disable validation. Can also be a string of the validator ruleset to be applied. + * Defaults to true/default. + * - associated: Associations listed here will be marshalled as well. Defaults to null. + * - fieldList: A whitelist of fields to be assigned to the entity. If not present, + * the accessible fields list in the entity will be used. Defaults to null. + * - accessibleFields: A list of fields to allow or deny in entity accessible fields. Defaults to null + * - forceNew: When enabled, belongsToMany associations will have 'new' entities created + * when primary key values are set, and a record does not already exist. Normally primary key + * on missing entities would be ignored. Defaults to false. + * + * The above options can be used in each nested `associated` array. In addition to the above + * options you can also use the `onlyIds` option for HasMany and BelongsToMany associations. + * When true this option restricts the request data to only be read from `_ids`. + * + * ``` + * $result = $marshaller->one($data, [ + * 'associated' => ['Tags' => ['onlyIds' => true]] + * ]); + * ``` * * @param array $data The data to hydrate. * @param array $options List of options - * @return \Muffin\Webservice\Model\Resource - * @see \Muffin\Webservice\Model\Endpoint::newEntity() + * @return \Cake\ORM\Entity + * @see \Cake\ORM\Table::newEntity() */ public function one(array $data, array $options = []) { list($data, $options) = $this->_prepareDataAndOptions($data, $options); - $primaryKey = (array)$this->_endpoint->primaryKey(); + $propertyMap = $this->_buildPropertyMap($options); + + $schema = $this->_endpoint->schema(); + $primaryKey = $schema->primaryKey(); $resourceClass = $this->_endpoint->resourceClass(); - /* @var \Muffin\Webservice\Model\Resource $entity */ $entity = new $resourceClass(); $entity->source($this->_endpoint->registryAlias()); @@ -62,16 +110,30 @@ public function one(array $data, array $options = []) } } + $marshallOptions = []; + if (isset($options['forceNew'])) { + $marshallOptions['forceNew'] = $options['forceNew']; + } + $errors = $this->_validate($data, $options, true); $properties = []; foreach ($data as $key => $value) { if (!empty($errors[$key])) { - $entity->invalid($key, $value); + if ($entity instanceof InvalidPropertyInterface) { + $entity->invalid($key, $value); + } continue; } - if ($value === '' && in_array($key, $primaryKey, true)) { + $columnType = $schema->columnType($key); + if (isset($propertyMap[$key])) { + $assoc = $propertyMap[$key]['association']; + $value = $this->_marshalAssociation($assoc, $value, $propertyMap[$key] + $marshallOptions); + } elseif ($value === '' && in_array($key, $primaryKey, true)) { // Skip marshalling '' for pk fields. continue; + } elseif ($columnType) { + $converter = Type::build($columnType); + $value = $converter->marshal($value); } $properties[$key] = $value; } @@ -132,9 +194,9 @@ protected function _prepareDataAndOptions($data, $options) { $options += ['validate' => true]; - $endpointName = $this->_endpoint->alias(); - if (isset($data[$endpointName])) { - $data = $data[$endpointName]; + $tableName = $this->_endpoint->alias(); + if (isset($data[$tableName])) { + $data = $data[$tableName]; } $data = new ArrayObject($data); @@ -145,18 +207,60 @@ protected function _prepareDataAndOptions($data, $options) } /** - * Hydrate many entities. + * Create a new sub-marshaller and marshal the associated data. + * + * @param \Cake\ORM\Association $assoc The association to marshall + * @param array $value The data to hydrate + * @param array $options List of options. + * @return mixed + */ + protected function _marshalAssociation($assoc, $value, $options) + { + if (!is_array($value)) { + return null; + } + $targetRepository = $assoc->target(); + $marshaller = $targetRepository->marshaller(); + $types = [Association::ONE_TO_ONE, Association::MANY_TO_ONE]; + if (in_array($assoc->type(), $types)) { + return $marshaller->one($value, (array)$options); + } + if ($assoc->type() === Association::ONE_TO_MANY || $assoc->type() === Association::MANY_TO_MANY) { + $hasIds = array_key_exists('_ids', $value); + $onlyIds = array_key_exists('onlyIds', $options) && $options['onlyIds']; + + if ($hasIds && is_array($value['_ids'])) { + return $this->_loadAssociatedByIds($assoc, $value['_ids']); + } + if ($hasIds || $onlyIds) { + return []; + } + } + if ($assoc->type() === Association::MANY_TO_MANY) { + return $marshaller->_belongsToMany($assoc, $value, (array)$options); + } + return $marshaller->many($value, (array)$options); + } + + /** + * Hydrate many entities and their associated data. * * ### Options: * - * * fieldList: A whitelist of fields to be assigned to the entity. If not present, - * the accessible fields list in the entity will be used. - * * accessibleFields: A list of fields to allow or deny in entity accessible fields. + * - validate: Set to false to disable validation. Can also be a string of the validator ruleset to be applied. + * Defaults to true/default. + * - associated: Associations listed here will be marshalled as well. Defaults to null. + * - fieldList: A whitelist of fields to be assigned to the entity. If not present, + * the accessible fields list in the entity will be used. Defaults to null. + * - accessibleFields: A list of fields to allow or deny in entity accessible fields. Defaults to null + * - forceNew: When enabled, belongsToMany associations will have 'new' entities created + * when primary key values are set, and a record does not already exist. Normally primary key + * on missing entities would be ignored. Defaults to false. * * @param array $data The data to hydrate. * @param array $options List of options * @return array An array of hydrated records. - * @see \Muffin\Webservice\Model\Endpoint::newEntities() + * @see \Cake\ORM\Table::newEntities() */ public function many(array $data, array $options = []) { @@ -171,15 +275,172 @@ public function many(array $data, array $options = []) } /** - * Merges `$data` into `$entity`. + * Marshals data for belongsToMany associations. + * + * Builds the related entities and handles the special casing + * for junction table entities. + * + * @param \Cake\ORM\Association $assoc The association to marshal. + * @param array $data The data to convert into entities. + * @param array $options List of options. + * @return array An array of built entities. + */ + protected function _belongsToMany(Association $assoc, array $data, $options = []) + { + $associated = isset($options['associated']) ? $options['associated'] : []; + $forceNew = isset($options['forceNew']) ? $options['forceNew'] : false; + + $data = array_values($data); + + $target = $assoc->target(); + $primaryKey = array_flip($target->schema()->primaryKey()); + $records = $conditions = []; + $primaryCount = count($primaryKey); + $conditions = []; + + foreach ($data as $i => $row) { + if (!is_array($row)) { + continue; + } + if (array_intersect_key($primaryKey, $row) === $primaryKey) { + $keys = array_intersect_key($row, $primaryKey); + if (count($keys) === $primaryCount) { + $rowConditions = []; + foreach ($keys as $key => $value) { + $rowConditions[][$target->aliasfield($key)] = $value; + } + + if ($forceNew && !$target->exists($rowConditions)) { + $records[$i] = $this->one($row, $options); + } + + $conditions = array_merge($conditions, $rowConditions); + } + } else { + $records[$i] = $this->one($row, $options); + } + } + + if (!empty($conditions)) { + $query = $target->find(); + $query->where($conditions); + + $keyFields = array_keys($primaryKey); + + $existing = []; + foreach ($query as $row) { + $k = implode(';', $row->extract($keyFields)); + $existing[$k] = $row; + } + + foreach ($data as $i => $row) { + $key = []; + foreach ($keyFields as $k) { + if (isset($row[$k])) { + $key[] = $row[$k]; + } + } + $key = implode(';', $key); + + // Update existing record and child associations + if (isset($existing[$key])) { + $records[$i] = $this->merge($existing[$key], $data[$i], $options); + } + } + } + + $jointMarshaller = $assoc->junction()->marshaller(); + + $nested = []; + if (isset($associated['_joinData'])) { + $nested = (array)$associated['_joinData']; + } + + foreach ($records as $i => $record) { + // Update junction table data in _joinData. + if (isset($data[$i]['_joinData'])) { + $joinData = $jointMarshaller->one($data[$i]['_joinData'], $nested); + $record->set('_joinData', $joinData); + } + } + return $records; + } + + /** + * Loads a list of belongs to many from ids. + * + * @param \Cake\ORM\Association $assoc The association class for the belongsToMany association. + * @param array $ids The list of ids to load. + * @return array An array of entities. + */ + protected function _loadAssociatedByIds($assoc, $ids) + { + if (empty($ids)) { + return []; + } + + $target = $assoc->target(); + $primaryKey = (array)$target->primaryKey(); + $multi = count($primaryKey) > 1; + $primaryKey = array_map([$target, 'aliasField'], $primaryKey); + + if ($multi) { + if (count(current($ids)) !== count($primaryKey)) { + return []; + } + $filter = new TupleComparison($primaryKey, $ids, [], 'IN'); + } else { + $filter = []; + foreach ($ids as $id) { + $filter[][$primaryKey[0]] = $id; + } + } + + return $target->find()->where($filter)->toArray(); + } + + /** + * Loads a list of belongs to many from ids. + * + * @param \Cake\ORM\Association $assoc The association class for the belongsToMany association. + * @param array $ids The list of ids to load. + * @return array An array of entities. + * @deprecated Use _loadAssociatedByIds() + */ + protected function _loadBelongsToMany($assoc, $ids) + { + return $this->_loadAssociatedByIds($assoc, $ids); + } + + /** + * Merges `$data` into `$entity` and recursively does the same for each one of + * the association names passed in `$options`. When merging associations, if an + * entity is not present in the parent entity for a given association, a new one + * will be created. + * + * When merging HasMany or BelongsToMany associations, all the entities in the + * `$data` array will appear, those that can be matched by primary key will get + * the data merged, but those that cannot, will be discarded. `ids` option can be used + * to determine whether the association must use the `_ids` format. * * ### Options: * - * * validate: Whether or not to validate data before hydrating the entities. Can + * - associated: Associations listed here will be marshalled as well. + * - validate: Whether or not to validate data before hydrating the entities. Can * also be set to a string to use a specific validator. Defaults to true/default. - * * fieldList: A whitelist of fields to be assigned to the entity. If not present + * - fieldList: A whitelist of fields to be assigned to the entity. If not present * the accessible fields list in the entity will be used. - * * accessibleFields: A list of fields to allow or deny in entity accessible fields. + * - accessibleFields: A list of fields to allow or deny in entity accessible fields. + * + * The above options can be used in each nested `associated` array. In addition to the above + * options you can also use the `onlyIds` option for HasMany and BelongsToMany associations. + * When true this option restricts the request data to only be read from `_ids`. + * + * ``` + * $result = $marshaller->merge($entity, $data, [ + * 'associated' => ['Tags' => ['onlyIds' => true]] + * ]); + * ``` * * @param \Cake\Datasource\EntityInterface $entity the entity that will get the * data merged in @@ -191,6 +452,7 @@ public function merge(EntityInterface $entity, array $data, array $options = []) { list($data, $options) = $this->_prepareDataAndOptions($data, $options); + $propertyMap = $this->_buildPropertyMap($options); $isNew = $entity->isNew(); $keys = []; @@ -205,15 +467,37 @@ public function merge(EntityInterface $entity, array $data, array $options = []) } $errors = $this->_validate($data + $keys, $options, $isNew); - $properties = []; + $schema = $this->_endpoint->schema(); + $properties = $marshalledAssocs = []; foreach ($data as $key => $value) { if (!empty($errors[$key])) { - if (method_exists($entity, 'invalid')) { + if ($entity instanceof InvalidPropertyInterface) { $entity->invalid($key, $value); } continue; } + $columnType = $schema->columnType($key); + $original = $entity->get($key); + debug($entity);exit(); + debug($key); + debug($original); + + if (isset($propertyMap[$key])) { + $assoc = $propertyMap[$key]['association']; + $value = $this->_mergeAssociation($original, $assoc, $value, $propertyMap[$key]); + $marshalledAssocs[$key] = true; + } elseif ($columnType) { + $converter = Type::build($columnType); + $value = $converter->marshal($value); + $isObject = is_object($value); + if ((!$isObject && $original === $value) || + ($isObject && $original == $value) + ) { + continue; + } + } + $properties[$key] = $value; } @@ -221,12 +505,20 @@ public function merge(EntityInterface $entity, array $data, array $options = []) $entity->set($properties); $entity->errors($errors); + foreach (array_keys($marshalledAssocs) as $field) { + if ($properties[$field] instanceof EntityInterface) { + $entity->dirty($field, $properties[$field]->dirty()); + } + } return $entity; } foreach ((array)$options['fieldList'] as $field) { if (array_key_exists($field, $properties)) { $entity->set($field, $properties[$field]); + if ($properties[$field] instanceof EntityInterface && isset($marshalledAssocs[$field])) { + $entity->dirty($field, $properties[$field]->dirty()); + } } } @@ -235,15 +527,25 @@ public function merge(EntityInterface $entity, array $data, array $options = []) } /** - * Merges each of the elements from `$data` into each of the entities in `$entities`. + * Merges each of the elements from `$data` into each of the entities in `$entities` + * and recursively does the same for each of the association names passed in + * `$options`. When merging associations, if an entity is not present in the parent + * entity for a given association, a new one will be created. * * Records in `$data` are matched against the entities using the primary key * column. Entries in `$entities` that cannot be matched to any record in * `$data` will be discarded. Records in `$data` that could not be matched will * be marshalled as a new entity. * + * When merging HasMany or BelongsToMany associations, all the entities in the + * `$data` array will appear, those that can be matched by primary key will get + * the data merged, but those that cannot, will be discarded. + * * ### Options: * + * - validate: Whether or not to validate data before hydrating the entities. Can + * also be set to a string to use a specific validator. Defaults to true/default. + * - associated: Associations listed here will be marshalled as well. * - fieldList: A whitelist of fields to be assigned to the entity. If not present, * the accessible fields list in the entity will be used. * - accessibleFields: A list of fields to allow or deny in entity accessible fields. @@ -289,6 +591,32 @@ public function mergeMany($entities, array $data, array $options = []) unset($indexed[$key]); } + $conditions = (new Collection($indexed)) + ->map(function ($data, $key) { + return explode(';', $key); + }) + ->filter(function ($keys) use ($primary) { + return count(array_filter($keys, 'strlen')) === count($primary); + }) + ->reduce(function ($conditions, $keys) use ($primary) { + $fields = array_map([$this->_endpoint, 'aliasField'], $primary); + + $conditions[] = array_combine($fields, $keys); + + return $conditions; + }, []); + $query = $this->_endpoint->find()->where($conditions); + + if (!empty($indexed) && count($query->clause('where'))) { + foreach ($query as $entity) { + $key = implode(';', $entity->extract($primary)); + if (isset($indexed[$key])) { + $output[] = $this->merge($entity, $indexed[$key], $options); + unset($indexed[$key]); + } + } + } + foreach ((new Collection($indexed))->append($new) as $value) { if (!is_array($value)) { continue; @@ -298,4 +626,124 @@ public function mergeMany($entities, array $data, array $options = []) return $output; } + + /** + * Creates a new sub-marshaller and merges the associated data. + * + * @param \Cake\Datasource\EntityInterface $original The original entity + * @param \Cake\ORM\Association $assoc The association to merge + * @param array $value The data to hydrate + * @param array $options List of options. + * @return mixed + */ + protected function _mergeAssociation($original, $assoc, $value, $options) + { + if (!$original) { + return $this->_marshalAssociation($assoc, $value, $options); + } + + $targetTable = $assoc->target(); + $marshaller = $targetTable->marshaller(); + $types = [Association::ONE_TO_ONE, Association::MANY_TO_ONE]; + if (in_array($assoc->type(), $types)) { + return $marshaller->merge($original, $value, (array)$options); + } + if ($assoc->type() === Association::MANY_TO_MANY) { + return $marshaller->_mergeBelongsToMany($original, $assoc, $value, (array)$options); + } + return $marshaller->mergeMany($original, $value, (array)$options); + } + + /** + * Creates a new sub-marshaller and merges the associated data for a BelongstoMany + * association. + * + * @param \Cake\Datasource\EntityInterface $original The original entity + * @param \Cake\ORM\Association $assoc The association to marshall + * @param array $value The data to hydrate + * @param array $options List of options. + * @return array + */ + protected function _mergeBelongsToMany($original, $assoc, $value, $options) + { + $associated = isset($options['associated']) ? $options['associated'] : []; + + $hasIds = array_key_exists('_ids', $value); + $onlyIds = array_key_exists('onlyIds', $options) && $options['onlyIds']; + + if ($hasIds && is_array($value['_ids'])) { + debug($value['_ids']); + return $this->_loadAssociatedByIds($assoc, $value['_ids']); + } + if ($hasIds || $onlyIds) { + return []; + } + + if (!empty($associated) && !in_array('_joinData', $associated) && !isset($associated['_joinData'])) { + return $this->mergeMany($original, $value, $options); + } + + return $this->_mergeJoinData($original, $assoc, $value, $options); + } + + /** + * Merge the special _joinData property into the entity set. + * + * @param \Cake\Datasource\EntityInterface $original The original entity + * @param \Cake\ORM\Association $assoc The association to marshall + * @param array $value The data to hydrate + * @param array $options List of options. + * @return array An array of entities + */ + protected function _mergeJoinData($original, $assoc, $value, $options) + { + $associated = isset($options['associated']) ? $options['associated'] : []; + $extra = []; + foreach ($original as $entity) { + // Mark joinData as accessible so we can marshal it properly. + $entity->accessible('_joinData', true); + + $joinData = $entity->get('_joinData'); + if ($joinData && $joinData instanceof EntityInterface) { + $extra[spl_object_hash($entity)] = $joinData; + } + } + + $joint = $assoc->junction(); + $marshaller = $joint->marshaller(); + + $nested = []; + if (isset($associated['_joinData'])) { + $nested = (array)$associated['_joinData']; + } + + $options['accessibleFields'] = ['_joinData' => true]; + + $records = $this->mergeMany($original, $value, $options); + foreach ($records as $record) { + $hash = spl_object_hash($record); + $value = $record->get('_joinData'); + + // Already an entity, no further marshalling required. + if ($value instanceof EntityInterface) { + continue; + } + + // Scalar data can't be handled + if (!is_array($value)) { + $record->unsetProperty('_joinData'); + continue; + } + + // Marshal data into the old object, or make a new joinData object. + if (isset($extra[$hash])) { + $record->set('_joinData', $marshaller->merge($extra[$hash], $value, $nested)); + } elseif (is_array($value)) { + $joinData = $marshaller->one($value, $nested); + $record->set('_joinData', $joinData); + } + } + + return $records; + } } diff --git a/src/Model/Endpoint.php b/src/Model/Endpoint.php index ec8e6e3..d494b1e 100644 --- a/src/Model/Endpoint.php +++ b/src/Model/Endpoint.php @@ -5,6 +5,7 @@ use ArrayObject; use BadMethodCallException; use Cake\Core\App; +use Cake\Datasource\AssociationCollection; use Cake\Datasource\EntityInterface; use Cake\Datasource\Exception\InvalidPrimaryKeyException; use Cake\Datasource\RepositoryInterface; @@ -17,15 +18,19 @@ use Cake\Network\Exception\NotImplementedException; use Cake\Utility\Inflector; use Cake\Validation\ValidatorAwareTrait; +use InvalidArgumentException; +use Muffin\Webservice\Association\BelongsTo; +use Muffin\Webservice\Association\BelongsToMany; +use Muffin\Webservice\Association\HasMany; use Muffin\Webservice\Exception\MissingResourceClassException; use Muffin\Webservice\Exception\UnexpectedDriverException; use Muffin\Webservice\Marshaller; use Muffin\Webservice\Query; use Muffin\Webservice\Schema; -use Muffin\Webservice\StreamQuery; +use RuntimeException; /** - * The table equivalent of a webservice endpoint + * The endpoint equivalent of a webservice endpoint * * @package Muffin\Webservice\Model */ @@ -94,6 +99,13 @@ class Endpoint implements RepositoryInterface, EventListenerInterface, EventDisp */ protected $_displayField; + /** + * The associations container for this Endpoint. + * + * @var \Cake\Datasource\AssociationCollection + */ + protected $_associations; + /** * The webservice instance to call * @@ -115,6 +127,7 @@ class Endpoint implements RepositoryInterface, EventListenerInterface, EventDisp * * - alias: Alias to be assigned to this endpoint (default to endpoint name) * - connection: The connection instance to use + * - webservice: The webservice instance to use * - endpoint: Name of the endpoint to represent * - resourceClass: The fully namespaced class name of the resource class that will * represent rows in this endpoint. @@ -131,13 +144,16 @@ public function __construct(array $config = []) if (!empty($config['connection'])) { $this->connection($config['connection']); } + if (!empty($config['webservice'])) { + $this->webservice($config['webservice']); + } if (!empty($config['displayField'])) { $this->displayField($config['displayField']); } if (!empty($config['endpoint'])) { $this->endpoint($config['endpoint']); } - $eventManager = null; + $eventManager = $associations = null; if (!empty($config['eventManager'])) { $eventManager = $config['eventManager']; } @@ -153,14 +169,260 @@ public function __construct(array $config = []) if (!empty($config['resourceClass'])) { $this->resourceClass($config['resourceClass']); } + if (!empty($config['associations'])) { + $associations = $config['associations']; + } $this->_eventManager = $eventManager ?: new EventManager(); + $this->_associations = $associations ?: new AssociationCollection(); + $this->initialize($config); $this->_eventManager->on($this); $this->dispatchEvent('Model.initialize'); } + /** + * Returns an association object configured for the specified alias if any + * + * @param string $name the alias used for the association. + * @return \Muffin\Webservice\\Association|null Either the association or null. + */ + public function association($name) + { + return $this->_associations->get($name); + } + + /** + * Get the associations collection for this endpoint. + * + * @return \Cake\Datasource\AssociationCollection The collection of association objects. + */ + public function associations() + { + return $this->_associations; + } + + /** + * Setup multiple associations. + * + * It takes an array containing set of table names indexed by association type + * as argument: + * + * ``` + * $this->Posts->addAssociations([ + * 'belongsTo' => [ + * 'Users' => ['className' => 'App\Model\Table\UsersTable'] + * ], + * 'hasMany' => ['Comments'], + * 'belongsToMany' => ['Tags'] + * ]); + * ``` + * + * Each association type accepts multiple associations where the keys + * are the aliases, and the values are association config data. If numeric + * keys are used the values will be treated as association aliases. + * + * @param array $params Set of associations to bind (indexed by association type) + * @return void + * @see \Cake\ORM\Table::belongsTo() + * @see \Cake\ORM\Table::hasOne() + * @see \Cake\ORM\Table::hasMany() + * @see \Cake\ORM\Table::belongsToMany() + */ + public function addAssociations(array $params) + { + foreach ($params as $assocType => $tables) { + foreach ($tables as $associated => $options) { + if (is_numeric($associated)) { + $associated = $options; + $options = []; + } + $this->{$assocType}($associated, $options); + } + } + } + + /** + * Creates a new BelongsTo association between this endpoint and a target + * endpoint. A "belongs to" association is a N-1 relationship where this endpoint + * is the N side, and where there is a single associated record in the target + * endpoint for each one in this endpoint. + * + * Target endpoint can be inferred by its name, which is provided in the + * first argument, or you can either pass the to be instantiated or + * an instance of it directly. + * + * The options array accept the following keys: + * + * - className: The class name of the target endpoint object + * - targetRepository: An instance of a endpoint object to be used as the target endpoint + * - foreignKey: The name of the field to use as foreign key, if false none + * will be used + * - conditions: array with a list of conditions to filter the join with + * - joinType: The type of join to be used (e.g. INNER) + * - strategy: The loading strategy to use. 'join' and 'select' are supported. + * - finder: The finder method to use when loading records from this association. + * Defaults to 'all'. When the strategy is 'join', only the fields, containments, + * and where conditions will be used from the finder. + * + * This method will return the association object that was built. + * + * @param string $associated the alias for the target endpoint. This is used to + * uniquely identify the association + * @param array $options list of options to configure the association definition + * @return \Muffin\Webservice\Association\BelongsTo + */ + public function belongsTo($associated, array $options = []) + { + $options += ['sourceRepository' => $this]; + $association = new BelongsTo($associated, $options); + return $this->_associations->add($association->name(), $association); + } + + /** + * Creates a new HasOne association between this endpoint and a target + * endpoint. A "has one" association is a 1-1 relationship. + * + * Target endpoint can be inferred by its name, which is provided in the + * first argument, or you can either pass the class name to be instantiated or + * an instance of it directly. + * + * The options array accept the following keys: + * + * - className: The class name of the target endpoint object + * - targetRepository: An instance of a endpoint object to be used as the target endpoint + * - foreignKey: The name of the field to use as foreign key, if false none + * will be used + * - dependent: Set to true if you want CakePHP to cascade deletes to the + * associated endpoint when an entity is removed on this endpoint. The delete operation + * on the associated endpoint will not cascade further. To get recursive cascades enable + * `cascadeCallbacks` as well. Set to false if you don't want CakePHP to remove + * associated data, or when you are using webservice constraints. + * - cascadeCallbacks: Set to true if you want CakePHP to fire callbacks on + * cascaded deletes. If false the ORM will use deleteAll() to remove data. + * When true records will be loaded and then deleted. + * - conditions: array with a list of conditions to filter the join with + * - joinType: The type of join to be used (e.g. LEFT) + * - strategy: The loading strategy to use. 'join' and 'select' are supported. + * - finder: The finder method to use when loading records from this association. + * Defaults to 'all'. When the strategy is 'join', only the fields, containments, + * and where conditions will be used from the finder. + * + * This method will return the association object that was built. + * + * @param string $associated the alias for the target endpoint. This is used to + * uniquely identify the association + * @param array $options list of options to configure the association definition + * @return \Muffin\Webservice\Association\HasOne + */ + public function hasOne($associated, array $options = []) + { + $options += ['sourceRepository' => $this]; + $association = new HasOne($associated, $options); + return $this->_associations->add($association->name(), $association); + } + + /** + * Creates a new HasMany association between this endpoint and a target + * endpoint. A "has many" association is a 1-N relationship. + * + * Target endpoint can be inferred by its name, which is provided in the + * first argument, or you can either pass the class name to be instantiated or + * an instance of it directly. + * + * The options array accept the following keys: + * + * - className: The class name of the target endpoint object + * - targetRepository: An instance of a endpoint object to be used as the target endpoint + * - foreignKey: The name of the field to use as foreign key, if false none + * will be used + * - dependent: Set to true if you want CakePHP to cascade deletes to the + * associated endpoint when an entity is removed on this endpoint. The delete operation + * on the associated endpoint will not cascade further. To get recursive cascades enable + * `cascadeCallbacks` as well. Set to false if you don't want CakePHP to remove + * associated data, or when you are using webservice constraints. + * - cascadeCallbacks: Set to true if you want CakePHP to fire callbacks on + * cascaded deletes. If false the ORM will use deleteAll() to remove data. + * When true records will be loaded and then deleted. + * - conditions: array with a list of conditions to filter the join with + * - sort: The order in which results for this association should be returned + * - saveStrategy: Either 'append' or 'replace'. When 'append' the current records + * are appended to any records in the webservice. When 'replace' associated records + * not in the current set will be removed. If the foreign key is a null able column + * or if `dependent` is true records will be orphaned. + * - strategy: The strategy to be used for selecting results Either 'select' + * or 'subquery'. If subquery is selected the query used to return results + * in the source endpoint will be used as conditions for getting rows in the + * target endpoint. + * - finder: The finder method to use when loading records from this association. + * Defaults to 'all'. + * + * This method will return the association object that was built. + * + * @param string $associated the alias for the target endpoint. This is used to + * uniquely identify the association + * @param array $options list of options to configure the association definition + * @return \Muffin\Webservice\Association\HasMany + */ + public function hasMany($associated, array $options = []) + { + $options += ['sourceRepository' => $this]; + $association = new HasMany($associated, $options); + return $this->_associations->add($association->name(), $association); + } + + /** + * Creates a new BelongsToMany association between this table and a target + * table. A "belongs to many" association is a M-N relationship. + * + * Target table can be inferred by its name, which is provided in the + * first argument, or you can either pass the class name to be instantiated or + * an instance of it directly. + * + * The options array accept the following keys: + * + * - className: The class name of the target table object. + * - targetTable: An instance of a table object to be used as the target table. + * - foreignKey: The name of the field to use as foreign key. + * - targetForeignKey: The name of the field to use as the target foreign key. + * - joinTable: The name of the table representing the link between the two + * - through: If you choose to use an already instantiated link table, set this + * key to a configured Table instance containing associations to both the source + * and target tables in this association. + * - dependent: Set to false, if you do not want junction table records removed + * when an owning record is removed. + * - cascadeCallbacks: Set to true if you want CakePHP to fire callbacks on + * cascaded deletes. If false the ORM will use deleteAll() to remove data. + * When true join/junction table records will be loaded and then deleted. + * - conditions: array with a list of conditions to filter the join with. + * - sort: The order in which results for this association should be returned. + * - strategy: The strategy to be used for selecting results Either 'select' + * or 'subquery'. If subquery is selected the query used to return results + * in the source table will be used as conditions for getting rows in the + * target table. + * - saveStrategy: Either 'append' or 'replace'. Indicates the mode to be used + * for saving associated entities. The former will only create new links + * between both side of the relation and the latter will do a wipe and + * replace to create the links between the passed entities when saving. + * - strategy: The loading strategy to use. 'select' and 'subquery' are supported. + * - finder: The finder method to use when loading records from this association. + * Defaults to 'all'. + * + * This method will return the association object that was built. + * + * @param string $associated the alias for the target table. This is used to + * uniquely identify the association + * @param array $options list of options to configure the association definition + * @return \Cake\ORM\Association\BelongsToMany + */ + public function belongsToMany($associated, array $options = []) + { + $options += ['sourceRepository' => $this]; + $association = new BelongsToMany($associated, $options); + return $this->_associations->add($association->name(), $association); + } + /** * Get the default connection name. * @@ -173,10 +435,15 @@ public function __construct(array $config = []) */ public static function defaultConnectionName() { - $namespaceParts = explode('\\', get_called_class()); - $plugin = array_slice(array_reverse($namespaceParts), 3, 2); + list($plugin) = pluginSplit(App::shortName(get_called_class(), 'Model/Endpoint', 'Endpoint')); + $pluginParts = explode('/', $plugin); - return Inflector::underscore(current($plugin)); + $connectionName = end($pluginParts); + if (!$connectionName) { + $connectionName = 'app'; + } + + return Inflector::underscore($connectionName); } /** @@ -308,7 +575,7 @@ public function schema($schema = null) return $this->_schema; } if (is_array($schema)) { - $schema = new Schema($this->table(), $schema); + $schema = new Schema($this->endpoint(), $schema); } return $this->_schema = $schema; } @@ -316,8 +583,8 @@ public function schema($schema = null) /** * Override this function in order to alter the schema used by this endpoint. * This function is only called after fetching the schema out of the webservice. - * If you wish to provide your own schema to this table without touching the - * database, you can override schema() or inject the definitions though that + * If you wish to provide your own schema to this endpoint without touching the + * webservice, you can override schema() or inject the definitions though that * method. * * ### Example: @@ -341,10 +608,10 @@ protected function _initializeSchema(Schema $schema) } /** - * Test to see if a Table has a specific field/column. + * Test to see if a Endpoint has a specific field/column. * * Delegates to the schema object and checks for column presence - * using the Schema\Table instance. + * using the Schema instance. * * @param string $field The field to check for. * @return bool True if the field exists, false if it does not. @@ -456,6 +723,12 @@ public function resourceClass($name = null) */ public function webservice($webservice = null) { + if (is_object($webservice)) { + $this->_webservice = $webservice; + + return $this; + } + if ((is_string($webservice)) || ($this->_webservice === null)) { if ($webservice === null) { $webservice = $this->endpoint(); @@ -470,13 +743,8 @@ public function webservice($webservice = null) return $this->_webservice; } - if ($webservice === null) { - return $this->_webservice; - } - - $this->_webservice = $webservice; - return $this; + return $this->_webservice; } /** @@ -800,39 +1068,234 @@ public function exists($conditions) public function save(EntityInterface $resource, $options = []) { $options = new ArrayObject($options + [ + 'associated' => true, 'checkRules' => true, + 'checkExisting' => true, + '_primary' => true ]); - $mode = $resource->isNew() ? RulesChecker::CREATE : RulesChecker::UPDATE; - if ($options['checkRules'] && !$this->checkRules($resource, $mode, $options)) { + if ($resource->errors()) { return false; } - $data = $resource->extract($this->schema()->columns(), true); + if ($resource->isNew() === false && !$resource->dirty()) { + return $resource; + } - if ($resource->isNew()) { - $query = $this->query()->create(); - } else { - $query = $this->query()->update()->where([ - $this->primaryKey() => $resource->get($this->primaryKey()) - ]); + $success = $this->_processSave($resource, $options); + + if ($success) { + if ($options['_primary']) { + $resource->isNew(false); + $resource->source($this->registryAlias()); + } + } + + return $success; + } + + /** + * Performs the actual saving of an entity based on the passed options. + * + * @param \Cake\Datasource\EntityInterface $entity the entity to be saved + * @param \ArrayObject $options the options to use for the save operation + * @return \Cake\Datasource\EntityInterface|bool + * @throws \RuntimeException When an entity is missing some of the primary keys. + */ + protected function _processSave($entity, $options) + { + $primaryColumns = (array)$this->primaryKey(); + + if ($options['checkExisting'] && $primaryColumns && $entity->isNew() && $entity->has($primaryColumns)) { + $alias = $this->alias(); + $conditions = []; + foreach ($entity->extract($primaryColumns) as $k => $v) { + $conditions["$alias.$k"] = $v; + } + $entity->isNew(!$this->exists($conditions)); } - $query->set($data); - $result = $query->execute(); - if (!$result) { + $mode = $entity->isNew() ? RulesChecker::CREATE : RulesChecker::UPDATE; + if ($options['checkRules'] && !$this->checkRules($entity, $mode, $options)) { return false; } - if (($resource->isNew()) && ($result instanceof EntityInterface)) { - return $result; + $options['associated'] = $this->_associations->normalizeKeys($options['associated']); + $event = $this->dispatchEvent('Model.beforeSave', compact('entity', 'options')); + + if ($event->isStopped()) { + return $event->result; } - $className = get_class($resource); - return new $className($resource->toArray(), [ - 'markNew' => false, - 'markClean' => true - ]); + $saved = $this->_associations->saveParents( + $this, + $entity, + $options['associated'], + ['_primary' => false] + $options->getArrayCopy() + ); + + if (!$saved && $options['atomic']) { + return false; + } + + $data = $entity->extract($this->schema()->columns(), true); + $isNew = $entity->isNew(); + + if ($isNew) { + $success = $this->_create($entity, $data); + } else { + $success = $this->_update($entity, $data); + } + + if ($success) { + $success = $this->_associations->saveChildren( + $this, + $entity, + $options['associated'], + ['_primary' => false] + $options->getArrayCopy() + ); + if ($success) { + $this->dispatchEvent('Model.afterSave', compact('entity', 'options')); + $entity->clean(); + if (!$options['_primary']) { + $entity->isNew(false); + $entity->source($this->registryAlias()); + } + $success = true; + } + } + + if (!$success && $isNew) { + $entity->unsetProperty($this->primaryKey()); + $entity->isNew(true); + } + if ($success) { + return $entity; + } + return false; + } + + /** + * Auxiliary function to handle the insert of an entity's data in the table + * + * @param \Cake\Datasource\EntityInterface $entity the subject entity from were $data was extracted + * @param array $data The actual data that needs to be saved + * @return \Cake\Datasource\EntityInterface|bool + * @throws \RuntimeException if not all the primary keys where supplied or could + * be generated when the table has composite primary keys. Or when the table has no primary key. + */ + protected function _create($entity, $data) + { + $primary = (array)$this->primaryKey(); + if (empty($primary)) { + $msg = sprintf( + 'Cannot insert row in "%s" endpoint, it has no primary key.', + $this->endpoint() + ); + throw new RuntimeException($msg); + } + $keys = array_fill(0, count($primary), null); + $id = (array)$this->_newId($primary) + $keys; + + // Generate primary keys preferring values in $data. + $primary = array_combine($primary, $id); + $primary = array_intersect_key($data, $primary) + $primary; + + $filteredKeys = array_filter($primary, 'strlen'); + $data = $data + $filteredKeys; + + if (count($primary) > 1) { + $schema = $this->schema(); + foreach ($primary as $k => $v) { + if (!isset($data[$k]) && empty($schema->column($k)['autoIncrement'])) { + $msg = 'Cannot insert row, some of the primary key values are missing. '; + $msg .= sprintf( + 'Got (%s), expecting (%s)', + implode(', ', $filteredKeys + $entity->extract(array_keys($primary))), + implode(', ', array_keys($primary)) + ); + throw new RuntimeException($msg); + } + } + } + + $success = false; + if (empty($data)) { + return $success; + } + + $resultSet = $this->query()->create() + ->set($data) + ->execute(); + if ($resultSet->total() !== 0) { + $result = $resultSet->first(); + $success = $entity; + $entity->set($filteredKeys, ['guard' => false]); + foreach ($primary as $key => $v) { + if (!isset($data[$key])) { + $id = $result->get($key); + $entity->set($key, $id); + break; + } + } + } + + return $success; + } + + /** + * Generate a primary key value for a new record. + * + * By default, this uses the type system to generate a new primary key + * value if possible. You can override this method if you have specific requirements + * for id generation. + * + * @param array $primary The primary key columns to get a new ID for. + * @return mixed Either null or the new primary key value. + */ + protected function _newId($primary) + { + if (!$primary || count((array)$primary) > 1) { + return null; + } + return null; + } + + /** + * Auxiliary function to handle the update of an entity's data in the table + * + * @param \Cake\Datasource\EntityInterface $entity the subject entity from were $data was extracted + * @param array $data The actual data that needs to be saved + * @return \Cake\Datasource\EntityInterface|bool + * @throws \InvalidArgumentException When primary key data is missing. + */ + protected function _update($entity, $data) + { + $primaryColumns = (array)$this->primaryKey(); + $primaryKey = $entity->extract($primaryColumns); + + $data = array_diff_key($data, $primaryKey); + if (empty($data)) { + return $entity; + } + + if (!$entity->has($primaryColumns)) { + $message = 'All primary key value(s) are needed for updating'; + throw new InvalidArgumentException($message); + } + + $query = $this->query(); + $statement = $query->update() + ->set($data) + ->where($primaryKey) + ->execute(); + + $success = false; + if ($statement->errorCode() === '00000') { + $success = $entity; + } + $statement->closeCursor(); + return $success; } /** @@ -968,6 +1431,40 @@ public function __call($method, $args) ); } + /** + * Returns the association named after the passed value if exists, otherwise + * throws an exception. + * + * @param string $property the association name + * @return \Cake\Datasource\AssociationInterface + * @throws \RuntimeException if no association with such name exists + */ + public function __get($property) + { + $association = $this->_associations->get($property); + if (!$association) { + throw new RuntimeException(sprintf( + 'Endpoint "%s" is not associated with "%s"', + get_class($this), + $property + )); + } + return $association; + } + + /** + * Returns whether an association named after the passed value + * exists for this endpoint. + * + * @param string $property the association name + * @return bool + */ + public function __isset($property) + { + return $this->_associations->has($property); + } + + /** * Get the object used to marshal/convert array data into objects. * @@ -995,6 +1492,9 @@ public function newEntity($data = null, array $options = []) $entity = new $class([], ['source' => $this->registryAlias()]); return $entity; } + if (!isset($options['associated'])) { + $options['associated'] = $this->_associations->keys(); + } $marshaller = $this->marshaller(); return $marshaller->one($data, $options); } @@ -1004,6 +1504,9 @@ public function newEntity($data = null, array $options = []) */ public function newEntities(array $data, array $options = []) { + if (!isset($options['associated'])) { + $options['associated'] = $this->_associations->keys(); + } $marshaller = $this->marshaller(); return $marshaller->many($data, $options); } @@ -1028,6 +1531,9 @@ public function newEntities(array $data, array $options = []) */ public function patchEntity(EntityInterface $entity, array $data, array $options = []) { + if (!isset($options['associated'])) { + $options['associated'] = $this->_associations->keys(); + } $marshaller = $this->marshaller(); return $marshaller->merge($entity, $data, $options); } @@ -1053,6 +1559,9 @@ public function patchEntity(EntityInterface $entity, array $data, array $options */ public function patchEntities($entities, array $data, array $options = []) { + if (!isset($options['associated'])) { + $options['associated'] = $this->_associations->keys(); + } $marshaller = $this->marshaller(); return $marshaller->mergeMany($entities, $data, $options); } @@ -1131,7 +1640,7 @@ public function __debugInfo() 'endpoint' => $this->endpoint(), 'resourceClass' => $this->resourceClass(), 'defaultConnection' => $this->defaultConnectionName(), - 'connectionName' => $conn ? $conn->configName() : null + 'connectionName' => $conn ? $conn->configName() : null, ]; } } diff --git a/src/Model/EndpointRegistry.php b/src/Model/EndpointRegistry.php index 8bc40a3..db2f122 100644 --- a/src/Model/EndpointRegistry.php +++ b/src/Model/EndpointRegistry.php @@ -43,6 +43,7 @@ public static function get($alias, array $options = []) return self::$_instances[$alias]; } + self::$_options[$alias] = $options; list(, $classAlias) = pluginSplit($alias); $options = ['alias' => $classAlias] + $options; @@ -65,8 +66,10 @@ public static function get($alias, array $options = []) $connectionName = $options['className']::defaultConnectionName(); } else { $pluginParts = explode('/', pluginSplit($alias)[0]); - $connectionName = Inflector::underscore(end($pluginParts)); + if (!$connectionName) { + $connectionName = 'app'; + } } $options['connection'] = ConnectionManager::get($connectionName); @@ -78,6 +81,21 @@ public static function get($alias, array $options = []) return self::$_instances[$alias]; } + public static function exists($alias) + { + return isset(self::$_instances[$alias]); + } + + /** + * Clears the registry of configuration and instances. + * + * @return void + */ + public static function clear() + { + self::$_instances = []; + } + /** * Wrapper for creating endpoint instances * diff --git a/src/Model/Schema/Collection.php b/src/Model/Schema/Collection.php new file mode 100644 index 0000000..e7eddc5 --- /dev/null +++ b/src/Model/Schema/Collection.php @@ -0,0 +1,57 @@ +_connection = $connection; + } + + /** + * Get the column metadata for a table. + * + * Caching will be applied if `cacheMetadata` key is present in the Connection + * configuration options. Defaults to _cake_model_ when true. + * + * @param string $name The name of the table to describe. + * @param array $options The options to use, see above. + * @return \Muffin\Webservice\Schema Object with column metadata. + */ + public function describe($name, array $options = []) + { + $config = $this->_connection->config(); + if (strpos($name, '.')) { + list($config['schema'], $name) = explode('.', $name); + } + + return $this->_connection->webservice($name)->describe($name); + } + + public function listTables() + { + return []; + } +} diff --git a/src/Panel/WebserviceQueriesPanel.php b/src/Panel/WebserviceQueriesPanel.php new file mode 100644 index 0000000..d28638a --- /dev/null +++ b/src/Panel/WebserviceQueriesPanel.php @@ -0,0 +1,31 @@ + QueryLog::queries() + ]; + } + + /** + * {@inheritDoc} + */ + public function summary() + { + $took = round(collection(QueryLog::queries())->sumOf('took'), 0); + + return __d('muffin/webservice', '{0} / {1} ms', count(QueryLog::queries()), $took); + } +} diff --git a/src/Query.php b/src/Query.php index f6551bb..42fb5cb 100644 --- a/src/Query.php +++ b/src/Query.php @@ -3,11 +3,14 @@ namespace Muffin\Webservice; use ArrayObject; +use Cake\Core\App; use Cake\Datasource\Exception\RecordNotFoundException; use Cake\Datasource\QueryInterface; use Cake\Datasource\QueryTrait; use Cake\Utility\Hash; +use DebugKit\DebugTimer; use IteratorAggregate; +use Muffin\Webservice\EagerLoader; use Muffin\Webservice\Model\Endpoint; use Muffin\Webservice\Webservice\WebserviceInterface; @@ -83,6 +86,21 @@ class Query implements QueryInterface, IteratorAggregate */ protected $__resultSet; + /** + * Whether to hydrate results into entity objects + * + * @var bool + */ + protected $_hydrate = true; + + /** + * Instance of a class responsible for storing association containments and + * for eager loading them when this query is executed + * + * @var \Muffin\Webservice\EagerLoader + */ + protected $_eagerLoader; + /** * Construct the query * @@ -208,6 +226,18 @@ public function find($finder, array $options = []) return $this->repository()->callFinder($finder, $this, $options); } + /** + * Marks a query as dirty, removing any preprocessed information + * from in memory caching such as previous results + * + * @return void + */ + protected function _dirty() + { + $this->_results = null; + $this->_resultsCount = null; + } + /** * Get the first result from the executing query or raise an exception. * @@ -227,18 +257,18 @@ public function firstOrFail() )); } - /** - * Alias a field with the endpoint's current alias. - * - * @param string $field The field to alias. - * @param null $alias Not being used - * - * @return string The field prefixed with the endpoint alias. - */ - public function aliasField($field, $alias = null) - { - return [$field => $field]; - } +// /** +// * Alias a field with the endpoint's current alias. +// * +// * @param string $field The field to alias. +// * @param null $alias Not being used +// * +// * @return string The field prefixed with the endpoint alias. +// */ +// public function aliasField($field, $alias = null) +// { +// return [$field => $field]; +// } /** * Apply conditions to the query @@ -255,7 +285,30 @@ public function where($conditions = null, $types = [], $overwrite = false) return $this->clause('where'); } - $this->_parts['where'] = (!$overwrite) ? Hash::merge($this->clause('where'), $conditions) : $conditions; + if ($overwrite) { + $this->_parts['where'] = $conditions; + + return $this; + } + if (count($conditions) === 0) { + return $this; + } + + if (($this->isConditionSet($this->_parts['where'])) && ($this->isConditionSet($conditions))) { + $this->_parts['where'] = array_merge($this->_parts['where'], $conditions); + + return $this; + } + if ((!$this->isConditionSet($this->_parts['where'])) && (!$this->isConditionSet($conditions))) { + $this->_parts['where'] = Hash::merge($this->clause('where'), $conditions); + + return $this; + } + + $regularConditions = (!$this->isConditionSet($conditions)) ? $conditions : $this->_parts['where']; + $conditionSet = ($this->isConditionSet($this->_parts['where'])) ? $this->_parts['where'] : $conditions; + + $this->_parts['where'] = $this->mergeConditionsIntoSet($regularConditions, $conditionSet); return $this; } @@ -377,38 +430,195 @@ public function order($fields, $overwrite = false) } /** + * {@inheritDoc} + * * Populates or adds parts to current query clauses using an array. - * This is handy for passing all query clauses at once. + * This is handy for passing all query clauses at once. The option array accepts: * - * @param array $options the options to be applied + * - conditions: Maps to the where method + * - limit: Maps to the limit method + * - order: Maps to the order method + * - offset: Maps to the offset method + * - group: Maps to the group method + * - having: Maps to the having method + * - contain: Maps to the contain options for eager loading + * - page: Maps to the page method * - * @return $this This object + * ### Example: + * + * ``` + * $query->applyOptions([ + * 'fields' => ['id', 'name'], + * 'conditions' => [ + * 'created >=' => '2013-01-01' + * ], + * 'limit' => 10 + * ]); + * ``` + * + * Is equivalent to: + * + * ``` + * $query + * ->select(['id', 'name']) + * ->where(['created >=' => '2013-01-01']) + * ->limit(10) + * ``` */ public function applyOptions(array $options) { - if (isset($options['page'])) { - $this->page($options['page']); + $valid = [ + 'conditions' => 'where', + 'order' => 'order', + 'limit' => 'limit', + 'offset' => 'offset', + 'group' => 'group', + 'having' => 'having', + 'contain' => 'contain', + 'page' => 'page', + ]; - unset($options['page']); + ksort($options); + foreach ($options as $option => $values) { + if (isset($valid[$option], $values)) { + $this->{$valid[$option]}($values); + } else { + $this->_options[$option] = $values; + } } - if (isset($options['limit'])) { - $this->limit($options['limit']); - unset($options['limit']); - } - if (isset($options['order'])) { - $this->order($options['order']); + return $this; + } - unset($options['order']); + /** + * Sets the instance of the eager loader class to use for loading associations + * and storing containments. If called with no arguments, it will return the + * currently configured instance. + * + * @param \Muffin\Webservice\EagerLoader|null $instance The eager loader to use. Pass null + * to get the current eagerloader. + * @return \Muffin\Webservice\EagerLoader|$this + */ + public function eagerLoader(EagerLoader $instance = null) + { + if ($instance === null) { + if ($this->_eagerLoader === null) { + $this->_eagerLoader = new EagerLoader; + } + return $this->_eagerLoader; } - if (isset($options['conditions'])) { - $this->where($options['conditions']); + $this->_eagerLoader = $instance; + return $this; + } - unset($options['conditions']); + /** + * Sets the list of associations that should be eagerly loaded along with this + * query. The list of associated endpoints passed must have been previously set as + * associations using the Endpoint API. + * + * ### Example: + * + * ``` + * // Bring articles' author information + * $query->contain('Author'); + * + * // Also bring the category and tags associated to each article + * $query->contain(['Category', 'Tag']); + * ``` + * + * Associations can be arbitrarily nested using dot notation or nested arrays, + * this allows this object to calculate joins or any additional queries that + * must be executed to bring the required associated data. + * + * ### Example: + * + * ``` + * // Eager load the product info, and for each product load other 2 associations + * $query->contain(['Product' => ['Manufacturer', 'Distributor']); + * + * // Which is equivalent to calling + * $query->contain(['Products.Manufactures', 'Products.Distributors']); + * + * // For an author query, load his region, state and country + * $query->contain('Regions.States.Countries'); + * ``` + * + * It is possible to control the conditions and fields selected for each of the + * contained associations: + * + * ### Example: + * + * ``` + * $query->contain(['Tags' => function ($q) { + * return $q->where(['Tags.is_popular' => true]); + * }]); + * + * $query->contain(['Products.Manufactures' => function ($q) { + * return $q->select(['name'])->where(['Manufactures.active' => true]); + * }]); + * ``` + * + * Each association might define special options when eager loaded, the allowed + * options that can be set per association are: + * + * - foreignKey: Used to set a different field to match both endpoints, if set to false + * no join conditions will be generated automatically. `false` can only be used on + * joinable associations and cannot be used with hasMany or belongsToMany associations. + * - fields: An array with the fields that should be fetched from the association + * - queryBuilder: Equivalent to passing a callable instead of an options array + * + * ### Example: + * + * ``` + * // Set options for the hasMany articles that will be eagerly loaded for an author + * $query->contain([ + * 'Articles' => [ + * 'fields' => ['title', 'author_id'] + * ] + * ]); + * ``` + * + * When containing associations, it is important to include foreign key columns. + * Failing to do so will trigger exceptions. + * + * ``` + * // Use special join conditions for getting an Articles's belongsTo 'authors' + * $query->contain([ + * 'Authors' => [ + * 'foreignKey' => false, + * 'queryBuilder' => function ($q) { + * return $q->where(...); // Add full filtering conditions + * } + * ] + * ]); + * ``` + * + * If called with no arguments, this function will return an array with + * with the list of previously configured associations to be contained in the + * result. + * + * If called with an empty first argument and $override is set to true, the + * previous list will be emptied. + * + * @param array|string|null $associations list of endpoint aliases to be queried + * @param bool $override whether override previous list with the one passed + * defaults to merging previous list with the new one. + * @return array|$this + */ + public function contain($associations = null, $override = false) + { + $loader = $this->eagerLoader(); + if ($override === true) { + $loader->clearContain(); + $this->_dirty(); } - $this->_options = Hash::merge($this->_options, $options); + if ($associations === null) { + return $loader->contain(); + } + $result = $loader->contain($associations); +// $this->_addAssociationsToTypeMap($this->repository(), $this->typeMap(), $result); return $this; } @@ -451,6 +661,26 @@ public function first() return $this->all()->first(); } + /** + * Toggle hydrating entities. + * + * If set to false array results will be returned + * + * @param bool|null $enable Use a boolean to set the hydration mode. + * Null will fetch the current hydration mode. + * @return bool|$this A boolean when reading, and $this when setting the mode. + */ + public function hydrate($enable = null) + { + if ($enable === null) { + return $this->_hydrate; + } + + $this->_dirty(); + $this->_hydrate = (bool)$enable; + return $this; + } + /** * Trigger the beforeFind event on the query's repository object. * @@ -481,6 +711,24 @@ public function execute() return $this->_execute(); } + public function isConditionSet($conditions) + { + if (count($conditions) === 0) { + return false; + } + + return array_keys($conditions) === range(0, count($conditions) - 1); + } + + public function mergeConditionsIntoSet($regularConditions, $set) + { + foreach ($set as &$conditions) { + $conditions = Hash::merge($conditions, $regularConditions); + } + + return $set; + } + /** * Executes this query and returns a traversable object containing the results * @@ -493,7 +741,18 @@ protected function _execute() $decorator = $this->_decoratorClass(); return new $decorator($this->__resultSet); } - return $this->__resultSet = $this->_webservice->execute($this); + + $start = microtime(true); + $result = $this->_webservice->execute($this); + if (!$result instanceof WebserviceResultSetInterface) { + return $result; + } + + QueryLog::log(clone $this, (microtime(true) - $start) * 1000, $result->total()); + + $resultSet = $this->eagerLoader()->loadExternal($this, $result); + + return $this->__resultSet = new ResultSet($this, $resultSet, $resultSet->total()); } /** @@ -503,10 +762,14 @@ protected function _execute() */ public function __debugInfo() { + $eagerLoader = $this->eagerLoader(); return [ '(help)' => 'This is a Query object, to get the results execute or iterate it.', 'action' => $this->action(), 'formatters' => $this->_formatters, + 'mapReducers' => count($this->_mapReduce), + 'contain' => $eagerLoader ? $eagerLoader->contain() : [], + 'matching' => $eagerLoader ? $eagerLoader->matching() : [], 'offset' => $this->clause('offset'), 'page' => $this->page(), 'limit' => $this->limit(), @@ -515,7 +778,7 @@ public function __debugInfo() 'extraOptions' => $this->getOptions(), 'conditions' => $this->where(), 'repository' => $this->endpoint(), - 'webservice' => $this->webservice() + 'webservice' => $this->webservice(), ]; } } diff --git a/src/QueryLog.php b/src/QueryLog.php new file mode 100644 index 0000000..978bf8e --- /dev/null +++ b/src/QueryLog.php @@ -0,0 +1,51 @@ + $query->action(), + 'alias' => $query->endpoint()->alias(), + 'where' => $query->where(), + 'offset' => $query->clause('offset'), + 'page' => $query->clause('page'), + 'limit' => $query->clause('limit'), + 'sort' => $query->clause('sort'), + 'options' => $query->getOptions(), + 'endpoint' => App::shortName(get_class($query->endpoint()), 'Model/Endpoint', 'Endpoint'), + 'webservice' => App::shortName(get_class($query->webservice()), 'Webservice', 'Webservice'), + 'took' => round($took, 4), + 'results' => $results + ]; + } + + /** + * Return the logged queries. + * + * @return array An array of queries. + */ + public static function queries() + { + return static::$_queries; + } +} diff --git a/src/ResultSet.php b/src/ResultSet.php index b5bb367..c200bbf 100644 --- a/src/ResultSet.php +++ b/src/ResultSet.php @@ -2,14 +2,27 @@ namespace Muffin\Webservice; +use Cake\Collection\Collection; use Cake\Collection\CollectionTrait; +use Cake\Core\Exception\Exception; +use Cake\Datasource\QueryInterface; use Cake\Datasource\ResultSetInterface; +use Cake\Utility\Hash; +use Iterator; +use SplFixedArray; class ResultSet implements ResultSetInterface { use CollectionTrait; + /** + * Feed with the results + * + * @var Iterator + */ + protected $_feed; + /** * Points to the next record number that should be fetched * @@ -18,12 +31,34 @@ class ResultSet implements ResultSetInterface protected $_index = 0; /** - * Last record fetched from the statement + * Last record fetched from the feed. * * @var array */ protected $_current; + /** + * Default repository instance. + * + * @var \Cake\Datasource\RepositoryInterface + */ + protected $_defaultRepository; + + /** + * The default repository alias + * + * @var string + */ + protected $_defaultAlias; + + /** + * List of matching associations and the column keys to expect + * from each of them. + * + * @var array + */ + protected $_matchingMapColumns = []; + /** * Results that have been fetched or hydrated into the results. * @@ -31,18 +66,61 @@ class ResultSet implements ResultSetInterface */ protected $_results = []; + /** + * Whether to hydrate results into objects or not + * + * @var bool + */ + protected $_hydrate = true; + + /** + * The fully namespaced name of the class to use for hydrating results + * + * @var string + */ + protected $_resourceClass; + + /** + * Whether or not to buffer results fetched from the statement + * + * @var bool + */ + protected $_useBuffering = false; + + /** + * Holds the count of records in this result set + * + * @var int + */ + protected $_count; + protected $_total; /** * Construct the ResultSet * - * @param array $resources The resources to attach + * @param Query $query The query to use in the ResultSet. + * @param \Muffin\Webservice\Model\Resource[]|\Iterator $resources The resources to attach * @param int|null $total The total amount of resources available */ - public function __construct(array $resources, $total = null) + public function __construct(Query $query, Iterator $resources, $total = null) { - $this->_results = \SplFixedArray::fromArray($resources, false); + $this->_feed = $resources; $this->_total = $total; + + $repository = $query->repository(); + $this->_defaultRepository = $query->repository(); + $this->_calculateAssociationMap($query); + $this->_hydrate = $query->hydrate(); + $this->_resourceClass = $repository->resourceClass(); + $this->_useBuffering = $this->_feed instanceof \Countable; + $this->_defaultAlias = $this->_defaultRepository->alias(); + $this->_calculateColumnMap($query); + + if ($this->_useBuffering) { + $count = $this->count(); + $this->_results = new SplFixedArray($count); + } } /** @@ -53,11 +131,140 @@ public function current() return $this->_current; } + /** + * Helper function to fetch the next result from the statement or + * seeded results. + * + * @return mixed + */ + protected function _fetchResult() + { + $groupResult = $this->_groupResult($this->_feed->current()); + + $this->_feed->next(); + + return $groupResult; + } + + /** + * Correctly nests results keys including those coming from associations + * + * @param mixed $row Array containing columns and values or false if there is no results + * @return array Results + */ + protected function _groupResult($row) + { + $defaultAlias = $this->_defaultAlias; + $results = $presentAliases = []; + $options = [ + 'useSetters' => false, + 'markClean' => true, + 'markNew' => false, + 'guard' => false + ]; + + foreach ($this->_matchingMapColumns as $alias => $keys) { + $matching = $this->_matchingMap[$alias]; + $results['_matchingData'][$alias] = array_combine( + $keys, + array_intersect_key($row, $keys) + ); + if ($this->_hydrate) { + $options['source'] = $matching['instance']->registryAlias(); + $entity = new $matching['entityClass']($results['_matchingData'][$alias], $options); + $entity->clean(); + $results['_matchingData'][$alias] = $entity; + } + } + + foreach ($this->_map as $endpoint => $keys) { + $results[$endpoint] = array_combine($keys, array_intersect_key($row, $keys)); + $presentAliases[$endpoint] = true; + } + + unset($presentAliases[$defaultAlias]); + + foreach ($this->_containMap as $assoc) { + $alias = $assoc['nestKey']; + + if ($assoc['canBeJoined'] && empty($this->_map[$alias])) { + continue; + } + + $instance = $assoc['instance']; + + if (!$assoc['canBeJoined'] && !isset($row[$alias])) { + $results = $instance->defaultRowValue($results, $assoc['canBeJoined']); + continue; + } + + if (!$assoc['canBeJoined']) { + $results[$alias] = $row[$alias]; + } + + $target = $instance->target(); + $options['source'] = $target->registryAlias(); + unset($presentAliases[$alias]); + + if ($assoc['canBeJoined']) { + $hasData = false; + foreach ($results[$alias] as $v) { + if ($v !== null && $v !== []) { + $hasData = true; + break; + } + } + + if (!$hasData) { + $results[$alias] = null; + } + } + + if ($this->_hydrate && $results[$alias] !== null && $assoc['canBeJoined']) { + $entity = new $assoc['resourceClass']($results[$alias], $options); + $entity->clean(); + $results[$alias] = $entity; + } + + $results = $instance->transformRow($results, $alias, $assoc['canBeJoined']); + } + + foreach ($presentAliases as $alias => $present) { + if (!isset($results[$alias])) { + continue; + } + $results[$defaultAlias][$alias] = $results[$alias]; + } + + if (isset($results['_matchingData'])) { + $results[$defaultAlias]['_matchingData'] = $results['_matchingData']; + } + + $options['source'] = $this->_defaultRepository->registryAlias(); + if (isset($results[$defaultAlias])) { + $results = $results[$defaultAlias]; + } + if ($this->_hydrate && !($results instanceof EntityInterface)) { + $results = new $this->_resourceClass($results, $options); + } + + return $results; + } + /** * {@inheritDoc} */ public function rewind() { + if ($this->_index == 0) { + return; + } + + if (!$this->_useBuffering) { + $msg = 'You cannot rewind an un-buffered ResultSet. Use Query::bufferResults() to get a buffered ResultSet.'; + throw new Exception($msg); + } + $this->_index = 0; } @@ -77,13 +284,39 @@ public function serialize() */ public function valid() { - if (!isset($this->_results[$this->key()])) { - return false; + if ($this->_useBuffering) { + $valid = $this->_index < $this->_count; + if ($valid && $this->_results[$this->_index] !== null) { + $this->_current = $this->_results[$this->_index]; + return true; + } + if (!$valid) { + return $valid; + } } - $this->_current = $this->_results[$this->key()]; + $this->_current = $this->_fetchResult(); + $valid = $this->_current !== false; + + if ($valid && $this->_useBuffering) { + $this->_results[$this->_index] = $this->_current; + } - return true; + return $valid; + } + + /** + * Get the first record from a result set. + * + * This method will also close the underlying statement cursor. + * + * @return array|object + */ + public function first() + { + foreach ($this as $result) { + return $result; + } } /** @@ -108,6 +341,8 @@ public function next() public function unserialize($serialized) { $this->_results = unserialize($serialized); + $this->_useBuffering = true; + $this->_count = count($this->_results); } /** @@ -115,7 +350,71 @@ public function unserialize($serialized) */ public function count() { - return count($this->_results); + if ($this->_count !== null) { + return $this->_count; + } + + return $this->_count = $this->_feed->count(); + } + + /** + * Calculates the list of associations that should get eager loaded + * when fetching each record + * + * @param \Cake\Datasource\QueryInterface $query The query from where to derive the associations + * @return void + */ + protected function _calculateAssociationMap($query) + { + $map = $query->eagerLoader()->associationsMap($this->_defaultRepository); + $this->_matchingMap = (new Collection($map)) + ->match(['matching' => true]) + ->indexBy('alias') + ->toArray(); + + $this->_containMap = (new Collection(array_reverse($map))) + ->match(['matching' => false]) + ->indexBy('nestKey') + ->toArray(); + } + + /** + * Creates a map of row keys out of the query select clause that can be + * used to hydrate nested result sets more quickly. + * + * @param \Muffin\Webservice\Query $query The query from where to derive the column map + * @return void + */ + protected function _calculateColumnMap($query) + { + $map = []; + + /* @var \Cake\Datasource\RepositoryInterface[] $repositories */ + $repositories = [ + $this->_defaultAlias => $query->repository() + ]; + foreach ($this->_containMap as $alias => $option) { + if (!$option['canBeJoined']) { + continue; + } + + $repositories[$alias] = $option['instance']->target(); + } + foreach ($repositories as $alias => $repository) { + foreach ($repository->schema()->columns() as $column) { + $map[$alias][$alias . '__' . $column] = $column; + } + } + + foreach ($this->_matchingMap as $alias => $assoc) { + if (!isset($map[$alias])) { + continue; + } + $this->_matchingMapColumns[$alias] = $map[$alias]; + unset($map[$alias]); + } + + $this->_map = $map; } /** diff --git a/src/Schema.php b/src/Schema.php index c3fcacd..2b67dc2 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -3,7 +3,7 @@ namespace Muffin\Webservice; /** - * Represents a single endpoint in a database schema. + * Represents a single endpoint in a webservice. * * Can either be populated using the reflection API's * or by incrementally building an instance using @@ -69,6 +69,7 @@ class Schema */ protected static $_columnKeys = [ 'type' => null, + 'primaryKey' => null, 'baseType' => null, 'length' => null, 'precision' => null, @@ -147,7 +148,7 @@ public function name() * This is only present/valid for integer, decimal, float columns. * * In addition to the above keys, the following keys are - * implemented in some database dialects, but not all: + * implemented in some webservice dialects, but not all: * * - `comment` The comment for the column. * @@ -236,9 +237,6 @@ public function baseColumnType($column) return null; } - if (Type::map($type)) { - $type = Type::build($type)->getBaseType(); - } return $this->_columns[$column]['baseType'] = $type; } @@ -306,7 +304,7 @@ public function primaryKey() $primaryKeys[] = $name; } - return []; + return $primaryKeys; } /** diff --git a/src/StreamingWebserviceResultSet.php b/src/StreamingWebserviceResultSet.php new file mode 100644 index 0000000..15e8df2 --- /dev/null +++ b/src/StreamingWebserviceResultSet.php @@ -0,0 +1,79 @@ +generator = $generator; + } + + /** + * {@inheritDoc} + */ + public function current() + { + return $this->generator->current(); + } + + /** + * {@inheritDoc} + */ + public function next() + { + $this->generator->next(); + } + + /** + * {@inheritDoc} + */ + public function key() + { + return $this->generator->key(); + } + + /** + * {@inheritDoc} + */ + public function valid() + { + return $this->generator->valid(); + } + + /** + * {@inheritDoc} + */ + public function rewind() + { + $this->generator->rewind(); + } + + /** + * {@inheritDoc} + */ + public function getInnerIterator() + { + return $this->generator; + } + + /** + * There's no total when using a streaming webservice. + * + * @return null Return null. + */ + public function total() + { + return null; + } +} diff --git a/src/Template/Element/webservice_queries_panel.ctp b/src/Template/Element/webservice_queries_panel.ctp new file mode 100644 index 0000000..5bd52e8 --- /dev/null +++ b/src/Template/Element/webservice_queries_panel.ctp @@ -0,0 +1,43 @@ + __d('muffin/webservice', 'Create'), + \Muffin\Webservice\Query::ACTION_READ => __d('muffin/webservice', 'Read'), + \Muffin\Webservice\Query::ACTION_UPDATE => __d('muffin/webservice', 'Update'), + \Muffin\Webservice\Query::ACTION_DELETE => __d('muffin/webservice', 'Delete'), +]; +?> + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Toolbar->makeNeatArray([ + 'conditions' => $query['where'], + 'options' => $query['options'], + 'offset' => $query['offset'], + 'page' => $query['page'], + 'limit' => $query['limit'], + 'sort' => $query['sort'], + ]) ?>
diff --git a/src/TestSuite/TestFixture.php b/src/TestSuite/TestFixture.php new file mode 100644 index 0000000..904b58b --- /dev/null +++ b/src/TestSuite/TestFixture.php @@ -0,0 +1,333 @@ +connection)) { + $connection = $this->connection; + if (strpos($connection, 'test') !== 0) { + $message = sprintf( + 'Invalid datasource name "%s" for "%s" fixture. Fixture datasource names must begin with "test".', + $connection, + $this->table + ); + throw new CakeException($message); + } + } + $this->init(); + } + + /** + * {@inheritDoc} + */ + public function connection() + { + return $this->connection; + } + + /** + * {@inheritDoc} + */ + public function sourceName() + { + return $this->table; + } + + /** + * Initialize the fixture. + * + * @return void + * @throws \Cake\ORM\Exception\MissingTableClassException When importing from a table that does not exist. + */ + public function init() + { + if ($this->table === null) { + $this->table = $this->_endpointFromClass(); + } + + if (empty($this->import) && !empty($this->fields)) { + $this->_schemaFromFields(); + } + + if (!empty($this->import)) { + $this->_schemaFromImport(); + } + + if (empty($this->import) && empty($this->fields)) { + $this->_schemaFromReflection(); + } + } + + /** + * Returns the endpoint name using the fixture class + * + * @return string + */ + protected function _endpointFromClass() + { + list(, $class) = namespaceSplit(get_class($this)); + preg_match('/^(.*)Fixture$/', $class, $matches); + $endpoint = $class; + + if (isset($matches[1])) { + $endpoint = $matches[1]; + } + + return Inflector::tableize($endpoint); + } + + /** + * Build the fixtures table schema from the fields property. + * + * @return void + */ + protected function _schemaFromFields() + { + $this->_schema = new Schema($this->table); + foreach ($this->fields as $field => $data) { + if ($field === '_options') { + continue; + } + $this->_schema->addColumn($field, $data); + } + if (!empty($this->fields['_options'])) { + $this->_schema->options($this->fields['_options']); + } + } + + /** + * Build fixture schema from a table in another datasource. + * + * @return void + * @throws \Cake\Core\Exception\Exception when trying to import from an empty table. + */ + protected function _schemaFromImport() + { + if (!is_array($this->import)) { + return; + } + $import = $this->import + ['connection' => 'default', 'endpoint' => null, 'model' => null]; + + if (!empty($import['model'])) { + if (!empty($import['endpoint'])) { + throw new CakeException('You cannot define both table and model.'); + } + $import['endpoint'] = EndpointRegistry::get($import['model'])->endpoint(); + } + + if (empty($import['endpoint'])) { + throw new CakeException('Cannot import from undefined endpoint.'); + } + + $this->table = $import['endpoint']; + + $connection = ConnectionManager::get($import['connection'], false); + $schema = $connection->webservice($import['endpoint'])->describe($import['endpoint']); + $this->_schema = $schema; + } + + /** + * Build fixture schema directly from the datasource + * + * @return void + * @throws \Cake\Core\Exception\Exception when trying to reflect a table that does not exist + */ + protected function _schemaFromReflection() + { + $connection = ConnectionManager::get($this->connection()); + + $this->_schema = $connection->webservice($this->table)->describe($this->table); + } + + /** + * Get/Set the \Muffin\Webservice\Schema instance used by this fixture. + * + * @param \Muffin\Webservice\Schema|null $schema The schema to set. + * @return \Muffin\Webservice\Schema|null + */ + public function schema(Schema $schema = null) + { + if ($schema) { + $this->_schema = $schema; + return null; + } + return $this->_schema; + } + + /** + * {@inheritDoc} + */ + public function create(ConnectionInterface $db) + { + return true; + } + + /** + * {@inheritDoc} + */ + public function drop(ConnectionInterface $db) + { + return true; + } + + /** + * {@inheritDoc} + */ + public function insert(ConnectionInterface $db) + { + if (isset($this->records) && !empty($this->records)) { + $values = $this->_getRecords(); + + $endpoint = new Endpoint([ + 'connection' => $db, + 'alias' => Inflector::camelize($this->table) + ]); + + foreach ($values as $record) { + $resource = $endpoint->newEntity($record); + + if (!$endpoint->save($resource)) { + debug($resource); + return false; + } + } + } + + return true; + } + + /** + * {@inheritDoc} + */ + public function createConstraints(ConnectionInterface $db) + { + return true; + } + + /** + * {@inheritDoc} + */ + public function dropConstraints(ConnectionInterface $db) + { + return true; + } + + /** + * Converts the internal records into data used to generate a query. + * + * @return array + */ + protected function _getRecords() + { + $fields = $values = $types = []; + $columns = $this->_schema->columns(); + foreach ($this->records as $record) { + $fields = array_merge($fields, array_intersect(array_keys($record), $columns)); + } + $fields = array_values(array_unique($fields)); + foreach ($fields as $field) { + $types[$field] = $this->_schema->column($field)['type']; + } + $default = array_fill_keys($fields, null); + foreach ($this->records as $record) { + $values[] = array_merge($default, $record); + } + return $values; + } + + /** + * {@inheritDoc} + */ + public function truncate(ConnectionInterface $db) + { + $endpoint = new Endpoint([ + 'connection' => $db, + 'alias' => Inflector::camelize($this->table) + ]); + + $endpoint->deleteAll([]); + + return true; + } +} diff --git a/src/Type.php b/src/Type.php new file mode 100644 index 0000000..e1c4e2c --- /dev/null +++ b/src/Type.php @@ -0,0 +1,309 @@ + 'Cake\Database\Type\IntegerType', + 'binary' => 'Cake\Database\Type\BinaryType', + 'boolean' => 'Cake\Database\Type\BoolType', + 'date' => 'Cake\Database\Type\DateType', + 'datetime' => 'Cake\Database\Type\DateTimeType', + 'decimal' => 'Cake\Database\Type\FloatType', + 'float' => 'Cake\Database\Type\FloatType', + 'integer' => 'Cake\Database\Type\IntegerType', + 'json' => 'Cake\Database\Type\JsonType', + 'string' => 'Cake\Database\Type\StringType', + 'text' => 'Cake\Database\Type\StringType', + 'time' => 'Cake\Database\Type\TimeType', + 'timestamp' => 'Cake\Database\Type\DateTimeType', + 'uuid' => 'Cake\Database\Type\UuidType', + ]; + + /** + * List of basic type mappings, used to avoid having to instantiate a class + * for doing conversion on these + * + * @var array + * @deprecated 3.1 All types will now use a specific class + */ + protected static $_basicTypes = [ + 'string' => ['callback' => ['\Cake\Database\Type', 'strval']], + 'text' => ['callback' => ['\Cake\Database\Type', 'strval']], + 'boolean' => [ + 'callback' => ['\Cake\Database\Type', 'boolval'], + 'pdo' => PDO::PARAM_BOOL + ], + ]; + + /** + * Contains a map of type object instances to be reused if needed + * + * @var array + */ + protected static $_builtTypes = []; + + /** + * Identifier name for this type + * + * @var string + */ + protected $_name = null; + + /** + * Constructor + * + * @param string|null $name The name identifying this type + */ + public function __construct($name = null) + { + $this->_name = $name; + } + + /** + * Returns a Type object capable of converting a type identified by $name + * + * @param string $name type identifier + * @throws \InvalidArgumentException If type identifier is unknown + * @return \Cake\Database\Type + */ + public static function build($name) + { + if (isset(static::$_builtTypes[$name])) { + return static::$_builtTypes[$name]; + } + if (!isset(static::$_types[$name])) { + throw new InvalidArgumentException(sprintf('Unknown type "%s"', $name)); + } + if (is_string(static::$_types[$name])) { + return static::$_builtTypes[$name] = new static::$_types[$name]($name); + } + + return static::$_builtTypes[$name] = static::$_types[$name]; + } + + /** + * Returns an arrays with all the mapped type objects, indexed by name + * + * @return array + */ + public static function buildAll() + { + $result = []; + foreach (self::$_types as $name => $type) { + $result[$name] = isset(static::$_builtTypes[$name]) ? static::$_builtTypes[$name] : static::build($name); + } + return $result; + } + + /** + * Returns a Type object capable of converting a type identified by $name + * + * @param string $name The type identifier you want to set. + * @param \Cake\Database\Type $instance The type instance you want to set. + * @return void + */ + public static function set($name, Type $instance) + { + static::$_builtTypes[$name] = $instance; + } + + /** + * Registers a new type identifier and maps it to a fully namespaced classname, + * If called with no arguments it will return current types map array + * If $className is omitted it will return mapped class for $type + * + * @param string|array|\Cake\Database\Type|null $type if string name of type to map, if array list of arrays to be mapped + * @param string|null $className The classname to register. + * @return array|string|null if $type is null then array with current map, if $className is null string + * configured class name for give $type, null otherwise + */ + public static function map($type = null, $className = null) + { + if ($type === null) { + return self::$_types; + } + if (is_array($type)) { + self::$_types = $type; + return null; + } + if ($className === null) { + return isset(self::$_types[$type]) ? self::$_types[$type] : null; + } + self::$_types[$type] = $className; + } + + /** + * Clears out all created instances and mapped types classes, useful for testing + * + * @return void + */ + public static function clear() + { + self::$_types = []; + self::$_builtTypes = []; + } + + /** + * Returns type identifier name for this object + * + * @return string + */ + public function getName() + { + return $this->_name; + } + + /** + * Returns the base type name that this class is inheriting. + * This is useful when extending base type for adding extra functionality + * but still want the rest of the framework to use the same assumptions it would + * do about the base type it inherits from. + * + * @return string + */ + public function getBaseType() + { + return $this->_name; + } + + /** + * Casts given value from a PHP type to one acceptable by database + * + * @param mixed $value value to be converted to database equivalent + * @param \Cake\Database\Driver $driver object from which database preferences and configuration will be extracted + * @return mixed + */ + public function toDatabase($value, Driver $driver) + { + return $this->_basicTypeCast($value); + } + + /** + * Casts given value from a database type to PHP equivalent + * + * @param mixed $value value to be converted to PHP equivalent + * @param \Cake\Database\Driver $driver object from which database preferences and configuration will be extracted + * @return mixed + */ + public function toPHP($value, Driver $driver) + { + return $this->_basicTypeCast($value); + } + + /** + * Checks whether this type is a basic one and can be converted using a callback + * If it is, returns converted value + * + * @param mixed $value value to be converted to PHP equivalent + * @return mixed + * @deprecated 3.1 All types should now be a specific class + */ + protected function _basicTypeCast($value) + { + if ($value === null) { + return null; + } + if (!empty(self::$_basicTypes[$this->_name])) { + $typeInfo = self::$_basicTypes[$this->_name]; + if (isset($typeInfo['callback'])) { + return $typeInfo['callback']($value); + } + } + return $value; + } + + /** + * Casts give value to Statement equivalent + * + * @param mixed $value value to be converted to PHP equivalent + * @param \Cake\Database\Driver $driver object from which database preferences and configuration will be extracted + * @return mixed + */ + public function toStatement($value, Driver $driver) + { + if ($value === null) { + return PDO::PARAM_NULL; + } + + return PDO::PARAM_STR; + } + + /** + * Type converter for boolean values. + * + * Will convert string true/false into booleans. + * + * @param mixed $value The value to convert to a boolean. + * @return bool + * @deprecated 3.1.8 This method is now unused. + */ + public static function boolval($value) + { + if (is_string($value) && !is_numeric($value)) { + return strtolower($value) === 'true' ? true : false; + } + return !empty($value); + } + + /** + * Type converter for string values. + * + * Will convert values into strings + * + * @param mixed $value The value to convert to a string. + * @return bool + * @deprecated 3.1.8 This method is now unused. + */ + public static function strval($value) + { + if (is_array($value)) { + $value = ''; + } + return strval($value); + } + + /** + * Generate a new primary key value for a given type. + * + * This method can be used by types to create new primary key values + * when entities are inserted. + * + * @return mixed A new primary key value. + * @see \Cake\Database\Type\UuidType + */ + public function newId() + { + return null; + } + + /** + * Marshalls flat data into PHP objects. + * + * Most useful for converting request data into PHP objects + * that make sense for the rest of the ORM/Database layers. + * + * @param mixed $value The value to convert. + * @return mixed Converted value. + */ + public function marshal($value) + { + return $this->_basicTypeCast($value); + } +} diff --git a/src/Webservice/Webservice.php b/src/Webservice/Webservice.php index 96a52f7..59825e1 100644 --- a/src/Webservice/Webservice.php +++ b/src/Webservice/Webservice.php @@ -2,6 +2,7 @@ namespace Muffin\Webservice\Webservice; +use Cake\Collection\Collection; use Cake\Core\App; use Cake\Core\Configure; use Cake\Datasource\ConnectionInterface; @@ -146,8 +147,6 @@ public function nestedResource(array $conditions) */ public function execute(Query $query, array $options = []) { - $result = $this->_executeQuery($query, $options); - if ($this->driver() === null) { throw new \UnexpectedValueException(__('No driver has been defined')); } @@ -157,6 +156,8 @@ public function execute(Query $query, array $options = []) $this->_logQuery($query, $this->driver()->logger()); } + $result = $this->_executeQuery($query, $options); + return $result; } @@ -201,7 +202,7 @@ protected function _executeQuery(Query $query, array $options = []) return $this->_executeDeleteQuery($query, $options); } - return false; + throw new \RuntimeException('No query action has been defined'); } /** @@ -269,19 +270,68 @@ protected function _executeDeleteQuery(Query $query, array $options = []) } /** - * Creates a resource with the given class and properties - * - * @param string $resourceClass The class to use to create the resource - * @param array $properties The properties to apply + * Creates a result set compatible result. * - * @return \Muffin\Webservice\Model\Resource + * @param Query $query The query to use. + * @param array $data The data to use when creating a result. + * @return array A ResultSet compatible result. */ - protected function _createResource($resourceClass, array $properties = []) + protected function _createResult(Query $query, array $data) { - return new $resourceClass($properties, [ - 'markClean' => true, - 'markNew' => false, - ]); + $map = $query->eagerLoader()->associationsMap($query->endpoint()); + $joinedAssociations = collection(array_reverse($map)) + ->match(['canBeJoined' => true]) + ->indexBy('alias') + ->toArray(); + foreach ($joinedAssociations as $alias => $association) { + /* @var \Muffin\Webservice\Association $associationInstance */ + $associationInstance = $association['instance']; + /* @var \Muffin\Webservice\Schema $schema */ + $schema = $associationInstance->target()->schema(); + if (isset($data[$alias])) { + continue; + } + + foreach ($schema->columns() as $column) { + $data[$alias][$column] = null; + } + } + + $alias = $query->endpoint()->alias(); + $schema = $query->endpoint()->schema(); + $defaults = $schema->defaultValues(); + foreach ($schema->columns() as $column) { + if (isset($data[$alias][$column])) { + continue; + } + + $data[$alias][$column] = array_key_exists($column, $defaults) ? $defaults[$column] : null; + } + + foreach ($joinedAssociations as $alias => $association) { + /* @var \Muffin\Webservice\Association $instance */ + $associationInstance = $association['instance']; + /* @var \Muffin\Webservice\Schema $schema */ + $associationSchema = $associationInstance->target()->schema(); + + $associationDefaults = $associationSchema->defaultValues(); + foreach ($associationSchema->columns() as $column) { + if (isset($data[$alias][$column])) { + continue; + } + + $data[$alias][$column] = array_key_exists($column, $associationDefaults) ? $associationDefaults[$column] : null; + } + } + + $flattened = []; + foreach ($data as $alias => $values) { + foreach ($values as $key => $value) { + $flattened[$alias . '__' . $key] = $value; + } + } + + return $flattened; } /** @@ -306,16 +356,16 @@ protected function _logQuery(Query $query, LoggerInterface $logger) /** * Loops through the results and turns them into resource objects * - * @param \Muffin\Webservice\Model\Endpoint $endpoint The endpoint class to use + * @param \Muffin\Webservice\Query $query The query class to use * @param array $results Array of results from the API * - * @return \Muffin\Webservice\Model\Resource[] Array of resource objects + * @return array Array of resources */ - protected function _transformResults(Endpoint $endpoint, array $results) + protected function _transformResults(Query $query, array $results) { $resources = []; foreach ($results as $result) { - $resources[] = $this->_transformResource($endpoint, $result); + $resources[] = $this->_transformResource($query, $result); } return $resources; @@ -324,20 +374,20 @@ protected function _transformResults(Endpoint $endpoint, array $results) /** * Turns a single result into a resource * - * @param \Muffin\Webservice\Model\Endpoint $endpoint The endpoint class to use + * @param \Muffin\Webservice\Query $query The query class to use * @param array $result The API result * - * @return \Muffin\Webservice\Model\Resource + * @return array */ - protected function _transformResource(Endpoint $endpoint, array $result) + protected function _transformResource(Query $query, array $result) { $properties = []; foreach ($result as $property => $value) { - $properties[$property] = $value; + $properties[$query->endpoint()->alias()][$property] = $value; } - return $this->_createResource($endpoint->resourceClass(), $properties); + return $this->_createResult($query, $properties); } /** diff --git a/src/Webservice/WebserviceInterface.php b/src/Webservice/WebserviceInterface.php index 16aed85..a56a98e 100644 --- a/src/Webservice/WebserviceInterface.php +++ b/src/Webservice/WebserviceInterface.php @@ -18,7 +18,7 @@ interface WebserviceInterface * @param Query $query The query to execute * @param array $options The options to use * - * @return \Muffin\Webservice\ResultSet|int|bool + * @return \Muffin\Webservice\WebserviceResultSetInterface|int|bool */ public function execute(Query $query, array $options = []); diff --git a/src/WebserviceResultSet.php b/src/WebserviceResultSet.php new file mode 100644 index 0000000..ebd0e02 --- /dev/null +++ b/src/WebserviceResultSet.php @@ -0,0 +1,56 @@ +total = $total; + } + + /** + * {@inheritDoc} + */ + public function total() + { + return $this->total; + } + + /** + * Return a result set with a single resource. + * + * @param array $resource Resource to include. + * @return WebserviceResultSet A result set with a single resource. + */ + public static function createForSingleResource(array $resource) + { + return new WebserviceResultSet([$resource], 1); + } + + /** + * Return an empty result set. + * + * @return WebserviceResultSet An empty result set. + */ + public static function createEmpty() + { + return new WebserviceResultSet([], 0); + } +} diff --git a/src/WebserviceResultSetInterface.php b/src/WebserviceResultSetInterface.php new file mode 100644 index 0000000..4d6a453 --- /dev/null +++ b/src/WebserviceResultSetInterface.php @@ -0,0 +1,13 @@ + 1, 'author_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', 'published' => 'Y'], + ['id' => 2, 'author_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', 'published' => 'Y'], + ['id' => 3, 'author_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', 'published' => 'Y'] + ]; +} diff --git a/tests/Fixture/ArticlesTagsFixture.php b/tests/Fixture/ArticlesTagsFixture.php new file mode 100644 index 0000000..4a4114d --- /dev/null +++ b/tests/Fixture/ArticlesTagsFixture.php @@ -0,0 +1,24 @@ + 1, 'tag_id' => 1], + ['article_id' => 1, 'tag_id' => 2], + ['article_id' => 2, 'tag_id' => 1], + ['article_id' => 2, 'tag_id' => 3] + ]; +} diff --git a/tests/Fixture/AuthorsFixture.php b/tests/Fixture/AuthorsFixture.php new file mode 100644 index 0000000..c94089e --- /dev/null +++ b/tests/Fixture/AuthorsFixture.php @@ -0,0 +1,23 @@ + 1, 'name' => 'mariano'], + ['id' => 2, 'name' => 'nate'], + ['id' => 3, 'name' => 'larry'], + ['id' => 4, 'name' => 'garrett'], + ]; +} diff --git a/tests/Fixture/CommentsFixture.php b/tests/Fixture/CommentsFixture.php new file mode 100644 index 0000000..8208c35 --- /dev/null +++ b/tests/Fixture/CommentsFixture.php @@ -0,0 +1,26 @@ + 1, 'article_id' => 1, 'user_id' => 2, 'comment' => 'First Comment for First Article', 'published' => 'Y', 'created' => '2007-03-18 10:45:23', 'updated' => '2007-03-18 10:47:31'], + ['id' => 2, 'article_id' => 1, 'user_id' => 4, 'comment' => 'Second Comment for First Article', 'published' => 'Y', 'created' => '2007-03-18 10:47:23', 'updated' => '2007-03-18 10:49:31'], + ['id' => 3, 'article_id' => 1, 'user_id' => 1, 'comment' => 'Third Comment for First Article', 'published' => 'Y', 'created' => '2007-03-18 10:49:23', 'updated' => '2007-03-18 10:51:31'], + ['id' => 4, 'article_id' => 1, 'user_id' => 1, 'comment' => 'Fourth Comment for First Article', 'published' => 'N', 'created' => '2007-03-18 10:51:23', 'updated' => '2007-03-18 10:53:31'], + ['id' => 5, 'article_id' => 2, 'user_id' => 1, 'comment' => 'First Comment for Second Article', 'published' => 'Y', 'created' => '2007-03-18 10:53:23', 'updated' => '2007-03-18 10:55:31'], + ['id' => 6, 'article_id' => 2, 'user_id' => 2, 'comment' => 'Second Comment for Second Article', 'published' => 'Y', 'created' => '2007-03-18 10:55:23', 'updated' => '2007-03-18 10:57:31'] + ]; +} diff --git a/tests/Fixture/SpecialTagsFixture.php b/tests/Fixture/SpecialTagsFixture.php new file mode 100644 index 0000000..372cd6d --- /dev/null +++ b/tests/Fixture/SpecialTagsFixture.php @@ -0,0 +1,23 @@ + 1, 'tag_id' => 3, 'highlighted' => false, 'highlighted_time' => null, 'author_id' => 1], + ['article_id' => 2, 'tag_id' => 1, 'highlighted' => true, 'highlighted_time' => '2014-06-01 10:10:00', 'author_id' => 2], + ['article_id' => 10, 'tag_id' => 10, 'highlighted' => true, 'highlighted_time' => '2014-06-01 10:10:00', 'author_id' => null] + ]; +} diff --git a/tests/Fixture/TagsFixture.php b/tests/Fixture/TagsFixture.php new file mode 100644 index 0000000..019d1fa --- /dev/null +++ b/tests/Fixture/TagsFixture.php @@ -0,0 +1,24 @@ + 'tag1', 'description' => 'A big description'], + ['name' => 'tag2', 'description' => 'Another big description'], + ['name' => 'tag3', 'description' => 'Yet another one'] + ]; +} diff --git a/tests/Fixture/UsersFixture.php b/tests/Fixture/UsersFixture.php new file mode 100644 index 0000000..826fd6e --- /dev/null +++ b/tests/Fixture/UsersFixture.php @@ -0,0 +1,23 @@ + 1, + 'username' => 'user1', + ], + [ + 'id' => 2, + 'username' => 'user2', + ], + [ + 'id' => 3, + 'username' => 'user3', + ] + ]; +} diff --git a/tests/TestCase/Association/BelongsToTest.php b/tests/TestCase/Association/BelongsToTest.php new file mode 100644 index 0000000..dad9fd6 --- /dev/null +++ b/tests/TestCase/Association/BelongsToTest.php @@ -0,0 +1,311 @@ +company = EndpointRegistry::get('Companies', [ + 'schema' => [ + 'id' => ['type' => 'integer', 'primaryKey' => true], + 'company_name' => ['type' => 'string'], + ], + ]); + $this->client = EndpointRegistry::get('Clients', [ + 'schema' => [ + 'id' => ['type' => 'integer', 'primaryKey' => true], + 'client_name' => ['type' => 'string'], + 'company_id' => ['type' => 'integer'], + ], + ]); + $this->companiesTypeMap = new TypeMap([ + 'Companies.id' => 'integer', + 'id' => 'integer', + 'Companies.company_name' => 'string', + 'company_name' => 'string', + 'Companies__id' => 'integer', + 'Companies__company_name' => 'string' + ]); + } + + /** + * Tear down + * + * @return void + */ + public function tearDown() + { + parent::tearDown(); + EndpointRegistry::clear(); + } + + /** + * Test that foreignKey generation ignores database names in target table. + * + * @return void + */ + public function testForeignKey() + { + $this->company->endpoint('schema.companies'); + $this->client->endpoint('schema.clients'); + $assoc = new BelongsTo('Companies', [ + 'sourceRepository' => $this->client, + 'targetRepository' => $this->company, + ]); + $this->assertEquals('company_id', $assoc->foreignKey()); + } + + /** + * Tests that the alias set on associations is actually on the Resource + * + * @return void + */ + public function testCustomAlias() + { + $table = EndpointRegistry::get('Articles', [ + 'className' => 'TestPlugin.Articles', + ]); + $table->addAssociations([ + 'belongsTo' => [ + 'FooAuthors' => ['className' => 'TestPlugin.Authors', 'foreignKey' => 'author_id'] + ] + ]); + $article = $table->find()->contain(['FooAuthors'])->first(); + + $this->assertTrue(isset($article->foo_author)); + $this->assertEquals($article->foo_author->name, 'mariano'); + $this->assertNull($article->Authors); + } + + /** + * Tests that the correct join and fields are attached to a query depending on + * the association config + * + * @return void + */ + public function testAttachTo() + { + $config = [ + 'foreignKey' => 'company_id', + 'sourceRepository' => $this->client, + 'targetRepository' => $this->company, + 'conditions' => ['Companies.is_active' => true] + ]; + $association = new BelongsTo('Companies', $config); + $query = $this->client->query(); + $association->attachTo($query); + +// $this->assertEquals( +// 'integer', +// $query->typeMap()->type('Companies__id'), +// 'Associations should map types.' +// ); + } + + /** + * Tests that using belongsto with a table having a multi column primary + * key will work if the foreign key is passed + * + * @return void + */ + public function testAttachToMultiPrimaryKey() + { + $this->company->primaryKey(['id', 'tenant_id']); + $config = [ + 'foreignKey' => ['company_id', 'company_tenant_id'], + 'sourceRepository' => $this->client, + 'targetRepository' => $this->company, + 'conditions' => ['Companies.is_active' => true] + ]; + $association = new BelongsTo('Companies', $config); + $query = $this->client->query(); + $association->attachTo($query); + } + + /** + * Tests that using belongsto with a table having a multi column primary + * key will work if the foreign key is passed + * + * @expectedException \RuntimeException + * @expectedExceptionMessage Cannot match provided foreignKey for "Companies", got "(company_id)" but expected foreign key for "(id, tenant_id)" + * @return void + */ + public function testAttachToMultiPrimaryKeyMismatch() + { + $this->company->primaryKey(['id', 'tenant_id']); + $query = $this->client->query(); + $config = [ + 'foreignKey' => 'company_id', + 'sourceRepository' => $this->client, + 'targetRepository' => $this->company, + 'conditions' => ['Companies.is_active' => true] + ]; + $association = new BelongsTo('Companies', $config); + $association->attachTo($query); + } + + /** + * Test the cascading delete of BelongsTo. + * + * @return void + */ + public function testCascadeDelete() + { + $mock = $this->getMock('Cake\ORM\Table', [], [], '', false); + $config = [ + 'sourceRepository' => $this->client, + 'targetRepository' => $mock, + ]; + $mock->expects($this->never()) + ->method('find'); + $mock->expects($this->never()) + ->method('delete'); + + $association = new BelongsTo('Companies', $config); + $entity = new Resource(['company_name' => 'CakePHP', 'id' => 1]); + $this->assertTrue($association->cascadeDelete($entity)); + } + + /** + * Test that saveAssociated() ignores non entity values. + * + * @return void + */ + public function testSaveAssociatedOnlyEntities() + { + $mock = $this->getMock('Cake\ORM\Table', ['saveAssociated'], [], '', false); + $config = [ + 'sourceRepository' => $this->client, + 'targetRepository' => $mock, + ]; + $mock->expects($this->never()) + ->method('saveAssociated'); + + $entity = new Resource([ + 'title' => 'A Title', + 'body' => 'A body', + 'author' => ['name' => 'Jose'] + ]); + + $association = new BelongsTo('Authors', $config); + $result = $association->saveAssociated($entity); + $this->assertSame($result, $entity); + $this->assertNull($entity->author_id); + } + + /** + * Tests that property is being set using the constructor options. + * + * @return void + */ + public function testPropertyOption() + { + $config = ['propertyName' => 'thing_placeholder']; + $association = new BelongsTo('Thing', $config); + $this->assertEquals('thing_placeholder', $association->property()); + } + + /** + * Test that plugin names are omitted from property() + * + * @return void + */ + public function testPropertyNoPlugin() + { + $mock = $this->getMock('Muffin\Webservice\Model\Endpoint', [], [], '', false); + $config = [ + 'sourceRepository' => $this->client, + 'targetRepository' => $mock, + ]; + $association = new BelongsTo('Contacts.Companies', $config); + $this->assertEquals('company', $association->property()); + } + + /** + * Tests that attaching an association to a query will trigger beforeFind + * for the target table + * + * @return void + */ + public function testAttachToBeforeFind() + { + $config = [ + 'foreignKey' => 'company_id', + 'sourceRepository' => $this->client, + 'targetRepository' => $this->company + ]; + $listener = $this->getMock('stdClass', ['__invoke']); + $this->company->eventManager()->attach($listener, 'Model.beforeFind'); + $association = new BelongsTo('Companies', $config); + $listener->expects($this->once())->method('__invoke') + ->with( + $this->isInstanceOf('\Cake\Event\Event'), + $this->isInstanceOf('\Muffin\Webservice\Query'), + $this->isInstanceOf('\ArrayObject'), + false + ); + $association->attachTo($this->client->query()); + } + + /** + * Tests that attaching an association to a query will trigger beforeFind + * for the target table + * + * @return void + */ + public function testAttachToBeforeFindExtraOptions() + { + $config = [ + 'foreignKey' => 'company_id', + 'sourceRepository' => $this->client, + 'targetRepository' => $this->company + ]; + $listener = $this->getMock('stdClass', ['__invoke']); + $this->company->eventManager()->attach($listener, 'Model.beforeFind'); + $association = new BelongsTo('Companies', $config); + $options = new \ArrayObject(['something' => 'more']); + $listener->expects($this->once())->method('__invoke') + ->with( + $this->isInstanceOf('\Cake\Event\Event'), + $this->isInstanceOf('\Muffin\Webservice\Query'), + $options, + false + ); + $query = $this->client->query(); + $association->attachTo($query, ['queryBuilder' => function ($q) { + return $q->applyOptions(['something' => 'more']); + }]); + } +} diff --git a/tests/TestCase/Association/HasManyTest.php b/tests/TestCase/Association/HasManyTest.php new file mode 100644 index 0000000..2ec0c9f --- /dev/null +++ b/tests/TestCase/Association/HasManyTest.php @@ -0,0 +1,488 @@ +author = EndpointRegistry::get('Authors'); + $connection = ConnectionManager::get('test'); + $this->article = $this->getMock( + 'Muffin\Webservice\Model\Endpoint', + ['find', 'deleteAll', 'delete'], + [['alias' => 'Articles', 'endpoint' => 'articles', 'connection' => $connection]] + ); + $this->articlesTypeMap = new TypeMap([ + 'Articles.id' => 'integer', + 'id' => 'integer', + 'Articles.title' => 'string', + 'title' => 'string', + 'Articles.author_id' => 'integer', + 'author_id' => 'integer', + 'Articles__id' => 'integer', + 'Articles__title' => 'string', + 'Articles__author_id' => 'integer', + ]); + $this->autoQuote = false; + } + + /** + * Tear down + * + * @return void + */ + public function tearDown() + { + parent::tearDown(); + EndpointRegistry::clear(); + } + + /** + * Test that foreignKey generation ignores database names in target endpoint. + * + * @return void + */ + public function testForeignKey() + { + $this->author->endpoint('schema.authors'); + $assoc = new HasMany('Articles', [ + 'sourceRepository' => $this->author + ]); + $this->assertEquals('author_id', $assoc->foreignKey()); + } + + /** + * Tests that the association reports it can be joined + * + * @return void + */ + public function testCanBeJoined() + { + $assoc = new HasMany('Test'); + $this->assertFalse($assoc->canBeJoined()); + } + + /** + * Tests sort() method + * + * @return void + */ + public function testSort() + { + $assoc = new HasMany('Test'); + $this->assertNull($assoc->sort()); + $assoc->sort(['id' => 'ASC']); + $this->assertEquals(['id' => 'ASC'], $assoc->sort()); + } + + /** + * Tests requiresKeys() method + * + * @return void + */ + public function testRequiresKeys() + { + $assoc = new HasMany('Test'); + $this->assertTrue($assoc->requiresKeys()); + +// $assoc->strategy(HasMany::STRATEGY_SUBQUERY); +// $this->assertFalse($assoc->requiresKeys()); +// +// $assoc->strategy(HasMany::STRATEGY_SELECT); +// $this->assertTrue($assoc->requiresKeys()); + } + + /** + * Test the eager loader method with no extra options + * + * @return void + */ + public function testEagerLoader() + { + $config = [ + 'sourceRepository' => $this->author, + 'targetRepository' => $this->article, + 'strategy' => 'query' + ]; + $association = new HasMany('Articles', $config); + $query = $this->article->query()->read(); + $this->article->method('find') + ->with('all') + ->will($this->returnValue($query)); + $keys = [1, 2, 3, 4]; + + $callable = $association->eagerLoader(compact('keys', 'query')); + $row = ['Authors__id' => 1]; + + $result = $callable($row); + $this->assertArrayHasKey('Articles', $result); + $this->assertEquals($row['Authors__id'], $result['Articles'][0]->author_id); + $this->assertEquals($row['Authors__id'], $result['Articles'][1]->author_id); + + $row = ['Authors__id' => 2]; + $result = $callable($row); + $this->assertArrayNotHasKey('Articles', $result); + + $row = ['Authors__id' => 3]; + $result = $callable($row); + $this->assertArrayHasKey('Articles', $result); + $this->assertEquals($row['Authors__id'], $result['Articles'][0]->author_id); + + $row = ['Authors__id' => 4]; + $result = $callable($row); + $this->assertArrayNotHasKey('Articles', $result); + } + + /** + * Test the eager loader method with default query clauses + * + * @return void + */ + public function testEagerLoaderWithDefaults() + { + $config = [ + 'sourceRepository' => $this->author, + 'targetRepository' => $this->article, + 'conditions' => ['Articles.published' => 'Y'], + 'sort' => ['id' => 'ASC'], + 'strategy' => 'query' + ]; + $association = new HasMany('Articles', $config); + $keys = [1, 2, 3, 4]; + + $query = $this->article->query()->read(); + $this->article->method('find') + ->with('all') + ->will($this->returnValue($query)); + + $association->eagerLoader(compact('keys', 'query')); + + $expected = ['Articles.published' => 'Y', 'Articles.author_id' => $keys]; + $this->assertWhereClause($expected, $query); + + $expected = ['id' => 'ASC']; + $this->assertOrderClause($expected, $query); + } + + /** + * Test the eager loader method with overridden query clauses + * + * @return void + */ + public function testEagerLoaderWithOverrides() + { + $config = [ + 'sourceRepository' => $this->author, + 'targetRepository' => $this->article, + 'conditions' => ['Articles.published' => 'Y'], + 'sort' => ['id' => 'ASC'], + 'strategy' => 'query' + ]; + $this->article->hasMany('Comments'); + + $association = new HasMany('Articles', $config); + $keys = [1, 2, 3, 4]; + $query = $this->article->query()->read(); +// $query->addDefaultTypes($this->article->Comments->source()); + + $this->article->method('find') + ->with('all') + ->will($this->returnValue($query)); + + $association->eagerLoader([ + 'conditions' => ['Articles.id !=' => 3], + 'sort' => ['title' => 'DESC'], + 'fields' => ['title', 'author_id'], + 'contain' => ['Comments' => ['fields' => ['comment', 'article_id']]], + 'keys' => $keys, + 'query' => $query + ]); +// $expected = [ +// 'Articles__title' => 'Articles.title', +// 'Articles__author_id' => 'Articles.author_id' +// ]; +// $this->assertSelectClause($expected, $query); + + $expected = [ + 'Articles.published' => 'Y', + 'Articles.id !=' => 3, + 'Articles.author_id' => $keys + ]; + $this->assertWhereClause($expected, $query); + + $expected = ['title' => 'DESC']; + $this->assertOrderClause($expected, $query); + $this->assertArrayHasKey('Comments', $query->contain()); + } + + /** + * Tests that eager loader accepts a queryBuilder option + * + * @return void + */ + public function testEagerLoaderWithQueryBuilder() + { + $config = [ + 'sourceRepository' => $this->author, + 'targetRepository' => $this->article, + 'strategy' => 'query' + ]; + $association = new HasMany('Articles', $config); + $keys = [1, 2, 3, 4]; + $query = $this->article->query()->read(); + $this->article->method('find') + ->with('all') + ->will($this->returnValue($query)); + + $association->eagerLoader(compact('keys', 'query')); + + $expected = [ + 'Articles.author_id' => $keys, + ]; + $this->assertWhereClause($expected, $query); + } + + /** + * Test the eager loader method with no extra options + * + * @return void + */ + public function testEagerLoaderMultipleKeys() + { + $config = [ + 'sourceRepository' => $this->author, + 'targetRepository' => $this->article, + 'strategy' => 'query', + 'foreignKey' => ['author_id', 'site_id'] + ]; + + $this->author->primaryKey(['id', 'site_id']); + $association = new HasMany('Articles', $config); + $keys = [[1, 10], [2, 20], [3, 30], [4, 40]]; + $query = $this->getMock('Muffin\Webservice\Query', ['all'], [$this->author->webservice(), $this->author]); + $this->article->method('find') + ->with('all') + ->will($this->returnValue($query)); + + $results = [ + ['id' => 1, 'title' => 'article 1', 'author_id' => 2, 'site_id' => 10], + ['id' => 2, 'title' => 'article 2', 'author_id' => 1, 'site_id' => 20] + ]; + $query->method('all') + ->will($this->returnValue($results)); + +// $tuple = new TupleComparison( +// ['Articles.author_id', 'Articles.site_id'], +// $keys, +// [], +// 'IN' +// ); +// $query->expects($this->once())->method('andWhere') +// ->with($tuple) +// ->will($this->returnSelf()); + + $callable = $association->eagerLoader(compact('keys', 'query')); + $row = ['Authors__id' => 2, 'Authors__site_id' => 10, 'username' => 'author 1']; + $result = $callable($row); + $row['Articles'] = [ + ['id' => 1, 'title' => 'article 1', 'author_id' => 2, 'site_id' => 10] + ]; + $this->assertEquals($row, $result); + + $row = ['Authors__id' => 1, 'username' => 'author 2', 'Authors__site_id' => 20]; + $result = $callable($row); + $row['Articles'] = [ + ['id' => 2, 'title' => 'article 2', 'author_id' => 1, 'site_id' => 20] + ]; + $this->assertEquals($row, $result); + } + + /** + * Test cascading deletes. + * + * @return void + */ + public function testCascadeDelete() + { + $config = [ + 'dependent' => true, + 'sourceRepository' => $this->author, + 'targetRepository' => $this->article, + 'conditions' => ['Articles.is_active' => true], + ]; + $association = new HasMany('Articles', $config); + + $this->article->expects($this->once()) + ->method('deleteAll') + ->with([ + 'Articles.is_active' => true, + 'author_id' => 1 + ]); + + $entity = new Entity(['id' => 1, 'name' => 'PHP']); + $association->cascadeDelete($entity); + } + + /** + * Test cascading delete with has many. + * + * @return void + */ + public function testCascadeDeleteCallbacks() + { + $articles = EndpointRegistry::get('Articles'); + $config = [ + 'dependent' => true, + 'sourceRepository' => $this->author, + 'targetRepository' => $articles, + 'conditions' => ['Articles.published' => 'Y'], + 'cascadeCallbacks' => true, + ]; + $association = new HasMany('Articles', $config); + + $author = new Entity(['id' => 1, 'name' => 'mark']); + $this->assertTrue($association->cascadeDelete($author)); + + $query = $articles->query()->read()->where(['author_id' => 1]); + $this->assertEquals(0, $query->count(), 'Cleared related rows'); + + $query = $articles->query()->read()->where(['author_id' => 3]); + $this->assertEquals(1, $query->count(), 'other records left behind'); + } + + /** + * Test that saveAssociated() ignores non entity values. + * + * @return void + */ + public function testSaveAssociatedOnlyEntities() + { + $mock = $this->getMock('Muffin\Webservice\Model\Endpoint', ['saveAssociated'], [], '', false); + $config = [ + 'sourceRepository' => $this->author, + 'targetRepository' => $mock, + ]; + + $entity = new Entity([ + 'username' => 'Mark', + 'email' => 'mark@example.com', + 'articles' => [ + ['title' => 'First Post'], + new Entity(['title' => 'Second Post']), + ] + ]); + + $mock->expects($this->never()) + ->method('saveAssociated'); + + $association = new HasMany('Articles', $config); + $association->saveAssociated($entity); + } + + /** + * Tests that property is being set using the constructor options. + * + * @return void + */ + public function testPropertyOption() + { + $config = ['propertyName' => 'thing_placeholder']; + $association = new HasMany('Thing', $config); + $this->assertEquals('thing_placeholder', $association->property()); + } + + /** + * Test that plugin names are omitted from property() + * + * @return void + */ + public function testPropertyNoPlugin() + { + $mock = $this->getMock('Muffin\Webservice\Model\Endpoint', [], [], '', false); + $config = [ + 'sourceRepository' => $this->author, + 'targetRepository' => $mock, + ]; + $association = new HasMany('Contacts.Addresses', $config); + $this->assertEquals('addresses', $association->property()); + } + + /** + * Assertion method for order by clause contents. + * + * @param array $expected The expected join clause. + * @param \Muffin\Webservice\Query $query The query to check. + * @return void + */ + protected function assertJoin($expected, $query) + { + if ($this->autoQuote) { + $driver = $query->connection()->driver(); + $quoter = new IdentifierQuoter($driver); + foreach ($expected as &$join) { + $join['endpoint'] = $driver->quoteIdentifier($join['endpoint']); + if ($join['conditions']) { + $quoter->quoteExpression($join['conditions']); + } + } + } + $this->assertEquals($expected, array_values($query->clause('join'))); + } + + /** + * Assertion method for where clause contents. + * + * @param \Cake\Database\QueryExpression $expected The expected where clause. + * @param \Muffin\Webservice\Query $query The query to check. + * @return void + */ + protected function assertWhereClause($expected, $query) + { + $this->assertEquals($expected, $query->clause('where')); + } + + /** + * Assertion method for order by clause contents. + * + * @param \Cake\Database\QueryExpression $expected The expected where clause. + * @param \Muffin\Webservice\Query $query The query to check. + * @return void + */ + protected function assertOrderClause($expected, $query) + { + $this->assertEquals($expected, $query->clause('order')); + } +} diff --git a/tests/TestCase/MarshallerTest.php b/tests/TestCase/MarshallerTest.php new file mode 100644 index 0000000..5654fa5 --- /dev/null +++ b/tests/TestCase/MarshallerTest.php @@ -0,0 +1,2885 @@ + true, + ]; +} + +/** + * Test entity for mass assignment. + */ +class Tag extends Resource +{ + protected $_accessible = [ + 'tag' => true, + ]; +} + +/** + * Test entity for mass assignment. + */ +class ProtectedArticle extends Resource +{ + protected $_accessible = [ + 'title' => true, + 'body' => true + ]; +} + +/** + * Test stub for greedy find operations. + */ +class GreedyCommentsEndpoint extends Endpoint +{ + /** + * initialize hook + * + * @param array $config Config data. + * @return void + */ + public function initialize(array $config) + { + $this->endpoint('comments'); + $this->alias('Comments'); + } + + /** + * Overload find to cause issues. + * + * @param string $type Find type + * @param array $options find options + * @return object + */ + public function find($type = 'all', $options = []) + { + if (empty($options['conditions'])) { + $options['conditions'] = []; + } + $options['conditions'] = array_merge($options['conditions'], ['Comments.published' => 'Y']); + return parent::find($type, $options); + } +} + +/** + * Marshaller test case + */ +class MarshallerTest extends TestCase +{ + + public $fixtures = [ + 'plugin.muffin/webservice.articles', + 'plugin.muffin/webservice.articles_tags', + 'plugin.muffin/webservice.comments', + 'plugin.muffin/webservice.special_tags', + 'plugin.muffin/webservice.tags', + 'plugin.muffin/webservice.users' + ]; + + /** + * setup + * + * @return void + */ + public function setUp() + { + parent::setUp(); + $articles = EndpointRegistry::get('Articles'); + $articles->belongsTo('Users', [ + 'foreignKey' => 'author_id' + ]); + $articles->hasMany('Comments'); + $articles->belongsToMany('Tags'); + + $comments = EndpointRegistry::get('Comments'); + $users = EndpointRegistry::get('Users'); + $tags = EndpointRegistry::get('Tags'); + $articleTags = EndpointRegistry::get('ArticlesTags'); + + $comments->belongsTo('Articles'); + $comments->belongsTo('Users'); + + $articles->resourceClass(__NAMESPACE__ . '\OpenResource'); + $comments->resourceClass(__NAMESPACE__ . '\OpenResource'); + $users->resourceClass(__NAMESPACE__ . '\OpenResource'); + $tags->resourceClass(__NAMESPACE__ . '\OpenResource'); + $articleTags->resourceClass(__NAMESPACE__ . '\OpenResource'); + + $this->articles = $articles; + $this->comments = $comments; + $this->users = $users; + $this->tags = $tags; + } + + /** + * Teardown + * + * @return void + */ + public function tearDown() + { + parent::tearDown(); + EndpointRegistry::clear(); + unset($this->articles, $this->comments, $this->users); + } + + /** + * Test one() in a simple use. + * + * @return void + */ + public function testOneSimple() + { + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => 1, + 'not_in_schema' => true + ]; + $marshall = new Marshaller($this->articles); + $result = $marshall->one($data, []); + + $this->assertInstanceOf('Muffin\Webservice\Model\Resource', $result); + $this->assertEquals($data, $result->toArray()); + $this->assertTrue($result->dirty(), 'Should be a dirty entity.'); + $this->assertTrue($result->isNew(), 'Should be new'); + $this->assertEquals('Articles', $result->source()); + } + + /** + * Test that marshalling an entity with '' for pk values results + * in no pk value being set. + * + * @return void + */ + public function testOneEmptyStringPrimaryKey() + { + $data = [ + 'id' => '', + 'username' => 'superuser', + 'password' => 'root', + 'created' => new Time('2013-10-10 00:00'), + 'updated' => new Time('2013-10-10 00:00') + ]; + $marshall = new Marshaller($this->articles); + $result = $marshall->one($data, []); + + $this->assertFalse($result->dirty('id')); + $this->assertNull($result->id); + } + + /** + * Test marshalling datetime/date field. + * + * @return void + */ + public function testOneWithDatetimeField() + { + $data = [ + 'comment' => 'My Comment text', + 'created' => [ + 'year' => '2014', + 'month' => '2', + 'day' => 14 + ] + ]; + $marshall = new Marshaller($this->comments); + $result = $marshall->one($data, []); + + $this->assertEquals(new Time('2014-02-14 00:00:00'), $result->created); + + $data['created'] = [ + 'year' => '2014', + 'month' => '2', + 'day' => 14, + 'hour' => 9, + 'minute' => 25, + 'meridian' => 'pm' + ]; + $result = $marshall->one($data, []); + $this->assertEquals(new Time('2014-02-14 21:25:00'), $result->created); + + $data['created'] = [ + 'year' => '2014', + 'month' => '2', + 'day' => 14, + 'hour' => 9, + 'minute' => 25, + ]; + $result = $marshall->one($data, []); + $this->assertEquals(new Time('2014-02-14 09:25:00'), $result->created); + + $data['created'] = '2014-02-14 09:25:00'; + $result = $marshall->one($data, []); + $this->assertEquals(new Time('2014-02-14 09:25:00'), $result->created); + + $data['created'] = 1392387900; + $result = $marshall->one($data, []); + $this->assertEquals($data['created'], $result->created->getTimestamp()); + } + + /** + * Ensure that marshalling casts reasonably. + * + * @return void + */ + public function testOneOnlyCastMatchingData() + { + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => 'derp', + 'created' => 'fale' + ]; + $this->articles->resourceClass(__NAMESPACE__ . '\OpenResource'); + $marshall = new Marshaller($this->articles); + $result = $marshall->one($data, []); + + $this->assertSame($data['title'], $result->title); + $this->assertNull($result->author_id, 'No cast on bad data.'); + $this->assertSame($data['created'], $result->created, 'No cast on bad data.'); + } + + /** + * Test one() follows mass-assignment rules. + * + * @return void + */ + public function testOneAccessibleProperties() + { + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => 1, + 'not_in_schema' => true + ]; + $this->articles->resourceClass(__NAMESPACE__ . '\ProtectedArticle'); + $marshall = new Marshaller($this->articles); + $result = $marshall->one($data, []); + + $this->assertInstanceOf(__NAMESPACE__ . '\ProtectedArticle', $result); + $this->assertNull($result->author_id); + $this->assertNull($result->not_in_schema); + } + + /** + * Test one() supports accessibleFields option + * + * @return void + */ + public function testOneAccessibleFieldsOption() + { + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => 1, + 'not_in_schema' => true + ]; + $this->articles->resourceClass(__NAMESPACE__ . '\ProtectedArticle'); + + $marshall = new Marshaller($this->articles); + + $result = $marshall->one($data, ['accessibleFields' => ['body' => false]]); + $this->assertNull($result->body); + + $result = $marshall->one($data, ['accessibleFields' => ['author_id' => true]]); + $this->assertEquals($data['author_id'], $result->author_id); + $this->assertNull($result->not_in_schema); + + $result = $marshall->one($data, ['accessibleFields' => ['*' => true]]); + $this->assertEquals($data['author_id'], $result->author_id); + $this->assertTrue($result->not_in_schema); + } + + /** + * Test one() supports accessibleFields option for associations + * + * @return void + */ + public function testOneAccessibleFieldsOptionForAssociations() + { + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'user' => [ + 'id' => 1, + 'username' => 'mark', + ] + ]; + $this->articles->resourceClass(__NAMESPACE__ . '\ProtectedArticle'); + $this->users->resourceClass(__NAMESPACE__ . '\ProtectedArticle'); + + $marshall = new Marshaller($this->articles); + + $result = $marshall->one($data, [ + 'associated' => [ + 'Users' => ['accessibleFields' => ['id' => true]] + ], + 'accessibleFields' => ['body' => false, 'user' => true] + ]); + $this->assertNull($result->body); + $this->assertNull($result->user->username); + $this->assertEquals(1, $result->user->id); + } + + /** + * test one() with a wrapping model name. + * + * @return void + */ + public function testOneWithAdditionalName() + { + $data = [ + 'Articles' => [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => 1, + 'not_in_schema' => true, + 'user' => [ + 'username' => 'mark', + ] + ] + ]; + $marshall = new Marshaller($this->articles); + $result = $marshall->one($data, ['associated' => ['Users']]); + + $this->assertInstanceOf('Muffin\Webservice\Model\Resource', $result); + $this->assertTrue($result->dirty(), 'Should be a dirty entity.'); + $this->assertTrue($result->isNew(), 'Should be new'); + $this->assertEquals($data['Articles']['title'], $result->title); + $this->assertEquals($data['Articles']['user']['username'], $result->user->username); + } + + /** + * test one() with association data. + * + * @return void + */ + public function testOneAssociationsSingle() + { + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => 1, + 'comments' => [ + ['comment' => 'First post', 'user_id' => 2], + ['comment' => 'Second post', 'user_id' => 2], + ], + 'user' => [ + 'username' => 'mark', + 'password' => 'secret' + ] + ]; + $marshall = new Marshaller($this->articles); + $result = $marshall->one($data, ['associated' => ['Users']]); + + $this->assertEquals($data['title'], $result->title); + $this->assertEquals($data['body'], $result->body); + $this->assertEquals($data['author_id'], $result->author_id); + + $this->assertInternalType('array', $result->comments); + $this->assertEquals($data['comments'], $result->comments); + + $this->assertInstanceOf('Muffin\Webservice\Model\Resource', $result->user); + $this->assertEquals($data['user']['username'], $result->user->username); + $this->assertEquals($data['user']['password'], $result->user->password); + } + + /** + * test one() with association data. + * + * @return void + */ + public function testOneAssociationsMany() + { + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => 1, + 'comments' => [ + ['comment' => 'First post', 'user_id' => 2], + ['comment' => 'Second post', 'user_id' => 2], + ], + 'user' => [ + 'username' => 'mark', + 'password' => 'secret' + ] + ]; + $marshall = new Marshaller($this->articles); + $result = $marshall->one($data, ['associated' => ['Comments']]); + + $this->assertEquals($data['title'], $result->title); + $this->assertEquals($data['body'], $result->body); + $this->assertEquals($data['author_id'], $result->author_id); + + $this->assertInternalType('array', $result->comments); + $this->assertCount(2, $result->comments); + $this->assertInstanceOf('Muffin\Webservice\Model\Resource', $result->comments[0]); + $this->assertInstanceOf('Muffin\Webservice\Model\Resource', $result->comments[1]); + $this->assertEquals($data['comments'][0]['comment'], $result->comments[0]->comment); + + $this->assertInternalType('array', $result->user); + $this->assertEquals($data['user'], $result->user); + } + + /** + * Test building the _joinData entity for belongstomany associations. + * + * @return void + */ + public function testOneBelongsToManyJoinData() + { + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => 1, + 'tags' => [ + ['tag' => 'news', '_joinData' => ['active' => 1]], + ['tag' => 'cakephp', '_joinData' => ['active' => 0]], + ], + ]; + $marshall = new Marshaller($this->articles); + $result = $marshall->one($data, [ + 'associated' => ['Tags'] + ]); + + $this->assertEquals($data['title'], $result->title); + $this->assertEquals($data['body'], $result->body); + + $this->assertInternalType('array', $result->tags); + $this->assertInstanceOf('Muffin\Webservice\Model\Resource', $result->tags[0]); + $this->assertEquals($data['tags'][0]['tag'], $result->tags[0]->tag); + + $this->assertInstanceOf( + 'Muffin\Webservice\Model\Resource', + $result->tags[0]->_joinData, + '_joinData should be an entity.' + ); + $this->assertEquals( + $data['tags'][0]['_joinData']['active'], + $result->tags[0]->_joinData->active, + '_joinData should be an entity.' + ); + } + + /** + * Test that the onlyIds option restricts to only accepting ids for belongs to many associations. + * + * @return void + */ + public function testOneBelongsToManyOnlyIdsRejectArray() + { + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => 1, + 'tags' => [ + ['tag' => 'news'], + ['tag' => 'cakephp'], + ], + ]; + $marshall = new Marshaller($this->articles); + $result = $marshall->one($data, [ + 'associated' => ['Tags' => ['onlyIds' => true]] + ]); + $this->assertEmpty($result->tags, 'Only ids should be marshalled.'); + } + + /** + * Test that the onlyIds option restricts to only accepting ids for belongs to many associations. + * + * @return void + */ + public function testOneBelongsToManyOnlyIdsWithIds() + { + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => 1, + 'tags' => [ + '_ids' => [1, 2], + ['tag' => 'news'], + ], + ]; + $marshall = new Marshaller($this->articles); + $result = $marshall->one($data, [ + 'associated' => ['Tags' => ['onlyIds' => true]] + ]); + $this->assertCount(2, $result->tags, 'Ids should be marshalled.'); + } + + /** + * Test marshalling nested associations on the _joinData structure. + * + * @return void + */ + public function testOneBelongsToManyJoinDataAssociated() + { + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => 1, + 'tags' => [ + [ + 'tag' => 'news', + '_joinData' => [ + 'active' => 1, + 'user' => ['username' => 'Bill'], + ] + ], + [ + 'tag' => 'cakephp', + '_joinData' => [ + 'active' => 0, + 'user' => ['username' => 'Mark'], + ] + ], + ], + ]; + + $articlesTags = EndpointRegistry::get('ArticlesTags'); + $articlesTags->belongsTo('Users'); + + $marshall = new Marshaller($this->articles); + $result = $marshall->one($data, ['associated' => ['Tags._joinData.Users']]); + $this->assertInstanceOf( + 'Muffin\Webservice\Model\Resource', + $result->tags[0]->_joinData->user, + 'joinData should contain a user entity.' + ); + $this->assertEquals('Bill', $result->tags[0]->_joinData->user->username); + $this->assertInstanceOf( + 'Muffin\Webservice\Model\Resource', + $result->tags[1]->_joinData->user, + 'joinData should contain a user entity.' + ); + $this->assertEquals('Mark', $result->tags[1]->_joinData->user->username); + } + + /** + * Test one() with with id and _joinData. + * + * @return void + */ + public function testOneBelongsToManyJoinDataAssociatedWithIds() + { + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => 1, + 'tags' => [ + 3 => [ + 'id' => 1, + '_joinData' => [ + 'active' => 1, + 'user' => ['username' => 'MyLux'], + ] + ], + 5 => [ + 'id' => 2, + '_joinData' => [ + 'active' => 0, + 'user' => ['username' => 'IronFall'], + ] + ], + ], + ]; + + $articlesTags = EndpointRegistry::get('ArticlesTags'); + $tags = EndpointRegistry::get('Tags'); + $t1 = $tags->find('all')->where(['id' => 1])->first(); + $t2 = $tags->find('all')->where(['id' => 2])->first(); + $articlesTags->belongsTo('Users'); + + $marshall = new Marshaller($this->articles); + $result = $marshall->one($data, ['associated' => ['Tags._joinData.Users']]); + $this->assertInstanceOf( + 'Muffin\Webservice\Model\Resource', + $result->tags[0] + ); + $this->assertInstanceOf( + 'Muffin\Webservice\Model\Resource', + $result->tags[1] + ); + + $this->assertInstanceOf( + 'Muffin\Webservice\Model\Resource', + $result->tags[0]->_joinData->user + ); + + $this->assertInstanceOf( + 'Muffin\Webservice\Model\Resource', + $result->tags[1]->_joinData->user + ); + $this->assertFalse($result->tags[0]->isNew(), 'Should not be new, as id is in db.'); + $this->assertFalse($result->tags[1]->isNew(), 'Should not be new, as id is in db.'); + $this->assertEquals($t1->tag, $result->tags[0]->tag); + $this->assertEquals($t2->tag, $result->tags[1]->tag); + $this->assertEquals($data['tags'][3]['_joinData']['user']['username'], $result->tags[0]->_joinData->user->username); + $this->assertEquals($data['tags'][5]['_joinData']['user']['username'], $result->tags[1]->_joinData->user->username); + } + + /** + * Test belongsToMany association with mixed data and _joinData + * + * @return void + */ + public function testOneBelongsToManyWithMixedJoinData() + { + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => 1, + 'tags' => [ + [ + 'id' => 1, + '_joinData' => [ + 'active' => 0, + ] + ], + [ + 'name' => 'tag5', + '_joinData' => [ + 'active' => 1, + ] + ] + ] + ]; + $marshall = new Marshaller($this->articles); + + $result = $marshall->one($data, ['associated' => ['Tags._joinData']]); + + $this->assertEquals($data['tags'][0]['id'], $result->tags[0]->id); + $this->assertEquals($data['tags'][1]['name'], $result->tags[1]->name); + $this->assertEquals(0, $result->tags[0]->_joinData->active); + $this->assertEquals(1, $result->tags[1]->_joinData->active); + } + + public function testOneBelongsToManyWithNestedAssociations() + { + $this->tags->belongsToMany('Articles'); + $data = [ + 'name' => 'new tag', + 'articles' => [ + // This nested article exists, and we want to update it. + [ + 'id' => 1, + 'title' => 'New tagged article', + 'body' => 'New tagged article', + 'user' => [ + 'id' => 1, + 'username' => 'newuser' + ], + 'comments' => [ + ['comment' => 'New comment', 'user_id' => 1], + ['comment' => 'Second comment', 'user_id' => 1], + ] + ] + ] + ]; + $marshaller = new Marshaller($this->tags); + $tag = $marshaller->one($data, ['associated' => ['Articles.Users', 'Articles.Comments']]); + + $this->assertNotEmpty($tag->articles); + $this->assertCount(1, $tag->articles); + $this->assertInstanceOf('Muffin\Webservice\Model\Resource', $tag->articles[0]); + $this->assertSame('New tagged article', $tag->articles[0]->title); + $this->assertFalse($tag->articles[0]->isNew()); + + $this->assertNotEmpty($tag->articles[0]->user); + $this->assertInstanceOf('Muffin\Webservice\Model\Resource', $tag->articles[0]->user); + $this->assertSame('newuser', $tag->articles[0]->user->username); + $this->assertTrue($tag->articles[0]->user->isNew()); + + $this->assertNotEmpty($tag->articles[0]->comments); + $this->assertCount(2, $tag->articles[0]->comments); + $this->assertInstanceOf('Muffin\Webservice\Model\Resource', $tag->articles[0]->comments[0]); + $this->assertTrue($tag->articles[0]->comments[0]->isNew()); + $this->assertTrue($tag->articles[0]->comments[1]->isNew()); + } + + /** + * Test belongsToMany association with mixed data and _joinData + * + * @return void + */ + public function testBelongsToManyAddingNewExisting() + { + $this->tags->resourceClass(__NAMESPACE__ . '\Tag'); + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => 1, + 'tags' => [ + [ + 'id' => 1, + '_joinData' => [ + 'active' => 0, + ] + ], + ] + ]; + $marshall = new Marshaller($this->articles); + $result = $marshall->one($data, ['associated' => ['Tags._joinData']]); + $data = [ + 'title' => 'New Title', + 'tags' => [ + [ + 'id' => 1, + '_joinData' => [ + 'active' => 0, + ] + ], + [ + 'id' => 2, + '_joinData' => [ + 'active' => 1, + ] + ] + ] + ]; + $result = $marshall->merge($result, $data, ['associated' => ['Tags._joinData']]); + + $this->assertEquals($data['title'], $result->title); + $this->assertEquals($data['tags'][0]['id'], $result->tags[0]->id); + $this->assertEquals($data['tags'][1]['id'], $result->tags[1]->id); + $this->assertNotEmpty($result->tags[0]->_joinData); + $this->assertNotEmpty($result->tags[1]->_joinData); + $this->assertEquals(0, $result->tags[0]->_joinData->active); + $this->assertEquals(1, $result->tags[1]->_joinData->active); + } + + /** + * Test belongsToMany association with mixed data and _joinData + * + * @return void + */ + public function testBelongsToManyWithMixedJoinDataOutOfOrder() + { + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => 1, + 'tags' => [ + [ + 'name' => 'tag5', + '_joinData' => [ + 'active' => 1, + ] + ], + [ + 'id' => 1, + '_joinData' => [ + 'active' => 0, + ] + ], + [ + 'name' => 'tag3', + '_joinData' => [ + 'active' => 1, + ] + ], + ] + ]; + $marshall = new Marshaller($this->articles); + $result = $marshall->one($data, ['associated' => ['Tags._joinData']]); + + $this->assertEquals($data['tags'][0]['name'], $result->tags[0]->name); + $this->assertEquals($data['tags'][1]['id'], $result->tags[1]->id); + $this->assertEquals($data['tags'][2]['name'], $result->tags[2]->name); + + $this->assertEquals(1, $result->tags[0]->_joinData->active); + $this->assertEquals(0, $result->tags[1]->_joinData->active); + $this->assertEquals(1, $result->tags[2]->_joinData->active); + } + + /** + * Test belongsToMany association with scalars + * + * @return void + */ + public function testBelongsToManyInvalidData() + { + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => 1, + 'tags' => [ + 'id' => 1 + ] + ]; + + $article = $this->articles->newEntity($data, [ + 'associated' => ['Tags'] + ]); + $this->assertEmpty($article->tags, 'No entity should be created'); + + $data['tags'] = 1; + $article = $this->articles->newEntity($data, [ + 'associated' => ['Tags'] + ]); + $this->assertEmpty($article->tags, 'No entity should be created'); + } + + /** + * Test belongsToMany association with mixed data array + * + * @return void + */ + public function testBelongsToManyWithMixedData() + { + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => 1, + 'tags' => [ + [ + 'name' => 'tag4' + ], + [ + 'name' => 'tag5' + ], + [ + 'id' => 1 + ] + ] + ]; + + $tags = EndpointRegistry::get('Tags'); + + $marshaller = new Marshaller($this->articles); + $article = $marshaller->one($data, ['associated' => ['Tags']]); + + $this->assertEquals($data['tags'][0]['name'], $article->tags[0]->name); + $this->assertEquals($data['tags'][1]['name'], $article->tags[1]->name); + $this->assertEquals($article->tags[2], $tags->get(1)); + + $this->assertEquals($article->tags[0]->isNew(), true); + $this->assertEquals($article->tags[1]->isNew(), true); + $this->assertEquals($article->tags[2]->isNew(), false); + + $tagCount = $tags->find()->count(); + $this->articles->save($article); + + $this->assertEquals($tagCount + 2, $tags->find()->count()); + } + + /** + * Test belongsToMany association with the ForceNewTarget to force saving + * new records on the target tables with BTM relationships when the primaryKey(s) + * of the target table is specified. + * + * @return void + */ + public function testBelongsToManyWithForceNew() + { + $data = [ + 'title' => 'Fourth Article', + 'body' => 'Fourth Article Body', + 'author_id' => 1, + 'tags' => [ + [ + 'id' => 3 + ], + [ + 'id' => 4, + 'name' => 'tag4' + ] + ] + ]; + + $marshaller = new Marshaller($this->articles); + $article = $marshaller->one($data, [ + 'associated' => ['Tags'], + 'forceNew' => true + ]); + + $this->assertFalse($article->tags[0]->isNew(), 'The tag should not be new'); + $this->assertTrue($article->tags[1]->isNew(), 'The tag should be new'); + $this->assertSame('tag4', $article->tags[1]->name, 'Property should match request data.'); + } + + /** + * Test HasMany association with _ids attribute + * + * @return void + */ + public function testOneHasManyWithIds() + { + $data = [ + 'title' => 'article', + 'body' => 'some content', + 'comments' => [ + '_ids' => [1, 2] + ] + ]; + + $marshaller = new Marshaller($this->articles); + $article = $marshaller->one($data, ['associated' => ['Comments']]); + + $this->assertEquals($article->comments[0], $this->comments->get(1)); + $this->assertEquals($article->comments[1], $this->comments->get(2)); + } + + /** + * Test that the onlyIds option restricts to only accepting ids for hasmany associations. + * + * @return void + */ + public function testOneHasManyOnlyIdsRejectArray() + { + $data = [ + 'title' => 'article', + 'body' => 'some content', + 'comments' => [ + ['comment' => 'first comment'], + ['comment' => 'second comment'], + ] + ]; + + $marshaller = new Marshaller($this->articles); + $article = $marshaller->one($data, [ + 'associated' => ['Comments' => ['onlyIds' => true]] + ]); + $this->assertEmpty($article->comments); + } + + /** + * Test that the onlyIds option restricts to only accepting ids for hasmany associations. + * + * @return void + */ + public function testOneHasManyOnlyIdsWithIds() + { + $data = [ + 'title' => 'article', + 'body' => 'some content', + 'comments' => [ + '_ids' => [1, 2], + ['comment' => 'first comment'], + ] + ]; + + $marshaller = new Marshaller($this->articles); + $article = $marshaller->one($data, [ + 'associated' => ['Comments' => ['onlyIds' => true]] + ]); + $this->assertCount(2, $article->comments); + } + + /** + * Test HasMany association with invalid data + * + * @return void + */ + public function testOneHasManyInvalidData() + { + $data = [ + 'title' => 'new title', + 'body' => 'some content', + 'comments' => [ + 'id' => 1 + ] + ]; + + $marshaller = new Marshaller($this->articles); + $article = $marshaller->one($data, ['associated' => ['Comments']]); + $this->assertEmpty($article->comments); + + $data['comments'] = 1; + $article = $marshaller->one($data, ['associated' => ['Comments']]); + $this->assertEmpty($article->comments); + } + + /** + * Test one() with deeper associations. + * + * @return void + */ + public function testOneDeepAssociations() + { + $data = [ + 'comment' => 'First post', + 'user_id' => 2, + 'article' => [ + 'title' => 'Article title', + 'body' => 'Article body', + 'user' => [ + 'username' => 'mark', + 'password' => 'secret' + ], + ] + ]; + $marshall = new Marshaller($this->comments); + $result = $marshall->one($data, ['associated' => ['Articles.Users']]); + + $this->assertEquals( + $data['article']['title'], + $result->article->title + ); + $this->assertEquals( + $data['article']['user']['username'], + $result->article->user->username + ); + } + + /** + * Test many() with a simple set of data. + * + * @return void + */ + public function testManySimple() + { + $data = [ + ['comment' => 'First post', 'user_id' => 2], + ['comment' => 'Second post', 'user_id' => 2], + ]; + $marshall = new Marshaller($this->comments); + $result = $marshall->many($data); + + $this->assertCount(2, $result); + $this->assertInstanceOf('Muffin\Webservice\Model\Resource', $result[0]); + $this->assertInstanceOf('Muffin\Webservice\Model\Resource', $result[1]); + $this->assertEquals($data[0]['comment'], $result[0]->comment); + $this->assertEquals($data[1]['comment'], $result[1]->comment); + } + + /** + * Test many() with some invalid data + * + * @return void + */ + public function testManyInvalidData() + { + $data = [ + ['id' => 2, 'comment' => 'Changed 2', 'user_id' => 2], + ['id' => 1, 'comment' => 'Changed 1', 'user_id' => 1], + '_csrfToken' => 'abc123', + ]; + $marshall = new Marshaller($this->comments); + $result = $marshall->many($data); + + $this->assertCount(2, $result); + } + + /** + * test many() with nested associations. + * + * @return void + */ + public function testManyAssociations() + { + $data = [ + [ + 'comment' => 'First post', + 'user_id' => 2, + 'user' => [ + 'username' => 'mark', + ], + ], + [ + 'comment' => 'Second post', + 'user_id' => 2, + 'user' => [ + 'username' => 'jose', + ], + ], + ]; + $marshall = new Marshaller($this->comments); + $result = $marshall->many($data, ['associated' => ['Users']]); + + $this->assertCount(2, $result); + $this->assertInstanceOf('Muffin\Webservice\Model\Resource', $result[0]); + $this->assertInstanceOf('Muffin\Webservice\Model\Resource', $result[1]); + $this->assertEquals( + $data[0]['user']['username'], + $result[0]->user->username + ); + $this->assertEquals( + $data[1]['user']['username'], + $result[1]->user->username + ); + } + + /** + * Test generating a list of entities from a list of ids. + * + * @return void + */ + public function testOneGenerateBelongsToManyEntitiesFromIds() + { + $data = [ + 'title' => 'Haz tags', + 'body' => 'Some content here', + 'tags' => ['_ids' => ''] + ]; + $marshall = new Marshaller($this->articles); + $result = $marshall->one($data, ['associated' => ['Tags']]); + $this->assertCount(0, $result->tags); + + $data = [ + 'title' => 'Haz tags', + 'body' => 'Some content here', + 'tags' => ['_ids' => false] + ]; + $result = $marshall->one($data, ['associated' => ['Tags']]); + $this->assertCount(0, $result->tags); + + $data = [ + 'title' => 'Haz tags', + 'body' => 'Some content here', + 'tags' => ['_ids' => null] + ]; + $result = $marshall->one($data, ['associated' => ['Tags']]); + $this->assertCount(0, $result->tags); + + $data = [ + 'title' => 'Haz tags', + 'body' => 'Some content here', + 'tags' => ['_ids' => []] + ]; + $result = $marshall->one($data, ['associated' => ['Tags']]); + $this->assertCount(0, $result->tags); + + $data = [ + 'title' => 'Haz tags', + 'body' => 'Some content here', + 'tags' => ['_ids' => [1, 2, 3]] + ]; + $marshall = new Marshaller($this->articles); + $result = $marshall->one($data, ['associated' => ['Tags']]); + + $this->assertCount(3, $result->tags); + $this->assertInstanceOf('Muffin\Webservice\Model\Resource', $result->tags[0]); + $this->assertInstanceOf('Muffin\Webservice\Model\Resource', $result->tags[1]); + $this->assertInstanceOf('Muffin\Webservice\Model\Resource', $result->tags[2]); + } + + /** + * Test merge() in a simple use. + * + * @return void + */ + public function testMergeSimple() + { + $data = [ + 'title' => 'My title', + 'author_id' => 1, + 'not_in_schema' => true + ]; + $marshall = new Marshaller($this->articles); + $entity = new Resource([ + 'title' => 'Foo', + 'body' => 'My Content' + ]); + $entity->accessible('*', true); + $entity->isNew(false); + $entity->clean(); + $result = $marshall->merge($entity, $data, []); + + $this->assertSame($entity, $result); + $this->assertEquals($data + ['body' => 'My Content'], $result->toArray()); + $this->assertTrue($result->dirty(), 'Should be a dirty entity.'); + $this->assertFalse($result->isNew(), 'Should not change the entity state'); + } + + /** + * Test merge() with accessibleFields options + * + * @return void + */ + public function testMergeAccessibleFields() + { + $data = [ + 'title' => 'My title', + 'body' => 'New content', + 'author_id' => 1, + 'not_in_schema' => true + ]; + $marshall = new Marshaller($this->articles); + $entity = new Resource([ + 'title' => 'Foo', + 'body' => 'My Content' + ]); + $entity->accessible('*', false); + $entity->isNew(false); + $entity->clean(); + $result = $marshall->merge($entity, $data, ['accessibleFields' => ['body' => true]]); + + $this->assertSame($entity, $result); + $this->assertEquals(['title' => 'Foo', 'body' => 'New content'], $result->toArray()); + $this->assertTrue($entity->accessible('body')); + } + + /** + * Provides empty values. + * + * @return array + */ + public function emptyProvider() + { + return [ + [0], + ['0'], + ]; + } + + /** + * Test merging empty values into an entity. + * + * @dataProvider emptyProvider + * @return void + */ + public function testMergeFalseyValues($value) + { + $marshall = new Marshaller($this->articles); + $entity = new Resource(); + $entity->accessible('*', true); + + $entity = $marshall->merge($entity, ['author_id' => $value]); + $this->assertTrue($entity->dirty('author_id'), 'Field should be dirty'); + $this->assertSame(0, $entity->get('author_id'), 'Value should be zero'); + } + + /** + * Tests that merge respects the entity accessible methods + * + * @return void + */ + public function testMergeWhitelist() + { + $data = [ + 'title' => 'My title', + 'author_id' => 1, + 'not_in_schema' => true + ]; + $marshall = new Marshaller($this->articles); + $entity = new Resource([ + 'title' => 'Foo', + 'body' => 'My Content' + ]); + $entity->accessible('*', false); + $entity->accessible('author_id', true); + $entity->isNew(false); + $result = $marshall->merge($entity, $data, []); + + $expected = [ + 'title' => 'Foo', + 'body' => 'My Content', + 'author_id' => 1 + ]; + $this->assertEquals($expected, $result->toArray()); + } + + /** + * Test merge when fieldList contains an association. + * + * @return void + */ + public function testMergeWithSingleAssociationAndFieldLists() + { + $user = new Resource([ + 'username' => 'user', + ]); + $article = new Resource([ + 'title' => 'title for post', + 'body' => 'body', + 'user' => $user, + ]); + + $user->accessible('*', true); + $article->accessible('*', true); + + $data = [ + 'title' => 'Chelsea', + 'user' => [ + 'username' => 'dee' + ] + ]; + + $marshall = new Marshaller($this->articles); + $marshall->merge($article, $data, [ + 'fieldList' => ['title', 'user'], + 'associated' => ['Users' => []] + ]); + $this->assertSame($user, $article->user); + } + + /** + * Tests that fields with the same value are not marked as dirty + * + * @return void + */ + public function testMergeDirty() + { + $marshall = new Marshaller($this->articles); + $entity = new Resource([ + 'title' => 'Foo', + 'author_id' => 1 + ]); + $data = [ + 'title' => 'Foo', + 'author_id' => 1, + 'crazy' => true + ]; + $entity->accessible('*', true); + $entity->clean(); + $result = $marshall->merge($entity, $data, []); + + $expected = [ + 'title' => 'Foo', + 'author_id' => 1, + 'crazy' => true + ]; + $this->assertEquals($expected, $result->toArray()); + $this->assertFalse($entity->dirty('title')); + $this->assertFalse($entity->dirty('author_id')); + $this->assertTrue($entity->dirty('crazy')); + } + + /** + * Tests merging data into an associated entity + * + * @return void + */ + public function testMergeWithSingleAssociation() + { + $user = new Resource([ + 'username' => 'mark', + 'password' => 'secret' + ]); + $entity = new Resource([ + 'title' => 'My Title', + 'user' => $user + ]); + $user->accessible('*', true); + $entity->accessible('*', true); + + $data = [ + 'body' => 'My Content', + 'user' => [ + 'password' => 'not a secret' + ] + ]; + $marshall = new Marshaller($this->articles); + $marshall->merge($entity, $data, ['associated' => ['Users']]); + $this->assertEquals('My Content', $entity->body); + $this->assertSame($user, $entity->user); + $this->assertEquals('mark', $entity->user->username); + $this->assertEquals('not a secret', $entity->user->password); + $this->assertTrue($entity->dirty('user')); + } + + /** + * Tests that new associated entities can be created when merging data into + * a parent entity + * + * @return void + */ + public function testMergeCreateAssociation() + { + $entity = new Resource([ + 'title' => 'My Title' + ]); + $entity->accessible('*', true); + $data = [ + 'body' => 'My Content', + 'user' => [ + 'username' => 'mark', + 'password' => 'not a secret' + ] + ]; + $marshall = new Marshaller($this->articles); + $marshall->merge($entity, $data, ['associated' => ['Users']]); + $this->assertEquals('My Content', $entity->body); + $this->assertInstanceOf('Muffin\Webservice\Model\Resource', $entity->user); + $this->assertEquals('mark', $entity->user->username); + $this->assertEquals('not a secret', $entity->user->password); + $this->assertTrue($entity->dirty('user')); + $this->assertTrue($entity->user->isNew()); + } + + /** + * Tests merging one to many associations + * + * @return void + */ + public function testMergeMultipleAssociations() + { + $user = new Resource(['username' => 'mark', 'password' => 'secret']); + $comment1 = new Resource(['id' => 1, 'comment' => 'A comment']); + $comment2 = new Resource(['id' => 2, 'comment' => 'Another comment']); + $entity = new Resource([ + 'title' => 'My Title', + 'user' => $user, + 'comments' => [$comment1, $comment2] + ]); + + $user->accessible('*', true); + $comment1->accessible('*', true); + $comment2->accessible('*', true); + $entity->accessible('*', true); + + $data = [ + 'title' => 'Another title', + 'user' => ['password' => 'not so secret'], + 'comments' => [ + ['comment' => 'Extra comment 1'], + ['id' => 2, 'comment' => 'Altered comment 2'], + ['id' => 1, 'comment' => 'Altered comment 1'], + ['id' => 3, 'comment' => 'Extra comment 3'], + ['id' => 4, 'comment' => 'Extra comment 4'], + ['comment' => 'Extra comment 2'] + ] + ]; + $marshall = new Marshaller($this->articles); + $result = $marshall->merge($entity, $data, ['associated' => ['Users', 'Comments']]); + $this->assertSame($entity, $result); + $this->assertSame($user, $result->user); + $this->assertEquals('not so secret', $entity->user->password); + $this->assertSame($comment1, $entity->comments[0]); + $this->assertSame($comment2, $entity->comments[1]); + $this->assertEquals('Altered comment 1', $entity->comments[0]->comment); + $this->assertEquals('Altered comment 2', $entity->comments[1]->comment); + + $thirdComment = $this->articles->Comments + ->find() + ->where(['id' => 3]) + ->hydrate(false) + ->first(); + + $this->assertEquals( + ['comment' => 'Extra comment 3'] + $thirdComment, + $entity->comments[2]->toArray() + ); + + $forthComment = $this->articles->Comments + ->find() + ->where(['id' => 4]) + ->hydrate(false) + ->first(); + + $this->assertEquals( + ['comment' => 'Extra comment 4'] + $forthComment, + $entity->comments[3]->toArray() + ); + + $this->assertEquals( + ['comment' => 'Extra comment 1'], + $entity->comments[4]->toArray() + ); + $this->assertEquals( + ['comment' => 'Extra comment 2'], + $entity->comments[5]->toArray() + ); + } + + /** + * Tests that merging data to an entity containing belongsToMany and _ids + * will just overwrite the data + * + * @return void + */ + public function testMergeBelongsToManyEntitiesFromIds() + { + $entity = new Resource([ + 'title' => 'Haz tags', + 'body' => 'Some content here', + 'tags' => [ + new Resource(['id' => 1, 'name' => 'Cake']), + new Resource(['id' => 2, 'name' => 'PHP']) + ] + ]); + + $data = [ + 'title' => 'Haz moar tags', + 'tags' => ['_ids' => [1, 2, 3]] + ]; + $entity->accessible('*', true); + $marshall = new Marshaller($this->articles); + $result = $marshall->merge($entity, $data, ['associated' => ['Tags']]); + + $this->assertCount(3, $result->tags); + $this->assertInstanceOf('Muffin\Webservice\Model\Resource', $result->tags[0]); + $this->assertInstanceOf('Muffin\Webservice\Model\Resource', $result->tags[1]); + $this->assertInstanceOf('Muffin\Webservice\Model\Resource', $result->tags[2]); + } + + /** + * Tests that merging data to an entity containing belongsToMany and _ids + * will not generate conflicting queries when associations are automatically selected + * + * @return void + */ +// public function testMergeFromIdsWithAutoAssociation() +// { +// $entity = new Resource([ +// 'title' => 'Haz tags', +// 'body' => 'Some content here', +// 'tags' => [ +// new Resource(['id' => 1, 'name' => 'Cake']), +// new Resource(['id' => 2, 'name' => 'PHP']) +// ] +// ]); +// +// $data = [ +// 'title' => 'Haz moar tags', +// 'tags' => ['_ids' => [1, 2, 3]] +// ]; +// $entity->accessible('*', true); +// +// // Adding a forced join to have another table with the same column names +// $this->articles->Tags->eventManager()->attach(function ($e, $query) { +// $left = new IdentifierExpression('Tags.id'); +// $right = new IdentifierExpression('a.id'); +// $query->leftJoin(['a' => 'tags'], $query->newExpr()->eq($left, $right)); +// }, 'Model.beforeFind'); +// +// $marshall = new Marshaller($this->articles); +// $result = $marshall->merge($entity, $data, ['associated' => ['Tags']]); +// +// $this->assertCount(3, $result->tags); +// } + + /** + * Tests that merging data to an entity containing belongsToMany and _ids + * with additional association conditions works. + * + * @return void + */ + public function testMergeBelongsToManyFromIdsWithConditions() + { + $this->articles->belongsToMany('Tags', [ + 'conditions' => ['ArticleTags.article_id' => 1] + ]); + + $entity = new Resource([ + 'title' => 'No tags', + 'body' => 'Some content here', + 'tags' => [] + ]); + + $data = [ + 'title' => 'Haz moar tags', + 'tags' => ['_ids' => [1, 2, 3]] + ]; + $entity->accessible('*', true); + $marshall = new Marshaller($this->articles); + $result = $marshall->merge($entity, $data, ['associated' => ['Tags']]); + + $this->assertCount(3, $result->tags); + $this->assertInstanceOf('Muffin\Webservice\Model\Resource', $result->tags[0]); + $this->assertInstanceOf('Muffin\Webservice\Model\Resource', $result->tags[1]); + $this->assertInstanceOf('Muffin\Webservice\Model\Resource', $result->tags[2]); + } + + /** + * Tests that merging data to an entity containing belongsToMany as an array + * with additional association conditions works. + * + * @return void + */ + public function testMergeBelongsToManyFromArrayWithConditions() + { + $this->articles->belongsToMany('Tags', [ + 'conditions' => ['ArticleTags.article_id' => 1] + ]); + + $this->articles->Tags->eventManager() + ->on('Model.beforeFind', function ($event, $query) use (&$called) { + $called = true; + return $query->where(['Tags.id >=' => 1]); + }); + + $entity = new Resource([ + 'title' => 'No tags', + 'body' => 'Some content here', + 'tags' => [] + ]); + + $data = [ + 'title' => 'Haz moar tags', + 'tags' => [ + ['id' => 1], + ['id' => 2] + ] + ]; + $entity->accessible('*', true); + $marshall = new Marshaller($this->articles); + $result = $marshall->merge($entity, $data, ['associated' => ['Tags']]); + + $this->assertCount(2, $result->tags); + $this->assertInstanceOf('Muffin\Webservice\Model\Resource', $result->tags[0]); + $this->assertInstanceOf('Muffin\Webservice\Model\Resource', $result->tags[1]); + $this->assertTrue($called); + } + + /** + * Tests that merging data to an entity containing belongsToMany and _ids + * will ignore empty values. + * + * @return void + */ + public function testMergeBelongsToManyEntitiesFromIdsEmptyValue() + { + $entity = new Resource([ + 'title' => 'Haz tags', + 'body' => 'Some content here', + 'tags' => [ + new Resource(['id' => 1, 'name' => 'Cake']), + new Resource(['id' => 2, 'name' => 'PHP']) + ] + ]); + + $data = [ + 'title' => 'Haz moar tags', + 'tags' => ['_ids' => ''] + ]; + $entity->accessible('*', true); + $marshall = new Marshaller($this->articles); + $result = $marshall->merge($entity, $data, ['associated' => ['Tags']]); + $this->assertCount(0, $result->tags); + + $data = [ + 'title' => 'Haz moar tags', + 'tags' => ['_ids' => false] + ]; + $result = $marshall->merge($entity, $data, ['associated' => ['Tags']]); + $this->assertCount(0, $result->tags); + + $data = [ + 'title' => 'Haz moar tags', + 'tags' => ['_ids' => null] + ]; + $result = $marshall->merge($entity, $data, ['associated' => ['Tags']]); + $this->assertCount(0, $result->tags); + } + + /** + * Test that the ids option restricts to only accepting ids for belongs to many associations. + * + * @return void + */ + public function testMergeBelongsToManyOnlyIdsRejectArray() + { + $entity = new Resource([ + 'title' => 'Haz tags', + 'body' => 'Some content here', + 'tags' => [ + new Resource(['id' => 1, 'name' => 'Cake']), + new Resource(['id' => 2, 'name' => 'PHP']) + ] + ]); + + $data = [ + 'title' => 'Haz moar tags', + 'tags' => [ + ['name' => 'new'], + ['name' => 'awesome'] + ] + ]; + $entity->accessible('*', true); + $marshall = new Marshaller($this->articles); + $result = $marshall->merge($entity, $data, [ + 'associated' => ['Tags' => ['onlyIds' => true]] + ]); + $this->assertCount(0, $result->tags); + } + + /** + * Test that the ids option restricts to only accepting ids for belongs to many associations. + * + * @return void + */ + public function testMergeBelongsToManyOnlyIdsWithIds() + { + $entity = new Resource([ + 'title' => 'Haz tags', + 'body' => 'Some content here', + 'tags' => [ + new Resource(['id' => 1, 'name' => 'Cake']), + new Resource(['id' => 2, 'name' => 'PHP']) + ] + ]); + + $data = [ + 'title' => 'Haz moar tags', + 'tags' => [ + '_ids' => [3] + ] + ]; + $entity->accessible('*', true); + $marshall = new Marshaller($this->articles); + $result = $marshall->merge($entity, $data, [ + 'associated' => ['Tags' => ['ids' => true]] + ]); + $this->assertCount(1, $result->tags); + $this->assertEquals('tag3', $result->tags[0]->name); + } + + /** + * Test that invalid _joinData (scalar data) is not marshalled. + * + * @return void + */ + public function testMergeBelongsToManyJoinDataScalar() + { + EndpointRegistry::clear(); + $articles = EndpointRegistry::get('Articles'); + $articles->belongsToMany('Tags', [ + 'through' => 'SpecialTags' + ]); + + $entity = $articles->get(1, ['contain' => 'Tags']); + debug($entity);exit(); + $data = [ + 'title' => 'Haz data', + 'tags' => [ + ['id' => 3, 'tag' => 'Cake', '_joinData' => 'Invalid'], + ] + ]; + $marshall = new Marshaller($articles); + $result = $marshall->merge($entity, $data, ['associated' => 'Tags._joinData']); + + $articles->save($entity, ['associated' => ['Tags._joinData']]); + $this->assertFalse($entity->tags[0]->dirty('_joinData')); + $this->assertEmpty($entity->tags[0]->_joinData); + } + + /** + * Test merging the _joinData entity for belongstomany associations when * is not + * accessible. + * + * @return void + */ + public function testMergeBelongsToManyJoinDataNotAccessible() + { + EndpointRegistry::clear(); + $articles = EndpointRegistry::get('Articles'); + $articles->belongsToMany('Tags', [ + 'through' => 'SpecialTags' + ]); + + $entity = $articles->get(1, ['contain' => 'Tags']); + $data = [ + 'title' => 'Haz data', + 'tags' => [ + ['id' => 3, 'tag' => 'Cake', '_joinData' => ['highlighted' => '1', 'author_id' => '99']], + ] + ]; + // Make only specific fields accessible, but not _joinData. + $entity->tags[0]->accessible('*', false); + $entity->tags[0]->accessible(['article_id', 'tag_id'], true); + + $marshall = new Marshaller($articles); + $result = $marshall->merge($entity, $data, ['associated' => 'Tags._joinData']); + + $this->assertTrue($entity->tags[0]->dirty('_joinData')); + $this->assertTrue($result->tags[0]->_joinData->dirty('author_id'), 'Field not modified'); + $this->assertTrue($result->tags[0]->_joinData->dirty('highlighted'), 'Field not modified'); + $this->assertSame(99, $result->tags[0]->_joinData->author_id); + $this->assertTrue($result->tags[0]->_joinData->highlighted); + } + + /** + * Test that _joinData is marshalled consistently with both + * new and existing records + * + * @return void + */ + public function testMergeBelongsToManyHandleJoinDataConsistently() + { + EndpointRegistry::clear(); + $articles = EndpointRegistry::get('Articles'); + $articles->belongsToMany('Tags', [ + 'through' => 'SpecialTags' + ]); + + $entity = $articles->get(1); + $data = [ + 'title' => 'Haz data', + 'tags' => [ + ['id' => 3, 'tag' => 'Cake', '_joinData' => ['highlighted' => true]], + ] + ]; + $marshall = new Marshaller($articles); + $result = $marshall->merge($entity, $data, ['associated' => 'Tags']); + $this->assertInstanceOf('Muffin\Webservice\Model\Resource', $result->tags[0]->_joinData); + $this->assertTrue($result->tags[0]->_joinData->highlighted); + + // Also ensure merge() overwrites existing data. + $entity = $articles->get(1, ['contain' => 'Tags']); + $data = [ + 'title' => 'Haz data', + 'tags' => [ + ['id' => 3, 'tag' => 'Cake', '_joinData' => ['highlighted' => true]], + ] + ]; + $marshall = new Marshaller($articles); + $result = $marshall->merge($entity, $data, ['associated' => 'Tags']); + $this->assertInstanceOf('Muffin\Webservice\Model\Resource', $result->tags[0]->_joinData); + $this->assertTrue($result->tags[0]->_joinData->highlighted); + } + + /** + * Test merging belongsToMany data doesn't create 'new' entities. + * + * @return void + */ + public function testMergeBelongsToManyJoinDataAssociatedWithIds() + { + $data = [ + 'title' => 'My title', + 'tags' => [ + [ + 'id' => 1, + '_joinData' => [ + 'active' => 1, + 'user' => ['username' => 'MyLux'], + ] + ], + [ + 'id' => 2, + '_joinData' => [ + 'active' => 0, + 'user' => ['username' => 'IronFall'], + ] + ], + ], + ]; + $articlesTags = EndpointRegistry::get('ArticlesTags'); + $articlesTags->belongsTo('Users'); + + $marshall = new Marshaller($this->articles); + $article = $this->articles->get(1, ['associated' => 'Tags']); + $result = $marshall->merge($article, $data, ['associated' => ['Tags._joinData.Users']]); + + $this->assertInstanceOf('Muffin\Webservice\Model\Resource', $result->tags[0]); + $this->assertInstanceOf('Muffin\Webservice\Model\Resource', $result->tags[1]); + $this->assertInstanceOf('Muffin\Webservice\Model\Resource', $result->tags[0]->_joinData->user); + + $this->assertInstanceOf('Muffin\Webservice\Model\Resource', $result->tags[1]->_joinData->user); + $this->assertFalse($result->tags[0]->isNew(), 'Should not be new, as id is in db.'); + $this->assertFalse($result->tags[1]->isNew(), 'Should not be new, as id is in db.'); + $this->assertEquals(1, $result->tags[0]->id); + $this->assertEquals(2, $result->tags[1]->id); + + $this->assertEquals(1, $result->tags[0]->_joinData->active); + $this->assertEquals(0, $result->tags[1]->_joinData->active); + + $this->assertEquals( + $data['tags'][0]['_joinData']['user']['username'], + $result->tags[0]->_joinData->user->username + ); + $this->assertEquals( + $data['tags'][1]['_joinData']['user']['username'], + $result->tags[1]->_joinData->user->username + ); + } + + /** + * Test merging the _joinData entity for belongstomany associations. + * + * @return void + */ + public function testMergeBelongsToManyJoinData() + { + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => 1, + 'tags' => [ + [ + 'id' => 1, + 'tag' => 'news', + '_joinData' => [ + 'active' => 0 + ] + ], + [ + 'id' => 2, + 'tag' => 'cakephp', + '_joinData' => [ + 'active' => 0 + ] + ], + ], + ]; + + $options = ['associated' => ['Tags._joinData']]; + $marshall = new Marshaller($this->articles); + $entity = $marshall->one($data, $options); + $entity->accessible('*', true); + + $data = [ + 'title' => 'Haz data', + 'tags' => [ + ['id' => 1, 'tag' => 'Cake', '_joinData' => ['foo' => 'bar']], + ['tag' => 'new tag', '_joinData' => ['active' => 1, 'foo' => 'baz']] + ] + ]; + + $tag1 = $entity->tags[0]; + $result = $marshall->merge($entity, $data, $options); + $this->assertEquals($data['title'], $result->title); + $this->assertEquals('My content', $result->body); + $this->assertSame($tag1, $entity->tags[0]); + $this->assertSame($tag1->_joinData, $entity->tags[0]->_joinData); + $this->assertSame( + ['active' => 0, 'foo' => 'bar'], + $entity->tags[0]->_joinData->toArray() + ); + $this->assertSame( + ['active' => 1, 'foo' => 'baz'], + $entity->tags[1]->_joinData->toArray() + ); + $this->assertEquals('new tag', $entity->tags[1]->tag); + $this->assertTrue($entity->tags[0]->dirty('_joinData')); + $this->assertTrue($entity->tags[1]->dirty('_joinData')); + } + + /** + * Test merging associations inside _joinData + * + * @return void + */ + public function testMergeJoinDataAssociations() + { + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => 1, + 'tags' => [ + [ + 'id' => 1, + 'tag' => 'news', + '_joinData' => [ + 'active' => 0, + 'user' => ['username' => 'Bill'] + ] + ], + [ + 'id' => 2, + 'tag' => 'cakephp', + '_joinData' => [ + 'active' => 0 + ] + ], + ] + ]; + + $articlesTags = EndpointRegistry::get('ArticlesTags'); + $articlesTags->belongsTo('Users'); + + $options = ['associated' => ['Tags._joinData.Users']]; + $marshall = new Marshaller($this->articles); + $entity = $marshall->one($data, $options); + $entity->accessible('*', true); + + $data = [ + 'title' => 'Haz data', + 'tags' => [ + [ + 'id' => 1, + 'tag' => 'news', + '_joinData' => [ + 'foo' => 'bar', + 'user' => ['password' => 'secret'] + ] + ], + [ + 'id' => 2, + '_joinData' => [ + 'active' => 1, + 'foo' => 'baz', + 'user' => ['username' => 'ber'] + ] + ] + ] + ]; + + $tag1 = $entity->tags[0]; + $result = $marshall->merge($entity, $data, $options); + $this->assertEquals($data['title'], $result->title); + $this->assertEquals('My content', $result->body); + $this->assertSame($tag1, $entity->tags[0]); + $this->assertSame($tag1->_joinData, $entity->tags[0]->_joinData); + $this->assertEquals('Bill', $entity->tags[0]->_joinData->user->username); + $this->assertEquals('secret', $entity->tags[0]->_joinData->user->password); + $this->assertEquals('ber', $entity->tags[1]->_joinData->user->username); + } + + /** + * Tests that merging belongsToMany association doesn't erase _joinData + * on existing objects. + * + * @return void + */ + public function testMergeBelongsToManyIdsRetainJoinData() + { + $this->articles->belongsToMany('Tags'); + $entity = $this->articles->get(1, ['contain' => ['Tags']]); + $entity->accessible('*', true); + $original = $entity->tags[0]->_joinData; + + $this->assertInstanceOf('Muffin\Webservice\Model\Resource', $entity->tags[0]->_joinData); + + $data = [ + 'title' => 'Haz moar tags', + 'tags' => [ + ['id' => 1], + ] + ]; + $marshall = new Marshaller($this->articles); + $result = $marshall->merge($entity, $data, ['associated' => ['Tags']]); + + $this->assertCount(1, $result->tags); + $this->assertInstanceOf('Muffin\Webservice\Model\Resource', $result->tags[0]); + $this->assertInstanceOf('Muffin\Webservice\Model\Resource', $result->tags[0]->_joinData); + $this->assertSame($original, $result->tags[0]->_joinData, 'Should be same object'); + } + + /** + * Test mergeMany() with a simple set of data. + * + * @return void + */ + public function testMergeManySimple() + { + $entities = [ + new OpenResource(['id' => 1, 'comment' => 'First post', 'user_id' => 2]), + new OpenResource(['id' => 2, 'comment' => 'Second post', 'user_id' => 2]) + ]; + $entities[0]->clean(); + $entities[1]->clean(); + + $data = [ + ['id' => 2, 'comment' => 'Changed 2', 'user_id' => 2], + ['id' => 1, 'comment' => 'Changed 1', 'user_id' => 1] + ]; + $marshall = new Marshaller($this->comments); + $result = $marshall->mergeMany($entities, $data); + + $this->assertSame($entities[0], $result[0]); + $this->assertSame($entities[1], $result[1]); + $this->assertEquals('Changed 1', $result[0]->comment); + $this->assertEquals(1, $result[0]->user_id); + $this->assertEquals('Changed 2', $result[1]->comment); + $this->assertTrue($result[0]->dirty('user_id')); + $this->assertFalse($result[1]->dirty('user_id')); + } + + /** + * Test mergeMany() with some invalid data + * + * @return void + */ + public function testMergeManyInvalidData() + { + $entities = [ + new OpenResource(['id' => 1, 'comment' => 'First post', 'user_id' => 2]), + new OpenResource(['id' => 2, 'comment' => 'Second post', 'user_id' => 2]) + ]; + $entities[0]->clean(); + $entities[1]->clean(); + + $data = [ + ['id' => 2, 'comment' => 'Changed 2', 'user_id' => 2], + ['id' => 1, 'comment' => 'Changed 1', 'user_id' => 1], + '_csrfToken' => 'abc123', + ]; + $marshall = new Marshaller($this->comments); + $result = $marshall->mergeMany($entities, $data); + + $this->assertSame($entities[0], $result[0]); + $this->assertSame($entities[1], $result[1]); + } + + /** + * Tests that only records found in the data array are returned, those that cannot + * be matched are discarded + * + * @return void + */ + public function testMergeManyWithAppend() + { + $entities = [ + new OpenResource(['comment' => 'First post', 'user_id' => 2]), + new OpenResource(['id' => 2, 'comment' => 'Second post', 'user_id' => 2]) + ]; + $entities[0]->clean(); + $entities[1]->clean(); + + $data = [ + ['id' => 2, 'comment' => 'Changed 2', 'user_id' => 2], + ['id' => 1, 'comment' => 'Comment 1', 'user_id' => 1] + ]; + $marshall = new Marshaller($this->comments); + $result = $marshall->mergeMany($entities, $data); + + $this->assertCount(2, $result); + $this->assertNotSame($entities[0], $result[0]); + $this->assertSame($entities[1], $result[0]); + $this->assertEquals('Changed 2', $result[0]->comment); + + $this->assertEquals('Comment 1', $result[1]->comment); + } + + /** + * Test that mergeMany() handles composite key associations properly. + * + * The articles_tags table has a composite primary key, and should be + * handled correctly. + * + * @return void + */ + public function testMergeManyCompositeKey() + { + $articlesTags = EndpointRegistry::get('ArticlesTags'); + + $entities = [ + new OpenResource(['article_id' => 1, 'tag_id' => 2]), + new OpenResource(['article_id' => 1, 'tag_id' => 1]), + ]; + $entities[0]->clean(); + $entities[1]->clean(); + + $data = [ + ['article_id' => 1, 'tag_id' => 1], + ['article_id' => 1, 'tag_id' => 2] + ]; + $marshall = new Marshaller($articlesTags); + $result = $marshall->mergeMany($entities, $data); + + $this->assertCount(2, $result, 'Should have two records'); + $this->assertSame($entities[0], $result[0], 'Should retain object'); + $this->assertSame($entities[1], $result[1], 'Should retain object'); + } + + /** + * Test mergeMany() with forced contain to ensure aliases are used in queries. + * + * @return void + */ + public function testMergeManyExistingQueryAliases() + { + $entities = [ + new OpenResource(['id' => 1, 'comment' => 'First post', 'user_id' => 2], ['markClean' => true]), + ]; + + $data = [ + ['id' => 1, 'comment' => 'Changed 1', 'user_id' => 1], + ['id' => 2, 'comment' => 'Changed 2', 'user_id' => 2], + ]; + $this->comments->eventManager()->on('Model.beforeFind', function ($event, $query) { + return $query->contain(['Articles']); + }); + $marshall = new Marshaller($this->comments); + $result = $marshall->mergeMany($entities, $data); + + $this->assertSame($entities[0], $result[0]); + } + + /** + * Test mergeMany() when the exist check returns nothing. + * + * @return void + */ + public function testMergeManyExistQueryFails() + { + $entities = [ + new Resource(['id' => 1, 'comment' => 'First post', 'user_id' => 2]), + new Resource(['id' => 2, 'comment' => 'Second post', 'user_id' => 2]) + ]; + $entities[0]->clean(); + $entities[1]->clean(); + + $data = [ + ['id' => 2, 'comment' => 'Changed 2', 'user_id' => 2], + ['id' => 1, 'comment' => 'Changed 1', 'user_id' => 1], + ['id' => 3, 'comment' => 'New 1'], + ]; + $comments = EndpointRegistry::get('GreedyComments', [ + 'className' => __NAMESPACE__ . '\\GreedyCommentsEndpoint' + ]); + $marshall = new Marshaller($comments); + $result = $marshall->mergeMany($entities, $data); + + $this->assertCount(3, $result); + $this->assertEquals('Changed 1', $result[0]->comment); + $this->assertEquals(1, $result[0]->user_id); + $this->assertEquals('Changed 2', $result[1]->comment); + $this->assertEquals('New 1', $result[2]->comment); + } + + /** + * Tests that it is possible to pass a fieldList option to the marshaller + * + * @return void + */ + public function testOneWithFieldList() + { + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => null + ]; + $marshall = new Marshaller($this->articles); + $result = $marshall->one($data, ['fieldList' => ['title', 'author_id']]); + + $this->assertInstanceOf('Muffin\Webservice\Model\Resource', $result); + unset($data['body']); + $this->assertEquals($data, $result->toArray()); + } + + /** + * Tests that it is possible to pass a fieldList option to the merge method + * + * @return void + */ + public function testMergeWithFieldList() + { + $data = [ + 'title' => 'My title', + 'body' => null, + 'author_id' => 1 + ]; + $marshall = new Marshaller($this->articles); + $entity = new Resource([ + 'title' => 'Foo', + 'body' => 'My content', + 'author_id' => 2 + ]); + $entity->accessible('*', false); + $entity->isNew(false); + $entity->clean(); + $result = $marshall->merge($entity, $data, ['fieldList' => ['title', 'body']]); + + $expected = [ + 'title' => 'My title', + 'body' => null, + 'author_id' => 2 + ]; + + $this->assertSame($entity, $result); + $this->assertEquals($expected, $result->toArray()); + $this->assertFalse($entity->accessible('*')); + } + + /** + * Test that many() also receives a fieldList option + * + * @return void + */ + public function testManyFieldList() + { + $data = [ + ['comment' => 'First post', 'user_id' => 2, 'foo' => 'bar'], + ['comment' => 'Second post', 'user_id' => 2, 'foo' => 'bar'], + ]; + $marshall = new Marshaller($this->comments); + $result = $marshall->many($data, ['fieldList' => ['comment', 'user_id']]); + + $this->assertCount(2, $result); + unset($data[0]['foo'], $data[1]['foo']); + $this->assertEquals($data[0], $result[0]->toArray()); + $this->assertEquals($data[1], $result[1]->toArray()); + } + + /** + * Test that many() also receives a fieldList option + * + * @return void + */ + public function testMergeManyFieldList() + { + $entities = [ + new OpenResource(['id' => 1, 'comment' => 'First post', 'user_id' => 2]), + new OpenResource(['id' => 2, 'comment' => 'Second post', 'user_id' => 2]) + ]; + $entities[0]->clean(); + $entities[1]->clean(); + + $data = [ + ['id' => 2, 'comment' => 'Changed 2', 'user_id' => 10], + ['id' => 1, 'comment' => 'Changed 1', 'user_id' => 20] + ]; + $marshall = new Marshaller($this->comments); + $result = $marshall->mergeMany($entities, $data, ['fieldList' => ['id', 'comment']]); + + $this->assertSame($entities[0], $result[0]); + $this->assertSame($entities[1], $result[1]); + + $expected = ['id' => 2, 'comment' => 'Changed 2', 'user_id' => 2]; + $this->assertEquals($expected, $entities[1]->toArray()); + + $expected = ['id' => 1, 'comment' => 'Changed 1', 'user_id' => 2]; + $this->assertEquals($expected, $entities[0]->toArray()); + } + + /** + * test marshalling association data while passing a fieldList + * + * @return void + */ + public function testAssociatoinsFieldList() + { + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => 1, + 'user' => [ + 'username' => 'mark', + 'password' => 'secret', + 'foo' => 'bar' + ] + ]; + $marshall = new Marshaller($this->articles); + $result = $marshall->one($data, [ + 'fieldList' => ['title', 'body', 'user'], + 'associated' => [ + 'Users' => ['fieldList' => ['username', 'foo']] + ] + ]); + + $this->assertEquals($data['title'], $result->title); + $this->assertEquals($data['body'], $result->body); + $this->assertNull($result->author_id); + + $this->assertInstanceOf('Muffin\Webservice\Model\Resource', $result->user); + $this->assertEquals($data['user']['username'], $result->user->username); + $this->assertNull($result->user->password); + } + + /** + * Tests merging associated data with a fieldList + * + * @return void + */ + public function testMergeAssociationWithfieldList() + { + $user = new Resource([ + 'username' => 'mark', + 'password' => 'secret' + ]); + $entity = new Resource([ + 'tile' => 'My Title', + 'user' => $user + ]); + $user->accessible('*', true); + $entity->accessible('*', true); + + $data = [ + 'body' => 'My Content', + 'something' => 'else', + 'user' => [ + 'password' => 'not a secret', + 'extra' => 'data' + ] + ]; + $marshall = new Marshaller($this->articles); + $marshall->merge($entity, $data, [ + 'fieldList' => ['something'], + 'associated' => ['Users' => ['fieldList' => ['extra']]] + ]); + $this->assertNull($entity->body); + $this->assertEquals('else', $entity->something); + $this->assertSame($user, $entity->user); + $this->assertEquals('mark', $entity->user->username); + $this->assertEquals('secret', $entity->user->password); + $this->assertEquals('data', $entity->user->extra); + $this->assertTrue($entity->dirty('user')); + } + + /** + * Test marshalling nested associations on the _joinData structure + * while having a fieldList + * + * @return void + */ + public function testJoinDataWhiteList() + { + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => 1, + 'tags' => [ + [ + 'tag' => 'news', + '_joinData' => [ + 'active' => 1, + 'crazy' => 'data', + 'user' => ['username' => 'Bill'], + ] + ], + [ + 'tag' => 'cakephp', + '_joinData' => [ + 'active' => 0, + 'crazy' => 'stuff', + 'user' => ['username' => 'Mark'], + ] + ], + ], + ]; + + $articlesTags = EndpointRegistry::get('ArticlesTags'); + $articlesTags->belongsTo('Users'); + + $marshall = new Marshaller($this->articles); + $result = $marshall->one($data, [ + 'associated' => [ + 'Tags._joinData' => ['fieldList' => ['active', 'user']], + 'Tags._joinData.Users' + ] + ]); + $this->assertInstanceOf( + 'Muffin\Webservice\Model\Resource', + $result->tags[0]->_joinData->user, + 'joinData should contain a user entity.' + ); + $this->assertEquals('Bill', $result->tags[0]->_joinData->user->username); + $this->assertInstanceOf( + 'Muffin\Webservice\Model\Resource', + $result->tags[1]->_joinData->user, + 'joinData should contain a user entity.' + ); + $this->assertEquals('Mark', $result->tags[1]->_joinData->user->username); + + $this->assertNull($result->tags[0]->_joinData->crazy); + $this->assertNull($result->tags[1]->_joinData->crazy); + } + + /** + * Test merging the _joinData entity for belongstomany associations + * while passing a whitelist + * + * @return void + */ + public function testMergeJoinDataWithFieldList() + { + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => 1, + 'tags' => [ + [ + 'id' => 1, + 'tag' => 'news', + '_joinData' => [ + 'active' => 0 + ] + ], + [ + 'id' => 2, + 'tag' => 'cakephp', + '_joinData' => [ + 'active' => 0 + ] + ], + ], + ]; + + $options = ['associated' => ['Tags' => ['associated' => ['_joinData']]]]; + $marshall = new Marshaller($this->articles); + $entity = $marshall->one($data, $options); + $entity->accessible('*', true); + + $data = [ + 'title' => 'Haz data', + 'tags' => [ + ['id' => 1, 'tag' => 'Cake', '_joinData' => ['foo' => 'bar', 'crazy' => 'something']], + ['tag' => 'new tag', '_joinData' => ['active' => 1, 'foo' => 'baz']] + ] + ]; + + $tag1 = $entity->tags[0]; + $result = $marshall->merge($entity, $data, [ + 'associated' => ['Tags._joinData' => ['fieldList' => ['foo']]] + ]); + $this->assertEquals($data['title'], $result->title); + $this->assertEquals('My content', $result->body); + $this->assertSame($tag1, $entity->tags[0]); + $this->assertSame($tag1->_joinData, $entity->tags[0]->_joinData); + $this->assertSame( + ['active' => 0, 'foo' => 'bar'], + $entity->tags[0]->_joinData->toArray() + ); + $this->assertSame( + ['foo' => 'baz'], + $entity->tags[1]->_joinData->toArray() + ); + $this->assertEquals('new tag', $entity->tags[1]->tag); + $this->assertTrue($entity->tags[0]->dirty('_joinData')); + $this->assertTrue($entity->tags[1]->dirty('_joinData')); + } + + /** + * Tests marshalling with validation errors + * + * @return void + */ + public function testValidationFail() + { + $data = [ + 'title' => 'Thing', + 'body' => 'hey' + ]; + + $this->articles->validator()->requirePresence('thing'); + $marshall = new Marshaller($this->articles); + $entity = $marshall->one($data); + $this->assertNotEmpty($entity->errors('thing')); + } + + /** + * Test that invalid validate options raise exceptions + * + * @expectedException \RuntimeException + * @return void + */ + public function testValidateInvalidType() + { + $data = ['title' => 'foo']; + $marshaller = new Marshaller($this->articles); + $marshaller->one($data, [ + 'validate' => ['derp'], + ]); + } + + /** + * Tests that associations are validated and custom validators can be used + * + * @return void + */ + public function testValidateWithAssociationsAndCustomValidator() + { + $data = [ + 'title' => 'foo', + 'body' => 'bar', + 'user' => [ + 'name' => 'Susan' + ], + 'comments' => [ + [ + 'comment' => 'foo' + ] + ] + ]; + $validator = (new Validator)->add('body', 'numeric', ['rule' => 'numeric']); + $this->articles->validator('custom', $validator); + + $validator2 = (new Validator)->requirePresence('thing'); + $this->articles->Users->validator('customThing', $validator2); + + $this->articles->Comments->validator('default', $validator2); + + $entity = (new Marshaller($this->articles))->one($data, [ + 'validate' => 'custom', + 'associated' => ['Users', 'Comments'] + ]); + $this->assertNotEmpty($entity->errors('body'), 'custom was not used'); + $this->assertNull($entity->body); + $this->assertEmpty($entity->user->errors('thing')); + $this->assertNotEmpty($entity->comments[0]->errors('thing')); + + $entity = (new Marshaller($this->articles))->one($data, [ + 'validate' => 'custom', + 'associated' => ['Users' => ['validate' => 'customThing'], 'Comments'] + ]); + $this->assertNotEmpty($entity->errors('body')); + $this->assertNull($entity->body); + $this->assertNotEmpty($entity->user->errors('thing'), 'customThing was not used'); + $this->assertNotEmpty($entity->comments[0]->errors('thing')); + } + + /** + * Tests that validation can be bypassed + * + * @return void + */ + public function testSkipValidation() + { + $data = [ + 'title' => 'foo', + 'body' => 'bar', + 'user' => [ + 'name' => 'Susan' + ], + ]; + $validator = (new Validator)->requirePresence('thing'); + $this->articles->validator('default', $validator); + $this->articles->Users->validator('default', $validator); + + $entity = (new Marshaller($this->articles))->one($data, [ + 'validate' => false, + 'associated' => ['Users'] + ]); + $this->assertEmpty($entity->errors('thing')); + $this->assertNotEmpty($entity->user->errors('thing')); + + $entity = (new Marshaller($this->articles))->one($data, [ + 'associated' => ['Users' => ['validate' => false]] + ]); + $this->assertNotEmpty($entity->errors('thing')); + $this->assertEmpty($entity->user->errors('thing')); + } + + /** + * Tests that it is possible to pass a validator directly in the options + * + * @return void + */ + public function testPassingCustomValidator() + { + $data = [ + 'title' => 'Thing', + 'body' => 'hey' + ]; + + $validator = clone $this->articles->validator(); + $validator->requirePresence('thing'); + $marshall = new Marshaller($this->articles); + $entity = $marshall->one($data, ['validate' => $validator]); + $this->assertNotEmpty($entity->errors('thing')); + } + + /** + * Tests that invalid property is being filled when data cannot be patched into an entity. + * + * @return void + */ + public function testValidationWithInvalidFilled() + { + $data = [ + 'title' => 'foo', + 'number' => 'bar', + ]; + $validator = (new Validator)->add('number', 'numeric', ['rule' => 'numeric']); + $marshall = new Marshaller($this->articles); + $entity = $marshall->one($data, ['validate' => $validator]); + $this->assertNotEmpty($entity->errors('number')); + $this->assertNull($entity->number); + $this->assertSame(['number' => 'bar'], $entity->invalid()); + } + + /** + * Test merge with validation error + * + * @return void + */ + public function testMergeWithValidation() + { + $data = [ + 'title' => 'My title', + 'author_id' => 'foo', + ]; + $marshall = new Marshaller($this->articles); + $entity = new Resource([ + 'id' => 1, + 'title' => 'Foo', + 'body' => 'My Content', + 'author_id' => 1 + ]); + $this->assertEmpty($entity->invalid()); + + $entity->accessible('*', true); + $entity->isNew(false); + $entity->clean(); + + $this->articles->validator() + ->requirePresence('thing', 'update') + ->requirePresence('id', 'update') + ->add('author_id', 'numeric', ['rule' => 'numeric']) + ->add('id', 'numeric', ['rule' => 'numeric', 'on' => 'update']); + + $expected = clone $entity; + $result = $marshall->merge($expected, $data, []); + + $this->assertSame($expected, $result); + $this->assertSame(1, $result->author_id); + $this->assertNotEmpty($result->errors('thing')); + $this->assertEmpty($result->errors('id')); + + $this->articles->validator()->requirePresence('thing', 'create'); + $result = $marshall->merge($entity, $data, []); + + $this->assertEmpty($result->errors('thing')); + $this->assertSame(['author_id' => 'foo'], $result->invalid()); + } + + /** + * Test merge with validation and create or update validation rules + * + * @return void + */ + public function testMergeWithCreate() + { + $data = [ + 'title' => 'My title', + 'author_id' => 'foo', + ]; + $marshall = new Marshaller($this->articles); + $entity = new Resource([ + 'title' => 'Foo', + 'body' => 'My Content', + 'author_id' => 1 + ]); + $entity->accessible('*', true); + $entity->isNew(true); + $entity->clean(); + + $this->articles->validator() + ->requirePresence('thing', 'update') + ->add('author_id', 'numeric', ['rule' => 'numeric', 'on' => 'update']); + + $expected = clone $entity; + $result = $marshall->merge($expected, $data, []); + + $this->assertEmpty($result->errors('author_id')); + $this->assertEmpty($result->errors('thing')); + + $entity->clean(); + $entity->isNew(false); + $result = $marshall->merge($entity, $data, []); + $this->assertNotEmpty($result->errors('author_id')); + $this->assertNotEmpty($result->errors('thing')); + } + + /** + * Test Model.beforeMarshal event. + * + * @return void + */ + public function testBeforeMarshalEvent() + { + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'user' => [ + 'name' => 'Robert', + 'username' => 'rob' + ] + ]; + + $marshall = new Marshaller($this->articles); + + $this->articles->eventManager()->attach(function ($e, $data, $options) { + $data['title'] = 'Modified title'; + $data['user']['username'] = 'robert'; + + $options['associated'] = ['Users']; + }, 'Model.beforeMarshal'); + + $entity = $marshall->one($data); + + $this->assertEquals('Modified title', $entity->title); + $this->assertEquals('My content', $entity->body); + $this->assertEquals('Robert', $entity->user->name); + $this->assertEquals('robert', $entity->user->username); + } + + /** + * Test Model.beforeMarshal event on associated tables. + * + * @return void + */ + public function testBeforeMarshalEventOnAssociations() + { + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => 1, + 'user' => [ + 'username' => 'mark', + 'password' => 'secret' + ], + 'comments' => [ + ['comment' => 'First post', 'user_id' => 2], + ['comment' => 'Second post', 'user_id' => 2], + ], + 'tags' => [ + ['tag' => 'news', '_joinData' => ['active' => 1]], + ['tag' => 'cakephp', '_joinData' => ['active' => 0]], + ], + ]; + + $marshall = new Marshaller($this->articles); + + $this->articles->users->eventManager()->attach(function ($e, $data) { + $data['secret'] = 'h45h3d'; + }, 'Model.beforeMarshal'); + + $this->articles->comments->eventManager()->attach(function ($e, $data) { + $data['comment'] .= ' (modified)'; + }, 'Model.beforeMarshal'); + + $this->articles->tags->eventManager()->attach(function ($e, $data) { + $data['tag'] .= ' (modified)'; + }, 'Model.beforeMarshal'); + + $this->articles->tags->junction()->eventManager()->attach(function ($e, $data) { + $data['modified_by'] = 1; + }, 'Model.beforeMarshal'); + + $entity = $marshall->one($data, [ + 'associated' => ['Users', 'Comments', 'Tags'] + ]); + + $this->assertEquals('h45h3d', $entity->user->secret); + $this->assertEquals('First post (modified)', $entity->comments[0]->comment); + $this->assertEquals('Second post (modified)', $entity->comments[1]->comment); + $this->assertEquals('news (modified)', $entity->tags[0]->tag); + $this->assertEquals('cakephp (modified)', $entity->tags[1]->tag); + $this->assertEquals(1, $entity->tags[0]->_joinData->modified_by); + $this->assertEquals(1, $entity->tags[1]->_joinData->modified_by); + } + + /** + * Tests that patching an association resulting in no changes, will + * not mark the parent entity as dirty + * + * @return void + */ + public function testAssociationNoChanges() + { + $options = ['markClean' => true, 'isNew' => false]; + $entity = new Resource([ + 'title' => 'My Title', + 'user' => new Resource([ + 'username' => 'mark', + 'password' => 'not a secret' + ], $options) + ], $options); + + $data = [ + 'body' => 'My Content', + 'user' => [ + 'username' => 'mark', + 'password' => 'not a secret' + ] + ]; + $marshall = new Marshaller($this->articles); + $marshall->merge($entity, $data, ['associated' => ['Users']]); + $this->assertEquals('My Content', $entity->body); + $this->assertInstanceOf('Muffin\Webservice\Model\Resource', $entity->user); + $this->assertEquals('mark', $entity->user->username); + $this->assertEquals('not a secret', $entity->user->password); + $this->assertFalse($entity->dirty('user')); + $this->assertTrue($entity->user->isNew()); + } +} diff --git a/tests/TestCase/Model/EndpointTest.php b/tests/TestCase/Model/EndpointTest.php index 756038b..aa53222 100644 --- a/tests/TestCase/Model/EndpointTest.php +++ b/tests/TestCase/Model/EndpointTest.php @@ -7,12 +7,11 @@ use Muffin\Webservice\Model\Endpoint; use Muffin\Webservice\Model\Resource; use Muffin\Webservice\Test\test_app\Model\Endpoint\AppEndpoint; -use Muffin\Webservice\Test\test_app\Model\Endpoint\TestEndpoint; +use Muffin\Webservice\Test\test_app\Model\Endpoint\PostsEndpoint; use SomeVendor\SomePlugin\Model\Endpoint\PluginEndpoint; class EndpointTest extends TestCase { - /** * @var Endpoint */ @@ -25,13 +24,14 @@ public function setUp() { parent::setUp(); - $this->endpoint = new TestEndpoint([ + $this->endpoint = new PostsEndpoint([ 'connection' => new Connection([ 'name' => 'test', 'service' => 'Test' ]), 'primaryKey' => 'id', - 'displayField' => 'title' + 'displayField' => 'title', + 'alias' => 'Posts' ]); } @@ -50,7 +50,8 @@ public function testFindByTitle() 'body' => 'Even more text' ], [ 'markNew' => false, - 'markClean' => true + 'markClean' => true, + 'source' => 'Posts' ]), $this->endpoint->findByTitle('Webservices')->first()); } @@ -166,6 +167,8 @@ public function testNewEntity() $this->assertEquals(new Resource([ 'title' => 'New entity', 'body' => 'New entity body' + ], [ + 'source' => 'Posts' ]), $this->endpoint->newEntity([ 'title' => 'New entity', 'body' => 'New entity body' @@ -178,10 +181,14 @@ public function testNewEntities() new Resource([ 'title' => 'New entity', 'body' => 'New entity body' + ], [ + 'source' => 'Posts' ]), new Resource([ 'title' => 'Second new entity', 'body' => 'Second new entity body' + ], [ + 'source' => 'Posts' ]) ], $this->endpoint->newEntities([ [ @@ -197,7 +204,7 @@ public function testNewEntities() public function testDefaultConnectionName() { - $this->assertEquals('test_app', AppEndpoint::defaultConnectionName()); + $this->assertEquals('app', AppEndpoint::defaultConnectionName()); $this->assertEquals('some_plugin', PluginEndpoint::defaultConnectionName()); } } diff --git a/tests/TestCase/QueryTest.php b/tests/TestCase/QueryTest.php index e52a3d4..a4dac2c 100644 --- a/tests/TestCase/QueryTest.php +++ b/tests/TestCase/QueryTest.php @@ -8,6 +8,7 @@ use Muffin\Webservice\Query; use Muffin\Webservice\ResultSet; use Muffin\Webservice\Test\test_app\Webservice\StaticWebservice; +use Muffin\Webservice\WebserviceResultSet; class QueryTest extends TestCase { @@ -24,7 +25,11 @@ public function setUp() { parent::setUp(); - $this->query = new Query(new StaticWebservice(), new Endpoint()); + $webservice = new StaticWebservice(); + $this->query = new Query($webservice, new Endpoint([ + 'webservice' => $webservice, + 'alias' => 'Tests' + ])); } public function testAction() @@ -52,7 +57,7 @@ public function testActionMethods() public function testAliasField() { - $this->assertEquals(['field' => 'field'], $this->query->aliasField('field')); + $this->assertEquals(['Tests__field' => 'Tests.field'], $this->query->aliasField('field')); } public function testCountNonReadAction() @@ -72,6 +77,10 @@ public function testFirst() $this->assertEquals(new Resource([ 'id' => 1, 'title' => 'Hello World' + ], [ + 'markNew' => false, + 'markClean' => true, + 'source' => 'Tests' ]), $this->query->first()); } @@ -170,19 +179,19 @@ public function testExecuteTwice() ]); $mockWebservice->expects($this->once()) ->method('execute') - ->will($this->returnValue(new ResultSet([ - new Resource([ - 'id' => 1, - 'title' => 'Hello World' - ]), - new Resource([ - 'id' => 2, - 'title' => 'New ORM' - ]), - new Resource([ - 'id' => 3, - 'title' => 'Webservices' - ]) + ->will($this->returnValue(new WebserviceResultSet([ + [ + $this->query->endpoint()->alias() . '__id' => 1, + $this->query->endpoint()->alias() . '__title' => 'Hello World' + ], + [ + $this->query->endpoint()->alias() . '__id' => 2, + $this->query->endpoint()->alias() . '__title' => 'New ORM' + ], + [ + $this->query->endpoint()->alias() . '__id' => 3, + $this->query->endpoint()->alias() . '__title' => 'Webservices' + ] ], 3))); $this->query->webservice($mockWebservice); @@ -194,10 +203,15 @@ public function testExecuteTwice() public function testDebugInfo() { + $webservice = new StaticWebservice(); + $this->assertEquals([ '(help)' => 'This is a Query object, to get the results execute or iterate it.', 'action' => null, 'formatters' => [], + 'mapReducers' => 0, + 'contain' => [], + 'matching' => [], 'offset' => null, 'page' => null, 'limit' => null, @@ -205,8 +219,11 @@ public function testDebugInfo() 'sort' => [], 'extraOptions' => [], 'conditions' => [], - 'repository' => new Endpoint(), - 'webservice' => new StaticWebservice() + 'repository' => new Endpoint([ + 'webservice' => $webservice, + 'alias' => 'Tests' + ]), + 'webservice' => $webservice ], $this->query->__debugInfo()); } diff --git a/tests/TestCase/ResultSetTest.php b/tests/TestCase/ResultSetTest.php index de7b2f4..a757d35 100644 --- a/tests/TestCase/ResultSetTest.php +++ b/tests/TestCase/ResultSetTest.php @@ -2,13 +2,16 @@ namespace Muffin\Webservice\Test\TestCase; +use ArrayIterator; use Cake\TestSuite\TestCase; +use Muffin\Webservice\Model\Endpoint; use Muffin\Webservice\Model\Resource; +use Muffin\Webservice\Query; use Muffin\Webservice\ResultSet; +use Muffin\Webservice\Test\test_app\Webservice\StaticWebservice; class ResultSetTest extends TestCase { - /** * @var ResultSet */ @@ -21,20 +24,26 @@ public function setUp() { parent::setUp(); - $this->resultSet = new ResultSet([ - new Resource([ - 'id' => 1, - 'title' => 'Hello World' - ]), - new Resource([ - 'id' => 2, - 'title' => 'New ORM' - ]), - new Resource([ - 'id' => 3, - 'title' => 'Webservices' - ]) - ], 6); + $webservice = new StaticWebservice(); + $query = new Query($webservice, new Endpoint([ + 'webservice' => $webservice, + 'alias' => 'Test' + ])); + + $this->resultSet = new ResultSet($query, new ArrayIterator([ + [ + $query->endpoint()->alias() . '__id' => 1, + $query->endpoint()->alias() . '__title' => 'Hello World' + ], + [ + $query->endpoint()->alias() . '__id' => 2, + $query->endpoint()->alias() . '__title' => 'New ORM' + ], + [ + $query->endpoint()->alias() . '__id' => 3, + $query->endpoint()->alias() . '__title' => 'Webservices' + ] + ]), 6); } public function testCount() diff --git a/tests/TestCase/Webservice/WebserviceTest.php b/tests/TestCase/Webservice/WebserviceTest.php index fc9eedb..11889e6 100644 --- a/tests/TestCase/Webservice/WebserviceTest.php +++ b/tests/TestCase/Webservice/WebserviceTest.php @@ -3,10 +3,12 @@ namespace Muffin\Webservice\Test\TestCase\Webservice; use Cake\TestSuite\TestCase; +use Muffin\Webservice\Exception\UnimplementedWebserviceMethodException; use Muffin\Webservice\Model\Endpoint; use Muffin\Webservice\Query; use Muffin\Webservice\Test\test_app\Webservice\Driver\Test; use Muffin\Webservice\Test\test_app\Webservice\TestWebservice; +use RuntimeException; class WebserviceTest extends TestCase { @@ -70,6 +72,7 @@ public function testExecuteWithoutDriver() $webservice = new TestWebservice(); $query = new Query($webservice, new Endpoint()); + $query->read(); $webservice->execute($query); } @@ -78,7 +81,10 @@ public function testExecuteLoggingWithoutLogger() { $query = new Query($this->webservice, new Endpoint()); - $this->webservice->execute($query); + try { + $this->webservice->execute($query); + } catch (RuntimeException $exception) { + } } public function testExecuteLoggingWithLogger() @@ -96,7 +102,10 @@ public function testExecuteLoggingWithLogger() $query = new Query($this->webservice, new Endpoint()); - $this->webservice->execute($query); + try{ + $this->webservice->execute($query); + } catch (RuntimeException $exception) { + } } public function testExecuteLoggingWithLoggerEnabled() @@ -115,7 +124,10 @@ public function testExecuteLoggingWithLoggerEnabled() $query = new Query($this->webservice, new Endpoint()); - $this->webservice->execute($query); + try{ + $this->webservice->execute($query); + } catch (RuntimeException $exception) { + } } /** @@ -166,19 +178,24 @@ public function testExecuteWithoutDelete() $this->webservice->execute($query); } - public function testCreateResource() + public function testCreateResult() { - /* @var \Muffin\Webservice\Model\Resource $resource */ - $resource = $this->webservice->createResource('\Muffin\Webservice\Model\Resource', []); + $query = new Query($this->webservice, new Endpoint([ + 'webservice' => $this->webservice + ])); + + $resource = $this->webservice->createResult($query, []); - $this->assertInstanceOf('\Muffin\Webservice\Model\Resource', $resource); - $this->assertFalse($resource->isNew()); - $this->assertFalse($resource->dirty()); + $this->assertInternalType('array', $resource); } public function testTransformResults() { - $resources = $this->webservice->transformResults(new Endpoint(), [ + $query = new Query($this->webservice, new Endpoint([ + 'webservice' => $this->webservice + ])); + + $resources = $this->webservice->transformResults($query, [ [ 'id' => 1, 'title' => 'Hello World', @@ -197,7 +214,7 @@ public function testTransformResults() ]); $this->assertInternalType('array', $resources); - $this->assertInstanceOf('\Muffin\Webservice\Model\Resource', $resources[0]); + $this->assertInternalType('array', $resources[0]); } public function testDebugInfo() diff --git a/tests/bootstrap.php b/tests/bootstrap.php index c7c0905..3e37d87 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -91,13 +91,14 @@ } $config = [ - 'url' => getenv('db_dsn'), - 'timezone' => 'UTC', + 'className' => 'Muffin\Webservice\Connection', + 'service' => 'Test', ]; // Use the test connection for 'debug_kit' as well. ConnectionManager::config('test', $config); -ConnectionManager::config('test_webservice', $config); +ConnectionManager::alias('test', 'app'); +ConnectionManager::alias('test', 'test_plugin'); Log::config([ diff --git a/tests/test_app/Model/Endpoint/ArticlesEndpoint.php b/tests/test_app/Model/Endpoint/ArticlesEndpoint.php new file mode 100644 index 0000000..1e5651d --- /dev/null +++ b/tests/test_app/Model/Endpoint/ArticlesEndpoint.php @@ -0,0 +1,57 @@ +belongsTo('authors'); + $this->belongsToMany('tags'); + $this->hasMany('ArticlesTags'); + } + + /** + * Find published + * + * @param \Cake\ORM\Query $query The query + * @return \Cake\ORM\Query + */ + public function findPublished($query) + { + return $query->where(['published' => 'Y']); + } + + /** + * Example public method + * + * @return void + */ + public function doSomething() + { + } + + /** + * Example Secondary public method + * + * @return void + */ + public function doSomethingElse() + { + } + + /** + * Example protected method + * + * @return void + */ + protected function _innerMethod() + { + } +} diff --git a/tests/test_app/Model/Endpoint/ArticlesTagsEndpoint.php b/tests/test_app/Model/Endpoint/ArticlesTagsEndpoint.php new file mode 100644 index 0000000..582aff8 --- /dev/null +++ b/tests/test_app/Model/Endpoint/ArticlesTagsEndpoint.php @@ -0,0 +1,18 @@ +belongsTo('articles'); + $this->belongsTo('tags'); + } +} diff --git a/tests/test_app/Model/Endpoint/TestEndpoint.php b/tests/test_app/Model/Endpoint/PostsEndpoint.php similarity index 95% rename from tests/test_app/Model/Endpoint/TestEndpoint.php rename to tests/test_app/Model/Endpoint/PostsEndpoint.php index e7da831..ac463e1 100644 --- a/tests/test_app/Model/Endpoint/TestEndpoint.php +++ b/tests/test_app/Model/Endpoint/PostsEndpoint.php @@ -5,10 +5,8 @@ use Cake\Validation\Validator; use Muffin\Webservice\Model\Endpoint; -class TestEndpoint extends Endpoint +class PostsEndpoint extends Endpoint { - - /** * Returns the default validator object. Subclasses can override this function * to add a default validation set to the validator object. diff --git a/tests/test_app/Model/Endpoint/Schema/ArticleSchema.php b/tests/test_app/Model/Endpoint/Schema/ArticleSchema.php new file mode 100644 index 0000000..45d7d0d --- /dev/null +++ b/tests/test_app/Model/Endpoint/Schema/ArticleSchema.php @@ -0,0 +1,23 @@ + ['type' => 'integer', 'primaryKey' => true], + 'author_id' => ['type' => 'integer', 'null' => true], + 'title' => ['type' => 'string', 'null' => true], + 'body' => 'text', + 'published' => ['type' => 'string', 'length' => 1, 'default' => 'N'], + ]; + + foreach ($columns as $field => $definition) { + $this->addColumn($field, $definition); + } + } +} diff --git a/tests/test_app/Model/Endpoint/Schema/ArticlesTagSchema.php b/tests/test_app/Model/Endpoint/Schema/ArticlesTagSchema.php new file mode 100644 index 0000000..1633d38 --- /dev/null +++ b/tests/test_app/Model/Endpoint/Schema/ArticlesTagSchema.php @@ -0,0 +1,20 @@ + ['type' => 'integer', 'null' => false, 'primaryKey' => true], + 'tag_id' => ['type' => 'integer', 'null' => false, 'primaryKey' => true], + ]; + + foreach ($columns as $field => $definition) { + $this->addColumn($field, $definition); + } + } +} diff --git a/tests/test_app/Model/Endpoint/Schema/AuthorSchema.php b/tests/test_app/Model/Endpoint/Schema/AuthorSchema.php new file mode 100644 index 0000000..13e46c9 --- /dev/null +++ b/tests/test_app/Model/Endpoint/Schema/AuthorSchema.php @@ -0,0 +1,20 @@ + ['type' => 'integer', 'primaryKey' => true], + 'name' => ['type' => 'string', 'default' => null], + ]; + + foreach ($columns as $field => $definition) { + $this->addColumn($field, $definition); + } + } +} diff --git a/tests/test_app/Model/Endpoint/Schema/CommentSchema.php b/tests/test_app/Model/Endpoint/Schema/CommentSchema.php new file mode 100644 index 0000000..c4670d8 --- /dev/null +++ b/tests/test_app/Model/Endpoint/Schema/CommentSchema.php @@ -0,0 +1,25 @@ + ['type' => 'integer', 'primaryKey' => true], + 'article_id' => ['type' => 'integer', 'null' => false], + 'user_id' => ['type' => 'integer', 'null' => false], + 'comment' => ['type' => 'text'], + 'published' => ['type' => 'string', 'length' => 1, 'default' => 'N'], + 'created' => ['type' => 'datetime'], + 'updated' => ['type' => 'datetime'], + ]; + + foreach ($columns as $field => $definition) { + $this->addColumn($field, $definition); + } + } +} diff --git a/tests/test_app/Model/Endpoint/Schema/TestSchema.php b/tests/test_app/Model/Endpoint/Schema/PostSchema.php similarity index 79% rename from tests/test_app/Model/Endpoint/Schema/TestSchema.php rename to tests/test_app/Model/Endpoint/Schema/PostSchema.php index 0152910..e34ff1a 100644 --- a/tests/test_app/Model/Endpoint/Schema/TestSchema.php +++ b/tests/test_app/Model/Endpoint/Schema/PostSchema.php @@ -4,13 +4,13 @@ use Muffin\Webservice\Model\Schema; -class TestSchema extends Schema +class PostSchema extends Schema { - public function initialize() { $this->addColumn('id', [ - 'type' => 'int' + 'type' => 'int', + 'primaryKey' => true ]); $this->addColumn('title', [ 'type' => 'string' diff --git a/tests/test_app/Model/Endpoint/Schema/SpecialTagSchema.php b/tests/test_app/Model/Endpoint/Schema/SpecialTagSchema.php new file mode 100644 index 0000000..aa55398 --- /dev/null +++ b/tests/test_app/Model/Endpoint/Schema/SpecialTagSchema.php @@ -0,0 +1,24 @@ + ['type' => 'integer', 'primaryKey' => true], + 'article_id' => ['type' => 'integer', 'null' => false], + 'tag_id' => ['type' => 'integer', 'null' => false], + 'highlighted' => ['type' => 'boolean', 'null' => true], + 'highlighted_time' => ['type' => 'timestamp', 'null' => true], + 'author_id' => ['type' => 'integer', 'null' => true], + ]; + + foreach ($columns as $field => $definition) { + $this->addColumn($field, $definition); + } + } +} diff --git a/tests/test_app/Model/Endpoint/Schema/TagSchema.php b/tests/test_app/Model/Endpoint/Schema/TagSchema.php new file mode 100644 index 0000000..1751a28 --- /dev/null +++ b/tests/test_app/Model/Endpoint/Schema/TagSchema.php @@ -0,0 +1,22 @@ + ['type' => 'integer', 'null' => false, 'primaryKey' => true], + 'name' => ['type' => 'string', 'null' => false], + 'description' => ['type' => 'text', 'length' => Table::LENGTH_MEDIUM], + ]; + + foreach ($columns as $field => $definition) { + $this->addColumn($field, $definition); + } + } +} diff --git a/tests/test_app/Model/Endpoint/Schema/UserSchema.php b/tests/test_app/Model/Endpoint/Schema/UserSchema.php new file mode 100644 index 0000000..16411d2 --- /dev/null +++ b/tests/test_app/Model/Endpoint/Schema/UserSchema.php @@ -0,0 +1,23 @@ + ['type' => 'integer', 'primaryKey' => true], + 'username' => ['type' => 'string', 'null' => true], + 'password' => ['type' => 'string', 'null' => true], + 'created' => ['type' => 'timestamp', 'null' => true], + 'updated' => ['type' => 'timestamp', 'null' => true], + ]; + + foreach ($columns as $field => $definition) { + $this->addColumn($field, $definition); + } + } +} diff --git a/tests/test_app/Webservice/EndpointTestWebservice.php b/tests/test_app/Webservice/EndpointTestWebservice.php index 5864e2a..d0f45cb 100644 --- a/tests/test_app/Webservice/EndpointTestWebservice.php +++ b/tests/test_app/Webservice/EndpointTestWebservice.php @@ -2,11 +2,11 @@ namespace Muffin\Webservice\Test\test_app\Webservice; +use Cake\Utility\Hash; use Muffin\Webservice\Model\Resource; use Muffin\Webservice\Query; -use Muffin\Webservice\ResultSet; -use Muffin\Webservice\Schema; use Muffin\Webservice\Webservice\Webservice; +use Muffin\Webservice\WebserviceResultSet; class EndpointTestWebservice extends Webservice { @@ -17,86 +17,194 @@ public function initialize() { parent::initialize(); - $this->resources = [ - new Resource([ + $this->resources['posts'] = [ + [ 'id' => 1, 'title' => 'Hello World', 'body' => 'Some text' - ], [ - 'markNew' => false, - 'markClean' => true - ]), - new Resource([ + ], + [ 'id' => 2, 'title' => 'New ORM', 'body' => 'Some more text' - ], [ - 'markNew' => false, - 'markClean' => true - ]), - new Resource([ + ], + [ 'id' => 3, 'title' => 'Webservices', 'body' => 'Even more text' - ], [ - 'markNew' => false, - 'markClean' => true - ]) + ] ]; } + protected function _executeQuery(Query $query, array $options = []) + { + if (!isset($this->resources[$query->endpoint()->endpoint()])) { + $this->resources[$query->endpoint()->endpoint()] = []; + } + + return parent::_executeQuery($query, $options); + } protected function _executeCreateQuery(Query $query, array $options = []) { $fields = $query->set(); + if (!isset($fields['id'])) { + $highestId = collection($this->resources[$query->endpoint()->endpoint()])->max('id'); + + $fields = ['id' => ($highestId) ? $highestId['id'] + 1: 1] + $fields; + } + if (!is_numeric($fields['id'])) { return false; } - $this->resources[] = new Resource($fields, [ - 'markNew' => false, - 'markClean' => true - ]); + $this->resources[$query->endpoint()->endpoint()][] = $fields; - return true; + return WebserviceResultSet::createForSingleResource($this->_transformResource($query, $fields)); } protected function _executeReadQuery(Query $query, array $options = []) { - if (!empty($query->where()['id'])) { - $index = $this->conditionsToIndex($query->where()); + $endpoint = $query->endpoint()->endpoint(); + + $conditions = $query->where(); + debug($conditions); + foreach ($conditions as $condition => $value) { + $splitCondition = explode('.', $condition); + if ($splitCondition[0] !== $query->endpoint()->alias()) { + continue; + } + + unset($conditions[$condition]); + $conditions[$splitCondition[1]] = $value; + } - if (!isset($this->resources[$index])) { - return new ResultSet([], 0); + $multiKey = false; + $multiKeySize = 0; + foreach ($conditions as $key => $value) { + if (!is_array($value)) { + continue; } - return new ResultSet([ - $this->resources[$index] - ], 1); + $multiKey = true; + $multiKeySize = count($value); + } + + if (array_keys($conditions) !== range(0, count($conditions) - 1)) { + $conditions = [$conditions]; + + $multiKey = true; } - if (isset($query->where()[$query->endpoint()->aliasField('title')])) { - $resources = []; - foreach ($this->resources as $resource) { - if ($resource->title !== $query->where()[$query->endpoint()->aliasField('title')]) { + foreach ($conditions as &$conditionGroup) { + foreach ($conditionGroup as $condition => $value) { + if (is_array($value)) { + debug($conditionGroup); + stackTrace(); + + exit(); + } + $splitCondition = explode('.', $condition); + if ($splitCondition[0] !== $query->endpoint()->alias()) { continue; } - $resources[] = $resource; + unset($conditionGroup[$condition]); + $conditionGroup[$splitCondition[1]] = $value; } + } + +// debug($this->resources[$endpoint]); + $resources = collection($this->resources[$endpoint]); + if ($multiKey) { + $conditionGroups = $conditions; +// foreach ($conditions as $key => $valueGroup) { +// if (is_array($valueGroup)) { +// foreach ($valueGroup as $index => $value) { +// $conditionGroups[$index][$key] = $value; +// } +// } else { +// for ($i = 0; $i < $multiKeySize; $i++) { +// $conditionGroups[$i][$key] = $valueGroup; +// } +// } +// } + $resources = $resources->filter(function ($resource) use ($conditionGroups) { +// debug($resource); + foreach ($conditionGroups as $conditionGroup) { + $match = true; + foreach ($conditionGroup as $key => $value) { + if (substr($key, -2) === '!=') { + if ($resource[substr($key, 0, -3)] != $value) { +// debug('Yep !='); + continue; + } + } elseif (substr($key, -2) === '>=') { +// debug($key); +// debug($resource[substr($key, 0, -3)]); +// debug('>='); +// debug($value); +// debug($resource[substr($key, 0, -3)] >= $value); + if ($resource[substr($key, 0, -3)] >= $value) { +// debug('Yep >='); + continue; + } else { +// debug(substr($key, 0, -3) . ': ' . $resource[substr($key, 0, -3)] . ' >= ' . $value); + } + } else { + if (!isset($resource[$key])) { + debug($key); + debug($resource);exit(); + } + if ($resource[$key] == $value) { +// debug('Match!'); +// debug('Yep =='); + continue; + } else { +// debug($key . ': ' . $resource[$key] . ' == ' . $value); + } + } + + $match = false; + } + +// debug($match); + if ($match) { + return true; + } + } - return new ResultSet($resources, count($resources)); +// debug($resource['id'] . ' didn\'t match'); + return false; + }); +// debug($resources->toList()); + + return new WebserviceResultSet($this->_transformResults($query, $resources->toList()), count($resources->toList())); } - return new ResultSet($this->resources, count($this->resources)); + $resources = $resources->match($conditions); + +// debug($resources->toList()); + + return new WebserviceResultSet($this->_transformResults($query, $resources->toList()), count($resources->toList())); } protected function _executeUpdateQuery(Query $query, array $options = []) { - $this->resources[$this->conditionsToIndex($query->where())]->set($query->set()); + $index = $this->conditionsToIndex($query->where()); - $this->resources[$this->conditionsToIndex($query->where())]->clean(); + debug($query->where()); + + debug($this->resources[$query->endpoint()->endpoint()]); + $resources = collection($this->resources[$query->endpoint()->endpoint()]) + ->match($query->where()) + ->map(function ($resource) use ($query) { + return Hash::merge($resource, $query->set()); + }); + debug($resources->toList()); + + $this->resources[$query->endpoint()->endpoint()][$index] = Hash::merge($this->resources[$query->endpoint()->endpoint()][$index], $query->set()); return 1; } @@ -105,25 +213,61 @@ protected function _executeDeleteQuery(Query $query, array $options = []) { $conditions = $query->where(); - if (is_int($conditions['id'])) { - $exists = isset($this->resources[$this->conditionsToIndex($conditions)]); + $endpoint = $query->endpoint()->endpoint(); + if (count($conditions) === 0) { + $count = $this->resources[$endpoint]; - unset($this->resources[$this->conditionsToIndex($conditions)]); + $this->resources[$endpoint] = []; - return ($exists) ? 1 : 0; - } elseif (is_array($conditions['id'])) { - $deleted = 0; + return $count; + } - foreach ($conditions['id'] as $id) { - if (!isset($this->resources[$id - 1])) { - continue; - } + $conditionSets = []; + foreach ($conditions as $key => $values) { + if (!is_array($values)) { + continue; + } - $deleted++; - unset($this->resources[$id - 1]); + foreach ($values as $index => $value) { + $conditionSets[$index][$key] = $value; } + } + if (count($conditionSets) !== 0) { + $count = 0; + foreach ($conditionSets as $conditionSet) { - return $deleted; + $count += count(collection($this->resources[$endpoint])->match($conditionSet)->each(function ($resource, $index) use ($endpoint) { + unset($this->resources[$endpoint][$index]); + })->toList()); + } + + return $count; + } + return count(collection($this->resources[$endpoint])->match($conditions)->each(function ($resource, $index) use ($endpoint) { + unset($this->resources[$endpoint][$index]); + })->toList()); + + if (isset($conditions['id'])) { + if (is_int($conditions['id'])) { + $exists = isset($this->resources[$endpoint][$this->conditionsToIndex($conditions)]); + + unset($this->resources[$endpoint][$this->conditionsToIndex($conditions)]); + + return ($exists) ? 1 : 0; + } elseif (is_array($conditions['id'])) { + $deleted = 0; + + foreach ($conditions['id'] as $id) { + if (!isset($this->resources[$endpoint][$id - 1])) { + continue; + } + + $deleted++; + unset($this->resources[$endpoint][$id - 1]); + } + + return $deleted; + } } return 0; diff --git a/tests/test_app/Webservice/StaticWebservice.php b/tests/test_app/Webservice/StaticWebservice.php index 22d2054..f736ff0 100644 --- a/tests/test_app/Webservice/StaticWebservice.php +++ b/tests/test_app/Webservice/StaticWebservice.php @@ -7,25 +7,26 @@ use Muffin\Webservice\ResultSet; use Muffin\Webservice\Schema; use Muffin\Webservice\Webservice\WebserviceInterface; +use Muffin\Webservice\WebserviceResultSet; class StaticWebservice implements WebserviceInterface { public function execute(Query $query, array $options = []) { - return new ResultSet([ - new Resource([ - 'id' => 1, - 'title' => 'Hello World' - ]), - new Resource([ - 'id' => 2, - 'title' => 'New ORM' - ]), - new Resource([ - 'id' => 3, - 'title' => 'Webservices' - ]) + return new WebserviceResultSet([ + [ + $query->endpoint()->alias() . '__id' => 1, + $query->endpoint()->alias() . '__title' => 'Hello World' + ], + [ + $query->endpoint()->alias() . '__id' => 2, + $query->endpoint()->alias() . '__title' => 'New ORM' + ], + [ + $query->endpoint()->alias() . '__id' => 3, + $query->endpoint()->alias() . '__title' => 'Webservices' + ] ], 3); } diff --git a/tests/test_app/Webservice/TestWebservice.php b/tests/test_app/Webservice/TestWebservice.php index 331ad64..876ab42 100644 --- a/tests/test_app/Webservice/TestWebservice.php +++ b/tests/test_app/Webservice/TestWebservice.php @@ -2,19 +2,35 @@ namespace Muffin\Webservice\Test\test_app\Webservice; -use Muffin\Webservice\Model\Endpoint; +use Muffin\Webservice\Query; +use Muffin\Webservice\Schema; use Muffin\Webservice\Webservice\Webservice; class TestWebservice extends Webservice { + public function createResult($query, array $data) + { + return $this->_createResult($query, $data); + } - public function createResource($resourceClass, array $properties = []) + public function transformResults(Query $query, array $results) { - return $this->_createResource($resourceClass, $properties); + return $this->_transformResults($query, $results); } - public function transformResults(Endpoint $endpoint, array $results) + public function describe($endpoint) { - return $this->_transformResults($endpoint, $results); + $schema = new Schema($endpoint); + $schema->addColumn('id', [ + 'type' => 'int' + ]); + $schema->addColumn('title', [ + 'type' => 'string' + ]); + $schema->addColumn('body', [ + 'type' => 'string' + ]); + + return $schema; } }