diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6096c71..e070390 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,8 +69,12 @@ jobs: - name: Run tests suite run: | - php ./vendor/bin/phpunit --coverage-clover=coverage.xml --coverage-xml=logs/coverage --log-junit=logs/coverage.junit.xml -d --min-coverage=100 + php ./vendor/bin/phpunit --coverage-clover=coverage.xml --coverage-xml=logs/coverage --log-junit=logs/coverage.junit.xml + + - name: Ensure every line is covered by tests + run: | + php tests/phpunit-coverage.php 96 - name: Run infection mutation testing run: - php ./vendor/bin/infection --min-msi=85 --min-covered-msi=90 --threads=max --no-interaction --logger-github=true --skip-initial-tests --coverage=logs \ No newline at end of file + php ./vendor/bin/infection --min-msi=85 --min-covered-msi=90 --only-covered --threads=max --no-interaction --logger-github=true --skip-initial-tests --coverage=logs \ No newline at end of file diff --git a/LICENSE b/LICENSE index a473a0e..dcaa642 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 Globy +Copyright (c) 2024 GlobyApp Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index eb72129..5dad72a 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Parse OData v4 query strings, outputs proper PHP objects. - [Examples](#examples) - [API](#api) - [Known issues](#known-issues) +- [Thanks](#thanks) ## About @@ -29,7 +30,7 @@ As I did not see a package exclusively dealing with parsing the query strings, a ## Requirements -- PHP >= 8.1.0 +- PHP >= 8.2.0 - [Composer](https://getcomposer.org/) ## Installation @@ -42,9 +43,6 @@ composer require globyapp/odata-query-parser ## Examples -- [1. Use \$select to filter on some fields](#1-use-select-to-filter-on-some-fields) -- [2. Use non dollar syntax](#2-use-non-dollar-syntax) - ### 1. Use \$select to filter on some fields In this example, we will use the `$select` OData query string command to filter the fields returned by our API. @@ -141,3 +139,9 @@ Filter = [ ## Known issues - `$filter` command will not parse `or` and functions (like `contains()` of `substringof`), because I did not focus on this for the moment (the parser for `$filter` is too simplistic, I should find a way to create an AST). + +## Thanks +Feel free to open any issues or PRs. + +--- +MIT © 2024 \ No newline at end of file diff --git a/composer.json b/composer.json index 1eff8c3..def1ba0 100644 --- a/composer.json +++ b/composer.json @@ -15,11 +15,12 @@ ], "minimum-stability": "stable", "require": { - "php": ">=8.2" + "php": ">=8.2", + "ext-mbstring": "*" }, "autoload": { "psr-4": { - "GlobyApp\\": "src/" + "GlobyApp\\OdataQueryParser\\": "src/" } }, "require-dev": { @@ -28,7 +29,8 @@ "phpstan/phpstan": "^1.10", "phpstan/phpstan-deprecation-rules": "^1.1", "vimeo/psalm": "^5.23", - "infection/infection": "^0.27.11" + "infection/infection": "^0.27.11", + "ext-simplexml": "*" }, "autoload-dev": { "psr-4": { diff --git a/composer.lock b/composer.lock index 981ae42..a898b36 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "6cd495bc58a7b08266cf967d18ef8067", + "content-hash": "8b18656a02f57310cc014bc88589dcfe", "packages": [], "packages-dev": [ { @@ -3505,16 +3505,16 @@ }, { "name": "spatie/array-to-xml", - "version": "3.2.3", + "version": "3.3.0", "source": { "type": "git", "url": "https://github.com/spatie/array-to-xml.git", - "reference": "c95fd4db94ec199f798d4b5b4a81757bd20d88ab" + "reference": "f56b220fe2db1ade4c88098d83413ebdfc3bf876" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/array-to-xml/zipball/c95fd4db94ec199f798d4b5b4a81757bd20d88ab", - "reference": "c95fd4db94ec199f798d4b5b4a81757bd20d88ab", + "url": "https://api.github.com/repos/spatie/array-to-xml/zipball/f56b220fe2db1ade4c88098d83413ebdfc3bf876", + "reference": "f56b220fe2db1ade4c88098d83413ebdfc3bf876", "shasum": "" }, "require": { @@ -3527,6 +3527,11 @@ "spatie/pest-plugin-snapshots": "^1.1" }, "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, "autoload": { "psr-4": { "Spatie\\ArrayToXml\\": "src" @@ -3552,7 +3557,7 @@ "xml" ], "support": { - "source": "https://github.com/spatie/array-to-xml/tree/3.2.3" + "source": "https://github.com/spatie/array-to-xml/tree/3.3.0" }, "funding": [ { @@ -3564,7 +3569,7 @@ "type": "github" } ], - "time": "2024-02-07T10:39:02+00:00" + "time": "2024-05-01T10:20:27+00:00" }, { "name": "symfony/console", @@ -3661,16 +3666,16 @@ }, { "name": "symfony/deprecation-contracts", - "version": "v3.4.0", + "version": "v3.5.0", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "7c3aff79d10325257a001fcf92d991f24fc967cf" + "reference": "0e0d29ce1f20deffb4ab1b016a7257c4f1e789a1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/7c3aff79d10325257a001fcf92d991f24fc967cf", - "reference": "7c3aff79d10325257a001fcf92d991f24fc967cf", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/0e0d29ce1f20deffb4ab1b016a7257c4f1e789a1", + "reference": "0e0d29ce1f20deffb4ab1b016a7257c4f1e789a1", "shasum": "" }, "require": { @@ -3679,7 +3684,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "3.4-dev" + "dev-main": "3.5-dev" }, "thanks": { "name": "symfony/contracts", @@ -3708,7 +3713,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.4.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.5.0" }, "funding": [ { @@ -3724,7 +3729,7 @@ "type": "tidelift" } ], - "time": "2023-05-23T14:45:45+00:00" + "time": "2024-04-18T09:32:20+00:00" }, { "name": "symfony/event-dispatcher", @@ -3808,16 +3813,16 @@ }, { "name": "symfony/event-dispatcher-contracts", - "version": "v3.4.2", + "version": "v3.5.0", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "4e64b49bf370ade88e567de29465762e316e4224" + "reference": "8f93aec25d41b72493c6ddff14e916177c9efc50" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/4e64b49bf370ade88e567de29465762e316e4224", - "reference": "4e64b49bf370ade88e567de29465762e316e4224", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/8f93aec25d41b72493c6ddff14e916177c9efc50", + "reference": "8f93aec25d41b72493c6ddff14e916177c9efc50", "shasum": "" }, "require": { @@ -3827,7 +3832,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "3.4-dev" + "dev-main": "3.5-dev" }, "thanks": { "name": "symfony/contracts", @@ -3864,7 +3869,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.4.2" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.5.0" }, "funding": [ { @@ -3880,7 +3885,7 @@ "type": "tidelift" } ], - "time": "2024-01-23T14:51:35+00:00" + "time": "2024-04-18T09:32:20+00:00" }, { "name": "symfony/filesystem", @@ -4614,21 +4619,22 @@ }, { "name": "symfony/service-contracts", - "version": "v3.4.2", + "version": "v3.5.0", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "11bbf19a0fb7b36345861e85c5768844c552906e" + "reference": "bd1d9e59a81d8fa4acdcea3f617c581f7475a80f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/11bbf19a0fb7b36345861e85c5768844c552906e", - "reference": "11bbf19a0fb7b36345861e85c5768844c552906e", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/bd1d9e59a81d8fa4acdcea3f617c581f7475a80f", + "reference": "bd1d9e59a81d8fa4acdcea3f617c581f7475a80f", "shasum": "" }, "require": { "php": ">=8.1", - "psr/container": "^1.1|^2.0" + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" }, "conflict": { "ext-psr": "<1.1|>=2" @@ -4636,7 +4642,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "3.4-dev" + "dev-main": "3.5-dev" }, "thanks": { "name": "symfony/contracts", @@ -4676,7 +4682,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.4.2" + "source": "https://github.com/symfony/service-contracts/tree/v3.5.0" }, "funding": [ { @@ -4692,7 +4698,7 @@ "type": "tidelift" } ], - "time": "2023-12-19T21:51:00+00:00" + "time": "2024-04-18T09:32:20+00:00" }, { "name": "symfony/stopwatch", @@ -5033,16 +5039,16 @@ }, { "name": "vimeo/psalm", - "version": "5.23.1", + "version": "5.24.0", "source": { "type": "git", "url": "https://github.com/vimeo/psalm.git", - "reference": "8471a896ccea3526b26d082f4461eeea467f10a4" + "reference": "462c80e31c34e58cc4f750c656be3927e80e550e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vimeo/psalm/zipball/8471a896ccea3526b26d082f4461eeea467f10a4", - "reference": "8471a896ccea3526b26d082f4461eeea467f10a4", + "url": "https://api.github.com/repos/vimeo/psalm/zipball/462c80e31c34e58cc4f750c656be3927e80e550e", + "reference": "462c80e31c34e58cc4f750c656be3927e80e550e", "shasum": "" }, "require": { @@ -5139,7 +5145,7 @@ "issues": "https://github.com/vimeo/psalm/issues", "source": "https://github.com/vimeo/psalm" }, - "time": "2024-03-11T20:33:46+00:00" + "time": "2024-05-01T19:32:08+00:00" }, { "name": "webmozart/assert", @@ -5206,8 +5212,11 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": ">=8.2" + "php": ">=8.2", + "ext-mbstring": "*" + }, + "platform-dev": { + "ext-simplexml": "*" }, - "platform-dev": [], "plugin-api-version": "2.6.0" } diff --git a/src/Datatype/FilterClause.php b/src/Datatype/FilterClause.php new file mode 100644 index 0000000..61ce92b --- /dev/null +++ b/src/Datatype/FilterClause.php @@ -0,0 +1,58 @@ + $value + */ + private int|float|string|bool|null|array $value; + + /** + * A filter clause with a field, an operator and the value to filter with + * + * @param string $property The property that should be filtered + * @param FilterOperator $operator The filter operator used + * @param int|float|string|bool|null|array $value The value to filter the property on with the operator + */ + public function __construct(string $property, FilterOperator $operator, int|float|string|bool|null|array $value) + { + $this->property = $property; + $this->operator = $operator; + $this->value = $value; + } + + /** + * @return string The property on which to filter + */ + public function getProperty(): string + { + return $this->property; + } + + /** + * @return FilterOperator The operator with which to filter + */ + public function getOperator(): FilterOperator + { + return $this->operator; + } + + /** + * @return int|float|string|bool|null|array The value to filter the property on with the operator + */ + public function getValue(): int|float|string|bool|null|array + { + return $this->value; + } +} diff --git a/src/Datatype/OrderByClause.php b/src/Datatype/OrderByClause.php new file mode 100644 index 0000000..fb5359a --- /dev/null +++ b/src/Datatype/OrderByClause.php @@ -0,0 +1,43 @@ +property = $property; + $this->direction = $direction; + } + + /** + * @return string The property on which to order + */ + public function getProperty(): string + { + return $this->property; + } + + /** + * @return OrderDirection The direction in which to order the data of the property in this entry + */ + public function getDirection(): OrderDirection + { + return $this->direction; + } +} diff --git a/src/Enum/FilterOperator.php b/src/Enum/FilterOperator.php new file mode 100644 index 0000000..a2f02e0 --- /dev/null +++ b/src/Enum/FilterOperator.php @@ -0,0 +1,17 @@ +select = $select; + $this->count = $count; + $this->top = $top; + $this->skip = $skip; + $this->orderBy = $orderBy; + $this->filter = $filter; + } + + /** + * @return string[] The list of properties to be returned + */ + public function getSelect(): array + { + return $this->select; + } + + /** + * @return bool|null Whether the amount of results should be included in the request + */ + public function getCount(): ?bool + { + return $this->count; + } + + /** + * @return int|null The top amount of results to return + */ + public function getTop(): ?int + { + return $this->top; + } + + /** + * @return int|null The amount of results to skip before starting to return results + */ + public function getSkip(): ?int + { + return $this->skip; + } + + /** + * @return OrderByClause[] The list of order by clauses + */ + public function getOrderBy(): array + { + return $this->orderBy; + } + + /** + * @return FilterClause[] The list of filter clauses + */ + public function getFilter(): array + { + return $this->filter; + } +} diff --git a/src/OdataQueryParser.php b/src/OdataQueryParser.php index bcf92b7..329383e 100644 --- a/src/OdataQueryParser.php +++ b/src/OdataQueryParser.php @@ -2,9 +2,14 @@ declare(strict_types=1); -namespace GlobyApp; +namespace GlobyApp\OdataQueryParser; +use GlobyApp\OdataQueryParser\Datatype\FilterClause; +use GlobyApp\OdataQueryParser\Datatype\OrderByClause; +use GlobyApp\OdataQueryParser\Enum\FilterOperator; +use GlobyApp\OdataQueryParser\Enum\OrderDirection; use InvalidArgumentException; +use LogicException; /** * The actual parser class that can parse an odata url @@ -13,311 +18,438 @@ */ class OdataQueryParser { - private const COUNT_KEY = "count"; - private const FILTER_KEY = "filter"; - private const FORMAT_KEY = "format"; - private const ORDER_BY_KEY = "orderby"; - private const SELECT_KEY = "select"; - private const SKIP_KEY = "skip"; - private const TOP_KEY = "top"; - - private static string $url = ""; - private static string $queryString = ""; - private static array $queryStrings = []; - private static bool $withDollar = false; - private static string $selectKey = ""; - private static string $countKey = ""; - private static string $filterKey = ""; - private static string $formatKey = ""; - private static string $orderByKey = ""; - private static string $skipKey = ""; - private static string $topKey = ""; + private static string $select; + private static string $count; + private static string $filter; + private static string $orderBy; + private static string $skip; + private static string $top; /** - * Parses a given URL, returns an associative array with the odata parts of the URL. + * Parses a given URL, returns a result object with the odata parts of the URL. * - * @param string $url The URL to parse the query strings from. It should be a "complete" or "full" URL, which means that http://example.com will pass while example.com will not pass. - * @param bool $withDollar When set to false, parses the odata keys without requiring the $ in front of odata keys. + * Usage: + * ``` + * OdataQueryParser::parse("http://example.com?$select=[field]&$top=10&$skip=5&$orderBy", true) + * OdataQueryParser::parse("http://example.com?select=[field]&top=10&skip=5&orderBy", false) + * ``` * - * @return array The associative array containing the different odata keys. + * @param string $url The URL to parse the query strings from. It should be a "complete" or "full" URL + * @param bool $withDollar When set to false, parses the odata keys without requiring the $ in front of odata keys + * + * @return OdataQuery|null OdataQuery object, parsed version of the input url, or null, if there is no query string + * @throws InvalidArgumentException The URL, or parts of it are malformed and could not be processed */ - public static function parse(string $url, bool $withDollar = true): array + public static function parse(string $url, bool $withDollar = true): ?OdataQuery { - $output = []; - - // Set the options and url - static::$url = $url; - static::$withDollar = $withDollar; - // Verify the URL is valid - if (\filter_var(static::$url, FILTER_VALIDATE_URL) === false) { - throw new InvalidArgumentException('url should be a valid url'); - } - - static::setQueryStrings(); - - static::setQueryParameterKeys(); - - // Extract the different odata keys and store them in the output array - if (static::selectQueryParameterIsValid()) { - $output["select"] = static::getSelectColumns(); + if (filter_var($url, FILTER_VALIDATE_URL) === false) { + throw new InvalidArgumentException('URL should be a valid, full URL.'); } - if (static::countQueryParameterIsValid()) { - $output["count"] = true; + // Extract the query string from the URL and parse it into it's components + $queryString = self::extractQueryString($url); + if ($queryString === null) { + // There is no query string, so there cannot be a result + return null; } - if (static::topQueryParameterIsValid()) { - $top = static::getTopValue(); - - if (!\is_numeric($top)) { - throw new InvalidArgumentException('top should be an integer'); - } + $parsedQueryString = self::parseQueryString($queryString); + self::setKeyConstants($withDollar); - $top = $top; + // Extract the different odata keys and store them in the output array + $select = self::getSelect($parsedQueryString); + $count = self::getCount($parsedQueryString); + $top = self::getTop($parsedQueryString); + $skip = self::getSkip($parsedQueryString); + $orderBy = self::getOrderBy($parsedQueryString); + $filter = self::getFilter($parsedQueryString); + + return new OdataQuery($select, $count, $top, $skip, $orderBy, $filter); + } - if ($top < 0) { - throw new InvalidArgumentException('top should be greater or equal to zero'); - } + /** + * Function to extract the query string from the input URL + * + * @param string $url The URL to parse + * + * @return string|null The query string from the input URL. Null if there is no query string. + * @throws InvalidArgumentException The URL is malformed and the query string could not be extracted + */ + private static function extractQueryString(string $url): ?string + { + $queryString = parse_url($url, PHP_URL_QUERY); - $output["top"] = (int) $top; + if ($queryString === false) { + throw new InvalidArgumentException("URL could not be parsed. Ensure the URL is not malformed."); } - if (static::skipQueryParameterIsValid()) { - $skip = static::getSkipValue(); + // The URL query string parser should return a string or null query string + if (!($queryString === null || is_string($queryString))) { + throw new InvalidArgumentException("URL query string should be a string."); + } - if (!\is_numeric($skip)) { - throw new InvalidArgumentException('skip should be an integer'); - } + return $queryString; + } - $skip = $skip; + /** + * Function to parse the query string into it's separate components + * + * @param string $queryString The query string to parse + * + * @return array The components of the query string, split up into an array + */ + private static function parseQueryString(string $queryString): array + { + $result = []; + parse_str($queryString, $result); - if ($skip < 0) { - throw new InvalidArgumentException('skip should be greater or equal to zero'); + // Verify that the parsed result only has string key and values + foreach ($result as $key => $value) { + if (!is_string($key) || !is_string($value)) { + throw new InvalidArgumentException("Parsed query string has non-string values."); } - - $output["skip"] = (int) $skip; } - if (static::orderByQueryParameterIsValid()) { - $items = static::getOrderByColumnsAndDirections(); - - $orderBy = \array_map(function ($item) { - $explodedItem = \explode(" ", $item); - - $explodedItem = array_values(array_filter($explodedItem, function ($item) { - return $item !== ""; - })); + /* @phpstan-ignore-next-line The structure of the return value is verified in the foreach block above */ + return $result; + } - $property = $explodedItem[0]; - $direction = isset($explodedItem[1]) ? $explodedItem[1] : "asc"; + /** + * Function to set the odata key constants depending on the $withDollar configuration + * + * @param bool $withDollar Whether to prepend a dollar key to the key name + * + * @return void Nothing, the method just sets the constants + */ + private static function setKeyConstants(bool $withDollar): void + { + self::$select = self::buildKeyConstant("select", $withDollar); + self::$count = self::buildKeyConstant("count", $withDollar); + self::$filter = self::buildKeyConstant("filter", $withDollar); + self::$orderBy = self::buildKeyConstant("orderby", $withDollar); + self::$skip = self::buildKeyConstant("skip", $withDollar); + self::$top = self::buildKeyConstant("top", $withDollar); + } - if ($direction !== "asc" && $direction !== "desc") { - throw new InvalidArgumentException('direction should be either asc or desc'); - } + /** + * Function to prepend a dollar to a key if required. + * + * @param string $key The name of the key to be built + * @param bool $withDollar Whether to prepend a dollar sign + * + * @return string The key with or without dollar sign prepended + */ + private static function buildKeyConstant(string $key, bool $withDollar): string + { + return $withDollar ? '$'.$key : $key; + } - return [ - "property" => $property, - "direction" => $direction - ]; - }, $items); + /** + * Function to determine whether an odata key is present in the input query string + * + * @param string $key The key to check for + * @param array $queryString The query string in which to find the key + * + * @return bool Whether the odata key is present in the input query string + */ + private static function hasKey(string $key, array $queryString): bool + { + return array_key_exists($key, $queryString); + } - $output["orderBy"] = $orderBy; + /** + * Function to easily validate that an array key exists in a query string and adheres to a specified filter_var filter + * + * @param array $queryString The query string to validate + * @param string $key The key to check in the query string + * @param int $filter The filter to validate the value against, if it exists in the query string + * + * @return bool Whether the key exists in the query string and adheres to the specified filter + * @throws InvalidArgumentException If the input value doesn't pass the given filter + */ + private static function validateWithFilterValidate(array $queryString, string $key, int $filter): bool + { + if (!self::hasKey($key, $queryString)) { + return false; } - if (static::filterQueryParameterIsValid()) { - $ands = static::getFilterValue(); - - $output["filter"] = $ands; + // Trim can only be used on a string and count. At this point, the value has not been cast to a native datatype + if (empty(trim($queryString[$key])) && trim($queryString[$key]) !== '0') { + return false; } + // Verify the value adheres to the specified filter + if (filter_var($queryString[$key], $filter, FILTER_NULL_ON_FAILURE) === null) { + throw new InvalidArgumentException("Invalid datatype for $key"); + } - return $output; + return true; } - private static function setQueryStrings(): void + /** + * Function to parse and process the list of select properties + * + * @param array $queryString The parsed query string + * + * @return string[] The list of properties to be selected + */ + private static function getSelect(array $queryString): array { - static::$queryString = static::getQueryString(); - static::$queryStrings = static::getQueryStrings(); + // If the original query string doesn't include a select part, return an empty array + if (!(self::hasKey(self::$select, $queryString) + && !empty(trim($queryString[self::$select])))) { + return []; + } + + // Split the select string into an array, as it's just a csv string + $csvSplit = explode(",", $queryString[self::$select]); + + return array_map(function (string $column) { + return trim($column); + }, $csvSplit); } - private static function getQueryString(): string + /** + * Function to determine whether a count key is present and return a parsed version of the value + * + * @param array $queryString The query string to find the count key in + * + * @return bool|null The value of the count key, or null, if no count key is present in the query string + * @throws InvalidArgumentException If the input value doesn't pass the given filter + */ + private static function getCount(array $queryString): ?bool { - $queryString = \parse_url(static::$url, PHP_URL_QUERY); + if (!self::validateWithFilterValidate($queryString, self::$count, FILTER_VALIDATE_BOOLEAN)) { + // 0 and 1 are also valid values for a boolean + if (!(array_key_exists(self::$count, $queryString) + && (trim($queryString[self::$count]) === '0' || trim($queryString[self::$count]) === '1'))) { + return null; + } + } - return $queryString === null ? "" : $queryString; + return boolval(trim($queryString[self::$count])); } - private static function getQueryStrings(): array + /** + * Function to determine whether a top key is present and return a parsed version of the value + * + * @param array $queryString The query string to find the top key in + * + * @return int|null The value of the top key, or null, if no top key is present in the query string + * @throws InvalidArgumentException If the input value is not a valid integer + */ + private static function getTop(array $queryString): ?int { - $result = []; + if (!self::validateWithFilterValidate($queryString, self::$top, FILTER_VALIDATE_INT)) { + return null; + } + + // Parse skip and ensure it's larger than 0, as negative values don't make sense in this context + $top = intval(trim($queryString[self::$top])); - if (!empty(static::$queryString)) { - \parse_str(static::$queryString, $result); + if ($top < 0) { + throw new InvalidArgumentException('Top should be greater or equal to zero'); } - return $result; + return $top; } - private static function hasKey(string $key): bool + /** + * Function to determine whether a skip key is present and return a parsed version of the value + * + * @param array $queryString The query string to find the skip key in + * + * @return int|null The value of the skip key, or null, if no skip key is present in the query string + * @throws InvalidArgumentException If the input value is not a valid integer + */ + private static function getSkip(array $queryString): ?int { - return isset(static::$queryStrings[$key]); - } + if (!self::validateWithFilterValidate($queryString, self::$skip, FILTER_VALIDATE_INT)) { + return null; + } - private static function selectQueryParameterIsValid(): bool - { - return static::hasKey(static::$selectKey) && !empty(static::$queryStrings[static::$selectKey]); - } + // Parse skip and ensure it's larger than 0, as negative values don't make sense in this context + $skip = intval(trim($queryString[self::$skip])); - private static function countQueryParameterIsValid(): bool - { - return static::hasKey(static::$countKey) && (bool) trim(static::$queryStrings[static::$countKey]) === true; - } + if ($skip < 0) { + throw new InvalidArgumentException('Skip should be greater or equal to zero'); + } - private static function topQueryParameterIsValid(): bool - { - return static::hasKey(static::$topKey); + return $skip; } - private static function skipQueryParameterIsValid(): bool + /** + * Function to split the orderBy part of a query string and return a list of order by clauses + * + * @param array $queryString The query string to get the order by clauses from + * + * @return OrderByClause[] The parsed order by clauses + * @throws InvalidArgumentException If the direction is not asc or desc, or the clause split found a clause that was incorrectly formed + */ + private static function getOrderBy(array $queryString): array { - return static::hasKey(static::$skipKey); - } + if (!(self::hasKey(self::$orderBy, $queryString) + && !empty(trim($queryString[self::$orderBy])))) { + return []; + } - private static function orderByQueryParameterIsValid(): bool - { - return static::hasKey(static::$orderByKey) && !empty(static::$queryStrings[static::$orderByKey]); - } + $csvSplit = explode(",", $queryString[self::$orderBy]); - private static function filterQueryParameterIsValid(): bool - { - return static::hasKey(static::$filterKey) && !empty(static::$queryStrings[static::$filterKey]); - } + return array_map(function (string $clause): OrderByClause { + $splitClause = explode(' ', $clause); - private static function getSelectColumns(): array - { - return array_map(function ($column) { - return trim($column); - }, explode(",", static::$queryStrings[static::$selectKey])); - } + // Remove empty strings from the result + $splitClause = array_values(array_filter($splitClause, function ($item) { + return !empty($item); + })); - private static function getTopValue(): string - { - return trim(static::$queryStrings[static::$topKey]); - } + // Verify that the split resulted in a valid pattern + $splitCount = count($splitClause); + if ($splitCount < 1 || $splitCount > 2) { + throw new InvalidArgumentException("An order by condition is invalid and resulted in a split of $splitCount terms."); + } - private static function getSkipValue(): string - { - return trim(static::$queryStrings[static::$skipKey]); + // Parse the direction and return an OrderByClause. The default order direction is ascending + $direction = $splitCount === 2 ? self::parseDirection(trim($splitClause[1])) : OrderDirection::ASC; + return new OrderByClause(trim($splitClause[0]), $direction); + }, $csvSplit); } - private static function getOrderByColumnsAndDirections(): array + /** + * Function to convert the string representation of an order direction to an enum + * + * @param string $direction The string representation of the order direction + * + * @return OrderDirection The parsed order direction + * @throws InvalidArgumentException If the direction is not asc or desc + */ + private static function parseDirection(string $direction): OrderDirection { - return explode(",", static::$queryStrings[static::$orderByKey]); + return match (strtolower($direction)) { + "asc" => OrderDirection::ASC, + "desc" => OrderDirection::DESC, + default => throw new InvalidArgumentException("Direction should be either asc or desc"), + }; } - private static function getFilterValue(): array + /** + * Function to split the filter part of a query string and return a list of filter clauses + * + * @param array $queryString The query string to find the filter key in + * + * @return FilterClause[] The parsed list of filter clauses + * @throws InvalidArgumentException If an invalid operator is found, or the clause split found a clause that was incorrectly formed + */ + private static function getFilter(array $queryString): array { - return array_map(function ($and) { - $items = []; - - preg_match("/(\w+)\s*(eq|ne|gt|ge|lt|le|in)\s*([\w',()\s.]+)/", $and, $items); + if (!(self::hasKey(self::$filter, $queryString) + && !empty(trim($queryString[self::$filter])))) { + return []; + } - $left = $items[1]; - $operator = static::getFilterOperatorName($items[2]); - $right = static::getFilterRightValue($operator, $items[3]); + $filterParts = explode("and", $queryString[self::$filter]); - /** - * @todo check whether [1], [2] and [3] are set -> will fix in a different PR - */ + return array_map(function (string $clause): FilterClause { + $clauseParts = []; + mb_ereg("(\w+)\s*([engliENGLI][qetnQETN])\s*([\w',()\s.]+)", $clause, $clauseParts); - return [ - "left" => $left, - "operator" => $operator, - "right" => $right - ]; - }, explode("and", static::$queryStrings[static::$filterKey])); - } + /** Determine whether there are 4 array keys present in the result: + * $clauseParts[0]: the entire input string + * $clauseParts[1]: the left hand side (property) + * $clauseParts[2]: the operator + * $clauseParts[3]: the right hand side (value) + **/ + if (count($clauseParts) !== 4) { + throw new InvalidArgumentException("A filter clause is invalid and resulted in a split of ".count($clauseParts)." terms."); + } - private static function setQueryParameterKeys(): void - { - static::$selectKey = static::getSelectKey(); - static::$countKey = static::getCountKey(); - static::$filterKey = static::getFilterKey(); - static::$formatKey = static::getFormatKey(); - static::$orderByKey = static::getOrderByKey(); - static::$skipKey = static::getSkipKey(); - static::$topKey = static::getTopKey(); + $operator = self::parseFilterOperator($clauseParts[2]); + $value = self::getFilterRightValue($clauseParts[3], $operator); + return new FilterClause($clauseParts[1], $operator, $value); + }, $filterParts); } - private static function getSelectKey(): string - { - return static::$withDollar ? '$' . static::SELECT_KEY : static::SELECT_KEY; - } - private static function getCountKey(): string + /** + * Function to convert the string representation of a filter operator to an enum + * + * @param string $operator The string representation of the filter operator + * + * @return FilterOperator The parsed filter operator + * @throws InvalidArgumentException If the filter operator is not valid + */ + private static function parseFilterOperator(string $operator): FilterOperator { - return static::$withDollar ? '$' . static::COUNT_KEY : static::COUNT_KEY; + return match (strtolower($operator)) { + "eq" => FilterOperator::EQUALS, + "ne" => FilterOperator::NOT_EQUALS, + "gt" => FilterOperator::GREATER_THAN, + "ge" => FilterOperator::GREATER_THAN_EQUALS, + "lt" => FilterOperator::LESS_THAN, + "le" => FilterOperator::LESS_THAN_EQUALS, + "in" => FilterOperator::IN, + default => throw new InvalidArgumentException("Filter operator should be eq, ne, gt, ge, lt, le or in."), + }; } - private static function getFilterKey(): string + /** + * Function to parse the filter right value of a filter clause to the correct php datatype + * + * @param string $value The value to parse into an array, or it's native php datatype + * @param FilterOperator $operator The operator, dictates whether the value is considered a list or a single value + * + * @return int|float|string|bool|null|array Either a native php datatype, or an array with a mix or native php datatypes + */ + private static function getFilterRightValue(string $value, FilterOperator $operator): int|float|string|bool|null|array { - return static::$withDollar ? '$' . static::FILTER_KEY : static::FILTER_KEY; - } + if ($operator === FilterOperator::IN) { + // Remove the start and end bracket, including possible whitespace from the list + $value = mb_ereg_replace("^\s*\(|\)\s*$", "", $value); - private static function getFormatKey(): string - { - return static::$withDollar ? '$' . static::FORMAT_KEY : static::FORMAT_KEY; - } + if (!is_string($value)) { + throw new LogicException("Could not execute regex replace on filter value."); + } - private static function getOrderByKey(): string - { - return static::$withDollar ? '$' . static::ORDER_BY_KEY : static::ORDER_BY_KEY; - } + // Split the list in values + $values = explode(",", $value); - private static function getSkipKey(): string - { - return static::$withDollar ? '$' . static::SKIP_KEY : static::SKIP_KEY; - } + // Parse the value as a single comparison value + return array_map(function (string $value): int|float|string|bool|null { + return self::getFilterRightValueSingle($value); + }, $values); + } - private static function getTopKey(): string - { - return static::$withDollar ? '$' . static::TOP_KEY : static::TOP_KEY; + // The value is not a list of values, parse the value as a single value into it's native php datatype + return self::getFilterRightValueSingle($value); } - private static function getFilterOperatorName(string $operator): string + /** + * Function to parse the right side filter value if it's known that the value cannot be an array + * + * @param string $value The value to parse into it's native php datatype + * + * @return int|float|string|bool|null The value parsed as a native php datatype + */ + private static function getFilterRightValueSingle(string $value): int|float|string|bool|null { - return match ($operator) { - "eq" => "equal", - "ne" => "notEqual", - "gt" => "greaterThan", - "ge" => "greaterOrEqual", - "lt" => "lowerThan", - "le" => "lowerOrEqual", - "in" => "in", - default => "unknown", - }; - } + // Trim the value before testing its datatype to prevent accidental mismatches + $value = trim($value); - private static function getFilterRightValue(string $operator, string $value): int|float|string|array - { - if ($operator !== "in") { - if (is_numeric($value)) { - if ((int) $value != $value) { - return (float) $value; - } else { - return (int) $value; - } - } else { - return str_replace("'", "", trim($value)); + // The operator is an equality operator, parse the value according to the inferred datatype + if (is_numeric($value)) { + if (intval($value) == $value) { + return intval($value); } - } else { - $value = preg_replace("/^\s*\(|\)\s*$/", "", $value); - $values = explode(",", $value); + return floatval($value); + } - return array_map(function ($value) { - return static::getFilterRightValue("equal", $value); - }, $values); + if ($value === 'true' || $value === 'false') { + return $value === 'true'; } + + // Either return the string with apostrophe's, or null, if the string without apostrophe's is empty + $stringRes = mb_ereg_replace("'", "", $value); + return empty($stringRes) ? null : $stringRes; } } diff --git a/tests/Assertions/AssertOdataQuerySame.php b/tests/Assertions/AssertOdataQuerySame.php new file mode 100644 index 0000000..a1c9de9 --- /dev/null +++ b/tests/Assertions/AssertOdataQuerySame.php @@ -0,0 +1,48 @@ +getFilter(), $actual->getFilter(), $message); + assertSame(count($expected->getFilter()), count($actual->getFilter()), $message); + + for ($i = 0; $i < count($expected->getFilter()); $i++) { + assertSame($expected->getFilter()[$i]->getValue(), $actual->getFilter()[$i]->getValue(), $message); + assertSame($expected->getFilter()[$i]->getProperty(), $actual->getFilter()[$i]->getProperty(), $message); + assertSame($expected->getFilter()[$i]->getOperator(), $actual->getFilter()[$i]->getOperator(), $message); + } + + assertEquals($expected->getSelect(), $actual->getSelect(), $message); + assertSame(count($expected->getSelect()), count($actual->getSelect()), $message); + + for ($i = 0; $i < count($expected->getSelect()); $i++) { + assertSame($expected->getSelect()[$i], $actual->getSelect()[$i]); + } + + assertEquals($expected->getOrderBy(), $actual->getOrderBy(), $message); + assertSame(count($expected->getOrderBy()), count($actual->getOrderBy()), $message); + + for ($i = 0; $i < count($expected->getOrderBy()); $i++) { + assertSame($expected->getOrderBy()[$i]->getProperty(), $actual->getOrderBy()[$i]->getProperty()); + assertSame($expected->getOrderBy()[$i]->getDirection(), $actual->getOrderBy()[$i]->getDirection()); + } + + assertSame($expected->getTop(), $actual->getTop(), $message); + assertSame($expected->getSkip(), $actual->getSkip(), $message); + assertSame($expected->getCount(), $actual->getCount(), $message); + } +} \ No newline at end of file diff --git a/tests/BaseTestCase.php b/tests/BaseTestCase.php new file mode 100644 index 0000000..3fb52fd --- /dev/null +++ b/tests/BaseTestCase.php @@ -0,0 +1,11 @@ + true]; - $actual = OdataQueryParser::parse('https://example.com/api/user?$count=1'); + $expected = new OdataQueryParser\OdataQuery([], true); + $actual = OdataQueryParser\OdataQueryParser::parse('https://example.com/api/user?$count=1'); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); } public function testShouldReturnCountTrueIfKeyFilledWithTrueAndSpaces(): void { - $expected = ["count" => true]; - $actual = OdataQueryParser::parse('https://example.om/api/user?$count=%201%20'); + $expected = new OdataQueryParser\OdataQuery([], true); + $actual = OdataQueryParser\OdataQueryParser::parse('https://example.om/api/user?$count=%201%20'); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); } public function testShouldNotReturnCountIfKeyFilledWithFalse(): void { - $expected = []; - $actual = OdataQueryParser::parse('https://example.com/api/user?$count=0'); + $expected = new OdataQueryParser\OdataQuery([], false); + $actual = OdataQueryParser\OdataQueryParser::parse('https://example.com/api/user?$count=0'); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); } public function testShouldNotReturnCountIfKeyFilledWithFalseAndSpaces(): void { - $expected = []; - $actual = OdataQueryParser::parse('https://example.com/api/user?$count=%200%20'); + $expected = new OdataQueryParser\OdataQuery([], false); + $actual = OdataQueryParser\OdataQueryParser::parse('https://example.com/api/user?$count=%200%20'); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); } public function testShouldReturnCountTrueIfKeyFillWithTrueInNonDollarMode(): void { - $expected = ["count" => true]; - $actual = OdataQueryParser::parse("https://example.com/api/user?count=1", false); + $expected = new OdataQueryParser\OdataQuery([], true); + $actual = OdataQueryParser\OdataQueryParser::parse("https://example.com/api/user?count=1", false); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); } public function testShouldReturnCountTrueIfKeyFilledWithTrueAndSpacesInNonDollarMode(): void { - $expected = ["count" => true]; - $actual = OdataQueryParser::parse('https://example.com/api/user?count=%201%20', false); + $expected = new OdataQueryParser\OdataQuery([], true); + $actual = OdataQueryParser\OdataQueryParser::parse('https://example.com/api/user?count=%201%20', false); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); } public function testShouldNotReturnCountIfKeyFilledWithFalseInNonDollarMode(): void { - $expected = []; - $actual = OdataQueryParser::parse("https://example.com/api/user?count=0", false); + $expected = new OdataQueryParser\OdataQuery([], false); + $actual = OdataQueryParser\OdataQueryParser::parse("https://example.com/api/user?count=0", false); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); } public function testShouldNotReturnCountIfKeyFilledWithFalseAndSpacesInNonDollarMode(): void { - $expected = []; - $actual = OdataQueryParser::parse('https://example.com/api/user?count=%200%20', false); + $expected = new OdataQueryParser\OdataQuery([], false); + $actual = OdataQueryParser\OdataQueryParser::parse('https://example.com/api/user?count=%200%20', false); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); } } \ No newline at end of file diff --git a/tests/DatatypeTest.php b/tests/DatatypeTest.php new file mode 100644 index 0000000..a6a85ee --- /dev/null +++ b/tests/DatatypeTest.php @@ -0,0 +1,37 @@ +assertEquals("property_name", $clause->getProperty()); + $this->assertEquals(FilterOperator::IN, $clause->getOperator()); + $this->assertEquals("test_value", $clause->getValue()); + } + + public function testFilterClauseContinuityArray(): void + { + $clause = new FilterClause("property_name", FilterOperator::GREATER_THAN, ["string", "false", true, 20, 294.29, null]); + + $this->assertEquals("property_name", $clause->getProperty()); + $this->assertEquals(FilterOperator::GREATER_THAN, $clause->getOperator()); + $this->assertEquals(["string", "false", true, 20, 294.29, null], $clause->getValue()); + } + + public function testOrderByClauseContinuity(): void + { + $clause = new OrderByClause("property_name", OrderDirection::DESC); + + $this->assertEquals("property_name", $clause->getProperty()); + $this->assertEquals(OrderDirection::DESC, $clause->getDirection()); + } +} \ No newline at end of file diff --git a/tests/FilterTest.php b/tests/FilterTest.php index 1c4878c..b3c4af1 100644 --- a/tests/FilterTest.php +++ b/tests/FilterTest.php @@ -3,164 +3,270 @@ namespace OdataQueryParserTests; use GlobyApp\OdataQueryParser; -use PHPUnit\Framework\TestCase; -final class FilterTest extends TestCase +final class FilterTest extends BaseTestCase { public function testShouldReturnEmptyArrayIfEmptyFilter(): void { - $expected = []; - $actual = OdataQueryParser::parse("https://example.com/api/user?\$filter="); + $expected = new OdataQueryParser\OdataQuery(); + $actual = OdataQueryParser\OdataQueryParser::parse("https://example.com/api/user?\$filter="); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); + } + public function testShouldReturnEmptyArrayIfEmptyFilterWithSpaces(): void + { + $expected = new OdataQueryParser\OdataQuery(); + $actual = OdataQueryParser\OdataQueryParser::parse("https://example.com/api/user?\$filter=%20%20"); + + $this->assertOdataQuerySame($expected, $actual); } public function testShouldReturnEqualClause(): void { - $expected = [ - "filter" => [ - ["left" => "name", "operator" => "equal", "right" => "foo"] - ] - ]; - $actual = OdataQueryParser::parse("https://example.com/api/user?\$filter=name%20eq%20%27foo%27"); + $expected = new OdataQueryParser\OdataQuery([], null, null, null, [], [ + new OdataQueryParser\Datatype\FilterClause('name', OdataQueryParser\Enum\FilterOperator::EQUALS, 'foo'), + ]); + $actual = OdataQueryParser\OdataQueryParser::parse("https://example.com/api/user?\$filter=name%20eq%20%27foo%27"); + + $this->assertOdataQuerySame($expected, $actual); + } + + public function testShouldReturnEqualClauseMixedCase(): void + { + $expected = new OdataQueryParser\OdataQuery([], null, null, null, [], [ + new OdataQueryParser\Datatype\FilterClause('name', OdataQueryParser\Enum\FilterOperator::EQUALS, 'foo'), + ]); + $actual = OdataQueryParser\OdataQueryParser::parse("https://example.com/api/user?\$filter=name%20eQ%20%27foo%27"); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); } public function testShouldReturnEqualClauseWithFloat(): void { - $this->assertIsFloat(OdataQueryParser::parse("https://example.com/api/user?\$filter=age%20eq%2042.42")["filter"][0]["right"]); + $this->assertIsFloat(OdataQueryParser\OdataQueryParser::parse("https://example.com/api/user?\$filter=age%20eq%2042.42")?->getFilter()[0]->getValue()); } public function testShouldReturnEqualClauseWithInteger(): void { - $this->assertIsInt(OdataQueryParser::parse("https://example.com/api/user?\$filter=age%20eq%2042")["filter"][0]["right"]); + $this->assertIsInt(OdataQueryParser\OdataQueryParser::parse("https://example.com/api/user?\$filter=age%20eq%2042")?->getFilter()[0]->getValue()); } public function testShouldReturnEqualClauseWithSpacedStrings(): void { - $expected = [ - "filter" => [ - ["left" => "name", "operator" => "equal", "right" => " foo "] - ] - ]; - $actual = OdataQueryParser::parse("https://example.com/api/user?\$filter=name%20eq%20%27%20foo%20%27"); + $expected = new OdataQueryParser\OdataQuery([], null, null, null, [], [ + new OdataQueryParser\Datatype\FilterClause('name', OdataQueryParser\Enum\FilterOperator::EQUALS, ' foo '), + ]); + $actual = OdataQueryParser\OdataQueryParser::parse("https://example.com/api/user?\$filter=name%20eq%20%27%20foo%20%27"); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); } public function testShouldReturnNotEqualClause(): void { - $expected = [ - "filter" => [ - ["left" => "name", "operator" => "notEqual", "right" => "foo"] - ] - ]; - $actual = OdataQueryParser::parse("https://example.com/api/user?\$filter=name%20ne%20%27foo%27"); + $expected = new OdataQueryParser\OdataQuery([], null, null, null, [], [ + new OdataQueryParser\Datatype\FilterClause('name', OdataQueryParser\Enum\FilterOperator::NOT_EQUALS, 'foo'), + ]); + $actual = OdataQueryParser\OdataQueryParser::parse("https://example.com/api/user?\$filter=name%20ne%20%27foo%27"); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); + } + + public function testShouldReturnNotEqualClauseMixedCase(): void + { + $expected = new OdataQueryParser\OdataQuery([], null, null, null, [], [ + new OdataQueryParser\Datatype\FilterClause('name', OdataQueryParser\Enum\FilterOperator::NOT_EQUALS, 'foo'), + ]); + $actual = OdataQueryParser\OdataQueryParser::parse("https://example.com/api/user?\$filter=name%20Ne%20%27foo%27"); + + $this->assertOdataQuerySame($expected, $actual); } public function testShouldReturnGreaterThanClause(): void { - $expected = [ - "filter" => [ - ["left" => "age", "operator" => "greaterThan", "right" => 20] - ] - ]; - $actual = OdataQueryParser::parse("https://example.com/api/user?\$filter=age%20gt%2020"); + $expected = new OdataQueryParser\OdataQuery([], null, null, null, [], [ + new OdataQueryParser\Datatype\FilterClause('age', OdataQueryParser\Enum\FilterOperator::GREATER_THAN, 20), + ]); + $actual = OdataQueryParser\OdataQueryParser::parse("https://example.com/api/user?\$filter=age%20gt%2020"); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); + } + + public function testShouldReturnGreaterThanClauseMixedCase(): void + { + $expected = new OdataQueryParser\OdataQuery([], null, null, null, [], [ + new OdataQueryParser\Datatype\FilterClause('age', OdataQueryParser\Enum\FilterOperator::GREATER_THAN, 20), + ]); + $actual = OdataQueryParser\OdataQueryParser::parse("https://example.com/api/user?\$filter=age%20Gt%2020"); + + $this->assertOdataQuerySame($expected, $actual); } public function testShouldReturnGreaterOrEqualToClause(): void { - $expected = [ - "filter" => [ - ["left" => "age", "operator" => "greaterOrEqual", "right" => 21] - ] - ]; - $actual = OdataQueryParser::parse("https://example.com/api/user?\$filter=age%20ge%2021"); + $expected = new OdataQueryParser\OdataQuery([], null, null, null, [], [ + new OdataQueryParser\Datatype\FilterClause('age', OdataQueryParser\Enum\FilterOperator::GREATER_THAN_EQUALS, 21), + ]); + $actual = OdataQueryParser\OdataQueryParser::parse("https://example.com/api/user?\$filter=age%20ge%2021"); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); + } + + public function testShouldReturnGreaterOrEqualToClauseMixedCase(): void + { + $expected = new OdataQueryParser\OdataQuery([], null, null, null, [], [ + new OdataQueryParser\Datatype\FilterClause('age', OdataQueryParser\Enum\FilterOperator::GREATER_THAN_EQUALS, 21), + ]); + $actual = OdataQueryParser\OdataQueryParser::parse("https://example.com/api/user?\$filter=age%20gE%2021"); + + $this->assertOdataQuerySame($expected, $actual); } public function testShouldReturnLowerThanClause(): void { - $expected = [ - "filter" => [ - ["left" => "age", "operator" => "lowerThan", "right" => 42] - ] - ]; - $actual = OdataQueryParser::parse("https://example.com/api/user?\$filter=age%20lt%2042"); + $expected = new OdataQueryParser\OdataQuery([], null, null, null, [], [ + new OdataQueryParser\Datatype\FilterClause('age', OdataQueryParser\Enum\FilterOperator::LESS_THAN, 42), + ]); + $actual = OdataQueryParser\OdataQueryParser::parse("https://example.com/api/user?\$filter=age%20lt%2042"); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); + } + + public function testShouldReturnLowerThanClauseMixedCase(): void + { + $expected = new OdataQueryParser\OdataQuery([], null, null, null, [], [ + new OdataQueryParser\Datatype\FilterClause('age', OdataQueryParser\Enum\FilterOperator::LESS_THAN, 42), + ]); + $actual = OdataQueryParser\OdataQueryParser::parse("https://example.com/api/user?\$filter=age%20lT%2042"); + + $this->assertOdataQuerySame($expected, $actual); } public function testShouldReturnLowerOrEqualToClause(): void { - $expected = [ - "filter" => [ - ["left" => "age", "operator" => "lowerOrEqual", "right" => 42] - ] - ]; - $actual = OdataQueryParser::parse("https://example.com/api/user?\$filter=age%20le%2042"); + $expected = new OdataQueryParser\OdataQuery([], null, null, null, [], [ + new OdataQueryParser\Datatype\FilterClause('age', OdataQueryParser\Enum\FilterOperator::LESS_THAN_EQUALS, 42), + ]); + $actual = OdataQueryParser\OdataQueryParser::parse("https://example.com/api/user?\$filter=age%20le%2042"); + + $this->assertOdataQuerySame($expected, $actual); + } + + public function testShouldReturnLowerOrEqualToClauseMixedCase(): void + { + $expected = new OdataQueryParser\OdataQuery([], null, null, null, [], [ + new OdataQueryParser\Datatype\FilterClause('age', OdataQueryParser\Enum\FilterOperator::LESS_THAN_EQUALS, 42), + ]); + $actual = OdataQueryParser\OdataQueryParser::parse("https://example.com/api/user?\$filter=age%20Le%2042"); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); } public function testShouldReturnInClause(): void { - $expected = [ - "filter" => [ - ["left" => "city", "operator" => "in", "right" => ["Paris", "Malaga", "London"]] - ] - ]; - $actual = OdataQueryParser::parse("https://example.com/api/user?\$filter=city%20in%20(%27Paris%27,%20%27Malaga%27,%20%27London%27)"); + $expected = new OdataQueryParser\OdataQuery([], null, null, null, [], [ + new OdataQueryParser\Datatype\FilterClause('city', OdataQueryParser\Enum\FilterOperator::IN, + ["Paris", "Malaga", "London"]), + ]); + $actual = OdataQueryParser\OdataQueryParser::parse("https://example.com/api/user?\$filter=city%20in%20(%27Paris%27,%20%27Malaga%27,%20%27London%27)"); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); + } + + public function testShouldReturnInClauseMixedCase(): void + { + $expected = new OdataQueryParser\OdataQuery([], null, null, null, [], [ + new OdataQueryParser\Datatype\FilterClause('city', OdataQueryParser\Enum\FilterOperator::IN, + ["Paris", "Malaga", "London"]), + ]); + $actual = OdataQueryParser\OdataQueryParser::parse("https://example.com/api/user?\$filter=city%20In%20(%27Paris%27,%20%27Malaga%27,%20%27London%27)"); + + $this->assertOdataQuerySame($expected, $actual); } public function testShouldReturnMultipleClauseSeparatedByTheAndOperator(): void { - $expected = [ - "filter" => [ - ["left" => "city", "operator" => "in", "right" => [" Paris", " Malaga ", "London "]], - ["left" => "name", "operator" => "equal", "right" => "foo"], - ["left" => "age", "operator" => "greaterThan", "right" => 20] - ] - ]; - $actual = OdataQueryParser::parse("https://example.com/api/user?\$filter=city%20in%20(%27%20Paris%27,%20%27%20Malaga%20%27,%20%27London%20%27)%20and%20name%20eq%20%27foo%27%20and%20age%20gt%2020"); + $expected = new OdataQueryParser\OdataQuery([], null, null, null, [], [ + new OdataQueryParser\Datatype\FilterClause('city', OdataQueryParser\Enum\FilterOperator::IN, + [" Paris", " Malaga ", "London "]), + new OdataQueryParser\Datatype\FilterClause('name', OdataQueryParser\Enum\FilterOperator::EQUALS, 'foo'), + new OdataQueryParser\Datatype\FilterClause('age', OdataQueryParser\Enum\FilterOperator::GREATER_THAN, 20), + ]); + $actual = OdataQueryParser\OdataQueryParser::parse("https://example.com/api/user?\$filter=city%20in%20(%27%20Paris%27,%20%27%20Malaga%20%27,%20%27London%20%27)%20and%20name%20eq%20%27foo%27%20and%20age%20gt%2020"); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); } public function testShouldReturnIntegersIfInIntegers(): void { - $expected = [ - "filter" => [ - ["left" => "age", "operator" => "in", "right" => [21, 31, 41]] - ] - ]; - $actual = OdataQueryParser::parse("http://example.com/api/user?\$filter=age%20in%20(21,%2031,%2041)"); + $expected = new OdataQueryParser\OdataQuery([], null, null, null, [], [ + new OdataQueryParser\Datatype\FilterClause('age', OdataQueryParser\Enum\FilterOperator::IN, + [21, 31, 41]), + ]); + $actual = OdataQueryParser\OdataQueryParser::parse("http://example.com/api/user?\$filter=age%20in%20(21,%2031,%2041)"); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); } public function testShouldReturnIntegersIfInFloats(): void { - $expected = [ - "filter" => [ - ["left" => "age", "operator" => "in", "right" => [21.42, 31.42, 41.42]] - ] - ]; - $actual = OdataQueryParser::parse("https://example.com/api/user?\$filter=age%20in%20(21.42,%2031.42,%2041.42)"); + $expected = new OdataQueryParser\OdataQuery([], null, null, null, [], [ + new OdataQueryParser\Datatype\FilterClause('age', OdataQueryParser\Enum\FilterOperator::IN, + [21.42, 31.42, 41.42]), + ]); + $actual = OdataQueryParser\OdataQueryParser::parse("https://example.com/api/user?\$filter=age%20in%20(21.42,%2031.42,%2041.42)"); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); } public function testShouldReturnFloatIfCheckingInFloat(): void { - $this->assertIsFloat(OdataQueryParser::parse("https://example.com/api/user?\$filter=taxRate%20in%20(19.5,%2020)")["filter"][0]["right"][0]); + $inArray = OdataQueryParser\OdataQueryParser::parse("https://example.com/api/user?\$filter=taxRate%20in%20(19.5,%2020)")?->getFilter()[0]->getValue(); + $this->assertIsArray($inArray); + $this->assertArrayHasKey(0, $inArray); + $this->assertIsFloat($inArray[0]); + } + + public function testBooleanTrueValue(): void + { + $bool = OdataQueryParser\OdataQueryParser::parse("https://example.com/api/user?\$filter=taxRate%20eq%20true")?->getFilter()[0]->getValue(); + $this->assertTrue($bool); + } + + public function testBooleanFalseValue(): void + { + $bool = OdataQueryParser\OdataQueryParser::parse("https://example.com/api/user?\$filter=taxRate%20eq%20false")?->getFilter()[0]->getValue(); + $this->assertFalse($bool); + } + + public function testInvalidOperator(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage("Filter operator should be eq, ne, gt, ge, lt, le or in"); + + OdataQueryParser\OdataQueryParser::parse("https://example.com/api/user?\$filter=taxRate%20GQ%20false"); + } + + public function testInvalidOperatorInNonDollarMode(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage("Filter operator should be eq, ne, gt, ge, lt, le or in."); + + OdataQueryParser\OdataQueryParser::parse("https://example.com/api/user?filter=taxRate%20GQ%20false", false); + } + + public function testInvalidStructure(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage("A filter clause is invalid and resulted in a split of 0 terms."); + + OdataQueryParser\OdataQueryParser::parse("https://example.com/api/user?\$filter=taxRate%20le"); + } + + public function testInvalidStructureInNonDollarMode(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage("A filter clause is invalid and resulted in a split of 0 terms."); + + OdataQueryParser\OdataQueryParser::parse("https://example.com/api/user?filter=taxRate%20le", false); } } diff --git a/tests/OrderByTest.php b/tests/OrderByTest.php index 19594e2..6d33808 100644 --- a/tests/OrderByTest.php +++ b/tests/OrderByTest.php @@ -4,156 +4,221 @@ use GlobyApp\OdataQueryParser; use InvalidArgumentException; -use PHPUnit\Framework\TestCase; -final class OrderByTest extends TestCase +final class OrderByTest extends BaseTestCase { - //orderBy public function testShouldReturnThePropertyInTheOrderBy(): void { - $expected = ["orderBy" => [["property" => "foo", "direction" => "asc"]]]; - $actual = OdataQueryParser::parse('https://example.com/api/user?$orderby=foo'); + $expected = new OdataQueryParser\OdataQuery([], null, null, null, [ + new OdataQueryParser\Datatype\OrderByClause('foo', OdataQueryParser\Enum\OrderDirection::ASC), + ]); + $actual = OdataQueryParser\OdataQueryParser::parse('https://example.com/api/user?$orderby=foo'); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); } public function testShouldReturnAllThePropertiesInTheOrderBy(): void { - $expected = ["orderBy" => [ - ["property" => "foo", "direction" => "asc"], - ["property" => "bar", "direction" => "asc"] - ]]; - $actual = OdataQueryParser::parse('https://example.com/api/user?$orderby=foo,bar'); + $expected = new OdataQueryParser\OdataQuery([], null, null, null, [ + new OdataQueryParser\Datatype\OrderByClause('foo', OdataQueryParser\Enum\OrderDirection::ASC), + new OdataQueryParser\Datatype\OrderByClause('bar', OdataQueryParser\Enum\OrderDirection::ASC), + ]); + $actual = OdataQueryParser\OdataQueryParser::parse('https://example.com/api/user?$orderby=foo,bar'); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); } public function testShouldReturnThePropertyInTheOrderByEvenIfFilledWithSpaces(): void { - $expected = ["orderBy" => [["property" => "foo", "direction" => "asc"]]]; - $actual = OdataQueryParser::parse('https://example.com/api/user?$orderby=%20foo%20'); + $expected = new OdataQueryParser\OdataQuery([], null, null, null, [ + new OdataQueryParser\Datatype\OrderByClause('foo', OdataQueryParser\Enum\OrderDirection::ASC), + ]); + $actual = OdataQueryParser\OdataQueryParser::parse('https://example.com/api/user?$orderby=%20foo%20'); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); } public function testShouldReturnAnEmptyArrayIfOrderByIsEmpty(): void { - $expected = []; - $actual = OdataQueryParser::parse('https://example.com/api/user?$orderby='); + $expected = new OdataQueryParser\OdataQuery(); + $actual = OdataQueryParser\OdataQueryParser::parse('https://example.com/api/user?$orderby='); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); + } + + public function testShouldReturnAnEmptyArrayIfOrderByIsEmptyWithSpace(): void + { + $expected = new OdataQueryParser\OdataQuery(); + $actual = OdataQueryParser\OdataQueryParser::parse('https://example.com/api/user?$orderby=%20%20'); + + $this->assertOdataQuerySame($expected, $actual); } public function testShouldReturnOrderByPropertyInAscDirectionIfSpecified(): void { - $expected = ["orderBy" => [["property" => "foo", "direction" => "asc"]]]; - $actual = OdataQueryParser::parse('https://example.com/api/user?$orderby=foo%20asc'); + $expected = new OdataQueryParser\OdataQuery([], null, null, null, [ + new OdataQueryParser\Datatype\OrderByClause('foo', OdataQueryParser\Enum\OrderDirection::ASC), + ]); + $actual = OdataQueryParser\OdataQueryParser::parse('https://example.com/api/user?$orderby=foo%20asc'); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); } public function testShouldReturnOrderByPropertyInAscDirectionIfSpecifiedEvenIfFilledWithSpaces(): void { - $expected = ["orderBy" => [["property" => "foo", "direction" => "asc"]]]; - $actual = OdataQueryParser::parse('https://example.com/api/user?$orderby=%20foo%20%20%20asc%20'); + $expected = new OdataQueryParser\OdataQuery([], null, null, null, [ + new OdataQueryParser\Datatype\OrderByClause('foo', OdataQueryParser\Enum\OrderDirection::ASC), + ]); + $actual = OdataQueryParser\OdataQueryParser::parse('https://example.com/api/user?$orderby=%20foo%20%20%20asc%20'); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); } public function testShouldReturnThePropertyInDescDirectionIfSpecified(): void { - $expected = ["orderBy" => [["property" => "foo", "direction" => "desc"]]]; - $actual = OdataQueryParser::parse('https://example.com/api/user?$orderby=foo%20desc'); + $expected = new OdataQueryParser\OdataQuery([], null, null, null, [ + new OdataQueryParser\Datatype\OrderByClause('foo', OdataQueryParser\Enum\OrderDirection::DESC), + ]); + $actual = OdataQueryParser\OdataQueryParser::parse('https://example.com/api/user?$orderby=foo%20desc'); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); + } + + public function testShouldReturnThePropertyInDescDirectionIfSpecifiedMixedCase(): void + { + $expected = new OdataQueryParser\OdataQuery([], null, null, null, [ + new OdataQueryParser\Datatype\OrderByClause('foo', OdataQueryParser\Enum\OrderDirection::DESC), + ]); + $actual = OdataQueryParser\OdataQueryParser::parse('https://example.com/api/user?$orderby=foo%20dESc'); + + $this->assertOdataQuerySame($expected, $actual); } public function testShouldReturnThePropertyInDescDirectionIfSpecifiedEvenIfFilledWithSpaces(): void { - $expected = ["orderBy" => [["property" => "foo", "direction" => "desc"]]]; - $actual = OdataQueryParser::parse('https://example.com/api/user?$orderby=%20foo%20%20%20desc%20'); + $expected = new OdataQueryParser\OdataQuery([], null, null, null, [ + new OdataQueryParser\Datatype\OrderByClause('foo', OdataQueryParser\Enum\OrderDirection::DESC), + ]); + $actual = OdataQueryParser\OdataQueryParser::parse('https://example.com/api/user?$orderby=%20foo%20%20%20desc%20'); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); } public function testShouldThrowExceptionIfDirectionInvalid(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage("direction should be either asc or desc"); + $this->expectExceptionMessage("Direction should be either asc or desc"); + + OdataQueryParser\OdataQueryParser::parse('https://example.com/api/user?$orderby=foo%20ascendant'); + } + + public function testShouldThrowExceptionIfTooManyArguments(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("An order by condition is invalid and resulted in a split of 3 terms."); - OdataQueryParser::parse('https://example.com/api/user?$orderby=foo%20ascendant'); + OdataQueryParser\OdataQueryParser::parse('https://example.com/api/user?$orderby=foo%20asc%20third'); } - // orderBy (no dollar mode) public function testShouldReturnThePropertyInTheOrderByInNonDollarMode(): void { - $expected = ["orderBy" => [["property" => "foo", "direction" => "asc"]]]; - $actual = OdataQueryParser::parse('https://example.com/api/user?orderby=foo', false); + $expected = new OdataQueryParser\OdataQuery([], null, null, null, [ + new OdataQueryParser\Datatype\OrderByClause('foo', OdataQueryParser\Enum\OrderDirection::ASC), + ]); + $actual = OdataQueryParser\OdataQueryParser::parse('https://example.com/api/user?orderby=foo', false); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); } public function testShouldReturnThePropertyInTheOrderByInNonDollarModeEvenIfFilledWithSpaces(): void { - $expected = ["orderBy" => [["property" => "foo", "direction" => "asc"]]]; - $actual = OdataQueryParser::parse('https://example.com/api/user?orderby=%20foo%20', false); + $expected = new OdataQueryParser\OdataQuery([], null, null, null, [ + new OdataQueryParser\Datatype\OrderByClause('foo', OdataQueryParser\Enum\OrderDirection::ASC), + ]); + $actual = OdataQueryParser\OdataQueryParser::parse('https://example.com/api/user?orderby=%20foo%20', false); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); } public function testShouldReturnAnEmptyArrayIfOrderByIsEmptyInNonDollarMode(): void { - $expected = []; - $actual = OdataQueryParser::parse('https://example.com/api/user?orderby=', false); + $expected = new OdataQueryParser\OdataQuery(); + $actual = OdataQueryParser\OdataQueryParser::parse('https://example.com/api/user?orderby=', false); + + $this->assertOdataQuerySame($expected, $actual); + } + + public function testShouldReturnAnEmptyArrayIfOrderByIsEmptyInNonDollarModeWithSpace(): void + { + $expected = new OdataQueryParser\OdataQuery(); + $actual = OdataQueryParser\OdataQueryParser::parse('https://example.com/api/user?orderby=%20%20', false); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); } public function testShouldReturnOrderByPropertyInAscDirectionIfSpecifiedInNonDollarMode(): void { - $expected = ["orderBy" => [["property" => "foo", "direction" => "asc"]]]; - $actual = OdataQueryParser::parse('https://example.com/api/user?orderby=foo%20asc', false); + $expected = new OdataQueryParser\OdataQuery([], null, null, null, [ + new OdataQueryParser\Datatype\OrderByClause('foo', OdataQueryParser\Enum\OrderDirection::ASC), + ]); + $actual = OdataQueryParser\OdataQueryParser::parse('https://example.com/api/user?orderby=foo%20asc', false); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); } public function testShouldReturnOrderByPropertyInAscDirectionIfSpecifiedInNonDollarModeEvenIfFilledWithSpaces(): void { - $expected = ["orderBy" => [["property" => "foo", "direction" => "asc"]]]; - $actual = OdataQueryParser::parse('https://example.com/api/user?orderby=%20foo%20%20%20asc%20', false); + $expected = new OdataQueryParser\OdataQuery([], null, null, null, [ + new OdataQueryParser\Datatype\OrderByClause('foo', OdataQueryParser\Enum\OrderDirection::ASC), + ]); + $actual = OdataQueryParser\OdataQueryParser::parse('https://example.com/api/user?orderby=%20foo%20%20%20asc%20', false); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); } public function testShouldReturnThePropertyInDescDirectionIfSpecifiedInNonDollarMode(): void { - $expected = ["orderBy" => [["property" => "foo", "direction" => "desc"]]]; - $actual = OdataQueryParser::parse('https://example.com/api/user?orderby=foo%20desc', false); + $expected = new OdataQueryParser\OdataQuery([], null, null, null, [ + new OdataQueryParser\Datatype\OrderByClause('foo', OdataQueryParser\Enum\OrderDirection::DESC), + ]); + $actual = OdataQueryParser\OdataQueryParser::parse('https://example.com/api/user?orderby=foo%20desc', false); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); + } + + public function testShouldReturnThePropertyInDescDirectionIfSpecifiedInNonDollarModeMixedCase(): void + { + $expected = new OdataQueryParser\OdataQuery([], null, null, null, [ + new OdataQueryParser\Datatype\OrderByClause('foo', OdataQueryParser\Enum\OrderDirection::DESC), + ]); + $actual = OdataQueryParser\OdataQueryParser::parse('https://example.com/api/user?orderby=foo%20dESC', false); + + $this->assertOdataQuerySame($expected, $actual); } public function testShouldReturnThePropertyInDescDirectionIfSpecifiedInNonDollarModeEvenIfFilledWithSpaces(): void { - $expected = ["orderBy" => [["property" => "foo", "direction" => "desc"]]]; - $actual = OdataQueryParser::parse('https://example.com/api/user?orderby=%20foo%20%20%20desc%20', false); + $expected = new OdataQueryParser\OdataQuery([], null, null, null, [ + new OdataQueryParser\Datatype\OrderByClause('foo', OdataQueryParser\Enum\OrderDirection::DESC), + ]); + $actual = OdataQueryParser\OdataQueryParser::parse('https://example.com/api/user?orderby=%20foo%20%20%20desc%20', false); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); } public function testShouldThrowExceptionIfDirectionInvalidInNonDollarMode(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage("direction should be either asc or desc"); + $this->expectExceptionMessage("Direction should be either asc or desc"); - OdataQueryParser::parse('https://example.com/api/user?orderby=foo%20ascendant', false); + OdataQueryParser\OdataQueryParser::parse('https://example.com/api/user?orderby=foo%20ascendant', false); } - public function testShouldReturnMultipleValues(): void + public function testShouldThrowExceptionIfTooManyArgumentsInNonDollarMode(): void { - $expected = ["select" => ["firstName", "lastName"], "orderBy" => [["property" => "id", "direction" => "asc"]], "top" => 10, "skip" => 10]; - $actual = OdataQueryParser::parse('https://example.com/api/user?$select=firstName,lastName&$orderby=id&$top=10&$skip=10'); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("An order by condition is invalid and resulted in a split of 3 terms."); - $this->assertEquals($expected, $actual); + OdataQueryParser\OdataQueryParser::parse('https://example.com/api/user?orderby=foo%20asc%20third', false); } } \ No newline at end of file diff --git a/tests/ParseTest.php b/tests/ParseTest.php index fba838e..7338d06 100644 --- a/tests/ParseTest.php +++ b/tests/ParseTest.php @@ -4,27 +4,36 @@ use GlobyApp\OdataQueryParser; use InvalidArgumentException; -use PHPUnit\Framework\TestCase; -final class ParseTest extends TestCase { +final class ParseTest extends BaseTestCase { public function testShouldReturnExceptionIfUrlIsEmpty(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage("url should be a valid url"); + $this->expectExceptionMessage("URL should be a valid, full URL"); - OdataQueryParser::parse(''); + OdataQueryParser\OdataQueryParser::parse(''); } public function testShouldReturnExceptionIfUrlIsNotValid(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage("url should be a valid url"); + $this->expectExceptionMessage("URL should be a valid, full URL"); - OdataQueryParser::parse('example.com'); + OdataQueryParser\OdataQueryParser::parse('example.com'); } public function testShouldReturnAnEmptyArrayIfNoQueryParameters(): void { - $expected = []; - $actual = OdataQueryParser::parse("https://example.com"); + $expected = null; + $actual = OdataQueryParser\OdataQueryParser::parse("https://example.com"); $this->assertEquals($expected, $actual); } + + public function testShouldReturnMultipleValues(): void + { + $expected = new OdataQueryParser\OdataQuery(['firstName', 'lastName'], null, 10, 10, [ + new OdataQueryParser\Datatype\OrderByClause('id', OdataQueryParser\Enum\OrderDirection::ASC), + ]); + $actual = OdataQueryParser\OdataQueryParser::parse('https://example.com/api/user?$select=firstName,lastName&$orderby=id&$top=10&$skip=10'); + + $this->assertOdataQuerySame($expected, $actual); + } } diff --git a/tests/SelectTest.php b/tests/SelectTest.php index 5e0aaf1..1316378 100644 --- a/tests/SelectTest.php +++ b/tests/SelectTest.php @@ -3,55 +3,70 @@ namespace OdataQueryParserTests; use GlobyApp\OdataQueryParser; -use PHPUnit\Framework\TestCase; -final class SelectTest extends TestCase +final class SelectTest extends BaseTestCase { public function testShouldReturnSelectColumns(): void { - $expected = ["select" => ["name", "type", "userId"]]; - $actual = OdataQueryParser::parse('https://example.com/users?$select=name,type,userId'); + $expected = new OdataQueryParser\OdataQuery(["name", "type", "userId"]); + $actual = OdataQueryParser\OdataQueryParser::parse('https://example.com/users?$select=name,type,userId'); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); } public function testShouldReturnSelectColumnsIfFilledWithSpaces(): void { - $expected = ["select" => ["name", "type", "userId"]]; - $actual = OdataQueryParser::parse('https://example.com/api/user?$select=%20name,%20type%20,userId%20'); + $expected = new OdataQueryParser\OdataQuery(["name", "type", "userId"]); + $actual = OdataQueryParser\OdataQueryParser::parse('https://example.com/api/user?$select=%20name,%20type%20,userId%20'); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); } public function testShouldReturnTheColumnsInNonDollarMode(): void { - $expected = ["select" => ["name", "type", "userId"]]; - $actual = OdataQueryParser::parse('https://example.com/?select=name,type,userId', false); + $expected = new OdataQueryParser\OdataQuery(["name", "type", "userId"]); + $actual = OdataQueryParser\OdataQueryParser::parse('https://example.com/?select=name,type,userId', false); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); } public function testShouldReturnTheColumnsIfFilledWithSpacesInNonDollarMode(): void { - $expected = ["select" => ["name", "type", "userId"]]; - $actual = OdataQueryParser::parse('https://example.com/api/user?select=%20name,%20type%20,userId%20', false); + $expected = new OdataQueryParser\OdataQuery(["name", "type", "userId"]); + $actual = OdataQueryParser\OdataQueryParser::parse('https://example.com/api/user?select=%20name,%20type%20,userId%20', false); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); } public function testShouldReturnAnEmptyArrayIfNoColumnFound(): void { - $expected = []; - $actual = OdataQueryParser::parse('https://example.com/?$select='); + $expected = new OdataQueryParser\OdataQuery(); + $actual = OdataQueryParser\OdataQueryParser::parse('https://example.com/?$select='); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); } public function testShouldReturnAnEmptyArrayIfNoColumnFoundInNonDollarMode(): void { - $expected = []; - $actual = OdataQueryParser::parse('https://example.com/?select='); + $expected = new OdataQueryParser\OdataQuery(); + $actual = OdataQueryParser\OdataQueryParser::parse('https://example.com/?select='); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); + } + + public function testShouldReturnAnEmptyArrayIfNoColumnFoundWithSpace(): void + { + $expected = new OdataQueryParser\OdataQuery(); + $actual = OdataQueryParser\OdataQueryParser::parse('https://example.com/?$select=%20%20'); + + $this->assertOdataQuerySame($expected, $actual); + } + + public function testShouldReturnAnEmptyArrayIfNoColumnFoundInNonDollarModeWithSpace(): void + { + $expected = new OdataQueryParser\OdataQuery(); + $actual = OdataQueryParser\OdataQueryParser::parse('https://example.com/?select=%20%20'); + + $this->assertOdataQuerySame($expected, $actual); } } \ No newline at end of file diff --git a/tests/SkipTest.php b/tests/SkipTest.php index 7723177..ebaa20c 100644 --- a/tests/SkipTest.php +++ b/tests/SkipTest.php @@ -4,101 +4,107 @@ use GlobyApp\OdataQueryParser; use InvalidArgumentException; -use PHPUnit\Framework\TestCase; -final class SkipTest extends TestCase +final class SkipTest extends BaseTestCase { public function testShouldThrowAnInvalidArgumentExceptionIfSkipParameterIsLowerThanZero(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage("skip should be greater or equal to zero"); + $this->expectExceptionMessage("Skip should be greater or equal to zero"); - OdataQueryParser::parse('https://example.com/?$skip=-1'); + OdataQueryParser\OdataQueryParser::parse('https://example.com/?$skip=-1'); } public function testShouldThrowAnInvalidArgumentExceptionIfSkipIsNotAnInteger(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage("skip should be an integer"); + $this->expectExceptionMessage("Invalid datatype for \$skip"); - OdataQueryParser::parse('https://example.com/?$skip=test'); + OdataQueryParser\OdataQueryParser::parse('https://example.com/?$skip=test'); } public function testShouldContainTheSkipValueIfProvidedInQueryParameters(): void { - $expected = ["skip" => 42]; - $actual = OdataQueryParser::parse('https://example.com/api/user?$skip=42'); + $expected = new OdataQueryParser\OdataQuery([], null, null, 42); + $actual = OdataQueryParser\OdataQueryParser::parse('https://example.com/api/user?$skip=42'); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); } public function testShouldContainTheSkipValueIfProvidedInTheQueryParameterAndFilledWithSpaces(): void { - $expected = ["skip" => 42]; - $actual = OdataQueryParser::parse('https://example.com/api/user?$skip=%2042%20'); + $expected = new OdataQueryParser\OdataQuery([], null, null, 42); + $actual = OdataQueryParser\OdataQueryParser::parse('https://example.com/api/user?$skip=%2042%20'); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); } public function testShouldContainAnEmptyArrayIfSkipParameterIsEmpty(): void { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage("skip should be an integer"); + $expected = new OdataQueryParser\OdataQuery(); + $actual = OdataQueryParser\OdataQueryParser::parse('https://example.com/api/user?$skip='); - OdataQueryParser::parse('https://example.com/api/user?$skip='); + $this->assertOdataQuerySame($expected, $actual); } public function testShouldNotThrowExceptionIfSkipIsEqualToZero(): void { - $expected = ["skip" => 0]; - $actual = OdataQueryParser::parse('https://example.com/api/user?$skip=0'); + $expected = new OdataQueryParser\OdataQuery([], null, null, 0); + $actual = OdataQueryParser\OdataQueryParser::parse('https://example.com/api/user?$skip=0'); + + $this->assertOdataQuerySame($expected, $actual); + } + + public function testShouldNotThrowExceptionIfSkipIsEqualToZeroWithSpace(): void + { + $expected = new OdataQueryParser\OdataQuery([], null, null, 0); + $actual = OdataQueryParser\OdataQueryParser::parse('https://example.com/api/user?$skip=%200'); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); } public function testShouldReturnAnIntegerForTheSkipValue(): void { - $this->assertIsInt(OdataQueryParser::parse('https://example.com/api/user?$skip=42')["skip"]); + $this->assertIsInt(OdataQueryParser\OdataQueryParser::parse('https://example.com/api/user?$skip=42')?->getSkip()); } - // skip (non dollar mode) - public function testShouldThrowAnInvalidArgumentExceptionIfSkipParameterIsLowerThanZeroInNonDolalrMode(): void + public function testShouldThrowAnInvalidArgumentExceptionIfSkipParameterIsLowerThanZeroInNonDollarMode(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage("skip should be greater or equal to zero"); + $this->expectExceptionMessage("Skip should be greater or equal to zero"); - OdataQueryParser::parse('https://example.com/?skip=-1', false); + OdataQueryParser\OdataQueryParser::parse('https://example.com/?skip=-1', false); } public function testShouldThrowAnInvalidArgumentExceptionIfSkipIsNotAnIntegerInNonDollarMode(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage("skip should be an integer"); + $this->expectExceptionMessage("Invalid datatype for skip"); - OdataQueryParser::parse('https://example.com/?skip=test', false); + OdataQueryParser\OdataQueryParser::parse('https://example.com/?skip=test', false); } public function testShouldContainTheSkipValueIfProvidedInQueryParametersInNonDollarMode(): void { - $expected = ["skip" => 42]; - $actual = OdataQueryParser::parse('https://example.com/api/user?skip=42', false); + $expected = new OdataQueryParser\OdataQuery([], null, null, 42); + $actual = OdataQueryParser\OdataQueryParser::parse('https://example.com/api/user?skip=42', false); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); } public function testShouldContainTheSkipValueIfProvidedInTheQueryParameterAndFilledWithSpacesInNonDollarMode(): void { - $expected = ["skip" => 42]; - $actual = OdataQueryParser::parse('https://example.com/api/user?skip=%2042%20', false); + $expected = new OdataQueryParser\OdataQuery([], null, null, 42); + $actual = OdataQueryParser\OdataQueryParser::parse('https://example.com/api/user?skip=%2042%20', false); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); } public function testShouldContainAnEmptyArrayIfSkipParameterIsEmptyInNonDollarMode(): void { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage("skip should be an integer"); + $expected = new OdataQueryParser\OdataQuery(); + $actual = OdataQueryParser\OdataQueryParser::parse('https://example.com/api/user?skip=', false); - OdataQueryParser::parse('https://example.com/api/user?skip=', false); + $this->assertOdataQuerySame($expected, $actual); } } \ No newline at end of file diff --git a/tests/TopTest.php b/tests/TopTest.php index 225ec56..bb9db1c 100644 --- a/tests/TopTest.php +++ b/tests/TopTest.php @@ -4,60 +4,67 @@ use GlobyApp\OdataQueryParser; use InvalidArgumentException; -use PHPUnit\Framework\TestCase; -final class TopTest extends TestCase +final class TopTest extends BaseTestCase { public function testShouldThrowAnInvalidArgumentExceptionIfTopQueryParameterIsLowerThanZero(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage("top should be greater or equal to zero"); + $this->expectExceptionMessage("Top should be greater or equal to zero"); - OdataQueryParser::parse('https://example.com/users?$top=-1'); + OdataQueryParser\OdataQueryParser::parse('https://example.com/users?$top=-1'); } public function testShouldNotThrowExceptionIfTopQueryParameterIsEqualToZero(): void { - $expected = ["top" => 0]; - $actual = OdataQueryParser::parse('https://example.com/api/user/?$top=0'); + $expected = new OdataQueryParser\OdataQuery([], null, 0); + $actual = OdataQueryParser\OdataQueryParser::parse('https://example.com/api/user/?$top=0'); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); } public function testShouldThrowAnExceptionIfTopQueryParameterIsLowerThanZeroAndFilledWithSpaces(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage("top should be greater or equal to zero"); + $this->expectExceptionMessage("Top should be greater or equal to zero"); - OdataQueryParser::parse('https://example.com/users?$top=%20-1%20'); + OdataQueryParser\OdataQueryParser::parse('https://example.com/users?$top=%20-1%20'); } public function testShouldThrowAnInvalidArgumentExceptionIfTopIsNotAnInteger(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage("top should be an integer"); + $this->expectExceptionMessage("Invalid datatype for \$top"); - OdataQueryParser::parse('https://example.com/?$top=foo'); + OdataQueryParser\OdataQueryParser::parse('https://example.com/?$top=foo'); } public function testShouldReturnTheTopValueIfProvidedInTheQueryParameters(): void { - $expected = ["top" => 42]; - $actual = OdataQueryParser::parse('https://example.com/?$top=42'); + $expected = new OdataQueryParser\OdataQuery([], null, 42); + $actual = OdataQueryParser\OdataQueryParser::parse('https://example.com/?$top=42'); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); } public function testShouldReturnAnIntegerTopValue(): void { - $this->assertIsInt(OdataQueryParser::parse('https://example.com/api/user?$top=42')["top"]); + $this->assertIsInt(OdataQueryParser\OdataQueryParser::parse('https://example.com/api/user?$top=42')?->getTop()); } public function testShouldReturnTheTopValueIfProvidedInTheQueryParametersAndFilledWithSpaces(): void { - $expected = ["top" => 42]; - $actual = OdataQueryParser::parse('https://example.com/api/user?$top=%2042%20'); + $expected = new OdataQueryParser\OdataQuery([], null, 42); + $actual = OdataQueryParser\OdataQueryParser::parse('https://example.com/api/user?$top=%2042%20'); - $this->assertEquals($expected, $actual); + $this->assertOdataQuerySame($expected, $actual); + } + + public function testShouldReturnNullIfTopIsEmpty(): void + { + $expected = new OdataQueryParser\OdataQuery(); + $actual = OdataQueryParser\OdataQueryParser::parse('https://example.com/api/user?$top=%20'); + + $this->assertOdataQuerySame($expected, $actual); } } \ No newline at end of file diff --git a/tests/phpunit-coverage.php b/tests/phpunit-coverage.php new file mode 100644 index 0000000..b41961d --- /dev/null +++ b/tests/phpunit-coverage.php @@ -0,0 +1,16 @@ +project->metrics["coveredelements"]/$coverage->project->metrics["elements"])*100); + +echo "Coverage: $ratio% of $threshold%\n"; + +if ($ratio < $threshold) { + echo "Coverage under $threshold%!\n"; + exit(-1); +} + +echo "Coverage above $threshold%!\n";