diff --git a/composer.lock b/composer.lock index 8e1ef842e8..1429dfe408 100644 --- a/composer.lock +++ b/composer.lock @@ -1029,16 +1029,16 @@ }, { "name": "symfony/polyfill-php80", - "version": "v1.28.0", + "version": "v1.29.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "6caa57379c4aec19c0a12a38b59b26487dcfe4b5" + "reference": "87b68208d5c1188808dd7839ee1e6c8ec3b02f1b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/6caa57379c4aec19c0a12a38b59b26487dcfe4b5", - "reference": "6caa57379c4aec19c0a12a38b59b26487dcfe4b5", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/87b68208d5c1188808dd7839ee1e6c8ec3b02f1b", + "reference": "87b68208d5c1188808dd7839ee1e6c8ec3b02f1b", "shasum": "" }, "require": { @@ -1046,9 +1046,6 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "1.28-dev" - }, "thanks": { "name": "symfony/polyfill", "url": "https://github.com/symfony/polyfill" @@ -1092,7 +1089,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.28.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.29.0" }, "funding": [ { @@ -1108,7 +1105,7 @@ "type": "tidelift" } ], - "time": "2023-01-26T09:26:14+00:00" + "time": "2024-01-29T20:11:03+00:00" }, { "name": "thecodingmachine/safe", @@ -5123,16 +5120,16 @@ }, { "name": "symfony/polyfill-ctype", - "version": "v1.28.0", + "version": "v1.29.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb" + "reference": "ef4d7e442ca910c4764bce785146269b30cb5fc4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb", - "reference": "ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/ef4d7e442ca910c4764bce785146269b30cb5fc4", + "reference": "ef4d7e442ca910c4764bce785146269b30cb5fc4", "shasum": "" }, "require": { @@ -5146,9 +5143,6 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "1.28-dev" - }, "thanks": { "name": "symfony/polyfill", "url": "https://github.com/symfony/polyfill" @@ -5185,7 +5179,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.28.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.29.0" }, "funding": [ { @@ -5201,20 +5195,20 @@ "type": "tidelift" } ], - "time": "2023-01-26T09:26:14+00:00" + "time": "2024-01-29T20:11:03+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.28.0", + "version": "v1.29.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "42292d99c55abe617799667f454222c54c60e229" + "reference": "9773676c8a1bb1f8d4340a62efe641cf76eda7ec" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/42292d99c55abe617799667f454222c54c60e229", - "reference": "42292d99c55abe617799667f454222c54c60e229", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/9773676c8a1bb1f8d4340a62efe641cf76eda7ec", + "reference": "9773676c8a1bb1f8d4340a62efe641cf76eda7ec", "shasum": "" }, "require": { @@ -5228,9 +5222,6 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "1.28-dev" - }, "thanks": { "name": "symfony/polyfill", "url": "https://github.com/symfony/polyfill" @@ -5268,7 +5259,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.28.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.29.0" }, "funding": [ { @@ -5284,7 +5275,7 @@ "type": "tidelift" } ], - "time": "2023-07-28T09:04:16+00:00" + "time": "2024-01-29T20:11:03+00:00" }, { "name": "textalk/websocket", @@ -5523,5 +5514,5 @@ "platform-overrides": { "php": "8.2" }, - "plugin-api-version": "2.2.0" + "plugin-api-version": "2.6.0" } diff --git a/src/Appwrite/Utopia/Request/Filters/V17.php b/src/Appwrite/Utopia/Request/Filters/V17.php index 50a33a93b9..74720b648c 100644 --- a/src/Appwrite/Utopia/Request/Filters/V17.php +++ b/src/Appwrite/Utopia/Request/Filters/V17.php @@ -3,9 +3,20 @@ namespace Appwrite\Utopia\Request\Filters; use Appwrite\Utopia\Request\Filter; +use Utopia\Database\Query; class V17 extends Filter { + protected const CHAR_SINGLE_QUOTE = '\''; + protected const CHAR_DOUBLE_QUOTE = '"'; + protected const CHAR_COMMA = ','; + protected const CHAR_SPACE = ' '; + protected const CHAR_BRACKET_START = '['; + protected const CHAR_BRACKET_END = ']'; + protected const CHAR_PARENTHESES_START = '('; + protected const CHAR_PARENTHESES_END = ')'; + protected const CHAR_BACKSLASH = '\\'; + // Convert 1.4 params to 1.5 public function parse(array $content, string $model): array { @@ -13,7 +24,319 @@ class V17 extends Filter case 'account.updateRecovery': unset($content['passwordAgain']); break; + // Queries + case 'account.listIdentities': + case 'account.listLogs': + case 'databases.list': + case 'databases.listLogs': + case 'databases.listCollections': + case 'databases.listCollectionLogs': + case 'databases.listAttributes': + case 'databases.listIndexes': + case 'databases.listDocuments': + case 'databases.getDocument': + case 'databases.listDocumentLogs': + case 'functions.list': + case 'functions.listDeployments': + case 'functions.listExecutions': + case 'migrations.list': + case 'projects.list': + case 'proxy.listRules': + case 'storage.listBuckets': + case 'storage.listFiles': + case 'teams.list': + case 'teams.listMemberships': + case 'teams.listLogs': + case 'users.list': + case 'users.listLogs': + case 'users.listIdentities': + case 'vcs.listInstallations': + $content = $this->convertOldQueries($content); + break; + } return $content; } + + function convertOldQueries(array $content): array + { + $parsed = []; + foreach ($content['queries'] as $query) { + try { + $query = $this->parseQuery($query); + $parsed[] = json_encode(array_filter($query->toArray())); + } catch (\Throwable $th) { + throw new \Exception("Invalid query: {$query}", previous: $th); + } + } + + $content['queries'] = $parsed; + + return $content; + } + + // 1.4 query parser + public function parseQuery(string $filter): Query + { + // Init empty vars we fill later + $method = ''; + $params = []; + + // Separate method from filter + $paramsStart = mb_strpos($filter, '('); + + if ($paramsStart === false) { + throw new \Exception('Invalid query'); + } + + $method = mb_substr($filter, 0, $paramsStart); + + // Separate params from filter + $paramsEnd = \strlen($filter) - 1; // -1 to ignore ) + $parametersStart = $paramsStart + 1; // +1 to ignore ( + + // Check for deprecated query syntax + if (\str_contains($method, '.')) { + throw new \Exception('Invalid query method'); + } + + $currentParam = ""; // We build param here before pushing when it's ended + $currentArrayParam = []; // We build array param here before pushing when it's ended + + $stack = []; // State for stack of parentheses + $stackCount = 0; // Length of stack array. Kept as variable to improve performance + $stringStackState = null; // State for string support + + + // Loop thorough all characters + for ($i = $parametersStart; $i < $paramsEnd; $i++) { + $char = $filter[$i]; + + $isStringStack = $stringStackState !== null; + $isArrayStack = !$isStringStack && $stackCount > 0; + + if ($char === static::CHAR_BACKSLASH) { + if (!(static::isSpecialChar($filter[$i + 1]))) { + static::appendSymbol($isStringStack, $filter[$i], $i, $filter, $currentParam); + } + + static::appendSymbol($isStringStack, $filter[$i + 1], $i, $filter, $currentParam); + $i++; + + continue; + } + + // String support + escaping support + if ( + (self::isQuote($char)) && // Must be string indicator + ($filter[$i - 1] !== static::CHAR_BACKSLASH || $filter[$i - 2] === static::CHAR_BACKSLASH) // Must not be escaped; + ) { + if ($isStringStack) { + // Dont mix-up string symbols. Only allow the same as on start + if ($char === $stringStackState) { + // End of string + $stringStackState = null; + } + + // Either way, add symbol to builder + static::appendSymbol($isStringStack, $char, $i, $filter, $currentParam); + } else { + // Start of string + $stringStackState = $char; + static::appendSymbol($isStringStack, $char, $i, $filter, $currentParam); + } + + continue; + } + + // Array support + if (!($isStringStack)) { + if ($char === static::CHAR_BRACKET_START) { + // Start of array + $stack[] = $char; + $stackCount++; + continue; + } elseif ($char === static::CHAR_BRACKET_END) { + // End of array + \array_pop($stack); + $stackCount--; + + if (strlen($currentParam)) { + $currentArrayParam[] = $currentParam; + } + + $params[] = $currentArrayParam; + $currentArrayParam = []; + $currentParam = ""; + + continue; + } elseif ($char === static::CHAR_COMMA) { // Params separation support + // If in array stack, dont merge yet, just mark it in array param builder + if ($isArrayStack) { + $currentArrayParam[] = $currentParam; + $currentParam = ""; + } else { + // Append from parap builder. Either value, or array + if (empty($currentArrayParam)) { + if (strlen($currentParam)) { + $params[] = $currentParam; + } + + $currentParam = ""; + } + } + continue; + } + } + + // Value, not relevant to syntax + static::appendSymbol($isStringStack, $char, $i, $filter, $currentParam); + } + + if (strlen($currentParam)) { + $params[] = $currentParam; + $currentParam = ""; + } + + $parsedParams = []; + + foreach ($params as $param) { + // If array, parse each child separatelly + if (\is_array($param)) { + foreach ($param as $element) { + $arr[] = self::parseValue($element); + } + + $parsedParams[] = $arr ?? []; + } else { + $parsedParams[] = self::parseValue($param); + } + } + + switch ($method) { + case Query::TYPE_EQUAL: + case Query::TYPE_NOT_EQUAL: + case Query::TYPE_LESSER: + case Query::TYPE_LESSER_EQUAL: + case Query::TYPE_GREATER: + case Query::TYPE_GREATER_EQUAL: + case Query::TYPE_CONTAINS: + case Query::TYPE_SEARCH: + case Query::TYPE_IS_NULL: + case Query::TYPE_IS_NOT_NULL: + case Query::TYPE_STARTS_WITH: + case Query::TYPE_ENDS_WITH: + $attribute = $parsedParams[0] ?? ''; + if (count($parsedParams) < 2) { + return new Query($method, $attribute); + } + return new Query($method, $attribute, \is_array($parsedParams[1]) ? $parsedParams[1] : [$parsedParams[1]]); + + case Query::TYPE_BETWEEN: + return new Query($method, $parsedParams[0], [$parsedParams[1], $parsedParams[2]]); + case Query::TYPE_SELECT: + return new Query($method, values: $parsedParams[0]); + case Query::TYPE_ORDER_ASC: + case Query::TYPE_ORDER_DESC: + return new Query($method, $parsedParams[0] ?? ''); + + case Query::TYPE_LIMIT: + case Query::TYPE_OFFSET: + case Query::TYPE_CURSOR_AFTER: + case Query::TYPE_CURSOR_BEFORE: + if (count($parsedParams) > 0) { + return new Query($method, values: [$parsedParams[0]]); + } + return new Query($method); + + default: + return new Query($method); + } + } + + /** + * Parses value. + * + * @param string $value + * @return mixed + */ + function parseValue(string $value): mixed + { + $value = \trim($value); + + if ($value === 'false') { // Boolean value + return false; + } elseif ($value === 'true') { + return true; + } elseif ($value === 'null') { // Null value + return null; + } elseif (\is_numeric($value)) { // Numeric value + // Cast to number + return $value + 0; + } elseif (\str_starts_with($value, static::CHAR_DOUBLE_QUOTE) || \str_starts_with($value, static::CHAR_SINGLE_QUOTE)) { // String param + $value = \substr($value, 1, -1); // Remove '' or "" + return $value; + } + + // Unknown format + return $value; + } + + /** + * Utility method to only append symbol if relevant. + * + * @param bool $isStringStack + * @param string $char + * @param int $index + * @param string $filter + * @param string $currentParam + * @return void + */ + function appendSymbol(bool $isStringStack, string $char, int $index, string $filter, string &$currentParam): void + { + // Ignore spaces and commas outside of string + $canBeIgnored = false; + + if ($char === static::CHAR_SPACE) { + $canBeIgnored = true; + } elseif ($char === static::CHAR_COMMA) { + $canBeIgnored = true; + } + + if ($canBeIgnored) { + if ($isStringStack) { + $currentParam .= $char; + } + } else { + $currentParam .= $char; + } + } + + function isQuote(string $char): bool + { + if ($char === self::CHAR_SINGLE_QUOTE) { + return true; + } elseif ($char === self::CHAR_DOUBLE_QUOTE) { + return true; + } + + return false; + } + + function isSpecialChar(string $char): bool + { + if ($char === static::CHAR_COMMA) { + return true; + } elseif ($char === static::CHAR_BRACKET_END) { + return true; + } elseif ($char === static::CHAR_BRACKET_START) { + return true; + } elseif ($char === static::CHAR_DOUBLE_QUOTE) { + return true; + } elseif ($char === static::CHAR_SINGLE_QUOTE) { + return true; + } + + return false; + } } diff --git a/tests/unit/Utopia/Request/Filters/V17Test.php b/tests/unit/Utopia/Request/Filters/V17Test.php index 624981dafc..4c0e155ec5 100644 --- a/tests/unit/Utopia/Request/Filters/V17Test.php +++ b/tests/unit/Utopia/Request/Filters/V17Test.php @@ -41,7 +41,6 @@ class V17Test extends TestCase ]; } - /** * @dataProvider createUpdateRecoveryProvider */ @@ -53,4 +52,38 @@ class V17Test extends TestCase $this->assertEquals($expected, $result); } + + public function createQueryProvider() + { + return [ + 'convert queries' => [ + [ + 'queries' => [ + 'cursorAfter("exampleId")', + 'search("name", ["example"])', + 'isNotNull("name")' + ] + ], + [ + 'queries' => [ + '{"method":"cursorAfter","values":["exampleId"]}', + '{"method":"search","attribute":"name","values":["example"]}', + '{"method":"isNotNull","attribute":"name"}' + ] + ], + ] + ]; + } + + /** + * @dataProvider createQueryProvider + */ + public function testQuery(array $content, array $expected): void + { + $model = 'databases.getDocument'; + + $result = $this->filter->parse($content, $model); + + $this->assertEquals($expected, $result); + } }