QueryParser.php

PHP

Path: src/Util/QueryParser.php

<?php

namespace mini\Util;

/**
 * QueryParser - Parse and match query string criteria
 *
 * Supports clean colon syntax for operators:
 *
 * Operators: eq, gt, gte, lt, lte, like
 *
 * Syntax examples:
 * - key=value (simple equality)
 * - key:eq=value (explicit equality)
 * - key:gt=10 (greater than)
 * - key:gte=18 (greater than or equal)
 * - key:lt=100 (less than)
 * - key:lte=50 (less than or equal)
 * - key:like=*pattern* (contains pattern)
 * - key:like=pattern* (starts with pattern)
 * - key:like=*pattern (ends with pattern)
 * - age:gte=18&age:lte=65 (range query)
 *
 * Usage:
 * $qp = new QueryParser($_GET);
 * $qp = new QueryParser("id=5&age:gte=18&score:gt=80");
 * $qp = new QueryParser($_GET, ["id", "name", "age"]); // with whitelist
 *
 * foreach ($rows as $row) {
 *     if ($qp->matches($row)) {
 *         // row matches criteria
 *     }
 * }
 */
class QueryParser
{
    private array $query = [];
    private array $allowedOperators = ['eq', 'gt', 'gte', 'lt', 'lte', 'like'];
    private array $operatorMap = [
        'eq' => '=',
        'gt' => '>',
        'gte' => '>=',
        'lt' => '<',
        'lte' => '<=',
        'like' => 'LIKE'
    ];

    /**
     * @param string|array $input Query string or parsed array (like $_GET)
     * @param array|null $whitelist Optional list of allowed keys
     */
    public function __construct($input, ?array $whitelist = null)
    {
        if (is_string($input)) {
            $parsed = $this->parseQueryString($input);
        } else {
            $parsed = $input;
        }

        $this->query = $this->parseQuery($parsed, $whitelist);
    }

    /**
     * Check if an object or array matches the query criteria
     */
    public function matches($data): bool
    {
        // Convert object to array for uniform access
        if (is_object($data)) {
            $data = get_object_vars($data);
        }

        foreach ($this->query as $key => $operators) {
            if (!array_key_exists($key, $data)) {
                return false; // Required key not present
            }

            $value = $data[$key];

            // All criteria are now stored as operator arrays
            foreach ($operators as $operator => $expectedValue) {
                if (!$this->compareValues($value, $operator, $expectedValue)) {
                    return false;
                }
            }
        }

        return true;
    }

    /**
     * Get the parsed query array (for debugging)
     * @deprecated Use getQueryStructure() instead
     */
    public function getQuery(): array
    {
        return $this->query;
    }

    /**
     * Get the normalized query structure
     *
     * Returns queries in consistent format: ["key": {"operator": "value"}]
     * This structure can be easily converted to SQL WHERE clauses.
     *
     * @return array Normalized query structure
     */
    public function getQueryStructure(): array
    {
        return $this->query;
    }

    /**
     * Parse query string using PHP's built-in parse_str with colon syntax support
     */
    protected function parseQueryString(string $queryString): array
    {
        if (empty($queryString)) {
            return [];
        }

        // Use PHP's built-in parser - it handles URL decoding and preserves colons
        parse_str($queryString, $parsed);

        // Post-process to handle colon syntax (key:op=value)
        return $this->processColonSyntax($parsed);
    }

    /**
     * Process colon syntax in parsed query parameters
     *
     * Converts key:op entries to structured format for consistent handling
     */
    private function processColonSyntax(array $parsed): array
    {
        $result = [];

        foreach ($parsed as $key => $value) {
            // Find the last colon in the key
            $colonPos = strrpos($key, ':');

            if ($colonPos !== false) {
                $baseKey = substr($key, 0, $colonPos);
                $operator = substr($key, $colonPos + 1);

                // Only process valid operators
                if (in_array($operator, $this->allowedOperators)) {
                    // Initialize array if needed
                    if (!isset($result[$baseKey])) {
                        $result[$baseKey] = [];
                    }

                    // Store the operator
                    $result[$baseKey][$operator] = $value;
                    continue;
                }
            }

            // Regular key=value (no colon or invalid operator)
            $result[$key] = $value;
        }

        return $result;
    }


    /**
     * Parse the input into a normalized query structure
     *
     * All queries are stored in consistent operator format: ["key": {"op": "value"}]
     * Invalid operators are filtered out during this phase.
     */
    protected function parseQuery(array $input, ?array $whitelist): array
    {
        $query = [];

        foreach ($input as $key => $value) {
            // Apply whitelist filter if provided
            if ($whitelist !== null && !in_array($key, $whitelist, true)) {
                continue;
            }

            // Handle operator syntax: key[op] = value or key.op = value
            if (is_array($value)) {
                $operators = [];
                foreach ($value as $operator => $operatorValue) {
                    // Only store valid operators (invalid ones are silently ignored)
                    if (in_array($operator, $this->allowedOperators, true)) {
                        // Normalize operator (gte -> >=, gt -> >, etc.)
                        $normalizedOperator = $this->operatorMap[$operator];
                        $operators[$normalizedOperator] = $operatorValue;
                    }
                }

                // Only add to query if we have valid operators
                if (!empty($operators)) {
                    $query[$key] = $operators;
                }
            } else {
                // Simple key=value - always store in operator format for consistency
                $query[$key] = ['=' => $value];
            }
        }

        return $query;
    }

    /**
     * Compare two values using SQLite3 semantics
     */
    private function compareValues($actual, string $operator, $expected): bool
    {
        // Handle null values
        if ($actual === null || $expected === null) {
            return $operator === '=' && $actual === $expected;
        }

        // Handle LIKE operator separately (always string-based)
        if ($operator === 'LIKE') {
            return $this->matchesPattern((string)$actual, (string)$expected);
        }

        // Convert to appropriate types for comparison
        // SQLite3 tries numeric comparison if both values look numeric
        if (is_numeric($actual) && is_numeric($expected)) {
            $actual = (float)$actual;
            $expected = (float)$expected;
        } else {
            // String comparison - convert both to strings
            $actual = (string)$actual;
            $expected = (string)$expected;
        }

        switch ($operator) {
            case '=':
                return $actual == $expected;
            case '>':
                return $actual > $expected;
            case '<':
                return $actual < $expected;
            case '>=':
                return $actual >= $expected;
            case '<=':
                return $actual <= $expected;
            default:
                return false;
        }
    }

    /**
     * Check if a string matches a LIKE pattern with * wildcards
     *
     * @param string $value The actual value to test
     * @param string $pattern The pattern with * wildcards
     * @return bool True if value matches pattern
     */
    private function matchesPattern(string $value, string $pattern): bool
    {
        // Convert LIKE pattern with * wildcards to regex
        // Escape special regex characters except *
        $escapedPattern = preg_quote($pattern, '/');

        // Replace escaped \* with .* (any characters)
        $regexPattern = str_replace('\\*', '.*', $escapedPattern);

        // Anchor the pattern to match the entire string
        $regexPattern = '/^' . $regexPattern . '$/i';

        return preg_match($regexPattern, $value) === 1;
    }
}