Add more validation cases

This commit is contained in:
Jake Barnby 2025-12-03 22:07:34 +13:00
parent 7d26131019
commit 4de4b475a1
3 changed files with 217 additions and 40 deletions

View file

@ -23,6 +23,7 @@ use Utopia\Database\Exception\Structure as StructureException;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Permission;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\Index as IndexValidator;
use Utopia\Database\Validator\Permissions;
use Utopia\Database\Validator\Structure;
use Utopia\Database\Validator\UID;
@ -135,11 +136,17 @@ class Create extends Action
throw $e;
}
$indexLimit = $dbForProject->getLimitForIndexes();
if (\count($indexes) > $indexLimit) {
$dbForProject->deleteDocument($databaseKey, $collection->getId());
throw new Exception($this->getLimitException(), "Cannot create more than $indexLimit indexes for a collection");
}
$collectionIndexes = [];
$indexDocuments = [];
try {
foreach ($indexes as $indexDef) {
$idxDoc = $this->buildIndexDocument($database, $collection, $indexDef, $collectionAttributes);
$idxDoc = $this->buildIndexDocument($database, $collection, $indexDef, $collectionAttributes, $dbForProject);
$collectionIndexes[] = $idxDoc['collection'];
$indexDocuments[] = $idxDoc['document'];
}
@ -260,6 +267,12 @@ class Create extends Action
throw new Exception($this->getDefaultUnsupportedException(), 'Cannot set default value for array ' . $this->getContext() . 's');
}
if (\in_array($type, Database::SPATIAL_TYPES)) {
if (!$dbForProject->getAdapter()->getSupportForSpatialIndex()) {
throw new Exception($this->getFormatUnsupportedException(), "Spatial attributes are not supported by the current database");
}
}
if ($type === Database::VAR_RELATIONSHIP) {
$options['side'] = Database::RELATION_SIDE_PARENT;
$relatedCollection = $dbForProject->getDocument('database_' . $database->getSequence(), $options['relatedCollection'] ?? '');
@ -315,7 +328,7 @@ class Create extends Action
*
* @return array{collection: Document, document: Document}
*/
protected function buildIndexDocument(Document $database, Document $collection, array $indexDef, array $attributeDocuments): array
protected function buildIndexDocument(Document $database, Document $collection, array $indexDef, array $attributeDocuments, Database $dbForProject): array
{
$key = $indexDef['key'];
$type = $indexDef['type'];
@ -380,6 +393,24 @@ class Create extends Action
'orders' => $orders,
]);
$indexValidator = new IndexValidator(
$attributeDocuments,
[],
$dbForProject->getAdapter()->getMaxIndexLength(),
$dbForProject->getAdapter()->getInternalIndexesKeys(),
$dbForProject->getAdapter()->getSupportForIndexArray(),
$dbForProject->getAdapter()->getSupportForSpatialIndexNull(),
$dbForProject->getAdapter()->getSupportForSpatialIndexOrder(),
$dbForProject->getAdapter()->getSupportForVectors(),
$dbForProject->getAdapter()->getSupportForAttributes(),
$dbForProject->getAdapter()->getSupportForMultipleFulltextIndexes(),
$dbForProject->getAdapter()->getSupportForIdenticalIndexes()
);
if (!$indexValidator->isValid($collectionDoc)) {
throw new Exception($this->getInvalidIndexException(), $indexValidator->getDescription());
}
return [
'collection' => $collectionDoc,
'document' => $document,

View file

@ -3,8 +3,12 @@
namespace Appwrite\Utopia\Database\Validator;
use Utopia\Database\Database;
use Utopia\Database\Validator\Datetime as DatetimeValidator;
use Utopia\Database\Validator\Key;
use Utopia\Validator;
use Utopia\Validator\Boolean as BooleanValidator;
use Utopia\Validator\Range;
use Utopia\Validator\Text;
class Attributes extends Validator
{
@ -65,12 +69,12 @@ class Attributes extends Validator
*/
public function isValid($value): bool
{
if (!is_array($value)) {
if (!\is_array($value)) {
$this->message = 'Attributes must be an array';
return false;
}
if (count($value) > $this->maxAttributes) {
if (\count($value) > $this->maxAttributes) {
$this->message = 'Maximum of ' . $this->maxAttributes . ' attributes allowed';
return false;
}
@ -79,7 +83,7 @@ class Attributes extends Validator
$keys = [];
foreach ($value as $index => $attribute) {
if (!is_array($attribute)) {
if (!\is_array($attribute)) {
$this->message = "Attribute at index $index must be an object";
return false;
}
@ -108,6 +112,13 @@ class Attributes extends Validator
}
$keys[] = $attribute['key'];
// Check for reserved keys
$reservedKeys = ['$id', '$createdAt', '$updatedAt', '$permissions', '$collection'];
if (in_array($attribute['key'], $reservedKeys)) {
$this->message = "Attribute key '" . $attribute['key'] . "' is reserved and cannot be used";
return false;
}
// Validate type
if (!in_array($attribute['type'], $this->supportedTypes)) {
$this->message = "Invalid type for attribute '" . $attribute['key'] . "': " . $attribute['type'];
@ -148,6 +159,12 @@ class Attributes extends Validator
return false;
}
// Validate signed only for integer/float types
if (isset($attribute['signed']) && !in_array($attribute['type'], [Database::VAR_INTEGER, Database::VAR_FLOAT])) {
$this->message = "Attribute '" . $attribute['key'] . "': 'signed' can only be used with integer or float types";
return false;
}
// Validate required and default conflict
if (isset($attribute['required']) && $attribute['required'] === true && isset($attribute['default']) && $attribute['default'] !== null) {
$this->message = "Attribute '" . $attribute['key'] . "' cannot have a default value when required is true";
@ -160,20 +177,137 @@ class Attributes extends Validator
return false;
}
// Validate min/max range for integer/float
if (isset($attribute['min']) && isset($attribute['max'])) {
if (!in_array($attribute['type'], [Database::VAR_INTEGER, Database::VAR_FLOAT])) {
$this->message = "Attribute '" . $attribute['key'] . "': min/max can only be used with integer or float types";
return false;
}
if ($attribute['min'] > $attribute['max']) {
$this->message = "Attribute '" . $attribute['key'] . "': minimum value must be less than or equal to maximum value";
return false;
}
}
// Validate default value matches attribute type
if (isset($attribute['default'])) {
switch ($attribute['type']) {
case Database::VAR_STRING:
if (!is_string($attribute['default'])) {
$this->message = "Default value for string attribute '" . $attribute['key'] . "' must be a string";
return false;
}
// Validate string size
$size = $attribute['size'] ?? 0;
if ($size > 0) {
$textValidator = new Text($size, 0);
if (!$textValidator->isValid($attribute['default'])) {
$this->message = "Default value for attribute '" . $attribute['key'] . "' exceeds maximum size of $size characters";
return false;
}
}
break;
case Database::VAR_INTEGER:
if (!is_int($attribute['default'])) {
$this->message = "Default value for integer attribute '" . $attribute['key'] . "' must be an integer";
return false;
}
// Validate within range if min/max specified
if (isset($attribute['min']) || isset($attribute['max'])) {
$min = $attribute['min'] ?? \PHP_INT_MIN;
$max = $attribute['max'] ?? \PHP_INT_MAX;
$rangeValidator = new Range($min, $max, Database::VAR_INTEGER);
if (!$rangeValidator->isValid($attribute['default'])) {
$this->message = "Default value for integer attribute '" . $attribute['key'] . "' must be between $min and $max";
return false;
}
}
break;
case Database::VAR_FLOAT:
if (!is_float($attribute['default']) && !is_int($attribute['default'])) {
$this->message = "Default value for float attribute '" . $attribute['key'] . "' must be a number";
return false;
}
// Validate within range if min/max specified
if (isset($attribute['min']) || isset($attribute['max'])) {
$min = $attribute['min'] ?? -\PHP_FLOAT_MAX;
$max = $attribute['max'] ?? \PHP_FLOAT_MAX;
$rangeValidator = new Range($min, $max, Database::VAR_FLOAT);
if (!$rangeValidator->isValid((float)$attribute['default'])) {
$this->message = "Default value for float attribute '" . $attribute['key'] . "' must be between $min and $max";
return false;
}
}
break;
case Database::VAR_BOOLEAN:
if (!is_bool($attribute['default'])) {
$this->message = "Default value for boolean attribute '" . $attribute['key'] . "' must be a boolean";
return false;
}
break;
case Database::VAR_DATETIME:
if (!is_string($attribute['default'])) {
$this->message = "Default value for datetime attribute '" . $attribute['key'] . "' must be a string in ISO 8601 format";
return false;
}
// Basic datetime format validation
$datetimeValidator = new DatetimeValidator();
if (!$datetimeValidator->isValid($attribute['default'])) {
$this->message = "Default value for datetime attribute '" . $attribute['key'] . "' must be in valid ISO 8601 format";
return false;
}
break;
}
}
// Validate enum elements if format is enum
if (isset($attribute['format']) && $attribute['format'] === APP_DATABASE_ATTRIBUTE_ENUM) {
if (!isset($attribute['elements']) || !is_array($attribute['elements']) || empty($attribute['elements'])) {
$this->message = "Attribute '" . $attribute['key'] . "' with enum format must have 'elements' array";
return false;
}
// Validate each enum element
foreach ($attribute['elements'] as $elementIndex => $element) {
if (!is_string($element) || empty($element)) {
$this->message = "Enum element at index $elementIndex for attribute '" . $attribute['key'] . "' must be a non-empty string";
return false;
}
if (strlen($element) > Database::LENGTH_KEY) {
$this->message = "Enum element at index $elementIndex for attribute '" . $attribute['key'] . "' exceeds maximum length of " . Database::LENGTH_KEY . " characters";
return false;
}
}
// Validate default exists in elements
if (isset($attribute['default']) && $attribute['default'] !== null) {
if (!in_array($attribute['default'], $attribute['elements'], true)) {
$this->message = "Default value for enum attribute '" . $attribute['key'] . "' must be one of the provided elements";
return false;
}
}
}
// Validate relationship options
if ($attribute['type'] === Database::VAR_RELATIONSHIP) {
if (!isset($attribute['relatedCollection']) || empty($attribute['relatedCollection'])) {
// Validate array cannot be true for relationship
if (isset($attribute['array']) && $attribute['array'] === true) {
$this->message = "Relationship attribute '" . $attribute['key'] . "' cannot be an array type";
return false;
}
// Validate required fields for relationship
if (empty($attribute['relatedCollection'])) {
$this->message = "Relationship attribute '" . $attribute['key'] . "' must have 'relatedCollection'";
return false;
}
if (!isset($attribute['relationType']) || !in_array($attribute['relationType'], [
Database::RELATION_ONE_TO_ONE,
Database::RELATION_ONE_TO_MANY,
@ -191,7 +325,7 @@ class Attributes extends Validator
}
// Validate twoWayKey if provided
if (isset($attribute['twoWayKey']) && !empty($attribute['twoWayKey'])) {
if (!empty($attribute['twoWayKey'])) {
if (!$keyValidator->isValid($attribute['twoWayKey'])) {
$this->message = "Invalid 'twoWayKey' for relationship attribute '" . $attribute['key'] . "': " . $keyValidator->getDescription();
return false;

View file

@ -70,95 +70,107 @@ class Indexes extends Validator
$keyValidator = new Key();
$keys = [];
foreach ($value as $index => $indexDef) {
if (!is_array($indexDef)) {
$this->message = "Index at position $index must be an object";
foreach ($value as $i => $index) {
if (!is_array($index)) {
$this->message = "Index at position $i must be an object";
return false;
}
// Validate required fields
if (!isset($indexDef['key'])) {
$this->message = "Index at position $index is missing required field 'key'";
if (!isset($index['key'])) {
$this->message = "Index at position $i is missing required field 'key'";
return false;
}
if (!isset($indexDef['type'])) {
$this->message = "Index at position $index is missing required field 'type'";
if (!isset($index['type'])) {
$this->message = "Index at position $i is missing required field 'type'";
return false;
}
if (!isset($indexDef['attributes']) || !is_array($indexDef['attributes'])) {
$this->message = "Index at position $index is missing required field 'attributes' (must be an array)";
if (!isset($index['attributes']) || !is_array($index['attributes'])) {
$this->message = "Index at position $i is missing required field 'attributes' (must be an array)";
return false;
}
// Validate key
if (!$keyValidator->isValid($indexDef['key'])) {
$this->message = "Invalid key for index at position $index: " . $keyValidator->getDescription();
if (!$keyValidator->isValid($index['key'])) {
$this->message = "Invalid key for index at position $i: " . $keyValidator->getDescription();
return false;
}
// Check for duplicate keys
if (in_array($indexDef['key'], $keys)) {
$this->message = "Duplicate index key: " . $indexDef['key'];
if (in_array($index['key'], $keys)) {
$this->message = "Duplicate index key: " . $index['key'];
return false;
}
$keys[] = $indexDef['key'];
$keys[] = $index['key'];
// Validate type
if (!in_array($indexDef['type'], $this->supportedTypes)) {
$this->message = "Invalid type for index '" . $indexDef['key'] . "': " . $indexDef['type'];
if (!in_array($index['type'], $this->supportedTypes)) {
$this->message = "Invalid type for index '" . $index['key'] . "': " . $index['type'];
return false;
}
// Validate attributes array
if (empty($indexDef['attributes'])) {
$this->message = "Index '" . $indexDef['key'] . "' must have at least one attribute";
if (empty($index['attributes'])) {
$this->message = "Index '" . $index['key'] . "' must have at least one attribute";
return false;
}
if (count($indexDef['attributes']) > APP_LIMIT_ARRAY_PARAMS_SIZE) {
$this->message = "Index '" . $indexDef['key'] . "' cannot have more than " . APP_LIMIT_ARRAY_PARAMS_SIZE . " attributes";
if (count($index['attributes']) > APP_LIMIT_ARRAY_PARAMS_SIZE) {
$this->message = "Index '" . $index['key'] . "' cannot have more than " . APP_LIMIT_ARRAY_PARAMS_SIZE . " attributes";
return false;
}
// Validate each attribute in the index
foreach ($indexDef['attributes'] as $attrIndex => $attr) {
foreach ($index['attributes'] as $attrIndex => $attr) {
if (!is_string($attr)) {
$this->message = "Invalid attribute at position $attrIndex in index '" . $indexDef['key'] . "': must be a string";
$this->message = "Invalid attribute at position $attrIndex in index '" . $index['key'] . "': must be a string";
return false;
}
if (!$keyValidator->isValid($attr) && !in_array($attr, ['$id', '$createdAt', '$updatedAt'])) {
$this->message = "Invalid attribute name '$attr' in index '" . $indexDef['key'] . "'";
$this->message = "Invalid attribute name '$attr' in index '" . $index['key'] . "'";
return false;
}
}
// Validate orders if provided
if (isset($indexDef['orders'])) {
if (!is_array($indexDef['orders'])) {
$this->message = "Index '" . $indexDef['key'] . "' orders must be an array";
if (isset($index['orders'])) {
if (!is_array($index['orders'])) {
$this->message = "Index '" . $index['key'] . "' orders must be an array";
return false;
}
foreach ($indexDef['orders'] as $order) {
// Validate orders array length matches attributes length
if (count($index['orders']) !== count($index['attributes'])) {
$this->message = "Index '" . $index['key'] . "': orders array length (" . count($index['orders']) . ") must match attributes array length (" . count($index['attributes']) . ")";
return false;
}
foreach ($index['orders'] as $order) {
if ($order !== null && $order !== '' && !in_array($order, $this->supportedOrders)) {
$this->message = "Invalid order '$order' in index '" . $indexDef['key'] . "'. Must be 'ASC' or 'DESC'";
$this->message = "Invalid order '$order' in index '" . $index['key'] . "'. Must be 'ASC' or 'DESC'";
return false;
}
}
}
// Validate lengths if provided
if (isset($indexDef['lengths'])) {
if (!is_array($indexDef['lengths'])) {
$this->message = "Index '" . $indexDef['key'] . "' lengths must be an array";
if (isset($index['lengths'])) {
if (!is_array($index['lengths'])) {
$this->message = "Index '" . $index['key'] . "' lengths must be an array";
return false;
}
foreach ($indexDef['lengths'] as $length) {
// MAJOR-7: Validate lengths array length matches attributes length
if (count($index['lengths']) !== count($index['attributes'])) {
$this->message = "Index '" . $index['key'] . "': lengths array length (" . count($index['lengths']) . ") must match attributes array length (" . count($index['attributes']) . ")";
return false;
}
foreach ($index['lengths'] as $length) {
if ($length !== null && (!is_int($length) || $length < 0)) {
$this->message = "Invalid length in index '" . $indexDef['key'] . "': must be a non-negative integer or null";
$this->message = "Invalid length in index '" . $index['key'] . "': must be a non-negative integer or null";
return false;
}
}