diff --git a/composer.lock b/composer.lock index 81cf8096c..f125acff0 100644 --- a/composer.lock +++ b/composer.lock @@ -410,16 +410,16 @@ }, { "name": "open-telemetry/api", - "version": "1.6.0", + "version": "1.7.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/api.git", - "reference": "ee17d937652eca06c2341b6fadc0f74c1c1a5af2" + "reference": "610b79ad9d6d97e8368bcb6c4d42394fbb87b522" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/ee17d937652eca06c2341b6fadc0f74c1c1a5af2", - "reference": "ee17d937652eca06c2341b6fadc0f74c1c1a5af2", + "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/610b79ad9d6d97e8368bcb6c4d42394fbb87b522", + "reference": "610b79ad9d6d97e8368bcb6c4d42394fbb87b522", "shasum": "" }, "require": { @@ -439,7 +439,7 @@ ] }, "branch-alias": { - "dev-main": "1.4.x-dev" + "dev-main": "1.7.x-dev" } }, "autoload": { @@ -476,7 +476,7 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-09-19T00:05:49+00:00" + "time": "2025-10-02T23:44:28+00:00" }, { "name": "open-telemetry/context", @@ -666,22 +666,22 @@ }, { "name": "open-telemetry/sdk", - "version": "1.8.0", + "version": "1.9.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/sdk.git", - "reference": "105c6e81e3d86150bd5704b00c7e4e165e957b89" + "reference": "8986bcbcbea79cb1ba9e91c1d621541ad63d6b3e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/105c6e81e3d86150bd5704b00c7e4e165e957b89", - "reference": "105c6e81e3d86150bd5704b00c7e4e165e957b89", + "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/8986bcbcbea79cb1ba9e91c1d621541ad63d6b3e", + "reference": "8986bcbcbea79cb1ba9e91c1d621541ad63d6b3e", "shasum": "" }, "require": { "ext-json": "*", "nyholm/psr7-server": "^1.1", - "open-telemetry/api": "^1.6", + "open-telemetry/api": "^1.7", "open-telemetry/context": "^1.4", "open-telemetry/sem-conv": "^1.0", "php": "^8.1", @@ -716,7 +716,7 @@ ] }, "branch-alias": { - "dev-main": "1.0.x-dev" + "dev-main": "1.9.x-dev" } }, "autoload": { @@ -759,7 +759,7 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-09-19T00:05:49+00:00" + "time": "2025-10-02T23:44:28+00:00" }, { "name": "open-telemetry/sem-conv", diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 25528ef5f..4a80ff751 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -14,6 +14,7 @@ use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Exception\Timeout as TimeoutException; use Utopia\Database\Exception\Transaction as TransactionException; +use Utopia\Database\Exception\Type as TypeException; use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; use Utopia\Mongo\Client; @@ -1655,15 +1656,19 @@ public function increaseDocumentAttribute(string $collection, string $id, string } $options = $this->getTransactionOptions(); - $this->client->update( - $this->getNamespace() . '_' . $this->filter($collection), - $filters, - [ - '$inc' => [$attribute => $value], - '$set' => ['_updatedAt' => $this->toMongoDatetime($updatedAt)], - ], - options: $options - ); + try { + $this->client->update( + $this->getNamespace() . '_' . $this->filter($collection), + $filters, + [ + '$inc' => [$attribute => $value], + '$set' => ['_updatedAt' => $this->toMongoDatetime($updatedAt)], + ], + options: $options + ); + } catch (MongoException $e) { + throw $this->processException($e); + } return true; } @@ -2323,7 +2328,18 @@ protected function buildFilter(Query $query): array $filter[$attribute]['$nin'] = $value; } elseif ($operator == '$in') { if ($query->getMethod() === Query::TYPE_CONTAINS && !$query->onArray()) { - $filter[$attribute]['$regex'] = $this->createSafeRegex($value, '.*%s.*'); + // contains support array values + if (is_array($value)) { + $filter['$or'] = array_map(function ($val) use ($attribute) { + return [ + $attribute => [ + '$regex' => $this->createSafeRegex($val, '.*%s.*', 'i') + ] + ]; + }, $value); + } else { + $filter[$attribute]['$regex'] = $this->createSafeRegex($value, '.*%s.*'); + } } else { $filter[$attribute]['$in'] = $query->getValues(); } @@ -2965,6 +2981,11 @@ protected function processException(Exception $e): \Exception return new TransactionException('No active transaction', $e->getCode(), $e); } + // Invalid operation(MongoDB error code 14) + if ($e->getCode() === 14) { + return new TypeException('Invalid operation', $e->getCode(), $e); + } + return $e; } diff --git a/src/Database/Database.php b/src/Database/Database.php index 0f89f9cc0..fdc646a96 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -3487,7 +3487,7 @@ public function getDocument(string $collection, string $id, array $queries = [], $this->checkQueriesType($queries); if ($this->validate) { - $validator = new DocumentValidator($attributes); + $validator = new DocumentValidator($attributes, $this->adapter->getSupportForAttributes()); if (!$validator->isValid($queries)) { throw new QueryException($validator->getDescription()); } @@ -5019,6 +5019,7 @@ public function updateDocuments( $this->maxQueryValues, $this->adapter->getMinDateTime(), $this->adapter->getMaxDateTime(), + $this->adapter->getSupportForAttributes() ); if (!$validator->isValid($queries)) { @@ -5892,24 +5893,25 @@ public function increaseDocumentAttribute( } $collection = $this->silent(fn () => $this->getCollection($collection)); + if ($this->adapter->getSupportForAttributes()) { + $attr = \array_filter($collection->getAttribute('attributes', []), function ($a) use ($attribute) { + return $a['$id'] === $attribute; + }); - $attr = \array_filter($collection->getAttribute('attributes', []), function ($a) use ($attribute) { - return $a['$id'] === $attribute; - }); - - if (empty($attr)) { - throw new NotFoundException('Attribute not found'); - } + if (empty($attr)) { + throw new NotFoundException('Attribute not found'); + } - $whiteList = [ - self::VAR_INTEGER, - self::VAR_FLOAT - ]; + $whiteList = [ + self::VAR_INTEGER, + self::VAR_FLOAT + ]; - /** @var Document $attr */ - $attr = \end($attr); - if (!\in_array($attr->getAttribute('type'), $whiteList) || $attr->getAttribute('array')) { - throw new TypeException('Attribute must be an integer or float and can not be an array.'); + /** @var Document $attr */ + $attr = \end($attr); + if (!\in_array($attr->getAttribute('type'), $whiteList) || $attr->getAttribute('array')) { + throw new TypeException('Attribute must be an integer or float and can not be an array.'); + } } $document = $this->withTransaction(function () use ($collection, $id, $attribute, $value, $max) { @@ -5990,25 +5992,27 @@ public function decreaseDocumentAttribute( $collection = $this->silent(fn () => $this->getCollection($collection)); - $attr = \array_filter($collection->getAttribute('attributes', []), function ($a) use ($attribute) { - return $a['$id'] === $attribute; - }); + if ($this->adapter->getSupportForAttributes()) { + $attr = \array_filter($collection->getAttribute('attributes', []), function ($a) use ($attribute) { + return $a['$id'] === $attribute; + }); - if (empty($attr)) { - throw new NotFoundException('Attribute not found'); - } + if (empty($attr)) { + throw new NotFoundException('Attribute not found'); + } - $whiteList = [ - self::VAR_INTEGER, - self::VAR_FLOAT - ]; + $whiteList = [ + self::VAR_INTEGER, + self::VAR_FLOAT + ]; - /** - * @var Document $attr - */ - $attr = \end($attr); - if (!\in_array($attr->getAttribute('type'), $whiteList) || $attr->getAttribute('array')) { - throw new TypeException('Attribute must be an integer or float and can not be an array.'); + /** + * @var Document $attr + */ + $attr = \end($attr); + if (!\in_array($attr->getAttribute('type'), $whiteList) || $attr->getAttribute('array')) { + throw new TypeException('Attribute must be an integer or float and can not be an array.'); + } } $document = $this->withTransaction(function () use ($collection, $id, $attribute, $value, $min) { @@ -6563,7 +6567,8 @@ public function deleteDocuments( $this->adapter->getIdAttributeType(), $this->maxQueryValues, $this->adapter->getMinDateTime(), - $this->adapter->getMaxDateTime() + $this->adapter->getMaxDateTime(), + $this->adapter->getSupportForAttributes() ); if (!$validator->isValid($queries)) { @@ -6992,6 +6997,7 @@ public function count(string $collection, array $queries = [], ?int $max = null) $this->maxQueryValues, $this->adapter->getMinDateTime(), $this->adapter->getMaxDateTime(), + $this->adapter->getSupportForAttributes() ); if (!$validator->isValid($queries)) { throw new QueryException($validator->getDescription()); @@ -7056,6 +7062,7 @@ public function sum(string $collection, string $attribute, array $queries = [], $this->maxQueryValues, $this->adapter->getMinDateTime(), $this->adapter->getMaxDateTime(), + $this->adapter->getSupportForAttributes() ); if (!$validator->isValid($queries)) { throw new QueryException($validator->getDescription()); @@ -7460,10 +7467,11 @@ private function validateSelections(Document $collection, array $queries): array $keys[] = $attribute['key'] ?? $attribute['$id']; } } - - $invalid = \array_diff($selections, $keys); - if (!empty($invalid) && !\in_array('*', $invalid)) { - throw new QueryException('Cannot select attributes: ' . \implode(', ', $invalid)); + if ($this->adapter->getSupportForAttributes()) { + $invalid = \array_diff($selections, $keys); + if (!empty($invalid) && !\in_array('*', $invalid)) { + throw new QueryException('Cannot select attributes: ' . \implode(', ', $invalid)); + } } $selections = \array_merge($selections, $relationshipSelections); diff --git a/src/Database/Validator/Index.php b/src/Database/Validator/Index.php index b63f71ab0..7c3da786c 100644 --- a/src/Database/Validator/Index.php +++ b/src/Database/Validator/Index.php @@ -232,6 +232,10 @@ public function checkIndexLength(Document $index): bool return true; } + if (!$this->supportForAttributes) { + return true; + } + $total = 0; $lengths = $index->getAttribute('lengths', []); $attributes = $index->getAttribute('attributes', []); diff --git a/src/Database/Validator/Queries/Document.php b/src/Database/Validator/Queries/Document.php index 41c9f3f9b..953e32bbf 100644 --- a/src/Database/Validator/Queries/Document.php +++ b/src/Database/Validator/Queries/Document.php @@ -11,9 +11,10 @@ class Document extends Queries { /** * @param array $attributes + * @param bool $supportForAttributes * @throws Exception */ - public function __construct(array $attributes) + public function __construct(array $attributes, $supportForAttributes = true) { $attributes[] = new \Utopia\Database\Document([ '$id' => '$id', @@ -35,7 +36,7 @@ public function __construct(array $attributes) ]); $validators = [ - new Select($attributes), + new Select($attributes, $supportForAttributes), ]; parent::__construct($validators); diff --git a/src/Database/Validator/Query/Filter.php b/src/Database/Validator/Query/Filter.php index 350b4877c..e2ef0962b 100644 --- a/src/Database/Validator/Query/Filter.php +++ b/src/Database/Validator/Query/Filter.php @@ -113,6 +113,11 @@ protected function isValidAttributeAndValues(string $attribute, array $values, s return false; } + if (!$this->supportForAttributes && !isset($this->schema[$attribute])) { + return true; + } + $attributeSchema = $this->schema[$attribute]; + $attributeType = $attributeSchema['type']; // If the query method is spatial-only, the attribute must be a spatial type diff --git a/tests/e2e/Adapter/Base.php b/tests/e2e/Adapter/Base.php index 37ad7cce3..7d24a8f33 100644 --- a/tests/e2e/Adapter/Base.php +++ b/tests/e2e/Adapter/Base.php @@ -10,6 +10,7 @@ use Tests\E2E\Adapter\Scopes\IndexTests; use Tests\E2E\Adapter\Scopes\PermissionTests; use Tests\E2E\Adapter\Scopes\RelationshipTests; +use Tests\E2E\Adapter\Scopes\SchemalessTests; use Tests\E2E\Adapter\Scopes\SpatialTests; use Utopia\Database\Database; use Utopia\Database\Validator\Authorization; @@ -25,6 +26,7 @@ abstract class Base extends TestCase use PermissionTests; use RelationshipTests; use SpatialTests; + use SchemalessTests; use GeneralTests; protected static string $namespace; diff --git a/tests/e2e/Adapter/Scopes/AttributeTests.php b/tests/e2e/Adapter/Scopes/AttributeTests.php index 53bdd54e7..8add6b1d8 100644 --- a/tests/e2e/Adapter/Scopes/AttributeTests.php +++ b/tests/e2e/Adapter/Scopes/AttributeTests.php @@ -1523,7 +1523,7 @@ public function testArrayAttribute(): void )); if ($database->getAdapter()->getSupportForIndexArray()) { - if ($database->getAdapter()->getMaxIndexLength() > 0) { + if ($database->getAdapter()->getSupportForAttributes() && $database->getAdapter()->getMaxIndexLength() > 0) { // If getMaxIndexLength() > 0 We clear length for array attributes $database->createIndex($collection, 'indx1', Database::INDEX_KEY, ['long_size'], [], []); $database->deleteIndex($collection, 'indx1'); diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index 6be6f0f77..14e3791ff 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -6164,106 +6164,6 @@ public function testCreateUpdateDocumentsMismatch(): void } $database->deleteCollection($colName); } - - public function testSchemalessDocumentOperation(): void - { - /** @var Database $database */ - $database = static::getDatabase(); - - if ($database->getAdapter()->getSupportForAttributes()) { - $this->expectNotToPerformAssertions(); - return; - } - - $colName = uniqid("schemaless"); - $database->createCollection($colName); - $database->createAttribute($colName, 'key', Database::VAR_STRING, 50, true); - $database->createAttribute($colName, 'value', Database::VAR_STRING, 50, false, 'value'); - - $permissions = [Permission::read(Role::any()), Permission::write(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any())]; - - // Valid documents without any predefined attributes - $docs = [ - new Document(['$id' => 'doc1', '$permissions' => $permissions, 'freeA' => 'doc1']), - new Document(['$id' => 'doc2', '$permissions' => $permissions, 'freeB' => 'test']), - new Document(['$id' => 'doc3', '$permissions' => $permissions]), - ]; - $this->assertEquals(3, $database->createDocuments($colName, $docs)); - - // Any extra attributes should be allowed (fully schemaless) - $docs = [ - new Document(['$id' => 'doc11', 'title' => 'doc1', '$permissions' => $permissions]), - new Document(['$id' => 'doc21', 'moviename' => 'doc2', 'moviedescription' => 'test', '$permissions' => $permissions]), - new Document(['$id' => 'doc31', '$permissions' => $permissions]), - ]; - - $createdDocs = $database->createDocuments($colName, $docs); - $this->assertEquals(3, $createdDocs); - - // Create a single document with extra attribute as well - $single = $database->createDocument($colName, new Document(['$id' => 'docS', 'extra' => 'yes', '$permissions' => $permissions])); - $this->assertEquals('docS', $single->getId()); - $this->assertEquals('yes', $single->getAttribute('extra')); - - $found = $database->find($colName); - $this->assertCount(7, $found); - $doc11 = $database->getDocument($colName, 'doc11'); - $this->assertEquals('doc1', $doc11->getAttribute('title')); - - $doc21 = $database->getDocument($colName, 'doc21'); - $this->assertEquals('doc2', $doc21->getAttribute('moviename')); - $this->assertEquals('test', $doc21->getAttribute('moviedescription')); - - $updated = $database->updateDocument($colName, 'doc31', new Document(['moviename' => 'updated'])) - ; - $this->assertEquals('updated', $updated->getAttribute('moviename')); - - $this->assertTrue($database->deleteDocument($colName, 'doc21')); - $deleted = $database->getDocument($colName, 'doc21'); - $this->assertTrue($deleted->isEmpty()); - $remaining = $database->find($colName); - $this->assertCount(6, $remaining); - - // Bulk update: set a new extra attribute on all remaining docs - $modified = $database->updateDocuments($colName, new Document(['bulkExtra' => 'yes'])); - $this->assertEquals(6, $modified); - $all = $database->find($colName); - foreach ($all as $doc) { - $this->assertEquals('yes', $doc->getAttribute('bulkExtra')); - } - - // Upsert: create new and update existing with extra attributes preserved - $upserts = [ - new Document(['$id' => 'docU1', 'extraU' => 1, '$permissions' => $permissions]), - new Document(['$id' => 'doc1', 'extraU' => 2, '$permissions' => $permissions]), - ]; - $countUpserts = $database->upsertDocuments($colName, $upserts); - $this->assertEquals(2, $countUpserts); - $docU1 = $database->getDocument($colName, 'docU1'); - $this->assertEquals(1, $docU1->getAttribute('extraU')); - $doc1AfterUpsert = $database->getDocument($colName, 'doc1'); - $this->assertEquals(2, $doc1AfterUpsert->getAttribute('extraU')); - - // Increase/Decrease numeric attribute: add numeric attribute and mutate it - $database->createAttribute($colName, 'counter', Database::VAR_INTEGER, 0, false, 0); - $docS = $database->getDocument($colName, 'docS'); - $this->assertEquals(0, $docS->getAttribute('counter')); - $docS = $database->increaseDocumentAttribute($colName, 'docS', 'counter', 5); - $this->assertEquals(5, $docS->getAttribute('counter')); - $docS = $database->decreaseDocumentAttribute($colName, 'docS', 'counter', 3); - $this->assertEquals(2, $docS->getAttribute('counter')); - - $deletedByCounter = $database->deleteDocuments($colName, [Query::equal('counter', [2])]); - $this->assertEquals(1, $deletedByCounter); - - $deletedCount = $database->deleteDocuments($colName, [Query::startsWith('$id', 'doc')]); - $this->assertEquals(6, $deletedCount); - $postDelete = $database->find($colName); - $this->assertCount(0, $postDelete); - - $database->deleteCollection($colName); - } - public function testDecodeWithDifferentSelectionTypes(): void { /** @var Database $database */ @@ -6274,7 +6174,6 @@ public function testDecodeWithDifferentSelectionTypes(): void return; } - if (!$database->getAdapter()->getSupportForSpatialAttributes()) { $this->expectNotToPerformAssertions(); return; @@ -6432,45 +6331,6 @@ function (mixed $value) { $database->deleteCollection($storesId); } - public function testSchemalessDocumentInvalidInteralAttributeValidation(): void - { - /** @var Database $database */ - $database = static::getDatabase(); - - // test to ensure internal attributes are checked during creating schemaless document - if ($database->getAdapter()->getSupportForAttributes()) { - $this->expectNotToPerformAssertions(); - return; - } - - $colName = uniqid("schemaless"); - $database->createCollection($colName); - try { - $docs = [ - new Document(['$id' => true, 'freeA' => 'doc1']), - new Document(['$id' => true, 'freeB' => 'test']), - new Document(['$id' => true]), - ]; - $database->createDocuments($colName, $docs); - } catch (\Throwable $e) { - $this->assertInstanceOf(StructureException::class, $e); - } - - try { - $docs = [ - new Document(['$createdAt' => true, 'freeA' => 'doc1']), - new Document(['$updatedAt' => true, 'freeB' => 'test']), - new Document(['$permissions' => 12]), - ]; - $database->createDocuments($colName, $docs); - } catch (\Throwable $e) { - $this->assertInstanceOf(StructureException::class, $e); - } - - $database->deleteCollection($colName); - - } - public function testDecodeWithoutRelationships(): void { /** @var Database $database */ @@ -6481,7 +6341,6 @@ public function testDecodeWithoutRelationships(): void return; } - $database->addFilter( 'encryptTest', function (mixed $value) { @@ -6573,42 +6432,11 @@ function (mixed $value) { $database->deleteCollection($collectionId); } - public function testSchemaEnforcedDocumentCreation(): void - { - /** @var Database $database */ - $database = static::getDatabase(); - - if (!$database->getAdapter()->getSupportForAttributes()) { - $this->expectNotToPerformAssertions(); - return; - } - - $colName = uniqid("schema"); - $database->createCollection($colName); - $database->createAttribute($colName, 'key', Database::VAR_STRING, 50, true); - $database->createAttribute($colName, 'value', Database::VAR_STRING, 50, false, 'value'); - - $permissions = [Permission::read(Role::any()), Permission::write(Role::any()), Permission::update(Role::any())]; - - // Extra attributes should fail - $docs = [ - new Document(['$id' => 'doc11', 'key' => 'doc1', 'title' => 'doc1', '$permissions' => $permissions]), - new Document(['$id' => 'doc21', 'key' => 'doc2', 'moviename' => 'doc2', 'moviedescription' => 'test', '$permissions' => $permissions]), - new Document(['$id' => 'doc31', 'key' => 'doc3', '$permissions' => $permissions]), - ]; - - $this->expectException(StructureException::class); - $database->createDocuments($colName, $docs); - - $database->deleteCollection($colName); - } - public function testDecodeWithMultipleFilters(): void { /** @var Database $database */ $database = static::getDatabase(); - $database->addFilter( 'upperCase', function (mixed $value) { return strtoupper($value); }, diff --git a/tests/e2e/Adapter/Scopes/IndexTests.php b/tests/e2e/Adapter/Scopes/IndexTests.php index 54a5f9df6..e3fefbd6b 100644 --- a/tests/e2e/Adapter/Scopes/IndexTests.php +++ b/tests/e2e/Adapter/Scopes/IndexTests.php @@ -175,19 +175,19 @@ public function testIndexValidation(): void $database->getAdapter()->getSupportForMultipleFulltextIndexes(), $database->getAdapter()->getSupportForIdenticalIndexes() ); - - $errorMessage = 'Index length 701 is larger than the size for title1: 700"'; - $this->assertFalse($validator->isValid($indexes[0])); - $this->assertEquals($errorMessage, $validator->getDescription()); - - try { - $database->createCollection($collection->getId(), $attributes, $indexes, [ - Permission::read(Role::any()), - Permission::create(Role::any()), - ]); - $this->fail('Failed to throw exception'); - } catch (Exception $e) { - $this->assertEquals($errorMessage, $e->getMessage()); + if ($database->getAdapter()->getSupportForIdenticalIndexes()) { + $errorMessage = 'Index length 701 is larger than the size for title1: 700"'; + $this->assertFalse($validator->isValid($indexes[0])); + $this->assertEquals($errorMessage, $validator->getDescription()); + try { + $database->createCollection($collection->getId(), $attributes, $indexes, [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ]); + $this->fail('Failed to throw exception'); + } catch (Exception $e) { + $this->assertEquals($errorMessage, $e->getMessage()); + } } $indexes = [ @@ -202,7 +202,7 @@ public function testIndexValidation(): void $collection->setAttribute('indexes', $indexes); - if ($database->getAdapter()->getMaxIndexLength() > 0) { + if ($database->getAdapter()->getSupportForAttributes() && $database->getAdapter()->getMaxIndexLength() > 0) { $errorMessage = 'Index length is longer than the maximum: ' . $database->getAdapter()->getMaxIndexLength(); $this->assertFalse($validator->isValid($indexes[0])); $this->assertEquals($errorMessage, $validator->getDescription()); @@ -244,6 +244,15 @@ public function testIndexValidation(): void 'indexes' => $indexes ]); + // not using $indexes[0] as the index validator skips indexes with same id + $newIndex = new Document([ + '$id' => ID::custom('newIndex1'), + 'type' => Database::INDEX_FULLTEXT, + 'attributes' => ['title1', 'integer'], + 'lengths' => [], + 'orders' => [], + ]); + $validator = new Index( $attributes, $indexes, @@ -258,7 +267,7 @@ public function testIndexValidation(): void $database->getAdapter()->getSupportForIdenticalIndexes() ); - $this->assertFalse($validator->isValid($indexes[0])); + $this->assertFalse($validator->isValid($newIndex)); if ($database->getAdapter()->getSupportForAttributes()) { $this->assertEquals('Attribute "integer" cannot be part of a fulltext index, must be of type string', $validator->getDescription()); @@ -285,37 +294,37 @@ public function testIndexValidation(): void 'orders' => [], ]), ]; + if ($database->getAdapter()->getSupportForAttributes()) { + $errorMessage = 'Negative index length provided for title1'; + $this->assertFalse($validator->isValid($indexes[0])); + $this->assertEquals($errorMessage, $validator->getDescription()); - $errorMessage = 'Negative index length provided for title1'; - $this->assertFalse($validator->isValid($indexes[0])); - $this->assertEquals($errorMessage, $validator->getDescription()); - - try { - $database->createCollection(ID::unique(), $attributes, $indexes); - $this->fail('Failed to throw exception'); - } catch (Exception $e) { - $this->assertEquals($errorMessage, $e->getMessage()); - } - - $indexes = [ - new Document([ - '$id' => ID::custom('index_extra_lengths'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['title1', 'title2'], - 'lengths' => [100, 100, 100], - 'orders' => [], - ]), - ]; + try { + $database->createCollection(ID::unique(), $attributes, $indexes); + $this->fail('Failed to throw exception'); + } catch (Exception $e) { + $this->assertEquals($errorMessage, $e->getMessage()); + } - $errorMessage = 'Invalid index lengths. Count of lengths must be equal or less than the number of attributes.'; - $this->assertFalse($validator->isValid($indexes[0])); - $this->assertEquals($errorMessage, $validator->getDescription()); + $indexes = [ + new Document([ + '$id' => ID::custom('index_extra_lengths'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['title1', 'title2'], + 'lengths' => [100, 100, 100], + 'orders' => [], + ]), + ]; + $errorMessage = 'Invalid index lengths. Count of lengths must be equal or less than the number of attributes.'; + $this->assertFalse($validator->isValid($indexes[0])); + $this->assertEquals($errorMessage, $validator->getDescription()); - try { - $database->createCollection(ID::unique(), $attributes, $indexes); - $this->fail('Failed to throw exception'); - } catch (Exception $e) { - $this->assertEquals($errorMessage, $e->getMessage()); + try { + $database->createCollection(ID::unique(), $attributes, $indexes); + $this->fail('Failed to throw exception'); + } catch (Exception $e) { + $this->assertEquals($errorMessage, $e->getMessage()); + } } } @@ -323,6 +332,10 @@ public function testIndexLengthZero(): void { /** @var Database $database */ $database = static::getDatabase(); + if (!$database->getAdapter()->getSupportForAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } $database->createCollection(__FUNCTION__); diff --git a/tests/e2e/Adapter/Scopes/SchemalessTests.php b/tests/e2e/Adapter/Scopes/SchemalessTests.php new file mode 100644 index 000000000..ee0985682 --- /dev/null +++ b/tests/e2e/Adapter/Scopes/SchemalessTests.php @@ -0,0 +1,1158 @@ +getAdapter()->getSupportForAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + $colName = uniqid('schemaless'); + $database->createCollection($colName); + $database->createAttribute($colName, 'key', Database::VAR_STRING, 50, true); + $database->createAttribute($colName, 'value', Database::VAR_STRING, 50, false, 'value'); + + $permissions = [Permission::read(Role::any()), Permission::write(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any())]; + + // Valid documents without any predefined attributes + $docs = [ + new Document(['$id' => 'doc1', '$permissions' => $permissions, 'freeA' => 'doc1']), + new Document(['$id' => 'doc2', '$permissions' => $permissions, 'freeB' => 'test']), + new Document(['$id' => 'doc3', '$permissions' => $permissions]), + ]; + $this->assertEquals(3, $database->createDocuments($colName, $docs)); + + // Any extra attributes should be allowed (fully schemaless) + $docs = [ + new Document(['$id' => 'doc11', 'title' => 'doc1', '$permissions' => $permissions]), + new Document(['$id' => 'doc21', 'moviename' => 'doc2', 'moviedescription' => 'test', '$permissions' => $permissions]), + new Document(['$id' => 'doc31', '$permissions' => $permissions]), + ]; + + $createdDocs = $database->createDocuments($colName, $docs); + $this->assertEquals(3, $createdDocs); + + // Create a single document with extra attribute as well + $single = $database->createDocument($colName, new Document(['$id' => 'docS', 'extra' => 'yes', '$permissions' => $permissions])); + $this->assertEquals('docS', $single->getId()); + $this->assertEquals('yes', $single->getAttribute('extra')); + + $found = $database->find($colName); + $this->assertCount(7, $found); + $doc11 = $database->getDocument($colName, 'doc11'); + $this->assertEquals('doc1', $doc11->getAttribute('title')); + + $doc21 = $database->getDocument($colName, 'doc21'); + $this->assertEquals('doc2', $doc21->getAttribute('moviename')); + $this->assertEquals('test', $doc21->getAttribute('moviedescription')); + + $updated = $database->updateDocument($colName, 'doc31', new Document(['moviename' => 'updated'])); + $this->assertEquals('updated', $updated->getAttribute('moviename')); + + $this->assertTrue($database->deleteDocument($colName, 'doc21')); + $deleted = $database->getDocument($colName, 'doc21'); + $this->assertTrue($deleted->isEmpty()); + $remaining = $database->find($colName); + $this->assertCount(6, $remaining); + + // Bulk update: set a new extra attribute on all remaining docs + $modified = $database->updateDocuments($colName, new Document(['bulkExtra' => 'yes'])); + $this->assertEquals(6, $modified); + $all = $database->find($colName); + foreach ($all as $doc) { + $this->assertEquals('yes', $doc->getAttribute('bulkExtra')); + } + + // Upsert: create new and update existing with extra attributes preserved + $upserts = [ + new Document(['$id' => 'docU1', 'extraU' => 1, '$permissions' => $permissions]), + new Document(['$id' => 'doc1', 'extraU' => 2, '$permissions' => $permissions]), + ]; + $countUpserts = $database->upsertDocuments($colName, $upserts); + $this->assertEquals(2, $countUpserts); + $docU1 = $database->getDocument($colName, 'docU1'); + $this->assertEquals(1, $docU1->getAttribute('extraU')); + $doc1AfterUpsert = $database->getDocument($colName, 'doc1'); + $this->assertEquals(2, $doc1AfterUpsert->getAttribute('extraU')); + + // Increase/Decrease numeric attribute: add numeric attribute and mutate it + $docS = $database->getDocument($colName, 'docS'); + $this->assertEquals(0, $docS->getAttribute('counter')); + $docS = $database->increaseDocumentAttribute($colName, 'docS', 'counter', 5); + $this->assertEquals(5, $docS->getAttribute('counter')); + $docS = $database->decreaseDocumentAttribute($colName, 'docS', 'counter', 3); + $this->assertEquals(2, $docS->getAttribute('counter')); + + $deletedByCounter = $database->deleteDocuments($colName, [Query::equal('counter', [2])]); + $this->assertEquals(1, $deletedByCounter); + + $deletedCount = $database->deleteDocuments($colName, [Query::startsWith('$id', 'doc')]); + $this->assertEquals(6, $deletedCount); + $postDelete = $database->find($colName); + $this->assertCount(0, $postDelete); + + $database->deleteCollection($colName); + } + + public function testSchemalessDocumentInvalidInteralAttributeValidation(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + // test to ensure internal attributes are checked during creating schemaless document + if ($database->getAdapter()->getSupportForAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + $colName = uniqid('schemaless'); + $database->createCollection($colName); + try { + $docs = [ + new Document(['$id' => true, 'freeA' => 'doc1']), + new Document(['$id' => true, 'freeB' => 'test']), + new Document(['$id' => true]), + ]; + $database->createDocuments($colName, $docs); + } catch (\Throwable $e) { + $this->assertInstanceOf(StructureException::class, $e); + } + + try { + $docs = [ + new Document(['$createdAt' => true, 'freeA' => 'doc1']), + new Document(['$updatedAt' => true, 'freeB' => 'test']), + new Document(['$permissions' => 12]), + ]; + $database->createDocuments($colName, $docs); + } catch (\Throwable $e) { + $this->assertInstanceOf(StructureException::class, $e); + } + + $database->deleteCollection($colName); + + } + + public function testSchemalessSelectionOnUnknownAttributes(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if ($database->getAdapter()->getSupportForAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + $colName = uniqid('schemaless'); + $database->createCollection($colName); + $permissions = [Permission::read(Role::any()), Permission::write(Role::any()), Permission::update(Role::any())]; + $docs = [ + new Document(['$id' => 'doc1', '$permissions' => $permissions, 'freeA' => 'doc1']), + new Document(['$id' => 'doc2', '$permissions' => $permissions, 'freeB' => 'test']), + new Document(['$id' => 'doc3', '$permissions' => $permissions]), + ]; + $this->assertEquals(3, $database->createDocuments($colName, $docs)); + + $docA = $database->getDocument($colName, 'doc1', [Query::select(['freeA'])]); + $this->assertEquals('doc1', $docA->getAttribute('freeA')); + + $docC = $database->getDocument($colName, 'doc1', [Query::select(['freeC'])]); + $this->assertNull($docC->getAttribute('freeC')); + + $docs = $database->find($colName, [Query::equal('$id', ['doc1','doc2']),Query::select(['freeC'])]); + foreach ($docs as $doc) { + $this->assertNull($doc->getAttribute('freeC')); + // since not selected + $this->assertArrayNotHasKey('freeA', $doc->getAttributes()); + $this->assertArrayNotHasKey('freeB', $doc->getAttributes()); + } + + $docA = $database->find($colName, [ + Query::equal('$id', ['doc1']), + Query::select(['freeA']) + ]); + $this->assertEquals('doc1', $docA[0]->getAttribute('freeA')); + + $docC = $database->find($colName, [ + Query::equal('$id', ['doc1']), + Query::select(['freeC']) + ]); + $this->assertArrayNotHasKey('freeC', $docC[0]->getAttributes()); + } + + public function testSchemalessIncrement(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if ($database->getAdapter()->getSupportForAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + $colName = uniqid("schemaless_increment"); + $database->createCollection($colName); + + $permissions = [ + Permission::read(Role::any()), + Permission::write(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()) + ]; + + $docs = [ + new Document(['$id' => 'doc1', '$permissions' => $permissions, 'counter' => 10, 'score' => 5.5]), + new Document(['$id' => 'doc2', '$permissions' => $permissions, 'counter' => 20, 'points' => 100]), + new Document(['$id' => 'doc3', '$permissions' => $permissions, 'value' => 0]), + ]; + $this->assertEquals(3, $database->createDocuments($colName, $docs)); + + $doc1 = $database->increaseDocumentAttribute($colName, 'doc1', 'counter', 5); + $this->assertEquals(15, $doc1->getAttribute('counter')); + $this->assertEquals(5.5, $doc1->getAttribute('score')); + + $doc1 = $database->increaseDocumentAttribute($colName, 'doc1', 'score', 2.3); + $this->assertEquals(7.8, $doc1->getAttribute('score')); + + $doc2 = $database->increaseDocumentAttribute($colName, 'doc2', 'points', 50); + $this->assertEquals(150, $doc2->getAttribute('points')); + + $doc3 = $database->increaseDocumentAttribute($colName, 'doc3', 'newCounter', 1); + $this->assertEquals(1, $doc3->getAttribute('newCounter')); + $this->assertEquals(0, $doc3->getAttribute('value')); + + try { + $database->increaseDocumentAttribute($colName, 'doc1', 'counter', 10, 20); + $this->assertEquals(20, $database->getDocument($colName, 'doc1')->getAttribute('counter')); + } catch (\Exception $e) { + $this->assertInstanceOf(LimitException::class, $e); + } + + $allDocs = $database->find($colName); + $this->assertCount(3, $allDocs); + + $database->deleteCollection($colName); + } + + public function testSchemalessDecrement(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if ($database->getAdapter()->getSupportForAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + $colName = uniqid("schemaless_decrement"); + $database->createCollection($colName); + + $permissions = [ + Permission::read(Role::any()), + Permission::write(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()) + ]; + + $docs = [ + new Document(['$id' => 'doc1', '$permissions' => $permissions, 'counter' => 100, 'balance' => 250.75]), + new Document(['$id' => 'doc2', '$permissions' => $permissions, 'score' => 50, 'extraData' => 'preserved']), + ]; + $this->assertEquals(2, $database->createDocuments($colName, $docs)); + + $doc1 = $database->decreaseDocumentAttribute($colName, 'doc1', 'counter', 25); + $this->assertEquals(75, $doc1->getAttribute('counter')); + $this->assertEquals(250.75, $doc1->getAttribute('balance')); + + $doc1 = $database->decreaseDocumentAttribute($colName, 'doc1', 'balance', 50.25); + $this->assertEquals(200.5, $doc1->getAttribute('balance')); + + $doc2 = $database->decreaseDocumentAttribute($colName, 'doc2', 'score', 15); + $this->assertEquals(35, $doc2->getAttribute('score')); + $this->assertEquals('preserved', $doc2->getAttribute('extraData')); + + try { + $database->decreaseDocumentAttribute($colName, 'doc2', 'score', 40, 0); + $this->fail('Expected LimitException not thrown'); + } catch (\Exception $e) { + $this->assertInstanceOf(LimitException::class, $e); + } + + $doc2 = $database->decreaseDocumentAttribute($colName, 'doc2', 'score', 50); + $this->assertEquals(-15, $doc2->getAttribute('score')); + + $retrievedDoc1 = $database->getDocument($colName, 'doc1'); + $this->assertEquals(75, $retrievedDoc1->getAttribute('counter')); + $this->assertEquals(200.5, $retrievedDoc1->getAttribute('balance')); + + $database->deleteCollection($colName); + } + + public function testSchemalessUpdateDocumentWithQuery(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if ($database->getAdapter()->getSupportForAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + $colName = uniqid("schemaless_update"); + $database->createCollection($colName); + + $permissions = [ + Permission::read(Role::any()), + Permission::write(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()) + ]; + + $docs = [ + new Document(['$id' => 'doc1', '$permissions' => $permissions, 'type' => 'user', 'status' => 'active', 'score' => 100]), + new Document(['$id' => 'doc2', '$permissions' => $permissions, 'type' => 'admin', 'status' => 'active', 'level' => 5]), + new Document(['$id' => 'doc3', '$permissions' => $permissions, 'type' => 'user', 'status' => 'inactive', 'score' => 50]), + new Document(['$id' => 'doc4', '$permissions' => $permissions, 'type' => 'user', 'status' => 'pending', 'newField' => 'test']), + ]; + $this->assertEquals(4, $database->createDocuments($colName, $docs)); + + $updatedDoc = $database->updateDocument($colName, 'doc1', new Document([ + 'status' => 'updated', + 'lastModified' => '2023-01-01', + 'newAttribute' => 'added' + ])); + + $this->assertEquals('updated', $updatedDoc->getAttribute('status')); + $this->assertEquals('2023-01-01', $updatedDoc->getAttribute('lastModified')); + $this->assertEquals('added', $updatedDoc->getAttribute('newAttribute')); + $this->assertEquals('user', $updatedDoc->getAttribute('type')); // Existing attributes preserved + $this->assertEquals(100, $updatedDoc->getAttribute('score')); + + $retrievedDoc = $database->getDocument($colName, 'doc1'); + $this->assertEquals('updated', $retrievedDoc->getAttribute('status')); + $this->assertEquals('added', $retrievedDoc->getAttribute('newAttribute')); + + $updatedDoc2 = $database->updateDocument($colName, 'doc2', new Document([ + 'customField1' => 'value1', + 'customField2' => 42, + 'customField3' => ['array', 'of', 'values'] + ])); + + $this->assertEquals('value1', $updatedDoc2->getAttribute('customField1')); + $this->assertEquals(42, $updatedDoc2->getAttribute('customField2')); + $this->assertEquals(['array', 'of', 'values'], $updatedDoc2->getAttribute('customField3')); + $this->assertEquals('admin', $updatedDoc2->getAttribute('type')); // Original attributes preserved + + $database->deleteCollection($colName); + } + + public function testSchemalessDeleteDocumentWithQuery(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if ($database->getAdapter()->getSupportForAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + $colName = uniqid("schemaless_delete"); + $database->createCollection($colName); + + $permissions = [ + Permission::read(Role::any()), + Permission::write(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()) + ]; + + $docs = [ + new Document(['$id' => 'doc1', '$permissions' => $permissions, 'category' => 'temp', 'priority' => 1]), + new Document(['$id' => 'doc2', '$permissions' => $permissions, 'category' => 'permanent', 'priority' => 5]), + new Document(['$id' => 'doc3', '$permissions' => $permissions, 'category' => 'temp', 'priority' => 3]), + ]; + $this->assertEquals(3, $database->createDocuments($colName, $docs)); + + $result = $database->deleteDocument($colName, 'doc1'); + $this->assertTrue($result); + + $deletedDoc = $database->getDocument($colName, 'doc1'); + $this->assertTrue($deletedDoc->isEmpty()); + + $remainingDocs = $database->find($colName); + $this->assertCount(2, $remainingDocs); + + $tempDocs = $database->find($colName, [Query::equal('category', ['temp'])]); + $this->assertCount(1, $tempDocs); + $this->assertEquals('doc3', $tempDocs[0]->getId()); + + $database->deleteCollection($colName); + } + + public function testSchemalessUpdateDocumentsWithQuery(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if ($database->getAdapter()->getSupportForAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + if (!$database->getAdapter()->getSupportForBatchOperations()) { + $this->expectNotToPerformAssertions(); + return; + } + + $colName = uniqid("schemaless_bulk_update"); + $database->createCollection($colName); + + $permissions = [ + Permission::read(Role::any()), + Permission::write(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()) + ]; + + $docs = []; + for ($i = 1; $i <= 10; $i++) { + $docs[] = new Document([ + '$id' => "doc{$i}", + '$permissions' => $permissions, + 'type' => $i <= 5 ? 'typeA' : 'typeB', + 'status' => 'pending', + 'score' => $i * 10, + 'customField' => "value{$i}" + ]); + } + $this->assertEquals(10, $database->createDocuments($colName, $docs)); + + $updatedCount = $database->updateDocuments($colName, new Document([ + 'status' => 'processed', + 'processedAt' => '2023-01-01', + 'newBulkField' => 'bulk_value' + ]), [Query::equal('type', ['typeA'])]); + + $this->assertEquals(5, $updatedCount); + + $processedDocs = $database->find($colName, [Query::equal('status', ['processed'])]); + $this->assertCount(5, $processedDocs); + + foreach ($processedDocs as $doc) { + $this->assertEquals('typeA', $doc->getAttribute('type')); + $this->assertEquals('processed', $doc->getAttribute('status')); + $this->assertEquals('2023-01-01', $doc->getAttribute('processedAt')); + $this->assertEquals('bulk_value', $doc->getAttribute('newBulkField')); + $this->assertNotNull($doc->getAttribute('score')); + $this->assertNotNull($doc->getAttribute('customField')); + } + + $pendingDocs = $database->find($colName, [Query::equal('status', ['pending'])]); + $this->assertCount(5, $pendingDocs); + + foreach ($pendingDocs as $doc) { + $this->assertEquals('typeB', $doc->getAttribute('type')); + $this->assertEquals('pending', $doc->getAttribute('status')); + $this->assertNull($doc->getAttribute('processedAt')); + $this->assertNull($doc->getAttribute('newBulkField')); + } + + $highScoreCount = $database->updateDocuments($colName, new Document([ + 'tier' => 'premium' + ]), [Query::greaterThan('score', 70)]); + + $this->assertEquals(3, $highScoreCount); // docs 8, 9, 10 + + $premiumDocs = $database->find($colName, [Query::equal('tier', ['premium'])]); + $this->assertCount(3, $premiumDocs); + + $allUpdateCount = $database->updateDocuments($colName, new Document([ + 'globalFlag' => true, + 'lastUpdate' => '2023-12-31' + ])); + + $this->assertEquals(10, $allUpdateCount); + + $allDocs = $database->find($colName); + $this->assertCount(10, $allDocs); + + foreach ($allDocs as $doc) { + $this->assertTrue($doc->getAttribute('globalFlag')); + $this->assertEquals('2023-12-31', $doc->getAttribute('lastUpdate')); + } + + $database->deleteCollection($colName); + } + + public function testSchemalessDeleteDocumentsWithQuery(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if ($database->getAdapter()->getSupportForAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + if (!$database->getAdapter()->getSupportForBatchOperations()) { + $this->expectNotToPerformAssertions(); + return; + } + + $colName = uniqid("schemaless_bulk_delete"); + $database->createCollection($colName); + + $permissions = [ + Permission::read(Role::any()), + Permission::write(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()) + ]; + + $docs = []; + for ($i = 1; $i <= 15; $i++) { + $docs[] = new Document([ + '$id' => "doc{$i}", + '$permissions' => $permissions, + 'category' => $i <= 5 ? 'temp' : ($i <= 10 ? 'archive' : 'active'), + 'priority' => $i % 3, // 0, 1, or 2 + 'score' => $i * 5, + 'tags' => ["tag{$i}", 'common'], + 'metadata' => ['created' => "2023-01-{$i}"] + ]); + } + $this->assertEquals(15, $database->createDocuments($colName, $docs)); + + $deletedCount = $database->deleteDocuments($colName, [Query::equal('category', ['temp'])]); + $this->assertEquals(5, $deletedCount); + + $remainingDocs = $database->find($colName); + $this->assertCount(10, $remainingDocs); + + $tempDocs = $database->find($colName, [Query::equal('category', ['temp'])]); + $this->assertCount(0, $tempDocs); + + $highScoreDeleted = $database->deleteDocuments($colName, [Query::greaterThan('score', 50)]); + $this->assertEquals(5, $highScoreDeleted); // docs 11-15 + + $remainingAfterScore = $database->find($colName); + $this->assertCount(5, $remainingAfterScore); // docs 6-10 remain + + foreach ($remainingAfterScore as $doc) { + $this->assertLessThanOrEqual(50, $doc->getAttribute('score')); + $this->assertEquals('archive', $doc->getAttribute('category')); + } + + $multiConditionDeleted = $database->deleteDocuments($colName, [ + Query::equal('category', ['archive']), + Query::equal('priority', [1]) + ]); + $this->assertEquals(2, $multiConditionDeleted); // docs 7 and 10 + + $finalRemaining = $database->find($colName); + $this->assertCount(3, $finalRemaining); // docs 6, 8, 9 + + foreach ($finalRemaining as $doc) { + $this->assertEquals('archive', $doc->getAttribute('category')); + $this->assertNotEquals(1, $doc->getAttribute('priority')); + } + + $allDeleted = $database->deleteDocuments($colName); + $this->assertEquals(3, $allDeleted); + + $emptyResult = $database->find($colName); + $this->assertCount(0, $emptyResult); + + $database->deleteCollection($colName); + } + + public function testSchemalessOperationsWithCallback(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if ($database->getAdapter()->getSupportForAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + if (!$database->getAdapter()->getSupportForBatchOperations()) { + $this->expectNotToPerformAssertions(); + return; + } + + $colName = uniqid("schemaless_callbacks"); + $database->createCollection($colName); + + $permissions = [ + Permission::read(Role::any()), + Permission::write(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()) + ]; + + $docs = []; + for ($i = 1; $i <= 8; $i++) { + $docs[] = new Document([ + '$id' => "doc{$i}", + '$permissions' => $permissions, + 'group' => $i <= 4 ? 'A' : 'B', + 'value' => $i * 10, + 'customData' => "data{$i}" + ]); + } + $this->assertEquals(8, $database->createDocuments($colName, $docs)); + + $updateResults = []; + $updateCount = $database->updateDocuments( + $colName, + new Document(['processed' => true, 'timestamp' => '2023-01-01']), + [Query::equal('group', ['A'])], + onNext: function ($doc) use (&$updateResults) { + $updateResults[] = $doc->getId(); + } + ); + + $this->assertEquals(4, $updateCount); + $this->assertCount(4, $updateResults); + $this->assertContains('doc1', $updateResults); + $this->assertContains('doc2', $updateResults); + $this->assertContains('doc3', $updateResults); + $this->assertContains('doc4', $updateResults); + + $processedDocs = $database->find($colName, [Query::equal('processed', [true])]); + $this->assertCount(4, $processedDocs); + + $deleteResults = []; + $deleteCount = $database->deleteDocuments( + $colName, + [Query::greaterThan('value', 50)], + onNext: function ($doc) use (&$deleteResults) { + $deleteResults[] = [ + 'id' => $doc->getId(), + 'value' => $doc->getAttribute('value'), + 'customData' => $doc->getAttribute('customData') + ]; + } + ); + + $this->assertEquals(3, $deleteCount); // docs 6, 7, 8 + $this->assertCount(3, $deleteResults); + + foreach ($deleteResults as $result) { + $this->assertGreaterThan(50, $result['value']); + $this->assertStringStartsWith('data', $result['customData']); + } + + $remainingDocs = $database->find($colName); + $this->assertCount(5, $remainingDocs); // docs 1-5 + + foreach ($remainingDocs as $doc) { + $this->assertLessThanOrEqual(50, $doc->getAttribute('value')); + } + + $database->deleteCollection($colName); + } + + public function testSchemalessIndexCreateListDelete(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if ($database->getAdapter()->getSupportForAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + // Schemaless adapter still supports defining attributes/indexes metadata + $col = uniqid('sl_idx'); + $database->createCollection($col); + + $database->createDocument($col, new Document([ + '$id' => 'a', + '$permissions' => [Permission::read(Role::any())], + 'title' => 't1', + 'rank' => 1, + ])); + $database->createDocument($col, new Document([ + '$id' => 'b', + '$permissions' => [Permission::read(Role::any())], + 'title' => 't2', + 'rank' => 2, + ])); + + $this->assertTrue($database->createIndex($col, 'idx_title_unique', Database::INDEX_UNIQUE, ['title'], [128], [Database::ORDER_ASC])); + $this->assertTrue($database->createIndex($col, 'idx_rank_key', Database::INDEX_KEY, ['rank'], [0], [Database::ORDER_ASC])); + + $collection = $database->getCollection($col); + $indexes = $collection->getAttribute('indexes'); + $this->assertCount(2, $indexes); + $ids = array_map(fn ($i) => $i['$id'], $indexes); + $this->assertContains('idx_rank_key', $ids); + $this->assertContains('idx_title_unique', $ids); + + $this->assertTrue($database->deleteIndex($col, 'idx_rank_key')); + $collection = $database->getCollection($col); + $this->assertCount(1, $collection->getAttribute('indexes')); + $this->assertEquals('idx_title_unique', $collection->getAttribute('indexes')[0]['$id']); + + $this->assertTrue($database->deleteIndex($col, 'idx_title_unique')); + $database->deleteCollection($col); + } + + public function testSchemalessIndexDuplicatePrevention(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if ($database->getAdapter()->getSupportForAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + $col = uniqid('sl_idx_dup'); + $database->createCollection($col); + + $database->createDocument($col, new Document([ + '$id' => 'a', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'x' + ])); + + $this->assertTrue($database->createIndex($col, 'duplicate', Database::INDEX_KEY, ['name'], [0], [Database::ORDER_ASC])); + + try { + $database->createIndex($col, 'duplicate', Database::INDEX_KEY, ['name'], [0], [Database::ORDER_ASC]); + $this->fail('Failed to throw exception'); + } catch (Exception $e) { + $this->assertInstanceOf(DuplicateException::class, $e); + } + + $database->deleteCollection($col); + } + + public function testSchemalessPermissions(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if ($database->getAdapter()->getSupportForAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + $col = uniqid('sl_perms'); + $database->createCollection($col); + + // Create with permissive read only + $doc = $database->createDocument($col, new Document([ + '$id' => 'd1', + '$permissions' => [ + Permission::read(Role::any()) + ], + 'field' => 'value' + ])); + + $this->assertFalse($doc->isEmpty()); + + // Without roles, cannot read + Authorization::cleanRoles(); + $empty = $database->getDocument($col, 'd1'); + $this->assertTrue($empty->isEmpty()); + + // With any role, can read + Authorization::setRole(Role::any()->toString()); + $fetched = $database->getDocument($col, 'd1'); + $this->assertEquals('value', $fetched->getAttribute('field')); + + // Attempt update without update permission + Authorization::cleanRoles(); + Authorization::setRole(Role::any()->toString()); + try { + $database->updateDocument($col, 'd1', new Document(['field' => 'updated'])); + $this->fail('Failed to throw exception'); + } catch (Exception $e) { + $this->assertInstanceOf(AuthorizationException::class, $e); + } + + // Grant update permission and update + Authorization::skip(function () use ($database, $col) { + $database->updateDocument($col, 'd1', new Document([ + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ] + ])); + }); + + $updated = $database->updateDocument($col, 'd1', new Document(['field' => 'updated'])); + $this->assertEquals('updated', $updated->getAttribute('field')); + + // Creating without any roles should fail + Authorization::cleanRoles(); + try { + $database->createDocument($col, new Document([ + 'field' => 'x' + ])); + $this->fail('Failed to throw exception'); + } catch (Exception $e) { + $this->assertInstanceOf(AuthorizationException::class, $e); + } + + $database->deleteCollection($col); + Authorization::cleanRoles(); + } + + public function testSchemalessInternalAttributes(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if ($database->getAdapter()->getSupportForAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + $col = uniqid('sl_internal_full'); + $database->createCollection($col); + + Authorization::setRole(Role::any()->toString()); + + $doc = $database->createDocument($col, new Document([ + '$id' => 'i1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'alpha', + ])); + + $this->assertEquals('i1', $doc->getId()); + $this->assertEquals($col, $doc->getCollection()); + $this->assertNotEmpty($doc->getSequence()); + $this->assertNotEmpty($doc->getAttribute('$createdAt')); + $this->assertNotEmpty($doc->getAttribute('$updatedAt')); + $perms = $doc->getPermissions(); + $this->assertGreaterThanOrEqual(1, count($perms)); + $this->assertContains(Permission::read(Role::any()), $perms); + $this->assertContains(Permission::update(Role::any()), $perms); + $this->assertContains(Permission::delete(Role::any()), $perms); + + $selected = $database->getDocument($col, 'i1', [ + Query::select(['name', '$id', '$sequence', '$collection', '$createdAt', '$updatedAt', '$permissions']) + ]); + $this->assertEquals('alpha', $selected->getAttribute('name')); + $this->assertArrayHasKey('$id', $selected); + $this->assertArrayHasKey('$sequence', $selected); + $this->assertArrayHasKey('$collection', $selected); + $this->assertArrayHasKey('$createdAt', $selected); + $this->assertArrayHasKey('$updatedAt', $selected); + $this->assertArrayHasKey('$permissions', $selected); + + $found = $database->find($col, [ + Query::equal('$id', ['i1']), + Query::select(['$id', '$sequence', '$collection', '$createdAt', '$updatedAt', '$permissions']) + ]); + $this->assertCount(1, $found); + $this->assertArrayHasKey('$id', $found[0]); + $this->assertArrayHasKey('$sequence', $found[0]); + $this->assertArrayHasKey('$collection', $found[0]); + $this->assertArrayHasKey('$createdAt', $found[0]); + $this->assertArrayHasKey('$updatedAt', $found[0]); + $this->assertArrayHasKey('$permissions', $found[0]); + + $seq = $doc->getSequence(); + $bySeq = $database->find($col, [Query::equal('$sequence', [$seq])]); + $this->assertCount(1, $bySeq); + $this->assertEquals('i1', $bySeq[0]->getId()); + + $createdAtBefore = $doc->getAttribute('$createdAt'); + $updatedAtBefore = $doc->getAttribute('$updatedAt'); + $updated = $database->updateDocument($col, 'i1', new Document(['name' => 'beta'])); + $this->assertEquals('beta', $updated->getAttribute('name')); + $this->assertEquals($createdAtBefore, $updated->getAttribute('$createdAt')); + $this->assertNotEquals($updatedAtBefore, $updated->getAttribute('$updatedAt')); + + $changed = $database->updateDocument($col, 'i1', new Document(['$id' => 'i1-new'])); + $this->assertEquals('i1-new', $changed->getId()); + $refetched = $database->getDocument($col, 'i1-new'); + $this->assertEquals('i1-new', $refetched->getId()); + + try { + $database->updateDocument($col, 'i1-new', new Document(['$permissions' => 'invalid'])); + $this->fail('Failed to throw exception'); + } catch (Throwable $e) { + $this->assertTrue($e instanceof StructureException); + } + + $database->setPreserveDates(true); + $customCreated = '2000-01-01T00:00:00.000+00:00'; + $customUpdated = '2000-01-02T00:00:00.000+00:00'; + $d2 = $database->createDocument($col, new Document([ + '$id' => 'i2', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + '$createdAt' => $customCreated, + '$updatedAt' => $customUpdated, + 'v' => 1 + ])); + $this->assertEquals($customCreated, $d2->getAttribute('$createdAt')); + $this->assertEquals($customUpdated, $d2->getAttribute('$updatedAt')); + + $newUpdated = '2000-01-03T00:00:00.000+00:00'; + $d2u = $database->updateDocument($col, 'i2', new Document([ + 'v' => 2, + '$updatedAt' => $newUpdated + ])); + $this->assertEquals($customCreated, $d2u->getAttribute('$createdAt')); + $this->assertEquals($newUpdated, $d2u->getAttribute('$updatedAt')); + $database->setPreserveDates(false); + + $database->deleteCollection($col); + Authorization::cleanRoles(); + } + + public function testSchemalessDates(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if ($database->getAdapter()->getSupportForAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + $col = uniqid('sl_dates'); + $database->createCollection($col); + + $permissions = [ + Permission::read(Role::any()), + Permission::write(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()) + ]; + + // Seed deterministic date strings + $createdAt1 = '2000-01-01T10:00:00.000+00:00'; + $updatedAt1 = '2000-01-02T11:11:11.000+00:00'; + $curDate1 = '2000-01-05T05:05:05.000+00:00'; + + // createDocument with preserved dates + $doc1 = $database->withPreserveDates(function () use ($database, $col, $permissions, $createdAt1, $updatedAt1, $curDate1) { + return $database->createDocument($col, new Document([ + '$id' => 'd1', + '$permissions' => $permissions, + '$createdAt' => $createdAt1, + '$updatedAt' => $updatedAt1, + 'curDate' => $curDate1, + 'counter' => 0, + ])); + }); + + $this->assertEquals('d1', $doc1->getId()); + $this->assertTrue(is_string($doc1->getAttribute('curDate'))); + $this->assertEquals($curDate1, $doc1->getAttribute('curDate')); + $this->assertTrue(is_string($doc1->getAttribute('$createdAt'))); + $this->assertTrue(is_string($doc1->getAttribute('$updatedAt'))); + $this->assertEquals($createdAt1, $doc1->getAttribute('$createdAt')); + $this->assertEquals($updatedAt1, $doc1->getAttribute('$updatedAt')); + + $fetched1 = $database->getDocument($col, 'd1'); + $this->assertEquals($curDate1, $fetched1->getAttribute('curDate')); + $this->assertTrue(is_string($fetched1->getAttribute('curDate'))); + $this->assertTrue(is_string($fetched1->getAttribute('$createdAt'))); + $this->assertTrue(is_string($fetched1->getAttribute('$updatedAt'))); + $this->assertEquals($createdAt1, $fetched1->getAttribute('$createdAt')); + $this->assertEquals($updatedAt1, $fetched1->getAttribute('$updatedAt')); + + // createDocuments with preserved dates + $createdAt2 = '2001-02-03T04:05:06.000+00:00'; + $updatedAt2 = '2001-02-04T04:05:07.000+00:00'; + $curDate2 = '2001-02-05T06:07:08.000+00:00'; + + $createdAt3 = '2002-03-04T05:06:07.000+00:00'; + $updatedAt3 = '2002-03-05T05:06:08.000+00:00'; + $curDate3 = '2002-03-06T07:08:09.000+00:00'; + + $countCreated = $database->withPreserveDates(function () use ($database, $col, $permissions, $createdAt2, $updatedAt2, $curDate2, $createdAt3, $updatedAt3, $curDate3) { + return $database->createDocuments($col, [ + new Document([ + '$id' => 'd2', + '$permissions' => $permissions, + '$createdAt' => $createdAt2, + '$updatedAt' => $updatedAt2, + 'curDate' => $curDate2, + ]), + new Document([ + '$id' => 'd3', + '$permissions' => $permissions, + '$createdAt' => $createdAt3, + '$updatedAt' => $updatedAt3, + 'curDate' => $curDate3, + ]), + ]); + }); + $this->assertEquals(2, $countCreated); + + $fetched2 = $database->getDocument($col, 'd2'); + $this->assertEquals($curDate2, $fetched2->getAttribute('curDate')); + $this->assertEquals($createdAt2, $fetched2->getAttribute('$createdAt')); + $this->assertEquals($updatedAt2, $fetched2->getAttribute('$updatedAt')); + + $fetched3 = $database->getDocument($col, 'd3'); + $this->assertEquals($curDate3, $fetched3->getAttribute('curDate')); + $this->assertEquals($createdAt3, $fetched3->getAttribute('$createdAt')); + $this->assertEquals($updatedAt3, $fetched3->getAttribute('$updatedAt')); + + // updateDocument with preserved $updatedAt and custom date field + $newCurDate1 = '2000-02-01T00:00:00.000+00:00'; + $newUpdatedAt1 = '2000-02-02T02:02:02.000+00:00'; + $updated1 = $database->withPreserveDates(function () use ($database, $col, $newCurDate1, $newUpdatedAt1) { + return $database->updateDocument($col, 'd1', new Document([ + 'curDate' => $newCurDate1, + '$updatedAt' => $newUpdatedAt1, + ])); + }); + $this->assertEquals($newCurDate1, $updated1->getAttribute('curDate')); + $this->assertEquals($newUpdatedAt1, $updated1->getAttribute('$updatedAt')); + $refetched1 = $database->getDocument($col, 'd1'); + $this->assertEquals($newCurDate1, $refetched1->getAttribute('curDate')); + $this->assertEquals($newUpdatedAt1, $refetched1->getAttribute('$updatedAt')); + + // updateDocuments with preserved $updatedAt over a subset + $bulkCurDate = '2001-01-01T00:00:00.000+00:00'; + $bulkUpdatedAt = '2001-01-02T00:00:00.000+00:00'; + $updatedCount = $database->withPreserveDates(function () use ($database, $col, $bulkCurDate, $bulkUpdatedAt) { + return $database->updateDocuments( + $col, + new Document([ + 'curDate' => $bulkCurDate, + '$updatedAt' => $bulkUpdatedAt, + ]), + [Query::equal('$id', ['d2', 'd3'])] + ); + }); + $this->assertEquals(2, $updatedCount); + $afterBulk2 = $database->getDocument($col, 'd2'); + $afterBulk3 = $database->getDocument($col, 'd3'); + $this->assertEquals($bulkCurDate, $afterBulk2->getAttribute('curDate')); + $this->assertEquals($bulkUpdatedAt, $afterBulk2->getAttribute('$updatedAt')); + $this->assertEquals($bulkCurDate, $afterBulk3->getAttribute('curDate')); + $this->assertEquals($bulkUpdatedAt, $afterBulk3->getAttribute('$updatedAt')); + + // upsertDocument: create new then update existing with preserved dates + $createdAt4 = '2003-03-03T03:03:03.000+00:00'; + $updatedAt4 = '2003-03-04T04:04:04.000+00:00'; + $curDate4 = '2003-03-05T05:05:05.000+00:00'; + $up1 = $database->withPreserveDates(function () use ($database, $col, $permissions, $createdAt4, $updatedAt4, $curDate4) { + return $database->upsertDocument($col, new Document([ + '$id' => 'd4', + '$permissions' => $permissions, + '$createdAt' => $createdAt4, + '$updatedAt' => $updatedAt4, + 'curDate' => $curDate4, + ])); + }); + $this->assertEquals('d4', $up1->getId()); + $this->assertEquals($curDate4, $up1->getAttribute('curDate')); + $this->assertEquals($createdAt4, $up1->getAttribute('$createdAt')); + $this->assertEquals($updatedAt4, $up1->getAttribute('$updatedAt')); + + $updatedAt4b = '2003-03-06T06:06:06.000+00:00'; + $curDate4b = '2003-03-07T07:07:07.000+00:00'; + $up2 = $database->withPreserveDates(function () use ($database, $col, $updatedAt4b, $curDate4b) { + return $database->upsertDocument($col, new Document([ + '$id' => 'd4', + 'curDate' => $curDate4b, + '$updatedAt' => $updatedAt4b, + ])); + }); + $this->assertEquals($curDate4b, $up2->getAttribute('curDate')); + $this->assertEquals($updatedAt4b, $up2->getAttribute('$updatedAt')); + $refetched4 = $database->getDocument($col, 'd4'); + $this->assertEquals($curDate4b, $refetched4->getAttribute('curDate')); + $this->assertEquals($updatedAt4b, $refetched4->getAttribute('$updatedAt')); + + // upsertDocuments: mix create and update with preserved dates + $createdAt5 = '2004-04-01T01:01:01.000+00:00'; + $updatedAt5 = '2004-04-02T02:02:02.000+00:00'; + $curDate5 = '2004-04-03T03:03:03.000+00:00'; + $updatedAt2b = '2001-02-08T08:08:08.000+00:00'; + $curDate2b = '2001-02-09T09:09:09.000+00:00'; + + $upCount = $database->withPreserveDates(function () use ($database, $col, $permissions, $createdAt5, $updatedAt5, $curDate5, $updatedAt2b, $curDate2b) { + return $database->upsertDocuments($col, [ + new Document([ + '$id' => 'd5', + '$permissions' => $permissions, + '$createdAt' => $createdAt5, + '$updatedAt' => $updatedAt5, + 'curDate' => $curDate5, + ]), + new Document([ + '$id' => 'd2', + '$updatedAt' => $updatedAt2b, + 'curDate' => $curDate2b, + ]), + ]); + }); + $this->assertEquals(2, $upCount); + + $fetched5 = $database->getDocument($col, 'd5'); + $this->assertEquals($curDate5, $fetched5->getAttribute('curDate')); + $this->assertEquals($createdAt5, $fetched5->getAttribute('$createdAt')); + $this->assertEquals($updatedAt5, $fetched5->getAttribute('$updatedAt')); + + $fetched2b = $database->getDocument($col, 'd2'); + $this->assertEquals($curDate2b, $fetched2b->getAttribute('curDate')); + $this->assertEquals($updatedAt2b, $fetched2b->getAttribute('$updatedAt')); + + // increase/decrease should not affect date types; ensure they remain strings + $afterInc = $database->increaseDocumentAttribute($col, 'd1', 'counter', 5); + $this->assertEquals(5, $afterInc->getAttribute('counter')); + $this->assertTrue(is_string($afterInc->getAttribute('curDate'))); + $this->assertTrue(is_string($afterInc->getAttribute('$createdAt'))); + $this->assertTrue(is_string($afterInc->getAttribute('$updatedAt'))); + + $afterIncFetched = $database->getDocument($col, 'd1'); + $this->assertEquals(5, $afterIncFetched->getAttribute('counter')); + $this->assertTrue(is_string($afterIncFetched->getAttribute('curDate'))); + $this->assertTrue(is_string($afterIncFetched->getAttribute('$createdAt'))); + $this->assertTrue(is_string($afterIncFetched->getAttribute('$updatedAt'))); + + $afterDec = $database->decreaseDocumentAttribute($col, 'd1', 'counter', 2); + $this->assertEquals(3, $afterDec->getAttribute('counter')); + $this->assertTrue(is_string($afterDec->getAttribute('curDate'))); + $this->assertTrue(is_string($afterDec->getAttribute('$createdAt'))); + $this->assertTrue(is_string($afterDec->getAttribute('$updatedAt'))); + + $afterDecFetched = $database->getDocument($col, 'd1'); + $this->assertEquals(3, $afterDecFetched->getAttribute('counter')); + $this->assertTrue(is_string($afterDecFetched->getAttribute('curDate'))); + $this->assertTrue(is_string($afterDecFetched->getAttribute('$createdAt'))); + $this->assertTrue(is_string($afterDecFetched->getAttribute('$updatedAt'))); + + $database->deleteCollection($col); + } +} diff --git a/tests/unit/Validator/IndexTest.php b/tests/unit/Validator/IndexTest.php index 9e544c6a6..987297ae2 100644 --- a/tests/unit/Validator/IndexTest.php +++ b/tests/unit/Validator/IndexTest.php @@ -349,4 +349,45 @@ public function testReservedIndexKey(): void $index = $collection->getAttribute('indexes')[0]; $this->assertFalse($validator->isValid($index)); } + + /** + * @throws Exception + */ + public function testIndexWithNoAttributeSupport(): void + { + $collection = new Document([ + '$id' => ID::custom('test'), + 'name' => 'test', + 'attributes' => [ + new Document([ + '$id' => ID::custom('title'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 769, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ]), + ], + 'indexes' => [ + new Document([ + '$id' => ID::custom('index1'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['new'], + 'lengths' => [], + 'orders' => [], + ]), + ], + ]); + + $validator = new Index(attributes: $collection->getAttribute('attributes'), indexes: $collection->getAttribute('indexes'), maxLength: 768); + $index = $collection->getAttribute('indexes')[0]; + $this->assertFalse($validator->isValid($index)); + + $validator = new Index(attributes: $collection->getAttribute('attributes'), indexes: $collection->getAttribute('indexes'), maxLength: 768, supportForAttributes: false); + $index = $collection->getAttribute('indexes')[0]; + $this->assertTrue($validator->isValid($index)); + } }