Skip to content
Extraits de code Groupes Projets
SegmentExpression.php 11,9 ko
Newer Older
  • Learn to ignore specific revisions
  • mattpiwik's avatar
    mattpiwik a validé
    <?php
    
    /**
     * Piwik - Open source web analytics
    
     * @link http://piwik.org
     * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
    
    /**
     *
     * @package Piwik
     */
    
    mattpiwik's avatar
    mattpiwik a validé
    {
        const AND_DELIMITER = ';';
        const OR_DELIMITER = ',';
    
    mattpiwik's avatar
    mattpiwik a validé
        const MATCH_EQUAL = '==';
        const MATCH_NOT_EQUAL = '!=';
    
        const MATCH_GREATER_OR_EQUAL = '>=';
        const MATCH_LESS_OR_EQUAL = '<=';
        const MATCH_GREATER = '>';
        const MATCH_LESS = '<';
        const MATCH_CONTAINS = '=@';
        const MATCH_DOES_NOT_CONTAIN = '!@';
    
    mattab's avatar
    mattab a validé
        // Note: you can't write this in the API, but access this feature
        // via field!=        <- IS NOT NULL
        // or via field==     <- IS NULL / empty
        const MATCH_IS_NOT_NULL_NOR_EMPTY = '::NOT_NULL';
        const MATCH_IS_NULL_OR_EMPTY = '::NULL';
    
    mattab's avatar
    mattab a validé
    
    
    mattpiwik's avatar
    mattpiwik a validé
        // Special case, since we look up Page URLs/Page titles in a sub SQL query
        const MATCH_ACTIONS_CONTAINS = 'IN';
    
    mattpiwik's avatar
    mattpiwik a validé
        const INDEX_BOOL_OPERATOR = 0;
        const INDEX_OPERAND = 1;
    
    mattpiwik's avatar
    mattpiwik a validé
        function __construct($string)
        {
            $this->string = $string;
            $this->tree = $this->parseTree();
        }
    
    mattpiwik's avatar
    mattpiwik a validé
        protected $valuesBind = array();
        protected $parsedTree = array();
        protected $tree = array();
    
        protected $parsedSubExpressions = array();
    
        /**
         * Given the array of parsed filters containing, for each filter,
         * the boolean operator (AND/OR) and the operand,
         * Will return the array where the filters are in SQL representation
         *
         * @throws Exception
         * @return array
         */
    
    mattpiwik's avatar
    mattpiwik a validé
        public function parseSubExpressions()
        {
            $parsedSubExpressions = array();
    
            foreach ($this->tree as $id => $leaf) {
    
    mattpiwik's avatar
    mattpiwik a validé
                $operand = $leaf[self::INDEX_OPERAND];
    
    mattab's avatar
    mattab a validé
    
                $operand = urldecode($operand);
    
    
    mattpiwik's avatar
    mattpiwik a validé
                $operator = $leaf[self::INDEX_BOOL_OPERATOR];
    
                $pattern = '/^(.+?)(' . self::MATCH_EQUAL . '|'
                    . self::MATCH_NOT_EQUAL . '|'
                    . self::MATCH_GREATER_OR_EQUAL . '|'
                    . self::MATCH_GREATER . '|'
                    . self::MATCH_LESS_OR_EQUAL . '|'
                    . self::MATCH_LESS . '|'
                    . self::MATCH_CONTAINS . '|'
                    . self::MATCH_DOES_NOT_CONTAIN
    
    mattab's avatar
    mattab a validé
                    . '){1}(.*)/';
    
                $match = preg_match($pattern, $operand, $matches);
                if ($match == 0) {
    
    mattab's avatar
    mattab a validé
                    throw new Exception('The segment \'' . $operand . '\' is not valid.');
    
    mattpiwik's avatar
    mattpiwik a validé
                }
    
    mattpiwik's avatar
    mattpiwik a validé
                $leftMember = $matches[1];
                $operation = $matches[2];
    
    mattab's avatar
    mattab a validé
                $valueRightMember = urldecode($matches[3]);
    
    mattab's avatar
    mattab a validé
    
                // is null / is not null
                if ($valueRightMember === '') {
    
                    if ($operation == self::MATCH_NOT_EQUAL) {
    
    mattab's avatar
    mattab a validé
                        $operation = self::MATCH_IS_NOT_NULL_NOR_EMPTY;
    
                    } elseif ($operation == self::MATCH_EQUAL) {
    
    mattab's avatar
    mattab a validé
                        $operation = self::MATCH_IS_NULL_OR_EMPTY;
                    } else {
                        throw new Exception('The segment \'' . $operand . '\' has no value specified. You can leave this value empty ' .
                            'only when you use the operators: ' . self::MATCH_NOT_EQUAL . ' (is not) or ' . self::MATCH_EQUAL . ' (is)');
                    }
                }
    
    
                $parsedSubExpressions[] = array(
    
    mattpiwik's avatar
    mattpiwik a validé
                    self::INDEX_BOOL_OPERATOR => $operator,
    
    mattpiwik's avatar
    mattpiwik a validé
                        $leftMember,
    
    mattpiwik's avatar
    mattpiwik a validé
            }
            $this->parsedSubExpressions = $parsedSubExpressions;
            return $parsedSubExpressions;
        }
    
        /**
         * Set the given expression
         * @param $parsedSubExpressions
         */
    
    mattpiwik's avatar
    mattpiwik a validé
        public function setSubExpressionsAfterCleanup($parsedSubExpressions)
        {
            $this->parsedSubExpressions = $parsedSubExpressions;
        }
    
        /**
         * @param array $availableTables
         */
        public function parseSubExpressionsIntoSqlExpressions(&$availableTables = array())
    
    mattpiwik's avatar
    mattpiwik a validé
        {
            $sqlSubExpressions = array();
            $this->valuesBind = array();
    
    
            foreach ($this->parsedSubExpressions as $leaf) {
    
    mattpiwik's avatar
    mattpiwik a validé
                $operator = $leaf[self::INDEX_BOOL_OPERATOR];
                $operandDefinition = $leaf[self::INDEX_OPERAND];
    
                $operand = $this->getSqlMatchFromDefinition($operandDefinition, $availableTables);
    
                if ($operand[1] !== null) {
                    $this->valuesBind[] = $operand[1];
                }
    
    mattpiwik's avatar
    mattpiwik a validé
                $operand = $operand[0];
                $sqlSubExpressions[] = array(
                    self::INDEX_BOOL_OPERATOR => $operator,
    
                    self::INDEX_OPERAND       => $operand,
                );
    
    mattpiwik's avatar
    mattpiwik a validé
            }
    
    mattpiwik's avatar
    mattpiwik a validé
            $this->tree = $sqlSubExpressions;
        }
    
        /**
         * Given an array representing one filter operand ( left member , operation , right member)
         * Will return an array containing
         * - the SQL substring,
         * - the values to bind to this substring
         *
         * @param array $def
         * @param array $availableTables
         * @throws Exception
         * @return array
         */
    
        protected function getSqlMatchFromDefinition($def, &$availableTables)
    
    mattpiwik's avatar
    mattpiwik a validé
        {
    
            $field = $def[0];
            $matchType = $def[1];
    
    mattpiwik's avatar
    mattpiwik a validé
            $value = $def[2];
    
    mattab's avatar
    mattab a validé
            $alsoMatchNULLValues = false;
    
            switch ($matchType) {
                case self::MATCH_EQUAL:
                    $sqlMatch = '=';
                    break;
                case self::MATCH_NOT_EQUAL:
                    $sqlMatch = '<>';
    
    mattab's avatar
    mattab a validé
                    $alsoMatchNULLValues = true;
    
                    break;
                case self::MATCH_GREATER:
                    $sqlMatch = '>';
                    break;
                case self::MATCH_LESS:
                    $sqlMatch = '<';
                    break;
                case self::MATCH_GREATER_OR_EQUAL:
                    $sqlMatch = '>=';
                    break;
                case self::MATCH_LESS_OR_EQUAL:
                    $sqlMatch = '<=';
                    break;
                case self::MATCH_CONTAINS:
                    $sqlMatch = 'LIKE';
                    $value = '%' . $this->escapeLikeString($value) . '%';
                    break;
                case self::MATCH_DOES_NOT_CONTAIN:
                    $sqlMatch = 'NOT LIKE';
                    $value = '%' . $this->escapeLikeString($value) . '%';
    
    mattab's avatar
    mattab a validé
                    $alsoMatchNULLValues = true;
    
    mattab's avatar
    mattab a validé
                case self::MATCH_IS_NOT_NULL_NOR_EMPTY:
    
                    $sqlMatch = 'IS NOT NULL AND (' . $field . ' <> \'\' OR ' . $field . ' = 0)';
    
    mattab's avatar
    mattab a validé
                    $value = null;
                    break;
    
    
    mattab's avatar
    mattab a validé
                case self::MATCH_IS_NULL_OR_EMPTY:
    
                    $sqlMatch = 'IS NULL OR ' . $field . ' = \'\' ';
    
    mattab's avatar
    mattab a validé
                    $value = null;
                    break;
    
    
    mattpiwik's avatar
    mattpiwik a validé
                case self::MATCH_ACTIONS_CONTAINS:
    
                    // this match type is not accessible from the outside
    
    mattpiwik's avatar
    mattpiwik a validé
                    // (it won't be matched in self::parseSubExpressions())
    
                    // it can be used internally to inject sub-expressions into the query.
    
                    $sqlMatch = 'IN (' . $value['SQL'] . ')';
    
    mattpiwik's avatar
    mattpiwik a validé
                    $value = $this->escapeLikeString($value['bind']);
    
                default:
                    throw new Exception("Filter contains the match type '" . $matchType . "' which is not supported");
                    break;
    
    mattpiwik's avatar
    mattpiwik a validé
            }
    
    mattab's avatar
    mattab a validé
            // We match NULL values when rows are excluded only when we are not doing a
            $alsoMatchNULLValues = $alsoMatchNULLValues && !empty($value);
    
    
    mattab's avatar
    mattab a validé
            if ($matchType === self::MATCH_ACTIONS_CONTAINS
    
    mattab's avatar
    mattab a validé
                $sqlExpression = "( $field $sqlMatch )";
    
    mattab's avatar
    mattab a validé
                    $sqlExpression = "( $field IS NULL OR $field $sqlMatch ? )";
                } else {
                    $sqlExpression = "$field $sqlMatch ?";
                }
    
            $this->checkFieldIsAvailable($field, $availableTables);
    
            return array($sqlExpression, $value);
        }
    
        /**
         * Check whether the field is available
         * If not, add it to the available tables
         *
         * @param string $field
         * @param array $availableTables
         */
    
        private function checkFieldIsAvailable($field, &$availableTables)
        {
            $fieldParts = explode('.', $field);
    
            $table = count($fieldParts) == 2 ? $fieldParts[0] : false;
    
            // remove sql functions from field name
    
            // example: `HOUR(log_visit.visit_last_action_time)` gets `HOUR(log_visit` => remove `HOUR(`
    
            $table = preg_replace('/^[A-Z_]+\(/', '', $table);
    
            $tableExists = !$table || in_array($table, $availableTables);
    
    
            if (!$tableExists) {
                $availableTables[] = $table;
    
    mattpiwik's avatar
    mattpiwik a validé
        }
    
        /**
         * Escape the characters % and _ in the given string
         * @param string $str
         * @return string
         */
    
        private function escapeLikeString($str)
        {
    
            $str = str_replace("%", "\%", $str);
            $str = str_replace("_", "\_", $str);
            return $str;
    
    mattpiwik's avatar
    mattpiwik a validé
        /**
    
         * Given a filter string,
         * will parse it into an array where each row contains the boolean operator applied to it,
    
         * and the operand
         *
         * @return array
    
    mattpiwik's avatar
    mattpiwik a validé
         */
        protected function parseTree()
        {
            $string = $this->string;
    
    mattpiwik's avatar
    mattpiwik a validé
                return array();
            }
            $tree = array();
            $i = 0;
            $length = strlen($string);
            $isBackslash = false;
            $operand = '';
    
    mattpiwik's avatar
    mattpiwik a validé
                $char = $string[$i];
    
                $isAND = ($char == self::AND_DELIMITER);
                $isOR = ($char == self::OR_DELIMITER);
    
                $isEnd = ($length == $i + 1);
    
                if ($isEnd) {
                    if ($isBackslash && ($isAND || $isOR)) {
                        $operand = substr($operand, 0, -1);
                    }
    
    mattpiwik's avatar
    mattpiwik a validé
                    $operand .= $char;
                    $tree[] = array(self::INDEX_BOOL_OPERATOR => '', self::INDEX_OPERAND => $operand);
                    break;
                }
    
    
                if ($isAND && !$isBackslash) {
                    $tree[] = array(self::INDEX_BOOL_OPERATOR => 'AND', self::INDEX_OPERAND => $operand);
                    $operand = '';
                } elseif ($isOR && !$isBackslash) {
                    $tree[] = array(self::INDEX_BOOL_OPERATOR => 'OR', self::INDEX_OPERAND => $operand);
                    $operand = '';
                } else {
                    if ($isBackslash && ($isAND || $isOR)) {
                        $operand = substr($operand, 0, -1);
                    }
                    $operand .= $char;
                }
    
    mattpiwik's avatar
    mattpiwik a validé
                $isBackslash = ($char == "\\");
                $i++;
            }
            return $tree;
        }
    
        /**
         * Given the array of parsed boolean logic, will return
         * an array containing the full SQL string representing the filter,
         * the needed joins and the values to bind to the query
         *
         * @throws Exception
         * @return array SQL Query, Joins and Bind parameters
         */
    
    mattpiwik's avatar
    mattpiwik a validé
        public function getSql()
        {
    
    mattpiwik's avatar
    mattpiwik a validé
                throw new Exception("Invalid segment, please specify a valid segment.");
            }
            $bind = array();
            $sql = '';
            $subExpression = false;
    
            foreach ($this->tree as $expression) {
    
    mattpiwik's avatar
    mattpiwik a validé
                $operator = $expression[self::INDEX_BOOL_OPERATOR];
                $operand = $expression[self::INDEX_OPERAND];
    
    
                if ($operator == 'OR'
                    && !$subExpression
                ) {
    
    mattpiwik's avatar
    mattpiwik a validé
                    $sql .= ' (';
                    $subExpression = true;
    
    mattpiwik's avatar
    mattpiwik a validé
                    $sql .= ' ';
                }
    
    mattpiwik's avatar
    mattpiwik a validé
                $sql .= $operand;
    
    
                if ($operator == 'AND'
                    && $subExpression
                ) {
    
    mattpiwik's avatar
    mattpiwik a validé
                    $sql .= ')';
                    $subExpression = false;
                }
    
    mattpiwik's avatar
    mattpiwik a validé
                $sql .= " $operator";
            }
    
    mattpiwik's avatar
    mattpiwik a validé
                $sql .= ')';
            }
            return array(
    
                'where' => $sql,
                'bind'  => $this->valuesBind,
                'join'  => implode(' ', $this->joins)
    
    mattpiwik's avatar
    mattpiwik a validé
            );
        }