diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Create.php index 1682fedcfa..a4e2265bfb 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Create.php @@ -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, diff --git a/src/Appwrite/Utopia/Database/Validator/Attributes.php b/src/Appwrite/Utopia/Database/Validator/Attributes.php index 48339bc642..fae6d6a3b0 100644 --- a/src/Appwrite/Utopia/Database/Validator/Attributes.php +++ b/src/Appwrite/Utopia/Database/Validator/Attributes.php @@ -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; diff --git a/src/Appwrite/Utopia/Database/Validator/Indexes.php b/src/Appwrite/Utopia/Database/Validator/Indexes.php index d60d53c764..31c8c83835 100644 --- a/src/Appwrite/Utopia/Database/Validator/Indexes.php +++ b/src/Appwrite/Utopia/Database/Validator/Indexes.php @@ -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; } }