From a165cd09d58d288f0b7f9f88a9d4525ebf195eb0 Mon Sep 17 00:00:00 2001 From: Steven Date: Thu, 11 Aug 2022 01:03:16 +0000 Subject: [PATCH] Update Queries Validator to validate all queries --- src/Database/Query.php | 42 ++++- src/Database/Validator/Queries.php | 98 ++++++----- src/Database/Validator/QueryValidator.php | 91 +++++++++-- tests/Database/QueryTest.php | 25 +++ tests/Database/Validator/QueriesTest.php | 154 +++++++++--------- .../Database/Validator/QueryValidatorTest.php | 68 ++++++++ 6 files changed, 336 insertions(+), 142 deletions(-) diff --git a/src/Database/Query.php b/src/Database/Query.php index b805f9b56..40028fda4 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -54,14 +54,19 @@ public function getMethod(): string return $this->method; } + public function getAttribute(): string + { + return $this->attribute; + } + public function getValues(): array { return $this->values; } - public function getAttribute(): string + public function getValue($default = null) { - return $this->attribute; + return $this->values[0] ?? $default; } /** @@ -76,6 +81,18 @@ public function setMethod(string $method): self return $this; } + /** + * Sets Attribute. + * @param string $attribute + * @return self + */ + public function setAttribute(string $attribute): self + { + $this->attribute = $attribute; + + return $this; + } + /** * Sets Values. * @param array $values @@ -89,13 +106,13 @@ public function setValues(array $values): self } /** - * Sets Attribute. - * @param string $attribute + * Sets Value. + * @param $value * @return self */ - public function setAttribute(string $attribute): self + public function setValue($value): self { - $this->attribute = $attribute; + $this->values = [$value]; return $this; } @@ -278,17 +295,24 @@ public static function parse(string $filter): self case self::TYPE_GREATEREQUAL: case self::TYPE_CONTAINS: case self::TYPE_SEARCH: - return new self($method, $parsedParams[0], \is_array($parsedParams[1]) ? $parsedParams[1] : [$parsedParams[1]]); + $attribute = $parsedParams[0] ?? ''; + if (count($parsedParams) < 2) { + return new self($method, $attribute); + } + return new self($method, $attribute, \is_array($parsedParams[1]) ? $parsedParams[1] : [$parsedParams[1]]); case self::TYPE_ORDERASC: case self::TYPE_ORDERDESC: - return new self($method, $parsedParams[0]); + return new self($method, $parsedParams[0] ?? ''); case self::TYPE_LIMIT: case self::TYPE_OFFSET: case self::TYPE_CURSORAFTER: case self::TYPE_CURSORBEFORE: - return new self($method, values: $parsedParams[0]); + if (count($parsedParams) > 0) { + return new self($method, values: [$parsedParams[0]]); + } + return new self($method); default: return new self($method); diff --git a/src/Database/Validator/Queries.php b/src/Database/Validator/Queries.php index 5a205da99..f4bc322bd 100644 --- a/src/Database/Validator/Queries.php +++ b/src/Database/Validator/Queries.php @@ -21,7 +21,12 @@ class Queries extends Validator protected $validator; /** - * @var array + * @var Document[] + */ + protected $attributes = []; + + /** + * @var Document[] */ protected $indexes = []; @@ -34,30 +39,31 @@ class Queries extends Validator * Queries constructor * * @param QueryValidator $validator - * @param Document[] $indexes + * @param Document $collection * @param bool $strict */ - public function __construct($validator, $indexes, $strict = true) + public function __construct($validator, $collection, $strict = true) { $this->validator = $validator; + $this->attributes = $collection->getAttribute('attributes', []); - $this->indexes[] = [ + $this->indexes[] = new Document([ 'type' => Database::INDEX_UNIQUE, 'attributes' => ['$id'] - ]; + ]); - $this->indexes[] = [ + $this->indexes[] = new Document([ 'type' => Database::INDEX_KEY, 'attributes' => ['$createdAt'] - ]; + ]); - $this->indexes[] = [ + $this->indexes[] = new Document([ 'type' => Database::INDEX_KEY, 'attributes' => ['$updatedAt'] - ]; + ]); - foreach ($indexes as $index) { - $this->indexes[] = $index->getArrayCopy(['attributes', 'type']); + foreach ($collection->getAttribute('indexes', []) as $index) { + $this->indexes[] = $index; } $this->strict = $strict; @@ -78,50 +84,66 @@ public function getDescription(): string /** * Is valid. * - * Returns true if all $queries are valid as a set. + * Returns false if: + * 1. any query in $value is invalid based on $validator + * + * In addition, if $strict is true, this returns false if: + * 1. there is no index with an exact match of the filters + * 2. there is no index with an exact mathc of the order attributes + * + * Otherwise, returns true. + * * @param mixed $value as array of Query objects * @return bool */ public function isValid($value): bool { - /** - * Array of attributes from query - * - * @var string[] - */ - $queries = []; - foreach ($value as $query) { - // [attribute => method] - $queries[$query->getAttribute()] = $query->getMethod(); - if (!$this->validator->isValid($query)) { $this->message = 'Query not valid: ' . $this->validator->getDescription(); return false; } } + if (!$this->strict) { + return true; + } + + // Check queries for exact index match + + $queriesByMethod = Queries::byMethod($value); + /** @var Query[] */ $filters = $queriesByMethod['filters']; + /** @var string[] */ $orderAttributes = $queriesByMethod['orderAttributes']; + + $filtersByAttribute = []; + foreach ($filters as $filter) { + $filtersByAttribute[$filter->getAttribute()] = $filter->getMethod(); + } + $found = null; - // Return false if attributes do not exactly match an index - if ($this->strict) { - // look for strict match among indexes - foreach ($this->indexes as $index) { - if ($this->arrayMatch($index['attributes'], array_keys($queries))) { - $found = $index; - } + foreach ($this->indexes as $index) { + if ($this->arrayMatch($index->getAttribute('attributes'), array_keys($filtersByAttribute))) { + $found = $index; } + } - if (!$found) { - $this->message = 'Index not found: ' . implode(",", array_keys($queries)); - return false; - } + if (!$found) { + $this->message = 'Index not found: ' . implode(",", array_keys($filtersByAttribute)); + return false; + } - // search method requires fulltext index - if (in_array(Query::TYPE_SEARCH, array_values($queries)) && $found['type'] !== Database::INDEX_FULLTEXT) { - $this->message = 'Search method requires fulltext index: ' . implode(",", array_keys($queries)); - return false; - } + // search method requires fulltext index + if (in_array(Query::TYPE_SEARCH, array_values($filtersByAttribute)) && $found['type'] !== Database::INDEX_FULLTEXT) { + $this->message = 'Search method requires fulltext index: ' . implode(",", array_keys($filtersByAttribute)); + return false; + } + + // Check order attributes for exact index match + $validator = new OrderAttributes($this->attributes, $this->indexes, true); + if (count($orderAttributes) > 0 && !$validator->isValid($orderAttributes)) { + $this->message = $validator->getDescription(); + return false; } return true; diff --git a/src/Database/Validator/QueryValidator.php b/src/Database/Validator/QueryValidator.php index 7957a4744..94d37a133 100644 --- a/src/Database/Validator/QueryValidator.php +++ b/src/Database/Validator/QueryValidator.php @@ -19,28 +19,32 @@ class QueryValidator extends Validator */ protected $schema = []; + protected int $maxLimit; + protected int $maxOffset; + protected int $maxValuesCount; + /** * Expression constructor * * @param Document[] $attributes */ - public function __construct(array $attributes) + public function __construct(array $attributes, int $maxLimit = 100, int $maxOffset = 5000, int $maxValuesCount = 100) { - $this->schema[] = [ + $this->schema['$id'] = [ 'key' => '$id', 'array' => false, 'type' => Database::VAR_STRING, 'size' => 512 ]; - $this->schema[] = [ + $this->schema['$createdAt'] = [ 'key' => '$createdAt', 'array' => false, 'type' => Database::VAR_DATETIME, 'size' => 0 ]; - $this->schema[] = [ + $this->schema['$updatedAt'] = [ 'key' => '$updatedAt', 'array' => false, 'type' => Database::VAR_DATETIME, @@ -48,8 +52,12 @@ public function __construct(array $attributes) ]; foreach ($attributes as $attribute) { - $this->schema[] = $attribute->getArrayCopy(); + $this->schema[$attribute->getAttribute('key')] = $attribute->getArrayCopy(); } + + $this->maxLimit = $maxLimit; + $this->maxOffset = $maxOffset; + $this->maxValuesCount = $maxValuesCount; } /** @@ -67,32 +75,85 @@ public function getDescription(): string /** * Is valid. * - * Returns true if query typed according to schema. + * Returns false if: + * 1. $query has an invalid method + * 2. limit value is not a number, less than 0, or greater than $maxLimit + * 3. offset value is not a number, less than 0, or greater than $maxOffset + * 4. attribute does not exist + * 5. count of values is greater than $maxValuesCount + * 6. value type does not match attribute type + * 6. contains method is used on non-array attribute + * + * Otherwise, returns true. * - * @param $query + * @param Query $query * * @return bool */ public function isValid($query): bool { // Validate method - if (!Query::isMethod($query->getMethod())) { - $this->message = 'Query method invalid: ' . $query->getMethod(); + $method = $query->getMethod(); + if (!Query::isMethod($method)) { + $this->message = 'Query method invalid: ' . $method; return false; } + if ($method === Query::TYPE_LIMIT) { + $limit = $query->getValue(); + if ($limit === null || $limit < 0 || $limit > $this->maxLimit) { + $this->message = 'Limit must be between 0 and ' . $this->maxLimit . '(inclusive)'; + return false; + } + return true; + } + + if ($method === Query::TYPE_OFFSET) { + $offset = $query->getValue(); + if ($offset === null || $offset < 0 || $offset > $this->maxOffset) { + $this->message = 'Offset must be between 0 and ' . $this->maxOffset . '(inclusive)'; + return false; + } + return true; + } + + if ($method === Query::TYPE_CURSORAFTER || $method === Query::TYPE_CURSORBEFORE) { + $value = $query->getValue(); + if ($value === null) { + $this->message = 'Cursor must not be null'; + return false; + } + return true; + } + + // Allow empty string for order attribute so we can order by natural order + $attribute = $query->getAttribute(); + if ($attribute === '' && ($method === DatabaseQuery::TYPE_ORDERASC || $method === DatabaseQuery::TYPE_ORDERDESC)) { + return true; + } + // Search for attribute in schema - $attributeIndex = array_search($query->getAttribute(), array_column($this->schema, 'key')); + if (!isset($this->schema[$attribute])) { + $this->message = 'Attribute not found in schema: ' . $attribute; + return false; + } + + if ($method === Query::TYPE_ORDERASC || $method === Query::TYPE_ORDERDESC) { + return true; + } + + $attributeSchema = $this->schema[$attribute]; - if ($attributeIndex === false) { - $this->message = 'Attribute not found in schema: ' . $query->getAttribute(); + $values = $query->getValues(); + if (count($values) > $this->maxValuesCount) { + $this->message = 'Query on attribute has greater than ' . $this->maxValuesCount . ' values: ' . $attribute; return false; } // Extract the type of desired attribute from collection $schema - $attributeType = $this->schema[$attributeIndex]['type']; + $attributeType = $attributeSchema['type']; - foreach ($query->getValues() as $value) { + foreach ($values as $value) { $condition = match ($attributeType) { Database::VAR_DATETIME => gettype($value) === Database::VAR_STRING, default => gettype($value) === $attributeType @@ -105,7 +166,7 @@ public function isValid($query): bool } // Contains method only supports array attributes - if (!$this->schema[$attributeIndex]['array'] && $query->getMethod() === Query::TYPE_CONTAINS) { + if (!$attributeSchema['array'] && $query->getMethod() === Query::TYPE_CONTAINS) { $this->message = 'Query method only supported on array attributes: ' . $query->getMethod(); return false; } diff --git a/tests/Database/QueryTest.php b/tests/Database/QueryTest.php index b8b1c540b..6e6dc8203 100644 --- a/tests/Database/QueryTest.php +++ b/tests/Database/QueryTest.php @@ -207,6 +207,31 @@ public function testParseV2() $this->assertCount(1, $query->getValues()); $this->assertEquals(1, $query->getAttribute()); $this->assertEquals("Hello\\\\\", ", $query->getValues()[0]); + + $query = Query::parse('equal()'); + $this->assertCount(0, $query->getValues()); + $this->assertEquals('', $query->getAttribute()); + $this->assertEquals(null, $query->getValue()); + + $query = Query::parse('limit()'); + $this->assertCount(0, $query->getValues()); + $this->assertEquals('', $query->getAttribute()); + $this->assertEquals(null, $query->getValue()); + + $query = Query::parse('offset()'); + $this->assertCount(0, $query->getValues()); + $this->assertEquals('', $query->getAttribute()); + $this->assertEquals(null, $query->getValue()); + + $query = Query::parse('cursorAfter()'); + $this->assertCount(0, $query->getValues()); + $this->assertEquals('', $query->getAttribute()); + $this->assertEquals(null, $query->getValue()); + + $query = Query::parse('orderDesc()'); + $this->assertCount(0, $query->getValues()); + $this->assertEquals('', $query->getAttribute()); + $this->assertEquals(null, $query->getValue()); } /* diff --git a/tests/Database/Validator/QueriesTest.php b/tests/Database/Validator/QueriesTest.php index bccd5ab08..568606f8e 100644 --- a/tests/Database/Validator/QueriesTest.php +++ b/tests/Database/Validator/QueriesTest.php @@ -14,75 +14,7 @@ class QueriesTest extends TestCase /** * @var array */ - protected $collection = [ - '$id' => Database::METADATA, - '$collection' => Database::METADATA, - 'name' => 'movies', - 'attributes' => [ - [ - '$id' => 'title', - 'key' => 'title', - 'type' => Database::VAR_STRING, - 'size' => 256, - 'required' => true, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'description', - 'key' => 'description', - 'type' => Database::VAR_STRING, - 'size' => 1000000, - 'required' => true, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'rating', - 'key' => 'rating', - 'type' => Database::VAR_INTEGER, - 'size' => 5, - 'required' => true, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'price', - 'key' => 'price', - 'type' => Database::VAR_FLOAT, - 'size' => 5, - 'required' => true, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'published', - 'key' => 'published', - 'type' => Database::VAR_BOOLEAN, - 'size' => 5, - 'required' => true, - 'signed' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'tags', - 'key' => 'tags', - 'type' => Database::VAR_STRING, - 'size' => 55, - 'required' => true, - 'signed' => true, - 'array' => true, - 'filters' => [], - ], - ], - 'indexes' => [], - ]; - + protected $collection = []; /** * @var Query[] $queries @@ -96,14 +28,76 @@ class QueriesTest extends TestCase public function setUp(): void { - // Query validator expects Document[] - $attributes = []; - /** @var Document[] $attributes */ - foreach ($this->collection['attributes'] as $attribute) { - $attributes[] = new Document($attribute); - } + $this->collection = [ + '$id' => Database::METADATA, + '$collection' => Database::METADATA, + 'name' => 'movies', + 'attributes' => [ + new Document([ + '$id' => 'title', + 'key' => 'title', + 'type' => Database::VAR_STRING, + 'size' => 256, + 'required' => true, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]), + new Document([ + '$id' => 'description', + 'key' => 'description', + 'type' => Database::VAR_STRING, + 'size' => 1000000, + 'required' => true, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]), + new Document([ + '$id' => 'rating', + 'key' => 'rating', + 'type' => Database::VAR_INTEGER, + 'size' => 5, + 'required' => true, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]), + new Document([ + '$id' => 'price', + 'key' => 'price', + 'type' => Database::VAR_FLOAT, + 'size' => 5, + 'required' => true, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]), + new Document([ + '$id' => 'published', + 'key' => 'published', + 'type' => Database::VAR_BOOLEAN, + 'size' => 5, + 'required' => true, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]), + new Document([ + '$id' => 'tags', + 'key' => 'tags', + 'type' => Database::VAR_STRING, + 'size' => 55, + 'required' => true, + 'signed' => true, + 'array' => true, + 'filters' => [], + ]), + ], + 'indexes' => [], + ]; - $this->queryValidator = new QueryValidator($attributes); + $this->queryValidator = new Query($this->collection['attributes']); $query1 = Query::parse('notEqual("title", ["Iron Man", "Ant Man"])'); $query2 = Query::parse('equal("description", "Best movie ever")'); @@ -166,9 +160,9 @@ public function tearDown(): void public function testQueries() { // test for SUCCESS - $validator = new Queries($this->queryValidator, $this->collection['indexes']); + $validator = new Queries($this->queryValidator, new Document($this->collection)); - $this->assertEquals(true, $validator->isValid($this->queries)); + $this->assertEquals(true, $validator->isValid($this->queries), $validator->getDescription()); $this->queries[] = Query::parse('lessThan("price", 6.50)'); $this->assertEquals(true, $validator->isValid($this->queries)); @@ -199,11 +193,11 @@ public function testQueries() public function testIsStrict() { - $validator = new Queries($this->queryValidator, $this->collection['indexes']); + $validator = new Queries($this->queryValidator, new Document($this->collection)); $this->assertEquals(true, $validator->isStrict()); - $validator = new Queries($this->queryValidator, $this->collection['indexes'], false); + $validator = new Queries($this->queryValidator, new Document($this->collection), false); $this->assertEquals(false, $validator->isStrict()); } diff --git a/tests/Database/Validator/QueryValidatorTest.php b/tests/Database/Validator/QueryValidatorTest.php index 7db85e53e..3ff9dbc86 100644 --- a/tests/Database/Validator/QueryValidatorTest.php +++ b/tests/Database/Validator/QueryValidatorTest.php @@ -113,6 +113,10 @@ public function testQuery() $this->assertEquals(true, $validator->isValid(Query::parse('greaterThan("rating", 4)')), $validator->getDescription()); $this->assertEquals(true, $validator->isValid(Query::parse('lessThan("price", 6.50)'))); $this->assertEquals(true, $validator->isValid(Query::parse('contains("tags", "action")'))); + $this->assertEquals(true, $validator->isValid(Query::parse('cursorAfter("docId")'))); + $this->assertEquals(true, $validator->isValid(Query::parse('cursorBefore("docId")'))); + $this->assertEquals(true, $validator->isValid(Query::parse('orderAsc("title")'))); + $this->assertEquals(true, $validator->isValid(Query::parse('orderDesc("title")'))); } public function testInvalidMethod() @@ -133,6 +137,11 @@ public function testAttributeNotFound() $this->assertEquals(false, $response); $this->assertEquals('Attribute not found in schema: name', $validator->getDescription()); + + $response = $validator->isValid(Query::parse('orderAsc("name")')); + + $this->assertEquals(false, $response); + $this->assertEquals('Attribute not found in schema: name', $validator->getDescription()); } public function testAttributeWrongType() @@ -161,4 +170,63 @@ public function testQueryDate() $response = $validator->isValid(Query::parse('greaterThan("birthDay", "1960-01-01 10:10:10")')); $this->assertEquals(true, $response); } + + public function testQueryLimit() + { + $validator = new QueryValidator($this->schema); + + $response = $validator->isValid(Query::parse('limit(25)')); + $this->assertEquals(true, $response); + + $response = $validator->isValid(Query::parse('limit()')); + $this->assertEquals(false, $response); + + $response = $validator->isValid(Query::parse('limit(-1)')); + $this->assertEquals(false, $response); + + $response = $validator->isValid(Query::parse('limit(10000)')); + $this->assertEquals(false, $response); + } + + public function testQueryOffset() + { + $validator = new QueryValidator($this->schema); + + $response = $validator->isValid(Query::parse('offset(25)')); + $this->assertEquals(true, $response); + + $response = $validator->isValid(Query::parse('offset()')); + $this->assertEquals(false, $response); + + $response = $validator->isValid(Query::parse('offset(-1)')); + $this->assertEquals(false, $response); + + $response = $validator->isValid(Query::parse('offset(10000)')); + $this->assertEquals(false, $response); + } + + public function testQueryOrder() + { + $validator = new QueryValidator($this->schema); + + $response = $validator->isValid(Query::parse('orderAsc("title")')); + $this->assertEquals(true, $response); + + $response = $validator->isValid(Query::parse('orderAsc("")')); + $this->assertEquals(true, $response); + + $response = $validator->isValid(Query::parse('orderAsc("doesNotExist")')); + $this->assertEquals(false, $response); + } + + public function testQueryCursor() + { + $validator = new QueryValidator($this->schema); + + $response = $validator->isValid(Query::parse('cursorAfter("asdf")')); + $this->assertEquals(true, $response); + + $response = $validator->isValid(Query::parse('cursorAfter()')); + $this->assertEquals(false, $response); + } }