From 2a88341edc0619cc4db1ac02a42cbc9c9ec228ff Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 28 Nov 2025 02:33:13 +1300 Subject: [PATCH] Add attributes + indexes params --- .../Http/Databases/Collections/Action.php | 60 +++++ .../Http/Databases/Collections/Create.php | 232 +++++++++++++++++- .../Databases/Http/TablesDB/Tables/Create.php | 4 + .../Utopia/Database/Validator/Attributes.php | 211 ++++++++++++++++ .../Utopia/Database/Validator/Indexes.php | 190 ++++++++++++++ 5 files changed, 694 insertions(+), 3 deletions(-) create mode 100644 src/Appwrite/Utopia/Database/Validator/Attributes.php create mode 100644 src/Appwrite/Utopia/Database/Validator/Indexes.php diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Action.php index b22119fad1..a148e23845 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Action.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Action.php @@ -105,4 +105,64 @@ abstract class Action extends UtopiaAction ? Exception::COLLECTION_LIMIT_EXCEEDED : Exception::TABLE_LIMIT_EXCEEDED; } + + /** + * Get the appropriate format unsupported exception. + */ + protected function getFormatUnsupportedException(): string + { + return $this->isCollectionsAPI() + ? Exception::ATTRIBUTE_FORMAT_UNSUPPORTED + : Exception::COLUMN_FORMAT_UNSUPPORTED; + } + + /** + * Get the correct default unsupported message. + */ + protected function getDefaultUnsupportedException(): string + { + return $this->isCollectionsAPI() + ? Exception::ATTRIBUTE_DEFAULT_UNSUPPORTED + : Exception::COLUMN_DEFAULT_UNSUPPORTED; + } + + /** + * Get the appropriate parent level not found exception. + */ + protected function getParentNotFoundException(): string + { + return $this->isCollectionsAPI() + ? Exception::COLLECTION_NOT_FOUND + : Exception::TABLE_NOT_FOUND; + } + + /** + * Get the correct invalid structure message. + */ + protected function getStructureException(): string + { + return $this->isCollectionsAPI() + ? Exception::DOCUMENT_INVALID_STRUCTURE + : Exception::ROW_INVALID_STRUCTURE; + } + + /** + * Get the exception for unknown attribute/column in index. + */ + protected function getParentUnknownException(): string + { + return $this->isCollectionsAPI() + ? Exception::ATTRIBUTE_UNKNOWN + : Exception::COLUMN_UNKNOWN; + } + + /** + * Get the exception for invalid attribute/column type in index. + */ + protected function getParentInvalidTypeException(): string + { + return $this->isCollectionsAPI() + ? Exception::ATTRIBUTE_TYPE_INVALID + : Exception::COLUMN_TYPE_INVALID; + } } 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 922cc45428..caa80c74d2 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Create.php @@ -9,7 +9,9 @@ use Appwrite\SDK\ContentType; use Appwrite\SDK\Deprecated; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Utopia\Database\Validator\Attributes as AttributesValidator; use Appwrite\Utopia\Database\Validator\CustomId; +use Appwrite\Utopia\Database\Validator\Indexes as IndexesValidator; use Appwrite\Utopia\Response as UtopiaResponse; use Utopia\Database\Database; use Utopia\Database\Document; @@ -17,10 +19,12 @@ use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Exception\Index as IndexException; use Utopia\Database\Exception\Limit as LimitException; use Utopia\Database\Exception\NotFound as NotFoundException; +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\Permissions; +use Utopia\Database\Validator\Structure; use Utopia\Database\Validator\UID; use Utopia\Swoole\Response as SwooleResponse; use Utopia\Validator\Boolean; @@ -75,13 +79,15 @@ class Create extends Action ->param('permissions', null, new Nullable(new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE)), 'An array of permissions strings. By default, no user is granted with any permissions. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) ->param('documentSecurity', false, new Boolean(true), 'Enables configuring permissions for individual documents. A user needs one of document or collection level permissions to access a document. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) ->param('enabled', true, new Boolean(), 'Is collection enabled? When set to \'disabled\', users cannot access the collection but Server SDKs with and API key can still read and write to the collection. No data is lost when this is toggled.', true) + ->param('attributes', [], new AttributesValidator(), 'Array of attribute definitions to create. Each attribute should contain: key (string), type (string: string, integer, float, boolean, datetime, relationship), size (integer, required for string type), required (boolean, optional), default (mixed, optional), array (boolean, optional), and type-specific options.', true) + ->param('indexes', [], new IndexesValidator(), 'Array of index definitions to create. Each index should contain: key (string), type (string: key, fulltext, unique, spatial), attributes (array of attribute keys), orders (array of ASC/DESC, optional), and lengths (array of integers, optional).', true) ->inject('response') ->inject('dbForProject') ->inject('queueForEvents') ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, string $name, ?array $permissions, bool $documentSecurity, bool $enabled, UtopiaResponse $response, Database $dbForProject, Event $queueForEvents): void + public function action(string $databaseId, string $collectionId, string $name, ?array $permissions, bool $documentSecurity, bool $enabled, array $attributes, array $indexes, UtopiaResponse $response, Database $dbForProject, Event $queueForEvents): void { $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); @@ -113,20 +119,53 @@ class Create extends Action throw new Exception(Exception::DATABASE_NOT_FOUND); } + $collectionAttributes = []; + $attributeDocuments = []; + foreach ($attributes as $attributeDef) { + $attrDoc = $this->buildAttributeDocument($database, $collection, $attributeDef, $dbForProject); + $collectionAttributes[] = $attrDoc['collection']; + $attributeDocuments[] = $attrDoc['document']; + } + + $collectionIndexes = []; + $indexDocuments = []; + foreach ($indexes as $indexDef) { + $idxDoc = $this->buildIndexDocument($database, $collection, $indexDef, $collectionAttributes); + $collectionIndexes[] = $idxDoc['collection']; + $indexDocuments[] = $idxDoc['document']; + } + try { $dbForProject->createCollection( id: 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), + attributes: $collectionAttributes, + indexes: $collectionIndexes, permissions: $permissions, documentSecurity: $documentSecurity ); } catch (DuplicateException) { throw new Exception($this->getDuplicateException()); - } catch (IndexException) { - throw new Exception($this->getInvalidIndexException()); + } catch (IndexException $e) { + throw new Exception($this->getInvalidIndexException(), $e->getMessage()); } catch (LimitException) { throw new Exception($this->getLimitException()); } + // Create documents in attributes and indexes collections + try { + if (!empty($attributeDocuments)) { + $dbForProject->createDocuments('attributes', $attributeDocuments); + } + if (!empty($indexDocuments)) { + $dbForProject->createDocuments('indexes', $indexDocuments); + } + } catch (DuplicateException) { + throw new Exception($this->getDuplicateException()); + } + + $dbForProject->purgeCachedDocument('database_' . $database->getSequence(), $collection->getId()); + $dbForProject->purgeCachedCollection('database_' . $database->getSequence() . '_collection_' . $collection->getSequence()); + $queueForEvents ->setContext('database', $database) ->setParam('databaseId', $databaseId) @@ -136,4 +175,191 @@ class Create extends Action ->setStatusCode(SwooleResponse::STATUS_CODE_CREATED) ->dynamic($collection, $this->getResponseModel()); } + + /** + * Build attribute Document objects from a definition array + * + * @return array{collection: Document, document: Document} + */ + protected function buildAttributeDocument(Document $database, Document $collection, array $attributeDef, Database $dbForProject): array + { + $key = $attributeDef['key']; + $type = $attributeDef['type']; + $size = $attributeDef['size'] ?? 0; + $required = $attributeDef['required'] ?? false; + $signed = $attributeDef['signed'] ?? true; + $array = $attributeDef['array'] ?? false; + $format = $attributeDef['format'] ?? ''; + $formatOptions = []; + $filters = $attributeDef['filters'] ?? []; + $default = $attributeDef['default'] ?? null; + $options = []; + + if ($type === Database::VAR_STRING) { + if ($size === 0) { + $size = 256; // Default size for strings + } + } + + if ($format === APP_DATABASE_ATTRIBUTE_ENUM && isset($attributeDef['elements'])) { + $formatOptions = ['elements' => $attributeDef['elements']]; + } + + if (isset($attributeDef['min']) && isset($attributeDef['max'])) { + $format = $type === Database::VAR_INTEGER ? APP_DATABASE_ATTRIBUTE_INT_RANGE : APP_DATABASE_ATTRIBUTE_FLOAT_RANGE; + $formatOptions = [ + 'min' => $attributeDef['min'], + 'max' => $attributeDef['max'], + ]; + } + + if ($type === Database::VAR_RELATIONSHIP) { + $options = [ + 'relatedCollection' => $attributeDef['relatedCollection'] ?? '', + 'relationType' => $attributeDef['relationType'] ?? Database::RELATION_ONE_TO_ONE, + 'twoWay' => $attributeDef['twoWay'] ?? false, + 'twoWayKey' => $attributeDef['twoWayKey'] ?? '', + 'onDelete' => $attributeDef['onDelete'] ?? Database::RELATION_MUTATE_RESTRICT, + ]; + } + + if (!empty($format)) { + if (!Structure::hasFormat($format, $type)) { + throw new Exception($this->getFormatUnsupportedException(), "Format $format not available for $type attributes."); + } + } + + if ($required && isset($default) && $default !== null) { + throw new Exception($this->getDefaultUnsupportedException(), 'Cannot set default value for required ' . $this->getContext()); + } + + if ($array && isset($default) && $default !== null) { + throw new Exception($this->getDefaultUnsupportedException(), 'Cannot set default value for array ' . $this->getContext() . 's'); + } + + if ($type === Database::VAR_RELATIONSHIP) { + $options['side'] = Database::RELATION_SIDE_PARENT; + $relatedCollection = $dbForProject->getDocument('database_' . $database->getSequence(), $options['relatedCollection'] ?? ''); + if ($relatedCollection->isEmpty()) { + $parent = $this->isCollectionsAPI() ? 'collection' : 'table'; + throw new Exception($this->getParentNotFoundException(), "The related $parent was not found."); + } + } + + $collectionDoc = new Document([ + '$id' => $key, + 'key' => $key, + 'type' => $type, + 'size' => $size, + 'required' => $required, + 'signed' => $signed, + 'default' => $default, + 'array' => $array, + 'format' => $format, + 'formatOptions' => $formatOptions, + 'filters' => $filters, + 'options' => $options, + ]); + + $document = new Document([ + '$id' => ID::custom($database->getSequence() . '_' . $collection->getSequence() . '_' . $key), + 'key' => $key, + 'databaseInternalId' => $database->getSequence(), + 'databaseId' => $database->getId(), + 'collectionInternalId' => $collection->getSequence(), + 'collectionId' => $collection->getId(), + 'type' => $type, + 'status' => 'available', + 'size' => $size, + 'required' => $required, + 'signed' => $signed, + 'default' => $default, + 'array' => $array, + 'format' => $format, + 'formatOptions' => $formatOptions, + 'filters' => $filters, + 'options' => $options, + ]); + + return [ + 'collection' => $collectionDoc, + 'document' => $document, + ]; + } + + /** + * Build index Document objects from a definition array + * + * @return array{collection: Document, document: Document} + */ + protected function buildIndexDocument(Document $database, Document $collection, array $indexDef, array $attributeDocuments): array + { + $key = $indexDef['key']; + $type = $indexDef['type']; + $indexAttributes = $indexDef['attributes']; + $orders = $indexDef['orders'] ?? []; + $lengths = $indexDef['lengths'] ?? []; + + $attrKeys = array_map(fn ($a) => $a->getAttribute('key'), $attributeDocuments); + + $systemAttrs = ['$id', '$createdAt', '$updatedAt']; + + foreach ($indexAttributes as $i => $attr) { + if (!in_array($attr, $attrKeys) && !in_array($attr, $systemAttrs)) { + throw new Exception($this->getParentUnknownException(), "Unknown attribute: " . $attr . ". Verify the attribute name or ensure it's in the attributes list."); + } + + $attrIndex = array_search($attr, $attrKeys); + if ($attrIndex !== false) { + $attrDoc = $attributeDocuments[$attrIndex]; + $attrType = $attrDoc->getAttribute('type'); + $attrArray = $attrDoc->getAttribute('array', false); + + if ($attrType === Database::VAR_RELATIONSHIP) { + throw new Exception($this->getParentInvalidTypeException(), "Cannot create an index for a relationship attribute: " . $attr); + } + + if (empty($lengths[$i])) { + $lengths[$i] = null; + } + + if ($attrArray === true) { + $lengths[$i] = Database::MAX_ARRAY_INDEX_LENGTH; + $orders[$i] = null; + } + } else { + if (empty($lengths[$i])) { + $lengths[$i] = null; + } + } + } + + $collectionDoc = new Document([ + '$id' => $key, + 'key' => $key, + 'type' => $type, + 'attributes' => $indexAttributes, + 'lengths' => $lengths, + 'orders' => $orders, + ]); + + $document = new Document([ + '$id' => ID::custom($database->getSequence() . '_' . $collection->getSequence() . '_' . $key), + 'key' => $key, + 'status' => 'available', + 'databaseInternalId' => $database->getSequence(), + 'databaseId' => $database->getId(), + 'collectionInternalId' => $collection->getSequence(), + 'collectionId' => $collection->getId(), + 'type' => $type, + 'attributes' => $indexAttributes, + 'lengths' => $lengths, + 'orders' => $orders, + ]); + + return [ + 'collection' => $collectionDoc, + 'document' => $document, + ]; + } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Create.php index 68d3e772ec..060f4d418e 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Create.php @@ -7,7 +7,9 @@ use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Utopia\Database\Validator\Attributes as AttributesValidator; use Appwrite\Utopia\Database\Validator\CustomId; +use Appwrite\Utopia\Database\Validator\Indexes as IndexesValidator; use Appwrite\Utopia\Response as UtopiaResponse; use Utopia\Database\Validator\Permissions; use Utopia\Database\Validator\UID; @@ -60,6 +62,8 @@ class Create extends CollectionCreate ->param('permissions', null, new Nullable(new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE)), 'An array of permissions strings. By default, no user is granted with any permissions. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) ->param('rowSecurity', false, new Boolean(true), 'Enables configuring permissions for individual rows. A user needs one of row or table level permissions to access a row. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) ->param('enabled', true, new Boolean(), 'Is table enabled? When set to \'disabled\', users cannot access the table but Server SDKs with and API key can still read and write to the table. No data is lost when this is toggled.', true) + ->param('columns', [], new AttributesValidator(), 'Array of column definitions to create. Each column should contain: key (string), type (string: string, integer, float, boolean, datetime, relationship), size (integer, required for string type), required (boolean, optional), default (mixed, optional), array (boolean, optional), and type-specific options.', true) + ->param('indexes', [], new IndexesValidator(), 'Array of index definitions to create. Each index should contain: key (string), type (string: key, fulltext, unique, spatial), attributes (array of column keys), orders (array of ASC/DESC, optional), and lengths (array of integers, optional).', true) ->inject('response') ->inject('dbForProject') ->inject('queueForEvents') diff --git a/src/Appwrite/Utopia/Database/Validator/Attributes.php b/src/Appwrite/Utopia/Database/Validator/Attributes.php new file mode 100644 index 0000000000..fafa1d0f8e --- /dev/null +++ b/src/Appwrite/Utopia/Database/Validator/Attributes.php @@ -0,0 +1,211 @@ + Supported attribute types + */ + protected array $supportedTypes = [ + Database::VAR_STRING, + Database::VAR_INTEGER, + Database::VAR_FLOAT, + Database::VAR_BOOLEAN, + Database::VAR_DATETIME, + Database::VAR_RELATIONSHIP, + Database::VAR_POINT, + Database::VAR_LINESTRING, + Database::VAR_POLYGON, + ]; + + /** + * @var array Supported formats for string attributes + */ + protected array $supportedFormats = [ + '', + APP_DATABASE_ATTRIBUTE_EMAIL, + APP_DATABASE_ATTRIBUTE_ENUM, + APP_DATABASE_ATTRIBUTE_IP, + APP_DATABASE_ATTRIBUTE_URL, + ]; + + /** + * @param int $maxAttributes Maximum number of attributes allowed + */ + public function __construct(int $maxAttributes = APP_LIMIT_ARRAY_PARAMS_SIZE) + { + $this->maxAttributes = $maxAttributes; + } + + /** + * Get Description + * + * Returns validator description + * + * @return string + */ + public function getDescription(): string + { + return $this->message; + } + + /** + * Is valid + * + * @param mixed $value + * @return bool + */ + public function isValid($value): bool + { + if (!is_array($value)) { + $this->message = 'Attributes must be an array'; + return false; + } + + if (count($value) > $this->maxAttributes) { + $this->message = 'Maximum of ' . $this->maxAttributes . ' attributes allowed'; + return false; + } + + $keyValidator = new Key(); + $keys = []; + + foreach ($value as $index => $attribute) { + if (!is_array($attribute)) { + $this->message = "Attribute at index $index must be an object"; + return false; + } + + // Validate required fields + if (!isset($attribute['key'])) { + $this->message = "Attribute at index $index is missing required field 'key'"; + return false; + } + + if (!isset($attribute['type'])) { + $this->message = "Attribute at index $index is missing required field 'type'"; + return false; + } + + // Validate key + if (!$keyValidator->isValid($attribute['key'])) { + $this->message = "Invalid key for attribute at index $index: " . $keyValidator->getDescription(); + return false; + } + + // Check for duplicate keys + if (in_array($attribute['key'], $keys)) { + $this->message = "Duplicate attribute key: " . $attribute['key']; + return false; + } + $keys[] = $attribute['key']; + + // Validate type + if (!in_array($attribute['type'], $this->supportedTypes)) { + $this->message = "Invalid type for attribute '" . $attribute['key'] . "': " . $attribute['type']; + return false; + } + + // Validate size for string types + if ($attribute['type'] === Database::VAR_STRING) { + if (!isset($attribute['size']) || !is_int($attribute['size']) || $attribute['size'] < 1 || $attribute['size'] > APP_DATABASE_ATTRIBUTE_STRING_MAX_LENGTH) { + $this->message = "Invalid or missing size for string attribute '" . $attribute['key'] . "'. Size must be between 1 and " . APP_DATABASE_ATTRIBUTE_STRING_MAX_LENGTH; + return false; + } + } + + // Validate format if provided + if (isset($attribute['format']) && $attribute['format'] !== '') { + if (!in_array($attribute['format'], $this->supportedFormats)) { + $this->message = "Invalid format for attribute '" . $attribute['key'] . "': " . $attribute['format']; + return false; + } + } + + // Validate required field if provided + if (isset($attribute['required']) && !is_bool($attribute['required'])) { + $this->message = "Invalid 'required' value for attribute '" . $attribute['key'] . "': must be a boolean"; + return false; + } + + // Validate array field if provided + if (isset($attribute['array']) && !is_bool($attribute['array'])) { + $this->message = "Invalid 'array' value for attribute '" . $attribute['key'] . "': must be a boolean"; + return false; + } + + // Validate signed field if provided + if (isset($attribute['signed']) && !is_bool($attribute['signed'])) { + $this->message = "Invalid 'signed' value for attribute '" . $attribute['key'] . "': must be a boolean"; + 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"; + return false; + } + + // Validate array and default conflict + if (isset($attribute['array']) && $attribute['array'] === true && isset($attribute['default']) && $attribute['default'] !== null) { + $this->message = "Attribute '" . $attribute['key'] . "' cannot have a default value when array is true"; + return false; + } + + // 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 relationship options + if ($attribute['type'] === Database::VAR_RELATIONSHIP) { + if (!isset($attribute['relatedCollection']) || 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, + Database::RELATION_MANY_TO_ONE, + Database::RELATION_MANY_TO_MANY, + ])) { + $this->message = "Relationship attribute '" . $attribute['key'] . "' must have valid 'relationType'"; + return false; + } + } + } + + return true; + } + + /** + * Is array + * + * @return bool + */ + public function isArray(): bool + { + return false; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return self::TYPE_ARRAY; + } +} diff --git a/src/Appwrite/Utopia/Database/Validator/Indexes.php b/src/Appwrite/Utopia/Database/Validator/Indexes.php new file mode 100644 index 0000000000..d60d53c764 --- /dev/null +++ b/src/Appwrite/Utopia/Database/Validator/Indexes.php @@ -0,0 +1,190 @@ + Supported index types + */ + protected array $supportedTypes = [ + Database::INDEX_KEY, + Database::INDEX_FULLTEXT, + Database::INDEX_UNIQUE, + Database::INDEX_SPATIAL, + ]; + + /** + * @var array Supported orders + */ + protected array $supportedOrders = [ + Database::ORDER_ASC, + Database::ORDER_DESC, + ]; + + /** + * @param int $maxIndexes Maximum number of indexes allowed + */ + public function __construct(int $maxIndexes = APP_LIMIT_ARRAY_PARAMS_SIZE) + { + $this->maxIndexes = $maxIndexes; + } + + /** + * Get Description + * + * Returns validator description + * + * @return string + */ + public function getDescription(): string + { + return $this->message; + } + + /** + * Is valid + * + * @param mixed $value + * @return bool + */ + public function isValid($value): bool + { + if (!is_array($value)) { + $this->message = 'Indexes must be an array'; + return false; + } + + if (count($value) > $this->maxIndexes) { + $this->message = 'Maximum of ' . $this->maxIndexes . ' indexes allowed'; + return false; + } + + $keyValidator = new Key(); + $keys = []; + + foreach ($value as $index => $indexDef) { + if (!is_array($indexDef)) { + $this->message = "Index at position $index must be an object"; + return false; + } + + // Validate required fields + if (!isset($indexDef['key'])) { + $this->message = "Index at position $index is missing required field 'key'"; + return false; + } + + if (!isset($indexDef['type'])) { + $this->message = "Index at position $index 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)"; + return false; + } + + // Validate key + if (!$keyValidator->isValid($indexDef['key'])) { + $this->message = "Invalid key for index at position $index: " . $keyValidator->getDescription(); + return false; + } + + // Check for duplicate keys + if (in_array($indexDef['key'], $keys)) { + $this->message = "Duplicate index key: " . $indexDef['key']; + return false; + } + $keys[] = $indexDef['key']; + + // Validate type + if (!in_array($indexDef['type'], $this->supportedTypes)) { + $this->message = "Invalid type for index '" . $indexDef['key'] . "': " . $indexDef['type']; + return false; + } + + // Validate attributes array + if (empty($indexDef['attributes'])) { + $this->message = "Index '" . $indexDef['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"; + return false; + } + + // Validate each attribute in the index + foreach ($indexDef['attributes'] as $attrIndex => $attr) { + if (!is_string($attr)) { + $this->message = "Invalid attribute at position $attrIndex in index '" . $indexDef['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'] . "'"; + 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"; + return false; + } + + foreach ($indexDef['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'"; + 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"; + return false; + } + + foreach ($indexDef['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"; + return false; + } + } + } + } + + return true; + } + + /** + * Is array + * + * @return bool + */ + public function isArray(): bool + { + return false; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return self::TYPE_ARRAY; + } +}