From 5f637cbd2beee66c3a4318b36a9d5996557b1301 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Jan 2026 17:57:45 +0530 Subject: [PATCH 1/6] feat: add support for TTL indexes in database adapters and validation --- src/Database/Adapter.php | 13 +- src/Database/Adapter/MariaDB.php | 2 +- src/Database/Adapter/Mongo.php | 27 +- src/Database/Adapter/Pool.php | 2 +- src/Database/Adapter/Postgres.php | 7 +- src/Database/Adapter/SQLite.php | 5 +- src/Database/Database.php | 10 +- src/Database/Mirror.php | 7 +- src/Database/Validator/Index.php | 50 ++- tests/e2e/Adapter/Scopes/SchemalessTests.php | 324 +++++++++++++++++++ tests/unit/Validator/IndexTest.php | 179 ++++++++++ 11 files changed, 611 insertions(+), 15 deletions(-) diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index 49a33e403..603c18be5 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -666,10 +666,12 @@ abstract public function renameIndex(string $collection, string $old, string $ne * @param array $lengths * @param array $orders * @param array $indexAttributeTypes + * @param array $collation + * @param int $ttl * * @return bool */ - abstract public function createIndex(string $collection, string $id, string $type, array $attributes, array $lengths, array $orders, array $indexAttributeTypes = []): bool; + abstract public function createIndex(string $collection, string $id, string $type, array $attributes, array $lengths, array $orders, array $indexAttributeTypes = [], array $collation = [], int $ttl = 0): bool; /** * Delete Index @@ -1483,4 +1485,13 @@ public function getSupportForRegex(): bool { return $this->getSupportForPCRERegex() || $this->getSupportForPOSIXRegex(); } + + /** + * Are ttl indexes supported? + * + * @return bool + */ + public function getSupportTTLIndexes(): bool { + return false; + } } diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index b8110b039..ef44171c6 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -715,7 +715,7 @@ public function renameIndex(string $collection, string $old, string $new): bool * @return bool * @throws DatabaseException */ - public function createIndex(string $collection, string $id, string $type, array $attributes, array $lengths, array $orders, array $indexAttributeTypes = []): bool + public function createIndex(string $collection, string $id, string $type, array $attributes, array $lengths, array $orders, array $indexAttributeTypes = [], array $collation = [], int $ttl = 0): bool { $metadataCollection = new Document(['$id' => Database::METADATA]); $collection = $this->getDocument($metadataCollection, $collection); diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index fdcdd80c2..e3441f92a 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -508,6 +508,9 @@ public function createCollection(string $name, array $attributes = [], array $in $order = $this->getOrder($this->filter($orders[$j] ?? Database::ORDER_ASC)); $unique = true; break; + case Database::INDEX_TTL: + $order = $this->getOrder($this->filter($orders[$j] ?? Database::ORDER_ASC)); + break; default: // index not supported return false; @@ -526,6 +529,14 @@ public function createCollection(string $name, array $attributes = [], array $in $newIndexes[$i]['default_language'] = 'none'; } + // Handle TTL indexes + if ($index->getAttribute('type') === Database::INDEX_TTL) { + $ttl = $index->getAttribute('ttl', 0); + if ($ttl > 0) { + $newIndexes[$i]['expireAfterSeconds'] = $ttl; + } + } + // Add partial filter for indexes to avoid indexing null values if (in_array($index->getAttribute('type'), [ Database::INDEX_UNIQUE, @@ -901,10 +912,11 @@ public function deleteRelationship( * @param array $orders * @param array $indexAttributeTypes * @param array $collation + * @param int $ttl * @return bool * @throws Exception */ - public function createIndex(string $collection, string $id, string $type, array $attributes, array $lengths, array $orders, array $indexAttributeTypes = [], array $collation = []): bool + public function createIndex(string $collection, string $id, string $type, array $attributes, array $lengths, array $orders, array $indexAttributeTypes = [], array $collation = [], int $ttl = 0): bool { $name = $this->getNamespace() . '_' . $this->filter($collection); $id = $this->filter($id); @@ -933,6 +945,8 @@ public function createIndex(string $collection, string $id, string $type, array case Database::INDEX_UNIQUE: $indexes['unique'] = true; break; + case Database::INDEX_TTL: + break; default: return false; } @@ -961,6 +975,11 @@ public function createIndex(string $collection, string $id, string $type, array $indexes['default_language'] = 'none'; } + // Handle TTL indexes + if ($type === Database::INDEX_TTL && $ttl > 0) { + $indexes['expireAfterSeconds'] = $ttl; + } + // Add partial filter for indexes to avoid indexing null values if (in_array($type, [Database::INDEX_UNIQUE, Database::INDEX_KEY])) { $partialFilter = []; @@ -1073,7 +1092,7 @@ public function renameIndex(string $collection, string $old, string $new): bool try { $deletedindex = $this->deleteIndex($collection, $old); - $createdindex = $this->createIndex($collection, $new, $index['type'], $index['attributes'], $index['lengths'] ?? [], $index['orders'] ?? [], $indexAttributeTypes); + $createdindex = $this->createIndex($collection, $new, $index['type'], $index['attributes'], $index['lengths'] ?? [], $index['orders'] ?? [], $indexAttributeTypes, [], $index['ttl'] ?? 0); } catch (\Exception $e) { throw $this->processException($e); } @@ -3395,4 +3414,8 @@ public function getSupportForTrigramIndex(): bool { return false; } + + public function getSupportTTLIndexes(): bool { + return true; + } } diff --git a/src/Database/Adapter/Pool.php b/src/Database/Adapter/Pool.php index d70a836ea..e6bf77681 100644 --- a/src/Database/Adapter/Pool.php +++ b/src/Database/Adapter/Pool.php @@ -210,7 +210,7 @@ public function renameIndex(string $collection, string $old, string $new): bool return $this->delegate(__FUNCTION__, \func_get_args()); } - public function createIndex(string $collection, string $id, string $type, array $attributes, array $lengths, array $orders, array $indexAttributeTypes = []): bool + public function createIndex(string $collection, string $id, string $type, array $attributes, array $lengths, array $orders, array $indexAttributeTypes = [], array $collation = [], int $ttl = 0): bool { return $this->delegate(__FUNCTION__, \func_get_args()); } diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 050180a0a..38c3c3a26 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -319,6 +319,7 @@ public function createCollection(string $name, array $attributes = [], array $in } } $indexOrders = $index->getAttribute('orders', []); + $indexTtl = $index->getAttribute('ttl', 0); if ($indexType === Database::INDEX_SPATIAL && count($indexOrders)) { throw new DatabaseException('Spatial indexes with explicit orders are not supported. Remove the orders to create this index.'); } @@ -329,7 +330,9 @@ public function createCollection(string $name, array $attributes = [], array $in $indexAttributes, [], $indexOrders, - $indexAttributesWithType + $indexAttributesWithType, + [], + $indexTtl ); } } catch (PDOException $e) { @@ -876,7 +879,7 @@ public function deleteRelationship( * @return bool */ - public function createIndex(string $collection, string $id, string $type, array $attributes, array $lengths, array $orders, array $indexAttributeTypes = []): bool + public function createIndex(string $collection, string $id, string $type, array $attributes, array $lengths, array $orders, array $indexAttributeTypes = [], array $collation = [], int $ttl = 0): bool { $collection = $this->filter($collection); $id = $this->filter($id); diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index 948070654..538b93a68 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -216,8 +216,9 @@ public function createCollection(string $name, array $attributes = [], array $in $indexAttributes = $index->getAttribute('attributes', []); $indexLengths = $index->getAttribute('lengths', []); $indexOrders = $index->getAttribute('orders', []); + $indexTtl = $index->getAttribute('ttl', 0); - $this->createIndex($id, $indexId, $indexType, $indexAttributes, $indexLengths, $indexOrders); + $this->createIndex($id, $indexId, $indexType, $indexAttributes, $indexLengths, $indexOrders, [], [], $indexTtl); } $this->createIndex("{$id}_perms", '_index_1', Database::INDEX_UNIQUE, ['_document', '_type', '_permission'], [], []); @@ -455,7 +456,7 @@ public function renameIndex(string $collection, string $old, string $new): bool * @throws Exception * @throws PDOException */ - public function createIndex(string $collection, string $id, string $type, array $attributes, array $lengths, array $orders, array $indexAttributeTypes = []): bool + public function createIndex(string $collection, string $id, string $type, array $attributes, array $lengths, array $orders, array $indexAttributeTypes = [], array $collation = [], int $ttl = 0): bool { $name = $this->filter($collection); $id = $this->filter($id); diff --git a/src/Database/Database.php b/src/Database/Database.php index a2bc2da55..f12795bf7 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -86,6 +86,7 @@ class Database public const INDEX_HNSW_COSINE = 'hnsw_cosine'; public const INDEX_HNSW_DOT = 'hnsw_dot'; public const INDEX_TRIGRAM = 'trigram'; + public const INDEX_TTL = 'ttl'; // Max limits public const MAX_INT = 2147483647; @@ -1648,6 +1649,7 @@ public function createCollection(string $id, array $attributes = [], array $inde $this->adapter->getSupportForIndex(), $this->adapter->getSupportForUniqueIndex(), $this->adapter->getSupportForFulltextIndex(), + $this->adapter->getSupportTTLIndexes() ); foreach ($indexes as $index) { if (!$validator->isValid($index)) { @@ -2798,6 +2800,7 @@ public function updateAttribute(string $collection, string $id, ?string $type = $this->adapter->getSupportForIndex(), $this->adapter->getSupportForUniqueIndex(), $this->adapter->getSupportForFulltextIndex(), + $this->adapter->getSupportTTLIndexes() ); foreach ($indexes as $index) { @@ -3603,6 +3606,7 @@ public function renameIndex(string $collection, string $old, string $new): bool * @param array $attributes * @param array $lengths * @param array $orders + * @param int $ttl * * @return bool * @throws AuthorizationException @@ -3613,7 +3617,7 @@ public function renameIndex(string $collection, string $old, string $new): bool * @throws StructureException * @throws Exception */ - public function createIndex(string $collection, string $id, string $type, array $attributes, array $lengths = [], array $orders = []): bool + public function createIndex(string $collection, string $id, string $type, array $attributes, array $lengths = [], array $orders = [], int $ttl = 0): bool { if (empty($attributes)) { throw new DatabaseException('Missing attributes'); @@ -3671,6 +3675,7 @@ public function createIndex(string $collection, string $id, string $type, array 'attributes' => $attributes, 'lengths' => $lengths, 'orders' => $orders, + 'ttl' => $ttl ]); if ($this->validate) { @@ -3693,6 +3698,7 @@ public function createIndex(string $collection, string $id, string $type, array $this->adapter->getSupportForIndex(), $this->adapter->getSupportForUniqueIndex(), $this->adapter->getSupportForFulltextIndex(), + $this->adapter->getSupportTTLIndexes() ); if (!$validator->isValid($index)) { throw new IndexException($validator->getDescription()); @@ -3702,7 +3708,7 @@ public function createIndex(string $collection, string $id, string $type, array $collection->setAttribute('indexes', $index, Document::SET_TYPE_APPEND); try { - $created = $this->adapter->createIndex($collection->getId(), $id, $type, $attributes, $lengths, $orders, $indexAttributesWithTypes); + $created = $this->adapter->createIndex($collection->getId(), $id, $type, $attributes, $lengths, $orders, $indexAttributesWithTypes, [], $ttl); if (!$created) { throw new DatabaseException('Failed to create index'); diff --git a/src/Database/Mirror.php b/src/Database/Mirror.php index d9a6d09df..3c65a88a9 100644 --- a/src/Database/Mirror.php +++ b/src/Database/Mirror.php @@ -469,9 +469,9 @@ public function deleteAttribute(string $collection, string $id): bool return $result; } - public function createIndex(string $collection, string $id, string $type, array $attributes, array $lengths = [], array $orders = []): bool + public function createIndex(string $collection, string $id, string $type, array $attributes, array $lengths = [], array $orders = [], int $ttl = 0): bool { - $result = $this->source->createIndex($collection, $id, $type, $attributes, $lengths, $orders); + $result = $this->source->createIndex($collection, $id, $type, $attributes, $lengths, $orders, $ttl); if ($this->destination === null) { return $result; @@ -502,7 +502,8 @@ public function createIndex(string $collection, string $id, string $type, array $document->getAttribute('type'), $document->getAttribute('attributes'), $document->getAttribute('lengths'), - $document->getAttribute('orders') + $document->getAttribute('orders'), + $document->getAttribute('ttl', 0) ); } catch (\Throwable $err) { $this->logError('createIndex', $err); diff --git a/src/Database/Validator/Index.php b/src/Database/Validator/Index.php index e2fc70a0b..243cde156 100644 --- a/src/Database/Validator/Index.php +++ b/src/Database/Validator/Index.php @@ -54,6 +54,7 @@ public function __construct( protected bool $supportForKeyIndexes = true, protected bool $supportForUniqueIndexes = true, protected bool $supportForFulltextIndexes = true, + protected bool $supportForTTLIndexes = false, ) { foreach ($attributes as $attribute) { $key = \strtolower($attribute->getAttribute('key', $attribute->getAttribute('$id'))); @@ -156,6 +157,9 @@ public function isValid($value): bool if (!$this->checkKeyUniqueFulltextSupport($value)) { return false; } + if (!$this->checkTTLIndexes($value)) { + return false; + } return true; } @@ -222,6 +226,13 @@ public function checkValidIndex(Document $index): bool } break; + case Database::INDEX_TTL: + if (!$this->supportForTTLIndexes) { + $this->message = 'TTL indexes are not supported'; + return false; + } + break; + default: $this->message = 'Unknown index type: ' . $type . '. Must be one of ' . Database::INDEX_KEY . ', ' . Database::INDEX_UNIQUE . ', ' . Database::INDEX_FULLTEXT . ', ' . Database::INDEX_SPATIAL . ', ' . Database::INDEX_OBJECT . ', ' . Database::INDEX_HNSW_EUCLIDEAN . ', ' . Database::INDEX_HNSW_COSINE . ', ' . Database::INDEX_HNSW_DOT . ', '.Database::INDEX_TRIGRAM; return false; @@ -673,7 +684,7 @@ public function checkIdenticalIndexes(Document $index): bool if ($attributesMatch && $ordersMatch) { // Allow fulltext + key/unique combinations (different purposes) - $regularTypes = [Database::INDEX_KEY, Database::INDEX_UNIQUE]; + $regularTypes = [Database::INDEX_KEY, Database::INDEX_UNIQUE, Database::INDEX_TTL]; $isRegularIndex = \in_array($indexType, $regularTypes); $isRegularExisting = \in_array($existingType, $regularTypes); @@ -729,4 +740,41 @@ public function checkObjectIndexes(Document $index): bool return true; } + + public function checkTTLIndexes(Document $index): bool{ + $type = $index->getAttribute('type'); + + $attributes = $index->getAttribute('attributes', []); + $orders = $index->getAttribute('orders', []); + $ttl = $index->getAttribute('ttl', 0); + if ($type !== Database::INDEX_TTL) { + return true; + } + + if (count($attributes) !== 1) { + $this->message = 'TTL index can be created on a single object attribute'; + return false; + } + + if (empty($orders)) { + $this->message = 'TTL index need explicit orders. Add the orders to create this index.'; + return false; + } + + $attributeName = $attributes[0] ?? ''; + $attribute = $this->attributes[\strtolower($attributeName)] ?? new Document(); + $attributeType = $attribute->getAttribute('type', ''); + + if ($attributeType !== Database::VAR_DATETIME) { + $this->message = 'Object index can only be created on datetime attributes. Attribute "' . $attributeName . '" is of type "' . $attributeType . '"'; + return false; + } + + if($ttl <= 0){ + $this->message = 'TTL must be atleast 1 second'; + return false; + } + + return true; + } } diff --git a/tests/e2e/Adapter/Scopes/SchemalessTests.php b/tests/e2e/Adapter/Scopes/SchemalessTests.php index 856d08263..3c5b0a76a 100644 --- a/tests/e2e/Adapter/Scopes/SchemalessTests.php +++ b/tests/e2e/Adapter/Scopes/SchemalessTests.php @@ -6,6 +6,7 @@ use Throwable; use Utopia\Database\Database; use Utopia\Database\Document; +use Utopia\Database\Exception as DatabaseException; use Utopia\Database\Exception\Authorization as AuthorizationException; use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Exception\Limit as LimitException; @@ -1865,4 +1866,327 @@ public function testSchemalessNestedObjectAttributeQueries(): void $database->deleteCollection($col); } + + public function testSchemalessTTLIndexes(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + // Only run for MongoDB adapter which supports TTL indexes + if ($database->getAdapter()->getSupportForAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + $col = uniqid('sl_ttl'); + $database->createCollection($col); + + // Create datetime attribute for TTL index + $database->createAttribute($col, 'expiresAt', Database::VAR_DATETIME, 0, false); + + $permissions = [ + Permission::read(Role::any()), + Permission::write(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()) + ]; + + // Test 1: Create valid TTL index with valid TTL value + $this->assertTrue( + $database->createIndex( + $col, + 'idx_ttl_valid', + Database::INDEX_TTL, + ['expiresAt'], + [], + [Database::ORDER_ASC], + 3600 // 1 hour TTL + ) + ); + + // Verify index was created and stored in metadata + $collection = $database->getCollection($col); + $indexes = $collection->getAttribute('indexes'); + $this->assertCount(1, $indexes); + $ttlIndex = $indexes[0]; + $this->assertEquals('idx_ttl_valid', $ttlIndex->getId()); + $this->assertEquals(Database::INDEX_TTL, $ttlIndex->getAttribute('type')); + $this->assertEquals(3600, $ttlIndex->getAttribute('ttl')); + + // Test 2: Create documents with expiresAt field + $now = new \DateTime(); + $future1 = (clone $now)->modify('+2 hours'); + $future2 = (clone $now)->modify('+1 hour'); + $past = (clone $now)->modify('-1 hour'); + + $doc1 = $database->createDocument($col, new Document([ + '$id' => 'doc1', + '$permissions' => $permissions, + 'expiresAt' => $future1->format(\DateTime::ATOM), + 'data' => 'will expire in 2 hours' + ])); + + $doc2 = $database->createDocument($col, new Document([ + '$id' => 'doc2', + '$permissions' => $permissions, + 'expiresAt' => $future2->format(\DateTime::ATOM), + 'data' => 'will expire in 1 hour' + ])); + + $doc3 = $database->createDocument($col, new Document([ + '$id' => 'doc3', + '$permissions' => $permissions, + 'expiresAt' => $past->format(\DateTime::ATOM), + 'data' => 'already expired' + ])); + + // Verify documents were created + $this->assertEquals('doc1', $doc1->getId()); + $this->assertEquals('doc2', $doc2->getId()); + $this->assertEquals('doc3', $doc3->getId()); + + // Test 3: Delete the first TTL index and create a new one with minimum valid TTL (1 second) + // MongoDB only allows one TTL index per attribute, so we need to delete the existing one first + $this->assertTrue($database->deleteIndex($col, 'idx_ttl_valid')); + + $this->assertTrue( + $database->createIndex( + $col, + 'idx_ttl_min', + Database::INDEX_TTL, + ['expiresAt'], + [], + [Database::ORDER_ASC], + 1 // Minimum TTL + ) + ); + + // Test 4: Try to create TTL index with TTL = 0 (should fail) + try { + $database->createIndex( + $col, + 'idx_ttl_zero', + Database::INDEX_TTL, + ['expiresAt'], + [], + [Database::ORDER_ASC], + 0 + ); + $this->fail('Expected exception for TTL = 0'); + } catch (Exception $e) { + $this->assertInstanceOf(DatabaseException::class, $e); + $this->assertStringContainsString('TTL must be atleast 1 second', $e->getMessage()); + } + + // Test 5: Try to create TTL index with negative TTL (should fail) + try { + $database->createIndex( + $col, + 'idx_ttl_negative', + Database::INDEX_TTL, + ['expiresAt'], + [], + [Database::ORDER_ASC], + -100 + ); + $this->fail('Expected exception for negative TTL'); + } catch (Exception $e) { + $this->assertInstanceOf(DatabaseException::class, $e); + $this->assertStringContainsString('TTL must be atleast 1 second', $e->getMessage()); + } + + // Test 6: Create TTL index via createCollection with indexes parameter + $col2 = uniqid('sl_ttl_collection'); + + // Create attribute document for the collection + $expiresAtAttr = new Document([ + '$id' => ID::custom('expiresAt'), + 'type' => Database::VAR_DATETIME, + 'size' => 0, + 'signed' => false, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => ['datetime'], + ]); + + $ttlIndexDoc = new Document([ + '$id' => ID::custom('idx_ttl_collection'), + 'type' => Database::INDEX_TTL, + 'attributes' => ['expiresAt'], + 'lengths' => [], + 'orders' => [Database::ORDER_ASC], + 'ttl' => 7200 // 2 hours + ]); + + $database->createCollection($col2, [$expiresAtAttr], [$ttlIndexDoc]); + + // Verify TTL index was created via createCollection + $collection2 = $database->getCollection($col2); + $indexes2 = $collection2->getAttribute('indexes'); + $this->assertCount(1, $indexes2); + $ttlIndex2 = $indexes2[0]; + $this->assertEquals('idx_ttl_collection', $ttlIndex2->getId()); + $this->assertEquals(7200, $ttlIndex2->getAttribute('ttl')); + + // Cleanup + $database->deleteCollection($col); + $database->deleteCollection($col2); + } + + public function testSchemalessTTLIndexDuplicatePrevention(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + // Only run for MongoDB adapter which supports TTL indexes + if ($database->getAdapter()->getSupportForAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + $col = uniqid('sl_ttl_dup'); + $database->createCollection($col); + + // Create datetime attribute for TTL index + $database->createAttribute($col, 'expiresAt', Database::VAR_DATETIME, 0, false); + $database->createAttribute($col, 'deletedAt', Database::VAR_DATETIME, 0, false); + + // Test 1: Create first TTL index on expiresAt + $this->assertTrue( + $database->createIndex( + $col, + 'idx_ttl_expires', + Database::INDEX_TTL, + ['expiresAt'], + [], + [Database::ORDER_ASC], + 3600 // 1 hour + ) + ); + + // Test 2: Try to create another TTL index on the same attribute (should fail) + try { + $database->createIndex( + $col, + 'idx_ttl_expires_duplicate', + Database::INDEX_TTL, + ['expiresAt'], + [], + [Database::ORDER_ASC], + 7200 // 2 hours + ); + $this->fail('Expected exception for duplicate TTL index on same attribute'); + } catch (Exception $e) { + $this->assertInstanceOf(DatabaseException::class, $e); + $this->assertStringContainsString('A TTL index already exists on attribute', $e->getMessage()); + } + + // Test 3: Create TTL index on different attribute (should succeed) + $this->assertTrue( + $database->createIndex( + $col, + 'idx_ttl_deleted', + Database::INDEX_TTL, + ['deletedAt'], + [], + [Database::ORDER_ASC], + 86400 // 24 hours + ) + ); + + // Verify both indexes exist + $collection = $database->getCollection($col); + $indexes = $collection->getAttribute('indexes'); + $this->assertCount(2, $indexes); + + $indexIds = array_map(fn ($idx) => $idx->getId(), $indexes); + $this->assertContains('idx_ttl_expires', $indexIds); + $this->assertContains('idx_ttl_deleted', $indexIds); + + // Test 4: Try to create another TTL index on deletedAt (should fail) + try { + $database->createIndex( + $col, + 'idx_ttl_deleted_duplicate', + Database::INDEX_TTL, + ['deletedAt'], + [], + [Database::ORDER_ASC], + 172800 // 48 hours + ); + $this->fail('Expected exception for duplicate TTL index on same attribute'); + } catch (Exception $e) { + $this->assertInstanceOf(DatabaseException::class, $e); + $this->assertStringContainsString('A TTL index already exists on attribute', $e->getMessage()); + } + + // Test 5: Delete first TTL index and create a new one on same attribute (should succeed) + $this->assertTrue($database->deleteIndex($col, 'idx_ttl_expires')); + + $this->assertTrue( + $database->createIndex( + $col, + 'idx_ttl_expires_new', + Database::INDEX_TTL, + ['expiresAt'], + [], + [Database::ORDER_ASC], + 1800 // 30 minutes + ) + ); + + // Verify the new index replaced the old one + $collection = $database->getCollection($col); + $indexes = $collection->getAttribute('indexes'); + $this->assertCount(2, $indexes); + + $indexIds = array_map(fn ($idx) => $idx->getId(), $indexes); + $this->assertNotContains('idx_ttl_expires', $indexIds); + $this->assertContains('idx_ttl_expires_new', $indexIds); + $this->assertContains('idx_ttl_deleted', $indexIds); + + // Test 6: Try to create TTL index via createCollection with duplicate (should fail validation) + $col3 = uniqid('sl_ttl_dup_collection'); + + $expiresAtAttr = new Document([ + '$id' => ID::custom('expiresAt'), + 'type' => Database::VAR_DATETIME, + 'size' => 0, + 'signed' => false, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => ['datetime'], + ]); + + $ttlIndex1 = new Document([ + '$id' => ID::custom('idx_ttl_1'), + 'type' => Database::INDEX_TTL, + 'attributes' => ['expiresAt'], + 'lengths' => [], + 'orders' => [Database::ORDER_ASC], + 'ttl' => 3600 + ]); + + $ttlIndex2 = new Document([ + '$id' => ID::custom('idx_ttl_2'), + 'type' => Database::INDEX_TTL, + 'attributes' => ['expiresAt'], + 'lengths' => [], + 'orders' => [Database::ORDER_ASC], + 'ttl' => 7200 + ]); + + try { + $database->createCollection($col3, [$expiresAtAttr], [$ttlIndex1, $ttlIndex2]); + $this->fail('Expected exception for duplicate TTL indexes in createCollection'); + } catch (Exception $e) { + $this->assertInstanceOf(DatabaseException::class, $e); + $this->assertStringContainsString('A TTL index already exists on attribute', $e->getMessage()); + } + + // Cleanup + $database->deleteCollection($col); + } } diff --git a/tests/unit/Validator/IndexTest.php b/tests/unit/Validator/IndexTest.php index 5dfe80e4e..b8bfee056 100644 --- a/tests/unit/Validator/IndexTest.php +++ b/tests/unit/Validator/IndexTest.php @@ -596,4 +596,183 @@ public function testTrigramIndexValidation(): void $this->assertFalse($validatorNoSupport->isValid($validIndex)); $this->assertEquals('Trigram indexes are not supported', $validatorNoSupport->getDescription()); } + + /** + * @throws Exception + */ + public function testTTLIndexValidation(): void + { + $collection = new Document([ + '$id' => ID::custom('test'), + 'name' => 'test', + 'attributes' => [ + new Document([ + '$id' => ID::custom('expiresAt'), + 'type' => Database::VAR_DATETIME, + 'format' => '', + 'size' => 0, + 'signed' => false, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => ['datetime'], + ]), + new Document([ + '$id' => ID::custom('name'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 255, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ]), + ], + 'indexes' => [] + ]); + + // Validator with supportForTTLIndexes enabled + $validator = new Index( + $collection->getAttribute('attributes'), + $collection->getAttribute('indexes', []), + 768, + [], + false, // supportForArrayIndexes + false, // supportForSpatialIndexNull + false, // supportForSpatialIndexOrder + false, // supportForVectorIndexes + true, // supportForAttributes + true, // supportForMultipleFulltextIndexes + true, // supportForIdenticalIndexes + false, // supportForObjectIndexes + false, // supportForTrigramIndexes + false, // supportForSpatialIndexes + true, // supportForKeyIndexes + true, // supportForUniqueIndexes + true, // supportForFulltextIndexes + true // supportForTTLIndexes + ); + + // Valid: TTL index on single datetime attribute with valid TTL + $validIndex = new Document([ + '$id' => ID::custom('idx_ttl_valid'), + 'type' => Database::INDEX_TTL, + 'attributes' => ['expiresAt'], + 'lengths' => [], + 'orders' => [Database::ORDER_ASC], + 'ttl' => 3600, + ]); + $this->assertTrue($validator->isValid($validIndex)); + + // Invalid: TTL index with TTL = 0 + $invalidIndexZero = new Document([ + '$id' => ID::custom('idx_ttl_zero'), + 'type' => Database::INDEX_TTL, + 'attributes' => ['expiresAt'], + 'lengths' => [], + 'orders' => [Database::ORDER_ASC], + 'ttl' => 0, + ]); + $this->assertFalse($validator->isValid($invalidIndexZero)); + $this->assertEquals('TTL must be atleast 1 second', $validator->getDescription()); + + // Invalid: TTL index with TTL < 0 + $invalidIndexNegative = new Document([ + '$id' => ID::custom('idx_ttl_negative'), + 'type' => Database::INDEX_TTL, + 'attributes' => ['expiresAt'], + 'lengths' => [], + 'orders' => [Database::ORDER_ASC], + 'ttl' => -100, + ]); + $this->assertFalse($validator->isValid($invalidIndexNegative)); + $this->assertEquals('TTL must be atleast 1 second', $validator->getDescription()); + + // Invalid: TTL index on non-datetime attribute + $invalidIndexType = new Document([ + '$id' => ID::custom('idx_ttl_invalid_type'), + 'type' => Database::INDEX_TTL, + 'attributes' => ['name'], + 'lengths' => [], + 'orders' => [Database::ORDER_ASC], + 'ttl' => 3600, + ]); + $this->assertFalse($validator->isValid($invalidIndexType)); + $this->assertStringContainsString('Object index can only be created on datetime attributes', $validator->getDescription()); + + // Invalid: TTL index on multiple attributes + $invalidIndexMulti = new Document([ + '$id' => ID::custom('idx_ttl_multi'), + 'type' => Database::INDEX_TTL, + 'attributes' => ['expiresAt', 'name'], + 'lengths' => [], + 'orders' => [Database::ORDER_ASC, Database::ORDER_ASC], + 'ttl' => 3600, + ]); + $this->assertFalse($validator->isValid($invalidIndexMulti)); + $this->assertStringContainsString('TTL index can be created on a single object attribute', $validator->getDescription()); + + // Invalid: TTL index without orders + $invalidIndexNoOrders = new Document([ + '$id' => ID::custom('idx_ttl_no_orders'), + 'type' => Database::INDEX_TTL, + 'attributes' => ['expiresAt'], + 'lengths' => [], + 'orders' => [], + 'ttl' => 3600, + ]); + $this->assertFalse($validator->isValid($invalidIndexNoOrders)); + $this->assertEquals('TTL index need explicit orders. Add the orders to create this index.', $validator->getDescription()); + + // Valid: TTL index with minimum valid TTL (1 second) + $validIndexMin = new Document([ + '$id' => ID::custom('idx_ttl_min'), + 'type' => Database::INDEX_TTL, + 'attributes' => ['expiresAt'], + 'lengths' => [], + 'orders' => [Database::ORDER_ASC], + 'ttl' => 1, + ]); + $this->assertTrue($validator->isValid($validIndexMin)); + + // Invalid: TTL index on same attribute when another TTL index already exists + $collection->setAttribute('indexes', $validIndex, Document::SET_TYPE_APPEND); + $validatorWithExisting = new Index( + $collection->getAttribute('attributes'), + $collection->getAttribute('indexes', []), + 768, + [], + false, // supportForArrayIndexes + false, // supportForSpatialIndexNull + false, // supportForSpatialIndexOrder + false, // supportForVectorIndexes + true, // supportForAttributes + true, // supportForMultipleFulltextIndexes + true, // supportForIdenticalIndexes + false, // supportForObjectIndexes + false, // supportForTrigramIndexes + false, // supportForSpatialIndexes + true, // supportForKeyIndexes + true, // supportForUniqueIndexes + true, // supportForFulltextIndexes + true // supportForTTLIndexes + ); + + $duplicateTTLIndex = new Document([ + '$id' => ID::custom('idx_ttl_duplicate'), + 'type' => Database::INDEX_TTL, + 'attributes' => ['expiresAt'], + 'lengths' => [], + 'orders' => [Database::ORDER_ASC], + 'ttl' => 7200, + ]); + $this->assertFalse($validatorWithExisting->isValid($duplicateTTLIndex)); + $this->assertStringContainsString('A TTL index already exists on attribute', $validatorWithExisting->getDescription()); + + // Validator with supportForTrigramIndexes disabled should reject TTL + $validatorNoSupport = new Index($collection->getAttribute('attributes'), $collection->getAttribute('indexes', []), 768, [], false, false, false, false, false, false, false, false, false); + $this->assertFalse($validatorNoSupport->isValid($validIndex)); + $this->assertEquals('TTL indexes are not supported', $validatorNoSupport->getDescription()); + } } From 321a4c53a11d72af961d7eae28819fc3351504d5 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 12 Jan 2026 13:40:47 +0530 Subject: [PATCH 2/6] refactor: standardize method formatting and improve TTL index validation tests --- src/Database/Adapter.php | 3 +- src/Database/Adapter/Mongo.php | 3 +- src/Database/Database.php | 1 - src/Database/Validator/Index.php | 28 +- tests/e2e/Adapter/Scopes/DocumentTests.php | 261 +++++++++++++++++++ tests/e2e/Adapter/Scopes/SchemalessTests.php | 83 +----- tests/unit/Validator/IndexTest.php | 4 +- 7 files changed, 300 insertions(+), 83 deletions(-) diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index 603c18be5..37772e1b2 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -1491,7 +1491,8 @@ public function getSupportForRegex(): bool * * @return bool */ - public function getSupportTTLIndexes(): bool { + public function getSupportTTLIndexes(): bool + { return false; } } diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index e3441f92a..cf3d28189 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -3415,7 +3415,8 @@ public function getSupportForTrigramIndex(): bool return false; } - public function getSupportTTLIndexes(): bool { + public function getSupportTTLIndexes(): bool + { return true; } } diff --git a/src/Database/Database.php b/src/Database/Database.php index f12795bf7..65526c348 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -3624,7 +3624,6 @@ public function createIndex(string $collection, string $id, string $type, array } $collection = $this->silent(fn () => $this->getCollection($collection)); - // index IDs are case-insensitive $indexes = $collection->getAttribute('indexes', []); diff --git a/src/Database/Validator/Index.php b/src/Database/Validator/Index.php index 243cde156..5226d3300 100644 --- a/src/Database/Validator/Index.php +++ b/src/Database/Validator/Index.php @@ -232,7 +232,7 @@ public function checkValidIndex(Document $index): bool return false; } break; - + default: $this->message = 'Unknown index type: ' . $type . '. Must be one of ' . Database::INDEX_KEY . ', ' . Database::INDEX_UNIQUE . ', ' . Database::INDEX_FULLTEXT . ', ' . Database::INDEX_SPATIAL . ', ' . Database::INDEX_OBJECT . ', ' . Database::INDEX_HNSW_EUCLIDEAN . ', ' . Database::INDEX_HNSW_COSINE . ', ' . Database::INDEX_HNSW_DOT . ', '.Database::INDEX_TRIGRAM; return false; @@ -684,7 +684,7 @@ public function checkIdenticalIndexes(Document $index): bool if ($attributesMatch && $ordersMatch) { // Allow fulltext + key/unique combinations (different purposes) - $regularTypes = [Database::INDEX_KEY, Database::INDEX_UNIQUE, Database::INDEX_TTL]; + $regularTypes = [Database::INDEX_KEY, Database::INDEX_UNIQUE]; $isRegularIndex = \in_array($indexType, $regularTypes); $isRegularExisting = \in_array($existingType, $regularTypes); @@ -741,7 +741,8 @@ public function checkObjectIndexes(Document $index): bool return true; } - public function checkTTLIndexes(Document $index): bool{ + public function checkTTLIndexes(Document $index): bool + { $type = $index->getAttribute('type'); $attributes = $index->getAttribute('attributes', []); @@ -765,16 +766,31 @@ public function checkTTLIndexes(Document $index): bool{ $attribute = $this->attributes[\strtolower($attributeName)] ?? new Document(); $attributeType = $attribute->getAttribute('type', ''); - if ($attributeType !== Database::VAR_DATETIME) { - $this->message = 'Object index can only be created on datetime attributes. Attribute "' . $attributeName . '" is of type "' . $attributeType . '"'; + if ($this->supportForAttributes && $attributeType !== Database::VAR_DATETIME) { + $this->message = 'TTL index can only be created on datetime attributes. Attribute "' . $attributeName . '" is of type "' . $attributeType . '"'; return false; } - if($ttl <= 0){ + if ($ttl <= 0) { $this->message = 'TTL must be atleast 1 second'; return false; } + foreach ($this->indexes as $existingIndex) { + $existingAttributes = $existingIndex->getAttribute('attributes', []); + $existingOrders = $existingIndex->getAttribute('orders', []); + $existingType = $existingIndex->getAttribute('type', ''); + if ($this->supportForAttributes && $existingType !== Database::INDEX_TTL) { + continue; + } + $attributeAlreadyPresent = ($this->supportForAttributes && in_array($attribute->getId(), $existingAttributes)) || in_array($attributeName, $existingAttributes); + $ordersMatched = empty(array_diff($existingOrders, $orders)); + if ($attributeAlreadyPresent && $ordersMatched) { + $this->message = 'There is already an index with the same attributes and orders'; + return false; + } + } + return true; } } diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index df31b9595..c3f0055e0 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -7427,4 +7427,265 @@ public function testRegexRedos(): void } $database->deleteCollection($collectionName); } + + public function testTTLIndexes(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportTTLIndexes()) { + $this->expectNotToPerformAssertions(); + return; + } + + $col = uniqid('sl_ttl'); + $database->createCollection($col); + + $database->createAttribute($col, 'expiresAt', Database::VAR_DATETIME, 0, false); + + $permissions = [ + Permission::read(Role::any()), + Permission::write(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()) + ]; + + $this->assertTrue( + $database->createIndex( + $col, + 'idx_ttl_valid', + Database::INDEX_TTL, + ['expiresAt'], + [], + [Database::ORDER_ASC], + 3600 // 1 hour TTL + ) + ); + + $collection = $database->getCollection($col); + $indexes = $collection->getAttribute('indexes'); + $this->assertCount(1, $indexes); + $ttlIndex = $indexes[0]; + $this->assertEquals('idx_ttl_valid', $ttlIndex->getId()); + $this->assertEquals(Database::INDEX_TTL, $ttlIndex->getAttribute('type')); + $this->assertEquals(3600, $ttlIndex->getAttribute('ttl')); + + $now = new \DateTime(); + $future1 = (clone $now)->modify('+2 hours'); + $future2 = (clone $now)->modify('+1 hour'); + $past = (clone $now)->modify('-1 hour'); + + $database->createDocuments($col, [ + new Document([ + '$id' => 'doc1', + '$permissions' => $permissions, + 'expiresAt' => $future1->format(\DateTime::ATOM), + ]), + new Document([ + '$id' => 'doc2', + '$permissions' => $permissions, + 'expiresAt' => $future2->format(\DateTime::ATOM), + ]), + new Document([ + '$id' => 'doc3', + '$permissions' => $permissions, + 'expiresAt' => $past->format(\DateTime::ATOM), + ]) + ]); + + $this->assertTrue($database->deleteIndex($col, 'idx_ttl_valid')); + + $this->assertTrue( + $database->createIndex( + $col, + 'idx_ttl_min', + Database::INDEX_TTL, + ['expiresAt'], + [], + [Database::ORDER_ASC], + 1 // Minimum TTL + ) + ); + + $col2 = uniqid('sl_ttl_collection'); + + $expiresAtAttr = new Document([ + '$id' => ID::custom('expiresAt'), + 'type' => Database::VAR_DATETIME, + 'size' => 0, + 'signed' => false, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => ['datetime'], + ]); + + $ttlIndexDoc = new Document([ + '$id' => ID::custom('idx_ttl_collection'), + 'type' => Database::INDEX_TTL, + 'attributes' => ['expiresAt'], + 'lengths' => [], + 'orders' => [Database::ORDER_ASC], + 'ttl' => 7200 // 2 hours + ]); + + $database->createCollection($col2, [$expiresAtAttr], [$ttlIndexDoc]); + + $collection2 = $database->getCollection($col2); + $indexes2 = $collection2->getAttribute('indexes'); + $this->assertCount(1, $indexes2); + $ttlIndex2 = $indexes2[0]; + $this->assertEquals('idx_ttl_collection', $ttlIndex2->getId()); + $this->assertEquals(7200, $ttlIndex2->getAttribute('ttl')); + + $database->deleteCollection($col); + $database->deleteCollection($col2); + } + + public function testTTLIndexDuplicatePrevention(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportTTLIndexes()) { + $this->expectNotToPerformAssertions(); + return; + } + + $col = uniqid('sl_ttl_dup'); + $database->createCollection($col); + + $database->createAttribute($col, 'expiresAt', Database::VAR_DATETIME, 0, false); + $database->createAttribute($col, 'deletedAt', Database::VAR_DATETIME, 0, false); + + $this->assertTrue( + $database->createIndex( + $col, + 'idx_ttl_expires', + Database::INDEX_TTL, + ['expiresAt'], + [], + [Database::ORDER_ASC], + 3600 // 1 hour + ) + ); + + try { + $database->createIndex( + $col, + 'idx_ttl_expires_duplicate', + Database::INDEX_TTL, + ['expiresAt'], + [], + [Database::ORDER_ASC], + 7200 // 2 hours + ); + $this->fail('Expected exception for duplicate TTL index on same attribute'); + } catch (Exception $e) { + $this->assertInstanceOf(DatabaseException::class, $e); + $this->assertStringContainsString('There is already an index with the same attributes and orders', $e->getMessage()); + } + + $this->assertTrue( + $database->createIndex( + $col, + 'idx_ttl_deleted', + Database::INDEX_TTL, + ['deletedAt'], + [], + [Database::ORDER_ASC], + 86400 // 24 hours + ) + ); + + $collection = $database->getCollection($col); + $indexes = $collection->getAttribute('indexes'); + $this->assertCount(2, $indexes); + + $indexIds = array_map(fn ($idx) => $idx->getId(), $indexes); + $this->assertContains('idx_ttl_expires', $indexIds); + $this->assertContains('idx_ttl_deleted', $indexIds); + + try { + $database->createIndex( + $col, + 'idx_ttl_deleted_duplicate', + Database::INDEX_TTL, + ['deletedAt'], + [], + [Database::ORDER_ASC], + 172800 // 48 hours + ); + $this->fail('Expected exception for duplicate TTL index on same attribute'); + } catch (Exception $e) { + $this->assertInstanceOf(DatabaseException::class, $e); + $this->assertStringContainsString('There is already an index with the same attributes and orders', $e->getMessage()); + } + + $this->assertTrue($database->deleteIndex($col, 'idx_ttl_expires')); + + $this->assertTrue( + $database->createIndex( + $col, + 'idx_ttl_expires_new', + Database::INDEX_TTL, + ['expiresAt'], + [], + [Database::ORDER_ASC], + 1800 // 30 minutes + ) + ); + + $collection = $database->getCollection($col); + $indexes = $collection->getAttribute('indexes'); + $this->assertCount(2, $indexes); + + $indexIds = array_map(fn ($idx) => $idx->getId(), $indexes); + $this->assertNotContains('idx_ttl_expires', $indexIds); + $this->assertContains('idx_ttl_expires_new', $indexIds); + $this->assertContains('idx_ttl_deleted', $indexIds); + + $col3 = uniqid('sl_ttl_dup_collection'); + + $expiresAtAttr = new Document([ + '$id' => ID::custom('expiresAt'), + 'type' => Database::VAR_DATETIME, + 'size' => 0, + 'signed' => false, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => ['datetime'], + ]); + + $ttlIndex1 = new Document([ + '$id' => ID::custom('idx_ttl_1'), + 'type' => Database::INDEX_TTL, + 'attributes' => ['expiresAt'], + 'lengths' => [], + 'orders' => [Database::ORDER_ASC], + 'ttl' => 3600 + ]); + + $ttlIndex2 = new Document([ + '$id' => ID::custom('idx_ttl_2'), + 'type' => Database::INDEX_TTL, + 'attributes' => ['expiresAt'], + 'lengths' => [], + 'orders' => [Database::ORDER_ASC], + 'ttl' => 7200 + ]); + + try { + $database->createCollection($col3, [$expiresAtAttr], [$ttlIndex1, $ttlIndex2]); + $this->fail('Expected exception for duplicate TTL indexes in createCollection'); + } catch (Exception $e) { + $this->assertInstanceOf(DatabaseException::class, $e); + // raised in the mongo level + $this->assertStringContainsString('Index already exists', $e->getMessage()); + } + + // Cleanup + $database->deleteCollection($col); + } } diff --git a/tests/e2e/Adapter/Scopes/SchemalessTests.php b/tests/e2e/Adapter/Scopes/SchemalessTests.php index 3c5b0a76a..079a1c7c3 100644 --- a/tests/e2e/Adapter/Scopes/SchemalessTests.php +++ b/tests/e2e/Adapter/Scopes/SchemalessTests.php @@ -1872,7 +1872,6 @@ public function testSchemalessTTLIndexes(): void /** @var Database $database */ $database = static::getDatabase(); - // Only run for MongoDB adapter which supports TTL indexes if ($database->getAdapter()->getSupportForAttributes()) { $this->expectNotToPerformAssertions(); return; @@ -1881,9 +1880,6 @@ public function testSchemalessTTLIndexes(): void $col = uniqid('sl_ttl'); $database->createCollection($col); - // Create datetime attribute for TTL index - $database->createAttribute($col, 'expiresAt', Database::VAR_DATETIME, 0, false); - $permissions = [ Permission::read(Role::any()), Permission::write(Role::any()), @@ -1891,7 +1887,6 @@ public function testSchemalessTTLIndexes(): void Permission::delete(Role::any()) ]; - // Test 1: Create valid TTL index with valid TTL value $this->assertTrue( $database->createIndex( $col, @@ -1904,7 +1899,6 @@ public function testSchemalessTTLIndexes(): void ) ); - // Verify index was created and stored in metadata $collection = $database->getCollection($col); $indexes = $collection->getAttribute('indexes'); $this->assertCount(1, $indexes); @@ -1913,7 +1907,6 @@ public function testSchemalessTTLIndexes(): void $this->assertEquals(Database::INDEX_TTL, $ttlIndex->getAttribute('type')); $this->assertEquals(3600, $ttlIndex->getAttribute('ttl')); - // Test 2: Create documents with expiresAt field $now = new \DateTime(); $future1 = (clone $now)->modify('+2 hours'); $future2 = (clone $now)->modify('+1 hour'); @@ -1945,10 +1938,8 @@ public function testSchemalessTTLIndexes(): void $this->assertEquals('doc2', $doc2->getId()); $this->assertEquals('doc3', $doc3->getId()); - // Test 3: Delete the first TTL index and create a new one with minimum valid TTL (1 second) - // MongoDB only allows one TTL index per attribute, so we need to delete the existing one first $this->assertTrue($database->deleteIndex($col, 'idx_ttl_valid')); - + $this->assertTrue( $database->createIndex( $col, @@ -1961,44 +1952,8 @@ public function testSchemalessTTLIndexes(): void ) ); - // Test 4: Try to create TTL index with TTL = 0 (should fail) - try { - $database->createIndex( - $col, - 'idx_ttl_zero', - Database::INDEX_TTL, - ['expiresAt'], - [], - [Database::ORDER_ASC], - 0 - ); - $this->fail('Expected exception for TTL = 0'); - } catch (Exception $e) { - $this->assertInstanceOf(DatabaseException::class, $e); - $this->assertStringContainsString('TTL must be atleast 1 second', $e->getMessage()); - } - - // Test 5: Try to create TTL index with negative TTL (should fail) - try { - $database->createIndex( - $col, - 'idx_ttl_negative', - Database::INDEX_TTL, - ['expiresAt'], - [], - [Database::ORDER_ASC], - -100 - ); - $this->fail('Expected exception for negative TTL'); - } catch (Exception $e) { - $this->assertInstanceOf(DatabaseException::class, $e); - $this->assertStringContainsString('TTL must be atleast 1 second', $e->getMessage()); - } - - // Test 6: Create TTL index via createCollection with indexes parameter $col2 = uniqid('sl_ttl_collection'); - - // Create attribute document for the collection + $expiresAtAttr = new Document([ '$id' => ID::custom('expiresAt'), 'type' => Database::VAR_DATETIME, @@ -2009,7 +1964,7 @@ public function testSchemalessTTLIndexes(): void 'array' => false, 'filters' => ['datetime'], ]); - + $ttlIndexDoc = new Document([ '$id' => ID::custom('idx_ttl_collection'), 'type' => Database::INDEX_TTL, @@ -2021,7 +1976,6 @@ public function testSchemalessTTLIndexes(): void $database->createCollection($col2, [$expiresAtAttr], [$ttlIndexDoc]); - // Verify TTL index was created via createCollection $collection2 = $database->getCollection($col2); $indexes2 = $collection2->getAttribute('indexes'); $this->assertCount(1, $indexes2); @@ -2029,7 +1983,6 @@ public function testSchemalessTTLIndexes(): void $this->assertEquals('idx_ttl_collection', $ttlIndex2->getId()); $this->assertEquals(7200, $ttlIndex2->getAttribute('ttl')); - // Cleanup $database->deleteCollection($col); $database->deleteCollection($col2); } @@ -2039,7 +1992,6 @@ public function testSchemalessTTLIndexDuplicatePrevention(): void /** @var Database $database */ $database = static::getDatabase(); - // Only run for MongoDB adapter which supports TTL indexes if ($database->getAdapter()->getSupportForAttributes()) { $this->expectNotToPerformAssertions(); return; @@ -2048,11 +2000,6 @@ public function testSchemalessTTLIndexDuplicatePrevention(): void $col = uniqid('sl_ttl_dup'); $database->createCollection($col); - // Create datetime attribute for TTL index - $database->createAttribute($col, 'expiresAt', Database::VAR_DATETIME, 0, false); - $database->createAttribute($col, 'deletedAt', Database::VAR_DATETIME, 0, false); - - // Test 1: Create first TTL index on expiresAt $this->assertTrue( $database->createIndex( $col, @@ -2065,7 +2012,6 @@ public function testSchemalessTTLIndexDuplicatePrevention(): void ) ); - // Test 2: Try to create another TTL index on the same attribute (should fail) try { $database->createIndex( $col, @@ -2079,10 +2025,9 @@ public function testSchemalessTTLIndexDuplicatePrevention(): void $this->fail('Expected exception for duplicate TTL index on same attribute'); } catch (Exception $e) { $this->assertInstanceOf(DatabaseException::class, $e); - $this->assertStringContainsString('A TTL index already exists on attribute', $e->getMessage()); + $this->assertStringContainsString('There is already an index with the same attributes and orders', $e->getMessage()); } - // Test 3: Create TTL index on different attribute (should succeed) $this->assertTrue( $database->createIndex( $col, @@ -2095,16 +2040,14 @@ public function testSchemalessTTLIndexDuplicatePrevention(): void ) ); - // Verify both indexes exist $collection = $database->getCollection($col); $indexes = $collection->getAttribute('indexes'); $this->assertCount(2, $indexes); - + $indexIds = array_map(fn ($idx) => $idx->getId(), $indexes); $this->assertContains('idx_ttl_expires', $indexIds); $this->assertContains('idx_ttl_deleted', $indexIds); - // Test 4: Try to create another TTL index on deletedAt (should fail) try { $database->createIndex( $col, @@ -2118,12 +2061,11 @@ public function testSchemalessTTLIndexDuplicatePrevention(): void $this->fail('Expected exception for duplicate TTL index on same attribute'); } catch (Exception $e) { $this->assertInstanceOf(DatabaseException::class, $e); - $this->assertStringContainsString('A TTL index already exists on attribute', $e->getMessage()); + $this->assertStringContainsString('There is already an index with the same attributes and orders', $e->getMessage()); } - // Test 5: Delete first TTL index and create a new one on same attribute (should succeed) $this->assertTrue($database->deleteIndex($col, 'idx_ttl_expires')); - + $this->assertTrue( $database->createIndex( $col, @@ -2136,19 +2078,17 @@ public function testSchemalessTTLIndexDuplicatePrevention(): void ) ); - // Verify the new index replaced the old one $collection = $database->getCollection($col); $indexes = $collection->getAttribute('indexes'); $this->assertCount(2, $indexes); - + $indexIds = array_map(fn ($idx) => $idx->getId(), $indexes); $this->assertNotContains('idx_ttl_expires', $indexIds); $this->assertContains('idx_ttl_expires_new', $indexIds); $this->assertContains('idx_ttl_deleted', $indexIds); - // Test 6: Try to create TTL index via createCollection with duplicate (should fail validation) $col3 = uniqid('sl_ttl_dup_collection'); - + $expiresAtAttr = new Document([ '$id' => ID::custom('expiresAt'), 'type' => Database::VAR_DATETIME, @@ -2159,7 +2099,7 @@ public function testSchemalessTTLIndexDuplicatePrevention(): void 'array' => false, 'filters' => ['datetime'], ]); - + $ttlIndex1 = new Document([ '$id' => ID::custom('idx_ttl_1'), 'type' => Database::INDEX_TTL, @@ -2183,10 +2123,9 @@ public function testSchemalessTTLIndexDuplicatePrevention(): void $this->fail('Expected exception for duplicate TTL indexes in createCollection'); } catch (Exception $e) { $this->assertInstanceOf(DatabaseException::class, $e); - $this->assertStringContainsString('A TTL index already exists on attribute', $e->getMessage()); + $this->assertStringContainsString('Index already exists', $e->getMessage()); } - // Cleanup $database->deleteCollection($col); } } diff --git a/tests/unit/Validator/IndexTest.php b/tests/unit/Validator/IndexTest.php index b8bfee056..6ade7a362 100644 --- a/tests/unit/Validator/IndexTest.php +++ b/tests/unit/Validator/IndexTest.php @@ -758,7 +758,7 @@ public function testTTLIndexValidation(): void true, // supportForFulltextIndexes true // supportForTTLIndexes ); - + $duplicateTTLIndex = new Document([ '$id' => ID::custom('idx_ttl_duplicate'), 'type' => Database::INDEX_TTL, @@ -768,7 +768,7 @@ public function testTTLIndexValidation(): void 'ttl' => 7200, ]); $this->assertFalse($validatorWithExisting->isValid($duplicateTTLIndex)); - $this->assertStringContainsString('A TTL index already exists on attribute', $validatorWithExisting->getDescription()); + $this->assertStringContainsString('There is already an index with the same attributes and orders', $validatorWithExisting->getDescription()); // Validator with supportForTrigramIndexes disabled should reject TTL $validatorNoSupport = new Index($collection->getAttribute('attributes'), $collection->getAttribute('indexes', []), 768, [], false, false, false, false, false, false, false, false, false); From 151703fc29f8903c5516b543987455418a8b9a1a Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 12 Jan 2026 13:41:06 +0530 Subject: [PATCH 3/6] updated adapters with the support method for the ttl index support --- src/Database/Adapter/MariaDB.php | 5 +++++ src/Database/Adapter/MySQL.php | 5 +++++ src/Database/Adapter/Pool.php | 5 +++++ src/Database/Adapter/Postgres.php | 5 +++++ src/Database/Adapter/SQLite.php | 5 +++++ 5 files changed, 25 insertions(+) diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index ef44171c6..242b0d9ad 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -2255,4 +2255,9 @@ public function getSupportForPOSIXRegex(): bool { return false; } + + public function getSupportTTLIndexes(): bool + { + return false; + } } diff --git a/src/Database/Adapter/MySQL.php b/src/Database/Adapter/MySQL.php index 308013738..d5740cf66 100644 --- a/src/Database/Adapter/MySQL.php +++ b/src/Database/Adapter/MySQL.php @@ -317,4 +317,9 @@ protected function getOperatorSQL(string $column, \Utopia\Database\Operator $ope // For all other operators, use parent implementation return parent::getOperatorSQL($column, $operator, $bindIndex); } + + public function getSupportTTLIndexes(): bool + { + return false; + } } diff --git a/src/Database/Adapter/Pool.php b/src/Database/Adapter/Pool.php index e6bf77681..1d8c892e6 100644 --- a/src/Database/Adapter/Pool.php +++ b/src/Database/Adapter/Pool.php @@ -649,4 +649,9 @@ public function getSupportForAlterLocks(): bool { return $this->delegate(__FUNCTION__, \func_get_args()); } + + public function getSupportTTLIndexes(): bool + { + return $this->delegate(__FUNCTION__, \func_get_args()); + } } diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 38c3c3a26..5cfe39d55 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -2832,4 +2832,9 @@ protected function getSQLTable(string $name): string return "{$this->quote($this->getDatabase())}.{$this->quote($table)}"; } + + public function getSupportTTLIndexes(): bool + { + return false; + } } diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index 538b93a68..55e195dc2 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -1909,4 +1909,9 @@ public function getSupportForPOSIXRegex(): bool { return false; } + + public function getSupportTTLIndexes(): bool + { + return false; + } } From cc63ade737a8f66599af0e594a4ddba1aefcca6f Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 12 Jan 2026 14:09:12 +0530 Subject: [PATCH 4/6] fix: update TTL index validation messages and conditions for shared tables --- src/Database/Adapter/Mongo.php | 4 +- src/Database/Validator/Index.php | 2 +- tests/e2e/Adapter/Scopes/DocumentTests.php | 261 --------------------- tests/e2e/Adapter/Scopes/IndexTests.php | 261 +++++++++++++++++++++ tests/unit/Validator/IndexTest.php | 4 +- 5 files changed, 266 insertions(+), 266 deletions(-) diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index cf3d28189..68935742d 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -488,7 +488,7 @@ public function createCollection(string $name, array $attributes = [], array $in $orders = $index->getAttribute('orders'); // If sharedTables, always add _tenant as the first key - if ($this->sharedTables) { + if ($this->sharedTables && $index->getAttribute('type') !== Database::INDEX_TTL) { $key['_tenant'] = $this->getOrder(Database::ORDER_ASC); } @@ -925,7 +925,7 @@ public function createIndex(string $collection, string $id, string $type, array $indexes['name'] = $id; // If sharedTables, always add _tenant as the first key - if ($this->sharedTables) { + if ($this->sharedTables && $type !== Database::INDEX_TTL) { $indexes['key']['_tenant'] = $this->getOrder(Database::ORDER_ASC); } diff --git a/src/Database/Validator/Index.php b/src/Database/Validator/Index.php index 5226d3300..35d0f78d8 100644 --- a/src/Database/Validator/Index.php +++ b/src/Database/Validator/Index.php @@ -753,7 +753,7 @@ public function checkTTLIndexes(Document $index): bool } if (count($attributes) !== 1) { - $this->message = 'TTL index can be created on a single object attribute'; + $this->message = 'TTL index can be created on a single datetime attribute'; return false; } diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index c3f0055e0..df31b9595 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -7427,265 +7427,4 @@ public function testRegexRedos(): void } $database->deleteCollection($collectionName); } - - public function testTTLIndexes(): void - { - /** @var Database $database */ - $database = static::getDatabase(); - - if (!$database->getAdapter()->getSupportTTLIndexes()) { - $this->expectNotToPerformAssertions(); - return; - } - - $col = uniqid('sl_ttl'); - $database->createCollection($col); - - $database->createAttribute($col, 'expiresAt', Database::VAR_DATETIME, 0, false); - - $permissions = [ - Permission::read(Role::any()), - Permission::write(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()) - ]; - - $this->assertTrue( - $database->createIndex( - $col, - 'idx_ttl_valid', - Database::INDEX_TTL, - ['expiresAt'], - [], - [Database::ORDER_ASC], - 3600 // 1 hour TTL - ) - ); - - $collection = $database->getCollection($col); - $indexes = $collection->getAttribute('indexes'); - $this->assertCount(1, $indexes); - $ttlIndex = $indexes[0]; - $this->assertEquals('idx_ttl_valid', $ttlIndex->getId()); - $this->assertEquals(Database::INDEX_TTL, $ttlIndex->getAttribute('type')); - $this->assertEquals(3600, $ttlIndex->getAttribute('ttl')); - - $now = new \DateTime(); - $future1 = (clone $now)->modify('+2 hours'); - $future2 = (clone $now)->modify('+1 hour'); - $past = (clone $now)->modify('-1 hour'); - - $database->createDocuments($col, [ - new Document([ - '$id' => 'doc1', - '$permissions' => $permissions, - 'expiresAt' => $future1->format(\DateTime::ATOM), - ]), - new Document([ - '$id' => 'doc2', - '$permissions' => $permissions, - 'expiresAt' => $future2->format(\DateTime::ATOM), - ]), - new Document([ - '$id' => 'doc3', - '$permissions' => $permissions, - 'expiresAt' => $past->format(\DateTime::ATOM), - ]) - ]); - - $this->assertTrue($database->deleteIndex($col, 'idx_ttl_valid')); - - $this->assertTrue( - $database->createIndex( - $col, - 'idx_ttl_min', - Database::INDEX_TTL, - ['expiresAt'], - [], - [Database::ORDER_ASC], - 1 // Minimum TTL - ) - ); - - $col2 = uniqid('sl_ttl_collection'); - - $expiresAtAttr = new Document([ - '$id' => ID::custom('expiresAt'), - 'type' => Database::VAR_DATETIME, - 'size' => 0, - 'signed' => false, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => ['datetime'], - ]); - - $ttlIndexDoc = new Document([ - '$id' => ID::custom('idx_ttl_collection'), - 'type' => Database::INDEX_TTL, - 'attributes' => ['expiresAt'], - 'lengths' => [], - 'orders' => [Database::ORDER_ASC], - 'ttl' => 7200 // 2 hours - ]); - - $database->createCollection($col2, [$expiresAtAttr], [$ttlIndexDoc]); - - $collection2 = $database->getCollection($col2); - $indexes2 = $collection2->getAttribute('indexes'); - $this->assertCount(1, $indexes2); - $ttlIndex2 = $indexes2[0]; - $this->assertEquals('idx_ttl_collection', $ttlIndex2->getId()); - $this->assertEquals(7200, $ttlIndex2->getAttribute('ttl')); - - $database->deleteCollection($col); - $database->deleteCollection($col2); - } - - public function testTTLIndexDuplicatePrevention(): void - { - /** @var Database $database */ - $database = static::getDatabase(); - - if (!$database->getAdapter()->getSupportTTLIndexes()) { - $this->expectNotToPerformAssertions(); - return; - } - - $col = uniqid('sl_ttl_dup'); - $database->createCollection($col); - - $database->createAttribute($col, 'expiresAt', Database::VAR_DATETIME, 0, false); - $database->createAttribute($col, 'deletedAt', Database::VAR_DATETIME, 0, false); - - $this->assertTrue( - $database->createIndex( - $col, - 'idx_ttl_expires', - Database::INDEX_TTL, - ['expiresAt'], - [], - [Database::ORDER_ASC], - 3600 // 1 hour - ) - ); - - try { - $database->createIndex( - $col, - 'idx_ttl_expires_duplicate', - Database::INDEX_TTL, - ['expiresAt'], - [], - [Database::ORDER_ASC], - 7200 // 2 hours - ); - $this->fail('Expected exception for duplicate TTL index on same attribute'); - } catch (Exception $e) { - $this->assertInstanceOf(DatabaseException::class, $e); - $this->assertStringContainsString('There is already an index with the same attributes and orders', $e->getMessage()); - } - - $this->assertTrue( - $database->createIndex( - $col, - 'idx_ttl_deleted', - Database::INDEX_TTL, - ['deletedAt'], - [], - [Database::ORDER_ASC], - 86400 // 24 hours - ) - ); - - $collection = $database->getCollection($col); - $indexes = $collection->getAttribute('indexes'); - $this->assertCount(2, $indexes); - - $indexIds = array_map(fn ($idx) => $idx->getId(), $indexes); - $this->assertContains('idx_ttl_expires', $indexIds); - $this->assertContains('idx_ttl_deleted', $indexIds); - - try { - $database->createIndex( - $col, - 'idx_ttl_deleted_duplicate', - Database::INDEX_TTL, - ['deletedAt'], - [], - [Database::ORDER_ASC], - 172800 // 48 hours - ); - $this->fail('Expected exception for duplicate TTL index on same attribute'); - } catch (Exception $e) { - $this->assertInstanceOf(DatabaseException::class, $e); - $this->assertStringContainsString('There is already an index with the same attributes and orders', $e->getMessage()); - } - - $this->assertTrue($database->deleteIndex($col, 'idx_ttl_expires')); - - $this->assertTrue( - $database->createIndex( - $col, - 'idx_ttl_expires_new', - Database::INDEX_TTL, - ['expiresAt'], - [], - [Database::ORDER_ASC], - 1800 // 30 minutes - ) - ); - - $collection = $database->getCollection($col); - $indexes = $collection->getAttribute('indexes'); - $this->assertCount(2, $indexes); - - $indexIds = array_map(fn ($idx) => $idx->getId(), $indexes); - $this->assertNotContains('idx_ttl_expires', $indexIds); - $this->assertContains('idx_ttl_expires_new', $indexIds); - $this->assertContains('idx_ttl_deleted', $indexIds); - - $col3 = uniqid('sl_ttl_dup_collection'); - - $expiresAtAttr = new Document([ - '$id' => ID::custom('expiresAt'), - 'type' => Database::VAR_DATETIME, - 'size' => 0, - 'signed' => false, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => ['datetime'], - ]); - - $ttlIndex1 = new Document([ - '$id' => ID::custom('idx_ttl_1'), - 'type' => Database::INDEX_TTL, - 'attributes' => ['expiresAt'], - 'lengths' => [], - 'orders' => [Database::ORDER_ASC], - 'ttl' => 3600 - ]); - - $ttlIndex2 = new Document([ - '$id' => ID::custom('idx_ttl_2'), - 'type' => Database::INDEX_TTL, - 'attributes' => ['expiresAt'], - 'lengths' => [], - 'orders' => [Database::ORDER_ASC], - 'ttl' => 7200 - ]); - - try { - $database->createCollection($col3, [$expiresAtAttr], [$ttlIndex1, $ttlIndex2]); - $this->fail('Expected exception for duplicate TTL indexes in createCollection'); - } catch (Exception $e) { - $this->assertInstanceOf(DatabaseException::class, $e); - // raised in the mongo level - $this->assertStringContainsString('Index already exists', $e->getMessage()); - } - - // Cleanup - $database->deleteCollection($col); - } } diff --git a/tests/e2e/Adapter/Scopes/IndexTests.php b/tests/e2e/Adapter/Scopes/IndexTests.php index e5eda16d0..6c5e21aaa 100644 --- a/tests/e2e/Adapter/Scopes/IndexTests.php +++ b/tests/e2e/Adapter/Scopes/IndexTests.php @@ -784,4 +784,265 @@ public function testTrigramIndexValidation(): void $database->deleteCollection($collectionId); } } + + public function testTTLIndexes(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportTTLIndexes()) { + $this->expectNotToPerformAssertions(); + return; + } + + $col = uniqid('sl_ttl'); + $database->createCollection($col); + + $database->createAttribute($col, 'expiresAt', Database::VAR_DATETIME, 0, false); + + $permissions = [ + Permission::read(Role::any()), + Permission::write(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()) + ]; + + $this->assertTrue( + $database->createIndex( + $col, + 'idx_ttl_valid', + Database::INDEX_TTL, + ['expiresAt'], + [], + [Database::ORDER_ASC], + 3600 // 1 hour TTL + ) + ); + + $collection = $database->getCollection($col); + $indexes = $collection->getAttribute('indexes'); + $this->assertCount(1, $indexes); + $ttlIndex = $indexes[0]; + $this->assertEquals('idx_ttl_valid', $ttlIndex->getId()); + $this->assertEquals(Database::INDEX_TTL, $ttlIndex->getAttribute('type')); + $this->assertEquals(3600, $ttlIndex->getAttribute('ttl')); + + $now = new \DateTime(); + $future1 = (clone $now)->modify('+2 hours'); + $future2 = (clone $now)->modify('+1 hour'); + $past = (clone $now)->modify('-1 hour'); + + $database->createDocuments($col, [ + new Document([ + '$id' => 'doc1', + '$permissions' => $permissions, + 'expiresAt' => $future1->format(\DateTime::ATOM), + ]), + new Document([ + '$id' => 'doc2', + '$permissions' => $permissions, + 'expiresAt' => $future2->format(\DateTime::ATOM), + ]), + new Document([ + '$id' => 'doc3', + '$permissions' => $permissions, + 'expiresAt' => $past->format(\DateTime::ATOM), + ]) + ]); + + $this->assertTrue($database->deleteIndex($col, 'idx_ttl_valid')); + + $this->assertTrue( + $database->createIndex( + $col, + 'idx_ttl_min', + Database::INDEX_TTL, + ['expiresAt'], + [], + [Database::ORDER_ASC], + 1 // Minimum TTL + ) + ); + + $col2 = uniqid('sl_ttl_collection'); + + $expiresAtAttr = new Document([ + '$id' => ID::custom('expiresAt'), + 'type' => Database::VAR_DATETIME, + 'size' => 0, + 'signed' => false, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => ['datetime'], + ]); + + $ttlIndexDoc = new Document([ + '$id' => ID::custom('idx_ttl_collection'), + 'type' => Database::INDEX_TTL, + 'attributes' => ['expiresAt'], + 'lengths' => [], + 'orders' => [Database::ORDER_ASC], + 'ttl' => 7200 // 2 hours + ]); + + $database->createCollection($col2, [$expiresAtAttr], [$ttlIndexDoc]); + + $collection2 = $database->getCollection($col2); + $indexes2 = $collection2->getAttribute('indexes'); + $this->assertCount(1, $indexes2); + $ttlIndex2 = $indexes2[0]; + $this->assertEquals('idx_ttl_collection', $ttlIndex2->getId()); + $this->assertEquals(7200, $ttlIndex2->getAttribute('ttl')); + + $database->deleteCollection($col); + $database->deleteCollection($col2); + } + + public function testTTLIndexDuplicatePrevention(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportTTLIndexes()) { + $this->expectNotToPerformAssertions(); + return; + } + + $col = uniqid('sl_ttl_dup'); + $database->createCollection($col); + + $database->createAttribute($col, 'expiresAt', Database::VAR_DATETIME, 0, false); + $database->createAttribute($col, 'deletedAt', Database::VAR_DATETIME, 0, false); + + $this->assertTrue( + $database->createIndex( + $col, + 'idx_ttl_expires', + Database::INDEX_TTL, + ['expiresAt'], + [], + [Database::ORDER_ASC], + 3600 // 1 hour + ) + ); + + try { + $database->createIndex( + $col, + 'idx_ttl_expires_duplicate', + Database::INDEX_TTL, + ['expiresAt'], + [], + [Database::ORDER_ASC], + 7200 // 2 hours + ); + $this->fail('Expected exception for duplicate TTL index on same attribute'); + } catch (Exception $e) { + $this->assertInstanceOf(DatabaseException::class, $e); + $this->assertStringContainsString('There is already an index with the same attributes and orders', $e->getMessage()); + } + + $this->assertTrue( + $database->createIndex( + $col, + 'idx_ttl_deleted', + Database::INDEX_TTL, + ['deletedAt'], + [], + [Database::ORDER_ASC], + 86400 // 24 hours + ) + ); + + $collection = $database->getCollection($col); + $indexes = $collection->getAttribute('indexes'); + $this->assertCount(2, $indexes); + + $indexIds = array_map(fn ($idx) => $idx->getId(), $indexes); + $this->assertContains('idx_ttl_expires', $indexIds); + $this->assertContains('idx_ttl_deleted', $indexIds); + + try { + $database->createIndex( + $col, + 'idx_ttl_deleted_duplicate', + Database::INDEX_TTL, + ['deletedAt'], + [], + [Database::ORDER_ASC], + 172800 // 48 hours + ); + $this->fail('Expected exception for duplicate TTL index on same attribute'); + } catch (Exception $e) { + $this->assertInstanceOf(DatabaseException::class, $e); + $this->assertStringContainsString('There is already an index with the same attributes and orders', $e->getMessage()); + } + + $this->assertTrue($database->deleteIndex($col, 'idx_ttl_expires')); + + $this->assertTrue( + $database->createIndex( + $col, + 'idx_ttl_expires_new', + Database::INDEX_TTL, + ['expiresAt'], + [], + [Database::ORDER_ASC], + 1800 // 30 minutes + ) + ); + + $collection = $database->getCollection($col); + $indexes = $collection->getAttribute('indexes'); + $this->assertCount(2, $indexes); + + $indexIds = array_map(fn ($idx) => $idx->getId(), $indexes); + $this->assertNotContains('idx_ttl_expires', $indexIds); + $this->assertContains('idx_ttl_expires_new', $indexIds); + $this->assertContains('idx_ttl_deleted', $indexIds); + + $col3 = uniqid('sl_ttl_dup_collection'); + + $expiresAtAttr = new Document([ + '$id' => ID::custom('expiresAt'), + 'type' => Database::VAR_DATETIME, + 'size' => 0, + 'signed' => false, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => ['datetime'], + ]); + + $ttlIndex1 = new Document([ + '$id' => ID::custom('idx_ttl_1'), + 'type' => Database::INDEX_TTL, + 'attributes' => ['expiresAt'], + 'lengths' => [], + 'orders' => [Database::ORDER_ASC], + 'ttl' => 3600 + ]); + + $ttlIndex2 = new Document([ + '$id' => ID::custom('idx_ttl_2'), + 'type' => Database::INDEX_TTL, + 'attributes' => ['expiresAt'], + 'lengths' => [], + 'orders' => [Database::ORDER_ASC], + 'ttl' => 7200 + ]); + + try { + $database->createCollection($col3, [$expiresAtAttr], [$ttlIndex1, $ttlIndex2]); + $this->fail('Expected exception for duplicate TTL indexes in createCollection'); + } catch (Exception $e) { + $this->assertInstanceOf(DatabaseException::class, $e); + // raised in the mongo level + $this->assertStringContainsString('Index already exists', $e->getMessage()); + } + + // Cleanup + $database->deleteCollection($col); + } } diff --git a/tests/unit/Validator/IndexTest.php b/tests/unit/Validator/IndexTest.php index 6ade7a362..e6aeb3c56 100644 --- a/tests/unit/Validator/IndexTest.php +++ b/tests/unit/Validator/IndexTest.php @@ -699,7 +699,7 @@ public function testTTLIndexValidation(): void 'ttl' => 3600, ]); $this->assertFalse($validator->isValid($invalidIndexType)); - $this->assertStringContainsString('Object index can only be created on datetime attributes', $validator->getDescription()); + $this->assertStringContainsString('TTL index can only be created on datetime attributes', $validator->getDescription()); // Invalid: TTL index on multiple attributes $invalidIndexMulti = new Document([ @@ -711,7 +711,7 @@ public function testTTLIndexValidation(): void 'ttl' => 3600, ]); $this->assertFalse($validator->isValid($invalidIndexMulti)); - $this->assertStringContainsString('TTL index can be created on a single object attribute', $validator->getDescription()); + $this->assertStringContainsString('TTL index can be created on a single datetime attribute', $validator->getDescription()); // Invalid: TTL index without orders $invalidIndexNoOrders = new Document([ From ed322e6b89e2dc0808ca392dc513ca24d1b52198 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 12 Jan 2026 14:23:15 +0530 Subject: [PATCH 5/6] typo --- src/Database/Validator/Index.php | 2 +- tests/unit/Validator/IndexTest.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Database/Validator/Index.php b/src/Database/Validator/Index.php index 35d0f78d8..ed7ffea74 100644 --- a/src/Database/Validator/Index.php +++ b/src/Database/Validator/Index.php @@ -772,7 +772,7 @@ public function checkTTLIndexes(Document $index): bool } if ($ttl <= 0) { - $this->message = 'TTL must be atleast 1 second'; + $this->message = 'TTL must be at least 1 second'; return false; } diff --git a/tests/unit/Validator/IndexTest.php b/tests/unit/Validator/IndexTest.php index e6aeb3c56..664874051 100644 --- a/tests/unit/Validator/IndexTest.php +++ b/tests/unit/Validator/IndexTest.php @@ -675,7 +675,7 @@ public function testTTLIndexValidation(): void 'ttl' => 0, ]); $this->assertFalse($validator->isValid($invalidIndexZero)); - $this->assertEquals('TTL must be atleast 1 second', $validator->getDescription()); + $this->assertEquals('TTL must be at least 1 second', $validator->getDescription()); // Invalid: TTL index with TTL < 0 $invalidIndexNegative = new Document([ @@ -687,7 +687,7 @@ public function testTTLIndexValidation(): void 'ttl' => -100, ]); $this->assertFalse($validator->isValid($invalidIndexNegative)); - $this->assertEquals('TTL must be atleast 1 second', $validator->getDescription()); + $this->assertEquals('TTL must be at least 1 second', $validator->getDescription()); // Invalid: TTL index on non-datetime attribute $invalidIndexType = new Document([ From 8fa2454e25d20cee1ecd74468f7828fa09bfabfe Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 12 Jan 2026 14:28:56 +0530 Subject: [PATCH 6/6] added ttl index in the missing index message --- src/Database/Validator/Index.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Database/Validator/Index.php b/src/Database/Validator/Index.php index ed7ffea74..1e367d1ad 100644 --- a/src/Database/Validator/Index.php +++ b/src/Database/Validator/Index.php @@ -234,7 +234,7 @@ public function checkValidIndex(Document $index): bool break; default: - $this->message = 'Unknown index type: ' . $type . '. Must be one of ' . Database::INDEX_KEY . ', ' . Database::INDEX_UNIQUE . ', ' . Database::INDEX_FULLTEXT . ', ' . Database::INDEX_SPATIAL . ', ' . Database::INDEX_OBJECT . ', ' . Database::INDEX_HNSW_EUCLIDEAN . ', ' . Database::INDEX_HNSW_COSINE . ', ' . Database::INDEX_HNSW_DOT . ', '.Database::INDEX_TRIGRAM; + $this->message = 'Unknown index type: ' . $type . '. Must be one of ' . Database::INDEX_KEY . ', ' . Database::INDEX_UNIQUE . ', ' . Database::INDEX_FULLTEXT . ', ' . Database::INDEX_SPATIAL . ', ' . Database::INDEX_OBJECT . ', ' . Database::INDEX_HNSW_EUCLIDEAN . ', ' . Database::INDEX_HNSW_COSINE . ', ' . Database::INDEX_HNSW_DOT . ', '.Database::INDEX_TRIGRAM . ', '.Database::INDEX_TTL; return false; } return true;