app/resto/core/utils/FilterParser.php

Summary

Maintainability
B
4 hrs
Test Coverage
<?php
/*
 * Copyright 2022 Jérôme Gasperi
 *
 * Licensed under the Apache License, version 2.0 (the "License");
 * You may not use this file except in compliance with the License.
 * You may obtain a copy of the License at:
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 * License for the specific language governing permissions and limitations
 * under the License.
 */

/**
 * Parser for CQL2 string
 */
class FilterParser
{
    private $lexer;

    /**
     * Constructor
     */
    public function __construct()
    {
        $this->lexer = new Lexer();
    }

    /**
     * Parse a CQL2 string
     *
     * Assuming a valid CQL2 string structure is composed of a set of triplets "property operation value"
     * separated by logical operators (AND, OR, etc)
     *
     * @param string $cql2
     * @return array
     */
    public function parseCQL2($cql2)
    {
        $this->lexer->setInput($cql2);
        $this->lexer->moveNext();

        $json = array();
        
        // Default logical operator is T_AND
        $logicalOperator = $this->lexer->namedOperation(Lexer::T_AND);

        while (null !== $this->lexer->lookahead) {
            /*
            print_r($this->lexer->lookahead);
            $this->lexer->moveNext();
            continue;
            */

            // Token is a logical operator - keep it and move to next token
            if ($this->isLogicalOperator($this->lexer->lookahead['type'])) {
                $logicalOperator = $this->lexer->namedOperation($this->lexer->lookahead['type']);
                $this->lexer->moveNext();
            }

            if (!isset($json[$logicalOperator])) {
                $json[$logicalOperator] = array();
            }

            $json[$logicalOperator][] = $this->processTriplet();

            // Reset logical operator and move to next token
            $logicalOperator = $this->lexer->namedOperation(Lexer::T_AND);
            $this->lexer->moveNext();
        }

        return $json;
    }

    /**
     * Process a triplet
     *
     * @return array
     */
    private function processTriplet()
    {
        $filter = array();
        $not = false;

        // First token is either a property or NOT operator
        if ($this->lexer->lookahead['type'] === Lexer::T_NOT) {
            $not = true;
            $this->lexer->moveNext();
        }

        // First token is a property
        switch ($this->lexer->lookahead['type']) {
            case Lexer::T_S_INTERSECTS:
                return $this->intersectsExpression($not);

            case Lexer::T_STRING:
                $filter['property'] = $this->lexer->lookahead['value'];
                $this->lexer->moveNext();
                break;
    
            default:
                throw new Exception('Invalid property');
                break;
        }
       
        $operation = $this->operationExpression();
        $filter['operation'] = $this->lexer->namedOperation($operation);
        switch ($operation) {
            // [WARNING] These operators are not supported yet
            case Lexer::T_IN:
            case Lexer::T_NI:
                throw new Exception('Operation ' . strtoupper($filter['operation']) . ' is not supported');
                break;

            case Lexer::T_IS_NULL:
                $filter['value'] = null;
                break;

            default:
                $filter['value'] = $this->numberOrDateOrDateExpression();
        }

        $filter['not'] = $not;

        return $filter;
    }

    /**
     * Check type
     *
     * @return boolean
     */
    private function mustMatch($type)
    {
        $bool = $type === $this->lexer->lookahead['type'];
        $this->lexer->moveNext();
        return $bool;
    }

    /**
     * Get current lookahead token assuming it is an operation
     *
     * @return integer
     */
    private function operationExpression()
    {
        // Special case for IS NULL
        if ($this->lexer->lookahead['value'] === 'is') {
            $this->lexer->moveNext();
            if ($this->lexer->lookahead['value'] === 'null') {
                $operation = Lexer::T_IS_NULL;
            }
        } elseif ($this->lexer->lookahead['type'] >= 300 && $this->lexer->lookahead['type'] < 400) {
            if ($this->lexer->namedOperation($this->lexer->lookahead['type']) === null) {
                throw new Exception('Unkown operation ' . $this->lexer->lookahead['value']);
            }
            $operation = $this->lexer->lookahead['type'];
        } else {
            throw new Exception('Invalid operation ' . $this->lexer->lookahead['value']);
        }

        $this->lexer->moveNext();

        return $operation;
    }

    /**
     * Get current lookahead token assuming it is an oper
     *
     * @return string
     *
    private function arrayExpression()
    {
        $result = 'TODO array';

        $this->lexer->moveNext();

        return $result;
    }
    */
    
    /**
     * Get current lookahead token assuming it is an oper
     *
     * @return string
     */
    private function numberOrDateOrDateExpression()
    {
        switch ($this->lexer->lookahead['type']) {
            case Lexer::T_TIMESTAMP:
                $this->lexer->moveNext();
                $this->mustMatch(Lexer::T_OPEN_PARENTHESIS);
                if ($this->lexer->lookahead['type'] !== Lexer::T_DATE) {
                    throw new Exception('Invalid date');
                }
                $result = $this->lexer->lookahead['value'];
                $this->mustMatch(Lexer::T_CLOSE_PARENTHESIS);
                break;

            case Lexer::T_NUMBER:
            case Lexer::T_STRING:
                $result = $this->lexer->lookahead['value'];
                break;
            
            default:
                throw new Exception('Invalid number, date or string');
                break;
        }

        $this->lexer->moveNext();

        return $result;
    }

    /**
     * Process intersects expression i.e. S_INTERSECTS(geometry, POLYGON((xxxx)))";
     *
     * @param boolean $not
     * @return string
     */
    private function intersectsExpression($not)
    {
        $filter = array(
            'not' => $not
        );

        $this->lexer->moveNext();
        $this->mustMatch(Lexer::T_OPEN_PARENTHESIS);
        $filter['property'] = $this->lexer->lookahead['value'];
        $this->lexer->moveNext();
        $this->mustMatch(Lexer::T_COMMA);

        // Next is a WKT geometry
        $lastType = 0;
        $openParenthesis = 0;
        $closeParenthesis = 0;
        $wkt = '';
        while (null !== $this->lexer->lookahead) {
            if ($this->lexer->lookahead['type'] === Lexer::T_OPEN_PARENTHESIS) {
                $openParenthesis++;
            } elseif ($this->lexer->lookahead['type'] === Lexer::T_CLOSE_PARENTHESIS) {
                $closeParenthesis++;
            }

            if ($closeParenthesis > $openParenthesis) {
                break;
            }

            $wkt .= $this->lexer->lookahead['type'] === Lexer::T_NUMBER && $lastType === Lexer::T_NUMBER ? ' ' . $this->lexer->lookahead['value'] : $this->lexer->lookahead['value'];
            $lastType = $this->lexer->lookahead['type'];

            $this->lexer->moveNext();
        }
        
        $filter['operation'] = 'intersects';
        $filter['value'] = $wkt;

        return $filter;
    }

    /**
     * Return true if the type is an operator
     *
     * @param int $type
     */
    private function isLogicalOperator($type)
    {
        return $type >= 200 && $type < 300;
    }
}