rugk/xenforo-threema-gateway

View on GitHub
src/library/ThreemaGateway/Model/Messages.php

Summary

Maintainability
D
1 day
Test Coverage
<?php
/**
 * Model for messages stored in database.
 *
 * @package ThreemaGateway
 * @author rugk
 * @copyright Copyright (c) 2015-2016 rugk
 * @license MIT
 */

class ThreemaGateway_Model_Messages extends XenForo_Model
{
    /**
     * @var string database table (prefix) for messages
     */
    const DB_TABLE_MESSAGES = 'xf_threemagw_messages';

    /**
     * @var string database table for files
     */
    const DB_TABLE_FILES = 'xf_threemagw_files';

    /**
     * @var string database table for acknowledged messages/delivery receipts
     */
    const DB_TABLE_DELIVERY_RECEIPT = 'xf_threemagw_ackmsgs';

    /**
     * @var int constant for type code
     */
    const TYPE_DELIVERY_MESSAGE = 0x80;

    /**
     * @var int constant for type code
     */
    const TYPE_FILE_MESSAGE = 0x17;

    /**
     * @var int constant for type code
     */
    const TYPE_IMAGE_MESSAGE = 0x02;

    /**
     * @var int constant for type code
     */
    const TYPE_TEXT_MESSAGE = 0x01;

    /**
     * @var array constant for type code
     */
    const ORDER_CHOICE = [
        'id' => 'message_id',
        'date_send' => 'date_send',
        'date_received' => 'date_received',
        'delivery_state' => 'message.receipt_type',
    ];

    /**
     * @var array data used when querying
     */
    protected $fetchOptions = [
        'where' => [],
        'params' => []
    ];

    /**
     * Execute this before any query.
     *
     * Sets internal values necessary for a correct connection to the database.
     */
    public function preQuery()
    {
        // set correct character encoding
        $this->_getDb()->query('SET NAMES utf8mb4');
    }

    /**
     * Inject or modify a fetch option manually.
     *
     * Sets internal values necessary for a correct connection to the database.
     * This should best be avoided, when it is not really necessary to change
     * the value directly.
     * It can e.g. be used to reset data. When you e.g. want to reset the where
     * option call it this way: injectFetchOption('where', []).
     *
     * @param string $option The option name to inject
     * @param string $value  The value of the option to set.
     * @param bool   $append If set to true, the value is not overriden, but
     *                       just appended as an array. (default: false)
     */
    public function injectFetchOption($option, $value, $append = false)
    {
        if ($append) {
            $this->fetchOptions[$option][] = $value;
        } else {
            $this->fetchOptions[$option] = $value;
        }
    }

    /**
     * Rests all fetch options. This is useful to prevent incorrect or
     * unexpected results when using one model for multiple queries (not
     * recommend) or for e.g. resetting the options before calling
     * {@link fetchAll()};.
     */
    public function resetFetchOptions()
    {
        $this->fetchOptions = [];
        // set empty data, which is required to prevent failing
        $this->fetchOptions['where']  = [];
        $this->fetchOptions['params'] = [];
    }

    /**
     * Sets the message ID(s) for the query.
     *
     * @param string|array $messageIds  one (string) or more (array) message IDs
     * @param string       $tablePrefix The table prefix (optional)
     */
    public function setMessageId($messageIds, $tablePrefix = null)
    {
        return $this->appendMixedCondition(
            ($tablePrefix ? $tablePrefix . '.' : '') . 'message_id',
            $messageIds
        );
    }

    /**
     * Sets the sender Threema ID(s) for querying it/them.
     *
     * @param string $threemaIds one (string) or more (array) Threema IDs
     */
    public function setSenderId($threemaIds)
    {
        return $this->appendMixedCondition(
            'metamessage.sender_threema_id',
            $threemaIds
        );
    }

    /**
     * Sets the type code(s) for querying only one (or a few) type.
     *
     * Please use the TYPE_* constants for specifying the type code(s).
     * You should avoid using this and rather use {@link getMessageDataByType()}
     * directly if you know the type code.
     * If you want to limit the types you want to query this method would be a
     * good way for you to use.
     *
     * @param string $typeCodes one (string) or more (array) type codes(s)
     */
    public function setTypeCode($typeCodes)
    {
        return $this->appendMixedCondition(
            'metamessage.message_type_code',
            $typeCodes
        );
    }

    /**
     * Sets a string to look for when querying text messages.
     *
     * The string is processed by MySQL via the `LIKE` command and may
     * therefore contain some wildcards: % for none or any character and
     * _ for exactly one character.
     * Attention: This is only possible when using the text message type!
     * Otherwise your query will fail.
     *
     * @param string $keyword a keyword to look for
     */
    public function setKeyword($keyword)
    {
        $this->fetchOptions['where'][]  = 'message.text LIKE ?';
        $this->fetchOptions['params'][] = $keyword;
    }

    /**
     * Sets the a time limit for what messages should be queried.
     *
     * @param int|null $dateMin   oldest date of messages (optional)
     * @param int|null $dateMax   latest date of messages (optional)
     * @param string   $attribute Set the atttribute to apply this to.
     */
    public function setTimeLimit($dateMin = null, $dateMax = null, $attribute = 'metamessage.date_send')
    {
        if ($dateMin) {
            $this->fetchOptions['where'][]  = $attribute . ' >= ?';
            $this->fetchOptions['params'][] = $dateMin;
        }
        if ($dateMax) {
            $this->fetchOptions['where'][]  = $attribute . ' <= ?';
            $this->fetchOptions['params'][] = $dateMax;
        }
    }

    /**
     * Limit the result to a number of datasets.
     *
     * @param int $limit oldest date of messages
     */
    public function setResultLimit($limit)
    {
        $this->fetchOptions['limit'] = $limit;
    }

    /**
     * Sets an order for the query.
     *
     * This function overwrites previous values if they were set as ordering by
     * multiple columns is not possible.
     *
     * @param int    $column    the column to order by (see {@link OrderChoice} for valid values)
     * @param string $direction asc or desc
     */
    public function setOrder($column, $direction = 'asc')
    {
        $this->fetchOptions['order']     = $column;
        $this->fetchOptions['direction'] = $direction;
    }

    /**
     * Queries all available data from a list of message IDs.
     *
     * Note that this requires one to have the meta data of the messages already
     * and therefore you have to run {@link getMessageMetaData()} before and
     * submit it as the first parameter.
     * This method also resets the conditions of the where clause
     * ($fetchOptions['where']) and the params ($fetchOptions['params']) based
     * on the results included in the meta data. Other fetch options however
     * remain and are still applied, so if you want to avoid this, use
     * {@link resetFetchOptions()}.
     * Note that the ordering values of different message types will not work as
     * this function internally needs to handle each message type differently.
     *
     * @param  array[string]     $metaData           The message meta data from
     *                                               {@link getMessageMetaData()}
     *                                               (without grouping)
     * @param  bool              $groupByMessageType Set to true to group the
     *                                               return value via message
     *                                               types. (default: false)
     * @throws XenForo_Exception
     * @return null|array
     */
    public function getAllMessageData(array $metaData, $groupByMessageType = false)
    {
        // get grouped messages by type
        $messageTypes = $this->groupArray($metaData, 'message_type_code');
        // we always need to do this (regardless of message_type_code) as each
        // message type needs to be handled individually

        // query message types individually
        $output = null;
        foreach ($messageTypes as $messageType => $messages) {
            // get messages of current data type in groups
            $groupedMessages = $this->groupArray($messages, 'message_id', true);

            // overwrite conditions with message IDs we already know
            $this->fetchOptions['params'] = [];
            $this->fetchOptions['where']  = [];
            $this->setMessageId($this->getMessageIdsFromResult($messages), 'message');

            // query data
            $groupedResult = $this->getMessageDataByType($messageType, false);
            // skip processing if there are no results (most likely all
            // messages of this type have been deleted)
            if (!is_array($groupedResult)) {
                continue;
            }

            // go through each message to merge result with meta data
            foreach ($groupedMessages as $msgId => $msgMetaData) {
                // ignore non-exisiting key (might be deleted messages)
                if (!array_key_exists($msgId, $groupedResult)) {
                    continue;
                }

                // merge arrays
                $mergedArrays = $msgMetaData + $groupedResult[$msgId];

                // remove unnecessary message_id (the ID is already the key)
                if (array_key_exists('message_id', $mergedArrays)) {
                    unset($mergedArrays['message_id']);
                }

                // save as output
                if ($groupByMessageType) {
                    // remove unnecessary message_type_code (as it is already
                    // grouped by it)
                    if (array_key_exists('message_type_code', $mergedArrays)) {
                        unset($mergedArrays['message_type_code']);
                    }

                    $output[$messageType][$msgId] = $mergedArrays;
                } else {
                    $output[$msgId] = $mergedArrays;
                }
            }
        }

        return $output;
    }

    /**
     * Queries all available data for a message type.
     *
     * The return value should be an array in the same format as the one
     * returned by {@link getAllMessageData()} when $groupByMessageType is set
     * to false. Of course, however, only one message type is returned here.
     *
     * @param  int               $messageType     The message type the messages belong to
     * @param  bool              $includeMetaData Set to true to also include the main
     *                                            message table in your query. If you do so you
     *                                            will also get the meta data of the message.
     *                                            (default: true)
     * @throws XenForo_Exception
     * @return null|array
     */
    public function getMessageDataByType($messageType, $includeMetaData = true)
    {
        /** @var array $output */
        $output = [];

        // prepare query
        /** @var array $limitOptions */
        $limitOptions = $this->prepareLimitFetchOptions($this->fetchOptions);

        // built query
        switch ($messageType) {
            case self::TYPE_DELIVERY_MESSAGE:
                /** @var Zend_Db_Table_Select $select */
                $select = $this->_getDb()->select()
                    ->from(['message' => self::DB_TABLE_MESSAGES . '_delivery_receipt'])
                    ->joinInner(
                        ['ack_messages' => self::DB_TABLE_DELIVERY_RECEIPT],
                        'message.message_id = ack_messages.message_id'
                    );

                /** @var string $resultIndex index to use for additional data from query */
                $resultIndex = 'ackmsgs';
                break;

            case self::TYPE_FILE_MESSAGE:
                /** @var Zend_Db_Table_Select $select */
                $select = $this->_getDb()->select()
                    ->from(['message' => self::DB_TABLE_MESSAGES . '_file'])
                    ->joinInner(
                        ['filelist' => self::DB_TABLE_FILES],
                        'filelist.message_id = message.message_id'
                    );

                /** @var string $resultIndex index to use for additional data from query */
                $resultIndex = 'files';
                break;

            case self::TYPE_IMAGE_MESSAGE:
                /** @var Zend_Db_Table_Select $select */
                $select = $this->_getDb()->select()
                    ->from(['message' => self::DB_TABLE_MESSAGES . '_image'])
                    ->joinInner(
                        ['filelist' => self::DB_TABLE_FILES],
                        'filelist.message_id = message.message_id'
                    );

                /** @var string $resultIndex index to use for additional data from query */
                $resultIndex = 'files';
                break;

            case self::TYPE_TEXT_MESSAGE:
                /** @var Zend_Db_Table_Select $select */
                $select = $this->_getDb()->select()
                    ->from(['message' => self::DB_TABLE_MESSAGES . '_text']);

                // although this is not strictly necessary, to ease the
                // processing the data later, we also index this
                /** @var string $resultIndex index to use for additional data from query */
                $resultIndex = 'text';
                break;

            default:
                throw new XenForo_Exception(new XenForo_Phrase('threemagw_unknown_message_type'));
        }

        // add table if necessary
        if ($includeMetaData) {
            $select->joinInner(
                ['metamessage' => self::DB_TABLE_MESSAGES],
                'message.message_id = metamessage.message_id'
            );
        }

        // general options for query
        $select
            ->where($this->getConditionsForClause($this->fetchOptions['where']))
            ->order($this->getOrderByClause(self::ORDER_CHOICE, $this->fetchOptions));

        // execute query
        /** @var array|null $result database query result */
        $result = $this->_getDb()->fetchAll(
            $this->limitQueryResults(
                $select,
                $limitOptions['limit'], $limitOptions['offset']),
        $this->fetchOptions['params']);

        // throw error if data is missing
        if (!is_array($result)) {
            throw new XenForo_Exception(new XenForo_Phrase('threemagw_missing_database_data'));
        }
        // if there is no result, just return null
        if (empty($result)) {
            return null;
        }

        // group array by message ID
        $result = $this->groupArray($result, 'message_id');

        // attributes to remove/push
        $removeAttributes = [
            'message_id',
            'file_name',
            'mime_type',
            'file_size'
        ];
        if ($includeMetaData) {
            $removeAttributes = array_merge($removeAttributes, [
                'message_type_code',
                'sender_threema_id',
                'date_send',
                'date_received'
            ]);
        }

        // push general attributes one array up
        if (!$resultIndex) {
            throw new XenForo_Exception(new XenForo_Phrase('threemagw_unknown_message_type'));
            break;
        }

        // go through each message
        foreach ($result as $msgId => $resultForId) {
            $output[$msgId] = [];
            $output[$msgId] = $this->pushArrayKeys($output[$msgId],
                                    $resultForId,
                                    $removeAttributes);
            $output[$msgId][$resultIndex] = $resultForId;

            // remove unnecessary message_id (the ID is already the key)
            if (array_key_exists('message_id', $output[$msgId])) {
                unset($output[$msgId]['message_id']);
            }
        }

        if (!$output) {
            return null;
        }

        return $output;
    }

    /**
     * Returns only the meta data of one or more messages not depending on the
     * type of the message.
     *
     * @param  bool       $groupById     When true groups the data by the message ID (default: false)
     * @param  bool       $ignoreInvalid When true removes data sets where the message content may be deleted (default: true)
     * @return null|array
     */
    public function getMessageMetaData($groupById = false, $ignoreInvalid = true)
    {
        /** @var array $limitOptions */
        $limitOptions = $this->prepareLimitFetchOptions($this->fetchOptions);

        /** @var array $result query result */
        $result = $this->_getDb()->fetchAll(
            $this->limitQueryResults(
                $this->_getDb()->select()
                    ->from(['metamessage' => self::DB_TABLE_MESSAGES])
                    ->where($this->getConditionsForClause($this->fetchOptions['where']))
                    ->order($this->getOrderByClause(self::ORDER_CHOICE, $this->fetchOptions)),
            $limitOptions['limit'], $limitOptions['offset']),
        $this->fetchOptions['params']);

        // fail if there is no data
        if (!is_array($result) || !$result) {
            return null;
        }

        // remove invalid data sets (where message might be deleted)
        if ($ignoreInvalid) {
            foreach ($result as $i => $msgData) {
                if (!array_key_exists('message_type_code', $msgData) ||
                    !$msgData['message_type_code']) {
                    unset($result[$i]);
                }
            }
        }

        // group array by message ID if wanted
        if ($groupById) {
            $result = $this->groupArray($result, 'message_id');
        }

        return $result;
    }

    /**
     * Removes entries or meta data fields from the meta data message table.
     *
     * Note that if the message(s) has/have other data saved in the database
     * the full deletion will fail.
     * Note: The number of where and params-options must be equal. You can
     * submit additional conditions with the first parameter.
     * Attention: This ignores the limit/offset clause for simplicity.
     *
     * @param string[] $additionalConditions Add additional where conditions if
     *                                       neccessary.
     * @param string[] $removeOnlyField      When set only the passed fields are
     *                                       updated to "null" rather than deleting
     *                                       the whole record.
     */
    public function removeMetaData(array $additionalConditions = [], array $removeOnlyField = [])
    {
        if (!empty($removeOnlyField)) {
            $this->_getDb()->update(
                self::DB_TABLE_MESSAGES,
                array_fill_keys($removeOnlyField, null),
                array_merge(
                    array_combine($this->fetchOptions['where'], $this->fetchOptions['params']),
                    $additionalConditions
                )
            );
        } else {
            $this->_getDb()->delete(
                self::DB_TABLE_MESSAGES,
                array_merge(
                    array_combine($this->fetchOptions['where'], $this->fetchOptions['params']),
                    $additionalConditions
                )
            );
        }
    }

    /**
     * Returns all available data from a list of message IDs.
     *
     * @param  array[string]     $messages The message result
     * @throws XenForo_Exception
     * @return null|array
     */
    protected function getMessageIdsFromResult(array $messages)
    {
        // use PHP function if available (>= PHP 5.5.0)
        if (function_exists('array_column')) {
            return array_column($messages, 'message_id');
        }

        // manually extract message_id from array
        $output = [];
        foreach ($messages as $message) {
            $output[] = $message['message_id'];
        }

        return $output;
    }

    /**
     * Removes the specified keys from the second array and pushes them into
     * the first base array.
     * The subarray must be indexed by integers, where each index contains an
     * associative array with the keys to remove.
     * It assumes that the 0-index of $subArray is there, including the data,
     * which should be pushed to $baseArray.
     *
     * @param array    $baseArray  the main array, where the key/value pairs get to
     * @param array    $subArray   the array, which keys should be removed
     * @param string[] $removeKeys an array of keys, which should be removed
     *
     * @throws XenForo_Exception
     * @return false|array
     */
    protected function pushArrayKeys(array &$baseArray, array &$subArray, array $removeKeys)
    {
        foreach ($removeKeys as $key) {
            // skip invalid keys
            if (!array_key_exists($key, $subArray[0])) {
                continue;
            }

            // move value from subarray to base array
            $baseArray[$key] = $subArray[0][$key];

            // then delete it from sub array
            /** @var int $subArrayCount */
            $subArrayCount = count($subArray);
            for ($i = 0; $i < $subArrayCount; $i++) {
                unset($subArray[$i][$key]);
            }
        }

        return $baseArray;
    }

    /**
     * Groups an array by using the value of a specific index in it.
     *
     * @param array      $array       the array, which is sued as the base
     * @param string|int $indexKey    the value of the key, which should be used
     *                                for indexing
     * @param bool       $ignoreIndex Set to true to ignore multiple values in
     *                                $array. If activated only the last key of
     *                                $array will be placed into the group and
     *                                it will be the only key. This is only
     *                                useful if you know for sure that only one
     *                                key is available.
     *
     * @return array
     */
    public function groupArray(array $array, $indexKey, $ignoreIndex = false)
    {
        /** @var array $output */
        $output = [];
        foreach ($array as $i => $value) {
            if ($ignoreIndex) {
                $output[$value[$indexKey]] = $value;
            } else {
                $output[$value[$indexKey]][] = $value;
            }
        }

        return $output;
    }

    /**
     * Appends a WHERE condition for either a string or an array.
     *
     * It automatically chooses between a simple `this = ?` or a more complex
     * `this IN (?, ?, ...)`.
     *
     * @param string       $attName  the name of the required attribut
     * @param string|array $attValue the value, which should be required
     *
     * @return array
     */
    protected function appendMixedCondition($attName, $attValue)
    {
        // convert arrays with only one value
        if (is_array($attValue) && count($attValue) == 1) {
            $attValue = $attValue[0];
        }

        if (!is_array($attValue)) {
            $this->fetchOptions['where'][]  = $attName . ' = ?';
            $this->fetchOptions['params'][] = $attValue;
        } else {
            $this->fetchOptions['where'][]  = $attName . ' IN (' . implode(', ', array_fill(0, count($attValue), '?')) . ')';
            $this->fetchOptions['params'] += $attValue;
        }
    }
}