Skip to content

Commit

Permalink
Update Queries Validator to validate all queries
Browse files Browse the repository at this point in the history
  • Loading branch information
stnguyen90 committed Aug 12, 2022
1 parent 4348f07 commit a165cd0
Show file tree
Hide file tree
Showing 6 changed files with 336 additions and 142 deletions.
42 changes: 33 additions & 9 deletions src/Database/Query.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -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
Expand All @@ -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;
}
Expand Down Expand Up @@ -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);
Expand Down
98 changes: 60 additions & 38 deletions src/Database/Validator/Queries.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ class Queries extends Validator
protected $validator;

/**
* @var array
* @var Document[]
*/
protected $attributes = [];

/**
* @var Document[]
*/
protected $indexes = [];

Expand All @@ -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;
Expand All @@ -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;
Expand Down
91 changes: 76 additions & 15 deletions src/Database/Validator/QueryValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,37 +19,45 @@ 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,
'size' => 0
];

foreach ($attributes as $attribute) {
$this->schema[] = $attribute->getArrayCopy();
$this->schema[$attribute->getAttribute('key')] = $attribute->getArrayCopy();
}

$this->maxLimit = $maxLimit;
$this->maxOffset = $maxOffset;
$this->maxValuesCount = $maxValuesCount;
}

/**
Expand All @@ -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
Expand All @@ -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;
}
Expand Down
Loading

0 comments on commit a165cd0

Please sign in to comment.