Seditio Source
Root |
./othercms/xenForo 2.2.8/src/XF/Mvc/Entity/Entity.php
<?php

namespace XF\Mvc\Entity;

use function
array_key_exists, array_slice, call_user_func_array, count, get_called_class, get_class, is_array, is_int, is_string, strlen;

abstract class
Entity implements \ArrayAccess
{
    const
REQUIRES_DECODING = 0x10000;

    const
INT                   =  0x0001;
    const
UINT                  =  0x0002;
    const
FLOAT                 =  0x0003;
    const
BOOL                  = 0x10004;
    const
STR                   =  0x0005;
    const
BINARY                =  0x0006;
    const
SERIALIZED            = 0x10007;
    const
JSON                  = 0x10009;
    const
JSON_ARRAY            = 0x10010;
    const
LIST_LINES            = 0x10011;
    const
LIST_COMMA            = 0x10012;

   
// This is a general purpose list. This isn't directly applicable to entities as it does not define an encoding
    // or decoding process. It can be used by the value formatter/array validator system which uses these types.
   
const LIST_ARRAY            = 0x10013;

   
/** @deprecated - use JSON_ARRAY instead  */
   
const SERIALIZED_ARRAY      = 0x10008;

    const
TO_ONE  = 1;
    const
TO_MANY = 2;

    const
VERBOSITY_QUIET = 0;
    const
VERBOSITY_NORMAL = 1;
    const
VERBOSITY_VERBOSE = 2;

   
// Note that all the variables and protected methods in this class are prefixed with _ to avoid
    // any possible conflicts with entries in the concrete methods. This is not the general practice.

   
private static $_entityCounter = 1;
    private
$_uniqueEntityId;

    protected
$rootClass;

   
// if true, pass arguments to db->insert to use REPLACE INTO instead of INSERT INTO
   
protected $_useReplaceInto = false;

    protected
$_newValues = [];
    protected
$_values = [];
    protected
$_relations = [];
    protected
$_getterCache = [];
    protected
$_valueCache = [];
    protected
$_previousValues = [];

    protected
$_options = [];

    protected
$_deleted = false;

    protected
$_readOnly = false;
    protected
$_writePending = false;
    protected
$_writeRunning = false;
    protected
$_errors = [];

   
/**
     * @var \Closure[]
     */
   
protected $_whenSaveable = [];

   
/**
     * @var Entity[]
     */
   
protected $_cascadeSave = [];

   
/**
     * @var null|Behavior[]
     */
   
protected $_behaviors = null;

   
/**
     * @var Structure
     */
   
protected $_structure;

   
/**
     * @var Manager
     */
   
protected $_em;

    public function
__construct(Manager $em, Structure $structure, array $values = [], array $relations = [])
    {
       
$this->_uniqueEntityId = self::$_entityCounter++;
       
$this->_em = $em;
       
$this->_structure = $structure;
       
$this->_values = $values;
       
$this->_relations = $relations;
       
$this->rootClass = \XF::extension()->resolveExtendedClassToRoot($this);

        if (!
$values)
        {
           
$this->_setupDefaults();

            \
XF::fire('entity_defaults', [$this], $this->rootClass);
        }
    }

    protected function
_setupDefaults()
    {
    }

    public function
__get($key)
    {
        return
$this->get($key);
    }

   
#[\ReturnTypeWillChange]
   
public function offsetGet($key)
    {
        return
$this->get($key);
    }

    public function
get($key)
    {
       
$structure = $this->_structure;
       
$originalKey = $key;

        if (
substr($key, -1) == '_')
        {
           
$key = substr($key, 0, -1);
           
$useGetter = false;
        }
        else
        {
           
$useGetter = true;
        }

        if (
$useGetter && isset($structure->getters[$key]))
        {
           
$getterVal = $structure->getters[$key];
            if (
is_array($getterVal))
            {
               
$cache = $getterVal['cache'] ?? true;
               
$getter = $getterVal['getter'];
            }
            else
            {
               
$cache = $getterVal; // is a boolean indicating cacheability
               
$getter = true; // key indicates getter name so build that below
           
}

            if (
$cache && array_key_exists($key, $this->_getterCache))
            {
                return
$this->_getterCache[$key];
            }

            if (
$getter === true)
            {
               
$getter = 'get' . \XF\Util\Php::camelCase($key);
            }

           
$result = $this->$getter();
            if (
$cache)
            {
               
$this->_getterCache[$key] = $result;
            }

            return
$result;
        }

        if (!empty(
$structure->columns[$key]))
        {
            if (
$useGetter && !empty($structure->columns[$key]['censor']))
            {
                if (!
array_key_exists($key, $this->_getterCache))
                {
                   
$v = $this->getValue($key);
                   
$this->_getterCache[$key] = $this->app()->stringFormatter()->censorText($v);
                }

                return
$this->_getterCache[$key];
            }
            else
            {
                return
$this->getValue($key);
            }
        }

        if (!empty(
$structure->relations[$key]))
        {
            return
$this->getRelation($key);
        }

        if (!empty(
$structure->columnAliases[$key]))
        {
           
$alias = $structure->columnAliases[$key] . ($useGetter ? '' : '_');
            return
$this->get($alias);
        }

        if (\
XF::$debugMode)
        {
           
// Note: this is intentionally triggering a warning rather than an exception. This will commonly
            // trigger in templates and we will still be able to render in that case.
           
trigger_error("Accessed unknown getter '$originalKey' on " . $this->__toString(), E_USER_WARNING);
        }

        \
XF::logException(
            new \
InvalidArgumentException("Accessed unknown getter '$originalKey' on " . $this->__toString())
        );

        return
null;
    }

    public function
getValue($key)
    {
       
$columns = $this->_structure->columns;

        if (empty(
$columns[$key]))
        {
            throw new \
InvalidArgumentException("Unknown column $key");
        }

       
$column = $columns[$key];

        if (
array_key_exists($key, $this->_newValues))
        {
           
$value = $this->_newValues[$key];
            if (
$value instanceof DeferredValue)
            {
               
$value = $this->_resolveDeferredValue($key, $value, 'get');
            }
        }
        else if (
array_key_exists($key, $this->_values))
        {
            if (
$column['type'] & self::REQUIRES_DECODING)
            {
                if (!
array_key_exists($key, $this->_valueCache))
                {
                   
$this->_valueCache[$key] = $this->_em->decodeValueFromSourceExtended($column['type'], $this->_values[$key], $column);
                }
               
$value = $this->_valueCache[$key];
            }
            else
            {
               
$value = $this->_values[$key];
            }
        }
        else if (
array_key_exists('default', $column))
        {
           
$value = $column['default'];
        }
        else
        {
           
$value = null;
        }

        return
$value;
    }

    public function
getValueSourceEncoded($key)
    {
       
$columns = $this->_structure->columns;

        if (empty(
$columns[$key]))
        {
            throw new \
InvalidArgumentException("Unknown column $key");
        }

       
$column = $columns[$key];

        if (
array_key_exists($key, $this->_newValues))
        {
           
$value = $this->_newValues[$key];
            if (
$value instanceof DeferredValue)
            {
               
$value = $this->_resolveDeferredValue($key, $value, 'get');
            }
        }
        else if (
array_key_exists($key, $this->_values))
        {
           
// already encoded
           
return $this->_values[$key];
        }
        else if (
array_key_exists('default', $column))
        {
           
$value = $column['default'];
        }
        else
        {
           
$value = null;
        }

        return
$this->_em->encodeValueForSource($column['type'], $value);
    }

    public function
getExistingValue($key)
    {
       
$columns = $this->_structure->columns;

        if (empty(
$columns[$key]))
        {
            throw new \
InvalidArgumentException("Unknown column $key");
        }

       
$column = $columns[$key];

        if (
array_key_exists($key, $this->_values))
        {
            if (
$column['type'] & self::REQUIRES_DECODING)
            {
                if (!
array_key_exists($key, $this->_valueCache))
                {
                   
$this->_valueCache[$key] = $this->_em->decodeValueFromSourceExtended($column['type'], $this->_values[$key], $column);
                }
               
$value = $this->_valueCache[$key];
            }
            else
            {
               
$value = $this->_values[$key];
            }
        }
        else if (
array_key_exists('default', $column))
        {
           
$value = $column['default'];
        }
        else
        {
           
$value = null;
        }

        return
$value;
    }

    public function
getPreviousValue($key)
    {
       
$columns = $this->_structure->columns;

        if (empty(
$columns[$key]))
        {
            throw new \
InvalidArgumentException("Unknown column $key");
        }

       
$column = $columns[$key];

        if (
array_key_exists($key, $this->_previousValues))
        {
           
$v = $this->_previousValues[$key];
        }
        else if (
array_key_exists($key, $this->_values))
        {
           
$v = $this->_values[$key];
        }
        else if (
array_key_exists('default', $column))
        {
            return
$column['default'];
        }
        else
        {
            return
null;
        }

        if (
$column['type'] & self::REQUIRES_DECODING)
        {
            return
$this->_em->decodeValueFromSourceExtended($column['type'], $v, $column);
        }
        else
        {
            return
$v;
        }
    }

    public function
getNewValues()
    {
        return
$this->_newValues;
    }

    public function
getPreviousValues()
    {
       
$values = [];
        foreach (
array_keys($this->_structure->columns) AS $k)
        {
           
$values[$k] = $this->getPreviousValue($k);
        }

        return
$values;
    }

    public function
getRelation($key)
    {
       
$relations = $this->_structure->relations;

        if (empty(
$relations[$key]))
        {
            throw new \
InvalidArgumentException("Unknown relation $key");
        }

        if (!
array_key_exists($key, $this->_relations))
        {
           
$this->_relations[$key] = $this->_em->getRelation($relations[$key], $this);
        }

        return
$this->_relations[$key];
    }

    public function
getRelationOrDefault($key, $cascadeSave = true)
    {
       
$relations = $this->_structure->relations;

        if (empty(
$relations[$key]))
        {
            throw new \
InvalidArgumentException("Unknown relation $key");
        }

       
$relation = $relations[$key];

        if (empty(
$this->_relations[$key]))
        {
           
$data = $this->_em->getRelation($relation, $this);
            if (!
$data)
            {
               
$data = $this->_em->hydrateDefaultFromRelation($this, $relation);
            }

           
$this->_relations[$key] = $data;
        }

        if (
$cascadeSave)
        {
           
$this->addCascadedSave($this->_relations[$key]);
        }

        return
$this->_relations[$key];
    }

   
/**
     * @param string $key
     *
     * @return bool
     */
   
public function hasRelation(string $key): bool
   
{
        return isset(
$this->_structure->relations[$key]);
    }

    public function
hydrateFinderRelation($key, array $entities)
    {
       
$relations = $this->_structure->relations;

        if (!isset(
$relations[$key]))
        {
            throw new \
InvalidArgumentException("Unknown relation $key");
        }

       
$relation = $relations[$key];

        if (empty(
$relation['key']) || $relation['type'] != self::TO_MANY)
        {
            throw new \
InvalidArgumentException("Relation $key does not support finder hydration");
        }

       
$falseEntities = [];
        foreach (
$entities AS $entityKey => $value)
        {
            if (!
$value)
            {
               
$falseEntities[$entityKey] = true;
                unset(
$entities[$entityKey]);
            }
        }

       
$finder = $this->_em->getRelationFinder($relation, $this);
       
$this->_relations[$key] = new FinderCollection($finder, $relation['key'], $entities, $falseEntities);
    }

    public function
hydrateRelation($key, $value)
    {
       
$relations = $this->_structure->relations;

        if (!isset(
$relations[$key]))
        {
            throw new \
InvalidArgumentException("Unknown relation $key");
        }

       
$relation = $relations[$key];

        if (
$relation['type'] == self::TO_MANY)
        {
            if (!(
$value instanceof AbstractCollection))
            {
                throw new \
InvalidArgumentException("To many relations must be hydrated with collections");
            }
        }
        else
        {
            if (
$value !== null && !($value instanceof Entity))
            {
                throw new \
InvalidArgumentException("To one relations must be hydrated with entities or null");
            }
        }

       
$this->_relations[$key] = $value;
    }

    public function
getExistingRelation($key)
    {
       
$relations = $this->_structure->relations;

        if (empty(
$relations[$key]))
        {
            throw new \
InvalidArgumentException("Unknown relation $key");
        }

        return
$this->_em->getRelation($relations[$key], $this, 'existing');
    }

    public function
getRelationFinder($key, $type = 'current')
    {
       
$relations = $this->_structure->relations;

        if (empty(
$relations[$key]))
        {
            throw new \
InvalidArgumentException("Unknown relation $key");
        }

        return
$this->_em->getRelationFinder($relations[$key], $this, $type);
    }

    public function
toArray($allowGetters = true)
    {
       
$output = [];
        foreach (
$this->_structure->columns AS $key => $null)
        {
           
$output[$key] = $allowGetters ? $this->get($key) : $this->getValue($key);
        }

        return
$output;
    }

    public final function
toApiResult($verbosity = self::VERBOSITY_NORMAL, array $options = [])
    {
       
$result = new \XF\Api\Result\EntityResult($this);
       
$subResult = $this->setupApiResultData($result, $verbosity, $options);

        if (
$subResult instanceof \XF\Api\Result\EntityResultInterface)
        {
            return
$subResult;
        }
        else
        {
            return
$result;
        }
    }

   
/**
     * This method must be overridden to enable API access to this entity.
     *
     * @param \XF\Api\Result\EntityResult $result
     * @param int $verbosity
     * @param array $options
     */
   
protected function setupApiResultData(
        \
XF\Api\Result\EntityResult $result, $verbosity = self::VERBOSITY_NORMAL, array $options = []
    )
    {
        throw new \
LogicException(
           
"API result rules not defined by " . $this->_structure->shortName . '. Override setupApiResultData().'
       
);
    }

    public function
__set($key, $value)
    {
       
$this->set($key, $value);
    }

   
#[\ReturnTypeWillChange]
   
public function offsetSet($key, $value)
    {
       
$this->set($key, $value);
    }

    public function
set($key, $value, array $options = [])
    {
        if (
$this->_readOnly)
        {
            throw new \
LogicException("Entity is read only");
        }

        if (
$this->_deleted)
        {
            throw new \
LogicException("Attempted to set '$key' on a deleted entity");
        }

        if (
$this->_writePending == 'delete')
        {
            throw new \
LogicException("Attempted to set '$key' while a deletion was pending");
        }

        if (
$this->_writePending && empty($options['forceSet']))
        {
            throw new \
LogicException("Attempted to set '$key' while a save was pending without forceSet");
        }

        if (!isset(
$this->_structure->columns[$key]))
        {
            if (empty(
$options['skipInvalid']))
            {
                throw new \
InvalidArgumentException("Column '$key' is unknown");
            }
            return
false;
        }

       
$column = $this->_structure->columns[$key];

        if ((!empty(
$column['readOnly']) || !empty($column['autoIncrement'])) && empty($options['forceSet']))
        {
            if (empty(
$options['skipInvalid']))
            {
                throw new \
InvalidArgumentException("Column '$key' is read only, can only be set with forceSet");
            }
            return
false;
        }

        if (!empty(
$column['writeOnce']) && $this->isUpdate() && empty($options['forceSet']))
        {
            if (empty(
$options['skipInvalid']))
            {
                throw new \
InvalidArgumentException("Column '$key' can only be written on insert or set with forceSet");
            }
            return
false;
        }

        if (
$value instanceof DeferredValue)
        {
           
$this->_setInternal($key, $value);
            return
true;
        }

        if (!
$this->_verifyValueCustom($value, $key, $column['type'], $column))
        {
            return
false;
        }

       
$value = $this->_castValueToType($value, $key, $column['type'], $column);

        if (!
$this->_em->getValueFormatter()->applyValueConstraints(
           
$value, $column['type'], $column, $constraintError, !empty($options['forceConstraint'])
        ))
        {
            if (
$constraintError)
            {
               
$this->error($constraintError, $key, false);
            }

            return
false;
        }

        if (
$this->_columnValueIsDifferent($key, $value, $column) && $value !== null)
        {
            if (!empty(
$column['unique']))
            {
                if (!
$this->_verifyUniqueValue($value, $key, $column['unique']))
                {
                    return
false;
                }
            }
            else if (!empty(
$column['autoIncrement']) || $this->_structure->primaryKey === $key)
            {
                if (!
$this->_verifyUniqueValue($value, $key, true))
                {
                    return
false;
                }
            }
        }

       
$this->_setInternal($key, $value);
        return
true;
    }

    public function
setTrusted($key, $value)
    {
        if (!isset(
$this->_structure->columns[$key]))
        {
            throw new \
InvalidArgumentException("Column '$key' is unknown");
        }

       
$column = $this->_structure->columns[$key];
       
$value = $this->_castValueToType($value, $key, $column['type'], $column);

       
$this->_setInternal($key, $value);
        return
true;
    }

    public function
setFromEncoded($key, $value, array $options = [])
    {
        if (!isset(
$this->_structure->columns[$key]))
        {
            throw new \
InvalidArgumentException("Column '$key' is unknown");
        }

       
$column = $this->_structure->columns[$key];

       
$value = $this->_em->decodeValueFromSourceExtended($column['type'], $value, $column);
        return
$this->set($key, $value, $options);
    }

    public function
setAsSaved($key, $value)
    {
        if (!
$this->exists() && !$this->_writeRunning)
        {
            throw new \
LogicException("Can only set an already saved value on a saved entity");
        }

        if (!isset(
$this->_structure->columns[$key]))
        {
            throw new \
InvalidArgumentException("Column '$key' is unknown");
        }

       
$column = $this->_structure->columns[$key];
       
$value = $this->_castValueToType($value, $key, $column['type'], $column);

       
$sourceValue = $this->_em->encodeValueForSource($column['type'], $value);

        if (
$this->_writeRunning)
        {
           
// If a write is currently happening, we can't write into $_values as it may cause isInsert and similar
            // to fail or it may potentially be overwritten. Treat it like a new value and it'll be pushed
           
$this->_setInternal($key, $value);
        }
        else
        {
           
$this->_values[$key] = $sourceValue;
           
$this->_invalidateCachesOnChange($key);
        }

        unset(
$this->_valueCache[$key]);

        return
$sourceValue;
    }

    public function
bulkSet(array $values, array $options = [])
    {
       
$results = [];
        foreach (
$values AS $key => $value)
        {
           
$results[$key] = $this->set($key, $value, $options);
        }

        return
$results;
    }

    public function
bulkSetIgnore(array $values, array $options = [])
    {
       
$options['skipInvalid'] = true;
        return
$this->bulkSet($values, $options);
    }

    protected function
_castValueToType($value, $key, $type, array $columnOptions = [])
    {
        try
        {
            return
$this->_em->getValueFormatter()->castValueToType($value, $type, $columnOptions);
        }
        catch (\
Exception $e)
        {
            throw new \
InvalidArgumentException($e->getMessage() . " [$key]", $e->getCode(), $e);
        }
    }

    protected function
_verifyValueCustom(&$value, $key, $type, array $columnOptions)
    {
       
$success = true;
        if (!empty(
$columnOptions['verify']))
        {
           
$verifier = $columnOptions['verify'];
            if (
is_array($verifier) && $verifier[0] == '$this')
            {
               
$verifier[0] = $this;
            }
            else if (
is_string($verifier))
            {
               
$verifier = [$this, $verifier];
            }

           
$success = call_user_func_array($verifier, [
                &
$value, $key, $type, $columnOptions, $this
           
]);
        }
        else
        {
           
$verifyMethod = 'verify' . \XF\Util\Php::camelCase($key);
            if (
method_exists($this, $verifyMethod))
            {
               
$success = $this->$verifyMethod($value, $key, $type, $columnOptions);
            }
        }

        if (
$success !== true && $success !== false)
        {
            throw new \
LogicException("Verification method of $key did not return a valid indicator (true/false)");
        }

        return
$success;
    }

    protected function
_verifyUniqueValue($value, $key, $error = true)
    {
        if (
$value === null)
        {
            return
true;
        }

       
$match = $this->_em->getFinder($this->_structure->shortName)->where($key, '=', $value)->fetchOne();
        if (!
$match || $match === $this)
        {
            return
true;
        }

        if (
$error === true)
        {
           
$this->error(\XF::phrase('all_x_values_must_be_unique', ['key' => $key]), $key);
        }
        else if (
is_string($error))
        {
           
$this->error(\XF::phrase($error), $key);
        }
        else if (
$error instanceof \Closure)
        {
           
$error($key, $this);
        }

        return
false;
    }

   
/**
     * This determines if the new value for a column is different from the *stored version*.
     * This distinction is significant for multiple calls to set(), when the second call reverts back to the stored
     * value (meaning this function will return false).
     *
     * @param string $key
     * @param mixed $value
     * @param array $column
     *
     * @return bool
     */
   
protected function _columnValueIsDifferent($key, $value, array $column)
    {
        return (
            !
array_key_exists($key, $this->_values)
            ||
$value !== $this->_em->decodeValueFromSourceExtended($column['type'], $this->_values[$key], $column)
        );
    }

    protected function
_setInternal($key, $value)
    {
        if (!isset(
$this->_structure->columns[$key]))
        {
            throw new \
InvalidArgumentException("Column $key is unknown");
        }

       
$column = $this->_structure->columns[$key];

       
$skipInvalidate = ($this->isInsert() && !empty($column['autoIncrement']) && $value === null);

        if (
$this->_columnValueIsDifferent($key, $value, $column))
        {
           
// this means the value is different from the stored value
           
$this->_newValues[$key] = $value;

            if (!
$skipInvalidate)
            {
               
$this->_invalidateCachesOnChange($key);
            }

            return
true;
        }
        else if (
array_key_exists($key, $this->_newValues))
        {
           
// value is different from the new value but the same as the old value
           
unset($this->_newValues[$key]);

            if (!
$skipInvalidate)
            {
               
$this->_invalidateCachesOnChange($key);
            }

            return
true;
        }

        return
false;
    }

    protected function
_invalidateCachesOnChange($key)
    {
        unset(
$this->_getterCache[$key]);
        unset(
$this->_relations[$key]);

       
// invalidate any getters that depend on this value
       
foreach ($this->_structure->getters AS $getterName => $getter)
        {
            if (!
is_array($getter) || !isset($getter['invalidate']))
            {
                continue;
            }

            foreach (
$getter['invalidate'] AS $invalidate)
            {
                if (
$invalidate === $key)
                {
                    unset(
$this->_getterCache[$getterName]);
                }
            }
        }

        foreach (
$this->_structure->relations AS $relationName => $relation)
        {
           
$conditions = $relation['conditions'];
            if (!
is_array($conditions))
            {
               
$conditions = [$conditions];
            }

            foreach (
$conditions AS $condition)
            {
                if (
is_string($condition))
                {
                    if (
$condition == $key)
                    {
                        unset(
$this->_relations[$relationName]);
                    }
                }
                else if (
count($condition) > 3)
                {
                    foreach (
array_slice($condition, 2) AS $v)
                    {
                        if (
$v && $v[0] == '$' && substr($v, 1) == $key)
                        {
                            unset(
$this->_relations[$relationName]);
                            break;
                        }
                    }
                }
                else if (
                   
is_string($condition[2])
                    &&
strlen($condition[2])
                    &&
$condition[2][0] == '$'
                   
&& substr($condition[2], 1) == $key
               
)
                {
                    unset(
$this->_relations[$relationName]);
                }

                if (
                   
is_array($condition)
                    &&
is_string($condition[0])
                    &&
strlen($condition[0])
                    &&
$condition[0][0] == '$'
                   
&& substr($condition[0], 1) == $key
               
)
                {
                    unset(
$this->_relations[$relationName]);
                }
            }
        }
    }

    public function
clearCache($key)
    {
       
$this->_invalidateCachesOnChange($key);
    }

    public function
updateVersionId($versionIdField = 'version_id', $versionStringField = 'version_string', $addOnIdField = 'addon_id')
    {
       
$addOnId = $this->getValue($addOnIdField);
        if (
$addOnId)
        {
           
$addOn = $this->_em->find('XF:AddOn', $addOnId);
            if (!
$addOn)
            {
               
$this->set($addOnIdField, ''); // no add-on found, make it custom

               
$versionId = 0;
               
$versionString = '';
            }
            else
            {
               
$versionId = $addOn->version_id;
               
$versionString = $addOn->version_string;
            }
        }
        else
        {
           
$versionId = 0;
           
$versionString = '';
        }

       
$this->set($versionIdField, $versionId);
       
$this->set($versionStringField, $versionString);
        return
$versionId;
    }

    public function
setOption($name, $value)
    {
        if (!
array_key_exists($name, $this->_structure->options))
        {
            throw new \
InvalidArgumentException("Unknown entity option: $name");
        }

       
$this->_options[$name] = $value;
    }

    public function
setOptions(array $options)
    {
        foreach (
$options AS $name => $value)
        {
           
$this->setOption($name, $value);
        }
    }

    public function
resetOption($name)
    {
        if (!
array_key_exists($name, $this->_structure->options))
        {
            throw new \
InvalidArgumentException("Unknown entity option: $name");
        }

        unset(
$this->_options[$name]);
    }

    public function
hasOption($name)
    {
        return
array_key_exists($name, $this->_structure->options);
    }

   
/**
     * @param string $name
     *
     * @return mixed
     */
   
public function getOption($name)
    {
        if (
array_key_exists($name, $this->_options))
        {
            return
$this->_options[$name];
        }
        else if (
array_key_exists($name, $this->_structure->options))
        {
            return
$this->_structure->options[$name];
        }
        else
        {
            throw new \
InvalidArgumentException("Unknown entity option: $name");
        }
    }

    public function
isUpdate()
    {
        return
$this->exists();
    }

    public function
isInsert()
    {
        if (
$this->_deleted)
        {
            return
false;
        }

        return
$this->_values ? false : true;
    }

    public function
exists()
    {
        if (
$this->_deleted)
        {
            return
false;
        }

        return
$this->_values ? true : false;
    }

    public function
isChanged($key)
    {
        if (
is_array($key))
        {
            foreach (
$key AS $subKey)
            {
                if (
$this->isChanged($subKey))
                {
                    return
true;
                }
            }

            return
false;
        }

       
$columns = $this->_structure->columns;

        if (empty(
$columns[$key]))
        {
            return
false;
        }

        return
array_key_exists($key, $this->_newValues);
    }

    public function
hasChanges()
    {
        return (
$this->isInsert() || $this->_newValues);
    }

    public function
isStateChanged($key, $state)
    {
        if (!
$this->isChanged($key))
        {
            return
false;
        }
        else if (
$this->getValue($key) == $state)
        {
            return
'enter';
        }
        else if (
$this->isUpdate() && $this->getExistingValue($key) == $state)
        {
            return
'leave';
        }
        else
        {
            return
false;
        }
    }

   
/**
     * @return null|Behavior[]
     */
   
public function getBehaviors()
    {
        if (
$this->_behaviors === null)
        {
           
$this->_behaviors = $this->_em->getBehaviors($this, $this->_structure->behaviors);
            foreach (
$this->_behaviors AS $behavior)
            {
               
$behavior->onSetup();
            }
        }

        return
$this->_behaviors;
    }

    public function
getBehavior($behavior)
    {
       
$behaviors = $this->getBehaviors();
        if (!isset(
$behaviors[$behavior]))
        {
            throw new \
InvalidArgumentException("Unknown behavior '$behavior'");
        }

        return
$behaviors[$behavior];
    }

    public function
hasBehavior($behavior)
    {
       
$behaviors = $this->getBehaviors();
        return isset(
$behaviors[$behavior]);
    }

    public function
useReplaceInto($useReplaceInto)
    {
       
$this->_useReplaceInto = $useReplaceInto ? true : false;
    }

    public function
whenSaveable(\Closure $fn)
    {
        if (
$this->_writeRunning)
        {
           
$this->_whenSaveable[] = $fn;
        }
        else
        {
           
$fn($this);
        }
    }

    public function
addCascadedSave(Entity $entity)
    {
        if (
$entity === $this)
        {
            return;
        }

       
$id = $entity->getUniqueEntityId();
       
$this->_cascadeSave[$id] = $entity;
    }

    public function
removeCascadedSave(Entity $entity)
    {
        if (
$entity === $this)
        {
            return;
        }

       
$id = $entity->getUniqueEntityId();
        unset(
$this->_cascadeSave[$id]);
    }

    public final function
save($throw = true, $newTransaction = true)
    {
        if (
$this->_readOnly)
        {
            throw new \
LogicException("Entity is read only");
        }
        if (
$this->_deleted)
        {
            throw new \
LogicException("Cannot save a deleted entity");
        }

        if (!
$this->preSave())
        {
            if (
$throw)
            {
                throw new \
XF\PrintableException($this->_errors);
            }
            return
false;
        }
        if (
$this->_fillDeferredValues('save'))
        {
           
// this could cause a required field to be invalid
           
$this->_validateRequirements();
            if (
$this->_errors)
            {
                if (
$throw)
                {
                    throw new \
XF\PrintableException($this->_errors);
                }
                return
false;
            }
        }

       
$db = $this->db();
       
$isInsert = $this->isInsert();

        if (
$newTransaction)
        {
           
$db->beginTransaction();
        }

       
$this->_writeRunning = true;

        try
        {
           
$this->_saveToSource();

            if (
$isInsert)
            {
               
$this->_em->attachEntity($this);
            }

            if (
$this->_cascadeSave)
            {
               
$this->_em->startCascadeEvent('save', $this);

                foreach (
$this->_cascadeSave AS $save)
                {
                    if (!
$this->_em->triggerCascadeAttempt('save', $save))
                    {
                        continue;
                    }

                   
$save->save($throw, false);
                }

               
$this->_em->finishCascadeEvent('save');
            }

           
$this->_postSave();
            foreach (
$this->getBehaviors() AS $behaviorId => $behavior)
            {
               
$behavior->postSave();
            }

            \
XF::fire('entity_post_save', [$this], $this->rootClass);
        }
        catch (\
Exception $e)
        {
            if (
$newTransaction)
            {
               
$db->rollback();
            }

            throw
$e;
        }

        if (
$newTransaction)
        {
           
$db->commit();
        }

       
// Need to calculate this again after any post-save behaviors as _newValues could've possibly been changed
        // by calls to things like fastUpdate or setAsSaved.
       
$newDbValues = $this->_newValues;
       
$columns = $this->_structure->columns;
        foreach (
$newDbValues AS $column => $value)
        {
           
$newDbValues[$column] = $this->_em->encodeValueForSource($columns[$column]['type'], $value);
        }

       
$this->_saveCleanUp($newDbValues);

        return
true;
    }

    public final function
saveIfChanged(&$saved = null, $throw = true, $newTransaction = true)
    {
        if (!
$this->_newValues && $this->isUpdate())
        {
           
$saved = false;
            return
true;
        }

       
$saved = true;
        return
$this->save($throw, $newTransaction);
    }

    public function
fastUpdate($key, $value = null)
    {
        if (!
$this->exists() && !$this->_writeRunning)
        {
            throw new \
LogicException("Cannot call fastUpdate until the entity is saved");
        }

        if (
is_array($key))
        {
           
$fields = $key;
        }
        else
        {
           
$fields = [$key => $value];
        }

        if (!
$fields)
        {
            return;
        }

       
// Note: while the save is running, the new values reflect what's in the DB.
        // Also make sure we do this before the setAsSaved call in case we're update the primary key.
       
$condition = $this->_getUpdateCondition($this->_writeRunning);

       
$dbUpdate = [];
        foreach (
$fields AS $key => $value)
        {
           
$dbUpdate[$key] = $this->setAsSaved($key, $value);
        }

       
$this->db()->update($this->_structure->table, $dbUpdate, $condition);
    }

    public final function
preSave()
    {
       
// write will be pending after calling this; this means call it only once
       
if ($this->_writePending != 'save')
        {
           
$this->_fillDeferredValues('preSave');
           
$this->_preSave();

            foreach (
$this->getBehaviors() AS $behavior)
            {
               
$behavior->preSave();
            }

            \
XF::fire('entity_pre_save', [$this], $this->rootClass);

            if (
$this->_cascadeSave)
            {
               
$this->_em->startCascadeEvent('preSave', $this);

                foreach (
$this->_cascadeSave AS $childObjectId => $save)
                {
                    if (!
$this->_em->triggerCascadeAttempt('preSave', $save))
                    {
                        continue;
                    }

                    if (!
$save->preSave())
                    {
                        foreach (
$save->getErrors() AS $key => $error)
                        {
                           
$this->error($error, is_int($key) ? null : $key, false);
                        }
                    }
                }

               
$this->_em->finishCascadeEvent('preSave');
            }

            if (
$this->isInsert())
            {
               
$this->_fillInsertDefaults();
            }
           
$this->_validateRequirements();

           
$this->_writePending = 'save';
        }

        return
count($this->_errors) == 0;
    }

    protected function
_preSave() {}

    protected function
_fillDeferredValues($context)
    {
       
$keys = [];
        foreach (
$this->_newValues AS $key => $value)
        {
            if (
$value instanceof DeferredValue)
            {
               
$this->_resolveDeferredValue($key, $value, $context);
               
$keys[] = $key;
            }
        }

        return
$keys;
    }

    protected function
_resolveDeferredValue($key, DeferredValue $deferred, $context)
    {
       
$result = $deferred($this, $context);
        if (
$deferred->isAssignableAt($context))
        {
           
$this->set($key, $result, ['forceSet' => true]);
        }

        return
$result;
    }

    protected function
_fillInsertDefaults()
    {
        foreach (
$this->_structure->columns AS $key => $column)
        {
            if (
array_key_exists($key, $this->_newValues))
            {
                continue;
            }

            if (
array_key_exists('default', $column))
            {
               
$this->_setInternal($key, $column['default']);
            }
            else if (!empty(
$column['nullable']))
            {
               
$this->_setInternal($key, null);
            }
        }
    }

    protected function
_validateRequirements()
    {
        foreach (
$this->_structure->columns AS $key => $column)
        {
            if (empty(
$column['required']))
            {
                continue;
            }

            if (isset(
$this->_newValues[$key]) && $this->_newValues[$key] instanceof DeferredValue)
            {
               
// this will be resolved later
               
continue;
            }

            if (
$this->isUpdate() && !array_key_exists($key, $this->_newValues))
            {
                continue;
            }

           
$value = $this->getValue($key);
           
$exists = array_key_exists($key, $this->_newValues) || array_key_exists($key, $this->_values);

            if (!empty(
$column['nullable']) && $value === null && $exists)
            {
                continue;
            }

            if (!
$exists || $value === '' || $value === [] || $value === null)
            {
                if (
is_string($column['required']))
                {
                   
$this->error(\XF::phrase($column['required']), $key, false);
                }
                else
                {
                   
$this->error(\XF::phrase('please_enter_value_for_required_field_x', ['field' => $key]), $key, false);
                }
            }
        }
    }

    protected function
_saveToSource()
    {
       
$db = $this->db();
       
$structure = $this->_structure;
       
$columns = $structure->columns;

       
$save = $this->_newValues;
        foreach (
$save AS $column => $value)
        {
            if (!isset(
$columns[$column]))
            {
                throw new \
LogicException("Unknown column $column was found in data to be saved");
            }

           
$save[$column] = $this->_em->encodeValueForSource($columns[$column]['type'], $value);
        }

        if (
$save)
        {
            if (
$this->isInsert())
            {
               
$db->insert($structure->table, $save, $this->_useReplaceInto);
               
$this->_fillAutoIncrement($db->lastInsertId(), $save);
            }
            else
            {
               
$db->update($structure->table, $save, $this->_getUpdateCondition());
            }
        }

        return
$save;
    }

    protected function
_fillAutoIncrement($value, array &$newSourceValues)
    {
        foreach (
$this->_structure->columns AS $key => $column)
        {
            if (!empty(
$column['autoIncrement']))
            {
               
$this->_setInternal($key, $value);
               
$newSourceValues[$key] = $value;
                return
true;
            }
        }

        return
false;
    }

    protected function
_postSave() {}

    protected function
_saveCleanUp(array $newDbValues)
    {
       
$this->_writePending = false;
       
$this->_writeRunning = false;
       
$this->_previousValues = $this->_values;
       
$this->_values = array_merge($this->_values, $newDbValues);
       
$this->_newValues = [];
       
$this->_errors = [];

       
// need to wipe out this cache as we've overridden
       
foreach ($newDbValues AS $key => $null)
        {
            unset(
$this->_valueCache[$key]);
        }

       
// need to run any pending callbacks now that writing is complete
       
while ($fn = array_shift($this->_whenSaveable))
        {
           
$fn($this);
        }
    }

    public function
reset()
    {
       
$this->_writePending = false;
       
$this->_newValues = [];
       
$this->_errors = [];
       
$this->_getterCache = [];
       
$this->_valueCache = [];
       
$this->_options = [];
    }

    public final function
delete($throw = true, $newTransaction = true)
    {
        if (
$this->_deleted)
        {
            return
true;
        }
        if (!
$this->exists())
        {
            throw new \
LogicException("Cannot delete a non-saved entity");
        }
        if (
$this->_newValues)
        {
            throw new \
LogicException("Cannot delete an entity that has been partially updated");
        }
        if (
$this->_readOnly)
        {
            throw new \
LogicException("Entity is read only");
        }

        if (!
$this->preDelete())
        {
            if (
$throw)
            {
                throw new \
XF\PrintableException($this->_errors);
            }
            return
false;
        }

       
$db = $this->db();

        if (
$newTransaction)
        {
           
$db->beginTransaction();
        }

       
$this->_writeRunning = true;
       
$this->_deleted = true;

       
$rowAffected = $db->delete($this->_structure->table, $this->_getUpdateCondition());

       
$this->_em->startCascadeEvent('delete', $this);

        foreach (
$this->_structure->relations AS $relationId => $definition)
        {
            if (!empty(
$definition['cascadeDelete']) && $relation = $this->getRelation($relationId))
            {
                if (
$relation instanceof Entity)
                {
                    if (!
$this->_em->triggerCascadeAttempt('delete', $relation))
                    {
                        continue;
                    }

                   
$relation->delete($throw, false);
                }
                else
                {
                   
/** @var $child Entity */
                   
foreach ($relation AS $child)
                    {
                        if (!
$this->_em->triggerCascadeAttempt('delete', $child))
                        {
                            continue;
                        }

                       
$child->delete($throw, false);
                    }
                }
            }
        }

       
$this->_em->finishCascadeEvent('delete');

       
// note: only perform the following actions if the actual delete affected a row
        // this ensures there cannot be any unintended consequences by calling them repeatedly
       
if ($rowAffected)
        {
           
$this->_postDelete();

            foreach (
$this->getBehaviors() AS $behavior)
            {
               
$behavior->postDelete();
            }

            \
XF::fire('entity_post_delete', [$this], $this->rootClass);
        }

        if (
$newTransaction)
        {
           
$db->commit();
        }

       
$this->_em->detachEntity($this);
       
$this->_writePending = false;
       
$this->_writeRunning = false;

        return
true;
    }

    public final function
preDelete()
    {
        if (
$this->_deleted)
        {
            return
true;
        }

        if (
$this->_writePending != 'delete')
        {
           
$this->_preDelete();

            foreach (
$this->getBehaviors() AS $behavior)
            {
               
$behavior->preDelete();
            }

            \
XF::fire('entity_pre_delete', [$this], $this->rootClass);

           
$this->_em->startCascadeEvent('preDelete', $this);

            foreach (
$this->_structure->relations AS $relationId => $definition)
            {
                if (!empty(
$definition['cascadeDelete']) && $relation = $this->getRelation($relationId))
                {
                    if (
$relation instanceof Entity)
                    {
                        if (!
$this->_em->triggerCascadeAttempt('preDelete', $relation))
                        {
                            continue;
                        }

                        if (!
$relation->preDelete())
                        {
                            foreach (
$relation->getErrors() AS $key => $error)
                            {
                               
$this->error($error, is_int($key) ? null : $key, false);
                            }
                        }
                    }
                    else
                    {
                       
/** @var $child Entity */
                       
foreach ($relation AS $child)
                        {
                            if (!
$this->_em->triggerCascadeAttempt('preDelete', $child))
                            {
                                continue;
                            }

                            if (!
$child->preDelete())
                            {
                                foreach (
$child->getErrors() AS $key => $error)
                                {
                                   
$this->error($error, is_int($key) ? null : $key, false);
                                }
                            }
                        }
                    }
                }
            }

           
$this->_em->finishCascadeEvent('preDelete');
           
$this->_writePending = 'delete';
        }

        return
count($this->_errors) == 0;
    }

    public function
isDeleted()
    {
        return
$this->_deleted;
    }

    protected function
_preDelete() {}

    protected function
_postDelete() {}

    protected function
_getUpdateCondition($current = false)
    {
        if (!
$this->_values)
        {
            if (!
$current || !$this->_writeRunning)
            {
                throw new \
LogicException("Cannot get the update condition for a non-existing entity");
            }
        }

       
$conditions = [];
       
$db = $this->db();
        foreach ((array)
$this->_structure->primaryKey AS $key)
        {
           
$value = $current ? $this->getValue($key) : $this->getExistingValue($key);
            if (
$value === null)
            {
                throw new \
LogicException("Found null in primary key for entity. Was this called before saving?");
            }
           
$conditions[] = "`$key` = " . $db->quote($value);
        }

        if (!
$conditions)
        {
            throw new \
LogicException("No primary key defined for entity " . get_class($this));
        }

        return
implode(' AND ', $conditions);
    }

    public function
getIdentifierValues()
    {
       
$values = [];
        foreach ((array)
$this->_structure->primaryKey AS $key)
        {
           
$value = $this->getValue($key);
            if (
$value === null)
            {
                return
null; // primary keys cannot be null (after being saved at least)
           
}
           
$values[$key] = $value;
        }

        if (!
$values)
        {
            throw new \
LogicException("No primary key defined for entity " . get_class($this));
        }

        return
$values;
    }

    public function
getIdentifier()
    {
       
$keys = $this->getIdentifierValues();
        return
$keys ? implode('-', $keys) : null;
    }

   
/**
     * @return string|int
     */
   
public function getEntityId()
    {
       
$this->assertSimpleEntityId();

       
$key = $this->_structure->primaryKey;
        return
$this->getValue($key);
    }

    public function
getExistingEntityId()
    {
       
$this->assertSimpleEntityId();

       
$key = $this->_structure->primaryKey;
        return
$this->getExistingValue($key);
    }

    protected function
assertSimpleEntityId()
    {
        if (
is_array($this->_structure->primaryKey))
        {
            throw new \
LogicException("Cannot get a simple ID from the entity " . $this->_structure->shortName);
        }
    }

    public function
getUniqueEntityId()
    {
        return
$this->_uniqueEntityId;
    }

    public function
getEntityContentType()
    {
        return
$this->_structure->contentType;
    }

    public function
getEntityContentTypeId()
    {
        if (!
$this->_structure->contentType)
        {
            throw new \
LogicException("No content type specified in entity structure for " . $this->_structure->shortName);
        }

        return
$this->_structure->contentType . '-' . $this->getEntityId();
    }

    public function
error($message, $key = null, $specificError = true)
    {
        if (
$key)
        {
            if (
$specificError || !isset($this->_errors[$key]))
            {
               
$this->_errors[$key] = $message;
            }
        }
        else
        {
           
$this->_errors[] = $message;
        }
    }

    public function
getErrors()
    {
        return
$this->_errors;
    }

    public function
hasErrors()
    {
        return
count($this->_errors) > 0;
    }

    public function
setReadOnly($readOnly)
    {
       
$this->_readOnly = (bool)$readOnly;
    }

    public function
getReadOnly()
    {
        return
$this->_readOnly;
    }

    public function
__isset($key)
    {
       
$structure = $this->_structure;

        if (
substr($key, -1) == '_')
        {
           
$key = substr($key, 0, -1);
           
$useGetter = false;
        }
        else
        {
           
$useGetter = true;
        }

        if (
$useGetter && isset($structure->getters[$key]))
        {
            return
true;
        }

        return (
            isset(
$structure->columns[$key])
            || isset(
$structure->relations[$key])
        );
    }

   
#[\ReturnTypeWillChange]
   
public function offsetExists($key)
    {
        return
$this->__isset($key);
    }

   
#[\ReturnTypeWillChange]
   
public function offsetUnset($key)
    {
        throw new \
LogicException('Entity offsets may not be unset');
    }

    public function
isValidColumn($key)
    {
        return isset(
$this->_structure->columns[$key]);
    }

    public function
isValidRelation($key)
    {
        return isset(
$this->_structure->relations[$key]);
    }

    public function
isValidGetter($key)
    {
        return isset(
$this->_structure->getters[$key]);
    }

    public function
isValidKey($key)
    {
        return
$this->__isset($key);
    }

    protected function
_getDeferredValue(\Closure $handler, $assignTime = 'preSave')
    {
        return
$this->_em->getDeferredValue($handler, $assignTime);
    }

   
/**
     * @param string $identifier
     *
     * @return Finder
     */
   
public function finder($identifier)
    {
        return
$this->_em->getFinder($identifier);
    }

   
/**
     * @param string $identifier
     *
     * @return Repository
     */
   
public function repository($identifier)
    {
        return
$this->_em->getRepository($identifier);
    }

   
/**
     * @return \XF\Db\AbstractAdapter
     */
   
public function db()
    {
        return
$this->_em->getDb();
    }

   
/**
     * @return Manager
     */
   
public function em()
    {
        return
$this->_em;
    }

   
/**
     * @return Structure
     */
   
public function structure()
    {
        return
$this->_structure;
    }

   
/**
     * @return \XF\App
     */
   
public function app()
    {
        return \
XF::app();
    }

    public function
__toString()
    {
       
$key = $this->getIdentifierValues();
        if (!
$key)
        {
           
$key = '[unsaved]';
        }
        else
        {
           
$key = '[' . implode(', ', $key) . ']';
        }

        return
$this->_structure->shortName . $key;
    }

    public function
__debugInfo()
    {
       
$dump = (array)$this;
        unset(
$dump["\0*\0_getterCache"], $dump["\0*\0_valueCache"], $dump["\0*\0_structure"], $dump["\0*\0_em"]);

        return
$dump;
    }

   
/**
     * @param Structure $structure
     * @return Structure
     * @throws \LogicException
     */
   
public static function getStructure(Structure $structure)
    {
        throw new \
LogicException(get_called_class() . '::getStructure() must be overridden');
    }

    public function
getMaxLength($fieldName)
    {
        if (isset(
$this->_structure->columns[$fieldName]['maxLength']))
        {
            return
$this->_structure->columns[$fieldName]['maxLength'];
        }
        else
        {
            return
null;
        }
    }

    public function
__sleep()
    {
        throw new \
LogicException('Entities cannot be serialized or unserialized');
    }

    public function
__wakeup()
    {
        throw new \
LogicException('Entities cannot be serialized or unserialized');
    }
}