<?php
/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 3.0.0
* @license https://opensource.org/licenses/mit-license.php MIT License
*/
namespace Cake\View\Form;
use ArrayAccess;
use Cake\Collection\Collection;
use Cake\Datasource\EntityInterface;
use Cake\Datasource\RepositoryInterface;
use Cake\Http\ServerRequest;
use Cake\ORM\Locator\LocatorAwareTrait;
use Cake\Utility\Inflector;
use RuntimeException;
use Traversable;
/**
* Provides a form context around a single entity and its relations.
* It also can be used as context around an array or iterator of entities.
*
* This class lets FormHelper interface with entities or collections
* of entities.
*
* Important Keys:
*
* - `entity` The entity this context is operating on.
* - `table` Either the ORM\Table instance to fetch schema/validators
* from, an array of table instances in the case of a form spanning
* multiple entities, or the name(s) of the table.
* If this is null the table name(s) will be determined using naming
* conventions.
* - `validator` Either the Validation\Validator to use, or the name of the
* validation method to call on the table object. For example 'default'.
* Defaults to 'default'. Can be an array of table alias=>validators when
* dealing with associated forms.
*/
class EntityContext implements ContextInterface
{
use LocatorAwareTrait;
/**
* The request object.
*
* @var \Cake\Http\ServerRequest
*/
protected $_request;
/**
* Context data for this object.
*
* @var array
*/
protected $_context;
/**
* The name of the top level entity/table object.
*
* @var string
*/
protected $_rootName;
/**
* Boolean to track whether or not the entity is a
* collection.
*
* @var bool
*/
protected $_isCollection = false;
/**
* A dictionary of tables
*
* @var array
*/
protected $_tables = [];
/**
* Dictionary of validators.
*
* @var \Cake\Validation\Validator[]
*/
protected $_validator = [];
/**
* Constructor.
*
* @param \Cake\Http\ServerRequest $request The request object.
* @param array $context Context info.
*/
public function __construct(ServerRequest $request, array $context)
{
$this->_request = $request;
$context += [
'entity' => null,
'table' => null,
'validator' => [],
];
$this->_context = $context;
$this->_prepare();
}
/**
* Prepare some additional data from the context.
*
* If the table option was provided to the constructor and it
* was a string, TableLocator will be used to get the correct table instance.
*
* If an object is provided as the table option, it will be used as is.
*
* If no table option is provided, the table name will be derived based on
* naming conventions. This inference will work with a number of common objects
* like arrays, Collection objects and ResultSets.
*
* @return void
* @throws \RuntimeException When a table object cannot be located/inferred.
*/
protected function _prepare()
{
$table = $this->_context['table'];
$entity = $this->_context['entity'];
if (empty($table)) {
if (is_array($entity) || $entity instanceof Traversable) {
foreach ($entity as $e) {
$entity = $e;
break;
}
}
$isEntity = $entity instanceof EntityInterface;
if ($isEntity) {
$table = $entity->getSource();
}
if (!$table && $isEntity && get_class($entity) !== 'Cake\ORM\Entity') {
list(, $entityClass) = namespaceSplit(get_class($entity));
$table = Inflector::pluralize($entityClass);
}
}
if (is_string($table)) {
$table = $this->getTableLocator()->get($table);
}
if (!($table instanceof RepositoryInterface)) {
throw new RuntimeException(
'Unable to find table class for current entity'
);
}
$this->_isCollection = (
is_array($entity) ||
$entity instanceof Traversable
);
$alias = $this->_rootName = $table->getAlias();
$this->_tables[$alias] = $table;
}
/**
* Get the primary key data for the context.
*
* Gets the primary key columns from the root entity's schema.
*
* @return array
*/
public function primaryKey()
{
return (array)$this->_tables[$this->_rootName]->getPrimaryKey();
}
/**
* {@inheritDoc}
*/
public function isPrimaryKey($field)
{
$parts = explode('.', $field);
$table = $this->_getTable($parts);
$primaryKey = (array)$table->getPrimaryKey();
return in_array(array_pop($parts), $primaryKey, true);
}
/**
* Check whether or not this form is a create or update.
*
* If the context is for a single entity, the entity's isNew() method will
* be used. If isNew() returns null, a create operation will be assumed.
*
* If the context is for a collection or array the first object in the
* collection will be used.
*
* @return bool
*/
public function isCreate()
{
$entity = $this->_context['entity'];
if (is_array($entity) || $entity instanceof Traversable) {
foreach ($entity as $e) {
$entity = $e;
break;
}
}
if ($entity instanceof EntityInterface) {
return $entity->isNew() !== false;
}
return true;
}
/**
* Get the value for a given path.
*
* Traverses the entity data and finds the value for $path.
*
* @param string $field The dot separated path to the value.
* @param array $options Options:
* - `default`: Default value to return if no value found in request
* data or entity.
* - `schemaDefault`: Boolean indicating whether default value from table
* schema should be used if it's not explicitly provided.
* @return mixed The value of the field or null on a miss.
*/
public function val($field, $options = [])
{
$options += [
'default' => null,
'schemaDefault' => true,
];
$val = $this->_request->getData($field);
if ($val !== null) {
return $val;
}
if (empty($this->_context['entity'])) {
return $options['default'];
}
$parts = explode('.', $field);
$entity = $this->entity($parts);
if (end($parts) === '_ids' && !empty($entity)) {
return $this->_extractMultiple($entity, $parts);
}
if ($entity instanceof EntityInterface) {
$part = end($parts);
$val = $entity->get($part);
if ($val !== null) {
return $val;
}
if (
$options['default'] !== null
|| !$options['schemaDefault']
|| !$entity->isNew()
) {
return $options['default'];
}
return $this->_schemaDefault($parts);
}
if (is_array($entity) || $entity instanceof ArrayAccess) {
$key = array_pop($parts);
return isset($entity[$key]) ? $entity[$key] : $options['default'];
}
return null;
}
/**
* Get default value from table schema for given entity field.
*
* @param array $parts Each one of the parts in a path for a field name
* @return mixed
*/
protected function _schemaDefault($parts)
{
$table = $this->_getTable($parts);
if ($table === false) {
return null;
}
$field = end($parts);
$defaults = $table->getSchema()->defaultValues();
if (!array_key_exists($field, $defaults)) {
return null;
}
return $defaults[$field];
}
/**
* Helper method used to extract all the primary key values out of an array, The
* primary key column is guessed out of the provided $path array
*
* @param array|\Traversable $values The list from which to extract primary keys from
* @param array $path Each one of the parts in a path for a field name
* @return array|null
*/
protected function _extractMultiple($values, $path)
{
if (!(is_array($values) || $values instanceof Traversable)) {
return null;
}
$table = $this->_getTable($path, false);
$primary = $table ? (array)$table->getPrimaryKey() : ['id'];
return (new Collection($values))->extract($primary[0])->toArray();
}
/**
* Fetch the entity or data value for a given path
*
* This method will traverse the given path and find the entity
* or array value for a given path.
*
* If you only want the terminal Entity for a path use `leafEntity` instead.
*
* @param array|null $path Each one of the parts in a path for a field name
* or null to get the entity passed in constructor context.
* @return \Cake\Datasource\EntityInterface|\Traversable|array|false
* @throws \RuntimeException When properties cannot be read.
*/
public function entity($path = null)
{
if ($path === null) {
return $this->_context['entity'];
}
$oneElement = count($path) === 1;
if ($oneElement && $this->_isCollection) {
return false;
}
$entity = $this->_context['entity'];
if ($oneElement) {
return $entity;
}
if ($path[0] === $this->_rootName) {
$path = array_slice($path, 1);
}
$len = count($path);
$last = $len - 1;
for ($i = 0; $i < $len; $i++) {
$prop = $path[$i];
$next = $this->_getProp($entity, $prop);
$isLast = ($i === $last);
if (!$isLast && $next === null && $prop !== '_ids') {
$table = $this->_getTable($path);
return $table->newEntity();
}
$isTraversable = (
is_array($next) ||
$next instanceof Traversable ||
$next instanceof EntityInterface
);
if ($isLast || !$isTraversable) {
return $entity;
}
$entity = $next;
}
throw new RuntimeException(sprintf(
'Unable to fetch property "%s"',
implode('.', $path)
));
}
/**
* Fetch the terminal or leaf entity for the given path.
*
* Traverse the path until an entity cannot be found. Lists containing
* entities will be traversed if the first element contains an entity.
* Otherwise the containing Entity will be assumed to be the terminal one.
*
* @param array|null $path Each one of the parts in a path for a field name
* or null to get the entity passed in constructor context.
* @return array Containing the found entity, and remaining un-matched path.
* @throws \RuntimeException When properties cannot be read.
*/
protected function leafEntity($path = null)
{
if ($path === null) {
return $this->_context['entity'];
}
$oneElement = count($path) === 1;
if ($oneElement && $this->_isCollection) {
throw new RuntimeException(sprintf(
'Unable to fetch property "%s"',
implode('.', $path)
));
}
$entity = $this->_context['entity'];
if ($oneElement) {
return [$entity, $path];
}
if ($path[0] === $this->_rootName) {
$path = array_slice($path, 1);
}
$len = count($path);
$last = $len - 1;
$leafEntity = $entity;
for ($i = 0; $i < $len; $i++) {
$prop = $path[$i];
$next = $this->_getProp($entity, $prop);
// Did not dig into an entity, return the current one.
if (is_array($entity) && !($next instanceof EntityInterface || $next instanceof Traversable)) {
return [$leafEntity, array_slice($path, $i - 1)];
}
if ($next instanceof EntityInterface) {
$leafEntity = $next;
}
// If we are at the end of traversable elements
// return the last entity found.
$isTraversable = (
is_array($next) ||
$next instanceof Traversable ||
$next instanceof EntityInterface
);
if (!$isTraversable) {
return [$leafEntity, array_slice($path, $i)];
}
$entity = $next;
}
throw new RuntimeException(sprintf(
'Unable to fetch property "%s"',
implode('.', $path)
));
}
/**
* Read property values or traverse arrays/iterators.
*
* @param mixed $target The entity/array/collection to fetch $field from.
* @param string $field The next field to fetch.
* @return mixed
*/
protected function _getProp($target, $field)
{
if (is_array($target) && isset($target[$field])) {
return $target[$field];
}
if ($target instanceof EntityInterface) {
return $target->get($field);
}
if ($target instanceof Traversable) {
foreach ($target as $i => $val) {
if ($i == $field) {
return $val;
}
}
return false;
}
}
/**
* Check if a field should be marked as required.
*
* @param string $field The dot separated path to the field you want to check.
* @return bool
*/
public function isRequired($field)
{
$parts = explode('.', $field);
$entity = $this->entity($parts);
$isNew = true;
if ($entity instanceof EntityInterface) {
$isNew = $entity->isNew();
}
$validator = $this->_getValidator($parts);
$fieldName = array_pop($parts);
if (!$validator->hasField($fieldName)) {
return false;
}
if ($this->type($field) !== 'boolean') {
return $validator->isEmptyAllowed($fieldName, $isNew) === false;
}
return false;
}
/**
* {@inheritDoc}
*/
public function getRequiredMessage($field)
{
$parts = explode('.', $field);
$validator = $this->_getValidator($parts);
$fieldName = array_pop($parts);
if (!$validator->hasField($fieldName)) {
return null;
}
$ruleset = $validator->field($fieldName);
$requiredMessage = $validator->getRequiredMessage($fieldName);
$emptyMessage = $validator->getNotEmptyMessage($fieldName);
if ($ruleset->isPresenceRequired() && $requiredMessage) {
return $requiredMessage;
}
if (!$ruleset->isEmptyAllowed() && $emptyMessage) {
return $emptyMessage;
}
return null;
}
/**
* Get field length from validation
*
* @param string $field The dot separated path to the field you want to check.
* @return int|null
*/
public function getMaxLength($field)
{
$parts = explode('.', $field);
$validator = $this->_getValidator($parts);
$fieldName = array_pop($parts);
if (!$validator->hasField($fieldName)) {
return null;
}
foreach ($validator->field($fieldName)->rules() as $rule) {
if ($rule->get('rule') === 'maxLength') {
return $rule->get('pass')[0];
}
}
return null;
}
/**
* Get the field names from the top level entity.
*
* If the context is for an array of entities, the 0th index will be used.
*
* @return array Array of fieldnames in the table/entity.
*/
public function fieldNames()
{
$table = $this->_getTable('0');
return $table->getSchema()->columns();
}
/**
* Get the validator associated to an entity based on naming
* conventions.
*
* @param array $parts Each one of the parts in a path for a field name
* @return \Cake\Validation\Validator
*/
protected function _getValidator($parts)
{
$keyParts = array_filter(array_slice($parts, 0, -1), function ($part) {
return !is_numeric($part);
});
$key = implode('.', $keyParts);
$entity = $this->entity($parts) ?: null;
if (isset($this->_validator[$key])) {
$this->_validator[$key]->setProvider('entity', $entity);
return $this->_validator[$key];
}
$table = $this->_getTable($parts);
$alias = $table->getAlias();
$method = 'default';
if (is_string($this->_context['validator'])) {
$method = $this->_context['validator'];
} elseif (isset($this->_context['validator'][$alias])) {
$method = $this->_context['validator'][$alias];
}
$validator = $table->getValidator($method);
$validator->setProvider('entity', $entity);
return $this->_validator[$key] = $validator;
}
/**
* Get the table instance from a property path
*
* @param array $parts Each one of the parts in a path for a field name
* @param bool $fallback Whether or not to fallback to the last found table
* when a non-existent field/property is being encountered.
* @return \Cake\ORM\Table|false Table instance or false
*/
protected function _getTable($parts, $fallback = true)
{
if (!is_array($parts) || count($parts) === 1) {
return $this->_tables[$this->_rootName];
}
$normalized = array_slice(array_filter($parts, function ($part) {
return !is_numeric($part);
}), 0, -1);
$path = implode('.', $normalized);
if (isset($this->_tables[$path])) {
return $this->_tables[$path];
}
if (current($normalized) === $this->_rootName) {
$normalized = array_slice($normalized, 1);
}
$table = $this->_tables[$this->_rootName];
$assoc = null;
foreach ($normalized as $part) {
if ($part === '_joinData') {
if ($assoc) {
$table = $assoc->junction();
$assoc = null;
continue;
}
} else {
$assoc = $table->associations()->getByProperty($part);
}
if (!$assoc && $fallback) {
break;
}
if (!$assoc && !$fallback) {
return false;
}
$table = $assoc->getTarget();
}
return $this->_tables[$path] = $table;
}
/**
* Get the abstract field type for a given field name.
*
* @param string $field A dot separated path to get a schema type for.
* @return string|null An abstract data type or null.
* @see \Cake\Database\Type
*/
public function type($field)
{
$parts = explode('.', $field);
$table = $this->_getTable($parts);
return $table->getSchema()->baseColumnType(array_pop($parts));
}
/**
* Get an associative array of other attributes for a field name.
*
* @param string $field A dot separated path to get additional data on.
* @return array An array of data describing the additional attributes on a field.
*/
public function attributes($field)
{
$parts = explode('.', $field);
$table = $this->_getTable($parts);
$column = (array)$table->getSchema()->getColumn(array_pop($parts));
$whitelist = ['length' => null, 'precision' => null];
return array_intersect_key($column, $whitelist);
}
/**
* Check whether or not a field has an error attached to it
*
* @param string $field A dot separated path to check errors on.
* @return bool Returns true if the errors for the field are not empty.
*/
public function hasError($field)
{
return $this->error($field) !== [];
}
/**
* Get the errors for a given field
*
* @param string $field A dot separated path to check errors on.
* @return array An array of errors.
*/
public function error($field)
{
$parts = explode('.', $field);
try {
list($entity, $remainingParts) = $this->leafEntity($parts);
} catch (RuntimeException $e) {
return [];
}
if (count($remainingParts) === 0) {
return $entity->getErrors();
}
if ($entity instanceof EntityInterface) {
$error = $entity->getError(implode('.', $remainingParts));
if ($error) {
return $error;
}
return $entity->getError(array_pop($parts));
}
return [];
}
}