eca-1.0.x-dev/src/TypedData/PropertyPathTrait.php
src/TypedData/PropertyPathTrait.php
<?php
namespace Drupal\eca\TypedData;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Access\AccessibleInterface;
use Drupal\Core\Cache\CacheableDependencyInterface;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\TypedData\ComplexDataInterface;
use Drupal\Core\TypedData\DataReferenceInterface;
use Drupal\Core\TypedData\Exception\MissingDataException;
use Drupal\Core\TypedData\ListInterface;
use Drupal\Core\TypedData\TypedDataInterface;
use Drupal\eca\Plugin\DataType\DataTransferObject;
/**
* A trait for traversing through the property path of typed data objects.
*/
trait PropertyPathTrait {
/**
* Returns the property that is addressed by the given property path.
*
* @param \Drupal\Core\TypedData\TypedDataInterface $object
* The object to start the traversal along the property path.
* @param string $property_path
* The property path. This argument will be automatically normalized by
* using ::normalizePropertyPath().
* @param array $options
* (optional) Options to use during path traversal. Following keys are
* available:
* - "auto_append": A boolean indicating whether auto-appending items to
* a list is allowed in case the traversal would stop otherwise. Default
* is not enabled (FALSE).
* - "auto_item": A boolean indicating to automatically use the
* main property or first item of a list in case the given property path
* did not directly specify a scalar property. Default is enabled (TRUE).
* - "access": A string that is the operation to check access for.
* By default, an access check regards "view" operation is checked. Set to
* FALSE to completely skip access checks.
* @param array &$metadata
* (optional) Metadata that was collected along the traversal. Following
* keys are available:
* - "entities" holds a list of entities that are involved along the path.
* - "cache" holds collected cacheability metadata.
* - "access" holds the calculated access result.
* - "parts" holds the extracted parts of the property path as array.
*
* @return \Drupal\Core\TypedData\TypedDataInterface|null
* The property target, or NULL if not found. the latter case can happen
* when either the property path does not exist, or the user has no access.
*/
protected function getTypedProperty(TypedDataInterface $object, string $property_path, array $options = [], array &$metadata = []): ?TypedDataInterface {
$property_path = $this->normalizePropertyPath($property_path);
if (empty($property_path)) {
// The source is always an entity. Within this action, it cannot be
// updated without at least specifying a field name.
return NULL;
}
$options += [
'auto_append' => FALSE,
'auto_item' => TRUE,
'access' => 'view',
];
$metadata += [
'entities' => [],
'cache' => new CacheableMetadata(),
'access' => AccessResult::allowed(),
];
$parts = explode('.', $property_path);
$metadata['parts'] = $parts;
$last_i = count($parts) - 1;
$data = $object;
foreach ($parts as $i => $property) {
if ($data instanceof ComplexDataInterface) {
$data = $this->getDataProperties($data, $property);
}
if ((($data instanceof \ArrayAccess) || is_array($data)) && isset($data[$property])) {
$data = $data[$property];
if ($data instanceof DataReferenceInterface) {
// Directly jump to the contained target, if any is present.
// @todo Should entities be auto-created here?
if (!($data = $data->getTarget())) {
return NULL;
}
}
}
elseif ($data instanceof ListInterface) {
// Allow for auto-appending an item, in case the given argument
// indicates that it either does not care (i.e. the current property
// is not a digit) or points to the next free delta in the list.
if (!ctype_digit($property)) {
// Some input may skip the delta, e.g. body.value is treated as an
// equivalent to body.0.value.
if ((NULL === $data->first()) && $options['auto_append']) {
$data->appendItem();
}
$data = $data->first();
if ($data instanceof ComplexDataInterface) {
$data = $this->getDataProperties($data, $property);
if (isset($data[$property])) {
$data = $data[$property];
}
else {
return NULL;
}
}
}
elseif (($property == count($data)) && $options['auto_append']) {
$data = $data->appendItem();
}
else {
return NULL;
}
}
else {
return NULL;
}
if (($i === $last_i) && ($options['auto_item'])) {
if ($data instanceof ListInterface) {
if ((NULL === $data->first()) && $options['auto_append']) {
$data->appendItem();
}
$data = $data->first();
}
if ($data instanceof ComplexDataInterface) {
$main_property = $data->getDataDefinition()->getMainPropertyName();
if ($main_property !== NULL) {
$data = $data->get($main_property);
}
}
}
if ($data === NULL) {
return NULL;
}
$value = $data->getValue();
$entity = NULL;
if (!($value instanceof EntityInterface) && method_exists($data, 'getEntity') && ($entity = $data->getEntity())) {
$value = $entity;
}
if ($value instanceof EntityInterface && !in_array($entity, $metadata['entities'], TRUE)) {
$metadata['entities'][] = $value;
}
// Perform access checks and add existing cacheability metadata.
foreach ([$data, $value] as $subject) {
if ($subject instanceof CacheableDependencyInterface) {
$metadata['cache']->addCacheableDependency($subject);
}
if ($options['access'] && $subject instanceof AccessibleInterface) {
// @todo Try to find a simpler solution path for access logic.
// @see https://www.drupal.org/project/drupal/issues/3244585
$op = ($subject instanceof FieldItemListInterface) && $options['access'] === 'update' ? 'edit' : $options['access'];
$access_result = $subject->access($op, NULL, TRUE);
if ($access_result instanceof CacheableDependencyInterface) {
$metadata['cache']->addCacheableDependency($access_result);
}
$metadata['access'] = $metadata['access']->andIf($access_result);
if (!$access_result->isAllowed()) {
return NULL;
}
}
}
}
if (empty($metadata['entities'])) {
// Try to fetch at least one entity that was involved along the path.
$root = $data->getRoot();
if (method_exists($root, 'getEntity')) {
$root = $root->getEntity();
$metadata['entities'][] = $root;
}
}
return $data;
}
/**
* Normalizes a key that may be given by user input to a property path.
*
* @param string $key
* The key to normalize.
*
* @return string
* The normalized key.
*/
protected function normalizePropertyPath(string $key): string {
// Always use lowercase letters.
$key = mb_strtolower(trim($key));
if (!empty($key)) {
if ($key[0] === '[' && $key[mb_strlen($key) - 1] === ']') {
// Remove the brackets coming from Token syntax.
$key = mb_substr($key, 1, -1);
}
if (mb_strpos($key, ':') !== FALSE) {
// Convert token-like syntax into a (hopefully) valid property path.
$key = str_replace(':', '.', $key);
}
}
return $key;
}
/**
* Helper method to get the properties of the given typed data object.
*
* @param \Drupal\Core\TypedData\ComplexDataInterface $data
* The typed data object.
* @param string|int $property_name
* The targeted property name.
*
* @return array
* The properties, keyed by property name. May be empty.
*/
protected function getDataProperties(ComplexDataInterface $data, $property_name): array {
$properties = $data->getProperties(TRUE);
$tdm = NULL;
$definitions = [];
if (!$properties || !isset($properties[$property_name])) {
// When the targeted property name is missing, we lookup whether it is
// allowed to add it on our own. Therefore we need the typed data manager
// and knowledge about existing property definitions.
$tdm = \Drupal::typedDataManager();
$definitions = $data->getDataDefinition()->getPropertyDefinitions();
}
if (!$properties && $tdm !== NULL) {
$values = $data->getValue();
if (is_iterable($values)) {
foreach ($values as $k => $v) {
try {
$properties[$k] = isset($definitions[$property_name]) ? $tdm->create($definitions[$property_name], $v, $k, $data) : DataTransferObject::create($v, $data, $k, FALSE);
}
catch (\InvalidArgumentException | MissingDataException $e) {
// Do nothing, we are only interested in values that could
// be successfully resolved.
}
}
}
}
if (!isset($properties[$property_name])) {
if ($tdm !== NULL && isset($definitions[$property_name])) {
$properties[$property_name] = $tdm->create($definitions[$property_name], NULL, $property_name, $data);
}
elseif (empty($definitions)) {
$properties[$property_name] = DataTransferObject::create(NULL, $data, $property_name, FALSE);
}
}
return $properties;
}
}
