<?php
namespace Jackalope;
use Exception;
use LogicException;
use ArrayIterator;
use IteratorAggregate;
use InvalidArgumentException;
use PHPCR\AccessDeniedException;
use PHPCR\Lock\LockException;
use PHPCR\NodeType\ConstraintViolationException;
use PHPCR\NodeType\NodeTypeInterface;
use PHPCR\NoSuchWorkspaceException;
use PHPCR\PropertyInterface;
use PHPCR\PropertyType;
use PHPCR\RepositoryException;
use PHPCR\ValueFormatException;
use PHPCR\InvalidItemStateException;
use PHPCR\ItemNotFoundException;
use PHPCR\NodeType\PropertyDefinitionInterface;
use PHPCR\Util\PathHelper;
use PHPCR\Util\UUIDHelper;
use PHPCR\Version\VersionException;
/**
* {@inheritDoc}
*
* @license http://www.apache.org/licenses Apache License Version 2.0, January 2004
* @license http://opensource.org/licenses/MIT MIT License
*
* @api
*/
class Property extends Item implements IteratorAggregate, PropertyInterface
{
/**
* flag to know if binary streams should be wrapped or retrieved
* immediately. this is a per session setting.
*
* @var boolean
* @see Property::__construct()
*/
protected $wrapBinaryStreams;
/**
* All binary stream wrapper instances
* @var array
*/
protected $streams = [];
/**
* The property value in suitable native format or object
* @var mixed
*/
protected $value;
/**
* length is only used for binary property, because binary loading is delayed until explicitly requested.
*
* @var int
*/
protected $length;
/**
* whether this is a multivalue property
* @var boolean
*/
protected $isMultiple = false;
/**
* the type constant from PropertyType
* @var int
*/
protected $type = PropertyType::UNDEFINED;
/**
* cached instance of the property definition that defines this property
* @var PropertyDefinitionInterface
* @see Property::getDefinition()
*/
protected $definition;
/**
* Create a property, either from server data or locally
*
* To indicate a property has newly been created locally, make sure to pass
* true for the $new parameter. In that case, you should pass an empty array
* for $data and use setValue afterwards to let the type magic be handled.
* Then multivalue is determined on setValue
*
* For binary properties, the value is the length of the data(s), not the
* data itself.
*
* @param FactoryInterface $factory the object factory
* @param array $data array with fields <tt>type</tt>
* (integer or string from PropertyType) and <tt>value</tt> (data for
* creating the property value - array for multivalue property)
* @param string $path the absolute path of this item
* @param Session $session the session instance
* @param ObjectManager $objectManager the objectManager instance - the
* caller has to take care of registering this item with the object
* manager
* @param boolean $new optional: set to true to make this property aware
* its not yet existing on the server. defaults to false
*
* @throws RepositoryException
* @throws \InvalidArgumentException
*/
public function __construct(FactoryInterface $factory, array $data, $path, Session $session, ObjectManager $objectManager, $new = false)
{
parent::__construct($factory, $path, $session, $objectManager, $new);
$this->wrapBinaryStreams = $session->getRepository()->getDescriptor(Repository::JACKALOPE_OPTION_STREAM_WRAPPER);
if (null === $data && $new) {
return;
}
if (! isset($data['value'])) {
throw new InvalidArgumentException("Can't create property at $path without any data");
}
$this->_setValue($data['value'], isset($data['type']) ? $data['type'] : PropertyType::UNDEFINED, true);
}
/**
* {@inheritDoc}
*
* @throws InvalidItemStateException
* @throws AccessDeniedException
* @throws ItemNotFoundException
*
* @api
*/
public function setValue($value, $type = PropertyType::UNDEFINED)
{
$this->checkState();
// need to determine type to avoid unnecessary modification
// don't try to determine if the value changed anyway (i.e. null to delete)
if (PropertyType::UNDEFINED === $type && $this->value === $value) {
$type = $this->valueConverter->determineType($value);
}
// Need to check both value and type, as native php type string is used for a number of phpcr types
if ($this->value !== $value || $this->type !== $type) {
if ($this->getDefinition()->isProtected()) {
$violation = true;
if ('jcr:mixinTypes' === $this->getDefinition()->getName()) {
// check that the jcr:mixinTypes are in sync with the mixin node types
$mixins = [];
foreach ($this->getParent()->getMixinNodeTypes() as $mixin) {
$mixins[] = $mixin->getName();
}
$violation = (bool) array_diff($mixins, $this->value);
}
if ($violation) {
$msg = sprintf("Property '%s' of node type '%s' is protected and cannot be modified", $this->name, $this->getDefinition()->getDeclaringNodeType()->getName());
throw new ConstraintViolationException($msg);
}
}
$this->setModified();
}
// The _setValue call MUST BE after the check to see if the value or type changed
$this->_setValue($value, $type);
}
/**
* {@inheritDoc}
*
* @throws RepositoryException
* @throws \InvalidArgumentException
*
* @api
*/
public function addValue($value)
{
$this->checkState();
if (!$this->isMultiple()) {
throw new ValueFormatException('You can not add values to non-multiple properties');
}
$this->value[] = $this->valueConverter->convertType($value, $this->type);
$this->setModified();
}
/**
* Tell this item that it has been modified.
*
* Used to make the parent node aware that this property has changed
*
* @throws AccessDeniedException
* @throws ItemNotFoundException
* @throws RepositoryException
*
* @private
*/
public function setModified()
{
parent::setModified();
$parent = $this->getParent();
if (!is_null($parent)) {
$parent->setModified();
}
}
/**
* {@inheritDoc}
*
* @api
*
* @throws InvalidItemStateException
* @throws ItemNotFoundException
* @throws RepositoryException
* @throws ValueFormatException
*/
public function getValue()
{
$this->checkState();
if ($this->type === PropertyType::REFERENCE
|| $this->type === PropertyType::WEAKREFERENCE
) {
return $this->getNode();
}
if ($this->type === PropertyType::BINARY) {
return $this->getBinary();
}
return $this->value;
}
/**
* Get the value of this property to store in the storage backend.
*
* Path and reference properties are not resolved to the node objects.
* If this is a binary property, from the moment this method has been
* called the stream will be read from the transport layer again.
*
* @throws InvalidItemStateException
*
* @private
*/
public function getValueForStorage()
{
$this->checkState();
$value = $this->value;
if (PropertyType::BINARY === $this->type) {
//from now on,
$this->value = null;
}
return $value;
}
/**
* {@inheritDoc}
*
* @throws InvalidItemStateException
* @throws InvalidArgumentException
*
* @api
*/
public function getString()
{
$this->checkState();
if ($this->type === PropertyType::BINARY && empty($this->value)) {
return $this->valueConverter->convertType($this->getBinary(), PropertyType::STRING, $this->type);
}
if ($this->type !== PropertyType::STRING) {
return $this->valueConverter->convertType($this->value, PropertyType::STRING, $this->type);
}
return $this->value;
}
/**
* {@inheritDoc}
*
* @throws InvalidArgumentException
* @throws LogicException
*
* @api
*/
public function getBinary()
{
$this->checkState();
if ($this->type !== PropertyType::BINARY) {
return $this->valueConverter->convertType($this->value, PropertyType::BINARY, $this->type);
}
if (! $this->wrapBinaryStreams && null === $this->value) {
// no caching the stream. we need to fetch the stream and then copy
// it into a memory stream so it can be accessed more than once.
$this->value = $this->objectManager->getBinaryStream($this->path);
}
if ($this->value !== null) {
// we have the stream locally: no wrapping or new or updated property
// copy the stream so the original stream stays usable for storing, fetching again...
$val = is_array($this->value) ? $this->value : [$this->value];
$ret = [];
foreach ($val as $s) {
$stream = fopen('php://memory', 'rwb+');
$pos = ftell($s);
stream_copy_to_stream($s, $stream);
rewind($stream);
fseek($s, $pos); //go back to previous position
$ret[] = $stream;
}
return is_array($this->value) ? $ret : $ret[0];
}
if (! $this->wrapBinaryStreams) {
throw new LogicException("Attempting to create 'jackalope' stream instances but stream wrapper is not activated");
}
// return wrapped stream
if ($this->isMultiple()) {
$results = [];
// identifies all streams loaded by one backend call
$token = md5(uniqid(mt_rand(), true));
// start with part = 1 since 0 will not be parsed properly by parse_url
for ($i = 1, $iMax = count($this->length); $i <= $iMax; $i++) {
$this->streams[] = $results[] = fopen('jackalope://' . $token. '@' . $this->session->getRegistryKey() . ':' . $i . $this->path, 'rwb+');
}
return $results;
}
// single property case
$result = fopen('jackalope://' . $this->session->getRegistryKey() . $this->path, 'rwb+');
$this->streams[] = $result;
return $result;
}
/**
* {@inheritDoc}
*
* @throws InvalidItemStateException
* @throws InvalidArgumentException
*
* @api
*/
public function getLong()
{
$this->checkState();
if ($this->type !== PropertyType::LONG) {
return $this->valueConverter->convertType($this->value, PropertyType::LONG, $this->type);
}
return $this->value;
}
/**
* {@inheritDoc}
*
* @throws InvalidArgumentException
*
* @api
*/
public function getDouble()
{
$this->checkState();
if ($this->type !== PropertyType::DOUBLE) {
return $this->valueConverter->convertType($this->value, PropertyType::DOUBLE, $this->type);
}
return $this->value;
}
/**
* {@inheritDoc}
*
* @throws InvalidItemStateException
* @throws InvalidArgumentException
*
* @api
*/
public function getDecimal()
{
$this->checkState();
if ($this->type !== PropertyType::DECIMAL) {
return $this->valueConverter->convertType($this->value, PropertyType::DECIMAL, $this->type);
}
return $this->value;
}
/**
* {@inheritDoc}
*
* @throws InvalidArgumentException
*
* @api
*/
public function getDate()
{
$this->checkState();
if ($this->type !== PropertyType::DATE) {
return $this->valueConverter->convertType($this->value, PropertyType::DATE, $this->type);
}
return $this->value;
}
/**
* {@inheritDoc}
*
* @throws InvalidArgumentException
*
* @api
*/
public function getBoolean()
{
$this->checkState();
if ($this->type !== PropertyType::BOOLEAN) {
return $this->valueConverter->convertType($this->value, PropertyType::BOOLEAN, $this->type);
}
return $this->value;
}
/**
* {@inheritDoc}
*
* @throws InvalidItemStateException
* @throws NoSuchWorkspaceException
*
* @api
*/
public function getNode()
{
$this->checkState();
$values = $this->isMultiple() ? $this->value : [$this->value];
$results = [];
switch ($this->type) {
case PropertyType::REFERENCE:
$results = $this->getReferencedNodes($values, false);
break;
case PropertyType::WEAKREFERENCE:
$results = $this->getReferencedNodes($values, true);
break;
case PropertyType::STRING:
foreach ($values as $value) {
if (UUIDHelper::isUUID($value)) {
$results[] = $this->objectManager->getNodeByIdentifier($value);
} else {
$results[] = $this->objectManager->getNode($value, $this->parentPath);
}
}
break;
case PropertyType::PATH:
case PropertyType::NAME:
foreach ($values as $value) {
// OPTIMIZE: use objectManager->getNodes instead of looping (but paths need to be absolute then)
$results[] = $this->objectManager->getNode($value, $this->parentPath);
}
break;
default:
throw new ValueFormatException('Property is not a REFERENCE, WEAKREFERENCE or PATH (or convertible to PATH)');
}
return $this->isMultiple() ? $results : reset($results);
}
/**
* {@inheritDoc}
*
* @throws InvalidItemStateException
*
* @api
*/
public function getProperty()
{
$this->checkState();
$values = $this->isMultiple() ? $this->value : [$this->value];
$results = [];
switch ($this->type) {
case PropertyType::PATH:
case PropertyType::STRING:
case PropertyType::NAME:
foreach ($values as $value) {
$results[] = $this->objectManager->getPropertyByPath(PathHelper::absolutizePath($value, $this->parentPath));
}
break;
default:
throw new ValueFormatException('Property is not a PATH (or convertible to PATH)');
}
return $this->isMultiple() ? $results : $results[0];
}
/**
* {@inheritDoc}
*
* @api
*/
public function getLength()
{
$this->checkState();
if (PropertyType::BINARY === $this->type) {
return $this->length;
}
$vals = $this->isMultiple ? $this->value : [$this->value];
$ret = [];
foreach ($vals as $value) {
try {
$ret[] = strlen($this->valueConverter->convertType($value, PropertyType::STRING, $this->type));
} catch (Exception $e) {
// @codeCoverageIgnoreStart
$ret[] = -1;
// @codeCoverageIgnoreEnd
}
}
return $this->isMultiple ? $ret : $ret[0];
}
/**
* {@inheritDoc}
*
* @api
*/
public function getDefinition()
{
$this->checkState();
if (empty($this->definition)) {
$this->definition = $this->findItemDefinition(function (NodeTypeInterface $nt) {
return $nt->getPropertyDefinitions();
});
}
return $this->definition;
}
/**
* {@inheritDoc}
*
* @api
*/
public function getType()
{
$this->checkState();
return $this->type;
}
/**
* {@inheritDoc}
*
* @api
*/
public function isMultiple()
{
$this->checkState();
return $this->isMultiple;
}
/**
* Also unsets internal reference in containing node
*
* {@inheritDoc}
*
* @uses Node::unsetProperty()
*
* @throws ItemNotFoundException
*
* @api
*/
public function remove()
{
$this->checkState();
$parentNodeType = $this->getParent()->getPrimaryNodeType();
//will throw a ConstraintViolationException if this property can't be removed
$parentNodeType->canRemoveProperty($this->getName(), true);
$this->getParent()->unsetProperty($this->name);
parent::remove();
}
/**
* Provide Traversable interface: redirect to getNodes with no filter
*
* @return \Iterator over all child nodes
*
* @throws InvalidItemStateException
* @throws ItemNotFoundException
* @throws RepositoryException
* @throws ValueFormatException
*/
public function getIterator()
{
$this->checkState();
$value = $this->getValue();
if (!is_array($value)) {
$value = [$value];
}
return new ArrayIterator($value);
}
/**
* Refresh this property
*
* {@inheritDoc}
*
* In Jackalope, this is also called internally to refresh when the node
* is accessed in state DIRTY.
*
* Triggers a reload of the containing node, as a property can only ever be
* loaded attached to a node.
*
* TODO: refactor this if we implement loading single properties
*
* @see Item::checkState
*/
protected function refresh($keepChanges, $internal = false)
{
if ($this->isDeleted()) {
if ($internal) {
// @codeCoverageIgnoreStart
// FIXME: this should not be possible
return;
// @codeCoverageIgnoreEnd
}
throw new InvalidItemStateException('This property is deleted');
}
// Let the node refresh us
try {
// do not use getParent to avoid checkState - could lead to an endless loop
$this->objectManager->getNodeByPath($this->parentPath)->refresh($keepChanges);
} catch (ItemNotFoundException $e) {
$this->setDeleted();
}
}
/**
* Internally used to set the value of the property without any notification
* of changes nor state change.
*
* @param mixed $value The value to set.
* @param int|string $type PropertyType constant
* @param boolean $constructor Whether this is called from the constructor.
*
* @see Property::setValue()
*
* @throws AccessDeniedException
* @throws ItemNotFoundException
* @throws LockException
* @throws ConstraintViolationException
* @throws RepositoryException
* @throws VersionException
* @throws InvalidArgumentException
* @throws ValueFormatException
*
* @private
*/
public function _setValue($value, $type = PropertyType::UNDEFINED, $constructor = false)
{
if (null === $value) {
$this->remove();
return;
}
if (is_string($type)) {
$type = PropertyType::valueFromName($type);
} elseif (!is_numeric($type)) {
throw new RepositoryException("INTERNAL ERROR -- No valid type specified ($type)");
} else {
//sanity check. this will throw InvalidArgumentException if $type is not a valid type
PropertyType::nameFromValue($type);
}
if ($constructor || $this->isNew()) {
$this->isMultiple = is_array($value);
}
if (is_array($value) && !$this->isMultiple) {
throw new ValueFormatException('Can not set a single value property ('.$this->name.') with an array of values');
}
if ($this->isMultiple && is_scalar($value)) {
throw new ValueFormatException('Can not set a multivalue property ('.$this->name.') with a scalar value');
}
if ($this->isMultiple) {
foreach ($value as $key => $v) {
if (null === $v) {
unset($value[$key]);
}
}
}
//TODO: check if changing type allowed.
/*
* if ($type !== null && ! canHaveType($type)) {
* throw new ConstraintViolationException("Can not set this property to type ".PropertyType::nameFromValue($type));
* }
*/
if (PropertyType::UNDEFINED === $type) {
// avoid changing type of multivalue property with empty array
if (!$this->isMultiple()
|| count($value)
|| PropertyType::UNDEFINED === $this->type
) {
$type = $this->valueConverter->determineType($value);
} else {
$type = $this->type;
}
}
$targetType = $type;
/*
* TODO: find out with node type definition if the new type is allowed
if ($this->type !== $type) {
if (!canHaveType($type)) {
//convert to an allowed type
}
}
*/
if (PropertyType::BINARY !== $targetType
|| $constructor && $this->isNew() // When in constructor mode, force conversion to re-determine the type as the desired type might not match the value
|| !$this->isNew() && PropertyType::UNDEFINED !== $this->type && $this->type !== $targetType // changing an existing property to binary needs conversion
) {
$value = $this->valueConverter->convertType($value, $targetType, $constructor ? PropertyType::UNDEFINED : $type);
}
if (PropertyType::BINARY === $targetType) {
if ($constructor && !$this->isNew()) {
// reading a binary property from backend, we do not get the stream immediately but just the size
if (is_array($value)) {
$this->isMultiple = true;
}
$this->type = PropertyType::BINARY;
$this->length = $value;
$this->value = null;
return;
}
if (is_array($value)) {
$this->length = [];
foreach ($value as $v) {
$stat = is_resource($v) ? fstat($v) : ['size' => -1];
$this->length[] = $stat['size'];
}
} elseif (is_resource($value)) {
$stat = fstat($value);
$this->length = $stat['size'];
} else {
$this->length = -1;
}
}
$this->type = $targetType;
$this->value = $value;
}
/**
* Internally used after refresh from backend to set new length
*
* @param int $length the new length of this binary
*
* @private
*/
public function _setLength($length)
{
$this->length = $length;
$this->value = null;
}
/**
* Close all open binary stream wrapper instances on shutdown.
*/
public function __destruct()
{
foreach ($this->streams as $k => $v) {
// if this is not a resource, it means the stream has already been
// closed by client code
if (is_resource($v)) {
fclose($v);
unset($this->streams[$k]);
}
}
}
/**
* Get all nodes for $ids, ordered by that array, with duplicates if there are duplicates in $ids.
*
* @param string[] $ids List of ids to fetch.
* @param boolean $weak Whether these are weak references, to throw the right exception.
*
* @return Node[]
*
* @throws ItemNotFoundException If not all $ids are found and weak is true.
* @throws RepositoryException If not all $ids are found and weak is false.
*/
private function getReferencedNodes($ids, $weak)
{
$results = [];
$nodes = $this->objectManager->getNodesByIdentifier($ids);
$missing = [];
foreach ($ids as $id) {
if (isset($nodes[$id])) {
$results[] = $nodes[$id];
} else {
$missing[$id] = $id;
}
}
if (count($missing)) {
if ($weak) {
throw new ItemNotFoundException(sprintf(
'One or more weak reference targets have not been found: "%s".',
implode('", "', $missing)
));
} else {
throw new RepositoryException(sprintf(
'Internal Error: Could not find one or more referenced nodes: "%s". ' .
'If the referencing node is a frozen version, this can happen, otherwise it would be a bug.',
implode('", "', $missing)
));
}
}
return $results;
}
}