<?php

/**
 * Joomla! Content Management System
 *
 * @copyright  (C) 2005 Open Source Matters, Inc. <https://www.joomla.org>
 * @license    GNU General Public License version 2 or later; see LICENSE.txt
 */

namespace Joomla\CMS\Table;

use Joomla\CMS\Access\Rules;
use Joomla\CMS\Event\AbstractEvent;
use Joomla\CMS\Event\Checkin\AfterCheckinEvent as GlobalAfterCheckinEvent;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Object\LegacyErrorHandlingTrait;
use Joomla\CMS\Object\LegacyPropertyManagementTrait;
use Joomla\Database\DatabaseAwareInterface;
use Joomla\Database\DatabaseAwareTrait;
use Joomla\Database\DatabaseInterface;
use Joomla\Database\DatabaseQuery;
use Joomla\Database\Exception\DatabaseNotFoundException;
use Joomla\Event\DispatcherAwareInterface;
use Joomla\Event\DispatcherAwareTrait;
use Joomla\Event\DispatcherInterface;
use Joomla\Filesystem\Path;
use Joomla\String\StringHelper;

// phpcs:disable PSR1.Files.SideEffects
\defined('_JEXEC') or die;
// phpcs:enable PSR1.Files.SideEffects

/**
 * Abstract Table class
 *
 * Parent class to all tables.
 *
 * @since  1.7.0
 */
abstract class Table extends \stdClass implements TableInterface, DispatcherAwareInterface, DatabaseAwareInterface
{
    use DispatcherAwareTrait;
    use DatabaseAwareTrait;
    use LegacyErrorHandlingTrait;
    use LegacyPropertyManagementTrait;


    /**
     * Include paths for searching for Table classes.
     *
     * @var    array
     * @since  3.0.0
     */
    private static $_includePaths = [];

    /**
     * Table fields cache
     *
     * @var   array
     * @since 3.10.4
     */
    private static $tableFields;

    /**
     * Name of the database table to model.
     *
     * @var    string
     * @since  1.7.0
     */
    protected $_tbl = '';

    /**
     * Name of the primary key field in the table.
     *
     * @var    string
     * @since  1.7.0
     */
    protected $_tbl_key = '';

    /**
     * Name of the primary key fields in the table.
     *
     * @var    array
     * @since  3.0.1
     */
    protected $_tbl_keys = [];

    /**
     * DatabaseInterface object.
     *
     * @var    DatabaseInterface
     * @since  1.7.0
     *
     * @deprecated  5.4.0 will be removed in 7.0
     *              Use setDatabase() and getDatabase() instead
     *              Example: $this->setDatabase($db);
     */
    protected $_db;

    /**
     * Should rows be tracked as ACL assets?
     *
     * @var    boolean
     * @since  1.7.0
     */
    protected $_trackAssets = false;

    /**
     * The rules associated with this record.
     *
     * @var    Rules  A Rules object.
     * @since  1.7.0
     */
    protected $_rules;

    /**
     * Indicator that the tables have been locked.
     *
     * @var    boolean
     * @since  1.7.0
     */
    protected $_locked = false;

    /**
     * Indicates that the primary keys autoincrement.
     *
     * @var    boolean
     * @since  3.1.4
     */
    protected $_autoincrement = true;

    /**
     * Array with alias for "special" columns such as ordering, hits etc etc
     *
     * @var    array
     * @since  3.4.0
     */
    protected $_columnAlias = [];

    /**
     * An array of key names to be json encoded in the bind function
     *
     * @var    array
     * @since  3.3
     */
    protected $_jsonEncode = [];

    /**
     * Indicates that columns fully support the NULL value in the database
     *
     * @var    boolean
     * @since  3.10.0
     */
    protected $_supportNullValue = false;

    /**
     * The UCM type alias. Used for tags, content versioning etc. Leave blank to effectively disable these features.
     *
     * @var    string
     * @since  4.0.0
     */
    public $typeAlias = null;

    /**
     * Object constructor to set table and key fields.  In most cases this will
     * be overridden by child classes to explicitly set the table and key fields
     * for a particular database table.
     *
     * @param   string                $table       Name of the table to model.
     * @param   mixed                 $key         Name of the primary key field in the table or array of field names that compose the primary key.
     * @param   DatabaseInterface     $db          Object which implements the DatabaseInterface.
     * @param   ?DispatcherInterface  $dispatcher  Event dispatcher for this table
     *
     * @since   1.7.0
     */
    public function __construct($table, $key, DatabaseInterface $db, ?DispatcherInterface $dispatcher = null)
    {
        // Set internal variables.
        $this->_tbl = $table;

        // Set the key to be an array.
        if (\is_string($key)) {
            $key = [$key];
        } elseif (\is_object($key)) {
            $key = (array) $key;
        }

        $this->_tbl_keys = $key;

        if (\count($key) == 1) {
            $this->_autoincrement = true;
        } else {
            $this->_autoincrement = false;
        }

        // Set the singular table key for backwards compatibility.
        $this->_tbl_key = $this->getKeyName();

        $this->setDatabase($db);

        // Initialise the table properties.
        $fields = $this->getFields();

        if ($fields) {
            foreach ($fields as $name => $v) {
                // Add the field if it is not already present.
                if (!$this->hasField($name)) {
                    $this->$name = null;
                }
            }
        }

        // If we are tracking assets, make sure an access field exists and initially set the default.
        if ($this->hasField('asset_id')) {
            $this->_trackAssets = true;
        }

        // If the access property exists, set the default.
        if ($this->hasField('access')) {
            $this->access = (int) Factory::getApplication()->get('access');
        }

        // Create or set a Dispatcher
        if (!\is_object($dispatcher) || !($dispatcher instanceof DispatcherInterface)) {
            // @todo Maybe we should use a dedicated "behaviour" dispatcher for performance reasons and to prevent system plugins from butting in?
            $dispatcher = Factory::getApplication()->getDispatcher();
        }

        $this->setDispatcher($dispatcher);

        $event = AbstractEvent::create(
            'onTableObjectCreate',
            [
                'subject' => $this,
            ]
        );
        $this->getDispatcher()->dispatch('onTableObjectCreate', $event);
    }

    /**
     * Get the columns from database table.
     *
     * @param   bool  $reload  flag to reload cache
     *
     * @return  mixed  An array of the field names, or false if an error occurs.
     *
     * @since   1.7.0
     * @throws  \UnexpectedValueException
     */
    public function getFields($reload = false)
    {
        $db  = $this->getDatabase();
        $key = $db->getServerType() . ':' . $db->getName() . ':' . $this->_tbl;

        if (!isset(self::$tableFields[$key]) || $reload) {
            // Lookup the fields for this table only once.
            $name   = $this->_tbl;
            $fields = $db->getTableColumns($name, false);

            if (empty($fields)) {
                throw new \UnexpectedValueException(\sprintf('No columns found for %s table', $name));
            }

            self::$tableFields[$key] = $fields;
        }

        return self::$tableFields[$key];
    }

    /**
     * Static method to get an instance of a Table class if it can be found in the table include paths.
     *
     * To add include paths for searching for Table classes see Table::addIncludePath().
     *
     * @param   string  $type    The type (name) of the Table class to get an instance of.
     * @param   string  $prefix  An optional prefix for the table class name.
     * @param   array   $config  An optional array of configuration values for the Table object.
     *
     * @return  Table|boolean   A Table object if found or boolean false on failure.
     *
     * @since       1.7.0
     *
     * @deprecated  4.3 will be removed in 7.0
     *              Use the MvcFactory instead or instantiate the table class directly.
     *              Example: Factory::getApplication()->bootComponent('...')->getMVCFactory()->createTable($name, $prefix, $config);
     *              $table = new \Joomla\CMS\Table\Content($db);
     */
    public static function getInstance($type, $prefix = 'JTable', $config = [])
    {
        /**
         * For B/C reasons we don't change the $prefix to \\Joomla\\CMS\\Table\\ since extensions which
         * use JTable as table prefix instead of an own prefix and not adding 'JTable' as prefix will
         * fail to load the table. We can't detect this situation.
         * Example:
         * class JTableMytable {}
         * JTable::getInstance('Mytable');
         * This will fail when we change the function default $prefix from JTable to \\Joomla\\CMS\\Table\\
         *
         * In case of $prefix is 'JTable' we make an additional check for '\\Joomla\\CMS\\Table\\' $type
         *
         */

        // Sanitize and prepare the table class name.
        $type       = preg_replace('/[^A-Z0-9_\.-]/i', '', $type);

        $tableClass       = $prefix . ucfirst($type);
        $tableClassLegacy = $tableClass;

        if ($prefix === 'JTable') {
            $tableClass = '\\Joomla\\CMS\\Table\\' . ucfirst($type);
        }

        // Only try to load the class if it doesn't already exist.
        if (!class_exists($tableClass) && !class_exists($tableClassLegacy)) {
            // Search for the class file in the JTable include paths.
            $paths     = self::addIncludePath();
            $pathIndex = 0;

            while (!class_exists($tableClass) && !class_exists($tableClassLegacy) && $pathIndex < \count($paths)) {
                if ($tryThis = Path::find($paths[$pathIndex++], strtolower($type) . '.php')) {
                    // Import the class file.
                    include_once $tryThis;
                }
            }
        }

        if (!class_exists($tableClass) && class_exists($tableClassLegacy)) {
            $tableClass = $tableClassLegacy;
        }

        if (!class_exists($tableClass)) {
            /*
            * If unable to find the class file in the Table include paths. Return false.
            * The warning JLIB_DATABASE_ERROR_NOT_SUPPORTED_FILE_NOT_FOUND has been removed in 3.6.3.
            * In 6.0 an Exception (type to be determined) will be thrown.
            * For more info see https://github.com/joomla/joomla-cms/issues/11570
            */

            return false;
        }

        // If a database object was passed in the configuration array use it, otherwise get the global one from Factory.
        $db = $config['dbo'] ?? Factory::getContainer()->get(DatabaseInterface::class);

        // Check for a possible service from the container otherwise manually instantiate the class
        if (Factory::getContainer()->has($tableClass)) {
            return Factory::getContainer()->get($tableClass);
        }

        // Instantiate a new table class and return it.
        return new $tableClass($db);
    }

    /**
     * Add a filesystem path where Table should search for table class files.
     *
     * @param   array|string  $path  A filesystem path or array of filesystem paths to add.
     *
     * @return  array  An array of filesystem paths to find Table classes in.
     *
     * @since       1.7.0
     *
     * @deprecated  4.3 will be removed in 7.0
     *              Should not be used anymore as tables are loaded through the MvcFactory
     */
    public static function addIncludePath($path = null)
    {
        // If the internal paths have not been initialised, do so with the base table path.
        if (empty(self::$_includePaths)) {
            self::$_includePaths = [__DIR__];
        }

        // Convert the passed path(s) to add to an array.
        settype($path, 'array');

        // If we have new paths to add, do so.
        if (!empty($path)) {
            // Check and add each individual new path.
            foreach ($path as $dir) {
                // Sanitize path.
                $dir = trim($dir);

                // Add to the front of the list so that custom paths are searched first.
                if (!\in_array($dir, self::$_includePaths)) {
                    array_unshift(self::$_includePaths, $dir);
                }
            }
        }

        return self::$_includePaths;
    }

    /**
     * Method to compute the default name of the asset.
     * The default name is in the form table_name.id
     * where id is the value of the primary key of the table.
     *
     * @return  string
     *
     * @since   1.7.0
     */
    protected function _getAssetName()
    {
        $keys = [];

        foreach ($this->_tbl_keys as $k) {
            $keys[] = (int) $this->$k;
        }

        return $this->_tbl . '.' . implode('.', $keys);
    }

    /**
     * Method to return the title to use for the asset table.
     *
     * In tracking the assets a title is kept for each asset so that there is some context available in a unified access manager.
     * Usually this would just return $this->title or $this->name or whatever is being used for the primary name of the row.
     * If this method is not overridden, the asset name is used.
     *
     * @return  string  The string to use as the title in the asset table.
     *
     * @since   1.7.0
     */
    protected function _getAssetTitle()
    {
        return $this->_getAssetName();
    }

    /**
     * Method to get the parent asset under which to register this one.
     *
     * By default, all assets are registered to the ROOT node with ID, which will default to 1 if none exists.
     * An extended class can define a table and ID to lookup.  If the asset does not exist it will be created.
     *
     * @param   ?Table    $table  A Table object for the asset parent.
     * @param   ?integer  $id     Id to look up
     *
     * @return  integer
     *
     * @since   1.7.0
     */
    protected function _getAssetParentId(?Table $table = null, $id = null)
    {
        // For simple cases, parent to the asset root.
        $assets = new Asset($this->getDatabase(), $this->getDispatcher());
        $rootId = $assets->getRootId();

        if (!empty($rootId)) {
            return $rootId;
        }

        return 1;
    }

    /**
     * Method to append the primary keys for this table to a query.
     *
     * @param   DatabaseQuery  $query  A query object to append.
     * @param   mixed          $pk     Optional primary key parameter.
     *
     * @return  void
     *
     * @since   3.1.4
     */
    public function appendPrimaryKeys($query, $pk = null)
    {
        $db = $this->getDatabase();

        if (\is_null($pk)) {
            foreach ($this->_tbl_keys as $k) {
                $query->where($db->quoteName($k) . ' = ' . $db->quote($this->$k));
            }
        } else {
            if (\is_string($pk)) {
                $pk = [$this->_tbl_key => $pk];
            }

            $pk = (object) $pk;

            foreach ($this->_tbl_keys as $k) {
                $query->where($db->quoteName($k) . ' = ' . $db->quote($pk->$k));
            }
        }
    }

    /**
     * Method to get the database table name for the class.
     *
     * @return  string  The name of the database table being modeled.
     *
     * @since   1.7.0
     */
    public function getTableName()
    {
        return $this->_tbl;
    }

    /**
     * Method to get the primary key field name for the table.
     *
     * @param   boolean  $multiple  True to return all primary keys (as an array) or false to return just the first one (as a string).
     *
     * @return  mixed  Array of primary key field names or string containing the first primary key field.
     *
     * @since   1.7.0
     */
    public function getKeyName($multiple = false)
    {
        // Count the number of keys
        if (\count($this->_tbl_keys)) {
            if ($multiple) {
                // If we want multiple keys, return the raw array.
                return $this->_tbl_keys;
            }

            // If we want the standard method, just return the first key.
            return $this->_tbl_keys[0];
        }

        return '';
    }

    /**
     * Returns the identity (primary key) value of this record
     *
     * @return  mixed
     *
     * @since   4.0.0
     */
    public function getId()
    {
        $key = $this->getKeyName();

        return $this->$key;
    }

    /**
     * Method to get the DatabaseInterface object.
     *
     * @return  DatabaseInterface  The internal database driver object.
     *
     * @since   1.7.0
     *
     * @deprecated  5.4.0 will be removed in 7.0
     *               Use getDatabase() instead
     *               Example: $this->getDatabase();
     */
    public function getDbo()
    {
        return $this->_db;
    }

    /**
     * Method to set the DatabaseInterface object.
     *
     * @param   DatabaseInterface  $db  A DatabaseInterface object to be used by the table object.
     *
     * @return  boolean  True on success.
     *
     * @since   1.7.0
     *
     * @deprecated  5.4.0 will be removed in 7.0
     *              Use setDatabase() instead
     *              Example: $this->setDatabase($db);
     */
    public function setDbo(DatabaseInterface $db)
    {
        $this->_db = $db;

        return true;
    }

    /**
     * Get the database.
     *
     * @return  DatabaseInterface
     *
     * @since   5.4.0
     * @throws  DatabaseNotFoundException May be thrown if the database has not been set.
     *
     * @note    This method will be removed in 7.0 and DatabaseAwareTrait will be used instead.
     */
    protected function getDatabase(): DatabaseInterface
    {
        return $this->getDbo();
    }

    /**
     * Set the database.
     *
     * @param   DatabaseInterface  $db  The database.
     *
     * @return  void
     *
     * @since   5.4.0
     *
     * @note    This method will be removed in 7.0 and DatabaseAwareTrait will be used instead.
     */
    public function setDatabase(DatabaseInterface $db): void
    {
        $this->_db                        = $db;
        $this->databaseAwareTraitDatabase = $db;
    }

    /**
     * Method to set rules for the record.
     *
     * @param   mixed  $input  A Rules object, JSON string, or array.
     *
     * @return  void
     *
     * @since   1.7.0
     */
    public function setRules($input)
    {
        if ($input instanceof Rules) {
            $this->_rules = $input;
        } else {
            $this->_rules = new Rules($input);
        }
    }

    /**
     * Method to get the rules for the record.
     *
     * @return  Rules object
     *
     * @since   1.7.0
     */
    public function getRules()
    {
        return $this->_rules;
    }

    /**
     * Method to reset class properties to the defaults set in the class
     * definition. It will ignore the primary key as well as any private class
     * properties (except $_errors).
     *
     * @return  void
     *
     * @since   1.7.0
     */
    public function reset()
    {
        $event = AbstractEvent::create(
            'onTableBeforeReset',
            [
                'subject' => $this,
            ]
        );
        $this->getDispatcher()->dispatch('onTableBeforeReset', $event);

        // Get the default values for the class from the table.
        foreach ($this->getFields() as $k => $v) {
            // If the property is not the primary key or private, reset it.
            if (!\in_array($k, $this->_tbl_keys) && (!str_starts_with($k, '_'))) {
                $this->$k = $v->Default;
            }
        }

        // Reset table errors
        $this->_errors = [];

        $event = AbstractEvent::create(
            'onTableAfterReset',
            [
                'subject' => $this,
            ]
        );
        $this->getDispatcher()->dispatch('onTableAfterReset', $event);
    }

    /**
     * Method to bind an associative array or object to the Table instance.This
     * method only binds properties that are publicly accessible and optionally
     * takes an array of properties to ignore when binding.
     *
     * @param   array|object  $src     An associative array or object to bind to the Table instance.
     * @param   array|string  $ignore  An optional array or space separated list of properties to ignore while binding.
     *
     * @return  boolean  True on success.
     *
     * @since   1.7.0
     * @throws  \InvalidArgumentException
     */
    public function bind($src, $ignore = [])
    {
        // Check if the source value is an array or object
        if (!\is_object($src) && !\is_array($src)) {
            throw new \InvalidArgumentException(
                \sprintf(
                    'Could not bind the data source in %1$s::bind(), the source must be an array or object but a "%2$s" was given.',
                    \get_class($this),
                    \gettype($src)
                )
            );
        }

        // If the ignore value is a string, explode it over spaces.
        if (!\is_array($ignore)) {
            $ignore = explode(' ', $ignore);
        }

        $event = AbstractEvent::create(
            'onTableBeforeBind',
            [
                'subject' => $this,
                'src'     => $src,
                'ignore'  => $ignore,
            ]
        );
        $this->getDispatcher()->dispatch('onTableBeforeBind', $event);

        // If the source value is an object, get its accessible properties.
        if (\is_object($src)) {
            $src = get_object_vars($src);
        }

        // JSON encode any fields required
        if (!empty($this->_jsonEncode)) {
            foreach ($this->_jsonEncode as $field) {
                if (isset($src[$field]) && \is_array($src[$field])) {
                    $src[$field] = json_encode($src[$field]);
                }
            }
        }

        // Bind the source value, excluding the ignored fields.
        foreach ($this->getProperties() as $k => $v) {
            // Only process fields not in the ignore array.
            if (!\in_array($k, $ignore)) {
                if (\array_key_exists($k, $src)) {
                    $this->$k = $src[$k];
                }
            }
        }

        $event = AbstractEvent::create(
            'onTableAfterBind',
            [
                'subject' => $this,
                'src'     => $src,
                'ignore'  => $ignore,
            ]
        );
        $this->getDispatcher()->dispatch('onTableAfterBind', $event);

        return true;
    }

    /**
     * Method to load a row from the database by primary key and bind the fields to the Table instance properties.
     *
     * @param   mixed    $keys   An optional primary key value to load the row by, or an array of fields to match.
     *                           If not set the instance property value is used.
     * @param   boolean  $reset  True to reset the default values before loading the new row.
     *
     * @return  boolean  True if successful. False if row not found.
     *
     * @since   1.7.0
     * @throws  \InvalidArgumentException
     * @throws  \RuntimeException
     * @throws  \UnexpectedValueException
     */
    public function load($keys = null, $reset = true)
    {
        $db = $this->getDatabase();

        // Pre-processing by observers
        $event = AbstractEvent::create(
            'onTableBeforeLoad',
            [
                'subject' => $this,
                'keys'    => $keys,
                'reset'   => $reset,
            ]
        );
        $this->getDispatcher()->dispatch('onTableBeforeLoad', $event);

        if (empty($keys)) {
            $empty = true;
            $keys  = [];

            // If empty, use the value of the current key
            foreach ($this->_tbl_keys as $key) {
                $empty      = $empty && empty($this->$key);
                $keys[$key] = $this->$key;
            }

            // If empty primary key there's is no need to load anything
            if ($empty) {
                return true;
            }
        } elseif (!\is_array($keys)) {
            // Load by primary key.
            $keyCount = \count($this->_tbl_keys);

            if ($keyCount) {
                if ($keyCount > 1) {
                    throw new \InvalidArgumentException('Table has multiple primary keys specified, only one primary key value provided.');
                }

                $keys = [$this->getKeyName() => $keys];
            } else {
                throw new \RuntimeException('No table keys defined.');
            }
        }

        if ($reset) {
            $this->reset();
        }

        // Initialise the query.
        $query = $db->createQuery()
            ->select('*')
            ->from($db->quoteName($this->_tbl));
        $fields = array_keys($this->getProperties());

        foreach ($keys as $field => $value) {
            // Check that $field is in the table.
            if (!\in_array($field, $fields)) {
                throw new \UnexpectedValueException(\sprintf('Missing field in database: %s &#160; %s.', \get_class($this), $field));
            }

            // Add the search tuple to the query.
            $query->where($db->quoteName($field) . ' = ' . $db->quote($value));
        }

        $db->setQuery($query);

        $row = $db->loadAssoc();

        // Check that we have a result.
        if (empty($row)) {
            $result = false;
        } else {
            // Bind the object with the row and return.
            $result = $this->bind($row);
        }

        // Post-processing by observers
        $event = AbstractEvent::create(
            'onTableAfterLoad',
            [
                'subject' => $this,
                'result'  => &$result,
                'row'     => $row,
            ]
        );
        $this->getDispatcher()->dispatch('onTableAfterLoad', $event);

        return $result;
    }

    /**
     * Method to perform sanity checks on the Table instance properties to ensure they are safe to store in the database.
     *
     * Child classes should override this method to make sure the data they are storing in the database is safe and as expected before storage.
     *
     * @return  boolean  True if the instance is sane and able to be stored in the database.
     *
     * @since   1.7.0
     */
    public function check()
    {
        // Post-processing by observers
        $event = AbstractEvent::create(
            'onTableCheck',
            [
                'subject' => $this,
            ]
        );
        $this->getDispatcher()->dispatch('onTableCheck', $event);

        return true;
    }

    /**
     * Method to store a row in the database from the Table instance properties.
     *
     * If a primary key value is set the row with that primary key value will be updated with the instance property values.
     * If no primary key value is set a new row will be inserted into the database with the properties from the Table instance.
     *
     * @param   boolean  $updateNulls  True to update fields even if they are null.
     *
     * @return  boolean  True on success.
     *
     * @since   1.7.0
     */
    public function store($updateNulls = false)
    {
        $db     = $this->getDatabase();
        $result = true;

        $k = $this->_tbl_keys;

        // Pre-processing by observers
        $event = AbstractEvent::create(
            'onTableBeforeStore',
            [
                'subject'     => $this,
                'updateNulls' => $updateNulls,
                'k'           => $k,
            ]
        );
        $this->getDispatcher()->dispatch('onTableBeforeStore', $event);

        $currentAssetId = 0;

        if (!empty($this->asset_id)) {
            $currentAssetId = $this->asset_id;
        }

        // The asset id field is managed privately by this class.
        if ($this->_trackAssets) {
            unset($this->asset_id);
        }

        // We have to unset typeAlias since updateObject / insertObject will try to insert / update all public variables...
        $typeAlias = $this->typeAlias;
        unset($this->typeAlias);

        try {
            // If a primary key exists update the object, otherwise insert it.
            if ($this->hasPrimaryKey()) {
                $db->updateObject($this->_tbl, $this, $this->_tbl_keys, $updateNulls);
            } else {
                $db->insertObject($this->_tbl, $this, $this->_tbl_keys[0]);
            }
        } catch (\Exception $e) {
            $this->setError($e->getMessage());
            $result = false;
        }

        $this->typeAlias = $typeAlias;

        // If the table is not set to track assets return true.
        if ($this->_trackAssets) {
            if ($this->_locked) {
                $this->_unlock();
            }

            /*
             * Asset Tracking
             */
            $parentId = $this->_getAssetParentId();
            $name     = $this->_getAssetName();
            $title    = $this->_getAssetTitle();
            $asset    = new Asset($this->getDatabase(), $this->getDispatcher());

            $asset->loadByName($name);

            // Re-inject the asset id.
            $this->asset_id = $asset->id;

            // Check for an error.
            $error = $asset->getError();

            if ($error) {
                $this->setError($error);

                return false;
            }

            // Specify how a new or moved node asset is inserted into the tree.
            if (empty($this->asset_id) || $asset->parent_id != $parentId) {
                $asset->setLocation($parentId, 'last-child');
            }

            // Prepare the asset to be stored.
            $asset->parent_id = $parentId;
            $asset->name      = $name;

            // Respect the table field limits
            $asset->title = StringHelper::substr($title, 0, 100);

            if ($this->_rules instanceof Rules) {
                $asset->rules = (string) $this->_rules;
            }

            if (!$asset->check() || !$asset->store()) {
                $this->setError($asset->getError());

                return false;
            }

            // Create an asset_id or heal one that is corrupted.
            if (empty($this->asset_id) || ($currentAssetId != $this->asset_id && !empty($this->asset_id))) {
                // Update the asset_id field in this table.
                $this->asset_id = (int) $asset->id;

                $query = $db->createQuery()
                    ->update($db->quoteName($this->_tbl))
                    ->set('asset_id = ' . (int) $this->asset_id);
                $this->appendPrimaryKeys($query);
                $db->setQuery($query)->execute();
            }
        }

        // Post-processing by observers
        $event = AbstractEvent::create(
            'onTableAfterStore',
            [
                'subject' => $this,
                'result'  => &$result,
            ]
        );
        $this->getDispatcher()->dispatch('onTableAfterStore', $event);

        return $result;
    }

    /**
     * Method to provide a shortcut to binding, checking and storing a Table instance to the database table.
     *
     * The method will check a row in once the data has been stored and if an ordering filter is present will attempt to reorder
     * the table rows based on the filter.  The ordering filter is an instance property name.  The rows that will be reordered
     * are those whose value matches the Table instance for the property specified.
     *
     * @param   array|object  $src             An associative array or object to bind to the Table instance.
     * @param   string        $orderingFilter  Filter for the order updating
     * @param   array|string  $ignore          An optional array or space separated list of properties to ignore while binding.
     *
     * @return  boolean  True on success.
     *
     * @since   1.7.0
     */
    public function save($src, $orderingFilter = '', $ignore = '')
    {
        $db = $this->getDatabase();

        // Attempt to bind the source to the instance.
        if (!$this->bind($src, $ignore)) {
            return false;
        }

        // Run any sanity checks on the instance and verify that it is ready for storage.
        if (!$this->check()) {
            return false;
        }

        // Attempt to store the properties to the database table.
        if (!$this->store()) {
            return false;
        }

        // Attempt to check the row in, just in case it was checked out.
        if (!$this->checkIn()) {
            return false;
        }

        // If an ordering filter is set, attempt reorder the rows in the table based on the filter and value.
        if ($orderingFilter) {
            $filterValue = $this->$orderingFilter;
            $this->reorder($orderingFilter ? $db->quoteName($orderingFilter) . ' = ' . $db->quote($filterValue) : '');
        }

        return true;
    }

    /**
     * Method to delete a row from the database table by primary key value.
     *
     * @param   mixed  $pk  An optional primary key value to delete.  If not set the instance property value is used.
     *
     * @return  boolean  True on success.
     *
     * @since   1.7.0
     * @throws  \UnexpectedValueException
     */
    public function delete($pk = null)
    {
        $db = $this->getDatabase();

        if (\is_null($pk)) {
            $pk = [];

            foreach ($this->_tbl_keys as $key) {
                $pk[$key] = $this->$key;
            }
        } elseif (!\is_array($pk)) {
            $pk = [$this->_tbl_key => $pk];
        }

        foreach ($this->_tbl_keys as $key) {
            $pk[$key] = \is_null($pk[$key]) ? $this->$key : $pk[$key];

            if ($pk[$key] === null) {
                throw new \UnexpectedValueException('Null primary key not allowed.');
            }

            $this->$key = $pk[$key];
        }

        // Pre-processing by observers
        $event = AbstractEvent::create(
            'onTableBeforeDelete',
            [
                'subject' => $this,
                'pk'      => $pk,
            ]
        );
        $this->getDispatcher()->dispatch('onTableBeforeDelete', $event);

        // If tracking assets, remove the asset first.
        if ($this->_trackAssets) {
            // Get the asset name
            $name  = $this->_getAssetName();
            $asset = new Asset($this->getDatabase(), $this->getDispatcher());

            if ($asset->loadByName($name)) {
                if (!$asset->delete()) {
                    $this->setError($asset->getError());

                    return false;
                }
            }
        }

        // Delete the row by primary key.
        $query = $db->createQuery()
            ->delete($db->quoteName($this->_tbl));
        $this->appendPrimaryKeys($query, $pk);

        $db->setQuery($query);

        // Check for a database error.
        $db->execute();

        // Post-processing by observers
        $event = AbstractEvent::create(
            'onTableAfterDelete',
            [
                'subject' => $this,
                'pk'      => $pk,
            ]
        );
        $this->getDispatcher()->dispatch('onTableAfterDelete', $event);

        return true;
    }

    /**
     * Method to check a row out if the necessary properties/fields exist.
     *
     * To prevent race conditions while editing rows in a database, a row can be checked out if the fields 'checked_out' and 'checked_out_time'
     * are available. While a row is checked out, any attempt to store the row by a user other than the one who checked the row out should be
     * held until the row is checked in again.
     *
     * @param   integer  $userId  The Id of the user checking out the row.
     * @param   mixed    $pk      An optional primary key value to check out.  If not set the instance property value is used.
     *
     * @return  boolean  True on success.
     *
     * @since   1.7.0
     * @throws  \UnexpectedValueException
     */
    public function checkOut($userId, $pk = null)
    {
        $db = $this->getDatabase();

        // Pre-processing by observers
        $event = AbstractEvent::create(
            'onTableBeforeCheckout',
            [
                'subject' => $this,
                'userId'  => $userId,
                'pk'      => $pk,
            ]
        );
        $this->getDispatcher()->dispatch('onTableBeforeCheckout', $event);

        // If there is no checked_out or checked_out_time field, just return true.
        if (!$this->hasField('checked_out') || !$this->hasField('checked_out_time')) {
            return true;
        }

        if (\is_null($pk)) {
            $pk = [];

            foreach ($this->_tbl_keys as $key) {
                $pk[$key] = $this->$key;
            }
        } elseif (!\is_array($pk)) {
            $pk = [$this->_tbl_key => $pk];
        }

        foreach ($this->_tbl_keys as $key) {
            $pk[$key] = \is_null($pk[$key]) ? $this->$key : $pk[$key];

            if ($pk[$key] === null) {
                throw new \UnexpectedValueException('Null primary key not allowed.');
            }
        }

        // Get column names.
        $checkedOutField     = $this->getColumnAlias('checked_out');
        $checkedOutTimeField = $this->getColumnAlias('checked_out_time');

        // Get the current time in the database format.
        $time = Factory::getDate()->toSql();

        // Check the row out by primary key.
        $query = $db->createQuery()
            ->update($db->quoteName($this->_tbl))
            ->set($db->quoteName($checkedOutField) . ' = ' . (int) $userId)
            ->set($db->quoteName($checkedOutTimeField) . ' = ' . $db->quote($time));
        $this->appendPrimaryKeys($query, $pk);
        $db->setQuery($query);
        $db->execute();

        // Set table values in the object.
        $this->$checkedOutField      = (int) $userId;
        $this->$checkedOutTimeField  = $time;

        // Post-processing by observers
        $event = AbstractEvent::create(
            'onTableAfterCheckout',
            [
                'subject' => $this,
                'userId'  => $userId,
                'pk'      => $pk,
            ]
        );
        $this->getDispatcher()->dispatch('onTableAfterCheckout', $event);

        return true;
    }

    /**
     * Method to check a row in if the necessary properties/fields exist.
     *
     * Checking a row in will allow other users the ability to edit the row.
     *
     * @param   mixed  $pk  An optional primary key value to check out.  If not set the instance property value is used.
     *
     * @return  boolean  True on success.
     *
     * @since   1.7.0
     * @throws  \UnexpectedValueException
     */
    public function checkIn($pk = null)
    {
        $db = $this->getDatabase();

        // Pre-processing by observers
        $event = AbstractEvent::create(
            'onTableBeforeCheckin',
            [
                'subject' => $this,
                'pk'      => $pk,
            ]
        );
        $this->getDispatcher()->dispatch('onTableBeforeCheckin', $event);

        // If there is no checked_out or checked_out_time field, just return true.
        if (!$this->hasField('checked_out') || !$this->hasField('checked_out_time')) {
            return true;
        }

        if (\is_null($pk)) {
            $pk = [];

            foreach ($this->_tbl_keys as $key) {
                $pk[$this->$key] = $this->$key;
            }
        } elseif (!\is_array($pk)) {
            $pk = [$this->_tbl_key => $pk];
        }

        foreach ($this->_tbl_keys as $key) {
            $pk[$key] = empty($pk[$key]) ? $this->$key : $pk[$key];

            if ($pk[$key] === null) {
                throw new \UnexpectedValueException('Null primary key not allowed.');
            }
        }

        // Get column names.
        $checkedOutField     = $this->getColumnAlias('checked_out');
        $checkedOutTimeField = $this->getColumnAlias('checked_out_time');

        $nullDate = $this->_supportNullValue ? 'NULL' : $db->quote($db->getNullDate());
        $nullID   = $this->_supportNullValue ? 'NULL' : '0';

        // Check the row in by primary key.
        $query = $db->createQuery()
            ->update($db->quoteName($this->_tbl))
            ->set($db->quoteName($checkedOutField) . ' = ' . $nullID)
            ->set($db->quoteName($checkedOutTimeField) . ' = ' . $nullDate);
        $this->appendPrimaryKeys($query, $pk);
        $db->setQuery($query);

        // Check for a database error.
        $db->execute();

        // Set table values in the object.
        $this->$checkedOutField     = $this->_supportNullValue ? null : 0;
        $this->$checkedOutTimeField = $this->_supportNullValue ? null : '';

        // Post-processing by observers
        $event = AbstractEvent::create(
            'onTableAfterCheckin',
            [
                'subject' => $this,
                'pk'      => $pk,
            ]
        );
        $this->getDispatcher()->dispatch('onTableAfterCheckin', $event);
        $this->getDispatcher()->dispatch('onAfterCheckin', new GlobalAfterCheckinEvent('onAfterCheckin', [
            'subject' => $this->_tbl,
        ]));

        return true;
    }

    /**
     * Validate that the primary key has been set.
     *
     * @return  boolean  True if the primary key(s) have been set.
     *
     * @since   3.1.4
     */
    public function hasPrimaryKey()
    {
        $db = $this->getDatabase();

        if ($this->_autoincrement) {
            $empty = true;

            foreach ($this->_tbl_keys as $key) {
                $empty = $empty && empty($this->$key);
            }
        } else {
            $query = $db->createQuery()
                ->select('COUNT(*)')
                ->from($db->quoteName($this->_tbl));
            $this->appendPrimaryKeys($query);

            $db->setQuery($query);
            $count = $db->loadResult();

            if ($count == 1) {
                $empty = false;
            } else {
                $empty = true;
            }
        }

        return !$empty;
    }

    /**
     * Method to increment the hits for a row if the necessary property/field exists.
     *
     * @param   mixed  $pk  An optional primary key value to increment. If not set the instance property value is used.
     *
     * @return  boolean  True on success.
     *
     * @since   1.7.0
     * @throws  \UnexpectedValueException
     */
    public function hit($pk = null)
    {
        $db = $this->getDatabase();

        // Pre-processing by observers
        $event = AbstractEvent::create(
            'onTableBeforeHit',
            [
                'subject' => $this,
                'pk'      => $pk,
            ]
        );
        $this->getDispatcher()->dispatch('onTableBeforeHit', $event);

        // If there is no hits field, just return true.
        if (!$this->hasField('hits')) {
            return true;
        }

        if (\is_null($pk)) {
            $pk = [];

            foreach ($this->_tbl_keys as $key) {
                $pk[$key] = $this->$key;
            }
        } elseif (!\is_array($pk)) {
            $pk = [$this->_tbl_key => $pk];
        }

        foreach ($this->_tbl_keys as $key) {
            $pk[$key] = \is_null($pk[$key]) ? $this->$key : $pk[$key];

            if ($pk[$key] === null) {
                throw new \UnexpectedValueException('Null primary key not allowed.');
            }
        }

        // Get column name.
        $hitsField = $this->getColumnAlias('hits');

        // Check the row in by primary key.
        $query = $db->createQuery()
            ->update($db->quoteName($this->_tbl))
            ->set($db->quoteName($hitsField) . ' = (' . $db->quoteName($hitsField) . ' + 1)');
        $this->appendPrimaryKeys($query, $pk);
        $db->setQuery($query);
        $db->execute();

        // Set table values in the object.
        $this->hits++;

        // Pre-processing by observers
        $event = AbstractEvent::create(
            'onTableAfterHit',
            [
                'subject' => $this,
                'pk'      => $pk,
            ]
        );
        $this->getDispatcher()->dispatch('onTableAfterHit', $event);

        return true;
    }

    /**
     * Method to determine if a row is checked out and therefore uneditable by a user.
     *
     * If the row is checked out by the same user, then it is considered not checked out -- as the user can still edit it.
     *
     * @param   integer  $with     The user ID to perform the match with, if an item is checked out by this user the function will return false.
     * @param   integer  $against  The user ID to perform the match against when the function is used as a static function.
     *
     * @return  boolean  True if checked out.
     *
     * @since   1.7.0
     */
    public function isCheckedOut($with = 0, $against = null)
    {
        // Handle the non-static case.
        if (isset($this) && ($this instanceof Table) && \is_null($against)) {
            $checkedOutField = $this->getColumnAlias('checked_out');
            $against         = $this->$checkedOutField;
        }

        // The item is not checked out or is checked out by the same user.
        if (!$against || ($against == $with)) {
            return false;
        }

        // This last check can only be relied on if tracking session metadata
        if (Factory::getApplication()->get('session_metadata', true)) {
            $db    = Factory::getDbo();
            $query = $db->createQuery()
                ->select('COUNT(userid)')
                ->from($db->quoteName('#__session'))
                ->where($db->quoteName('userid') . ' = ' . (int) $against);
            $db->setQuery($query);
            $checkedOut = (bool) $db->loadResult();

            // If a session exists for the user then it is checked out.
            return $checkedOut;
        }

        // Assume if we got here that there is a value in the checked out column but it doesn't match the given user
        return true;
    }

    /**
     * Method to get the next ordering value for a group of rows defined by an SQL WHERE clause.
     *
     * This is useful for placing a new item last in a group of items in the table.
     *
     * @param   string  $where  WHERE clause to use for selecting the MAX(ordering) for the table.
     *
     * @return  integer  The next ordering value.
     *
     * @since   1.7.0
     * @throws  \UnexpectedValueException
     */
    public function getNextOrder($where = '')
    {
        $db = $this->getDatabase();

        // Check if there is an ordering field set
        if (!$this->hasField('ordering')) {
            throw new \UnexpectedValueException(\sprintf('%s does not support ordering.', \get_class($this)));
        }

        // Get the largest ordering value for a given where clause.
        $query = $db->createQuery()
            ->select('MAX(' . $db->quoteName($this->getColumnAlias('ordering')) . ')')
            ->from($db->quoteName($this->_tbl));

        if ($where) {
            $query->where($where);
        }

        $db->setQuery($query);
        $max = (int) $db->loadResult();

        // Return the largest ordering value + 1.
        return $max + 1;
    }

    /**
     * Get the primary key values for this table using passed in values as a default.
     *
     * @param   array  $keys  Optional primary key values to use.
     *
     * @return  array  An array of primary key names and values.
     *
     * @since   3.1.4
     */
    public function getPrimaryKey(array $keys = [])
    {
        foreach ($this->_tbl_keys as $key) {
            if (!isset($keys[$key])) {
                if (!empty($this->$key)) {
                    $keys[$key] = $this->$key;
                }
            }
        }

        return $keys;
    }

    /**
     * Method to compact the ordering values of rows in a group of rows defined by an SQL WHERE clause.
     *
     * @param   string|string[]  $where  WHERE clause to use for limiting the selection of rows to compact the ordering values.
     *
     * @return  mixed  Boolean  True on success.
     *
     * @since   1.7.0
     * @throws  \UnexpectedValueException
     */
    public function reorder($where = '')
    {
        $db = $this->getDatabase();

        // Check if there is an ordering field set
        if (!$this->hasField('ordering')) {
            throw new \UnexpectedValueException(\sprintf('%s does not support ordering.', \get_class($this)));
        }

        $quotedOrderingField = $db->quoteName($this->getColumnAlias('ordering'));

        $subquery = $db->createQuery()
            ->from($db->quoteName($this->_tbl))
            ->selectRowNumber($quotedOrderingField, 'new_ordering');

        $query = $db->createQuery()
            ->update($db->quoteName($this->_tbl))
            ->set($quotedOrderingField . ' = sq.new_ordering');

        $innerOn = [];

        // Get the primary keys for the selection.
        foreach ($this->_tbl_keys as $i => $k) {
            $subquery->select($db->quoteName($k, 'pk__' . $i));
            $innerOn[] = $db->quoteName($k) . ' = sq.' . $db->quoteName('pk__' . $i);
        }

        // Setup the extra where and ordering clause data.
        if ($where) {
            $subquery->where($where);
            $query->where($where);
        }

        $subquery->where($quotedOrderingField . ' >= 0');
        $query->where($quotedOrderingField . ' >= 0');
        $query->innerJoin('(' . (string) $subquery . ') AS sq ');

        foreach ($innerOn as $key) {
            $query->where($key);
        }

        // Pre-processing by observers
        $event = AbstractEvent::create(
            'onTableBeforeReorder',
            [
                'subject' => $this,
                'query'   => $query,
                'where'   => $where,
            ]
        );
        $this->getDispatcher()->dispatch('onTableBeforeReorder', $event);

        $db->setQuery($query);
        $db->execute();

        // Post-processing by observers
        $event = AbstractEvent::create(
            'onTableAfterReorder',
            [
                'subject' => $this,
                'where'   => $where,
            ]
        );
        $this->getDispatcher()->dispatch('onTableAfterReorder', $event);

        return true;
    }

    /**
     * Method to move a row in the ordering sequence of a group of rows defined by an SQL WHERE clause.
     *
     * Negative numbers move the row up in the sequence and positive numbers move it down.
     *
     * @param   integer          $delta  The direction and magnitude to move the row in the ordering sequence.
     * @param   string|string[]  $where  WHERE clause to use for limiting the selection of rows to compact the ordering values.
     *
     * @return  boolean  True on success.
     *
     * @since   1.7.0
     * @throws  \UnexpectedValueException
     */
    public function move($delta, $where = '')
    {
        $db = $this->getDatabase();

        // Check if there is an ordering field set
        if (!$this->hasField('ordering')) {
            throw new \UnexpectedValueException(\sprintf('%s does not support ordering.', \get_class($this)));
        }

        $orderingField       = $this->getColumnAlias('ordering');
        $quotedOrderingField = $db->quoteName($orderingField);

        // If the change is none, do nothing.
        if (empty($delta)) {
            return true;
        }

        $row   = null;
        $query = $db->createQuery();

        // Select the primary key and ordering values from the table.
        $query->select(implode(',', $this->_tbl_keys) . ', ' . $quotedOrderingField)
            ->from($db->quoteName($this->_tbl));

        // If the movement delta is negative move the row up.
        if ($delta < 0) {
            $query->where($quotedOrderingField . ' < ' . (int) $this->$orderingField)
                ->order($quotedOrderingField . ' DESC');
        } elseif ($delta > 0) {
            // If the movement delta is positive move the row down.
            $query->where($quotedOrderingField . ' > ' . (int) $this->$orderingField)
                ->order($quotedOrderingField . ' ASC');
        }

        // Add the custom WHERE clause if set.
        if ($where) {
            $query->where($where);
        }

        // Pre-processing by observers
        $event = AbstractEvent::create(
            'onTableBeforeMove',
            [
                'subject' => $this,
                'query'   => $query,
                'delta'   => $delta,
                'where'   => $where,
            ]
        );
        $this->getDispatcher()->dispatch('onTableBeforeMove', $event);

        // Select the first row with the criteria.
        $query->setLimit(1);
        $db->setQuery($query);
        $row = $db->loadObject();

        // If a row is found, move the item.
        if (!empty($row)) {
            // Update the ordering field for this instance to the row's ordering value.
            $query->clear()
                ->update($db->quoteName($this->_tbl))
                ->set($quotedOrderingField . ' = ' . (int) $row->$orderingField);
            $this->appendPrimaryKeys($query);
            $db->setQuery($query);
            $db->execute();

            // Update the ordering field for the row to this instance's ordering value.
            $query->clear()
                ->update($db->quoteName($this->_tbl))
                ->set($quotedOrderingField . ' = ' . (int) $this->$orderingField);
            $this->appendPrimaryKeys($query, $row);
            $db->setQuery($query);
            $db->execute();

            // Update the instance value.
            $this->$orderingField = $row->$orderingField;
        } else {
            // Update the ordering field for this instance.
            $query->clear()
                ->update($db->quoteName($this->_tbl))
                ->set($quotedOrderingField . ' = ' . (int) $this->$orderingField);
            $this->appendPrimaryKeys($query);
            $db->setQuery($query);
            $db->execute();
        }

        // Post-processing by observers
        $event = AbstractEvent::create(
            'onTableAfterMove',
            [
                'subject' => $this,
                'row'     => $row,
                'delta'   => $delta,
                'where'   => $where,
            ]
        );
        $this->getDispatcher()->dispatch('onTableAfterMove', $event);

        return true;
    }

    /**
     * Method to set the publishing state for a row or list of rows in the database table.
     *
     * The method respects checked out rows by other users and will attempt to checkin rows that it can after adjustments are made.
     *
     * @param   mixed    $pks     An optional array of primary key values to update. If not set the instance property value is used.
     * @param   integer  $state   The publishing state. eg. [0 = unpublished, 1 = published]
     * @param   integer  $userId  The user ID of the user performing the operation.
     *
     * @return  boolean  True on success; false if $pks is empty.
     *
     * @since   1.7.0
     */
    public function publish($pks = null, $state = 1, $userId = 0)
    {
        $db = $this->getDatabase();

        // Sanitize input
        $userId = (int) $userId;
        $state  = (int) $state;

        // Pre-processing by observers
        $event = AbstractEvent::create(
            'onTableBeforePublish',
            [
                'subject' => $this,
                'pks'     => $pks,
                'state'   => $state,
                'userId'  => $userId,
            ]
        );
        $this->getDispatcher()->dispatch('onTableBeforePublish', $event);

        if (!\is_null($pks)) {
            if (!\is_array($pks)) {
                $pks = [$pks];
            }

            foreach ($pks as $key => $pk) {
                if (!\is_array($pk)) {
                    $pks[$key] = [$this->_tbl_key => $pk];
                }
            }
        }

        // If there are no primary keys set check to see if the instance key is set.
        if (empty($pks)) {
            $pk = [];

            foreach ($this->_tbl_keys as $key) {
                if ($this->$key) {
                    $pk[$key] = $this->$key;
                } else {
                    // We don't have a full primary key - return false
                    $this->setError(Text::_('JLIB_DATABASE_ERROR_NO_ROWS_SELECTED'));

                    return false;
                }
            }

            $pks = [$pk];
        }

        $publishedField  = $this->getColumnAlias('published');
        $checkedOutField = $this->getColumnAlias('checked_out');

        foreach ($pks as $pk) {
            // Update the publishing state for rows with the given primary keys.
            $query = $db->createQuery()
                ->update($db->quoteName($this->_tbl))
                ->set($db->quoteName($publishedField) . ' = ' . (int) $state);

            // If publishing, set published date/time if not previously set
            if ($state && $this->hasField('publish_up') && (int) $this->publish_up == 0) {
                $nowDate = $db->quote(Factory::getDate()->toSql());
                $query->set($db->quoteName($this->getColumnAlias('publish_up')) . ' = ' . $nowDate);
            }

            // Determine if there is checkin support for the table.
            if ($this->hasField('checked_out') || $this->hasField('checked_out_time')) {
                $query->where(
                    '('
                        . $db->quoteName($checkedOutField) . ' = 0'
                        . ' OR ' . $db->quoteName($checkedOutField) . ' = ' . (int) $userId
                        . ' OR ' . $db->quoteName($checkedOutField) . ' IS NULL'
                        . ')'
                );
                $checkin = true;
            } else {
                $checkin = false;
            }

            // Build the WHERE clause for the primary keys.
            $this->appendPrimaryKeys($query, $pk);

            $db->setQuery($query);

            try {
                $db->execute();
            } catch (\RuntimeException $e) {
                $this->setError($e->getMessage());

                return false;
            }

            // If checkin is supported and all rows were adjusted, check them in.
            if ($checkin && (\count($pks) == $db->getAffectedRows())) {
                $this->checkIn($pk);
            }

            // If the Table instance value is in the list of primary keys that were set, set the instance.
            $ours = true;

            foreach ($this->_tbl_keys as $key) {
                if ($this->$key != $pk[$key]) {
                    $ours = false;
                }
            }

            if ($ours) {
                $this->$publishedField = $state;
            }
        }

        // Pre-processing by observers
        $event = AbstractEvent::create(
            'onTableAfterPublish',
            [
                'subject' => $this,
                'pks'     => $pks,
                'state'   => $state,
                'userId'  => $userId,
            ]
        );
        $this->getDispatcher()->dispatch('onTableAfterPublish', $event);

        return true;
    }

    /**
     * Method to lock the database table for writing.
     *
     * @return  boolean  True on success.
     *
     * @since   1.7.0
     * @throws  \RuntimeException
     */
    protected function _lock()
    {
        $this->getDatabase()->lockTable($this->_tbl);
        $this->_locked = true;

        return true;
    }

    /**
     * Method to return the real name of a "special" column such as ordering, hits, published
     * etc etc. In this way you are free to follow your db naming convention and use the
     * built in \Joomla functions.
     *
     * @param   string  $column  Name of the "special" column (ie ordering, hits)
     *
     * @return  string  The string that identify the special
     *
     * @since   3.4
     */
    public function getColumnAlias($column)
    {
        // Get the column data if set
        $return = $this->_columnAlias[$column] ?? $column;

        // Sanitize the name
        $return = preg_replace('#[^A-Z0-9_]#i', '', $return);

        return $return;
    }

    /**
     * Method to register a column alias for a "special" column.
     *
     * @param   string  $column       The "special" column (ie ordering)
     * @param   string  $columnAlias  The real column name (ie foo_ordering)
     *
     * @return  void
     *
     * @since   3.4
     */
    public function setColumnAlias($column, $columnAlias)
    {
        // Sanitize the column name alias
        $column = strtolower($column);
        $column = preg_replace('#[^A-Z0-9_]#i', '', $column);

        // Set the column alias internally
        $this->_columnAlias[$column] = $columnAlias;
    }

    /**
     * Method to unlock the database table for writing.
     *
     * @return  boolean  True on success.
     *
     * @since   1.7.0
     */
    protected function _unlock()
    {
        if ($this->_locked) {
            $this->getDatabase()->unlockTables();
            $this->_locked = false;
        }

        return true;
    }

    /**
     * Check if the record has a property (applying a column alias if it exists)
     *
     * @param   string  $key  key to be checked
     *
     * @return  boolean
     *
     * @since   3.9.11
     */
    public function hasField($key)
    {
        $key = $this->getColumnAlias($key);

        return property_exists($this, $key);
    }
}
